diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-10-19 15:57:54 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-10-19 15:57:54 +0300 |
commit | 419c53ec62de6e97a517abd5fdd4cbde3a942a34 (patch) | |
tree | 1f43a548b46bca8a5fb8fe0c31cef1883d49c5b6 /spec/frontend | |
parent | 1da20d9135b3ad9e75e65b028bffc921aaf8deb7 (diff) |
Add latest changes from gitlab-org/gitlab@16-5-stable-eev16.5.0-rc42
Diffstat (limited to 'spec/frontend')
294 files changed, 9150 insertions, 3963 deletions
diff --git a/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap b/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap index 42818c14029..2bd2b17a12d 100644 --- a/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap +++ b/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap @@ -21,7 +21,6 @@ exports[`~/access_tokens/components/expires_at_field should render datepicker wi mindate="Mon Jul 06 2020 00:00:00 GMT+0000 (Greenwich Mean Time)" placeholder="YYYY-MM-DD" showclearbutton="true" - size="medium" theme="" /> </gl-form-group-stub> diff --git a/spec/frontend/admin/abuse_report/components/report_actions_spec.js b/spec/frontend/admin/abuse_report/components/report_actions_spec.js index 0e20630db14..3c366980c14 100644 --- a/spec/frontend/admin/abuse_report/components/report_actions_spec.js +++ b/spec/frontend/admin/abuse_report/components/report_actions_spec.js @@ -17,6 +17,9 @@ import { ERROR_MESSAGE, NO_ACTION, USER_ACTION_OPTIONS, + TRUST_ACTION, + TRUST_REASON, + REASON_OPTIONS, } from '~/admin/abuse_report/constants'; import { mockAbuseReport } from '../mock_data'; @@ -40,10 +43,11 @@ describe('ReportActions', () => { const setCloseReport = (close) => wrapper.findByTestId('close').find('input').setChecked(close); const setSelectOption = (id, value) => wrapper.findByTestId(`${id}-select`).find(`option[value=${value}]`).setSelected(); - const selectAction = (action) => setSelectOption('action', action); + const selectAction = (chosenAction) => setSelectOption('action', chosenAction); const selectReason = (reason) => setSelectOption('reason', reason); const setComment = (comment) => wrapper.findByTestId('comment').find('input').setValue(comment); const submitForm = () => wrapper.findByTestId('submit-button').vm.$emit('click'); + const findReasonOptions = () => wrapper.findByTestId('reason-select'); const createComponent = (props = {}) => { wrapper = mountExtended(ReportActions, { @@ -79,8 +83,8 @@ describe('ReportActions', () => { expect(options).toHaveLength(USER_ACTION_OPTIONS.length); - USER_ACTION_OPTIONS.forEach((action, index) => { - expect(options.at(index).text()).toBe(action.text); + USER_ACTION_OPTIONS.forEach((userAction, index) => { + expect(options.at(index).text()).toBe(userAction.text); }); }); }); @@ -100,6 +104,51 @@ describe('ReportActions', () => { }); }); + describe('reasons', () => { + beforeEach(() => { + clickActionsButton(); + }); + + it('shows all non-trust reasons by default', () => { + const reasons = findReasonOptions().findAll('option'); + expect(reasons).toHaveLength(REASON_OPTIONS.length); + + REASON_OPTIONS.forEach((reason, index) => { + expect(reasons.at(index).text()).toBe(reason.text); + }); + }); + + describe('when user selects any non-trust action', () => { + it('shows non-trust reasons', () => { + const reasonLength = REASON_OPTIONS.length; + let reasons; + + USER_ACTION_OPTIONS.forEach((userAction) => { + if (userAction !== TRUST_ACTION && userAction !== NO_ACTION) { + selectAction(userAction.value); + + reasons = findReasonOptions().findAll('option'); + expect(reasons).toHaveLength(reasonLength); + } + }); + }); + }); + + describe('when user selects "Trust user"', () => { + beforeEach(() => { + selectAction(TRUST_ACTION.value); + }); + + it('only shows "Confirmed trusted user" reason', () => { + const reasons = findReasonOptions().findAll('option'); + + expect(reasons).toHaveLength(1); + + expect(reasons.at(0).text()).toBe(TRUST_REASON.text); + }); + }); + }); + describe('when clicking the actions button', () => { beforeEach(() => { clickActionsButton(); diff --git a/spec/frontend/admin/abuse_report/components/user_details_spec.js b/spec/frontend/admin/abuse_report/components/user_details_spec.js index f3d8d5bb610..24ec0cdb1b2 100644 --- a/spec/frontend/admin/abuse_report/components/user_details_spec.js +++ b/spec/frontend/admin/abuse_report/components/user_details_spec.js @@ -70,14 +70,6 @@ describe('UserDetails', () => { expect(findUserDetailLabel('credit-card-verification')).toBe(USER_DETAILS_I18N.creditCard); }); - it('renders the users name', () => { - expect(findUserDetail('credit-card-verification').text()).toContain( - sprintf(USER_DETAILS_I18N.registeredWith, { ...user.creditCard }), - ); - - expect(findUserDetail('credit-card-verification').text()).toContain(user.creditCard.name); - }); - describe('similar credit cards', () => { it('renders the number of similar records', () => { expect(findUserDetail('credit-card-verification').text()).toContain( diff --git a/spec/frontend/alert_spec.js b/spec/frontend/alert_spec.js index 1ae8373016b..de3093c6c19 100644 --- a/spec/frontend/alert_spec.js +++ b/spec/frontend/alert_spec.js @@ -271,6 +271,74 @@ describe('Flash', () => { expect(findTextContent()).toBe('message 1 message 2'); }); }); + + describe('with message links', () => { + const findAlertMessageLinks = () => + Array.from(document.querySelectorAll('.flash-container a')); + + it('creates a link', () => { + alert = createAlert({ + message: 'Read more at %{exampleLinkStart}example site%{exampleLinkEnd}.', + messageLinks: { + exampleLink: 'https://example.com', + }, + }); + const messageLinks = findAlertMessageLinks(); + + expect(messageLinks).toHaveLength(1); + const link = messageLinks.at(0); + expect(link.textContent).toBe('example site'); + expect(link.getAttribute('href')).toBe('https://example.com'); + }); + + it('creates multiple links', () => { + alert = createAlert({ + message: + 'Read more at %{exampleLinkStart}example site%{exampleLinkEnd}, or on %{docsLinkStart}the documentation%{docsLinkEnd}.', + messageLinks: { + exampleLink: 'https://example.com', + docsLink: 'https://docs.example.com', + }, + }); + const messageLinks = findAlertMessageLinks(); + + expect(messageLinks).toHaveLength(2); + const [firstLink, secondLink] = messageLinks; + expect(firstLink.textContent).toBe('example site'); + expect(firstLink.getAttribute('href')).toBe('https://example.com'); + expect(secondLink.textContent).toBe('the documentation'); + expect(secondLink.getAttribute('href')).toBe('https://docs.example.com'); + }); + + it('allows passing more props to gl-link', () => { + alert = createAlert({ + message: 'Read more at %{exampleLinkStart}example site%{exampleLinkEnd}.', + messageLinks: { + exampleLink: { + href: 'https://example.com', + target: '_blank', + }, + }, + }); + const messageLinks = findAlertMessageLinks(); + + expect(messageLinks).toHaveLength(1); + const link = messageLinks.at(0); + expect(link.textContent).toBe('example site'); + expect(link.getAttribute('href')).toBe('https://example.com'); + expect(link.getAttribute('target')).toBe('_blank'); + }); + + it('does not create any links when given an empty messageLinks object', () => { + alert = createAlert({ + message: 'Read more at %{exampleLinkStart}example site%{exampleLinkEnd}.', + messageLinks: {}, + }); + const messageLinks = findAlertMessageLinks(); + + expect(messageLinks).toHaveLength(0); + }); + }); }); }); }); diff --git a/spec/frontend/analytics/cycle_analytics/components/base_spec.js b/spec/frontend/analytics/cycle_analytics/components/base_spec.js index 653934000b3..cd477ff36aa 100644 --- a/spec/frontend/analytics/cycle_analytics/components/base_spec.js +++ b/spec/frontend/analytics/cycle_analytics/components/base_spec.js @@ -141,9 +141,11 @@ describe('Value stream analytics component', () => { namespacePath: groupPath, endDate: createdBefore, hasDateRangeFilter: true, + hasPredefinedDateRangesFilter: true, hasProjectFilter: false, selectedProjects: [], startDate: createdAfter, + predefinedDateRange: null, }); }); diff --git a/spec/frontend/analytics/cycle_analytics/components/value_stream_filters_spec.js b/spec/frontend/analytics/cycle_analytics/components/value_stream_filters_spec.js index e3bcb0ab557..a04ffa79a68 100644 --- a/spec/frontend/analytics/cycle_analytics/components/value_stream_filters_spec.js +++ b/spec/frontend/analytics/cycle_analytics/components/value_stream_filters_spec.js @@ -1,20 +1,29 @@ -import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import Daterange from '~/analytics/shared/components/daterange.vue'; import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue'; import FilterBar from '~/analytics/cycle_analytics/components/filter_bar.vue'; import ValueStreamFilters from '~/analytics/cycle_analytics/components/value_stream_filters.vue'; +import DateRangesDropdown from '~/analytics/shared/components/date_ranges_dropdown.vue'; import { - createdAfter as startDate, - createdBefore as endDate, - currentGroup, - selectedProjects, -} from '../mock_data'; + DATE_RANGE_LAST_30_DAYS_VALUE, + DATE_RANGE_CUSTOM_VALUE, + LAST_30_DAYS, +} from '~/analytics/shared/constants'; +import { useFakeDate } from 'helpers/fake_date'; +import { currentGroup, selectedProjects } from '../mock_data'; const { path } = currentGroup; const groupPath = `groups/${path}`; +const defaultFeatureFlags = { + vsaPredefinedDateRanges: false, +}; -function createComponent(props = {}) { - return shallowMount(ValueStreamFilters, { +const startDate = LAST_30_DAYS; +const endDate = new Date('2019-01-14T00:00:00.000Z'); + +function createComponent({ props = {}, featureFlags = defaultFeatureFlags } = {}) { + return shallowMountExtended(ValueStreamFilters, { propsData: { selectedProjects, groupPath, @@ -23,15 +32,23 @@ function createComponent(props = {}) { endDate, ...props, }, + provide: { + glFeatures: { + ...featureFlags, + }, + }, }); } describe('ValueStreamFilters', () => { + useFakeDate(2019, 0, 14, 10, 10); + let wrapper; const findProjectsDropdown = () => wrapper.findComponent(ProjectsDropdownFilter); const findDateRangePicker = () => wrapper.findComponent(Daterange); const findFilterBar = () => wrapper.findComponent(FilterBar); + const findDateRangesDropdown = () => wrapper.findComponent(DateRangesDropdown); beforeEach(() => { wrapper = createComponent(); @@ -55,6 +72,10 @@ describe('ValueStreamFilters', () => { expect(findDateRangePicker().exists()).toBe(true); }); + it('will not render the date ranges dropdown', () => { + expect(findDateRangesDropdown().exists()).toBe(false); + }); + it('will emit `selectProject` when a project is selected', () => { findProjectsDropdown().vm.$emit('selected'); @@ -69,21 +90,168 @@ describe('ValueStreamFilters', () => { describe('hasDateRangeFilter = false', () => { beforeEach(() => { - wrapper = createComponent({ hasDateRangeFilter: false }); + wrapper = createComponent({ props: { hasDateRangeFilter: false } }); }); - it('will not render the date range picker', () => { + it('should not render the date range picker', () => { expect(findDateRangePicker().exists()).toBe(false); }); }); describe('hasProjectFilter = false', () => { beforeEach(() => { - wrapper = createComponent({ hasProjectFilter: false }); + wrapper = createComponent({ props: { hasProjectFilter: false } }); }); it('will not render the project dropdown', () => { expect(findProjectsDropdown().exists()).toBe(false); }); }); + + describe('`vsaPredefinedDateRanges` feature flag is enabled', () => { + const lastMonthValue = 'lastMonthValue'; + const mockDateRange = { + value: lastMonthValue, + startDate: new Date('2023-08-08T00:00:00.000Z'), + endDate: new Date('2023-09-08T00:00:00.000Z'), + }; + + beforeEach(() => { + wrapper = createComponent({ featureFlags: { vsaPredefinedDateRanges: true } }); + }); + + it('should render date ranges dropdown', () => { + expect(findDateRangesDropdown().exists()).toBe(true); + }); + + it('should not render date range picker', () => { + expect(findDateRangePicker().exists()).toBe(false); + }); + + describe('when a date range is selected from the dropdown', () => { + describe('predefined date range option', () => { + beforeEach(async () => { + findDateRangesDropdown().vm.$emit('selected', mockDateRange); + + await nextTick(); + }); + + it('should emit `setDateRange` with date range', () => { + const { value, ...dateRange } = mockDateRange; + + expect(wrapper.emitted('setDateRange')).toEqual([[dateRange]]); + }); + + it('should emit `setPredefinedDateRange` with correct value', () => { + expect(wrapper.emitted('setPredefinedDateRange')).toEqual([[lastMonthValue]]); + }); + }); + + describe('custom date range option', () => { + beforeEach(async () => { + findDateRangesDropdown().vm.$emit('customDateRangeSelected'); + + await nextTick(); + }); + + it('should emit `setPredefinedDateRange` with custom date range value', () => { + expect(wrapper.emitted('setPredefinedDateRange')).toEqual([[DATE_RANGE_CUSTOM_VALUE]]); + }); + + it('should not emit `setDateRange`', () => { + expect(wrapper.emitted('setDateRange')).toBeUndefined(); + }); + }); + }); + + describe.each` + predefinedDateRange | shouldRenderDateRangePicker | dateRangeType + ${DATE_RANGE_CUSTOM_VALUE} | ${true} | ${'custom date range'} + ${lastMonthValue} | ${false} | ${'predefined date range'} + `( + 'when the `predefinedDateRange` prop is set to a $dateRangeType', + ({ predefinedDateRange, shouldRenderDateRangePicker }) => { + beforeEach(() => { + wrapper = createComponent({ + props: { predefinedDateRange }, + featureFlags: { vsaPredefinedDateRanges: true }, + }); + }); + + it("should be passed into the dropdown's `selected` prop", () => { + expect(findDateRangesDropdown().props('selected')).toBe(predefinedDateRange); + }); + + it(`should ${ + shouldRenderDateRangePicker ? '' : 'not' + } render the date range picker`, () => { + expect(findDateRangePicker().exists()).toBe(shouldRenderDateRangePicker); + }); + }, + ); + + describe('when the `predefinedDateRange` prop is null', () => { + const laterStartDate = new Date('2018-12-01T00:00:00.000Z'); + const earlierStartDate = new Date('2019-01-01T00:00:00.000Z'); + const customEndDate = new Date('2019-02-01T00:00:00.000Z'); + + describe.each` + dateRange | expectedDateRangeOption | shouldRenderDateRangePicker | description + ${{ startDate, endDate }} | ${DATE_RANGE_LAST_30_DAYS_VALUE} | ${false} | ${'is default'} + ${{ startDate: laterStartDate, endDate }} | ${DATE_RANGE_CUSTOM_VALUE} | ${true} | ${'has a later start date than the default'} + ${{ startDate: earlierStartDate, endDate }} | ${DATE_RANGE_CUSTOM_VALUE} | ${true} | ${'has an earlier start date than the default'} + ${{ startDate, endDate: customEndDate }} | ${DATE_RANGE_CUSTOM_VALUE} | ${true} | ${'has an end date that is not today'} + `( + 'date range $description', + ({ dateRange, expectedDateRangeOption, shouldRenderDateRangePicker }) => { + beforeEach(() => { + wrapper = createComponent({ + props: { predefinedDateRange: null, ...dateRange }, + featureFlags: { vsaPredefinedDateRanges: true }, + }); + }); + + it("should set the dropdown's `selected` prop to the correct value", () => { + expect(findDateRangesDropdown().props('selected')).toBe(expectedDateRangeOption); + }); + + it(`should ${ + shouldRenderDateRangePicker ? '' : 'not' + } render the date range picker`, () => { + expect(findDateRangePicker().exists()).toBe(shouldRenderDateRangePicker); + }); + }, + ); + }); + + describe('hasPredefinedDateRangesFilter = false', () => { + beforeEach(() => { + wrapper = createComponent({ + props: { hasPredefinedDateRangesFilter: false }, + featureFlags: { vsaPredefinedDateRanges: true }, + }); + }); + + it('should not render the date ranges dropdown', () => { + expect(findDateRangesDropdown().exists()).toBe(false); + }); + }); + + describe('hasDateRangeFilter = false', () => { + beforeEach(() => { + wrapper = createComponent({ + props: { hasDateRangeFilter: false }, + featureFlags: { vsaPredefinedDateRanges: true }, + }); + }); + + it('should not render the date range picker', () => { + expect(findDateRangePicker().exists()).toBe(false); + }); + + it('should remove custom date range option from date ranges dropdown', () => { + expect(findDateRangesDropdown().props('includeCustomDateRangeOption')).toBe(false); + }); + }); + }); }); diff --git a/spec/frontend/analytics/cycle_analytics/mock_data.js b/spec/frontend/analytics/cycle_analytics/mock_data.js index f9587bf1967..7ad95cab9ad 100644 --- a/spec/frontend/analytics/cycle_analytics/mock_data.js +++ b/spec/frontend/analytics/cycle_analytics/mock_data.js @@ -261,3 +261,5 @@ export const basePaginationResult = { direction: PAGINATION_SORT_DIRECTION_DESC, page: null, }; + +export const predefinedDateRange = 'last_week'; diff --git a/spec/frontend/analytics/cycle_analytics/store/actions_spec.js b/spec/frontend/analytics/cycle_analytics/store/actions_spec.js index b2ce8596c22..c3551d3da6f 100644 --- a/spec/frontend/analytics/cycle_analytics/store/actions_spec.js +++ b/spec/frontend/analytics/cycle_analytics/store/actions_spec.js @@ -14,6 +14,7 @@ import { initialPaginationState, reviewEvents, projectNamespace as namespace, + predefinedDateRange, } from '../mock_data'; const { path: groupPath } = currentGroup; @@ -32,6 +33,7 @@ const defaultState = { createdAfter, createdBefore, pagination: initialPaginationState, + predefinedDateRange, }; describe('Project Value Stream Analytics actions', () => { @@ -53,6 +55,7 @@ describe('Project Value Stream Analytics actions', () => { describe.each` action | payload | expectedActions | expectedMutations ${'setDateRange'} | ${{ createdAfter, createdBefore }} | ${[{ type: 'refetchStageData' }]} | ${[mockSetDateActionCommit]} + ${'setPredefinedDateRange'} | ${{ predefinedDateRange }} | ${[]} | ${[{ type: 'SET_PREDEFINED_DATE_RANGE', payload: { predefinedDateRange } }]} ${'setFilters'} | ${[]} | ${[{ type: 'refetchStageData' }]} | ${[]} ${'setSelectedStage'} | ${{ selectedStage }} | ${[{ type: 'refetchStageData' }]} | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]} ${'setSelectedValueStream'} | ${{ selectedValueStream }} | ${[{ type: 'fetchValueStreamStages' }]} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]} diff --git a/spec/frontend/analytics/cycle_analytics/store/mutations_spec.js b/spec/frontend/analytics/cycle_analytics/store/mutations_spec.js index 70b7454f4a0..25fed2b1714 100644 --- a/spec/frontend/analytics/cycle_analytics/store/mutations_spec.js +++ b/spec/frontend/analytics/cycle_analytics/store/mutations_spec.js @@ -18,6 +18,7 @@ import { stageCounts, initialPaginationState as pagination, projectNamespace as mockNamespace, + predefinedDateRange, } from '../mock_data'; let state; @@ -94,6 +95,7 @@ describe('Project Value Stream Analytics mutations', () => { mutation | payload | stateKey | value ${types.SET_DATE_RANGE} | ${mockSetDatePayload} | ${'createdAfter'} | ${mockCreatedAfter} ${types.SET_DATE_RANGE} | ${mockSetDatePayload} | ${'createdBefore'} | ${mockCreatedBefore} + ${types.SET_PREDEFINED_DATE_RANGE} | ${predefinedDateRange} | ${'predefinedDateRange'} | ${predefinedDateRange} ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true} ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false} ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream} diff --git a/spec/frontend/analytics/shared/components/date_ranges_dropdown_spec.js b/spec/frontend/analytics/shared/components/date_ranges_dropdown_spec.js new file mode 100644 index 00000000000..63407900be7 --- /dev/null +++ b/spec/frontend/analytics/shared/components/date_ranges_dropdown_spec.js @@ -0,0 +1,165 @@ +import { nextTick } from 'vue'; +import { GlCollapsibleListbox, GlIcon } from '@gitlab/ui'; +import DateRangesDropdown from '~/analytics/shared/components/date_ranges_dropdown.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +describe('DateRangesDropdown', () => { + let wrapper; + + const customDateRangeValue = 'custom'; + const lastWeekValue = 'lastWeek'; + const last30DaysValue = 'lastMonth'; + const mockLastWeek = { + text: 'Last week', + value: lastWeekValue, + startDate: new Date('2023-09-08T00:00:00.000Z'), + endDate: new Date('2023-09-14T00:00:00.000Z'), + }; + const mockLast30Days = { + text: 'Last month', + value: last30DaysValue, + startDate: new Date('2023-08-16T00:00:00.000Z'), + endDate: new Date('2023-09-14T00:00:00.000Z'), + }; + const mockCustomDateRangeItem = { + text: 'Custom', + value: customDateRangeValue, + }; + const mockDateRanges = [mockLastWeek, mockLast30Days]; + const mockItems = mockDateRanges.map(({ text, value }) => ({ text, value })); + const mockTooltipText = 'Max date range is 180 days'; + + const createComponent = ({ props = {}, dateRangeOptions = mockDateRanges } = {}) => { + wrapper = shallowMountExtended(DateRangesDropdown, { + propsData: { + dateRangeOptions, + ...props, + }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + }); + }; + + const findListBox = () => wrapper.findComponent(GlCollapsibleListbox); + const findDaysSelectedCount = () => wrapper.findByTestId('predefined-date-range-days-count'); + const findHelpIcon = () => wrapper.findComponent(GlIcon); + + describe('default state', () => { + beforeEach(() => { + createComponent(); + }); + + it('should pass items to listbox `items` prop in correct order', () => { + expect(findListBox().props('items')).toStrictEqual([...mockItems, mockCustomDateRangeItem]); + }); + + it('should display first option as selected', () => { + expect(findListBox().props('selected')).toBe(lastWeekValue); + }); + + it('should not display info icon', () => { + expect(findHelpIcon().exists()).toBe(false); + }); + + describe.each` + dateRangeValue | dateRangeItem + ${lastWeekValue} | ${mockLastWeek} + ${last30DaysValue} | ${mockLast30Days} + `('when $dateRangeValue date range is selected', ({ dateRangeValue, dateRangeItem }) => { + beforeEach(async () => { + findListBox().vm.$emit('select', dateRangeValue); + + await nextTick(); + }); + + it('should emit `selected` event with value and date range', () => { + const { text, ...dateRangeProps } = dateRangeItem; + + expect(wrapper.emitted('selected')).toEqual([[dateRangeProps]]); + }); + + it('should display days selected indicator', () => { + expect(findDaysSelectedCount().exists()).toBe(true); + }); + + it('should not emit `customDateRangeSelected` event', () => { + expect(wrapper.emitted('customDateRangeSelected')).toBeUndefined(); + }); + }); + + describe('when the custom date range option is selected', () => { + beforeEach(async () => { + findListBox().vm.$emit('select', customDateRangeValue); + + await nextTick(); + }); + + it('should emit `customDateRangeSelected` event', () => { + expect(wrapper.emitted('customDateRangeSelected')).toHaveLength(1); + }); + + it('should hide days selected indicator', () => { + expect(findDaysSelectedCount().exists()).toBe(false); + }); + + it('should not emit `selected` event', () => { + expect(wrapper.emitted('selected')).toBeUndefined(); + }); + }); + }); + + describe('when a date range is preselected', () => { + beforeEach(() => { + createComponent({ props: { selected: 'lastMonth' } }); + }); + + it('should display preselected date range as selected in listbox', () => { + expect(findListBox().props('selected')).toBe(last30DaysValue); + }); + }); + + describe('days selected indicator', () => { + it.each` + selected | includeEndDateInDaysSelected | expectedDaysCount + ${lastWeekValue} | ${true} | ${7} + ${last30DaysValue} | ${true} | ${30} + ${lastWeekValue} | ${false} | ${6} + ${last30DaysValue} | ${false} | ${29} + `( + 'should display correct days selected when includeEndDateInDaysSelected=$includeEndDateInDaysSelected', + ({ selected, includeEndDateInDaysSelected, expectedDaysCount }) => { + createComponent({ props: { selected, includeEndDateInDaysSelected } }); + + expect(wrapper.findByText(`${expectedDaysCount} days selected`).exists()).toBe(true); + }, + ); + }); + + describe('when the `tooltip` prop is set', () => { + beforeEach(() => { + createComponent({ props: { tooltip: mockTooltipText } }); + }); + + it('should display info icon with tooltip', () => { + const helpIcon = findHelpIcon(); + const tooltip = getBinding(helpIcon.element, 'gl-tooltip'); + + expect(helpIcon.props('name')).toBe('information-o'); + expect(helpIcon.attributes('title')).toBe(mockTooltipText); + + expect(tooltip).toBeDefined(); + }); + }); + + describe('when `includeCustomDateRangeOption` = false', () => { + beforeEach(() => { + createComponent({ props: { includeCustomDateRangeOption: false } }); + }); + + it('should pass items without custom date range option to listbox `items` prop', () => { + expect(findListBox().props('items')).toEqual(mockItems); + }); + }); +}); diff --git a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js index 802da47d6cd..15f5759752d 100644 --- a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js +++ b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js @@ -26,6 +26,7 @@ const projects = [ avatarUrl: null, }, ]; +const groupNamespace = 'gitlab-org'; const defaultMocks = { $apollo: { @@ -46,7 +47,7 @@ describe('ProjectsDropdownFilter component', () => { mocks: { ...defaultMocks }, propsData: { groupId: 1, - groupNamespace: 'gitlab-org', + groupNamespace, ...props, }, stubs: { @@ -93,34 +94,50 @@ describe('ProjectsDropdownFilter component', () => { const findSelectedButtonAvatarItemAtIndex = (index) => findSelectedDropdownAtIndex(index).find('img.gl-avatar'); - describe('queryParams are applied when fetching data', () => { + describe('when fetching data', () => { + const mockQueryParams = { + first: 50, + includeSubgroups: true, + }; + + const mockVariables = { + groupFullPath: groupNamespace, + ...mockQueryParams, + }; + beforeEach(() => { createComponent({ props: { - queryParams: { - first: 50, - includeSubgroups: true, - }, + queryParams: mockQueryParams, }, }); + + spyQuery.mockClear(); }); - it('applies the correct queryParams when making an api call', async () => { + it('should apply the correct queryParams when making an API call', async () => { findDropdown().vm.$emit('search', 'gitlab'); + await waitForPromises(); + expect(spyQuery).toHaveBeenCalledTimes(1); - await nextTick(); - expect(spyQuery).toHaveBeenCalledWith({ + expect(spyQuery).toHaveBeenLastCalledWith({ query: getProjects, variables: { search: 'gitlab', - groupFullPath: wrapper.vm.groupNamespace, - first: 50, - includeSubgroups: true, + ...mockVariables, }, }); }); + + it('should not make an API call when search query is below minimum search length', async () => { + findDropdown().vm.$emit('search', 'hi'); + + await waitForPromises(); + + expect(spyQuery).toHaveBeenCalledTimes(0); + }); }); describe('highlighted items', () => { @@ -230,6 +247,31 @@ describe('ProjectsDropdownFilter component', () => { }); }); + describe('with an array of projects passed to `defaultProjects` and a search term', () => { + const { name: searchQuery } = projects[2]; + + beforeEach(async () => { + createComponent({ + mountFn: mountExtended, + props: { + defaultProjects: [projects[0], projects[1]], + multiSelect: true, + }, + }); + + await waitForPromises(); + + findDropdown().vm.$emit('search', searchQuery); + }); + + it('should add search result to selected projects when selected', async () => { + await selectDropdownItemAtIndex([0, 1, 2]); + + expect(findSelectedDropdownItems()).toHaveLength(3); + expect(findDropdownButton().text()).toBe('3 projects selected'); + }); + }); + describe('when multiSelect is false', () => { const blockDefaultProps = { multiSelect: false }; beforeEach(() => { diff --git a/spec/frontend/batch_comments/components/preview_dropdown_spec.js b/spec/frontend/batch_comments/components/preview_dropdown_spec.js index 608e9c82961..c0ad40b75ad 100644 --- a/spec/frontend/batch_comments/components/preview_dropdown_spec.js +++ b/spec/frontend/batch_comments/components/preview_dropdown_spec.js @@ -16,7 +16,7 @@ Vue.use(Vuex); let wrapper; -const setCurrentFileHash = jest.fn(); +const goToFile = jest.fn(); const scrollToDraft = jest.fn(); const findPreviewItem = () => wrapper.findComponent(PreviewItem); @@ -27,7 +27,7 @@ function factory({ viewDiffsFileByFile = false, draftsCount = 1, sortedDrafts = diffs: { namespaced: true, actions: { - setCurrentFileHash, + goToFile, }, state: { viewDiffsFileByFile, @@ -59,12 +59,12 @@ describe('Batch comments preview dropdown', () => { it('toggles active file when viewDiffsFileByFile is true', async () => { factory({ viewDiffsFileByFile: true, - sortedDrafts: [{ id: 1, file_hash: 'hash' }], + sortedDrafts: [{ id: 1, file_hash: 'hash', file_path: 'foo' }], }); findPreviewItem().trigger('click'); await nextTick(); - expect(setCurrentFileHash).toHaveBeenCalledWith(expect.anything(), 'hash'); + expect(goToFile).toHaveBeenCalledWith(expect.anything(), { path: 'foo' }); await nextTick(); expect(scrollToDraft).toHaveBeenCalledWith( diff --git a/spec/frontend/behaviors/autosize_spec.js b/spec/frontend/behaviors/autosize_spec.js deleted file mode 100644 index 7008b7b2eb6..00000000000 --- a/spec/frontend/behaviors/autosize_spec.js +++ /dev/null @@ -1,42 +0,0 @@ -import '~/behaviors/autosize'; -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; - -jest.mock('~/helpers/startup_css_helper', () => { - return { - waitForCSSLoaded: jest.fn().mockImplementation((cb) => { - // This is a hack: - // autosize.js will execute and modify the DOM - // whenever waitForCSSLoaded calls its callback function. - // This setTimeout is here because everything within setTimeout will be queued - // as async code until the current call stack is executed. - // If we would not do this, the mock for waitForCSSLoaded would call its callback - // before the fixture in the beforeEach is set and the Test would fail. - // more on this here: https://johnresig.com/blog/how-javascript-timers-work/ - setTimeout(() => { - cb.apply(); - }, 0); - }), - }; -}); - -describe('Autosize behavior', () => { - beforeEach(() => { - setHTMLFixture('<textarea class="js-autosize"></textarea>'); - }); - - afterEach(() => { - resetHTMLFixture(); - }); - - it('is applied to the textarea', () => { - // This is the second part of the Hack: - // Because we are forcing the mock for WaitForCSSLoaded and the very end of our callstack - // to call its callback. This querySelector needs to go to the very end of our callstack - // as well, if we would not have this jest.runOnlyPendingTimers here, the querySelector - // would not run and the test would fail. - jest.runOnlyPendingTimers(); - - const textarea = document.querySelector('textarea'); - expect(textarea.classList).toContain('js-autosize-initialized'); - }); -}); diff --git a/spec/frontend/behaviors/components/global_alerts_spec.js b/spec/frontend/behaviors/components/global_alerts_spec.js new file mode 100644 index 00000000000..4a20805c9a6 --- /dev/null +++ b/spec/frontend/behaviors/components/global_alerts_spec.js @@ -0,0 +1,135 @@ +import { nextTick } from 'vue'; +import { GlAlert } from '@gitlab/ui'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import GlobalAlerts from '~/behaviors/components/global_alerts.vue'; +import { getGlobalAlerts, setGlobalAlerts, removeGlobalAlertById } from '~/lib/utils/global_alerts'; + +jest.mock('~/lib/utils/global_alerts'); + +describe('GlobalAlerts', () => { + const alert1 = { + dismissible: true, + persistOnPages: [], + id: 'foo', + variant: 'success', + title: 'Foo title', + message: 'Foo', + }; + const alert2 = { + dismissible: true, + persistOnPages: [], + id: 'bar', + variant: 'danger', + message: 'Bar', + }; + const alert3 = { + dismissible: true, + persistOnPages: ['dashboard:groups:index'], + id: 'baz', + variant: 'info', + message: 'Baz', + }; + + let wrapper; + + const createComponent = async () => { + wrapper = shallowMountExtended(GlobalAlerts); + await nextTick(); + }; + + const findAllAlerts = () => wrapper.findAllComponents(GlAlert); + + describe('when there are alerts to display', () => { + beforeEach(() => { + getGlobalAlerts.mockImplementationOnce(() => [alert1, alert2]); + }); + + it('displays alerts and removes them from session storage', async () => { + await createComponent(); + + const alerts = findAllAlerts(); + + expect(alerts.at(0).text()).toBe('Foo'); + expect(alerts.at(0).props()).toMatchObject({ + title: 'Foo title', + variant: 'success', + dismissible: true, + }); + + expect(alerts.at(1).text()).toBe('Bar'); + expect(alerts.at(1).props()).toMatchObject({ + variant: 'danger', + dismissible: true, + }); + + expect(setGlobalAlerts).toHaveBeenCalledWith([]); + }); + + describe('when alert is dismissed', () => { + it('removes alert', async () => { + await createComponent(); + + wrapper.findComponent(GlAlert).vm.$emit('dismiss'); + await nextTick(); + + expect(findAllAlerts().length).toBe(1); + expect(removeGlobalAlertById).toHaveBeenCalledWith(alert1.id); + }); + }); + }); + + describe('when alert has `persistOnPages` key set', () => { + const alerts = [alert3]; + + beforeEach(() => { + getGlobalAlerts.mockImplementationOnce(() => alerts); + }); + + describe('when page matches specified page', () => { + beforeEach(() => { + document.body.dataset.page = 'dashboard:groups:index'; + }); + + afterEach(() => { + delete document.body.dataset.page; + }); + + it('renders alert and does not remove it from session storage', async () => { + await createComponent(); + + expect(wrapper.findComponent(GlAlert).text()).toBe('Baz'); + expect(setGlobalAlerts).toHaveBeenCalledWith(alerts); + }); + }); + + describe('when page does not match specified page', () => { + beforeEach(() => { + document.body.dataset.page = 'dashboard:groups:show'; + }); + + afterEach(() => { + delete document.body.dataset.page; + }); + + it('does not render alert and does not remove it from session storage', async () => { + await createComponent(); + + expect(wrapper.findComponent(GlAlert).exists()).toBe(false); + expect(setGlobalAlerts).toHaveBeenCalledWith(alerts); + }); + }); + }); + + describe('when there are no alerts to display', () => { + beforeEach(() => { + getGlobalAlerts.mockImplementationOnce(() => []); + }); + + it('renders nothing', async () => { + await createComponent(); + + expect(wrapper.html()).toBe(''); + }); + }); +}); diff --git a/spec/frontend/behaviors/components/json_table_spec.js b/spec/frontend/behaviors/components/json_table_spec.js index ae62d28d6c0..3277e58669a 100644 --- a/spec/frontend/behaviors/components/json_table_spec.js +++ b/spec/frontend/behaviors/components/json_table_spec.js @@ -70,7 +70,7 @@ describe('behaviors/components/json_table', () => { }); it('renders gltable', () => { - expect(findTable().props()).toEqual({ + expect(findTable().props()).toMatchObject({ fields: [], items: [], }); @@ -121,7 +121,7 @@ describe('behaviors/components/json_table', () => { }); it('passes cleaned fields and items to table', () => { - expect(findTable().props()).toEqual({ + expect(findTable().props()).toMatchObject({ fields: [ 'A', { diff --git a/spec/frontend/behaviors/markdown/render_observability_spec.js b/spec/frontend/behaviors/markdown/render_observability_spec.js deleted file mode 100644 index f464c01ac15..00000000000 --- a/spec/frontend/behaviors/markdown/render_observability_spec.js +++ /dev/null @@ -1,43 +0,0 @@ -import Vue from 'vue'; -import { createWrapper } from '@vue/test-utils'; -import renderObservability from '~/behaviors/markdown/render_observability'; -import { INLINE_EMBED_DIMENSIONS, SKELETON_VARIANT_EMBED } from '~/observability/constants'; -import ObservabilityApp from '~/observability/components/observability_app.vue'; - -describe('renderObservability', () => { - let subject; - - beforeEach(() => { - subject = document.createElement('div'); - subject.classList.add('js-render-observability'); - subject.dataset.frameUrl = 'https://observe.gitlab.com/'; - document.body.appendChild(subject); - }); - - afterEach(() => { - subject.remove(); - }); - - it('should return an array of Vue instances', () => { - const vueInstances = renderObservability([ - ...document.querySelectorAll('.js-render-observability'), - ]); - expect(vueInstances).toEqual([expect.any(Vue)]); - }); - - it('should correctly pass props to the ObservabilityApp component', () => { - const vueInstances = renderObservability([ - ...document.querySelectorAll('.js-render-observability'), - ]); - - const wrapper = createWrapper(vueInstances[0]); - - expect(wrapper.findComponent(ObservabilityApp).props()).toMatchObject({ - observabilityIframeSrc: 'https://observe.gitlab.com/', - skeletonVariant: SKELETON_VARIANT_EMBED, - inlineEmbed: true, - height: INLINE_EMBED_DIMENSIONS.HEIGHT, - width: INLINE_EMBED_DIMENSIONS.WIDTH, - }); - }); -}); 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 292a0da2bfe..f32dd902b8e 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 @@ -13,7 +13,7 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = ` /> <strong class="file-title-name js-blob-header-filepath mr-1" - data-qa-selector="file_title_content" + data-testid="file-title-content" > foo/bar/dummy.md </strong> diff --git a/spec/frontend/blob/components/blob_header_default_actions_spec.js b/spec/frontend/blob/components/blob_header_default_actions_spec.js index 4c8c256121f..cc4c13060a5 100644 --- a/spec/frontend/blob/components/blob_header_default_actions_spec.js +++ b/spec/frontend/blob/components/blob_header_default_actions_spec.js @@ -35,7 +35,7 @@ describe('Blob Header Default Actions', () => { }); describe('renders', () => { - const findCopyButton = () => wrapper.findByTestId('copyContentsButton'); + const findCopyButton = () => wrapper.findByTestId('copy-contents-button'); const findViewRawButton = () => wrapper.findByTestId('viewRawButton'); it('gl-button-group component', () => { diff --git a/spec/frontend/blob/csv/csv_viewer_spec.js b/spec/frontend/blob/csv/csv_viewer_spec.js index 8f105f04aa7..04d11011e70 100644 --- a/spec/frontend/blob/csv/csv_viewer_spec.js +++ b/spec/frontend/blob/csv/csv_viewer_spec.js @@ -1,10 +1,12 @@ -import { GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { GlLoadingIcon, GlTable, GlButton } from '@gitlab/ui'; import { getAllByRole } from '@testing-library/dom'; import { shallowMount, mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import Papa from 'papaparse'; import CsvViewer from '~/blob/csv/csv_viewer.vue'; import PapaParseAlert from '~/vue_shared/components/papa_parse_alert.vue'; +import { s__ } from '~/locale'; +import { MAX_ROWS_TO_RENDER } from '~/blob/csv/constants'; const validCsv = 'one,two,three'; const brokenCsv = '{\n "json": 1,\n "key": [1, 2, 3]\n}'; @@ -28,6 +30,8 @@ describe('app/assets/javascripts/blob/csv/csv_viewer.vue', () => { const findCsvTable = () => wrapper.findComponent(GlTable); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAlert = () => wrapper.findComponent(PapaParseAlert); + const findSwitchToRawViewBtn = () => wrapper.findComponent(GlButton); + const findLargeCsvText = () => wrapper.find('[data-testid="large-csv-text"]'); it('should render loading spinner', () => { createComponent(); @@ -76,6 +80,33 @@ describe('app/assets/javascripts/blob/csv/csv_viewer.vue', () => { }); }); + describe('when the CSV is larger than 2000 lines', () => { + beforeEach(async () => { + const largeCsv = validCsv.repeat(3000); + jest.spyOn(Papa, 'parse').mockImplementation(() => { + return { data: largeCsv.split(','), errors: [] }; + }); + createComponent({ csv: largeCsv }); + await nextTick(); + }); + it('renders not more than max rows value', () => { + expect(Papa.parse).toHaveBeenCalledTimes(1); + expect(wrapper.vm.items).toHaveLength(MAX_ROWS_TO_RENDER); + }); + it('renders large csv text', () => { + expect(findLargeCsvText().text()).toBe( + s__( + 'CsvViewer|The file is too large to render all the rows. To see the entire file, switch to the raw view.', + ), + ); + }); + it('renders button with link to raw view', () => { + const url = 'http://test.host/?plain=1'; + expect(findSwitchToRawViewBtn().text()).toBe(s__('CsvViewer|View raw data')); + expect(findSwitchToRawViewBtn().attributes('href')).toBe(url); + }); + }); + describe('when csv prop is path and indicates a remote file', () => { it('should render call parse with download flag true', async () => { const path = 'path/to/remote/file.csv'; diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js index 95b5712bab0..8314cbda7a1 100644 --- a/spec/frontend/boards/board_card_inner_spec.js +++ b/spec/frontend/boards/board_card_inner_spec.js @@ -10,6 +10,7 @@ import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import IssuableBlockedIcon from '~/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue'; import BoardCardInner from '~/boards/components/board_card_inner.vue'; +import isShowingLabelsQuery from '~/graphql_shared/client/is_showing_labels.query.graphql'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import eventHub from '~/boards/eventhub'; import defaultStore from '~/boards/stores'; @@ -63,17 +64,23 @@ describe('Board card component', () => { actions: { performSearch: performSearchMock, }, - state: { - ...defaultStore.state, - isShowingLabels: true, - }, + state: defaultStore.state, }); }; + const mockApollo = createMockApollo(); + const createWrapper = ({ props = {}, isEpicBoard = false, isGroupBoard = true } = {}) => { + mockApollo.clients.defaultClient.cache.writeQuery({ + query: isShowingLabelsQuery, + data: { + isShowingLabels: true, + }, + }); + wrapper = mountExtended(BoardCardInner, { store, - apolloProvider: createMockApollo(), + apolloProvider: mockApollo, propsData: { list, item: issue, @@ -235,7 +242,7 @@ describe('Board card component', () => { expect(tooltip).toBeDefined(); expect(findHiddenIssueIcon().attributes('title')).toBe( - 'This issue is hidden because its author has been banned', + 'This issue is hidden because its author has been banned.', ); }); }); diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js index 7367b34c4df..5bafd9a8d0e 100644 --- a/spec/frontend/boards/board_list_helper.js +++ b/spec/frontend/boards/board_list_helper.js @@ -122,5 +122,7 @@ export default function createComponent({ }, }); + jest.spyOn(store, 'dispatch').mockImplementation(() => {}); + return component; } diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index e0a110678b1..30bb4fba4e3 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -202,8 +202,6 @@ describe('Board list component', () => { describe('handleDragOnEnd', () => { beforeEach(() => { - jest.spyOn(wrapper.vm, 'moveItem').mockImplementation(() => {}); - startDrag(); }); diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js index f0d40af94fe..11f9a4f6ff2 100644 --- a/spec/frontend/boards/components/board_card_spec.js +++ b/spec/frontend/boards/components/board_card_spec.js @@ -4,11 +4,14 @@ import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import VueApollo from 'vue-apollo'; +import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import BoardCard from '~/boards/components/board_card.vue'; import BoardCardInner from '~/boards/components/board_card_inner.vue'; import { inactiveId } from '~/boards/constants'; +import selectedBoardItemsQuery from '~/boards/graphql/client/selected_board_items.query.graphql'; +import isShowingLabelsQuery from '~/graphql_shared/client/is_showing_labels.query.graphql'; import { mockLabelList, mockIssue, DEFAULT_COLOR } from '../mock_data'; describe('Board card', () => { @@ -20,9 +23,11 @@ describe('Board card', () => { Vue.use(VueApollo); const mockSetActiveBoardItemResolver = jest.fn(); + const mockSetSelectedBoardItemsResolver = jest.fn(); const mockApollo = createMockApollo([], { Mutation: { setActiveBoardItem: mockSetActiveBoardItemResolver, + setSelectedBoardItems: mockSetSelectedBoardItemsResolver, }, }); @@ -49,7 +54,21 @@ describe('Board card', () => { provide = {}, stubs = { BoardCardInner }, item = mockIssue, + selectedBoardItems = [], } = {}) => { + mockApollo.clients.defaultClient.cache.writeQuery({ + query: isShowingLabelsQuery, + data: { + isShowingLabels: true, + }, + }); + mockApollo.clients.defaultClient.cache.writeQuery({ + query: selectedBoardItemsQuery, + data: { + selectedBoardItems, + }, + }); + wrapper = shallowMountExtended(BoardCard, { apolloProvider: mockApollo, stubs: { @@ -99,7 +118,7 @@ describe('Board card', () => { describe('when GlLabel is clicked in BoardCardInner', () => { it('doesnt call toggleBoardItem', () => { - createStore({ initialState: { isShowingLabels: true } }); + createStore(); mountComponent(); wrapper.findComponent(GlLabel).trigger('mouseup'); @@ -132,10 +151,9 @@ describe('Board card', () => { createStore({ initialState: { activeId: inactiveId, - selectedBoardItems: [mockIssue], }, }); - mountComponent(); + mountComponent({ selectedBoardItems: [mockIssue.id] }); expect(wrapper.classes()).toContain('multi-select'); expect(wrapper.classes()).not.toContain('is-active'); @@ -163,13 +181,17 @@ describe('Board card', () => { window.gon = { features: { boardMultiSelect: true } }; }); - it('should call vuex action "multiSelectBoardItem" with correct parameters', async () => { + it('should call setSelectedBoardItemsMutation with correct parameters', async () => { await multiSelectCard(); - expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledTimes(1); - expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledWith( + expect(mockSetSelectedBoardItemsResolver).toHaveBeenCalledTimes(1); + expect(mockSetSelectedBoardItemsResolver).toHaveBeenCalledWith( expect.any(Object), - mockIssue, + { + itemId: mockIssue.id, + }, + expect.anything(), + expect.anything(), ); }); }); @@ -240,6 +262,7 @@ describe('Board card', () => { it('set active board item on client when clicking on card', async () => { await selectCard(); + await waitForPromises(); expect(mockSetActiveBoardItemResolver).toHaveBeenCalledWith( {}, diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index 15ee3976fb1..a0dacf085e2 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -14,6 +14,7 @@ import createBoardMutation from '~/boards/graphql/board_create.mutation.graphql' import destroyBoardMutation from '~/boards/graphql/board_destroy.mutation.graphql'; import updateBoardMutation from '~/boards/graphql/board_update.mutation.graphql'; import eventHub from '~/boards/eventhub'; +import * as cacheUpdates from '~/boards/graphql/cache_updates'; import { visitUrl } from '~/lib/utils/url_utility'; jest.mock('~/lib/utils/url_utility', () => ({ @@ -55,12 +56,10 @@ describe('BoardForm', () => { const findInput = () => wrapper.find('#board-new-name'); const setBoardMock = jest.fn(); - const setErrorMock = jest.fn(); const store = new Vuex.Store({ actions: { setBoard: setBoardMock, - setError: setErrorMock, }, }); @@ -113,6 +112,10 @@ describe('BoardForm', () => { }); }; + beforeEach(() => { + cacheUpdates.setError = jest.fn(); + }); + describe('when user can not admin the board', () => { beforeEach(() => { createComponent({ @@ -237,7 +240,7 @@ describe('BoardForm', () => { await waitForPromises(); expect(setBoardMock).not.toHaveBeenCalled(); - expect(setErrorMock).toHaveBeenCalled(); + expect(cacheUpdates.setError).toHaveBeenCalled(); }); describe('when Apollo boards FF is on', () => { @@ -353,7 +356,7 @@ describe('BoardForm', () => { await waitForPromises(); expect(setBoardMock).not.toHaveBeenCalled(); - expect(setErrorMock).toHaveBeenCalled(); + expect(cacheUpdates.setError).toHaveBeenCalled(); }); describe('when Apollo boards FF is on', () => { @@ -434,9 +437,11 @@ describe('BoardForm', () => { await waitForPromises(); expect(visitUrl).not.toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith('setError', { - message: 'Failed to delete board. Please try again.', - }); + expect(cacheUpdates.setError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to delete board. Please try again.', + }), + ); }); }); }); diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js index fa18b47cf54..0a628af9939 100644 --- a/spec/frontend/boards/components/boards_selector_spec.js +++ b/spec/frontend/boards/components/boards_selector_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlLoadingIcon, GlDropdownSectionHeader } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; // eslint-disable-next-line no-restricted-imports @@ -13,7 +13,7 @@ import projectRecentBoardsQuery from '~/boards/graphql/project_recent_boards.que import * as cacheUpdates from '~/boards/graphql/cache_updates'; import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; import createMockApollo from 'helpers/mock_apollo_helper'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { mockBoard, mockGroupAllBoardsResponse, @@ -47,17 +47,11 @@ describe('BoardsSelector', () => { }); }; - const fillSearchBox = (filterTerm) => { - const searchBox = wrapper.findComponent({ ref: 'searchBox' }); - const searchBoxInput = searchBox.find('input'); - searchBoxInput.setValue(filterTerm); - searchBoxInput.trigger('input'); - }; + const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); - const getDropdownItems = () => wrapper.findAllByTestId('dropdown-item'); - const getDropdownHeaders = () => wrapper.findAllComponents(GlDropdownSectionHeader); - const getLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findDropdown = () => wrapper.findComponent(GlDropdown); + const fillSearchBox = async (filterTerm) => { + await findDropdown().vm.$emit('search', filterTerm); + }; const projectBoardsQueryHandlerSuccess = jest .fn() @@ -96,7 +90,7 @@ describe('BoardsSelector', () => { [groupRecentBoardsQuery, groupRecentBoardsQueryHandlerSuccess], ]); - wrapper = mountExtended(BoardsSelector, { + wrapper = shallowMountExtended(BoardsSelector, { store, apolloProvider: fakeApollo, propsData: { @@ -142,13 +136,19 @@ describe('BoardsSelector', () => { }); it('shows loading spinner', async () => { + createComponent({ + provide: { + isApolloBoard: true, + }, + props: { + isCurrentBoardLoading: true, + }, + }); // Emits gl-dropdown show event to simulate the dropdown is opened at initialization time - findDropdown().vm.$emit('show'); + findDropdown().vm.$emit('shown'); await nextTick(); - expect(getLoadingIcon().exists()).toBe(true); - expect(getDropdownHeaders()).toHaveLength(0); - expect(getDropdownItems()).toHaveLength(0); + expect(findDropdown().props('loading')).toBe(true); }); }); @@ -158,7 +158,7 @@ describe('BoardsSelector', () => { await nextTick(); // Emits gl-dropdown show event to simulate the dropdown is opened at initialization time - findDropdown().vm.$emit('show'); + findDropdown().vm.$emit('shown'); await nextTick(); }); @@ -167,9 +167,8 @@ describe('BoardsSelector', () => { expect(projectBoardsQueryHandlerSuccess).toHaveBeenCalled(); }); - it('hides loading spinner', async () => { - await nextTick(); - expect(getLoadingIcon().exists()).toBe(false); + it('hides loading spinner', () => { + expect(findDropdown().props('loading')).toBe(false); }); describe('filtering', () => { @@ -178,25 +177,26 @@ describe('BoardsSelector', () => { }); it('shows all boards without filtering', () => { - expect(getDropdownItems()).toHaveLength(boards.length + recentIssueBoards.length); + expect(findDropdown().props('items')[0].text).toBe('Recent'); + expect(findDropdown().props('items')[0].options).toHaveLength(recentIssueBoards.length); + expect(findDropdown().props('items')[1].text).toBe('All'); + expect(findDropdown().props('items')[1].options).toHaveLength( + boards.length - recentIssueBoards.length, + ); }); it('shows only matching boards when filtering', async () => { const filterTerm = 'board1'; const expectedCount = boards.filter((board) => board.name.includes(filterTerm)).length; - fillSearchBox(filterTerm); - - await nextTick(); - expect(getDropdownItems()).toHaveLength(expectedCount); + await fillSearchBox(filterTerm); + expect(findDropdown().props('items')).toHaveLength(expectedCount); }); it('shows message if there are no matching boards', async () => { - fillSearchBox('does not exist'); + await fillSearchBox('does not exist'); - await nextTick(); - expect(getDropdownItems()).toHaveLength(0); - expect(wrapper.text().includes('No matching boards found')).toBe(true); + expect(findDropdown().props('noResultsText')).toBe('No matching boards found'); }); }); @@ -204,14 +204,18 @@ describe('BoardsSelector', () => { it('shows only when boards are greater than 10', async () => { await nextTick(); expect(projectRecentBoardsQueryHandlerSuccess).toHaveBeenCalled(); - expect(getDropdownHeaders()).toHaveLength(2); + + expect(findDropdown().props('items')).toHaveLength(2); + expect(findDropdown().props('items')[0].text).toBe('Recent'); + expect(findDropdown().props('items')[1].text).toBe('All'); }); it('does not show when boards are less than 10', async () => { createComponent({ projectBoardsQueryHandler: smallBoardsQueryHandlerSuccess }); await nextTick(); - expect(getDropdownHeaders()).toHaveLength(0); + + expect(findDropdown().props('items')).toHaveLength(0); }); it('does not show when recentIssueBoards api returns empty array', async () => { @@ -220,14 +224,14 @@ describe('BoardsSelector', () => { }); await nextTick(); - expect(getDropdownHeaders()).toHaveLength(0); + expect(findDropdown().props('items')).toHaveLength(0); }); it('does not show when search is active', async () => { fillSearchBox('Random string'); await nextTick(); - expect(getDropdownHeaders()).toHaveLength(0); + expect(findDropdown().props('items')).toHaveLength(0); }); }); }); @@ -248,7 +252,7 @@ describe('BoardsSelector', () => { await nextTick(); // Emits gl-dropdown show event to simulate the dropdown is opened at initialization time - findDropdown().vm.$emit('show'); + findDropdown().vm.$emit('shown'); await nextTick(); @@ -272,7 +276,7 @@ describe('BoardsSelector', () => { await nextTick(); // Emits gl-dropdown show event to simulate the dropdown is opened at initialization time - findDropdown().vm.$emit('show'); + findDropdown().vm.$emit('shown'); await waitForPromises(); @@ -286,6 +290,7 @@ describe('BoardsSelector', () => { createStore(); createComponent({ provide: { multipleIssueBoardsAvailable: true } }); expect(findDropdown().exists()).toBe(true); + expect(findDropdown().props('toggleText')).toBe('Select board'); }); }); @@ -296,6 +301,7 @@ describe('BoardsSelector', () => { provide: { multipleIssueBoardsAvailable: false, hasMissingBoards: true }, }); expect(findDropdown().exists()).toBe(true); + expect(findDropdown().props('toggleText')).toBe('Select board'); }); }); @@ -317,6 +323,7 @@ describe('BoardsSelector', () => { provide: { isApolloBoard: true }, }); expect(findDropdown().props('loading')).toBe(true); + expect(findDropdown().props('toggleText')).toBe('Select board'); }); }); }); diff --git a/spec/frontend/boards/components/issue_board_filtered_search_spec.js b/spec/frontend/boards/components/issue_board_filtered_search_spec.js index 16ad54f0854..1edb6812af0 100644 --- a/spec/frontend/boards/components/issue_board_filtered_search_spec.js +++ b/spec/frontend/boards/components/issue_board_filtered_search_spec.js @@ -26,14 +26,11 @@ describe('IssueBoardFilter', () => { }); }; - let fetchUsersSpy; let fetchLabelsSpy; beforeEach(() => { - fetchUsersSpy = jest.fn(); fetchLabelsSpy = jest.fn(); issueBoardFilters.mockReturnValue({ - fetchUsers: fetchUsersSpy, fetchLabels: fetchLabelsSpy, }); }); @@ -61,7 +58,7 @@ describe('IssueBoardFilter', () => { ({ isSignedIn }) => { createComponent({ isSignedIn }); - const tokens = mockTokens(fetchLabelsSpy, fetchUsersSpy, isSignedIn); + const tokens = mockTokens(fetchLabelsSpy, isSignedIn); expect(findBoardsFilteredSearch().props('tokens')).toEqual(orderBy(tokens, ['title'])); }, diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index dfcdb4c05d0..dfc8b18e197 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -827,7 +827,7 @@ export const mockConfidentialToken = { ], }; -export const mockTokens = (fetchLabels, fetchUsers, isSignedIn) => [ +export const mockTokens = (fetchLabels, isSignedIn) => [ { icon: 'user', title: TOKEN_TITLE_ASSIGNEE, @@ -836,7 +836,8 @@ export const mockTokens = (fetchLabels, fetchUsers, isSignedIn) => [ token: UserToken, dataType: 'user', unique: true, - fetchUsers, + fullPath: 'gitlab-org', + isProject: false, preloadedUsers: [], }, { @@ -848,7 +849,8 @@ export const mockTokens = (fetchLabels, fetchUsers, isSignedIn) => [ token: UserToken, dataType: 'user', unique: true, - fetchUsers, + fullPath: 'gitlab-org', + isProject: false, preloadedUsers: [], }, { @@ -973,7 +975,7 @@ export const boardListQueryResponse = ({ boardList: { __typename: 'BoardList', id: listId, - totalWeight: 5, + totalIssueWeight: '5', issuesCount, }, }, diff --git a/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap b/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap index ee8031f2475..dfb45083c7b 100644 --- a/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap +++ b/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap @@ -13,7 +13,7 @@ exports[`Delete merged branches component Delete merged branches confirmation mo size="medium" textsronly="true" toggleid="dropdown-toggle-btn-25" - toggletext="" + toggletext="More actions" variant="default" > <ul diff --git a/spec/frontend/branches/components/sort_dropdown_spec.js b/spec/frontend/branches/components/sort_dropdown_spec.js index 64ef30bb8a8..777e54f8e69 100644 --- a/spec/frontend/branches/components/sort_dropdown_spec.js +++ b/spec/frontend/branches/components/sort_dropdown_spec.js @@ -1,6 +1,7 @@ import { GlSearchBoxByClick } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import setWindowLocation from 'helpers/set_window_location_helper'; import SortDropdown from '~/branches/components/sort_dropdown.vue'; import * as urlUtils from '~/lib/utils/url_utility'; @@ -67,20 +68,33 @@ describe('Branches Sort Dropdown', () => { }); }); + describe('when url contains a search param', () => { + const branchName = 'branch-1'; + + beforeEach(() => { + setWindowLocation(`/root/ci-cd-project-demo/-/branches?search=${branchName}`); + wrapper = createWrapper(); + }); + + it('should set the default the input value to search param', () => { + expect(findSearchBox().props('value')).toBe(branchName); + }); + }); + describe('when submitting a search term', () => { beforeEach(() => { urlUtils.visitUrl = jest.fn(); - wrapper = createWrapper(); }); it('should call visitUrl', () => { + const searchTerm = 'branch-1'; const searchBox = findSearchBox(); - + searchBox.vm.$emit('input', searchTerm); searchBox.vm.$emit('submit'); expect(urlUtils.visitUrl).toHaveBeenCalledWith( - '/root/ci-cd-project-demo/-/branches?state=all&sort=updated_desc', + '/root/ci-cd-project-demo/-/branches?state=all&sort=updated_desc&search=branch-1', ); }); }); diff --git a/spec/frontend/ci/admin/jobs_table/components/cells/runner_cell_spec.js b/spec/frontend/ci/admin/jobs_table/components/cells/runner_cell_spec.js index 2f1dae71572..c9758c5ab24 100644 --- a/spec/frontend/ci/admin/jobs_table/components/cells/runner_cell_spec.js +++ b/spec/frontend/ci/admin/jobs_table/components/cells/runner_cell_spec.js @@ -1,5 +1,6 @@ import { GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import RunnerTypeIcon from '~/ci/runner/components/runner_type_icon.vue'; import RunnerCell from '~/ci/admin/jobs_table/components/cells/runner_cell.vue'; import { RUNNER_EMPTY_TEXT } from '~/ci/admin/jobs_table/constants'; import { allRunnersData } from 'jest/ci/runner/mock_data'; @@ -61,4 +62,29 @@ describe('Runner Cell', () => { }); }); }); + + describe('Runner Type Icon', () => { + const findRunnerTypeIcon = () => wrapper.findComponent(RunnerTypeIcon); + + describe('Job with runner', () => { + beforeEach(() => { + createComponent({ job: mockJobWithRunner }); + }); + + it('shows the runner type icon', () => { + expect(findRunnerTypeIcon().exists()).toBe(true); + expect(findRunnerTypeIcon().props('type')).toBe(mockJobWithRunner.runner.runnerType); + }); + }); + + describe('Job without runner', () => { + beforeEach(() => { + createComponent({ job: mockJobWithoutRunner }); + }); + + it('does not show the runner type icon', () => { + expect(findRunnerTypeIcon().exists()).toBe(false); + }); + }); + }); }); diff --git a/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js b/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js index 1cbb1a714c9..3628af31aa1 100644 --- a/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js +++ b/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js @@ -1,16 +1,8 @@ -import { - GlLoadingIcon, - GlTable, - GlLink, - GlBadge, - GlPagination, - GlModal, - GlFormCheckbox, -} from '@gitlab/ui'; +import { GlLoadingIcon, GlTable, GlLink, GlPagination, GlModal, GlFormCheckbox } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import getJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql.json'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import waitForPromises from 'helpers/wait_for_promises'; import JobArtifactsTable from '~/ci/artifacts/components/job_artifacts_table.vue'; import ArtifactsTableRowDetails from '~/ci/artifacts/components/artifacts_table_row_details.vue'; @@ -59,13 +51,13 @@ describe('JobArtifactsTable component', () => { const findStatuses = () => wrapper.findAllByTestId('job-artifacts-job-status'); const findSuccessfulJobStatus = () => findStatuses().at(0); - const findFailedJobStatus = () => findStatuses().at(1); + const findCiBadgeLink = () => findSuccessfulJobStatus().findComponent(CiBadgeLink); const findLinks = () => wrapper.findAllComponents(GlLink); const findJobLink = () => findLinks().at(0); const findPipelineLink = () => findLinks().at(1); - const findRefLink = () => findLinks().at(2); - const findCommitLink = () => findLinks().at(3); + const findCommitLink = () => findLinks().at(2); + const findRefLink = () => findLinks().at(3); const findSize = () => wrapper.findByTestId('job-artifacts-size'); const findCreated = () => wrapper.findByTestId('job-artifacts-created'); @@ -209,13 +201,13 @@ describe('JobArtifactsTable component', () => { }); it('shows the job status as an icon for a successful job', () => { - expect(findSuccessfulJobStatus().findComponent(CiIcon).exists()).toBe(true); - expect(findSuccessfulJobStatus().findComponent(GlBadge).exists()).toBe(false); - }); - - it('shows the job status as a badge for other job statuses', () => { - expect(findFailedJobStatus().findComponent(GlBadge).exists()).toBe(true); - expect(findFailedJobStatus().findComponent(CiIcon).exists()).toBe(false); + expect(findCiBadgeLink().props()).toMatchObject({ + status: { + group: 'success', + }, + size: 'sm', + showText: false, + }); }); it('shows links to the job, pipeline, ref, and commit', () => { diff --git a/spec/frontend/ci/catalog/components/ci_catalog_home_spec.js b/spec/frontend/ci/catalog/components/ci_catalog_home_spec.js new file mode 100644 index 00000000000..1b5c86c19cb --- /dev/null +++ b/spec/frontend/ci/catalog/components/ci_catalog_home_spec.js @@ -0,0 +1,46 @@ +import { shallowMount } from '@vue/test-utils'; +import { createRouter } from '~/ci/catalog/router'; +import ciResourceDetailsPage from '~/ci/catalog/components/pages/ci_resource_details_page.vue'; +import CiCatalogHome from '~/ci/catalog/components/ci_catalog_home.vue'; + +describe('CiCatalogHome', () => { + const defaultProps = {}; + const baseRoute = '/'; + const resourcesPageComponentStub = { + name: 'page-component', + template: '<div>Hello</div>', + }; + const router = createRouter(baseRoute, resourcesPageComponentStub); + + const createComponent = ({ props = {} } = {}) => { + shallowMount(CiCatalogHome, { + propsData: { + ...defaultProps, + ...props, + }, + router, + }); + }; + + describe('when mounted', () => { + beforeEach(() => { + createComponent(); + }); + + describe('router', () => { + it.each` + path | component + ${baseRoute} | ${resourcesPageComponentStub} + ${'/1'} | ${ciResourceDetailsPage} + `('when route is $path it renders the right component', async ({ path, component }) => { + if (path !== '/') { + await router.push(path); + } + + const [root] = router.currentRoute.matched; + + expect(root.components.default).toBe(component); + }); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/details/ci_resource_about_spec.js b/spec/frontend/ci/catalog/components/details/ci_resource_about_spec.js new file mode 100644 index 00000000000..658a135534b --- /dev/null +++ b/spec/frontend/ci/catalog/components/details/ci_resource_about_spec.js @@ -0,0 +1,120 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CiResourceAbout from '~/ci/catalog/components/details/ci_resource_about.vue'; +import { formatDate } from '~/lib/utils/datetime_utility'; + +describe('CiResourceAbout', () => { + let wrapper; + + const defaultProps = { + isLoadingSharedData: false, + isLoadingDetails: false, + openIssuesCount: 4, + openMergeRequestsCount: 9, + latestVersion: { + id: 1, + tagName: 'v1.0.0', + tagPath: 'path/to/release', + releasedAt: '2022-08-23T17:19:09Z', + }, + webPath: 'path/to/project', + }; + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMountExtended(CiResourceAbout, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + const findProjectLink = () => wrapper.findByText('Go to the project'); + const findIssueCount = () => wrapper.findByText(`${defaultProps.openIssuesCount} issues`); + const findMergeRequestCount = () => + wrapper.findByText(`${defaultProps.openMergeRequestsCount} merge requests`); + const findLastRelease = () => + wrapper.findByText( + `Last release at ${formatDate(defaultProps.latestVersion.releasedAt, 'yyyy-mm-dd')}`, + ); + const findAllLoadingItems = () => wrapper.findAllByTestId('skeleton-loading-line'); + + // Shared data items are items which gets their data from the index page query. + const sharedDataItems = [findProjectLink, findLastRelease]; + // additional details items gets their state only when on the details page + const additionalDetailsItems = [findIssueCount, findMergeRequestCount]; + const allItems = [...sharedDataItems, ...additionalDetailsItems]; + + describe('when loading shared data', () => { + beforeEach(() => { + createComponent({ props: { isLoadingSharedData: true, isLoadingDetails: true } }); + }); + + it('renders all server-side data as loading', () => { + allItems.forEach((finder) => { + expect(finder().exists()).toBe(false); + }); + + expect(findAllLoadingItems()).toHaveLength(allItems.length); + }); + }); + + describe('when loading additional details', () => { + beforeEach(() => { + createComponent({ props: { isLoadingDetails: true } }); + }); + + it('renders only the details query as loading', () => { + sharedDataItems.forEach((finder) => { + expect(finder().exists()).toBe(true); + }); + + additionalDetailsItems.forEach((finder) => { + expect(finder().exists()).toBe(false); + }); + + expect(findAllLoadingItems()).toHaveLength(additionalDetailsItems.length); + }); + }); + + describe('when has loaded', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders project link', () => { + expect(findProjectLink().exists()).toBe(true); + }); + + it('renders the number of issues opened', () => { + expect(findIssueCount().exists()).toBe(true); + }); + + it('renders the number of merge requests opened', () => { + expect(findMergeRequestCount().exists()).toBe(true); + }); + + it('renders the last release date', () => { + expect(findLastRelease().exists()).toBe(true); + }); + + describe('links', () => { + it('has the correct project link', () => { + expect(findProjectLink().attributes('href')).toBe(defaultProps.webPath); + }); + + it('has the correct issues link', () => { + expect(findIssueCount().attributes('href')).toBe(`${defaultProps.webPath}/issues`); + }); + + it('has the correct merge request link', () => { + expect(findMergeRequestCount().attributes('href')).toBe( + `${defaultProps.webPath}/merge_requests`, + ); + }); + + it('has no link for release data', () => { + expect(findLastRelease().attributes('href')).toBe(undefined); + }); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/details/ci_resource_components_spec.js b/spec/frontend/ci/catalog/components/details/ci_resource_components_spec.js new file mode 100644 index 00000000000..a41996d20b3 --- /dev/null +++ b/spec/frontend/ci/catalog/components/details/ci_resource_components_spec.js @@ -0,0 +1,113 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { resolvers } from '~/ci/catalog/graphql/settings'; +import CiResourceComponents from '~/ci/catalog/components/details/ci_resource_components.vue'; +import getCiCatalogcomponentComponents from '~/ci/catalog/graphql/queries/get_ci_catalog_resource_components.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import { mockComponents } from '../../mock'; + +Vue.use(VueApollo); +jest.mock('~/alert'); + +describe('CiResourceComponents', () => { + let wrapper; + let mockComponentsResponse; + + const components = mockComponents.data.ciCatalogResource.components.nodes; + + const resourceId = 'gid://gitlab/Ci::Catalog::Resource/1'; + + const defaultProps = { resourceId }; + + const createComponent = async () => { + const handlers = [[getCiCatalogcomponentComponents, mockComponentsResponse]]; + const mockApollo = createMockApollo(handlers, resolvers); + + wrapper = mountExtended(CiResourceComponents, { + propsData: { + ...defaultProps, + }, + apolloProvider: mockApollo, + }); + + await waitForPromises(); + }; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findComponents = () => wrapper.findAllByTestId('component-section'); + + beforeEach(() => { + mockComponentsResponse = jest.fn(); + mockComponentsResponse.mockResolvedValue(mockComponents); + }); + + describe('when queries are loading', () => { + beforeEach(() => { + createComponent(); + }); + + it('render a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not render components', () => { + expect(findComponents()).toHaveLength(0); + }); + + it('does not throw an error', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); + }); + + describe('when components query throws an error', () => { + beforeEach(async () => { + mockComponentsResponse.mockRejectedValue(); + await createComponent(); + }); + + it('calls createAlert with the correct message', () => { + expect(createAlert).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledWith({ + message: "There was an error fetching this resource's components", + }); + }); + + it('does not render the loading state', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('when queries have loaded', () => { + beforeEach(async () => { + await createComponent(); + }); + + it('renders every component', () => { + expect(findComponents()).toHaveLength(components.length); + }); + + it('renders the component name, description and snippet', () => { + components.forEach((component) => { + expect(wrapper.text()).toContain(component.name); + expect(wrapper.text()).toContain(component.description); + expect(wrapper.text()).toContain(component.path); + }); + }); + + describe('inputs', () => { + it('renders the component parameter attributes', () => { + const [firstComponent] = components; + + firstComponent.inputs.nodes.forEach((input) => { + expect(findComponents().at(0).text()).toContain(input.name); + expect(findComponents().at(0).text()).toContain(input.defaultValue); + expect(findComponents().at(0).text()).toContain('Yes'); + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js b/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js new file mode 100644 index 00000000000..1f7dcf9d4e5 --- /dev/null +++ b/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js @@ -0,0 +1,83 @@ +import { GlTabs, GlTab } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import CiResourceComponents from '~/ci/catalog/components/details/ci_resource_components.vue'; +import CiResourceDetails from '~/ci/catalog/components/details/ci_resource_details.vue'; +import CiResourceReadme from '~/ci/catalog/components/details/ci_resource_readme.vue'; + +describe('CiResourceDetails', () => { + let wrapper; + + const defaultProps = { + resourceId: 'gid://gitlab/Ci::Catalog::Resource/1', + }; + const defaultProvide = { + glFeatures: { ciCatalogComponentsTab: true }, + }; + + const createComponent = ({ provide = {}, props = {} } = {}) => { + wrapper = shallowMount(CiResourceDetails, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + ...defaultProvide, + ...provide, + }, + stubs: { + GlTabs, + }, + }); + }; + const findAllTabs = () => wrapper.findAllComponents(GlTab); + const findCiResourceReadme = () => wrapper.findComponent(CiResourceReadme); + const findCiResourceComponents = () => wrapper.findComponent(CiResourceComponents); + + describe('tabs', () => { + describe('when feature flag `ci_catalog_components_tab` is enabled', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the readme and components tab', () => { + expect(findAllTabs()).toHaveLength(2); + expect(findCiResourceComponents().exists()).toBe(true); + expect(findCiResourceReadme().exists()).toBe(true); + }); + }); + + describe('when feature flag `ci_catalog_components_tab` is disabled', () => { + beforeEach(() => { + createComponent({ + provide: { glFeatures: { ciCatalogComponentsTab: false } }, + }); + }); + + it('renders only readme tab as default', () => { + expect(findCiResourceReadme().exists()).toBe(true); + expect(findCiResourceComponents().exists()).toBe(false); + expect(findAllTabs()).toHaveLength(1); + }); + }); + + describe('UI', () => { + beforeEach(() => { + createComponent(); + }); + + it('passes lazy attribute to all tabs', () => { + findAllTabs().wrappers.forEach((tab) => { + expect(tab.attributes().lazy).not.toBeUndefined(); + }); + }); + + it('passes the right props to the readme component', () => { + expect(findCiResourceReadme().props().resourceId).toBe(defaultProps.resourceId); + }); + + it('passes the right props to the components tab', () => { + expect(findCiResourceComponents().props().resourceId).toBe(defaultProps.resourceId); + }); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/details/ci_resource_header_spec.js b/spec/frontend/ci/catalog/components/details/ci_resource_header_spec.js new file mode 100644 index 00000000000..6ab9520508d --- /dev/null +++ b/spec/frontend/ci/catalog/components/details/ci_resource_header_spec.js @@ -0,0 +1,139 @@ +import { GlAvatar, GlAvatarLink, GlBadge } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CiResourceHeader from '~/ci/catalog/components/details/ci_resource_header.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiResourceAbout from '~/ci/catalog/components/details/ci_resource_about.vue'; +import { catalogSharedDataMock, catalogAdditionalDetailsMock } from '../../mock'; + +describe('CiResourceHeader', () => { + let wrapper; + + const resource = { ...catalogSharedDataMock.data.ciCatalogResource }; + const resourceAdditionalData = { ...catalogAdditionalDetailsMock.data.ciCatalogResource }; + + const defaultProps = { + openIssuesCount: resourceAdditionalData.openIssuesCount, + openMergeRequestsCount: resourceAdditionalData.openMergeRequestsCount, + isLoadingDetails: false, + isLoadingSharedData: false, + resource, + }; + + const findAboutComponent = () => wrapper.findComponent(CiResourceAbout); + const findAvatar = () => wrapper.findComponent(GlAvatar); + const findAvatarLink = () => wrapper.findComponent(GlAvatarLink); + const findVersionBadge = () => wrapper.findComponent(GlBadge); + const findPipelineStatusBadge = () => wrapper.findComponent(CiBadgeLink); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMountExtended(CiResourceHeader, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + describe('when mounted', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the project name and description', () => { + expect(wrapper.html()).toContain(resource.name); + expect(wrapper.html()).toContain(resource.description); + }); + + it('renders the namespace and project path', () => { + expect(wrapper.html()).toContain(resource.rootNamespace.fullPath); + expect(wrapper.html()).toContain(resource.rootNamespace.name); + }); + + it('renders the avatar', () => { + const { id, name } = resource; + + expect(findAvatar().exists()).toBe(true); + expect(findAvatarLink().exists()).toBe(true); + expect(findAvatar().props()).toMatchObject({ + entityId: getIdFromGraphQLId(id), + entityName: name, + }); + }); + + it('renders the catalog about section and passes props', () => { + expect(findAboutComponent().exists()).toBe(true); + expect(findAboutComponent().props()).toEqual({ + isLoadingDetails: false, + isLoadingSharedData: false, + openIssuesCount: defaultProps.openIssuesCount, + openMergeRequestsCount: defaultProps.openMergeRequestsCount, + latestVersion: resource.latestVersion, + webPath: resource.webPath, + }); + }); + }); + + describe('Version badge', () => { + describe('without a version', () => { + beforeEach(() => { + createComponent({ props: { resource: { ...resource, latestVersion: null } } }); + }); + + it('does not render', () => { + expect(findVersionBadge().exists()).toBe(false); + }); + }); + + describe('with a version', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders', () => { + expect(findVersionBadge().exists()).toBe(true); + }); + }); + }); + + describe('when the project has a release', () => { + const pipelineStatus = { + detailsPath: 'path/to/pipeline', + icon: 'status_success', + text: 'passed', + group: 'success', + }; + + describe.each` + hasPipelineBadge | describeText | testText | status + ${true} | ${'is'} | ${'renders'} | ${pipelineStatus} + ${false} | ${'is not'} | ${'does not render'} | ${{}} + `('and there $describeText a pipeline', ({ hasPipelineBadge, testText, status }) => { + beforeEach(() => { + createComponent({ + props: { + pipelineStatus: status, + latestVersion: { tagName: '1.0.0', tagPath: 'path/to/release' }, + }, + }); + }); + + it('renders the version badge', () => { + expect(findVersionBadge().exists()).toBe(true); + }); + + it(`${testText} the pipeline status badge`, () => { + expect(findPipelineStatusBadge().exists()).toBe(hasPipelineBadge); + if (hasPipelineBadge) { + expect(findPipelineStatusBadge().props()).toEqual({ + showText: true, + size: 'sm', + status: pipelineStatus, + showTooltip: true, + useLink: true, + }); + } + }); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js b/spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js new file mode 100644 index 00000000000..0dadac236a8 --- /dev/null +++ b/spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js @@ -0,0 +1,96 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CiResourceReadme from '~/ci/catalog/components/details/ci_resource_readme.vue'; +import getCiCatalogResourceReadme from '~/ci/catalog/graphql/queries/get_ci_catalog_resource_readme.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; + +jest.mock('~/alert'); + +Vue.use(VueApollo); + +const readmeHtml = '<h1>This is a readme file</h1>'; +const resourceId = 'gid://gitlab/Ci::Catalog::Resource/1'; + +describe('CiResourceReadme', () => { + let wrapper; + let mockReadmeResponse; + + const readmeMockData = { + data: { + ciCatalogResource: { + id: resourceId, + readmeHtml, + }, + }, + }; + + const defaultProps = { resourceId }; + + const createComponent = ({ props = {} } = {}) => { + const handlers = [[getCiCatalogResourceReadme, mockReadmeResponse]]; + + wrapper = shallowMountExtended(CiResourceReadme, { + propsData: { + ...defaultProps, + ...props, + }, + apolloProvider: createMockApollo(handlers), + }); + }; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + beforeEach(() => { + mockReadmeResponse = jest.fn(); + }); + + describe('when loading', () => { + beforeEach(() => { + mockReadmeResponse.mockResolvedValue(readmeMockData); + createComponent(); + }); + + it('renders only a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(wrapper.html()).not.toContain(readmeHtml); + }); + }); + + describe('when mounted', () => { + beforeEach(async () => { + mockReadmeResponse.mockResolvedValue(readmeMockData); + + createComponent(); + await waitForPromises(); + }); + + it('renders only the received HTML', () => { + expect(findLoadingIcon().exists()).toBe(false); + expect(wrapper.html()).toContain(readmeHtml); + }); + + it('does not render an error', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); + }); + + describe('when there is an error loading the readme', () => { + beforeEach(async () => { + mockReadmeResponse.mockRejectedValue({ errors: [] }); + + createComponent(); + await waitForPromises(); + }); + + it('calls the createAlert function to show an error', () => { + expect(createAlert).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledWith({ + message: "There was a problem loading this project's readme content.", + }); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/list/catalog_header_spec.js b/spec/frontend/ci/catalog/components/list/catalog_header_spec.js new file mode 100644 index 00000000000..912fd9e1a93 --- /dev/null +++ b/spec/frontend/ci/catalog/components/list/catalog_header_spec.js @@ -0,0 +1,86 @@ +import { GlBanner, GlButton } from '@gitlab/ui'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CatalogHeader from '~/ci/catalog/components/list/catalog_header.vue'; +import { CATALOG_FEEDBACK_DISMISSED_KEY } from '~/ci/catalog/constants'; + +describe('CatalogHeader', () => { + useLocalStorageSpy(); + + let wrapper; + + const defaultProps = {}; + const defaultProvide = { + pageTitle: 'Catalog page', + pageDescription: 'This is a nice catalog page', + }; + + const findBanner = () => wrapper.findComponent(GlBanner); + const findFeedbackButton = () => findBanner().findComponent(GlButton); + const findTitle = () => wrapper.findByText(defaultProvide.pageTitle); + const findDescription = () => wrapper.findByText(defaultProvide.pageDescription); + + const createComponent = ({ props = {}, stubs = {} } = {}) => { + wrapper = shallowMountExtended(CatalogHeader, { + propsData: { + ...defaultProps, + ...props, + }, + provide: defaultProvide, + stubs: { + ...stubs, + }, + }); + }; + + it('renders the Catalog title and description', () => { + createComponent(); + + expect(findTitle().exists()).toBe(true); + expect(findDescription().exists()).toBe(true); + }); + + describe('Feedback banner', () => { + describe('when user has never dismissed', () => { + beforeEach(() => { + createComponent({ stubs: { GlBanner } }); + }); + + it('is visible', () => { + expect(findBanner().exists()).toBe(true); + }); + + it('has link to feedback issue', () => { + expect(findFeedbackButton().attributes().href).toBe( + 'https://gitlab.com/gitlab-org/gitlab/-/issues/407556', + ); + }); + }); + + describe('when user dismisses it', () => { + beforeEach(() => { + createComponent(); + }); + + it('sets the local storage and removes the banner', async () => { + expect(findBanner().exists()).toBe(true); + + await findBanner().vm.$emit('close'); + + expect(localStorage.setItem).toHaveBeenCalledWith(CATALOG_FEEDBACK_DISMISSED_KEY, 'true'); + expect(findBanner().exists()).toBe(false); + }); + }); + + describe('when user has dismissed it before', () => { + beforeEach(() => { + localStorage.setItem(CATALOG_FEEDBACK_DISMISSED_KEY, 'true'); + createComponent(); + }); + + it('does not show the banner', () => { + expect(findBanner().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/list/catalog_list_skeleton_loader_spec.js b/spec/frontend/ci/catalog/components/list/catalog_list_skeleton_loader_spec.js new file mode 100644 index 00000000000..d21fd56eb2e --- /dev/null +++ b/spec/frontend/ci/catalog/components/list/catalog_list_skeleton_loader_spec.js @@ -0,0 +1,22 @@ +import { shallowMount } from '@vue/test-utils'; +import CatalogListSkeletonLoader from '~/ci/catalog/components/list/catalog_list_skeleton_loader.vue'; + +describe('CatalogListSkeletonLoader', () => { + let wrapper; + + const findSkeletonLoader = () => wrapper.findComponent(CatalogListSkeletonLoader); + + const createComponent = () => { + wrapper = shallowMount(CatalogListSkeletonLoader, {}); + }; + + describe('when mounted', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders skeleton item', () => { + expect(findSkeletonLoader().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/list/ci_resources_list_item_spec.js b/spec/frontend/ci/catalog/components/list/ci_resources_list_item_spec.js new file mode 100644 index 00000000000..7f446064366 --- /dev/null +++ b/spec/frontend/ci/catalog/components/list/ci_resources_list_item_spec.js @@ -0,0 +1,198 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import { GlAvatar, GlBadge, GlButton, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { createRouter } from '~/ci/catalog/router/index'; +import CiResourcesListItem from '~/ci/catalog/components/list/ci_resources_list_item.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { CI_RESOURCE_DETAILS_PAGE_NAME } from '~/ci/catalog/router/constants'; +import { catalogSinglePageResponse } from '../../mock'; + +Vue.use(VueRouter); + +let router; +let routerPush; + +describe('CiResourcesListItem', () => { + let wrapper; + + const resource = catalogSinglePageResponse.data.ciCatalogResources.nodes[0]; + const release = { + author: { name: 'author', webUrl: '/user/1' }, + releasedAt: Date.now(), + tagName: '1.0.0', + }; + const defaultProps = { + resource, + }; + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMountExtended(CiResourcesListItem, { + router, + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + GlSprintf, + RouterLink: true, + RouterView: true, + }, + }); + }; + + const findAvatar = () => wrapper.findComponent(GlAvatar); + const findBadge = () => wrapper.findComponent(GlBadge); + const findResourceName = () => wrapper.findComponent(GlButton); + const findResourceDescription = () => wrapper.findByText(defaultProps.resource.description); + const findUserLink = () => wrapper.findByTestId('user-link'); + const findTimeAgoMessage = () => wrapper.findComponent(GlSprintf); + const findFavorites = () => wrapper.findByTestId('stats-favorites'); + const findForks = () => wrapper.findByTestId('stats-forks'); + + beforeEach(() => { + router = createRouter(); + routerPush = jest.spyOn(router, 'push').mockImplementation(() => {}); + }); + + describe('when mounted', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the resource avatar and passes the right props', () => { + const { icon, id, name } = defaultProps.resource; + + expect(findAvatar().exists()).toBe(true); + expect(findAvatar().props()).toMatchObject({ + entityId: getIdFromGraphQLId(id), + entityName: name, + src: icon, + }); + }); + + it('renders the resource name button', () => { + expect(findResourceName().exists()).toBe(true); + }); + + it('renders the resource version badge', () => { + expect(findBadge().exists()).toBe(true); + }); + + it('renders the resource description', () => { + expect(findResourceDescription().exists()).toBe(true); + }); + + describe('release time', () => { + describe('when there is no release data', () => { + beforeEach(() => { + createComponent({ props: { resource: { ...resource, latestVersion: null } } }); + }); + + it('does not render the release', () => { + expect(findTimeAgoMessage().exists()).toBe(false); + }); + + it('renders the generic `unreleased` badge', () => { + expect(findBadge().exists()).toBe(true); + expect(findBadge().text()).toBe('Unreleased'); + }); + }); + + describe('when there is release data', () => { + beforeEach(() => { + createComponent({ props: { resource: { ...resource, latestVersion: { ...release } } } }); + }); + + it('renders the user link', () => { + expect(findUserLink().exists()).toBe(true); + expect(findUserLink().attributes('href')).toBe(release.author.webUrl); + }); + + it('renders the time since the resource was released', () => { + expect(findTimeAgoMessage().exists()).toBe(true); + }); + + it('renders the version badge', () => { + expect(findBadge().exists()).toBe(true); + expect(findBadge().text()).toBe(release.tagName); + }); + }); + }); + }); + + describe('when clicking on an item title', () => { + beforeEach(async () => { + createComponent(); + + await findResourceName().vm.$emit('click'); + }); + + it('navigates to the details page', () => { + expect(routerPush).toHaveBeenCalledWith({ + name: CI_RESOURCE_DETAILS_PAGE_NAME, + params: { + id: getIdFromGraphQLId(resource.id), + }, + }); + }); + }); + + describe('when clicking on an item avatar', () => { + beforeEach(async () => { + createComponent(); + + await findAvatar().vm.$emit('click'); + }); + + it('navigates to the details page', () => { + expect(routerPush).toHaveBeenCalledWith({ + name: CI_RESOURCE_DETAILS_PAGE_NAME, + params: { + id: getIdFromGraphQLId(resource.id), + }, + }); + }); + }); + + describe('statistics', () => { + describe('when there are no statistics', () => { + beforeEach(() => { + createComponent({ + props: { + resource: { + forksCount: 0, + starCount: 0, + }, + }, + }); + }); + + it('render favorites as 0', () => { + expect(findFavorites().exists()).toBe(true); + expect(findFavorites().text()).toBe('0'); + }); + + it('render forks as 0', () => { + expect(findForks().exists()).toBe(true); + expect(findForks().text()).toBe('0'); + }); + }); + + describe('where there are statistics', () => { + beforeEach(() => { + createComponent(); + }); + + it('render favorites', () => { + expect(findFavorites().exists()).toBe(true); + expect(findFavorites().text()).toBe(String(defaultProps.resource.starCount)); + }); + + it('render forks', () => { + expect(findForks().exists()).toBe(true); + expect(findForks().text()).toBe(String(defaultProps.resource.forksCount)); + }); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/list/ci_resources_list_spec.js b/spec/frontend/ci/catalog/components/list/ci_resources_list_spec.js new file mode 100644 index 00000000000..aca20a83979 --- /dev/null +++ b/spec/frontend/ci/catalog/components/list/ci_resources_list_spec.js @@ -0,0 +1,143 @@ +import { GlKeysetPagination } from '@gitlab/ui'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CiResourcesList from '~/ci/catalog/components/list/ci_resources_list.vue'; +import CiResourcesListItem from '~/ci/catalog/components/list/ci_resources_list_item.vue'; +import { ciCatalogResourcesItemsCount } from '~/ci/catalog/graphql/settings'; +import { catalogResponseBody, catalogSinglePageResponse } from '../../mock'; + +describe('CiResourcesList', () => { + let wrapper; + + const createComponent = ({ props = {} } = {}) => { + const { nodes, pageInfo, count } = catalogResponseBody.data.ciCatalogResources; + + const defaultProps = { + currentPage: 1, + resources: nodes, + pageInfo, + totalCount: count, + }; + + wrapper = shallowMountExtended(CiResourcesList, { + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + GlKeysetPagination, + }, + }); + }; + + const findPageCount = () => wrapper.findByTestId('pageCount'); + const findResourcesListItems = () => wrapper.findAllComponents(CiResourcesListItem); + const findPrevBtn = () => wrapper.findByTestId('prevButton'); + const findNextBtn = () => wrapper.findByTestId('nextButton'); + + describe('contains only one page', () => { + const { nodes, pageInfo, count } = catalogSinglePageResponse.data.ciCatalogResources; + + beforeEach(async () => { + await createComponent({ + props: { currentPage: 1, resources: nodes, pageInfo, totalCount: count }, + }); + }); + + it('shows the right number of items', () => { + expect(findResourcesListItems()).toHaveLength(nodes.length); + }); + + it('hides the keyset control for previous page', () => { + expect(findPrevBtn().exists()).toBe(false); + }); + + it('hides the keyset control for next page', () => { + expect(findNextBtn().exists()).toBe(false); + }); + + it('shows the correct count of current page', () => { + expect(findPageCount().text()).toContain('1 of 1'); + }); + }); + + describe.each` + hasPreviousPage | hasNextPage | pageText | expectedTotal | currentPage + ${false} | ${true} | ${'1 of 3'} | ${ciCatalogResourcesItemsCount} | ${1} + ${true} | ${true} | ${'2 of 3'} | ${ciCatalogResourcesItemsCount} | ${2} + ${true} | ${false} | ${'3 of 3'} | ${ciCatalogResourcesItemsCount} | ${3} + `( + 'when on page $pageText', + ({ currentPage, expectedTotal, pageText, hasPreviousPage, hasNextPage }) => { + const { nodes, pageInfo, count } = catalogResponseBody.data.ciCatalogResources; + + const previousPageState = hasPreviousPage ? 'enabled' : 'disabled'; + const nextPageState = hasNextPage ? 'enabled' : 'disabled'; + + beforeEach(async () => { + await createComponent({ + props: { + currentPage, + resources: nodes, + pageInfo: { ...pageInfo, hasPreviousPage, hasNextPage }, + totalCount: count, + }, + }); + }); + + it('shows the right number of items', () => { + expect(findResourcesListItems()).toHaveLength(expectedTotal); + }); + + it(`shows the keyset control for previous page as ${previousPageState}`, () => { + const disableAttr = findPrevBtn().attributes('disabled'); + + if (previousPageState === 'disabled') { + expect(disableAttr).toBeDefined(); + } else { + expect(disableAttr).toBeUndefined(); + } + }); + + it(`shows the keyset control for next page as ${nextPageState}`, () => { + const disableAttr = findNextBtn().attributes('disabled'); + + if (nextPageState === 'disabled') { + expect(disableAttr).toBeDefined(); + } else { + expect(disableAttr).toBeUndefined(); + } + }); + + it('shows the correct count of current page', () => { + expect(findPageCount().text()).toContain(pageText); + }); + }, + ); + + describe('when there is an error getting the page count', () => { + beforeEach(() => { + createComponent({ props: { totalCount: 0 } }); + }); + + it('hides the page count', () => { + expect(findPageCount().exists()).toBe(false); + }); + }); + + describe('emitted events', () => { + beforeEach(() => { + createComponent(); + }); + + it.each` + btnText | elFinder | eventName + ${'previous'} | ${findPrevBtn} | ${'onPrevPage'} + ${'next'} | ${findNextBtn} | ${'onNextPage'} + `('emits $eventName when clicking on the $btnText button', async ({ elFinder, eventName }) => { + await elFinder().vm.$emit('click'); + + expect(wrapper.emitted(eventName)).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/list/empty_state_spec.js b/spec/frontend/ci/catalog/components/list/empty_state_spec.js new file mode 100644 index 00000000000..f589ad96a9d --- /dev/null +++ b/spec/frontend/ci/catalog/components/list/empty_state_spec.js @@ -0,0 +1,27 @@ +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import EmptyState from '~/ci/catalog/components/list/empty_state.vue'; + +describe('EmptyState', () => { + let wrapper; + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMountExtended(EmptyState, { + propsData: { + ...props, + }, + }); + }; + + describe('when mounted', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the empty state', () => { + expect(findEmptyState().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js b/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js new file mode 100644 index 00000000000..40f243ed891 --- /dev/null +++ b/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js @@ -0,0 +1,186 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { CI_CATALOG_RESOURCE_TYPE, cacheConfig } from '~/ci/catalog/graphql/settings'; + +import getCiCatalogResourceSharedData from '~/ci/catalog/graphql/queries/get_ci_catalog_resource_shared_data.query.graphql'; +import getCiCatalogResourceDetails from '~/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql'; + +import CiResourceDetails from '~/ci/catalog/components/details/ci_resource_details.vue'; +import CiResourceDetailsPage from '~/ci/catalog/components/pages/ci_resource_details_page.vue'; +import CiResourceHeader from '~/ci/catalog/components/details/ci_resource_header.vue'; +import CiResourceHeaderSkeletonLoader from '~/ci/catalog/components/details/ci_resource_header_skeleton_loader.vue'; + +import { createRouter } from '~/ci/catalog/router/index'; +import { CI_RESOURCE_DETAILS_PAGE_NAME } from '~/ci/catalog/router/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { catalogSharedDataMock, catalogAdditionalDetailsMock } from '../../mock'; + +Vue.use(VueApollo); +Vue.use(VueRouter); + +let router; + +const defaultSharedData = { ...catalogSharedDataMock.data.ciCatalogResource }; +const defaultAdditionalData = { ...catalogAdditionalDetailsMock.data.ciCatalogResource }; + +describe('CiResourceDetailsPage', () => { + let wrapper; + let sharedDataResponse; + let additionalDataResponse; + + const defaultProps = {}; + + const defaultProvide = { + ciCatalogPath: '/ci/catalog/resources', + }; + + const findDetailsComponent = () => wrapper.findComponent(CiResourceDetails); + const findHeaderComponent = () => wrapper.findComponent(CiResourceHeader); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findHeaderSkeletonLoader = () => wrapper.findComponent(CiResourceHeaderSkeletonLoader); + + const createComponent = ({ props = {} } = {}) => { + const handlers = [ + [getCiCatalogResourceSharedData, sharedDataResponse], + [getCiCatalogResourceDetails, additionalDataResponse], + ]; + + const mockApollo = createMockApollo(handlers, undefined, cacheConfig); + + wrapper = shallowMount(CiResourceDetailsPage, { + router, + apolloProvider: mockApollo, + provide: { + ...defaultProvide, + }, + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + RouterView: true, + }, + }); + }; + + beforeEach(async () => { + sharedDataResponse = jest.fn(); + additionalDataResponse = jest.fn(); + + router = createRouter(); + await router.push({ + name: CI_RESOURCE_DETAILS_PAGE_NAME, + params: { id: defaultSharedData.id }, + }); + }); + + describe('when the app is loading', () => { + describe('and shared data is pre-fetched', () => { + beforeEach(() => { + // By mocking a return value and not a promise, we skip the loading + // to simulate having the pre-fetched query + sharedDataResponse.mockReturnValueOnce(catalogSharedDataMock); + additionalDataResponse.mockResolvedValue(catalogAdditionalDetailsMock); + createComponent(); + }); + + it('renders the header skeleton loader', () => { + expect(findHeaderSkeletonLoader().exists()).toBe(false); + }); + + it('passes down the loading state to the header component', () => { + sharedDataResponse.mockReturnValueOnce(catalogSharedDataMock); + + expect(findHeaderComponent().props()).toMatchObject({ + isLoadingDetails: true, + isLoadingSharedData: false, + }); + }); + }); + + describe('and shared data is not pre-fetched', () => { + beforeEach(() => { + sharedDataResponse.mockResolvedValue(catalogSharedDataMock); + additionalDataResponse.mockResolvedValue(catalogAdditionalDetailsMock); + createComponent(); + }); + + it('does not render the header skeleton', () => { + expect(findHeaderSkeletonLoader().exists()).toBe(false); + }); + + it('passes all loading state to the header component as true', () => { + expect(findHeaderComponent().props()).toMatchObject({ + isLoadingDetails: true, + isLoadingSharedData: true, + }); + }); + }); + }); + + describe('and there are no resources', () => { + beforeEach(async () => { + const mockError = new Error('error'); + sharedDataResponse.mockRejectedValue(mockError); + additionalDataResponse.mockRejectedValue(mockError); + + createComponent(); + await waitForPromises(); + }); + + it('renders the empty state', () => { + expect(findDetailsComponent().exists()).toBe(false); + expect(findEmptyState().exists()).toBe(true); + expect(findEmptyState().props('primaryButtonLink')).toBe(defaultProvide.ciCatalogPath); + }); + }); + + describe('when data has loaded', () => { + beforeEach(async () => { + sharedDataResponse.mockResolvedValue(catalogSharedDataMock); + additionalDataResponse.mockResolvedValue(catalogAdditionalDetailsMock); + createComponent(); + + await waitForPromises(); + }); + + it('does not render the header skeleton loader', () => { + expect(findHeaderSkeletonLoader().exists()).toBe(false); + }); + + describe('Catalog header', () => { + it('exists', () => { + expect(findHeaderComponent().exists()).toBe(true); + }); + + it('passes expected props', () => { + expect(findHeaderComponent().props()).toEqual({ + isLoadingDetails: false, + isLoadingSharedData: false, + openIssuesCount: defaultAdditionalData.openIssuesCount, + openMergeRequestsCount: defaultAdditionalData.openMergeRequestsCount, + pipelineStatus: + defaultAdditionalData.versions.nodes[0].commit.pipelines.nodes[0].detailedStatus, + resource: defaultSharedData, + }); + }); + }); + + describe('Catalog details', () => { + it('exists', () => { + expect(findDetailsComponent().exists()).toBe(true); + }); + + it('passes expected props', () => { + expect(findDetailsComponent().props()).toEqual({ + resourceId: convertToGraphQLId(CI_CATALOG_RESOURCE_TYPE, defaultAdditionalData.id), + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/mock.js b/spec/frontend/ci/catalog/mock.js new file mode 100644 index 00000000000..21fed6ac8ec --- /dev/null +++ b/spec/frontend/ci/catalog/mock.js @@ -0,0 +1,546 @@ +import { componentsMockData } from '~/ci/catalog/constants'; + +export const catalogResponseBody = { + data: { + ciCatalogResources: { + pageInfo: { + startCursor: + 'eyJjcmVhdGVkX2F0IjoiMjAxNS0wNy0wMyAxMDowMDowMC4wMDAwMDAwMDAgKzAwMDAiLCJpZCI6IjEyOSJ9', + endCursor: + 'eyJjcmVhdGVkX2F0IjoiMjAxNS0wNy0wMyAxMDowMDowMC4wMDAwMDAwMDAgKzAwMDAiLCJpZCI6IjExMCJ9', + hasNextPage: true, + hasPreviousPage: false, + __typename: 'PageInfo', + }, + count: 41, + nodes: [ + { + id: 'gid://gitlab/Ci::Catalog::Resource/129', + icon: null, + name: 'Project-42 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-42', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/128', + icon: null, + name: 'Project-41 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-41', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/127', + icon: null, + name: 'Project-40 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-40', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/126', + icon: null, + name: 'Project-39 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-39', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/125', + icon: null, + name: 'Project-38 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-38', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/124', + icon: null, + name: 'Project-37 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-37', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/123', + icon: null, + name: 'Project-36 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-36', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/122', + icon: null, + name: 'Project-35 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-35', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/121', + icon: null, + name: 'Project-34 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-34', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/120', + icon: null, + name: 'Project-33 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-33', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/119', + icon: null, + name: 'Project-32 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-32', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/118', + icon: null, + name: 'Project-31 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-31', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/117', + icon: null, + name: 'Project-30 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-30', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/116', + icon: null, + name: 'Project-29 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-29', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/115', + icon: null, + name: 'Project-28 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-28', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/114', + icon: null, + name: 'Project-27 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-27', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/113', + icon: null, + name: 'Project-26 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-26', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/112', + icon: null, + name: 'Project-25 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-25', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/111', + icon: null, + name: 'Project-24 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-24', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/110', + icon: null, + name: 'Project-23 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-23', + __typename: 'CiCatalogResource', + }, + ], + __typename: 'CiCatalogResourceConnection', + }, + }, +}; + +export const catalogSinglePageResponse = { + data: { + ciCatalogResources: { + pageInfo: { + startCursor: + 'eyJjcmVhdGVkX2F0IjoiMjAxNS0wNy0wMyAxMDowMDowMC4wMDAwMDAwMDAgKzAwMDAiLCJpZCI6IjEzMiJ9', + endCursor: + 'eyJjcmVhdGVkX2F0IjoiMjAxNS0wNy0wMyAxMDowMDowMC4wMDAwMDAwMDAgKzAwMDAiLCJpZCI6IjEzMCJ9', + hasNextPage: false, + hasPreviousPage: false, + __typename: 'PageInfo', + }, + count: 3, + nodes: [ + { + id: 'gid://gitlab/Ci::Catalog::Resource/132', + icon: null, + name: 'Project-45 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-45', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/131', + icon: null, + name: 'Project-44 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-44', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/130', + icon: null, + name: 'Project-43 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-43', + __typename: 'CiCatalogResource', + }, + ], + __typename: 'CiCatalogResourceConnection', + }, + }, +}; + +export const catalogSharedDataMock = { + data: { + ciCatalogResource: { + __typename: 'CiCatalogResource', + id: `gid://gitlab/CiCatalogResource/1`, + icon: null, + description: 'This is the description of the repo', + name: 'Ruby', + rootNamespace: { id: 1, fullPath: '/group/project', name: 'my-dumb-project' }, + starCount: 1, + forksCount: 2, + latestVersion: { + __typename: 'Release', + id: '3', + tagName: '1.0.0', + tagPath: 'path/to/release', + releasedAt: Date.now(), + author: { id: 1, webUrl: 'profile/1', name: 'username' }, + }, + webPath: 'path/to/project', + }, + }, +}; + +export const catalogAdditionalDetailsMock = { + data: { + ciCatalogResource: { + __typename: 'CiCatalogResource', + id: `gid://gitlab/CiCatalogResource/1`, + openIssuesCount: 4, + openMergeRequestsCount: 10, + readmeHtml: '<h1>Hello world</h1>', + versions: { + __typename: 'ReleaseConnection', + nodes: [ + { + __typename: 'Release', + id: 'gid://gitlab/Release/3', + commit: { + __typename: 'Commit', + id: 'gid://gitlab/CommitPresenter/afa936495f20e08c26ed4a67130ee2166f94fa6e', + pipelines: { + __typename: 'PipelineConnection', + nodes: [ + { + __typename: 'Pipeline', + id: 'gid://gitlab/Ci::Pipeline/583', + detailedStatus: { + __typename: 'DetailedStatus', + id: 'success-583-583', + detailsPath: '/root/cicd-circular/-/pipelines/583', + icon: 'status_success', + text: 'passed', + group: 'success', + }, + }, + ], + }, + }, + tagName: 'v1.0.2', + releasedAt: '2022-08-23T17:19:09Z', + }, + ], + }, + }, + }, +}; + +const generateResourcesNodes = (count = 20, startId = 0) => { + const nodes = []; + for (let i = startId; i < startId + count; i += 1) { + nodes.push({ + __typename: 'CiCatalogResource', + id: `gid://gitlab/CiCatalogResource/${i}`, + description: `This is a component that does a bunch of stuff and is really just a number: ${i}`, + forksCount: 5, + icon: 'my-icon', + name: `My component #${i}`, + rootNamespace: { + id: 1, + __typename: 'Namespace', + name: 'namespaceName', + path: 'namespacePath', + }, + starCount: 10, + latestVersion: { + __typename: 'Release', + id: '3', + tagName: '1.0.0', + tagPath: 'path/to/release', + releasedAt: Date.now(), + author: { id: 1, webUrl: 'profile/1', name: 'username' }, + }, + webPath: 'path/to/project', + }); + } + + return nodes; +}; + +export const mockCatalogResourceItem = generateResourcesNodes(1)[0]; + +export const mockComponents = { + data: { + ciCatalogResource: { + __typename: 'CiCatalogResource', + id: `gid://gitlab/CiCatalogResource/1`, + components: { + ...componentsMockData, + }, + }, + }, +}; diff --git a/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js index 64227872af3..353b5fd3c47 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js @@ -1,10 +1,4 @@ -import { - GlListboxItem, - GlCollapsibleListbox, - GlDropdownDivider, - GlDropdownItem, - GlIcon, -} from '@gitlab/ui'; +import { GlListboxItem, GlCollapsibleListbox, GlDropdownDivider, GlIcon } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { allEnvironments, ENVIRONMENT_QUERY_LIMIT } from '~/ci/ci_variable_list/constants'; import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue'; @@ -16,7 +10,6 @@ describe('Ci environments dropdown', () => { const defaultProps = { areEnvironmentsLoading: false, environments: envs, - hasEnvScopeQuery: false, selectedEnvironmentScope: '', }; @@ -25,7 +18,7 @@ describe('Ci environments dropdown', () => { const findActiveIconByIndex = (index) => findListboxItemByIndex(index).findComponent(GlIcon); const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); const findListboxText = () => findListbox().props('toggleText'); - const findCreateWildcardButton = () => wrapper.findComponent(GlDropdownItem); + const findCreateWildcardButton = () => wrapper.findByTestId('create-wildcard-button'); const findDropdownDivider = () => wrapper.findComponent(GlDropdownDivider); const findMaxEnvNote = () => wrapper.findByTestId('max-envs-notice'); @@ -57,32 +50,23 @@ describe('Ci environments dropdown', () => { }); describe('Search term is empty', () => { - describe.each` - hasEnvScopeQuery | status | defaultEnvStatus | firstItemValue | envIndices - ${true} | ${'exists'} | ${'prepends'} | ${'*'} | ${[1, 2, 3]} - ${false} | ${'does not exist'} | ${'does not prepend'} | ${envs[0]} | ${[0, 1, 2]} - `( - 'when query for fetching environment scope $status', - ({ defaultEnvStatus, firstItemValue, hasEnvScopeQuery, envIndices }) => { - beforeEach(() => { - createComponent({ props: { environments: envs, hasEnvScopeQuery } }); - }); - - it(`${defaultEnvStatus} * in listbox`, () => { - expect(findListboxItemByIndex(0).text()).toBe(firstItemValue); - }); - - it('renders all environments', () => { - expect(findListboxItemByIndex(envIndices[0]).text()).toBe(envs[0]); - expect(findListboxItemByIndex(envIndices[1]).text()).toBe(envs[1]); - expect(findListboxItemByIndex(envIndices[2]).text()).toBe(envs[2]); - }); - - it('does not display active checkmark', () => { - expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(true); - }); - }, - ); + beforeEach(() => { + createComponent({ props: { environments: envs } }); + }); + + it(`prepends * in listbox`, () => { + expect(findListboxItemByIndex(0).text()).toBe('*'); + }); + + it('renders all environments', () => { + expect(findListboxItemByIndex(1).text()).toBe(envs[0]); + expect(findListboxItemByIndex(2).text()).toBe(envs[1]); + expect(findListboxItemByIndex(3).text()).toBe(envs[2]); + }); + + it('does not display active checkmark', () => { + expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(true); + }); }); describe('when `*` is the value of selectedEnvironmentScope props', () => { @@ -98,40 +82,13 @@ describe('Ci environments dropdown', () => { }); }); - describe('when environments are not fetched via graphql', () => { + describe('when fetching environments', () => { const currentEnv = envs[2]; beforeEach(() => { createComponent(); }); - it('filters on the frontend and renders only the environment searched for', async () => { - await findListbox().vm.$emit('search', currentEnv); - - expect(findAllListboxItems()).toHaveLength(1); - expect(findListboxItemByIndex(0).text()).toBe(currentEnv); - }); - - it('does not emit event when searching', async () => { - expect(wrapper.emitted('search-environment-scope')).toBeUndefined(); - - await findListbox().vm.$emit('search', currentEnv); - - expect(wrapper.emitted('search-environment-scope')).toBeUndefined(); - }); - - it('does not display note about max environments shown', () => { - expect(findMaxEnvNote().exists()).toBe(false); - }); - }); - - describe('when fetching environments via graphql', () => { - const currentEnv = envs[2]; - - beforeEach(() => { - createComponent({ props: { hasEnvScopeQuery: true } }); - }); - it('renders dropdown divider', () => { expect(findDropdownDivider().exists()).toBe(true); }); @@ -143,7 +100,7 @@ describe('Ci environments dropdown', () => { }); it('renders dropdown loading icon while fetch query is loading', () => { - createComponent({ props: { areEnvironmentsLoading: true, hasEnvScopeQuery: true } }); + createComponent({ props: { areEnvironmentsLoading: true } }); expect(findListbox().props('loading')).toBe(true); expect(findListbox().props('searching')).toBe(false); @@ -151,7 +108,7 @@ describe('Ci environments dropdown', () => { }); it('renders search loading icon while search query is loading and dropdown is open', async () => { - createComponent({ props: { areEnvironmentsLoading: true, hasEnvScopeQuery: true } }); + createComponent({ props: { areEnvironmentsLoading: true } }); await findListbox().vm.$emit('shown'); expect(findListbox().props('loading')).toBe(false); @@ -188,16 +145,35 @@ describe('Ci environments dropdown', () => { }); }); - describe('when creating a new environment from a search term', () => { - const search = 'new-env'; + describe('when creating a new environment scope from a search term', () => { + const searchTerm = 'new-env'; beforeEach(() => { - createComponent({ searchTerm: search }); + createComponent({ searchTerm }); }); - it('emits create-environment-scope', () => { - findCreateWildcardButton().vm.$emit('click'); + it('sets new environment scope as the selected environment scope', async () => { + findCreateWildcardButton().trigger('click'); + + await findListbox().vm.$emit('search', searchTerm); + + expect(findListbox().props('selected')).toBe(searchTerm); + }); + + it('includes new environment scope in search if it matches search term', async () => { + findCreateWildcardButton().trigger('click'); + + await findListbox().vm.$emit('search', searchTerm); + + expect(findAllListboxItems()).toHaveLength(envs.length + 1); + expect(findListboxItemByIndex(1).text()).toBe(searchTerm); + }); + + it('excludes new environment scope in search if it does not match the search term', async () => { + findCreateWildcardButton().trigger('click'); + + await findListbox().vm.$emit('search', 'not-new-env'); - expect(wrapper.emitted('create-environment-scope')).toEqual([[search]]); + expect(findAllListboxItems()).toHaveLength(envs.length); }); }); }); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js index ab5d914a6a1..207ea7aa060 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js @@ -1,4 +1,5 @@ -import { GlDrawer, GlFormCombobox, GlFormInput, GlFormSelect } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { GlDrawer, GlFormCombobox, GlFormInput, GlFormSelect, GlModal } from '@gitlab/ui'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue'; import CiVariableDrawer from '~/ci/ci_variable_list/components/ci_variable_drawer.vue'; @@ -67,6 +68,8 @@ describe('CI Variable Drawer', () => { }; const findConfirmBtn = () => wrapper.findByTestId('ci-variable-confirm-btn'); + const findConfirmDeleteModal = () => wrapper.findComponent(GlModal); + const findDeleteBtn = () => wrapper.findByTestId('ci-variable-delete-btn'); const findDisabledEnvironmentScopeDropdown = () => wrapper.findComponent(GlFormInput); const findDrawer = () => wrapper.findComponent(GlDrawer); const findEnvironmentScopeDropdown = () => wrapper.findComponent(CiEnvironmentsDropdown); @@ -363,22 +366,118 @@ describe('CI Variable Drawer', () => { }); it('title and confirm button renders the correct text', () => { - expect(findTitle().text()).toBe('Add Variable'); - expect(findConfirmBtn().text()).toBe('Add Variable'); + expect(findTitle().text()).toBe('Add variable'); + expect(findConfirmBtn().text()).toBe('Add variable'); + }); + + it('does not render delete button', () => { + expect(findDeleteBtn().exists()).toBe(false); + }); + + it('dispatches the add-variable event', async () => { + await findKeyField().vm.$emit('input', 'NEW_VARIABLE'); + await findProtectedCheckbox().vm.$emit('input', false); + await findExpandedCheckbox().vm.$emit('input', true); + await findMaskedCheckbox().vm.$emit('input', true); + await findValueField().vm.$emit('input', 'NEW_VALUE'); + + findConfirmBtn().vm.$emit('click'); + + expect(wrapper.emitted('add-variable')).toEqual([ + [ + { + environmentScope: '*', + key: 'NEW_VARIABLE', + masked: true, + protected: false, + raw: false, // opposite of expanded + value: 'NEW_VALUE', + variableType: 'ENV_VAR', + }, + ], + ]); }); }); describe('when editing a variable', () => { beforeEach(() => { createComponent({ - props: { mode: EDIT_VARIABLE_ACTION }, + props: { mode: EDIT_VARIABLE_ACTION, selectedVariable: mockProjectVariableFileType }, stubs: { GlDrawer }, }); }); it('title and confirm button renders the correct text', () => { - expect(findTitle().text()).toBe('Edit Variable'); - expect(findConfirmBtn().text()).toBe('Edit Variable'); + expect(findTitle().text()).toBe('Edit variable'); + expect(findConfirmBtn().text()).toBe('Edit variable'); + }); + + it('dispatches the edit-variable event', async () => { + await findValueField().vm.$emit('input', 'EDITED_VALUE'); + + findConfirmBtn().vm.$emit('click'); + + expect(wrapper.emitted('update-variable')).toEqual([ + [ + { + ...mockProjectVariableFileType, + value: 'EDITED_VALUE', + }, + ], + ]); + }); + }); + + describe('when deleting a variable', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + props: { mode: EDIT_VARIABLE_ACTION, selectedVariable: mockProjectVariableFileType }, + }); + }); + + it('bubbles up the delete-variable event', async () => { + findDeleteBtn().vm.$emit('click'); + + await nextTick(); + + findConfirmDeleteModal().vm.$emit('primary'); + + expect(wrapper.emitted('delete-variable')).toEqual([[mockProjectVariableFileType]]); + }); + }); + + describe('environment scope events', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + props: { + mode: EDIT_VARIABLE_ACTION, + selectedVariable: mockProjectVariableFileType, + areScopedVariablesAvailable: true, + hideEnvironmentScope: false, + }, + }); + }); + + it('sets the environment scope', async () => { + await findEnvironmentScopeDropdown().vm.$emit('select-environment', 'staging'); + await findConfirmBtn().vm.$emit('click'); + + expect(wrapper.emitted('update-variable')).toEqual([ + [ + { + ...mockProjectVariableFileType, + environmentScope: 'staging', + }, + ], + ]); + }); + + it('bubbles up the search event', async () => { + await findEnvironmentScopeDropdown().vm.$emit('search-environment-scope', 'staging'); + + expect(wrapper.emitted('search-environment-scope')).toEqual([['staging']]); }); }); }); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js index 7dce23f72c0..5ba9b3b8c20 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js @@ -12,12 +12,10 @@ import { ENVIRONMENT_SCOPE_LINK_TITLE, AWS_TIP_TITLE, AWS_TIP_MESSAGE, - groupString, instanceString, - projectString, variableOptions, } from '~/ci/ci_variable_list/constants'; -import { mockEnvs, mockVariablesWithScopes, mockVariablesWithUniqueScopes } from '../mocks'; +import { mockVariablesWithScopes } from '../mocks'; import ModalStub from '../stubs'; describe('Ci variable modal', () => { @@ -46,7 +44,6 @@ describe('Ci variable modal', () => { areScopedVariablesAvailable: true, environments: [], hideEnvironmentScope: false, - hasEnvScopeQuery: false, mode: ADD_VARIABLE_ACTION, selectedVariable: {}, variables: [], @@ -352,42 +349,6 @@ describe('Ci variable modal', () => { expect(link.attributes('title')).toBe(ENVIRONMENT_SCOPE_LINK_TITLE); expect(link.attributes('href')).toBe(defaultProvide.environmentScopeLink); }); - - describe('when query for envioronment scope exists', () => { - beforeEach(() => { - createComponent({ - props: { - environments: mockEnvs, - hasEnvScopeQuery: true, - variables: mockVariablesWithUniqueScopes(projectString), - }, - }); - }); - - it('does not merge environment scope sources', () => { - const expectedLength = mockEnvs.length; - - expect(findCiEnvironmentsDropdown().props('environments')).toHaveLength(expectedLength); - }); - }); - - describe('when feature flag is disabled', () => { - const mockGroupVariables = mockVariablesWithUniqueScopes(groupString); - beforeEach(() => { - createComponent({ - props: { - environments: mockEnvs, - variables: mockGroupVariables, - }, - }); - }); - - it('merges environment scope sources', () => { - const expectedLength = mockGroupVariables.length + mockEnvs.length; - - expect(findCiEnvironmentsDropdown().props('environments')).toHaveLength(expectedLength); - }); - }); }); describe('and section is hidden', () => { diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js index 79dd638e2bd..04145c2c6aa 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js @@ -23,7 +23,6 @@ describe('Ci variable table', () => { environments: mapEnvironmentNames(mockEnvs), hideEnvironmentScope: false, isLoading: false, - hasEnvScopeQuery: false, maxVariableLimit: 5, pageInfo: { after: '' }, variables: mockVariablesWithScopes(projectString), @@ -70,7 +69,6 @@ describe('Ci variable table', () => { areEnvironmentsLoading: defaultProps.areEnvironmentsLoading, areScopedVariablesAvailable: defaultProps.areScopedVariablesAvailable, environments: defaultProps.environments, - hasEnvScopeQuery: defaultProps.hasEnvScopeQuery, hideEnvironmentScope: defaultProps.hideEnvironmentScope, variables: defaultProps.variables, mode: ADD_VARIABLE_ACTION, @@ -142,7 +140,7 @@ describe('Ci variable table', () => { }); }); - describe('variable events', () => { + describe('variable events for modal', () => { beforeEach(() => { createComponent(); }); @@ -161,6 +159,25 @@ describe('Ci variable table', () => { }); }); + describe('variable events for drawer', () => { + beforeEach(() => { + createComponent({ featureFlags: { ciVariableDrawer: true } }); + }); + + it.each` + eventName + ${'add-variable'} + ${'update-variable'} + ${'delete-variable'} + `('bubbles up the $eventName event', async ({ eventName }) => { + await findCiVariableTable().vm.$emit('set-selected-variable'); + + await findCiVariableDrawer().vm.$emit(eventName, newVariable); + + expect(wrapper.emitted(eventName)).toEqual([[newVariable]]); + }); + }); + describe('pages events', () => { beforeEach(() => { createComponent(); @@ -178,7 +195,7 @@ describe('Ci variable table', () => { }); }); - describe('environment events', () => { + describe('environment events for modal', () => { beforeEach(() => { createComponent(); }); @@ -191,4 +208,18 @@ describe('Ci variable table', () => { expect(wrapper.emitted('search-environment-scope')).toEqual([['staging']]); }); }); + + describe('environment events for drawer', () => { + beforeEach(() => { + createComponent({ featureFlags: { ciVariableDrawer: true } }); + }); + + it('bubbles up the search event', async () => { + await findCiVariableTable().vm.$emit('set-selected-variable'); + + await findCiVariableDrawer().vm.$emit('search-environment-scope', 'staging'); + + expect(wrapper.emitted('search-environment-scope')).toEqual([['staging']]); + }); + }); }); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js index 6fa1915f3c1..c90ff4cc682 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js @@ -52,7 +52,6 @@ const mockProvide = { const defaultProps = { areScopedVariablesAvailable: true, - hasEnvScopeQuery: false, pageInfo: {}, hideEnvironmentScope: false, refetchAfterMutation: false, @@ -514,7 +513,6 @@ describe('Ci Variable Shared Component', () => { areEnvironmentsLoading: false, areScopedVariablesAvailable: wrapper.props().areScopedVariablesAvailable, hideEnvironmentScope: defaultProps.hideEnvironmentScope, - hasEnvScopeQuery: props.hasEnvScopeQuery, pageInfo: defaultProps.pageInfo, isLoading: false, maxVariableLimit, diff --git a/spec/frontend/ci/ci_variable_list/mocks.js b/spec/frontend/ci/ci_variable_list/mocks.js index 41dfc0ebfda..9c9c99ad5ea 100644 --- a/spec/frontend/ci/ci_variable_list/mocks.js +++ b/spec/frontend/ci/ci_variable_list/mocks.js @@ -189,7 +189,6 @@ export const createProjectProps = () => { componentName: 'ProjectVariable', entity: 'project', fullPath: '/namespace/project/', - hasEnvScopeQuery: true, id: 'gid://gitlab/Project/20', mutationData: { [ADD_MUTATION_ACTION]: addProjectVariable, @@ -214,7 +213,6 @@ export const createGroupProps = () => { componentName: 'GroupVariable', entity: 'group', fullPath: '/my-group', - hasEnvScopeQuery: false, id: 'gid://gitlab/Group/20', mutationData: { [ADD_MUTATION_ACTION]: addGroupVariable, @@ -233,7 +231,6 @@ export const createGroupProps = () => { export const createInstanceProps = () => { return { componentName: 'InstanceVariable', - hasEnvScopeQuery: false, entity: '', mutationData: { [ADD_MUTATION_ACTION]: addAdminVariable, diff --git a/spec/frontend/ci/ci_variable_list/utils_spec.js b/spec/frontend/ci/ci_variable_list/utils_spec.js index beeae71376a..fbcf0e7c5a5 100644 --- a/spec/frontend/ci/ci_variable_list/utils_spec.js +++ b/spec/frontend/ci/ci_variable_list/utils_spec.js @@ -1,58 +1,7 @@ -import { - createJoinedEnvironments, - convertEnvironmentScope, - mapEnvironmentNames, -} from '~/ci/ci_variable_list/utils'; +import { convertEnvironmentScope, mapEnvironmentNames } from '~/ci/ci_variable_list/utils'; import { allEnvironments } from '~/ci/ci_variable_list/constants'; describe('utils', () => { - const environments = ['dev', 'prod']; - const newEnvironments = ['staging']; - - describe('createJoinedEnvironments', () => { - it('returns only `environments` if `variables` argument is undefined', () => { - const variables = undefined; - - expect(createJoinedEnvironments(variables, environments, [])).toEqual(environments); - }); - - it('returns a list of environments and environment scopes taken from variables in alphabetical order', () => { - const envScope1 = 'new1'; - const envScope2 = 'new2'; - - const variables = [{ environmentScope: envScope1 }, { environmentScope: envScope2 }]; - - expect(createJoinedEnvironments(variables, environments, [])).toEqual([ - environments[0], - envScope1, - envScope2, - environments[1], - ]); - }); - - it('returns combined list with new environments included', () => { - const variables = undefined; - - expect(createJoinedEnvironments(variables, environments, newEnvironments)).toEqual([ - ...environments, - ...newEnvironments, - ]); - }); - - it('removes duplicate environments', () => { - const envScope1 = environments[0]; - const envScope2 = 'new2'; - - const variables = [{ environmentScope: envScope1 }, { environmentScope: envScope2 }]; - - expect(createJoinedEnvironments(variables, environments, [])).toEqual([ - environments[0], - envScope2, - environments[1], - ]); - }); - }); - describe('convertEnvironmentScope', () => { it('converts the * to the `All environments` text', () => { expect(convertEnvironmentScope('*')).toBe(allEnvironments.text); diff --git a/spec/frontend/ci/common/pipelines_table_spec.js b/spec/frontend/ci/common/pipelines_table_spec.js index 26dd1a2fcc5..6cf391d72ca 100644 --- a/spec/frontend/ci/common/pipelines_table_spec.js +++ b/spec/frontend/ci/common/pipelines_table_spec.js @@ -1,9 +1,7 @@ -import '~/commons'; import { GlTableLite } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; import fixture from 'test_fixtures/pipelines/pipelines.json'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; import PipelineFailedJobsWidget from '~/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue'; import PipelineOperations from '~/ci/pipelines_page/components/pipeline_operations.vue'; @@ -12,7 +10,7 @@ import PipelineUrl from '~/ci/pipelines_page/components/pipeline_url.vue'; import PipelinesTable from '~/ci/common/pipelines_table.vue'; import PipelinesTimeago from '~/ci/pipelines_page/components/time_ago.vue'; import { - PipelineKeyOptions, + PIPELINE_ID_KEY, BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL, TRACKING_CATEGORIES, @@ -20,51 +18,43 @@ import { import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; -jest.mock('~/ci/event_hub'); - describe('Pipelines Table', () => { - let pipeline; let wrapper; let trackingSpy; const defaultProvide = { - glFeatures: {}, - withFailedJobsDetails: false, + fullPath: '/my-project/', + useFailedJobsWidget: false, }; - const provideWithDetails = { - glFeatures: { - ciJobFailuresInMr: true, - }, - withFailedJobsDetails: true, + const provideWithFailedJobsWidget = { + useFailedJobsWidget: true, }; - const defaultProps = { - pipelines: [], - viewType: 'root', - pipelineKeyOption: PipelineKeyOptions[0], - }; + const { pipelines } = fixture; - const createMockPipeline = () => { - // Clone fixture as it could be modified by tests - const { pipelines } = JSON.parse(JSON.stringify(fixture)); - return pipelines.find((p) => p.user !== null && p.commit !== null); + const defaultProps = { + pipelines, + pipelineIdType: PIPELINE_ID_KEY, }; - const createComponent = (props = {}, provide = {}) => { - wrapper = extendedWrapper( - mount(PipelinesTable, { - propsData: { - ...defaultProps, - ...props, - }, - provide: { - ...defaultProvide, - ...provide, - }, - stubs: ['PipelineFailedJobsWidget'], - }), - ); + const [firstPipeline] = pipelines; + + const createComponent = ({ props = {}, provide = {}, stubs = {} } = {}) => { + wrapper = mountExtended(PipelinesTable, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + ...defaultProvide, + ...provide, + }, + stubs: { + PipelineOperations: true, + ...stubs, + }, + }); }; const findGlTableLite = () => wrapper.findComponent(GlTableLite); @@ -84,13 +74,9 @@ describe('Pipelines Table', () => { const findRetryBtn = () => wrapper.findByTestId('pipelines-retry-button'); const findCancelBtn = () => wrapper.findByTestId('pipelines-cancel-button'); - beforeEach(() => { - pipeline = createMockPipeline(); - }); - describe('Pipelines Table', () => { beforeEach(() => { - createComponent({ pipelines: [pipeline], viewType: 'root' }); + createComponent({ props: { viewType: 'root' } }); }); it('displays table', () => { @@ -105,7 +91,7 @@ describe('Pipelines Table', () => { }); it('should display a table row', () => { - expect(findTableRows()).toHaveLength(1); + expect(findTableRows()).toHaveLength(pipelines.length); }); describe('status cell', () => { @@ -120,7 +106,7 @@ describe('Pipelines Table', () => { }); it('should display the pipeline id', () => { - expect(findPipelineInfo().text()).toContain(`#${pipeline.id}`); + expect(findPipelineInfo().text()).toContain(`#${firstPipeline.id}`); }); }); @@ -130,24 +116,33 @@ describe('Pipelines Table', () => { }); it('should render the right number of stages', () => { - const stagesLength = pipeline.details.stages.length; - expect(findLegacyPipelineMiniGraph().props('stages').length).toBe(stagesLength); + const stagesLength = firstPipeline.details.stages.length; + expect(findLegacyPipelineMiniGraph().props('stages')).toHaveLength(stagesLength); }); it('should render the latest downstream pipelines only', () => { // component receives two downstream pipelines. one of them is already outdated // because we retried the trigger job, so the mini pipeline graph will only // render the newly created downstream pipeline instead - expect(pipeline.triggered).toHaveLength(2); + expect(firstPipeline.triggered).toHaveLength(2); expect(findLegacyPipelineMiniGraph().props('downstreamPipelines')).toHaveLength(1); }); describe('when pipeline does not have stages', () => { beforeEach(() => { - pipeline = createMockPipeline(); - pipeline.details.stages = []; - - createComponent({ pipelines: [pipeline] }); + createComponent({ + props: { + pipelines: [ + { + ...firstPipeline, + details: { + ...firstPipeline.details, + stages: [], + }, + }, + ], + }, + }); }); it('stages are not rendered', () => { @@ -163,6 +158,10 @@ describe('Pipelines Table', () => { }); describe('operations cell', () => { + beforeEach(() => { + createComponent({ stubs: { PipelineOperations } }); + }); + it('should render pipeline operations', () => { expect(findActions().exists()).toBe(true); }); @@ -183,97 +182,101 @@ describe('Pipelines Table', () => { }); describe('failed jobs details', () => { - describe('row', () => { - describe('when the FF is disabled', () => { - beforeEach(() => { - createComponent({ pipelines: [pipeline] }); - }); + describe('when `useFailedJobsWidget` value is provided', () => { + beforeEach(() => { + createComponent({ provide: provideWithFailedJobsWidget }); + }); - it('does not render', () => { - expect(findTableRows()).toHaveLength(1); - expect(findPipelineFailureWidget().exists()).toBe(false); - }); + it('renders', () => { + // We have 2 rows per pipeline with the widget + expect(findTableRows()).toHaveLength(pipelines.length * 2); + expect(findPipelineFailureWidget().exists()).toBe(true); }); - describe('when the FF is enabled', () => { - describe('and `withFailedJobsDetails` value is provided', () => { - beforeEach(() => { - createComponent({ pipelines: [pipeline] }, provideWithDetails); - }); - - it('renders', () => { - expect(findTableRows()).toHaveLength(2); - expect(findPipelineFailureWidget().exists()).toBe(true); - }); - - it('passes the expected props', () => { - expect(findPipelineFailureWidget().props()).toStrictEqual({ - failedJobsCount: pipeline.failed_builds.length, - isPipelineActive: pipeline.active, - pipelineIid: pipeline.iid, - pipelinePath: pipeline.path, - // Make sure the forward slash was removed - projectPath: 'frontend-fixtures/pipelines-project', - }); - }); + it('passes the expected props', () => { + expect(findPipelineFailureWidget().props()).toStrictEqual({ + failedJobsCount: firstPipeline.failed_builds_count, + isPipelineActive: firstPipeline.active, + pipelineIid: firstPipeline.iid, + pipelinePath: firstPipeline.path, + // Make sure the forward slash was removed + projectPath: 'frontend-fixtures/pipelines-project', }); + }); + }); - describe('and `withFailedJobsDetails` value is not provided', () => { - beforeEach(() => { - createComponent( - { pipelines: [pipeline] }, - { glFeatures: { ciJobFailuresInMr: true } }, - ); - }); - - it('does not render', () => { - expect(findTableRows()).toHaveLength(1); - expect(findPipelineFailureWidget().exists()).toBe(false); - }); - }); + describe('and `useFailedJobsWidget` value is not provided', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not render', () => { + expect(findTableRows()).toHaveLength(pipelines.length); + expect(findPipelineFailureWidget().exists()).toBe(false); }); }); }); + }); - describe('tracking', () => { - beforeEach(() => { - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + describe('events', () => { + beforeEach(() => { + createComponent(); + }); + + describe('when confirming to cancel a pipeline', () => { + beforeEach(async () => { + await findActions().vm.$emit('cancel-pipeline', firstPipeline); }); - afterEach(() => { - unmockTracking(); + it('emits the `cancel-pipeline` event', () => { + expect(wrapper.emitted('cancel-pipeline')).toEqual([[firstPipeline]]); }); + }); - it('tracks status badge click', () => { - findCiBadgeLink().vm.$emit('ciStatusBadgeClick'); + describe('when retrying a pipeline', () => { + beforeEach(() => { + findActions().vm.$emit('retry-pipeline', firstPipeline); + }); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_ci_status_badge', { - label: TRACKING_CATEGORIES.table, - }); + it('emits the `retry-pipeline` event', () => { + expect(wrapper.emitted('retry-pipeline')).toEqual([[firstPipeline]]); }); + }); - it('tracks retry pipeline button click', () => { - findRetryBtn().vm.$emit('click'); + describe('when refreshing pipelines', () => { + beforeEach(() => { + findActions().vm.$emit('refresh-pipelines-table'); + }); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_retry_button', { - label: TRACKING_CATEGORIES.table, - }); + it('emits the `refresh-pipelines-table` event', () => { + expect(wrapper.emitted('refresh-pipelines-table')).toEqual([[]]); }); + }); + }); - it('tracks cancel pipeline button click', () => { - findCancelBtn().vm.$emit('click'); + describe('tracking', () => { + beforeEach(() => { + createComponent(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_cancel_button', { - label: TRACKING_CATEGORIES.table, - }); + afterEach(() => { + unmockTracking(); + }); + + it('tracks status badge click', () => { + findCiBadgeLink().vm.$emit('ciStatusBadgeClick'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_ci_status_badge', { + label: TRACKING_CATEGORIES.table, }); + }); - it('tracks pipeline mini graph stage click', () => { - findLegacyPipelineMiniGraph().vm.$emit('miniGraphStageClick'); + it('tracks pipeline mini graph stage click', () => { + findLegacyPipelineMiniGraph().vm.$emit('miniGraphStageClick'); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_minigraph', { - label: TRACKING_CATEGORIES.table, - }); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_minigraph', { + label: TRACKING_CATEGORIES.table, }); }); }); diff --git a/spec/frontend/ci/job_details/components/job_header_spec.js b/spec/frontend/ci/job_details/components/job_header_spec.js index 6fc55732353..609369316f5 100644 --- a/spec/frontend/ci/job_details/components/job_header_spec.js +++ b/spec/frontend/ci/job_details/components/job_header_spec.js @@ -16,7 +16,7 @@ describe('Header CI Component', () => { text: 'failed', details_path: 'path', }, - name: 'Job build_job', + name: 'build_job', time: '2017-05-08T14:57:39.781Z', user: { id: 1234, @@ -34,17 +34,15 @@ describe('Header CI Component', () => { const findUserLink = () => wrapper.findComponent(GlAvatarLink); const findSidebarToggleBtn = () => wrapper.findComponent(GlButton); const findStatusTooltip = () => wrapper.findComponent(GlTooltip); - const findActionButtons = () => wrapper.findByTestId('job-header-action-buttons'); const findJobName = () => wrapper.findByTestId('job-name'); - const createComponent = (props, slots) => { + const createComponent = (props) => { wrapper = extendedWrapper( shallowMount(JobHeader, { propsData: { ...defaultProps, ...props, }, - ...slots, }), ); }; @@ -54,6 +52,10 @@ describe('Header CI Component', () => { createComponent(); }); + it('renders the correct job name', () => { + expect(findJobName().text()).toBe(defaultProps.name); + }); + it('should render status badge', () => { expect(findCiBadgeLink().exists()).toBe(true); }); @@ -65,10 +67,6 @@ describe('Header CI Component', () => { it('should render sidebar toggle button', () => { expect(findSidebarToggleBtn().exists()).toBe(true); }); - - it('should not render header action buttons when slot is empty', () => { - expect(findActionButtons().exists()).toBe(false); - }); }); describe('user avatar', () => { @@ -124,31 +122,12 @@ describe('Header CI Component', () => { }); }); - describe('job name', () => { - beforeEach(() => { - createComponent(); - }); - - it('should render the job name', () => { - expect(findJobName().text()).toBe('Job build_job'); - }); - }); - - describe('slot', () => { - it('should render header action buttons', () => { - createComponent({}, { slots: { default: 'Test Actions' } }); - - expect(findActionButtons().exists()).toBe(true); - expect(findActionButtons().text()).toBe('Test Actions'); - }); - }); - describe('shouldRenderTriggeredLabel', () => { it('should render created keyword when the shouldRenderTriggeredLabel is false', () => { createComponent({ shouldRenderTriggeredLabel: false }); - expect(wrapper.text()).toContain('created'); - expect(wrapper.text()).not.toContain('started'); + expect(wrapper.text()).toContain('Created'); + expect(wrapper.text()).not.toContain('Started'); }); }); }); diff --git a/spec/frontend/ci/job_details/components/log/collapsible_section_spec.js b/spec/frontend/ci/job_details/components/log/collapsible_section_spec.js index e3d5c448338..5abf2a5ce53 100644 --- a/spec/frontend/ci/job_details/components/log/collapsible_section_spec.js +++ b/spec/frontend/ci/job_details/components/log/collapsible_section_spec.js @@ -1,6 +1,7 @@ import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import CollapsibleSection from '~/ci/job_details/components/log/collapsible_section.vue'; +import LogLine from '~/ci/job_details/components/log/line.vue'; import LogLineHeader from '~/ci/job_details/components/log/line_header.vue'; import { collapsibleSectionClosed, collapsibleSectionOpened } from './mock_data'; @@ -9,9 +10,9 @@ describe('Job Log Collapsible Section', () => { const jobLogEndpoint = 'jobs/335'; - const findCollapsibleLine = () => wrapper.find('.collapsible-line'); - const findCollapsibleLineSvg = () => wrapper.find('.collapsible-line svg'); const findLogLineHeader = () => wrapper.findComponent(LogLineHeader); + const findLogLineHeaderSvg = () => findLogLineHeader().find('svg'); + const findLogLines = () => wrapper.findAllComponents(LogLine); const createComponent = (props = {}) => { wrapper = mount(CollapsibleSection, { @@ -30,11 +31,16 @@ describe('Job Log Collapsible Section', () => { }); it('renders clickable header line', () => { - expect(findCollapsibleLine().attributes('role')).toBe('button'); + expect(findLogLineHeader().text()).toBe('1 foo'); + expect(findLogLineHeader().attributes('role')).toBe('button'); }); - it('renders an icon with the closed state', () => { - expect(findCollapsibleLineSvg().attributes('data-testid')).toBe('chevron-lg-right-icon'); + it('renders an icon with a closed state', () => { + expect(findLogLineHeaderSvg().attributes('data-testid')).toBe('chevron-lg-right-icon'); + }); + + it('does not render collapsed lines', () => { + expect(findLogLines()).toHaveLength(0); }); }); @@ -47,15 +53,17 @@ describe('Job Log Collapsible Section', () => { }); it('renders clickable header line', () => { - expect(findCollapsibleLine().attributes('role')).toBe('button'); + expect(findLogLineHeader().text()).toContain('foo'); + expect(findLogLineHeader().attributes('role')).toBe('button'); }); it('renders an icon with the open state', () => { - expect(findCollapsibleLineSvg().attributes('data-testid')).toBe('chevron-lg-down-icon'); + expect(findLogLineHeaderSvg().attributes('data-testid')).toBe('chevron-lg-down-icon'); }); - it('renders collapsible lines content', () => { - expect(wrapper.findAll('.js-line').length).toEqual(collapsibleSectionOpened.lines.length); + it('renders collapsible lines', () => { + expect(findLogLines().at(0).text()).toContain('this is a collapsible nested section'); + expect(findLogLines()).toHaveLength(collapsibleSectionOpened.lines.length); }); }); @@ -65,7 +73,7 @@ describe('Job Log Collapsible Section', () => { jobLogEndpoint, }); - findCollapsibleLine().trigger('click'); + findLogLineHeader().trigger('click'); await nextTick(); expect(wrapper.emitted('onClickCollapsibleLine').length).toBe(1); diff --git a/spec/frontend/ci/job_details/components/log/line_header_spec.js b/spec/frontend/ci/job_details/components/log/line_header_spec.js index 7d1b05346f2..45296e4b6c2 100644 --- a/spec/frontend/ci/job_details/components/log/line_header_spec.js +++ b/spec/frontend/ci/job_details/components/log/line_header_spec.js @@ -16,7 +16,7 @@ describe('Job Log Header Line', () => { style: 'term-fg-l-green', }, ], - lineNumber: 76, + lineNumber: 77, }, isClosed: true, path: '/jashkenas/underscore/-/jobs/335', diff --git a/spec/frontend/ci/job_details/components/log/line_number_spec.js b/spec/frontend/ci/job_details/components/log/line_number_spec.js index d5c1d0fd985..db964e341b7 100644 --- a/spec/frontend/ci/job_details/components/log/line_number_spec.js +++ b/spec/frontend/ci/job_details/components/log/line_number_spec.js @@ -5,7 +5,7 @@ describe('Job Log Line Number', () => { let wrapper; const data = { - lineNumber: 0, + lineNumber: 1, path: '/jashkenas/underscore/-/jobs/335', }; diff --git a/spec/frontend/ci/job_details/components/log/line_spec.js b/spec/frontend/ci/job_details/components/log/line_spec.js index b6f3a2b68df..dad41d0cd7f 100644 --- a/spec/frontend/ci/job_details/components/log/line_spec.js +++ b/spec/frontend/ci/job_details/components/log/line_spec.js @@ -224,7 +224,7 @@ describe('Job Log Line', () => { offset: 24526, content: [{ text: 'job log content' }], section: 'custom-section', - lineNumber: 76, + lineNumber: 77, }, path: '/root/ci-project/-/jobs/6353', }); diff --git a/spec/frontend/ci/job_details/components/log/log_spec.js b/spec/frontend/ci/job_details/components/log/log_spec.js index cc1621b87d6..1931d5046dc 100644 --- a/spec/frontend/ci/job_details/components/log/log_spec.js +++ b/spec/frontend/ci/job_details/components/log/log_spec.js @@ -7,7 +7,7 @@ import { scrollToElement } from '~/lib/utils/common_utils'; import Log from '~/ci/job_details/components/log/log.vue'; import LogLineHeader from '~/ci/job_details/components/log/line_header.vue'; import { logLinesParser } from '~/ci/job_details/store/utils'; -import { jobLog } from './mock_data'; +import { mockJobLog, mockJobLogLineCount } from './mock_data'; jest.mock('~/lib/utils/common_utils', () => ({ ...jest.requireActual('~/lib/utils/common_utils'), @@ -39,7 +39,7 @@ describe('Job Log', () => { }; state = { - jobLog: logLinesParser(jobLog), + jobLog: logLinesParser(mockJobLog), jobLogEndpoint: 'jobs/id', }; @@ -57,15 +57,18 @@ describe('Job Log', () => { createComponent(); }); - it('renders a line number for each open line', () => { - expect(wrapper.find('#L1').text()).toBe('1'); - expect(wrapper.find('#L2').text()).toBe('2'); - expect(wrapper.find('#L3').text()).toBe('3'); - }); + it.each([...Array(mockJobLogLineCount).keys()])( + 'renders a line number for each line %d', + (index) => { + const lineNumber = wrapper + .findAll('.js-log-line') + .at(index) + .find(`#L${index + 1}`); - it('links to the provided path and correct line number', () => { - expect(wrapper.find('#L1').attributes('href')).toBe(`${state.jobLogEndpoint}#L1`); - }); + expect(lineNumber.text()).toBe(`${index + 1}`); + expect(lineNumber.attributes('href')).toBe(`${state.jobLogEndpoint}#L${index + 1}`); + }, + ); }); describe('collapsible sections', () => { @@ -103,7 +106,7 @@ describe('Job Log', () => { await waitForPromises(); - expect(wrapper.find('#L6').exists()).toBe(false); + expect(wrapper.find('#L9').exists()).toBe(false); expect(scrollToElement).not.toHaveBeenCalled(); }); }); @@ -116,19 +119,19 @@ describe('Job Log', () => { it('scrolls to line number', async () => { createComponent(); - state.jobLog = logLinesParser(jobLog, [], '#L6'); + state.jobLog = logLinesParser(mockJobLog, [], '#L6'); await waitForPromises(); expect(scrollToElement).toHaveBeenCalledTimes(1); - state.jobLog = logLinesParser(jobLog, [], '#L7'); + state.jobLog = logLinesParser(mockJobLog, [], '#L7'); await waitForPromises(); expect(scrollToElement).toHaveBeenCalledTimes(1); }); it('line number within collapsed section is visible', () => { - state.jobLog = logLinesParser(jobLog, [], '#L6'); + state.jobLog = logLinesParser(mockJobLog, [], '#L6'); createComponent(); @@ -148,7 +151,7 @@ describe('Job Log', () => { ], section: 'prepare-executor', section_header: true, - lineNumber: 2, + lineNumber: 3, }, ]; diff --git a/spec/frontend/ci/job_details/components/log/mock_data.js b/spec/frontend/ci/job_details/components/log/mock_data.js index fa51b92a044..14669872cc1 100644 --- a/spec/frontend/ci/job_details/components/log/mock_data.js +++ b/spec/frontend/ci/job_details/components/log/mock_data.js @@ -1,4 +1,4 @@ -export const jobLog = [ +export const mockJobLog = [ { offset: 1000, content: [{ text: 'Running with gitlab-runner 12.1.0 (de7731dd)' }], @@ -19,69 +19,50 @@ export const jobLog = [ }, { offset: 1003, - content: [{ text: 'Starting service postgres:9.6.14 ...', style: 'text-green' }], + content: [{ text: 'Docker executor with image registry.gitlab.com ...' }], section: 'prepare-executor', }, { offset: 1004, - content: [ - { - text: 'Restore cache', - style: 'term-fg-l-cyan term-bold', - }, - ], - section: 'restore-cache', - section_header: true, - section_options: { - collapsed: 'true', - }, + content: [{ text: 'Starting service ...', style: 'term-fg-l-green' }], + section: 'prepare-executor', }, { offset: 1005, - content: [ - { - text: 'Checking cache for ruby-gems-debian-bullseye-ruby-3.0-16...', - style: 'term-fg-l-green term-bold', - }, - ], - section: 'restore-cache', - }, -]; - -export const utilsMockData = [ - { - offset: 1001, - content: [{ text: ' on docker-auto-scale-com 8a6210b8' }], + content: [], + section: 'prepare-executor', + section_duration: '00:09', }, { - offset: 1002, + offset: 1006, content: [ { - text: - 'Using Docker executor with image dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.6.6-golang-1.14-git-2.28-lfs-2.9-chrome-84-node-12.x-yarn-1.21-postgresql-11-graphicsmagick-1.3.34', + text: 'Getting source from Git repository', }, ], - section: 'prepare-executor', + section: 'get-sources', section_header: true, }, { - offset: 1003, - content: [{ text: 'Starting service postgres:9.6.14 ...' }], - section: 'prepare-executor', + offset: 1007, + content: [{ text: 'Fetching changes with git depth set to 20...' }], + section: 'get-sources', }, { - offset: 1004, - content: [{ text: 'Pulling docker image postgres:9.6.14 ...', style: 'term-fg-l-green' }], - section: 'prepare-executor', + offset: 1008, + content: [{ text: 'Initialized empty Git repository', style: 'term-fg-l-green' }], + section: 'get-sources', }, { - offset: 1005, + offset: 1009, content: [], - section: 'prepare-executor', - section_duration: '10:00', + section: 'get-sources', + section_duration: '00:19', }, ]; +export const mockJobLogLineCount = 8; // `text` entries in mockJobLog + export const originalTrace = [ { offset: 1, @@ -191,7 +172,7 @@ export const collapsibleSectionClosed = { offset: 80, content: [{ text: 'this is a collapsible nested section' }], section: 'prepare-script', - lineNumber: 3, + lineNumber: 2, }, ], }; @@ -212,7 +193,7 @@ export const collapsibleSectionOpened = { offset: 80, content: [{ text: 'this is a collapsible nested section' }], section: 'prepare-script', - lineNumber: 3, + lineNumber: 2, }, ], }; diff --git a/spec/frontend/ci/job_details/components/sidebar/artifacts_block_spec.js b/spec/frontend/ci/job_details/components/sidebar/artifacts_block_spec.js index 1d61bf3243f..e539be2b220 100644 --- a/spec/frontend/ci/job_details/components/sidebar/artifacts_block_spec.js +++ b/spec/frontend/ci/job_details/components/sidebar/artifacts_block_spec.js @@ -30,31 +30,31 @@ describe('Artifacts block', () => { 'These artifacts are the latest. They will not be deleted (even if expired) until newer artifacts are available.'; const expiredArtifact = { - expire_at: expireAt, + expireAt, expired: true, locked: false, }; const nonExpiredArtifact = { - download_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/download', - browse_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/browse', - keep_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/keep', - expire_at: expireAt, + downloadPath: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/download', + browsePath: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/browse', + keepPath: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/keep', + expireAt, expired: false, locked: false, }; const lockedExpiredArtifact = { ...expiredArtifact, - download_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/download', - browse_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/browse', + downloadPath: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/download', + browsePath: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/browse', expired: true, locked: true, }; const lockedNonExpiredArtifact = { ...nonExpiredArtifact, - keep_path: undefined, + keepPath: undefined, locked: true, }; diff --git a/spec/frontend/ci/job_details/components/sidebar/sidebar_header_spec.js b/spec/frontend/ci/job_details/components/sidebar/sidebar_header_spec.js index 1063bec6f3b..81181fc71b2 100644 --- a/spec/frontend/ci/job_details/components/sidebar/sidebar_header_spec.js +++ b/spec/frontend/ci/job_details/components/sidebar/sidebar_header_spec.js @@ -55,15 +55,9 @@ describe('Sidebar Header', () => { const findEraseButton = () => wrapper.findByTestId('job-log-erase-link'); const findNewIssueButton = () => wrapper.findByTestId('job-new-issue'); const findTerminalLink = () => wrapper.findByTestId('terminal-link'); - const findJobName = () => wrapper.findByTestId('job-name'); const findRetryButton = () => wrapper.findComponent(JobRetryButton); describe('when rendering contents', () => { - it('renders the correct job name', async () => { - await createComponentWithApollo(); - expect(findJobName().text()).toBe(mockJobResponse.data.project.job.name); - }); - it('does not render buttons with no paths', async () => { await createComponentWithApollo(); expect(findCancelButton().exists()).toBe(false); diff --git a/spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js b/spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js index e188d99b8b1..37a2ca75df0 100644 --- a/spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js +++ b/spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js @@ -53,7 +53,6 @@ describe('Job Sidebar Details Container', () => { ['erased_at', 'Erased: 3 weeks ago'], ['finished_at', 'Finished: 3 weeks ago'], ['queued_duration', 'Queued: 9 seconds'], - ['id', 'Job ID: #4757'], ['runner', 'Runner: #1 (ABCDEFGH) local ci runner'], ['coverage', 'Coverage: 20%'], ])('uses %s to render job-%s', async (detail, value) => { @@ -78,7 +77,7 @@ describe('Job Sidebar Details Container', () => { createWrapper(); await store.dispatch('receiveJobSuccess', job); - expect(findAllDetailsRow()).toHaveLength(8); + expect(findAllDetailsRow()).toHaveLength(7); }); describe('duration row', () => { diff --git a/spec/frontend/ci/job_details/job_app_spec.js b/spec/frontend/ci/job_details/job_app_spec.js index c2d91771495..ff84b2d0283 100644 --- a/spec/frontend/ci/job_details/job_app_spec.js +++ b/spec/frontend/ci/job_details/job_app_spec.js @@ -31,8 +31,6 @@ describe('Job App', () => { const initSettings = { endpoint: `${TEST_HOST}jobs/123.json`, pagePath: `${TEST_HOST}jobs/123`, - logState: - 'eyJvZmZzZXQiOjE3NDUxLCJuX29wZW5fdGFncyI6MCwiZmdfY29sb3IiOm51bGwsImJnX2NvbG9yIjpudWxsLCJzdHlsZV9tYXNrIjowfQ%3D%3D', }; const props = { diff --git a/spec/frontend/ci/job_details/store/actions_spec.js b/spec/frontend/ci/job_details/store/actions_spec.js index bb5c1fe32bd..2799bc9578c 100644 --- a/spec/frontend/ci/job_details/store/actions_spec.js +++ b/spec/frontend/ci/job_details/store/actions_spec.js @@ -2,7 +2,6 @@ import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; import { - setJobEndpoint, setJobLogOptions, clearEtagPoll, stopPolling, @@ -39,25 +38,21 @@ describe('Job State actions', () => { mockedState = state(); }); - describe('setJobEndpoint', () => { - it('should commit SET_JOB_ENDPOINT mutation', () => { - return testAction( - setJobEndpoint, - 'job/872324.json', - mockedState, - [{ type: types.SET_JOB_ENDPOINT, payload: 'job/872324.json' }], - [], - ); - }); - }); - describe('setJobLogOptions', () => { it('should commit SET_JOB_LOG_OPTIONS mutation', () => { return testAction( setJobLogOptions, - { pagePath: 'job/872324/trace.json' }, + { endpoint: '/group1/project1/-/jobs/99.json', pagePath: '/group1/project1/-/jobs/99' }, mockedState, - [{ type: types.SET_JOB_LOG_OPTIONS, payload: { pagePath: 'job/872324/trace.json' } }], + [ + { + type: types.SET_JOB_LOG_OPTIONS, + payload: { + endpoint: '/group1/project1/-/jobs/99.json', + pagePath: '/group1/project1/-/jobs/99', + }, + }, + ], [], ); }); diff --git a/spec/frontend/ci/job_details/store/mutations_spec.js b/spec/frontend/ci/job_details/store/mutations_spec.js index 0835c534fb9..78b29efed68 100644 --- a/spec/frontend/ci/job_details/store/mutations_spec.js +++ b/spec/frontend/ci/job_details/store/mutations_spec.js @@ -12,11 +12,17 @@ describe('Jobs Store Mutations', () => { stateCopy = state(); }); - describe('SET_JOB_ENDPOINT', () => { + describe('SET_JOB_LOG_OPTIONS', () => { it('should set jobEndpoint', () => { - mutations[types.SET_JOB_ENDPOINT](stateCopy, 'job/21312321.json'); + mutations[types.SET_JOB_LOG_OPTIONS](stateCopy, { + endpoint: '/group1/project1/-/jobs/99.json', + pagePath: '/group1/project1/-/jobs/99', + }); - expect(stateCopy.jobEndpoint).toEqual('job/21312321.json'); + expect(stateCopy).toMatchObject({ + jobLogEndpoint: '/group1/project1/-/jobs/99', + jobEndpoint: '/group1/project1/-/jobs/99.json', + }); }); }); @@ -39,13 +45,13 @@ describe('Jobs Store Mutations', () => { describe('RECEIVE_JOB_LOG_SUCCESS', () => { describe('when job log has state', () => { it('sets jobLogState', () => { - const stateLog = + const logState = 'eyJvZmZzZXQiOjczNDQ1MSwibl9vcGVuX3RhZ3MiOjAsImZnX2NvbG9yIjpudWxsLCJiZ19jb2xvciI6bnVsbCwic3R5bGVfbWFzayI6MH0='; mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { - state: stateLog, + state: logState, }); - expect(stateCopy.jobLogState).toEqual(stateLog); + expect(stateCopy.jobLogState).toEqual(logState); }); }); @@ -100,7 +106,7 @@ describe('Jobs Store Mutations', () => { { offset: 1, content: [{ text: 'Running with gitlab-runner 11.12.1 (5a147c92)' }], - lineNumber: 0, + lineNumber: 1, }, ]); }); @@ -121,7 +127,7 @@ describe('Jobs Store Mutations', () => { { offset: 0, content: [{ text: 'Running with gitlab-runner 11.11.1 (5a147c92)' }], - lineNumber: 0, + lineNumber: 1, }, ]); }); diff --git a/spec/frontend/ci/job_details/store/utils_spec.js b/spec/frontend/ci/job_details/store/utils_spec.js index 4ffba35761e..394ce0ab737 100644 --- a/spec/frontend/ci/job_details/store/utils_spec.js +++ b/spec/frontend/ci/job_details/store/utils_spec.js @@ -6,10 +6,10 @@ import { addDurationToHeader, isCollapsibleSection, findOffsetAndRemove, - getIncrementalLineNumber, + getNextLineNumber, } from '~/ci/job_details/store/utils'; import { - utilsMockData, + mockJobLog, originalTrace, regularIncremental, regularIncrementalRepeated, @@ -187,39 +187,49 @@ describe('Jobs Store Utils', () => { let result; beforeEach(() => { - result = logLinesParser(utilsMockData); + result = logLinesParser(mockJobLog); }); describe('regular line', () => { it('adds a lineNumber property with correct index', () => { - expect(result[0].lineNumber).toEqual(0); - expect(result[1].line.lineNumber).toEqual(1); + expect(result[0].lineNumber).toEqual(1); + expect(result[1].lineNumber).toEqual(2); + expect(result[2].line.lineNumber).toEqual(3); + expect(result[2].lines[0].lineNumber).toEqual(4); + expect(result[2].lines[1].lineNumber).toEqual(5); + expect(result[3].line.lineNumber).toEqual(6); + expect(result[3].lines[0].lineNumber).toEqual(7); + expect(result[3].lines[1].lineNumber).toEqual(8); }); }); describe('collapsible section', () => { it('adds a `isClosed` property', () => { - expect(result[1].isClosed).toEqual(false); + expect(result[2].isClosed).toEqual(false); + expect(result[3].isClosed).toEqual(false); }); it('adds a `isHeader` property', () => { - expect(result[1].isHeader).toEqual(true); + expect(result[2].isHeader).toEqual(true); + expect(result[3].isHeader).toEqual(true); }); it('creates a lines array property with the content of the collapsible section', () => { - expect(result[1].lines.length).toEqual(2); - expect(result[1].lines[0].content).toEqual(utilsMockData[2].content); - expect(result[1].lines[1].content).toEqual(utilsMockData[3].content); + expect(result[2].lines.length).toEqual(2); + expect(result[2].lines[0].content).toEqual(mockJobLog[3].content); + expect(result[2].lines[1].content).toEqual(mockJobLog[4].content); }); }); describe('section duration', () => { it('adds the section information to the header section', () => { - expect(result[1].line.section_duration).toEqual(utilsMockData[4].section_duration); + expect(result[2].line.section_duration).toEqual(mockJobLog[5].section_duration); + expect(result[3].line.section_duration).toEqual(mockJobLog[9].section_duration); }); it('does not add section duration as a line', () => { - expect(result[1].lines.includes(utilsMockData[4])).toEqual(false); + expect(result[2].lines.includes(mockJobLog[5])).toEqual(false); + expect(result[3].lines.includes(mockJobLog[9])).toEqual(false); }); }); }); @@ -316,17 +326,24 @@ describe('Jobs Store Utils', () => { }); }); - describe('getIncrementalLineNumber', () => { - describe('when last line is 0', () => { + describe('getNextLineNumber', () => { + describe('when there is no previous log', () => { + it('returns 1', () => { + expect(getNextLineNumber([])).toEqual(1); + expect(getNextLineNumber(undefined)).toEqual(1); + }); + }); + + describe('when last line is 1', () => { it('returns 1', () => { const log = [ { content: [], - lineNumber: 0, + lineNumber: 1, }, ]; - expect(getIncrementalLineNumber(log)).toEqual(1); + expect(getNextLineNumber(log)).toEqual(2); }); }); @@ -343,7 +360,7 @@ describe('Jobs Store Utils', () => { }, ]; - expect(getIncrementalLineNumber(log)).toEqual(102); + expect(getNextLineNumber(log)).toEqual(102); }); }); @@ -364,7 +381,7 @@ describe('Jobs Store Utils', () => { }, ]; - expect(getIncrementalLineNumber(log)).toEqual(102); + expect(getNextLineNumber(log)).toEqual(102); }); }); @@ -391,7 +408,7 @@ describe('Jobs Store Utils', () => { }, ]; - expect(getIncrementalLineNumber(log)).toEqual(104); + expect(getNextLineNumber(log)).toEqual(104); }); }); }); @@ -410,7 +427,7 @@ describe('Jobs Store Utils', () => { text: 'Downloading', }, ], - lineNumber: 0, + lineNumber: 1, }, { offset: 2, @@ -419,7 +436,7 @@ describe('Jobs Store Utils', () => { text: 'log line', }, ], - lineNumber: 1, + lineNumber: 2, }, ]); }); @@ -438,7 +455,7 @@ describe('Jobs Store Utils', () => { text: 'log line', }, ], - lineNumber: 0, + lineNumber: 1, }, ]); }); @@ -462,7 +479,7 @@ describe('Jobs Store Utils', () => { }, ], section: 'section', - lineNumber: 0, + lineNumber: 1, }, lines: [], }, @@ -488,7 +505,7 @@ describe('Jobs Store Utils', () => { }, ], section: 'section', - lineNumber: 0, + lineNumber: 1, }, lines: [ { @@ -499,7 +516,7 @@ describe('Jobs Store Utils', () => { }, ], section: 'section', - lineNumber: 1, + lineNumber: 2, }, ], }, diff --git a/spec/frontend/ci/jobs_page/components/job_cells/job_cell_spec.js b/spec/frontend/ci/jobs_page/components/job_cells/job_cell_spec.js index cb8f6ed8f9b..bb44d970bd7 100644 --- a/spec/frontend/ci/jobs_page/components/job_cells/job_cell_spec.js +++ b/spec/frontend/ci/jobs_page/components/job_cells/job_cell_spec.js @@ -40,20 +40,20 @@ describe('Job Cell', () => { }; describe('Job Id', () => { - it('displays the job id and links to the job', () => { + it('displays the job id, job name and links to the job', () => { createComponent(); - const expectedJobId = `#${getIdFromGraphQLId(mockJob.id)}`; + const expectedJobId = `#${getIdFromGraphQLId(mockJob.id)}: ${mockJob.name}`; expect(findJobIdLink().text()).toBe(expectedJobId); expect(findJobIdLink().attributes('href')).toBe(mockJob.detailedStatus.detailsPath); expect(findJobIdNoLink().exists()).toBe(false); }); - it('display the job id with no link', () => { + it('display the job id and job name with no link', () => { createComponent(jobAsGuest); - const expectedJobId = `#${getIdFromGraphQLId(jobAsGuest.id)}`; + const expectedJobId = `#${getIdFromGraphQLId(jobAsGuest.id)}: ${jobAsGuest.name}`; expect(findJobIdNoLink().text()).toBe(expectedJobId); expect(findJobIdNoLink().exists()).toBe(true); diff --git a/spec/frontend/ci/jobs_page/components/job_cells/duration_cell_spec.js b/spec/frontend/ci/jobs_page/components/job_cells/status_cell_spec.js index 21f14ba0c98..e66942cc730 100644 --- a/spec/frontend/ci/jobs_page/components/job_cells/duration_cell_spec.js +++ b/spec/frontend/ci/jobs_page/components/job_cells/status_cell_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import DurationCell from '~/ci/jobs_page/components/job_cells/duration_cell.vue'; +import StatusCell from '~/ci/jobs_page/components/job_cells/status_cell.vue'; describe('Duration Cell', () => { let wrapper; @@ -12,7 +12,7 @@ describe('Duration Cell', () => { const createComponent = (props) => { wrapper = extendedWrapper( - shallowMount(DurationCell, { + shallowMount(StatusCell, { propsData: { job: { ...props, diff --git a/spec/frontend/ci/jobs_page/components/jobs_table_empty_state_spec.js b/spec/frontend/ci/jobs_page/components/jobs_table_empty_state_spec.js index f4893c4077f..0f85c4590ec 100644 --- a/spec/frontend/ci/jobs_page/components/jobs_table_empty_state_spec.js +++ b/spec/frontend/ci/jobs_page/components/jobs_table_empty_state_spec.js @@ -6,7 +6,7 @@ describe('Jobs table empty state', () => { let wrapper; const pipelineEditorPath = '/root/project/-/ci/editor'; - const emptyStateSvgPath = 'assets/jobs-empty-state.svg'; + const emptyStateSvgPath = 'illustrations/empty-state/empty-pipeline-md.svg'; const findEmptyState = () => wrapper.findComponent(GlEmptyState); diff --git a/spec/frontend/ci/jobs_page/components/jobs_table_spec.js b/spec/frontend/ci/jobs_page/components/jobs_table_spec.js index 3adb95bf371..d4e0ce92bc2 100644 --- a/spec/frontend/ci/jobs_page/components/jobs_table_spec.js +++ b/spec/frontend/ci/jobs_page/components/jobs_table_spec.js @@ -2,6 +2,7 @@ import { GlTable } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import JobsTable from '~/ci/jobs_page/components/jobs_table.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import { DEFAULT_FIELDS_ADMIN } from '~/ci/admin/jobs_table/constants'; import ProjectCell from '~/ci/admin/jobs_table/components/cells/project_cell.vue'; @@ -47,11 +48,11 @@ describe('Jobs Table', () => { expect(findCiBadgeLink().exists()).toBe(true); }); - it('displays the job stage and name', () => { + it('displays the job stage, id and name', () => { const [firstJob] = mockJobsNodes; - expect(findJobStage().text()).toBe(firstJob.stage.name); - expect(findJobName().text()).toBe(firstJob.name); + expect(findJobStage().text()).toBe(`Stage: ${firstJob.stage.name}`); + expect(findJobName().text()).toBe(`#${getIdFromGraphQLId(firstJob.id)}: ${firstJob.name}`); }); it('displays the coverage for only jobs that have coverage', () => { diff --git a/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js b/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js index 107f0df5c02..de9ee8a16bf 100644 --- a/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js +++ b/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js @@ -1,10 +1,11 @@ import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; -import { GlBadge, GlModal, GlToast } from '@gitlab/ui'; +import { GlModal, GlToast } from '@gitlab/ui'; import JobItem from '~/ci/pipeline_details/graph/components/job_item.vue'; import axios from '~/lib/utils/axios_utils'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import ActionComponent from '~/ci/common/private/job_action_component.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { @@ -27,9 +28,10 @@ describe('pipeline graph job item', () => { const findJobWithoutLink = () => wrapper.findByTestId('job-without-link'); const findJobWithLink = () => wrapper.findByTestId('job-with-link'); const findActionVueComponent = () => wrapper.findComponent(ActionComponent); - const findActionComponent = () => wrapper.findByTestId('ci-action-component'); - const findBadge = () => wrapper.findComponent(GlBadge); + const findActionComponent = () => wrapper.findByTestId('ci-action-button'); + const findBadge = () => wrapper.findByTestId('job-bridge-badge'); const findJobLink = () => wrapper.findByTestId('job-with-link'); + const findJobCiBadge = () => wrapper.findComponent(CiBadgeLink); const findModal = () => wrapper.findComponent(GlModal); const clickOnModalPrimaryBtn = () => findModal().vm.$emit('primary'); @@ -57,6 +59,9 @@ describe('pipeline graph job item', () => { mocks: { ...mocks, }, + stubs: { + CiBadgeLink, + }, }); }; @@ -81,7 +86,8 @@ describe('pipeline graph job item', () => { expect(link.attributes('title')).toBe(`${mockJob.name} - ${mockJob.status.label}`); - expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true); + expect(findJobCiBadge().exists()).toBe(true); + expect(findJobCiBadge().find('.ci-status-icon-success').exists()).toBe(true); expect(wrapper.text()).toBe(mockJob.name); }); @@ -99,7 +105,8 @@ describe('pipeline graph job item', () => { }); it('should render status and name', () => { - expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true); + expect(findJobCiBadge().exists()).toBe(true); + expect(findJobCiBadge().find('.ci-status-icon-success').exists()).toBe(true); expect(findJobLink().exists()).toBe(false); expect(wrapper.text()).toBe(mockJobWithoutDetails.name); @@ -110,6 +117,15 @@ describe('pipeline graph job item', () => { }); }); + describe('CiBadgeLink', () => { + it('should not render a link', () => { + createWrapper(); + + expect(findJobCiBadge().exists()).toBe(true); + expect(findJobCiBadge().props('useLink')).toBe(false); + }); + }); + describe('action icon', () => { it('should render the action icon', () => { createWrapper(); diff --git a/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js b/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js index 5541b0db54a..5fe8581e81b 100644 --- a/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js +++ b/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js @@ -37,7 +37,7 @@ describe('Linked pipeline', () => { const findButton = () => wrapper.findComponent(GlButton); const findCancelButton = () => wrapper.findByLabelText('Cancel downstream pipeline'); const findCardTooltip = () => wrapper.findComponent(GlTooltip); - const findDownstreamPipelineTitle = () => wrapper.findByTestId('downstream-title'); + const findDownstreamPipelineTitle = () => wrapper.findByTestId('downstream-title-content'); const findExpandButton = () => wrapper.findByTestId('expand-pipeline-button'); const findLinkedPipeline = () => wrapper.findComponent({ ref: 'linkedPipeline' }); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); diff --git a/spec/frontend/ci/pipeline_details/mock_data.js b/spec/frontend/ci/pipeline_details/mock_data.js index e32d0a0df47..56365622544 100644 --- a/spec/frontend/ci/pipeline_details/mock_data.js +++ b/spec/frontend/ci/pipeline_details/mock_data.js @@ -640,7 +640,7 @@ export const mockPipeline = (projectPath) => { triggered_by: null, triggered: [], }, - pipelineScheduleUrl: 'foo', + pipelineSchedulesPath: 'foo', pipelineKey: 'id', viewType: 'root', }; @@ -865,7 +865,7 @@ export const mockPipelineTag = () => { triggered_by: null, triggered: [], }, - pipelineScheduleUrl: 'foo', + pipelineSchedulesPath: 'foo', pipelineKey: 'id', viewType: 'root', }; @@ -1072,7 +1072,7 @@ export const mockPipelineBranch = () => { triggered_by: null, triggered: [], }, - pipelineScheduleUrl: 'foo', + pipelineSchedulesPath: 'foo', pipelineKey: 'id', viewType: 'root', }; diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js index 1a2ed60a6f4..9bb0618b758 100644 --- a/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js @@ -1,4 +1,4 @@ -import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import { GlIcon, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; @@ -43,7 +43,7 @@ describe('Pipeline Status', () => { }, projectFullPath: mockProjectFullPath, }, - stubs: { GlLink, GlSprintf }, + stubs: { GlSprintf }, }); }; diff --git a/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js b/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js index 30a0b868c5f..4b357a9fc7c 100644 --- a/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js +++ b/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js @@ -2,7 +2,7 @@ import { GlDropdown } from '@gitlab/ui'; import { nextTick } from 'vue'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import LegacyPipelineStage from '~/ci/pipeline_mini_graph/legacy_pipeline_stage.vue'; @@ -52,7 +52,7 @@ describe('Pipelines stage component', () => { }); const findCiActionBtn = () => wrapper.find('.js-ci-action'); - const findCiIcon = () => wrapper.findComponent(CiIcon); + const findCiIcon = () => wrapper.findComponent(CiBadgeLink); const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdownToggle = () => wrapper.find('button.dropdown-toggle'); const findDropdownMenu = () => @@ -106,17 +106,6 @@ describe('Pipelines stage component', () => { expect(findDropdownToggle().exists()).toBe(true); expect(findCiIcon().exists()).toBe(true); }); - - it('renders a borderless ci-icon', () => { - expect(findCiIcon().exists()).toBe(true); - expect(findCiIcon().props('isBorderless')).toBe(true); - expect(findCiIcon().classes('borderless')).toBe(true); - }); - - it('renders a ci-icon with a custom border class', () => { - expect(findCiIcon().exists()).toBe(true); - expect(findCiIcon().classes('gl-border')).toBe(true); - }); }); describe('when user opens dropdown and stage request is successful', () => { diff --git a/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js b/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js index 0396029cdaf..3c9d235bfcc 100644 --- a/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js +++ b/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js @@ -50,19 +50,6 @@ describe('Linked pipeline mini list', () => { expect(findCiIcon().exists()).toBe(true); }); - it('should render a borderless ci-icon', () => { - expect(findCiIcon().exists()).toBe(true); - - expect(findCiIcon().props('isBorderless')).toBe(true); - expect(findCiIcon().classes('borderless')).toBe(true); - }); - - it('should render a ci-icon with a custom border class', () => { - expect(findCiIcon().exists()).toBe(true); - - expect(findCiIcon().classes('gl-border')).toBe(true); - }); - it('should render the correct ci status icon', () => { expect(findCiIcon().classes('ci-status-icon-running')).toBe(true); }); @@ -124,19 +111,6 @@ describe('Linked pipeline mini list', () => { expect(findLinkedPipelineMiniList().classes('is-downstream')).toBe(true); }); - it('should render a borderless ci-icon', () => { - expect(findCiIcon().exists()).toBe(true); - - expect(findCiIcon().props('isBorderless')).toBe(true); - expect(findCiIcon().classes('borderless')).toBe(true); - }); - - it('should render a ci-icon with a custom border class', () => { - expect(findCiIcon().exists()).toBe(true); - - expect(findCiIcon().classes('gl-border')).toBe(true); - }); - it('should render the pipeline counter', () => { expect(findLinkedPipelineCounter().exists()).toBe(true); }); diff --git a/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js index 1d4ae33c667..2807cc0f2a1 100644 --- a/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js +++ b/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js @@ -55,12 +55,12 @@ describe('Pipeline New Form', () => { const findForm = () => wrapper.findComponent(GlForm); const findRefsDropdown = () => wrapper.findComponent(RefsDropdown); - const findSubmitButton = () => wrapper.findByTestId('run_pipeline_button'); - const findVariableRows = () => wrapper.findAllByTestId('ci-variable-row'); + const findSubmitButton = () => wrapper.findByTestId('run-pipeline-button'); + const findVariableRows = () => wrapper.findAllByTestId('ci-variable-row-container'); const findRemoveIcons = () => wrapper.findAllByTestId('remove-ci-variable-row'); const findVariableTypes = () => wrapper.findAllByTestId('pipeline-form-ci-variable-type'); - const findKeyInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-key'); - const findValueInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-value'); + const findKeyInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-key-field'); + const findValueInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-value-field'); const findValueDropdowns = () => wrapper.findAllByTestId('pipeline-form-ci-variable-value-dropdown'); const findValueDropdownItems = (dropdown) => dropdown.findAllComponents(GlDropdownItem); diff --git a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_empty_state_spec.js b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_empty_state_spec.js new file mode 100644 index 00000000000..5ad0f915f62 --- /dev/null +++ b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_empty_state_spec.js @@ -0,0 +1,37 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import PipelineSchedulesEmptyState from '~/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue'; + +describe('Pipeline Schedules Empty State', () => { + let wrapper; + + const mockSchedulePath = 'root/test/-/pipeline_schedules/new"'; + + const createComponent = () => { + wrapper = shallowMount(PipelineSchedulesEmptyState, { + provide: { + newSchedulePath: mockSchedulePath, + }, + stubs: { GlSprintf }, + }); + }; + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findLink = () => wrapper.findComponent(GlLink); + + beforeEach(() => { + createComponent(); + }); + + it('shows empty state', () => { + expect(findEmptyState().exists()).toBe(true); + }); + + it('has link to create new schedule', () => { + expect(findEmptyState().props('primaryButtonLink')).toBe(mockSchedulePath); + }); + + it('has link to help documentation', () => { + expect(findLink().attributes('href')).toBe('/help/ci/pipelines/schedules'); + }); +}); diff --git a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js index eb76b0bfbb4..d1844d609f2 100644 --- a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlEmptyState, GlLink, GlLoadingIcon, GlTabs } from '@gitlab/ui'; +import { GlAlert, GlEmptyState, GlLink, GlLoadingIcon, GlPagination, GlTabs } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { trimText } from 'helpers/text_helper'; @@ -14,6 +14,7 @@ import deletePipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/muta import playPipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/mutations/play_pipeline_schedule.mutation.graphql'; import takeOwnershipMutation from '~/ci/pipeline_schedules/graphql/mutations/take_ownership.mutation.graphql'; import getPipelineSchedulesQuery from '~/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql'; +import { SCHEDULES_PER_PAGE } from '~/ci/pipeline_schedules/constants'; import { mockGetPipelineSchedulesGraphQLResponse, mockPipelineScheduleNodes, @@ -22,6 +23,7 @@ import { playMutationResponse, takeOwnershipMutationResponse, emptyPipelineSchedulesResponse, + mockPipelineSchedulesResponseWithPagination, } from '../mock_data'; Vue.use(VueApollo); @@ -34,6 +36,9 @@ describe('Pipeline schedules app', () => { let wrapper; const successHandler = jest.fn().mockResolvedValue(mockGetPipelineSchedulesGraphQLResponse); + const successHandlerWithPagination = jest + .fn() + .mockResolvedValue(mockPipelineSchedulesResponseWithPagination); const successEmptyHandler = jest.fn().mockResolvedValue(emptyPipelineSchedulesResponse); const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); @@ -81,6 +86,11 @@ describe('Pipeline schedules app', () => { const findInactiveTab = () => wrapper.findByTestId('pipeline-schedules-inactive-tab'); const findSchedulesCharacteristics = () => wrapper.findByTestId('pipeline-schedules-characteristics'); + const findPagination = () => wrapper.findComponent(GlPagination); + const setPage = async (page) => { + findPagination().vm.$emit('input', page); + await waitForPromises(); + }; describe('default', () => { beforeEach(() => { @@ -107,6 +117,10 @@ describe('Pipeline schedules app', () => { it('new schedule button links to new schedule path', () => { expect(findNewButton().attributes('href')).toBe('/root/ci-project/-/pipeline_schedules/new'); }); + + it('does not display pagination when no next page exists', () => { + expect(findPagination().exists()).toBe(false); + }); }); describe('fetching pipeline schedules', () => { @@ -333,6 +347,10 @@ describe('Pipeline schedules app', () => { ids: null, projectPath: 'gitlab-org/gitlab', status: null, + first: SCHEDULES_PER_PAGE, + last: null, + nextPageCursor: '', + prevPageCursor: '', }); }); }); @@ -370,4 +388,57 @@ describe('Pipeline schedules app', () => { }); }); }); + + describe('pagination', () => { + const { pageInfo } = mockPipelineSchedulesResponseWithPagination.data.project.pipelineSchedules; + + beforeEach(async () => { + createComponent([[getPipelineSchedulesQuery, successHandlerWithPagination]]); + + await waitForPromises(); + }); + + it('displays pagination', () => { + expect(findPagination().exists()).toBe(true); + expect(findPagination().props()).toMatchObject({ + value: 1, + prevPage: Number(pageInfo.hasPreviousPage), + nextPage: Number(pageInfo.hasNextPage), + }); + expect(successHandlerWithPagination).toHaveBeenCalledWith({ + projectPath: 'gitlab-org/gitlab', + ids: null, + first: SCHEDULES_PER_PAGE, + last: null, + nextPageCursor: '', + prevPageCursor: '', + }); + }); + + it('updates query variables when going to next page', async () => { + await setPage(2); + + expect(successHandlerWithPagination).toHaveBeenCalledWith({ + projectPath: 'gitlab-org/gitlab', + ids: null, + first: SCHEDULES_PER_PAGE, + last: null, + prevPageCursor: '', + nextPageCursor: pageInfo.endCursor, + }); + expect(findPagination().props('value')).toEqual(2); + }); + + it('when switching tabs pagination should reset', async () => { + await setPage(2); + + expect(findPagination().props('value')).toEqual(2); + + await findInactiveTab().trigger('click'); + + await waitForPromises(); + + expect(findPagination().props('value')).toEqual(1); + }); + }); }); diff --git a/spec/frontend/ci/pipeline_schedules/mock_data.js b/spec/frontend/ci/pipeline_schedules/mock_data.js index 711b120c61e..1bff296305d 100644 --- a/spec/frontend/ci/pipeline_schedules/mock_data.js +++ b/spec/frontend/ci/pipeline_schedules/mock_data.js @@ -48,6 +48,26 @@ export const mockSinglePipelineScheduleNodeNoVars = { }, }; +export const mockPipelineSchedulesResponseWithPagination = { + data: { + currentUser: mockGetPipelineSchedulesGraphQLResponse.data.currentUser, + project: { + id: mockGetPipelineSchedulesGraphQLResponse.data.project.id, + pipelineSchedules: { + count: 3, + nodes: mockGetPipelineSchedulesGraphQLResponse.data.project.pipelineSchedules.nodes, + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'eyJpZCI6IjQ0In0', + endCursor: 'eyJpZCI6IjI4In0', + __typename: 'PageInfo', + }, + }, + }, + }, +}; + export const emptyPipelineSchedulesResponse = { data: { currentUser: { @@ -59,6 +79,13 @@ export const emptyPipelineSchedulesResponse = { pipelineSchedules: { count: 0, nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + __typename: 'PageInfo', + }, }, }, }, diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js index b5c9a3030e0..6b0d5b18f7d 100644 --- a/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js +++ b/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js @@ -15,6 +15,7 @@ describe('Pipeline label component', () => { const findAutoDevopsTag = () => wrapper.findByTestId('pipeline-url-autodevops'); const findAutoDevopsTagLink = () => wrapper.findByTestId('pipeline-url-autodevops-link'); const findDetachedTag = () => wrapper.findByTestId('pipeline-url-detached'); + const findMergedResultsTag = () => wrapper.findByTestId('pipeline-url-merged-results'); const findFailureTag = () => wrapper.findByTestId('pipeline-url-failure'); const findForkTag = () => wrapper.findByTestId('pipeline-url-fork'); const findTrainTag = () => wrapper.findByTestId('pipeline-url-train'); @@ -25,6 +26,7 @@ describe('Pipeline label component', () => { wrapper = shallowMountExtended(PipelineLabelsComponent, { propsData: { ...defaultProps, ...props }, provide: { + pipelineSchedulesPath: 'group/project/-/schedules', targetProjectFullPath: projectPath, }, }); @@ -41,6 +43,7 @@ describe('Pipeline label component', () => { expect(findScheduledTag().exists()).toBe(false); expect(findForkTag().exists()).toBe(false); expect(findTrainTag().exists()).toBe(false); + expect(findMergedResultsTag().exists()).toBe(false); }); it('should render the stuck tag when flag is provided', () => { @@ -140,9 +143,33 @@ describe('Pipeline label component', () => { expect(findForkTag().text()).toBe('fork'); }); + it('should render the merged results badge when the pipeline is a merged results pipeline', () => { + const mergedResultsPipeline = defaultProps.pipeline; + mergedResultsPipeline.flags.merged_result_pipeline = true; + + createComponent({ + ...mergedResultsPipeline, + }); + + expect(findMergedResultsTag().text()).toBe('merged results'); + }); + + it('should not render the merged results badge when the pipeline is not a merged results pipeline', () => { + const mergedResultsPipeline = defaultProps.pipeline; + mergedResultsPipeline.flags.merged_result_pipeline = false; + + createComponent({ + ...mergedResultsPipeline, + }); + + expect(findMergedResultsTag().exists()).toBe(false); + }); + it('should render the train badge when the pipeline is a merge train pipeline', () => { const mergeTrainPipeline = defaultProps.pipeline; mergeTrainPipeline.flags.merge_train_pipeline = true; + // a merge train pipeline is also a merged results pipeline + mergeTrainPipeline.flags.merged_result_pipeline = true; createComponent({ ...mergeTrainPipeline, @@ -161,4 +188,17 @@ describe('Pipeline label component', () => { expect(findTrainTag().exists()).toBe(false); }); + + it('should not render the merged results badge when the pipeline is a merge train pipeline', () => { + const mergeTrainPipeline = defaultProps.pipeline; + mergeTrainPipeline.flags.merge_train_pipeline = true; + // a merge train pipeline is also a merged results pipeline + mergeTrainPipeline.flags.merged_result_pipeline = true; + + createComponent({ + ...mergeTrainPipeline, + }); + + expect(findMergedResultsTag().exists()).toBe(false); + }); }); diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js index d2eab64b317..6205a37e291 100644 --- a/spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js +++ b/spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js @@ -1,10 +1,13 @@ +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import PipelinesManualActions from '~/ci/pipelines_page/components/pipelines_manual_actions.vue'; import PipelineMultiActions from '~/ci/pipelines_page/components/pipeline_multi_actions.vue'; import PipelineOperations from '~/ci/pipelines_page/components/pipeline_operations.vue'; -import eventHub from '~/ci/event_hub'; +import PipelineStopModal from '~/ci/pipelines_page/components/pipeline_stop_modal.vue'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; describe('Pipeline operations', () => { + let trackingSpy; let wrapper; const defaultProps = { @@ -36,6 +39,7 @@ describe('Pipeline operations', () => { const findMultiActions = () => wrapper.findComponent(PipelineMultiActions); const findRetryBtn = () => wrapper.findByTestId('pipelines-retry-button'); const findCancelBtn = () => wrapper.findByTestId('pipelines-cancel-button'); + const findPipelineStopModal = () => wrapper.findComponent(PipelineStopModal); it('should display pipeline manual actions', () => { createComponent(); @@ -49,28 +53,71 @@ describe('Pipeline operations', () => { expect(findMultiActions().exists()).toBe(true); }); + it('does not show the confirmation modal', () => { + createComponent(); + + expect(findPipelineStopModal().props().showConfirmationModal).toBe(false); + }); + + describe('when cancelling a pipeline', () => { + beforeEach(async () => { + createComponent(); + await findCancelBtn().vm.$emit('click'); + }); + + it('should show a confirmation modal', () => { + expect(findPipelineStopModal().props().showConfirmationModal).toBe(true); + }); + + it('should emit cancel-pipeline event when confirming', async () => { + await findPipelineStopModal().vm.$emit('submit'); + + expect(wrapper.emitted('cancel-pipeline')).toEqual([[defaultProps.pipeline]]); + expect(findPipelineStopModal().props().showConfirmationModal).toBe(false); + }); + + it('should hide the modal when closing', async () => { + await findPipelineStopModal().vm.$emit('close-modal'); + + expect(findPipelineStopModal().props().showConfirmationModal).toBe(false); + }); + }); + describe('events', () => { beforeEach(() => { createComponent(); - - jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); }); it('should emit retryPipeline event', () => { findRetryBtn().vm.$emit('click'); - expect(eventHub.$emit).toHaveBeenCalledWith( - 'retryPipeline', - defaultProps.pipeline.retry_path, - ); + expect(wrapper.emitted('retry-pipeline')).toEqual([[defaultProps.pipeline]]); + }); + }); + + describe('tracking', () => { + beforeEach(() => { + createComponent(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('tracks retry pipeline button click', () => { + findRetryBtn().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_retry_button', { + label: TRACKING_CATEGORIES.table, + }); }); - it('should emit openConfirmationModal event', () => { + it('tracks cancel pipeline button click', () => { findCancelBtn().vm.$emit('click'); - expect(eventHub.$emit).toHaveBeenCalledWith('openConfirmationModal', { - pipeline: defaultProps.pipeline, - endpoint: defaultProps.pipeline.cancel_path, + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_cancel_button', { + label: TRACKING_CATEGORIES.table, }); }); }); diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js index 4d78a923542..1e276840c07 100644 --- a/spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js +++ b/spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js @@ -1,15 +1,17 @@ import { shallowMount } from '@vue/test-utils'; -import { GlSprintf } from '@gitlab/ui'; +import { GlModal, GlSprintf } from '@gitlab/ui'; import { mockPipelineHeader } from 'jest/ci/pipeline_details/mock_data'; import PipelineStopModal from '~/ci/pipelines_page/components/pipeline_stop_modal.vue'; describe('PipelineStopModal', () => { let wrapper; - const createComponent = () => { + const createComponent = ({ props = {} } = {}) => { wrapper = shallowMount(PipelineStopModal, { propsData: { pipeline: mockPipelineHeader, + showConfirmationModal: false, + ...props, }, stubs: { GlSprintf, @@ -17,11 +19,43 @@ describe('PipelineStopModal', () => { }); }; + const findModal = () => wrapper.findComponent(GlModal); + beforeEach(() => { createComponent(); }); - it('should render "stop pipeline" warning', () => { - expect(wrapper.text()).toMatch(`You’re about to stop pipeline #${mockPipelineHeader.id}.`); + describe('when `showConfirmationModal` is false', () => { + it('passes the visiblity value to the modal', () => { + expect(findModal().props().visible).toBe(false); + }); + }); + + describe('when `showConfirmationModal` is true', () => { + beforeEach(() => { + createComponent({ props: { showConfirmationModal: true } }); + }); + + it('passes the visiblity value to the modal', () => { + expect(findModal().props().visible).toBe(true); + }); + + it('renders "stop pipeline" warning', () => { + expect(wrapper.text()).toMatch(`You're about to stop pipeline #${mockPipelineHeader.id}.`); + }); + }); + + describe('events', () => { + beforeEach(() => { + createComponent({ props: { showConfirmationModal: true } }); + }); + + it('emits the close-modal event when the visiblity changes', async () => { + expect(wrapper.emitted('close-modal')).toBeUndefined(); + + await findModal().vm.$emit('change', false); + + expect(wrapper.emitted('close-modal')).toEqual([[]]); + }); }); }); diff --git a/spec/frontend/ci/pipelines_page/pipelines_spec.js b/spec/frontend/ci/pipelines_page/pipelines_spec.js index 5d1f431e57c..fd95f98e7f8 100644 --- a/spec/frontend/ci/pipelines_page/pipelines_spec.js +++ b/spec/frontend/ci/pipelines_page/pipelines_spec.js @@ -28,7 +28,7 @@ import NavigationControls from '~/ci/pipelines_page/components/nav_controls.vue' import PipelinesComponent from '~/ci/pipelines_page/pipelines.vue'; import PipelinesCiTemplates from '~/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue'; import PipelinesTableComponent from '~/ci/common/pipelines_table.vue'; -import { RAW_TEXT_WARNING, TRACKING_CATEGORIES } from '~/ci/constants'; +import { PIPELINE_IID_KEY, RAW_TEXT_WARNING, TRACKING_CATEGORIES } from '~/ci/constants'; import Store from '~/ci/pipeline_details/stores/pipelines_store'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; @@ -57,28 +57,23 @@ describe('Pipelines', () => { let mockApollo; let mock; let trackingSpy; + let mutationMock; - const paths = { - emptyStateSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', - errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', - noPipelinesSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', + const withPermissionsProps = { ciLintPath: '/ci/lint', resetCachePath: `${mockProjectPath}/settings/ci_cd/reset_cache`, newPipelinePath: `${mockProjectPath}/pipelines/new`, - ciRunnerSettingsPath: `${mockProjectPath}/-/settings/ci_cd#js-runners-settings`, - }; - - const noPermissions = { - emptyStateSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', - errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', - noPipelinesSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', + canCreatePipeline: true, }; const defaultProps = { hasGitlabCi: true, - canCreatePipeline: true, - ...paths, + canCreatePipeline: false, + projectId: mockProjectId, + defaultBranchName: mockDefaultBranchName, + endpoint: mockPipelinesEndpoint, + params: {}, }; const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); @@ -87,10 +82,9 @@ describe('Pipelines', () => { const findNavigationControls = () => wrapper.findComponent(NavigationControls); const findPipelinesTable = () => wrapper.findComponent(PipelinesTableComponent); const findTablePagination = () => wrapper.findComponent(TablePagination); - const findPipelineKeyCollapsibleBoxVue = () => wrapper.findComponent(GlCollapsibleListbox); + const findPipelineKeyCollapsibleBox = () => wrapper.findComponent(GlCollapsibleListbox); const findTab = (tab) => wrapper.findByTestId(`pipelines-tab-${tab}`); - const findPipelineKeyCollapsibleBox = () => wrapper.findByTestId('pipeline-key-collapsible-box'); const findRunPipelineButton = () => wrapper.findByTestId('run-pipeline-button'); const findCiLintButton = () => wrapper.findByTestId('ci-lint-button'); const findCleanCacheButton = () => wrapper.findByTestId('clear-cache-button'); @@ -98,25 +92,23 @@ describe('Pipelines', () => { wrapper.find('[data-testid="mini-pipeline-graph-dropdown"] .dropdown-toggle'); const findPipelineUrlLinks = () => wrapper.findAll('[data-testid="pipeline-url-link"]'); - const createComponent = (props = defaultProps) => { - const { mutationMock, ...restProps } = props; + const createComponent = ({ props = {}, withPermissions = true } = {}) => { mockApollo = createMockApollo([[setSortPreferenceMutation, mutationMock]]); + const permissionsProps = withPermissions ? { ...withPermissionsProps } : {}; wrapper = extendedWrapper( mount(PipelinesComponent, { provide: { pipelineEditorPath: '', suggestedCiTemplates: [], - ciRunnerSettingsPath: paths.ciRunnerSettingsPath, + ciRunnerSettingsPath: defaultProps.ciRunnerSettingsPath, anyRunnersAvailable: true, }, propsData: { + ...defaultProps, + ...permissionsProps, + ...props, store: new Store(), - projectId: mockProjectId, - defaultBranchName: mockDefaultBranchName, - endpoint: mockPipelinesEndpoint, - params: {}, - ...restProps, }, apolloProvider: mockApollo, }), @@ -124,12 +116,11 @@ describe('Pipelines', () => { }; beforeEach(() => { - setWindowLocation(TEST_HOST); - }); - - beforeEach(() => { mock = new MockAdapter(axios); + setWindowLocation(TEST_HOST); + mutationMock = jest.fn(); + jest.spyOn(window.history, 'pushState'); jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches }); @@ -169,7 +160,9 @@ describe('Pipelines', () => { describe('when user has no permissions', () => { beforeEach(async () => { - createComponent({ hasGitlabCi: true, canCreatePipeline: false, ...noPermissions }); + createComponent({ + withPermissions: false, + }); await waitForPromises(); }); @@ -225,11 +218,13 @@ describe('Pipelines', () => { }); it('renders Run pipeline link', () => { - expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); + expect(findRunPipelineButton().attributes('href')).toBe( + withPermissionsProps.newPipelinePath, + ); }); it('renders CI lint link', () => { - expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); + expect(findCiLintButton().attributes('href')).toBe(withPermissionsProps.ciLintPath); }); it('renders Clear runner cache button', () => { @@ -382,7 +377,7 @@ describe('Pipelines', () => { it('should change the text to Show Pipeline IID', async () => { expect(findPipelineKeyCollapsibleBox().exists()).toBe(true); expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.id}`); - findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); + findPipelineKeyCollapsibleBox().vm.$emit('select', PIPELINE_IID_KEY); await waitForPromises(); @@ -390,21 +385,21 @@ describe('Pipelines', () => { }); it('calls mutation to save idType preference', () => { - const mutationMock = jest.fn().mockResolvedValue(setIdTypePreferenceMutationResponse); - createComponent({ ...defaultProps, mutationMock }); + mutationMock = jest.fn().mockResolvedValue(setIdTypePreferenceMutationResponse); + createComponent(); - findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); + findPipelineKeyCollapsibleBox().vm.$emit('select', PIPELINE_IID_KEY); - expect(mutationMock).toHaveBeenCalledWith({ input: { visibilityPipelineIdType: 'IID' } }); + expect(mutationMock).toHaveBeenCalledWith({ + input: { visibilityPipelineIdType: PIPELINE_IID_KEY.toUpperCase() }, + }); }); it('captures error when mutation response has errors', async () => { - const mutationMock = jest - .fn() - .mockResolvedValue(setIdTypePreferenceMutationResponseWithErrors); - createComponent({ ...defaultProps, mutationMock }); + mutationMock = jest.fn().mockResolvedValue(setIdTypePreferenceMutationResponseWithErrors); + createComponent(); - findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); + findPipelineKeyCollapsibleBox().vm.$emit('select', PIPELINE_IID_KEY); await waitForPromises(); expect(Sentry.captureException).toHaveBeenCalledWith(new Error('oh no!')); @@ -610,11 +605,13 @@ describe('Pipelines', () => { }); it('renders Run pipeline link', () => { - expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); + expect(findRunPipelineButton().attributes('href')).toBe( + withPermissionsProps.newPipelinePath, + ); }); it('renders CI lint link', () => { - expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); + expect(findCiLintButton().attributes('href')).toBe(withPermissionsProps.ciLintPath); }); it('renders Clear runner cache button', () => { @@ -651,7 +648,7 @@ describe('Pipelines', () => { describe('when CI is not enabled and user has permissions', () => { beforeEach(async () => { - createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths }); + createComponent({ props: { hasGitlabCi: false } }); await waitForPromises(); }); @@ -678,7 +675,7 @@ describe('Pipelines', () => { describe('when CI is not enabled and user has no permissions', () => { beforeEach(async () => { - createComponent({ hasGitlabCi: false, canCreatePipeline: false, ...noPermissions }); + createComponent({ props: { hasGitlabCi: false }, withPermissions: false }); await waitForPromises(); }); @@ -700,7 +697,7 @@ describe('Pipelines', () => { describe('when CI is enabled and user has no permissions', () => { beforeEach(() => { - createComponent({ hasGitlabCi: true, canCreatePipeline: false, ...noPermissions }); + createComponent({ props: { hasGitlabCi: true }, withPermissions: false }); return waitForPromises(); }); @@ -798,8 +795,10 @@ describe('Pipelines', () => { describe('when user has no permissions', () => { beforeEach(async () => { - createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...noPermissions }); - + createComponent({ + props: { hasGitlabCi: false }, + withPermissions: false, + }); await waitForPromises(); }); @@ -834,9 +833,11 @@ describe('Pipelines', () => { }); it('renders buttons', () => { - expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); + expect(findRunPipelineButton().attributes('href')).toBe( + withPermissionsProps.newPipelinePath, + ); - expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); + expect(findCiLintButton().attributes('href')).toBe(withPermissionsProps.ciLintPath); expect(findCleanCacheButton().text()).toBe('Clear runner caches'); }); diff --git a/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js index c9349c64bfb..4a75c353487 100644 --- a/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js +++ b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js @@ -103,11 +103,6 @@ describe('AdminRunnerShowApp', () => { it('shows basic runner details', () => { const expected = `Description My Runner Last contact Never contacted - Version 1.0.0 - IP Address None - Executor None - Architecture None - Platform darwin Configuration Runs untagged jobs Maximum job timeout None Token expiry diff --git a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js index 1bbcb991619..bc28147db27 100644 --- a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js @@ -156,9 +156,7 @@ describe('AdminRunnersApp', () => { await createComponent({ mountFn: mountExtended }); }); - // quarantine: https://gitlab.com/gitlab-org/gitlab/-/issues/414975 - // eslint-disable-next-line jest/no-disabled-tests - it.skip('fetches counts', () => { + it('fetches counts', () => { expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); }); diff --git a/spec/frontend/ci/runner/components/runner_details_spec.js b/spec/frontend/ci/runner/components/runner_details_spec.js index cc91340655b..9d5f89a2642 100644 --- a/spec/frontend/ci/runner/components/runner_details_spec.js +++ b/spec/frontend/ci/runner/components/runner_details_spec.js @@ -49,13 +49,6 @@ describe('RunnerDetails', () => { ${'Description'} | ${{ description: null }} | ${'None'} ${'Last contact'} | ${{ contactedAt: mockOneHourAgo }} | ${'1 hour ago'} ${'Last contact'} | ${{ contactedAt: null }} | ${'Never contacted'} - ${'Version'} | ${{ version: '12.3' }} | ${'12.3'} - ${'Version'} | ${{ version: null }} | ${'None'} - ${'Executor'} | ${{ executorName: 'shell' }} | ${'shell'} - ${'Architecture'} | ${{ architectureName: 'amd64' }} | ${'amd64'} - ${'Platform'} | ${{ platformName: 'darwin' }} | ${'darwin'} - ${'IP Address'} | ${{ ipAddress: '127.0.0.1' }} | ${'127.0.0.1'} - ${'IP Address'} | ${{ ipAddress: null }} | ${'None'} ${'Configuration'} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED, runUntagged: true }} | ${'Protected, Runs untagged jobs'} ${'Configuration'} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED, runUntagged: false }} | ${'Protected'} ${'Configuration'} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED, runUntagged: true }} | ${'Runs untagged jobs'} diff --git a/spec/frontend/ci/runner/components/runner_details_tabs_spec.js b/spec/frontend/ci/runner/components/runner_details_tabs_spec.js index 689d0575726..516209794ad 100644 --- a/spec/frontend/ci/runner/components/runner_details_tabs_spec.js +++ b/spec/frontend/ci/runner/components/runner_details_tabs_spec.js @@ -54,7 +54,7 @@ describe('RunnerDetailsTabs', () => { ...options, }); - routerPush = jest.spyOn(wrapper.vm.$router, 'push').mockImplementation(() => {}); + routerPush = jest.spyOn(wrapper.vm.$router, 'push'); return waitForPromises(); }; @@ -67,9 +67,8 @@ describe('RunnerDetailsTabs', () => { }); it('shows runner jobs', async () => { - setWindowLocation(`#${JOBS_ROUTE_PATH}`); - - await createComponent({ mountFn: mountExtended }); + createComponent({ mountFn: mountExtended }); + await wrapper.vm.$router.push({ path: JOBS_ROUTE_PATH }); expect(findRunnerDetails().exists()).toBe(false); expect(findRunnerJobs().props('runner')).toBe(mockRunner); @@ -101,10 +100,9 @@ describe('RunnerDetailsTabs', () => { } }); - it.each(['#/', '#/unknown-tab'])('shows details when location hash is `%s`', async (hash) => { - setWindowLocation(hash); - - await createComponent({ mountFn: mountExtended }); + it.each(['#/', '#/unknown-tab'])('shows details when location hash is `%s`', async (path) => { + createComponent({ mountFn: mountExtended }); + await wrapper.vm.$router.push({ path }); expect(findTabs().props('value')).toBe(0); expect(findRunnerDetails().exists()).toBe(true); diff --git a/spec/frontend/ci/runner/components/runner_list_spec.js b/spec/frontend/ci/runner/components/runner_list_spec.js index 9da640afeb7..7c00aa48d31 100644 --- a/spec/frontend/ci/runner/components/runner_list_spec.js +++ b/spec/frontend/ci/runner/components/runner_list_spec.js @@ -1,14 +1,11 @@ import { GlTableLite, GlSkeletonLoader } from '@gitlab/ui'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; -import { - extendedWrapper, - shallowMountExtended, - mountExtended, -} from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { createLocalState } from '~/ci/runner/graphql/list/local_state'; +import { stubComponent } from 'helpers/stub_component'; import RunnerList from '~/ci/runner/components/runner_list.vue'; import RunnerBulkDelete from '~/ci/runner/components/runner_bulk_delete.vue'; @@ -29,14 +26,11 @@ describe('RunnerList', () => { const findHeaders = () => wrapper.findAll('th'); const findRows = () => wrapper.findAll('[data-testid^="runner-row-"]'); const findCell = ({ row = 0, fieldKey }) => - extendedWrapper(findRows().at(row).find(`[data-testid="td-${fieldKey}"]`)); + findRows().at(row).find(`[data-testid="td-${fieldKey}"]`); const findRunnerBulkDelete = () => wrapper.findComponent(RunnerBulkDelete); const findRunnerBulkDeleteCheckbox = () => wrapper.findComponent(RunnerBulkDeleteCheckbox); - const createComponent = ( - { props = {}, provide = {}, ...options } = {}, - mountFn = shallowMountExtended, - ) => { + const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMountExtended) => { ({ cacheConfig, localMutations } = createLocalState()); wrapper = mountFn(RunnerList, { @@ -49,7 +43,6 @@ describe('RunnerList', () => { localMutations, onlineContactTimeoutSecs, staleTimeoutSecs, - ...provide, }, ...options, }); @@ -81,7 +74,11 @@ describe('RunnerList', () => { }); it('Sets runner id as a row key', () => { - createComponent(); + createComponent({ + stubs: { + GlTableLite: stubComponent(GlTableLite), + }, + }); expect(findTable().attributes('primary-key')).toBe('id'); }); @@ -220,7 +217,12 @@ describe('RunnerList', () => { describe('When data is loading', () => { it('shows a busy state', () => { - createComponent({ props: { runners: [], loading: true } }); + createComponent({ + props: { runners: [], loading: true }, + stubs: { + GlTableLite: stubComponent(GlTableLite), + }, + }); expect(findTable().classes('gl-opacity-6')).toBe(true); }); diff --git a/spec/frontend/ci/runner/components/runner_type_icon_spec.js b/spec/frontend/ci/runner/components/runner_type_icon_spec.js new file mode 100644 index 00000000000..01f3de10aa6 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_type_icon_spec.js @@ -0,0 +1,67 @@ +import { GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerTypeIcon from '~/ci/runner/components/runner_type_icon.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { assertProps } from 'helpers/assert_props'; +import { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + I18N_INSTANCE_TYPE, + I18N_GROUP_TYPE, + I18N_PROJECT_TYPE, +} from '~/ci/runner/constants'; + +describe('RunnerTypeIcon', () => { + let wrapper; + + const findIcon = () => wrapper.findComponent(GlIcon); + const getTooltip = () => getBinding(findIcon().element, 'gl-tooltip'); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(RunnerTypeIcon, { + propsData: { + ...props, + }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + }); + }; + + describe.each` + type | tooltipText + ${INSTANCE_TYPE} | ${I18N_INSTANCE_TYPE} + ${GROUP_TYPE} | ${I18N_GROUP_TYPE} + ${PROJECT_TYPE} | ${I18N_PROJECT_TYPE} + `('displays $type runner', ({ type, tooltipText }) => { + beforeEach(() => { + createComponent({ props: { type } }); + }); + + it(`with no text`, () => { + expect(findIcon().text()).toBe(''); + }); + + it(`with aria-label`, () => { + expect(findIcon().props('ariaLabel')).toBeDefined(); + }); + + it('with a tooltip', () => { + expect(getTooltip().value).toBeDefined(); + expect(getTooltip().value).toContain(tooltipText); + }); + }); + + it('validation fails for an incorrect type', () => { + expect(() => { + assertProps(RunnerTypeIcon, { type: 'AN_UNKNOWN_VALUE' }); + }).toThrow(); + }); + + it('does not render content when type is missing', () => { + createComponent({ props: { type: undefined } }); + + expect(findIcon().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js b/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js index 7438c47e32c..8258bd1d507 100644 --- a/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js +++ b/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js @@ -108,11 +108,6 @@ describe('GroupRunnerShowApp', () => { it('shows basic runner details', () => { const expected = `Description My Runner Last contact Never contacted - Version 1.0.0 - IP Address None - Executor None - Architecture None - Platform darwin Configuration Runs untagged jobs Maximum job timeout None Token expiry diff --git a/spec/frontend/ci/runner/sentry_utils_spec.js b/spec/frontend/ci/runner/sentry_utils_spec.js index 2f17cc43ac5..59d386a5899 100644 --- a/spec/frontend/ci/runner/sentry_utils_spec.js +++ b/spec/frontend/ci/runner/sentry_utils_spec.js @@ -4,24 +4,12 @@ import { captureException } from '~/ci/runner/sentry_utils'; jest.mock('@sentry/browser'); describe('~/ci/runner/sentry_utils', () => { - let mockSetTag; - - beforeEach(() => { - mockSetTag = jest.fn(); - - Sentry.withScope.mockImplementation((fn) => { - const scope = { setTag: mockSetTag }; - fn(scope); - }); - }); - describe('captureException', () => { const mockError = new Error('Something went wrong!'); it('error is reported to sentry', () => { captureException({ error: mockError }); - expect(Sentry.withScope).toHaveBeenCalled(); expect(Sentry.captureException).toHaveBeenCalledWith(mockError); }); @@ -30,10 +18,11 @@ describe('~/ci/runner/sentry_utils', () => { captureException({ error: mockError, component: mockComponentName }); - expect(Sentry.withScope).toHaveBeenCalled(); - expect(Sentry.captureException).toHaveBeenCalledWith(mockError); - - expect(mockSetTag).toHaveBeenCalledWith('vue_component', mockComponentName); + expect(Sentry.captureException).toHaveBeenCalledWith(mockError, { + tags: { + vue_component: mockComponentName, + }, + }); }); }); }); diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js index 207bfddcb4f..d4474b1c643 100644 --- a/spec/frontend/clusters_list/components/clusters_spec.js +++ b/spec/frontend/clusters_list/components/clusters_spec.js @@ -8,8 +8,15 @@ import ClustersEmptyState from '~/clusters_list/components/clusters_empty_state. import ClusterStore from '~/clusters_list/store'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { + SET_LOADING_NODES, + SET_CLUSTERS_DATA, + SET_LOADING_CLUSTERS, +} from '~/clusters_list/store/mutation_types'; import { apiData } from '../mock_data'; +jest.mock('@sentry/browser'); + describe('Clusters', () => { let mock; let store; @@ -59,15 +66,7 @@ describe('Clusters', () => { }; }; - let captureException; - beforeEach(() => { - jest.spyOn(Sentry, 'withScope').mockImplementation((fn) => { - const mockScope = { setTag: () => {} }; - fn(mockScope); - }); - captureException = jest.spyOn(Sentry, 'captureException'); - mock = new MockAdapter(axios); mockPollingApi(HTTP_STATUS_OK, apiData, paginationHeader()); @@ -76,13 +75,12 @@ describe('Clusters', () => { afterEach(() => { mock.restore(); - captureException.mockRestore(); }); describe('clusters table', () => { describe('when data is loading', () => { beforeEach(() => { - wrapper.vm.$store.state.loadingClusters = true; + store.commit(SET_LOADING_CLUSTERS, true); }); it('displays a loader instead of the table while loading', () => { @@ -99,7 +97,12 @@ describe('Clusters', () => { describe('when there are no clusters', () => { beforeEach(() => { - wrapper.vm.$store.state.totalClusters = 0; + store.commit(SET_CLUSTERS_DATA, { + data: {}, + paginationInformation: { + total: 0, + }, + }); }); it('should render empty state', () => { expect(findEmptyState().exists()).toBe(true); @@ -175,7 +178,7 @@ describe('Clusters', () => { describe('nodes finish loading', () => { beforeEach(async () => { - wrapper.vm.$store.state.loadingNodes = false; + store.commit(SET_LOADING_NODES, false); await nextTick(); }); @@ -198,19 +201,23 @@ describe('Clusters', () => { describe('nodes with unknown quantity', () => { it('notifies Sentry about all missing quantity types', () => { - expect(captureException).toHaveBeenCalledTimes(8); + expect(Sentry.captureException).toHaveBeenCalledTimes(8); }); it('notifies Sentry about CPU missing quantity types', () => { const missingCpuTypeError = new Error('UnknownK8sCpuQuantity:1missingCpuUnit'); - expect(captureException).toHaveBeenCalledWith(missingCpuTypeError); + expect(Sentry.captureException).toHaveBeenCalledWith(missingCpuTypeError, { + tags: { javascript_clusters_list: 'totalCpuAndUsageError' }, + }); }); it('notifies Sentry about Memory missing quantity types', () => { const missingMemoryTypeError = new Error('UnknownK8sMemoryQuantity:1missingMemoryUnit'); - expect(captureException).toHaveBeenCalledWith(missingMemoryTypeError); + expect(Sentry.captureException).toHaveBeenCalledWith(missingMemoryTypeError, { + tags: { javascript_clusters_list: 'totalMemoryAndUsageError' }, + }); }); }); }); diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js index 6d23db0517d..9e6da595a75 100644 --- a/spec/frontend/clusters_list/store/actions_spec.js +++ b/spec/frontend/clusters_list/store/actions_spec.js @@ -18,10 +18,6 @@ describe('Clusters store actions', () => { describe('reportSentryError', () => { beforeEach(() => { - jest.spyOn(Sentry, 'withScope').mockImplementation((fn) => { - const mockScope = { setTag: () => {} }; - fn(mockScope); - }); captureException = jest.spyOn(Sentry, 'captureException'); }); @@ -34,7 +30,11 @@ describe('Clusters store actions', () => { const tag = 'sentryErrorTag'; await testAction(actions.reportSentryError, { error: sentryError, tag }, {}, [], []); - expect(captureException).toHaveBeenCalledWith(sentryError); + expect(captureException).toHaveBeenCalledWith(sentryError, { + tags: { + javascript_clusters_list: tag, + }, + }); }); }); diff --git a/spec/frontend/commit/commit_pipeline_status_spec.js b/spec/frontend/commit/commit_pipeline_status_spec.js index 73031724b12..08a7ec17785 100644 --- a/spec/frontend/commit/commit_pipeline_status_spec.js +++ b/spec/frontend/commit/commit_pipeline_status_spec.js @@ -137,7 +137,7 @@ describe('Commit pipeline status component', () => { }); it('renders CI icon with the correct title and status', () => { - expect(findCiIcon().attributes('title')).toEqual('Pipeline: passed'); + expect(findCiIcon().attributes('title')).toEqual('Pipeline: Passed'); expect(findCiIcon().props('status')).toEqual(mockCiStatus); }); }); diff --git a/spec/frontend/commit/components/commit_box_pipeline_status_spec.js b/spec/frontend/commit/components/commit_box_pipeline_status_spec.js index 80b75a0a65e..844a2d81832 100644 --- a/spec/frontend/commit/components/commit_box_pipeline_status_spec.js +++ b/spec/frontend/commit/components/commit_box_pipeline_status_spec.js @@ -1,11 +1,11 @@ -import { GlLoadingIcon, GlLink } from '@gitlab/ui'; +import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import CommitBoxPipelineStatus from '~/projects/commit_box/info/components/commit_box_pipeline_status.vue'; import { COMMIT_BOX_POLL_INTERVAL, @@ -32,8 +32,7 @@ describe('Commit box pipeline status', () => { const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findStatusIcon = () => wrapper.findComponent(CiIcon); - const findPipelineLink = () => wrapper.findComponent(GlLink); + const findCiBadgeLink = () => wrapper.findComponent(CiBadgeLink); const advanceToNextFetch = () => { jest.advanceTimersByTime(COMMIT_BOX_POLL_INTERVAL); @@ -50,6 +49,9 @@ describe('Commit box pipeline status', () => { provide: { ...mockProvide, }, + stubs: { + CiBadgeLink, + }, apolloProvider: createMockApolloProvider(handler), }); }; @@ -59,7 +61,7 @@ describe('Commit box pipeline status', () => { createComponent(); expect(findLoadingIcon().exists()).toBe(true); - expect(findStatusIcon().exists()).toBe(false); + expect(findCiBadgeLink().exists()).toBe(false); }); }); @@ -71,7 +73,7 @@ describe('Commit box pipeline status', () => { }); it('should display pipeline status after the query is resolved successfully', () => { - expect(findStatusIcon().exists()).toBe(true); + expect(findCiBadgeLink().exists()).toBe(true); expect(findLoadingIcon().exists()).toBe(false); expect(createAlert).toHaveBeenCalledTimes(0); @@ -88,7 +90,7 @@ describe('Commit box pipeline status', () => { }, } = mockPipelineStatusResponse; - expect(findPipelineLink().attributes('href')).toBe(detailsPath); + expect(findCiBadgeLink().attributes('href')).toBe(detailsPath); }); }); diff --git a/spec/frontend/commit/pipelines/legacy_pipelines_table_wrapper_spec.js b/spec/frontend/commit/pipelines/legacy_pipelines_table_wrapper_spec.js index 4af292e3588..d58b139dae3 100644 --- a/spec/frontend/commit/pipelines/legacy_pipelines_table_wrapper_spec.js +++ b/spec/frontend/commit/pipelines/legacy_pipelines_table_wrapper_spec.js @@ -1,13 +1,13 @@ import { GlLoadingIcon, GlModal, GlTableLite } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import fixture from 'test_fixtures/pipelines/pipelines.json'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import Api from '~/api'; -import LegacyPipelinesTableWraper from '~/commit/pipelines/legacy_pipelines_table_wrapper.vue'; +import LegacyPipelinesTableWrapper from '~/commit/pipelines/legacy_pipelines_table_wrapper.vue'; +import PipelinesTable from '~/ci/common/pipelines_table.vue'; import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_INTERNAL_SERVER_ERROR, @@ -39,27 +39,26 @@ describe('Pipelines table in Commits and Merge requests', () => { const findTableRows = () => wrapper.findAllByTestId('pipeline-table-row'); const findModal = () => wrapper.findComponent(GlModal); const findMrPipelinesDocsLink = () => wrapper.findByTestId('mr-pipelines-docs-link'); - - const createComponent = ({ props = {} } = {}) => { - wrapper = extendedWrapper( - mount(LegacyPipelinesTableWraper, { - propsData: { - endpoint: 'endpoint.json', - emptyStateSvgPath: 'foo', - errorStateSvgPath: 'foo', - ...props, - }, - mocks: { - $toast, - }, - stubs: { - GlModal: stubComponent(GlModal, { - template: '<div />', - methods: { show: showMock }, - }), - }, - }), - ); + const findPipelinesTable = () => wrapper.findComponent(PipelinesTable); + + const createComponent = ({ props = {}, mountFn = mountExtended } = {}) => { + wrapper = mountFn(LegacyPipelinesTableWrapper, { + propsData: { + endpoint: 'endpoint.json', + emptyStateSvgPath: 'foo', + errorStateSvgPath: 'foo', + ...props, + }, + mocks: { + $toast, + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: '<div />', + methods: { show: showMock }, + }), + }, + }); }; beforeEach(() => { @@ -116,7 +115,6 @@ describe('Pipelines table in Commits and Merge requests', () => { it('should make an API request when using pagination', async () => { expect(mock.history.get).toHaveLength(1); - expect(mock.history.get[0].params.page).toBe('1'); wrapper.find('.next-page-item').trigger('click'); @@ -359,4 +357,53 @@ describe('Pipelines table in Commits and Merge requests', () => { ); }); }); + + describe('events', () => { + beforeEach(async () => { + mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, [pipeline]); + + createComponent({ mountFn: shallowMountExtended }); + + await waitForPromises(); + }); + + describe('When cancelling a pipeline', () => { + it('sends the cancel action', async () => { + expect(mock.history.post).toHaveLength(0); + + findPipelinesTable().vm.$emit('cancel-pipeline', pipeline); + + await waitForPromises(); + + expect(mock.history.post).toHaveLength(1); + expect(mock.history.post[0].url).toContain('cancel.json'); + }); + }); + + describe('When retrying a pipeline', () => { + it('sends the retry action', async () => { + expect(mock.history.post).toHaveLength(0); + + findPipelinesTable().vm.$emit('retry-pipeline', pipeline); + + await waitForPromises(); + + expect(mock.history.post).toHaveLength(1); + expect(mock.history.post[0].url).toContain('retry.json'); + }); + }); + + describe('When refreshing a pipeline', () => { + it('calls the pipelines endpoint again', async () => { + expect(mock.history.get).toHaveLength(1); + + findPipelinesTable().vm.$emit('refresh-pipelines-table'); + + await waitForPromises(); + + expect(mock.history.get).toHaveLength(2); + expect(mock.history.get[1].url).toContain('endpoint.json'); + }); + }); + }); }); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 3eb00f69345..548c6030ed7 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -206,6 +206,14 @@ describe('markdownSerializer', () => { ); }); + it('correctly serializes a malformed URL-encoded link', () => { + expect( + serialize( + paragraph(link({ href: 'https://example.com/%E0%A4%A' }, 'https://example.com/%E0%A4%A')), + ), + ).toBe('https://example.com/%E0%A4%A'); + }); + it('correctly serializes a link with a title', () => { expect( serialize( diff --git a/spec/frontend/contributors/component/contributors_spec.js b/spec/frontend/contributors/component/contributors_spec.js index f915b834aff..7d863a8eb78 100644 --- a/spec/frontend/contributors/component/contributors_spec.js +++ b/spec/frontend/contributors/component/contributors_spec.js @@ -8,6 +8,7 @@ import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { visitUrl } from '~/lib/utils/url_utility'; import RefSelector from '~/ref/components/ref_selector.vue'; import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; +import { SET_CHART_DATA, SET_LOADING_STATE } from '~/contributors/stores/mutation_types'; jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn(), @@ -66,14 +67,14 @@ describe('Contributors charts', () => { }); it('should display loader whiled loading data', async () => { - wrapper.vm.$store.state.loading = true; + store.commit(SET_LOADING_STATE, true); await nextTick(); expect(findLoadingIcon().exists()).toBe(true); }); it('should render charts and a RefSelector when loading completed and there is chart data', async () => { - wrapper.vm.$store.state.loading = false; - wrapper.vm.$store.state.chartData = chartData; + store.commit(SET_LOADING_STATE, false); + store.commit(SET_CHART_DATA, chartData); await nextTick(); expect(findLoadingIcon().exists()).toBe(false); @@ -92,8 +93,8 @@ describe('Contributors charts', () => { }); it('should have a history button with a set href attribute', async () => { - wrapper.vm.$store.state.loading = false; - wrapper.vm.$store.state.chartData = chartData; + store.commit(SET_LOADING_STATE, false); + store.commit(SET_CHART_DATA, chartData); await nextTick(); const historyButton = findHistoryButton(); @@ -102,8 +103,8 @@ describe('Contributors charts', () => { }); it('visits a URL when clicking on a branch/tag', async () => { - wrapper.vm.$store.state.loading = false; - wrapper.vm.$store.state.chartData = chartData; + store.commit(SET_LOADING_STATE, false); + store.commit(SET_CHART_DATA, chartData); await nextTick(); findRefSelector().vm.$emit('input', branch); diff --git a/spec/frontend/crm/crm_form_spec.js b/spec/frontend/crm/crm_form_spec.js index fabf43ceb9d..083b49b7c30 100644 --- a/spec/frontend/crm/crm_form_spec.js +++ b/spec/frontend/crm/crm_form_spec.js @@ -10,7 +10,7 @@ import routes from '~/crm/contacts/routes'; import createContactMutation from '~/crm/contacts/components/graphql/create_contact.mutation.graphql'; import updateContactMutation from '~/crm/contacts/components/graphql/update_contact.mutation.graphql'; import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql'; -import createOrganizationMutation from '~/crm/organizations/components/graphql/create_organization.mutation.graphql'; +import createOrganizationMutation from '~/crm/organizations/components/graphql/create_customer_relations_organization.mutation.graphql'; import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql'; import { createContactMutationErrorResponse, diff --git a/spec/frontend/crm/organization_form_wrapper_spec.js b/spec/frontend/crm/organization_form_wrapper_spec.js index 8408c1920a9..f15fcac71d5 100644 --- a/spec/frontend/crm/organization_form_wrapper_spec.js +++ b/spec/frontend/crm/organization_form_wrapper_spec.js @@ -2,7 +2,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import OrganizationFormWrapper from '~/crm/organizations/components/organization_form_wrapper.vue'; import CrmForm from '~/crm/components/crm_form.vue'; import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql'; -import createOrganizationMutation from '~/crm/organizations/components/graphql/create_organization.mutation.graphql'; +import createOrganizationMutation from '~/crm/organizations/components/graphql/create_customer_relations_organization.mutation.graphql'; import updateOrganizationMutation from '~/crm/organizations/components/graphql/update_organization.mutation.graphql'; describe('Customer relations organization form wrapper', () => { diff --git a/spec/frontend/design_management/components/design_description/description_form_spec.js b/spec/frontend/design_management/components/design_description/description_form_spec.js index f7feff98da3..7d68a3b80d5 100644 --- a/spec/frontend/design_management/components/design_description/description_form_spec.js +++ b/spec/frontend/design_management/components/design_description/description_form_spec.js @@ -42,7 +42,6 @@ describe('Design description form', () => { showEditor = false, isSubmitting = false, designVariables = mockDesignVariables, - contentEditorOnIssues = false, designUpdateMutationHandler = mockDesignUpdateMutationHandler, } = {}) => { mockApollo = createMockApollo([[updateDesignDescriptionMutation, designUpdateMutationHandler]]); @@ -52,11 +51,6 @@ describe('Design description form', () => { markdownPreviewPath: '/gitlab-org/gitlab-test/preview_markdown?target_type=Issue', designVariables, }, - provide: { - glFeatures: { - contentEditorOnIssues, - }, - }, apolloProvider: mockApollo, data() { return { @@ -131,7 +125,7 @@ describe('Design description form', () => { expect(findMarkdownEditor().props()).toMatchObject({ value: 'Test description', renderMarkdownPath: '/gitlab-org/gitlab-test/preview_markdown?target_type=Issue', - enableContentEditor: false, + enableContentEditor: true, formFieldProps, autofocus: true, enableAutocomplete: true, diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index 961ea27f0f4..9b5e812c021 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -191,7 +191,7 @@ describe('Design management index page', () => { [moveDesignMutation, moveDesignHandler], ]; - fakeApollo = createMockApollo(requestHandlers, {}, { addTypename: true }); + fakeApollo = createMockApollo(requestHandlers, {}); wrapper = shallowMountExtended(Index, { apolloProvider: fakeApollo, router, diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index e10aad6214c..212def72b90 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -6,6 +6,7 @@ import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'spec/test_constants'; + import App from '~/diffs/components/app.vue'; import CommitWidget from '~/diffs/components/commit_widget.vue'; import CompareVersions from '~/diffs/components/compare_versions.vue'; @@ -17,6 +18,8 @@ import DiffsFileTree from '~/diffs/components/diffs_file_tree.vue'; import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue'; import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue'; +import eventHub from '~/diffs/event_hub'; + import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { Mousetrap } from '~/lib/mousetrap'; @@ -760,4 +763,29 @@ describe('diffs/components/app', () => { ); }); }); + + describe('autoscroll', () => { + let loadSpy; + + beforeEach(() => { + createComponent(); + loadSpy = jest.spyOn(wrapper.vm, 'loadCollapsedDiff').mockResolvedValue('resolved'); + }); + + it('does nothing if the location hash does not include a file hash', () => { + window.location.hash = 'not_a_file_hash'; + + eventHub.$emit('doneLoadingBatches'); + + expect(loadSpy).not.toHaveBeenCalled(); + }); + + it('requests that the correct file be loaded', () => { + window.location.hash = '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_0_1'; + + eventHub.$emit('doneLoadingBatches'); + + expect(loadSpy).toHaveBeenCalledWith({ file: store.state.diffs.diffFiles[0] }); + }); + }); }); diff --git a/spec/frontend/diffs/components/diff_file_header_spec.js b/spec/frontend/diffs/components/diff_file_header_spec.js index b089825090b..b0d98e0e4a6 100644 --- a/spec/frontend/diffs/components/diff_file_header_spec.js +++ b/spec/frontend/diffs/components/diff_file_header_spec.js @@ -8,8 +8,12 @@ import { mockTracking, triggerEvent } from 'helpers/tracking_helper'; import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; import { DIFF_FILE_AUTOMATIC_COLLAPSE, DIFF_FILE_MANUAL_COLLAPSE } from '~/diffs/constants'; -import { reviewFile } from '~/diffs/store/actions'; -import { SET_DIFF_FILE_VIEWED, SET_MR_FILE_REVIEWS } from '~/diffs/store/mutation_types'; +import { reviewFile, setFileForcedOpen } from '~/diffs/store/actions'; +import { + SET_DIFF_FILE_VIEWED, + SET_MR_FILE_REVIEWS, + SET_FILE_FORCED_OPEN, +} from '~/diffs/store/mutation_types'; import { diffViewerModes } from '~/ide/constants'; import { scrollToElement } from '~/lib/utils/common_utils'; import { truncateSha } from '~/lib/utils/text_utility'; @@ -67,6 +71,7 @@ describe('DiffFileHeader component', () => { toggleFullDiff: jest.fn(), setCurrentFileHash: jest.fn(), setFileCollapsedByUser: jest.fn(), + setFileForcedOpen: jest.fn(), reviewFile: jest.fn(), }, }, @@ -138,6 +143,19 @@ describe('DiffFileHeader component', () => { expect(wrapper.emitted().toggleFile).toBeDefined(); }); + it('when header is clicked it triggers the action that removes the value that forces a file to be uncollapsed', () => { + createComponent(); + findHeader().trigger('click'); + + return testAction( + setFileForcedOpen, + { filePath: diffFile.file_path, forced: false }, + {}, + [{ type: SET_FILE_FORCED_OPEN, payload: { filePath: diffFile.file_path, forced: false } }], + [], + ); + }); + it('when collapseIcon is clicked emits toggleFile', async () => { createComponent({ props: { collapsible: true } }); findCollapseButton().vm.$emit('click', new Event('click')); @@ -643,6 +661,44 @@ describe('DiffFileHeader component', () => { expect(Boolean(wrapper.emitted().toggleFile)).toBe(fires); }, ); + + it('removes the property that forces a file to be shown when the file review is toggled', () => { + createComponent({ + props: { + diffFile: { + ...diffFile, + viewer: { + ...diffFile.viewer, + automaticallyCollapsed: false, + manuallyCollapsed: null, + }, + }, + showLocalFileReviews: true, + addMergeRequestButtons: true, + expanded: false, + }, + }); + + findReviewFileCheckbox().vm.$emit('change', true); + + testAction( + setFileForcedOpen, + { filePath: diffFile.file_path, forced: false }, + {}, + [{ type: SET_FILE_FORCED_OPEN, payload: { filePath: diffFile.file_path, forced: false } }], + [], + ); + + findReviewFileCheckbox().vm.$emit('change', false); + + testAction( + setFileForcedOpen, + { filePath: diffFile.file_path, forced: false }, + {}, + [{ type: SET_FILE_FORCED_OPEN, payload: { filePath: diffFile.file_path, forced: false } }], + [], + ); + }); }); it('should render the comment on files button', () => { diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js index 53f135471b7..13efd3584b4 100644 --- a/spec/frontend/diffs/components/diff_file_spec.js +++ b/spec/frontend/diffs/components/diff_file_spec.js @@ -324,6 +324,22 @@ describe('DiffFile', () => { }); describe('collapsing', () => { + describe('forced open', () => { + it('should have content even when it is automatically collapsed', () => { + makeFileAutomaticallyCollapsed(store); + + expect(findDiffContentArea(wrapper).element.children.length).toBe(1); + expect(wrapper.classes('has-body')).toBe(true); + }); + + it('should have content even when it is manually collapsed', () => { + makeFileManuallyCollapsed(store); + + expect(findDiffContentArea(wrapper).element.children.length).toBe(1); + expect(wrapper.classes('has-body')).toBe(true); + }); + }); + describe(`\`${EVT_EXPAND_ALL_FILES}\` event`, () => { beforeEach(() => { jest.spyOn(wrapper.vm, 'handleToggle').mockImplementation(() => {}); diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index 387407a7e4d..18e81232b5c 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -1627,6 +1627,7 @@ describe('DiffsStoreActions', () => { name: updatedViewerName, automaticallyCollapsed: false, manuallyCollapsed: false, + forceOpen: false, }; const testData = [{ rich_text: 'test' }, { rich_text: 'file2' }]; let renamedFile; @@ -1673,7 +1674,7 @@ describe('DiffsStoreActions', () => { }); }); - describe('setFileUserCollapsed', () => { + describe('setFileCollapsedByUser', () => { it('commits SET_FILE_COLLAPSED', () => { return testAction( diffActions.setFileCollapsedByUser, @@ -1690,6 +1691,17 @@ describe('DiffsStoreActions', () => { }); }); + describe('setFileForcedOpen', () => { + it('commits SET_FILE_FORCED_OPEN', () => { + return testAction(diffActions.setFileForcedOpen, { filePath: 'test', forced: true }, null, [ + { + type: types.SET_FILE_FORCED_OPEN, + payload: { filePath: 'test', forced: true }, + }, + ]); + }); + }); + describe('setExpandedDiffLines', () => { beforeEach(() => { utils.idleCallback.mockImplementation((cb) => { diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js index e87c5d0a9b1..fdcf7c3eeab 100644 --- a/spec/frontend/diffs/store/mutations_spec.js +++ b/spec/frontend/diffs/store/mutations_spec.js @@ -1055,4 +1055,14 @@ describe('DiffsStoreMutations', () => { expect(state.diffFiles[0].drafts[0]).toEqual('test'); }); }); + + describe('SET_FILE_FORCED_OPEN', () => { + it('sets the forceOpen property of a diff file viewer correctly', () => { + const state = { diffFiles: [{ file_path: 'abc', viewer: { forceOpen: 'not-a-boolean' } }] }; + + mutations[types.SET_FILE_FORCED_OPEN](state, { filePath: 'abc', force: true }); + + expect(state.diffFiles[0].viewer.forceOpen).toBe(true); + }); + }); }); diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index 24cb8158739..720b72f4965 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -927,19 +927,21 @@ describe('DiffsStoreUtils', () => { describe('parseUrlHashAsFileHash', () => { it.each` - input | currentDiffId | resultId - ${'#note_12345'} | ${'1A2B3C'} | ${'1A2B3C'} - ${'note_12345'} | ${'1A2B3C'} | ${'1A2B3C'} - ${'#note_12345'} | ${undefined} | ${null} - ${'note_12345'} | ${undefined} | ${null} - ${'#diff-content-12345'} | ${undefined} | ${'12345'} - ${'diff-content-12345'} | ${undefined} | ${'12345'} - ${'#diff-content-12345'} | ${'98765'} | ${'12345'} - ${'diff-content-12345'} | ${'98765'} | ${'12345'} - ${'#e334a2a10f036c00151a04cea7938a5d4213a818'} | ${undefined} | ${'e334a2a10f036c00151a04cea7938a5d4213a818'} - ${'e334a2a10f036c00151a04cea7938a5d4213a818'} | ${undefined} | ${'e334a2a10f036c00151a04cea7938a5d4213a818'} - ${'#Z334a2a10f036c00151a04cea7938a5d4213a818'} | ${undefined} | ${null} - ${'Z334a2a10f036c00151a04cea7938a5d4213a818'} | ${undefined} | ${null} + input | currentDiffId | resultId + ${'#note_12345'} | ${'1A2B3C'} | ${'1A2B3C'} + ${'note_12345'} | ${'1A2B3C'} | ${'1A2B3C'} + ${'#note_12345'} | ${undefined} | ${null} + ${'note_12345'} | ${undefined} | ${null} + ${'#diff-content-12345'} | ${undefined} | ${'12345'} + ${'diff-content-12345'} | ${undefined} | ${'12345'} + ${'#diff-content-12345'} | ${'98765'} | ${'12345'} + ${'diff-content-12345'} | ${'98765'} | ${'12345'} + ${'#e334a2a10f036c00151a04cea7938a5d4213a818'} | ${undefined} | ${'e334a2a10f036c00151a04cea7938a5d4213a818'} + ${'e334a2a10f036c00151a04cea7938a5d4213a818'} | ${undefined} | ${'e334a2a10f036c00151a04cea7938a5d4213a818'} + ${'#Z334a2a10f036c00151a04cea7938a5d4213a818'} | ${undefined} | ${null} + ${'Z334a2a10f036c00151a04cea7938a5d4213a818'} | ${undefined} | ${null} + ${'#e334a2a10f036c00151a04cea7938a5d4213a818_0_42'} | ${undefined} | ${'e334a2a10f036c00151a04cea7938a5d4213a818'} + ${'e334a2a10f036c00151a04cea7938a5d4213a818_0_42'} | ${undefined} | ${'e334a2a10f036c00151a04cea7938a5d4213a818'} `('returns $resultId for $input and $currentDiffId', ({ input, currentDiffId, resultId }) => { expect(utils.parseUrlHashAsFileHash(input, currentDiffId)).toBe(resultId); }); diff --git a/spec/frontend/diffs/utils/merge_request_spec.js b/spec/frontend/diffs/utils/merge_request_spec.js index 11c0efb9a9c..f5145b3c4c7 100644 --- a/spec/frontend/diffs/utils/merge_request_spec.js +++ b/spec/frontend/diffs/utils/merge_request_spec.js @@ -1,6 +1,7 @@ import { updateChangesTabCount, getDerivedMergeRequestInformation, + extractFileHash, } from '~/diffs/utils/merge_request'; import { ZERO_CHANGES_ALT_DISPLAY } from '~/diffs/constants'; import { diffMetadata } from '../mock_data/diff_metadata'; @@ -128,4 +129,19 @@ describe('Merge Request utilities', () => { }); }); }); + + describe('extractFileHash', () => { + const sha1Like = 'abcdef1234567890abcdef1234567890abcdef12'; + const sha1LikeToo = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + + it('returns undefined when a SHA1-like string cannot be found in the input', () => { + expect(extractFileHash({ input: 'something' })).toBe(undefined); + }); + + it('returns the first matching string of SHA1-like characters in the input', () => { + const fullString = `#${sha1Like}_34_42--${sha1LikeToo}`; + + expect(extractFileHash({ input: fullString })).toBe(sha1Like); + }); + }); }); diff --git a/spec/frontend/diffs/utils/sort_errors_by_file_spec.js b/spec/frontend/diffs/utils/sort_errors_by_file_spec.js new file mode 100644 index 00000000000..ca8a8ec3516 --- /dev/null +++ b/spec/frontend/diffs/utils/sort_errors_by_file_spec.js @@ -0,0 +1,52 @@ +import { sortFindingsByFile } from '~/diffs/utils/sort_findings_by_file'; + +describe('sort_findings_by_file utilities', () => { + const mockDescription = 'mockDescription'; + const mockSeverity = 'mockseverity'; + const mockLine = '00'; + const mockFile1 = 'file1.js'; + const mockFile2 = 'file2.rb'; + const emptyResponse = { + files: {}, + }; + + const unsortedFindings = [ + { + severity: mockSeverity, + filePath: mockFile1, + line: mockLine, + description: mockDescription, + }, + { + severity: mockSeverity, + filePath: mockFile2, + line: mockLine, + description: mockDescription, + }, + ]; + const sortedFindings = { + files: { + [mockFile1]: [ + { + line: mockLine, + description: mockDescription, + severity: mockSeverity, + }, + ], + [mockFile2]: [ + { + line: mockLine, + description: mockDescription, + severity: mockSeverity, + }, + ], + }, + }; + + it('sorts Findings correctly', () => { + expect(sortFindingsByFile(unsortedFindings)).toEqual(sortedFindings); + }); + it('does not throw error when given no input', () => { + expect(sortFindingsByFile()).toEqual(emptyResponse); + }); +}); diff --git a/spec/frontend/editor/schema/ci/ci_schema_spec.js b/spec/frontend/editor/schema/ci/ci_schema_spec.js index 77c7f0d49a8..0f380f13679 100644 --- a/spec/frontend/editor/schema/ci/ci_schema_spec.js +++ b/spec/frontend/editor/schema/ci/ci_schema_spec.js @@ -36,6 +36,7 @@ import HooksYaml from './yaml_tests/positive_tests/hooks.yml'; import SecretsYaml from './yaml_tests/positive_tests/secrets.yml'; import ServicesYaml from './yaml_tests/positive_tests/services.yml'; import NeedsParallelMatrixYaml from './yaml_tests/positive_tests/needs_parallel_matrix.yml'; +import ScriptYaml from './yaml_tests/positive_tests/script.yml'; // YAML NEGATIVE TEST import ArtifactsNegativeYaml from './yaml_tests/negative_tests/artifacts.yml'; @@ -60,6 +61,7 @@ import ServicesNegativeYaml from './yaml_tests/negative_tests/services.yml'; import NeedsParallelMatrixNumericYaml from './yaml_tests/negative_tests/needs/parallel_matrix/numeric.yml'; import NeedsParallelMatrixWrongParallelValueYaml from './yaml_tests/negative_tests/needs/parallel_matrix/wrong_parallel_value.yml'; import NeedsParallelMatrixWrongMatrixValueYaml from './yaml_tests/negative_tests/needs/parallel_matrix/wrong_matrix_value.yml'; +import ScriptNegativeYaml from './yaml_tests/negative_tests/script.yml'; const ajv = new Ajv({ strictTypes: false, @@ -101,6 +103,7 @@ describe('positive tests', () => { ServicesYaml, SecretsYaml, NeedsParallelMatrixYaml, + ScriptYaml, }), )('schema validates %s', (_, input) => { // We construct a new "JSON" from each main key that is inside a @@ -144,6 +147,7 @@ describe('negative tests', () => { NeedsParallelMatrixNumericYaml, NeedsParallelMatrixWrongParallelValueYaml, NeedsParallelMatrixWrongMatrixValueYaml, + ScriptNegativeYaml, }), )('schema validates %s', (_, input) => { // We construct a new "JSON" from each main key that is inside a diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/script.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/script.yml new file mode 100644 index 00000000000..f5bf3f54f6f --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/script.yml @@ -0,0 +1,14 @@ +script: echo "invalid global script" + +default: + before_script: 0.1 + after_script: 1 + +invalid_script_type: + script: true + +empty_array_script: + script: [] + +empty_string_script: + script: "" diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/script.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/script.yml new file mode 100644 index 00000000000..0ffb1f3e89e --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/script.yml @@ -0,0 +1,52 @@ +default: + before_script: + - echo "default before_script" + after_script: | + echo "default after_script" + +valid_job_with_empty_string_script: + before_script: "" + after_script: "" + script: + - echo "overwrite default before_script and after_script" + +valid_job_with_empty_array_script: + before_script: [] + after_script: [] + script: + - echo "overwrite default before_script and after_script" + +valid_job_with_string_scripts: + before_script: echo before_script + script: echo script + after_script: echo after_script + +valid_job_with_multi_line_scripts: + before_script: | + echo multiline + echo before_script + script: | + echo multiline + echo script + after_script: | + echo multiline + echo after_script + +valid_job_with_array_scripts: + before_script: + - echo array + - echo before_script + script: + - echo array + - echo script + after_script: + - echo array + - echo after_script + +valid_job_with_nested_array_scripts: + before_script: + - [echo nested_array, echo before_script] + script: + - [echo nested_array, echo script] + after_script: + - [echo nested_array, echo after_script] diff --git a/spec/frontend/editor/source_editor_spec.js b/spec/frontend/editor/source_editor_spec.js index 6a8e7b296aa..f66de61da1e 100644 --- a/spec/frontend/editor/source_editor_spec.js +++ b/spec/frontend/editor/source_editor_spec.js @@ -9,21 +9,6 @@ import SourceEditor from '~/editor/source_editor'; import { DEFAULT_THEME, themes } from '~/ide/lib/themes'; import { joinPaths } from '~/lib/utils/url_utility'; -jest.mock('~/helpers/startup_css_helper', () => { - return { - waitForCSSLoaded: jest.fn().mockImplementation((cb) => { - // We have to artificially put the callback's execution - // to the end of the current call stack to be able to - // test that the callback is called after waitForCSSLoaded. - // setTimeout with 0 delay does exactly that. - // Otherwise we might end up with false positive results - setTimeout(() => { - cb.apply(); - }, 0); - }), - }; -}); - describe('Base editor', () => { let editorEl; let editor; @@ -161,7 +146,7 @@ describe('Base editor', () => { expect(instance.getModel()).toBeNull(); }); - it('resets the layout in waitForCSSLoaded callback', async () => { + it('resets the layout in createInstance', () => { const layoutSpy = jest.fn(); jest.spyOn(monacoEditor, 'create').mockReturnValue({ layout: layoutSpy, @@ -170,10 +155,6 @@ describe('Base editor', () => { dispose: jest.fn(), }); editor.createInstance(defaultArguments); - expect(layoutSpy).not.toHaveBeenCalled(); - - // We're waiting for the waitForCSSLoaded mock to kick in - await jest.runOnlyPendingTimers(); expect(layoutSpy).toHaveBeenCalled(); }); diff --git a/spec/frontend/environments/canary_ingress_spec.js b/spec/frontend/environments/canary_ingress_spec.js index e0247731b63..1d0d9385bfe 100644 --- a/spec/frontend/environments/canary_ingress_spec.js +++ b/spec/frontend/environments/canary_ingress_spec.js @@ -1,21 +1,21 @@ -import { GlDropdownItem } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { createMockDirective } from 'helpers/vue_mock_directive'; import CanaryIngress from '~/environments/components/canary_ingress.vue'; -import { CANARY_UPDATE_MODAL } from '~/environments/constants'; import { rolloutStatus } from './graphql/mock_data'; +jest.mock('lodash/uniqueId', () => { + return jest.fn((input) => input); +}); + describe('/environments/components/canary_ingress.vue', () => { let wrapper; - const setWeightTo = (weightWrapper, x) => - weightWrapper - .findAllComponents(GlDropdownItem) - .at(x / 5) - .vm.$emit('click'); + const setWeightTo = (weightWrapper, x) => { + weightWrapper.vm.$emit('select', x); + }; const createComponent = (props = {}, options = {}) => { - wrapper = mount(CanaryIngress, { + wrapper = mountExtended(CanaryIngress, { propsData: { canaryIngress: { canary_weight: 60, @@ -37,11 +37,11 @@ describe('/environments/components/canary_ingress.vue', () => { let stableWeightDropdown; beforeEach(() => { - stableWeightDropdown = wrapper.find('[data-testid="stable-weight"]'); + stableWeightDropdown = extendedWrapper(wrapper.find('#stable-weight-')); }); it('displays the current stable weight', () => { - expect(stableWeightDropdown.props('text')).toBe('40'); + expect(stableWeightDropdown.props('selected')).toBe(40); }); it('emits a change with the new canary weight', () => { @@ -51,17 +51,9 @@ describe('/environments/components/canary_ingress.vue', () => { }); it('lists options from 0 to 100 in increments of 5', () => { - const options = stableWeightDropdown.findAllComponents(GlDropdownItem); + const options = stableWeightDropdown.props('items'); expect(options).toHaveLength(21); - options.wrappers.forEach((w, i) => expect(w.text()).toBe((i * 5).toString())); - }); - - it('is set to open the change modal', () => { - stableWeightDropdown - .findAllComponents(GlDropdownItem) - .wrappers.forEach((w) => - expect(getBinding(w.element, 'gl-modal')).toMatchObject({ value: CANARY_UPDATE_MODAL }), - ); + options.forEach((option, i) => expect(option.text).toBe((i * 5).toString())); }); }); @@ -69,11 +61,11 @@ describe('/environments/components/canary_ingress.vue', () => { let canaryWeightDropdown; beforeEach(() => { - canaryWeightDropdown = wrapper.find('[data-testid="canary-weight"]'); + canaryWeightDropdown = wrapper.find('#canary-weight-'); }); it('displays the current canary weight', () => { - expect(canaryWeightDropdown.props('text')).toBe('60'); + expect(canaryWeightDropdown.props('selected')).toBe(60); }); it('emits a change with the new canary weight', () => { @@ -83,17 +75,9 @@ describe('/environments/components/canary_ingress.vue', () => { }); it('lists options from 0 to 100 in increments of 5', () => { - canaryWeightDropdown - .findAllComponents(GlDropdownItem) - .wrappers.forEach((w, i) => expect(w.text()).toBe((i * 5).toString())); - }); - - it('is set to open the change modal', () => { - canaryWeightDropdown - .findAllComponents(GlDropdownItem) - .wrappers.forEach((w) => - expect(getBinding(w.element, 'gl-modal')).toMatchObject({ value: CANARY_UPDATE_MODAL }), - ); + const options = canaryWeightDropdown.props('items'); + expect(options).toHaveLength(21); + options.forEach((option, i) => expect(option.text).toBe((i * 5).toString())); }); }); @@ -106,8 +90,8 @@ describe('/environments/components/canary_ingress.vue', () => { }); it('shows the correct weight', () => { - const canaryWeightDropdown = wrapper.find('[data-testid="canary-weight"]'); - expect(canaryWeightDropdown.props('text')).toBe('50'); + const canaryWeightDropdown = wrapper.find('#canary-weight-'); + expect(canaryWeightDropdown.props('selected')).toBe(50); }); }); }); diff --git a/spec/frontend/environments/environment_form_spec.js b/spec/frontend/environments/environment_form_spec.js index 22dd7437d82..5888b22aece 100644 --- a/spec/frontend/environments/environment_form_spec.js +++ b/spec/frontend/environments/environment_form_spec.js @@ -28,12 +28,11 @@ const userAccessAuthorizedAgents = [ const configuration = { basePath: mockKasTunnelUrl.replace(/\/$/, ''), - baseOptions: { - headers: { - 'GitLab-Agent-Id': 2, - }, - withCredentials: true, + headers: { + 'GitLab-Agent-Id': 2, + 'Content-Type': 'application/json', }, + credentials: 'include', }; describe('~/environments/components/form.vue', () => { diff --git a/spec/frontend/environments/graphql/resolvers/kubernetes_spec.js b/spec/frontend/environments/graphql/resolvers/kubernetes_spec.js index 1d41fb11b14..ed15c66f4c6 100644 --- a/spec/frontend/environments/graphql/resolvers/kubernetes_spec.js +++ b/spec/frontend/environments/graphql/resolvers/kubernetes_spec.js @@ -29,9 +29,7 @@ describe('~/frontend/environments/graphql/resolvers', () => { describe('k8sPods', () => { const mockPodsListFn = jest.fn().mockImplementation(() => { return Promise.resolve({ - data: { - items: k8sPodsMock, - }, + items: k8sPodsMock, }); }); @@ -50,7 +48,7 @@ describe('~/frontend/environments/graphql/resolvers', () => { it('should request namespaced pods from the cluster_client library if namespace is specified', async () => { const pods = await mockResolvers.Query.k8sPods(null, { configuration, namespace }); - expect(mockNamespacedPodsListFn).toHaveBeenCalledWith(namespace); + expect(mockNamespacedPodsListFn).toHaveBeenCalledWith({ namespace }); expect(mockAllPodsListFn).not.toHaveBeenCalled(); expect(pods).toEqual(k8sPodsMock); @@ -76,22 +74,42 @@ describe('~/frontend/environments/graphql/resolvers', () => { describe('k8sServices', () => { const mockServicesListFn = jest.fn().mockImplementation(() => { return Promise.resolve({ - data: { - items: k8sServicesMock, - }, + items: k8sServicesMock, }); }); + const mockNamespacedServicesListFn = jest.fn().mockImplementation(mockServicesListFn); + const mockAllServicesListFn = jest.fn().mockImplementation(mockServicesListFn); + beforeEach(() => { jest .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces') .mockImplementation(mockServicesListFn); + + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1NamespacedService') + .mockImplementation(mockNamespacedServicesListFn); + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces') + .mockImplementation(mockAllServicesListFn); }); - it('should request services from the cluster_client library', async () => { - const services = await mockResolvers.Query.k8sServices(null, { configuration }); + it('should request namespaced services from the cluster_client library if namespace is specified', async () => { + const services = await mockResolvers.Query.k8sServices(null, { configuration, namespace }); + + expect(mockNamespacedServicesListFn).toHaveBeenCalledWith({ namespace }); + expect(mockAllServicesListFn).not.toHaveBeenCalled(); + + expect(services).toEqual(k8sServicesMock); + }); + it('should request all services from the cluster_client library if namespace is not specified', async () => { + const services = await mockResolvers.Query.k8sServices(null, { + configuration, + namespace: '', + }); expect(mockServicesListFn).toHaveBeenCalled(); + expect(mockNamespacedServicesListFn).not.toHaveBeenCalled(); expect(services).toEqual(k8sServicesMock); }); @@ -159,7 +177,7 @@ describe('~/frontend/environments/graphql/resolvers', () => { await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace }); namespacedMocks.forEach((workloadMock) => { - expect(workloadMock.spy).toHaveBeenCalledWith(namespace); + expect(workloadMock.spy).toHaveBeenCalledWith({ namespace }); }); }); @@ -194,9 +212,7 @@ describe('~/frontend/environments/graphql/resolvers', () => { describe('k8sNamespaces', () => { const mockNamespacesListFn = jest.fn().mockImplementation(() => { return Promise.resolve({ - data: { - items: k8sNamespacesMock, - }, + items: k8sNamespacesMock, }); }); @@ -221,13 +237,7 @@ describe('~/frontend/environments/graphql/resolvers', () => { ])( 'should throw an error if the API call fails with the reason "%s"', async (reason, message) => { - jest.spyOn(CoreV1Api.prototype, 'listCoreV1Namespace').mockRejectedValue({ - response: { - data: { - reason, - }, - }, - }); + jest.spyOn(CoreV1Api.prototype, 'listCoreV1Namespace').mockRejectedValue({ reason }); await expect(mockResolvers.Query.k8sNamespaces(null, { configuration })).rejects.toThrow( message, diff --git a/spec/frontend/environments/kubernetes_overview_spec.js b/spec/frontend/environments/kubernetes_overview_spec.js index aa7e2e9a3b7..2b810aac653 100644 --- a/spec/frontend/environments/kubernetes_overview_spec.js +++ b/spec/frontend/environments/kubernetes_overview_spec.js @@ -27,10 +27,11 @@ const provide = { const configuration = { basePath: provide.kasTunnelUrl.replace(/\/$/, ''), - baseOptions: { - headers: { 'GitLab-Agent-Id': '1' }, - withCredentials: true, + headers: { + 'GitLab-Agent-Id': '1', + 'Content-Type': 'application/json', }, + credentials: 'include', }; describe('~/environments/components/kubernetes_overview.vue', () => { diff --git a/spec/frontend/environments/kubernetes_pods_spec.js b/spec/frontend/environments/kubernetes_pods_spec.js index 0420d8df1a9..a51c85468b4 100644 --- a/spec/frontend/environments/kubernetes_pods_spec.js +++ b/spec/frontend/environments/kubernetes_pods_spec.js @@ -123,7 +123,7 @@ describe('~/environments/components/kubernetes_pods.vue', () => { }); it('emits an error message', () => { - expect(wrapper.emitted('cluster-error')).toMatchObject([[error]]); + expect(wrapper.emitted('cluster-error')).toMatchObject([[error.message]]); }); }); }); diff --git a/spec/frontend/environments/kubernetes_summary_spec.js b/spec/frontend/environments/kubernetes_summary_spec.js index 22c81f29f64..fdcf32e7d01 100644 --- a/spec/frontend/environments/kubernetes_summary_spec.js +++ b/spec/frontend/environments/kubernetes_summary_spec.js @@ -16,9 +16,7 @@ describe('~/environments/components/kubernetes_summary.vue', () => { const namespace = 'my-kubernetes-namespace'; const configuration = { basePath: mockKasTunnelUrl, - baseOptions: { - headers: { 'GitLab-Agent-Id': '1' }, - }, + headers: { 'GitLab-Agent-Id': '1', 'Content-Type': 'application/json' }, }; const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); @@ -121,7 +119,7 @@ describe('~/environments/components/kubernetes_summary.vue', () => { createWrapper(createErroredApolloProvider()); await waitForPromises(); - expect(wrapper.emitted('cluster-error')).toEqual([[error]]); + expect(wrapper.emitted('cluster-error')).toEqual([[error.message]]); }); }); }); diff --git a/spec/frontend/environments/kubernetes_tabs_spec.js b/spec/frontend/environments/kubernetes_tabs_spec.js index 81b0bb86e0e..fecd6d2a8ee 100644 --- a/spec/frontend/environments/kubernetes_tabs_spec.js +++ b/spec/frontend/environments/kubernetes_tabs_spec.js @@ -162,7 +162,7 @@ describe('~/environments/components/kubernetes_tabs.vue', () => { createWrapper(createErroredApolloProvider()); await waitForPromises(); - expect(wrapper.emitted('cluster-error')).toEqual([[error]]); + expect(wrapper.emitted('cluster-error')).toEqual([[error.message]]); }); }); diff --git a/spec/frontend/fixtures/autocomplete.rb b/spec/frontend/fixtures/autocomplete.rb index 6215fa44e27..0ceacc41cdb 100644 --- a/spec/frontend/fixtures/autocomplete.rb +++ b/spec/frontend/fixtures/autocomplete.rb @@ -22,15 +22,17 @@ RSpec.describe ::AutocompleteController, '(JavaScript fixtures)', type: :control project.add_developer(user) end - get :users, - format: :json, - params: { - project_id: project.id, - active: true, - current_user: true, - author: merge_request.author.id, - merge_request_iid: merge_request.iid - } + get( + :users, + format: :json, + params: { + project_id: project.id, + active: true, + current_user: true, + author: merge_request.author.id, + merge_request_iid: merge_request.iid + } + ) expect(response).to be_successful end diff --git a/spec/frontend/fixtures/autocomplete_sources.rb b/spec/frontend/fixtures/autocomplete_sources.rb index 74bf58cc106..2c28440ab0c 100644 --- a/spec/frontend/fixtures/autocomplete_sources.rb +++ b/spec/frontend/fixtures/autocomplete_sources.rb @@ -26,14 +26,16 @@ RSpec.describe Projects::AutocompleteSourcesController, '(JavaScript fixtures)', create(:label, project: project, title: 'P3') create(:label, project: project, title: 'P4') - get :labels, - format: :json, - params: { - namespace_id: group.path, - project_id: project.path, - type: issue.class.name, - type_id: issue.id - } + get( + :labels, + format: :json, + params: { + namespace_id: group.path, + project_id: project.path, + type: issue.class.name, + type_id: issue.id + } + ) expect(response).to be_successful end diff --git a/spec/frontend/fixtures/environments.rb b/spec/frontend/fixtures/environments.rb index 81f1eb11e3e..8cf0977c5ed 100644 --- a/spec/frontend/fixtures/environments.rb +++ b/spec/frontend/fixtures/environments.rb @@ -27,13 +27,16 @@ RSpec.describe 'Environments (JavaScript fixtures)', feature_category: :environm query = get_graphql_query_as_string(environment_details_query_path) puts project.full_path puts environment.name - post_graphql(query, current_user: admin, - variables: - { - projectFullPath: project.full_path, - environmentName: environment.name, - pageSize: 10 - }) + post_graphql( + query, + current_user: admin, + variables: + { + projectFullPath: project.full_path, + environmentName: environment.name, + pageSize: 10 + } + ) expect_graphql_errors_to_be_empty end end @@ -58,13 +61,16 @@ RSpec.describe 'Environments (JavaScript fixtures)', feature_category: :environm it "graphql/#{environment_details_query_path}.json" do query = get_graphql_query_as_string(environment_details_query_path) - post_graphql(query, current_user: admin, - variables: - { - projectFullPath: project.full_path, - environmentName: environment.name, - pageSize: 10 - }) + post_graphql( + query, + current_user: admin, + variables: + { + projectFullPath: project.full_path, + environmentName: environment.name, + pageSize: 10 + } + ) expect_graphql_errors_to_be_empty end end diff --git a/spec/frontend/fixtures/issues.rb b/spec/frontend/fixtures/issues.rb index 9e6fcea2d17..90aa0544526 100644 --- a/spec/frontend/fixtures/issues.rb +++ b/spec/frontend/fixtures/issues.rb @@ -70,25 +70,29 @@ RSpec.describe API::Issues, '(JavaScript fixtures)', type: :request do issue_title = 'foo' issue_description = 'closed' milestone = create(:milestone, title: '1.0.0', project: project) - issue = create :issue, - author: user, - assignees: [user], - project: project, - milestone: milestone, - created_at: generate(:past_time), - updated_at: 1.hour.ago, - title: issue_title, - description: issue_description + issue = create( + :issue, + author: user, + assignees: [user], + project: project, + milestone: milestone, + created_at: generate(:past_time), + updated_at: 1.hour.ago, + title: issue_title, + description: issue_description + ) project.add_reporter(user) create_referencing_mr(user, project, issue) - create(:merge_request, - :simple, - author: user, - source_project: project, - target_project: project, - description: "Some description") + create( + :merge_request, + :simple, + author: user, + source_project: project, + target_project: project, + description: "Some description" + ) project2 = create(:project, :public, creator_id: user.id, namespace: user.namespace) create_referencing_mr(user, project2, issue).update!(head_pipeline: create(:ci_pipeline)) diff --git a/spec/frontend/fixtures/releases.rb b/spec/frontend/fixtures/releases.rb index c7e3d8fe804..32ebe174800 100644 --- a/spec/frontend/fixtures/releases.rb +++ b/spec/frontend/fixtures/releases.rb @@ -11,23 +11,27 @@ RSpec.describe 'Releases (JavaScript fixtures)' do let_it_be(:user) { create(:user, email: 'user@example.gitlab.com', username: 'user1') } 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')) + 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')) + 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 @@ -47,68 +51,78 @@ RSpec.describe 'Releases (JavaScript fixtures)' do 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: user, - description: 'Best. Release. **Ever.** :rocket:', - created_at: Time.zone.parse('2018-12-3'), - released_at: Time.zone.parse('2018-12-10')) + create( + :release, + milestones: [milestone_12_3, milestone_12_4], + project: project, + tag: 'v1.1', + name: 'The first release', + author: user, + 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')) + 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') + 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) + 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) + 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) + create( + :release_link, + id: 13, + release: release, + name: 'Image', + url: 'https://example.com/image', + link_type: :image + ) end let_it_be(:another_release) do - create(:release, - project: project, - tag: 'v1.2', - name: 'The second release', - author: user, - description: 'An okay release :shrug:', - created_at: Time.zone.parse('2019-01-03'), - released_at: Time.zone.parse('2019-01-10')) + create( + :release, + project: project, + tag: 'v1.2', + name: 'The second release', + author: user, + description: 'An okay release :shrug:', + created_at: Time.zone.parse('2019-01-03'), + released_at: Time.zone.parse('2019-01-10') + ) end before do diff --git a/spec/frontend/fixtures/search.rb b/spec/frontend/fixtures/search.rb index b2da383d657..0036fb353a5 100644 --- a/spec/frontend/fixtures/search.rb +++ b/spec/frontend/fixtures/search.rb @@ -58,9 +58,10 @@ RSpec.describe SearchController, '(JavaScript fixtures)', type: :controller do project_id: project.id, startline: 2) ], - total_count: 4, - limit: 4, - offset: 0) + total_count: 4, + limit: 4, + offset: 0 + ) end before do diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 2d19c9871b6..da465552db3 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -55,14 +55,14 @@ describe('GfmAutoComplete', () => { describe('assets loading', () => { beforeEach(() => { - atwhoInstance = { setting: {}, $inputor: 'inputor', at: '[vulnerability:' }; + atwhoInstance = { setting: {}, $inputor: 'inputor', at: '~' }; items = ['loading']; filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, '', items); }); it('should call the fetchData function without query', () => { - expect(fetchDataMock.fetchData).toHaveBeenCalledWith('inputor', '[vulnerability:'); + expect(fetchDataMock.fetchData).toHaveBeenCalledWith('inputor', '~'); }); it('should not call the default atwho filter', () => { @@ -80,6 +80,29 @@ describe('GfmAutoComplete', () => { items = []; }); + describe('when loading', () => { + beforeEach(() => { + items = ['loading']; + filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, 'oldquery', items); + }); + + it('should call the fetchData function with query', () => { + expect(fetchDataMock.fetchData).toHaveBeenCalledWith( + 'inputor', + '[vulnerability:', + 'oldquery', + ); + }); + + it('should not call the default atwho filter', () => { + expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled(); + }); + + it('should return the passed unfiltered items', () => { + expect(filterValue).toEqual(items); + }); + }); + describe('when previous query is different from current one', () => { beforeEach(() => { gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({ @@ -173,7 +196,7 @@ describe('GfmAutoComplete', () => { context = { isLoadingData: { '[vulnerability:': false }, dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' }, - cachedData: {}, + cachedData: { '[vulnerability:': { other_query: [] } }, }; }); @@ -206,15 +229,14 @@ describe('GfmAutoComplete', () => { const context = { isLoadingData: { '[vulnerability:': false }, dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' }, - cachedData: { '[vulnerability:': [{}] }, + cachedData: { '[vulnerability:': { query: [] } }, + loadData: () => {}, }; fetchData.call(context, {}, '[vulnerability:', 'query'); }); - it('should anyway call axios with query ignoring cache', () => { - expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', { - params: { search: 'query' }, - }); + it('should not call axios', () => { + expect(axios.get).not.toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/google_tag_manager/index_spec.js b/spec/frontend/google_tag_manager/index_spec.js index dd8e886e6bc..c32c86d5f5a 100644 --- a/spec/frontend/google_tag_manager/index_spec.js +++ b/spec/frontend/google_tag_manager/index_spec.js @@ -1,537 +1,9 @@ -import { merge } from 'lodash'; -import { v4 as uuidv4 } from 'uuid'; -import { - trackCombinedGroupProjectForm, - trackFreeTrialAccountSubmissions, - trackProjectImport, - trackNewRegistrations, - trackSaasTrialSubmit, - trackSaasTrialGroup, - trackSaasTrialGetStarted, - trackTrialAcceptTerms, - trackCheckout, - trackTransaction, - trackAddToCartUsageTab, - getNamespaceId, - trackCompanyForm, -} from '~/google_tag_manager'; -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import { logError } from '~/lib/logger'; - -jest.mock('~/lib/logger'); -jest.mock('uuid'); +import { trackTrialAcceptTerms } from 'ee_else_ce/google_tag_manager'; describe('~/google_tag_manager/index', () => { - let spy; - - beforeEach(() => { - spy = jest.fn(); - - window.dataLayer = { - push: spy, - }; - window.gon.features = { - gitlabGtmDatalayer: true, - }; - }); - - const createHTML = ({ links = [], forms = [] } = {}) => { - // .foo elements are used to test elements which shouldn't do anything - const allLinks = links.concat({ cls: 'foo' }); - const allForms = forms.concat({ cls: 'foo' }); - - const el = document.createElement('div'); - - allLinks.forEach(({ cls = '', id = '', href = '#', text = 'Hello', attributes = {} }) => { - const a = document.createElement('a'); - a.id = id; - a.href = href || '#'; - a.className = cls; - a.textContent = text; - - Object.entries(attributes).forEach(([key, value]) => { - a.setAttribute(key, value); - }); - - el.append(a); - }); - - allForms.forEach(({ cls = '', id = '' }) => { - const form = document.createElement('form'); - form.id = id; - form.className = cls; - - el.append(form); - }); - - return el.innerHTML; - }; - - const triggerEvent = (selector, eventType) => { - const el = document.querySelector(selector); - - el.dispatchEvent(new Event(eventType)); - }; - - const getSelector = ({ id, cls }) => (id ? `#${id}` : `.${cls}`); - - const createTestCase = (subject, { forms = [], links = [] }) => { - const expectedFormEvents = forms.map(({ expectation, ...form }) => ({ - selector: getSelector(form), - trigger: 'submit', - expectation, - })); - - const expectedLinkEvents = links.map(({ expectation, ...link }) => ({ - selector: getSelector(link), - trigger: 'click', - expectation, - })); - - return [ - subject, - { - forms, - links, - expectedEvents: [...expectedFormEvents, ...expectedLinkEvents], - }, - ]; - }; - - const createOmniAuthTestCase = (subject, accountType) => - createTestCase(subject, { - forms: [ - { - id: 'new_new_user', - expectation: { - event: 'accountSubmit', - accountMethod: 'form', - accountType, - }, - }, - ], - links: [ - { - // id is needed so that the test selects the right element to trigger - id: 'test-0', - cls: 'js-oauth-login', - attributes: { - 'data-provider': 'myspace', - }, - expectation: { - event: 'accountSubmit', - accountMethod: 'myspace', - accountType, - }, - }, - { - id: 'test-1', - cls: 'js-oauth-login', - attributes: { - 'data-provider': 'gitlab', - }, - expectation: { - event: 'accountSubmit', - accountMethod: 'gitlab', - accountType, - }, - }, - ], - }); - - describe.each([ - createOmniAuthTestCase(trackFreeTrialAccountSubmissions, 'freeThirtyDayTrial'), - createOmniAuthTestCase(trackNewRegistrations, 'standardSignUp'), - createTestCase(trackSaasTrialGroup, { - forms: [{ cls: 'js-saas-trial-group', expectation: { event: 'saasTrialGroup' } }], - }), - createTestCase(trackProjectImport, { - links: [ - { - id: 'js-test-btn-0', - cls: 'js-import-project-btn', - attributes: { 'data-platform': 'bitbucket' }, - expectation: { event: 'projectImport', platform: 'bitbucket' }, - }, - { - // id is neeeded so we trigger the right element in the test - id: 'js-test-btn-1', - cls: 'js-import-project-btn', - attributes: { 'data-platform': 'github' }, - expectation: { event: 'projectImport', platform: 'github' }, - }, - ], - }), - createTestCase(trackSaasTrialGetStarted, { - links: [ - { - cls: 'js-get-started-btn', - expectation: { event: 'saasTrialGetStarted' }, - }, - ], - }), - createTestCase(trackAddToCartUsageTab, { - links: [ - { - cls: 'js-buy-additional-minutes', - expectation: { - event: 'EECproductAddToCart', - ecommerce: { - currencyCode: 'USD', - add: { - products: [ - { - name: 'CI/CD Minutes', - id: '0003', - price: '10', - brand: 'GitLab', - category: 'DevOps', - variant: 'add-on', - quantity: 1, - }, - ], - }, - }, - }, - }, - ], - }), - createTestCase(trackCombinedGroupProjectForm, { - forms: [ - { - cls: 'js-groups-projects-form', - expectation: { event: 'combinedGroupProjectFormSubmit' }, - }, - ], - }), - ])('%p', (subject, { links = [], forms = [], expectedEvents }) => { - beforeEach(() => { - setHTMLFixture(createHTML({ links, forms })); - - subject(); - }); - - afterEach(() => { - resetHTMLFixture(); - }); - - it.each(expectedEvents)('when %p', ({ selector, trigger, expectation }) => { - expect(spy).not.toHaveBeenCalled(); - - triggerEvent(selector, trigger); - - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith(expectation); - expect(logError).not.toHaveBeenCalled(); - }); - - it('when random link is clicked, does nothing', () => { - triggerEvent('a.foo', 'click'); - - expect(spy).not.toHaveBeenCalled(); - }); - - it('when random form is submitted, does nothing', () => { - triggerEvent('form.foo', 'submit'); - - expect(spy).not.toHaveBeenCalled(); - }); - }); - describe('No listener events', () => { - it('when trackSaasTrialSubmit is invoked', () => { - expect(spy).not.toHaveBeenCalled(); - - trackSaasTrialSubmit(); - - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith({ event: 'saasTrialSubmit' }); - expect(logError).not.toHaveBeenCalled(); - }); - it('when trackTrialAcceptTerms is invoked', () => { - expect(spy).not.toHaveBeenCalled(); - - trackTrialAcceptTerms(); - - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith({ event: 'saasTrialAcceptTerms' }); - expect(logError).not.toHaveBeenCalled(); - }); - - describe('when trackCheckout is invoked', () => { - it('with selectedPlan: 2c92a00d76f0d5060176f2fb0a5029ff', () => { - expect(spy).not.toHaveBeenCalled(); - - trackCheckout('2c92a00d76f0d5060176f2fb0a5029ff', 1); - - expect(spy.mock.calls.flatMap((x) => x)).toEqual([ - { ecommerce: null }, - { - event: 'EECCheckout', - ecommerce: { - currencyCode: 'USD', - checkout: { - actionField: { step: 1 }, - products: [ - { - brand: 'GitLab', - category: 'DevOps', - id: '0002', - name: 'Premium', - price: '228', - quantity: 1, - variant: 'SaaS', - }, - ], - }, - }, - }, - ]); - }); - - it('with selectedPlan: 2c92a0ff76f0d5250176f2f8c86f305a', () => { - expect(spy).not.toHaveBeenCalled(); - - trackCheckout('2c92a0ff76f0d5250176f2f8c86f305a', 1); - - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith({ ecommerce: null }); - expect(spy).toHaveBeenCalledWith({ - event: 'EECCheckout', - ecommerce: { - currencyCode: 'USD', - checkout: { - actionField: { step: 1 }, - products: [ - { - brand: 'GitLab', - category: 'DevOps', - id: '0001', - name: 'Ultimate', - price: '1188', - quantity: 1, - variant: 'SaaS', - }, - ], - }, - }, - }); - }); - - it('with selectedPlan: Something else', () => { - expect(spy).not.toHaveBeenCalled(); - - trackCheckout('Something else', 1); - - expect(spy).not.toHaveBeenCalled(); - }); - - it('with a different number of users', () => { - expect(spy).not.toHaveBeenCalled(); - - trackCheckout('2c92a0ff76f0d5250176f2f8c86f305a', 5); - - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith({ ecommerce: null }); - expect(spy).toHaveBeenCalledWith({ - event: 'EECCheckout', - ecommerce: { - currencyCode: 'USD', - checkout: { - actionField: { step: 1 }, - products: [ - { - brand: 'GitLab', - category: 'DevOps', - id: '0001', - name: 'Ultimate', - price: '1188', - quantity: 5, - variant: 'SaaS', - }, - ], - }, - }, - }); - }); - }); - - describe('when trackTransactions is invoked', () => { - describe.each([ - { - selectedPlan: '2c92a00d76f0d5060176f2fb0a5029ff', - revenue: 228, - name: 'Premium', - id: '0002', - }, - { - selectedPlan: '2c92a0ff76f0d5250176f2f8c86f305a', - revenue: 1188, - name: 'Ultimate', - id: '0001', - }, - ])('with %o', (planObject) => { - it('invokes pushes a new event that references the selected plan', () => { - const { selectedPlan, revenue, name, id } = planObject; - - expect(spy).not.toHaveBeenCalled(); - uuidv4.mockImplementationOnce(() => '123'); - - const transactionDetails = { - paymentOption: 'visa', - revenue, - tax: 10, - selectedPlan, - quantity: 1, - }; - - trackTransaction(transactionDetails); - - expect(spy.mock.calls.flatMap((x) => x)).toEqual([ - { ecommerce: null }, - { - event: 'EECtransactionSuccess', - ecommerce: { - currencyCode: 'USD', - purchase: { - actionField: { - id: '123', - affiliation: 'GitLab', - option: 'visa', - revenue: revenue.toString(), - tax: '10', - }, - products: [ - { - brand: 'GitLab', - category: 'DevOps', - dimension36: 'not available', - id, - name, - price: revenue.toString(), - quantity: 1, - variant: 'SaaS', - }, - ], - }, - }, - }, - ]); - }); - }); - }); - - describe('when trackTransaction is invoked', () => { - describe('with an invalid plan object', () => { - it('does not get called', () => { - expect(spy).not.toHaveBeenCalled(); - - trackTransaction({ selectedPlan: 'notAplan' }); - - expect(spy).not.toHaveBeenCalled(); - }); - }); - }); - - describe('when trackCompanyForm is invoked', () => { - it('with an ultimate trial', () => { - expect(spy).not.toHaveBeenCalled(); - - trackCompanyForm('ultimate_trial'); - - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith({ - event: 'aboutYourCompanyFormSubmit', - aboutYourCompanyType: 'ultimate_trial', - }); - expect(logError).not.toHaveBeenCalled(); - }); - - it('with a free account', () => { - expect(spy).not.toHaveBeenCalled(); - - trackCompanyForm('free_account'); - - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith({ - event: 'aboutYourCompanyFormSubmit', - aboutYourCompanyType: 'free_account', - }); - expect(logError).not.toHaveBeenCalled(); - }); - }); - }); - - describe.each([ - { dataLayer: null }, - { gon: { features: null } }, - { gon: { features: { gitlabGtmDatalayer: false } } }, - ])('when window %o', (windowAttrs) => { - beforeEach(() => { - merge(window, windowAttrs); - }); - - it('no ops', () => { - setHTMLFixture(createHTML({ forms: [{ cls: 'js-saas-trial-group' }] })); - - trackSaasTrialGroup(); - - triggerEvent('.js-saas-trial-group', 'submit'); - - expect(spy).not.toHaveBeenCalled(); - expect(logError).not.toHaveBeenCalled(); - - resetHTMLFixture(); - }); - }); - - describe('when window.dataLayer throws error', () => { - const pushError = new Error('test'); - - beforeEach(() => { - window.dataLayer = { - push() { - throw pushError; - }, - }; - }); - - it('logs error', () => { - setHTMLFixture(createHTML({ forms: [{ cls: 'js-saas-trial-group' }] })); - - trackSaasTrialGroup(); - - triggerEvent('.js-saas-trial-group', 'submit'); - - expect(logError).toHaveBeenCalledWith( - 'Unexpected error while pushing to dataLayer', - pushError, - ); - - resetHTMLFixture(); - }); - }); - - describe('when getting the namespace_id from Snowplow standard context', () => { - describe('when window.gl.snowplowStandardContext.data.namespace_id has a value', () => { - beforeEach(() => { - window.gl = { snowplowStandardContext: { data: { namespace_id: '321' } } }; - }); - - it('returns the value', () => { - expect(getNamespaceId()).toBe('321'); - }); - }); - - describe('when window.gl.snowplowStandardContext.data.namespace_id is undefined', () => { - beforeEach(() => { - window.gl = {}; - }); - - it('returns a placeholder value', () => { - expect(getNamespaceId()).toBe('not available'); - }); + expect(trackTrialAcceptTerms()).toBeUndefined(); }); }); }); diff --git a/spec/frontend/helpers/startup_css_helper_spec.js b/spec/frontend/helpers/startup_css_helper_spec.js deleted file mode 100644 index 28c742386cc..00000000000 --- a/spec/frontend/helpers/startup_css_helper_spec.js +++ /dev/null @@ -1,67 +0,0 @@ -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import { waitForCSSLoaded } from '~/helpers/startup_css_helper'; - -describe('waitForCSSLoaded', () => { - let mockedCallback; - - beforeEach(() => { - mockedCallback = jest.fn(); - }); - - describe('Promise-like api', () => { - it('can be used with a callback', async () => { - await waitForCSSLoaded(mockedCallback); - expect(mockedCallback).toHaveBeenCalledTimes(1); - }); - - it('can be used as a promise', async () => { - await waitForCSSLoaded().then(mockedCallback); - expect(mockedCallback).toHaveBeenCalledTimes(1); - }); - }); - - describe('when gon features is not provided', () => { - beforeEach(() => { - window.gon = null; - }); - - it('should invoke the action right away', async () => { - const events = waitForCSSLoaded(mockedCallback); - await events; - - expect(mockedCallback).toHaveBeenCalledTimes(1); - }); - }); - - describe('with startup css enabled', () => { - it('should dispatch CSSLoaded when the assets are cached or already loaded', async () => { - setHTMLFixture(` - <link href="one.css" data-startupcss="loaded"> - <link href="two.css" data-startupcss="loaded"> - `); - await waitForCSSLoaded(mockedCallback); - - expect(mockedCallback).toHaveBeenCalledTimes(1); - - resetHTMLFixture(); - }); - - it('should wait to call CssLoaded until the assets are loaded', async () => { - setHTMLFixture(` - <link href="one.css" data-startupcss="loading"> - <link href="two.css" data-startupcss="loading"> - `); - const events = waitForCSSLoaded(mockedCallback); - document.querySelectorAll('[data-startupcss="loading"]').forEach((elem) => { - // eslint-disable-next-line no-param-reassign - elem.dataset.startupcss = 'loaded'; - }); - document.dispatchEvent(new CustomEvent('CSSStartupLinkLoaded')); - await events; - - expect(mockedCallback).toHaveBeenCalledTimes(1); - - resetHTMLFixture(); - }); - }); -}); diff --git a/spec/frontend/ide/init_gitlab_web_ide_spec.js b/spec/frontend/ide/init_gitlab_web_ide_spec.js index efbbd6c7514..6a5bedb0bbb 100644 --- a/spec/frontend/ide/init_gitlab_web_ide_spec.js +++ b/spec/frontend/ide/init_gitlab_web_ide_spec.js @@ -4,6 +4,7 @@ import { initGitlabWebIDE } from '~/ide/init_gitlab_web_ide'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_action'; import { createAndSubmitForm } from '~/lib/utils/create_and_submit_form'; import { handleTracking } from '~/ide/lib/gitlab_web_ide/handle_tracking_event'; +import Tracking from '~/tracking'; import { TEST_HOST } from 'helpers/test_constants'; import setWindowLocation from 'helpers/set_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -15,6 +16,7 @@ jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token', headerKey: 'mock-csrf-header', })); +jest.mock('~/tracking'); const ROOT_ELEMENT_ID = 'ide'; const TEST_NONCE = 'test123nonce'; @@ -34,9 +36,9 @@ const TEST_START_REMOTE_PARAMS = { remotePath: '/test/projects/f oo', connectionToken: '123abc', }; -const TEST_EDITOR_FONT_SRC_URL = 'http://gitlab.test/assets/jetbrains-mono/JetBrainsMono.woff2'; +const TEST_EDITOR_FONT_SRC_URL = 'http://gitlab.test/assets/gitlab-mono/GitLabMono.woff2'; const TEST_EDITOR_FONT_FORMAT = 'woff2'; -const TEST_EDITOR_FONT_FAMILY = 'JebBrains Mono'; +const TEST_EDITOR_FONT_FAMILY = 'GitLab Mono'; describe('ide/init_gitlab_web_ide', () => { let resolveConfirm; @@ -54,9 +56,20 @@ describe('ide/init_gitlab_web_ide', () => { el.dataset.userPreferencesPath = TEST_USER_PREFERENCES_PATH; el.dataset.mergeRequest = TEST_MR_ID; el.dataset.filePath = TEST_FILE_PATH; - el.dataset.editorFontSrcUrl = TEST_EDITOR_FONT_SRC_URL; - el.dataset.editorFontFormat = TEST_EDITOR_FONT_FORMAT; - el.dataset.editorFontFamily = TEST_EDITOR_FONT_FAMILY; + el.dataset.editorFont = JSON.stringify({ + fallback_font_family: 'monospace', + font_faces: [ + { + family: TEST_EDITOR_FONT_FAMILY, + src: [ + { + url: TEST_EDITOR_FONT_SRC_URL, + format: TEST_EDITOR_FONT_FORMAT, + }, + ], + }, + ], + }); el.dataset.signInPath = TEST_SIGN_IN_PATH; document.body.append(el); @@ -88,7 +101,11 @@ describe('ide/init_gitlab_web_ide', () => { }); describe('default', () => { + const telemetryEnabled = true; + beforeEach(() => { + Tracking.enabled.mockReturnValueOnce(telemetryEnabled); + createSubject(); }); @@ -115,12 +132,22 @@ describe('ide/init_gitlab_web_ide', () => { signIn: TEST_SIGN_IN_PATH, }, editorFont: { - srcUrl: TEST_EDITOR_FONT_SRC_URL, - fontFamily: TEST_EDITOR_FONT_FAMILY, - format: TEST_EDITOR_FONT_FORMAT, + fallbackFontFamily: 'monospace', + fontFaces: [ + { + family: TEST_EDITOR_FONT_FAMILY, + src: [ + { + url: TEST_EDITOR_FONT_SRC_URL, + format: TEST_EDITOR_FONT_FORMAT, + }, + ], + }, + ], }, handleStartRemote: expect.any(Function), handleTracking, + telemetryEnabled, }); }); diff --git a/spec/frontend/import/details/mock_data.js b/spec/frontend/import/details/mock_data.js index 67148173404..b61a7f36f85 100644 --- a/spec/frontend/import/details/mock_data.js +++ b/spec/frontend/import/details/mock_data.js @@ -7,7 +7,7 @@ export const mockImportFailures = [ exception_class: 'ActiveRecord::RecordInvalid', exception_message: 'Record invalid', source: 'Gitlab::GithubImport::Importer::PullRequestImporter', - github_identifiers: { + external_identifiers: { iid: 2, issuable_type: 'MergeRequest', object_type: 'pull_request', @@ -22,7 +22,7 @@ export const mockImportFailures = [ exception_class: 'ActiveRecord::RecordInvalid', exception_message: 'Record invalid', source: 'Gitlab::GithubImport::Importer::PullRequestImporter', - github_identifiers: { + external_identifiers: { iid: 3, issuable_type: 'MergeRequest', object_type: 'pull_request', @@ -37,7 +37,7 @@ export const mockImportFailures = [ exception_class: 'NameError', exception_message: 'some message', source: 'Gitlab::GithubImport::Importer::LfsObjectImporter', - github_identifiers: { + external_identifiers: { oid: '3a9257fae9e86faee27d7208cb55e086f18e6f29f48c430bfbc26d42eb', size: 2473979, }, diff --git a/spec/frontend/import_entities/components/group_dropdown_spec.js b/spec/frontend/import_entities/components/group_dropdown_spec.js deleted file mode 100644 index 14f39a35387..00000000000 --- a/spec/frontend/import_entities/components/group_dropdown_spec.js +++ /dev/null @@ -1,94 +0,0 @@ -import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import GroupDropdown from '~/import_entities/components/group_dropdown.vue'; -import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; -import searchNamespacesWhereUserCanImportProjectsQuery from '~/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql'; - -Vue.use(VueApollo); - -const makeGroupMock = (fullPath) => ({ - id: `gid://gitlab/Group/${fullPath}`, - fullPath, - name: fullPath, - visibility: 'public', - webUrl: `http://gdk.test:3000/groups/${fullPath}`, - __typename: 'Group', -}); - -const AVAILABLE_NAMESPACES = [ - makeGroupMock('match1'), - makeGroupMock('unrelated'), - makeGroupMock('match2'), -]; - -const SEARCH_NAMESPACES_MOCK = Promise.resolve({ - data: { - currentUser: { - id: 'gid://gitlab/User/1', - groups: { - nodes: AVAILABLE_NAMESPACES, - __typename: 'GroupConnection', - }, - namespace: { - id: 'gid://gitlab/Namespaces::UserNamespace/1', - fullPath: 'root', - __typename: 'Namespace', - }, - __typename: 'UserCore', - }, - }, -}); - -describe('Import entities group dropdown component', () => { - let wrapper; - let namespacesTracker; - - const createComponent = (propsData) => { - const apolloProvider = createMockApollo([ - [searchNamespacesWhereUserCanImportProjectsQuery, () => SEARCH_NAMESPACES_MOCK], - ]); - - namespacesTracker = jest.fn(); - - wrapper = shallowMount(GroupDropdown, { - apolloProvider, - scopedSlots: { - default: namespacesTracker, - }, - stubs: { GlDropdown }, - propsData, - }); - }; - - it('passes namespaces from graphql query to default slot', async () => { - createComponent(); - jest.advanceTimersByTime(DEBOUNCE_DELAY); - await nextTick(); - await waitForPromises(); - await nextTick(); - - expect(namespacesTracker).toHaveBeenCalledWith({ namespaces: AVAILABLE_NAMESPACES }); - }); - - it('filters namespaces based on user input', async () => { - createComponent(); - - namespacesTracker.mockReset(); - wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'match'); - jest.advanceTimersByTime(DEBOUNCE_DELAY); - await nextTick(); - await waitForPromises(); - await nextTick(); - - expect(namespacesTracker).toHaveBeenCalledWith({ - namespaces: [ - expect.objectContaining({ fullPath: 'match1' }), - expect.objectContaining({ fullPath: 'match2' }), - ], - }); - }); -}); diff --git a/spec/frontend/import_entities/components/import_target_dropdown_spec.js b/spec/frontend/import_entities/components/import_target_dropdown_spec.js index c12baed2374..ba0bb0b0f74 100644 --- a/spec/frontend/import_entities/components/import_target_dropdown_spec.js +++ b/spec/frontend/import_entities/components/import_target_dropdown_spec.js @@ -18,7 +18,6 @@ describe('ImportTargetDropdown', () => { const defaultProps = { selected: mockUserNamespace, - userNamespace: mockUserNamespace, }; const createComponent = ({ props = {} } = {}) => { @@ -39,7 +38,7 @@ describe('ImportTargetDropdown', () => { }; const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); - const findListboxUsersItems = () => findListbox().props('items')[0].options; + const findListboxFirstGroupItems = () => findListbox().props('items')[0].options; const findListboxGroupsItems = () => findListbox().props('items')[1].options; const waitForQuery = async () => { @@ -63,12 +62,54 @@ describe('ImportTargetDropdown', () => { expect(findListbox().props('toggleText')).toBe('a-group-path-that-is-lo…'); }); - it('passes userNamespace as "Users" group item', () => { - createComponent(); + describe('when used on group import', () => { + beforeEach(() => { + createComponent(); + }); - expect(findListboxUsersItems()).toEqual([ - { text: mockUserNamespace, value: mockUserNamespace }, - ]); + it('adds "No parent" in "Parent" group', () => { + expect(findListboxFirstGroupItems()).toEqual([{ text: 'No parent', value: '' }]); + }); + + it('emits "select" event with { fullPath: "", id: null } when "No parent" is selected', () => { + findListbox().vm.$emit('select', ''); + + expect(wrapper.emitted('select')[0]).toEqual([{ fullPath: '', id: null }]); + }); + + it('emits "select" event with { fullPath, id } when a group is selected', async () => { + await waitForQuery(); + + const mockGroupPath = 'match1'; + + findListbox().vm.$emit('select', mockGroupPath); + + expect(wrapper.emitted('select')[0]).toEqual([ + { fullPath: mockGroupPath, id: `gid://gitlab/Group/${mockGroupPath}` }, + ]); + }); + }); + + describe('when used on project import', () => { + beforeEach(() => { + createComponent({ + props: { userNamespace: mockUserNamespace }, + }); + }); + + it('passes userNamespace as "Users" group item', () => { + expect(findListboxFirstGroupItems()).toEqual([ + { text: mockUserNamespace, value: mockUserNamespace }, + ]); + }); + + it('emits "select" event with path as value', () => { + const mockProjectPath = 'mock-project'; + + findListbox().vm.$emit('select', mockProjectPath); + + expect(wrapper.emitted('select')[0]).toEqual([mockProjectPath]); + }); }); it('passes namespaces from GraphQL as "Groups" group item', async () => { diff --git a/spec/frontend/import_entities/import_groups/components/import_table_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_spec.js index 03d0920994c..4fab22e316a 100644 --- a/spec/frontend/import_entities/import_groups/components/import_table_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_table_spec.js @@ -1,8 +1,8 @@ import { GlEmptyState, GlIcon, GlLoadingIcon } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import MockAdapter from 'axios-mock-adapter'; +import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; @@ -55,14 +55,15 @@ describe('import table', () => { wrapper.findAll('tbody td button').wrappers.filter((w) => w.text() === 'Import with projects')[ idx ]; - const findPaginationDropdown = () => wrapper.find('[data-testid="page-size"]'); + const findPaginationDropdown = () => wrapper.findByTestId('page-size'); const findTargetNamespaceDropdown = (rowWrapper) => - rowWrapper.find('[data-testid="target-namespace-selector"]'); + extendedWrapper(rowWrapper).findByTestId('target-namespace-dropdown'); + const findTargetNamespaceInput = (rowWrapper) => + extendedWrapper(rowWrapper).findByTestId('target-namespace-input'); const findPaginationDropdownText = () => findPaginationDropdown().find('button').text(); const findSelectionCount = () => wrapper.find('[data-test-id="selection-count"]'); const findNewPathCol = () => wrapper.find('[data-test-id="new-path-col"]'); - const findUnavailableFeaturesWarning = () => - wrapper.find('[data-testid="unavailable-features-alert"]'); + const findUnavailableFeaturesWarning = () => wrapper.findByTestId('unavailable-features-alert'); const triggerSelectAllCheckbox = (checked = true) => wrapper.find('thead input[type=checkbox]').setChecked(checked); @@ -88,7 +89,7 @@ describe('import table', () => { }, ); - wrapper = mount(ImportTable, { + wrapper = mountExtended(ImportTable, { propsData: { groupPathRegex: /.*/, jobsPath: '/fake_job_path', @@ -220,32 +221,42 @@ describe('import table', () => { expect(wrapper.text()).not.toContain('Showing 1-0'); }); - it('invokes importGroups mutation when row button is clicked', async () => { - createComponent({ - bulkImportSourceGroups: () => ({ - nodes: [FAKE_GROUP], - pageInfo: FAKE_PAGE_INFO, - versionValidation: FAKE_VERSION_VALIDATION, - }), - }); + describe('when import button is clicked', () => { + beforeEach(async () => { + createComponent({ + bulkImportSourceGroups: () => ({ + nodes: [FAKE_GROUP], + pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, + }), + }); - jest.spyOn(apolloProvider.defaultClient, 'mutate'); + jest.spyOn(apolloProvider.defaultClient, 'mutate'); - await waitForPromises(); + await waitForPromises(); - await findRowImportDropdownAtIndex(0).trigger('click'); - expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({ - mutation: importGroupsMutation, - variables: { - importRequests: [ - { - migrateProjects: true, - newName: FAKE_GROUP.lastImportTarget.newName, - sourceGroupId: FAKE_GROUP.id, - targetNamespace: AVAILABLE_NAMESPACES[0].fullPath, - }, - ], - }, + await findRowImportDropdownAtIndex(0).trigger('click'); + }); + + it('invokes importGroups mutation', () => { + expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({ + mutation: importGroupsMutation, + variables: { + importRequests: [ + { + migrateProjects: true, + newName: FAKE_GROUP.lastImportTarget.newName, + sourceGroupId: FAKE_GROUP.id, + targetNamespace: AVAILABLE_NAMESPACES[0].fullPath, + }, + ], + }, + }); + }); + + it('disables the import target input', () => { + const firstRow = wrapper.find('tbody tr'); + expect(findTargetNamespaceInput(firstRow).attributes('disabled')).toBe('disabled'); }); }); @@ -294,6 +305,42 @@ describe('import table', () => { expect(wrapper.find('tbody tr').text()).toContain(i18n.ERROR_TOO_MANY_REQUESTS); }); + it('displays inline error if backend returns validation error', async () => { + const mockValidationError = + 'Import failed. Destination URL must not start or end with a special character and must not contain consecutive special characters.'; + const mockMutationWithProgressError = jest.fn().mockResolvedValue({ + __typename: 'ClientBulkImportSourceGroup', + id: 1, + lastImportTarget: { id: 1, targetNamespace: 'root', newName: 'group1' }, + progress: { + __typename: 'ClientBulkImportProgress', + id: null, + status: 'failed', + message: mockValidationError, + }, + }); + + createComponent({ + bulkImportSourceGroups: () => ({ + nodes: [FAKE_GROUP], + pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, + }), + importGroups: mockMutationWithProgressError, + }); + + await waitForPromises(); + await findRowImportDropdownAtIndex(0).trigger('click'); + await waitForPromises(); + + expect(mockMutationWithProgressError).toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); + + const firstRow = wrapper.find('tbody tr'); + expect(findTargetNamespaceInput(firstRow).attributes('disabled')).toBeUndefined(); + expect(firstRow.text()).toContain(mockValidationError); + }); + describe('pagination', () => { const bulkImportSourceGroupsQueryMock = jest.fn().mockResolvedValue({ nodes: [FAKE_GROUP], @@ -345,6 +392,28 @@ describe('import table', () => { ); }); + it('resets page to 1 when page size is changed', async () => { + wrapper.findComponent(PaginationBar).vm.$emit('set-page', 2); + await waitForPromises(); + + expect(bulkImportSourceGroupsQueryMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ page: 2, perPage: 50 }), + expect.anything(), + expect.anything(), + ); + + wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', 200); + await waitForPromises(); + + expect(bulkImportSourceGroupsQueryMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ page: 1, perPage: 200 }), + expect.anything(), + expect.anything(), + ); + }); + it('updates status text when page is changed', async () => { const REQUESTED_PAGE = 2; bulkImportSourceGroupsQueryMock.mockResolvedValue({ @@ -601,7 +670,7 @@ describe('import table', () => { }); describe('re-import', () => { - it('renders finished row as disabled by default', async () => { + beforeEach(async () => { createComponent({ bulkImportSourceGroups: () => ({ nodes: [generateFakeEntry({ id: 5, status: STATUSES.FINISHED })], @@ -609,21 +678,15 @@ describe('import table', () => { versionValidation: FAKE_VERSION_VALIDATION, }), }); + await waitForPromises(); + }); + it('renders finished row as disabled by default', () => { expect(findRowCheckbox(0).attributes('disabled')).toBeDefined(); }); it('enables row after clicking re-import', async () => { - createComponent({ - bulkImportSourceGroups: () => ({ - nodes: [generateFakeEntry({ id: 5, status: STATUSES.FINISHED })], - pageInfo: FAKE_PAGE_INFO, - versionValidation: FAKE_VERSION_VALIDATION, - }), - }); - await waitForPromises(); - const reimportButton = wrapper .findAll('tbody td button') .wrappers.find((w) => w.text().includes('Re-import')); diff --git a/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js b/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js index 46884a42707..ac95026a9a4 100644 --- a/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js @@ -1,10 +1,9 @@ -import { GlDropdownItem, GlFormInput } from '@gitlab/ui'; +import { GlFormInput } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMount } from '@vue/test-utils'; import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import ImportGroupDropdown from '~/import_entities/components/group_dropdown.vue'; +import ImportTargetDropdown from '~/import_entities/components/import_target_dropdown.vue'; import { STATUSES } from '~/import_entities/constants'; import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue'; import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -37,7 +36,7 @@ describe('import target cell', () => { let group; const findNameInput = () => wrapper.findComponent(GlFormInput); - const findNamespaceDropdown = () => wrapper.findComponent(ImportGroupDropdown); + const findNamespaceDropdown = () => wrapper.findComponent(ImportTargetDropdown); const createComponent = (props) => { apolloProvider = createMockApollo([ @@ -49,7 +48,7 @@ describe('import target cell', () => { wrapper = shallowMount(ImportTargetCell, { apolloProvider, - stubs: { ImportGroupDropdown }, + stubs: { ImportTargetDropdown }, propsData: { groupPathRegex: /.*/, ...props, @@ -73,14 +72,14 @@ describe('import target cell', () => { }); it('emits update-target-namespace when dropdown option is clicked', () => { - const dropdownItem = findNamespaceDropdown().findAllComponents(GlDropdownItem).at(2); + const targetNamespace = { + fullPath: AVAILABLE_NAMESPACES[1].fullPath, + id: AVAILABLE_NAMESPACES[1].id, + }; - dropdownItem.vm.$emit('click'); + findNamespaceDropdown().vm.$emit('select', targetNamespace); - expect(wrapper.emitted('update-target-namespace')).toBeDefined(); - expect(wrapper.emitted('update-target-namespace')[0][0]).toStrictEqual( - AVAILABLE_NAMESPACES[1], - ); + expect(wrapper.emitted('update-target-namespace')[0]).toStrictEqual([targetNamespace]); }); }); @@ -101,36 +100,6 @@ describe('import target cell', () => { }); }); - it('renders only no parent option if available namespaces list is empty', () => { - createComponent({ - group: generateFakeTableEntry({ id: 1, status: STATUSES.NONE }), - availableNamespaces: [], - }); - - const items = findNamespaceDropdown() - .findAllComponents(GlDropdownItem) - .wrappers.map((w) => w.text()); - - expect(items[0]).toBe('No parent'); - expect(items).toHaveLength(1); - }); - - it('renders both no parent option and available namespaces list when available namespaces list is not empty', async () => { - createComponent({ - group: generateFakeTableEntry({ id: 1, status: STATUSES.NONE }), - }); - jest.advanceTimersByTime(DEBOUNCE_DELAY); - await waitForPromises(); - await nextTick(); - - const [firstItem, ...rest] = findNamespaceDropdown() - .findAllComponents(GlDropdownItem) - .wrappers.map((w) => w.text()); - - expect(firstItem).toBe('No parent'); - expect(rest).toHaveLength(AVAILABLE_NAMESPACES.length); - }); - describe('when entity is not available for import', () => { beforeEach(() => { group = generateFakeTableEntry({ @@ -147,6 +116,7 @@ describe('import target cell', () => { describe('when entity is available for import', () => { const FAKE_PROGRESS_MESSAGE = 'progress message'; + beforeEach(() => { group = generateFakeTableEntry({ id: 1, diff --git a/spec/frontend/integrations/gitlab_slack_application/components/projects_dropdown_spec.js b/spec/frontend/integrations/gitlab_slack_application/components/projects_dropdown_spec.js new file mode 100644 index 00000000000..8879a86a578 --- /dev/null +++ b/spec/frontend/integrations/gitlab_slack_application/components/projects_dropdown_spec.js @@ -0,0 +1,54 @@ +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import ProjectsDropdown from '~/integrations/gitlab_slack_application/components/projects_dropdown.vue'; + +describe('Slack application projects dropdown', () => { + let wrapper; + + const projectsMockData = [ + { + avatar_url: null, + id: 1, + name: 'Gitlab Smoke Tests', + name_with_namespace: 'Toolbox / Gitlab Smoke Tests', + }, + { + avatar_url: null, + id: 2, + name: 'Gitlab Test', + name_with_namespace: 'Gitlab Org / Gitlab Test', + }, + { + avatar_url: 'foo/bar', + id: 3, + name: 'Gitlab Shell', + name_with_namespace: 'Gitlab Org / Gitlab Shell', + }, + ]; + + const createComponent = (props = {}) => { + wrapper = shallowMount(ProjectsDropdown, { + propsData: { + projects: projectsMockData, + ...props, + }, + }); + }; + + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + + beforeEach(() => { + createComponent(); + }); + + it('renders the listbox with 3 items', () => { + expect(findListbox().exists()).toBe(true); + expect(findListbox().props('items')).toHaveLength(3); + }); + + it('should emit project-selected if a project is clicked', () => { + findListbox().vm.$emit('select', 1); + + expect(wrapper.emitted('project-selected')).toMatchObject([[projectsMockData[0]]]); + }); +}); diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js index 526487f6460..cfc2fd65cc1 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -34,6 +34,7 @@ import { displaySuccessfulInvitationAlert, reloadOnInvitationSuccess, } from '~/invite_members/utils/trigger_successful_invite_alert'; +import { captureException } from '~/ci/runner/sentry_utils'; import { GROUPS_INVITATIONS_PATH, invitationsApiResponse } from '../mock_data/api_responses'; import { propsData, @@ -52,6 +53,7 @@ import { jest.mock('~/invite_members/utils/trigger_successful_invite_alert'); jest.mock('~/experimentation/experiment_tracking'); +jest.mock('~/ci/runner/sentry_utils'); describe('InviteMembersModal', () => { let wrapper; @@ -130,10 +132,10 @@ describe('InviteMembersModal', () => { const findUserLimitAlert = () => wrapper.findComponent(UserLimitNotification); const findAccordion = () => wrapper.findComponent(GlCollapse); const findErrorsIcon = () => wrapper.findComponent(GlIcon); - const findMemberErrorMessage = (element) => - `${Object.keys(invitationsApiResponse.EXPANDED_RESTRICTED.message)[element]}: ${ - Object.values(invitationsApiResponse.EXPANDED_RESTRICTED.message)[element] - }`; + const expectedErrorMessage = (index, errorType) => { + const [username, message] = Object.entries(errorType.parsedMessage)[index]; + return `${username}: ${message}`; + }; const findActionButton = () => wrapper.findByTestId('invite-modal-submit'); const findCancelButton = () => wrapper.findByTestId('invite-modal-cancel'); @@ -315,8 +317,6 @@ describe('InviteMembersModal', () => { mock.onPost(GROUPS_INVITATIONS_PATH).reply(code, data); }; - const expectedEmailRestrictedError = - "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups."; const expectedSyntaxError = 'email contains an invalid email address'; describe('when no invites have been entered in the form and then some are entered', () => { @@ -447,10 +447,8 @@ describe('InviteMembersModal', () => { }); it('displays the generic error for http server error', async () => { - mockInvitationsApi( - HTTP_STATUS_INTERNAL_SERVER_ERROR, - 'Request failed with status code 500', - ); + const SERVER_ERROR_MESSAGE = 'Request failed with status code 500'; + mockInvitationsApi(HTTP_STATUS_INTERNAL_SERVER_ERROR, SERVER_ERROR_MESSAGE); clickInviteButton(); @@ -458,17 +456,25 @@ describe('InviteMembersModal', () => { expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong'); expect(findMembersSelect().props('exceptionState')).toBe(false); + expect(captureException).toHaveBeenCalledWith({ + error: new Error(SERVER_ERROR_MESSAGE), + component: wrapper.vm.$options.name, + }); }); it('displays the restricted user api message for response with bad request', async () => { mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.EMAIL_RESTRICTED); + await triggerMembersTokenSelect([user3]); + clickInviteButton(); await waitForPromises(); expect(findMemberErrorAlert().exists()).toBe(true); - expect(findMemberErrorAlert().text()).toContain(expectedEmailRestrictedError); + expect(findMemberErrorAlert().text()).toContain( + expectedErrorMessage(0, invitationsApiResponse.EMAIL_RESTRICTED), + ); expect(membersFormGroupInvalidFeedback()).toBe(''); expect(findMembersSelect().props('exceptionState')).not.toBe(false); }); @@ -476,19 +482,21 @@ describe('InviteMembersModal', () => { it('displays all errors when there are multiple existing users that are restricted by email', async () => { mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED); + await triggerMembersTokenSelect([user3, user4, user5]); + clickInviteButton(); await waitForPromises(); expect(findMemberErrorAlert().exists()).toBe(true); expect(findMemberErrorAlert().text()).toContain( - Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[0], + expectedErrorMessage(0, invitationsApiResponse.MULTIPLE_RESTRICTED), ); expect(findMemberErrorAlert().text()).toContain( - Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[1], + expectedErrorMessage(1, invitationsApiResponse.MULTIPLE_RESTRICTED), ); expect(findMemberErrorAlert().text()).toContain( - Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[2], + expectedErrorMessage(2, invitationsApiResponse.MULTIPLE_RESTRICTED), ); expect(membersFormGroupInvalidFeedback()).toBe(''); expect(findMembersSelect().props('exceptionState')).not.toBe(false); @@ -608,7 +616,9 @@ describe('InviteMembersModal', () => { await waitForPromises(); expect(findMemberErrorAlert().exists()).toBe(true); - expect(findMemberErrorAlert().text()).toContain(expectedEmailRestrictedError); + expect(findMemberErrorAlert().text()).toContain( + expectedErrorMessage(0, invitationsApiResponse.EMAIL_RESTRICTED), + ); expect(membersFormGroupInvalidFeedback()).toBe(''); expect(findMembersSelect().props('exceptionState')).not.toBe(false); expect(findActionButton().props('loading')).toBe(false); @@ -617,19 +627,21 @@ describe('InviteMembersModal', () => { it('displays all errors when there are multiple emails that return a restricted error message', async () => { mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED); + await triggerMembersTokenSelect([user3, user4, user5]); + clickInviteButton(); await waitForPromises(); expect(findMemberErrorAlert().exists()).toBe(true); expect(findMemberErrorAlert().text()).toContain( - Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[0], + expectedErrorMessage(0, invitationsApiResponse.MULTIPLE_RESTRICTED), ); expect(findMemberErrorAlert().text()).toContain( - Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[1], + expectedErrorMessage(1, invitationsApiResponse.MULTIPLE_RESTRICTED), ); expect(findMemberErrorAlert().text()).toContain( - Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[2], + expectedErrorMessage(2, invitationsApiResponse.MULTIPLE_RESTRICTED), ); expect(membersFormGroupInvalidFeedback()).toBe(''); expect(findMembersSelect().props('exceptionState')).not.toBe(false); @@ -685,10 +697,18 @@ describe('InviteMembersModal', () => { expect(findMemberErrorAlert().props('title')).toContain( "The following 4 members couldn't be invited", ); - expect(findMemberErrorAlert().text()).toContain(findMemberErrorMessage(0)); - expect(findMemberErrorAlert().text()).toContain(findMemberErrorMessage(1)); - expect(findMemberErrorAlert().text()).toContain(findMemberErrorMessage(2)); - expect(findMemberErrorAlert().text()).toContain(findMemberErrorMessage(3)); + expect(findMemberErrorAlert().text()).toContain( + expectedErrorMessage(0, invitationsApiResponse.EXPANDED_RESTRICTED), + ); + expect(findMemberErrorAlert().text()).toContain( + expectedErrorMessage(1, invitationsApiResponse.EXPANDED_RESTRICTED), + ); + expect(findMemberErrorAlert().text()).toContain( + expectedErrorMessage(2, invitationsApiResponse.EXPANDED_RESTRICTED), + ); + expect(findMemberErrorAlert().text()).toContain( + expectedErrorMessage(3, invitationsApiResponse.EXPANDED_RESTRICTED), + ); expect(findAccordion().exists()).toBe(true); expect(findMoreInviteErrorsButton().text()).toContain('Show more (2)'); expect(findErrorsIcon().attributes('class')).not.toContain('gl-rotate-180'); @@ -711,7 +731,9 @@ describe('InviteMembersModal', () => { expect(findMemberErrorAlert().props('title')).toContain( "The following 3 members couldn't be invited", ); - expect(findMemberErrorAlert().text()).not.toContain(findMemberErrorMessage(0)); + expect(findMemberErrorAlert().text()).not.toContain( + expectedErrorMessage(0, invitationsApiResponse.EXPANDED_RESTRICTED), + ); await removeMembersToken(user6); @@ -719,14 +741,18 @@ describe('InviteMembersModal', () => { expect(findMemberErrorAlert().props('title')).toContain( "The following 2 members couldn't be invited", ); - expect(findMemberErrorAlert().text()).not.toContain(findMemberErrorMessage(2)); + expect(findMemberErrorAlert().text()).not.toContain( + expectedErrorMessage(2, invitationsApiResponse.EXPANDED_RESTRICTED), + ); await removeMembersToken(user4); expect(findMemberErrorAlert().props('title')).toContain( "The following member couldn't be invited", ); - expect(findMemberErrorAlert().text()).not.toContain(findMemberErrorMessage(1)); + expect(findMemberErrorAlert().text()).not.toContain( + expectedErrorMessage(1, invitationsApiResponse.EXPANDED_RESTRICTED), + ); await removeMembersToken(user5); diff --git a/spec/frontend/invite_members/mock_data/api_responses.js b/spec/frontend/invite_members/mock_data/api_responses.js index 4f773009f37..9190f85d7a0 100644 --- a/spec/frontend/invite_members/mock_data/api_responses.js +++ b/spec/frontend/invite_members/mock_data/api_responses.js @@ -6,36 +6,56 @@ const ERROR_EMAIL_INVALID = { error: 'email contains an invalid email address', }; +const BASE_ERROR_MEMBER_NOT_ALLOWED = `The member's email address is not allowed for this project. \ +Go to the 'Admin area > Sign-up restrictions', and check`; + +const ALLOWED_DOMAIN_ERROR = `${BASE_ERROR_MEMBER_NOT_ALLOWED} 'Allowed domains for sign-ups'.`; +const DOMAIN_DENYLIST_ERROR = `${BASE_ERROR_MEMBER_NOT_ALLOWED} the 'Domain denylist'.`; + +function htmlDecode(input) { + const doc = new DOMParser().parseFromString(input, 'text/html'); + return doc.documentElement.textContent; +} + +const DECODED_ALLOWED_DOMAIN_ERROR = htmlDecode(ALLOWED_DOMAIN_ERROR); +const DECODED_DOMAIN_DENYLIST_ERROR = htmlDecode(DOMAIN_DENYLIST_ERROR); + const EMAIL_RESTRICTED = { message: { - 'email@example.com': - "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.", + 'email@example.com': ALLOWED_DOMAIN_ERROR, + }, + parsedMessage: { + 'email@example.com': DECODED_ALLOWED_DOMAIN_ERROR, }, status: 'error', }; const MULTIPLE_RESTRICTED = { message: { - 'email@example.com': - "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.", - 'email4@example.com': - "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check the Domain denylist.", - root: - "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.", + 'email@example.com': ALLOWED_DOMAIN_ERROR, + 'email4@example.com': DOMAIN_DENYLIST_ERROR, + root: ALLOWED_DOMAIN_ERROR, + }, + parsedMessage: { + 'email@example.com': DECODED_ALLOWED_DOMAIN_ERROR, + 'email4@example.com': DECODED_DOMAIN_DENYLIST_ERROR, + root: DECODED_ALLOWED_DOMAIN_ERROR, }, status: 'error', }; const EXPANDED_RESTRICTED = { message: { - 'email@example.com': - "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.", - 'email4@example.com': - "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check the Domain denylist.", - 'email5@example.com': - "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check the Domain denylist.", - root: - "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.", + 'email@example.com': ALLOWED_DOMAIN_ERROR, + 'email4@example.com': DOMAIN_DENYLIST_ERROR, + 'email5@example.com': DOMAIN_DENYLIST_ERROR, + root: ALLOWED_DOMAIN_ERROR, + }, + parsedMessage: { + 'email@example.com': DECODED_ALLOWED_DOMAIN_ERROR, + 'email4@example.com': DECODED_DOMAIN_DENYLIST_ERROR, + 'email5@example.com': DECODED_DOMAIN_DENYLIST_ERROR, + root: DECODED_ALLOWED_DOMAIN_ERROR, }, status: 'error', }; diff --git a/spec/frontend/issuable/components/hidden_badge_spec.js b/spec/frontend/issuable/components/hidden_badge_spec.js new file mode 100644 index 00000000000..db2248bb2d2 --- /dev/null +++ b/spec/frontend/issuable/components/hidden_badge_spec.js @@ -0,0 +1,45 @@ +import { GlBadge, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import HiddenBadge from '~/issuable/components/hidden_badge.vue'; + +describe('HiddenBadge component', () => { + let wrapper; + + const mountComponent = () => { + wrapper = shallowMount(HiddenBadge, { + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + propsData: { + issuableType: 'issue', + }, + }); + }; + + const findBadge = () => wrapper.findComponent(GlBadge); + const findIcon = () => wrapper.findComponent(GlIcon); + + beforeEach(() => { + mountComponent(); + }); + + it('renders warning badge', () => { + expect(findBadge().text()).toBe('Hidden'); + expect(findBadge().props('variant')).toEqual('warning'); + }); + + it('renders spam icon', () => { + expect(findIcon().props('name')).toBe('spam'); + }); + + it('has tooltip', () => { + expect(getBinding(wrapper.element, 'gl-tooltip')).not.toBeUndefined(); + }); + + it('has title', () => { + expect(findBadge().attributes('title')).toBe( + 'This issue is hidden because its author has been banned.', + ); + }); +}); diff --git a/spec/frontend/issuable/components/locked_badge_spec.js b/spec/frontend/issuable/components/locked_badge_spec.js new file mode 100644 index 00000000000..73ab6e36ba1 --- /dev/null +++ b/spec/frontend/issuable/components/locked_badge_spec.js @@ -0,0 +1,45 @@ +import { GlBadge, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import LockedBadge from '~/issuable/components/locked_badge.vue'; + +describe('LockedBadge component', () => { + let wrapper; + + const mountComponent = () => { + wrapper = shallowMount(LockedBadge, { + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + propsData: { + issuableType: 'issue', + }, + }); + }; + + const findBadge = () => wrapper.findComponent(GlBadge); + const findIcon = () => wrapper.findComponent(GlIcon); + + beforeEach(() => { + mountComponent(); + }); + + it('renders warning badge', () => { + expect(findBadge().text()).toBe('Locked'); + expect(findBadge().props('variant')).toEqual('warning'); + }); + + it('renders lock icon', () => { + expect(findIcon().props('name')).toBe('lock'); + }); + + it('has tooltip', () => { + expect(getBinding(wrapper.element, 'gl-tooltip')).not.toBeUndefined(); + }); + + it('has title', () => { + expect(findBadge().attributes('title')).toBe( + 'This issue is locked. Only project members can comment.', + ); + }); +}); diff --git a/spec/frontend/issues/dashboard/mock_data.js b/spec/frontend/issues/dashboard/mock_data.js index 1e3abd5a018..adcd4268449 100644 --- a/spec/frontend/issues/dashboard/mock_data.js +++ b/spec/frontend/issues/dashboard/mock_data.js @@ -19,7 +19,6 @@ export const issuesQueryResponse = { reference: 'group/project#123456', state: 'opened', title: 'Issue title', - titleHtml: 'Issue title', type: 'issue', updatedAt: '2021-05-22T04:08:01Z', upvotes: 3, diff --git a/spec/frontend/issues/list/mock_data.js b/spec/frontend/issues/list/mock_data.js index 73fda11f38c..b9a8bc171db 100644 --- a/spec/frontend/issues/list/mock_data.js +++ b/spec/frontend/issues/list/mock_data.js @@ -49,7 +49,6 @@ export const getIssuesQueryResponse = { moved: false, state: 'opened', title: 'Issue title', - titleHtml: 'Issue title', updatedAt: '2021-05-22T04:08:01Z', closedAt: null, upvotes: 3, diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js index 93860aaa925..25e89db7957 100644 --- a/spec/frontend/issues/show/components/description_spec.js +++ b/spec/frontend/issues/show/components/description_spec.js @@ -69,8 +69,8 @@ describe('Description component', () => { wrapper = shallowMountExtended(Description, { apolloProvider: mockApollo, propsData: { - issueId: 1, - issueIid: 1, + issueId: '1', + issueIid: '1', ...initialProps, ...props, }, diff --git a/spec/frontend/issues/show/components/fields/description_spec.js b/spec/frontend/issues/show/components/fields/description_spec.js index 83b927d3699..e1d2809be9d 100644 --- a/spec/frontend/issues/show/components/fields/description_spec.js +++ b/spec/frontend/issues/show/components/fields/description_spec.js @@ -10,7 +10,7 @@ describe('Description field component', () => { let trackingSpy; const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor); - const mountComponent = ({ description = 'test', contentEditorOnIssues = false } = {}) => { + const mountComponent = ({ description = 'test' } = {}) => { wrapper = shallowMount(DescriptionField, { attachTo: document.body, propsData: { @@ -18,11 +18,6 @@ describe('Description field component', () => { markdownDocsPath: '/', value: description, }, - provide: { - glFeatures: { - contentEditorOnIssues, - }, - }, stubs: { MarkdownField, }, @@ -33,15 +28,7 @@ describe('Description field component', () => { trackingSpy = mockTracking(undefined, null, jest.spyOn); jest.spyOn(eventHub, '$emit'); - mountComponent({ contentEditorOnIssues: true }); - }); - - it('passes feature flag to the MarkdownEditorComponent', () => { - expect(findMarkdownEditor().props('enableContentEditor')).toBe(true); - - mountComponent({ contentEditorOnIssues: false }); - - expect(findMarkdownEditor().props('enableContentEditor')).toBe(false); + mountComponent(); }); it('uses the MarkdownEditor component to edit markdown', () => { diff --git a/spec/frontend/issues/show/components/header_actions_spec.js b/spec/frontend/issues/show/components/header_actions_spec.js index ce2161f4670..e508045eff3 100644 --- a/spec/frontend/issues/show/components/header_actions_spec.js +++ b/spec/frontend/issues/show/components/header_actions_spec.js @@ -123,7 +123,7 @@ describe('HeaderActions component', () => { const findMobileDropdownItems = () => findMobileDropdown().findAllComponents(GlDropdownItem); const findDesktopDropdownItems = () => findDesktopDropdown().findAllComponents(GlDropdownItem); const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector); - const findReportAbuseSelectorItem = () => wrapper.find(`[data-testid="report-abuse-item"]`); + const findReportAbuseButton = () => wrapper.find(`[data-testid="report-abuse-item"]`); const findNotificationWidget = () => wrapper.find(`[data-testid="notification-toggle"]`); const findLockIssueWidget = () => wrapper.find(`[data-testid="lock-issue-toggle"]`); const findCopyRefenceDropdownItem = () => wrapper.find(`[data-testid="copy-reference"]`); @@ -239,24 +239,24 @@ describe('HeaderActions component', () => { }); describe.each` - description | isCloseIssueItemVisible | findDropdownItems | findDropdown - ${'mobile dropdown'} | ${true} | ${findMobileDropdownItems} | ${findMobileDropdown} - ${'desktop dropdown'} | ${false} | ${findDesktopDropdownItems} | ${findDesktopDropdown} - `('$description', ({ isCloseIssueItemVisible, findDropdownItems, findDropdown }) => { + description | findDropdownItems + ${'mobile dropdown'} | ${findMobileDropdownItems} + ${'desktop dropdown'} | ${findDesktopDropdownItems} + `('$description', ({ findDropdownItems }) => { describe.each` - description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam | canPromoteToEpic | canDestroyIssue - ${`when user can update ${issueType}`} | ${`Close ${issueType}`} | ${isCloseIssueItemVisible} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} - ${`when user cannot update ${issueType}`} | ${`Close ${issueType}`} | ${false} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} - ${`when user can create ${issueType}`} | ${`New related ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} - ${`when user cannot create ${issueType}`} | ${`New related ${issueType}`} | ${false} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} - ${'when user can promote to epic'} | ${'Promote to epic'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} - ${'when user cannot promote to epic'} | ${'Promote to epic'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${true} - ${'when user can report abuse'} | ${'Report abuse to administrator'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} - ${'when user cannot report abuse'} | ${'Report abuse to administrator'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} - ${'when user can submit as spam'} | ${'Submit as spam'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} - ${'when user cannot submit as spam'} | ${'Submit as spam'} | ${false} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} - ${`when user can delete ${issueType}`} | ${`Delete ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} - ${`when user cannot delete ${issueType}`} | ${`Delete ${issueType}`} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} + description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam | canPromoteToEpic | canDestroyIssue + ${`when user can update ${issueType}`} | ${`Close ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${`when user cannot update ${issueType}`} | ${`Close ${issueType}`} | ${false} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} + ${`when user can create ${issueType}`} | ${`New related ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${`when user cannot create ${issueType}`} | ${`New related ${issueType}`} | ${false} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} + ${'when user can promote to epic'} | ${'Promote to epic'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${'when user cannot promote to epic'} | ${'Promote to epic'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${true} + ${'when user can report abuse'} | ${'Report abuse to administrator'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} + ${'when user cannot report abuse'} | ${'Report abuse to administrator'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${'when user can submit as spam'} | ${'Submit as spam'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${'when user cannot submit as spam'} | ${'Submit as spam'} | ${false} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} + ${`when user can delete ${issueType}`} | ${`Delete ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${`when user cannot delete ${issueType}`} | ${`Delete ${issueType}`} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} `( '$description', ({ @@ -292,24 +292,6 @@ describe('HeaderActions component', () => { }); }, ); - - describe(`when user can update but not create ${issueType}`, () => { - beforeEach(() => { - wrapper = mountComponent({ - props: { - canUpdateIssue: true, - canCreateIssue: false, - isIssueAuthor: true, - issueType, - canReportSpam: false, - canPromoteToEpic: false, - }, - }); - }); - it(`${isCloseIssueItemVisible ? 'shows' : 'hides'} the dropdown button`, () => { - expect(findDropdown().exists()).toBe(isCloseIssueItemVisible); - }); - }); }); describe(`show edit button ${issueType}`, () => { @@ -346,7 +328,7 @@ describe('HeaderActions component', () => { }); it('tracks clicking on button', () => { - findDesktopDropdownItems().at(3).vm.$emit('click'); + findDesktopDropdownItems().at(4).vm.$emit('click'); expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_dropdown', { label: 'delete_issue', @@ -490,29 +472,41 @@ describe('HeaderActions component', () => { }); }); - describe('abuse category selector', () => { + describe('report abuse to admin button', () => { beforeEach(() => { wrapper = mountComponent({ props: { isIssueAuthor: false } }); }); - it("doesn't render", () => { + it('renders the button but not the abuse category drawer', () => { + expect(findReportAbuseButton().exists()).toBe(true); expect(findAbuseCategorySelector().exists()).toEqual(false); }); - it('opens the drawer', async () => { - findReportAbuseSelectorItem().vm.$emit('click'); + it('opens the abuse category drawer', async () => { + findReportAbuseButton().vm.$emit('click'); await nextTick(); expect(findAbuseCategorySelector().props('showDrawer')).toEqual(true); }); - it('closes the drawer', async () => { - await findReportAbuseSelectorItem().vm.$emit('click'); - await findAbuseCategorySelector().vm.$emit('close-drawer'); + it('closes the abuse category drawer', async () => { + await findReportAbuseButton().vm.$emit('click'); + expect(findAbuseCategorySelector().exists()).toEqual(true); + await findAbuseCategorySelector().vm.$emit('close-drawer'); expect(findAbuseCategorySelector().exists()).toEqual(false); }); + + describe('when the logged in user is the issue author', () => { + beforeEach(() => { + wrapper = mountComponent({ props: { isIssueAuthor: true } }); + }); + + it('does not render the button', () => { + expect(findReportAbuseButton().exists()).toBe(false); + }); + }); }); describe('notification toggle', () => { @@ -694,7 +688,7 @@ describe('HeaderActions component', () => { expect(findDesktopDropdown().exists()).toBe(headerActionsVisible); expect(findCopyRefenceDropdownItem().exists()).toBe(headerActionsVisible); expect(findNotificationWidget().exists()).toBe(false); - expect(findReportAbuseSelectorItem().exists()).toBe(false); + expect(findReportAbuseButton().exists()).toBe(false); expect(findLockIssueWidget().exists()).toBe(false); }); }, @@ -720,7 +714,7 @@ describe('HeaderActions component', () => { `${capitalizeFirstCharacter(expectedText)} actions`, ); expect(findDropdownBy('copy-email').text()).toBe(`Copy ${expectedText} email address`); - expect(findDesktopDropdownItems().at(0).text()).toBe(`New related ${expectedText}`); + expect(findDesktopDropdownItems().at(1).text()).toBe(`New related ${expectedText}`); }); }); }); diff --git a/spec/frontend/issues/show/components/new_header_actions_popover_spec.js b/spec/frontend/issues/show/components/new_header_actions_popover_spec.js deleted file mode 100644 index bf3e81c7d3a..00000000000 --- a/spec/frontend/issues/show/components/new_header_actions_popover_spec.js +++ /dev/null @@ -1,77 +0,0 @@ -import { GlPopover } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import NewHeaderActionsPopover from '~/issues/show/components/new_header_actions_popover.vue'; -import { NEW_ACTIONS_POPOVER_KEY } from '~/issues/show/constants'; -import { TYPE_ISSUE } from '~/issues/constants'; -import * as utils from '~/lib/utils/common_utils'; - -describe('NewHeaderActionsPopover', () => { - let wrapper; - - const createComponent = ({ issueType = TYPE_ISSUE, movedMrSidebarEnabled = true }) => { - wrapper = shallowMountExtended(NewHeaderActionsPopover, { - propsData: { - issueType, - }, - stubs: { - GlPopover, - }, - provide: { - glFeatures: { - movedMrSidebar: movedMrSidebarEnabled, - }, - }, - }); - }; - - const findPopover = () => wrapper.findComponent(GlPopover); - const findConfirmButton = () => wrapper.findByTestId('confirm-button'); - - it('should not be visible when the feature flag :moved_mr_sidebar is disabled', () => { - createComponent({ movedMrSidebarEnabled: false }); - expect(findPopover().exists()).toBe(false); - }); - - describe('without the popover cookie', () => { - beforeEach(() => { - utils.setCookie = jest.fn(); - - createComponent({}); - }); - - it('renders the popover with correct text', () => { - expect(findPopover().exists()).toBe(true); - expect(findPopover().text()).toContain('issue actions'); - }); - - it('does not call setCookie', () => { - expect(utils.setCookie).not.toHaveBeenCalled(); - }); - - describe('when the confirm button is clicked', () => { - beforeEach(() => { - findConfirmButton().vm.$emit('click'); - }); - - it('sets the popover cookie', () => { - expect(utils.setCookie).toHaveBeenCalledWith(NEW_ACTIONS_POPOVER_KEY, true); - }); - - it('hides the popover', () => { - expect(findPopover().exists()).toBe(false); - }); - }); - }); - - describe('with the popover cookie', () => { - beforeEach(() => { - jest.spyOn(utils, 'getCookie').mockReturnValue('true'); - - createComponent({}); - }); - - it('does not render the popover', () => { - expect(findPopover().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/issues/show/components/sticky_header_spec.js b/spec/frontend/issues/show/components/sticky_header_spec.js index 0c54ae45e70..a909084956f 100644 --- a/spec/frontend/issues/show/components/sticky_header_spec.js +++ b/spec/frontend/issues/show/components/sticky_header_spec.js @@ -1,6 +1,7 @@ -import { GlIcon } from '@gitlab/ui'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { GlIcon, GlLink } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import HiddenBadge from '~/issuable/components/hidden_badge.vue'; +import LockedBadge from '~/issuable/components/locked_badge.vue'; import { issuableStatusText, STATUS_CLOSED, @@ -17,20 +18,17 @@ describe('StickyHeader component', () => { let wrapper; const findConfidentialBadge = () => wrapper.findComponent(ConfidentialityBadge); - const findHiddenBadge = () => wrapper.findByTestId('hidden'); - const findLockedBadge = () => wrapper.findByTestId('locked'); + const findHiddenBadge = () => wrapper.findComponent(HiddenBadge); + const findLockedBadge = () => wrapper.findComponent(LockedBadge); + const findTitle = () => wrapper.findComponent(GlLink); const createComponent = (props = {}) => { wrapper = shallowMountExtended(StickyHeader, { - directives: { - GlTooltip: createMockDirective('gl-tooltip'), - }, propsData: { issuableStatus: STATUS_OPEN, issuableType: TYPE_ISSUE, show: true, title: 'A sticky issue', - titleHtml: '', ...props, }, }); @@ -91,13 +89,6 @@ describe('StickyHeader component', () => { const lockedBadge = findLockedBadge(); expect(lockedBadge.exists()).toBe(isLocked); - - if (isLocked) { - expect(lockedBadge.attributes('title')).toBe( - 'This issue is locked. Only project members can comment.', - ); - expect(getBinding(lockedBadge.element, 'gl-tooltip')).not.toBeUndefined(); - } }); it.each` @@ -109,27 +100,13 @@ describe('StickyHeader component', () => { const hiddenBadge = findHiddenBadge(); expect(hiddenBadge.exists()).toBe(isHidden); - - if (isHidden) { - expect(hiddenBadge.attributes('title')).toBe( - 'This issue is hidden because its author has been banned', - ); - expect(getBinding(hiddenBadge.element, 'gl-tooltip')).not.toBeUndefined(); - } }); it('shows with title', () => { createComponent(); - const title = wrapper.find('a'); + const title = findTitle(); expect(title.text()).toContain('A sticky issue'); expect(title.attributes('href')).toBe('#top'); }); - - it('shows title containing markup', () => { - const titleHtml = '<b>A sticky issue</b>'; - createComponent({ titleHtml }); - - expect(wrapper.find('a').html()).toContain(titleHtml); - }); }); diff --git a/spec/frontend/issues/show/mock_data/mock_data.js b/spec/frontend/issues/show/mock_data/mock_data.js index 37aa18ced8d..ed969a08ac5 100644 --- a/spec/frontend/issues/show/mock_data/mock_data.js +++ b/spec/frontend/issues/show/mock_data/mock_data.js @@ -1,9 +1,8 @@ import { TEST_HOST } from 'helpers/test_constants'; export const initialRequest = { - title: '<gl-emoji title="party-parrot"></gl-emoji>this is a title', + title: '<p>this is a title</p>', title_text: 'this is a title', - title_html: '<gl-emoji title="party-parrot"></gl-emoji>this is a title', description: '<p>this is a description!</p>', description_text: 'this is a description', task_completion_status: { completed_count: 2, count: 4 }, diff --git a/spec/frontend/lib/utils/global_alerts_spec.js b/spec/frontend/lib/utils/global_alerts_spec.js new file mode 100644 index 00000000000..97fe427c281 --- /dev/null +++ b/spec/frontend/lib/utils/global_alerts_spec.js @@ -0,0 +1,80 @@ +import { + getGlobalAlerts, + setGlobalAlerts, + removeGlobalAlertById, + GLOBAL_ALERTS_SESSION_STORAGE_KEY, +} from '~/lib/utils/global_alerts'; + +describe('global alerts utils', () => { + describe('getGlobalAlerts', () => { + describe('when there are alerts', () => { + beforeEach(() => { + jest + .spyOn(Storage.prototype, 'getItem') + .mockImplementation(() => '[{"id":"foo","variant":"danger","message":"Foo"}]'); + }); + + it('returns alerts from session storage', () => { + expect(getGlobalAlerts()).toEqual([{ id: 'foo', variant: 'danger', message: 'Foo' }]); + }); + }); + + describe('when there are no alerts', () => { + beforeEach(() => { + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => null); + }); + + it('returns empty array', () => { + expect(getGlobalAlerts()).toEqual([]); + }); + }); + }); +}); + +describe('setGlobalAlerts', () => { + it('sets alerts in session storage', () => { + const setItemSpy = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {}); + + setGlobalAlerts([ + { + id: 'foo', + variant: 'danger', + message: 'Foo', + }, + { + id: 'bar', + variant: 'success', + message: 'Bar', + persistOnPages: ['dashboard:groups:index'], + dismissible: false, + }, + ]); + + expect(setItemSpy).toHaveBeenCalledWith( + GLOBAL_ALERTS_SESSION_STORAGE_KEY, + '[{"dismissible":true,"persistOnPages":[],"id":"foo","variant":"danger","message":"Foo"},{"dismissible":false,"persistOnPages":["dashboard:groups:index"],"id":"bar","variant":"success","message":"Bar"}]', + ); + }); +}); + +describe('removeGlobalAlertById', () => { + beforeEach(() => { + jest + .spyOn(Storage.prototype, 'getItem') + .mockImplementation( + () => + '[{"id":"foo","variant":"success","message":"Foo"},{"id":"bar","variant":"danger","message":"Bar"}]', + ); + }); + + it('removes alert', () => { + const setItemSpy = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {}); + + removeGlobalAlertById('bar'); + + expect(setItemSpy).toHaveBeenCalledWith( + GLOBAL_ALERTS_SESSION_STORAGE_KEY, + '[{"id":"foo","variant":"success","message":"Foo"}]', + ); + }); +}); diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index ecd2d7f888d..3a846bbda06 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -1,8 +1,20 @@ import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import * as urlUtils from '~/lib/utils/url_utility'; +import { setGlobalAlerts } from '~/lib/utils/global_alerts'; import { safeUrls, unsafeUrls } from './mock_data'; +jest.mock('~/lib/utils/global_alerts', () => ({ + getGlobalAlerts: jest.fn().mockImplementation(() => [ + { + id: 'foo', + message: 'Foo', + variant: 'success', + }, + ]), + setGlobalAlerts: jest.fn(), +})); + const shas = { valid: [ 'ad9be38573f9ee4c4daec22673478c2dd1d81cd8', @@ -327,6 +339,26 @@ describe('URL utility', () => { }); }); + describe('getLocationHash', () => { + it('gets a default empty value', () => { + setWindowLocation(TEST_HOST); + + expect(urlUtils.getLocationHash()).toBeUndefined(); + }); + + it('gets a value', () => { + setWindowLocation('#hash-value'); + + expect(urlUtils.getLocationHash()).toBe('hash-value'); + }); + + it('gets an empty value when only hash is set', () => { + setWindowLocation('#'); + + expect(urlUtils.getLocationHash()).toBeUndefined(); + }); + }); + describe('doesHashExistInUrl', () => { beforeEach(() => { setWindowLocation('#note_1'); @@ -462,6 +494,48 @@ describe('URL utility', () => { }); }); + describe('visitUrlWithAlerts', () => { + let originalLocation; + + beforeAll(() => { + originalLocation = window.location; + + Object.defineProperty(window, 'location', { + writable: true, + value: { + assign: jest.fn(), + protocol: 'http:', + host: TEST_HOST, + }, + }); + }); + + afterAll(() => { + window.location = originalLocation; + }); + + it('sets alerts and then visits url', () => { + const url = '/foo/bar'; + const alert = { + id: 'bar', + message: 'Bar', + variant: 'danger', + }; + + urlUtils.visitUrlWithAlerts(url, [alert]); + + expect(setGlobalAlerts).toHaveBeenCalledWith([ + { + id: 'foo', + message: 'Foo', + variant: 'success', + }, + alert, + ]); + expect(window.location.assign).toHaveBeenCalledWith(url); + }); + }); + describe('updateHistory', () => { const state = { key: 'prop' }; const title = 'TITLE'; diff --git a/spec/frontend/merge_requests/components/header_metadata_spec.js b/spec/frontend/merge_requests/components/header_metadata_spec.js deleted file mode 100644 index 2823b4b9d97..00000000000 --- a/spec/frontend/merge_requests/components/header_metadata_spec.js +++ /dev/null @@ -1,93 +0,0 @@ -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import HeaderMetadata from '~/merge_requests/components/header_metadata.vue'; -import mrStore from '~/mr_notes/stores'; -import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; - -jest.mock('~/mr_notes/stores', () => jest.requireActual('helpers/mocks/mr_notes/stores')); - -describe('HeaderMetadata component', () => { - let wrapper; - - const findConfidentialIcon = () => wrapper.findComponent(ConfidentialityBadge); - const findLockedIcon = () => wrapper.findByTestId('locked'); - const findHiddenIcon = () => wrapper.findByTestId('hidden'); - - const renderTestMessage = (renders) => (renders ? 'renders' : 'does not render'); - - const createComponent = ({ store, provide }) => { - wrapper = shallowMountExtended(HeaderMetadata, { - mocks: { - $store: store, - }, - provide, - directives: { - GlTooltip: createMockDirective('gl-tooltip'), - }, - }); - }; - - describe.each` - lockStatus | confidentialStatus | hiddenStatus - ${true} | ${true} | ${false} - ${true} | ${false} | ${false} - ${false} | ${true} | ${false} - ${false} | ${false} | ${false} - ${true} | ${true} | ${true} - ${true} | ${false} | ${true} - ${false} | ${true} | ${true} - ${false} | ${false} | ${true} - `( - `when locked=$lockStatus, confidential=$confidentialStatus, and hidden=$hiddenStatus`, - ({ lockStatus, confidentialStatus, hiddenStatus }) => { - const store = mrStore; - - beforeEach(() => { - store.getters.getNoteableData = {}; - store.getters.getNoteableData.confidential = confidentialStatus; - store.getters.getNoteableData.discussion_locked = lockStatus; - store.getters.getNoteableData.targetType = 'merge_request'; - - createComponent({ store, provide: { hidden: hiddenStatus } }); - }); - - it(`${renderTestMessage(lockStatus)} the locked icon`, () => { - const lockedIcon = findLockedIcon(); - - expect(lockedIcon.exists()).toBe(lockStatus); - - if (lockStatus) { - expect(lockedIcon.attributes('title')).toBe( - `This merge request is locked. Only project members can comment.`, - ); - expect(getBinding(lockedIcon.element, 'gl-tooltip')).not.toBeUndefined(); - } - }); - - it(`${renderTestMessage(confidentialStatus)} the confidential icon`, () => { - const confidentialIcon = findConfidentialIcon(); - expect(confidentialIcon.exists()).toBe(confidentialStatus); - - if (confidentialStatus && !hiddenStatus) { - expect(confidentialIcon.props()).toMatchObject({ - workspaceType: 'project', - issuableType: 'issue', - }); - } - }); - - it(`${renderTestMessage(confidentialStatus)} the hidden icon`, () => { - const hiddenIcon = findHiddenIcon(); - - expect(hiddenIcon.exists()).toBe(hiddenStatus); - - if (hiddenStatus) { - expect(hiddenIcon.attributes('title')).toBe( - `This merge request is hidden because its author has been banned`, - ); - expect(getBinding(hiddenIcon.element, 'gl-tooltip')).not.toBeUndefined(); - } - }); - }, - ); -}); diff --git a/spec/frontend/merge_requests/components/merge_request_header_spec.js b/spec/frontend/merge_requests/components/merge_request_header_spec.js new file mode 100644 index 00000000000..3f774098379 --- /dev/null +++ b/spec/frontend/merge_requests/components/merge_request_header_spec.js @@ -0,0 +1,88 @@ +import { shallowMount } from '@vue/test-utils'; +import HiddenBadge from '~/issuable/components/hidden_badge.vue'; +import LockedBadge from '~/issuable/components/locked_badge.vue'; +import StatusBadge from '~/issuable/components/status_badge.vue'; +import MergeRequestHeader from '~/merge_requests/components/merge_request_header.vue'; +import mrStore from '~/mr_notes/stores'; +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; + +jest.mock('~/mr_notes/stores', () => jest.requireActual('helpers/mocks/mr_notes/stores')); + +describe('MergeRequestHeader component', () => { + let wrapper; + + const findConfidentialBadge = () => wrapper.findComponent(ConfidentialityBadge); + const findLockedBadge = () => wrapper.findComponent(LockedBadge); + const findHiddenBadge = () => wrapper.findComponent(HiddenBadge); + const findStatusBadge = () => wrapper.findComponent(StatusBadge); + + const renderTestMessage = (renders) => (renders ? 'renders' : 'does not render'); + + const createComponent = ({ confidential, hidden, locked }) => { + const store = mrStore; + store.getters.getNoteableData = {}; + store.getters.getNoteableData.confidential = confidential; + store.getters.getNoteableData.discussion_locked = locked; + store.getters.getNoteableData.targetType = 'merge_request'; + + wrapper = shallowMount(MergeRequestHeader, { + mocks: { + $store: store, + }, + provide: { + hidden, + }, + propsData: { + initialState: 'opened', + }, + }); + }; + + it('renders status badge', () => { + createComponent({ propsData: { initialState: 'opened' } }); + + expect(findStatusBadge().props()).toEqual({ + issuableType: 'merge_request', + state: 'opened', + }); + }); + + describe.each` + locked | confidential | hidden + ${true} | ${true} | ${false} + ${true} | ${false} | ${false} + ${false} | ${true} | ${false} + ${false} | ${false} | ${false} + ${true} | ${true} | ${true} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + ${false} | ${false} | ${true} + `( + `when locked=$locked, confidential=$confidential, and hidden=$hidden`, + ({ locked, confidential, hidden }) => { + beforeEach(() => { + createComponent({ confidential, hidden, locked }); + }); + + it(`${renderTestMessage(confidential)} the confidential badge`, () => { + const confidentialBadge = findConfidentialBadge(); + expect(confidentialBadge.exists()).toBe(confidential); + + if (confidential && !hidden) { + expect(confidentialBadge.props()).toMatchObject({ + workspaceType: 'project', + issuableType: 'issue', + }); + } + }); + + it(`${renderTestMessage(locked)} the locked badge`, () => { + expect(findLockedBadge().exists()).toBe(locked); + }); + + it(`${renderTestMessage(hidden)} the hidden badge`, () => { + expect(findHiddenBadge().exists()).toBe(hidden); + }); + }, + ); +}); diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js index 53dbd796d85..cd252560590 100644 --- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js +++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js @@ -2,15 +2,14 @@ import { shallowMount } from '@vue/test-utils'; import DetailRow from '~/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue'; describe('CandidateDetailRow', () => { - const SECTION_LABEL_CELL = 0; - const ROW_LABEL_CELL = 1; - const ROW_VALUE_CELL = 2; + const ROW_LABEL_CELL = 0; + const ROW_VALUE_CELL = 1; let wrapper; const createWrapper = ({ slots = {} } = {}) => { wrapper = shallowMount(DetailRow, { - propsData: { sectionLabel: 'Section', label: 'Item' }, + propsData: { label: 'Item' }, slots, }); }; @@ -19,10 +18,6 @@ describe('CandidateDetailRow', () => { beforeEach(() => createWrapper()); - it('renders section label', () => { - expect(findCellAt(SECTION_LABEL_CELL).text()).toBe('Section'); - }); - it('renders row label', () => { expect(findCellAt(ROW_LABEL_CELL).text()).toBe('Item'); }); diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js index 0b3b780cb3f..296728af46a 100644 --- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js +++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js @@ -1,32 +1,51 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlAvatarLabeled, GlLink } from '@gitlab/ui'; +import { GlAvatarLabeled, GlLink, GlTableLite } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import MlCandidatesShow from '~/ml/experiment_tracking/routes/candidates/show'; import DetailRow from '~/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue'; -import { TITLE_LABEL } from '~/ml/experiment_tracking/routes/candidates/show/translations'; +import { + TITLE_LABEL, + NO_PARAMETERS_MESSAGE, + NO_METRICS_MESSAGE, + NO_METADATA_MESSAGE, + NO_CI_MESSAGE, +} from '~/ml/experiment_tracking/routes/candidates/show/translations'; import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue'; +import { stubComponent } from 'helpers/stub_component'; import { newCandidate } from './mock_data'; describe('MlCandidatesShow', () => { let wrapper; const CANDIDATE = newCandidate(); - const USER_ROW = 6; + const USER_ROW = 1; + + const INFO_SECTION = 0; + const CI_SECTION = 1; + const PARAMETER_SECTION = 2; + const METADATA_SECTION = 3; const createWrapper = (createCandidate = () => CANDIDATE) => { - wrapper = shallowMount(MlCandidatesShow, { + wrapper = shallowMountExtended(MlCandidatesShow, { propsData: { candidate: createCandidate() }, + stubs: { + GlTableLite: { ...stubComponent(GlTableLite), props: ['items', 'fields'] }, + }, }); }; const findDeleteButton = () => wrapper.findComponent(DeleteButton); const findHeader = () => wrapper.findComponent(ModelExperimentsHeader); - const findNthDetailRow = (index) => wrapper.findAllComponents(DetailRow).at(index); - const findLinkInNthDetailRow = (index) => findNthDetailRow(index).findComponent(GlLink); - const findSectionLabel = (label) => wrapper.find(`[sectionLabel='${label}']`); + const findSection = (section) => wrapper.findAll('section').at(section); + const findRowInSection = (section, row) => + findSection(section).findAllComponents(DetailRow).at(row); + const findLinkAtRow = (section, rowIndex) => + findRowInSection(section, rowIndex).findComponent(GlLink); + const findNoDataMessage = (label) => wrapper.findByText(label); const findLabel = (label) => wrapper.find(`[label='${label}']`); - const findCiUserDetailRow = () => findNthDetailRow(USER_ROW); + const findCiUserDetailRow = () => findRowInSection(CI_SECTION, USER_ROW); const findCiUserAvatar = () => findCiUserDetailRow().findComponent(GlAvatarLabeled); const findCiUserAvatarNameLink = () => findCiUserAvatar().findComponent(GlLink); + const findMetricsTable = () => wrapper.findComponent(GlTableLite); describe('Header', () => { beforeEach(() => createWrapper()); @@ -50,42 +69,57 @@ describe('MlCandidatesShow', () => { const mrText = `!${CANDIDATE.info.ci_job.merge_request.iid} ${CANDIDATE.info.ci_job.merge_request.title}`; const expectedTable = [ - ['Info', 'ID', CANDIDATE.info.iid], - ['', 'MLflow run ID', CANDIDATE.info.eid], - ['', 'Status', CANDIDATE.info.status], - ['', 'Experiment', CANDIDATE.info.experiment_name], - ['', 'Artifacts', 'Artifacts'], - ['CI', 'Job', CANDIDATE.info.ci_job.name], - ['', 'Triggered by', 'CI User'], - ['', 'Merge request', mrText], - ['Parameters', CANDIDATE.params[0].name, CANDIDATE.params[0].value], - ['', CANDIDATE.params[1].name, CANDIDATE.params[1].value], - ['Metrics', CANDIDATE.metrics[0].name, CANDIDATE.metrics[0].value], - ['', CANDIDATE.metrics[1].name, CANDIDATE.metrics[1].value], - ['Metadata', CANDIDATE.metadata[0].name, CANDIDATE.metadata[0].value], - ['', CANDIDATE.metadata[1].name, CANDIDATE.metadata[1].value], - ].map((row, index) => [index, ...row]); - - it.each(expectedTable)( - 'row %s is created correctly', - (rowIndex, sectionLabel, label, text) => { - const row = findNthDetailRow(rowIndex); - - expect(row.props()).toMatchObject({ sectionLabel, label }); - expect(row.text()).toBe(text); - }, - ); + [INFO_SECTION, 0, 'ID', CANDIDATE.info.iid], + [INFO_SECTION, 1, 'MLflow run ID', CANDIDATE.info.eid], + [INFO_SECTION, 2, 'Status', CANDIDATE.info.status], + [INFO_SECTION, 3, 'Experiment', CANDIDATE.info.experiment_name], + [INFO_SECTION, 4, 'Artifacts', 'Artifacts'], + [CI_SECTION, 0, 'Job', CANDIDATE.info.ci_job.name], + [CI_SECTION, 1, 'Triggered by', 'CI User'], + [CI_SECTION, 2, 'Merge request', mrText], + [PARAMETER_SECTION, 0, CANDIDATE.params[0].name, CANDIDATE.params[0].value], + [PARAMETER_SECTION, 1, CANDIDATE.params[1].name, CANDIDATE.params[1].value], + [METADATA_SECTION, 0, CANDIDATE.metadata[0].name, CANDIDATE.metadata[0].value], + [METADATA_SECTION, 1, CANDIDATE.metadata[1].name, CANDIDATE.metadata[1].value], + ]; + + it.each(expectedTable)('row %s is created correctly', (section, rowIndex, label, text) => { + const row = findRowInSection(section, rowIndex); + + expect(row.props()).toMatchObject({ label }); + expect(row.text()).toBe(text); + }); describe('Table links', () => { const linkRows = [ - [3, CANDIDATE.info.path_to_experiment], - [4, CANDIDATE.info.path_to_artifact], - [5, CANDIDATE.info.ci_job.path], - [7, CANDIDATE.info.ci_job.merge_request.path], + [INFO_SECTION, 3, CANDIDATE.info.path_to_experiment], + [INFO_SECTION, 4, CANDIDATE.info.path_to_artifact], + [CI_SECTION, 0, CANDIDATE.info.ci_job.path], + [CI_SECTION, 2, CANDIDATE.info.ci_job.merge_request.path], ]; - it.each(linkRows)('row %s is created correctly', (rowIndex, href) => { - expect(findLinkInNthDetailRow(rowIndex).attributes().href).toBe(href); + it.each(linkRows)('row %s is created correctly', (section, rowIndex, href) => { + expect(findLinkAtRow(section, rowIndex).attributes().href).toBe(href); + }); + }); + + describe('Metrics table', () => { + it('computes metrics table items correctly', () => { + expect(findMetricsTable().props('items')).toEqual([ + { name: 'AUC', 0: '.55' }, + { name: 'Accuracy', 1: '.99', 2: '.98', 3: '.97' }, + { name: 'F1', 3: '.1' }, + ]); + }); + + it('computes metrics table fields correctly', () => { + expect(findMetricsTable().props('fields')).toEqual([ + expect.objectContaining({ key: 'name', label: 'Metric' }), + expect.objectContaining({ key: '0', label: 'Step 0' }), + expect.objectContaining({ key: '1', label: 'Step 1' }), + expect.objectContaining({ key: '2', label: 'Step 2' }), + expect.objectContaining({ key: '3', label: 'Step 3' }), + ]); }); }); @@ -105,22 +139,6 @@ describe('MlCandidatesShow', () => { expect(nameLink.text()).toEqual('CI User'); }); }); - - it('does not render params', () => { - expect(findSectionLabel('Parameters').exists()).toBe(true); - }); - - it('renders all conditional rows', () => { - // This is a bit of a duplicated test from the above table test, but having this makes sure that the - // tests that test the negatives are implemented correctly - expect(findLabel('Artifacts').exists()).toBe(true); - expect(findSectionLabel('Parameters').exists()).toBe(true); - expect(findSectionLabel('Metadata').exists()).toBe(true); - expect(findSectionLabel('Metrics').exists()).toBe(true); - expect(findSectionLabel('CI').exists()).toBe(true); - expect(findLabel('Merge request').exists()).toBe(true); - expect(findLabel('Triggered by').exists()).toBe(true); - }); }); describe('No artifact path', () => { @@ -150,19 +168,19 @@ describe('MlCandidatesShow', () => { ); it('does not render params', () => { - expect(findSectionLabel('Parameters').exists()).toBe(false); + expect(findNoDataMessage(NO_PARAMETERS_MESSAGE).exists()).toBe(true); }); it('does not render metadata', () => { - expect(findSectionLabel('Metadata').exists()).toBe(false); + expect(findNoDataMessage(NO_METADATA_MESSAGE).exists()).toBe(true); }); it('does not render metrics', () => { - expect(findSectionLabel('Metrics').exists()).toBe(false); + expect(findNoDataMessage(NO_METRICS_MESSAGE).exists()).toBe(true); }); it('does not render CI info', () => { - expect(findSectionLabel('CI').exists()).toBe(false); + expect(findNoDataMessage(NO_CI_MESSAGE).exists()).toBe(true); }); }); diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js index 3fbcf122997..4ea23ed2513 100644 --- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js +++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js @@ -4,8 +4,11 @@ export const newCandidate = () => ({ { name: 'MaxDepth', value: '3' }, ], metrics: [ - { name: 'AUC', value: '.55' }, - { name: 'Accuracy', value: '.99' }, + { name: 'AUC', value: '.55', step: 0 }, + { name: 'Accuracy', value: '.99', step: 1 }, + { name: 'Accuracy', value: '.98', step: 2 }, + { name: 'Accuracy', value: '.97', step: 3 }, + { name: 'F1', value: '.1', step: 3 }, ], metadata: [ { name: 'FileName', value: 'test.py' }, diff --git a/spec/frontend/ml/model_registry/apps/show_ml_model_spec.js b/spec/frontend/ml/model_registry/apps/show_ml_model_spec.js new file mode 100644 index 00000000000..57a5a5f003f --- /dev/null +++ b/spec/frontend/ml/model_registry/apps/show_ml_model_spec.js @@ -0,0 +1,15 @@ +import { shallowMount } from '@vue/test-utils'; +import { ShowMlModel } from '~/ml/model_registry/apps'; +import { MODEL } from '../mock_data'; + +let wrapper; +const createWrapper = () => { + wrapper = shallowMount(ShowMlModel, { propsData: { model: MODEL } }); +}; + +describe('ShowMlModel', () => { + beforeEach(() => createWrapper()); + it('renders the app', () => { + expect(wrapper.text()).toContain(MODEL.name); + }); +}); diff --git a/spec/frontend/ml/model_registry/mock_data.js b/spec/frontend/ml/model_registry/mock_data.js new file mode 100644 index 00000000000..18b2b32e069 --- /dev/null +++ b/spec/frontend/ml/model_registry/mock_data.js @@ -0,0 +1 @@ +export const MODEL = { name: 'blah' }; diff --git a/spec/frontend/ml/model_registry/routes/models/index/components/ml_models_index_spec.js b/spec/frontend/ml/model_registry/routes/models/index/components/ml_models_index_spec.js index d1715ccd8f1..c1b9aef9634 100644 --- a/spec/frontend/ml/model_registry/routes/models/index/components/ml_models_index_spec.js +++ b/spec/frontend/ml/model_registry/routes/models/index/components/ml_models_index_spec.js @@ -1,39 +1,63 @@ -import { GlLink } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import MlModelsIndexApp from '~/ml/model_registry/routes/models/index'; -import { TITLE_LABEL } from '~/ml/model_registry/routes/models/index/translations'; -import { mockModels } from './mock_data'; +import ModelRow from '~/ml/model_registry/routes/models/index/components/model_row.vue'; +import { TITLE_LABEL, NO_MODELS_LABEL } from '~/ml/model_registry/routes/models/index/translations'; +import Pagination from '~/vue_shared/components/incubation/pagination.vue'; +import { mockModels, startCursor, defaultPageInfo } from './mock_data'; let wrapper; -const createWrapper = (models = mockModels) => { - wrapper = shallowMountExtended(MlModelsIndexApp, { - propsData: { models }, - }); +const createWrapper = (propsData = { models: mockModels, pageInfo: defaultPageInfo }) => { + wrapper = shallowMountExtended(MlModelsIndexApp, { propsData }); }; -const findModelLink = (index) => wrapper.findAllComponents(GlLink).at(index); -const modelLinkText = (index) => findModelLink(index).text(); -const modelLinkHref = (index) => findModelLink(index).attributes('href'); +const findModelRow = (index) => wrapper.findAllComponents(ModelRow).at(index); +const findPagination = () => wrapper.findComponent(Pagination); const findTitle = () => wrapper.findByText(TITLE_LABEL); +const findEmptyLabel = () => wrapper.findByText(NO_MODELS_LABEL); describe('MlModelsIndex', () => { - beforeEach(() => { - createWrapper(); - }); + describe('empty state', () => { + beforeEach(() => createWrapper({ models: [], pageInfo: defaultPageInfo })); + + it('displays empty state when no experiment', () => { + expect(findEmptyLabel().exists()).toBe(true); + }); - describe('header', () => { - it('displays the title', () => { - expect(findTitle().exists()).toBe(true); + it('does not show pagination', () => { + expect(findPagination().exists()).toBe(false); }); }); - describe('model list', () => { - it('displays the models', () => { - expect(modelLinkHref(0)).toBe(mockModels[0].path); - expect(modelLinkText(0)).toBe(`${mockModels[0].name} / ${mockModels[0].version}`); + describe('with data', () => { + beforeEach(() => { + createWrapper(); + }); + + it('does not show empty state', () => { + expect(findEmptyLabel().exists()).toBe(false); + }); + + describe('header', () => { + it('displays the title', () => { + expect(findTitle().exists()).toBe(true); + }); + }); + + describe('model list', () => { + it('displays the models', () => { + expect(findModelRow(0).props('model')).toMatchObject(mockModels[0]); + expect(findModelRow(1).props('model')).toMatchObject(mockModels[1]); + }); + }); + + describe('pagination', () => { + it('should show', () => { + expect(findPagination().exists()).toBe(true); + }); - expect(modelLinkHref(1)).toBe(mockModels[1].path); - expect(modelLinkText(1)).toBe(`${mockModels[1].name} / ${mockModels[1].version}`); + it('passes pagination to pagination component', () => { + expect(findPagination().props('startCursor')).toBe(startCursor); + }); }); }); }); diff --git a/spec/frontend/ml/model_registry/routes/models/index/components/mock_data.js b/spec/frontend/ml/model_registry/routes/models/index/components/mock_data.js index b8a999abbbd..841a543606f 100644 --- a/spec/frontend/ml/model_registry/routes/models/index/components/mock_data.js +++ b/spec/frontend/ml/model_registry/routes/models/index/components/mock_data.js @@ -3,10 +3,27 @@ export const mockModels = [ name: 'model_1', version: '1.0', path: 'path/to/model_1', + versionCount: 3, }, { name: 'model_2', - version: '1.0', + version: '1.1', path: 'path/to/model_2', + versionCount: 1, }, ]; + +export const modelWithoutVersion = { + name: 'model_without_version', + path: 'path/to/model_without_version', + versionCount: 0, +}; + +export const startCursor = 'eyJpZCI6IjE2In0'; + +export const defaultPageInfo = Object.freeze({ + startCursor, + endCursor: 'eyJpZCI6IjIifQ', + hasNextPage: true, + hasPreviousPage: true, +}); diff --git a/spec/frontend/ml/model_registry/routes/models/index/components/model_row_spec.js b/spec/frontend/ml/model_registry/routes/models/index/components/model_row_spec.js new file mode 100644 index 00000000000..7600288f560 --- /dev/null +++ b/spec/frontend/ml/model_registry/routes/models/index/components/model_row_spec.js @@ -0,0 +1,42 @@ +import { GlLink } from '@gitlab/ui'; +import { + mockModels, + modelWithoutVersion, +} from 'jest/ml/model_registry/routes/models/index/components/mock_data'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ModelRow from '~/ml/model_registry/routes/models/index/components/model_row.vue'; + +let wrapper; +const createWrapper = (model = mockModels[0]) => { + wrapper = shallowMountExtended(ModelRow, { propsData: { model } }); +}; + +const findLink = () => wrapper.findComponent(GlLink); +const findMessage = (message) => wrapper.findByText(message); + +describe('ModelRow', () => { + beforeEach(() => { + createWrapper(); + }); + + it('Has a link to the model', () => { + expect(findLink().text()).toBe(mockModels[0].name); + expect(findLink().attributes('href')).toBe(mockModels[0].path); + }); + + it('Shows the latest version and the version count', () => { + expect(findMessage('1.0 · 3 versions').exists()).toBe(true); + }); + + it('Shows the latest version and no version count if it has only 1 version', () => { + createWrapper(mockModels[1]); + + expect(findMessage('1.1 · No other versions').exists()).toBe(true); + }); + + it('Shows no version message if model has no versions', () => { + createWrapper(modelWithoutVersion); + + expect(findMessage('No registered versions').exists()).toBe(true); + }); +}); diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 9b1678c0a8a..1309fd79c14 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -5,6 +5,7 @@ import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; // eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; +import waitForPromises from 'helpers/wait_for_promises'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import batchComments from '~/batch_comments/stores/modules/batch_comments'; @@ -34,7 +35,6 @@ describe('issue_comment_form component', () => { useLocalStorageSpy(); let trackingSpy; - let store; let wrapper; let axiosMock; @@ -48,21 +48,7 @@ describe('issue_comment_form component', () => { const findCommentButton = () => findCommentTypeDropdown().find('button'); const findErrorAlerts = () => wrapper.findAllComponents(GlAlert).wrappers; - async function clickCommentButton({ waitForComponent = true, waitForNetwork = true } = {}) { - findCommentButton().trigger('click'); - - if (waitForComponent || waitForNetwork) { - // Wait for the click to bubble out and trigger the handler - await nextTick(); - - if (waitForNetwork) { - // Wait for the network request promise to resolve - await nextTick(); - } - } - } - - function createStore({ actions = {} } = {}) { + const createStore = ({ actions = {}, state = {} } = {}) => { const baseModule = notesModule(); return new Vuex.Store({ @@ -71,8 +57,12 @@ describe('issue_comment_form component', () => { ...baseModule.actions, ...actions, }, + state: { + ...baseModule.state, + ...state, + }, }); - } + }; const createNotableDataMock = (data = {}) => { return { @@ -105,6 +95,7 @@ describe('issue_comment_form component', () => { userData = userDataMock, features = {}, mountFunction = shallowMount, + store = createStore(), } = {}) => { store.dispatch('setNoteableData', noteableData); store.dispatch('setNotesData', notesData); @@ -139,7 +130,6 @@ describe('issue_comment_form component', () => { beforeEach(() => { axiosMock = new MockAdapter(axios); - store = createStore(); trackingSpy = mockTracking(undefined, null, jest.spyOn); }); @@ -149,25 +139,32 @@ describe('issue_comment_form component', () => { describe('user is logged in', () => { describe('handleSave', () => { - it('should request to save note when note is entered', () => { - mountComponent({ mountFunction: mount, initialData: { note: 'hello world' } }); - - jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); - - findCloseReopenButton().trigger('click'); + const note = 'hello world'; - expect(wrapper.vm.isSubmitting).toBe(true); - expect(wrapper.vm.note).toBe(''); - expect(wrapper.vm.saveNote).toHaveBeenCalled(); + it('should request to save note when note is entered', async () => { + const saveNoteSpy = jest.fn(); + const store = createStore({ + actions: { + saveNote: saveNoteSpy, + }, + }); + mountComponent({ mountFunction: mount, initialData: { note }, store }); + expect(findCloseReopenButton().props('disabled')).toBe(false); + expect(findMarkdownEditor().props('value')).toBe(note); + await findCloseReopenButton().trigger('click'); + expect(findCloseReopenButton().props('disabled')).toBe(true); + expect(findMarkdownEditor().props('value')).toBe(''); + expect(saveNoteSpy).toHaveBeenCalled(); }); - it('tracks event', () => { - mountComponent({ mountFunction: mount, initialData: { note: 'hello world' } }); - - jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); - - findCloseReopenButton().trigger('click'); - + it('tracks event', async () => { + const store = createStore({ + actions: { + saveNote: jest.fn().mockResolvedValue(), + }, + }); + mountComponent({ mountFunction: mount, initialData: { note }, store }); + await findCloseReopenButton().trigger('click'); expect(trackingSpy).toHaveBeenCalledWith(undefined, 'save_markdown', { label: 'markdown_editor', property: 'Issue_comment', @@ -175,12 +172,13 @@ describe('issue_comment_form component', () => { }); it('does not report errors in the UI when the save succeeds', async () => { - mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' } }); - - jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); - - await clickCommentButton(); - + const store = createStore({ + actions: { + saveNote: jest.fn().mockResolvedValue(), + }, + }); + mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' }, store }); + await findCommentButton().trigger('click'); // findErrorAlerts().exists returns false if *any* wrapper is empty, // not necessarily that there aren't any at all. // We want to check here that there are none found, so we use the @@ -197,20 +195,17 @@ describe('issue_comment_form component', () => { `( 'displays the correct errors ($errors) for a $httpStatus network response', async ({ errors, httpStatus }) => { - store = createStore({ + const store = createStore({ actions: { saveNote: jest.fn().mockRejectedValue({ response: { status: httpStatus, data: { errors: { commands_only: errors } } }, }), }, }); - - mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' } }); - - await clickCommentButton(); - + mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' }, store }); + await findCommentButton().trigger('click'); + await waitForPromises(); const errorAlerts = findErrorAlerts(); - expect(errorAlerts.length).toBe(errors.length); errors.forEach((msg, index) => { const alert = errorAlerts[index]; @@ -222,7 +217,7 @@ describe('issue_comment_form component', () => { describe('if response contains validation errors', () => { beforeEach(() => { - store = createStore({ + const store = createStore({ actions: { saveNote: jest.fn().mockRejectedValue({ response: { @@ -233,9 +228,9 @@ describe('issue_comment_form component', () => { }, }); - mountComponent({ mountFunction: mount, initialData: { note: 'invalid note' } }); + mountComponent({ mountFunction: mount, initialData: { note: 'invalid note' }, store }); - clickCommentButton(); + findCommentButton().trigger('click'); }); it('renders an error message', () => { @@ -251,7 +246,7 @@ describe('issue_comment_form component', () => { it('should remove the correct error from the list when it is dismissed', async () => { const commandErrors = ['1', '2', '3']; - store = createStore({ + const store = createStore({ actions: { saveNote: jest.fn().mockRejectedValue({ response: { @@ -261,10 +256,9 @@ describe('issue_comment_form component', () => { }), }, }); - - mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' } }); - - await clickCommentButton(); + mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' }, store }); + await findCommentButton().trigger('click'); + await waitForPromises(); let errorAlerts = findErrorAlerts(); @@ -314,15 +308,8 @@ describe('issue_comment_form component', () => { }); }); - it('hides content editor switcher if feature flag content_editor_on_issues is off', () => { - mountComponent({ mountFunction: mount, features: { contentEditorOnIssues: false } }); - - expect(wrapper.text()).not.toContain('Switch to rich text editing'); - }); - - it('shows content editor switcher if feature flag content_editor_on_issues is on', () => { - mountComponent({ mountFunction: mount, features: { contentEditorOnIssues: true } }); - + it('shows content editor switcher', () => { + mountComponent({ mountFunction: mount }); expect(wrapper.text()).toContain('Switch to rich text editing'); }); @@ -335,11 +322,8 @@ describe('issue_comment_form component', () => { `( 'should render textarea with placeholder for $noteType', async ({ noteIsInternal, placeholder }) => { - mountComponent(); - - wrapper.vm.noteIsInternal = noteIsInternal; - await nextTick(); - + await mountComponent(); + await findConfidentialNoteCheckbox().vm.$emit('input', noteIsInternal); expect(findMarkdownEditor().props('formFieldProps').placeholder).toBe(placeholder); }, ); @@ -371,25 +355,20 @@ describe('issue_comment_form component', () => { expect(wrapper.find(`[href="${markdownDocsPath}"]`).exists()).toBe(true); }); - it('should resize textarea after note discarded', async () => { - mountComponent({ mountFunction: mount, initialData: { note: 'foo' } }); - - jest.spyOn(wrapper.vm, 'discard'); - - wrapper.vm.discard(); - - await nextTick(); - + it('should resize textarea after note is saved', async () => { + const store = createStore(); + store.registerModule('batchComments', batchComments()); + store.state.batchComments.drafts = [{ note: 'A' }]; + await mountComponent({ mountFunction: mount, initialData: { note: 'foo' }, store }); + await findAddCommentNowButton().trigger('click'); + await waitForPromises(); expect(Autosize.update).toHaveBeenCalled(); }); }); describe('edit mode', () => { - beforeEach(() => { - mountComponent({ mountFunction: mount }); - }); - it('should enter edit mode when arrow up is pressed', () => { + mountComponent({ mountFunction: mount }); jest.spyOn(wrapper.vm, 'editCurrentUserLastNote'); findMarkdownEditorTextarea().trigger('keydown.up'); @@ -400,6 +379,7 @@ describe('issue_comment_form component', () => { describe('event enter', () => { describe('when no draft exists', () => { it('should save note when cmd+enter is pressed', () => { + mountComponent({ mountFunction: mount }); jest.spyOn(wrapper.vm, 'handleSave'); findMarkdownEditorTextarea().trigger('keydown.enter', { metaKey: true }); @@ -408,6 +388,7 @@ describe('issue_comment_form component', () => { }); it('should save note when ctrl+enter is pressed', () => { + mountComponent({ mountFunction: mount }); jest.spyOn(wrapper.vm, 'handleSave'); findMarkdownEditorTextarea().trigger('keydown.enter', { ctrlKey: true }); @@ -417,24 +398,25 @@ describe('issue_comment_form component', () => { }); describe('when a draft exists', () => { + let store; + beforeEach(() => { + store = createStore(); store.registerModule('batchComments', batchComments()); store.state.batchComments.drafts = [{ note: 'A' }]; }); - it('should save note draft when cmd+enter is pressed', () => { + it('should save note draft when cmd+enter is pressed', async () => { + mountComponent({ mountFunction: mount, store }); jest.spyOn(wrapper.vm, 'handleSaveDraft'); - - findMarkdownEditorTextarea().trigger('keydown.enter', { metaKey: true }); - + await findMarkdownEditorTextarea().trigger('keydown.enter', { metaKey: true }); expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith(); }); - it('should save note draft when ctrl+enter is pressed', () => { + it('should save note draft when ctrl+enter is pressed', async () => { + mountComponent({ mountFunction: mount, store }); jest.spyOn(wrapper.vm, 'handleSaveDraft'); - - findMarkdownEditorTextarea().trigger('keydown.enter', { ctrlKey: true }); - + await findMarkdownEditorTextarea().trigger('keydown.enter', { ctrlKey: true }); expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith(); }); }); @@ -706,7 +688,7 @@ describe('issue_comment_form component', () => { jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); - clickCommentButton(); + findCommentButton().trigger('click'); expect(wrapper.vm.saveNote).not.toHaveBeenCalled(); }); @@ -719,7 +701,7 @@ describe('issue_comment_form component', () => { jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); - clickCommentButton(); + findCommentButton().trigger('click'); expect(wrapper.vm.saveNote).toHaveBeenCalled(); }); @@ -740,14 +722,16 @@ describe('issue_comment_form component', () => { }); describe('with batchComments in store', () => { - beforeEach(() => { - store.registerModule('batchComments', batchComments()); - }); - describe('add to review and comment now buttons', () => { - it('when no drafts exist, should not render', () => { - mountComponent(); + let store; + + beforeEach(() => { + store = createStore(); + store.registerModule('batchComments', batchComments()); + }); + it('when no drafts exist, should not render', () => { + mountComponent({ store }); expect(findCommentTypeDropdown().exists()).toBe(true); expect(findAddToReviewButton().exists()).toBe(false); expect(findAddCommentNowButton().exists()).toBe(false); @@ -758,20 +742,17 @@ describe('issue_comment_form component', () => { store.state.batchComments.drafts = [{ note: 'A' }]; }); - it('should render', () => { - mountComponent(); - + it('should render', async () => { + await mountComponent({ store }); expect(findCommentTypeDropdown().exists()).toBe(false); expect(findAddToReviewButton().exists()).toBe(true); expect(findAddCommentNowButton().exists()).toBe(true); }); - it('clicking `add to review`, should call draft endpoint, set `isDraft` true', () => { - mountComponent({ mountFunction: mount, initialData: { note: 'a draft note' } }); - + it('clicking `add to review`, should call draft endpoint, set `isDraft` true', async () => { + mountComponent({ mountFunction: mount, initialData: { note: 'a draft note' }, store }); jest.spyOn(store, 'dispatch').mockResolvedValue(); - findAddToReviewButton().trigger('click'); - + await findAddToReviewButton().trigger('click'); expect(store.dispatch).toHaveBeenCalledWith( 'saveNote', expect.objectContaining({ @@ -781,12 +762,10 @@ describe('issue_comment_form component', () => { ); }); - it('clicking `add comment now`, should call note endpoint, set `isDraft` false', () => { - mountComponent({ mountFunction: mount, initialData: { note: 'a comment' } }); - + it('clicking `add comment now`, should call note endpoint, set `isDraft` false', async () => { + await mountComponent({ mountFunction: mount, initialData: { note: 'a comment' }, store }); jest.spyOn(store, 'dispatch').mockResolvedValue(); - findAddCommentNowButton().trigger('click'); - + await findAddCommentNowButton().trigger('click'); expect(store.dispatch).toHaveBeenCalledWith( 'saveNote', expect.objectContaining({ diff --git a/spec/frontend/notes/components/email_participants_warning_spec.js b/spec/frontend/notes/components/email_participants_warning_spec.js index 34b7524d8fb..620c753e3c5 100644 --- a/spec/frontend/notes/components/email_participants_warning_spec.js +++ b/spec/frontend/notes/components/email_participants_warning_spec.js @@ -1,10 +1,12 @@ import { mount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; + import EmailParticipantsWarning from '~/notes/components/email_participants_warning.vue'; describe('Email Participants Warning Component', () => { let wrapper; - const findMoreButton = () => wrapper.find('button'); + const findMoreButton = () => wrapper.findComponent(GlButton); const createWrapper = (emails) => { wrapper = mount(EmailParticipantsWarning, { @@ -48,7 +50,7 @@ describe('Email Participants Warning Component', () => { describe('when more button clicked', () => { beforeEach(() => { - findMoreButton().trigger('click'); + findMoreButton().vm.$emit('click'); }); it('more button no longer exists', () => { diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index 3c461f2b382..e2072ebd04d 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -4,6 +4,7 @@ import batchComments from '~/batch_comments/stores/modules/batch_comments'; import NoteForm from '~/notes/components/note_form.vue'; import createStore from '~/notes/stores'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import CommentFieldLayout from '~/notes/components/comment_field_layout.vue'; import { AT_WHO_ACTIVE_CLASS } from '~/gfm_auto_complete'; import eventHub from '~/environments/event_hub'; import { mountExtended } from 'helpers/vue_test_utils_helper'; @@ -75,14 +76,8 @@ describe('issue_note_form component', () => { }); }); - it('hides content editor switcher if feature flag content_editor_on_issues is off', () => { - createComponentWrapper({}, { contentEditorOnIssues: false }); - - expect(wrapper.text()).not.toContain('Switch to rich text editing'); - }); - - it('shows content editor switcher if feature flag content_editor_on_issues is on', () => { - createComponentWrapper({}, { contentEditorOnIssues: true }); + it('shows content editor switcher', () => { + createComponentWrapper(); expect(wrapper.text()).toContain('Switch to rich text editing'); }); @@ -239,6 +234,21 @@ describe('issue_note_form component', () => { property: 'Issue_note', }); }); + + describe('when discussion is confidential', () => { + beforeEach(() => { + createComponentWrapper({ + discussion: { + ...discussionMock, + confidential: true, + }, + }); + }); + + it('passes correct confidentiality to CommentFieldLayout', () => { + expect(wrapper.findComponent(CommentFieldLayout).props('isInternalNote')).toBe(true); + }); + }); }); }); diff --git a/spec/frontend/notes/mock_data.js b/spec/frontend/notes/mock_data.js index b291eba61f5..67c0ba90d40 100644 --- a/spec/frontend/notes/mock_data.js +++ b/spec/frontend/notes/mock_data.js @@ -321,6 +321,7 @@ export const discussionMock = { individual_note: false, resolvable: true, active: true, + confidential: false, }; export const loggedOutnoteableData = { diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 104c297b44e..f07ba1e032f 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -1343,8 +1343,6 @@ describe('Actions Notes Store', () => { }); it('dispatches `fetchDiscussionsBatch` action with notes_filter 0 for merge request', () => { - window.gon = { features: { mrActivityFilters: true } }; - return testAction( actions.fetchDiscussions, { path: 'test-path', filter: 'test-filter', persistFilter: 'test-persist-filter' }, @@ -1397,7 +1395,7 @@ describe('Actions Notes Store', () => { type: 'fetchDiscussionsBatch', payload: { config: { - params: { notes_filter: 'test-filter', persist_filter: 'test-persist-filter' }, + params: { notes_filter: 0, persist_filter: false }, }, path: 'test-path', perPage: 20, diff --git a/spec/frontend/observability/client_spec.js b/spec/frontend/observability/client_spec.js index 056175eac07..68a53131539 100644 --- a/spec/frontend/observability/client_spec.js +++ b/spec/frontend/observability/client_spec.js @@ -12,7 +12,8 @@ describe('buildClient', () => { const tracingUrl = 'https://example.com/tracing'; const provisioningUrl = 'https://example.com/provisioning'; - + const servicesUrl = 'https://example.com/services'; + const operationsUrl = 'https://example.com/services/$SERVICE_NAME$/operations'; const FETCHING_TRACES_ERROR = 'traces are missing/invalid in the response'; beforeEach(() => { @@ -22,6 +23,8 @@ describe('buildClient', () => { client = buildClient({ tracingUrl, provisioningUrl, + servicesUrl, + operationsUrl, }); }); @@ -29,6 +32,27 @@ describe('buildClient', () => { axiosMock.restore(); }); + describe('buildClient', () => { + it('rejects if params are missing', () => { + const e = new Error( + 'missing required params. provisioningUrl, tracingUrl, servicesUrl, operationsUrl are required', + ); + expect(() => + buildClient({ tracingUrl: 'test', servicesUrl: 'test', operationsUrl: 'test' }), + ).toThrow(e); + expect(() => + buildClient({ provisioningUrl: 'test', servicesUrl: 'test', operationsUrl: 'test' }), + ).toThrow(e); + expect(() => + buildClient({ provisioningUrl: 'test', tracingUrl: 'test', operationsUrl: 'test' }), + ).toThrow(e); + expect(() => + buildClient({ provisioningUrl: 'test', tracingUrl: 'test', servicesUrl: 'test' }), + ).toThrow(e); + expect(() => buildClient({})).toThrow(e); + }); + }); + describe('isTracingEnabled', () => { it('returns true if requests succeedes', async () => { axiosMock.onGet(provisioningUrl).reply(200, { @@ -145,18 +169,18 @@ describe('buildClient', () => { describe('fetchTraces', () => { it('fetches traces from the tracing URL', async () => { - const mockTraces = [ - { - trace_id: 'trace-1', - duration_nano: 3000, - spans: [{ duration_nano: 1000 }, { duration_nano: 2000 }], - }, - { trace_id: 'trace-2', duration_nano: 3000, spans: [{ duration_nano: 2000 }] }, - ]; - - axiosMock.onGet(tracingUrl).reply(200, { - traces: mockTraces, - }); + const mockResponse = { + traces: [ + { + trace_id: 'trace-1', + duration_nano: 3000, + spans: [{ duration_nano: 1000 }, { duration_nano: 2000 }], + }, + { trace_id: 'trace-2', duration_nano: 3000, spans: [{ duration_nano: 2000 }] }, + ], + }; + + axiosMock.onGet(tracingUrl).reply(200, mockResponse); const result = await client.fetchTraces(); @@ -165,7 +189,7 @@ describe('buildClient', () => { withCredentials: true, params: new URLSearchParams(), }); - expect(result).toEqual(mockTraces); + expect(result).toEqual(mockResponse); }); it('rejects if traces are missing', async () => { @@ -197,28 +221,42 @@ describe('buildClient', () => { expect(getQueryParam()).toBe(''); }); + it('appends page_token if specified', async () => { + await client.fetchTraces({ pageToken: 'page-token' }); + + expect(getQueryParam()).toBe('page_token=page-token'); + }); + + it('appends page_size if specified', async () => { + await client.fetchTraces({ pageSize: 10 }); + + expect(getQueryParam()).toBe('page_size=10'); + }); + it('converts filter to proper query params', async () => { await client.fetchTraces({ - durationMs: [ - { operator: '>', value: '100' }, - { operator: '<', value: '1000' }, - ], - operation: [ - { operator: '=', value: 'op' }, - { operator: '!=', value: 'not-op' }, - ], - serviceName: [ - { operator: '=', value: 'service' }, - { operator: '!=', value: 'not-service' }, - ], - period: [{ operator: '=', value: '5m' }], - traceId: [ - { operator: '=', value: 'trace-id' }, - { operator: '!=', value: 'not-trace-id' }, - ], + filters: { + durationMs: [ + { operator: '>', value: '100' }, + { operator: '<', value: '1000' }, + ], + operation: [ + { operator: '=', value: 'op' }, + { operator: '!=', value: 'not-op' }, + ], + serviceName: [ + { operator: '=', value: 'service' }, + { operator: '!=', value: 'not-service' }, + ], + period: [{ operator: '=', value: '5m' }], + traceId: [ + { operator: '=', value: 'trace-id' }, + { operator: '!=', value: 'not-trace-id' }, + ], + }, }); expect(getQueryParam()).toBe( - 'gt[duration_nano]=100000<[duration_nano]=1000000' + + 'gt[duration_nano]=100000000<[duration_nano]=1000000000' + '&operation=op¬[operation]=not-op' + '&service_name=service¬[service_name]=not-service' + '&period=5m' + @@ -228,17 +266,21 @@ describe('buildClient', () => { it('handles repeated params', async () => { await client.fetchTraces({ - operation: [ - { operator: '=', value: 'op' }, - { operator: '=', value: 'op2' }, - ], + filters: { + operation: [ + { operator: '=', value: 'op' }, + { operator: '=', value: 'op2' }, + ], + }, }); expect(getQueryParam()).toBe('operation=op&operation=op2'); }); it('ignores unsupported filters', async () => { await client.fetchTraces({ - unsupportedFilter: [{ operator: '=', value: 'foo' }], + filters: { + unsupportedFilter: [{ operator: '=', value: 'foo' }], + }, }); expect(getQueryParam()).toBe(''); @@ -246,8 +288,10 @@ describe('buildClient', () => { it('ignores empty filters', async () => { await client.fetchTraces({ - durationMs: null, - traceId: undefined, + filters: { + durationMs: null, + traceId: undefined, + }, }); expect(getQueryParam()).toBe(''); @@ -255,28 +299,103 @@ describe('buildClient', () => { it('ignores unsupported operators', async () => { await client.fetchTraces({ - durationMs: [ - { operator: '*', value: 'foo' }, - { operator: '=', value: 'foo' }, - { operator: '!=', value: 'foo' }, - ], - operation: [ - { operator: '>', value: 'foo' }, - { operator: '<', value: 'foo' }, - ], - serviceName: [ - { operator: '>', value: 'foo' }, - { operator: '<', value: 'foo' }, - ], - period: [{ operator: '!=', value: 'foo' }], - traceId: [ - { operator: '>', value: 'foo' }, - { operator: '<', value: 'foo' }, - ], + filters: { + durationMs: [ + { operator: '*', value: 'foo' }, + { operator: '=', value: 'foo' }, + { operator: '!=', value: 'foo' }, + ], + operation: [ + { operator: '>', value: 'foo' }, + { operator: '<', value: 'foo' }, + ], + serviceName: [ + { operator: '>', value: 'foo' }, + { operator: '<', value: 'foo' }, + ], + period: [{ operator: '!=', value: 'foo' }], + traceId: [ + { operator: '>', value: 'foo' }, + { operator: '<', value: 'foo' }, + ], + }, }); expect(getQueryParam()).toBe(''); }); }); }); + + describe('fetchServices', () => { + it('fetches services from the services URL', async () => { + const mockResponse = { + services: [{ name: 'service-1' }, { name: 'service-2' }], + }; + + axiosMock.onGet(servicesUrl).reply(200, mockResponse); + + const result = await client.fetchServices(); + + expect(axios.get).toHaveBeenCalledTimes(1); + expect(axios.get).toHaveBeenCalledWith(servicesUrl, { + withCredentials: true, + }); + expect(result).toEqual(mockResponse.services); + }); + + it('rejects if services are missing', async () => { + axiosMock.onGet(servicesUrl).reply(200, {}); + + const e = 'failed to fetch services. invalid response'; + await expect(client.fetchServices()).rejects.toThrow(e); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error(e)); + }); + }); + + describe('fetchOperations', () => { + const serviceName = 'test-service'; + const parsedOperationsUrl = `https://example.com/services/${serviceName}/operations`; + + it('fetches operations from the operations URL', async () => { + const mockResponse = { + operations: [{ name: 'operation-1' }, { name: 'operation-2' }], + }; + + axiosMock.onGet(parsedOperationsUrl).reply(200, mockResponse); + + const result = await client.fetchOperations(serviceName); + + expect(axios.get).toHaveBeenCalledTimes(1); + expect(axios.get).toHaveBeenCalledWith(parsedOperationsUrl, { + withCredentials: true, + }); + expect(result).toEqual(mockResponse.operations); + }); + + it('rejects if serviceName is missing', async () => { + const e = 'fetchOperations() - serviceName is required.'; + await expect(client.fetchOperations()).rejects.toThrow(e); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error(e)); + }); + + it('rejects if operationUrl does not contain $SERVICE_NAME$', async () => { + client = buildClient({ + tracingUrl, + provisioningUrl, + servicesUrl, + operationsUrl: 'something', + }); + const e = 'fetchOperations() - operationsUrl must contain $SERVICE_NAME$'; + await expect(client.fetchOperations(serviceName)).rejects.toThrow(e); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error(e)); + }); + + it('rejects if operations are missing', async () => { + axiosMock.onGet(parsedOperationsUrl).reply(200, {}); + + const e = 'failed to fetch operations. invalid response'; + await expect(client.fetchOperations(serviceName)).rejects.toThrow(e); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error(e)); + }); + }); }); diff --git a/spec/frontend/observability/index_spec.js b/spec/frontend/observability/index_spec.js deleted file mode 100644 index 25eb048c62b..00000000000 --- a/spec/frontend/observability/index_spec.js +++ /dev/null @@ -1,64 +0,0 @@ -import { createWrapper } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import renderObservability from '~/observability/index'; -import ObservabilityApp from '~/observability/components/observability_app.vue'; -import { SKELETON_VARIANTS_BY_ROUTE } from '~/observability/constants'; - -describe('renderObservability', () => { - let element; - let vueInstance; - let component; - - const OBSERVABILITY_ROUTES = Object.keys(SKELETON_VARIANTS_BY_ROUTE); - const SKELETON_VARIANTS = Object.values(SKELETON_VARIANTS_BY_ROUTE); - - beforeEach(() => { - element = document.createElement('div'); - element.setAttribute('id', 'js-observability-app'); - element.dataset.observabilityIframeSrc = 'https://observe.gitlab.com/'; - document.body.appendChild(element); - - vueInstance = renderObservability(); - component = createWrapper(vueInstance).findComponent(ObservabilityApp); - }); - - afterEach(() => { - element.remove(); - }); - - it('should return a Vue instance', () => { - expect(vueInstance).toEqual(expect.any(Vue)); - }); - - it('should render the ObservabilityApp component', () => { - expect(component.props('observabilityIframeSrc')).toBe('https://observe.gitlab.com/'); - }); - - describe('skeleton variant', () => { - it.each` - pathDescription | path | variant - ${'dashboards'} | ${OBSERVABILITY_ROUTES[0]} | ${SKELETON_VARIANTS[0]} - ${'explore'} | ${OBSERVABILITY_ROUTES[1]} | ${SKELETON_VARIANTS[1]} - ${'manage dashboards'} | ${OBSERVABILITY_ROUTES[2]} | ${SKELETON_VARIANTS[2]} - ${'any other'} | ${'unknown/route'} | ${SKELETON_VARIANTS[0]} - `( - 'renders the $variant skeleton variant for $pathDescription path', - async ({ path, variant }) => { - component.vm.$router.push(path); - await nextTick(); - - expect(component.props('skeletonVariant')).toBe(variant); - }, - ); - }); - - it('handle route-update events', () => { - component.vm.$router.push('/something?foo=bar'); - component.vm.$emit('route-update', { url: '/some_path' }); - expect(component.vm.$router.currentRoute.path).toBe('/something'); - expect(component.vm.$router.currentRoute.query).toEqual({ - foo: 'bar', - observability_path: '/some_path', - }); - }); -}); diff --git a/spec/frontend/observability/observability_app_spec.js b/spec/frontend/observability/observability_app_spec.js deleted file mode 100644 index 392992a5962..00000000000 --- a/spec/frontend/observability/observability_app_spec.js +++ /dev/null @@ -1,201 +0,0 @@ -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { stubComponent } from 'helpers/stub_component'; -import ObservabilityApp from '~/observability/components/observability_app.vue'; -import ObservabilitySkeleton from '~/observability/components/skeleton/index.vue'; -import { - MESSAGE_EVENT_TYPE, - INLINE_EMBED_DIMENSIONS, - FULL_APP_DIMENSIONS, - SKELETON_VARIANT_EMBED, -} from '~/observability/constants'; - -import { darkModeEnabled } from '~/lib/utils/color_utils'; - -jest.mock('~/lib/utils/color_utils'); - -describe('ObservabilityApp', () => { - let wrapper; - - const $route = { - pathname: 'https://gitlab.com/gitlab-org/', - path: 'https://gitlab.com/gitlab-org/-/observability/dashboards', - query: { otherQuery: 100 }, - }; - - const mockSkeletonOnContentLoaded = jest.fn(); - - const findIframe = () => wrapper.findByTestId('observability-ui-iframe'); - - const TEST_IFRAME_SRC = 'https://observe.gitlab.com/9970/?groupId=14485840'; - - const TEST_USERNAME = 'test-user'; - - const mountComponent = (props) => { - wrapper = shallowMountExtended(ObservabilityApp, { - propsData: { - observabilityIframeSrc: TEST_IFRAME_SRC, - ...props, - }, - stubs: { - ObservabilitySkeleton: stubComponent(ObservabilitySkeleton, { - methods: { onContentLoaded: mockSkeletonOnContentLoaded }, - }), - }, - mocks: { - $route, - }, - }); - }; - - const dispatchMessageEvent = (message) => - window.dispatchEvent(new MessageEvent('message', message)); - - beforeEach(() => { - gon.current_username = TEST_USERNAME; - }); - - describe('iframe src', () => { - it('should render an iframe with observabilityIframeSrc, decorated with light theme and username', () => { - darkModeEnabled.mockReturnValueOnce(false); - mountComponent(); - const iframe = findIframe(); - - expect(iframe.exists()).toBe(true); - expect(iframe.attributes('src')).toBe( - `${TEST_IFRAME_SRC}&theme=light&username=${TEST_USERNAME}`, - ); - }); - - it('should render an iframe with observabilityIframeSrc decorated with dark theme and username', () => { - darkModeEnabled.mockReturnValueOnce(true); - mountComponent(); - const iframe = findIframe(); - - expect(iframe.exists()).toBe(true); - expect(iframe.attributes('src')).toBe( - `${TEST_IFRAME_SRC}&theme=dark&username=${TEST_USERNAME}`, - ); - }); - }); - - describe('iframe sandbox', () => { - it('should render an iframe with sandbox attributes', () => { - mountComponent(); - const iframe = findIframe(); - - expect(iframe.exists()).toBe(true); - expect(iframe.attributes('sandbox')).toBe('allow-same-origin allow-forms allow-scripts'); - }); - }); - - describe('iframe kiosk query param', () => { - it('when inlineEmbed, it should set the proper kiosk query parameter', () => { - mountComponent({ - inlineEmbed: true, - }); - - const iframe = findIframe(); - - expect(iframe.attributes('src')).toBe( - `${TEST_IFRAME_SRC}&theme=light&username=${TEST_USERNAME}&kiosk=inline-embed`, - ); - }); - }); - - describe('iframe size', () => { - it('should set the specified size', () => { - mountComponent({ - height: INLINE_EMBED_DIMENSIONS.HEIGHT, - width: INLINE_EMBED_DIMENSIONS.WIDTH, - }); - - const iframe = findIframe(); - - expect(iframe.attributes('width')).toBe(INLINE_EMBED_DIMENSIONS.WIDTH); - expect(iframe.attributes('height')).toBe(INLINE_EMBED_DIMENSIONS.HEIGHT); - }); - - it('should fallback to default size', () => { - mountComponent({}); - - const iframe = findIframe(); - - expect(iframe.attributes('width')).toBe(FULL_APP_DIMENSIONS.WIDTH); - expect(iframe.attributes('height')).toBe(FULL_APP_DIMENSIONS.HEIGHT); - }); - }); - - describe('skeleton variant', () => { - it('sets the specified skeleton variant', () => { - mountComponent({ skeletonVariant: SKELETON_VARIANT_EMBED }); - const props = wrapper.findComponent(ObservabilitySkeleton).props(); - - expect(props.variant).toBe(SKELETON_VARIANT_EMBED); - }); - - it('should have a default skeleton variant', () => { - mountComponent(); - const props = wrapper.findComponent(ObservabilitySkeleton).props(); - - expect(props.variant).toBe('dashboards'); - }); - }); - - describe('on GOUI_ROUTE_UPDATE', () => { - it('should emit a route-update event', () => { - mountComponent(); - - const payload = { url: '/explore' }; - dispatchMessageEvent({ - data: { type: MESSAGE_EVENT_TYPE.GOUI_ROUTE_UPDATE, payload }, - origin: 'https://observe.gitlab.com', - }); - - expect(wrapper.emitted('route-update')[0]).toEqual([payload]); - }); - }); - - describe('on GOUI_LOADED', () => { - beforeEach(() => { - mountComponent(); - }); - - it('should call onContentLoaded method', () => { - dispatchMessageEvent({ - data: { type: MESSAGE_EVENT_TYPE.GOUI_LOADED }, - origin: 'https://observe.gitlab.com', - }); - expect(mockSkeletonOnContentLoaded).toHaveBeenCalled(); - }); - - it('should not call onContentLoaded method if origin is different', () => { - dispatchMessageEvent({ - data: { type: MESSAGE_EVENT_TYPE.GOUI_LOADED }, - origin: 'https://example.com', - }); - expect(mockSkeletonOnContentLoaded).not.toHaveBeenCalled(); - }); - - it('should not call onContentLoaded method if event type is different', () => { - dispatchMessageEvent({ - data: { type: 'UNKNOWN_EVENT' }, - origin: 'https://observe.gitlab.com', - }); - expect(mockSkeletonOnContentLoaded).not.toHaveBeenCalled(); - }); - }); - - describe('on unmount', () => { - it('should not emit any even on route update', () => { - mountComponent(); - wrapper.destroy(); - - dispatchMessageEvent({ - data: { type: MESSAGE_EVENT_TYPE.GOUI_ROUTE_UPDATE, payload: { url: '/explore' } }, - origin: 'https://observe.gitlab.com', - }); - - expect(wrapper.emitted('route-update')).toBeUndefined(); - }); - }); -}); diff --git a/spec/frontend/observability/observability_container_spec.js b/spec/frontend/observability/observability_container_spec.js index 1152df072d4..5d838756308 100644 --- a/spec/frontend/observability/observability_container_spec.js +++ b/spec/frontend/observability/observability_container_spec.js @@ -16,6 +16,8 @@ describe('ObservabilityContainer', () => { const OAUTH_URL = 'https://example.com/oauth'; const TRACING_URL = 'https://example.com/tracing'; const PROVISIONING_URL = 'https://example.com/provisioning'; + const SERVICES_URL = 'https://example.com/services'; + const OPERATIONS_URL = 'https://example.com/operations'; beforeEach(() => { jest.spyOn(console, 'error').mockImplementation(); @@ -27,6 +29,8 @@ describe('ObservabilityContainer', () => { oauthUrl: OAUTH_URL, tracingUrl: TRACING_URL, provisioningUrl: PROVISIONING_URL, + servicesUrl: SERVICES_URL, + operationsUrl: OPERATIONS_URL, }, stubs: { ObservabilitySkeleton: stubComponent(ObservabilitySkeleton, { @@ -93,6 +97,8 @@ describe('ObservabilityContainer', () => { expect(buildClient).toHaveBeenCalledWith({ provisioningUrl: PROVISIONING_URL, tracingUrl: TRACING_URL, + servicesUrl: SERVICES_URL, + operationsUrl: OPERATIONS_URL, }); expect(findIframe().exists()).toBe(false); }); diff --git a/spec/frontend/observability/skeleton_spec.js b/spec/frontend/observability/skeleton_spec.js index 979070cfb12..5501fa117e0 100644 --- a/spec/frontend/observability/skeleton_spec.js +++ b/spec/frontend/observability/skeleton_spec.js @@ -3,32 +3,16 @@ import { GlSkeletonLoader, GlAlert, GlLoadingIcon } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import Skeleton from '~/observability/components/skeleton/index.vue'; -import DashboardsSkeleton from '~/observability/components/skeleton/dashboards.vue'; -import ExploreSkeleton from '~/observability/components/skeleton/explore.vue'; -import ManageSkeleton from '~/observability/components/skeleton/manage.vue'; -import EmbedSkeleton from '~/observability/components/skeleton/embed.vue'; -import { - SKELETON_VARIANTS_BY_ROUTE, - DEFAULT_TIMERS, - SKELETON_VARIANT_EMBED, -} from '~/observability/constants'; +import { DEFAULT_TIMERS } from '~/observability/constants'; describe('Skeleton component', () => { let wrapper; - const SKELETON_VARIANTS = [...Object.values(SKELETON_VARIANTS_BY_ROUTE), 'spinner']; + const findSpinner = () => wrapper.findComponent(GlLoadingIcon); const findContentWrapper = () => wrapper.findByTestId('content-wrapper'); - const findExploreSkeleton = () => wrapper.findComponent(ExploreSkeleton); - - const findDashboardsSkeleton = () => wrapper.findComponent(DashboardsSkeleton); - - const findManageSkeleton = () => wrapper.findComponent(ManageSkeleton); - - const findEmbedSkeleton = () => wrapper.findComponent(EmbedSkeleton); - const findAlert = () => wrapper.findComponent(GlAlert); const mountComponent = ({ ...props } = {}) => { @@ -39,39 +23,39 @@ describe('Skeleton component', () => { describe('on mount', () => { beforeEach(() => { - mountComponent({ variant: 'explore' }); + mountComponent({ variant: 'spinner' }); }); describe('showing content', () => { it('shows the skeleton if content is not loaded within CONTENT_WAIT_MS', async () => { - expect(findExploreSkeleton().exists()).toBe(false); - expect(findContentWrapper().isVisible()).toBe(false); + expect(findSpinner().exists()).toBe(false); + expect(findContentWrapper().exists()).toBe(false); jest.advanceTimersByTime(DEFAULT_TIMERS.CONTENT_WAIT_MS); await nextTick(); - expect(findExploreSkeleton().exists()).toBe(true); - expect(findContentWrapper().isVisible()).toBe(false); + expect(findSpinner().exists()).toBe(true); + expect(findContentWrapper().exists()).toBe(false); }); it('does not show the skeleton if content loads within CONTENT_WAIT_MS', async () => { - expect(findExploreSkeleton().exists()).toBe(false); - expect(findContentWrapper().isVisible()).toBe(false); + expect(findSpinner().exists()).toBe(false); + expect(findContentWrapper().exists()).toBe(false); wrapper.vm.onContentLoaded(); await nextTick(); - expect(findContentWrapper().isVisible()).toBe(true); - expect(findExploreSkeleton().exists()).toBe(false); + expect(findContentWrapper().exists()).toBe(true); + expect(findSpinner().exists()).toBe(false); jest.advanceTimersByTime(DEFAULT_TIMERS.CONTENT_WAIT_MS); await nextTick(); - expect(findContentWrapper().isVisible()).toBe(true); - expect(findExploreSkeleton().exists()).toBe(false); + expect(findContentWrapper().exists()).toBe(true); + expect(findSpinner().exists()).toBe(false); }); it('hides the skeleton after content loads', async () => { @@ -79,15 +63,15 @@ describe('Skeleton component', () => { await nextTick(); - expect(findExploreSkeleton().exists()).toBe(true); - expect(findContentWrapper().isVisible()).toBe(false); + expect(findSpinner().exists()).toBe(true); + expect(findContentWrapper().exists()).toBe(false); wrapper.vm.onContentLoaded(); await nextTick(); - expect(findContentWrapper().isVisible()).toBe(true); - expect(findExploreSkeleton().exists()).toBe(false); + expect(findContentWrapper().exists()).toBe(true); + expect(findSpinner().exists()).toBe(false); }); }); @@ -99,7 +83,7 @@ describe('Skeleton component', () => { await nextTick(); expect(findAlert().exists()).toBe(true); - expect(findContentWrapper().isVisible()).toBe(false); + expect(findContentWrapper().exists()).toBe(false); }); it('shows the error dialog if content fails to load', async () => { @@ -110,7 +94,7 @@ describe('Skeleton component', () => { await nextTick(); expect(findAlert().exists()).toBe(true); - expect(findContentWrapper().isVisible()).toBe(false); + expect(findContentWrapper().exists()).toBe(false); }); it('does not show the error dialog if content has loaded within TIMEOUT_MS', async () => { @@ -120,36 +104,28 @@ describe('Skeleton component', () => { await nextTick(); expect(findAlert().exists()).toBe(false); - expect(findContentWrapper().isVisible()).toBe(true); + expect(findContentWrapper().exists()).toBe(true); }); }); }); describe('skeleton variant', () => { - it.each` - skeletonType | condition | variant - ${'dashboards'} | ${'variant is dashboards'} | ${SKELETON_VARIANTS[0]} - ${'explore'} | ${'variant is explore'} | ${SKELETON_VARIANTS[1]} - ${'manage'} | ${'variant is manage'} | ${SKELETON_VARIANTS[2]} - ${'embed'} | ${'variant is embed'} | ${SKELETON_VARIANT_EMBED} - ${'spinner'} | ${'variant is spinner'} | ${'spinner'} - ${'default'} | ${'variant is not manage, dashboards or explore'} | ${'unknown'} - `('should render $skeletonType skeleton if $condition', async ({ skeletonType, variant }) => { - mountComponent({ variant }); + it('shows only the spinner variant when variant is spinner', async () => { + mountComponent({ variant: 'spinner' }); jest.advanceTimersByTime(DEFAULT_TIMERS.CONTENT_WAIT_MS); await nextTick(); - const showsDefaultSkeleton = ![...SKELETON_VARIANTS, SKELETON_VARIANT_EMBED].includes( - variant, - ); - expect(findDashboardsSkeleton().exists()).toBe(skeletonType === SKELETON_VARIANTS[0]); - expect(findExploreSkeleton().exists()).toBe(skeletonType === SKELETON_VARIANTS[1]); - expect(findManageSkeleton().exists()).toBe(skeletonType === SKELETON_VARIANTS[2]); - expect(findEmbedSkeleton().exists()).toBe(skeletonType === SKELETON_VARIANT_EMBED); + expect(findSpinner().exists()).toBe(true); + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false); + }); - expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(showsDefaultSkeleton); + it('shows only the default variant when variant is not spinner', async () => { + mountComponent({ variant: 'unknown' }); + jest.advanceTimersByTime(DEFAULT_TIMERS.CONTENT_WAIT_MS); + await nextTick(); - expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(variant === 'spinner'); + expect(findSpinner().exists()).toBe(false); + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true); }); }); diff --git a/spec/frontend/organizations/index/components/app_spec.js b/spec/frontend/organizations/index/components/app_spec.js new file mode 100644 index 00000000000..175b1e1c552 --- /dev/null +++ b/spec/frontend/organizations/index/components/app_spec.js @@ -0,0 +1,87 @@ +import { GlButton } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import { organizations } from '~/organizations/mock_data'; +import resolvers from '~/organizations/shared/graphql/resolvers'; +import organizationsQuery from '~/organizations/index/graphql/organizations.query.graphql'; +import OrganizationsIndexApp from '~/organizations/index/components/app.vue'; +import OrganizationsView from '~/organizations/index/components/organizations_view.vue'; +import { MOCK_NEW_ORG_URL } from '../mock_data'; + +jest.mock('~/alert'); + +Vue.use(VueApollo); + +describe('OrganizationsIndexApp', () => { + let wrapper; + let mockApollo; + + const createComponent = (mockResolvers = resolvers) => { + mockApollo = createMockApollo([[organizationsQuery, mockResolvers]]); + + wrapper = shallowMountExtended(OrganizationsIndexApp, { + apolloProvider: mockApollo, + provide: { + newOrganizationUrl: MOCK_NEW_ORG_URL, + }, + }); + }; + + afterEach(() => { + mockApollo = null; + }); + + const findOrganizationHeaderText = () => wrapper.findByText('Organizations'); + const findNewOrganizationButton = () => wrapper.findComponent(GlButton); + const findOrganizationsView = () => wrapper.findComponent(OrganizationsView); + + const loadingResolver = jest.fn().mockReturnValue(new Promise(() => {})); + const successfulResolver = (nodes) => + jest.fn().mockResolvedValue({ + data: { currentUser: { id: 1, organizations: { nodes } } }, + }); + const errorResolver = jest.fn().mockRejectedValue('error'); + + describe.each` + description | mockResolver | headerText | newOrgLink | loading | orgsData | error + ${'when API call is loading'} | ${loadingResolver} | ${true} | ${MOCK_NEW_ORG_URL} | ${true} | ${[]} | ${false} + ${'when API returns successful with results'} | ${successfulResolver(organizations)} | ${true} | ${MOCK_NEW_ORG_URL} | ${false} | ${organizations} | ${false} + ${'when API returns successful without results'} | ${successfulResolver([])} | ${false} | ${false} | ${false} | ${[]} | ${false} + ${'when API returns error'} | ${errorResolver} | ${false} | ${false} | ${false} | ${[]} | ${true} + `('$description', ({ mockResolver, headerText, newOrgLink, loading, orgsData, error }) => { + beforeEach(async () => { + createComponent(mockResolver); + await waitForPromises(); + }); + + it(`does ${headerText ? '' : 'not '}render the header text`, () => { + expect(findOrganizationHeaderText().exists()).toBe(headerText); + }); + + it(`does ${newOrgLink ? '' : 'not '}render new organization button with correct link`, () => { + expect( + findNewOrganizationButton().exists() && findNewOrganizationButton().attributes('href'), + ).toBe(newOrgLink); + }); + + it(`renders the organizations view with ${loading} loading prop`, () => { + expect(findOrganizationsView().props('loading')).toBe(loading); + }); + + it(`renders the organizations view with ${ + orgsData ? 'correct' : 'empty' + } organizations array prop`, () => { + expect(findOrganizationsView().props('organizations')).toStrictEqual(orgsData); + }); + + it(`does ${error ? '' : 'not '}render an error message`, () => { + return error + ? expect(createAlert).toHaveBeenCalled() + : expect(createAlert).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/organizations/index/components/organizations_list_item_spec.js b/spec/frontend/organizations/index/components/organizations_list_item_spec.js new file mode 100644 index 00000000000..b3bff5ed517 --- /dev/null +++ b/spec/frontend/organizations/index/components/organizations_list_item_spec.js @@ -0,0 +1,70 @@ +import { GlAvatarLabeled } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import OrganizationsListItem from '~/organizations/index/components/organizations_list_item.vue'; +import { organizations } from '~/organizations/mock_data'; + +const MOCK_ORGANIZATION = organizations[0]; + +describe('OrganizationsListItem', () => { + let wrapper; + + const defaultProps = { + organization: MOCK_ORGANIZATION, + }; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(OrganizationsListItem, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + const findGlAvatarLabeled = () => wrapper.findComponent(GlAvatarLabeled); + const findHTMLOrganizationDescription = () => + wrapper.findByTestId('organization-description-html'); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders GlAvatarLabeled with correct data', () => { + expect(findGlAvatarLabeled().attributes()).toMatchObject({ + 'entity-id': getIdFromGraphQLId(MOCK_ORGANIZATION.id).toString(), + 'entity-name': MOCK_ORGANIZATION.name, + src: MOCK_ORGANIZATION.avatarUrl, + label: MOCK_ORGANIZATION.name, + labellink: MOCK_ORGANIZATION.webUrl, + }); + }); + }); + + describe('organization description', () => { + const descriptionHtml = '<p>Foo bar</p>'; + + describe('is a HTML description', () => { + beforeEach(() => { + createComponent({ organization: { ...MOCK_ORGANIZATION, descriptionHtml } }); + }); + + it('renders HTML description', () => { + expect(findHTMLOrganizationDescription().html()).toContain(descriptionHtml); + }); + }); + + describe('is not a HTML description', () => { + beforeEach(() => { + createComponent({ + organization: { ...MOCK_ORGANIZATION, descriptionHtml: null }, + }); + }); + + it('does not render HTML description', () => { + expect(findHTMLOrganizationDescription().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/organizations/index/components/organizations_list_spec.js b/spec/frontend/organizations/index/components/organizations_list_spec.js new file mode 100644 index 00000000000..0b59c212314 --- /dev/null +++ b/spec/frontend/organizations/index/components/organizations_list_spec.js @@ -0,0 +1,28 @@ +import { shallowMount } from '@vue/test-utils'; +import OrganizationsList from '~/organizations/index/components/organizations_list.vue'; +import OrganizationsListItem from '~/organizations/index/components/organizations_list_item.vue'; +import { organizations } from '~/organizations/mock_data'; + +describe('OrganizationsList', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(OrganizationsList, { + propsData: { + organizations, + }, + }); + }; + + const findAllOrganizationsListItem = () => wrapper.findAllComponents(OrganizationsListItem); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders a list item for each organization', () => { + expect(findAllOrganizationsListItem()).toHaveLength(organizations.length); + }); + }); +}); diff --git a/spec/frontend/organizations/index/components/organizations_view_spec.js b/spec/frontend/organizations/index/components/organizations_view_spec.js new file mode 100644 index 00000000000..85a1c11a2b1 --- /dev/null +++ b/spec/frontend/organizations/index/components/organizations_view_spec.js @@ -0,0 +1,57 @@ +import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { organizations } from '~/organizations/mock_data'; +import OrganizationsView from '~/organizations/index/components/organizations_view.vue'; +import OrganizationsList from '~/organizations/index/components/organizations_list.vue'; +import { MOCK_NEW_ORG_URL, MOCK_ORG_EMPTY_STATE_SVG } from '../mock_data'; + +describe('OrganizationsView', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(OrganizationsView, { + propsData: { + ...props, + }, + provide: { + newOrganizationUrl: MOCK_NEW_ORG_URL, + organizationsEmptyStateSvgPath: MOCK_ORG_EMPTY_STATE_SVG, + }, + }); + }; + + const findGlLoading = () => wrapper.findComponent(GlLoadingIcon); + const findOrganizationsList = () => wrapper.findComponent(OrganizationsList); + const findGlEmptyState = () => wrapper.findComponent(GlEmptyState); + + describe.each` + description | loading | orgsData | emptyStateSvg | emptyStateUrl + ${'when loading'} | ${true} | ${[]} | ${false} | ${false} + ${'when not loading and has organizations'} | ${false} | ${organizations} | ${false} | ${false} + ${'when not loading and has no organizations'} | ${false} | ${[]} | ${MOCK_ORG_EMPTY_STATE_SVG} | ${MOCK_NEW_ORG_URL} + `('$description', ({ loading, orgsData, emptyStateSvg, emptyStateUrl }) => { + beforeEach(() => { + createComponent({ loading, organizations: orgsData }); + }); + + it(`does ${loading ? '' : 'not '}render loading icon`, () => { + expect(findGlLoading().exists()).toBe(loading); + }); + + it(`does ${orgsData.length ? '' : 'not '}render organizations list`, () => { + expect(findOrganizationsList().exists()).toBe(Boolean(orgsData.length)); + }); + + it(`does ${emptyStateSvg ? '' : 'not '}render empty state with SVG`, () => { + expect(findGlEmptyState().exists() && findGlEmptyState().attributes('svgpath')).toBe( + emptyStateSvg, + ); + }); + + it(`does ${emptyStateUrl ? '' : 'not '}render empty state with URL`, () => { + expect( + findGlEmptyState().exists() && findGlEmptyState().attributes('primarybuttonlink'), + ).toBe(emptyStateUrl); + }); + }); +}); diff --git a/spec/frontend/organizations/index/mock_data.js b/spec/frontend/organizations/index/mock_data.js new file mode 100644 index 00000000000..50b20b4f79c --- /dev/null +++ b/spec/frontend/organizations/index/mock_data.js @@ -0,0 +1,3 @@ +export const MOCK_NEW_ORG_URL = 'gitlab.com/organizations/new'; + +export const MOCK_ORG_EMPTY_STATE_SVG = 'illustrations/empty-state/empty-organizations-md.svg'; diff --git a/spec/frontend/organizations/new/components/app_spec.js b/spec/frontend/organizations/new/components/app_spec.js new file mode 100644 index 00000000000..06d30ad6b12 --- /dev/null +++ b/spec/frontend/organizations/new/components/app_spec.js @@ -0,0 +1,113 @@ +import VueApollo from 'vue-apollo'; +import Vue, { nextTick } from 'vue'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import App from '~/organizations/new/components/app.vue'; +import resolvers from '~/organizations/shared/graphql/resolvers'; +import NewEditForm from '~/organizations/shared/components/new_edit_form.vue'; +import { visitUrlWithAlerts } from '~/lib/utils/url_utility'; +import { createOrganizationResponse } from '~/organizations/mock_data'; +import { createAlert } from '~/alert'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +Vue.use(VueApollo); +jest.useFakeTimers(); + +jest.mock('~/lib/utils/url_utility'); +jest.mock('~/alert'); + +describe('OrganizationNewApp', () => { + let wrapper; + let mockApollo; + + const createComponent = ({ mockResolvers = resolvers } = {}) => { + mockApollo = createMockApollo([], mockResolvers); + + wrapper = shallowMountExtended(App, { apolloProvider: mockApollo }); + }; + + const findForm = () => wrapper.findComponent(NewEditForm); + const submitForm = async () => { + findForm().vm.$emit('submit', { name: 'Foo bar', path: 'foo-bar' }); + await nextTick(); + }; + + afterEach(() => { + mockApollo = null; + }); + + it('renders form', () => { + createComponent(); + + expect(findForm().exists()).toBe(true); + }); + + describe('when form is submitted', () => { + describe('when API is loading', () => { + beforeEach(async () => { + const mockResolvers = { + Mutation: { + createOrganization: jest.fn().mockReturnValueOnce(new Promise(() => {})), + }, + }; + + createComponent({ mockResolvers }); + + await submitForm(); + }); + + it('sets `NewEditForm` `loading` prop to `true`', () => { + expect(findForm().props('loading')).toBe(true); + }); + }); + + describe('when API request is successful', () => { + beforeEach(async () => { + createComponent(); + await submitForm(); + jest.runAllTimers(); + await waitForPromises(); + }); + + it('redirects user to organization path', () => { + expect(visitUrlWithAlerts).toHaveBeenCalledWith( + createOrganizationResponse.organization.path, + [ + { + id: 'organization-successfully-created', + title: 'Organization successfully created.', + message: 'You can now start using your new organization.', + variant: 'success', + }, + ], + ); + }); + }); + + describe('when API request is not successful', () => { + const error = new Error(); + + beforeEach(async () => { + const mockResolvers = { + Mutation: { + createOrganization: jest.fn().mockRejectedValueOnce(error), + }, + }; + + createComponent({ mockResolvers }); + await submitForm(); + jest.runAllTimers(); + await waitForPromises(); + }); + + it('displays error alert', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred creating an organization. Please try again.', + error, + captureError: true, + }); + }); + }); + }); +}); diff --git a/spec/frontend/organizations/shared/components/new_edit_form_spec.js b/spec/frontend/organizations/shared/components/new_edit_form_spec.js new file mode 100644 index 00000000000..43c099fbb1c --- /dev/null +++ b/spec/frontend/organizations/shared/components/new_edit_form_spec.js @@ -0,0 +1,112 @@ +import { GlButton, GlInputGroupText, GlTruncate } from '@gitlab/ui'; + +import NewEditForm from '~/organizations/shared/components/new_edit_form.vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; + +describe('NewEditForm', () => { + let wrapper; + + const defaultProvide = { + organizationsPath: '/-/organizations', + rootUrl: 'http://127.0.0.1:3000/', + }; + + const defaultPropsData = { + loading: false, + }; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = mountExtended(NewEditForm, { + attachTo: document.body, + provide: defaultProvide, + propsData: { + ...defaultPropsData, + ...propsData, + }, + }); + }; + + const findNameField = () => wrapper.findByLabelText('Organization name'); + const findUrlField = () => wrapper.findByLabelText('Organization URL'); + const submitForm = async () => { + await wrapper.findByRole('button', { name: 'Create organization' }).trigger('click'); + }; + + it('renders `Organization name` field', () => { + createComponent(); + + expect(findNameField().exists()).toBe(true); + }); + + it('renders `Organization URL` field', () => { + createComponent(); + + expect(wrapper.findComponent(GlInputGroupText).findComponent(GlTruncate).props('text')).toBe( + 'http://127.0.0.1:3000/-/organizations/', + ); + expect(findUrlField().exists()).toBe(true); + }); + + describe('when form is submitted without filling in required fields', () => { + beforeEach(async () => { + createComponent(); + await submitForm(); + }); + + it('shows error messages', () => { + expect(wrapper.findByText('Organization name is required.').exists()).toBe(true); + expect(wrapper.findByText('Organization URL is required.').exists()).toBe(true); + }); + }); + + describe('when form is submitted successfully', () => { + beforeEach(async () => { + createComponent(); + + await findNameField().setValue('Foo bar'); + await findUrlField().setValue('foo-bar'); + await submitForm(); + }); + + it('emits `submit` event with form values', () => { + expect(wrapper.emitted('submit')).toEqual([[{ name: 'Foo bar', path: 'foo-bar' }]]); + }); + }); + + describe('when `Organization URL` has not been manually set', () => { + beforeEach(async () => { + createComponent(); + + await findNameField().setValue('Foo bar'); + await submitForm(); + }); + + it('sets `Organization URL` when typing in `Organization name`', () => { + expect(findUrlField().element.value).toBe('foo-bar'); + }); + }); + + describe('when `Organization URL` has been manually set', () => { + beforeEach(async () => { + createComponent(); + + await findUrlField().setValue('foo-bar-baz'); + await findNameField().setValue('Foo bar'); + await submitForm(); + }); + + it('does not modify `Organization URL` when typing in `Organization name`', () => { + expect(findUrlField().element.value).toBe('foo-bar-baz'); + }); + }); + + describe('when `loading` prop is `true`', () => { + beforeEach(() => { + createComponent({ propsData: { loading: true } }); + }); + + it('shows button with loading icon', () => { + expect(wrapper.findComponent(GlButton).props('loading')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap index 7f26ed778a5..6af9e38192e 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap @@ -9,51 +9,44 @@ exports[`packages_list_app renders 1`] = ` <infrastructure-search-stub /> <div> <section - class="empty-state gl-display-flex gl-flex-direction-column gl-text-center" + class="gl-display-flex gl-empty-state gl-flex-direction-column gl-text-center" > <div class="gl-max-w-full" > - <div - class="svg-250 svg-content" - > - <img - alt="" - class="gl-dark-invert-keep-hue gl-max-w-full" - role="img" - src="helpSvg" - /> - </div> + <img + alt="" + class="gl-dark-invert-keep-hue gl-max-w-full" + height="144" + role="img" + src="helpSvg" + /> </div> <div - class="gl-m-auto gl-max-w-full" + class="gl-empty-state-content gl-m-auto gl-mx-auto gl-my-0 gl-p-5" data-testid="gl-empty-state-content" > - <div - class="gl-mx-auto gl-my-0 gl-p-5" + <h1 + class="gl-font-size-h-display gl-line-height-36 gl-mb-0 gl-mt-0 h4" > - <h1 - class="gl-font-size-h-display gl-line-height-36 h4" - > - There are no packages yet - </h1> - <p - class="gl-mt-3" + There are no packages yet + </h1> + <p + class="gl-mb-0 gl-mt-4" + > + Learn how to + <b-link-stub + class="gl-link" + href="helpUrl" + target="_blank" > - Learn how to - <b-link-stub - class="gl-link" - href="helpUrl" - target="_blank" - > - publish and share your packages - </b-link-stub> - with GitLab. - </p> - <div - class="gl-display-flex gl-flex-wrap gl-justify-content-center" - /> - </div> + publish and share your packages + </b-link-stub> + with GitLab. + </p> + <div + class="gl-display-flex gl-flex-wrap gl-justify-content-center gl-mt-5" + /> </div> </section> </div> diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap index 05a5a718e52..17acf7381c0 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap @@ -78,7 +78,7 @@ exports[`PypiInstallation renders all the messages 1`] = ` tabindex="-1" > <span - class="gl-bg-gray-50! gl-new-dropdown-item-content" + class="gl-new-dropdown-item-content" > <svg aria-hidden="true" diff --git a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js index 0037934cbc5..be50858bc88 100644 --- a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js +++ b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js @@ -29,6 +29,7 @@ describe('BulkImportsHistoryApp', () => { source_full_path: 'top-level-group-12', destination_full_path: 'h5bp/top-level-group-12', destination_name: 'top-level-group-12', + destination_slug: 'top-level-group-12', destination_namespace: 'h5bp', created_at: '2021-07-08T10:03:44.743Z', failures: [], @@ -40,6 +41,7 @@ describe('BulkImportsHistoryApp', () => { entity_type: 'project', source_full_path: 'autodevops-demo', destination_name: 'autodevops-demo', + destination_slug: 'autodevops-demo', destination_full_path: 'some-group/autodevops-demo', destination_namespace: 'flightjs', parent_id: null, @@ -141,6 +143,25 @@ describe('BulkImportsHistoryApp', () => { ); }); + it('resets page to 1 when page size is changed', async () => { + const NEW_PAGE_SIZE = 4; + + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent(); + await axios.waitForAll(); + wrapper.findComponent(PaginationBar).vm.$emit('set-page', 2); + await axios.waitForAll(); + mock.resetHistory(); + + wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE); + await axios.waitForAll(); + + expect(mock.history.get.length).toBe(1); + expect(mock.history.get[0].params).toStrictEqual( + expect.objectContaining({ per_page: NEW_PAGE_SIZE, page: 1 }), + ); + }); + it('sets up the local storage sync correctly', async () => { const NEW_PAGE_SIZE = 4; @@ -154,7 +175,7 @@ describe('BulkImportsHistoryApp', () => { expect(findLocalStorageSync().props('value')).toBe(NEW_PAGE_SIZE); }); - it('renders correct url for destination group when relative_url is empty', async () => { + it('renders link to destination_full_path for destination group', async () => { createComponent({ shallow: false }); await axios.waitForAll(); @@ -163,14 +184,17 @@ describe('BulkImportsHistoryApp', () => { ); }); - it('renders loading icon when destination namespace is not defined', async () => { + it('renders destination as text when destination_full_path is not defined', async () => { const RESPONSE = [{ ...DUMMY_RESPONSE[0], destination_full_path: null }]; mock.onGet(API_URL).reply(HTTP_STATUS_OK, RESPONSE, DEFAULT_HEADERS); createComponent({ shallow: false }); await axios.waitForAll(); - expect(wrapper.find('tbody tr').findComponent(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.find('tbody tr a').exists()).toBe(false); + expect(wrapper.find('tbody tr span').text()).toBe( + `${DUMMY_RESPONSE[0].destination_namespace}/${DUMMY_RESPONSE[0].destination_slug}/`, + ); }); it('adds slash to group urls', async () => { diff --git a/spec/frontend/pages/projects/find_file/ref_switcher/ref_switcher_utils_spec.js b/spec/frontend/pages/projects/find_file/ref_switcher/ref_switcher_utils_spec.js index ef2e5d779d8..62eae19ce4c 100644 --- a/spec/frontend/pages/projects/find_file/ref_switcher/ref_switcher_utils_spec.js +++ b/spec/frontend/pages/projects/find_file/ref_switcher/ref_switcher_utils_spec.js @@ -10,7 +10,7 @@ describe('generateRefDestinationPath', () => { ${`${projectRootPath}/-/find_file/flightjs/Flight`} | ${`http://test.host/${projectRootPath}/-/find_file/${selectedRef}`} ${`${projectRootPath}/-/find_file/test/test1?test=something`} | ${`http://test.host/${projectRootPath}/-/find_file/${selectedRef}?test=something`} ${`${projectRootPath}/-/find_file/simpletest?test=something&test=it`} | ${`http://test.host/${projectRootPath}/-/find_file/${selectedRef}?test=something&test=it`} - ${`${projectRootPath}/-/find_file/some_random_char?test=something&test[]=it&test[]=is`} | ${`http://test.host/${projectRootPath}/-/find_file/${selectedRef}?test=something&test[]=it&test[]=is`} + ${`${projectRootPath}/-/find_file/some_random_char?test=something&test[]=it&test[]=is`} | ${`http://test.host/${projectRootPath}/-/find_file/${selectedRef}?test=something&test%5B%5D=it&test%5B%5D=is`} `('generates the correct destination path for $currentPath', ({ currentPath, result }) => { setWindowLocation(currentPath); expect(generateRefDestinationPath(selectedRef, '/-/find_file')).toBe(result); @@ -36,4 +36,11 @@ describe('generateRefDestinationPath', () => { `http://test.host/${projectRootPath}/-/find_file/flightjs/Flight`, ); }); + + it('removes ref_type from the destination url if ref is neither a branch or tag', () => { + setWindowLocation(`${projectRootPath}/-/find_file/somebranch?ref_type=heads`); + expect(generateRefDestinationPath('8e90e533', '/-/find_file')).toBe( + `http://test.host/${projectRootPath}/-/find_file/8e90e533`, + ); + }); }); diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js index f5a7dfe6d11..50d09481b93 100644 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js @@ -1,6 +1,5 @@ -import { GlIcon } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { trimText } from 'helpers/text_helper'; import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue'; @@ -21,15 +20,15 @@ describe('Interval Pattern Input Component', () => { const everyDayKey = 'everyDay'; const cronIntervalNotInPreset = `0 12 * * *`; - const findEveryDayRadio = () => wrapper.find(`[data-testid=${everyDayKey}]`); - const findEveryWeekRadio = () => wrapper.find('[data-testid="everyWeek"]'); - const findEveryMonthRadio = () => wrapper.find('[data-testid="everyMonth"]'); - const findCustomRadio = () => wrapper.find(`[data-testid="${customKey}"]`); + const findEveryDayRadio = () => wrapper.findByTestId(everyDayKey); + const findEveryWeekRadio = () => wrapper.findByTestId('everyWeek'); + const findEveryMonthRadio = () => wrapper.findByTestId('everyMonth'); + const findCustomRadio = () => wrapper.findByTestId(customKey); const findCustomInput = () => wrapper.find('#schedule_cron'); const findAllLabels = () => wrapper.findAll('label'); const findSelectedRadio = () => wrapper.findAll('input[type="radio"]').wrappers.find((x) => x.element.checked); - const findIcon = () => wrapper.findComponent(GlIcon); + const findIcon = () => wrapper.findByTestId('daily-limit'); const findSelectedRadioKey = () => findSelectedRadio()?.attributes('data-testid'); const selectEveryDayRadio = () => findEveryDayRadio().setChecked(true); const selectEveryWeekRadio = () => findEveryWeekRadio().setChecked(true); @@ -37,7 +36,7 @@ describe('Interval Pattern Input Component', () => { const selectCustomRadio = () => findCustomRadio().setChecked(true); const createWrapper = (props = {}, data = {}) => { - wrapper = mount(IntervalPatternInput, { + wrapper = mountExtended(IntervalPatternInput, { propsData: { ...props }, data() { return { @@ -132,7 +131,7 @@ describe('Interval Pattern Input Component', () => { 'Every day (at 4:00am)', 'Every week (Monday at 4:00am)', 'Every month (Day 1 at 4:00am)', - 'Custom (Learn more.)', + 'Custom', ]); }); }); diff --git a/spec/frontend/performance_bar/components/request_warning_spec.js b/spec/frontend/performance_bar/components/request_warning_spec.js index 7b6d8ff695d..a4f0d388e33 100644 --- a/spec/frontend/performance_bar/components/request_warning_spec.js +++ b/spec/frontend/performance_bar/components/request_warning_spec.js @@ -1,6 +1,9 @@ +import Vue from 'vue'; import { shallowMount } from '@vue/test-utils'; import RequestWarning from '~/performance_bar/components/request_warning.vue'; +Vue.config.ignoredElements = ['gl-emoji']; + describe('request warning', () => { let wrapper; const htmlId = 'request-123'; @@ -16,8 +19,8 @@ describe('request warning', () => { }); it('adds a warning emoji with the correct ID', () => { - expect(wrapper.find('span[id]').attributes('id')).toEqual(htmlId); - expect(wrapper.find('span[id] gl-emoji').element.dataset.name).toEqual('warning'); + expect(wrapper.find('span gl-emoji[id]').attributes('id')).toEqual(htmlId); + expect(wrapper.find('span gl-emoji[id]').element.dataset.name).toEqual('warning'); }); }); diff --git a/spec/frontend/performance_bar/index_spec.js b/spec/frontend/performance_bar/index_spec.js index 1849c373326..cfc752655bd 100644 --- a/spec/frontend/performance_bar/index_spec.js +++ b/spec/frontend/performance_bar/index_spec.js @@ -1,3 +1,4 @@ +import Vue from 'vue'; import MockAdapter from 'axios-mock-adapter'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import axios from '~/lib/utils/axios_utils'; @@ -6,6 +7,8 @@ import '~/performance_bar/components/performance_bar_app.vue'; import performanceBar from '~/performance_bar'; import PerformanceBarService from '~/performance_bar/services/performance_bar_service'; +Vue.config.ignoredElements = ['gl-emoji']; + jest.mock('~/performance_bar/performance_bar_log'); describe('performance bar wrapper', () => { 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 479530c1d38..b39644c51eb 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 @@ -24,7 +24,7 @@ exports[`Project remove modal initialized matches the snapshot 1`] = ` <gl-button-stub buttontextclasses="" category="primary" - data-qa-selector="delete_button" + data-testid="delete-button" icon="" size="medium" variant="danger" diff --git a/spec/frontend/projects/project_find_file_spec.js b/spec/frontend/projects/project_find_file_spec.js index efc9d411a98..9dae2bdc5bb 100644 --- a/spec/frontend/projects/project_find_file_spec.js +++ b/spec/frontend/projects/project_find_file_spec.js @@ -30,12 +30,13 @@ describe('ProjectFindFile', () => { let element; let mock; - const getProjectFindFileInstance = () => - new ProjectFindFile(element, { - url: FILE_FIND_URL, + const getProjectFindFileInstance = (extraOptions) => { + return new ProjectFindFile(element, { treeUrl: FIND_TREE_URL, blobUrlTemplate: BLOB_URL_TEMPLATE, + ...extraOptions, }); + }; const findFiles = () => element @@ -64,9 +65,6 @@ describe('ProjectFindFile', () => { HTTP_STATUS_OK, files.map((x) => x.path), ); - getProjectFindFileInstance(); // This triggers a load / axios call + subsequent render in the constructor - - return waitForPromises(); }); afterEach(() => { @@ -75,19 +73,44 @@ describe('ProjectFindFile', () => { sanitize.mockClear(); }); - it('loads and renders elements from remote server', () => { - expect(findFiles()).toEqual( - files.map(({ path, escaped }) => ({ - text: path, - href: `${BLOB_URL_TEMPLATE}/${escaped}`, - })), - ); + describe('rendering without refType', () => { + beforeEach(() => { + const instance = getProjectFindFileInstance(); + instance.load(FILE_FIND_URL); // axios call + subsequent render + return waitForPromises(); + }); + + it('loads and renders elements from remote server', () => { + expect(findFiles()).toEqual( + files.map(({ path, escaped }) => ({ + text: path, + href: `${BLOB_URL_TEMPLATE}/${escaped}`, + })), + ); + }); + + it('sanitizes search text', () => { + const searchText = element.find('.file-finder-input').val(); + + expect(sanitize).toHaveBeenCalledTimes(1); + expect(sanitize).toHaveBeenCalledWith(searchText); + }); }); - it('sanitizes search text', () => { - const searchText = element.find('.file-finder-input').val(); + describe('with refType option', () => { + beforeEach(() => { + const instance = getProjectFindFileInstance({ refType: 'heads' }); + instance.load(FILE_FIND_URL); // axios call + subsequent render + return waitForPromises(); + }); - expect(sanitize).toHaveBeenCalledTimes(1); - expect(sanitize).toHaveBeenCalledWith(searchText); + it('loads and renders elements from remote server', () => { + expect(findFiles()).toEqual( + files.map(({ path, escaped }) => ({ + text: path, + href: `${BLOB_URL_TEMPLATE}/${escaped}?ref_type=heads`, + })), + ); + }); }); }); diff --git a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js index 0ed2e51e8c3..7c8cc1bb38d 100644 --- a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js +++ b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js @@ -14,11 +14,13 @@ import AccessDropdown, { i18n } from '~/projects/settings/components/access_drop import { ACCESS_LEVELS, LEVEL_TYPES } from '~/projects/settings/constants'; jest.mock('~/projects/settings/api/access_dropdown_api', () => ({ - getGroups: jest.fn().mockResolvedValue([ - { id: 4, name: 'group4' }, - { id: 5, name: 'group5' }, - { id: 6, name: 'group6' }, - ]), + getGroups: jest.fn().mockResolvedValue({ + data: [ + { id: 4, name: 'group4' }, + { id: 5, name: 'group5' }, + { id: 6, name: 'group6' }, + ], + }), getUsers: jest.fn().mockResolvedValue({ data: [ { id: 7, name: 'user7' }, diff --git a/spec/frontend/ref/components/ambiguous_ref_modal_spec.js b/spec/frontend/ref/components/ambiguous_ref_modal_spec.js new file mode 100644 index 00000000000..bb3fd0fa1f0 --- /dev/null +++ b/spec/frontend/ref/components/ambiguous_ref_modal_spec.js @@ -0,0 +1,64 @@ +import { GlModal, GlSprintf } from '@gitlab/ui'; +import AmbiguousRefModal from '~/ref/components/ambiguous_ref_modal.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent, RENDER_ALL_SLOTS_TEMPLATE } from 'helpers/stub_component'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { TEST_HOST } from 'spec/test_constants'; + +jest.mock('~/lib/utils/url_utility'); + +describe('AmbiguousRefModal component', () => { + let wrapper; + const showModalSpy = jest.fn(); + + const createComponent = () => { + wrapper = shallowMountExtended(AmbiguousRefModal, { + propsData: { refName: 'main' }, + stubs: { + GlModal: stubComponent(GlModal, { + methods: { + show: showModalSpy, + }, + template: RENDER_ALL_SLOTS_TEMPLATE, + }), + GlSprintf, + }, + }); + }; + + beforeEach(() => createComponent()); + + const findModal = () => wrapper.findComponent(GlModal); + const findByText = (text) => wrapper.findByText(text); + const findViewTagButton = () => findByText('View tag'); + const findViewBranchButton = () => findByText('View branch'); + + it('renders a GlModal component with the correct props', () => { + expect(showModalSpy).toHaveBeenCalled(); + expect(findModal().props('title')).toBe('Which reference do you want to view?'); + }); + + it('renders a description', () => { + expect(wrapper.text()).toContain('There is a branch and a tag with the same name of main.'); + expect(wrapper.text()).toContain('Which reference would you like to view?'); + }); + + it('renders action buttons', () => { + expect(findViewTagButton().exists()).toBe(true); + expect(findViewBranchButton().exists()).toBe(true); + }); + + describe('when clicking the action buttons', () => { + it('redirects to the tag ref when tag button is clicked', () => { + findViewTagButton().vm.$emit('click'); + + expect(visitUrl).toHaveBeenCalledWith(`${TEST_HOST}/?ref_type=tags`); + }); + + it('redirects to the branch ref when branch button is clicked', () => { + findViewBranchButton().vm.$emit('click'); + + expect(visitUrl).toHaveBeenCalledWith(`${TEST_HOST}/?ref_type=heads`); + }); + }); +}); diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js index 12ca0d053e9..26010a1cfa6 100644 --- a/spec/frontend/ref/components/ref_selector_spec.js +++ b/spec/frontend/ref/components/ref_selector_spec.js @@ -23,13 +23,17 @@ import { REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS, + BRANCH_REF_TYPE_ICON, + TAG_REF_TYPE_ICON, } from '~/ref/constants'; import createStore from '~/ref/stores/'; Vue.use(Vuex); describe('Ref selector component', () => { - const fixtures = { branches, tags, commit }; + const branchRefTypeMock = { name: 'refs/heads/test_branch' }; + const tagRefTypeMock = { name: 'refs/tags/test_tag' }; + const fixtures = { branches: [branchRefTypeMock, tagRefTypeMock, ...branches], tags, commit }; const projectId = '8'; const totalBranchesCount = 123; @@ -614,6 +618,19 @@ describe('Ref selector component', () => { }); it.each` + selectedBranch | icon + ${branchRefTypeMock.name} | ${BRANCH_REF_TYPE_ICON} + ${tagRefTypeMock.name} | ${TAG_REF_TYPE_ICON} + ${branches[0].name} | ${''} + `('renders the correct icon for the selected ref', async ({ selectedBranch, icon }) => { + createComponent(); + findListbox().vm.$emit('select', selectedBranch); + await nextTick(); + + expect(findListbox().props('icon')).toBe(icon); + }); + + it.each` enabledRefType | findVisibleSection | findHiddenSections ${REF_TYPE_BRANCHES} | ${findBranchesSection} | ${[findTagsSection, findCommitsSection]} ${REF_TYPE_TAGS} | ${findTagsSection} | ${[findBranchesSection, findCommitsSection]} diff --git a/spec/frontend/ref/init_ambiguous_ref_modal_spec.js b/spec/frontend/ref/init_ambiguous_ref_modal_spec.js new file mode 100644 index 00000000000..322978f598f --- /dev/null +++ b/spec/frontend/ref/init_ambiguous_ref_modal_spec.js @@ -0,0 +1,48 @@ +import Vue from 'vue'; +import initAmbiguousRefModal from '~/ref/init_ambiguous_ref_modal'; +import AmbiguousRefModal from '~/ref/components/ambiguous_ref_modal.vue'; +import { setHTMLFixture } from 'helpers/fixtures'; +import setWindowLocation from 'helpers/set_window_location_helper'; + +const generateFixture = (isAmbiguous) => { + return `<div id="js-ambiguous-ref-modal" data-ambiguous="${isAmbiguous}" data-ref="main"></div>`; +}; + +const init = ({ isAmbiguous, htmlFixture = generateFixture(isAmbiguous) }) => { + setHTMLFixture(htmlFixture); + initAmbiguousRefModal(); +}; + +beforeEach(() => jest.spyOn(Vue, 'extend')); + +describe('initAmbiguousRefModal', () => { + it('inits a new AmbiguousRefModal Vue component', () => { + init({ isAmbiguous: true }); + expect(Vue.extend).toHaveBeenCalledWith(AmbiguousRefModal); + }); + + it.each(['<div></div>', '', null])( + 'does not render a new AmbiguousRefModal Vue component when root element is %s', + (htmlFixture) => { + init({ isAmbiguous: true, htmlFixture }); + + expect(Vue.extend).not.toHaveBeenCalledWith(AmbiguousRefModal); + }, + ); + + it('does not render a new AmbiguousRefModal Vue component "ambiguous" data attribute is "false"', () => { + init({ isAmbiguous: false }); + + expect(Vue.extend).not.toHaveBeenCalledWith(AmbiguousRefModal); + }); + + it.each(['tags', 'heads'])( + 'does not render a new AmbiguousRefModal Vue component when "ref_type" param is set to %s', + (refType) => { + setWindowLocation(`?ref_type=${refType}`); + init({ isAmbiguous: true }); + + expect(Vue.extend).not.toHaveBeenCalledWith(AmbiguousRefModal); + }, + ); +}); diff --git a/spec/frontend/releases/components/tag_field_new_spec.js b/spec/frontend/releases/components/tag_field_new_spec.js index 3468338b8a7..e155cdbbd3c 100644 --- a/spec/frontend/releases/components/tag_field_new_spec.js +++ b/spec/frontend/releases/components/tag_field_new_spec.js @@ -1,4 +1,4 @@ -import { GlFormGroup, GlDropdown, GlPopover } from '@gitlab/ui'; +import { GlFormGroup, GlTruncate, GlPopover } from '@gitlab/ui'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; @@ -60,7 +60,7 @@ describe('releases/components/tag_field_new', () => { afterEach(() => mock.restore()); const findTagNameFormGroup = () => wrapper.findComponent(GlFormGroup); - const findTagNameInput = () => wrapper.findComponent(GlDropdown); + const findTagNameInputText = () => wrapper.findComponent(GlTruncate); const findTagNamePopover = () => wrapper.findComponent(GlPopover); const findTagNameSearch = () => wrapper.findComponent(TagSearch); const findTagNameCreate = () => wrapper.findComponent(TagCreate); @@ -99,9 +99,10 @@ describe('releases/components/tag_field_new', () => { it("updates the store's release.tagName property", async () => { findTagNameCreate().vm.$emit('change', NONEXISTENT_TAG_NAME); await findTagNameCreate().vm.$emit('create'); - expect(store.state.editNew.release.tagName).toBe(NONEXISTENT_TAG_NAME); - expect(findTagNameInput().props('text')).toBe(NONEXISTENT_TAG_NAME); + + const text = findTagNameInputText(); + expect(text.props('text')).toBe(NONEXISTENT_TAG_NAME); }); }); @@ -114,8 +115,10 @@ describe('releases/components/tag_field_new', () => { }); it("updates the store's release.tagName property", () => { + const buttonText = findTagNameInputText(); expect(store.state.editNew.release.tagName).toBe(updatedTagName); - expect(findTagNameInput().props('text')).toBe(updatedTagName); + + expect(buttonText.props('text')).toBe(updatedTagName); }); it('hides the "Create from" field', () => { diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js index 1d164b9f5c1..d18437ccec3 100644 --- a/spec/frontend/releases/stores/modules/detail/actions_spec.js +++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js @@ -1,9 +1,11 @@ import { cloneDeep } from 'lodash'; import originalOneReleaseForEditingQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release_for_editing.query.graphql.json'; import testAction from 'helpers/vuex_action_helper'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { getTag } from '~/api/tags_api'; import { createAlert } from '~/alert'; import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import AccessorUtilities from '~/lib/utils/accessor'; import { s__ } from '~/locale'; import { ASSET_LINK_TYPE } from '~/releases/constants'; import createReleaseAssetLinkMutation from '~/releases/graphql/mutations/create_release_link.mutation.graphql'; @@ -20,6 +22,7 @@ jest.mock('~/api/tags_api'); jest.mock('~/alert'); +jest.mock('~/lib/utils/accessor'); jest.mock('~/lib/utils/url_utility', () => ({ redirectTo: jest.fn(), joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths, @@ -34,78 +37,203 @@ jest.mock('~/releases/util', () => ({ })); describe('Release edit/new actions', () => { + useLocalStorageSpy(); + let state; let releaseResponse; let error; const projectPath = 'test/project-path'; + const draftActions = [{ type: 'saveDraftRelease' }, { type: 'saveDraftCreateFrom' }]; const setupState = (updates = {}) => { state = { ...createState({ projectPath, projectId: '18', - isExistingRelease: true, + isExistingRelease: false, tagName: releaseResponse.tag_name, releasesPagePath: 'path/to/releases/page', markdownDocsPath: 'path/to/markdown/docs', markdownPreviewPath: 'path/to/markdown/preview', }), + localStorageKey: `${projectPath}/release/new`, + localStorageCreateFromKey: `${projectPath}/release/new/createFrom`, ...updates, }; }; beforeEach(() => { + AccessorUtilities.canUseLocalStorage.mockReturnValue(true); releaseResponse = cloneDeep(originalOneReleaseForEditingQueryResponse); gon.api_version = 'v4'; error = new Error('Yikes!'); createAlert.mockClear(); }); + afterEach(() => { + jest.clearAllMocks(); + }); + describe('when creating a new release', () => { beforeEach(() => { setupState({ isExistingRelease: false }); }); describe('initializeRelease', () => { - it(`commits ${types.INITIALIZE_EMPTY_RELEASE}`, () => { - testAction(actions.initializeRelease, undefined, state, [ - { type: types.INITIALIZE_EMPTY_RELEASE }, - ]); + it('dispatches loadDraftRelease', () => { + return testAction({ + action: actions.initializeRelease, + state, + expectedMutations: [], + expectedActions: [{ type: 'loadDraftRelease' }], + }); + }); + }); + + describe('loadDraftRelease', () => { + it(`with no saved release, it commits ${types.INITIALIZE_EMPTY_RELEASE}`, () => { + testAction({ + action: actions.loadDraftRelease, + state, + expectedMutations: [{ type: types.INITIALIZE_EMPTY_RELEASE }], + }); + }); + + it('with saved release, loads the release from local storage', () => { + const release = { + tagName: 'v1.3', + tagMessage: 'hello', + name: '', + description: '', + milestones: [], + groupMilestones: [], + releasedAt: new Date(), + assets: { + links: [], + }, + }; + const createFrom = 'main'; + + window.localStorage.setItem(`${state.projectPath}/release/new`, JSON.stringify(release)); + window.localStorage.setItem( + `${state.projectPath}/release/new/createFrom`, + JSON.stringify(createFrom), + ); + + return testAction({ + action: actions.loadDraftRelease, + state, + expectedMutations: [ + { type: types.INITIALIZE_RELEASE, payload: release }, + { type: types.UPDATE_CREATE_FROM, payload: createFrom }, + ], + }); + }); + }); + + describe('clearDraftRelease', () => { + it('calls window.localStorage.clear', () => { + return testAction({ action: actions.clearDraftRelease, state }).then(() => { + expect(window.localStorage.removeItem).toHaveBeenCalledTimes(2); + expect(window.localStorage.removeItem).toHaveBeenCalledWith(state.localStorageKey); + expect(window.localStorage.removeItem).toHaveBeenCalledWith( + state.localStorageCreateFromKey, + ); + }); + }); + }); + + describe('saveDraftCreateFrom', () => { + it('saves the create from to local storage', () => { + const createFrom = 'main'; + setupState({ createFrom }); + return testAction({ action: actions.saveDraftCreateFrom, state }).then(() => { + expect(window.localStorage.setItem).toHaveBeenCalledTimes(1); + expect(window.localStorage.setItem).toHaveBeenCalledWith( + state.localStorageCreateFromKey, + JSON.stringify(createFrom), + ); + }); + }); + }); + + describe('saveDraftRelease', () => { + let release; + + beforeEach(() => { + release = { + tagName: 'v1.3', + tagMessage: 'hello', + name: '', + description: '', + milestones: [], + groupMilestones: [], + releasedAt: new Date(), + assets: { + links: [], + }, + }; + }); + + it('saves the draft release to local storage', () => { + setupState({ release, releasedAtChanged: true }); + + return testAction({ action: actions.saveDraftRelease, state }).then(() => { + expect(window.localStorage.setItem).toHaveBeenCalledTimes(1); + expect(window.localStorage.setItem).toHaveBeenCalledWith( + state.localStorageKey, + JSON.stringify(state.release), + ); + }); + }); + + it('ignores the released at date if it has not been changed', () => { + setupState({ release, releasedAtChanged: false }); + + return testAction({ action: actions.saveDraftRelease, state }).then(() => { + expect(window.localStorage.setItem).toHaveBeenCalledTimes(1); + expect(window.localStorage.setItem).toHaveBeenCalledWith( + state.localStorageKey, + JSON.stringify({ ...state.release, releasedAt: undefined }), + ); + }); }); }); describe('saveRelease', () => { it(`commits ${types.REQUEST_SAVE_RELEASE} and then dispatched "createRelease"`, () => { - testAction( - actions.saveRelease, - undefined, + testAction({ + action: actions.saveRelease, state, - [{ type: types.REQUEST_SAVE_RELEASE }], - [{ type: 'createRelease' }], - ); + expectedMutations: [{ type: types.REQUEST_SAVE_RELEASE }], + expectedActions: [{ type: 'createRelease' }], + }); }); }); }); describe('when editing an existing release', () => { - beforeEach(setupState); + beforeEach(() => setupState({ isExistingRelease: true })); describe('initializeRelease', () => { it('dispatches "fetchRelease"', () => { - testAction(actions.initializeRelease, undefined, state, [], [{ type: 'fetchRelease' }]); + testAction({ + action: actions.initializeRelease, + state, + expectedActions: [{ type: 'fetchRelease' }], + }); }); }); describe('saveRelease', () => { it(`commits ${types.REQUEST_SAVE_RELEASE} and then dispatched "updateRelease"`, () => { - testAction( - actions.saveRelease, - undefined, + testAction({ + action: actions.saveRelease, state, - [{ type: types.REQUEST_SAVE_RELEASE }], - [{ type: 'updateRelease' }], - ); + expectedMutations: [{ type: types.REQUEST_SAVE_RELEASE }], + expectedActions: [{ type: 'updateRelease' }], + }); }); }); }); @@ -120,15 +248,19 @@ describe('Release edit/new actions', () => { }); it(`commits ${types.REQUEST_RELEASE} and then commits ${types.RECEIVE_RELEASE_SUCCESS} with the converted release object`, () => { - return testAction(actions.fetchRelease, undefined, state, [ - { - type: types.REQUEST_RELEASE, - }, - { - type: types.RECEIVE_RELEASE_SUCCESS, - payload: convertOneReleaseGraphQLResponse(releaseResponse).data, - }, - ]); + return testAction({ + action: actions.fetchRelease, + state, + expectedMutations: [ + { + type: types.REQUEST_RELEASE, + }, + { + type: types.RECEIVE_RELEASE_SUCCESS, + payload: convertOneReleaseGraphQLResponse(releaseResponse).data, + }, + ], + }); }); }); @@ -138,15 +270,19 @@ describe('Release edit/new actions', () => { }); it(`commits ${types.REQUEST_RELEASE} and then commits ${types.RECEIVE_RELEASE_ERROR} with an error object`, () => { - return testAction(actions.fetchRelease, undefined, state, [ - { - type: types.REQUEST_RELEASE, - }, - { - type: types.RECEIVE_RELEASE_ERROR, - payload: expect.any(Error), - }, - ]); + return testAction({ + action: actions.fetchRelease, + state, + expectedMutations: [ + { + type: types.REQUEST_RELEASE, + }, + { + type: types.RECEIVE_RELEASE_ERROR, + payload: expect.any(Error), + }, + ], + }); }); it(`shows an alert message`, () => { @@ -163,89 +299,140 @@ describe('Release edit/new actions', () => { describe('updateReleaseTagName', () => { it(`commits ${types.UPDATE_RELEASE_TAG_NAME} with the updated tag name`, () => { const newTag = 'updated-tag-name'; - return testAction(actions.updateReleaseTagName, newTag, state, [ - { type: types.UPDATE_RELEASE_TAG_NAME, payload: newTag }, - ]); + return testAction({ + action: actions.updateReleaseTagName, + payload: newTag, + state, + expectedMutations: [{ type: types.UPDATE_RELEASE_TAG_NAME, payload: newTag }], + expectedActions: draftActions, + }); + }); + it('does not save drafts when editing', () => { + const newTag = 'updated-tag-name'; + return testAction({ + action: actions.updateReleaseTagName, + payload: newTag, + state: { ...state, isExistingRelease: true }, + expectedMutations: [{ type: types.UPDATE_RELEASE_TAG_NAME, payload: newTag }], + }); }); }); describe('updateReleaseTagMessage', () => { it(`commits ${types.UPDATE_RELEASE_TAG_MESSAGE} with the updated tag name`, () => { const newMessage = 'updated-tag-message'; - return testAction(actions.updateReleaseTagMessage, newMessage, state, [ - { type: types.UPDATE_RELEASE_TAG_MESSAGE, payload: newMessage }, - ]); + return testAction({ + action: actions.updateReleaseTagMessage, + payload: newMessage, + state, + expectedMutations: [{ type: types.UPDATE_RELEASE_TAG_MESSAGE, payload: newMessage }], + expectedActions: draftActions, + }); }); }); describe('updateReleasedAt', () => { it(`commits ${types.UPDATE_RELEASED_AT} with the updated date`, () => { const newDate = new Date(); - return testAction(actions.updateReleasedAt, newDate, state, [ - { type: types.UPDATE_RELEASED_AT, payload: newDate }, - ]); + return testAction({ + action: actions.updateReleasedAt, + payload: newDate, + state, + expectedMutations: [{ type: types.UPDATE_RELEASED_AT, payload: newDate }], + expectedActions: draftActions, + }); }); }); describe('updateCreateFrom', () => { it(`commits ${types.UPDATE_CREATE_FROM} with the updated ref`, () => { const newRef = 'my-feature-branch'; - return testAction(actions.updateCreateFrom, newRef, state, [ - { type: types.UPDATE_CREATE_FROM, payload: newRef }, - ]); + return testAction({ + action: actions.updateCreateFrom, + payload: newRef, + state, + expectedMutations: [{ type: types.UPDATE_CREATE_FROM, payload: newRef }], + expectedActions: draftActions, + }); }); }); describe('updateShowCreateFrom', () => { it(`commits ${types.UPDATE_SHOW_CREATE_FROM} with the updated ref`, () => { const newRef = 'my-feature-branch'; - return testAction(actions.updateShowCreateFrom, newRef, state, [ - { type: types.UPDATE_SHOW_CREATE_FROM, payload: newRef }, - ]); + return testAction({ + action: actions.updateShowCreateFrom, + payload: newRef, + state, + expectedMutations: [{ type: types.UPDATE_SHOW_CREATE_FROM, payload: newRef }], + }); }); }); describe('updateReleaseTitle', () => { it(`commits ${types.UPDATE_RELEASE_TITLE} with the updated release title`, () => { const newTitle = 'The new release title'; - return testAction(actions.updateReleaseTitle, newTitle, state, [ - { type: types.UPDATE_RELEASE_TITLE, payload: newTitle }, - ]); + return testAction({ + action: actions.updateReleaseTitle, + payload: newTitle, + state, + expectedMutations: [{ type: types.UPDATE_RELEASE_TITLE, payload: newTitle }], + expectedActions: draftActions, + }); }); }); describe('updateReleaseNotes', () => { it(`commits ${types.UPDATE_RELEASE_NOTES} with the updated release notes`, () => { const newReleaseNotes = 'The new release notes'; - return testAction(actions.updateReleaseNotes, newReleaseNotes, state, [ - { type: types.UPDATE_RELEASE_NOTES, payload: newReleaseNotes }, - ]); + return testAction({ + action: actions.updateReleaseNotes, + payload: newReleaseNotes, + state, + expectedMutations: [{ type: types.UPDATE_RELEASE_NOTES, payload: newReleaseNotes }], + expectedActions: draftActions, + }); }); }); describe('updateReleaseMilestones', () => { it(`commits ${types.UPDATE_RELEASE_MILESTONES} with the updated release milestones`, () => { const newReleaseMilestones = ['v0.0', 'v0.1']; - return testAction(actions.updateReleaseMilestones, newReleaseMilestones, state, [ - { type: types.UPDATE_RELEASE_MILESTONES, payload: newReleaseMilestones }, - ]); + return testAction({ + action: actions.updateReleaseMilestones, + payload: newReleaseMilestones, + state, + expectedMutations: [ + { type: types.UPDATE_RELEASE_MILESTONES, payload: newReleaseMilestones }, + ], + expectedActions: draftActions, + }); }); }); 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 }, - ]); + return testAction({ + action: actions.updateReleaseGroupMilestones, + payload: newReleaseGroupMilestones, + state, + expectedMutations: [ + { type: types.UPDATE_RELEASE_GROUP_MILESTONES, payload: newReleaseGroupMilestones }, + ], + expectedActions: draftActions, + }); }); }); describe('addEmptyAssetLink', () => { it(`commits ${types.ADD_EMPTY_ASSET_LINK}`, () => { - return testAction(actions.addEmptyAssetLink, undefined, state, [ - { type: types.ADD_EMPTY_ASSET_LINK }, - ]); + return testAction({ + action: actions.addEmptyAssetLink, + state, + expectedMutations: [{ type: types.ADD_EMPTY_ASSET_LINK }], + expectedActions: draftActions, + }); }); }); @@ -256,9 +443,13 @@ describe('Release edit/new actions', () => { newUrl: 'https://example.com/updated', }; - return testAction(actions.updateAssetLinkUrl, params, state, [ - { type: types.UPDATE_ASSET_LINK_URL, payload: params }, - ]); + return testAction({ + action: actions.updateAssetLinkUrl, + payload: params, + state, + expectedMutations: [{ type: types.UPDATE_ASSET_LINK_URL, payload: params }], + expectedActions: draftActions, + }); }); }); @@ -269,9 +460,13 @@ describe('Release edit/new actions', () => { newName: 'Updated link name', }; - return testAction(actions.updateAssetLinkName, params, state, [ - { type: types.UPDATE_ASSET_LINK_NAME, payload: params }, - ]); + return testAction({ + action: actions.updateAssetLinkName, + payload: params, + state, + expectedMutations: [{ type: types.UPDATE_ASSET_LINK_NAME, payload: params }], + expectedActions: draftActions, + }); }); }); @@ -282,30 +477,45 @@ describe('Release edit/new actions', () => { newType: ASSET_LINK_TYPE.RUNBOOK, }; - return testAction(actions.updateAssetLinkType, params, state, [ - { type: types.UPDATE_ASSET_LINK_TYPE, payload: params }, - ]); + return testAction({ + action: actions.updateAssetLinkType, + payload: params, + state, + expectedMutations: [{ type: types.UPDATE_ASSET_LINK_TYPE, payload: params }], + expectedActions: draftActions, + }); }); }); describe('removeAssetLink', () => { it(`commits ${types.REMOVE_ASSET_LINK} with the ID of the asset link to remove`, () => { const idToRemove = 2; - return testAction(actions.removeAssetLink, idToRemove, state, [ - { type: types.REMOVE_ASSET_LINK, payload: idToRemove }, - ]); + return testAction({ + action: actions.removeAssetLink, + payload: idToRemove, + state, + expectedMutations: [{ type: types.REMOVE_ASSET_LINK, payload: idToRemove }], + expectedActions: draftActions, + }); }); }); describe('receiveSaveReleaseSuccess', () => { - it(`commits ${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () => - testAction(actions.receiveSaveReleaseSuccess, releaseResponse, state, [ - { type: types.RECEIVE_SAVE_RELEASE_SUCCESS }, - ])); + it(`commits ${types.RECEIVE_SAVE_RELEASE_SUCCESS} and dispatches clearDraftRelease`, () => + testAction({ + action: actions.receiveSaveReleaseSuccess, + payload: releaseResponse, + state, + expectedMutations: [{ type: types.RECEIVE_SAVE_RELEASE_SUCCESS }], + expectedActions: [{ type: 'clearDraftRelease' }], + })); it("redirects to the release's dedicated page", () => { const { selfUrl } = releaseResponse.data.project.release.links; - actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state }, selfUrl); + actions.receiveSaveReleaseSuccess( + { commit: jest.fn(), state, dispatch: jest.fn() }, + selfUrl, + ); expect(redirectTo).toHaveBeenCalledTimes(1); // eslint-disable-line import/no-deprecated expect(redirectTo).toHaveBeenCalledWith(selfUrl); // eslint-disable-line import/no-deprecated }); @@ -346,18 +556,16 @@ describe('Release edit/new actions', () => { }); it(`dispatches "receiveSaveReleaseSuccess" with the converted release object`, () => { - return testAction( - actions.createRelease, - undefined, + return testAction({ + action: actions.createRelease, state, - [], - [ + expectedActions: [ { type: 'receiveSaveReleaseSuccess', payload: selfUrl, }, ], - ); + }); }); }); @@ -367,12 +575,16 @@ describe('Release edit/new actions', () => { }); it(`commits ${types.RECEIVE_SAVE_RELEASE_ERROR} with an error object`, () => { - return testAction(actions.createRelease, undefined, state, [ - { - type: types.RECEIVE_SAVE_RELEASE_ERROR, - payload: expect.any(Error), - }, - ]); + return testAction({ + action: actions.createRelease, + state, + expectedMutations: [ + { + type: types.RECEIVE_SAVE_RELEASE_ERROR, + payload: expect.any(Error), + }, + ], + }); }); it(`shows an alert message`, () => { @@ -393,12 +605,16 @@ describe('Release edit/new actions', () => { }); it(`commits ${types.RECEIVE_SAVE_RELEASE_ERROR} with an error object`, () => { - return testAction(actions.createRelease, undefined, state, [ - { - type: types.RECEIVE_SAVE_RELEASE_ERROR, - payload: expect.any(Error), - }, - ]); + return testAction({ + action: actions.createRelease, + state, + expectedMutations: [ + { + type: types.RECEIVE_SAVE_RELEASE_ERROR, + payload: expect.any(Error), + }, + ], + }); }); it(`shows an alert message`, () => { @@ -760,16 +976,15 @@ describe('Release edit/new actions', () => { const tag = { message: 'this is a tag' }; getTag.mockResolvedValue({ data: tag }); - await testAction( - actions.fetchTagNotes, - tagName, + await testAction({ + action: actions.fetchTagNotes, + payload: tagName, state, - [ + expectedMutations: [ { type: types.REQUEST_TAG_NOTES }, { type: types.RECEIVE_TAG_NOTES_SUCCESS, payload: tag }, ], - [], - ); + }); expect(getTag).toHaveBeenCalledWith(state.projectId, tagName); }); @@ -777,16 +992,15 @@ describe('Release edit/new actions', () => { error = new Error(); getTag.mockRejectedValue(error); - await testAction( - actions.fetchTagNotes, - tagName, + await testAction({ + action: actions.fetchTagNotes, + payload: tagName, state, - [ + expectedMutations: [ { type: types.REQUEST_TAG_NOTES }, { type: types.RECEIVE_TAG_NOTES_ERROR, payload: error }, ], - [], - ); + }); expect(createAlert).toHaveBeenCalledWith({ message: s__('Release|Unable to fetch the tag notes.'), diff --git a/spec/frontend/releases/stores/modules/detail/getters_spec.js b/spec/frontend/releases/stores/modules/detail/getters_spec.js index 736eae13fb3..24490e19296 100644 --- a/spec/frontend/releases/stores/modules/detail/getters_spec.js +++ b/spec/frontend/releases/stores/modules/detail/getters_spec.js @@ -470,4 +470,20 @@ describe('Release edit/new getters', () => { expect(getters.releasedAtChanged({ originalReleasedAt, release: { releasedAt } })).toBe(true); }); }); + + describe('localStorageKey', () => { + it('returns a string key with the project path for local storage', () => { + const projectPath = 'test/project'; + expect(getters.localStorageKey({ projectPath })).toBe('test/project/release/new'); + }); + }); + + describe('localStorageCreateFromKey', () => { + it('returns a string key with the project path for local storage', () => { + const projectPath = 'test/project'; + expect(getters.localStorageCreateFromKey({ projectPath })).toBe( + 'test/project/release/new/createFrom', + ); + }); + }); }); 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 3f901dc61b8..1a5301c5525 100644 --- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap @@ -1,97 +1,50 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Repository last commit component renders commit widget 1`] = ` -<div - class="commit gl-display-flex gl-p-5 gl-w-full well-segment" +<commit-info-stub + commit="[object Object]" > - <user-avatar-link-stub - class="gl-mr-4 gl-my-2" - imgalt="" - imgcssclasses="" - imgcsswrapperclasses="" - imgsize="32" - imgsrc="https://test.com" - linkhref="/test" - popoveruserid="" - popoverusername="" - tooltipplacement="top" - tooltiptext="" - username="" - /> <div - class="commit-detail flex-list gl-align-items-center gl-display-flex gl-flex-grow-1 gl-justify-content-space-between gl-min-w-0" + class="commit-actions gl-align-items-center gl-display-flex gl-flex-align gl-flex-direction-row" > <div - class="commit-content" - data-qa-selector="commit_content" + class="ci-status-link" > - <gl-link-stub - class="commit-row-message item-title" - href="/commit/123" - > - Commit title - </gl-link-stub> - <div - class="committer" - > - <gl-link-stub - class="commit-author-link js-user-link" - href="/test" - > - Test - </gl-link-stub> - authored - <timeago-tooltip-stub - cssclass="" - datetimeformat="DATE_WITH_TIME_FORMAT" - time="2019-01-01" - tooltipplacement="bottom" - /> - </div> + <ci-badge-link-stub + aria-label="Pipeline: failed" + class="js-commit-pipeline" + details-path="https://test.com/pipeline" + showtooltip="true" + size="md" + status="[object Object]" + uselink="true" + /> </div> - <div - class="gl-flex-grow-1" - /> - <div - class="commit-actions gl-align-items-center gl-display-flex gl-flex-align gl-flex-direction-row" + <gl-button-group-stub + class="gl-ml-4 js-commit-sha-group" > - <div - class="ci-status-link" - > - <ci-badge-link-stub - aria-label="Pipeline: failed" - class="js-commit-pipeline" - details-path="https://test.com/pipeline" - size="lg" - status="[object Object]" - /> - </div> - <gl-button-group-stub - class="gl-ml-4 js-commit-sha-group" + <gl-button-stub + buttontextclasses="" + category="primary" + class="gl-font-monospace" + data-testid="last-commit-id-label" + icon="" + label="true" + size="medium" + variant="default" > - <gl-button-stub - buttontextclasses="" - category="primary" - class="gl-font-monospace" - data-testid="last-commit-id-label" - icon="" - label="true" - size="medium" - variant="default" - > - 12345678 - </gl-button-stub> - <clipboard-button-stub - category="secondary" - class="input-group-text" - size="medium" - text="123456789" - title="Copy commit SHA" - tooltipplacement="top" - variant="default" - /> - </gl-button-group-stub> - </div> + 12345678 + </gl-button-stub> + <clipboard-button-stub + category="secondary" + class="input-group-text" + size="medium" + text="123456789" + title="Copy commit SHA" + tooltipplacement="top" + variant="default" + /> + </gl-button-group-stub> </div> -</div> +</commit-info-stub> `; diff --git a/spec/frontend/repository/components/commit_info_spec.js b/spec/frontend/repository/components/commit_info_spec.js new file mode 100644 index 00000000000..34e941aa858 --- /dev/null +++ b/spec/frontend/repository/components/commit_info_spec.js @@ -0,0 +1,87 @@ +import { nextTick } from 'vue'; +import { GlButton } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CommitInfo from '~/repository/components/commit_info.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; + +let wrapper; +const commit = { + title: 'Commit title', + titleHtml: 'Commit title html', + message: 'Commit message', + authoredDate: '2019-01-01', + authorName: 'Test authorName', + author: { name: 'Test name', avatarUrl: 'https://test.com', webPath: '/test' }, +}; + +const findTextExpander = () => wrapper.findComponent(GlButton); +const findUserLink = () => wrapper.findByText(commit.author.name); +const findUserAvatarLink = () => wrapper.findComponent(UserAvatarLink); +const findAuthorName = () => wrapper.findByText(`${commit.authorName} authored`); +const findCommitRowDescription = () => wrapper.find('pre'); +const findTitleHtml = () => wrapper.findByText(commit.titleHtml); + +const createComponent = async ({ commitMock = {} } = {}) => { + wrapper = shallowMountExtended(CommitInfo, { + propsData: { commit: { ...commit, ...commitMock } }, + }); + + await nextTick(); +}; + +describe('Repository last commit component', () => { + it('renders author info', () => { + createComponent(); + + expect(findUserLink().exists()).toBe(true); + expect(findUserAvatarLink().exists()).toBe(true); + }); + + it('hides author component when author does not exist', () => { + createComponent({ commitMock: { author: null } }); + + expect(findUserLink().exists()).toBe(false); + expect(findUserAvatarLink().exists()).toBe(false); + expect(findAuthorName().exists()).toBe(true); + }); + + it('does not render description expander when description is null', () => { + createComponent(); + + expect(findTextExpander().exists()).toBe(false); + expect(findCommitRowDescription().exists()).toBe(false); + }); + + describe('when the description is present', () => { + beforeEach(() => { + createComponent({ commitMock: { descriptionHtml: '
Update ADOPTERS.md' } }); + }); + + it('strips the first newline of the description', () => { + expect(findCommitRowDescription().html()).toBe( + '<pre class="commit-row-description gl-mb-3 gl-white-space-pre-line">Update ADOPTERS.md</pre>', + ); + }); + + it('renders commit description collapsed by default', () => { + expect(findCommitRowDescription().classes('gl-display-block!')).toBe(false); + expect(findTextExpander().classes('open')).toBe(false); + expect(findTextExpander().props('selected')).toBe(false); + }); + + it('expands commit description when clicking expander', async () => { + findTextExpander().vm.$emit('click'); + await nextTick(); + + expect(findCommitRowDescription().classes('gl-display-block!')).toBe(true); + expect(findTextExpander().classes('open')).toBe(true); + expect(findTextExpander().props('selected')).toBe(true); + }); + }); + + it('sets correct CSS class if the commit message is empty', () => { + createComponent({ commitMock: { message: '' } }); + + expect(findTitleHtml().classes()).toContain('gl-font-style-italic'); + }); +}); diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js index c207d32d61d..d5ec34b1f6d 100644 --- a/spec/frontend/repository/components/last_commit_spec.js +++ b/spec/frontend/repository/components/last_commit_spec.js @@ -1,29 +1,26 @@ import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; import { GlLoadingIcon } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import LastCommit from '~/repository/components/last_commit.vue'; +import CommitInfo from '~/repository/components/commit_info.vue'; import SignatureBadge from '~/commit/components/signature_badge.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import eventHub from '~/repository/event_hub'; import pathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql'; import { FORK_UPDATED_EVENT } from '~/repository/constants'; import { refMock } from '../mock_data'; let wrapper; +let commitData; let mockResolver; const findPipeline = () => wrapper.find('.js-commit-pipeline'); -const findTextExpander = () => wrapper.find('.text-expander'); -const findUserLink = () => wrapper.find('.js-user-link'); -const findUserAvatarLink = () => wrapper.findComponent(UserAvatarLink); const findLastCommitLabel = () => wrapper.findByTestId('last-commit-id-label'); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); -const findCommitRowDescription = () => wrapper.find('.commit-row-description'); const findStatusBox = () => wrapper.findComponent(SignatureBadge); -const findItemTitle = () => wrapper.find('.item-title'); +const findCommitInfo = () => wrapper.findComponent(CommitInfo); const defaultPipelineEdges = [ { @@ -44,23 +41,7 @@ const defaultPipelineEdges = [ }, ]; -const defaultAuthor = { - __typename: 'UserCore', - id: 'gid://gitlab/User/1', - name: 'Test', - avatarUrl: 'https://test.com', - webPath: '/test', -}; - -const defaultMessage = 'Commit title'; - -const createCommitData = ({ - pipelineEdges = defaultPipelineEdges, - author = defaultAuthor, - descriptionHtml = '', - signature = null, - message = defaultMessage, -}) => { +const createCommitData = ({ pipelineEdges = defaultPipelineEdges, signature = null }) => { return { data: { project: { @@ -79,13 +60,19 @@ const createCommitData = ({ sha: '123456789', title: 'Commit title', titleHtml: 'Commit title', - descriptionHtml, - message, + descriptionHtml: '', + message: '', webPath: '/commit/123', authoredDate: '2019-01-01', authorName: 'Test', authorGravatar: 'https://test.com', - author, + author: { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + name: 'Test', + avatarUrl: 'https://test.com', + webPath: '/test', + }, signature, pipelines: { __typename: 'PipelineConnection', @@ -101,12 +88,13 @@ const createCommitData = ({ }; }; -const createComponent = (data = {}) => { +const createComponent = async (data = {}) => { Vue.use(VueApollo); const currentPath = 'path'; - mockResolver = jest.fn().mockResolvedValue(createCommitData(data)); + commitData = createCommitData(data); + mockResolver = jest.fn().mockResolvedValue(commitData); wrapper = shallowMountExtended(LastCommit, { apolloProvider: createMockApollo([[pathLastCommitQuery, mockResolver]]), @@ -116,8 +104,13 @@ const createComponent = (data = {}) => { SignatureBadge, }, }); + + await waitForPromises(); + await nextTick(); }; +beforeEach(() => createComponent()); + afterEach(() => { mockResolver = null; }); @@ -137,17 +130,17 @@ describe('Repository last commit component', () => { expect(findLoadingIcon().exists()).toBe(loading); }); - it('renders commit widget', async () => { - createComponent(); - await waitForPromises(); + it('renders a CommitInfo component', () => { + const commit = { ...commitData.project?.repository.paginatedTree.nodes[0].lastCommit }; - expect(wrapper.element).toMatchSnapshot(); + expect(findCommitInfo().props().commit).toMatchObject(commit); }); - it('renders short commit ID', async () => { - createComponent(); - await waitForPromises(); + it('renders commit widget', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + it('renders short commit ID', () => { expect(findLastCommitLabel().text()).toBe('12345678'); }); @@ -158,29 +151,10 @@ describe('Repository last commit component', () => { expect(findPipeline().exists()).toBe(false); }); - it('renders pipeline components when pipeline exists', async () => { - createComponent(); - await waitForPromises(); - + it('renders pipeline components when pipeline exists', () => { expect(findPipeline().exists()).toBe(true); }); - it('hides author component when author does not exist', async () => { - createComponent({ author: null }); - await waitForPromises(); - - expect(findUserLink().exists()).toBe(false); - expect(findUserAvatarLink().exists()).toBe(false); - }); - - it('does not render description expander when description is null', async () => { - createComponent(); - await waitForPromises(); - - expect(findTextExpander().exists()).toBe(false); - expect(findCommitRowDescription().exists()).toBe(false); - }); - describe('created', () => { it('binds `epicsListScrolled` event listener via eventHub', () => { jest.spyOn(eventHub, '$on').mockImplementation(() => {}); @@ -200,32 +174,6 @@ describe('Repository last commit component', () => { }); }); - describe('when the description is present', () => { - beforeEach(async () => { - createComponent({ descriptionHtml: '
Update ADOPTERS.md' }); - await waitForPromises(); - }); - - it('strips the first newline of the description', () => { - expect(findCommitRowDescription().html()).toBe( - '<pre class="commit-row-description gl-mb-3 gl-white-space-pre-line">Update ADOPTERS.md</pre>', - ); - }); - - it('expands commit description when clicking expander', async () => { - expect(findCommitRowDescription().classes('d-block')).toBe(false); - expect(findTextExpander().classes('open')).toBe(false); - expect(findTextExpander().props('selected')).toBe(false); - - findTextExpander().vm.$emit('click'); - await nextTick(); - - expect(findCommitRowDescription().classes('d-block')).toBe(true); - expect(findTextExpander().classes('open')).toBe(true); - expect(findTextExpander().props('selected')).toBe(true); - }); - }); - it('renders the signature HTML as returned by the backend', async () => { const signatureResponse = { __typename: 'GpgSignature', @@ -241,11 +189,4 @@ describe('Repository last commit component', () => { expect(findStatusBox().props()).toMatchObject({ signature: signatureResponse }); }); - - it('sets correct CSS class if the commit message is empty', async () => { - createComponent({ message: '' }); - await waitForPromises(); - - expect(findItemTitle().classes()).toContain('font-italic'); - }); }); diff --git a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap index 17ebdf8725d..af7eca6a52d 100644 --- a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap +++ b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap @@ -9,7 +9,7 @@ exports[`Repository table row component renders a symlink table row 1`] = ` > <a class="str-truncated tree-item-link" - data-qa-selector="file_name_link" + data-testid="file-name-link" href="https://test.com" title="test" > @@ -65,7 +65,7 @@ exports[`Repository table row component renders table row 1`] = ` > <a class="str-truncated tree-item-link" - data-qa-selector="file_name_link" + data-testid="file-name-link" href="https://test.com" title="test" > @@ -121,7 +121,7 @@ exports[`Repository table row component renders table row for path with special > <a class="str-truncated tree-item-link" - data-qa-selector="file_name_link" + data-testid="file-name-link" href="https://test.com" title="test" > diff --git a/spec/frontend/search/sidebar/components/app_spec.js b/spec/frontend/search/sidebar/components/app_spec.js index 8e23f9c1680..d8d2492209e 100644 --- a/spec/frontend/search/sidebar/components/app_spec.js +++ b/spec/frontend/search/sidebar/components/app_spec.js @@ -16,6 +16,7 @@ import BlobsFilters from '~/search/sidebar/components/blobs_filters.vue'; import ProjectsFilters from '~/search/sidebar/components/projects_filters.vue'; import NotesFilters from '~/search/sidebar/components/notes_filters.vue'; import CommitsFilters from '~/search/sidebar/components/commits_filters.vue'; +import MilestonesFilters from '~/search/sidebar/components/milestones_filters.vue'; import ScopeLegacyNavigation from '~/search/sidebar/components/scope_legacy_navigation.vue'; import SmallScreenDrawerNavigation from '~/search/sidebar/components/small_screen_drawer_navigation.vue'; import ScopeSidebarNavigation from '~/search/sidebar/components/scope_sidebar_navigation.vue'; @@ -47,6 +48,7 @@ describe('GlobalSearchSidebar', () => { glFeatures: { searchNotesHideArchivedProjects: true, searchCommitsHideArchivedProjects: true, + searchMilestonesHideArchivedProjects: true, }, }, }); @@ -59,6 +61,7 @@ describe('GlobalSearchSidebar', () => { const findProjectsFilters = () => wrapper.findComponent(ProjectsFilters); const findNotesFilters = () => wrapper.findComponent(NotesFilters); const findCommitsFilters = () => wrapper.findComponent(CommitsFilters); + const findMilestonesFilters = () => wrapper.findComponent(MilestonesFilters); const findScopeLegacyNavigation = () => wrapper.findComponent(ScopeLegacyNavigation); const findSmallScreenDrawerNavigation = () => wrapper.findComponent(SmallScreenDrawerNavigation); const findScopeSidebarNavigation = () => wrapper.findComponent(ScopeSidebarNavigation); @@ -83,10 +86,12 @@ describe('GlobalSearchSidebar', () => { ${'blobs'} | ${findBlobsFilters} | ${SEARCH_TYPE_BASIC} | ${false} ${'blobs'} | ${findBlobsFilters} | ${SEARCH_TYPE_ADVANCED} | ${true} ${'blobs'} | ${findBlobsFilters} | ${SEARCH_TYPE_ZOEKT} | ${false} - ${'notes'} | ${findNotesFilters} | ${SEARCH_TYPE_BASIC} | ${false} + ${'notes'} | ${findNotesFilters} | ${SEARCH_TYPE_BASIC} | ${true} ${'notes'} | ${findNotesFilters} | ${SEARCH_TYPE_ADVANCED} | ${true} - ${'commits'} | ${findCommitsFilters} | ${SEARCH_TYPE_BASIC} | ${false} + ${'commits'} | ${findCommitsFilters} | ${SEARCH_TYPE_BASIC} | ${true} ${'commits'} | ${findCommitsFilters} | ${SEARCH_TYPE_ADVANCED} | ${true} + ${'milestones'} | ${findMilestonesFilters} | ${SEARCH_TYPE_BASIC} | ${true} + ${'milestones'} | ${findMilestonesFilters} | ${SEARCH_TYPE_ADVANCED} | ${true} `('with sidebar $scope scope:', ({ scope, filter, searchType, isShown }) => { beforeEach(() => { getterSpies.currentScope = jest.fn(() => scope); diff --git a/spec/frontend/search/sidebar/components/archived_filter_spec.js b/spec/frontend/search/sidebar/components/archived_filter_spec.js index 69bf2ebd72e..9ed677ca297 100644 --- a/spec/frontend/search/sidebar/components/archived_filter_spec.js +++ b/spec/frontend/search/sidebar/components/archived_filter_spec.js @@ -1,8 +1,9 @@ -import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; // eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import { GlFormCheckboxGroup } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import ArchivedFilter from '~/search/sidebar/components/archived_filter/index.vue'; import { archivedFilterData } from '~/search/sidebar/components/archived_filter/data'; @@ -12,17 +13,26 @@ Vue.use(Vuex); describe('ArchivedFilter', () => { let wrapper; + const defaultActions = { + setQuery: jest.fn(), + }; + const createComponent = (state) => { const store = new Vuex.Store({ state, + actions: defaultActions, }); - wrapper = shallowMount(ArchivedFilter, { + wrapper = shallowMountExtended(ArchivedFilter, { store, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, }); }; const findCheckboxFilter = () => wrapper.findComponent(GlFormCheckboxGroup); + const findCheckboxFilterLabel = () => wrapper.findByTestId('label'); const findH5 = () => wrapper.findComponent('h5'); describe('old sidebar', () => { @@ -38,6 +48,12 @@ describe('ArchivedFilter', () => { expect(findH5().exists()).toBe(true); expect(findH5().text()).toBe(archivedFilterData.headerLabel); }); + + it('wraps the label element with a tooltip', () => { + const tooltip = getBinding(findCheckboxFilterLabel().element, 'gl-tooltip'); + expect(tooltip).toBeDefined(); + expect(tooltip.value).toBe('Include search results from archived projects'); + }); }); describe('new sidebar', () => { @@ -53,6 +69,12 @@ describe('ArchivedFilter', () => { expect(findH5().exists()).toBe(true); expect(findH5().text()).toBe(archivedFilterData.headerLabel); }); + + it('wraps the label element with a tooltip', () => { + const tooltip = getBinding(findCheckboxFilterLabel().element, 'gl-tooltip'); + expect(tooltip).toBeDefined(); + expect(tooltip.value).toBe('Include search results from archived projects'); + }); }); describe.each` @@ -70,4 +92,20 @@ describe('ArchivedFilter', () => { expect(findCheckboxFilter().attributes('checked')).toBe(checkboxState); }); }); + + describe('selectedFilter logic', () => { + beforeEach(() => { + createComponent(); + }); + + it('correctly executes setQuery without mutating the input', () => { + const selectedFilter = [false]; + findCheckboxFilter().vm.$emit('input', selectedFilter); + expect(defaultActions.setQuery).toHaveBeenCalledWith(expect.any(Object), { + key: 'include_archived', + value: 'false', + }); + expect(selectedFilter).toEqual([false]); + }); + }); }); diff --git a/spec/frontend/search/sidebar/components/issues_filters_spec.js b/spec/frontend/search/sidebar/components/issues_filters_spec.js index 39d10cbb8b4..c3b3a93e362 100644 --- a/spec/frontend/search/sidebar/components/issues_filters_spec.js +++ b/spec/frontend/search/sidebar/components/issues_filters_spec.js @@ -111,11 +111,11 @@ describe('GlobalSearch IssuesFilters', () => { }); it("doesn't render ArchivedFilter", () => { - expect(findArchivedFilter().exists()).toBe(false); + expect(findArchivedFilter().exists()).toBe(true); }); it('renders 1 divider', () => { - expect(findDividers()).toHaveLength(1); + expect(findDividers()).toHaveLength(2); }); }); diff --git a/spec/frontend/search/sidebar/components/merge_requests_filters_spec.js b/spec/frontend/search/sidebar/components/merge_requests_filters_spec.js index b50f348be69..278249c2660 100644 --- a/spec/frontend/search/sidebar/components/merge_requests_filters_spec.js +++ b/spec/frontend/search/sidebar/components/merge_requests_filters_spec.js @@ -79,12 +79,12 @@ describe('GlobalSearch MergeRequestsFilters', () => { expect(findStatusFilter().exists()).toBe(true); }); - it("doesn't render ArchivedFilter", () => { - expect(findArchivedFilter().exists()).toBe(false); + it('renders render ArchivedFilter', () => { + expect(findArchivedFilter().exists()).toBe(true); }); it('renders 1 divider', () => { - expect(findDividers()).toHaveLength(0); + expect(findDividers()).toHaveLength(1); }); }); diff --git a/spec/frontend/search/sidebar/components/milestones_filters_spec.js b/spec/frontend/search/sidebar/components/milestones_filters_spec.js new file mode 100644 index 00000000000..e7fcfb030f4 --- /dev/null +++ b/spec/frontend/search/sidebar/components/milestones_filters_spec.js @@ -0,0 +1,28 @@ +import { shallowMount } from '@vue/test-utils'; +import MilestonesFilters from '~/search/sidebar/components/milestones_filters.vue'; +import ArchivedFilter from '~/search/sidebar/components/archived_filter/index.vue'; +import FiltersTemplate from '~/search/sidebar/components/filters_template.vue'; + +describe('GlobalSearch MilestonesFilters', () => { + let wrapper; + + const findArchivedFilter = () => wrapper.findComponent(ArchivedFilter); + const findFiltersTemplate = () => wrapper.findComponent(FiltersTemplate); + + const createComponent = () => { + wrapper = shallowMount(MilestonesFilters); + }; + + describe('Renders correctly', () => { + beforeEach(() => { + createComponent(); + }); + it('renders ArchivedFilter', () => { + expect(findArchivedFilter().exists()).toBe(true); + }); + + it('renders FiltersTemplate', () => { + expect(findFiltersTemplate().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/search/topbar/components/app_spec.js b/spec/frontend/search/topbar/components/app_spec.js index 62d0e377d74..9704277c86b 100644 --- a/spec/frontend/search/topbar/components/app_spec.js +++ b/spec/frontend/search/topbar/components/app_spec.js @@ -9,7 +9,10 @@ import GlobalSearchTopbar from '~/search/topbar/components/app.vue'; import GroupFilter from '~/search/topbar/components/group_filter.vue'; import ProjectFilter from '~/search/topbar/components/project_filter.vue'; import MarkdownDrawer from '~/vue_shared/components/markdown_drawer/markdown_drawer.vue'; -import { SYNTAX_OPTIONS_DOCUMENT } from '~/search/topbar/constants'; +import { + SYNTAX_OPTIONS_ADVANCED_DOCUMENT, + SYNTAX_OPTIONS_ZOEKT_DOCUMENT, +} from '~/search/topbar/constants'; Vue.use(Vuex); @@ -22,7 +25,7 @@ describe('GlobalSearchTopbar', () => { preloadStoredFrequentItems: jest.fn(), }; - const createComponent = (initialState, props, stubs) => { + const createComponent = (initialState = {}, defaultBranchName = '', stubs = {}) => { const store = new Vuex.Store({ state: { query: MOCK_QUERY, @@ -33,7 +36,7 @@ describe('GlobalSearchTopbar', () => { wrapper = shallowMount(GlobalSearchTopbar, { store, - propsData: props, + propsData: { defaultBranchName }, stubs, }); }; @@ -76,80 +79,82 @@ describe('GlobalSearchTopbar', () => { }); }); - describe('syntax option feature', () => { - describe('template', () => { - beforeEach(() => { - createComponent( - { query: { repository_ref: '' } }, - { elasticsearchEnabled: true, defaultBranchName: '' }, - ); - }); + describe.each` + searchType | showSyntaxOptions + ${'basic'} | ${false} + ${'advanced'} | ${true} + ${'zoekt'} | ${true} + `('syntax options drawer with searchType: $searchType', ({ searchType, showSyntaxOptions }) => { + beforeEach(() => { + createComponent({ query: { repository_ref: '' }, searchType }); + }); - it('renders button correctly', () => { - expect(findSyntaxOptionButton().exists()).toBe(true); - }); + it('renders button correctly', () => { + expect(findSyntaxOptionButton().exists()).toBe(showSyntaxOptions); + }); - it('renders drawer correctly', () => { - expect(findSyntaxOptionDrawer().exists()).toBe(true); - expect(findSyntaxOptionDrawer().attributes('documentpath')).toBe(SYNTAX_OPTIONS_DOCUMENT); - }); + it('renders drawer correctly', () => { + expect(findSyntaxOptionDrawer().exists()).toBe(showSyntaxOptions); + }); + }); + + describe.each` + searchType | documentPath + ${'advanced'} | ${SYNTAX_OPTIONS_ADVANCED_DOCUMENT} + ${'zoekt'} | ${SYNTAX_OPTIONS_ZOEKT_DOCUMENT} + `('syntax options drawer with searchType: $searchType', ({ searchType, documentPath }) => { + beforeEach(() => { + createComponent({ query: { repository_ref: '' }, searchType }); + }); - it('dispatched correct click action', () => { - const drawerToggleSpy = jest.fn(); - - createComponent( - { query: { repository_ref: '' } }, - { elasticsearchEnabled: true, defaultBranchName: '' }, - { - MarkdownDrawer: stubComponent(MarkdownDrawer, { - methods: { toggleDrawer: drawerToggleSpy }, - }), - }, - ); - - findSyntaxOptionButton().vm.$emit('click'); - expect(drawerToggleSpy).toHaveBeenCalled(); + it('renders drawer with correct document', () => { + expect(findSyntaxOptionDrawer()?.attributes('documentpath')).toBe(documentPath); + }); + }); + + describe('actions', () => { + it('dispatched correct click action', () => { + const drawerToggleSpy = jest.fn(); + + createComponent({ query: { repository_ref: '' }, searchType: 'advanced' }, '', { + MarkdownDrawer: stubComponent(MarkdownDrawer, { + methods: { toggleDrawer: drawerToggleSpy }, + }), }); + + findSyntaxOptionButton().vm.$emit('click'); + expect(drawerToggleSpy).toHaveBeenCalled(); }); + }); - describe.each` - query | propsData | hasSyntaxOptions - ${null} | ${{ elasticsearchEnabled: false, defaultBranchName: '' }} | ${false} - ${{ query: { repository_ref: '' } }} | ${{ elasticsearchEnabled: false, defaultBranchName: '' }} | ${false} - ${{ query: { repository_ref: 'master' } }} | ${{ elasticsearchEnabled: false, defaultBranchName: 'master' }} | ${false} - ${{ query: { repository_ref: 'master' } }} | ${{ elasticsearchEnabled: true, defaultBranchName: '' }} | ${false} - ${{ query: { repository_ref: '' } }} | ${{ elasticsearchEnabled: true, defaultBranchName: 'master' }} | ${true} - ${{ query: { repository_ref: '' } }} | ${{ elasticsearchEnabled: true, defaultBranchName: '' }} | ${true} - ${{ query: { repository_ref: 'master' } }} | ${{ elasticsearchEnabled: true, defaultBranchName: 'master' }} | ${true} - `( - 'renders the syntax option based on component state', - ({ query, propsData, hasSyntaxOptions }) => { - beforeEach(() => { - createComponent(query, { ...propsData }); - }); + describe.each` + state | defaultBranchName | hasSyntaxOptions + ${{ query: { repository_ref: '' }, searchType: 'basic' }} | ${'master'} | ${false} + ${{ query: { repository_ref: 'v0.1' }, searchType: 'basic' }} | ${''} | ${false} + ${{ query: { repository_ref: 'master' }, searchType: 'basic' }} | ${'master'} | ${false} + ${{ query: { repository_ref: 'master' }, searchType: 'advanced' }} | ${''} | ${false} + ${{ query: { repository_ref: '' }, searchType: 'advanced' }} | ${'master'} | ${true} + ${{ query: { repository_ref: 'v0.1' }, searchType: 'advanced' }} | ${''} | ${false} + ${{ query: { repository_ref: 'master' }, searchType: 'advanced' }} | ${'master'} | ${true} + ${{ query: { repository_ref: 'master' }, searchType: 'zoekt' }} | ${'master'} | ${true} + `( + `the syntax option based on component state`, + ({ state, defaultBranchName, hasSyntaxOptions }) => { + beforeEach(() => { + createComponent({ ...state }, defaultBranchName); + }); - it(`does${ - hasSyntaxOptions ? '' : ' not' - } have syntax option button when repository_ref: '${ - query?.query?.repository_ref - }', elasticsearchEnabled: ${propsData.elasticsearchEnabled}, defaultBranchName: '${ - propsData.defaultBranchName - }'`, () => { + describe(`repository: ${state.query.repository_ref}, searchType: ${state.searchType}`, () => { + it(`renders correctly button`, () => { expect(findSyntaxOptionButton().exists()).toBe(hasSyntaxOptions); }); - it(`does${ - hasSyntaxOptions ? '' : ' not' - } have syntax option drawer when repository_ref: '${ - query?.query?.repository_ref - }', elasticsearchEnabled: ${propsData.elasticsearchEnabled}, defaultBranchName: '${ - propsData.defaultBranchName - }'`, () => { + it(`renders correctly drawer when branch name is ${state.query.repository_ref}`, () => { expect(findSyntaxOptionDrawer().exists()).toBe(hasSyntaxOptions); }); - }, - ); - }); + }); + }, + ); }); describe('actions', () => { diff --git a/spec/frontend/sentry/init_sentry_spec.js b/spec/frontend/sentry/init_sentry_spec.js index e31068b935b..fb0dba35759 100644 --- a/spec/frontend/sentry/init_sentry_spec.js +++ b/spec/frontend/sentry/init_sentry_spec.js @@ -3,11 +3,10 @@ import { defaultStackParser, makeFetchTransport, defaultIntegrations, + BrowserTracing, // exports captureException, - captureMessage, - withScope, SDK_VERSION, } from 'sentrybrowser'; import * as Sentry from 'sentrybrowser'; @@ -96,11 +95,17 @@ describe('SentryConfig', () => { transport: makeFetchTransport, stackParser: defaultStackParser, - integrations: defaultIntegrations, + integrations: [...defaultIntegrations, expect.any(BrowserTracing)], }), ); }); + it('Uses data-page to set BrowserTracing transaction name', () => { + const context = BrowserTracing.mock.calls[0][0].beforeNavigate(); + + expect(context).toMatchObject({ name: mockPage }); + }); + it('binds the BrowserClient to the hub', () => { expect(mockBindClient).toHaveBeenCalledTimes(1); expect(mockBindClient).toHaveBeenCalledWith(expect.any(BrowserClient)); @@ -126,8 +131,6 @@ describe('SentryConfig', () => { // eslint-disable-next-line no-underscore-dangle expect(window._Sentry).toEqual({ captureException, - captureMessage, - withScope, SDK_VERSION, }); }); @@ -173,5 +176,27 @@ describe('SentryConfig', () => { expect(window._Sentry).toBe(undefined); }); }); + + describe('when data-page is not defined in the body', () => { + beforeEach(() => { + delete document.body.dataset.page; + initSentry(); + }); + + it('calls Sentry.setTags with gon values', () => { + expect(mockSetTags).toHaveBeenCalledTimes(1); + expect(mockSetTags).toHaveBeenCalledWith( + expect.objectContaining({ + page: undefined, + }), + ); + }); + + it('Uses location.path to set BrowserTracing transaction name', () => { + const context = BrowserTracing.mock.calls[0][0].beforeNavigate({ op: 'pageload' }); + + expect(context).toEqual({ op: 'pageload', name: window.location.pathname }); + }); + }); }); }); diff --git a/spec/frontend/sentry/sentry_browser_wrapper_spec.js b/spec/frontend/sentry/sentry_browser_wrapper_spec.js index 55354eceb8d..d98286e1371 100644 --- a/spec/frontend/sentry/sentry_browser_wrapper_spec.js +++ b/spec/frontend/sentry/sentry_browser_wrapper_spec.js @@ -1,8 +1,6 @@ import * as Sentry from '~/sentry/sentry_browser_wrapper'; const mockError = new Error('error!'); -const mockMsg = 'msg!'; -const mockFn = () => {}; describe('SentryBrowserWrapper', () => { afterEach(() => { @@ -14,27 +12,19 @@ describe('SentryBrowserWrapper', () => { it('methods fail silently', () => { expect(() => { Sentry.captureException(mockError); - Sentry.captureMessage(mockMsg); - Sentry.withScope(mockFn); }).not.toThrow(); }); }); describe('when _Sentry is defined', () => { let mockCaptureException; - let mockCaptureMessage; - let mockWithScope; beforeEach(() => { mockCaptureException = jest.fn(); - mockCaptureMessage = jest.fn(); - mockWithScope = jest.fn(); // eslint-disable-next-line no-underscore-dangle window._Sentry = { captureException: mockCaptureException, - captureMessage: mockCaptureMessage, - withScope: mockWithScope, }; }); @@ -43,17 +33,5 @@ describe('SentryBrowserWrapper', () => { expect(mockCaptureException).toHaveBeenCalledWith(mockError); }); - - it('captureMessage is called', () => { - Sentry.captureMessage(mockMsg); - - expect(mockCaptureMessage).toHaveBeenCalledWith(mockMsg); - }); - - it('withScope is called', () => { - Sentry.withScope(mockFn); - - expect(mockWithScope).toHaveBeenCalledWith(mockFn); - }); }); }); diff --git a/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js b/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js index 39b480b295c..b2477e9b41c 100644 --- a/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js +++ b/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js @@ -22,6 +22,7 @@ describe('Sidebar Todo Widget', () => { const createComponent = ({ todosQueryHandler = jest.fn().mockResolvedValue(noTodosResponse), + provide = {}, } = {}) => { fakeApollo = createMockApollo([[epicTodoQuery, todosQueryHandler]]); @@ -30,6 +31,7 @@ describe('Sidebar Todo Widget', () => { provide: { canUpdate: true, isClassicSidebar: true, + ...provide, }, propsData: { fullPath: 'group', @@ -122,4 +124,23 @@ describe('Sidebar Todo Widget', () => { expect(wrapper.emitted('todoUpdated')).toEqual([[false]]); }); }); + + describe('when the query is pending', () => { + it('is in the loading state', () => { + createComponent(); + + expect(findTodoButton().attributes('loading')).toBe('true'); + }); + + it('is not in the loading state if notificationsTodosButtons and movedMrSidebar feature flags are enabled', () => { + createComponent({ + provide: { + glFeatures: { notificationsTodosButtons: true, movedMrSidebar: true }, + }, + }); + + expect(findTodoButton().attributes('loading')).toBeUndefined(); + expect(findTodoButton().attributes('disabled')).toBe('true'); + }); + }); }); 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 1c60c3af310..6414ab6dfba 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,11 +3,11 @@ exports[`Snippet Blob Edit component with loaded blob matches snapshot 1`] = ` <div class="file-holder snippet" - data-qa-selector="file_holder_container" + data-testid="file-holder-container" > <blob-header-edit-stub candelete="true" - data-qa-selector="file_name_field" + data-testid="file-name-field" id="reference-0" showdelete="true" value="foo/bar/test.md" 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 5ed3b520b70..92511acc4f8 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 @@ -17,7 +17,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = > <gl-form-input-stub class="form-control" - data-qa-selector="description_placeholder" + data-testid="description-placeholder" placeholder="Describe what your snippet does or how to use it…" /> </div> @@ -46,8 +46,8 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = <textarea aria-label="Description" class="js-autosize js-gfm-input js-gfm-input-initialized markdown-area note-textarea" - data-qa-selector="snippet_description_field" data-supports-quick-actions="false" + data-testid="snippet-description-field" dir="auto" id="reference-0" placeholder="Write a comment or drag your files here…" diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_view_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_view_spec.js.snap index 2b2335036f6..7c5fbf4cfb7 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_description_view_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_view_spec.js.snap @@ -3,7 +3,7 @@ exports[`Snippet Description component matches the snapshot 1`] = ` <markdown-field-view-stub class="snippet-description" - data-qa-selector="snippet_description_content" + data-testid="snippet-description-content" > <div class="js-snippet-description md" diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap index 3274f41e4af..ab96d1a3653 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap @@ -44,8 +44,8 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] = /> <span class="font-weight-bold js-visibility-option ml-1" - data-qa-selector="visibility_content" data-qa-visibility="Private" + data-testid="visibility-content" > Private </span> @@ -64,8 +64,8 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] = /> <span class="font-weight-bold js-visibility-option ml-1" - data-qa-selector="visibility_content" data-qa-visibility="Internal" + data-testid="visibility-content" > Internal </span> @@ -84,8 +84,8 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] = /> <span class="font-weight-bold js-visibility-option ml-1" - data-qa-selector="visibility_content" data-qa-visibility="Public" + data-testid="visibility-content" > Public </span> diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index 17862953920..5fbc16ff430 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -117,7 +117,8 @@ describe('Snippet Edit app', () => { .map((path) => `<input name="files[]" value="${path}">`) .join(''); }; - const setTitle = (val) => wrapper.findByTestId('snippet-title-input').vm.$emit('input', val); + const setTitle = (val) => + wrapper.findByTestId('snippet-title-input-field').vm.$emit('input', val); const setDescription = (val) => wrapper.findComponent(SnippetDescriptionEdit).vm.$emit('input', val); 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 cb11e98cd35..fab65434c3a 100644 --- a/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js @@ -40,7 +40,7 @@ describe('snippets/components/snippet_blob_actions_edit', () => { classes: x.classes(), })); const findFirstBlobEdit = () => findBlobEdits().at(0); - const findAddButton = () => wrapper.find('[data-testid="add_button"]'); + const findAddButton = () => wrapper.find('[data-testid="add-button"]'); const findLimitationsText = () => wrapper.find('[data-testid="limitations_text"]'); const getLastActions = () => { const events = wrapper.emitted().actions; diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js index 4bf64bfd3cd..3932675aa52 100644 --- a/spec/frontend/snippets/components/snippet_header_spec.js +++ b/spec/frontend/snippets/components/snippet_header_spec.js @@ -331,7 +331,7 @@ describe('Snippet header component', () => { expect(findDeleteModal().props().visible).toBe(true); // Click delete button in delete modal - document.querySelector('[data-testid="delete-snippet"').click(); + document.querySelector('[data-testid="delete-snippet-button"').click(); await waitForPromises(); }; diff --git a/spec/frontend/super_sidebar/components/create_menu_spec.js b/spec/frontend/super_sidebar/components/create_menu_spec.js index b967fb18a39..ffbc789d220 100644 --- a/spec/frontend/super_sidebar/components/create_menu_spec.js +++ b/spec/frontend/super_sidebar/components/create_menu_spec.js @@ -47,7 +47,7 @@ describe('CreateMenu component', () => { createWrapper(); expect(findGlDisclosureDropdown().props('dropdownOffset')).toEqual({ - crossAxis: -179, + crossAxis: -177, mainAxis: 4, }); }); @@ -98,7 +98,7 @@ describe('CreateMenu component', () => { createWrapper({ provide: { isImpersonating: true } }); expect(findGlDisclosureDropdown().props('dropdownOffset')).toEqual({ - crossAxis: -147, + crossAxis: -143, mainAxis: 4, }); }); diff --git a/spec/frontend/super_sidebar/components/help_center_spec.js b/spec/frontend/super_sidebar/components/help_center_spec.js index c92f8a68678..39537b65fa5 100644 --- a/spec/frontend/super_sidebar/components/help_center_spec.js +++ b/spec/frontend/super_sidebar/components/help_center_spec.js @@ -104,7 +104,7 @@ describe('HelpCenter component', () => { createWrapper({ ...sidebarData, show_tanuki_bot: true }); }); - it('shows Ask GitLab Duo with the help items', () => { + it('shows GitLab Duo Chat with the help items', () => { expect(findDropdownGroup(0).props('group').items).toEqual([ expect.objectContaining({ icon: 'tanuki-ai', @@ -115,9 +115,9 @@ describe('HelpCenter component', () => { ]); }); - describe('when Ask GitLab Duo button is clicked', () => { + describe('when GitLab Duo Chat button is clicked', () => { beforeEach(() => { - findButton('Ask GitLab Duo').click(); + findButton('GitLab Duo Chat').click(); }); it('sets helpCenterState.showTanukiBotChatDrawer to true', () => { diff --git a/spec/frontend/super_sidebar/components/nav_item_spec.js b/spec/frontend/super_sidebar/components/nav_item_spec.js index 89d774c4b43..e6de9b1de22 100644 --- a/spec/frontend/super_sidebar/components/nav_item_spec.js +++ b/spec/frontend/super_sidebar/components/nav_item_spec.js @@ -10,6 +10,7 @@ import { TRACKING_UNKNOWN_ID, TRACKING_UNKNOWN_PANEL, } from '~/super_sidebar/constants'; +import eventHub from '~/super_sidebar/event_hub'; describe('NavItem component', () => { let wrapper; @@ -49,7 +50,7 @@ describe('NavItem component', () => { it.each([0, 5, 3.4, 'foo', '10%'])('item with pill_data `%p` renders a pill', (pillCount) => { createWrapper({ item: { title: 'Foo', pill_count: pillCount } }); - expect(findPill().text()).toEqual(pillCount.toString()); + expect(findPill().text()).toBe(pillCount.toString()); }); it.each([null, undefined, false, true, '', NaN, Number.POSITIVE_INFINITY])( @@ -57,9 +58,49 @@ describe('NavItem component', () => { (pillCount) => { createWrapper({ item: { title: 'Foo', pill_count: pillCount } }); - expect(findPill().exists()).toEqual(false); + expect(findPill().exists()).toBe(false); }, ); + + describe('updating pill value', () => { + const initialPillValue = '20%'; + const updatedPillValue = '50%'; + const itemIdForUpdate = '_some_item_id_'; + const triggerPillValueUpdate = async ({ + value = updatedPillValue, + itemId = itemIdForUpdate, + } = {}) => { + eventHub.$emit('updatePillValue', { value, itemId }); + await nextTick(); + }; + + it('updates the pill count', async () => { + createWrapper({ item: { id: itemIdForUpdate, pill_count: initialPillValue } }); + + await triggerPillValueUpdate(); + + expect(findPill().text()).toBe(updatedPillValue); + }); + + it('does not update the pill count for non matching item id', async () => { + createWrapper({ item: { id: '_non_matching_id_', pill_count: initialPillValue } }); + + await triggerPillValueUpdate(); + + expect(findPill().text()).toBe(initialPillValue); + }); + }); + }); + + describe('destroyed', () => { + it('should unbind event listeners on eventHub', async () => { + jest.spyOn(eventHub, '$off'); + + createWrapper({ item: {} }); + await wrapper.destroy(); + + expect(eventHub.$off).toHaveBeenCalledWith('updatePillValue', expect.any(Function)); + }); }); describe('pins', () => { diff --git a/spec/frontend/super_sidebar/components/super_sidebar_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_spec.js index 1371f8f00a7..92736b99e14 100644 --- a/spec/frontend/super_sidebar/components/super_sidebar_spec.js +++ b/spec/frontend/super_sidebar/components/super_sidebar_spec.js @@ -45,6 +45,7 @@ const peekHintClass = 'super-sidebar-peek-hint'; describe('SuperSidebar component', () => { let wrapper; + const findSkipToLink = () => wrapper.findByTestId('super-sidebar-skip-to'); const findSidebar = () => wrapper.findByTestId('super-sidebar'); const findUserBar = () => wrapper.findComponent(UserBar); const findNavContainer = () => wrapper.findByTestId('nav-container'); @@ -89,6 +90,24 @@ describe('SuperSidebar component', () => { }); describe('default', () => { + it('renders skip to main content link when logged in', () => { + createWrapper(); + expect(findSkipToLink().attributes('href')).toBe('#content-body'); + }); + + it('does not render skip to main content link when logged out', () => { + createWrapper({ sidebarData: { is_logged_in: false } }); + expect(findSkipToLink().exists()).toBe(false); + }); + + it('has accessible role and name', () => { + createWrapper(); + const nav = wrapper.findByRole('navigation'); + const heading = wrapper.findByText('Primary navigation'); + expect(nav.attributes('aria-labelledby')).toBe('super-sidebar-heading'); + expect(heading.attributes('id')).toBe('super-sidebar-heading'); + }); + it('adds inert attribute when collapsed', () => { createWrapper({ sidebarState: { isCollapsed: true } }); expect(findSidebar().attributes('inert')).toBe('inert'); @@ -295,11 +314,4 @@ describe('SuperSidebar component', () => { expect(findTrialStatusPopover().exists()).toBe(true); }); }); - - describe('ARIA attributes', () => { - it('adds aria-label attribute to nav element', () => { - createWrapper(); - expect(wrapper.find('nav').attributes('aria-label')).toBe('Primary'); - }); - }); }); diff --git a/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js index 1f2e5602d10..974eb529113 100644 --- a/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js +++ b/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js @@ -18,13 +18,8 @@ describe('SuperSidebarToggle component', () => { const findButton = () => wrapper.findComponent(GlButton); const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value; - const createWrapper = ({ props = {}, sidebarState = {} } = {}) => { + const createWrapper = (props = {}) => { wrapper = shallowMountExtended(SuperSidebarToggle, { - data() { - return { - ...sidebarState, - }; - }, directives: { GlTooltip: createMockDirective('gl-tooltip'), }, @@ -40,18 +35,15 @@ describe('SuperSidebarToggle component', () => { expect(findButton().attributes('aria-controls')).toBe('super-sidebar'); }); - it('has aria-expanded as true when expanded', () => { - createWrapper(); + it('has aria-expanded as true when type is collapse', () => { + createWrapper({ type: 'collapse' }); expect(findButton().attributes('aria-expanded')).toBe('true'); }); - it.each(['isCollapsed', 'isPeek', 'isHoverPeek'])( - 'has aria-expanded as false when %s is `true`', - (stateProp) => { - createWrapper({ sidebarState: { [stateProp]: true } }); - expect(findButton().attributes('aria-expanded')).toBe('false'); - }, - ); + it('has aria-expanded as false when type is expand', () => { + createWrapper(); + expect(findButton().attributes('aria-expanded')).toBe('false'); + }); it('has aria-label attribute', () => { createWrapper(); @@ -60,13 +52,13 @@ describe('SuperSidebarToggle component', () => { }); describe('tooltip', () => { - it('displays collapse when expanded', () => { - createWrapper(); + it('displays "Hide sidebar" when type is collapse', () => { + createWrapper({ type: 'collapse' }); expect(getTooltip().title).toBe('Hide sidebar'); }); - it('displays expand when collapsed', () => { - createWrapper({ sidebarState: { isCollapsed: true } }); + it('displays "Keep sidebar visible" when type is expand', () => { + createWrapper(); expect(getTooltip().title).toBe('Keep sidebar visible'); }); }); @@ -88,13 +80,11 @@ describe('SuperSidebarToggle component', () => { }); it('collapses the sidebar and focuses the other toggle', async () => { - createWrapper(); + createWrapper({ type: 'collapse' }); findButton().vm.$emit('click'); await nextTick(); expect(toggleSuperSidebarCollapsed).toHaveBeenCalledWith(true, true); - expect(document.activeElement).toEqual( - document.querySelector(`.${JS_TOGGLE_COLLAPSE_CLASS}`), - ); + expect(document.activeElement).toEqual(document.querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`)); expect(trackingSpy).toHaveBeenCalledWith(undefined, 'nav_hide', { label: 'nav_toggle', property: 'nav_sidebar', @@ -102,11 +92,13 @@ describe('SuperSidebarToggle component', () => { }); it('expands the sidebar and focuses the other toggle', async () => { - createWrapper({ sidebarState: { isCollapsed: true } }); + createWrapper(); findButton().vm.$emit('click'); await nextTick(); expect(toggleSuperSidebarCollapsed).toHaveBeenCalledWith(false, true); - expect(document.activeElement).toEqual(document.querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`)); + expect(document.activeElement).toEqual( + document.querySelector(`.${JS_TOGGLE_COLLAPSE_CLASS}`), + ); expect(trackingSpy).toHaveBeenCalledWith(undefined, 'nav_show', { label: 'nav_toggle', property: 'nav_sidebar', diff --git a/spec/frontend/super_sidebar/components/user_name_group_spec.js b/spec/frontend/super_sidebar/components/user_menu_profile_item_spec.js index a31ad93d143..9cf55154a59 100644 --- a/spec/frontend/super_sidebar/components/user_name_group_spec.js +++ b/spec/frontend/super_sidebar/components/user_menu_profile_item_spec.js @@ -1,12 +1,11 @@ -import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlTooltip } from '@gitlab/ui'; +import { GlDisclosureDropdownItem, GlTooltip } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import UserNameGroup from '~/super_sidebar/components/user_name_group.vue'; +import UserMenuProfileItem from '~/super_sidebar/components/user_menu_profile_item.vue'; import { userMenuMockData, userMenuMockStatus } from '../mock_data'; -describe('UserNameGroup component', () => { +describe('UserMenuProfileItem component', () => { let wrapper; - const findGlDisclosureDropdownGroup = () => wrapper.findComponent(GlDisclosureDropdownGroup); const findGlDisclosureDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem); const findGlTooltip = () => wrapper.findComponent(GlTooltip); const findUserStatus = () => wrapper.findByTestId('user-menu-status'); @@ -14,7 +13,7 @@ describe('UserNameGroup component', () => { const GlEmoji = { template: '<img/>' }; const createWrapper = (userDataChanges = {}) => { - wrapper = shallowMountExtended(UserNameGroup, { + wrapper = shallowMountExtended(UserMenuProfileItem, { propsData: { user: { ...userMenuMockData, @@ -32,10 +31,6 @@ describe('UserNameGroup component', () => { createWrapper(); }); - it('renders the menu item in a separate group', () => { - expect(findGlDisclosureDropdownGroup().exists()).toBe(true); - }); - it('renders menu item', () => { expect(findGlDisclosureDropdownItem().exists()).toBe(true); }); diff --git a/spec/frontend/super_sidebar/components/user_menu_spec.js b/spec/frontend/super_sidebar/components/user_menu_spec.js index bcc3383bcd4..79a31492f3f 100644 --- a/spec/frontend/super_sidebar/components/user_menu_spec.js +++ b/spec/frontend/super_sidebar/components/user_menu_spec.js @@ -2,7 +2,7 @@ import { GlAvatar, GlDisclosureDropdown } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { stubComponent } from 'helpers/stub_component'; import UserMenu from '~/super_sidebar/components/user_menu.vue'; -import UserNameGroup from '~/super_sidebar/components/user_name_group.vue'; +import UserMenuProfileItem from '~/super_sidebar/components/user_menu_profile_item.vue'; import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; import invalidUrl from '~/lib/utils/invalid_url'; import { mockTracking } from 'helpers/tracking_helper'; @@ -56,7 +56,7 @@ describe('UserMenu component', () => { createWrapper(null, null, { isImpersonating: true }); expect(findDropdown().props('dropdownOffset')).toEqual({ - crossAxis: -179, + crossAxis: -177, mainAxis: 4, }); }); @@ -86,9 +86,9 @@ describe('UserMenu component', () => { describe('User Menu Group', () => { it('renders and passes data to it', () => { createWrapper(); - const userNameGroup = wrapper.findComponent(UserNameGroup); - expect(userNameGroup.exists()).toBe(true); - expect(userNameGroup.props('user')).toEqual(userMenuMockData); + const userMenuProfileItem = wrapper.findComponent(UserMenuProfileItem); + expect(userMenuProfileItem.exists()).toBe(true); + expect(userMenuProfileItem.props('user')).toEqual(userMenuMockData); }); }); diff --git a/spec/frontend/super_sidebar/utils_spec.js b/spec/frontend/super_sidebar/utils_spec.js index 85f45de06ba..85c13a4c892 100644 --- a/spec/frontend/super_sidebar/utils_spec.js +++ b/spec/frontend/super_sidebar/utils_spec.js @@ -11,7 +11,7 @@ import axios from '~/lib/utils/axios_utils'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import AccessorUtilities from '~/lib/utils/accessor'; import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/frequent_items/constants'; -import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK, HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status'; import waitForPromises from 'helpers/wait_for_promises'; import { unsortedFrequentItems, sortedFrequentItems } from '../frequent_items/mock_data'; import { cachedFrequentProjects } from './mock_data'; @@ -58,7 +58,6 @@ describe('Super sidebar utils spec', () => { const storageKey = `${username}/frequent-${context.namespace}`; beforeEach(() => { - gon.features = { serverSideFrecentNamespaces: true }; axiosMock = new MockAdapter(axios); axiosMock.onPost(trackVisitsPath).reply(HTTP_STATUS_OK); }); @@ -99,12 +98,12 @@ describe('Super sidebar utils spec', () => { expect(axiosMock.history.post[0].url).toBe(trackVisitsPath); }); - it('does not send a POST request when the serverSideFrecentNamespaces feature flag is disabled', async () => { - gon.features = { serverSideFrecentNamespaces: false }; + it('logs an error to Sentry when the request fails', async () => { + axiosMock.onPost(trackVisitsPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); trackContextAccess(username, context, trackVisitsPath); await waitForPromises(); - expect(axiosMock.history.post).toHaveLength(0); + expect(Sentry.captureException).toHaveBeenCalled(); }); it('updates existing item frequency/access time if it was persisted to the local storage over 15 minutes ago', () => { diff --git a/spec/frontend/tags/components/sort_dropdown_spec.js b/spec/frontend/tags/components/sort_dropdown_spec.js index ebf79c93f9b..a0ba263e832 100644 --- a/spec/frontend/tags/components/sort_dropdown_spec.js +++ b/spec/frontend/tags/components/sort_dropdown_spec.js @@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import * as urlUtils from '~/lib/utils/url_utility'; import SortDropdown from '~/tags/components/sort_dropdown.vue'; +import setWindowLocation from 'helpers/set_window_location_helper'; describe('Tags sort dropdown', () => { let wrapper; @@ -45,20 +46,33 @@ describe('Tags sort dropdown', () => { }); }); + describe('when url contains a search param', () => { + const branchName = 'branch-1'; + + beforeEach(() => { + setWindowLocation(`/root/ci-cd-project-demo/-/branches?search=${branchName}`); + wrapper = createWrapper(); + }); + + it('should set the default the input value to search param', () => { + expect(findSearchBox().props('value')).toBe(branchName); + }); + }); + describe('when submitting a search term', () => { beforeEach(() => { urlUtils.visitUrl = jest.fn(); - wrapper = createWrapper(); }); it('should call visitUrl', () => { + const searchTerm = 'branch-1'; const searchBox = findSearchBox(); - + searchBox.vm.$emit('input', searchTerm); searchBox.vm.$emit('submit'); expect(urlUtils.visitUrl).toHaveBeenCalledWith( - '/root/ci-cd-project-demo/-/tags?sort=updated_desc', + '/root/ci-cd-project-demo/-/tags?search=branch-1&sort=updated_desc', ); }); diff --git a/spec/frontend/tracking/internal_events_spec.js b/spec/frontend/tracking/internal_events_spec.js index 6e773fde4db..44a048a4b5f 100644 --- a/spec/frontend/tracking/internal_events_spec.js +++ b/spec/frontend/tracking/internal_events_spec.js @@ -6,7 +6,6 @@ import { GITLAB_INTERNAL_EVENT_CATEGORY, SERVICE_PING_SCHEMA, LOAD_INTERNAL_EVENTS_SELECTOR, - USER_CONTEXT_SCHEMA, } from '~/tracking/constants'; import * as utils from '~/tracking/utils'; import { Tracker } from '~/tracking/tracker'; @@ -26,18 +25,27 @@ Tracker.enabled = jest.fn(); const event = 'TestEvent'; describe('InternalEvents', () => { - describe('track_event', () => { - it('track_event calls API.trackInternalEvent with correct arguments', () => { - InternalEvents.track_event(event); + describe('trackEvent', () => { + it('trackEvent calls API.trackInternalEvent with correct arguments', () => { + InternalEvents.trackEvent(event); expect(API.trackInternalEvent).toHaveBeenCalledTimes(1); expect(API.trackInternalEvent).toHaveBeenCalledWith(event); }); - it('track_event calls tracking.event functions with correct arguments', () => { + it('trackEvent calls trackBrowserSDK with correct arguments', () => { + jest.spyOn(InternalEvents, 'trackBrowserSDK').mockImplementation(() => {}); + + InternalEvents.trackEvent(event); + + expect(InternalEvents.trackBrowserSDK).toHaveBeenCalledTimes(1); + expect(InternalEvents.trackBrowserSDK).toHaveBeenCalledWith(event); + }); + + it('trackEvent calls tracking.event functions with correct arguments', () => { const trackingSpy = mockTracking(GITLAB_INTERNAL_EVENT_CATEGORY, undefined, jest.spyOn); - InternalEvents.track_event(event, { context: extraContext }); + InternalEvents.trackEvent(event, { context: extraContext }); expect(trackingSpy).toHaveBeenCalledTimes(1); expect(trackingSpy).toHaveBeenCalledWith(GITLAB_INTERNAL_EVENT_CATEGORY, event, { @@ -66,10 +74,10 @@ describe('InternalEvents', () => { `, methods: { handleButton1Click() { - this.track_event(event); + this.trackEvent(event); }, handleButton2Click() { - this.track_event(event, extraContext); + this.trackEvent(event, extraContext); }, }, mixins: [InternalEvents.mixin()], @@ -79,8 +87,8 @@ describe('InternalEvents', () => { wrapper = shallowMountExtended(Component); }); - it('this.track_event function calls InternalEvent`s track function with an event', async () => { - const trackEventSpy = jest.spyOn(InternalEvents, 'track_event'); + it('this.trackEvent function calls InternalEvent`s track function with an event', async () => { + const trackEventSpy = jest.spyOn(InternalEvents, 'trackEvent'); await wrapper.findByTestId('button1').trigger('click'); @@ -88,9 +96,9 @@ describe('InternalEvents', () => { expect(trackEventSpy).toHaveBeenCalledWith(event, {}); }); - it("this.track_event function calls InternalEvent's track function with an event and data", async () => { + it("this.trackEvent function calls InternalEvent's track function with an event and data", async () => { const data = extraContext; - const trackEventSpy = jest.spyOn(InternalEvents, 'track_event'); + const trackEventSpy = jest.spyOn(InternalEvents, 'trackEvent'); await wrapper.findByTestId('button2').trigger('click'); @@ -147,7 +155,7 @@ describe('InternalEvents', () => { describe('tracking', () => { let trackEventSpy; beforeEach(() => { - trackEventSpy = jest.spyOn(InternalEvents, 'track_event'); + trackEventSpy = jest.spyOn(InternalEvents, 'trackEvent'); }); it('should track event if action exists', () => { @@ -181,16 +189,6 @@ describe('InternalEvents', () => { environment: 'testing', key: 'value', }; - window.gl.snowplowStandardContext = { - schema: 'iglu:com.gitlab/gitlab_standard', - data: { - environment: 'testing', - key: 'value', - google_analytics_id: '', - source: 'gitlab-javascript', - extra: {}, - }, - }; }); it('should not call setDocumentTitle or page methods when window.glClient is undefined', () => { @@ -203,55 +201,48 @@ describe('InternalEvents', () => { }); it('should call setDocumentTitle and page methods on window.glClient when it is defined', () => { - const mockStandardContext = window.gl.snowplowStandardContext; - const userContext = { - schema: USER_CONTEXT_SCHEMA, - data: mockStandardContext?.data, - }; - InternalEvents.initBrowserSDK(); expect(window.glClient.setDocumentTitle).toHaveBeenCalledWith('GitLab'); expect(window.glClient.page).toHaveBeenCalledWith({ title: 'GitLab', - context: [userContext], }); }); - it('should call page method with combined standard and experiment contexts', () => { - const mockStandardContext = window.gl.snowplowStandardContext; - const userContext = { - schema: USER_CONTEXT_SCHEMA, - data: mockStandardContext?.data, - }; + it('should call setDocumentTitle and page methods with default data when window.gl is undefined', () => { + window.gl = undefined; InternalEvents.initBrowserSDK(); + expect(window.glClient.setDocumentTitle).toHaveBeenCalledWith('GitLab'); expect(window.glClient.page).toHaveBeenCalledWith({ title: 'GitLab', - context: [userContext], }); }); + }); - it('should call setDocumentTitle and page methods with default data when window.gl is undefined', () => { - window.gl = undefined; + describe('trackBrowserSDK', () => { + beforeEach(() => { + window.glClient = { + track: jest.fn(), + }; + }); - InternalEvents.initBrowserSDK(); + it('should not call glClient.track if Tracker is not enabled', () => { + Tracker.enabled.mockReturnValue(false); - expect(window.glClient.setDocumentTitle).toHaveBeenCalledWith('GitLab'); - expect(window.glClient.page).toHaveBeenCalledWith({ - title: 'GitLab', - context: [ - { - schema: USER_CONTEXT_SCHEMA, - data: { - google_analytics_id: '', - source: 'gitlab-javascript', - extra: {}, - }, - }, - ], - }); + InternalEvents.trackBrowserSDK(event); + + expect(window.glClient.track).not.toHaveBeenCalled(); + }); + + it('should call glClient.track with correct arguments if Tracker is enabled', () => { + Tracker.enabled.mockReturnValue(true); + + InternalEvents.trackBrowserSDK(event); + + expect(window.glClient.track).toHaveBeenCalledTimes(1); + expect(window.glClient.track).toHaveBeenCalledWith(event); }); }); }); diff --git a/spec/frontend/users_select/test_helper.js b/spec/frontend/users_select/test_helper.js index 5aae922fec2..0d8e3275aa5 100644 --- a/spec/frontend/users_select/test_helper.js +++ b/spec/frontend/users_select/test_helper.js @@ -147,6 +147,7 @@ export const createInputsModelExpectation = (users) => name: user.name, show_status: user.show_status.toString(), state: user.state, + locked: user.locked.toString(), username: user.username, web_url: user.web_url, }, diff --git a/spec/frontend/vue_alerts_spec.js b/spec/frontend/vue_alerts_spec.js index de2faa09438..be4a45639cf 100644 --- a/spec/frontend/vue_alerts_spec.js +++ b/spec/frontend/vue_alerts_spec.js @@ -1,4 +1,5 @@ import { nextTick } from 'vue'; +import { alertVariantOptions } from '@gitlab/ui/dist/utils/constants'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import initVueAlerts from '~/vue_alerts'; @@ -55,7 +56,11 @@ describe('VueAlerts', () => { primaryButtonText: alert.querySelector('.gl-alert-action').textContent.trim(), primaryButtonLink: alert.querySelector('.gl-alert-action').href, variant: [...alert.classList] - .find((x) => x.match(/gl-alert-(?!not-dismissible)/)) + .find((cssClass) => { + return Object.values(alertVariantOptions).some( + (variant) => cssClass === `gl-alert-${variant}`, + ); + }) .replace('gl-alert-', ''), }); diff --git a/spec/frontend/vue_merge_request_widget/components/checks/conflicts_spec.js b/spec/frontend/vue_merge_request_widget/components/checks/conflicts_spec.js new file mode 100644 index 00000000000..57dcd2fd819 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/checks/conflicts_spec.js @@ -0,0 +1,90 @@ +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import ConflictsComponent from '~/vue_merge_request_widget/components/checks/conflicts.vue'; +import conflictsStateQuery from '~/vue_merge_request_widget/queries/states/conflicts.query.graphql'; + +Vue.use(VueApollo); + +let wrapper; +let apolloProvider; + +function factory({ + result = 'passed', + canMerge = true, + pushToSourceBranch = true, + shouldBeRebased = false, + sourceBranchProtected = false, + mr = {}, +} = {}) { + apolloProvider = createMockApollo([ + [ + conflictsStateQuery, + jest.fn().mockResolvedValue({ + data: { + project: { + id: 1, + mergeRequest: { + id: 1, + shouldBeRebased, + sourceBranchProtected, + userPermissions: { canMerge, pushToSourceBranch }, + }, + }, + }, + }), + ], + ]); + + wrapper = mountExtended(ConflictsComponent, { + apolloProvider, + propsData: { + mr, + check: { result, failureReason: 'Conflicts message' }, + }, + }); +} + +describe('Merge request merge checks conflicts component', () => { + afterEach(() => { + apolloProvider = null; + }); + + it('renders failure reason text', () => { + factory(); + + expect(wrapper.text()).toEqual('Conflicts message'); + }); + + it.each` + conflictResolutionPath | pushToSourceBranch | sourceBranchProtected | rendersConflictButton | rendersConflictButtonText + ${'https://gitlab.com'} | ${true} | ${false} | ${true} | ${'renders'} + ${undefined} | ${true} | ${false} | ${false} | ${'does not render'} + ${'https://gitlab.com'} | ${false} | ${false} | ${false} | ${'does not render'} + ${'https://gitlab.com'} | ${true} | ${true} | ${false} | ${'does not render'} + ${'https://gitlab.com'} | ${false} | ${false} | ${false} | ${'does not render'} + ${undefined} | ${false} | ${false} | ${false} | ${'does not render'} + `( + '$rendersConflictButtonText the conflict button for $conflictResolutionPath $pushToSourceBranch $sourceBranchProtected $rendersConflictButton', + async ({ + conflictResolutionPath, + pushToSourceBranch, + sourceBranchProtected, + rendersConflictButton, + }) => { + factory({ mr: { conflictResolutionPath }, pushToSourceBranch, sourceBranchProtected }); + + await waitForPromises(); + + expect(wrapper.findAllByTestId('extension-actions-button').length).toBe( + rendersConflictButton ? 2 : 1, + ); + + expect(wrapper.findAllByTestId('extension-actions-button').at(-1).text()).toBe( + rendersConflictButton ? 'Resolve conflicts' : 'Resolve locally', + ); + }, + ); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/checks/message_spec.js b/spec/frontend/vue_merge_request_widget/components/checks/message_spec.js new file mode 100644 index 00000000000..4446eb7324b --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/checks/message_spec.js @@ -0,0 +1,30 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import MessageComponent from '~/vue_merge_request_widget/components/checks/message.vue'; +import StatusIcon from '~/vue_merge_request_widget/components/widget/status_icon.vue'; + +let wrapper; + +function factory(propsData = {}) { + wrapper = mountExtended(MessageComponent, { + propsData, + }); +} + +describe('Merge request merge checks message component', () => { + it('renders failure reason text', () => { + factory({ check: { result: 'passed', failureReason: 'Failed message' } }); + + expect(wrapper.text()).toEqual('Failed message'); + }); + + it.each` + result | icon + ${'passed'} | ${'success'} + ${'failed'} | ${'failed'} + ${'allowed_to_fail'} | ${'neutral'} + `('renders $icon icon for $result result', ({ result, icon }) => { + factory({ check: { result, failureReason: 'Failed message' } }); + + expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe(icon); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js b/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js new file mode 100644 index 00000000000..c86fe6d0a10 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js @@ -0,0 +1,92 @@ +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import MergeChecksComponent from '~/vue_merge_request_widget/components/merge_checks.vue'; +import mergeChecksQuery from '~/vue_merge_request_widget/queries/merge_checks.query.graphql'; +import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; + +Vue.use(VueApollo); + +let wrapper; +let apolloProvider; + +function factory({ canMerge = true, mergeChecks = [] } = {}) { + apolloProvider = createMockApollo([ + [ + mergeChecksQuery, + jest.fn().mockResolvedValue({ + data: { + project: { + id: 1, + mergeRequest: { id: 1, userPermissions: { canMerge }, mergeChecks }, + }, + }, + }), + ], + ]); + + wrapper = mountExtended(MergeChecksComponent, { + apolloProvider, + propsData: { + mr: {}, + }, + }); +} + +describe('Merge request merge checks component', () => { + afterEach(() => { + apolloProvider = null; + }); + + it('renders ready to merge text if user can merge', async () => { + factory({ canMerge: true }); + + await waitForPromises(); + + expect(wrapper.text()).toBe('Ready to merge!'); + }); + + it('renders ready to merge by members text if user can not merge', async () => { + factory({ canMerge: false }); + + await waitForPromises(); + + expect(wrapper.text()).toBe('Ready to merge by members who can write to the target branch.'); + }); + + it.each` + mergeChecks | text + ${[{ identifier: 'discussions', result: 'failed' }]} | ${'Merge blocked: 1 check failed'} + ${[{ identifier: 'discussions', result: 'failed' }, { identifier: 'rebase', result: 'failed' }]} | ${'Merge blocked: 2 checks failed'} + `('renders $text for $mergeChecks', async ({ mergeChecks, text }) => { + factory({ mergeChecks }); + + await waitForPromises(); + + expect(wrapper.text()).toBe(text); + }); + + it.each` + result | statusIcon + ${'failed'} | ${'failed'} + ${'passed'} | ${'success'} + `('renders $statusIcon for $result result', async ({ result, statusIcon }) => { + factory({ mergeChecks: [{ result, identifier: 'discussions' }] }); + + await waitForPromises(); + + expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe(statusIcon); + }); + + it('expands collapsed area', async () => { + factory(); + + await waitForPromises(); + + await wrapper.findByTestId('widget-toggle').trigger('click'); + + expect(wrapper.findByTestId('merge-checks-full').exists()).toBe(true); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/widget/action_buttons_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/action_buttons_spec.js index adefce9060c..86e3922ec8b 100644 --- a/spec/frontend/vue_merge_request_widget/components/widget/action_buttons_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/widget/action_buttons_spec.js @@ -1,4 +1,4 @@ -import { GlButton, GlDropdownItem } from '@gitlab/ui'; +import { GlButton, GlDisclosureDropdown } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Actions from '~/vue_merge_request_widget/components/widget/action_buttons.vue'; @@ -37,7 +37,7 @@ describe('~/vue_merge_request_widget/components/widget/action_buttons.vue', () = tertiaryButtons: [{ text: 'hello world', href: 'https://gitlab.com', target: '_blank' }], }); - expect(wrapper.findAllComponents(GlDropdownItem)).toHaveLength(1); + expect(wrapper.findAllComponents(GlDisclosureDropdown)).toHaveLength(1); }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js index 09f58f17fd9..eb3d624dc04 100644 --- a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js @@ -21,7 +21,7 @@ import { registerExtension, registeredExtensions, } from '~/vue_merge_request_widget/components/extensions'; -import { STATUS_CLOSED, STATUS_OPEN } from '~/issues/constants'; +import { STATUS_CLOSED, STATUS_OPEN, STATUS_MERGED } from '~/issues/constants'; import { STATE_QUERY_POLLING_INTERVAL_BACKOFF } from '~/vue_merge_request_widget/constants'; import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants'; import eventHub from '~/vue_merge_request_widget/event_hub'; @@ -30,6 +30,7 @@ import Approvals from '~/vue_merge_request_widget/components/approvals/approvals import ConflictsState from '~/vue_merge_request_widget/components/states/mr_widget_conflicts.vue'; import Preparing from '~/vue_merge_request_widget/components/states/mr_widget_preparing.vue'; import ShaMismatch from '~/vue_merge_request_widget/components/states/sha_mismatch.vue'; +import MergedState from '~/vue_merge_request_widget/components/states/mr_widget_merged.vue'; import WidgetContainer from '~/vue_merge_request_widget/components/widget/app.vue'; import WidgetSuggestPipeline from '~/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue'; import MrWidgetAlertMessage from '~/vue_merge_request_widget/components/mr_widget_alert_message.vue'; @@ -78,23 +79,13 @@ describe('MrWidgetOptions', () => { const COLLABORATION_MESSAGE = 'Members who can merge are allowed to add commits'; - const setInitialData = (data) => { - gl.mrWidgetData = { ...mockData, ...data }; - mock - .onGet(mockData.merge_request_widget_path) - .reply(() => [HTTP_STATUS_OK, { ...mockData, ...data }]); - mock - .onGet(mockData.merge_request_cached_widget_path) - .reply(() => [HTTP_STATUS_OK, { ...mockData, ...data }]); - }; - const createComponent = ({ updatedMrData = {}, options = {}, data = {}, mountFn = shallowMountExtended, } = {}) => { - setInitialData(updatedMrData); + gl.mrWidgetData = { ...mockData, ...updatedMrData }; const mrData = { ...mockData, ...updatedMrData }; const mockedApprovalsSubscription = createMockApolloSubscription(); queryResponse = { @@ -172,8 +163,10 @@ describe('MrWidgetOptions', () => { const findWidgetContainer = () => wrapper.findComponent(WidgetContainer); beforeEach(() => { - gon.features = { asyncMrWidget: true }; + gon.features = {}; mock = new MockAdapter(axios); + mock.onGet(mockData.merge_request_widget_path).reply(HTTP_STATUS_OK, {}); + mock.onGet(mockData.merge_request_cached_widget_path).reply(HTTP_STATUS_OK, {}); }); afterEach(() => { @@ -186,25 +179,13 @@ describe('MrWidgetOptions', () => { describe('default', () => { describe('computed', () => { describe('componentName', () => { - beforeEach(async () => { - await createComponent(); - }); - - // quarantine: https://gitlab.com/gitlab-org/gitlab/-/issues/409365 - // eslint-disable-next-line jest/no-disabled-tests - it.skip.each` - ${'merged'} | ${'mr-widget-merged'} - `('should translate $state into $componentName', ({ state, componentName }) => { - wrapper.vm.mr.state = state; - - expect(wrapper.vm.componentName).toEqual(componentName); - }); - it.each` state | componentName | component + ${STATUS_MERGED} | ${'MergedState'} | ${MergedState} ${'conflicts'} | ${'ConflictsState'} | ${ConflictsState} ${'shaMismatch'} | ${'ShaMismatch'} | ${ShaMismatch} `('should translate $state into $componentName component', async ({ state, component }) => { + await createComponent(); Vue.set(wrapper.vm.mr, 'state', state); await nextTick(); expect(wrapper.findComponent(component).exists()).toBe(true); @@ -336,13 +317,23 @@ describe('MrWidgetOptions', () => { describe('methods', () => { describe('checkStatus', () => { + const updatedMrData = { foo: 1 }; + beforeEach(() => { + mock + .onGet(mockData.merge_request_widget_path) + .reply(HTTP_STATUS_OK, { ...mockData, ...updatedMrData }); + mock + .onGet(mockData.merge_request_cached_widget_path) + .reply(HTTP_STATUS_OK, { ...mockData, ...updatedMrData }); + }); + it('checks the status of the pipelines', async () => { const callback = jest.fn(); - await createComponent({ updatedMrData: { foo: 1 } }); + await createComponent({ updatedMrData }); await waitForPromises(); eventHub.$emit('MRWidgetUpdateRequested', callback); await waitForPromises(); - expect(callback).toHaveBeenCalledWith(expect.objectContaining({ foo: 1 })); + expect(callback).toHaveBeenCalledWith(expect.objectContaining(updatedMrData)); }); it('notifies the user of the pipeline status', async () => { @@ -515,29 +506,42 @@ describe('MrWidgetOptions', () => { }); describe('handleNotification', () => { + const updatedMrData = { gitlabLogo: 'logo.png' }; beforeEach(() => { jest.spyOn(notify, 'notifyMe').mockImplementation(() => {}); }); - it('should call notifyMe', async () => { - const logoFilename = 'logo.png'; - await createComponent({ updatedMrData: { gitlabLogo: logoFilename } }); - expect(notify.notifyMe).toHaveBeenCalledWith( - `Pipeline passed`, - `Pipeline passed for "${mockData.title}"`, - logoFilename, - ); - }); + describe('when pipeline has passed', () => { + beforeEach(() => { + mock + .onGet(mockData.merge_request_widget_path) + .reply(HTTP_STATUS_OK, { ...mockData, ...updatedMrData }); + mock + .onGet(mockData.merge_request_cached_widget_path) + .reply(HTTP_STATUS_OK, { ...mockData, ...updatedMrData }); + }); - it('should not call notifyMe if the status has not changed', async () => { - await createComponent({ updatedMrData: { ci_status: undefined } }); - await eventHub.$emit('MRWidgetUpdateRequested'); - expect(notify.notifyMe).not.toHaveBeenCalled(); + it('should call notifyMe', async () => { + await createComponent({ updatedMrData }); + expect(notify.notifyMe).toHaveBeenCalledWith( + `Pipeline passed`, + `Pipeline passed for "${mockData.title}"`, + updatedMrData.gitlabLogo, + ); + }); }); - it('should not notify if no pipeline provided', async () => { - await createComponent({ updatedMrData: { pipeline: undefined } }); - expect(notify.notifyMe).not.toHaveBeenCalled(); + describe('when pipeline has not passed', () => { + it('should not call notifyMe if the status has not changed', async () => { + await createComponent({ updatedMrData: { ci_status: undefined } }); + await eventHub.$emit('MRWidgetUpdateRequested'); + expect(notify.notifyMe).not.toHaveBeenCalled(); + }); + + it('should not notify if no pipeline provided', async () => { + await createComponent({ updatedMrData: { pipeline: undefined } }); + expect(notify.notifyMe).not.toHaveBeenCalled(); + }); }); }); diff --git a/spec/frontend/vue_shared/alert_details/alert_status_spec.js b/spec/frontend/vue_shared/alert_details/alert_status_spec.js index 90d29f0bfd4..478df81a966 100644 --- a/spec/frontend/vue_shared/alert_details/alert_status_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_status_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; import updateAlertStatusMutation from '~/graphql_shared//mutations/alert_status_update.mutation.graphql'; @@ -34,13 +34,13 @@ describe('AlertManagementStatus', () => { }, }); - const findStatusDropdown = () => wrapper.findComponent(GlDropdown); - const findFirstStatusOption = () => findStatusDropdown().findComponent(GlDropdownItem); - const findAllStatusOptions = () => findStatusDropdown().findAllComponents(GlDropdownItem); - const findStatusDropdownHeader = () => wrapper.findByTestId('dropdown-header'); + const findStatusDropdown = () => wrapper.findComponent(GlCollapsibleListbox); + const findFirstStatusOption = () => findStatusDropdown().findComponent(GlListboxItem); + const findAllStatusOptions = () => findStatusDropdown().findAllComponents(GlListboxItem); + const findStatusDropdownHeader = () => wrapper.findByTestId('listbox-header-text'); const selectFirstStatusOption = () => { - findFirstStatusOption().vm.$emit('click'); + findFirstStatusOption().vm.$emit('select', new Event('click')); return waitForPromises(); }; @@ -57,7 +57,7 @@ describe('AlertManagementStatus', () => { provide = {}, handler = mockUpdatedMutationResult(), } = {}) { - wrapper = shallowMountExtended(AlertManagementStatus, { + wrapper = mountExtended(AlertManagementStatus, { apolloProvider: createMockApolloProvider(handler), propsData: { alert: { ...mockAlert }, @@ -82,7 +82,7 @@ describe('AlertManagementStatus', () => { it('shows the dropdown', () => { mountComponent({ props: { isSidebar: true, isDropdownShowing: true } }); - expect(wrapper.classes()).toContain('show'); + expect(wrapper.classes()).not.toContain('gl-display-none'); }); }); @@ -92,8 +92,7 @@ describe('AlertManagementStatus', () => { }); it('calls `$apollo.mutate` with `updateAlertStatus` mutation and variables containing `iid`, `status`, & `projectPath`', async () => { - findFirstStatusOption().vm.$emit('click'); - await waitForPromises(); + await selectFirstStatusOption(); expect(requestHandler).toHaveBeenCalledWith({ iid, @@ -194,9 +193,7 @@ describe('AlertManagementStatus', () => { handler: mockUpdatedMutationResult({ nodes: mockAlerts }), }); Tracking.event.mockClear(); - findFirstStatusOption().vm.$emit('click'); - - await waitForPromises(); + await selectFirstStatusOption(); const status = findFirstStatusOption().text(); const { category, action, label } = trackAlertStatusUpdateOptions; diff --git a/spec/frontend/vue_shared/components/badges/__snapshots__/beta_badge_spec.js.snap b/spec/frontend/vue_shared/components/badges/__snapshots__/beta_badge_spec.js.snap index 359aaacde0b..499a971d791 100644 --- a/spec/frontend/vue_shared/components/badges/__snapshots__/beta_badge_spec.js.snap +++ b/spec/frontend/vue_shared/components/badges/__snapshots__/beta_badge_spec.js.snap @@ -2,22 +2,15 @@ exports[`Beta badge component renders the badge 1`] = ` <div> - <gl-badge-stub - class="gl-cursor-pointer" + <a + class="badge badge-neutral badge-pill gl-badge gl-cursor-pointer md" href="#" - iconsize="md" - size="md" - variant="neutral" + target="_self" > Beta - </gl-badge-stub> - <gl-popover-stub - cssclasses="" - data-testid="beta-badge" - showclosebutton="true" - target="[Function]" - title="What's Beta?" - triggers="hover focus click" + </a> + <div + class="gl-popover" > <p> A Beta feature is not production-ready, but is unlikely to change drastically before it's released. We encourage users to try Beta features and provide feedback. @@ -43,6 +36,6 @@ exports[`Beta badge component renders the badge 1`] = ` Is complete or near completion. </li> </ul> - </gl-popover-stub> + </div> </div> `; diff --git a/spec/frontend/vue_shared/components/badges/__snapshots__/experiment_badge_spec.js.snap b/spec/frontend/vue_shared/components/badges/__snapshots__/experiment_badge_spec.js.snap new file mode 100644 index 00000000000..4ad70338f3c --- /dev/null +++ b/spec/frontend/vue_shared/components/badges/__snapshots__/experiment_badge_spec.js.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Experiment badge component renders the badge 1`] = ` +<div> + <a + class="badge badge-neutral badge-pill gl-badge gl-cursor-pointer md" + href="#" + target="_self" + > + Experiment + </a> + <div + class="gl-popover" + > + <p> + An Experiment is a feature that's in the process of being developed. It's not production-ready. We encourage users to try Experimental features and provide feedback. + </p> + <p + class="gl-mb-0" + > + An Experiment: + </p> + <ul + class="gl-pl-4" + > + <li> + May be unstable. + </li> + <li> + Can cause data loss. + </li> + <li> + Has no support and might not be documented. + </li> + <li> + Can be removed at any time. + </li> + </ul> + </div> +</div> +`; diff --git a/spec/frontend/vue_shared/components/badges/beta_badge_spec.js b/spec/frontend/vue_shared/components/badges/beta_badge_spec.js index c930c6d5708..d826ca5c7c0 100644 --- a/spec/frontend/vue_shared/components/badges/beta_badge_spec.js +++ b/spec/frontend/vue_shared/components/badges/beta_badge_spec.js @@ -1,4 +1,4 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import { GlBadge } from '@gitlab/ui'; import BetaBadge from '~/vue_shared/components/badges/beta_badge.vue'; @@ -7,7 +7,7 @@ describe('Beta badge component', () => { const findBadge = () => wrapper.findComponent(GlBadge); const createWrapper = (props = {}) => { - wrapper = shallowMount(BetaBadge, { + wrapper = mount(BetaBadge, { propsData: { ...props }, }); }; diff --git a/spec/frontend/vue_shared/components/badges/experiment_badge_spec.js b/spec/frontend/vue_shared/components/badges/experiment_badge_spec.js new file mode 100644 index 00000000000..3239578a173 --- /dev/null +++ b/spec/frontend/vue_shared/components/badges/experiment_badge_spec.js @@ -0,0 +1,32 @@ +import { mount } from '@vue/test-utils'; +import { GlBadge } from '@gitlab/ui'; +import ExperimentBadge from '~/vue_shared/components/badges/experiment_badge.vue'; + +describe('Experiment badge component', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + const createWrapper = (props = {}) => { + wrapper = mount(ExperimentBadge, { + propsData: { ...props }, + }); + }; + + it('renders the badge', () => { + createWrapper(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('passes default size to badge', () => { + createWrapper(); + + expect(findBadge().props('size')).toBe('md'); + }); + + it('passes given size to badge', () => { + createWrapper({ size: 'sm' }); + + expect(findBadge().props('size')).toBe('sm'); + }); +}); diff --git a/spec/frontend/vue_shared/components/badges/hover_badge_spec.js b/spec/frontend/vue_shared/components/badges/hover_badge_spec.js new file mode 100644 index 00000000000..68f368215c0 --- /dev/null +++ b/spec/frontend/vue_shared/components/badges/hover_badge_spec.js @@ -0,0 +1,50 @@ +import { mount } from '@vue/test-utils'; +import { GlBadge, GlPopover } from '@gitlab/ui'; +import HoverBadge from '~/vue_shared/components/badges/hover_badge.vue'; + +describe('Hover badge component', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + const findPopover = () => wrapper.findComponent(GlPopover); + const createWrapper = ({ props = {}, slots } = {}) => { + wrapper = mount(HoverBadge, { + propsData: { + label: 'Label', + title: 'Title', + ...props, + }, + slots, + }); + }; + + it('passes label to popover', () => { + createWrapper(); + + expect(findBadge().text()).toBe('Label'); + }); + + it('passes title to popover', () => { + createWrapper(); + + expect(findPopover().props('title')).toBe('Title'); + }); + + it('renders the default slot', () => { + createWrapper({ slots: { default: '<p>This is an awesome content</p>' } }); + + expect(findPopover().text()).toContain('This is an awesome content'); + }); + + it('passes default size to badge', () => { + createWrapper(); + + expect(findBadge().props('size')).toBe('md'); + }); + + it('passes given size to badge', () => { + createWrapper({ props: { size: 'sm' } }); + + expect(findBadge().props('size')).toBe('sm'); + }); +}); diff --git a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js index eadcd452929..c1109f21b47 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js +++ b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js @@ -60,6 +60,7 @@ describe('Blob Rich Viewer component', () => { expect(wrapper.text()).toContain('Line: 10'); expect(wrapper.text()).toContain('Line: 50'); expect(wrapper.emitted(CONTENT_LOADED_EVENT)).toHaveLength(1); + expect(handleLocationHash).toHaveBeenCalled(); expect(findMarkdownFieldView().props('isLoading')).toBe(false); }); diff --git a/spec/frontend/vue_shared/components/ci_badge_link_spec.js b/spec/frontend/vue_shared/components/ci_badge_link_spec.js index c74964c13f5..e1660225a5c 100644 --- a/spec/frontend/vue_shared/components/ci_badge_link_spec.js +++ b/spec/frontend/vue_shared/components/ci_badge_link_spec.js @@ -149,4 +149,10 @@ describe('CI Badge Link Component', () => { expect(findBadge().props('size')).toBe('lg'); }); + + it('should have class `gl-px-2` when `showText` is false', () => { + createComponent({ status: statuses.success, size: 'md', showText: false }); + + expect(findBadge().classes()).toContain('gl-px-2'); + }); }); diff --git a/spec/frontend/vue_shared/components/clone_dropdown/clone_dropdown_item_spec.js b/spec/frontend/vue_shared/components/clone_dropdown/clone_dropdown_item_spec.js index e0dfa084f3e..341afa03f80 100644 --- a/spec/frontend/vue_shared/components/clone_dropdown/clone_dropdown_item_spec.js +++ b/spec/frontend/vue_shared/components/clone_dropdown/clone_dropdown_item_spec.js @@ -6,11 +6,11 @@ describe('Clone Dropdown Button', () => { let wrapper; const link = 'ssh://foo.bar'; const label = 'SSH'; - const qaSelector = 'some-selector'; + const testId = 'some-selector'; const defaultPropsData = { link, label, - qaSelector, + testId, }; const findCopyButton = () => wrapper.findComponent(GlButton); @@ -46,7 +46,7 @@ describe('Clone Dropdown Button', () => { }); it('sets the qa selector', () => { - expect(findCopyButton().attributes('data-qa-selector')).toBe(qaSelector); + expect(findCopyButton().attributes('data-testid')).toBe(testId); }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js index a22ad4c450e..7c9f3a3546a 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -97,6 +97,19 @@ export const projectMilestonesResponse = { }, }; +export const projectUsersResponse = { + data: { + project: { + id: 'gid://gitlab/Project/1', + attributes: { + nodes: mockUsers, + __typename: 'UserConnection', + }, + __typename: 'Project', + }, + }, +}; + export const mockCrmContacts = [ { __typename: 'CustomerRelationsContact', @@ -247,8 +260,8 @@ export const mockAuthorToken = { symbol: '@', token: UserToken, operators: OPERATORS_IS, - fetchPath: 'gitlab-org/gitlab-test', - fetchUsers: Api.projectUsers.bind(Api), + fullPath: 'gitlab-org/gitlab-test', + isProject: true, }; export const mockLabelToken = { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js index 63eacaabd0c..72e3475df75 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js @@ -420,6 +420,12 @@ describe('BaseToken', () => { expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); + it('renders `footer` slot when present', () => { + wrapper = createComponent({ slots: { footer: "<div class='custom-footer' />" } }); + + expect(wrapper.find('.custom-footer').exists()).toBe(true); + }); + describe('events', () => { describe('when activeToken has been selected', () => { beforeEach(() => { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js index e4ca7dcb19a..0229d00eb91 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js @@ -6,16 +6,21 @@ import { } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; +import usersAutocompleteQuery from '~/graphql_shared/queries/users_autocomplete.query.graphql'; import { OPTIONS_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; -import { mockAuthorToken, mockUsers } from '../mock_data'; +import { mockAuthorToken, mockUsers, projectUsersResponse } from '../mock_data'; + +Vue.use(VueApollo); jest.mock('~/alert'); const defaultStubs = { @@ -37,6 +42,9 @@ const mockPreloadedUsers = [ }, ]; +const usersQueryHandler = jest.fn().mockResolvedValue(projectUsersResponse); +const mockApollo = createMockApollo([[usersAutocompleteQuery, usersQueryHandler]]); + function createComponent(options = {}) { const { config = mockAuthorToken, @@ -47,6 +55,7 @@ function createComponent(options = {}) { listeners = {}, } = options; return mount(UserToken, { + apolloProvider: mockApollo, propsData: { config, value, @@ -145,6 +154,33 @@ describe('UserToken', () => { expect(findBaseToken().props('suggestionsLoading')).toBe(false); }); }); + + describe('default - when fetchMilestones function is not provided in config', () => { + beforeEach(() => { + wrapper = createComponent({}); + return triggerFetchUsers(); + }); + + it('calls searchMilestonesQuery to fetch milestones', () => { + expect(usersQueryHandler).toHaveBeenCalledWith({ + fullPath: mockAuthorToken.fullPath, + isProject: mockAuthorToken.isProject, + search: null, + }); + }); + + it('calls searchMilestonesQuery with search parameter when provided', async () => { + const searchTerm = 'foo'; + + await triggerFetchUsers(searchTerm); + + expect(usersQueryHandler).toHaveBeenCalledWith({ + fullPath: mockAuthorToken.fullPath, + isProject: mockAuthorToken.isProject, + search: searchTerm, + }); + }); + }); }); }); diff --git a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js index eee85ce4fd3..72a0eb98a07 100644 --- a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js +++ b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js @@ -363,13 +363,13 @@ describe('InputCopyToggleVisibility', () => { it('passes no `size` prop', () => { createComponent(); - expect(findFormInput().props('size')).toBe(null); + expect(findFormInput().props('width')).toBe(null); }); it('passes `size` prop to the input', () => { createComponent({ props: { size: 'md' } }); - expect(findFormInput().props('size')).toBe('md'); + expect(findFormInput().props('width')).toBe('md'); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/editor_mode_switcher_spec.js b/spec/frontend/vue_shared/components/markdown/editor_mode_switcher_spec.js index 712e78458c6..57f54f7e7d3 100644 --- a/spec/frontend/vue_shared/components/markdown/editor_mode_switcher_spec.js +++ b/spec/frontend/vue_shared/components/markdown/editor_mode_switcher_spec.js @@ -1,41 +1,22 @@ import { nextTick } from 'vue'; -import { GlButton, GlLink, GlPopover } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue'; -import { counter } from '~/vue_shared/components/markdown/utils'; -import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; -import { stubComponent } from 'helpers/stub_component'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; -jest.mock('~/vue_shared/components/markdown/utils', () => ({ - counter: jest.fn().mockReturnValue(0), -})); - describe('vue_shared/component/markdown/editor_mode_switcher', () => { let wrapper; useLocalStorageSpy(); - const createComponent = ({ - value, - userCalloutDismisserSlotProps = { dismiss: jest.fn() }, - } = {}) => { + const createComponent = ({ value } = {}) => { wrapper = mount(EditorModeSwitcher, { propsData: { value, }, - stubs: { - UserCalloutDismisser: stubComponent(UserCalloutDismisser, { - render() { - return this.$scopedSlots.default(userCalloutDismisserSlotProps); - }, - }), - }, }); }; const findSwitcherButton = () => wrapper.findComponent(GlButton); - const findUserCalloutDismisser = () => wrapper.findComponent(UserCalloutDismisser); - const findCalloutPopover = () => wrapper.findComponent(GlPopover); describe.each` value | buttonText @@ -54,62 +35,7 @@ describe('vue_shared/component/markdown/editor_mode_switcher', () => { await nextTick(); findSwitcherButton().vm.$emit('click'); - expect(wrapper.emitted().switch).toEqual([[false]]); - }); - }); - - describe('rich text editor callout', () => { - let dismiss; - - beforeEach(() => { - dismiss = jest.fn(); - createComponent({ value: 'markdown', userCalloutDismisserSlotProps: { dismiss } }); - }); - - it('does not skip the user_callout_dismisser query', () => { - expect(findUserCalloutDismisser().props()).toMatchObject({ - skipQuery: false, - featureName: 'rich_text_editor', - }); - }); - - it('mounts new rich text editor popover', () => { - expect(findCalloutPopover().props()).toMatchObject({ - showCloseButton: '', - triggers: 'manual', - target: 'switch-to-rich-text-editor', - }); - }); - - it('dismisses the callout and emits "switch" event when popover close button is clicked', async () => { - await findCalloutPopover().findComponent(GlLink).vm.$emit('click'); - - expect(wrapper.emitted().switch).toEqual([[true]]); - expect(dismiss).toHaveBeenCalled(); - }); - - it('dismisses the callout when action button is clicked', () => { - findSwitcherButton().vm.$emit('click'); - - expect(dismiss).toHaveBeenCalled(); - }); - - it('does not show the callout if rich text is already enabled', async () => { - await wrapper.setProps({ value: 'richText' }); - - expect(findCalloutPopover().props()).toMatchObject({ - show: false, - }); - }); - - it('does not show the callout if already displayed once on the page', () => { - counter.mockReturnValue(1); - - createComponent({ value: 'markdown' }); - - expect(findCalloutPopover().props()).toMatchObject({ - show: false, - }); + expect(wrapper.emitted().switch).toEqual([[]]); }); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js index c69b18bca88..b4c90fe49d1 100644 --- a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js +++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js @@ -7,6 +7,8 @@ import { EDITING_MODE_MARKDOWN_FIELD, EDITING_MODE_CONTENT_EDITOR, CLEAR_AUTOSAVE_ENTRY_EVENT, + CONTENT_EDITOR_READY_EVENT, + MARKDOWN_EDITOR_READY_EVENT, } from '~/vue_shared/constants'; import markdownEditorEventHub from '~/vue_shared/components/markdown/eventhub'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; @@ -83,22 +85,23 @@ describe('vue_shared/component/markdown/markdown_editor', () => { const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); const findContentEditor = () => { const result = wrapper.findComponent(ContentEditor); - // In Vue.js 3 there are nuances stubbing component with custom stub on mount // So we try to search for stub also return result.exists() ? result : wrapper.findComponent(ContentEditorStub); }; - const enableContentEditor = async () => { - findMarkdownField().vm.$emit('enableContentEditor'); - await nextTick(); - await waitForPromises(); + const enableContentEditor = () => { + return new Promise((resolve) => { + markdownEditorEventHub.$once(CONTENT_EDITOR_READY_EVENT, resolve); + findMarkdownField().vm.$emit('enableContentEditor'); + }); }; - const enableMarkdownEditor = async () => { - findContentEditor().vm.$emit('enableMarkdownEditor'); - await nextTick(); - await waitForPromises(); + const enableMarkdownEditor = () => { + return new Promise((resolve) => { + markdownEditorEventHub.$once(MARKDOWN_EDITOR_READY_EVENT, resolve); + findContentEditor().vm.$emit('enableMarkdownEditor'); + }); }; beforeEach(() => { @@ -128,9 +131,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { }); }); - // quarantine: https://gitlab.com/gitlab-org/gitlab/-/issues/412618 - // eslint-disable-next-line jest/no-disabled-tests - it.skip('passes render_quick_actions param to renderMarkdownPath if quick actions are enabled', async () => { + it('passes render_quick_actions param to renderMarkdownPath if quick actions are enabled', async () => { buildWrapper({ propsData: { supportsQuickActions: true } }); await enableContentEditor(); @@ -139,9 +140,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { expect(mock.history.post[0].url).toContain(`render_quick_actions=true`); }); - // quarantine: https://gitlab.com/gitlab-org/gitlab/-/issues/411565 - // eslint-disable-next-line jest/no-disabled-tests - it.skip('does not pass render_quick_actions param to renderMarkdownPath if quick actions are disabled', async () => { + it('does not pass render_quick_actions param to renderMarkdownPath if quick actions are disabled', async () => { buildWrapper({ propsData: { supportsQuickActions: false } }); await enableContentEditor(); @@ -213,9 +212,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { expect(findMarkdownField().find('textarea').attributes('disabled')).toBe(undefined); }); - // quarantine: https://gitlab.com/gitlab-org/gitlab/-/issues/404734 - // eslint-disable-next-line jest/no-disabled-tests - it.skip('disables content editor when disabled prop is true', async () => { + it('disables content editor when disabled prop is true', async () => { buildWrapper({ propsData: { disabled: true } }); await enableContentEditor(); @@ -358,9 +355,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { }); it(`emits ${EDITING_MODE_MARKDOWN_FIELD} event when enableMarkdownEditor emitted from content editor`, async () => { - buildWrapper({ - stubs: { ContentEditor: ContentEditorStub }, - }); + buildWrapper(); await enableContentEditor(); await enableMarkdownEditor(); @@ -494,12 +489,62 @@ describe('vue_shared/component/markdown/markdown_editor', () => { expect(findContentEditor().props().autofocus).toBe(false); }); - it('bubbles up keydown event', () => { - const event = new Event('keydown'); + describe('when keydown event is fired', () => { + let event; + beforeEach(() => { + event = new Event('keydown'); + window.getSelection = jest.fn(() => ({ + toString: jest.fn(() => 'test'), + removeAllRanges: jest.fn(), + })); + Object.assign(event, { preventDefault: jest.fn() }); + }); + it('bubbles up keydown event', () => { + findContentEditor().vm.$emit('keydown', event); + + expect(wrapper.emitted('keydown')).toEqual([[event]]); + }); + + it('bubbles up keydown event for meta key with default behaviour intact', () => { + event.metaKey = true; + findContentEditor().vm.$emit('keydown', event); - findContentEditor().vm.$emit('keydown', event); + expect(wrapper.emitted('keydown')).toEqual([[event]]); + expect(event.preventDefault).toHaveBeenCalledTimes(0); + }); + + it('bubbles up keydown event for meta + k key on selected text with default behaviour prevented', () => { + event.metaKey = true; + event.key = 'k'; + findContentEditor().vm.$emit('keydown', event); + + expect(wrapper.emitted('keydown')).toEqual([[event]]); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('bubbles up keydown event for meta + k key without text selection with default behaviour prevented', () => { + event.metaKey = true; + event.key = 'k'; + window.getSelection = jest.fn(() => ({ + toString: jest.fn(() => ''), + removeAllRanges: jest.fn(), + })); + + findContentEditor().vm.$emit('keydown', event); - expect(wrapper.emitted('keydown')).toEqual([[event]]); + expect(wrapper.emitted('keydown')).toEqual([[event]]); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('bubbles up keydown event for meta + non-k key with default behaviour intact', () => { + event.metaKey = true; + event.key = 'l'; + + findContentEditor().vm.$emit('keydown', event); + + expect(wrapper.emitted('keydown')).toEqual([[event]]); + expect(event.preventDefault).toHaveBeenCalledTimes(0); + }); }); describe(`when richText editor triggers enableMarkdownEditor event`, () => { diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js index 90d8ce3b500..59f01b7ff7f 100644 --- a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js +++ b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js @@ -3,7 +3,6 @@ import Toolbar from '~/vue_shared/components/markdown/toolbar.vue'; import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue'; import { updateText } from '~/lib/utils/text_markdown'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; jest.mock('~/lib/utils/text_markdown'); @@ -83,28 +82,5 @@ describe('toolbar', () => { expect(wrapper.emitted('enableContentEditor')).toEqual([[]]); expect(updateText).not.toHaveBeenCalled(); }); - - it('does not insert a template text if textarea has some value', () => { - wrapper.findComponent(EditorModeSwitcher).vm.$emit('switch', true); - - expect(updateText).not.toHaveBeenCalled(); - }); - - it('inserts a "getting started with rich text" template when switched for the first time', () => { - document.querySelector('textarea').value = ''; - - wrapper.findComponent(EditorModeSwitcher).vm.$emit('switch', true); - - expect(updateText).toHaveBeenCalledWith( - expect.objectContaining({ - tag: `### Rich text editor - -Try out **styling** _your_ content right here or read the [direction](${PROMO_URL}/direction/plan/knowledge/content_editor/).`, - textArea: document.querySelector('textarea'), - cursorOffset: 0, - wrap: false, - }), - ); - }); }); }); diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_cli_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_cli_instructions_spec.js index c6cd963fc33..67aa57a019b 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_cli_instructions_spec.js +++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_cli_instructions_spec.js @@ -1,5 +1,5 @@ -import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlAlert, GlListboxItem, GlLoadingIcon } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -32,7 +32,7 @@ describe('RunnerCliInstructions component', () => { const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAlert = () => wrapper.findComponent(GlAlert); - const findArchitectureDropdownItems = () => wrapper.findAllByTestId('architecture-dropdown-item'); + const findArchitectureDropdownItems = () => wrapper.findAllComponents(GlListboxItem); const findBinaryDownloadButton = () => wrapper.findByTestId('binary-download-button'); const findBinaryInstructions = () => wrapper.findByTestId('binary-instructions'); const findRegisterCommand = () => wrapper.findByTestId('register-command'); @@ -43,7 +43,7 @@ describe('RunnerCliInstructions component', () => { fakeApollo = createMockApollo(requestHandlers); wrapper = extendedWrapper( - shallowMount(RunnerCliInstructions, { + mount(RunnerCliInstructions, { propsData: { platform: mockPlatform, registrationToken: 'MY_TOKEN', diff --git a/spec/frontend/vue_shared/components/segmented_control_button_group_spec.js b/spec/frontend/vue_shared/components/segmented_control_button_group_spec.js index c1feb64dacb..623a8739907 100644 --- a/spec/frontend/vue_shared/components/segmented_control_button_group_spec.js +++ b/spec/frontend/vue_shared/components/segmented_control_button_group_spec.js @@ -10,6 +10,7 @@ const DEFAULT_OPTIONS = [ ]; describe('~/vue_shared/components/segmented_control_button_group.vue', () => { + let consoleSpy; let wrapper; const createComponent = (props = {}, scopedSlots = {}) => { @@ -97,4 +98,34 @@ describe('~/vue_shared/components/segmented_control_button_group.vue', () => { ); }); }); + + describe('options prop validation', () => { + beforeEach(() => { + consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + }); + + it.each([ + [[{ disabled: true }]], + [[{ value: '1', disabled: 'false' }]], + [[{ value: null, disabled: 'true' }]], + [[[{ value: true }, null]]], + ])('with options=%j, fails validation', (options) => { + createComponent({ options }); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid prop: custom validator check failed for prop "options"'), + ); + }); + + it.each([ + [[{ value: '1' }]], + [[{ value: 1, disabled: true }]], + [[{ value: true, disabled: false }]], + ])('with options=%j, passes validation', (options) => { + createComponent({ options }); + + expect(consoleSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/__snapshots__/utils_spec.js.snap b/spec/frontend/vue_shared/components/source_viewer/__snapshots__/utils_spec.js.snap new file mode 100644 index 00000000000..e75b07dcf71 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/__snapshots__/utils_spec.js.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SourceViewer utils toggleBlameClasses adds classes 1`] = ` +<div + class="content" +> + <div + class="gl-border-gray-500 gl-border-t gl-pt-3!" + > + <div + id="reference-0" + > + 1 + </div> + <div + id="reference-1" + > + 2 + </div> + <div + id="reference-2" + > + 3 + </div> + </div> + <div> + <div + class="gl-border-gray-500 gl-border-t gl-pt-3!" + id="reference-3" + > + Content 1 + </div> + <div + class="gl-border-gray-500 gl-border-t gl-pt-3!" + id="reference-4" + > + Content 2 + </div> + <div + class="gl-border-gray-500 gl-border-t gl-pt-3!" + id="reference-5" + > + Content 3 + </div> + </div> +</div> +`; + +exports[`SourceViewer utils toggleBlameClasses removes classes 1`] = ` +<div + class="content" +> + <div> + <div + id="reference-0" + > + 1 + </div> + <div + id="reference-1" + > + 2 + </div> + <div + id="reference-2" + > + 3 + </div> + </div> + <div> + <div + id="reference-3" + > + Content 1 + </div> + <div + id="reference-4" + > + Content 2 + </div> + <div + id="reference-5" + > + Content 3 + </div> + </div> +</div> +`; diff --git a/spec/frontend/vue_shared/components/source_viewer/components/blame_info_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/blame_info_spec.js new file mode 100644 index 00000000000..ff8b2be9634 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/components/blame_info_spec.js @@ -0,0 +1,63 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { setHTMLFixture } from 'helpers/fixtures'; +import CommitInfo from '~/repository/components/commit_info.vue'; +import BlameInfo from '~/vue_shared/components/source_viewer/components/blame_info.vue'; +import * as utils from '~/vue_shared/components/source_viewer/utils'; +import { SOURCE_CODE_CONTENT_MOCK, BLAME_DATA_MOCK } from '../mock_data'; + +describe('BlameInfo component', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(BlameInfo, { + propsData: { blameData: BLAME_DATA_MOCK }, + }); + }; + + beforeEach(() => { + setHTMLFixture(SOURCE_CODE_CONTENT_MOCK); + jest.spyOn(utils, 'toggleBlameClasses'); + createComponent(); + }); + + const findCommitInfoComponents = () => wrapper.findAllComponents(CommitInfo); + + it('adds the necessary classes to the DOM', () => { + expect(utils.toggleBlameClasses).toHaveBeenCalledWith(BLAME_DATA_MOCK, true); + }); + + it('renders a CommitInfo component for each blame entry', () => { + expect(findCommitInfoComponents().length).toBe(BLAME_DATA_MOCK.length); + }); + + it.each(BLAME_DATA_MOCK)( + 'sets the correct data and positioning for the commitInfo', + ({ lineno, commit, index }) => { + const commitInfoComponent = findCommitInfoComponents().at(index); + + expect(commitInfoComponent.props('commit')).toEqual(commit); + expect(commitInfoComponent.element.style.top).toBe(utils.calculateBlameOffset(lineno)); + }, + ); + + describe('commitInfo component styling', () => { + const borderTopClassName = 'gl-border-t'; + + it('does not add a top border for the first entry', () => { + expect(findCommitInfoComponents().at(0).element.classList).not.toContain(borderTopClassName); + }); + + it('add a top border for the rest of the entries', () => { + expect(findCommitInfoComponents().at(1).element.classList).toContain(borderTopClassName); + expect(findCommitInfoComponents().at(2).element.classList).toContain(borderTopClassName); + }); + }); + + describe('when component is destroyed', () => { + beforeEach(() => wrapper.destroy()); + + it('resets the DOM to its original state', () => { + expect(utils.toggleBlameClasses).toHaveBeenCalledWith(BLAME_DATA_MOCK, false); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/mock_data.js b/spec/frontend/vue_shared/components/source_viewer/mock_data.js index f35e9607d5c..b3516f7ed72 100644 --- a/spec/frontend/vue_shared/components/source_viewer/mock_data.js +++ b/spec/frontend/vue_shared/components/source_viewer/mock_data.js @@ -22,3 +22,24 @@ export const CHUNK_2 = { startingFrom: 70, blamePath, }; + +export const SOURCE_CODE_CONTENT_MOCK = ` +<div class="content"> + <div> + <div id="L1">1</div> + <div id="L2">2</div> + <div id="L3">3</div> + </div> + + <div> + <div id="LC1">Content 1</div> + <div id="LC2">Content 2</div> + <div id="LC3">Content 3</div> + </div> +</div>`; + +export const BLAME_DATA_MOCK = [ + { lineno: 1, commit: { author: 'Peter' }, index: 0 }, + { lineno: 2, commit: { author: 'Sarah' }, index: 1 }, + { lineno: 3, commit: { author: 'Peter' }, index: 2 }, +]; diff --git a/spec/frontend/vue_shared/components/source_viewer/utils_spec.js b/spec/frontend/vue_shared/components/source_viewer/utils_spec.js new file mode 100644 index 00000000000..0ac72aa9afb --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/utils_spec.js @@ -0,0 +1,35 @@ +import { setHTMLFixture } from 'helpers/fixtures'; +import { + calculateBlameOffset, + toggleBlameClasses, +} from '~/vue_shared/components/source_viewer/utils'; +import { SOURCE_CODE_CONTENT_MOCK, BLAME_DATA_MOCK } from './mock_data'; + +describe('SourceViewer utils', () => { + beforeEach(() => setHTMLFixture(SOURCE_CODE_CONTENT_MOCK)); + + const findContent = () => document.querySelector('.content'); + + describe('calculateBlameOffset', () => { + it('returns an offset of zero if line number === 1', () => { + expect(calculateBlameOffset(1)).toBe('0px'); + }); + + it('calculates an offset for the blame component', () => { + const { offsetTop } = document.querySelector('#LC3'); + expect(calculateBlameOffset(3)).toBe(`${offsetTop}px`); + }); + }); + + describe('toggleBlameClasses', () => { + it('adds classes', () => { + toggleBlameClasses(BLAME_DATA_MOCK, true); + expect(findContent()).toMatchSnapshot(); + }); + + it('removes classes', () => { + toggleBlameClasses(BLAME_DATA_MOCK, false); + expect(findContent()).toMatchSnapshot(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js b/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js index 17a363ad8b1..41cf1d2b2e8 100644 --- a/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js +++ b/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { GlTruncate } from '@gitlab/ui'; import timezoneMock from 'timezone-mock'; import { formatDate, getTimeago } from '~/lib/utils/datetime_utility'; @@ -36,6 +37,14 @@ describe('Time ago with tooltip component', () => { expect(vm.text()).toEqual(timeAgoTimestamp); }); + it('should render truncated value with gl-truncate as true', () => { + buildVm({ + enableTruncation: true, + }); + + expect(vm.findComponent(GlTruncate).exists()).toBe(true); + }); + it('should render provided html class', () => { buildVm({ cssClass: 'foo', diff --git a/spec/frontend/vue_shared/components/toggle_labels_spec.js b/spec/frontend/vue_shared/components/toggle_labels_spec.js new file mode 100644 index 00000000000..e4b4b7f9e0c --- /dev/null +++ b/spec/frontend/vue_shared/components/toggle_labels_spec.js @@ -0,0 +1,56 @@ +import { GlToggle } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +import ToggleLabels from '~/vue_shared/components/toggle_labels.vue'; +import isShowingLabelsQuery from '~/graphql_shared/client/is_showing_labels.query.graphql'; + +Vue.use(VueApollo); + +describe('ToggleLabels', () => { + let wrapper; + + const findToggle = () => wrapper.findComponent(GlToggle); + + const mockSetIsShowingLabelsResolver = jest.fn(); + const mockApollo = createMockApollo([], { + Mutation: { + setIsShowingLabels: mockSetIsShowingLabelsResolver, + }, + }); + + const createComponent = () => { + mockApollo.clients.defaultClient.cache.writeQuery({ + query: isShowingLabelsQuery, + data: { + isShowingLabels: true, + }, + }); + wrapper = shallowMountExtended(ToggleLabels, { + apolloProvider: mockApollo, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('calls setIsShowingLabelsMutation on toggle', async () => { + expect(findToggle().props('value')).toBe(true); + findToggle().vm.$emit('change', false); + + await waitForPromises(); + + expect(mockSetIsShowingLabelsResolver).toHaveBeenCalledWith( + {}, + { + isShowingLabels: false, + }, + expect.anything(), + expect.anything(), + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/vuex_module_provider_spec.js b/spec/frontend/vue_shared/components/vuex_module_provider_spec.js index e24c5a4609d..95f557b10c1 100644 --- a/spec/frontend/vue_shared/components/vuex_module_provider_spec.js +++ b/spec/frontend/vue_shared/components/vuex_module_provider_spec.js @@ -1,6 +1,4 @@ import { mount } from '@vue/test-utils'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue'; const TestComponent = { @@ -38,12 +36,4 @@ describe('~/vue_shared/components/vuex_module_provider', () => { }); expect(findProvidedVuexModule()).toBe(TEST_VUEX_MODULE); }); - - it('does not blow up when used with vue-apollo', () => { - // See https://github.com/vuejs/vue-apollo/pull/1153 for details - Vue.use(VueApollo); - - createComponent(); - expect(findProvidedVuexModule()).toBe(TEST_VUEX_MODULE); - }); }); diff --git a/spec/frontend/vue_shared/issuable/create/components/issuable_create_root_spec.js b/spec/frontend/vue_shared/issuable/create/components/issuable_create_root_spec.js index 03f509a3fa3..35e3564c599 100644 --- a/spec/frontend/vue_shared/issuable/create/components/issuable_create_root_spec.js +++ b/spec/frontend/vue_shared/issuable/create/components/issuable_create_root_spec.js @@ -5,6 +5,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import IssuableCreateRoot from '~/vue_shared/issuable/create/components/issuable_create_root.vue'; import IssuableForm from '~/vue_shared/issuable/create/components/issuable_form.vue'; +import { TYPE_TEST_CASE } from '~/issues/constants'; Vue.use(VueApollo); @@ -13,6 +14,7 @@ const createComponent = ({ descriptionHelpPath = '/help/user/markdown', labelsFetchPath = '/gitlab-org/gitlab-shell/-/labels.json', labelsManagePath = '/gitlab-org/gitlab-shell/-/labels', + issuableType = TYPE_TEST_CASE, } = {}) => { return mount(IssuableCreateRoot, { propsData: { @@ -20,6 +22,7 @@ const createComponent = ({ descriptionHelpPath, labelsFetchPath, labelsManagePath, + issuableType, }, apolloProvider: createMockApollo(), slots: { diff --git a/spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js b/spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js index 62361705843..61185f913d9 100644 --- a/spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js +++ b/spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js @@ -1,9 +1,10 @@ -import { GlFormInput } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlFormInput, GlFormGroup, GlFormCheckbox } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import IssuableForm from '~/vue_shared/issuable/create/components/issuable_form.vue'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import LabelsSelect from '~/sidebar/components/labels/labels_select_vue/labels_select_root.vue'; +import { TYPE_TEST_CASE } from '~/issues/constants'; import { __ } from '~/locale'; const createComponent = ({ @@ -11,13 +12,15 @@ const createComponent = ({ descriptionHelpPath = '/help/user/markdown', labelsFetchPath = '/gitlab-org/gitlab-shell/-/labels.json', labelsManagePath = '/gitlab-org/gitlab-shell/-/labels', + issuableType = TYPE_TEST_CASE, } = {}) => { - return shallowMount(IssuableForm, { + return shallowMountExtended(IssuableForm, { propsData: { descriptionPreviewPath, descriptionHelpPath, labelsFetchPath, labelsManagePath, + issuableType, }, slots: { actions: ` @@ -58,7 +61,7 @@ describe('IssuableForm', () => { describe('template', () => { it('renders issuable title input field', () => { - const titleFieldEl = wrapper.find('[data-testid="issuable-title"]'); + const titleFieldEl = wrapper.findByTestId('issuable-title'); expect(titleFieldEl.exists()).toBe(true); expect(titleFieldEl.find('label').text()).toBe('Title'); @@ -68,7 +71,7 @@ describe('IssuableForm', () => { }); it('renders issuable description input field', () => { - const descriptionFieldEl = wrapper.find('[data-testid="issuable-description"]'); + const descriptionFieldEl = wrapper.findByTestId('issuable-description'); expect(descriptionFieldEl.exists()).toBe(true); expect(descriptionFieldEl.find('label').text()).toBe('Description'); @@ -88,8 +91,23 @@ describe('IssuableForm', () => { }); }); + it('renders issuable confidential checkbox', () => { + const confidentialCheckboxEl = wrapper.findByTestId('issuable-confidential'); + expect(confidentialCheckboxEl.exists()).toBe(true); + + expect(confidentialCheckboxEl.findComponent(GlFormGroup).exists()).toBe(true); + expect(confidentialCheckboxEl.findComponent(GlFormGroup).attributes('label')).toBe( + 'Confidentiality', + ); + + expect(confidentialCheckboxEl.findComponent(GlFormCheckbox).exists()).toBe(true); + expect(confidentialCheckboxEl.findComponent(GlFormCheckbox).text()).toBe( + 'This test case is confidential and should only be visible to team members with at least Reporter access.', + ); + }); + it('renders labels select field', () => { - const labelsSelectEl = wrapper.find('[data-testid="issuable-labels"]'); + const labelsSelectEl = wrapper.findByTestId('issuable-labels'); expect(labelsSelectEl.exists()).toBe(true); expect(labelsSelectEl.find('label').text()).toBe('Labels'); @@ -111,7 +129,7 @@ describe('IssuableForm', () => { it('renders contents for slot "actions"', () => { const buttonEl = wrapper - .find('[data-testid="issuable-create-actions"]') + .findByTestId('issuable-create-actions') .find('button.js-issuable-save'); expect(buttonEl.exists()).toBe(true); diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js index 9f7254ba0e6..47da111b604 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js @@ -1,6 +1,5 @@ import { GlLink, GlLabel, GlIcon, GlFormCheckbox, GlSprintf } from '@gitlab/ui'; import { nextTick } from 'vue'; -import { escape } from 'lodash'; import { useFakeDate } from 'helpers/fake_date'; import { shallowMountExtended as shallowMount } from 'helpers/vue_test_utils_helper'; import IssuableItem from '~/vue_shared/issuable/list/components/issuable_item.vue'; @@ -288,23 +287,10 @@ describe('IssuableItem', () => { expect(titleEl.exists()).toBe(true); expect(titleEl.findComponent(GlLink).attributes('href')).toBe(expectedHref); expect(titleEl.findComponent(GlLink).attributes('target')).toBe(expectedTarget); - expect(titleEl.findComponent(GlLink).html()).toContain(mockIssuable.titleHtml); + expect(titleEl.findComponent(GlLink).text()).toBe(mockIssuable.title); }, ); - it('renders issuable title with escaped markup when issue tracker is external', () => { - const mockTitle = '<script>foobar</script>'; - wrapper = createComponent({ - issuable: { - ...mockIssuable, - title: mockTitle, - externalTracker: 'jira', - }, - }); - - expect(wrapper.findByTestId('issuable-title').html()).toContain(escape(mockTitle)); - }); - it('renders checkbox when `showCheckbox` prop is true', async () => { wrapper = createComponent({ showCheckbox: true, @@ -366,7 +352,7 @@ describe('IssuableItem', () => { expect(hiddenIcon.props('name')).toBe('spam'); expect(hiddenIcon.attributes()).toMatchObject({ - title: 'This issue is hidden because its author has been banned', + title: 'This issue is hidden because its author has been banned.', arialabel: 'Hidden', }); }); diff --git a/spec/frontend/vue_shared/issuable/list/mock_data.js b/spec/frontend/vue_shared/issuable/list/mock_data.js index b39d177f292..f8cf3ba5271 100644 --- a/spec/frontend/vue_shared/issuable/list/mock_data.js +++ b/spec/frontend/vue_shared/issuable/list/mock_data.js @@ -42,7 +42,7 @@ export const mockCurrentUserTodo = { export const mockIssuable = { iid: '30', title: 'Dismiss Cipher with no integrity', - titleHtml: '<gl-emoji title="party-parrot"></gl-emoji>Dismiss Cipher with no integrity', + titleHtml: 'Dismiss Cipher with no integrity', description: 'fortitudinis _fomentis_ dolor mitigari solet.', descriptionHtml: 'fortitudinis <i>fomentis</i> dolor mitigari solet.', state: 'opened', diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js index 3b6f06d835b..03395e5dfc0 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js @@ -2,6 +2,8 @@ import { GlBadge, GlButton, GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import HiddenBadge from '~/issuable/components/hidden_badge.vue'; +import LockedBadge from '~/issuable/components/locked_badge.vue'; import { STATUS_CLOSED, STATUS_OPEN, STATUS_REOPENED, TYPE_ISSUE } from '~/issues/constants'; import { __ } from '~/locale'; import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; @@ -23,8 +25,8 @@ describe('IssuableHeader component', () => { wrapper.findAllComponents(GlIcon).filter((component) => component.props('name') === name); const findIcon = (name) => findGlIconWithName(name).exists() ? findGlIconWithName(name).at(0) : undefined; - const findBlockedIcon = () => findIcon('lock'); - const findHiddenIcon = () => findIcon('spam'); + const findBlockedBadge = () => wrapper.findComponent(LockedBadge); + const findHiddenBadge = () => wrapper.findComponent(HiddenBadge); const findExternalLinkIcon = () => findIcon('external-link'); const findFirstContributionIcon = () => findIcon('first-contribution'); const findComponentTooltip = (component) => getBinding(component.element, 'gl-tooltip'); @@ -111,49 +113,31 @@ describe('IssuableHeader component', () => { }); }); - describe('blocked icon', () => { + describe('blocked badge', () => { it('renders when issuable is blocked', () => { createComponent({ blocked: true }); - expect(findBlockedIcon().props('ariaLabel')).toBe('Blocked'); - }); - - it('has tooltip', () => { - createComponent({ blocked: true }); - - expect(findComponentTooltip(findBlockedIcon())).toBeDefined(); - expect(findBlockedIcon().attributes('title')).toBe( - 'This issue is locked. Only project members can comment.', - ); + expect(findBlockedBadge().props('issuableType')).toBe('issue'); }); it('does not render when issuable is not blocked', () => { createComponent({ blocked: false }); - expect(findBlockedIcon()).toBeUndefined(); + expect(findBlockedBadge().exists()).toBe(false); }); }); - describe('hidden icon', () => { + describe('hidden badge', () => { it('renders when issuable is hidden', () => { createComponent({ isHidden: true }); - expect(findHiddenIcon().props('ariaLabel')).toBe('Hidden'); - }); - - it('has tooltip', () => { - createComponent({ isHidden: true }); - - expect(findComponentTooltip(findHiddenIcon())).toBeDefined(); - expect(findHiddenIcon().attributes('title')).toBe( - 'This issue is hidden because its author has been banned', - ); + expect(findHiddenBadge().props('issuableType')).toBe('issue'); }); it('does not render when issuable is not hidden', () => { createComponent({ isHidden: false }); - expect(findHiddenIcon()).toBeUndefined(); + expect(findHiddenBadge().exists()).toBe(false); }); }); diff --git a/spec/frontend/work_items/components/notes/work_item_add_note_spec.js b/spec/frontend/work_items/components/notes/work_item_add_note_spec.js index 826fc2b2230..b2b372d9d0d 100644 --- a/spec/frontend/work_items/components/notes/work_item_add_note_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_add_note_spec.js @@ -10,9 +10,11 @@ import WorkItemCommentLocked from '~/work_items/components/notes/work_item_comme import WorkItemCommentForm from '~/work_items/components/notes/work_item_comment_form.vue'; import createNoteMutation from '~/work_items/graphql/notes/create_work_item_note.mutation.graphql'; import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; +import groupWorkItemByIidQuery from '~/work_items/graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import { createWorkItemNoteResponse, + groupWorkItemByIidResponseFactory, workItemByIidResponseFactory, workItemQueryResponse, } from '../../mock_data'; @@ -29,6 +31,7 @@ describe('Work item add note', () => { const mutationSuccessHandler = jest.fn().mockResolvedValue(createWorkItemNoteResponse); let workItemResponseHandler; + let groupWorkItemResponseHandler; const findCommentForm = () => wrapper.findComponent(WorkItemCommentForm); const findTextarea = () => wrapper.findByTestId('note-reply-textarea'); @@ -40,29 +43,32 @@ describe('Work item add note', () => { canCreateNote = true, workItemIid = '1', workItemResponse = workItemByIidResponseFactory({ canUpdate, canCreateNote }), + groupWorkItemResponse = groupWorkItemByIidResponseFactory({ canUpdate, canCreateNote }), signedIn = true, isEditing = true, + isGroup = false, workItemType = 'Task', isInternalThread = false, } = {}) => { workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse); + groupWorkItemResponseHandler = jest.fn().mockResolvedValue(groupWorkItemResponse); if (signedIn) { window.gon.current_user_id = '1'; window.gon.current_user_avatar_url = 'avatar.png'; } - const apolloProvider = createMockApollo([ - [workItemByIidQuery, workItemResponseHandler], - [createNoteMutation, mutationHandler], - ]); - const { id } = workItemQueryResponse.data.workItem; wrapper = shallowMountExtended(WorkItemAddNote, { - apolloProvider, + apolloProvider: createMockApollo([ + [workItemByIidQuery, workItemResponseHandler], + [groupWorkItemByIidQuery, groupWorkItemResponseHandler], + [createNoteMutation, mutationHandler], + ]), provide: { - fullPath: 'test-project-path', + isGroup, }, propsData: { + fullPath: 'test-project-path', workItemId: id, workItemIid, workItemType, @@ -272,16 +278,44 @@ describe('Work item add note', () => { }); }); - it('calls the work item query', async () => { - await createComponent(); + describe('when project context', () => { + it('calls the project work item query', async () => { + await createComponent(); + + expect(workItemResponseHandler).toHaveBeenCalled(); + }); + + it('skips calling the group work item query', async () => { + await createComponent(); + + expect(groupWorkItemResponseHandler).not.toHaveBeenCalled(); + }); + + it('skips calling the project work item query when missing workItemIid', async () => { + await createComponent({ workItemIid: '', isEditing: false }); - expect(workItemResponseHandler).toHaveBeenCalled(); + expect(workItemResponseHandler).not.toHaveBeenCalled(); + }); }); - it('skips calling the work item query when missing workItemIid', async () => { - await createComponent({ workItemIid: '', isEditing: false }); + describe('when group context', () => { + it('skips calling the project work item query', async () => { + await createComponent({ isGroup: true }); + + expect(workItemResponseHandler).not.toHaveBeenCalled(); + }); + + it('calls the group work item query', async () => { + await createComponent({ isGroup: true }); - expect(workItemResponseHandler).not.toHaveBeenCalled(); + expect(groupWorkItemResponseHandler).toHaveBeenCalled(); + }); + + it('skips calling the group work item query when missing workItemIid', async () => { + await createComponent({ isGroup: true, workItemIid: '', isEditing: false }); + + expect(groupWorkItemResponseHandler).not.toHaveBeenCalled(); + }); }); it('wrapper adds `internal-note` class when internal thread', async () => { diff --git a/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js b/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js index dd88f34ae4f..ee2b434bd75 100644 --- a/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js @@ -48,6 +48,7 @@ describe('Work item comment form component', () => { } = {}) => { wrapper = shallowMount(WorkItemCommentForm, { propsData: { + fullPath: 'test-project-path', workItemState, workItemId, workItemType, @@ -59,9 +60,6 @@ describe('Work item comment form component', () => { autocompleteDataSources: {}, isNewDiscussion, }, - provide: { - fullPath: 'test-project-path', - }, directives: { GlTooltip: createMockDirective('gl-tooltip'), }, diff --git a/spec/frontend/work_items/components/notes/work_item_discussion_spec.js b/spec/frontend/work_items/components/notes/work_item_discussion_spec.js index 9d22a64f2cb..fa53ba54faa 100644 --- a/spec/frontend/work_items/components/notes/work_item_discussion_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_discussion_spec.js @@ -31,10 +31,8 @@ describe('Work Item Discussion', () => { workItemType = 'Task', } = {}) => { wrapper = shallowMount(WorkItemDiscussion, { - provide: { - fullPath: 'gitlab-org', - }, propsData: { + fullPath: 'gitlab-org', discussion, workItemId, workItemIid: '1', diff --git a/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js b/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js index e4180b2d178..6a24987b737 100644 --- a/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js @@ -48,6 +48,7 @@ describe('Work Item Note Actions', () => { } = {}) => { wrapper = shallowMountExtended(WorkItemNoteActions, { propsData: { + fullPath: 'gitlab-org', showReply, showEdit, workItemIid: '1', @@ -63,7 +64,6 @@ describe('Work Item Note Actions', () => { projectName, }, provide: { - fullPath: 'gitlab-org', glFeatures: { workItemsMvc2: true, }, diff --git a/spec/frontend/work_items/components/notes/work_item_note_awards_list_spec.js b/spec/frontend/work_items/components/notes/work_item_note_awards_list_spec.js index d425f1e50dc..ce915635946 100644 --- a/spec/frontend/work_items/components/notes/work_item_note_awards_list_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_note_awards_list_spec.js @@ -61,10 +61,8 @@ describe('Work Item Note Awards List', () => { }); wrapper = shallowMount(WorkItemNoteAwardsList, { - provide: { - fullPath, - }, propsData: { + fullPath, workItemIid, note, isModal: false, diff --git a/spec/frontend/work_items/components/notes/work_item_note_spec.js b/spec/frontend/work_items/components/notes/work_item_note_spec.js index 9049a69656a..2b4c9604382 100644 --- a/spec/frontend/work_items/components/notes/work_item_note_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_note_spec.js @@ -15,8 +15,10 @@ import NoteActions from '~/work_items/components/notes/work_item_note_actions.vu import WorkItemCommentForm from '~/work_items/components/notes/work_item_comment_form.vue'; import updateWorkItemNoteMutation from '~/work_items/graphql/notes/update_work_item_note.mutation.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import groupWorkItemByIidQuery from '~/work_items/graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import { + groupWorkItemByIidResponseFactory, mockAssignees, mockWorkItemCommentNote, updateWorkItemMutationResponse, @@ -68,6 +70,9 @@ describe('Work Item Note', () => { }); const workItemResponseHandler = jest.fn().mockResolvedValue(workItemByIidResponseFactory()); + const groupWorkItemResponseHandler = jest + .fn() + .mockResolvedValue(groupWorkItemByIidResponseFactory()); const workItemByAuthoredByDifferentUser = jest .fn() .mockResolvedValue(mockWorkItemByDifferentUser); @@ -90,6 +95,7 @@ describe('Work Item Note', () => { const createComponent = ({ note = mockWorkItemCommentNote, isFirstNote = false, + isGroup = false, updateNoteMutationHandler = successHandler, workItemId = mockWorkItemId, updateWorkItemMutationHandler = updateWorkItemMutationSuccessHandler, @@ -98,9 +104,10 @@ describe('Work Item Note', () => { } = {}) => { wrapper = shallowMount(WorkItemNote, { provide: { - fullPath: 'test-project-path', + isGroup, }, propsData: { + fullPath: 'test-project-path', workItemId, workItemIid: '1', note, @@ -112,6 +119,7 @@ describe('Work Item Note', () => { }, apolloProvider: mockApollo([ [workItemByIidQuery, workItemByIidResponseHandler], + [groupWorkItemByIidQuery, groupWorkItemResponseHandler], [updateWorkItemNoteMutation, updateNoteMutationHandler], [updateWorkItemMutation, updateWorkItemMutationHandler], ]), @@ -442,4 +450,32 @@ describe('Work Item Note', () => { expect(findAwardsList().props('workItemIid')).toBe('1'); }); }); + + describe('when project context', () => { + it('calls the project work item query', () => { + createComponent(); + + expect(workItemResponseHandler).toHaveBeenCalled(); + }); + + it('skips calling the group work item query', () => { + createComponent(); + + expect(groupWorkItemResponseHandler).not.toHaveBeenCalled(); + }); + }); + + describe('when group context', () => { + it('skips calling the project work item query', () => { + createComponent({ isGroup: true }); + + expect(workItemResponseHandler).not.toHaveBeenCalled(); + }); + + it('calls the group work item query', () => { + createComponent({ isGroup: true }); + + expect(groupWorkItemResponseHandler).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/work_items/components/shared/work_item_link_child_contents_spec.js b/spec/frontend/work_items/components/shared/work_item_link_child_contents_spec.js index b86f9ff34ae..2e1a7983dec 100644 --- a/spec/frontend/work_items/components/shared/work_item_link_child_contents_spec.js +++ b/spec/frontend/work_items/components/shared/work_item_link_child_contents_spec.js @@ -1,4 +1,4 @@ -import { GlLabel, GlIcon } from '@gitlab/ui'; +import { GlLabel, GlIcon, GlLink } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -33,7 +33,7 @@ describe('WorkItemLinkChildContents', () => { const findStatusIconComponent = () => wrapper.findByTestId('item-status-icon').findComponent(GlIcon); const findConfidentialIconComponent = () => wrapper.findByTestId('confidential-icon'); - const findTitleEl = () => wrapper.findByTestId('item-title'); + const findTitleEl = () => wrapper.findComponent(GlLink); const findStatusTooltipComponent = () => wrapper.findComponent(RichTimestampTooltip); const findMetadataComponent = () => wrapper.findComponent(WorkItemLinkChildMetadata); const findAllLabels = () => wrapper.findAllComponents(GlLabel); @@ -46,7 +46,6 @@ describe('WorkItemLinkChildContents', () => { propsData: { canUpdate, childItem, - childPath: '/gitlab-org/gitlab-test/-/work_items/4', }, }); }; diff --git a/spec/frontend/work_items/components/work_item_actions_spec.js b/spec/frontend/work_items/components/work_item_actions_spec.js index 0098a2e0864..15c33bf5b1e 100644 --- a/spec/frontend/work_items/components/work_item_actions_spec.js +++ b/spec/frontend/work_items/components/work_item_actions_spec.js @@ -22,13 +22,12 @@ import { import updateWorkItemNotificationsMutation from '~/work_items/graphql/update_work_item_notifications.mutation.graphql'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; import convertWorkItemMutation from '~/work_items/graphql/work_item_convert.mutation.graphql'; -import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import { convertWorkItemMutationResponse, projectWorkItemTypesQueryResponse, convertWorkItemMutationErrorResponse, - workItemByIidResponseFactory, + updateWorkItemNotificationsMutationResponse, } from '../mock_data'; jest.mock('~/lib/utils/common_utils'); @@ -38,10 +37,7 @@ describe('WorkItemActions component', () => { Vue.use(VueApollo); let wrapper; - let mockApollo; const mockWorkItemReference = 'gitlab-org/gitlab-test#1'; - const mockWorkItemIid = '1'; - const mockFullPath = 'gitlab-org/gitlab-test'; const mockWorkItemCreateNoteEmail = 'gitlab-incoming+gitlab-org-gitlab-test-2-ddpzuq0zd2wefzofcpcdr3dg7-issue-1@gmail.com'; @@ -75,14 +71,22 @@ describe('WorkItemActions component', () => { hide: jest.fn(), }; + const typesQuerySuccessHandler = jest.fn().mockResolvedValue(projectWorkItemTypesQueryResponse); const convertWorkItemMutationSuccessHandler = jest .fn() .mockResolvedValue(convertWorkItemMutationResponse); - const convertWorkItemMutationErrorHandler = jest .fn() .mockResolvedValue(convertWorkItemMutationErrorResponse); - const typesQuerySuccessHandler = jest.fn().mockResolvedValue(projectWorkItemTypesQueryResponse); + const toggleNotificationsOffHandler = jest + .fn() + .mockResolvedValue(updateWorkItemNotificationsMutationResponse(false)); + const toggleNotificationsOnHandler = jest + .fn() + .mockResolvedValue(updateWorkItemNotificationsMutationResponse(true)); + const toggleNotificationsFailureHandler = jest + .fn() + .mockRejectedValue(new Error('Failed to subscribe')); const createComponent = ({ canUpdate = true, @@ -90,35 +94,21 @@ describe('WorkItemActions component', () => { isConfidential = false, subscribed = false, isParentConfidential = false, - notificationsMock = [updateWorkItemNotificationsMutation, jest.fn()], convertWorkItemMutationHandler = convertWorkItemMutationSuccessHandler, + notificationsMutationHandler, workItemType = 'Task', workItemReference = mockWorkItemReference, workItemCreateNoteEmail = mockWorkItemCreateNoteEmail, - writeQueryCache = false, } = {}) => { - const handlers = [notificationsMock]; - mockApollo = createMockApollo([ - ...handlers, - [convertWorkItemMutation, convertWorkItemMutationHandler], - [projectWorkItemTypesQuery, typesQuerySuccessHandler], - ]); - - // Write the query cache only when required e.g., notification widget mutation is called - if (writeQueryCache) { - const workItemQueryResponse = workItemByIidResponseFactory({ canUpdate: true }); - - mockApollo.clients.defaultClient.cache.writeQuery({ - query: workItemByIidQuery, - variables: { fullPath: mockFullPath, iid: mockWorkItemIid }, - data: workItemQueryResponse.data, - }); - } - wrapper = shallowMountExtended(WorkItemActions, { isLoggedIn: isLoggedIn(), - apolloProvider: mockApollo, + apolloProvider: createMockApollo([ + [projectWorkItemTypesQuery, typesQuerySuccessHandler], + [convertWorkItemMutation, convertWorkItemMutationHandler], + [updateWorkItemNotificationsMutation, notificationsMutationHandler], + ]), propsData: { + fullPath: 'gitlab-org/gitlab-test', workItemId: 'gid://gitlab/WorkItem/1', canUpdate, canDelete, @@ -128,10 +118,9 @@ describe('WorkItemActions component', () => { workItemType, workItemReference, workItemCreateNoteEmail, - workItemIid: '1', }, provide: { - fullPath: mockFullPath, + isGroup: false, glFeatures: { workItemsMvc2: true }, }, mocks: { @@ -159,7 +148,6 @@ describe('WorkItemActions component', () => { it('renders modal', () => { createComponent(); - expect(findModal().exists()).toBe(true); expect(findModal().props('visible')).toBe(false); }); @@ -247,59 +235,15 @@ describe('WorkItemActions component', () => { }); it('does not render when canDelete is false', () => { - createComponent({ - canDelete: false, - }); + createComponent({ canDelete: false }); expect(findDeleteButton().exists()).toBe(false); }); }); describe('notifications action', () => { - const errorMessage = 'Failed to subscribe'; - const notificationToggledOffMessage = 'Notifications turned off.'; - const notificationToggledOnMessage = 'Notifications turned on.'; - - const toggleNotificationsOffHandler = jest.fn().mockResolvedValue({ - data: { - updateWorkItemNotificationsSubscription: { - issue: { - id: 'gid://gitlab/WorkItem/1', - subscribed: false, - }, - errors: [], - }, - }, - }); - - const toggleNotificationsOnHandler = jest.fn().mockResolvedValue({ - data: { - updateWorkItemNotificationsSubscription: { - issue: { - id: 'gid://gitlab/WorkItem/1', - subscribed: true, - }, - errors: [], - }, - }, - }); - - const toggleNotificationsFailureHandler = jest.fn().mockRejectedValue(new Error(errorMessage)); - - const notificationsOffMock = [ - updateWorkItemNotificationsMutation, - toggleNotificationsOffHandler, - ]; - - const notificationsOnMock = [updateWorkItemNotificationsMutation, toggleNotificationsOnHandler]; - - const notificationsFailureMock = [ - updateWorkItemNotificationsMutation, - toggleNotificationsFailureHandler, - ]; - beforeEach(() => { - createComponent({ writeQueryCache: true }); + createComponent(); isLoggedIn.mockReturnValue(true); }); @@ -308,25 +252,26 @@ describe('WorkItemActions component', () => { }); it.each` - scenario | subscribedToNotifications | notificationsMock | subscribedState | toastMessage - ${'turned off'} | ${false} | ${notificationsOffMock} | ${false} | ${notificationToggledOffMessage} - ${'turned on'} | ${true} | ${notificationsOnMock} | ${true} | ${notificationToggledOnMessage} + scenario | subscribedToNotifications | notificationsMutationHandler | subscribed | toastMessage + ${'turned off'} | ${false} | ${toggleNotificationsOffHandler} | ${false} | ${'Notifications turned off.'} + ${'turned on'} | ${true} | ${toggleNotificationsOnHandler} | ${true} | ${'Notifications turned on.'} `( 'calls mutation and displays toast when notification toggle is $scenario', - async ({ subscribedToNotifications, notificationsMock, subscribedState, toastMessage }) => { - createComponent({ notificationsMock, writeQueryCache: true }); - - await waitForPromises(); + async ({ + subscribedToNotifications, + notificationsMutationHandler, + subscribed, + toastMessage, + }) => { + createComponent({ notificationsMutationHandler }); findNotificationsToggle().vm.$emit('change', subscribedToNotifications); - await waitForPromises(); - expect(notificationsMock[1]).toHaveBeenCalledWith({ + expect(notificationsMutationHandler).toHaveBeenCalledWith({ input: { - projectPath: mockFullPath, - iid: mockWorkItemIid, - subscribedState, + id: 'gid://gitlab/WorkItem/1', + subscribed, }, }); expect(toast).toHaveBeenCalledWith(toastMessage); @@ -334,15 +279,12 @@ describe('WorkItemActions component', () => { ); it('emits error when the update notification mutation fails', async () => { - createComponent({ notificationsMock: notificationsFailureMock, writeQueryCache: true }); - - await waitForPromises(); + createComponent({ notificationsMutationHandler: toggleNotificationsFailureHandler }); findNotificationsToggle().vm.$emit('change', false); - await waitForPromises(); - expect(wrapper.emitted('error')).toEqual([[errorMessage]]); + expect(wrapper.emitted('error')).toEqual([['Failed to subscribe']]); }); }); @@ -359,13 +301,11 @@ describe('WorkItemActions component', () => { it('promote key result to objective', async () => { createComponent({ workItemType: 'Key Result' }); - - // wait for work item types await waitForPromises(); expect(findPromoteButton().exists()).toBe(true); - findPromoteButton().vm.$emit('action'); + findPromoteButton().vm.$emit('action'); await waitForPromises(); expect(convertWorkItemMutationSuccessHandler).toHaveBeenCalled(); @@ -378,13 +318,11 @@ describe('WorkItemActions component', () => { workItemType: 'Key Result', convertWorkItemMutationHandler: convertWorkItemMutationErrorHandler, }); - - // wait for work item types await waitForPromises(); expect(findPromoteButton().exists()).toBe(true); - findPromoteButton().vm.$emit('action'); + findPromoteButton().vm.$emit('action'); await waitForPromises(); expect(convertWorkItemMutationErrorHandler).toHaveBeenCalled(); @@ -399,6 +337,7 @@ describe('WorkItemActions component', () => { createComponent(); expect(findCopyReferenceButton().exists()).toBe(true); + findCopyReferenceButton().vm.$emit('action'); expect(toast).toHaveBeenCalledWith('Reference copied'); @@ -421,6 +360,7 @@ describe('WorkItemActions component', () => { createComponent(); expect(findCopyCreateNoteEmailButton().exists()).toBe(true); + findCopyCreateNoteEmailButton().vm.$emit('action'); expect(toast).toHaveBeenCalledWith('Email address copied'); diff --git a/spec/frontend/work_items/components/work_item_assignees_spec.js b/spec/frontend/work_items/components/work_item_assignees_spec.js index 50a8847032e..196e19791df 100644 --- a/spec/frontend/work_items/components/work_item_assignees_spec.js +++ b/spec/frontend/work_items/components/work_item_assignees_spec.js @@ -6,7 +6,8 @@ import waitForPromises from 'helpers/wait_for_promises'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { mockTracking } from 'helpers/tracking_helper'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; +import groupUsersSearchQuery from '~/graphql_shared/queries/group_users_search.query.graphql'; +import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql'; import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; @@ -53,6 +54,9 @@ describe('WorkItemAssignees component', () => { const successSearchQueryHandler = jest .fn() .mockResolvedValue(projectMembersResponseWithCurrentUser); + const successGroupSearchQueryHandler = jest + .fn() + .mockResolvedValue(projectMembersResponseWithCurrentUser); const successSearchQueryHandlerWithMoreAssignees = jest .fn() .mockResolvedValue(projectMembersResponseWithCurrentUserWithNextPage); @@ -75,19 +79,22 @@ describe('WorkItemAssignees component', () => { allowsMultipleAssignees = true, canInviteMembers = false, canUpdate = true, + isGroup = false, } = {}) => { const apolloProvider = createMockApollo([ - [userSearchQuery, searchQueryHandler], + [usersSearchQuery, searchQueryHandler], + [groupUsersSearchQuery, successGroupSearchQueryHandler], [currentUserQuery, currentUserQueryHandler], [updateWorkItemMutation, updateWorkItemMutationHandler], ]); wrapper = mountExtended(WorkItemAssignees, { provide: { - fullPath: 'test-project-path', + isGroup, }, propsData: { assignees, + fullPath: 'test-project-path', workItemId, allowsMultipleAssignees, workItemType: TASK_TYPE_NAME, @@ -540,4 +547,36 @@ describe('WorkItemAssignees component', () => { expect(findTokenSelector().props('dropdownItems')).toHaveLength(2); }); + + describe('when project context', () => { + beforeEach(() => { + createComponent(); + findTokenSelector().vm.$emit('focus'); + findTokenSelector().vm.$emit('text-input', 'jane'); + }); + + it('calls the project users search query', () => { + expect(successSearchQueryHandler).toHaveBeenCalled(); + }); + + it('does not call the group users search query', () => { + expect(successGroupSearchQueryHandler).not.toHaveBeenCalled(); + }); + }); + + describe('when group context', () => { + beforeEach(() => { + createComponent({ isGroup: true }); + findTokenSelector().vm.$emit('focus'); + findTokenSelector().vm.$emit('text-input', 'jane'); + }); + + it('does not call the project users search query', () => { + expect(successSearchQueryHandler).not.toHaveBeenCalled(); + }); + + it('calls the group users search query', () => { + expect(successGroupSearchQueryHandler).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js b/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js index 8b7e04854af..123cf647674 100644 --- a/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js +++ b/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js @@ -20,6 +20,7 @@ describe('WorkItemAttributesWrapper component', () => { const createComponent = ({ workItem = workItemQueryResponse.data.workItem } = {}) => { wrapper = shallowMount(WorkItemAttributesWrapper, { propsData: { + fullPath: 'group/project', workItem, }, provide: { @@ -28,7 +29,6 @@ describe('WorkItemAttributesWrapper component', () => { hasOkrsFeature: true, hasIssuableHealthStatusFeature: true, projectNamespace: 'namespace', - fullPath: 'group/project', }, stubs: { WorkItemWeight: true, diff --git a/spec/frontend/work_items/components/work_item_created_updated_spec.js b/spec/frontend/work_items/components/work_item_created_updated_spec.js index f77c5481906..3f14615e173 100644 --- a/spec/frontend/work_items/components/work_item_created_updated_spec.js +++ b/spec/frontend/work_items/components/work_item_created_updated_spec.js @@ -7,12 +7,18 @@ import waitForPromises from 'helpers/wait_for_promises'; import WorkItemCreatedUpdated from '~/work_items/components/work_item_created_updated.vue'; import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; +import groupWorkItemByIidQuery from '~/work_items/graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; -import { workItemByIidResponseFactory, mockAssignees } from '../mock_data'; +import { + groupWorkItemByIidResponseFactory, + mockAssignees, + workItemByIidResponseFactory, +} from '../mock_data'; describe('WorkItemCreatedUpdated component', () => { let wrapper; let successHandler; + let groupSuccessHandler; Vue.use(VueApollo); @@ -30,21 +36,31 @@ describe('WorkItemCreatedUpdated component', () => { updatedAt, confidential = false, updateInProgress = false, + isGroup = false, } = {}) => { - const workItemQueryResponse = workItemByIidResponseFactory({ + const workItemQueryResponse = workItemByIidResponseFactory({ author, updatedAt, confidential }); + const groupWorkItemQueryResponse = groupWorkItemByIidResponseFactory({ author, updatedAt, confidential, }); successHandler = jest.fn().mockResolvedValue(workItemQueryResponse); + groupSuccessHandler = jest.fn().mockResolvedValue(groupWorkItemQueryResponse); wrapper = shallowMount(WorkItemCreatedUpdated, { - apolloProvider: createMockApollo([[workItemByIidQuery, successHandler]]), + apolloProvider: createMockApollo([ + [workItemByIidQuery, successHandler], + [groupWorkItemByIidQuery, groupSuccessHandler], + ]), provide: { + isGroup, + }, + propsData: { fullPath: '/some/project', + workItemIid, + updateInProgress, }, - propsData: { workItemIid, updateInProgress }, stubs: { GlAvatarLink, GlSprintf, @@ -54,10 +70,44 @@ describe('WorkItemCreatedUpdated component', () => { await waitForPromises(); }; - it('skips the work item query when workItemIid is not defined', async () => { - await createComponent({ workItemIid: null }); + describe('when project context', () => { + it('calls the project work item query', async () => { + await createComponent(); + + expect(successHandler).toHaveBeenCalled(); + }); + + it('skips calling the group work item query', async () => { + await createComponent(); + + expect(groupSuccessHandler).not.toHaveBeenCalled(); + }); + + it('skips calling the project work item query when workItemIid is not defined', async () => { + await createComponent({ workItemIid: null }); + + expect(successHandler).not.toHaveBeenCalled(); + }); + }); + + describe('when group context', () => { + it('skips calling the project work item query', async () => { + await createComponent({ isGroup: true }); + + expect(successHandler).not.toHaveBeenCalled(); + }); + + it('calls the group work item query', async () => { + await createComponent({ isGroup: true }); - expect(successHandler).not.toHaveBeenCalled(); + expect(groupSuccessHandler).toHaveBeenCalled(); + }); + + it('skips calling the group work item query when workItemIid is not defined', async () => { + await createComponent({ isGroup: true, workItemIid: null }); + + expect(groupSuccessHandler).not.toHaveBeenCalled(); + }); }); it('shows work item type metadata with type and icon', async () => { diff --git a/spec/frontend/work_items/components/work_item_description_spec.js b/spec/frontend/work_items/components/work_item_description_spec.js index 8b9963b2476..de2895591dd 100644 --- a/spec/frontend/work_items/components/work_item_description_spec.js +++ b/spec/frontend/work_items/components/work_item_description_spec.js @@ -13,9 +13,11 @@ import WorkItemDescription from '~/work_items/components/work_item_description.v import WorkItemDescriptionRendered from '~/work_items/components/work_item_description_rendered.vue'; import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import groupWorkItemByIidQuery from '~/work_items/graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import { autocompleteDataSources, markdownPreviewPath } from '~/work_items/utils'; import { + groupWorkItemByIidResponseFactory, updateWorkItemMutationResponse, workItemByIidResponseFactory, workItemQueryResponse, @@ -33,6 +35,7 @@ describe('WorkItemDescription', () => { const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); let workItemResponseHandler; + let groupWorkItemResponseHandler; const findForm = () => wrapper.findComponent(GlForm); const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor); @@ -51,22 +54,28 @@ describe('WorkItemDescription', () => { canUpdate = true, workItemResponse = workItemByIidResponseFactory({ canUpdate }), isEditing = false, + isGroup = false, workItemIid = '1', } = {}) => { workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse); + groupWorkItemResponseHandler = jest + .fn() + .mockResolvedValue(groupWorkItemByIidResponseFactory({ canUpdate })); const { id } = workItemQueryResponse.data.workItem; wrapper = shallowMount(WorkItemDescription, { apolloProvider: createMockApollo([ [workItemByIidQuery, workItemResponseHandler], + [groupWorkItemByIidQuery, groupWorkItemResponseHandler], [updateWorkItemMutation, mutationHandler], ]), propsData: { + fullPath: 'test-project-path', workItemId: id, workItemIid, }, provide: { - fullPath: 'test-project-path', + isGroup, }, }); @@ -247,9 +256,31 @@ describe('WorkItemDescription', () => { }); }); - it('calls the work item query', async () => { - await createComponent(); + describe('when project context', () => { + it('calls the project work item query', () => { + createComponent(); + + expect(workItemResponseHandler).toHaveBeenCalled(); + }); - expect(workItemResponseHandler).toHaveBeenCalled(); + it('skips calling the group work item query', () => { + createComponent(); + + expect(groupWorkItemResponseHandler).not.toHaveBeenCalled(); + }); + }); + + describe('when group context', () => { + it('skips calling the project work item query', () => { + createComponent({ isGroup: true }); + + expect(workItemResponseHandler).not.toHaveBeenCalled(); + }); + + it('calls the group work item query', () => { + createComponent({ isGroup: true }); + + expect(groupWorkItemResponseHandler).toHaveBeenCalled(); + }); }); }); diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js index fec6d0673c6..28826748cb0 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -28,12 +28,14 @@ import WorkItemStateToggleButton from '~/work_items/components/work_item_state_t import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; import WorkItemTodos from '~/work_items/components/work_item_todos.vue'; import { i18n } from '~/work_items/constants'; +import groupWorkItemByIidQuery from '~/work_items/graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql'; import workItemUpdatedSubscription from '~/work_items/graphql/work_item_updated.subscription.graphql'; import { + groupWorkItemByIidResponseFactory, mockParent, workItemByIidResponseFactory, objectiveType, @@ -49,6 +51,10 @@ describe('WorkItemDetail component', () => { Vue.use(VueApollo); const workItemQueryResponse = workItemByIidResponseFactory({ canUpdate: true, canDelete: true }); + const groupWorkItemQueryResponse = groupWorkItemByIidResponseFactory({ + canUpdate: true, + canDelete: true, + }); const workItemQueryResponseWithCannotUpdate = workItemByIidResponseFactory({ canUpdate: false, canDelete: false, @@ -59,6 +65,7 @@ describe('WorkItemDetail component', () => { canDelete: true, }); const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse); + const groupSuccessHandler = jest.fn().mockResolvedValue(groupWorkItemQueryResponse); const showModalHandler = jest.fn(); const { id } = workItemQueryResponse.data.workspace.workItems.nodes[0]; const workItemUpdatedSubscriptionHandler = jest @@ -92,6 +99,7 @@ describe('WorkItemDetail component', () => { const findWorkItemTypeIcon = () => wrapper.findComponent(WorkItemTypeIcon); const createComponent = ({ + isGroup = false, isModal = false, updateInProgress = false, workItemIid = '1', @@ -101,14 +109,13 @@ describe('WorkItemDetail component', () => { workItemsMvc2Enabled = false, linkedWorkItemsEnabled = false, } = {}) => { - const handlers = [ - [workItemByIidQuery, handler], - [workItemUpdatedSubscription, workItemUpdatedSubscriptionHandler], - confidentialityMock, - ]; - wrapper = shallowMountExtended(WorkItemDetail, { - apolloProvider: createMockApollo(handlers), + apolloProvider: createMockApollo([ + [workItemByIidQuery, handler], + [groupWorkItemByIidQuery, groupSuccessHandler], + [workItemUpdatedSubscription, workItemUpdatedSubscriptionHandler], + confidentialityMock, + ]), isLoggedIn: isLoggedIn(), propsData: { isModal, @@ -131,6 +138,7 @@ describe('WorkItemDetail component', () => { hasIssuableHealthStatusFeature: true, projectNamespace: 'namespace', fullPath: 'group/project', + isGroup, reportAbusePath: '/report/abuse/path', }, stubs: { @@ -484,25 +492,64 @@ describe('WorkItemDetail component', () => { expect(findAlert().text()).toBe(updateError); }); - it('calls the work item query', async () => { - createComponent(); - await waitForPromises(); + describe('when project context', () => { + it('calls the project work item query', async () => { + createComponent(); + await waitForPromises(); - expect(successHandler).toHaveBeenCalledWith({ fullPath: 'group/project', iid: '1' }); - }); + expect(successHandler).toHaveBeenCalledWith({ fullPath: 'group/project', iid: '1' }); + }); - it('skips the work item query when there is no workItemIid', async () => { - createComponent({ workItemIid: null }); - await waitForPromises(); + it('skips calling the group work item query', async () => { + createComponent(); + await waitForPromises(); + + expect(groupSuccessHandler).not.toHaveBeenCalled(); + }); - expect(successHandler).not.toHaveBeenCalled(); + it('skips calling the project work item query when there is no workItemIid', async () => { + createComponent({ workItemIid: null }); + await waitForPromises(); + + expect(successHandler).not.toHaveBeenCalled(); + }); + + it('calls the project work item query when isModal=true', async () => { + createComponent({ isModal: true }); + await waitForPromises(); + + expect(successHandler).toHaveBeenCalledWith({ fullPath: 'group/project', iid: '1' }); + }); }); - it('calls the work item query when isModal=true', async () => { - createComponent({ isModal: true }); - await waitForPromises(); + describe('when group context', () => { + it('skips calling the project work item query', async () => { + createComponent({ isGroup: true }); + await waitForPromises(); + + expect(successHandler).not.toHaveBeenCalled(); + }); + + it('calls the group work item query', async () => { + createComponent({ isGroup: true }); + await waitForPromises(); + + expect(groupSuccessHandler).toHaveBeenCalledWith({ fullPath: 'group/project', iid: '1' }); + }); + + it('skips calling the group work item query when there is no workItemIid', async () => { + createComponent({ isGroup: true, workItemIid: null }); + await waitForPromises(); - expect(successHandler).toHaveBeenCalledWith({ fullPath: 'group/project', iid: '1' }); + expect(groupSuccessHandler).not.toHaveBeenCalled(); + }); + + it('calls the group work item query when isModal=true', async () => { + createComponent({ isGroup: true, isModal: true }); + await waitForPromises(); + + expect(groupSuccessHandler).toHaveBeenCalledWith({ fullPath: 'group/project', iid: '1' }); + }); }); describe('hierarchy widget', () => { diff --git a/spec/frontend/work_items/components/work_item_labels_spec.js b/spec/frontend/work_items/components/work_item_labels_spec.js index 4a20e654060..28aa7ffa1be 100644 --- a/spec/frontend/work_items/components/work_item_labels_spec.js +++ b/spec/frontend/work_items/components/work_item_labels_spec.js @@ -7,10 +7,12 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import labelSearchQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import groupWorkItemByIidQuery from '~/work_items/graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; import { i18n, I18N_WORK_ITEM_ERROR_FETCHING_LABELS } from '~/work_items/constants'; import { + groupWorkItemByIidResponseFactory, projectLabelsResponse, mockLabels, workItemByIidResponseFactory, @@ -32,6 +34,9 @@ describe('WorkItemLabels component', () => { const workItemQuerySuccess = jest .fn() .mockResolvedValue(workItemByIidResponseFactory({ labels: null })); + const groupWorkItemQuerySuccess = jest + .fn() + .mockResolvedValue(groupWorkItemByIidResponseFactory({ labels: null })); const successSearchQueryHandler = jest.fn().mockResolvedValue(projectLabelsResponse); const successUpdateWorkItemMutationHandler = jest .fn() @@ -40,6 +45,7 @@ describe('WorkItemLabels component', () => { const createComponent = ({ canUpdate = true, + isGroup = false, workItemQueryHandler = workItemQuerySuccess, searchQueryHandler = successSearchQueryHandler, updateWorkItemMutationHandler = successUpdateWorkItemMutationHandler, @@ -48,13 +54,15 @@ describe('WorkItemLabels component', () => { wrapper = mountExtended(WorkItemLabels, { apolloProvider: createMockApollo([ [workItemByIidQuery, workItemQueryHandler], + [groupWorkItemByIidQuery, groupWorkItemQuerySuccess], [labelSearchQuery, searchQueryHandler], [updateWorkItemMutation, updateWorkItemMutationHandler], ]), provide: { - fullPath: 'test-project-path', + isGroup, }, propsData: { + fullPath: 'test-project-path', workItemId, workItemIid, canUpdate, @@ -244,17 +252,49 @@ describe('WorkItemLabels component', () => { }); }); - it('calls the work item query', async () => { - createComponent(); - await waitForPromises(); + describe('when project context', () => { + it('calls the project work item query', async () => { + createComponent(); + await waitForPromises(); + + expect(workItemQuerySuccess).toHaveBeenCalled(); + }); + + it('skips calling the group work item query', async () => { + createComponent(); + await waitForPromises(); + + expect(groupWorkItemQuerySuccess).not.toHaveBeenCalled(); + }); - expect(workItemQuerySuccess).toHaveBeenCalled(); + it('skips calling the project work item query when missing workItemIid', async () => { + createComponent({ workItemIid: '' }); + await waitForPromises(); + + expect(workItemQuerySuccess).not.toHaveBeenCalled(); + }); }); - it('skips calling the work item query when missing workItemIid', async () => { - createComponent({ workItemIid: '' }); - await waitForPromises(); + describe('when group context', () => { + it('skips calling the project work item query', async () => { + createComponent({ isGroup: true }); + await waitForPromises(); + + expect(workItemQuerySuccess).not.toHaveBeenCalled(); + }); - expect(workItemQuerySuccess).not.toHaveBeenCalled(); + it('calls the group work item query', async () => { + createComponent({ isGroup: true }); + await waitForPromises(); + + expect(groupWorkItemQuerySuccess).toHaveBeenCalled(); + }); + + it('skips calling the group work item query when missing workItemIid', async () => { + createComponent({ isGroup: true, workItemIid: '' }); + await waitForPromises(); + + expect(groupWorkItemQuerySuccess).not.toHaveBeenCalled(); + }); }); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js index cd077fbf705..0147b199040 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js @@ -53,9 +53,10 @@ describe('WorkItemChildrenWrapper', () => { wrapper = shallowMountExtended(WorkItemChildrenWrapper, { apolloProvider: mockApollo, provide: { - fullPath: 'test/project', + isGroup: false, }, propsData: { + fullPath: 'test/project', workItemType, workItemId: 'gid://gitlab/WorkItem/515', workItemIid: '1', diff --git a/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js index a624bbe8567..9addf6c3450 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js @@ -62,9 +62,6 @@ describe('WorkItemLinkChild', () => { [getWorkItemTreeQuery, getWorkItemTreeQueryHandler], [updateWorkItemMutation, mutationChangeParentHandler], ]), - provide: { - fullPath: 'gitlab-org/gitlab-test', - }, propsData: { canUpdate, issuableGid, @@ -93,23 +90,7 @@ describe('WorkItemLinkChild', () => { expect(findWorkItemLinkChildContents().props()).toEqual({ childItem: workItemObjectiveWithChild, canUpdate: true, - childPath: '/gitlab-org/gitlab-test/-/work_items/12', - }); - }); - - describe('with relative instance', () => { - beforeEach(() => { - window.gon = { relative_url_root: '/test' }; - createComponent({ - childItem: workItemObjectiveWithChild, - workItemType: WORK_ITEM_TYPE_VALUE_OBJECTIVE, - }); - }); - - it('adds the relative url to child path value', () => { - expect(findWorkItemLinkChildContents().props('childPath')).toBe( - '/test/gitlab-org/gitlab-test/-/work_items/12', - ); + showTaskIcon: false, }); }); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js index aaab22fd18d..0a9da17d284 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js @@ -54,6 +54,7 @@ describe('WorkItemLinksForm', () => { [createWorkItemMutation, createMutationResolver], ]), propsData: { + fullPath: 'project/path', issuableGid: 'gid://gitlab/WorkItem/1', parentConfidential, parentIteration, @@ -62,8 +63,8 @@ describe('WorkItemLinksForm', () => { formType, }, provide: { - fullPath: 'project/path', hasIterationsFeature, + isGroup: false, }, }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js index e24cfe27616..0b88b3ff5b4 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js @@ -13,9 +13,11 @@ import WorkItemChildrenWrapper from '~/work_items/components/work_item_links/wor import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; import { FORM_TYPES } from '~/work_items/constants'; +import groupWorkItemByIidQuery from '~/work_items/graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import { getIssueDetailsResponse, + groupWorkItemByIidResponseFactory, workItemHierarchyResponse, workItemHierarchyEmptyResponse, workItemHierarchyNoUpdatePermissionResponse, @@ -32,6 +34,9 @@ describe('WorkItemLinks', () => { let mockApollo; const responseWithAddChildPermission = jest.fn().mockResolvedValue(workItemHierarchyResponse); + const groupResponseWithAddChildPermission = jest + .fn() + .mockResolvedValue(groupWorkItemByIidResponseFactory()); const responseWithoutAddChildPermission = jest .fn() .mockResolvedValue(workItemByIidResponseFactory({ adminParentLink: false })); @@ -40,20 +45,22 @@ describe('WorkItemLinks', () => { fetchHandler = responseWithAddChildPermission, issueDetailsQueryHandler = jest.fn().mockResolvedValue(getIssueDetailsResponse()), hasIterationsFeature = false, + isGroup = false, } = {}) => { mockApollo = createMockApollo( [ [workItemByIidQuery, fetchHandler], + [groupWorkItemByIidQuery, groupResponseWithAddChildPermission], [issueDetailsQuery, issueDetailsQueryHandler], ], resolvers, - { addTypename: true }, ); wrapper = shallowMountExtended(WorkItemLinks, { provide: { fullPath: 'project/path', hasIterationsFeature, + isGroup, reportAbusePath: '/report/abuse/path', }, propsData: { @@ -243,4 +250,32 @@ describe('WorkItemLinks', () => { expect(findAbuseCategorySelector().exists()).toBe(false); }); }); + + describe('when project context', () => { + it('calls the project work item query', () => { + createComponent(); + + expect(responseWithAddChildPermission).toHaveBeenCalled(); + }); + + it('skips calling the group work item query', () => { + createComponent(); + + expect(groupResponseWithAddChildPermission).not.toHaveBeenCalled(); + }); + }); + + describe('when group context', () => { + it('skips calling the project work item query', () => { + createComponent({ isGroup: true }); + + expect(responseWithAddChildPermission).not.toHaveBeenCalled(); + }); + + it('calls the group work item query', () => { + createComponent({ isGroup: true }); + + expect(groupResponseWithAddChildPermission).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js index 01fa4591cde..f30fded0b45 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js @@ -29,10 +29,8 @@ describe('WorkItemTree', () => { canUpdate = true, } = {}) => { wrapper = shallowMountExtended(WorkItemTree, { - provide: { - fullPath: 'test/project', - }, propsData: { + fullPath: 'test/project', workItemType, parentWorkItemType, workItemId: 'gid://gitlab/WorkItem/515', diff --git a/spec/frontend/work_items/components/work_item_milestone_spec.js b/spec/frontend/work_items/components/work_item_milestone_spec.js index c42c9a573e5..e303ad4b481 100644 --- a/spec/frontend/work_items/components/work_item_milestone_spec.js +++ b/spec/frontend/work_items/components/work_item_milestone_spec.js @@ -66,10 +66,8 @@ describe('WorkItemMilestone component', () => { [projectMilestonesQuery, searchQueryHandler], [updateWorkItemMutation, mutationHandler], ]), - provide: { - fullPath: 'full-path', - }, propsData: { + fullPath: 'full-path', canUpdate, workItemMilestone: milestone, workItemId, diff --git a/spec/frontend/work_items/components/work_item_notes_spec.js b/spec/frontend/work_items/components/work_item_notes_spec.js index 35f01c85ec8..9e02e0708d4 100644 --- a/spec/frontend/work_items/components/work_item_notes_spec.js +++ b/spec/frontend/work_items/components/work_item_notes_spec.js @@ -98,10 +98,8 @@ describe('WorkItemNotes component', () => { [workItemNoteUpdatedSubscription, notesUpdateSubscriptionHandler], [workItemNoteDeletedSubscription, notesDeleteSubscriptionHandler], ]), - provide: { - fullPath: 'test-path', - }, propsData: { + fullPath: 'test-path', workItemId, workItemIid, workItemType: 'task', diff --git a/spec/frontend/work_items/components/work_item_parent_spec.js b/spec/frontend/work_items/components/work_item_parent_spec.js new file mode 100644 index 00000000000..a72eeabc43c --- /dev/null +++ b/spec/frontend/work_items/components/work_item_parent_spec.js @@ -0,0 +1,236 @@ +import * as Sentry from '@sentry/browser'; +import { GlCollapsibleListbox, GlFormGroup } from '@gitlab/ui'; + +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import WorkItemParent from '~/work_items/components/work_item_parent.vue'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import projectWorkItemsQuery from '~/work_items/graphql/project_work_items.query.graphql'; +import { WORK_ITEM_TYPE_ENUM_OBJECTIVE } from '~/work_items/constants'; + +import { + availableObjectivesResponse, + mockParentWidgetResponse, + updateWorkItemMutationResponseFactory, + searchedObjectiveResponse, + updateWorkItemMutationErrorResponse, +} from '../mock_data'; + +jest.mock('@sentry/browser'); + +describe('WorkItemParent component', () => { + Vue.use(VueApollo); + + let wrapper; + + const workItemId = 'gid://gitlab/WorkItem/1'; + const workItemType = 'Objective'; + + const availableWorkItemsSuccessHandler = jest.fn().mockResolvedValue(availableObjectivesResponse); + const availableWorkItemsFailureHandler = jest.fn().mockRejectedValue(new Error()); + + const successUpdateWorkItemMutationHandler = jest + .fn() + .mockResolvedValue(updateWorkItemMutationResponseFactory({ parent: mockParentWidgetResponse })); + + const createComponent = ({ + canUpdate = true, + parent = null, + searchQueryHandler = availableWorkItemsSuccessHandler, + mutationHandler = successUpdateWorkItemMutationHandler, + } = {}) => { + wrapper = shallowMountExtended(WorkItemParent, { + apolloProvider: createMockApollo([ + [projectWorkItemsQuery, searchQueryHandler], + [updateWorkItemMutation, mutationHandler], + ]), + provide: { + fullPath: 'full-path', + }, + propsData: { + canUpdate, + parent, + workItemId, + workItemType, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + const findInputGroup = () => wrapper.findComponent(GlFormGroup); + const findParentText = () => wrapper.findByTestId('disabled-text'); + const findCollapsibleListbox = () => wrapper.findComponent(GlCollapsibleListbox); + + describe('template', () => { + it('shows field label as Parent', () => { + expect(findInputGroup().exists()).toBe(true); + expect(findInputGroup().attributes('label')).toBe('Parent'); + }); + + it('renders the collapsible listbox with required props', () => { + expect(findCollapsibleListbox().exists()).toBe(true); + expect(findCollapsibleListbox().props()).toMatchObject({ + items: [], + headerText: 'Assign parent', + category: 'tertiary', + loading: false, + noCaret: true, + isCheckCentered: true, + searchable: true, + searching: false, + infiniteScroll: false, + noResultsText: 'No matching results', + toggleText: 'None', + searchPlaceholder: 'Search', + resetButtonLabel: 'Unassign', + block: true, + }); + }); + + it('displays parent text instead of listbox if canUpdate is false', () => { + createComponent({ canUpdate: false, parent: mockParentWidgetResponse }); + + expect(findCollapsibleListbox().exists()).toBe(false); + expect(findParentText().exists()).toBe(true); + expect(findParentText().text()).toBe('Objective 101'); + }); + + it('shows loading while searching', async () => { + await findCollapsibleListbox().vm.$emit('shown'); + expect(findCollapsibleListbox().props('searching')).toBe(true); + expect(findCollapsibleListbox().props('no-caret')).toBeUndefined(); + }); + }); + + describe('work items query', () => { + it('loads work items in the listbox', async () => { + await findCollapsibleListbox().vm.$emit('shown'); + + await waitForPromises(); + + expect(findCollapsibleListbox().props('searching')).toBe(false); + expect(findCollapsibleListbox().props('items')).toStrictEqual([ + { text: 'Objective 101', value: 'gid://gitlab/WorkItem/716' }, + { text: 'Objective 103', value: 'gid://gitlab/WorkItem/712' }, + { text: 'Objective 102', value: 'gid://gitlab/WorkItem/711' }, + ]); + expect(availableWorkItemsSuccessHandler).toHaveBeenCalled(); + }); + + it('emits error when the query fails', async () => { + createComponent({ searchQueryHandler: availableWorkItemsFailureHandler }); + + await findCollapsibleListbox().vm.$emit('shown'); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([ + ['Something went wrong while fetching items. Please try again.'], + ]); + }); + + it('searches item when input data is entered', async () => { + const searchedItemQueryHandler = jest.fn().mockResolvedValue(searchedObjectiveResponse); + createComponent({ + searchQueryHandler: searchedItemQueryHandler, + }); + + await findCollapsibleListbox().vm.$emit('shown'); + await findCollapsibleListbox().vm.$emit('search', 'Objective 101'); + + await waitForPromises(); + + expect(searchedItemQueryHandler).toHaveBeenCalledWith({ + fullPath: 'full-path', + searchTerm: 'Objective 101', + types: [WORK_ITEM_TYPE_ENUM_OBJECTIVE], + in: 'TITLE', + }); + + await nextTick(); + + expect(findCollapsibleListbox().props('items')).toStrictEqual([ + { text: 'Objective 101', value: 'gid://gitlab/WorkItem/716' }, + ]); + }); + }); + + describe('listbox', () => { + const selectWorkItem = async (workItem) => { + await findCollapsibleListbox().vm.$emit('shown'); + await findCollapsibleListbox().vm.$emit('select', workItem); + }; + + it('calls mutation when item is selected', async () => { + selectWorkItem('gid://gitlab/WorkItem/716'); + + await waitForPromises(); + + expect(successUpdateWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { + id: 'gid://gitlab/WorkItem/1', + hierarchyWidget: { + parentId: 'gid://gitlab/WorkItem/716', + }, + }, + }); + }); + + it('calls mutation when item is unassigned', async () => { + const unAssignParentWorkItemMutationHandler = jest + .fn() + .mockResolvedValue(updateWorkItemMutationResponseFactory({ parent: null })); + createComponent({ + mutationHandler: unAssignParentWorkItemMutationHandler, + }); + + await findCollapsibleListbox().vm.$emit('reset'); + + await waitForPromises(); + + expect(unAssignParentWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { + id: 'gid://gitlab/WorkItem/1', + hierarchyWidget: { + parentId: null, + }, + }, + }); + }); + + it('emits error when mutation fails', async () => { + createComponent({ + mutationHandler: jest.fn().mockResolvedValue(updateWorkItemMutationErrorResponse), + }); + + selectWorkItem('gid://gitlab/WorkItem/716'); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([['Error!']]); + }); + + it('emits error and captures exception in sentry when network request fails', async () => { + const error = new Error('error'); + createComponent({ + mutationHandler: jest.fn().mockRejectedValue(error), + }); + + selectWorkItem('gid://gitlab/WorkItem/716'); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([ + ['Something went wrong while updating the objective. Please try again.'], + ]); + expect(Sentry.captureException).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_relationships/__snapshots__/work_item_relationship_list_spec.js.snap b/spec/frontend/work_items/components/work_item_relationships/__snapshots__/work_item_relationship_list_spec.js.snap index 9105e4de5e0..bbc19a011a5 100644 --- a/spec/frontend/work_items/components/work_item_relationships/__snapshots__/work_item_relationship_list_spec.js.snap +++ b/spec/frontend/work_items/components/work_item_relationships/__snapshots__/work_item_relationship_list_spec.js.snap @@ -1,7 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`WorkItemRelationshipList renders linked item list 1`] = ` -<div> +<div + data-testid="work-item-linked-items-list" +> <h4 class="gl-font-sm gl-font-weight-semibold gl-mb-2 gl-mt-3 gl-mx-2 gl-text-gray-700" data-testid="work-items-list-heading" @@ -20,7 +22,7 @@ exports[`WorkItemRelationshipList renders linked item list 1`] = ` <work-item-link-child-contents-stub canupdate="true" childitem="[object Object]" - childpath="/test-project-path/-/work_items/83" + showtaskicon="true" /> </li> </ul> diff --git a/spec/frontend/work_items/components/work_item_relationships/work_item_add_relationship_form_spec.js b/spec/frontend/work_items/components/work_item_relationships/work_item_add_relationship_form_spec.js new file mode 100644 index 00000000000..d7b3ced2ff9 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_relationships/work_item_add_relationship_form_spec.js @@ -0,0 +1,156 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlForm, GlFormRadioGroup, GlAlert } from '@gitlab/ui'; + +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import WorkItemAddRelationshipForm from '~/work_items/components/work_item_relationships/work_item_add_relationship_form.vue'; +import WorkItemTokenInput from '~/work_items/components/shared/work_item_token_input.vue'; +import addLinkedItemsMutation from '~/work_items/graphql/add_linked_items.mutation.graphql'; +import { LINKED_ITEM_TYPE_VALUE, MAX_WORK_ITEMS } from '~/work_items/constants'; + +import { linkedWorkItemResponse, generateWorkItemsListWithId } from '../../mock_data'; + +describe('WorkItemAddRelationshipForm', () => { + Vue.use(VueApollo); + + let wrapper; + const linkedWorkItemsSuccessMutationHandler = jest + .fn() + .mockResolvedValue(linkedWorkItemResponse()); + + const createComponent = async ({ + workItemId = 'gid://gitlab/WorkItem/1', + workItemIid = '1', + workItemType = 'Objective', + childrenIds = [], + linkedWorkItemsMutationHandler = linkedWorkItemsSuccessMutationHandler, + } = {}) => { + const mockApolloProvider = createMockApollo([ + [addLinkedItemsMutation, linkedWorkItemsMutationHandler], + ]); + + wrapper = shallowMountExtended(WorkItemAddRelationshipForm, { + apolloProvider: mockApolloProvider, + propsData: { + workItemId, + workItemIid, + workItemFullPath: 'test-project-path', + workItemType, + childrenIds, + }, + }); + + await waitForPromises(); + }; + + const findLinkWorkItemForm = () => wrapper.findComponent(GlForm); + const findLinkWorkItemButton = () => wrapper.findByTestId('link-work-item-button'); + const findMaxWorkItemNote = () => wrapper.findByTestId('max-work-item-note'); + const findRadioGroup = () => wrapper.findComponent(GlFormRadioGroup); + const findWorkItemTokenInput = () => wrapper.findComponent(WorkItemTokenInput); + const findGlAlert = () => wrapper.findComponent(GlAlert); + + beforeEach(async () => { + await createComponent(); + }); + + it('renders link work item form with default values', () => { + expect(findLinkWorkItemForm().exists()).toBe(true); + expect(findRadioGroup().props('options')).toEqual([ + { text: 'relates to', value: LINKED_ITEM_TYPE_VALUE.RELATED }, + { text: 'blocks', value: LINKED_ITEM_TYPE_VALUE.BLOCKS }, + { text: 'is blocked by', value: LINKED_ITEM_TYPE_VALUE.BLOCKED_BY }, + ]); + expect(findLinkWorkItemButton().attributes('disabled')).toBe('true'); + expect(findMaxWorkItemNote().text()).toBe('Add a maximum of 10 items at a time.'); + }); + + it('renders work item token input with default props', () => { + expect(findWorkItemTokenInput().props()).toMatchObject({ + value: [], + fullPath: 'test-project-path', + childrenIds: [], + parentWorkItemId: 'gid://gitlab/WorkItem/1', + areWorkItemsToAddValid: true, + }); + }); + + describe('linking a work item', () => { + const selectWorkItemTokens = (workItems) => { + findWorkItemTokenInput().vm.$emit('input', workItems); + }; + + it('enables add button when work item is selected', async () => { + await selectWorkItemTokens([ + { + id: 'gid://gitlab/WorkItem/644', + }, + ]); + expect(findLinkWorkItemButton().attributes('disabled')).toBeUndefined(); + }); + + it('disables button when more than 10 work items are selected', async () => { + await selectWorkItemTokens(generateWorkItemsListWithId(MAX_WORK_ITEMS + 1)); + + expect(findWorkItemTokenInput().props('areWorkItemsToAddValid')).toBe(false); + expect(findLinkWorkItemButton().attributes('disabled')).toBe('true'); + }); + + it.each` + assertionName | linkTypeInput + ${'related'} | ${LINKED_ITEM_TYPE_VALUE.RELATED} + ${'blocking'} | ${LINKED_ITEM_TYPE_VALUE.BLOCKED_BY} + `('selects and links $assertionName work item', async ({ linkTypeInput }) => { + findRadioGroup().vm.$emit('input', linkTypeInput); + await selectWorkItemTokens([ + { + id: 'gid://gitlab/WorkItem/641', + }, + { + id: 'gid://gitlab/WorkItem/642', + }, + ]); + + expect(findWorkItemTokenInput().props('areWorkItemsToAddValid')).toBe(true); + + findLinkWorkItemForm().vm.$emit('submit', { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }); + await waitForPromises(); + + expect(linkedWorkItemsSuccessMutationHandler).toHaveBeenCalledWith({ + input: { + id: 'gid://gitlab/WorkItem/1', + linkType: linkTypeInput, + workItemsIds: ['gid://gitlab/WorkItem/641', 'gid://gitlab/WorkItem/642'], + }, + }); + }); + + it.each` + errorType | mutationMock | errorMessage + ${'an error in the mutation response'} | ${jest.fn().mockResolvedValue(linkedWorkItemResponse({}, ['Linked Item failed']))} | ${'Linked Item failed'} + ${'a network error'} | ${jest.fn().mockRejectedValue(new Error('Network Error'))} | ${'Something went wrong when trying to link a item. Please try again.'} + `('shows an error message when there is $errorType', async ({ mutationMock, errorMessage }) => { + createComponent({ linkedWorkItemsMutationHandler: mutationMock }); + await selectWorkItemTokens([ + { + id: 'gid://gitlab/WorkItem/641', + }, + ]); + + findLinkWorkItemForm().vm.$emit('submit', { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }); + await waitForPromises(); + + expect(findGlAlert().exists()).toBe(true); + expect(findGlAlert().text()).toBe(errorMessage); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_relationships/work_item_relationship_list_spec.js b/spec/frontend/work_items/components/work_item_relationships/work_item_relationship_list_spec.js index 759ab7e14da..e26bea46ab1 100644 --- a/spec/frontend/work_items/components/work_item_relationships/work_item_relationship_list_spec.js +++ b/spec/frontend/work_items/components/work_item_relationships/work_item_relationship_list_spec.js @@ -14,7 +14,6 @@ describe('WorkItemRelationshipList', () => { linkedItems, heading, canUpdate, - workItemFullPath: 'test-project-path', }, }); }; @@ -35,7 +34,7 @@ describe('WorkItemRelationshipList', () => { expect(findWorkItemLinkChildContents().props()).toMatchObject({ childItem: mockLinkedItems[0].workItem, canUpdate: true, - childPath: '/test-project-path/-/work_items/83', + showTaskIcon: true, }); }); }); diff --git a/spec/frontend/work_items/components/work_item_relationships/work_item_relationships_spec.js b/spec/frontend/work_items/components/work_item_relationships/work_item_relationships_spec.js index c9a2499b127..7178fa1aae7 100644 --- a/spec/frontend/work_items/components/work_item_relationships/work_item_relationships_spec.js +++ b/spec/frontend/work_items/components/work_item_relationships/work_item_relationships_spec.js @@ -9,12 +9,17 @@ import waitForPromises from 'helpers/wait_for_promises'; import WidgetWrapper from '~/work_items/components/widget_wrapper.vue'; import WorkItemRelationships from '~/work_items/components/work_item_relationships/work_item_relationships.vue'; import WorkItemRelationshipList from '~/work_items/components/work_item_relationships/work_item_relationship_list.vue'; +import WorkItemAddRelationshipForm from '~/work_items/components/work_item_relationships/work_item_add_relationship_form.vue'; +import groupWorkItemByIidQuery from '~/work_items/graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; +import removeLinkedItemsMutation from '~/work_items/graphql/remove_linked_items.mutation.graphql'; import { + groupWorkItemByIidResponseFactory, workItemByIidResponseFactory, mockLinkedItems, mockBlockingLinkedItem, + removeLinkedWorkItemResponse, } from '../../mock_data'; describe('WorkItemRelationships', () => { @@ -24,23 +29,44 @@ describe('WorkItemRelationships', () => { const emptyLinkedWorkItemsQueryHandler = jest .fn() .mockResolvedValue(workItemByIidResponseFactory()); - const linkedWorkItemsQueryHandler = jest + const groupWorkItemsQueryHandler = jest .fn() - .mockResolvedValue(workItemByIidResponseFactory({ linkedItems: mockLinkedItems })); - const blockingLinkedWorkItemQueryHandler = jest + .mockResolvedValue(groupWorkItemByIidResponseFactory()); + const removeLinkedWorkItemSuccessMutationHandler = jest .fn() - .mockResolvedValue(workItemByIidResponseFactory({ linkedItems: mockBlockingLinkedItem })); + .mockResolvedValue(removeLinkedWorkItemResponse('Successfully unlinked IDs: 2.')); + const removeLinkedWorkItemErrorMutationHandler = jest + .fn() + .mockResolvedValue(removeLinkedWorkItemResponse(null, ['Linked item removal failed'])); + const $toast = { + show: jest.fn(), + }; const createComponent = async ({ workItemQueryHandler = emptyLinkedWorkItemsQueryHandler, + workItemType = 'Task', + isGroup = false, + removeLinkedWorkItemMutationHandler = removeLinkedWorkItemSuccessMutationHandler, } = {}) => { - const mockApollo = createMockApollo([[workItemByIidQuery, workItemQueryHandler]]); + const mockApollo = createMockApollo([ + [workItemByIidQuery, workItemQueryHandler], + [removeLinkedItemsMutation, removeLinkedWorkItemMutationHandler], + [groupWorkItemByIidQuery, groupWorkItemsQueryHandler], + ]); wrapper = shallowMountExtended(WorkItemRelationships, { apolloProvider: mockApollo, propsData: { + workItemId: 'gid://gitlab/WorkItem/1', workItemIid: '1', workItemFullPath: 'test-project-path', + workItemType, + }, + provide: { + isGroup, + }, + mocks: { + $toast, }, }); @@ -51,8 +77,11 @@ describe('WorkItemRelationships', () => { const findWidgetWrapper = () => wrapper.findComponent(WidgetWrapper); const findEmptyRelatedMessageContainer = () => wrapper.findByTestId('links-empty'); const findLinkedItemsCountContainer = () => wrapper.findByTestId('linked-items-count'); + const findLinkedItemsHelpLink = () => wrapper.findByTestId('help-link'); const findAllWorkItemRelationshipListComponents = () => wrapper.findAllComponents(WorkItemRelationshipList); + const findAddButton = () => wrapper.findByTestId('link-item-add-button'); + const findWorkItemRelationshipForm = () => wrapper.findComponent(WorkItemAddRelationshipForm); it('shows loading icon when query is not processed', () => { createComponent(); @@ -60,22 +89,35 @@ describe('WorkItemRelationships', () => { expect(findLoadingIcon().exists()).toBe(true); }); - it('renders the component with empty message when there are no items', async () => { + it('renders the component with with defaults', async () => { await createComponent(); expect(wrapper.find('.work-item-relationships').exists()).toBe(true); expect(findEmptyRelatedMessageContainer().exists()).toBe(true); + expect(findAddButton().exists()).toBe(true); + expect(findWorkItemRelationshipForm().exists()).toBe(false); + expect(findLinkedItemsHelpLink().attributes('href')).toBe( + '/help/user/okrs.md#linked-items-in-okrs', + ); }); it('renders blocking linked item lists', async () => { - await createComponent({ workItemQueryHandler: blockingLinkedWorkItemQueryHandler }); + await createComponent({ + workItemQueryHandler: jest + .fn() + .mockResolvedValue(workItemByIidResponseFactory({ linkedItems: mockBlockingLinkedItem })), + }); expect(findAllWorkItemRelationshipListComponents().length).toBe(1); expect(findLinkedItemsCountContainer().text()).toBe('1'); }); it('renders blocking, blocked by and related to linked item lists with proper count', async () => { - await createComponent({ workItemQueryHandler: linkedWorkItemsQueryHandler }); + await createComponent({ + workItemQueryHandler: jest + .fn() + .mockResolvedValue(workItemByIidResponseFactory({ linkedItems: mockLinkedItems })), + }); // renders all 3 lists: blocking, blocked by and related to expect(findAllWorkItemRelationshipListComponents().length).toBe(3); @@ -90,4 +132,103 @@ describe('WorkItemRelationships', () => { expect(findWidgetWrapper().props('error')).toBe(errorMessage); }); + + it('does not render add button when there is no permission', async () => { + await createComponent({ + workItemQueryHandler: jest + .fn() + .mockResolvedValue(workItemByIidResponseFactory({ canAdminWorkItemLink: false })), + }); + + expect(findAddButton().exists()).toBe(false); + }); + + it('shows form on add button and hides when cancel button is clicked', async () => { + await createComponent(); + + await findAddButton().vm.$emit('click'); + expect(findWorkItemRelationshipForm().exists()).toBe(true); + + await findWorkItemRelationshipForm().vm.$emit('cancel'); + expect(findWorkItemRelationshipForm().exists()).toBe(false); + }); + + describe('when project context', () => { + it('calls the project work item query', () => { + createComponent(); + + expect(emptyLinkedWorkItemsQueryHandler).toHaveBeenCalled(); + }); + + it('skips calling the group work item query', () => { + createComponent(); + + expect(groupWorkItemsQueryHandler).not.toHaveBeenCalled(); + }); + }); + + describe('when group context', () => { + it('skips calling the project work item query', () => { + createComponent({ isGroup: true }); + + expect(emptyLinkedWorkItemsQueryHandler).not.toHaveBeenCalled(); + }); + + it('calls the group work item query', () => { + createComponent({ isGroup: true }); + + expect(groupWorkItemsQueryHandler).toHaveBeenCalled(); + }); + }); + + it('removes linked item and shows toast message when removeLinkedItem event is emitted', async () => { + await createComponent({ + workItemQueryHandler: jest + .fn() + .mockResolvedValue(workItemByIidResponseFactory({ linkedItems: mockLinkedItems })), + }); + + expect(findLinkedItemsCountContainer().text()).toBe('3'); + + await findAllWorkItemRelationshipListComponents() + .at(0) + .vm.$emit('removeLinkedItem', { id: 'gid://gitlab/WorkItem/2' }); + + await waitForPromises(); + + expect(removeLinkedWorkItemSuccessMutationHandler).toHaveBeenCalledWith({ + input: { + id: 'gid://gitlab/WorkItem/1', + workItemsIds: ['gid://gitlab/WorkItem/2'], + }, + }); + + expect($toast.show).toHaveBeenCalledWith('Linked item removed'); + + expect(findLinkedItemsCountContainer().text()).toBe('2'); + }); + + it.each` + errorType | mutationMock | errorMessage + ${'an error in the mutation response'} | ${removeLinkedWorkItemErrorMutationHandler} | ${'Linked item removal failed'} + ${'a network error'} | ${jest.fn().mockRejectedValue(new Error('Network Error'))} | ${'Something went wrong when removing item. Please refresh this page.'} + `( + 'shows an error message when there is $errorType while removing items', + async ({ mutationMock, errorMessage }) => { + await createComponent({ + workItemQueryHandler: jest + .fn() + .mockResolvedValue(workItemByIidResponseFactory({ linkedItems: mockLinkedItems })), + removeLinkedWorkItemMutationHandler: mutationMock, + }); + + await findAllWorkItemRelationshipListComponents() + .at(0) + .vm.$emit('removeLinkedItem', { id: 'gid://gitlab/WorkItem/2' }); + + await waitForPromises(); + + expect(findWidgetWrapper().props('error')).toBe(errorMessage); + }, + ); }); diff --git a/spec/frontend/work_items/components/work_item_todos_spec.js b/spec/frontend/work_items/components/work_item_todos_spec.js index 454bd97bbee..c76cdbcee53 100644 --- a/spec/frontend/work_items/components/work_item_todos_spec.js +++ b/spec/frontend/work_items/components/work_item_todos_spec.js @@ -86,6 +86,9 @@ describe('WorkItemTodo component', () => { workItemFullpath: mockWorkItemFullpath, currentUserTodos, }, + provide: { + isGroup: false, + }, }); }; diff --git a/spec/frontend/work_items/graphql/cache_utils_spec.js b/spec/frontend/work_items/graphql/cache_utils_spec.js index 6d0083790d1..64ef1bdbb88 100644 --- a/spec/frontend/work_items/graphql/cache_utils_spec.js +++ b/spec/frontend/work_items/graphql/cache_utils_spec.js @@ -43,7 +43,7 @@ describe('work items graphql cache utils', () => { title: 'New child', }; - addHierarchyChild(mockCache, fullPath, iid, child); + addHierarchyChild({ cache: mockCache, fullPath, iid, workItem: child }); expect(mockCache.writeQuery).toHaveBeenCalledWith({ query: workItemByIidQuery, @@ -88,7 +88,7 @@ describe('work items graphql cache utils', () => { title: 'New child', }; - addHierarchyChild(mockCache, fullPath, iid, child); + addHierarchyChild({ cache: mockCache, fullPath, iid, workItem: child }); expect(mockCache.writeQuery).not.toHaveBeenCalled(); }); @@ -106,7 +106,7 @@ describe('work items graphql cache utils', () => { title: 'Child', }; - removeHierarchyChild(mockCache, fullPath, iid, childToRemove); + removeHierarchyChild({ cache: mockCache, fullPath, iid, workItem: childToRemove }); expect(mockCache.writeQuery).toHaveBeenCalledWith({ query: workItemByIidQuery, @@ -145,7 +145,7 @@ describe('work items graphql cache utils', () => { title: 'Child', }; - removeHierarchyChild(mockCache, fullPath, iid, childToRemove); + removeHierarchyChild({ cache: mockCache, fullPath, iid, workItem: childToRemove }); expect(mockCache.writeQuery).not.toHaveBeenCalled(); }); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index ba244b19eb5..9eb604c81cb 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -146,6 +146,7 @@ export const workItemQueryResponse = { setWorkItemMetadata: false, adminParentLink: false, createNote: false, + adminWorkItemLink: true, __typename: 'WorkItemPermissions', }, widgets: [ @@ -193,6 +194,7 @@ export const workItemQueryResponse = { confidential: false, title: '123', state: 'OPEN', + webUrl: '/gitlab-org/gitlab-test/-/work_items/4', workItemType: { id: '1', name: 'Task', @@ -251,6 +253,7 @@ export const updateWorkItemMutationResponse = { setWorkItemMetadata: false, adminParentLink: false, createNote: false, + adminWorkItemLink: true, __typename: 'WorkItemPermissions', }, reference: 'test-project-path#1', @@ -269,6 +272,7 @@ export const updateWorkItemMutationResponse = { confidential: false, title: '123', state: 'OPEN', + webUrl: '/gitlab-org/gitlab-test/-/work_items/4', workItemType: { id: '1', name: 'Task', @@ -360,6 +364,7 @@ export const convertWorkItemMutationResponse = { setWorkItemMetadata: false, adminParentLink: false, createNote: false, + adminWorkItemLink: true, __typename: 'WorkItemPermissions', }, reference: 'gitlab-org/gitlab-test#1', @@ -378,6 +383,7 @@ export const convertWorkItemMutationResponse = { confidential: false, title: '123', state: 'OPEN', + webUrl: '/gitlab-org/gitlab-test/-/work_items/4', workItemType: { id: '1', name: 'Task', @@ -486,6 +492,7 @@ export const mockBlockingLinkedItem = { state: 'OPEN', createdAt: '2023-03-28T10:50:16Z', closedAt: null, + webUrl: '/gitlab-org/gitlab-test/-/work_items/83', widgets: [], __typename: 'WorkItem', }, @@ -518,6 +525,7 @@ export const mockLinkedItems = { state: 'OPEN', createdAt: '2023-03-28T10:50:16Z', closedAt: null, + webUrl: '/gitlab-org/gitlab-test/-/work_items/83', widgets: [], __typename: 'WorkItem', }, @@ -540,6 +548,7 @@ export const mockLinkedItems = { state: 'OPEN', createdAt: '2023-03-28T10:50:16Z', closedAt: null, + webUrl: '/gitlab-org/gitlab-test/-/work_items/55', widgets: [], __typename: 'WorkItem', }, @@ -562,6 +571,7 @@ export const mockLinkedItems = { state: 'OPEN', createdAt: '2023-03-28T10:50:16Z', closedAt: null, + webUrl: '/gitlab-org/gitlab-test/-/work_items/56', widgets: [], __typename: 'WorkItem', }, @@ -579,6 +589,7 @@ export const workItemResponseFactory = ({ canDelete = false, canCreateNote = false, adminParentLink = false, + canAdminWorkItemLink = true, notificationsWidgetPresent = true, currentUserTodosWidgetPresent = true, awardEmojiWidgetPresent = true, @@ -636,6 +647,7 @@ export const workItemResponseFactory = ({ updateWorkItem: canUpdate, setWorkItemMetadata: canUpdate, adminParentLink, + adminWorkItemLink: canAdminWorkItemLink, createNote: canCreateNote, __typename: 'WorkItemPermissions', }, @@ -756,6 +768,7 @@ export const workItemResponseFactory = ({ confidential: false, title: '123', state: 'OPEN', + webUrl: '/gitlab-org/gitlab-test/-/work_items/5', workItemType: { id: '1', name: 'Task', @@ -828,13 +841,16 @@ export const workItemByIidResponseFactory = (options) => { }; }; -export const updateWorkItemMutationResponseFactory = (options) => { +export const groupWorkItemByIidResponseFactory = (options) => { const response = workItemResponseFactory(options); return { data: { - workItemUpdate: { - workItem: response.data.workItem, - errors: [], + workspace: { + __typename: 'Group', + id: 'gid://gitlab/Group/1', + workItems: { + nodes: [response.data.workItem], + }, }, }, }; @@ -914,6 +930,7 @@ export const createWorkItemMutationResponse = { setWorkItemMetadata: false, adminParentLink: false, createNote: false, + adminWorkItemLink: true, __typename: 'WorkItemPermissions', }, reference: 'test-project-path#1', @@ -996,6 +1013,7 @@ export const workItemHierarchyEmptyResponse = { setWorkItemMetadata: false, adminParentLink: false, createNote: false, + adminWorkItemLink: true, __typename: 'WorkItemPermissions', }, confidential: false, @@ -1046,6 +1064,7 @@ export const workItemHierarchyNoUpdatePermissionResponse = { setWorkItemMetadata: false, adminParentLink: false, createNote: false, + adminWorkItemLink: true, __typename: 'WorkItemPermissions', }, project: { @@ -1077,6 +1096,7 @@ export const workItemHierarchyNoUpdatePermissionResponse = { confidential: false, createdAt: '2022-08-03T12:41:54Z', closedAt: null, + webUrl: '/gitlab-org/gitlab-test/-/work_items/2', widgets: [ { type: 'HIERARCHY', @@ -1110,6 +1130,7 @@ export const workItemTask = { confidential: false, createdAt: '2022-08-03T12:41:54Z', closedAt: null, + webUrl: '/gitlab-org/gitlab-test/-/work_items/4', widgets: [], __typename: 'WorkItem', }; @@ -1128,6 +1149,7 @@ export const confidentialWorkItemTask = { confidential: true, createdAt: '2022-08-03T12:41:54Z', closedAt: null, + webUrl: '/gitlab-org/gitlab-test/-/work_items/2', widgets: [], __typename: 'WorkItem', }; @@ -1146,6 +1168,7 @@ export const closedWorkItemTask = { confidential: false, createdAt: '2022-08-03T12:41:54Z', closedAt: '2022-08-12T13:07:52Z', + webUrl: '/gitlab-org/gitlab-test/-/work_items/3', widgets: [], __typename: 'WorkItem', }; @@ -1168,6 +1191,7 @@ export const childrenWorkItems = [ confidential: false, createdAt: '2022-08-03T12:41:54Z', closedAt: null, + webUrl: '/gitlab-org/gitlab-test/-/work_items/5', widgets: [], __typename: 'WorkItem', }, @@ -1196,6 +1220,7 @@ export const workItemHierarchyResponse = { setWorkItemMetadata: true, adminParentLink: true, createNote: true, + adminWorkItemLink: true, __typename: 'WorkItemPermissions', }, author: { @@ -1297,6 +1322,7 @@ export const workItemObjectiveWithChild = { setWorkItemMetadata: true, adminParentLink: true, createNote: true, + adminWorkItemLink: true, __typename: 'WorkItemPermissions', }, author: { @@ -1368,6 +1394,7 @@ export const workItemHierarchyTreeResponse = { setWorkItemMetadata: true, adminParentLink: true, createNote: true, + adminWorkItemLink: true, __typename: 'WorkItemPermissions', }, confidential: false, @@ -1403,6 +1430,7 @@ export const workItemHierarchyTreeResponse = { confidential: false, createdAt: '2022-08-03T12:41:54Z', closedAt: null, + webUrl: '/gitlab-org/gitlab-test/-/work_items/13', widgets: [ { type: 'HIERARCHY', @@ -1449,6 +1477,7 @@ export const changeIndirectWorkItemParentMutationResponse = { setWorkItemMetadata: true, adminParentLink: true, createNote: true, + adminWorkItemLink: true, __typename: 'WorkItemPermissions', }, description: null, @@ -1517,6 +1546,7 @@ export const changeWorkItemParentMutationResponse = { setWorkItemMetadata: true, adminParentLink: true, createNote: true, + adminWorkItemLink: true, __typename: 'WorkItemPermissions', }, description: null, @@ -1568,6 +1598,7 @@ export const availableWorkItemsResponse = { nodes: [ { id: 'gid://gitlab/WorkItem/458', + iid: '2', title: 'Task 1', state: 'OPEN', createdAt: '2022-08-03T12:41:54Z', @@ -1576,6 +1607,7 @@ export const availableWorkItemsResponse = { }, { id: 'gid://gitlab/WorkItem/459', + iid: '3', title: 'Task 2', state: 'OPEN', createdAt: '2022-08-03T12:41:54Z', @@ -1584,6 +1616,7 @@ export const availableWorkItemsResponse = { }, { id: 'gid://gitlab/WorkItem/460', + iid: '4', title: 'Task 3', state: 'OPEN', createdAt: '2022-08-03T12:41:54Z', @@ -1596,6 +1629,64 @@ export const availableWorkItemsResponse = { }, }; +export const availableObjectivesResponse = { + data: { + workspace: { + __typename: 'Project', + id: 'gid://gitlab/Project/2', + workItems: { + nodes: [ + { + id: 'gid://gitlab/WorkItem/716', + iid: '122', + title: 'Objective 101', + state: 'OPEN', + confidential: false, + __typename: 'WorkItem', + }, + { + id: 'gid://gitlab/WorkItem/712', + iid: '118', + title: 'Objective 103', + state: 'OPEN', + confidential: false, + __typename: 'WorkItem', + }, + { + id: 'gid://gitlab/WorkItem/711', + iid: '117', + title: 'Objective 102', + state: 'OPEN', + confidential: false, + __typename: 'WorkItem', + }, + ], + }, + }, + }, +}; + +export const searchedObjectiveResponse = { + data: { + workspace: { + __typename: 'Project', + id: 'gid://gitlab/Project/2', + workItems: { + nodes: [ + { + id: 'gid://gitlab/WorkItem/716', + iid: '122', + title: 'Objective 101', + state: 'OPEN', + confidential: false, + __typename: 'WorkItem', + }, + ], + }, + }, + }, +}; + export const searchedWorkItemsResponse = { data: { workspace: { @@ -1605,6 +1696,7 @@ export const searchedWorkItemsResponse = { nodes: [ { id: 'gid://gitlab/WorkItem/459', + iid: '3', title: 'Task 2', state: 'OPEN', createdAt: '2022-08-03T12:41:54Z', @@ -1931,6 +2023,21 @@ export const mockMilestoneWidgetResponse = { title: 'v4.0', }; +export const mockParentWidgetResponse = { + id: 'gid://gitlab/WorkItem/716', + iid: '122', + title: 'Objective 101', + confidential: false, + webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-test/-/work_items/122', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/6', + name: 'Objective', + iconName: 'issue-type-objective', + __typename: 'WorkItemType', + }, + __typename: 'WorkItem', +}; + export const projectMilestonesResponse = { data: { workspace: { @@ -3439,6 +3546,31 @@ export const getTodosMutationResponse = (state) => { }; }; +export const linkedWorkItemResponse = (options, errors = []) => { + const response = workItemResponseFactory(options); + return { + data: { + workItemAddLinkedItems: { + workItem: response.data.workItem, + errors, + __typename: 'WorkItemAddLinkedItemsPayload', + }, + }, + }; +}; + +export const removeLinkedWorkItemResponse = (message, errors = []) => { + return { + data: { + workItemRemoveLinkedItems: { + errors, + message, + __typename: 'WorkItemRemoveLinkedItemsPayload', + }, + }, + }; +}; + export const groupWorkItemsQueryResponse = { data: { group: { @@ -3498,3 +3630,36 @@ export const groupWorkItemsQueryResponse = { }, }, }; + +export const updateWorkItemMutationResponseFactory = (options) => { + const response = workItemResponseFactory(options); + return { + data: { + workItemUpdate: { + workItem: response.data.workItem, + errors: [], + }, + }, + }; +}; + +export const updateWorkItemNotificationsMutationResponse = (subscribed) => ({ + data: { + workItemSubscribe: { + workItem: { + id: 'gid://gitlab/WorkItem/1', + widgets: [ + { + __typename: 'WorkItemWidgetNotifications', + type: 'NOTIFICATIONS', + subscribed, + }, + ], + }, + errors: [], + }, + }, +}); + +export const generateWorkItemsListWithId = (count) => + Array.from({ length: count }, (_, i) => ({ id: `gid://gitlab/WorkItem/${i + 1}` })); diff --git a/spec/frontend/work_items/pages/create_work_item_spec.js b/spec/frontend/work_items/pages/create_work_item_spec.js index c369a454286..527f5890338 100644 --- a/spec/frontend/work_items/pages/create_work_item_spec.js +++ b/spec/frontend/work_items/pages/create_work_item_spec.js @@ -65,6 +65,7 @@ describe('Create work item component', () => { }, provide: { fullPath: 'full-path', + isGroup: false, }, }); }; @@ -199,8 +200,6 @@ describe('Create work item component', () => { wrapper.find('form').trigger('submit'); await waitForPromises(); - expect(findAlert().text()).toBe( - 'Something went wrong when creating work item. Please try again.', - ); + expect(findAlert().text()).toBe('Something went wrong when creating item. Please try again.'); }); }); diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js index 79ba31e7012..d4efcf78189 100644 --- a/spec/frontend/work_items/router_spec.js +++ b/spec/frontend/work_items/router_spec.js @@ -41,6 +41,7 @@ describe('Work items router', () => { router, provide: { fullPath: 'full-path', + isGroup: false, issuesListPath: 'full-path/-/issues', hasIssueWeightsFeature: false, hasIterationsFeature: false, diff --git a/spec/frontend/work_items/utils_spec.js b/spec/frontend/work_items/utils_spec.js index 8a49140119d..aa24b80cf08 100644 --- a/spec/frontend/work_items/utils_spec.js +++ b/spec/frontend/work_items/utils_spec.js @@ -1,4 +1,4 @@ -import { autocompleteDataSources, markdownPreviewPath, workItemPath } from '~/work_items/utils'; +import { autocompleteDataSources, markdownPreviewPath } from '~/work_items/utils'; describe('autocompleteDataSources', () => { beforeEach(() => { @@ -25,14 +25,3 @@ describe('markdownPreviewPath', () => { ); }); }); - -describe('workItemPath', () => { - it('returns corrrect data sources', () => { - expect(workItemPath('project/group', '2')).toEqual('/project/group/-/work_items/2'); - }); - - it('returns corrrect data sources with relative url root', () => { - gon.relative_url_root = '/foobar'; - expect(workItemPath('project/group', '2')).toEqual('/foobar/project/group/-/work_items/2'); - }); -}); |