diff options
Diffstat (limited to 'spec/frontend/vue_shared/components')
22 files changed, 819 insertions, 455 deletions
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 07cbfe1e79b..4f24ec2d015 100644 --- a/spec/frontend/vue_shared/components/ci_badge_link_spec.js +++ b/spec/frontend/vue_shared/components/ci_badge_link_spec.js @@ -1,6 +1,6 @@ import { GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; jest.mock('~/lib/utils/url_utility', () => ({ @@ -79,7 +79,7 @@ describe('CI Badge Link Component', () => { const findIcon = () => wrapper.findComponent(CiIcon); const createComponent = (propsData) => { - wrapper = shallowMount(CiBadge, { propsData }); + wrapper = shallowMount(CiBadgeLink, { propsData }); }; afterEach(() => { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js index 66ef473f368..63c22aff3d5 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js @@ -4,7 +4,7 @@ import testAction from 'helpers/vuex_action_helper'; import { mockBranches } from 'jest/vue_shared/components/filtered_search_bar/mock_data'; import Api from '~/api'; import { createAlert } from '~/flash'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK, HTTP_STATUS_SERVICE_UNAVAILABLE } from '~/lib/utils/http_status'; import * as actions from '~/vue_shared/components/filtered_search_bar/store/modules/filters/actions'; import * as types from '~/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types'; import initialState from '~/vue_shared/components/filtered_search_bar/store/modules/filters/state'; @@ -122,7 +122,7 @@ describe('Filters actions', () => { ':id', encodeURIComponent(projectEndpoint), ); - mock.onGet(url).replyOnce(httpStatusCodes.OK, mockBranches); + mock.onGet(url).replyOnce(HTTP_STATUS_OK, mockBranches); }); it('dispatches RECEIVE_BRANCHES_SUCCESS with received data', () => { @@ -143,7 +143,7 @@ describe('Filters actions', () => { describe('error', () => { beforeEach(() => { - mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE); + mock.onAny().replyOnce(HTTP_STATUS_SERVICE_UNAVAILABLE); }); it('dispatches RECEIVE_BRANCHES_ERROR', () => { @@ -155,7 +155,7 @@ describe('Filters actions', () => { { type: types.REQUEST_BRANCHES }, { type: types.RECEIVE_BRANCHES_ERROR, - payload: httpStatusCodes.SERVICE_UNAVAILABLE, + payload: HTTP_STATUS_SERVICE_UNAVAILABLE, }, ], [], @@ -177,7 +177,7 @@ describe('Filters actions', () => { describe('success', () => { beforeEach(() => { - mock.onAny().replyOnce(httpStatusCodes.OK, filterUsers); + mock.onAny().replyOnce(HTTP_STATUS_OK, filterUsers); }); it('dispatches RECEIVE_AUTHORS_SUCCESS with received data and groupEndpoint set', () => { @@ -215,7 +215,7 @@ describe('Filters actions', () => { describe('error', () => { beforeEach(() => { - mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE); + mock.onAny().replyOnce(HTTP_STATUS_SERVICE_UNAVAILABLE); }); it('dispatches RECEIVE_AUTHORS_ERROR and groupEndpoint set', () => { @@ -227,7 +227,7 @@ describe('Filters actions', () => { { type: types.REQUEST_AUTHORS }, { type: types.RECEIVE_AUTHORS_ERROR, - payload: httpStatusCodes.SERVICE_UNAVAILABLE, + payload: HTTP_STATUS_SERVICE_UNAVAILABLE, }, ], [], @@ -246,7 +246,7 @@ describe('Filters actions', () => { { type: types.REQUEST_AUTHORS }, { type: types.RECEIVE_AUTHORS_ERROR, - payload: httpStatusCodes.SERVICE_UNAVAILABLE, + payload: HTTP_STATUS_SERVICE_UNAVAILABLE, }, ], [], @@ -261,7 +261,7 @@ describe('Filters actions', () => { describe('fetchMilestones', () => { describe('success', () => { beforeEach(() => { - mock.onGet(milestonesEndpoint).replyOnce(httpStatusCodes.OK, filterMilestones); + mock.onGet(milestonesEndpoint).replyOnce(HTTP_STATUS_OK, filterMilestones); }); it('dispatches RECEIVE_MILESTONES_SUCCESS with received data', () => { @@ -282,7 +282,7 @@ describe('Filters actions', () => { describe('error', () => { beforeEach(() => { - mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE); + mock.onAny().replyOnce(HTTP_STATUS_SERVICE_UNAVAILABLE); }); it('dispatches RECEIVE_MILESTONES_ERROR', () => { @@ -294,7 +294,7 @@ describe('Filters actions', () => { { type: types.REQUEST_MILESTONES }, { type: types.RECEIVE_MILESTONES_ERROR, - payload: httpStatusCodes.SERVICE_UNAVAILABLE, + payload: HTTP_STATUS_SERVICE_UNAVAILABLE, }, ], [], @@ -307,7 +307,7 @@ describe('Filters actions', () => { describe('success', () => { let restoreVersion; beforeEach(() => { - mock.onAny().replyOnce(httpStatusCodes.OK, filterUsers); + mock.onAny().replyOnce(HTTP_STATUS_OK, filterUsers); restoreVersion = gon.api_version; gon.api_version = 'v1'; }); @@ -352,7 +352,7 @@ describe('Filters actions', () => { describe('error', () => { let restoreVersion; beforeEach(() => { - mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE); + mock.onAny().replyOnce(HTTP_STATUS_SERVICE_UNAVAILABLE); restoreVersion = gon.api_version; gon.api_version = 'v1'; }); @@ -370,7 +370,7 @@ describe('Filters actions', () => { { type: types.REQUEST_ASSIGNEES }, { type: types.RECEIVE_ASSIGNEES_ERROR, - payload: httpStatusCodes.SERVICE_UNAVAILABLE, + payload: HTTP_STATUS_SERVICE_UNAVAILABLE, }, ], [], @@ -389,7 +389,7 @@ describe('Filters actions', () => { { type: types.REQUEST_ASSIGNEES }, { type: types.RECEIVE_ASSIGNEES_ERROR, - payload: httpStatusCodes.SERVICE_UNAVAILABLE, + payload: HTTP_STATUS_SERVICE_UNAVAILABLE, }, ], [], @@ -404,7 +404,7 @@ describe('Filters actions', () => { describe('fetchLabels', () => { describe('success', () => { beforeEach(() => { - mock.onGet(labelsEndpoint).replyOnce(httpStatusCodes.OK, filterLabels); + mock.onGet(labelsEndpoint).replyOnce(HTTP_STATUS_OK, filterLabels); }); it('dispatches RECEIVE_LABELS_SUCCESS with received data', () => { @@ -425,7 +425,7 @@ describe('Filters actions', () => { describe('error', () => { beforeEach(() => { - mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE); + mock.onAny().replyOnce(HTTP_STATUS_SERVICE_UNAVAILABLE); }); it('dispatches RECEIVE_LABELS_ERROR', () => { @@ -437,7 +437,7 @@ describe('Filters actions', () => { { type: types.REQUEST_LABELS }, { type: types.RECEIVE_LABELS_ERROR, - payload: httpStatusCodes.SERVICE_UNAVAILABLE, + payload: HTTP_STATUS_SERVICE_UNAVAILABLE, }, ], [], diff --git a/spec/frontend/vue_shared/components/group_select/group_select_spec.js b/spec/frontend/vue_shared/components/group_select/group_select_spec.js index c10b32c6acc..87dd7795b98 100644 --- a/spec/frontend/vue_shared/components/group_select/group_select_spec.js +++ b/spec/frontend/vue_shared/components/group_select/group_select_spec.js @@ -1,20 +1,18 @@ import { nextTick } from 'vue'; -import { GlCollapsibleListbox } from '@gitlab/ui'; +import { GlFormGroup, GlCollapsibleListbox } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import axios from '~/lib/utils/axios_utils'; -import { createAlert } from '~/flash'; import GroupSelect from '~/vue_shared/components/group_select/group_select.vue'; import { TOGGLE_TEXT, + RESET_LABEL, FETCH_GROUPS_ERROR, FETCH_GROUP_ERROR, QUERY_TOO_SHORT_MESSAGE, } from '~/vue_shared/components/group_select/constants'; import waitForPromises from 'helpers/wait_for_promises'; -jest.mock('~/flash'); - describe('GroupSelect', () => { let wrapper; let mock; @@ -26,22 +24,34 @@ describe('GroupSelect', () => { }; const groupEndpoint = `/api/undefined/groups/${groupMock.id}`; + // Stubs + const GlAlert = { + template: '<div><slot /></div>', + }; + // Props + const label = 'label'; const inputName = 'inputName'; const inputId = 'inputId'; // Finders + const findFormGroup = () => wrapper.findComponent(GlFormGroup); const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); const findInput = () => wrapper.findByTestId('input'); + const findAlert = () => wrapper.findComponent(GlAlert); // Helpers const createComponent = ({ props = {} } = {}) => { wrapper = shallowMountExtended(GroupSelect, { propsData: { + label, inputName, inputId, ...props, }, + stubs: { + GlAlert, + }, }); }; const openListbox = () => findListbox().vm.$emit('shown'); @@ -65,6 +75,12 @@ describe('GroupSelect', () => { mock.restore(); }); + it('passes the label to GlFormGroup', () => { + createComponent(); + + expect(findFormGroup().attributes('label')).toBe(label); + }); + describe('on mount', () => { it('fetches groups when the listbox is opened', async () => { createComponent(); @@ -94,13 +110,13 @@ describe('GroupSelect', () => { .reply(200, [{ full_name: 'notTheSelectedGroup', id: '2' }]); mock.onGet(groupEndpoint).reply(500); createComponent({ props: { initialSelection: groupMock.id } }); + + expect(findAlert().exists()).toBe(false); + await waitForPromises(); - expect(createAlert).toHaveBeenCalledWith({ - message: FETCH_GROUP_ERROR, - error: expect.any(Error), - parent: wrapper.vm.$el, - }); + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(FETCH_GROUP_ERROR); }); }); }); @@ -109,13 +125,12 @@ describe('GroupSelect', () => { mock.onGet('/api/undefined/groups.json').reply(500); createComponent(); openListbox(); + expect(findAlert().exists()).toBe(false); + await waitForPromises(); - expect(createAlert).toHaveBeenCalledWith({ - message: FETCH_GROUPS_ERROR, - error: expect.any(Error), - parent: wrapper.vm.$el, - }); + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(FETCH_GROUPS_ERROR); }); describe('selection', () => { @@ -186,7 +201,11 @@ describe('GroupSelect', () => { await waitForPromises(); expect(mock.history.get).toHaveLength(2); - expect(mock.history.get[1].params).toStrictEqual({ search: searchString }); + expect(mock.history.get[1].params).toStrictEqual({ + page: 1, + per_page: 20, + search: searchString, + }); }); it('shows a notice if the search query is too short', async () => { @@ -199,4 +218,105 @@ describe('GroupSelect', () => { expect(findListbox().props('noResultsText')).toBe(QUERY_TOO_SHORT_MESSAGE); }); }); + + describe('pagination', () => { + const searchString = 'searchString'; + + beforeEach(async () => { + let requestCount = 0; + mock.onGet('/api/undefined/groups.json').reply(({ params }) => { + requestCount += 1; + return [ + 200, + [ + { + full_name: `Group [page: ${params.page} - search: ${params.search}]`, + id: requestCount, + }, + ], + { + page: params.page, + 'x-total-pages': 3, + }, + ]; + }); + createComponent(); + openListbox(); + findListbox().vm.$emit('bottom-reached'); + return waitForPromises(); + }); + + it('fetches the next page when bottom is reached', async () => { + expect(mock.history.get).toHaveLength(2); + expect(mock.history.get[1].params).toStrictEqual({ + page: 2, + per_page: 20, + search: '', + }); + }); + + it('fetches the first page when the search query changes', async () => { + search(searchString); + await waitForPromises(); + + expect(mock.history.get).toHaveLength(3); + expect(mock.history.get[2].params).toStrictEqual({ + page: 1, + per_page: 20, + search: searchString, + }); + }); + + it('retains the search query when infinite scrolling', async () => { + search(searchString); + await waitForPromises(); + findListbox().vm.$emit('bottom-reached'); + await waitForPromises(); + + expect(mock.history.get).toHaveLength(4); + expect(mock.history.get[3].params).toStrictEqual({ + page: 2, + per_page: 20, + search: searchString, + }); + }); + + it('pauses infinite scroll after fetching the last page', async () => { + expect(findListbox().props('infiniteScroll')).toBe(true); + + findListbox().vm.$emit('bottom-reached'); + await waitForPromises(); + + expect(findListbox().props('infiniteScroll')).toBe(false); + }); + + it('resumes infinite scroll when search query changes', async () => { + findListbox().vm.$emit('bottom-reached'); + await waitForPromises(); + + expect(findListbox().props('infiniteScroll')).toBe(false); + + search(searchString); + await waitForPromises(); + + expect(findListbox().props('infiniteScroll')).toBe(true); + }); + }); + + it.each` + description | clearable | expectedLabel + ${'passes'} | ${true} | ${RESET_LABEL} + ${'does not pass'} | ${false} | ${''} + `( + '$description the reset button label to the listbox when clearable is $clearable', + ({ clearable, expectedLabel }) => { + createComponent({ + props: { + clearable, + }, + }); + + expect(findListbox().props('resetButtonLabel')).toBe(expectedLabel); + }, + ); }); diff --git a/spec/frontend/vue_shared/components/header_ci_component_spec.js b/spec/frontend/vue_shared/components/header_ci_component_spec.js index aea76f164f0..94e1ece8c6b 100644 --- a/spec/frontend/vue_shared/components/header_ci_component_spec.js +++ b/spec/frontend/vue_shared/components/header_ci_component_spec.js @@ -84,11 +84,12 @@ describe('Header CI Component', () => { expect(findUserLink().text()).toContain(defaultProps.user.username); }); - it('has the correct data attributes', () => { + it('has the correct HTML attributes', () => { expect(findUserLink().attributes()).toMatchObject({ 'data-user-id': defaultProps.user.id.toString(), 'data-username': defaultProps.user.username, 'data-name': defaultProps.user.name, + href: defaultProps.user.web_url, }); }); diff --git a/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js b/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js index cb7262b15e3..7ed6a59c844 100644 --- a/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js +++ b/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js @@ -1,11 +1,13 @@ import { shallowMount } from '@vue/test-utils'; -import { GlListbox } from '@gitlab/ui'; +import { GlFormGroup, GlListbox } from '@gitlab/ui'; import ListboxInput from '~/vue_shared/components/listbox_input/listbox_input.vue'; describe('ListboxInput', () => { let wrapper; // Props + const label = 'label'; + const decription = 'decription'; const name = 'name'; const defaultToggleText = 'defaultToggleText'; const items = [ @@ -21,30 +23,70 @@ describe('ListboxInput', () => { options: [{ text: 'Item 3', value: '3' }], }, ]; + const id = 'id'; // Finders + const findGlFormGroup = () => wrapper.findComponent(GlFormGroup); const findGlListbox = () => wrapper.findComponent(GlListbox); const findInput = () => wrapper.find('input'); const createComponent = (propsData) => { wrapper = shallowMount(ListboxInput, { propsData: { + label, + decription, name, defaultToggleText, items, ...propsData, }, + attrs: { + id, + }, }); }; - describe('input attributes', () => { + describe('wrapper', () => { + it.each` + description | labelProp | descriptionProp | rendersGlFormGroup + ${'does not render'} | ${''} | ${''} | ${false} + ${'renders'} | ${'labelProp'} | ${''} | ${true} + ${'renders'} | ${''} | ${'descriptionProp'} | ${true} + ${'renders'} | ${'labelProp'} | ${'descriptionProp'} | ${true} + `( + "$description a GlFormGroup when label is '$labelProp' and description is '$descriptionProp'", + ({ labelProp, descriptionProp, rendersGlFormGroup }) => { + createComponent({ label: labelProp, description: descriptionProp }); + + expect(findGlFormGroup().exists()).toBe(rendersGlFormGroup); + }, + ); + }); + + describe('options', () => { beforeEach(() => { createComponent(); }); + it('passes the label to the form group', () => { + expect(findGlFormGroup().attributes('label')).toBe(label); + }); + + it('passes the decription to the form group', () => { + expect(findGlFormGroup().attributes('decription')).toBe(decription); + }); + it('sets the input name', () => { expect(findInput().attributes('name')).toBe(name); }); + + it('is not filterable with few items', () => { + expect(findGlListbox().props('searchable')).toBe(false); + }); + + it('passes attributes to the root element', () => { + expect(findGlFormGroup().attributes('id')).toBe(id); + }); }); describe('toggle text', () => { @@ -91,12 +133,29 @@ describe('ListboxInput', () => { }); describe('search', () => { - beforeEach(() => { - createComponent(); + it('is searchable when there are more than 10 items', () => { + createComponent({ + items: [ + { + text: 'Group 1', + options: [...Array(10).keys()].map((index) => ({ + text: index + 1, + value: String(index + 1), + })), + }, + { + text: 'Group 2', + options: [{ text: 'Item 11', value: '11' }], + }, + ], + }); + + expect(findGlListbox().props('searchable')).toBe(true); }); it('passes all items to GlListbox by default', () => { createComponent(); + expect(findGlListbox().props('items')).toStrictEqual(items); }); diff --git a/spec/frontend/vue_shared/components/markdown/editor_mode_dropdown_spec.js b/spec/frontend/vue_shared/components/markdown/editor_mode_dropdown_spec.js new file mode 100644 index 00000000000..34071775b9c --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/editor_mode_dropdown_spec.js @@ -0,0 +1,58 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import EditorModeDropdown from '~/vue_shared/components/markdown/editor_mode_dropdown.vue'; + +describe('vue_shared/component/markdown/editor_mode_dropdown', () => { + let wrapper; + + const createComponent = ({ value, size } = {}) => { + wrapper = shallowMount(EditorModeDropdown, { + propsData: { + value, + size, + }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItem = (text) => + wrapper + .findAllComponents(GlDropdownItem) + .filter((item) => item.text().startsWith(text)) + .at(0); + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each` + modeText | value | dropdownText | otherMode + ${'Rich text'} | ${'richText'} | ${'View markdown'} | ${'Markdown'} + ${'Markdown'} | ${'markdown'} | ${'View rich text'} | ${'Rich text'} + `('$modeText', ({ modeText, value, dropdownText, otherMode }) => { + beforeEach(() => { + createComponent({ value }); + }); + + it('shows correct dropdown label', () => { + expect(findDropdown().props('text')).toEqual(dropdownText); + }); + + it('checks correct checked dropdown item', () => { + expect(findDropdownItem(modeText).props().isChecked).toBe(true); + expect(findDropdownItem(otherMode).props().isChecked).toBe(false); + }); + + it('emits event on click', () => { + findDropdownItem(modeText).vm.$emit('click'); + + expect(wrapper.emitted().input).toEqual([[value]]); + }); + }); + + it('passes size to dropdown', () => { + createComponent({ size: 'small', value: 'markdown' }); + + expect(findDropdown().props('size')).toEqual('small'); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index 285ea10c813..3b8e78bbadd 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -37,7 +37,7 @@ describe('Markdown field component', () => { axiosMock.restore(); }); - function createSubject({ lines = [], enablePreview = true } = {}) { + function createSubject({ lines = [], enablePreview = true, showContentEditorSwitcher } = {}) { // We actually mount a wrapper component so that we can force Vue to rerender classes in order to test a regression // caused by mixing Vanilla JS and Vue. subject = mountExtended( @@ -68,6 +68,7 @@ describe('Markdown field component', () => { lines, enablePreview, restrictedToolBarItems, + showContentEditorSwitcher, }, }, ); @@ -191,6 +192,7 @@ describe('Markdown field component', () => { markdownDocsPath, quickActionsDocsPath: '', showCommentToolBar: true, + showContentEditorSwitcher: false, }); }); }); @@ -342,4 +344,18 @@ describe('Markdown field component', () => { restrictedToolBarItems, ); }); + + describe('showContentEditorSwitcher', () => { + it('defaults to false', () => { + createSubject(); + + expect(findMarkdownToolbar().props('showContentEditorSwitcher')).toBe(false); + }); + + it('passes showContentEditorSwitcher', () => { + createSubject({ showContentEditorSwitcher: true }); + + expect(findMarkdownToolbar().props('showContentEditorSwitcher')).toBe(true); + }); + }); }); 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 5f416db2676..e3df2cde1c1 100644 --- a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js +++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js @@ -1,4 +1,3 @@ -import { GlSegmentedControl } from '@gitlab/ui'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; @@ -49,7 +48,6 @@ describe('vue_shared/component/markdown/markdown_editor', () => { }, }); }; - const findSegmentedControl = () => wrapper.findComponent(GlSegmentedControl); const findMarkdownField = () => wrapper.findComponent(MarkdownField); const findTextarea = () => wrapper.find('textarea'); const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); @@ -97,36 +95,28 @@ describe('vue_shared/component/markdown/markdown_editor', () => { expect(findTextarea().element.value).toBe(value); }); - it('renders switch segmented control', () => { + it(`emits ${EDITING_MODE_CONTENT_EDITOR} event when enableContentEditor emitted from markdown editor`, async () => { buildWrapper(); - expect(findSegmentedControl().props()).toEqual({ - checked: EDITING_MODE_MARKDOWN_FIELD, - options: [ - { - text: expect.any(String), - value: EDITING_MODE_MARKDOWN_FIELD, - }, - { - text: expect.any(String), - value: EDITING_MODE_CONTENT_EDITOR, - }, - ], - }); - }); + findMarkdownField().vm.$emit('enableContentEditor'); - describe.each` - editingMode - ${EDITING_MODE_CONTENT_EDITOR} - ${EDITING_MODE_MARKDOWN_FIELD} - `('when segmented control emits change event with $editingMode value', ({ editingMode }) => { - it(`emits ${editingMode} event`, () => { - buildWrapper(); + await nextTick(); - findSegmentedControl().vm.$emit('change', editingMode); + expect(wrapper.emitted(EDITING_MODE_CONTENT_EDITOR)).toHaveLength(1); + }); - expect(wrapper.emitted(editingMode)).toHaveLength(1); + it(`emits ${EDITING_MODE_MARKDOWN_FIELD} event when enableMarkdownEditor emitted from content editor`, async () => { + buildWrapper({ + stubs: { ContentEditor: stubComponent(ContentEditor) }, }); + + findMarkdownField().vm.$emit('enableContentEditor'); + + await nextTick(); + + findContentEditor().vm.$emit('enableMarkdownEditor'); + + expect(wrapper.emitted(EDITING_MODE_MARKDOWN_FIELD)).toHaveLength(1); }); describe(`when editingMode is ${EDITING_MODE_MARKDOWN_FIELD}`, () => { @@ -159,11 +149,10 @@ describe('vue_shared/component/markdown/markdown_editor', () => { expect(wrapper.emitted('keydown')).toHaveLength(1); }); - describe(`when segmented control triggers input event with ${EDITING_MODE_CONTENT_EDITOR} value`, () => { + describe(`when markdown field triggers enableContentEditor event`, () => { beforeEach(() => { buildWrapper(); - findSegmentedControl().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR); - findSegmentedControl().vm.$emit('change', EDITING_MODE_CONTENT_EDITOR); + findMarkdownField().vm.$emit('enableContentEditor'); }); it('displays the content editor', () => { @@ -202,7 +191,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { describe(`when editingMode is ${EDITING_MODE_CONTENT_EDITOR}`, () => { beforeEach(() => { buildWrapper(); - findSegmentedControl().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR); + findMarkdownField().vm.$emit('enableContentEditor'); }); describe('when autofocus is true', () => { @@ -234,9 +223,9 @@ describe('vue_shared/component/markdown/markdown_editor', () => { expect(wrapper.emitted('keydown')).toEqual([[event]]); }); - describe(`when segmented control triggers input event with ${EDITING_MODE_MARKDOWN_FIELD} value`, () => { + describe(`when richText editor triggers enableMarkdownEditor event`, () => { beforeEach(() => { - findSegmentedControl().vm.$emit('input', EDITING_MODE_MARKDOWN_FIELD); + findContentEditor().vm.$emit('enableMarkdownEditor'); }); it('hides the content editor', () => { @@ -251,29 +240,5 @@ describe('vue_shared/component/markdown/markdown_editor', () => { expect(findLocalStorageSync().props().value).toBe(EDITING_MODE_MARKDOWN_FIELD); }); }); - - describe('when content editor emits loading event', () => { - beforeEach(() => { - findContentEditor().vm.$emit('loading'); - }); - - it('disables switch editing mode control', () => { - // This is the only way that I found to check the segmented control is disabled - expect(findSegmentedControl().find('input[disabled]').exists()).toBe(true); - }); - - describe.each` - event - ${'loadingSuccess'} - ${'loadingError'} - `('when content editor emits $event event', ({ event }) => { - beforeEach(() => { - findContentEditor().vm.$emit(event); - }); - it('enables the switch editing mode control', () => { - expect(findSegmentedControl().find('input[disabled]').exists()).toBe(false); - }); - }); - }); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js index f698794b951..b1a1dbbeb7a 100644 --- a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js +++ b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils'; import Toolbar from '~/vue_shared/components/markdown/toolbar.vue'; +import EditorModeDropdown from '~/vue_shared/components/markdown/editor_mode_dropdown.vue'; describe('toolbar', () => { let wrapper; @@ -47,4 +48,18 @@ describe('toolbar', () => { expect(wrapper.find('.comment-toolbar').exists()).toBe(true); }); }); + + describe('with content editor switcher', () => { + beforeEach(() => { + createMountedWrapper({ + showContentEditorSwitcher: true, + }); + }); + + it('re-emits event from switcher', () => { + wrapper.findComponent(EditorModeDropdown).vm.$emit('input', 'richText'); + + expect(wrapper.emitted('enableContentEditor')).toEqual([[]]); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap deleted file mode 100644 index 2ea8985b16a..00000000000 --- a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap +++ /dev/null @@ -1,177 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` -<gl-modal-stub - actionprimary="[object Object]" - actionsecondary="[object Object]" - arialabel="" - dismisslabel="Close" - modalclass="" - modalid="runner-aws-deployments-modal" - size="sm" - title="Deploy GitLab Runner in AWS" - titletag="h4" -> - <p> - Select your preferred option here. In the next step, you can choose the capacity for your runner in the AWS CloudFormation console. - </p> - - <gl-form-radio-group-stub - checked="[object Object]" - disabledfield="disabled" - htmlfield="html" - label="Choose your preferred GitLab Runner" - label-sr-only="" - options="" - textfield="text" - valuefield="value" - > - <gl-form-radio-stub - class="gl-py-5 gl-pl-8 gl-border-b" - value="[object Object]" - > - <div - class="gl-mt-n1 gl-pl-4 gl-pb-2 gl-font-weight-bold" - > - - Amazon Linux 2 Docker HA with manual scaling and optional scheduling. Non-spot. - - <gl-accordion-stub - class="gl-pt-3" - headerlevel="3" - > - <gl-accordion-item-stub - class="gl-font-weight-normal" - headerclass="" - title="More Details" - titlevisible="Less Details" - > - <p - class="gl-pt-2" - > - No spot. This is the default choice for Linux Docker executor. - </p> - - <p - class="gl-m-0" - > - A capacity of 1 enables warm HA through Auto Scaling group re-spawn. A capacity of 2 enables hot HA because the service is available even when a node is lost. A capacity of 3 or more enables hot HA and manual scaling of runner fleet. - </p> - </gl-accordion-item-stub> - </gl-accordion-stub> - </div> - </gl-form-radio-stub> - <gl-form-radio-stub - class="gl-py-5 gl-pl-8 gl-border-b" - value="[object Object]" - > - <div - class="gl-mt-n1 gl-pl-4 gl-pb-2 gl-font-weight-bold" - > - - Amazon Linux 2 Docker HA with manual scaling and optional scheduling. 100% spot. - - <gl-accordion-stub - class="gl-pt-3" - headerlevel="3" - > - <gl-accordion-item-stub - class="gl-font-weight-normal" - headerclass="" - title="More Details" - titlevisible="Less Details" - > - <p - class="gl-pt-2" - > - 100% spot. - </p> - - <p - class="gl-m-0" - > - Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet. - </p> - </gl-accordion-item-stub> - </gl-accordion-stub> - </div> - </gl-form-radio-stub> - <gl-form-radio-stub - class="gl-py-5 gl-pl-8 gl-border-b" - value="[object Object]" - > - <div - class="gl-mt-n1 gl-pl-4 gl-pb-2 gl-font-weight-bold" - > - - Windows 2019 Shell with manual scaling and optional scheduling. Non-spot. - - <gl-accordion-stub - class="gl-pt-3" - headerlevel="3" - > - <gl-accordion-item-stub - class="gl-font-weight-normal" - headerclass="" - title="More Details" - titlevisible="Less Details" - > - <p - class="gl-pt-2" - > - No spot. Default choice for Windows Shell executor. - </p> - - <p - class="gl-m-0" - > - Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet. - </p> - </gl-accordion-item-stub> - </gl-accordion-stub> - </div> - </gl-form-radio-stub> - <gl-form-radio-stub - class="gl-py-5 gl-pl-8" - value="[object Object]" - > - <div - class="gl-mt-n1 gl-pl-4 gl-pb-2 gl-font-weight-bold" - > - - Windows 2019 Shell with manual scaling and optional scheduling. 100% spot. - - <gl-accordion-stub - class="gl-pt-3" - headerlevel="3" - > - <gl-accordion-item-stub - class="gl-font-weight-normal" - headerclass="" - title="More Details" - titlevisible="Less Details" - > - <p - class="gl-pt-2" - > - 100% spot. - </p> - - <p - class="gl-m-0" - > - Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet. - </p> - </gl-accordion-item-stub> - </gl-accordion-stub> - </div> - </gl-form-radio-stub> - </gl-form-radio-group-stub> - - <p> - <gl-sprintf-stub - message="Don't see what you are looking for? See the full list of options, including a fully customizable option %{linkStart}here%{linkEnd}." - /> - </p> -</gl-modal-stub> -`; diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js index a9ba4946358..c8ca75787f1 100644 --- a/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js +++ b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js @@ -1,30 +1,28 @@ -import { GlModal, GlFormRadio } from '@gitlab/ui'; +import { GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { getBaseURL, visitUrl } from '~/lib/utils/url_utility'; -import { mockTracking } from 'helpers/tracking_helper'; -import { - CF_BASE_URL, - TEMPLATES_BASE_URL, - EASY_BUTTONS, -} from '~/vue_shared/components/runner_aws_deployments/constants'; +import { s__ } from '~/locale'; import RunnerAwsDeploymentsModal from '~/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue'; +import RunnerAwsInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue'; jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), visitUrl: jest.fn(), })); +const mockModalId = 'runner-aws-deployments-modal'; + describe('RunnerAwsDeploymentsModal', () => { let wrapper; const findModal = () => wrapper.findComponent(GlModal); - const findEasyButtons = () => wrapper.findAllComponents(GlFormRadio); + const findRunnerAwsInstructions = () => wrapper.findComponent(RunnerAwsInstructions); - const createComponent = () => { + const createComponent = (options) => { wrapper = shallowMount(RunnerAwsDeploymentsModal, { propsData: { - modalId: 'runner-aws-deployments-modal', + modalId: mockModalId, }, + ...options, }); }; @@ -36,39 +34,39 @@ describe('RunnerAwsDeploymentsModal', () => { wrapper.destroy(); }); - it('renders the modal', () => { - expect(wrapper.element).toMatchSnapshot(); + it('renders modal', () => { + expect(findModal().props()).toMatchObject({ + size: 'sm', + modalId: mockModalId, + title: s__('Runners|Deploy GitLab Runner in AWS'), + }); + expect(findModal().attributes()).toMatchObject({ + 'hide-footer': '', + }); }); - it('should contain all easy buttons', () => { - expect(findEasyButtons()).toHaveLength(EASY_BUTTONS.length); + it('renders modal contents', () => { + expect(findRunnerAwsInstructions().exists()).toBe(true); }); - describe('first easy button', () => { - it('should contain the correct description', () => { - expect(findEasyButtons().at(0).text()).toContain(EASY_BUTTONS[0].description); - }); - - it('should contain the correct link', () => { - const templateUrl = encodeURIComponent(TEMPLATES_BASE_URL + EASY_BUTTONS[0].templateName); - const { stackName } = EASY_BUTTONS[0]; - const instanceUrl = encodeURIComponent(getBaseURL()); - const url = `${CF_BASE_URL}templateURL=${templateUrl}&stackName=${stackName}¶m_3GITLABRunnerInstanceURL=${instanceUrl}`; - - findModal().vm.$emit('primary'); + it('when contents trigger closing, modal closes', () => { + const mockClose = jest.fn(); - expect(visitUrl).toHaveBeenCalledWith(url, true); + createComponent({ + stubs: { + GlModal: { + template: '<div><slot/></div>', + methods: { + close: mockClose, + }, + }, + }, }); - it('should track an event when clicked', () => { - const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + expect(mockClose).toHaveBeenCalledTimes(0); - findModal().vm.$emit('primary'); + findRunnerAwsInstructions().vm.$emit('close'); - expect(trackingSpy).toHaveBeenCalledTimes(1); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { - label: EASY_BUTTONS[0].stackName, - }); - }); + expect(mockClose).toHaveBeenCalledTimes(1); }); }); diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/__snapshots__/runner_docker_instructions_spec.js.snap b/spec/frontend/vue_shared/components/runner_instructions/instructions/__snapshots__/runner_docker_instructions_spec.js.snap new file mode 100644 index 00000000000..d14f66df8a1 --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/__snapshots__/runner_docker_instructions_spec.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RunnerDockerInstructions renders contents 1`] = `"To install Runner in a container follow the instructions described in the GitLab documentation View installation instructions Close"`; diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/__snapshots__/runner_kubernetes_instructions_spec.js.snap b/spec/frontend/vue_shared/components/runner_instructions/instructions/__snapshots__/runner_kubernetes_instructions_spec.js.snap new file mode 100644 index 00000000000..1172bf07dff --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/__snapshots__/runner_kubernetes_instructions_spec.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RunnerKubernetesInstructions renders contents 1`] = `"To install Runner in Kubernetes follow the instructions described in the GitLab documentation. View installation instructions Close"`; diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_aws_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_aws_instructions_spec.js new file mode 100644 index 00000000000..4d566dbec0c --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_aws_instructions_spec.js @@ -0,0 +1,117 @@ +import { + GlAccordion, + GlAccordionItem, + GlButton, + GlFormRadio, + GlFormRadioGroup, + GlLink, + GlSprintf, +} from '@gitlab/ui'; +import { getBaseURL, visitUrl } from '~/lib/utils/url_utility'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking } from 'helpers/tracking_helper'; +import { + AWS_README_URL, + AWS_CF_BASE_URL, + AWS_TEMPLATES_BASE_URL, + AWS_EASY_BUTTONS, +} from '~/vue_shared/components/runner_instructions/constants'; +import RunnerAwsInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue'; +import { __ } from '~/locale'; + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + visitUrl: jest.fn(), +})); + +describe('RunnerAwsInstructions', () => { + let wrapper; + + const findEasyButtonsRadioGroup = () => wrapper.findComponent(GlFormRadioGroup); + const findEasyButtons = () => wrapper.findAllComponents(GlFormRadio); + const findEasyButtonAt = (i) => findEasyButtons().at(i); + const findLink = () => wrapper.findComponent(GlLink); + const findOkButton = () => + wrapper + .findAllComponents(GlButton) + .filter((w) => w.props('variant') === 'confirm') + .at(0); + const findCloseButton = () => wrapper.findByText(__('Close')); + + const createComponent = () => { + wrapper = shallowMountExtended(RunnerAwsInstructions, { + stubs: { + GlSprintf, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('should contain every button', () => { + expect(findEasyButtons()).toHaveLength(AWS_EASY_BUTTONS.length); + }); + + const AWS_EASY_BUTTONS_PARAMS = AWS_EASY_BUTTONS.map((val, idx) => ({ ...val, idx })); + + describe.each(AWS_EASY_BUTTONS_PARAMS)( + 'easy button %#', + ({ idx, description, moreDetails1, moreDetails2, templateName, stackName }) => { + it('should contain button description', () => { + const text = findEasyButtonAt(idx).text(); + + expect(text).toContain(description); + expect(text).toContain(moreDetails1); + expect(text).toContain(moreDetails2); + }); + + it('should show more details', () => { + const accordion = findEasyButtonAt(idx).findComponent(GlAccordion); + const accordionItem = accordion.findComponent(GlAccordionItem); + + expect(accordion.props('headerLevel')).toBe(3); + expect(accordionItem.props('title')).toBe(__('More Details')); + expect(accordionItem.props('titleVisible')).toBe(__('Less Details')); + }); + + describe('when clicked', () => { + let trackingSpy; + + beforeEach(() => { + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + findEasyButtonsRadioGroup().vm.$emit('input', idx); + findOkButton().vm.$emit('click'); + }); + + it('should contain the correct link', () => { + const templateUrl = encodeURIComponent(AWS_TEMPLATES_BASE_URL + templateName); + const instanceUrl = encodeURIComponent(getBaseURL()); + const url = `${AWS_CF_BASE_URL}templateURL=${templateUrl}&stackName=${stackName}¶m_3GITLABRunnerInstanceURL=${instanceUrl}`; + + expect(visitUrl).toHaveBeenCalledTimes(1); + expect(visitUrl).toHaveBeenCalledWith(url, true); + }); + + it('should track an event when clicked', () => { + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { + label: stackName, + }); + }); + }); + }, + ); + + it('displays link with more information', () => { + expect(findLink().attributes('href')).toBe(AWS_README_URL); + }); + + it('triggers the modal to close', () => { + findCloseButton().vm.$emit('click'); + + expect(wrapper.emitted('close')).toHaveLength(1); + }); +}); 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 new file mode 100644 index 00000000000..f9d700fe67f --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_cli_instructions_spec.js @@ -0,0 +1,169 @@ +import { GlAlert, 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 { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import getRunnerSetupInstructionsQuery from '~/vue_shared/components/runner_instructions/graphql/get_runner_setup.query.graphql'; +import RunnerCliInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_cli_instructions.vue'; + +import { mockRunnerPlatforms, mockInstructions, mockInstructionsWindows } from '../mock_data'; + +Vue.use(VueApollo); + +jest.mock('@gitlab/ui/dist/utils'); + +const mockPlatforms = mockRunnerPlatforms.data.runnerPlatforms.nodes.map( + ({ name, humanReadableName, architectures }) => ({ + name, + humanReadableName, + architectures: architectures?.nodes || [], + }), +); + +const [mockPlatform, mockPlatform2] = mockPlatforms; +const mockArchitectures = mockPlatform.architectures; + +describe('RunnerCliInstructions component', () => { + let wrapper; + let fakeApollo; + let runnerSetupInstructionsHandler; + + const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findAlert = () => wrapper.findComponent(GlAlert); + const findArchitectureDropdownItems = () => wrapper.findAllByTestId('architecture-dropdown-item'); + const findBinaryDownloadButton = () => wrapper.findByTestId('binary-download-button'); + const findBinaryInstructions = () => wrapper.findByTestId('binary-instructions'); + const findRegisterCommand = () => wrapper.findByTestId('register-command'); + + const createComponent = ({ props, ...options } = {}) => { + const requestHandlers = [[getRunnerSetupInstructionsQuery, runnerSetupInstructionsHandler]]; + + fakeApollo = createMockApollo(requestHandlers); + + wrapper = extendedWrapper( + shallowMount(RunnerCliInstructions, { + propsData: { + platform: mockPlatform, + registrationToken: 'MY_TOKEN', + ...props, + }, + apolloProvider: fakeApollo, + ...options, + }), + ); + }; + + beforeEach(() => { + runnerSetupInstructionsHandler = jest.fn().mockResolvedValue(mockInstructions); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when the instructions are shown', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('should not show alert', async () => { + expect(findAlert().exists()).toBe(false); + }); + + it('should contain a number of dropdown items for the architecture options', () => { + expect(findArchitectureDropdownItems()).toHaveLength( + mockRunnerPlatforms.data.runnerPlatforms.nodes[0].architectures.nodes.length, + ); + }); + + describe('should display instructions', () => { + const { installInstructions } = mockInstructions.data.runnerSetup; + + it('runner instructions are requested', () => { + expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({ + platform: 'linux', + architecture: 'amd64', + }); + }); + + it('binary instructions are shown', async () => { + const instructions = findBinaryInstructions().text(); + + expect(instructions).toBe(installInstructions.trim()); + }); + + it('register command is shown with a replaced token', async () => { + const command = findRegisterCommand().text(); + + expect(command).toBe( + 'sudo gitlab-runner register --url http://localhost/ --registration-token MY_TOKEN', + ); + }); + + it('architecture download link is shown', () => { + expect(findBinaryDownloadButton().attributes('href')).toBe( + mockArchitectures[0].downloadLocation, + ); + }); + }); + + describe('after another platform and architecture are selected', () => { + beforeEach(async () => { + runnerSetupInstructionsHandler.mockResolvedValue(mockInstructionsWindows); + + findArchitectureDropdownItems().at(1).vm.$emit('click'); + + wrapper.setProps({ platform: mockPlatform2 }); + await waitForPromises(); + }); + + it('runner instructions are requested', () => { + expect(runnerSetupInstructionsHandler).toHaveBeenLastCalledWith({ + platform: mockPlatform2.name, + architecture: mockPlatform2.architectures[0].name, + }); + }); + }); + }); + + describe('when a register token is not known', () => { + beforeEach(async () => { + createComponent({ props: { registrationToken: undefined } }); + await waitForPromises(); + }); + + it('register command is shown without a defined registration token', () => { + const instructions = findRegisterCommand().text(); + + expect(instructions).toBe(mockInstructions.data.runnerSetup.registerInstructions); + }); + }); + + describe('when apollo is loading', () => { + it('should show a loading icon', async () => { + createComponent(); + + expect(findGlLoadingIcon().exists()).toBe(true); + + await waitForPromises(); + + expect(findGlLoadingIcon().exists()).toBe(false); + }); + }); + + describe('when instructions cannot be loaded', () => { + beforeEach(async () => { + runnerSetupInstructionsHandler.mockRejectedValue(); + + createComponent(); + await waitForPromises(); + }); + + it('should show alert', () => { + expect(wrapper.emitted()).toEqual({ error: [[]] }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_docker_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_docker_instructions_spec.js new file mode 100644 index 00000000000..2922d261b24 --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_docker_instructions_spec.js @@ -0,0 +1,28 @@ +import { shallowMount } from '@vue/test-utils'; + +import { GlButton } from '@gitlab/ui'; +import RunnerDockerInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_docker_instructions.vue'; + +describe('RunnerDockerInstructions', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(RunnerDockerInstructions, {}); + }; + + const findButton = () => wrapper.findComponent(GlButton); + + beforeEach(() => { + createComponent(); + }); + + it('renders contents', () => { + expect(wrapper.text().replace(/\s+/g, ' ')).toMatchSnapshot(); + }); + + it('renders link', () => { + expect(findButton().attributes('href')).toBe( + 'https://docs.gitlab.com/runner/install/docker.html', + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions_spec.js new file mode 100644 index 00000000000..0bfcc0e3d86 --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions_spec.js @@ -0,0 +1,28 @@ +import { shallowMount } from '@vue/test-utils'; + +import { GlButton } from '@gitlab/ui'; +import RunnerKubernetesInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions.vue'; + +describe('RunnerKubernetesInstructions', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(RunnerKubernetesInstructions, {}); + }; + + const findButton = () => wrapper.findComponent(GlButton); + + beforeEach(() => { + createComponent(); + }); + + it('renders contents', () => { + expect(wrapper.text().replace(/\s+/g, ' ')).toMatchSnapshot(); + }); + + it('renders link', () => { + expect(findButton().attributes('href')).toBe( + 'https://docs.gitlab.com/runner/install/kubernetes.html', + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/runner_instructions/mock_data.js b/spec/frontend/vue_shared/components/runner_instructions/mock_data.js index 79cacadd6af..add334f166c 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/mock_data.js +++ b/spec/frontend/vue_shared/components/runner_instructions/mock_data.js @@ -1,5 +1,5 @@ -import mockGraphqlRunnerPlatforms from 'test_fixtures/graphql/runner_instructions/get_runner_platforms.query.graphql.json'; -import mockGraphqlInstructions from 'test_fixtures/graphql/runner_instructions/get_runner_setup.query.graphql.json'; -import mockGraphqlInstructionsWindows from 'test_fixtures/graphql/runner_instructions/get_runner_setup.query.graphql.windows.json'; +import mockRunnerPlatforms from 'test_fixtures/graphql/runner_instructions/get_runner_platforms.query.graphql.json'; +import mockInstructions from 'test_fixtures/graphql/runner_instructions/get_runner_setup.query.graphql.json'; +import mockInstructionsWindows from 'test_fixtures/graphql/runner_instructions/get_runner_setup.query.graphql.windows.json'; -export { mockGraphqlRunnerPlatforms, mockGraphqlInstructions, mockGraphqlInstructionsWindows }; +export { mockRunnerPlatforms, mockInstructions, mockInstructionsWindows }; diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js index ae9157591c5..19f2dd137ff 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js +++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlModal, GlButton, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui'; +import { GlAlert, GlModal, GlButton, GlSkeletonLoader } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; @@ -6,15 +6,13 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import getRunnerPlatformsQuery from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql'; -import getRunnerSetupInstructionsQuery from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql'; +import getRunnerPlatformsQuery from '~/vue_shared/components/runner_instructions/graphql/get_runner_platforms.query.graphql'; import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import RunnerCliInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_cli_instructions.vue'; +import RunnerDockerInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_docker_instructions.vue'; +import RunnerKubernetesInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions.vue'; -import { - mockGraphqlRunnerPlatforms, - mockGraphqlInstructions, - mockGraphqlInstructionsWindows, -} from './mock_data'; +import { mockRunnerPlatforms } from './mock_data'; Vue.use(VueApollo); @@ -40,24 +38,16 @@ describe('RunnerInstructionsModal component', () => { let wrapper; let fakeApollo; let runnerPlatformsHandler; - let runnerSetupInstructionsHandler; const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); - const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAlert = () => wrapper.findComponent(GlAlert); const findModal = () => wrapper.findComponent(GlModal); const findPlatformButtonGroup = () => wrapper.findByTestId('platform-buttons'); const findPlatformButtons = () => findPlatformButtonGroup().findAllComponents(GlButton); - const findArchitectureDropdownItems = () => wrapper.findAllByTestId('architecture-dropdown-item'); - const findBinaryDownloadButton = () => wrapper.findByTestId('binary-download-button'); - const findBinaryInstructions = () => wrapper.findByTestId('binary-instructions'); - const findRegisterCommand = () => wrapper.findByTestId('register-command'); + const findRunnerCliInstructions = () => wrapper.findComponent(RunnerCliInstructions); const createComponent = ({ props, shown = true, ...options } = {}) => { - const requestHandlers = [ - [getRunnerPlatformsQuery, runnerPlatformsHandler], - [getRunnerSetupInstructionsQuery, runnerSetupInstructionsHandler], - ]; + const requestHandlers = [[getRunnerPlatformsQuery, runnerPlatformsHandler]]; fakeApollo = createMockApollo(requestHandlers); @@ -80,8 +70,7 @@ describe('RunnerInstructionsModal component', () => { }; beforeEach(() => { - runnerPlatformsHandler = jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms); - runnerSetupInstructionsHandler = jest.fn().mockResolvedValue(mockGraphqlInstructions); + runnerPlatformsHandler = jest.fn().mockResolvedValue(mockRunnerPlatforms); }); afterEach(() => { @@ -103,90 +92,15 @@ describe('RunnerInstructionsModal component', () => { const buttons = findPlatformButtons(); - expect(buttons).toHaveLength(mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes.length); + expect(buttons).toHaveLength(mockRunnerPlatforms.data.runnerPlatforms.nodes.length); }); - it('should contain a number of dropdown items for the architecture options', () => { - expect(findArchitectureDropdownItems()).toHaveLength( - mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes[0].architectures.nodes.length, - ); - }); - - describe('should display default instructions', () => { - const { installInstructions } = mockGraphqlInstructions.data.runnerSetup; - - it('runner instructions are requested', () => { - expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({ - platform: 'linux', - architecture: 'amd64', - }); - }); - - it('binary instructions are shown', async () => { - const instructions = findBinaryInstructions().text(); - - expect(instructions).toBe(installInstructions.trim()); - }); - - it('register command is shown with a replaced token', async () => { - const command = findRegisterCommand().text(); - - expect(command).toBe( - 'sudo gitlab-runner register --url http://localhost/ --registration-token MY_TOKEN', - ); - }); - }); - - describe('after a platform and architecture are selected', () => { - const windowsIndex = 2; - const { installInstructions } = mockGraphqlInstructionsWindows.data.runnerSetup; - - beforeEach(async () => { - runnerSetupInstructionsHandler.mockResolvedValue(mockGraphqlInstructionsWindows); - - findPlatformButtons().at(windowsIndex).vm.$emit('click'); - await waitForPromises(); - }); + it('should display architecture options', () => { + const { architectures } = findRunnerCliInstructions().props('platform'); - it('runner instructions are requested', () => { - expect(runnerSetupInstructionsHandler).toHaveBeenLastCalledWith({ - platform: 'windows', - architecture: 'amd64', - }); - }); - - it('architecture download link is updated', () => { - const architectures = - mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes[windowsIndex].architectures.nodes; - - expect(findBinaryDownloadButton().attributes('href')).toBe( - architectures[0].downloadLocation, - ); - }); - - it('other binary instructions are shown', () => { - const instructions = findBinaryInstructions().text(); - - expect(instructions).toBe(installInstructions.trim()); - }); - - it('register command is shown', () => { - const command = findRegisterCommand().text(); - - expect(command).toBe( - './gitlab-runner.exe register --url http://localhost/ --registration-token MY_TOKEN', - ); - }); - - it('runner instructions are requested with another architecture', async () => { - findArchitectureDropdownItems().at(1).vm.$emit('click'); - await waitForPromises(); - - expect(runnerSetupInstructionsHandler).toHaveBeenLastCalledWith({ - platform: 'windows', - architecture: '386', - }); - }); + expect(architectures).toEqual( + mockRunnerPlatforms.data.runnerPlatforms.nodes[0].architectures.nodes, + ); }); describe('when the modal resizes', () => { @@ -206,16 +120,14 @@ describe('RunnerInstructionsModal component', () => { }); }); - describe('when a register token is not known', () => { + describe.each([null, 'DEFINED'])('when registration token is %p', (token) => { beforeEach(async () => { - createComponent({ props: { registrationToken: undefined } }); + createComponent({ props: { registrationToken: token } }); await waitForPromises(); }); it('register command is shown without a defined registration token', () => { - const instructions = findRegisterCommand().text(); - - expect(instructions).toBe(mockGraphqlInstructions.data.runnerSetup.registerInstructions); + expect(findRunnerCliInstructions().props('registrationToken')).toBe(token); }); }); @@ -225,21 +137,33 @@ describe('RunnerInstructionsModal component', () => { await waitForPromises(); }); - it('runner instructions for the default selected platform are requested', () => { - expect(runnerSetupInstructionsHandler).toHaveBeenLastCalledWith({ - platform: 'osx', - architecture: 'amd64', - }); + it('should preselect', () => { + const selected = findPlatformButtons() + .filter((btn) => btn.props('selected')) + .at(0); + + expect(selected.text()).toBe('macOS'); }); - it('sets the focus on the default selected platform', () => { - const findOsxPlatformButton = () => wrapper.findComponent({ ref: 'osx' }); + it('runner instructions for the default selected platform are requested', () => { + const { name } = findRunnerCliInstructions().props('platform'); - findOsxPlatformButton().element.focus = jest.fn(); + expect(name).toBe('osx'); + }); + }); - findModal().vm.$emit('shown'); + describe.each` + platform | component + ${'docker'} | ${RunnerDockerInstructions} + ${'kubernetes'} | ${RunnerKubernetesInstructions} + `('with platform "$platform"', ({ platform, component }) => { + beforeEach(async () => { + createComponent({ props: { defaultPlatformName: platform } }); + await waitForPromises(); + }); - expect(findOsxPlatformButton().element.focus).toHaveBeenCalled(); + it(`runner instructions for ${platform} are shown`, () => { + expect(wrapper.findComponent(component).exists()).toBe(true); }); }); @@ -251,7 +175,6 @@ describe('RunnerInstructionsModal component', () => { it('does not fetch instructions', () => { expect(runnerPlatformsHandler).not.toHaveBeenCalled(); - expect(runnerSetupInstructionsHandler).not.toHaveBeenCalled(); }); }); @@ -259,43 +182,41 @@ describe('RunnerInstructionsModal component', () => { it('should show a skeleton loader', async () => { createComponent(); await nextTick(); - await nextTick(); expect(findSkeletonLoader().exists()).toBe(true); - expect(findGlLoadingIcon().exists()).toBe(false); - - // wait on fetch of both `platforms` and `instructions` - await nextTick(); - await nextTick(); - - expect(findGlLoadingIcon().exists()).toBe(true); }); it('once loaded, should not show a loading state', async () => { createComponent(); - await waitForPromises(); expect(findSkeletonLoader().exists()).toBe(false); - expect(findGlLoadingIcon().exists()).toBe(false); }); }); - describe('when instructions cannot be loaded', () => { - beforeEach(async () => { - runnerSetupInstructionsHandler.mockRejectedValue(); + describe('errors', () => { + it('should show an alert when platforms cannot be loaded', async () => { + runnerPlatformsHandler.mockRejectedValue(); createComponent(); await waitForPromises(); - }); - it('should show alert', () => { expect(findAlert().exists()).toBe(true); }); - it('should not show instructions', () => { - expect(findBinaryInstructions().exists()).toBe(false); - expect(findRegisterCommand().exists()).toBe(false); + it('should show alert when instructions cannot be loaded', async () => { + createComponent(); + await waitForPromises(); + + findRunnerCliInstructions().vm.$emit('error'); + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + + findAlert().vm.$emit('dismiss'); + await nextTick(); + + expect(findAlert().exists()).toBe(false); }); }); @@ -312,14 +233,16 @@ describe('RunnerInstructionsModal component', () => { describe('show()', () => { let mockShow; + let mockClose; beforeEach(() => { mockShow = jest.fn(); + mockClose = jest.fn(); createComponent({ shown: false, stubs: { - GlModal: getGlModalStub({ show: mockShow }), + GlModal: getGlModalStub({ show: mockShow, close: mockClose }), }, }); }); @@ -329,6 +252,12 @@ describe('RunnerInstructionsModal component', () => { expect(mockShow).toHaveBeenCalledTimes(1); }); + + it('delegates close()', () => { + wrapper.vm.close(); + + expect(mockClose).toHaveBeenCalledTimes(1); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js index 33f370efdfa..5461d38599d 100644 --- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js @@ -90,6 +90,17 @@ describe('Source Viewer component', () => { }); }); + describe('legacy fallbacks', () => { + it('tracks a fallback event and emits an error when viewing python files', () => { + const fallbackLanguage = 'python'; + const eventData = { label: EVENT_LABEL_FALLBACK, property: fallbackLanguage }; + createComponent({ language: fallbackLanguage }); + + expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); + expect(wrapper.emitted('error')).toHaveLength(1); + }); + }); + describe('highlight.js', () => { beforeEach(() => createComponent({ language: mappedLanguage })); @@ -114,10 +125,10 @@ describe('Source Viewer component', () => { }); it('correctly maps languages starting with uppercase', async () => { - await createComponent({ language: 'Python3' }); - const languageDefinition = await import(`highlight.js/lib/languages/python`); + await createComponent({ language: 'Ruby' }); + const languageDefinition = await import(`highlight.js/lib/languages/ruby`); - expect(hljs.registerLanguage).toHaveBeenCalledWith('python', languageDefinition.default); + expect(hljs.registerLanguage).toHaveBeenCalledWith('ruby', languageDefinition.default); }); it('highlights the first chunk', () => { diff --git a/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js b/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js index e5f56c63031..c8351ed61d7 100644 --- a/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js @@ -1,4 +1,5 @@ import { GlDropdownItem, GlDropdown } from '@gitlab/ui'; +import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue'; import { formatTimezone } from '~/lib/utils/datetime_utility'; @@ -105,7 +106,14 @@ describe('Deploy freeze timezone dropdown', () => { }); it('renders selected time zone as dropdown label', () => { - expect(wrapper.findComponent(GlDropdown).props().text).toBe('[UTC + 2] Berlin'); + expect(wrapper.findComponent(GlDropdown).props().text).toBe('[UTC+2] Berlin'); + }); + + it('adds a checkmark to the selected option', async () => { + const selectedTZOption = findAllDropdownItems().at(0); + selectedTZOption.vm.$emit('click'); + await nextTick(); + expect(selectedTZOption.attributes('ischecked')).toBe('true'); }); }); }); diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js index 3b0f0fe6e73..2a0d2089fe3 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -7,15 +7,19 @@ import WebIdeLink, { i18n, PREFERRED_EDITOR_RESET_KEY, PREFERRED_EDITOR_KEY, - KEY_WEB_IDE, } from '~/vue_shared/components/web_ide_link.vue'; import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; +import { KEY_WEB_IDE } from '~/vue_shared/components/constants'; import { stubComponent } from 'helpers/stub_component'; import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { visitUrl } from '~/lib/utils/url_utility'; + +jest.mock('~/lib/utils/url_utility'); + const TEST_EDIT_URL = '/gitlab-test/test/-/edit/main/'; const TEST_WEB_IDE_URL = '/-/ide/project/gitlab-test/test/edit/main/-/'; const TEST_GITPOD_URL = 'https://gitpod.test/'; @@ -52,6 +56,7 @@ const ACTION_WEB_IDE = { 'data-track-action': 'click_consolidated_edit_ide', 'data-track-label': 'web_ide', }, + handle: expect.any(Function), }; const ACTION_WEB_IDE_CONFIRM_FORK = { ...ACTION_WEB_IDE, @@ -258,6 +263,14 @@ describe('Web IDE link component', () => { selectedKey: ACTION_PIPELINE_EDITOR.key, }); }); + + it('when web ide button is clicked it opens in a new tab', async () => { + findActionsButton().props('actions')[1].handle({ + preventDefault: jest.fn(), + }); + await nextTick(); + expect(visitUrl).toHaveBeenCalledWith(ACTION_WEB_IDE.href, true); + }); }); describe('with multiple actions', () => { |