diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-04-21 02:50:22 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-04-21 02:50:22 +0300 |
commit | 9dc93a4519d9d5d7be48ff274127136236a3adb3 (patch) | |
tree | 70467ae3692a0e35e5ea56bcb803eb512a10bedb /spec/frontend/vue_shared | |
parent | 4b0f34b6d759d6299322b3a54453e930c6121ff0 (diff) |
Add latest changes from gitlab-org/gitlab@13-11-stable-eev13.11.0-rc43
Diffstat (limited to 'spec/frontend/vue_shared')
31 files changed, 1635 insertions, 515 deletions
diff --git a/spec/frontend/vue_shared/alert_details/alert_details_spec.js b/spec/frontend/vue_shared/alert_details/alert_details_spec.js index 68bcf1dc491..1fc655f1ebc 100644 --- a/spec/frontend/vue_shared/alert_details/alert_details_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_details_spec.js @@ -8,7 +8,7 @@ import { joinPaths } from '~/lib/utils/url_utility'; import Tracking from '~/tracking'; import AlertDetails from '~/vue_shared/alert_details/components/alert_details.vue'; import AlertSummaryRow from '~/vue_shared/alert_details/components/alert_summary_row.vue'; -import { SEVERITY_LEVELS } from '~/vue_shared/alert_details/constants'; +import { PAGE_CONFIG, SEVERITY_LEVELS } from '~/vue_shared/alert_details/constants'; import createIssueMutation from '~/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; import mockAlerts from './mocks/alerts.json'; @@ -271,7 +271,13 @@ describe('AlertDetails', () => { }); it('should display a table of raw alert details data', () => { - expect(findDetailsTable().exists()).toBe(true); + const details = findDetailsTable(); + expect(details.exists()).toBe(true); + expect(details.props()).toStrictEqual({ + alert: mockAlert, + statuses: PAGE_CONFIG.OPERATIONS.STATUSES, + loading: false, + }); }); }); 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 a866fc13539..c532f688cbd 100644 --- a/spec/frontend/vue_shared/alert_details/alert_status_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_status_spec.js @@ -12,6 +12,7 @@ describe('AlertManagementStatus', () => { let wrapper; const findStatusDropdown = () => wrapper.find(GlDropdown); const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem); + const findAllStatusOptions = () => findStatusDropdown().findAll(GlDropdownItem); const selectFirstStatusOption = () => { findFirstStatusOption().vm.$emit('click'); @@ -131,6 +132,24 @@ describe('AlertManagementStatus', () => { }); }); + describe('Statuses', () => { + it('renders default translated statuses', () => { + mountComponent({}); + expect(findAllStatusOptions().length).toBe(3); + expect(findFirstStatusOption().text()).toBe('Triggered'); + }); + + it('renders translated statuses', () => { + const status = 'TEST'; + const translatedStatus = 'Test'; + mountComponent({ + props: { alert: { ...mockAlert, status }, statuses: { [status]: translatedStatus } }, + }); + expect(findAllStatusOptions().length).toBe(1); + expect(findFirstStatusOption().text()).toBe(translatedStatus); + }); + }); + describe('Snowplow tracking', () => { beforeEach(() => { jest.spyOn(Tracking, 'event'); diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_spec.js index 70cf2597963..ef75e038bff 100644 --- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_spec.js +++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_spec.js @@ -76,20 +76,4 @@ describe('Alert Details Sidebar', () => { expect(wrapper.find(SidebarStatus).exists()).toBe(true); }); }); - - describe('the sidebar renders for threat monitoring', () => { - beforeEach(() => { - mock = new MockAdapter(axios); - mountComponent(); - }); - - it('should not render side bar status dropdown', () => { - mountComponent({ - mountMethod: mount, - alert: mockAlert, - provide: { isThreatMonitoringPage: true }, - }); - expect(wrapper.find(SidebarStatus).exists()).toBe(false); - }); - }); }); diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js index f5b9efb4d98..0014957517f 100644 --- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js +++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js @@ -1,7 +1,9 @@ import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import updateAlertStatusMutation from '~/graphql_shared/mutations/alert_status_update.mutation.graphql'; +import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue'; import AlertSidebarStatus from '~/vue_shared/alert_details/components/sidebar/sidebar_status.vue'; +import { PAGE_CONFIG } from '~/vue_shared/alert_details/constants'; import mockAlerts from '../mocks/alerts.json'; const mockAlert = mockAlerts[0]; @@ -12,8 +14,16 @@ describe('Alert Details Sidebar Status', () => { const findStatusDropdownItem = () => wrapper.find(GlDropdownItem); const findStatusLoadingIcon = () => wrapper.find(GlLoadingIcon); const findStatusDropdownHeader = () => wrapper.find('[data-testid="dropdown-header"]'); + const findAlertStatus = () => wrapper.findComponent(AlertStatus); + const findStatus = () => wrapper.find('[data-testid="status"]'); - function mountComponent({ data, sidebarCollapsed = true, loading = false, stubs = {} } = {}) { + function mountComponent({ + data, + sidebarCollapsed = true, + loading = false, + stubs = {}, + provide = {}, + } = {}) { wrapper = mount(AlertSidebarStatus, { propsData: { alert: { ...mockAlert }, @@ -32,6 +42,7 @@ describe('Alert Details Sidebar Status', () => { }, }, stubs, + provide, }); } @@ -96,8 +107,24 @@ describe('Alert Details Sidebar Status', () => { jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); findStatusDropdownItem().vm.$emit('click'); expect(findStatusLoadingIcon().exists()).toBe(false); - expect(wrapper.find('[data-testid="status"]').text()).toBe('Triggered'); + expect(findStatus().text()).toBe('Triggered'); }); }); }); + + describe('Statuses', () => { + it('renders default translated statuses', () => { + mountComponent({}); + expect(findAlertStatus().props('statuses')).toBe(PAGE_CONFIG.OPERATIONS.STATUSES); + expect(findStatus().text()).toBe('Triggered'); + }); + + it('renders translated statuses', () => { + const status = 'TEST'; + const statuses = { [status]: 'Test' }; + mountComponent({ data: { alert: { ...mockAlert, status } }, provide: { statuses } }); + expect(findAlertStatus().props('statuses')).toBe(statuses); + expect(findStatus().text()).toBe(statuses.TEST); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap index 1bf757ea312..bab928318ce 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap @@ -40,6 +40,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` tag="div" > <gl-button-stub + aria-label="Copy URL" buttontextclasses="" category="primary" class="d-inline-flex" @@ -82,6 +83,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` tag="div" > <gl-button-stub + aria-label="Copy URL" buttontextclasses="" category="primary" class="d-inline-flex" diff --git a/spec/frontend/vue_shared/components/alert_details_table_spec.js b/spec/frontend/vue_shared/components/alert_details_table_spec.js index 49b82cb4d4e..03b04a92bdf 100644 --- a/spec/frontend/vue_shared/components/alert_details_table_spec.js +++ b/spec/frontend/vue_shared/components/alert_details_table_spec.js @@ -75,45 +75,62 @@ describe('AlertDetails', () => { }); describe('with table data', () => { - beforeEach(mountComponent); - - it('renders a table', () => { - expect(findTableComponent().exists()).toBe(true); - }); - - it('renders a cell based on alert data', () => { - expect(findTableComponent().text()).toContain('SyntaxError: Invalid or unexpected token'); - }); - - it('should show allowed alert fields', () => { - const fields = findTableKeys(); - - expect(findTableField(fields, 'Iid').exists()).toBe(true); - expect(findTableField(fields, 'Title').exists()).toBe(true); - expect(findTableField(fields, 'Severity').exists()).toBe(true); - expect(findTableField(fields, 'Status').exists()).toBe(true); - expect(findTableField(fields, 'Hosts').exists()).toBe(true); - expect(findTableField(fields, 'Environment').exists()).toBe(true); + describe('default', () => { + beforeEach(mountComponent); + + it('renders a table', () => { + expect(findTableComponent().exists()).toBe(true); + }); + + it('renders a cell based on alert data', () => { + expect(findTableComponent().text()).toContain('SyntaxError: Invalid or unexpected token'); + }); + + it('should show allowed alert fields', () => { + const fields = findTableKeys(); + ['Iid', 'Title', 'Severity', 'Status', 'Hosts', 'Environment'].forEach((field) => { + expect(findTableField(fields, field).exists()).toBe(true); + }); + }); + + it('should not show disallowed alert fields', () => { + const fields = findTableKeys(); + ['Typename', 'Todos', 'Notes', 'Assignees'].forEach((field) => { + expect(findTableField(fields, field).exists()).toBe(false); + }); + }); }); - it('should not show disallowed alert fields', () => { - const fields = findTableKeys(); + describe('environment', () => { + it('should display only the name for the environment', () => { + mountComponent(); + expect(findTableFieldValueByKey('Environment').text()).toBe(environmentName); + }); - expect(findTableField(fields, 'Typename').exists()).toBe(false); - expect(findTableField(fields, 'Todos').exists()).toBe(false); - expect(findTableField(fields, 'Notes').exists()).toBe(false); - expect(findTableField(fields, 'Assignees').exists()).toBe(false); - }); + it('should not display the environment row if there is not data', () => { + environmentData = { name: null, path: null }; + mountComponent(); - it('should display only the name for the environment', () => { - expect(findTableFieldValueByKey('Environment').text()).toBe(environmentName); + expect(findTableFieldValueByKey('Environment').text()).toBeFalsy(); + }); }); - it('should not display the environment row if there is not data', () => { - environmentData = { name: null, path: null }; - mountComponent(); - - expect(findTableFieldValueByKey('Environment').text()).toBeFalsy(); + describe('status', () => { + it('should show the translated status for the default statuses', () => { + mountComponent(); + expect(findTableFieldValueByKey('Status').text()).toBe('Triggered'); + }); + + it('should show the translated status for provided statuses', () => { + const translatedStatus = 'Test'; + mountComponent({ statuses: { TRIGGERED: translatedStatus } }); + expect(findTableFieldValueByKey('Status').text()).toBe(translatedStatus); + }); + + it('should show the provided status if value is not defined in statuses', () => { + mountComponent({ statuses: {} }); + expect(findTableFieldValueByKey('Status').text()).toBe('TRIGGERED'); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap b/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap index 023895099b1..06753044e93 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap +++ b/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap @@ -1,87 +1,88 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Blob Simple Viewer component rendering matches the snapshot 1`] = ` -<div - class="file-content code js-syntax-highlight" - data-qa-selector="file_content" -> +<div> <div - class="line-numbers" + class="file-content code js-syntax-highlight" > - <a - class="diff-line-num js-line-number" - data-line-number="1" - href="#LC1" - id="L1" + <div + class="line-numbers" > - <gl-icon-stub - name="link" - size="12" - /> + <a + class="diff-line-num js-line-number" + data-line-number="1" + href="#LC1" + id="L1" + > + <gl-icon-stub + name="link" + size="12" + /> + + 1 - 1 - - </a> - <a - class="diff-line-num js-line-number" - data-line-number="2" - href="#LC2" - id="L2" - > - <gl-icon-stub - name="link" - size="12" - /> + </a> + <a + class="diff-line-num js-line-number" + data-line-number="2" + href="#LC2" + id="L2" + > + <gl-icon-stub + name="link" + size="12" + /> + + 2 - 2 - - </a> - <a - class="diff-line-num js-line-number" - data-line-number="3" - href="#LC3" - id="L3" - > - <gl-icon-stub - name="link" - size="12" - /> + </a> + <a + class="diff-line-num js-line-number" + data-line-number="3" + href="#LC3" + id="L3" + > + <gl-icon-stub + name="link" + size="12" + /> + + 3 - 3 - - </a> - </div> - - <div - class="blob-content" - > - <pre - class="code highlight" + </a> + </div> + + <div + class="blob-content" > - <code - data-blob-hash="foo-bar" + <pre + class="code highlight" > - <span - id="LC1" + <code + data-blob-hash="foo-bar" > - First - </span> - + <span + id="LC1" + > + First + </span> + - <span - id="LC2" - > - Second - </span> - + <span + id="LC2" + > + Second + </span> + - <span - id="LC3" - > - Third - </span> - </code> - </pre> + <span + id="LC3" + > + Third + </span> + </code> + </pre> + </div> </div> </div> `; diff --git a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js index 9a0616343fe..46d4edad891 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js +++ b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js @@ -1,20 +1,31 @@ import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; import { HIGHLIGHT_CLASS_NAME } from '~/vue_shared/components/blob_viewers/constants'; import SimpleViewer from '~/vue_shared/components/blob_viewers/simple_viewer.vue'; +import EditorLite from '~/vue_shared/components/editor_lite.vue'; describe('Blob Simple Viewer component', () => { let wrapper; const contentMock = `<span id="LC1">First</span>\n<span id="LC2">Second</span>\n<span id="LC3">Third</span>`; const blobHash = 'foo-bar'; - function createComponent(content = contentMock) { + function createComponent( + content = contentMock, + isRawContent = false, + isRefactorFlagEnabled = false, + ) { wrapper = shallowMount(SimpleViewer, { provide: { blobHash, + glFeatures: { + refactorBlobViewer: isRefactorFlagEnabled, + }, }, propsData: { content, type: 'text', + fileName: 'test.js', + isRawContent, }, }); } @@ -83,4 +94,32 @@ describe('Blob Simple Viewer component', () => { }); }); }); + + describe('Vue refactoring to use Source Editor', () => { + const findEditorLite = () => wrapper.find(EditorLite); + + it.each` + doesRender | condition | isRawContent | isRefactorFlagEnabled + ${'Does not'} | ${'rawContent is not specified'} | ${false} | ${true} + ${'Does not'} | ${'feature flag is disabled is not specified'} | ${true} | ${false} + ${'Does not'} | ${'both, the FF and rawContent are not specified'} | ${false} | ${false} + ${'Does'} | ${'both, the FF and rawContent are specified'} | ${true} | ${true} + `( + '$doesRender render Editor Lite component in readonly mode when $condition', + async ({ isRawContent, isRefactorFlagEnabled } = {}) => { + createComponent('raw content', isRawContent, isRefactorFlagEnabled); + await waitForPromises(); + + if (isRawContent && isRefactorFlagEnabled) { + expect(findEditorLite().exists()).toBe(true); + + expect(findEditorLite().props('value')).toBe('raw content'); + expect(findEditorLite().props('fileName')).toBe('test.js'); + expect(findEditorLite().props('editorOptions')).toEqual({ readOnly: true }); + } else { + expect(findEditorLite().exists()).toBe(false); + } + }, + ); + }); }); diff --git a/spec/frontend/vue_shared/components/delete_label_modal_spec.js b/spec/frontend/vue_shared/components/delete_label_modal_spec.js new file mode 100644 index 00000000000..3905690dab4 --- /dev/null +++ b/spec/frontend/vue_shared/components/delete_label_modal_spec.js @@ -0,0 +1,64 @@ +import { GlModal } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import { TEST_HOST } from 'helpers/test_constants'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import DeleteLabelModal from '~/vue_shared/components/delete_label_modal.vue'; + +const MOCK_MODAL_DATA = { + labelName: 'label 1', + subjectName: 'GitLab Org', + destroyPath: `${TEST_HOST}/1`, +}; + +describe('vue_shared/components/delete_label_modal', () => { + let wrapper; + + const createComponent = () => { + wrapper = extendedWrapper( + mount(DeleteLabelModal, { + propsData: { + selector: '.js-test-btn', + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: + '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>', + }), + }, + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findModal = () => wrapper.find(GlModal); + const findPrimaryModalButton = () => wrapper.findByTestId('delete-button'); + + describe('template', () => { + describe('when modal data is set', () => { + beforeEach(() => { + createComponent(); + wrapper.vm.labelName = MOCK_MODAL_DATA.labelName; + wrapper.vm.subjectName = MOCK_MODAL_DATA.subjectName; + wrapper.vm.destroyPath = MOCK_MODAL_DATA.destroyPath; + }); + + it('renders GlModal', () => { + expect(findModal().exists()).toBe(true); + }); + + it('displays the label name and subject name', () => { + expect(findModal().text()).toContain( + `${MOCK_MODAL_DATA.labelName} will be permanently deleted from ${MOCK_MODAL_DATA.subjectName}. This cannot be undone`, + ); + }); + + it('passes the destroyPath to the button', () => { + expect(findPrimaryModalButton().attributes('href')).toBe(MOCK_MODAL_DATA.destroyPath); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/deprecated_modal_spec.js b/spec/frontend/vue_shared/components/deprecated_modal_spec.js deleted file mode 100644 index b9793ce2d80..00000000000 --- a/spec/frontend/vue_shared/components/deprecated_modal_spec.js +++ /dev/null @@ -1,73 +0,0 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; - -const modalComponent = Vue.extend(DeprecatedModal); - -describe('DeprecatedModal', () => { - let vm; - - afterEach(() => { - vm.$destroy(); - }); - - describe('props', () => { - describe('without primaryButtonLabel', () => { - beforeEach(() => { - vm = mountComponent(modalComponent, { - primaryButtonLabel: null, - }); - }); - - it('does not render a primary button', () => { - expect(vm.$el.querySelector('.js-primary-button')).toBeNull(); - }); - }); - - describe('with id', () => { - describe('does not render a primary button', () => { - beforeEach(() => { - vm = mountComponent(modalComponent, { - id: 'my-modal', - }); - }); - - it('assigns the id to the modal', () => { - expect(vm.$el.querySelector('#my-modal.modal')).not.toBeNull(); - }); - - it('does not show the modal immediately', () => { - expect(vm.$el.querySelector('#my-modal.modal')).not.toHaveClass('show'); - }); - - it('does not show a backdrop', () => { - expect(vm.$el.querySelector('modal-backdrop')).toBeNull(); - }); - }); - }); - - it('works with data-toggle="modal"', () => { - setFixtures(` - <button id="modal-button" data-toggle="modal" data-target="#my-modal"></button> - <div id="modal-container"></div> - `); - - const modalContainer = document.getElementById('modal-container'); - const modalButton = document.getElementById('modal-button'); - vm = mountComponent( - modalComponent, - { - id: 'my-modal', - }, - modalContainer, - ); - const modalElement = vm.$el.querySelector('#my-modal'); - - expect(modalElement).not.toHaveClass('show'); - - modalButton.click(); - - expect(modalElement).toHaveClass('show'); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/ensure_data_spec.js b/spec/frontend/vue_shared/components/ensure_data_spec.js new file mode 100644 index 00000000000..eef8b452f5f --- /dev/null +++ b/spec/frontend/vue_shared/components/ensure_data_spec.js @@ -0,0 +1,145 @@ +import { GlEmptyState } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { mount } from '@vue/test-utils'; +import ensureData from '~/ensure_data'; + +const mockData = { message: 'Hello there' }; +const defaultOptions = { + parseData: () => mockData, + data: mockData, +}; + +const MockChildComponent = { + inject: ['message'], + render(createElement) { + return createElement('h1', this.message); + }, +}; + +const MockParentComponent = { + components: { + MockChildComponent, + }, + props: { + message: { + type: String, + required: true, + }, + otherProp: { + type: Boolean, + default: false, + required: false, + }, + }, + render(createElement) { + return createElement('div', [this.message, createElement(MockChildComponent)]); + }, +}; + +describe('EnsureData', () => { + let wrapper; + + function findEmptyState() { + return wrapper.findComponent(GlEmptyState); + } + + function findChild() { + return wrapper.findComponent(MockChildComponent); + } + function findParent() { + return wrapper.findComponent(MockParentComponent); + } + + function createComponent(options = defaultOptions) { + return mount(ensureData(MockParentComponent, options)); + } + + beforeEach(() => { + Sentry.captureException = jest.fn(); + }); + + afterEach(() => { + wrapper.destroy(); + Sentry.captureException.mockClear(); + }); + + describe('when parseData throws', () => { + it('should render GlEmptyState', () => { + wrapper = createComponent({ + parseData: () => { + throw new Error(); + }, + }); + + expect(findParent().exists()).toBe(false); + expect(findChild().exists()).toBe(false); + expect(findEmptyState().exists()).toBe(true); + }); + + it('should not log to Sentry when shouldLog=false (default)', () => { + wrapper = createComponent({ + parseData: () => { + throw new Error(); + }, + }); + + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('should log to Sentry when shouldLog=true', () => { + const error = new Error('Error!'); + wrapper = createComponent({ + parseData: () => { + throw error; + }, + shouldLog: true, + }); + + expect(Sentry.captureException).toHaveBeenCalledWith(error); + }); + }); + + describe('when parseData succeeds', () => { + it('should render MockParentComponent and MockChildComponent', () => { + wrapper = createComponent(); + + expect(findEmptyState().exists()).toBe(false); + expect(findParent().exists()).toBe(true); + expect(findChild().exists()).toBe(true); + }); + + it('enables user to provide data to child components', () => { + wrapper = createComponent(); + + const childComponent = findChild(); + expect(childComponent.text()).toBe(mockData.message); + }); + + it('enables user to override provide data', () => { + const message = 'Another message'; + wrapper = createComponent({ ...defaultOptions, provide: { message } }); + + const childComponent = findChild(); + expect(childComponent.text()).toBe(message); + }); + + it('enables user to pass props to parent component', () => { + wrapper = createComponent(); + + expect(findParent().props()).toMatchObject(mockData); + }); + + it('enables user to override props data', () => { + const props = { message: 'Another message', otherProp: true }; + wrapper = createComponent({ ...defaultOptions, props }); + + expect(findParent().props()).toMatchObject(props); + }); + + it('should not log to Sentry when shouldLog=true', () => { + wrapper = createComponent({ ...defaultOptions, shouldLog: true }); + + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + }); +}); 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 7606b3bd91c..c24528ba4d2 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 @@ -3,6 +3,8 @@ import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue import Api from '~/api'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue'; +import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'; +import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; @@ -59,6 +61,21 @@ export const mockMilestones = [ mockEscapedMilestone, ]; +export const mockEpics = [ + { iid: 1, id: 1, title: 'Foo' }, + { iid: 2, id: 2, title: 'Bar' }, +]; + +export const mockEmoji1 = { + name: 'thumbsup', +}; + +export const mockEmoji2 = { + name: 'star', +}; + +export const mockEmojis = [mockEmoji1, mockEmoji2]; + export const mockBranchToken = { type: 'source_branch', icon: 'branch', @@ -103,6 +120,28 @@ export const mockMilestoneToken = { fetchMilestones: () => Promise.resolve({ data: mockMilestones }), }; +export const mockEpicToken = { + type: 'epic_iid', + icon: 'clock', + title: 'Epic', + unique: true, + symbol: '&', + token: EpicToken, + operators: [{ value: '=', description: 'is', default: 'true' }], + fetchEpics: () => Promise.resolve({ data: mockEpics }), + fetchSingleEpic: () => Promise.resolve({ data: mockEpics[0] }), +}; + +export const mockReactionEmojiToken = { + type: 'my_reaction_emoji', + icon: 'thumb-up', + title: 'My-Reaction', + unique: true, + token: EmojiToken, + operators: [{ value: '=', description: 'is', default: 'true' }], + fetchEmojis: () => Promise.resolve(mockEmojis), +}; + export const mockMembershipToken = { type: 'with_inherited_permissions', icon: 'group', @@ -168,6 +207,14 @@ export const tokenValuePlain = { value: { data: 'foo' }, }; +export const tokenValueEpic = { + type: 'epic_iid', + value: { + operator: '=', + data: '"foo"::&42', + }, +}; + export const mockHistoryItems = [ [tokenValueAuthor, tokenValueLabel, tokenValueMilestone, 'duo'], [tokenValueAuthor, 'si'], diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js new file mode 100644 index 00000000000..231f2f01428 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js @@ -0,0 +1,217 @@ +import { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlFilteredSearchTokenSegment, + GlDropdownDivider, +} from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import axios from '~/lib/utils/axios_utils'; + +import { + DEFAULT_LABEL_NONE, + DEFAULT_LABEL_ANY, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'; + +import { mockReactionEmojiToken, mockEmojis } from '../mock_data'; + +jest.mock('~/flash'); +const GlEmoji = { template: '<img/>' }; +const defaultStubs = { + Portal: true, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, + GlEmoji, +}; + +function createComponent(options = {}) { + const { + config = mockReactionEmojiToken, + value = { data: '' }, + active = false, + stubs = defaultStubs, + } = options; + return mount(EmojiToken, { + propsData: { + config, + value, + active, + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: 'custom-class', + }, + stubs, + }); +} + +describe('EmojiToken', () => { + let mock; + let wrapper; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + describe('computed', () => { + beforeEach(async () => { + wrapper = createComponent({ value: { data: mockEmojis[0].name } }); + + wrapper.setData({ + emojis: mockEmojis, + }); + + await wrapper.vm.$nextTick(); + }); + + describe('currentValue', () => { + it('returns lowercase string for `value.data`', () => { + expect(wrapper.vm.currentValue).toBe(mockEmojis[0].name); + }); + }); + + describe('activeEmoji', () => { + it('returns object for currently present `value.data`', () => { + expect(wrapper.vm.activeEmoji).toEqual(mockEmojis[0]); + }); + }); + }); + + describe('methods', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + describe('fetchEmojiBySearchTerm', () => { + it('calls `config.fetchEmojis` with provided searchTerm param', () => { + jest.spyOn(wrapper.vm.config, 'fetchEmojis'); + + wrapper.vm.fetchEmojiBySearchTerm('foo'); + + expect(wrapper.vm.config.fetchEmojis).toHaveBeenCalledWith('foo'); + }); + + it('sets response to `emojis` when request is successful', () => { + jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockResolvedValue(mockEmojis); + + wrapper.vm.fetchEmojiBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(wrapper.vm.emojis).toEqual(mockEmojis); + }); + }); + + it('calls `createFlash` with flash error message when request fails', () => { + jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({}); + + wrapper.vm.fetchEmojiBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(createFlash).toHaveBeenCalledWith('There was a problem fetching emojis.'); + }); + }); + + it('sets `loading` to false when request completes', () => { + jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({}); + + wrapper.vm.fetchEmojiBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(wrapper.vm.loading).toBe(false); + }); + }); + }); + }); + + describe('template', () => { + const defaultEmojis = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; + + beforeEach(async () => { + wrapper = createComponent({ + value: { data: `"${mockEmojis[0].name}"` }, + }); + + wrapper.setData({ + emojis: mockEmojis, + }); + + await wrapper.vm.$nextTick(); + }); + + it('renders gl-filtered-search-token component', () => { + expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true); + }); + + it('renders token item when value is selected', () => { + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + + expect(tokenSegments).toHaveLength(3); // My Reaction, =, "thumbsup" + expect(tokenSegments.at(2).find(GlEmoji).attributes('data-name')).toEqual('thumbsup'); + }); + + it('renders provided defaultEmojis as suggestions', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockReactionEmojiToken, defaultEmojis }, + stubs: { Portal: true, GlEmoji }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(defaultEmojis.length); + defaultEmojis.forEach((emoji, index) => { + expect(suggestions.at(index).text()).toBe(emoji.text); + }); + }); + + it('does not render divider when no defaultEmojis', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockReactionEmojiToken, defaultEmojis: [] }, + stubs: { Portal: true, GlEmoji }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlFilteredSearchSuggestion).exists()).toBe(false); + expect(wrapper.find(GlDropdownDivider).exists()).toBe(false); + }); + + it('renders `DEFAULT_LABEL_NONE` and `DEFAULT_LABEL_ANY` as default suggestions', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockReactionEmojiToken }, + stubs: { Portal: true, GlEmoji }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(2); + expect(suggestions.at(0).text()).toBe(DEFAULT_LABEL_NONE.text); + expect(suggestions.at(1).text()).toBe(DEFAULT_LABEL_ANY.text); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js new file mode 100644 index 00000000000..0c3f9e1363f --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js @@ -0,0 +1,180 @@ +import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; + +import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue'; + +import { mockEpicToken, mockEpics } from '../mock_data'; + +jest.mock('~/flash'); + +const defaultStubs = { + Portal: true, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, +}; + +function createComponent(options = {}) { + const { + config = mockEpicToken, + value = { data: '' }, + active = false, + stubs = defaultStubs, + } = options; + return mount(EpicToken, { + propsData: { + config, + value, + active, + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: 'custom-class', + }, + stubs, + }); +} + +describe('EpicToken', () => { + let mock; + let wrapper; + + beforeEach(() => { + mock = new MockAdapter(axios); + wrapper = createComponent(); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + describe('computed', () => { + beforeEach(async () => { + wrapper = createComponent({ + data: { + epics: mockEpics, + }, + }); + + await wrapper.vm.$nextTick(); + }); + + describe('currentValue', () => { + it.each` + data | id + ${`${mockEpics[0].title}::&${mockEpics[0].iid}`} | ${mockEpics[0].iid} + ${mockEpics[0].iid} | ${mockEpics[0].iid} + ${'foobar'} | ${'foobar'} + `('$data returns $id', async ({ data, id }) => { + wrapper.setProps({ value: { data } }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.currentValue).toBe(id); + }); + }); + + describe('activeEpic', () => { + it('returns object for currently present `value.data`', async () => { + wrapper.setProps({ + value: { data: `${mockEpics[0].iid}` }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.activeEpic).toEqual(mockEpics[0]); + }); + }); + }); + + describe('methods', () => { + describe('fetchEpicsBySearchTerm', () => { + it('calls `config.fetchEpics` with provided searchTerm param', () => { + jest.spyOn(wrapper.vm.config, 'fetchEpics'); + + wrapper.vm.fetchEpicsBySearchTerm('foo'); + + expect(wrapper.vm.config.fetchEpics).toHaveBeenCalledWith('foo'); + }); + + it('sets response to `epics` when request is successful', async () => { + jest.spyOn(wrapper.vm.config, 'fetchEpics').mockResolvedValue({ + data: mockEpics, + }); + + wrapper.vm.fetchEpicsBySearchTerm(); + + await waitForPromises(); + + expect(wrapper.vm.epics).toEqual(mockEpics); + }); + + it('calls `createFlash` with flash error message when request fails', async () => { + jest.spyOn(wrapper.vm.config, 'fetchEpics').mockRejectedValue({}); + + wrapper.vm.fetchEpicsBySearchTerm('foo'); + + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching epics.', + }); + }); + + it('sets `loading` to false when request completes', async () => { + jest.spyOn(wrapper.vm.config, 'fetchEpics').mockRejectedValue({}); + + wrapper.vm.fetchEpicsBySearchTerm('foo'); + + await waitForPromises(); + + expect(wrapper.vm.loading).toBe(false); + }); + }); + + describe('fetchSingleEpic', () => { + it('calls `config.fetchSingleEpic` with provided iid param', async () => { + jest.spyOn(wrapper.vm.config, 'fetchSingleEpic'); + + wrapper.vm.fetchSingleEpic(1); + + expect(wrapper.vm.config.fetchSingleEpic).toHaveBeenCalledWith(1); + + await waitForPromises(); + + expect(wrapper.vm.epics).toEqual([mockEpics[0]]); + }); + }); + }); + + describe('template', () => { + beforeEach(async () => { + wrapper = createComponent({ + value: { data: `${mockEpics[0].iid}` }, + data: { epics: mockEpics }, + }); + + await wrapper.vm.$nextTick(); + }); + + it('renders gl-filtered-search-token component', () => { + expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true); + }); + + it('renders token item when value is selected', () => { + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + + expect(tokenSegments).toHaveLength(3); + expect(tokenSegments.at(2).text()).toBe(`${mockEpics[0].title}::&${mockEpics[0].iid}`); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js index 7676ce10ce0..8528c062426 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js @@ -118,6 +118,22 @@ describe('LabelToken', () => { wrapper = createComponent(); }); + describe('getLabelName', () => { + it('returns value of `name` or `title` property present in provided label param', () => { + let mockLabel = { + title: 'foo', + }; + + expect(wrapper.vm.getLabelName(mockLabel)).toBe(mockLabel.title); + + mockLabel = { + name: 'foo', + }; + + expect(wrapper.vm.getLabelName(mockLabel)).toBe(mockLabel.name); + }); + }); + describe('fetchLabelBySearchTerm', () => { it('calls `config.fetchLabels` with provided searchTerm param', () => { jest.spyOn(wrapper.vm.config, 'fetchLabels'); diff --git a/spec/frontend/vue_shared/components/gl_toggle_vuex_spec.js b/spec/frontend/vue_shared/components/gl_toggle_vuex_spec.js deleted file mode 100644 index ac670b622b1..00000000000 --- a/spec/frontend/vue_shared/components/gl_toggle_vuex_spec.js +++ /dev/null @@ -1,114 +0,0 @@ -import { GlToggle } from '@gitlab/ui'; -import { mount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import GlToggleVuex from '~/vue_shared/components/gl_toggle_vuex.vue'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('GlToggleVuex component', () => { - let wrapper; - let store; - - const findButton = () => wrapper.find('button'); - - const createWrapper = (props = {}) => { - wrapper = mount(GlToggleVuex, { - localVue, - store, - propsData: { - stateProperty: 'toggleState', - ...props, - }, - }); - }; - - beforeEach(() => { - store = new Vuex.Store({ - state: { - toggleState: false, - }, - actions: { - setToggleState: ({ commit }, { key, value }) => commit('setToggleState', { key, value }), - }, - mutations: { - setToggleState: (state, { key, value }) => { - state[key] = value; - }, - }, - }); - createWrapper(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders gl-toggle', () => { - expect(wrapper.find(GlToggle).exists()).toBe(true); - }); - - it('properly computes default value for setAction', () => { - expect(wrapper.props('setAction')).toBe('setToggleState'); - }); - - describe('without a store module', () => { - it('calls action with new value when value changes', () => { - jest.spyOn(store, 'dispatch'); - - findButton().trigger('click'); - expect(store.dispatch).toHaveBeenCalledWith('setToggleState', { - key: 'toggleState', - value: true, - }); - }); - - it('updates store property when value changes', () => { - findButton().trigger('click'); - expect(store.state.toggleState).toBe(true); - }); - }); - - describe('with a store module', () => { - beforeEach(() => { - store = new Vuex.Store({ - modules: { - someModule: { - namespaced: true, - state: { - toggleState: false, - }, - actions: { - setToggleState: ({ commit }, { key, value }) => - commit('setToggleState', { key, value }), - }, - mutations: { - setToggleState: (state, { key, value }) => { - state[key] = value; - }, - }, - }, - }, - }); - - createWrapper({ - storeModule: 'someModule', - }); - }); - - it('calls action with new value when value changes', () => { - jest.spyOn(store, 'dispatch'); - - findButton().trigger('click'); - expect(store.dispatch).toHaveBeenCalledWith('someModule/setToggleState', { - key: 'toggleState', - value: true, - }); - }); - - it('updates store property when value changes', () => { - findButton().trigger('click'); - expect(store.state.someModule.toggleState).toBe(true); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/help_popover_spec.js b/spec/frontend/vue_shared/components/help_popover_spec.js index baf80a8a04e..30c6fa04032 100644 --- a/spec/frontend/vue_shared/components/help_popover_spec.js +++ b/spec/frontend/vue_shared/components/help_popover_spec.js @@ -27,7 +27,6 @@ describe('HelpPopover', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); it('renders a link button with an icon question', () => { @@ -35,17 +34,12 @@ describe('HelpPopover', () => { icon: 'question', variant: 'link', }); - expect(findQuestionButton().attributes().tabindex).toBe('0'); }); it('renders popover that uses the question button as target', () => { expect(findPopover().props().target()).toBe(findQuestionButton().vm.$el); }); - it('triggers popover on hover and focus', () => { - expect(findPopover().props().triggers).toBe('hover focus'); - }); - it('allows rendering title with HTML tags', () => { expect(findPopover().find('strong').exists()).toBe(true); }); @@ -54,6 +48,14 @@ describe('HelpPopover', () => { expect(findPopover().find('b').exists()).toBe(true); }); + describe('without title', () => { + it('does not render title', () => { + buildWrapper({ title: null }); + + expect(findPopover().find('span').exists()).toBe(false); + }); + }); + it('binds other popover options to the popover instance', () => { const placement = 'bottom'; diff --git a/spec/frontend/vue_shared/components/lib/utils/props_utils_spec.js b/spec/frontend/vue_shared/components/lib/utils/props_utils_spec.js new file mode 100644 index 00000000000..f1c9fbb00c9 --- /dev/null +++ b/spec/frontend/vue_shared/components/lib/utils/props_utils_spec.js @@ -0,0 +1,91 @@ +import { propsUnion } from '~/vue_shared/components/lib/utils/props_utils'; + +describe('propsUnion', () => { + const stringRequired = { + type: String, + required: true, + }; + + const stringOptional = { + type: String, + required: false, + }; + + const numberOptional = { + type: Number, + required: false, + }; + + const booleanRequired = { + type: Boolean, + required: true, + }; + + const FooComponent = { + props: { foo: stringRequired }, + }; + + const BarComponent = { + props: { bar: numberOptional }, + }; + + const FooBarComponent = { + props: { + foo: stringRequired, + bar: numberOptional, + }, + }; + + const FooOptionalComponent = { + props: { + foo: stringOptional, + }, + }; + + const QuxComponent = { + props: { + foo: booleanRequired, + qux: stringRequired, + }, + }; + + it('returns an empty object given no components', () => { + expect(propsUnion([])).toEqual({}); + }); + + it('merges non-overlapping props', () => { + expect(propsUnion([FooComponent, BarComponent])).toEqual({ + ...FooComponent.props, + ...BarComponent.props, + }); + }); + + it('merges overlapping props', () => { + expect(propsUnion([FooComponent, BarComponent, FooBarComponent])).toEqual({ + ...FooComponent.props, + ...BarComponent.props, + ...FooBarComponent.props, + }); + }); + + it.each` + components + ${[FooComponent, FooOptionalComponent]} + ${[FooOptionalComponent, FooComponent]} + `('prefers required props over non-required props', ({ components }) => { + expect(propsUnion(components)).toEqual(FooComponent.props); + }); + + it('throws if given props with conflicting types', () => { + expect(() => propsUnion([FooComponent, QuxComponent])).toThrow(/incompatible prop types/); + }); + + it.each` + components + ${[{ props: ['foo', 'bar'] }]} + ${[{ props: { foo: String, bar: Number } }]} + ${[{ props: { foo: {}, bar: {} } }]} + `('throw if given a non-verbose props object', ({ components }) => { + expect(() => propsUnion(components)).toThrow(/expected verbose prop/); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js index 5364e2d5f52..ba2450b56c9 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js @@ -1,5 +1,6 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import ApplySuggestion from '~/vue_shared/components/markdown/apply_suggestion.vue'; import SuggestionDiffHeader from '~/vue_shared/components/markdown/suggestion_diff_header.vue'; @@ -16,17 +17,14 @@ const DEFAULT_PROPS = { describe('Suggestion Diff component', () => { let wrapper; - const createComponent = (props, glFeatures = {}) => { + const createComponent = (props) => { wrapper = shallowMount(SuggestionDiffHeader, { propsData: { ...DEFAULT_PROPS, ...props, }, - provide: { - glFeatures: { - batchSuggestions: true, - ...glFeatures, - }, + directives: { + GlTooltip: createMockDirective(), }, }); }; @@ -211,18 +209,6 @@ describe('Suggestion Diff component', () => { }); }); - describe('batchSuggestions feature flag is set to false', () => { - beforeEach(() => { - createComponent({}, { batchSuggestions: false }); - }); - - it('disables add to batch buttons but keeps apply suggestion enabled', () => { - expect(findApplyButton().exists()).toBe(true); - expect(findAddToBatchButton().exists()).toBe(false); - expect(findApplyButton().attributes('disabled')).not.toBe('true'); - }); - }); - describe('canApply is set to false', () => { beforeEach(() => { createComponent({ canApply: false }); @@ -236,15 +222,23 @@ describe('Suggestion Diff component', () => { }); describe('tooltip message for apply button', () => { + const findTooltip = () => getBinding(findApplyButton().element, 'gl-tooltip'); + it('renders correct tooltip message when button is applicable', () => { createComponent(); - expect(wrapper.vm.tooltipMessage).toBe('This also resolves this thread'); + const tooltip = findTooltip(); + + expect(tooltip.modifiers.viewport).toBe(true); + expect(tooltip.value).toBe('This also resolves this thread'); }); it('renders the inapplicable reason in the tooltip when button is not applicable', () => { const inapplicableReason = 'lorem'; createComponent({ canApply: false, inapplicableReason }); - expect(wrapper.vm.tooltipMessage).toBe(inapplicableReason); + const tooltip = findTooltip(); + + expect(tooltip.modifiers.viewport).toBe(true); + expect(tooltip.value).toBe(inapplicableReason); }); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js index e7c31014bfc..eddc4033a65 100644 --- a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js +++ b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js @@ -1,35 +1,75 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import toolbar from '~/vue_shared/components/markdown/toolbar.vue'; +import { mount } from '@vue/test-utils'; +import { isExperimentVariant } from '~/experimentation/utils'; +import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; +import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants'; +import Toolbar from '~/vue_shared/components/markdown/toolbar.vue'; + +jest.mock('~/experimentation/utils', () => ({ isExperimentVariant: jest.fn() })); describe('toolbar', () => { - let vm; - const Toolbar = Vue.extend(toolbar); - const props = { - markdownDocsPath: '', + let wrapper; + + const createMountedWrapper = (props = {}) => { + wrapper = mount(Toolbar, { + propsData: { markdownDocsPath: '', ...props }, + stubs: { 'invite-members-trigger': true }, + }); }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + isExperimentVariant.mockReset(); }); describe('user can attach file', () => { beforeEach(() => { - vm = mountComponent(Toolbar, props); + createMountedWrapper(); }); it('should render uploading-container', () => { - expect(vm.$el.querySelector('.uploading-container')).not.toBeNull(); + expect(wrapper.vm.$el.querySelector('.uploading-container')).not.toBeNull(); }); }); describe('user cannot attach file', () => { beforeEach(() => { - vm = mountComponent(Toolbar, { ...props, canAttachFile: false }); + createMountedWrapper({ canAttachFile: false }); }); it('should not render uploading-container', () => { - expect(vm.$el.querySelector('.uploading-container')).toBeNull(); + expect(wrapper.vm.$el.querySelector('.uploading-container')).toBeNull(); + }); + }); + + describe('user can invite member', () => { + const findInviteLink = () => wrapper.find(InviteMembersTrigger); + + beforeEach(() => { + isExperimentVariant.mockReturnValue(true); + createMountedWrapper(); + }); + + it('should render the invite members trigger', () => { + expect(findInviteLink().exists()).toBe(true); + }); + + it('should have correct props', () => { + expect(findInviteLink().props().displayText).toBe('Invite Member'); + expect(findInviteLink().props().trackExperiment).toBe(INVITE_MEMBERS_IN_COMMENT); + expect(findInviteLink().props().triggerSource).toBe(INVITE_MEMBERS_IN_COMMENT); + }); + }); + + describe('user can not invite member', () => { + const findInviteLink = () => wrapper.find(InviteMembersTrigger); + + beforeEach(() => { + isExperimentVariant.mockReturnValue(false); + createMountedWrapper(); + }); + + it('should render the invite members trigger', () => { + expect(findInviteLink().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/vue_shared/components/recaptcha_eventhub_spec.js b/spec/frontend/vue_shared/components/recaptcha_eventhub_spec.js deleted file mode 100644 index d86d627886f..00000000000 --- a/spec/frontend/vue_shared/components/recaptcha_eventhub_spec.js +++ /dev/null @@ -1,21 +0,0 @@ -import { eventHub, callbackName } from '~/vue_shared/components/recaptcha_eventhub'; - -describe('reCAPTCHA event hub', () => { - // the following test case currently crashes - // see https://gitlab.com/gitlab-org/gitlab/issues/29192#note_217840035 - // eslint-disable-next-line jest/no-disabled-tests - it.skip('throws an error for overriding the callback', () => { - expect(() => { - window[callbackName] = 'something'; - }).toThrow(); - }); - - it('triggering callback emits a submit event', () => { - const eventHandler = jest.fn(); - eventHub.$once('submit', eventHandler); - - window[callbackName](); - - expect(eventHandler).toHaveBeenCalled(); - }); -}); diff --git a/spec/frontend/vue_shared/components/recaptcha_modal_spec.js b/spec/frontend/vue_shared/components/recaptcha_modal_spec.js deleted file mode 100644 index 8ab65efd388..00000000000 --- a/spec/frontend/vue_shared/components/recaptcha_modal_spec.js +++ /dev/null @@ -1,35 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; - -import { eventHub } from '~/vue_shared/components/recaptcha_eventhub'; - -import RecaptchaModal from '~/vue_shared/components/recaptcha_modal.vue'; - -describe('RecaptchaModal', () => { - const recaptchaFormId = 'recaptcha-form'; - const recaptchaHtml = `<form id="${recaptchaFormId}"></form>`; - - let wrapper; - - const findRecaptchaForm = () => wrapper.find(`#${recaptchaFormId}`).element; - - beforeEach(() => { - wrapper = shallowMount(RecaptchaModal, { - propsData: { - html: recaptchaHtml, - }, - }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('submits the form if event hub emits submit event', () => { - const form = findRecaptchaForm(); - jest.spyOn(form, 'submit').mockImplementation(); - - eventHub.$emit('submit'); - - expect(form.submit).toHaveBeenCalled(); - }); -}); diff --git a/spec/frontend/vue_shared/components/registry/registry_search_spec.js b/spec/frontend/vue_shared/components/registry/registry_search_spec.js index 28bdb275756..f5ef5b3d443 100644 --- a/spec/frontend/vue_shared/components/registry/registry_search_spec.js +++ b/spec/frontend/vue_shared/components/registry/registry_search_spec.js @@ -1,5 +1,6 @@ import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import component from '~/vue_shared/components/registry/registry_search.vue'; describe('Registry Search', () => { @@ -12,8 +13,18 @@ describe('Registry Search', () => { const defaultProps = { filter: [], sorting: { sort: 'asc', orderBy: 'name' }, - tokens: ['foo'], - sortableFields: [{ label: 'name', orderBy: 'name' }, { label: 'baz' }], + tokens: [{ type: 'foo' }], + sortableFields: [ + { label: 'name', orderBy: 'name' }, + { label: 'baz', orderBy: 'bar' }, + ], + }; + + const defaultQueryChangedPayload = { + foo: '', + orderBy: 'name', + search: [], + sort: 'asc', }; const mountComponent = (propsData = defaultProps) => { @@ -55,20 +66,22 @@ describe('Registry Search', () => { expect(wrapper.emitted('filter:changed')).toEqual([['foo']]); }); - it('emits filter:submit on submit event', () => { + it('emits filter:submit and query:changed on submit event', () => { mountComponent(); findFilteredSearch().vm.$emit('submit'); expect(wrapper.emitted('filter:submit')).toEqual([[]]); + expect(wrapper.emitted('query:changed')).toEqual([[defaultQueryChangedPayload]]); }); - it('emits filter:changed and filter:submit on clear event', () => { + it('emits filter:changed, filter:submit and query:changed on clear event', () => { mountComponent(); findFilteredSearch().vm.$emit('clear'); expect(wrapper.emitted('filter:changed')).toEqual([[[]]]); expect(wrapper.emitted('filter:submit')).toEqual([[]]); + expect(wrapper.emitted('query:changed')).toEqual([[defaultQueryChangedPayload]]); }); it('binds tokens prop', () => { @@ -90,15 +103,47 @@ describe('Registry Search', () => { findPackageListSorting().vm.$emit('sortDirectionChange'); expect(wrapper.emitted('sorting:changed')).toEqual([[{ sort: 'desc' }]]); + expect(wrapper.emitted('query:changed')).toEqual([ + [{ ...defaultQueryChangedPayload, sort: 'desc' }], + ]); }); it('on sort item click emits sorting:changed event ', () => { mountComponent(); - findSortingItems().at(0).vm.$emit('click'); + findSortingItems().at(1).vm.$emit('click'); expect(wrapper.emitted('sorting:changed')).toEqual([ - [{ orderBy: defaultProps.sortableFields[0].orderBy }], + [{ orderBy: defaultProps.sortableFields[1].orderBy }], + ]); + expect(wrapper.emitted('query:changed')).toEqual([ + [{ ...defaultQueryChangedPayload, orderBy: 'bar' }], + ]); + }); + }); + + describe('query string calculation', () => { + const filter = [ + { type: FILTERED_SEARCH_TERM, value: { data: 'one' } }, + { type: FILTERED_SEARCH_TERM, value: { data: 'two' } }, + { type: 'typeOne', value: { data: 'value_one' } }, + { type: 'typeTwo', value: { data: 'value_two' } }, + ]; + + it('aggregates the filter in the correct object', () => { + mountComponent({ ...defaultProps, filter }); + + findFilteredSearch().vm.$emit('submit'); + + expect(wrapper.emitted('query:changed')).toEqual([ + [ + { + ...defaultQueryChangedPayload, + search: ['one', 'two'], + typeOne: 'value_one', + typeTwo: 'value_two', + }, + ], ]); }); }); diff --git a/spec/frontend/vue_shared/components/remove_member_modal_spec.js b/spec/frontend/vue_shared/components/remove_member_modal_spec.js index 78fe6d53eee..ce9de28d53c 100644 --- a/spec/frontend/vue_shared/components/remove_member_modal_spec.js +++ b/spec/frontend/vue_shared/components/remove_member_modal_spec.js @@ -1,13 +1,25 @@ -import { GlFormCheckbox, GlModal } from '@gitlab/ui'; +import { GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; +const mockSchedules = JSON.stringify({ + schedules: [ + { + id: 1, + name: 'Schedule 1', + }, + ], + name: 'User1', +}); + describe('RemoveMemberModal', () => { const memberPath = '/gitlab-org/gitlab-test/-/project_members/90'; let wrapper; const findForm = () => wrapper.find({ ref: 'form' }); - const findGlModal = () => wrapper.find(GlModal); + const findGlModal = () => wrapper.findComponent(GlModal); + const findOnCallSchedulesList = () => wrapper.findComponent(OncallSchedulesList); afterEach(() => { wrapper.destroy(); @@ -15,26 +27,43 @@ describe('RemoveMemberModal', () => { }); describe.each` - state | isAccessRequest | actionText | checkboxTestDescription | checkboxExpected | message - ${'removing a member'} | ${'false'} | ${'Remove member'} | ${'shows a checkbox to allow removal from related issues and MRs'} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} - ${'denying an access request'} | ${'true'} | ${'Deny access request'} | ${'does not show a checkbox'} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} + state | memberType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | onCallSchedules + ${'removing a group member'} | ${'GroupMember'} | ${false} | ${'false'} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${`{}`} + ${'removing a project member'} | ${'ProjectMember'} | ${false} | ${'false'} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${mockSchedules} + ${'denying an access request'} | ${'ProjectMember'} | ${true} | ${'false'} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} | ${`{}`} + ${'revoking invite'} | ${'ProjectMember'} | ${false} | ${'true'} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'} | ${mockSchedules} `( 'when $state', - ({ actionText, isAccessRequest, message, checkboxTestDescription, checkboxExpected }) => { + ({ + actionText, + memberType, + isAccessRequest, + isInvite, + message, + removeSubMembershipsCheckboxExpected, + unassignIssuablesCheckboxExpected, + onCallSchedules, + }) => { beforeEach(() => { wrapper = shallowMount(RemoveMemberModal, { data() { return { modalData: { isAccessRequest, + isInvite, message, memberPath, + memberType, + onCallSchedules, }, }; }, }); }); + const parsedSchedules = JSON.parse(onCallSchedules); + const isPartOfOncallSchedules = Boolean(isAccessRequest && parsedSchedules.schedules?.length); + it(`has the title ${actionText}`, () => { expect(findGlModal().attributes('title')).toBe(actionText); }); @@ -47,8 +76,24 @@ describe('RemoveMemberModal', () => { expect(wrapper.find('[data-testid=modal-message]').text()).toBe(message); }); - it(`${checkboxTestDescription}`, () => { - expect(wrapper.find(GlFormCheckbox).exists()).toBe(checkboxExpected); + it(`shows ${ + removeSubMembershipsCheckboxExpected ? 'a' : 'no' + } checkbox to remove direct memberships of subgroups/projects`, () => { + expect(wrapper.find('[name=remove_sub_memberships]').exists()).toBe( + removeSubMembershipsCheckboxExpected, + ); + }); + + it(`shows ${ + unassignIssuablesCheckboxExpected ? 'a' : 'no' + } checkbox to allow removal from related issues and MRs`, () => { + expect(wrapper.find('[name=unassign_issuables]').exists()).toBe( + unassignIssuablesCheckboxExpected, + ); + }); + + it(`shows ${isPartOfOncallSchedules ? 'all' : 'no'} related on-call schedules`, () => { + expect(findOnCallSchedulesList().exists()).toBe(isPartOfOncallSchedules); }); it('submits the form when the modal is submitted', () => { 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 01f7f3d49c7..bc1545014d7 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/mock_data.js +++ b/spec/frontend/vue_shared/components/runner_instructions/mock_data.js @@ -98,9 +98,21 @@ export const mockGraphqlInstructions = { data: { runnerSetup: { installInstructions: - "# Download the binary for your system\nsudo curl -L --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64\n\n# Give it permissions to execute\nsudo chmod +x /usr/local/bin/gitlab-runner\n\n# Create a GitLab CI user\nsudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash\n\n# Install and run as service\nsudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner\nsudo gitlab-runner start\n", + '# Install and run as service\nsudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner\nsudo gitlab-runner start', registerInstructions: - 'sudo gitlab-runner register --url http://192.168.1.81:3000/ --registration-token GE5gsjeep_HAtBf9s3Yz', + 'sudo gitlab-runner register --url http://gdk.test:3000/ --registration-token $REGISTRATION_TOKEN', + __typename: 'RunnerSetup', + }, + }, +}; + +export const mockGraphqlInstructionsWindows = { + data: { + runnerSetup: { + installInstructions: + '# Windows runner, then run\n.gitlab-runner.exe install\n.gitlab-runner.exe start', + registerInstructions: + './gitlab-runner.exe register --url http://gdk.test:3000/ --registration-token $REGISTRATION_TOKEN', __typename: 'RunnerSetup', }, }, 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 new file mode 100644 index 00000000000..4033c943b82 --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js @@ -0,0 +1,184 @@ +import { GlAlert, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { nextTick } 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 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 RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; + +import { + mockGraphqlRunnerPlatforms, + mockGraphqlInstructions, + mockGraphqlInstructionsWindows, +} from './mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +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 findPlatformButtons = () => wrapper.findAllByTestId('platform-button'); + const findArchitectureDropdownItems = () => wrapper.findAllByTestId('architecture-dropdown-item'); + const findBinaryInstructions = () => wrapper.findByTestId('binary-instructions'); + const findRegisterCommand = () => wrapper.findByTestId('register-command'); + + const createComponent = () => { + const requestHandlers = [ + [getRunnerPlatformsQuery, runnerPlatformsHandler], + [getRunnerSetupInstructionsQuery, runnerSetupInstructionsHandler], + ]; + + fakeApollo = createMockApollo(requestHandlers); + + wrapper = extendedWrapper( + shallowMount(RunnerInstructionsModal, { + propsData: { + modalId: 'runner-instructions-modal', + }, + localVue, + apolloProvider: fakeApollo, + }), + ); + }; + + beforeEach(async () => { + runnerPlatformsHandler = jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms); + runnerSetupInstructionsHandler = jest.fn().mockResolvedValue(mockGraphqlInstructions); + + createComponent(); + + await nextTick(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should not show alert', () => { + expect(findAlert().exists()).toBe(false); + }); + + it('should contain a number of platforms buttons', () => { + expect(runnerPlatformsHandler).toHaveBeenCalledWith({}); + + const buttons = findPlatformButtons(); + + expect(buttons).toHaveLength(mockGraphqlRunnerPlatforms.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, registerInstructions } = mockGraphqlInstructions.data.runnerSetup; + + it('runner instructions are requested', () => { + expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({ + platform: 'linux', + architecture: 'amd64', + }); + }); + + it('binary instructions are shown', () => { + const instructions = findBinaryInstructions().text(); + + expect(instructions).toBe(installInstructions); + }); + + it('register command is shown', () => { + const instructions = findRegisterCommand().text(); + + expect(instructions).toBe(registerInstructions); + }); + }); + + describe('after a platform and architecture are selected', () => { + const { + installInstructions, + registerInstructions, + } = mockGraphqlInstructionsWindows.data.runnerSetup; + + beforeEach(async () => { + runnerSetupInstructionsHandler.mockResolvedValue(mockGraphqlInstructionsWindows); + + findPlatformButtons().at(2).vm.$emit('click'); // another option, happens to be windows + await nextTick(); + + findArchitectureDropdownItems().at(1).vm.$emit('click'); // another option + await nextTick(); + }); + + it('runner instructions are requested', () => { + expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({ + platform: 'windows', + architecture: '386', + }); + }); + + it('other binary instructions are shown', () => { + const instructions = findBinaryInstructions().text(); + + expect(instructions).toBe(installInstructions); + }); + + it('register command is shown', () => { + const command = findRegisterCommand().text(); + + expect(command).toBe(registerInstructions); + }); + }); + + describe('when apollo is loading', () => { + it('should show a skeleton loader', async () => { + createComponent(); + expect(findSkeletonLoader().exists()).toBe(true); + expect(findGlLoadingIcon().exists()).toBe(false); + + await nextTick(); // wait for platforms + + expect(findGlLoadingIcon().exists()).toBe(true); + }); + + it('once loaded, should not show a loading state', async () => { + createComponent(); + + await nextTick(); // wait for platforms + await nextTick(); // wait for architectures + + expect(findSkeletonLoader().exists()).toBe(false); + expect(findGlLoadingIcon().exists()).toBe(false); + }); + }); + + describe('when instructions cannot be loaded', () => { + beforeEach(async () => { + runnerSetupInstructionsHandler.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); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js index 48db60bfd33..23f8d6afcb5 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js +++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js @@ -1,113 +1,41 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import getRunnerPlatforms from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql'; -import getRunnerSetupInstructions from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue'; - -import { mockGraphqlRunnerPlatforms, mockGraphqlInstructions } from './mock_data'; - -const projectPath = 'gitlab-org/gitlab'; -const localVue = createLocalVue(); -localVue.use(VueApollo); +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; describe('RunnerInstructions component', () => { let wrapper; - let fakeApollo; - - const findModalButton = () => wrapper.find('[data-testid="show-modal-button"]'); - const findPlatformButtons = () => wrapper.findAll('[data-testid="platform-button"]'); - const findArchitectureDropdownItems = () => - wrapper.findAll('[data-testid="architecture-dropdown-item"]'); - const findBinaryInstructionsSection = () => wrapper.find('[data-testid="binary-instructions"]'); - const findRunnerInstructionsSection = () => wrapper.find('[data-testid="runner-instructions"]'); - beforeEach(async () => { - const requestHandlers = [ - [getRunnerPlatforms, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)], - [getRunnerSetupInstructions, jest.fn().mockResolvedValue(mockGraphqlInstructions)], - ]; + const findModalButton = () => wrapper.findByTestId('show-modal-button'); + const findModal = () => wrapper.findComponent(RunnerInstructionsModal); - fakeApollo = createMockApollo(requestHandlers); + const createComponent = () => { + wrapper = extendedWrapper(shallowMount(RunnerInstructions)); + }; - wrapper = shallowMount(RunnerInstructions, { - provide: { - projectPath, - }, - localVue, - apolloProvider: fakeApollo, - }); - - await wrapper.vm.$nextTick(); + beforeEach(() => { + createComponent(); }); afterEach(() => { wrapper.destroy(); - wrapper = null; }); it('should show the "Show Runner installation instructions" button', () => { - const button = findModalButton(); - - expect(button.exists()).toBe(true); - expect(button.text()).toBe('Show Runner installation instructions'); - }); - - it('should contain a number of platforms buttons', () => { - const buttons = findPlatformButtons(); - - expect(buttons).toHaveLength(mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes.length); - }); - - it('should contain a number of dropdown items for the architecture options', () => { - const platformButton = findPlatformButtons().at(0); - platformButton.vm.$emit('click'); - - return wrapper.vm.$nextTick(() => { - const dropdownItems = findArchitectureDropdownItems(); - - expect(dropdownItems).toHaveLength( - mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes[0].architectures.nodes.length, - ); - }); + expect(findModalButton().exists()).toBe(true); + expect(findModalButton().text()).toBe('Show Runner installation instructions'); }); - it('should display the binary installation instructions for a selected architecture', async () => { - const platformButton = findPlatformButtons().at(0); - platformButton.vm.$emit('click'); - - await wrapper.vm.$nextTick(); - - const dropdownItem = findArchitectureDropdownItems().at(0); - dropdownItem.vm.$emit('click'); - - await wrapper.vm.$nextTick(); - - const runner = findBinaryInstructionsSection(); - - expect(runner.text()).toMatch('sudo chmod +x /usr/local/bin/gitlab-runner'); - expect(runner.text()).toMatch( - `sudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash`, - ); - expect(runner.text()).toMatch( - 'sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner', - ); - expect(runner.text()).toMatch('sudo gitlab-runner start'); + it('should not render the modal once mounted', () => { + expect(findModal().exists()).toBe(false); }); - it('should display the runner register instructions for a selected architecture', async () => { - const platformButton = findPlatformButtons().at(0); - platformButton.vm.$emit('click'); - - await wrapper.vm.$nextTick(); - - const dropdownItem = findArchitectureDropdownItems().at(0); - dropdownItem.vm.$emit('click'); - - await wrapper.vm.$nextTick(); + it('should render the modal once clicked', async () => { + findModalButton().vm.$emit('click'); - const runner = findRunnerInstructionsSection(); + await nextTick(); - expect(runner.text()).toMatch(mockGraphqlInstructions.data.runnerSetup.registerInstructions); + expect(findModal().exists()).toBe(true); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/copyable_field_spec.js b/spec/frontend/vue_shared/components/sidebar/copyable_field_spec.js new file mode 100644 index 00000000000..b99b1a66b79 --- /dev/null +++ b/spec/frontend/vue_shared/components/sidebar/copyable_field_spec.js @@ -0,0 +1,74 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue'; + +describe('SidebarCopyableField', () => { + let wrapper; + + const defaultProps = { + value: 'Gl-1', + name: 'Reference', + }; + + const createComponent = (propsData = defaultProps) => { + wrapper = shallowMount(CopyableField, { + propsData, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findClipboardButton = () => wrapper.findComponent(ClipboardButton); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + describe('template', () => { + describe('when `isLoading` prop is `false`', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders copyable field', () => { + expect(wrapper.text()).toContain('Reference: Gl-1'); + }); + + it('renders ClipboardButton with correct props', () => { + const clipboardButton = findClipboardButton(); + + expect(clipboardButton.exists()).toBe(true); + expect(clipboardButton.props('title')).toBe(`Copy ${defaultProps.name}`); + expect(clipboardButton.props('text')).toBe(defaultProps.value); + }); + + it('does not render loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('when `isLoading` prop is `true`', () => { + beforeEach(() => { + createComponent({ ...defaultProps, isLoading: true }); + }); + + it('renders loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(findLoadingIcon().props('label')).toBe('Loading Reference'); + }); + + it('does not render clipboard button', () => { + expect(findClipboardButton().exists()).toBe(false); + }); + }); + + describe('with `clipboardTooltipText` prop', () => { + it('sets ClipboardButton `title` prop to `clipboardTooltipText` value', () => { + const mockClipboardTooltipText = 'Copy my custom value'; + createComponent({ ...defaultProps, clipboardTooltipText: mockClipboardTooltipText }); + + expect(findClipboardButton().props('title')).toBe(mockClipboardTooltipText); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/url_sync_spec.js b/spec/frontend/vue_shared/components/url_sync_spec.js new file mode 100644 index 00000000000..86bbc146c5f --- /dev/null +++ b/spec/frontend/vue_shared/components/url_sync_spec.js @@ -0,0 +1,97 @@ +import { shallowMount } from '@vue/test-utils'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { historyPushState } from '~/lib/utils/common_utils'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import UrlSyncComponent from '~/vue_shared/components/url_sync.vue'; + +jest.mock('~/lib/utils/url_utility', () => ({ + mergeUrlParams: jest.fn((query, url) => `urlParams: ${query} ${url}`), +})); + +jest.mock('~/lib/utils/common_utils', () => ({ + historyPushState: jest.fn(), +})); + +describe('url sync component', () => { + let wrapper; + const mockQuery = { group_id: '5014437163714', project_ids: ['5014437608314'] }; + const TEST_HOST = 'http://testhost/'; + + setWindowLocation(TEST_HOST); + + const findButton = () => wrapper.find('button'); + + const createComponent = ({ query = mockQuery, scopedSlots, slots } = {}) => { + wrapper = shallowMount(UrlSyncComponent, { + propsData: { query }, + scopedSlots, + slots, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const expectUrlSync = (query, times, mergeUrlParamsReturnValue) => { + expect(mergeUrlParams).toHaveBeenCalledTimes(times); + expect(mergeUrlParams).toHaveBeenCalledWith(query, TEST_HOST, { spreadArrays: true }); + + expect(historyPushState).toHaveBeenCalledTimes(times); + expect(historyPushState).toHaveBeenCalledWith(mergeUrlParamsReturnValue); + }; + + describe('with query as a props', () => { + it('immediately syncs the query to the URL', () => { + createComponent(); + + expectUrlSync(mockQuery, 1, mergeUrlParams.mock.results[0].value); + }); + + describe('when the query is modified', () => { + const newQuery = { foo: true }; + + it('updates the URL with the new query', async () => { + createComponent(); + // using setProps to test the watcher + await wrapper.setProps({ query: newQuery }); + + expectUrlSync(mockQuery, 2, mergeUrlParams.mock.results[1].value); + }); + }); + }); + + describe('with scoped slot', () => { + const scopedSlots = { + default: ` + <button @click="props.updateQuery({bar: 'baz'})">Update Query </button> + `, + }; + + it('renders the scoped slot', () => { + createComponent({ query: null, scopedSlots }); + + expect(findButton().exists()).toBe(true); + }); + + it('syncs the url with the scoped slots function', () => { + createComponent({ query: null, scopedSlots }); + + findButton().trigger('click'); + + expectUrlSync({ bar: 'baz' }, 1, mergeUrlParams.mock.results[0].value); + }); + }); + + describe('with slot', () => { + const slots = { + default: '<button>Normal Slot</button>', + }; + + it('renders the default slot', () => { + createComponent({ query: null, slots }); + + expect(findButton().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js index 184a1e458b5..87fe8619f28 100644 --- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js @@ -1,4 +1,4 @@ -import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlSprintf, GlIcon } from '@gitlab/ui'; +import { GlSkeletonLoader, GlSprintf, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { AVAILABILITY_STATUS } from '~/set_status_modal/utils'; import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; @@ -52,7 +52,7 @@ describe('User Popover Component', () => { }; describe('when user is loading', () => { - it('displays skeleton loaders', () => { + it('displays skeleton loader', () => { createWrapper({ user: { name: null, @@ -65,7 +65,7 @@ describe('User Popover Component', () => { }, }); - expect(wrapper.findAll(GlSkeletonLoading)).toHaveLength(4); + expect(wrapper.find(GlSkeletonLoader).exists()).toBe(true); }); }); diff --git a/spec/frontend/vue_shared/oncall_schedules_list_spec.js b/spec/frontend/vue_shared/oncall_schedules_list_spec.js new file mode 100644 index 00000000000..5c30809c09b --- /dev/null +++ b/spec/frontend/vue_shared/oncall_schedules_list_spec.js @@ -0,0 +1,87 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; + +const mockSchedules = [ + { + name: 'Schedule 1', + scheduleUrl: 'http://gitlab.com/gitlab-org/gitlab-shell/-/oncall_schedules', + projectName: 'Shell', + projectUrl: 'http://gitlab.com/gitlab-org/gitlab-shell/', + }, + { + name: 'Schedule 2', + scheduleUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/-/oncall_schedules', + projectName: 'UI', + projectUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/', + }, +]; + +const userName = 'User 1'; + +describe('On-call schedules list', () => { + let wrapper; + + function createComponent(props) { + wrapper = extendedWrapper( + shallowMount(OncallSchedulesList, { + propsData: { + schedules: mockSchedules, + userName, + ...props, + }, + stubs: { + GlSprintf, + }, + }), + ); + } + + afterEach(() => { + wrapper.destroy(); + }); + + const findLinks = () => wrapper.findAllComponents(GlLink); + const findTitle = () => wrapper.findByTestId('title'); + const findFooter = () => wrapper.findByTestId('footer'); + const findSchedules = () => wrapper.findByTestId('schedules-list'); + + describe.each` + isCurrentUser | titleText | footerText + ${true} | ${'You are currently a part of:'} | ${'Removing yourself may put your on-call team at risk of missing a notification.'} + ${false} | ${`User ${userName} is currently part of:`} | ${'Removing this user may put their on-call team at risk of missing a notification.'} + `('when current user ', ({ isCurrentUser, titleText, footerText }) => { + it(`${isCurrentUser ? 'is' : 'is not'} a part of on-call schedule`, async () => { + createComponent({ + isCurrentUser, + }); + + expect(findTitle().text()).toBe(titleText); + expect(findFooter().text()).toBe(footerText); + }); + }); + + describe.each(mockSchedules)( + 'renders each on-call schedule data', + ({ name, scheduleUrl, projectName, projectUrl }) => { + beforeEach(() => { + createComponent({ schedules: [{ name, scheduleUrl, projectName, projectUrl }] }); + }); + + it(`renders schedule ${name}'s name and link`, () => { + const msg = findSchedules().text(); + + expect(msg).toContain(`On-call schedule ${name}`); + expect(findLinks().at(0).attributes('href')).toBe(scheduleUrl); + }); + + it(`renders project ${projectName}'s name and link`, () => { + const msg = findSchedules().text(); + + expect(msg).toContain(`in Project ${projectName}`); + expect(findLinks().at(1).attributes('href')).toBe(projectUrl); + }); + }, + ); +}); |