diff options
Diffstat (limited to 'spec/frontend')
367 files changed, 9315 insertions, 7085 deletions
diff --git a/spec/frontend/__helpers__/dl_locator_helper.js b/spec/frontend/__helpers__/dl_locator_helper.js new file mode 100644 index 00000000000..b507dcd599d --- /dev/null +++ b/spec/frontend/__helpers__/dl_locator_helper.js @@ -0,0 +1,28 @@ +import { createWrapper, ErrorWrapper } from '@vue/test-utils'; + +/** + * Find the definition (<dd>) that corresponds to this term (<dt>) + * + * Given html in the `wrapper`: + * + * <dl> + * <dt>My label</dt> + * <dd>Value</dd> + * </dl> + * + * findDd('My label', wrapper) + * + * Returns `<dd>Value</dd>` + * + * @param {object} wrapper - Parent wrapper + * @param {string} dtLabel - Label for this value + * @returns Wrapper + */ +export const findDd = (dtLabel, wrapper) => { + const dt = wrapper.findByText(dtLabel).element; + const dd = dt.nextElementSibling; + if (dt.tagName === 'DT' && dd.tagName === 'DD') { + return createWrapper(dd, {}); + } + return ErrorWrapper(dtLabel); +}; diff --git a/spec/frontend/__helpers__/emoji.js b/spec/frontend/__helpers__/emoji.js index 014a7854024..6c9291bdc8f 100644 --- a/spec/frontend/__helpers__/emoji.js +++ b/spec/frontend/__helpers__/emoji.js @@ -58,6 +58,16 @@ export const validEmoji = { unicodeVersion: '6.0', description: 'because it contains multiple zero width joiners', }, + thumbsup: { + moji: '👍', + unicodeVersion: '6.0', + description: 'thumbs up sign', + }, + thumbsdown: { + moji: '👎', + description: 'thumbs down sign', + unicodeVersion: '6.0', + }, }; export const invalidEmoji = { diff --git a/spec/frontend/__helpers__/init_vue_mr_page_helper.js b/spec/frontend/__helpers__/init_vue_mr_page_helper.js index ee01e9e6268..6b719a32480 100644 --- a/spec/frontend/__helpers__/init_vue_mr_page_helper.js +++ b/spec/frontend/__helpers__/init_vue_mr_page_helper.js @@ -13,16 +13,16 @@ export default function initVueMRPage() { const diffsAppProjectPath = 'testproject'; const mrEl = document.createElement('div'); mrEl.className = 'merge-request fixture-mr'; - mrEl.setAttribute('data-mr-action', 'diffs'); + mrEl.dataset.mrAction = 'diffs'; mrTestEl.appendChild(mrEl); const mrDiscussionsEl = document.createElement('div'); mrDiscussionsEl.id = 'js-vue-mr-discussions'; - mrDiscussionsEl.setAttribute('data-current-user-data', JSON.stringify(userDataMock)); - mrDiscussionsEl.setAttribute('data-noteable-data', JSON.stringify(noteableDataMock)); - mrDiscussionsEl.setAttribute('data-notes-data', JSON.stringify(notesDataMock)); - mrDiscussionsEl.setAttribute('data-noteable-type', 'merge-request'); - mrDiscussionsEl.setAttribute('data-is-locked', 'false'); + mrDiscussionsEl.dataset.currentUserData = JSON.stringify(userDataMock); + mrDiscussionsEl.dataset.noteableData = JSON.stringify(noteableDataMock); + mrDiscussionsEl.dataset.notesData = JSON.stringify(notesDataMock); + mrDiscussionsEl.dataset.noteableType = 'merge-request'; + mrDiscussionsEl.dataset.isLocked = 'false'; mrTestEl.appendChild(mrDiscussionsEl); const discussionCounterEl = document.createElement('div'); @@ -31,9 +31,9 @@ export default function initVueMRPage() { const diffsAppEl = document.createElement('div'); diffsAppEl.id = 'js-diffs-app'; - diffsAppEl.setAttribute('data-endpoint', diffsAppEndpoint); - diffsAppEl.setAttribute('data-project-path', diffsAppProjectPath); - diffsAppEl.setAttribute('data-current-user-data', JSON.stringify(userDataMock)); + diffsAppEl.dataset.endpoint = diffsAppEndpoint; + diffsAppEl.dataset.projectPath = diffsAppProjectPath; + diffsAppEl.dataset.currentUserData = JSON.stringify(userDataMock); mrTestEl.appendChild(diffsAppEl); const mock = new MockAdapter(axios); diff --git a/spec/frontend/__helpers__/matchers/to_have_sprite_icon.js b/spec/frontend/__helpers__/matchers/to_have_sprite_icon.js index bce9d93bea8..45b9c31c4db 100644 --- a/spec/frontend/__helpers__/matchers/to_have_sprite_icon.js +++ b/spec/frontend/__helpers__/matchers/to_have_sprite_icon.js @@ -9,7 +9,7 @@ export const toHaveSpriteIcon = (element, iconName) => { const iconReferences = [].slice.apply(element.querySelectorAll('svg use')); const matchingIcon = iconReferences.find( - (reference) => reference.parentNode.getAttribute('data-testid') === `${iconName}-icon`, + (reference) => reference.parentNode.dataset.testid === `${iconName}-icon`, ); const pass = Boolean(matchingIcon); diff --git a/spec/frontend/access_tokens/components/access_token_table_app_spec.js b/spec/frontend/access_tokens/components/access_token_table_app_spec.js new file mode 100644 index 00000000000..b45abe418e4 --- /dev/null +++ b/spec/frontend/access_tokens/components/access_token_table_app_spec.js @@ -0,0 +1,241 @@ +import { GlPagination, GlTable } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import AccessTokenTableApp from '~/access_tokens/components/access_token_table_app.vue'; +import { EVENT_SUCCESS, PAGE_SIZE } from '~/access_tokens/components/constants'; +import { __, s__, sprintf } from '~/locale'; +import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; + +describe('~/access_tokens/components/access_token_table_app', () => { + let wrapper; + + const accessTokenType = 'personal access token'; + const accessTokenTypePlural = 'personal access tokens'; + const initialActiveAccessTokens = []; + const noActiveTokensMessage = 'This user has no active personal access tokens.'; + const showRole = false; + + const defaultActiveAccessTokens = [ + { + name: 'a', + scopes: ['api'], + created_at: '2021-05-01T00:00:00.000Z', + last_used_at: null, + expired: false, + expires_soon: true, + expires_at: null, + revoked: false, + revoke_path: '/-/profile/personal_access_tokens/1/revoke', + role: 'Maintainer', + }, + { + name: 'b', + scopes: ['api', 'sudo'], + created_at: '2022-04-21T00:00:00.000Z', + last_used_at: '2022-04-21T00:00:00.000Z', + expired: true, + expires_soon: false, + expires_at: new Date().toISOString(), + revoked: false, + revoke_path: '/-/profile/personal_access_tokens/2/revoke', + role: 'Maintainer', + }, + ]; + + const createComponent = (props = {}) => { + wrapper = mount(AccessTokenTableApp, { + provide: { + accessTokenType, + accessTokenTypePlural, + initialActiveAccessTokens, + noActiveTokensMessage, + showRole, + ...props, + }, + }); + }; + + const triggerSuccess = async (activeAccessTokens = defaultActiveAccessTokens) => { + wrapper + .findComponent(DomElementListener) + .vm.$emit(EVENT_SUCCESS, { detail: [{ active_access_tokens: activeAccessTokens }] }); + await nextTick(); + }; + + const findTable = () => wrapper.findComponent(GlTable); + const findHeaders = () => findTable().findAll('th > :first-child'); + const findCells = () => findTable().findAll('td'); + const findPagination = () => wrapper.findComponent(GlPagination); + + afterEach(() => { + wrapper?.destroy(); + }); + + it('should render the `GlTable` with default empty message', () => { + createComponent(); + + const cells = findCells(); + expect(cells).toHaveLength(1); + expect(cells.at(0).text()).toBe( + sprintf(__('This user has no active %{accessTokenTypePlural}.'), { accessTokenTypePlural }), + ); + }); + + it('should render the `GlTable` with custom empty message', () => { + const noTokensMessage = 'This group has no active access tokens.'; + createComponent({ noActiveTokensMessage: noTokensMessage }); + + const cells = findCells(); + expect(cells).toHaveLength(1); + expect(cells.at(0).text()).toBe(noTokensMessage); + }); + + it('should render an h5 element', () => { + createComponent(); + + expect(wrapper.find('h5').text()).toBe( + sprintf(__('Active %{accessTokenTypePlural} (%{totalAccessTokens})'), { + accessTokenTypePlural, + totalAccessTokens: initialActiveAccessTokens.length, + }), + ); + }); + + it('should render the `GlTable` component with default 6 column headers', () => { + createComponent(); + + const headers = findHeaders(); + expect(headers).toHaveLength(6); + [ + __('Token name'), + __('Scopes'), + s__('AccessTokens|Created'), + __('Last Used'), + __('Expires'), + __('Action'), + ].forEach((text, index) => { + expect(headers.at(index).text()).toBe(text); + }); + }); + + it('should render the `GlTable` component with 7 headers', () => { + createComponent({ showRole: true }); + + const headers = findHeaders(); + expect(headers).toHaveLength(7); + [ + __('Token name'), + __('Scopes'), + s__('AccessTokens|Created'), + __('Last Used'), + __('Expires'), + __('Role'), + __('Action'), + ].forEach((text, index) => { + expect(headers.at(index).text()).toBe(text); + }); + }); + + it('`Last Used` header should contain a link and an assistive message', () => { + createComponent(); + + const headers = wrapper.findAll('th'); + const lastUsed = headers.at(3); + const anchor = lastUsed.find('a'); + const assistiveElement = lastUsed.find('.gl-sr-only'); + expect(anchor.exists()).toBe(true); + expect(anchor.attributes('href')).toBe( + '/help/user/profile/personal_access_tokens.md#view-the-last-time-a-token-was-used', + ); + expect(assistiveElement.text()).toBe(s__('AccessTokens|The last time a token was used')); + }); + + it('updates the table after a success AJAX event', async () => { + createComponent({ showRole: true }); + await triggerSuccess(); + + const cells = findCells(); + expect(cells).toHaveLength(14); + + // First row + expect(cells.at(0).text()).toBe('a'); + expect(cells.at(1).text()).toBe('api'); + expect(cells.at(2).text()).not.toBe(__('Never')); + expect(cells.at(3).text()).toBe(__('Never')); + expect(cells.at(4).text()).toBe(__('Never')); + expect(cells.at(5).text()).toBe('Maintainer'); + let anchor = cells.at(6).find('a'); + expect(anchor.attributes()).toMatchObject({ + 'aria-label': __('Revoke'), + 'data-qa-selector': __('revoke_button'), + href: '/-/profile/personal_access_tokens/1/revoke', + 'data-confirm': sprintf( + __( + 'Are you sure you want to revoke this %{accessTokenType}? This action cannot be undone.', + ), + { accessTokenType }, + ), + }); + + expect(anchor.classes()).toContain('btn-danger-secondary'); + + // Second row + expect(cells.at(7).text()).toBe('b'); + expect(cells.at(8).text()).toBe('api, sudo'); + expect(cells.at(9).text()).not.toBe(__('Never')); + expect(cells.at(10).text()).not.toBe(__('Never')); + expect(cells.at(11).text()).toBe(__('Expired')); + expect(cells.at(12).text()).toBe('Maintainer'); + anchor = cells.at(13).find('a'); + expect(anchor.attributes('href')).toBe('/-/profile/personal_access_tokens/2/revoke'); + expect(anchor.classes()).toEqual(['btn', 'btn-danger', 'btn-md', 'gl-button', 'btn-icon']); + }); + + it('sorts rows alphabetically', async () => { + createComponent({ showRole: true }); + await triggerSuccess(); + + const cells = findCells(); + + // First and second rows + expect(cells.at(0).text()).toBe('a'); + expect(cells.at(7).text()).toBe('b'); + + const headers = findHeaders(); + await headers.at(0).trigger('click'); + await headers.at(0).trigger('click'); + + // First and second rows have swapped + expect(cells.at(0).text()).toBe('b'); + expect(cells.at(7).text()).toBe('a'); + }); + + it('sorts rows by date', async () => { + createComponent({ showRole: true }); + await triggerSuccess(); + + const cells = findCells(); + + // First and second rows + expect(cells.at(3).text()).toBe('Never'); + expect(cells.at(10).text()).not.toBe('Never'); + + const headers = findHeaders(); + await headers.at(3).trigger('click'); + + // First and second rows have swapped + expect(cells.at(3).text()).not.toBe('Never'); + expect(cells.at(10).text()).toBe('Never'); + }); + + it('should show the pagination component when needed', async () => { + createComponent(); + expect(findPagination().exists()).toBe(false); + + await triggerSuccess(Array(PAGE_SIZE).fill(defaultActiveAccessTokens[0])); + expect(findPagination().exists()).toBe(false); + + await triggerSuccess(Array(PAGE_SIZE + 1).fill(defaultActiveAccessTokens[0])); + expect(findPagination().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/access_tokens/components/expires_at_field_spec.js b/spec/frontend/access_tokens/components/expires_at_field_spec.js index fc8edcb573f..cb899d10ba7 100644 --- a/spec/frontend/access_tokens/components/expires_at_field_spec.js +++ b/spec/frontend/access_tokens/components/expires_at_field_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { GlDatepicker } from '@gitlab/ui'; import ExpiresAtField from '~/access_tokens/components/expires_at_field.vue'; describe('~/access_tokens/components/expires_at_field', () => { @@ -12,22 +13,40 @@ describe('~/access_tokens/components/expires_at_field', () => { }, }; - const createComponent = (propsData = defaultPropsData) => { + const findDatepicker = () => wrapper.findComponent(GlDatepicker); + + const createComponent = (props = {}) => { wrapper = shallowMount(ExpiresAtField, { - propsData, + propsData: { + ...defaultPropsData, + ...props, + }, }); }; - beforeEach(() => { - createComponent(); - }); - afterEach(() => { wrapper.destroy(); - wrapper = null; }); it('should render datepicker with input info', () => { + createComponent(); + expect(wrapper.element).toMatchSnapshot(); }); + + it('should set the date pickers minimum date', () => { + const minDate = new Date('1970-01-01'); + + createComponent({ minDate }); + + expect(findDatepicker().props('minDate')).toStrictEqual(minDate); + }); + + it('should set the date pickers maximum date', () => { + const maxDate = new Date('1970-01-01'); + + createComponent({ maxDate }); + + expect(findDatepicker().props('maxDate')).toStrictEqual(maxDate); + }); }); diff --git a/spec/frontend/access_tokens/components/new_access_token_app_spec.js b/spec/frontend/access_tokens/components/new_access_token_app_spec.js new file mode 100644 index 00000000000..9ccadbebf7a --- /dev/null +++ b/spec/frontend/access_tokens/components/new_access_token_app_spec.js @@ -0,0 +1,169 @@ +import { GlAlert } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import NewAccessTokenApp from '~/access_tokens/components/new_access_token_app.vue'; +import { EVENT_ERROR, EVENT_SUCCESS, FORM_SELECTOR } from '~/access_tokens/components/constants'; +import { createAlert, VARIANT_INFO } from '~/flash'; +import { __, sprintf } from '~/locale'; +import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; +import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; + +jest.mock('~/flash'); + +describe('~/access_tokens/components/new_access_token_app', () => { + let wrapper; + + const accessTokenType = 'personal access token'; + + const createComponent = (provide = { accessTokenType }) => { + wrapper = mountExtended(NewAccessTokenApp, { + provide, + }); + }; + + const triggerSuccess = async (newToken = 'new token') => { + wrapper.find(DomElementListener).vm.$emit(EVENT_SUCCESS, { detail: [{ new_token: newToken }] }); + await nextTick(); + }; + + const triggerError = async (errors = ['1', '2']) => { + wrapper.find(DomElementListener).vm.$emit(EVENT_ERROR, { detail: [{ errors }] }); + await nextTick(); + }; + + beforeEach(() => { + // NewAccessTokenApp observes a form element + setHTMLFixture(`<form id="${FORM_SELECTOR.slice(1)}"><input type="submit"/></form>`); + + createComponent(); + }); + + afterEach(() => { + resetHTMLFixture(); + wrapper.destroy(); + createAlert.mockClear(); + }); + + it('should render nothing', () => { + expect(wrapper.findComponent(InputCopyToggleVisibility).exists()).toBe(false); + expect(wrapper.findComponent(GlAlert).exists()).toBe(false); + }); + + describe('on success', () => { + it('should render `InputCopyToggleVisibility` component', async () => { + const newToken = '12345'; + await triggerSuccess(newToken); + + expect(wrapper.findComponent(GlAlert).exists()).toBe(false); + + const InputCopyToggleVisibilityComponent = wrapper.findComponent(InputCopyToggleVisibility); + expect(InputCopyToggleVisibilityComponent.props('value')).toBe(newToken); + expect(InputCopyToggleVisibilityComponent.props('copyButtonTitle')).toBe( + sprintf(__('Copy %{accessTokenType}'), { accessTokenType }), + ); + expect(InputCopyToggleVisibilityComponent.props('initialVisibility')).toBe(true); + expect(InputCopyToggleVisibilityComponent.attributes('label')).toBe( + sprintf(__('Your new %{accessTokenType}'), { accessTokenType }), + ); + }); + + it('input field should contain QA-related selectors', async () => { + const newToken = '12345'; + await triggerSuccess(newToken); + + expect(wrapper.findComponent(GlAlert).exists()).toBe(false); + + const inputAttributes = wrapper + .findByLabelText(sprintf(__('Your new %{accessTokenType}'), { accessTokenType })) + .attributes(); + expect(inputAttributes).toMatchObject({ + class: expect.stringContaining('qa-created-access-token'), + 'data-qa-selector': 'created_access_token_field', + }); + }); + + it('should render an info alert', async () => { + await triggerSuccess(); + + expect(createAlert).toHaveBeenCalledWith({ + message: sprintf(__('Your new %{accessTokenType} has been created.'), { + accessTokenType, + }), + variant: VARIANT_INFO, + }); + }); + + it('should reset the form', async () => { + const resetSpy = jest.spyOn(wrapper.vm.form, 'reset'); + + await triggerSuccess(); + + expect(resetSpy).toHaveBeenCalled(); + }); + }); + + describe('on error', () => { + it('should render an error alert', async () => { + await triggerError(['first', 'second']); + + expect(wrapper.findComponent(InputCopyToggleVisibility).exists()).toBe(false); + + let GlAlertComponent = wrapper.findComponent(GlAlert); + expect(GlAlertComponent.props('title')).toBe(__('The form contains the following errors:')); + expect(GlAlertComponent.props('variant')).toBe('danger'); + let itemEls = wrapper.findAll('li'); + expect(itemEls).toHaveLength(2); + expect(itemEls.at(0).text()).toBe('first'); + expect(itemEls.at(1).text()).toBe('second'); + + await triggerError(['one']); + + GlAlertComponent = wrapper.findComponent(GlAlert); + expect(GlAlertComponent.props('title')).toBe(__('The form contains the following error:')); + expect(GlAlertComponent.props('variant')).toBe('danger'); + itemEls = wrapper.findAll('li'); + expect(itemEls).toHaveLength(1); + }); + + it('the error alert should be dismissible', async () => { + await triggerError(); + + const GlAlertComponent = wrapper.findComponent(GlAlert); + expect(GlAlertComponent.exists()).toBe(true); + + GlAlertComponent.vm.$emit('dismiss'); + await nextTick(); + + expect(wrapper.findComponent(GlAlert).exists()).toBe(false); + }); + }); + + describe('before error or success', () => { + it('should scroll to the container', async () => { + const containerEl = wrapper.vm.$refs.container; + const scrollIntoViewSpy = jest.spyOn(containerEl, 'scrollIntoView'); + + await triggerSuccess(); + + expect(scrollIntoViewSpy).toHaveBeenCalledWith(false); + expect(scrollIntoViewSpy).toHaveBeenCalledTimes(1); + + await triggerError(); + + expect(scrollIntoViewSpy).toHaveBeenCalledWith(false); + expect(scrollIntoViewSpy).toHaveBeenCalledTimes(2); + }); + + it('should dismiss the info alert', async () => { + const dismissSpy = jest.fn(); + createAlert.mockReturnValue({ dismiss: dismissSpy }); + + await triggerSuccess(); + await triggerError(); + + expect(dismissSpy).toHaveBeenCalled(); + expect(dismissSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/spec/frontend/access_tokens/index_spec.js b/spec/frontend/access_tokens/index_spec.js index 1d8ac7cec25..b6119f1d167 100644 --- a/spec/frontend/access_tokens/index_spec.js +++ b/spec/frontend/access_tokens/index_spec.js @@ -1,27 +1,118 @@ +/* eslint-disable vue/require-prop-types */ +/* eslint-disable vue/one-component-per-file */ import { createWrapper } from '@vue/test-utils'; import Vue from 'vue'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import { initExpiresAtField, initProjectsField } from '~/access_tokens'; +import { + initAccessTokenTableApp, + initExpiresAtField, + initNewAccessTokenApp, + initProjectsField, + initTokensApp, +} from '~/access_tokens'; +import * as AccessTokenTableApp from '~/access_tokens/components/access_token_table_app.vue'; import * as ExpiresAtField from '~/access_tokens/components/expires_at_field.vue'; +import * as NewAccessTokenApp from '~/access_tokens/components/new_access_token_app.vue'; import * as ProjectsField from '~/access_tokens/components/projects_field.vue'; +import * as TokensApp from '~/access_tokens/components/tokens_app.vue'; +import { FEED_TOKEN, INCOMING_EMAIL_TOKEN, STATIC_OBJECT_TOKEN } from '~/access_tokens/constants'; +import { __, sprintf } from '~/locale'; describe('access tokens', () => { - const FakeComponent = Vue.component('FakeComponent', { - props: { - inputAttrs: { - type: Object, - required: true, - }, - }, - render: () => null, - }); + let wrapper; - beforeEach(() => { - window.gon = { features: { personalAccessTokensScopedToProjects: true } }; + afterEach(() => { + wrapper?.destroy(); + resetHTMLFixture(); }); - afterEach(() => { - document.body.innerHTML = ''; + describe('initAccessTokenTableApp', () => { + const accessTokenType = 'personal access token'; + const accessTokenTypePlural = 'personal access tokens'; + const initialActiveAccessTokens = [{ id: '1' }]; + + const FakeAccessTokenTableApp = Vue.component('FakeComponent', { + inject: [ + 'accessTokenType', + 'accessTokenTypePlural', + 'initialActiveAccessTokens', + 'noActiveTokensMessage', + 'showRole', + ], + props: [ + 'accessTokenType', + 'accessTokenTypePlural', + 'initialActiveAccessTokens', + 'noActiveTokensMessage', + 'showRole', + ], + render: () => null, + }); + AccessTokenTableApp.default = FakeAccessTokenTableApp; + + it('mounts the component and provides required values', () => { + setHTMLFixture( + `<div id="js-access-token-table-app" + data-access-token-type="${accessTokenType}" + data-access-token-type-plural="${accessTokenTypePlural}" + data-initial-active-access-tokens=${JSON.stringify(initialActiveAccessTokens)} + > + </div>`, + ); + + const vueInstance = initAccessTokenTableApp(); + + wrapper = createWrapper(vueInstance); + const component = wrapper.findComponent(FakeAccessTokenTableApp); + + expect(component.exists()).toBe(true); + + expect(component.props()).toMatchObject({ + // Required value + accessTokenType, + accessTokenTypePlural, + initialActiveAccessTokens, + + // Default values + noActiveTokensMessage: sprintf(__('This user has no active %{accessTokenTypePlural}.'), { + accessTokenTypePlural, + }), + showRole: false, + }); + }); + + it('mounts the component and provides all values', () => { + const noActiveTokensMessage = 'This group has no active access tokens.'; + setHTMLFixture( + `<div id="js-access-token-table-app" + data-access-token-type="${accessTokenType}" + data-access-token-type-plural="${accessTokenTypePlural}" + data-initial-active-access-tokens=${JSON.stringify(initialActiveAccessTokens)} + data-no-active-tokens-message="${noActiveTokensMessage}" + data-show-role + > + </div>`, + ); + + const vueInstance = initAccessTokenTableApp(); + + wrapper = createWrapper(vueInstance); + const component = wrapper.findComponent(FakeAccessTokenTableApp); + + expect(component.exists()).toBe(true); + expect(component.props()).toMatchObject({ + accessTokenType, + accessTokenTypePlural, + initialActiveAccessTokens, + noActiveTokensMessage, + showRole: true, + }); + }); + + it('returns `null`', () => { + expect(initNewAccessTokenApp()).toBe(null); + }); }); describe.each` @@ -30,33 +121,42 @@ describe('access tokens', () => { ${initProjectsField} | ${'js-access-tokens-projects'} | ${'projects'} | ${ProjectsField} `('$initFunction', ({ initFunction, mountSelector, fieldName, expectedComponent }) => { describe('when mount element exists', () => { + const FakeComponent = Vue.component('FakeComponent', { + props: ['inputAttrs'], + render: () => null, + }); + const nameAttribute = `access_tokens[${fieldName}]`; const idAttribute = `access_tokens_${fieldName}`; beforeEach(() => { - const mountEl = document.createElement('div'); - mountEl.classList.add(mountSelector); - - const input = document.createElement('input'); - input.setAttribute('name', nameAttribute); - input.setAttribute('data-js-name', fieldName); - input.setAttribute('id', idAttribute); - input.setAttribute('placeholder', 'Foo bar'); - input.setAttribute('value', '1,2'); + window.gon = { features: { personalAccessTokensScopedToProjects: true } }; - mountEl.appendChild(input); - - document.body.appendChild(mountEl); + setHTMLFixture( + `<div class="${mountSelector}"> + <input + name="${nameAttribute}" + data-js-name="${fieldName}" + id="${idAttribute}" + placeholder="Foo bar" + value="1,2" + /> + </div>`, + ); // Mock component so we don't have to deal with mocking Apollo // eslint-disable-next-line no-param-reassign expectedComponent.default = FakeComponent; }); + afterEach(() => { + delete window.gon; + }); + it('mounts component and sets `inputAttrs` prop', async () => { const vueInstance = await initFunction(); - const wrapper = createWrapper(vueInstance); + wrapper = createWrapper(vueInstance); const component = wrapper.findComponent(FakeComponent); expect(component.exists()).toBe(true); @@ -75,4 +175,64 @@ describe('access tokens', () => { }); }); }); + + describe('initNewAccessTokenApp', () => { + it('mounts the component and sets `accessTokenType` prop', () => { + const accessTokenType = 'personal access token'; + setHTMLFixture( + `<div id="js-new-access-token-app" data-access-token-type="${accessTokenType}"></div>`, + ); + + const FakeNewAccessTokenApp = Vue.component('FakeComponent', { + inject: ['accessTokenType'], + props: ['accessTokenType'], + render: () => null, + }); + NewAccessTokenApp.default = FakeNewAccessTokenApp; + + const vueInstance = initNewAccessTokenApp(); + + wrapper = createWrapper(vueInstance); + const component = wrapper.findComponent(FakeNewAccessTokenApp); + + expect(component.exists()).toBe(true); + expect(component.props('accessTokenType')).toEqual(accessTokenType); + }); + + it('returns `null`', () => { + expect(initNewAccessTokenApp()).toBe(null); + }); + }); + + describe('initTokensApp', () => { + it('mounts the component and provides`tokenTypes` ', () => { + const tokensData = { + [FEED_TOKEN]: FEED_TOKEN, + [INCOMING_EMAIL_TOKEN]: INCOMING_EMAIL_TOKEN, + [STATIC_OBJECT_TOKEN]: STATIC_OBJECT_TOKEN, + }; + setHTMLFixture( + `<div id="js-tokens-app" data-tokens-data=${JSON.stringify(tokensData)}></div>`, + ); + + const FakeTokensApp = Vue.component('FakeComponent', { + inject: ['tokenTypes'], + props: ['tokenTypes'], + render: () => null, + }); + TokensApp.default = FakeTokensApp; + + const vueInstance = initTokensApp(); + + wrapper = createWrapper(vueInstance); + const component = wrapper.findComponent(FakeTokensApp); + + expect(component.exists()).toBe(true); + expect(component.props('tokenTypes')).toEqual(tokensData); + }); + + it('returns `null`', () => { + expect(initNewAccessTokenApp()).toBe(null); + }); + }); }); diff --git a/spec/frontend/admin/application_settings/inactive_project_deletion/components/form_spec.js b/spec/frontend/admin/application_settings/inactive_project_deletion/components/form_spec.js new file mode 100644 index 00000000000..2db997942a7 --- /dev/null +++ b/spec/frontend/admin/application_settings/inactive_project_deletion/components/form_spec.js @@ -0,0 +1,148 @@ +import { GlFormCheckbox } from '@gitlab/ui'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +import SettingsForm from '~/admin/application_settings/inactive_project_deletion/components/form.vue'; + +describe('Form component', () => { + let wrapper; + + const findEnabledCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findProjectDeletionSettings = () => + wrapper.findByTestId('inactive-project-deletion-settings'); + const findMinSizeGroup = () => wrapper.findByTestId('min-size-group'); + const findMinSizeInputGroup = () => wrapper.findByTestId('min-size-input-group'); + const findMinSizeInput = () => wrapper.findByTestId('min-size-input'); + const findDeleteAfterMonthsGroup = () => wrapper.findByTestId('delete-after-months-group'); + const findDeleteAfterMonthsInputGroup = () => + wrapper.findByTestId('delete-after-months-input-group'); + const findDeleteAfterMonthsInput = () => wrapper.findByTestId('delete-after-months-input'); + const findSendWarningEmailAfterMonthsGroup = () => + wrapper.findByTestId('send-warning-email-after-months-group'); + const findSendWarningEmailAfterMonthsInputGroup = () => + wrapper.findByTestId('send-warning-email-after-months-input-group'); + const findSendWarningEmailAfterMonthsInput = () => + wrapper.findByTestId('send-warning-email-after-months-input'); + + const createComponent = ( + mountFn = shallowMountExtended, + propsData = { deleteInactiveProjects: true }, + ) => { + wrapper = mountFn(SettingsForm, { propsData }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Enable inactive project deletion', () => { + it('has the checkbox', () => { + createComponent(); + + expect(findEnabledCheckbox().exists()).toBe(true); + }); + + it.each([[true], [false]])( + 'when the checkbox is %s then the project deletion settings visibility is set to %s', + (visible) => { + createComponent(shallowMountExtended, { deleteInactiveProjects: visible }); + + expect(findProjectDeletionSettings().exists()).toBe(visible); + }, + ); + }); + + describe('Minimum size for deletion', () => { + beforeEach(() => { + createComponent(mountExtended); + }); + + it('has the minimum size input', () => { + expect(findMinSizeInput().exists()).toBe(true); + }); + + it('has the field description', () => { + expect(findMinSizeGroup().text()).toContain('Delete inactive projects that exceed'); + }); + + it('has the appended text on the field', () => { + expect(findMinSizeInputGroup().text()).toContain('MB'); + }); + + it.each` + value | valid + ${'0'} | ${true} + ${'250'} | ${true} + ${'-1'} | ${false} + `( + 'when the minimum size input has a value of $value, then its validity should be $valid', + async ({ value, valid }) => { + await findMinSizeInput().find('input').setValue(value); + + expect(findMinSizeGroup().classes('is-valid')).toBe(valid); + expect(findMinSizeInput().classes('is-valid')).toBe(valid); + }, + ); + }); + + describe('Delete project after', () => { + beforeEach(() => { + createComponent(mountExtended); + }); + + it('has the delete after months input', () => { + expect(findDeleteAfterMonthsInput().exists()).toBe(true); + }); + + it('has the appended text on the field', () => { + expect(findDeleteAfterMonthsInputGroup().text()).toContain('months'); + }); + + it.each` + value | valid + ${'0'} | ${false} + ${'1'} | ${false /* Less than the default send warning email months */} + ${'2'} | ${true} + `( + 'when the delete after months input has a value of $value, then its validity should be $valid', + async ({ value, valid }) => { + await findDeleteAfterMonthsInput().find('input').setValue(value); + + expect(findDeleteAfterMonthsGroup().classes('is-valid')).toBe(valid); + expect(findDeleteAfterMonthsInput().classes('is-valid')).toBe(valid); + }, + ); + }); + + describe('Send warning email', () => { + beforeEach(() => { + createComponent(mountExtended); + }); + + it('has the send warning email after months input', () => { + expect(findSendWarningEmailAfterMonthsInput().exists()).toBe(true); + }); + + it('has the field description', () => { + expect(findSendWarningEmailAfterMonthsGroup().text()).toContain( + 'Send email to maintainers after project is inactive for', + ); + }); + + it('has the appended text on the field', () => { + expect(findSendWarningEmailAfterMonthsInputGroup().text()).toContain('months'); + }); + + it.each` + value | valid + ${'2'} | ${true} + ${'0'} | ${false} + `( + 'when the minimum size input has a value of $value, then its validity should be $valid', + async ({ value, valid }) => { + await findSendWarningEmailAfterMonthsInput().find('input').setValue(value); + + expect(findSendWarningEmailAfterMonthsGroup().classes('is-valid')).toBe(valid); + expect(findSendWarningEmailAfterMonthsInput().classes('is-valid')).toBe(valid); + }, + ); + }); +}); diff --git a/spec/frontend/admin/users/index_spec.js b/spec/frontend/admin/users/index_spec.js index 06dbadd6d3d..961fa96acdd 100644 --- a/spec/frontend/admin/users/index_spec.js +++ b/spec/frontend/admin/users/index_spec.js @@ -12,8 +12,8 @@ describe('initAdminUsersApp', () => { beforeEach(() => { el = document.createElement('div'); - el.setAttribute('data-users', JSON.stringify(users)); - el.setAttribute('data-paths', JSON.stringify(paths)); + el.dataset.users = JSON.stringify(users); + el.dataset.paths = JSON.stringify(paths); wrapper = createWrapper(initAdminUsersApp(el)); }); @@ -40,8 +40,8 @@ describe('initAdminUserActions', () => { beforeEach(() => { el = document.createElement('div'); - el.setAttribute('data-user', JSON.stringify(user)); - el.setAttribute('data-paths', JSON.stringify(paths)); + el.dataset.user = JSON.stringify(user); + el.dataset.paths = JSON.stringify(paths); wrapper = createWrapper(initAdminUserActions(el)); }); diff --git a/spec/frontend/analytics/usage_trends/components/usage_counts_spec.js b/spec/frontend/analytics/usage_trends/components/usage_counts_spec.js index 703767dab47..f4cbc56be5c 100644 --- a/spec/frontend/analytics/usage_trends/components/usage_counts_spec.js +++ b/spec/frontend/analytics/usage_trends/components/usage_counts_spec.js @@ -1,4 +1,4 @@ -import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; +import { GlSkeletonLoader } from '@gitlab/ui'; import { GlSingleStat } from '@gitlab/ui/dist/charts'; import { shallowMount } from '@vue/test-utils'; import UsageCounts from '~/analytics/usage_trends/components/usage_counts.vue'; @@ -30,7 +30,7 @@ describe('UsageCounts', () => { wrapper.destroy(); }); - const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoading); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const findAllSingleStats = () => wrapper.findAllComponents(GlSingleStat); describe('while loading', () => { diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index 5f162f498c4..1f92010b771 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -2,7 +2,6 @@ import MockAdapter from 'axios-mock-adapter'; import Api, { DEFAULT_PER_PAGE } from '~/api'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; -import createFlash from '~/flash'; jest.mock('~/flash'); @@ -608,30 +607,10 @@ describe('Api', () => { }, ]); - return new Promise((resolve) => { - Api.groupProjects(groupId, query, {}, (response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - resolve(); - }); - }); - }); - - it('uses flesh on error by default', async () => { - const groupId = '123456'; - const query = 'dummy query'; - const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/projects.json`; - const flashCallback = (callCount) => { - expect(createFlash).toHaveBeenCalledTimes(callCount); - createFlash.mockClear(); - }; - - mock.onGet(expectedUrl).reply(500, null); - - const response = await Api.groupProjects(groupId, query, {}, () => {}).then(() => { - flashCallback(1); + return Api.groupProjects(groupId, query, {}).then((response) => { + expect(response.data.length).toBe(1); + expect(response.data[0].name).toBe('test'); }); - expect(response).toBeUndefined(); }); it('NOT uses flesh on error with param useCustomErrorHandler', async () => { @@ -640,7 +619,7 @@ describe('Api', () => { const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/projects.json`; mock.onGet(expectedUrl).reply(500, null); - const apiCall = Api.groupProjects(groupId, query, {}, () => {}, true); + const apiCall = Api.groupProjects(groupId, query, {}); await expect(apiCall).rejects.toThrow(); }); }); diff --git a/spec/frontend/authentication/two_factor_auth/index_spec.js b/spec/frontend/authentication/two_factor_auth/index_spec.js index 0ff9d60f409..f9a6b2df662 100644 --- a/spec/frontend/authentication/two_factor_auth/index_spec.js +++ b/spec/frontend/authentication/two_factor_auth/index_spec.js @@ -15,8 +15,8 @@ describe('initRecoveryCodes', () => { beforeEach(() => { el = document.createElement('div'); el.setAttribute('class', 'js-2fa-recovery-codes'); - el.setAttribute('data-codes', codesJsonString); - el.setAttribute('data-profile-account-path', profileAccountPath); + el.dataset.codes = codesJsonString; + el.dataset.profileAccountPath = profileAccountPath; document.body.appendChild(el); wrapper = createWrapper(initRecoveryCodes()); diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js index 5d657745615..b14bc5122b9 100644 --- a/spec/frontend/awards_handler_spec.js +++ b/spec/frontend/awards_handler_spec.js @@ -57,6 +57,18 @@ describe('AwardsHandler', () => { d: 'white question mark ornament', u: '6.0', }, + thumbsup: { + c: 'people', + e: '👍', + d: 'thumbs up sign', + u: '6.0', + }, + thumbsdown: { + c: 'people', + e: '👎', + d: 'thumbs down sign', + u: '6.0', + }, }; const openAndWaitForEmojiMenu = (sel = '.js-add-award') => { @@ -296,6 +308,23 @@ describe('AwardsHandler', () => { awardsHandler.searchEmojis('👼'); expect($('[data-name=angel]').is(':visible')).toBe(true); }); + + it('should show positive intent emoji first', async () => { + await openAndWaitForEmojiMenu(); + + awardsHandler.searchEmojis('thumb'); + + const $menu = $('.emoji-menu'); + const $thumbsUpItem = $menu.find('[data-name=thumbsup]'); + const $thumbsDownItem = $menu.find('[data-name=thumbsdown]'); + + expect($thumbsUpItem.is(':visible')).toBe(true); + expect($thumbsDownItem.is(':visible')).toBe(true); + + expect($thumbsUpItem.parents('.emoji-menu-list-item').index()).toBeLessThan( + $thumbsDownItem.parents('.emoji-menu-list-item').index(), + ); + }); }); describe('emoji menu', () => { diff --git a/spec/frontend/batch_comments/components/submit_dropdown_spec.js b/spec/frontend/batch_comments/components/submit_dropdown_spec.js new file mode 100644 index 00000000000..4f5ff797230 --- /dev/null +++ b/spec/frontend/batch_comments/components/submit_dropdown_spec.js @@ -0,0 +1,69 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import SubmitDropdown from '~/batch_comments/components/submit_dropdown.vue'; + +Vue.use(Vuex); + +let wrapper; +let publishReview; + +function factory() { + publishReview = jest.fn(); + + const store = new Vuex.Store({ + getters: { + getNotesData: () => ({ + markdownDocsPath: '/markdown/docs', + quickActionsDocsPath: '/quickactions/docs', + }), + getNoteableData: () => ({ id: 1, preview_note_path: '/preview' }), + noteableType: () => 'merge_request', + }, + modules: { + batchComments: { + namespaced: true, + actions: { + publishReview, + }, + }, + }, + }); + wrapper = mountExtended(SubmitDropdown, { + store, + }); +} + +const findCommentTextarea = () => wrapper.findByTestId('comment-textarea'); +const findSubmitButton = () => wrapper.findByTestId('submit-review-button'); +const findForm = () => wrapper.findByTestId('submit-gl-form'); + +describe('Batch comments submit dropdown', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('calls publishReview with note data', async () => { + factory(); + + findCommentTextarea().setValue('Hello world'); + + await findForm().vm.$emit('submit', { preventDefault: jest.fn() }); + + expect(publishReview).toHaveBeenCalledWith(expect.anything(), { + noteable_type: 'merge_request', + noteable_id: 1, + note: 'Hello world', + }); + }); + + it('sets submit dropdown to loading', async () => { + factory(); + + findCommentTextarea().setValue('Hello world'); + + await findForm().vm.$emit('submit', { preventDefault: jest.fn() }); + + expect(findSubmitButton().props('loading')).toBe(true); + }); +}); diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js index e9535d8cc12..172b510645d 100644 --- a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js +++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js @@ -179,6 +179,16 @@ describe('Batch comments store actions', () => { }); }); + it('calls service with notes data', () => { + jest.spyOn(axios, 'post'); + + return actions + .publishReview({ dispatch, commit, getters, rootGetters }, { note: 'test' }) + .then(() => { + expect(axios.post.mock.calls[0]).toEqual(['http://test.host', { note: 'test' }]); + }); + }); + it('dispatches error commits', () => { mock.onAny().reply(500); diff --git a/spec/frontend/behaviors/markdown/render_mermaid_spec.js b/spec/frontend/behaviors/markdown/render_mermaid_spec.js deleted file mode 100644 index 51a345cab0e..00000000000 --- a/spec/frontend/behaviors/markdown/render_mermaid_spec.js +++ /dev/null @@ -1,25 +0,0 @@ -import { initMermaid } from '~/behaviors/markdown/render_mermaid'; -import * as ColorUtils from '~/lib/utils/color_utils'; - -describe('Render mermaid diagrams for Gitlab Flavoured Markdown', () => { - it.each` - darkMode | expectedTheme - ${false} | ${'neutral'} - ${true} | ${'dark'} - `('is $darkMode $expectedTheme', async ({ darkMode, expectedTheme }) => { - jest.spyOn(ColorUtils, 'darkModeEnabled').mockImplementation(() => darkMode); - - const mermaid = { - initialize: jest.fn(), - }; - - await initMermaid(mermaid); - - expect(mermaid.initialize).toHaveBeenCalledTimes(1); - expect(mermaid.initialize).toHaveBeenCalledWith( - expect.objectContaining({ - theme: expectedTheme, - }), - ); - }); -}); diff --git a/spec/frontend/blob/blob_file_dropzone_spec.js b/spec/frontend/blob/blob_file_dropzone_spec.js deleted file mode 100644 index d6fc824258b..00000000000 --- a/spec/frontend/blob/blob_file_dropzone_spec.js +++ /dev/null @@ -1,49 +0,0 @@ -import $ from 'jquery'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import BlobFileDropzone from '~/blob/blob_file_dropzone'; - -describe('BlobFileDropzone', () => { - let dropzone; - let replaceFileButton; - - beforeEach(() => { - loadHTMLFixture('blob/show.html'); - const form = $('.js-upload-blob-form'); - // eslint-disable-next-line no-new - new BlobFileDropzone(form, 'POST'); - dropzone = $('.js-upload-blob-form .dropzone').get(0).dropzone; - dropzone.processQueue = jest.fn(); - replaceFileButton = $('#submit-all'); - }); - - afterEach(() => { - resetHTMLFixture(); - }); - - describe('submit button', () => { - it('requires file', () => { - jest.spyOn(window, 'alert').mockImplementation(() => {}); - - replaceFileButton.click(); - - expect(window.alert).toHaveBeenCalled(); - }); - - it('is disabled while uploading', () => { - jest.spyOn(window, 'alert').mockImplementation(() => {}); - - const file = new File([], 'some-file.jpg'); - const fakeEvent = $.Event('drop', { - dataTransfer: { files: [file] }, - }); - - dropzone.listeners[0].events.drop(fakeEvent); - - replaceFileButton.click(); - - expect(window.alert).not.toHaveBeenCalled(); - expect(replaceFileButton.is(':disabled')).toEqual(true); - expect(dropzone.processQueue).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap index d698ee72ea4..fdbb9bdd0d0 100644 --- a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap +++ b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap @@ -7,7 +7,7 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = ` <file-icon-stub aria-hidden="true" - cssclasses="mr-2" + cssclasses="gl-mr-3" filemode="" filename="foo/bar/dummy.md" size="16" @@ -32,7 +32,7 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = ` /> <small - class="mr-2" + class="gl-mr-3" > a lot </small> diff --git a/spec/frontend/blob/components/blob_header_default_actions_spec.js b/spec/frontend/blob/components/blob_header_default_actions_spec.js index af605b257de..aa538facae2 100644 --- a/spec/frontend/blob/components/blob_header_default_actions_spec.js +++ b/spec/frontend/blob/components/blob_header_default_actions_spec.js @@ -88,6 +88,14 @@ describe('Blob Header Default Actions', () => { expect(findCopyButton().exists()).toBe(false); expect(findViewRawButton().exists()).toBe(false); }); + + it('emits a copy event if overrideCopy is set to true', () => { + createComponent({ overrideCopy: true }); + jest.spyOn(wrapper.vm, '$emit'); + findCopyButton().vm.$emit('click'); + + expect(wrapper.vm.$emit).toHaveBeenCalledWith('copy'); + }); }); describe('view on environment button', () => { diff --git a/spec/frontend/blob/components/table_contents_spec.js b/spec/frontend/blob/components/table_contents_spec.js index 358ac31819c..2cbac809a0d 100644 --- a/spec/frontend/blob/components/table_contents_spec.js +++ b/spec/frontend/blob/components/table_contents_spec.js @@ -11,7 +11,7 @@ function createComponent() { } async function setLoaded(loaded) { - document.querySelector('.blob-viewer').setAttribute('data-loaded', loaded); + document.querySelector('.blob-viewer').dataset.loaded = loaded; await nextTick(); } @@ -53,7 +53,7 @@ describe('Markdown table of contents component', () => { it('does not show dropdown when viewing non-rich content', async () => { createComponent(); - document.querySelector('.blob-viewer').setAttribute('data-type', 'simple'); + document.querySelector('.blob-viewer').dataset.type = 'simple'; await setLoaded(true); diff --git a/spec/frontend/blob/csv/csv_viewer_spec.js b/spec/frontend/blob/csv/csv_viewer_spec.js index ff96193a20c..9364f76da5e 100644 --- a/spec/frontend/blob/csv/csv_viewer_spec.js +++ b/spec/frontend/blob/csv/csv_viewer_spec.js @@ -44,7 +44,7 @@ describe('app/assets/javascripts/blob/csv/csv_viewer.vue', () => { describe('when the CSV contains errors', () => { it('should render alert with correct props', async () => { createComponent({ csv: brokenCsv }); - await nextTick; + await nextTick(); expect(findAlert().props()).toMatchObject({ papaParseErrors: [{ code: 'UndetectableDelimiter' }], @@ -55,14 +55,14 @@ describe('app/assets/javascripts/blob/csv/csv_viewer.vue', () => { describe('when the CSV contains no errors', () => { it('should not render alert', async () => { createComponent(); - await nextTick; + await nextTick(); expect(findAlert().exists()).toBe(false); }); it('renders the CSV table with the correct attributes', async () => { createComponent(); - await nextTick; + await nextTick(); expect(findCsvTable().attributes()).toMatchObject({ 'empty-text': 'No CSV data to display.', @@ -72,7 +72,7 @@ describe('app/assets/javascripts/blob/csv/csv_viewer.vue', () => { it('renders the CSV table with the correct content', async () => { createComponent({ mountFunction: mount }); - await nextTick; + await nextTick(); expect(getAllByRole(wrapper.element, 'row', { name: /One/i })).toHaveLength(1); expect(getAllByRole(wrapper.element, 'row', { name: /Two/i })).toHaveLength(1); @@ -93,7 +93,7 @@ describe('app/assets/javascripts/blob/csv/csv_viewer.vue', () => { skipEmptyLines: true, complete: expect.any(Function), }); - await nextTick; + await nextTick(); expect(wrapper.vm.items).toEqual(validCsv.split(',')); }); }); diff --git a/spec/frontend/blob/viewer/index_spec.js b/spec/frontend/blob/viewer/index_spec.js index 5f6baf3f63d..b2559af182b 100644 --- a/spec/frontend/blob/viewer/index_spec.js +++ b/spec/frontend/blob/viewer/index_spec.js @@ -80,9 +80,9 @@ describe('Blob viewer', () => { return asyncClick() .then(() => asyncClick()) .then(() => { - expect( - document.querySelector('.blob-viewer[data-type="simple"]').getAttribute('data-loaded'), - ).toBe('true'); + expect(document.querySelector('.blob-viewer[data-type="simple"]').dataset.loaded).toBe( + 'true', + ); }); }); diff --git a/spec/frontend/boards/components/board_column_spec.js b/spec/frontend/boards/components/board_column_spec.js index f1964daa8b2..c13f7caba76 100644 --- a/spec/frontend/boards/components/board_column_spec.js +++ b/spec/frontend/boards/components/board_column_spec.js @@ -20,8 +20,6 @@ describe('Board Column Component', () => { }; const createComponent = ({ listType = ListType.backlog, collapsed = false } = {}) => { - const boardId = '1'; - const listMock = { ...listObj, listType, @@ -39,9 +37,6 @@ describe('Board Column Component', () => { disabled: false, list: listMock, }, - provide: { - boardId, - }, }); }; diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index 6a659623b53..fdc16b46167 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -1,4 +1,6 @@ import { GlModal } from '@gitlab/ui'; +import Vue from 'vue'; +import Vuex from 'vuex'; import setWindowLocation from 'helpers/set_window_location_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -8,7 +10,6 @@ import { formType } from '~/boards/constants'; import createBoardMutation from '~/boards/graphql/board_create.mutation.graphql'; import destroyBoardMutation from '~/boards/graphql/board_destroy.mutation.graphql'; import updateBoardMutation from '~/boards/graphql/board_update.mutation.graphql'; -import { createStore } from '~/boards/stores'; import { visitUrl } from '~/lib/utils/url_utility'; jest.mock('~/lib/utils/url_utility', () => ({ @@ -16,6 +17,8 @@ jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn().mockName('visitUrlMock'), })); +Vue.use(Vuex); + const currentBoard = { id: 'gid://gitlab/Board/1', name: 'test', @@ -46,11 +49,18 @@ describe('BoardForm', () => { const findDeleteConfirmation = () => wrapper.findByTestId('delete-confirmation-message'); const findInput = () => wrapper.find('#board-new-name'); - const store = createStore({ + const setBoardMock = jest.fn(); + const setErrorMock = jest.fn(); + + const store = new Vuex.Store({ getters: { isGroupBoard: () => true, isProjectBoard: () => false, }, + actions: { + setBoard: setBoardMock, + setError: setErrorMock, + }, }); const createComponent = (props, data) => { @@ -168,7 +178,7 @@ describe('BoardForm', () => { expect(mutate).not.toHaveBeenCalled(); }); - it('calls a correct GraphQL mutation and redirects to correct page from existing board', async () => { + it('calls a correct GraphQL mutation and sets board in state', async () => { createComponent({ canAdminBoard: true, currentPage: formType.new }); fillForm(); @@ -184,13 +194,12 @@ describe('BoardForm', () => { }); await waitForPromises(); - expect(visitUrl).toHaveBeenCalledWith('test-path'); + expect(setBoardMock).toHaveBeenCalledTimes(1); }); - it('shows a GlAlert if GraphQL mutation fails', async () => { + it('sets error in state if GraphQL mutation fails', async () => { mutate = jest.fn().mockRejectedValue('Houston, we have a problem'); createComponent({ canAdminBoard: true, currentPage: formType.new }); - jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); fillForm(); @@ -199,8 +208,8 @@ describe('BoardForm', () => { expect(mutate).toHaveBeenCalled(); await waitForPromises(); - expect(visitUrl).not.toHaveBeenCalled(); - expect(wrapper.vm.setError).toHaveBeenCalled(); + expect(setBoardMock).not.toHaveBeenCalled(); + expect(setErrorMock).toHaveBeenCalled(); }); }); }); @@ -256,7 +265,8 @@ describe('BoardForm', () => { }); await waitForPromises(); - expect(visitUrl).toHaveBeenCalledWith('test-path'); + expect(setBoardMock).toHaveBeenCalledTimes(1); + expect(global.window.location.href).not.toContain('?group_by=epic'); }); it('calls GraphQL mutation with correct parameters when issues are grouped by epic', async () => { @@ -282,13 +292,13 @@ describe('BoardForm', () => { }); await waitForPromises(); - expect(visitUrl).toHaveBeenCalledWith('test-path?group_by=epic'); + expect(setBoardMock).toHaveBeenCalledTimes(1); + expect(global.window.location.href).toContain('?group_by=epic'); }); - it('shows a GlAlert if GraphQL mutation fails', async () => { + it('sets error in state if GraphQL mutation fails', async () => { mutate = jest.fn().mockRejectedValue('Houston, we have a problem'); createComponent({ canAdminBoard: true, currentPage: formType.edit }); - jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); findInput().trigger('keyup.enter', { metaKey: true }); @@ -297,8 +307,8 @@ describe('BoardForm', () => { expect(mutate).toHaveBeenCalled(); await waitForPromises(); - expect(visitUrl).not.toHaveBeenCalled(); - expect(wrapper.vm.setError).toHaveBeenCalled(); + expect(setBoardMock).not.toHaveBeenCalled(); + expect(setErrorMock).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js index f60d04af4fc..d91e81fe4d0 100644 --- a/spec/frontend/boards/components/boards_selector_spec.js +++ b/spec/frontend/boards/components/boards_selector_spec.js @@ -2,11 +2,10 @@ import { GlDropdown, GlLoadingIcon, GlDropdownSectionHeader } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import Vuex from 'vuex'; +import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'spec/test_constants'; import BoardsSelector from '~/boards/components/boards_selector.vue'; import { BoardType } from '~/boards/constants'; -import groupBoardQuery from '~/boards/graphql/group_board.query.graphql'; -import projectBoardQuery from '~/boards/graphql/project_board.query.graphql'; import groupBoardsQuery from '~/boards/graphql/group_boards.query.graphql'; import projectBoardsQuery from '~/boards/graphql/project_boards.query.graphql'; import groupRecentBoardsQuery from '~/boards/graphql/group_recent_boards.query.graphql'; @@ -15,8 +14,7 @@ import defaultStore from '~/boards/stores'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { - mockGroupBoardResponse, - mockProjectBoardResponse, + mockBoard, mockGroupAllBoardsResponse, mockProjectAllBoardsResponse, mockGroupRecentBoardsResponse, @@ -49,6 +47,7 @@ describe('BoardsSelector', () => { }, state: { boardType: isGroupBoard ? 'group' : 'project', + board: mockBoard, }, }); }; @@ -65,9 +64,6 @@ describe('BoardsSelector', () => { const getLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findDropdown = () => wrapper.findComponent(GlDropdown); - const projectBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockProjectBoardResponse); - const groupBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockGroupBoardResponse); - const projectBoardsQueryHandlerSuccess = jest .fn() .mockResolvedValue(mockProjectAllBoardsResponse); @@ -92,8 +88,6 @@ describe('BoardsSelector', () => { projectRecentBoardsQueryHandler = projectRecentBoardsQueryHandlerSuccess, } = {}) => { fakeApollo = createMockApollo([ - [projectBoardQuery, projectBoardQueryHandlerSuccess], - [groupBoardQuery, groupBoardQueryHandlerSuccess], [projectBoardsQuery, projectBoardsQueryHandler], [groupBoardsQuery, groupBoardsQueryHandlerSuccess], [projectRecentBoardsQuery, projectRecentBoardsQueryHandler], @@ -133,12 +127,13 @@ describe('BoardsSelector', () => { describe('loading', () => { // we are testing loading state, so don't resolve responses until after the tests afterEach(async () => { - await nextTick(); + await waitForPromises(); }); - it('shows loading spinner', () => { + it('shows loading spinner', async () => { // Emits gl-dropdown show event to simulate the dropdown is opened at initialization time findDropdown().vm.$emit('show'); + await nextTick(); expect(getLoadingIcon().exists()).toBe(true); expect(getDropdownHeaders()).toHaveLength(0); @@ -251,23 +246,4 @@ describe('BoardsSelector', () => { expect(notCalledHandler).not.toHaveBeenCalled(); }); }); - - describe('fetching current board', () => { - it.each` - boardType | queryHandler | notCalledHandler - ${BoardType.group} | ${groupBoardQueryHandlerSuccess} | ${projectBoardQueryHandlerSuccess} - ${BoardType.project} | ${projectBoardQueryHandlerSuccess} | ${groupBoardQueryHandlerSuccess} - `('fetches $boardType board', async ({ boardType, queryHandler, notCalledHandler }) => { - createStore({ - isProjectBoard: boardType === BoardType.project, - isGroupBoard: boardType === BoardType.group, - }); - createComponent(); - - await nextTick(); - - expect(queryHandler).toHaveBeenCalled(); - expect(notCalledHandler).not.toHaveBeenCalled(); - }); - }); }); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 26ad9790840..6ec39be5d29 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -144,30 +144,6 @@ export const mockProjectRecentBoardsResponse = { }, }; -export const mockGroupBoardResponse = { - data: { - workspace: { - board: { - id: 'gid://gitlab/Board/1', - name: 'Development', - }, - __typename: 'Group', - }, - }, -}; - -export const mockProjectBoardResponse = { - data: { - workspace: { - board: { - id: 'gid://gitlab/Board/2', - name: 'Development', - }, - __typename: 'Project', - }, - }, -}; - export const mockAssigneesList = [ { id: 2, @@ -802,3 +778,15 @@ export const boardListQueryResponse = (issuesCount = 20) => ({ }, }, }); + +export const epicBoardListQueryResponse = (totalWeight = 5) => ({ + data: { + epicBoardList: { + __typename: 'EpicList', + id: 'gid://gitlab/Boards::EpicList/3', + metadata: { + totalWeight, + }, + }, + }, +}); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index eacf9db191e..e48b946ff1b 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -77,7 +77,7 @@ describe('fetchBoard', () => { }, }; - it('should commit mutation RECEIVE_BOARD_SUCCESS and dispatch setBoardConfig on success', async () => { + it('should commit mutation REQUEST_CURRENT_BOARD and dispatch setBoard on success', async () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); await testAction({ @@ -85,11 +85,10 @@ describe('fetchBoard', () => { payload, expectedMutations: [ { - type: types.RECEIVE_BOARD_SUCCESS, - payload: mockBoard, + type: types.REQUEST_CURRENT_BOARD, }, ], - expectedActions: [{ type: 'setBoardConfig', payload: mockBoard }], + expectedActions: [{ type: 'setBoard', payload: mockBoard }], }); }); @@ -101,6 +100,9 @@ describe('fetchBoard', () => { payload, expectedMutations: [ { + type: types.REQUEST_CURRENT_BOARD, + }, + { type: types.RECEIVE_BOARD_FAILURE, }, ], @@ -133,6 +135,20 @@ describe('setBoardConfig', () => { }); }); +describe('setBoard', () => { + it('dispatches setBoardConfig', () => { + return testAction({ + action: actions.setBoard, + payload: mockBoard, + expectedMutations: [{ type: types.RECEIVE_BOARD_SUCCESS, payload: mockBoard }], + expectedActions: [ + { type: 'setBoardConfig', payload: mockBoard }, + { type: 'performSearch', payload: { resetLists: true } }, + ], + }); + }); +}); + describe('setFilters', () => { it.each([ [ @@ -172,7 +188,11 @@ describe('performSearch', () => { {}, {}, [], - [{ type: 'setFilters', payload: {} }, { type: 'fetchLists' }, { type: 'resetIssues' }], + [ + { type: 'setFilters', payload: {} }, + { type: 'fetchLists', payload: { resetLists: false } }, + { type: 'resetIssues' }, + ], ); }); }); @@ -955,10 +975,6 @@ describe('fetchItemsForList', () => { state, [ { - type: types.RESET_ITEMS_FOR_LIST, - payload: listId, - }, - { type: types.REQUEST_ITEMS_FOR_LIST, payload: { listId, fetchNext: false }, }, @@ -980,10 +996,6 @@ describe('fetchItemsForList', () => { state, [ { - type: types.RESET_ITEMS_FOR_LIST, - payload: listId, - }, - { type: types.REQUEST_ITEMS_FOR_LIST, payload: { listId, fetchNext: false }, }, diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index 738737bf4b6..7d79993a0ee 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -34,6 +34,14 @@ describe('Board Store Mutations', () => { state = defaultState(); }); + describe('REQUEST_CURRENT_BOARD', () => { + it('Should set isBoardLoading state to true', () => { + mutations[types.REQUEST_CURRENT_BOARD](state); + + expect(state.isBoardLoading).toBe(true); + }); + }); + describe('RECEIVE_BOARD_SUCCESS', () => { it('Should set board to state', () => { mutations[types.RECEIVE_BOARD_SUCCESS](state, mockBoard); @@ -292,24 +300,6 @@ describe('Board Store Mutations', () => { }); }); - describe('RESET_ITEMS_FOR_LIST', () => { - it('should remove issues from boardItemsByListId state', () => { - const listId = 'gid://gitlab/List/1'; - const boardItemsByListId = { - [listId]: [mockIssue.id], - }; - - state = { - ...state, - boardItemsByListId, - }; - - mutations[types.RESET_ITEMS_FOR_LIST](state, listId); - - expect(state.boardItemsByListId[listId]).toEqual([]); - }); - }); - describe('REQUEST_ITEMS_FOR_LIST', () => { const listId = 'gid://gitlab/List/1'; const boardItemsByListId = { diff --git a/spec/frontend/cascading_settings/components/lock_popovers_spec.js b/spec/frontend/cascading_settings/components/lock_popovers_spec.js index 585e6ac505b..182e3c1c8ff 100644 --- a/spec/frontend/cascading_settings/components/lock_popovers_spec.js +++ b/spec/frontend/cascading_settings/components/lock_popovers_spec.js @@ -21,12 +21,12 @@ describe('LockPopovers', () => { }; if (lockedByApplicationSetting) { - popoverMountEl.setAttribute('data-popover-data', JSON.stringify(popoverData)); + popoverMountEl.dataset.popoverData = JSON.stringify(popoverData); } else if (lockedByAncestor) { - popoverMountEl.setAttribute( - 'data-popover-data', - JSON.stringify({ ...popoverData, ancestor_namespace: mockNamespace }), - ); + popoverMountEl.dataset.popoverData = JSON.stringify({ + ...popoverData, + ancestor_namespace: mockNamespace, + }); } document.body.appendChild(popoverMountEl); diff --git a/spec/frontend/ci_variable_list/components/ci_environments_dropdown_spec.js b/spec/frontend/ci_variable_list/components/legacy_ci_environments_dropdown_spec.js index e7e4897abfa..b3e23ba4201 100644 --- a/spec/frontend/ci_variable_list/components/ci_environments_dropdown_spec.js +++ b/spec/frontend/ci_variable_list/components/legacy_ci_environments_dropdown_spec.js @@ -2,7 +2,7 @@ import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; -import CiEnvironmentsDropdown from '~/ci_variable_list/components/ci_environments_dropdown.vue'; +import LegacyCiEnvironmentsDropdown from '~/ci_variable_list/components/legacy_ci_environments_dropdown.vue'; Vue.use(Vuex); @@ -20,7 +20,7 @@ describe('Ci environments dropdown', () => { }, }); - wrapper = mount(CiEnvironmentsDropdown, { + wrapper = mount(LegacyCiEnvironmentsDropdown, { store, propsData: { value: term, diff --git a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci_variable_list/components/legacy_ci_variable_modal_spec.js index d26378d9382..42c6501dcce 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js +++ b/spec/frontend/ci_variable_list/components/legacy_ci_variable_modal_spec.js @@ -4,7 +4,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import { mockTracking } from 'helpers/tracking_helper'; import CiEnvironmentsDropdown from '~/ci_variable_list/components/ci_environments_dropdown.vue'; -import CiVariableModal from '~/ci_variable_list/components/ci_variable_modal.vue'; +import LegacyCiVariableModal from '~/ci_variable_list/components/legacy_ci_variable_modal.vue'; import { AWS_ACCESS_KEY_ID, EVENT_LABEL, @@ -30,7 +30,7 @@ describe('Ci variable modal', () => { isGroup: options.isGroup, environmentScopeLink: '/help/environments', }); - wrapper = method(CiVariableModal, { + wrapper = method(LegacyCiVariableModal, { attachTo: document.body, stubs: { GlModal: ModalStub, @@ -42,10 +42,7 @@ describe('Ci variable modal', () => { const findCiEnvironmentsDropdown = () => wrapper.find(CiEnvironmentsDropdown); const findModal = () => wrapper.find(ModalStub); - const findAddorUpdateButton = () => - findModal() - .findAll(GlButton) - .wrappers.find((button) => button.props('variant') === 'confirm'); + const findAddorUpdateButton = () => findModal().find('[data-testid="ciUpdateOrAddVariableBtn"]'); const deleteVariableButton = () => findModal() .findAll(GlButton) diff --git a/spec/frontend/ci_variable_list/components/ci_variable_settings_spec.js b/spec/frontend/ci_variable_list/components/legacy_ci_variable_settings_spec.js index 13e417940a8..9c941f99982 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_settings_spec.js +++ b/spec/frontend/ci_variable_list/components/legacy_ci_variable_settings_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; -import CiVariableSettings from '~/ci_variable_list/components/ci_variable_settings.vue'; +import LegacyCiVariableSettings from '~/ci_variable_list/components/legacy_ci_variable_settings.vue'; import createStore from '~/ci_variable_list/store'; Vue.use(Vuex); @@ -15,7 +15,7 @@ describe('Ci variable table', () => { store = createStore(); store.state.isGroup = groupState; jest.spyOn(store, 'dispatch').mockImplementation(); - wrapper = shallowMount(CiVariableSettings, { + wrapper = shallowMount(LegacyCiVariableSettings, { store, }); }; diff --git a/spec/frontend/ci_variable_list/components/ci_variable_table_spec.js b/spec/frontend/ci_variable_list/components/legacy_ci_variable_table_spec.js index 62f9ae4eb4e..310afc8003a 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_table_spec.js +++ b/spec/frontend/ci_variable_list/components/legacy_ci_variable_table_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import CiVariableTable from '~/ci_variable_list/components/ci_variable_table.vue'; +import LegacyCiVariableTable from '~/ci_variable_list/components/legacy_ci_variable_table.vue'; import createStore from '~/ci_variable_list/store'; import mockData from '../services/mock_data'; @@ -14,7 +14,7 @@ describe('Ci variable table', () => { const createComponent = () => { store = createStore(); jest.spyOn(store, 'dispatch').mockImplementation(); - wrapper = mountExtended(CiVariableTable, { + wrapper = mountExtended(LegacyCiVariableTable, { attachTo: document.body, store, }); diff --git a/spec/frontend/clusters/agents/components/create_token_button_spec.js b/spec/frontend/clusters/agents/components/create_token_button_spec.js index b9a3a851e57..fb1a3aa2963 100644 --- a/spec/frontend/clusters/agents/components/create_token_button_spec.js +++ b/spec/frontend/clusters/agents/components/create_token_button_spec.js @@ -11,6 +11,7 @@ import { TOKEN_NAME_LIMIT, TOKEN_STATUS_ACTIVE, MAX_LIST_COUNT, + CREATE_TOKEN_MODAL, } from '~/clusters/agents/constants'; import createNewAgentToken from '~/clusters/agents/graphql/mutations/create_new_agent_token.mutation.graphql'; import getClusterAgentQuery from '~/clusters/agents/graphql/queries/get_cluster_agent.query.graphql'; @@ -231,7 +232,11 @@ describe('CreateTokenButton', () => { }); it('shows agent instructions', () => { - expect(findAgentInstructions().exists()).toBe(true); + expect(findAgentInstructions().props()).toMatchObject({ + agentName, + agentToken: 'token-secret', + modalId: CREATE_TOKEN_MODAL, + }); }); it('renders a close button', () => { diff --git a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap index 42d81900911..46ee123a12d 100644 --- a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap +++ b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap @@ -1,167 +1,44 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Remove cluster confirmation modal renders splitbutton with modal included 1`] = ` +exports[`Remove cluster confirmation modal renders buttons with modal included 1`] = ` <div - class="gl-display-flex gl-justify-content-end" + class="gl-display-flex" > - <div - class="dropdown b-dropdown gl-new-dropdown btn-group" - menu-class="dropdown-menu-large" + <button + class="btn gl-mr-3 btn-danger btn-md gl-button" + data-testid="remove-integration-and-resources-button" + type="button" > - <button - class="btn btn-danger btn-md gl-button split-content-button" - type="button" + <!----> + + <!----> + + <span + class="gl-button-text" > - <!----> - - <!----> - - <span - class="gl-new-dropdown-button-text" - > - Remove integration and resources - </span> - - <!----> - </button> - <button - aria-expanded="false" - aria-haspopup="true" - class="btn dropdown-toggle btn-danger btn-md gl-button gl-dropdown-toggle dropdown-toggle-split" - type="button" - > - <span - class="sr-only" - > - Toggle dropdown - </span> - </button> - <ul - class="dropdown-menu dropdown-menu-large" - role="menu" - tabindex="-1" + + Remove integration and resources + + </span> + </button> + + <button + class="btn btn-danger btn-md gl-button btn-danger-secondary" + data-testid="remove-integration-button" + type="button" + > + <!----> + + <!----> + + <span + class="gl-button-text" > - <div - class="gl-new-dropdown-inner" - > - <!----> - - <!----> - - <div - class="gl-new-dropdown-contents" - > - <!----> - - <li - class="gl-new-dropdown-item" - role="presentation" - > - <button - class="dropdown-item" - role="menuitem" - type="button" - > - <svg - aria-hidden="true" - class="gl-icon s16 gl-new-dropdown-item-check-icon gl-mt-3 gl-align-self-start" - data-testid="dropdown-item-checkbox" - role="img" - > - <use - href="#mobile-issue-close" - /> - </svg> - - <!----> - - <!----> - - <div - class="gl-new-dropdown-item-text-wrapper" - > - <p - class="gl-new-dropdown-item-text-primary" - > - <strong> - Remove integration and resources - </strong> - - <div> - Deletes all GitLab resources attached to this cluster during removal - </div> - </p> - - <!----> - </div> - - <!----> - </button> - </li> - - <li - class="gl-new-dropdown-divider" - role="presentation" - > - <hr - aria-orientation="horizontal" - class="dropdown-divider" - role="separator" - /> - </li> - <li - class="gl-new-dropdown-item" - role="presentation" - > - <button - class="dropdown-item" - role="menuitem" - type="button" - > - <svg - aria-hidden="true" - class="gl-icon s16 gl-new-dropdown-item-check-icon gl-visibility-hidden gl-mt-3 gl-align-self-start" - data-testid="dropdown-item-checkbox" - role="img" - > - <use - href="#mobile-issue-close" - /> - </svg> - - <!----> - - <!----> - - <div - class="gl-new-dropdown-item-text-wrapper" - > - <p - class="gl-new-dropdown-item-text-primary" - > - <strong> - Remove integration - </strong> - - <div> - Removes cluster from project but keeps associated resources - </div> - </p> - - <!----> - </div> - - <!----> - </button> - </li> - - <!----> - </div> - - <!----> - </div> - </ul> - </div> + + Remove integration + + </span> + </button> <!----> </div> diff --git a/spec/frontend/clusters/components/remove_cluster_confirmation_spec.js b/spec/frontend/clusters/components/remove_cluster_confirmation_spec.js index 173fefe6167..53683af893a 100644 --- a/spec/frontend/clusters/components/remove_cluster_confirmation_spec.js +++ b/spec/frontend/clusters/components/remove_cluster_confirmation_spec.js @@ -3,7 +3,6 @@ import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { stubComponent } from 'helpers/stub_component'; import RemoveClusterConfirmation from '~/clusters/components/remove_cluster_confirmation.vue'; -import SplitButton from '~/vue_shared/components/split_button.vue'; describe('Remove cluster confirmation modal', () => { let wrapper; @@ -24,14 +23,17 @@ describe('Remove cluster confirmation modal', () => { wrapper = null; }); - it('renders splitbutton with modal included', () => { + it('renders buttons with modal included', () => { createComponent(); expect(wrapper.element).toMatchSnapshot(); }); - describe('split button dropdown', () => { + describe('two buttons', () => { const findModal = () => wrapper.findComponent(GlModal); - const findSplitButton = () => wrapper.findComponent(SplitButton); + const findRemoveIntegrationButton = () => + wrapper.find('[data-testid="remove-integration-button"]'); + const findRemoveIntegrationAndResourcesButton = () => + wrapper.find('[data-testid="remove-integration-and-resources-button"]'); beforeEach(() => { createComponent({ @@ -41,8 +43,8 @@ describe('Remove cluster confirmation modal', () => { jest.spyOn(findModal().vm, 'show').mockReturnValue(); }); - it('opens modal with "cleanup" option', async () => { - findSplitButton().vm.$emit('remove-cluster-and-cleanup'); + it('open modal with "cleanup" option', async () => { + findRemoveIntegrationAndResourcesButton().trigger('click'); await nextTick(); @@ -53,8 +55,8 @@ describe('Remove cluster confirmation modal', () => { ); }); - it('opens modal without "cleanup" option', async () => { - findSplitButton().vm.$emit('remove-cluster'); + it('open modal without "cleanup" option', async () => { + findRemoveIntegrationButton().trigger('click'); await nextTick(); @@ -71,8 +73,8 @@ describe('Remove cluster confirmation modal', () => { }); it('renders regular button instead', () => { - expect(findSplitButton().exists()).toBe(false); - expect(wrapper.find('[data-testid="btnRemove"]').exists()).toBe(true); + expect(findRemoveIntegrationAndResourcesButton().exists()).toBe(false); + expect(findRemoveIntegrationButton().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/clusters_list/components/agent_token_spec.js b/spec/frontend/clusters_list/components/agent_token_spec.js index cdd94d33545..8d3130b45a6 100644 --- a/spec/frontend/clusters_list/components/agent_token_spec.js +++ b/spec/frontend/clusters_list/components/agent_token_spec.js @@ -7,6 +7,7 @@ import CodeBlock from '~/vue_shared/components/code_block.vue'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; const kasAddress = 'kas.example.com'; +const agentName = 'my-agent'; const agentToken = 'agent-token'; const kasVersion = '15.0.0'; const modalId = INSTALL_AGENT_MODAL_ID; @@ -26,6 +27,7 @@ describe('InstallAgentModal', () => { }; const propsData = { + agentName, agentToken, modalId, }; @@ -61,7 +63,12 @@ describe('InstallAgentModal', () => { it('renders a copy button', () => { expect(findCopyButton().props()).toMatchObject({ title: 'Copy command', - text: generateAgentRegistrationCommand(agentToken, kasAddress, kasVersion), + text: generateAgentRegistrationCommand({ + name: agentName, + token: agentToken, + version: kasVersion, + address: kasAddress, + }), modalId, }); }); @@ -71,6 +78,7 @@ describe('InstallAgentModal', () => { }); it('shows code block with agent installation command', () => { + expect(findCodeBlock().props('code')).toContain(`helm upgrade --install ${agentName}`); expect(findCodeBlock().props('code')).toContain(`--set config.token=${agentToken}`); expect(findCodeBlock().props('code')).toContain(`--set config.kasAddress=${kasAddress}`); expect(findCodeBlock().props('code')).toContain(`--set image.tag=v${kasVersion}`); diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js index 3f3f5e0daf6..c150a7f05d0 100644 --- a/spec/frontend/clusters_list/components/clusters_spec.js +++ b/spec/frontend/clusters_list/components/clusters_spec.js @@ -1,9 +1,4 @@ -import { - GlLoadingIcon, - GlPagination, - GlDeprecatedSkeletonLoading as GlSkeletonLoading, - GlTableLite, -} from '@gitlab/ui'; +import { GlLoadingIcon, GlPagination, GlSkeletonLoader, GlTableLite } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; @@ -171,7 +166,7 @@ describe('Clusters', () => { if (nodeSize) { expect(size.text()).toBe(nodeSize); } else { - expect(size.find(GlSkeletonLoading).exists()).toBe(true); + expect(size.findComponent(GlSkeletonLoader).exists()).toBe(true); } }); }); @@ -195,7 +190,7 @@ describe('Clusters', () => { const size = sizes.at(lineNumber); expect(size.text()).toContain(nodeText); - expect(size.find(GlSkeletonLoading).exists()).toBe(false); + expect(size.findComponent(GlSkeletonLoader).exists()).toBe(false); }); }); @@ -221,12 +216,12 @@ describe('Clusters', () => { describe('cluster CPU', () => { it.each` clusterCpu | lineNumber - ${''} | ${0} + ${'Loading'} | ${0} ${'1.93 (87% free)'} | ${1} ${'3.87 (86% free)'} | ${2} ${'(% free)'} | ${3} ${'(% free)'} | ${4} - ${''} | ${5} + ${'Loading'} | ${5} `('renders total cpu for each cluster', ({ clusterCpu, lineNumber }) => { const clusterCpus = findTable().findAll('td:nth-child(4)'); const cpuData = clusterCpus.at(lineNumber); @@ -238,12 +233,12 @@ describe('Clusters', () => { describe('cluster Memory', () => { it.each` clusterMemory | lineNumber - ${''} | ${0} + ${'Loading'} | ${0} ${'5.92 (78% free)'} | ${1} ${'12.86 (79% free)'} | ${2} ${'(% free)'} | ${3} ${'(% free)'} | ${4} - ${''} | ${5} + ${'Loading'} | ${5} `('renders total memory for each cluster', ({ clusterMemory, lineNumber }) => { const clusterMemories = findTable().findAll('td:nth-child(5)'); const memoryData = clusterMemories.at(lineNumber); diff --git a/spec/frontend/clusters_list/components/install_agent_modal_spec.js b/spec/frontend/clusters_list/components/install_agent_modal_spec.js index 38f653509a8..29884675b24 100644 --- a/spec/frontend/clusters_list/components/install_agent_modal_spec.js +++ b/spec/frontend/clusters_list/components/install_agent_modal_spec.js @@ -15,6 +15,7 @@ import { EVENT_ACTIONS_SELECT, MODAL_TYPE_EMPTY, MODAL_TYPE_REGISTER, + INSTALL_AGENT_MODAL_ID, } from '~/clusters_list/constants'; import getAgentsQuery from '~/clusters_list/graphql/queries/get_agents.query.graphql'; import getAgentConfigurations from '~/clusters_list/graphql/queries/agent_configurations.query.graphql'; @@ -222,7 +223,11 @@ describe('InstallAgentModal', () => { }); it('shows agent instructions', () => { - expect(findAgentInstructions().exists()).toBe(true); + expect(findAgentInstructions().props()).toMatchObject({ + agentName: 'agent-name', + agentToken: 'mock-agent-token', + modalId: INSTALL_AGENT_MODAL_ID, + }); }); describe('error creating agent', () => { diff --git a/spec/frontend/code_navigation/store/actions_spec.js b/spec/frontend/code_navigation/store/actions_spec.js index c47a9e697b6..8eee61d1342 100644 --- a/spec/frontend/code_navigation/store/actions_spec.js +++ b/spec/frontend/code_navigation/store/actions_spec.js @@ -195,8 +195,8 @@ describe('Code navigation actions', () => { it('commits SET_CURRENT_DEFINITION with LSIF data', () => { target.classList.add('js-code-navigation'); - target.setAttribute('data-line-index', '0'); - target.setAttribute('data-char-index', '0'); + target.dataset.lineIndex = '0'; + target.dataset.charIndex = '0'; return testAction( actions.showDefinition, @@ -218,8 +218,8 @@ describe('Code navigation actions', () => { it('adds hll class to target element', () => { target.classList.add('js-code-navigation'); - target.setAttribute('data-line-index', '0'); - target.setAttribute('data-char-index', '0'); + target.dataset.lineIndex = '0'; + target.dataset.charIndex = '0'; return testAction( actions.showDefinition, @@ -243,8 +243,8 @@ describe('Code navigation actions', () => { it('caches current target element', () => { target.classList.add('js-code-navigation'); - target.setAttribute('data-line-index', '0'); - target.setAttribute('data-char-index', '0'); + target.dataset.lineIndex = '0'; + target.dataset.charIndex = '0'; return testAction( actions.showDefinition, diff --git a/spec/frontend/confirm_modal_spec.js b/spec/frontend/confirm_modal_spec.js index 53991349ee5..4224fb6be2a 100644 --- a/spec/frontend/confirm_modal_spec.js +++ b/spec/frontend/confirm_modal_spec.js @@ -31,9 +31,9 @@ describe('ConfirmModal', () => { buttons.forEach((x) => { const button = document.createElement('button'); button.setAttribute('class', 'js-confirm-modal-button'); - button.setAttribute('data-path', x.path); - button.setAttribute('data-method', x.method); - button.setAttribute('data-modal-attributes', JSON.stringify(x.modalAttributes)); + button.dataset.path = x.path; + button.dataset.method = x.method; + button.dataset.modalAttributes = JSON.stringify(x.modalAttributes); button.innerHTML = 'Action'; buttonContainer.appendChild(button); }); diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap index 7abd6b422ad..b54f7cf17c8 100644 --- a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap +++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap @@ -16,15 +16,13 @@ exports[`content_editor/components/toolbar_link_button renders dropdown componen <!----> <li role=\\"presentation\\" class=\\"gl-px-3!\\"> <form tabindex=\\"-1\\" class=\\"b-dropdown-form gl-p-0\\"> - <div placeholder=\\"Link URL\\"> - <div role=\\"group\\" class=\\"input-group\\"> - <!----> - <!----> <input type=\\"text\\" placeholder=\\"Link URL\\" class=\\"form-control gl-form-input\\"> - <div class=\\"input-group-append\\"><button type=\\"button\\" class=\\"btn btn-confirm btn-md gl-button\\"> - <!----> - <!----> <span class=\\"gl-button-text\\">Apply</span></button></div> - <!----> - </div> + <div role=\\"group\\" class=\\"input-group\\" placeholder=\\"Link URL\\"> + <!----> + <!----> <input type=\\"text\\" placeholder=\\"Link URL\\" class=\\"form-control gl-form-input\\"> + <div class=\\"input-group-append\\"><button type=\\"button\\" class=\\"btn btn-confirm btn-md gl-button\\"> + <!----> + <!----> <span class=\\"gl-button-text\\">Apply</span></button></div> + <!----> </div> </form> </li> diff --git a/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js b/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js index 3a15ea45f40..646d068e795 100644 --- a/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js @@ -1,21 +1,33 @@ import { BubbleMenu } from '@tiptap/vue-2'; -import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; -import Vue from 'vue'; +import { + GlDropdown, + GlDropdownForm, + GlDropdownItem, + GlSearchBoxByType, + GlFormInput, +} from '@gitlab/ui'; +import { nextTick } from 'vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; import CodeBlockBubbleMenu from '~/content_editor/components/bubble_menus/code_block.vue'; import eventHubFactory from '~/helpers/event_hub_factory'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; +import Diagram from '~/content_editor/extensions/diagram'; import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader'; import { createTestEditor, emitEditorEvent } from '../../test_utils'; +const createFakeEvent = () => ({ preventDefault: jest.fn(), stopPropagation: jest.fn() }); + describe('content_editor/components/bubble_menus/code_block', () => { let wrapper; let tiptapEditor; + let contentEditor; let bubbleMenu; let eventHub; const buildEditor = () => { - tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] }); + tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight, Diagram] }); + contentEditor = { renderDiagram: jest.fn() }; eventHub = eventHubFactory(); }; @@ -23,8 +35,12 @@ describe('content_editor/components/bubble_menus/code_block', () => { wrapper = mountExtended(CodeBlockBubbleMenu, { provide: { tiptapEditor, + contentEditor, eventHub, }, + stubs: { + GlDropdownItem: stubComponent(GlDropdownItem), + }, }); }; @@ -36,7 +52,7 @@ describe('content_editor/components/bubble_menus/code_block', () => { checked: x.props('isChecked'), })); - beforeEach(() => { + beforeEach(async () => { buildEditor(); buildWrapper(); }); @@ -73,6 +89,15 @@ describe('content_editor/components/bubble_menus/code_block', () => { expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Javascript'); }); + it('selects diagram sytnax for mermaid', async () => { + tiptapEditor.commands.insertContent('<pre lang="mermaid">test</pre>'); + bubbleMenu = wrapper.findComponent(BubbleMenu); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Diagram (mermaid)'); + }); + it("selects Custom (syntax) if the language doesn't exist in the list", async () => { tiptapEditor.commands.insertContent('<pre lang="nomnoml">test</pre>'); bubbleMenu = wrapper.findComponent(BubbleMenu); @@ -104,22 +129,57 @@ describe('content_editor/components/bubble_menus/code_block', () => { }); }); + describe('preview button', () => { + it('does not appear for a regular code block', async () => { + tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); + + expect(wrapper.findByTestId('preview-diagram').exists()).toBe(false); + }); + + it.each` + diagramType | diagramCode + ${'mermaid'} | ${'<pre lang="mermaid">graph TD;\n A-->B;</pre>'} + ${'nomnoml'} | ${'<img data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,WzxmcmFtZT5EZWNvcmF0b3IgcGF0dGVybl0=">'} + `('toggles preview for a $diagramType diagram', async ({ diagramType, diagramCode }) => { + tiptapEditor.commands.insertContent(diagramCode); + + await nextTick(); + await wrapper.findByTestId('preview-diagram').vm.$emit('click'); + + expect(tiptapEditor.getAttributes(Diagram.name)).toEqual({ + isDiagram: true, + language: diagramType, + showPreview: false, + }); + + await wrapper.findByTestId('preview-diagram').vm.$emit('click'); + + expect(tiptapEditor.getAttributes(Diagram.name)).toEqual({ + isDiagram: true, + language: diagramType, + showPreview: true, + }); + }); + }); + describe('when opened and search is changed', () => { beforeEach(async () => { tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'js'); - await Vue.nextTick(); + await nextTick(); }); it('shows dropdown items', () => { - expect(findDropdownItemsData()).toEqual([ - { text: 'Javascript', visible: true, checked: true }, - { text: 'Java', visible: true, checked: false }, - { text: 'Javascript', visible: false, checked: false }, - { text: 'JSON', visible: true, checked: false }, - ]); + expect(findDropdownItemsData()).toEqual( + expect.arrayContaining([ + { text: 'Javascript', visible: true, checked: true }, + { text: 'Java', visible: true, checked: false }, + { text: 'Javascript', visible: false, checked: false }, + { text: 'JSON', visible: true, checked: false }, + ]), + ); }); describe('when dropdown item is clicked', () => { @@ -128,7 +188,7 @@ describe('content_editor/components/bubble_menus/code_block', () => { findDropdownItems().at(1).vm.$emit('click'); - await Vue.nextTick(); + await nextTick(); }); it('loads language', () => { @@ -152,5 +212,78 @@ describe('content_editor/components/bubble_menus/code_block', () => { expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Java'); }); }); + + describe('Create custom type', () => { + beforeEach(async () => { + tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); + + await wrapper.findComponent(GlDropdown).vm.show(); + await wrapper.findByTestId('create-custom-type').trigger('click'); + }); + + it('shows custom language input form and hides dropdown items', () => { + expect(wrapper.findComponent(GlDropdownItem).exists()).toBe(false); + expect(wrapper.findComponent(GlSearchBoxByType).exists()).toBe(false); + expect(wrapper.findComponent(GlDropdownForm).exists()).toBe(true); + }); + + describe('on clicking back', () => { + it('hides the custom language input form and shows dropdown items', async () => { + await wrapper.findByRole('button', { name: 'Go back' }).trigger('click'); + + expect(wrapper.findComponent(GlDropdownItem).exists()).toBe(true); + expect(wrapper.findComponent(GlSearchBoxByType).exists()).toBe(true); + expect(wrapper.findComponent(GlDropdownForm).exists()).toBe(false); + }); + }); + + describe('on clicking cancel', () => { + it('hides the custom language input form and shows dropdown items', async () => { + await wrapper.findByRole('button', { name: 'Cancel' }).trigger('click'); + + expect(wrapper.findComponent(GlDropdownItem).exists()).toBe(true); + expect(wrapper.findComponent(GlSearchBoxByType).exists()).toBe(true); + expect(wrapper.findComponent(GlDropdownForm).exists()).toBe(false); + }); + }); + + describe('on dropdown hide', () => { + it('hides the form', async () => { + wrapper.findComponent(GlFormInput).setValue('foobar'); + await wrapper.findComponent(GlDropdown).vm.$emit('hide'); + + expect(wrapper.findComponent(GlDropdownItem).exists()).toBe(true); + expect(wrapper.findComponent(GlSearchBoxByType).exists()).toBe(true); + expect(wrapper.findComponent(GlDropdownForm).exists()).toBe(false); + }); + }); + + describe('on clicking apply', () => { + beforeEach(async () => { + wrapper.findComponent(GlFormInput).setValue('foobar'); + await wrapper.findComponent(GlDropdownForm).vm.$emit('submit', createFakeEvent()); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + }); + + it('hides the custom language input form and shows dropdown items', async () => { + expect(wrapper.findComponent(GlDropdownItem).exists()).toBe(true); + expect(wrapper.findComponent(GlSearchBoxByType).exists()).toBe(true); + expect(wrapper.findComponent(GlDropdownForm).exists()).toBe(false); + }); + + it('updates dropdown value to the custom language type', () => { + expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Custom (foobar)'); + }); + + it('updates tiptap editor to the custom language type', () => { + expect(tiptapEditor.getAttributes(CodeBlockHighlight.name)).toEqual( + expect.objectContaining({ + language: 'foobar', + }), + ); + }); + }); + }); }); }); diff --git a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js new file mode 100644 index 00000000000..0334a18c9a1 --- /dev/null +++ b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js @@ -0,0 +1,54 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ToolbarMoreDropdown from '~/content_editor/components/toolbar_more_dropdown.vue'; +import Diagram from '~/content_editor/extensions/diagram'; +import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; +import { createTestEditor, mockChainedCommands } from '../test_utils'; + +describe('content_editor/components/toolbar_more_dropdown', () => { + let wrapper; + let tiptapEditor; + + const buildEditor = () => { + tiptapEditor = createTestEditor({ + extensions: [Diagram, HorizontalRule], + }); + }; + + const buildWrapper = (propsData = {}) => { + wrapper = mountExtended(ToolbarMoreDropdown, { + provide: { + tiptapEditor, + }, + propsData, + }); + }; + + beforeEach(() => { + buildEditor(); + buildWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each` + label | contentType | data + ${'Mermaid diagram'} | ${'diagram'} | ${{ language: 'mermaid' }} + ${'PlantUML diagram'} | ${'diagram'} | ${{ language: 'plantuml' }} + ${'Horizontal rule'} | ${'horizontalRule'} | ${undefined} + `('when option $label is clicked', ({ label, contentType, data }) => { + it(`inserts a ${contentType}`, async () => { + const commands = mockChainedCommands(tiptapEditor, ['setNode', 'focus', 'run']); + + const btn = wrapper.findByRole('menuitem', { name: label }); + await btn.trigger('click'); + + expect(commands.focus).toHaveBeenCalled(); + expect(commands.setNode).toHaveBeenCalledWith(contentType, data); + expect(commands.run).toHaveBeenCalled(); + + expect(wrapper.emitted('execute')).toEqual([[{ contentType }]]); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js index ec58877470c..d98a9a52aff 100644 --- a/spec/frontend/content_editor/components/top_toolbar_spec.js +++ b/spec/frontend/content_editor/components/top_toolbar_spec.js @@ -23,20 +23,21 @@ describe('content_editor/components/top_toolbar', () => { }); describe.each` - testId | controlProps - ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }} - ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }} - ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike' }} - ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }} - ${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }} - ${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }} - ${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }} - ${'details'} | ${{ contentType: 'details', iconName: 'details-block', label: 'Add a collapsible section', editorCommand: 'toggleDetails' }} - ${'horizontal-rule'} | ${{ contentType: 'horizontalRule', iconName: 'dash', label: 'Add a horizontal rule', editorCommand: 'setHorizontalRule' }} - ${'code-block'} | ${{ contentType: 'codeBlock', iconName: 'doc-code', label: 'Insert a code block', editorCommand: 'toggleCodeBlock' }} - ${'text-styles'} | ${{}} - ${'link'} | ${{}} - ${'image'} | ${{}} + testId | controlProps + ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }} + ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }} + ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike' }} + ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }} + ${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }} + ${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }} + ${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }} + ${'details'} | ${{ contentType: 'details', iconName: 'details-block', label: 'Add a collapsible section', editorCommand: 'toggleDetails' }} + ${'code-block'} | ${{ contentType: 'codeBlock', iconName: 'doc-code', label: 'Insert a code block', editorCommand: 'toggleCodeBlock' }} + ${'text-styles'} | ${{}} + ${'link'} | ${{}} + ${'image'} | ${{}} + ${'table'} | ${{}} + ${'more'} | ${{}} `('given a $testId toolbar control', ({ testId, controlProps }) => { beforeEach(() => { buildWrapper(); diff --git a/spec/frontend/content_editor/components/wrappers/code_block_spec.js b/spec/frontend/content_editor/components/wrappers/code_block_spec.js index a564959a3a6..17a365e12bb 100644 --- a/spec/frontend/content_editor/components/wrappers/code_block_spec.js +++ b/spec/frontend/content_editor/components/wrappers/code_block_spec.js @@ -1,8 +1,14 @@ import { nextTick } from 'vue'; import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; -import { shallowMount } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; +import eventHubFactory from '~/helpers/event_hub_factory'; +import SandboxedMermaid from '~/behaviors/components/sandboxed_mermaid.vue'; +import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; +import Diagram from '~/content_editor/extensions/diagram'; import CodeBlockWrapper from '~/content_editor/components/wrappers/code_block.vue'; import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader'; +import { emitEditorEvent, createTestEditor } from '../../test_utils'; jest.mock('~/content_editor/services/code_block_language_loader'); @@ -10,22 +16,43 @@ describe('content/components/wrappers/code_block', () => { const language = 'yaml'; let wrapper; let updateAttributesFn; + let tiptapEditor; + let contentEditor; + let eventHub; + + const buildEditor = () => { + tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight, Diagram] }); + contentEditor = { renderDiagram: jest.fn().mockResolvedValue('url/to/some/diagram') }; + eventHub = eventHubFactory(); + }; const createWrapper = async (nodeAttrs = { language }) => { updateAttributesFn = jest.fn(); - wrapper = shallowMount(CodeBlockWrapper, { + wrapper = mountExtended(CodeBlockWrapper, { propsData: { + editor: tiptapEditor, node: { attrs: nodeAttrs, }, updateAttributes: updateAttributesFn, }, + stubs: { + NodeViewContent: stubComponent(NodeViewContent), + NodeViewWrapper: stubComponent(NodeViewWrapper), + }, + provide: { + contentEditor, + tiptapEditor, + eventHub, + }, }); }; beforeEach(() => { - codeBlockLanguageLoader.findLanguageBySyntax.mockReturnValue({ syntax: language }); + buildEditor(); + + codeBlockLanguageLoader.findOrCreateLanguageBySyntax.mockReturnValue({ syntax: language }); }); afterEach(() => { @@ -68,4 +95,56 @@ describe('content/components/wrappers/code_block', () => { expect(updateAttributesFn).toHaveBeenCalledWith({ language }); }); + + describe('diagrams', () => { + beforeEach(() => { + jest.spyOn(tiptapEditor, 'isActive').mockReturnValue(true); + }); + + it('does not render a preview if showPreview: false', async () => { + createWrapper({ language: 'plantuml', isDiagram: true, showPreview: false }); + + expect(wrapper.find({ ref: 'diagramContainer' }).exists()).toBe(false); + }); + + it('does not update preview when diagram is not active', async () => { + createWrapper({ language: 'plantuml', isDiagram: true, showPreview: true }); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + await nextTick(); + + expect(wrapper.find('img').attributes('src')).toBe('url/to/some/diagram'); + + jest.spyOn(tiptapEditor, 'isActive').mockReturnValue(false); + + const alternateUrl = 'url/to/another/diagram'; + + contentEditor.renderDiagram.mockResolvedValue(alternateUrl); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + await nextTick(); + + expect(wrapper.find('img').attributes('src')).toBe('url/to/some/diagram'); + }); + + it('renders an image with preview for a plantuml/kroki diagram', async () => { + createWrapper({ language: 'plantuml', isDiagram: true, showPreview: true }); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + await nextTick(); + + expect(wrapper.find('img').attributes('src')).toBe('url/to/some/diagram'); + expect(wrapper.find(SandboxedMermaid).exists()).toBe(false); + }); + + it('renders an iframe with preview for a mermaid diagram', async () => { + createWrapper({ language: 'mermaid', isDiagram: true, showPreview: true }); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + await nextTick(); + + expect(wrapper.find(SandboxedMermaid).props('source')).toBe(''); + expect(wrapper.find('img').exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/content_editor/components/wrappers/footnote_definition_spec.js b/spec/frontend/content_editor/components/wrappers/footnote_definition_spec.js new file mode 100644 index 00000000000..1ff750eb2ac --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/footnote_definition_spec.js @@ -0,0 +1,30 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import FootnoteDefinitionWrapper from '~/content_editor/components/wrappers/footnote_definition.vue'; + +describe('content/components/wrappers/footnote_definition', () => { + let wrapper; + + const createWrapper = async (node = {}) => { + wrapper = shallowMountExtended(FootnoteDefinitionWrapper, { + propsData: { + node, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders footnote label as a readyonly element', () => { + const label = 'footnote'; + + createWrapper({ + attrs: { + label, + }, + }); + expect(wrapper.text()).toContain(label); + expect(wrapper.findByTestId('footnote-label').attributes().contenteditable).toBe('false'); + }); +}); diff --git a/spec/frontend/content_editor/extensions/footnote_definition_spec.js b/spec/frontend/content_editor/extensions/footnote_definition_spec.js new file mode 100644 index 00000000000..d3dbc56ae0e --- /dev/null +++ b/spec/frontend/content_editor/extensions/footnote_definition_spec.js @@ -0,0 +1,7 @@ +import FootnoteDefinition from '~/content_editor/extensions/footnote_definition'; + +describe('content_editor/extensions/footnote_definition', () => { + it('sets the isolation option to true', () => { + expect(FootnoteDefinition.config.isolating).toBe(true); + }); +}); diff --git a/spec/frontend/content_editor/remark_markdown_processing_spec.js b/spec/frontend/content_editor/remark_markdown_processing_spec.js index 6348b97d918..60dc540e192 100644 --- a/spec/frontend/content_editor/remark_markdown_processing_spec.js +++ b/spec/frontend/content_editor/remark_markdown_processing_spec.js @@ -3,6 +3,8 @@ import Blockquote from '~/content_editor/extensions/blockquote'; import BulletList from '~/content_editor/extensions/bullet_list'; import Code from '~/content_editor/extensions/code'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; +import FootnoteDefinition from '~/content_editor/extensions/footnote_definition'; +import FootnoteReference from '~/content_editor/extensions/footnote_reference'; import HardBreak from '~/content_editor/extensions/hard_break'; import Heading from '~/content_editor/extensions/heading'; import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; @@ -11,11 +13,19 @@ import Italic from '~/content_editor/extensions/italic'; import Link from '~/content_editor/extensions/link'; import ListItem from '~/content_editor/extensions/list_item'; import OrderedList from '~/content_editor/extensions/ordered_list'; +import Paragraph from '~/content_editor/extensions/paragraph'; import Sourcemap from '~/content_editor/extensions/sourcemap'; +import Strike from '~/content_editor/extensions/strike'; +import Table from '~/content_editor/extensions/table'; +import TableHeader from '~/content_editor/extensions/table_header'; +import TableRow from '~/content_editor/extensions/table_row'; +import TableCell from '~/content_editor/extensions/table_cell'; +import TaskList from '~/content_editor/extensions/task_list'; +import TaskItem from '~/content_editor/extensions/task_item'; import remarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; import markdownSerializer from '~/content_editor/services/markdown_serializer'; -import { createTestEditor } from './test_utils'; +import { createTestEditor, createDocBuilder } from './test_utils'; const tiptapEditor = createTestEditor({ extensions: [ @@ -24,6 +34,8 @@ const tiptapEditor = createTestEditor({ BulletList, Code, CodeBlockHighlight, + FootnoteDefinition, + FootnoteReference, HardBreak, Heading, HorizontalRule, @@ -33,9 +45,72 @@ const tiptapEditor = createTestEditor({ ListItem, OrderedList, Sourcemap, + Strike, + Table, + TableRow, + TableHeader, + TableCell, + TaskList, + TaskItem, ], }); +const { + builders: { + doc, + paragraph, + bold, + blockquote, + bulletList, + code, + codeBlock, + footnoteDefinition, + footnoteReference, + hardBreak, + heading, + horizontalRule, + image, + italic, + link, + listItem, + orderedList, + strike, + table, + tableRow, + tableHeader, + tableCell, + taskItem, + taskList, + }, +} = createDocBuilder({ + tiptapEditor, + names: { + blockquote: { nodeType: Blockquote.name }, + bold: { markType: Bold.name }, + bulletList: { nodeType: BulletList.name }, + code: { markType: Code.name }, + codeBlock: { nodeType: CodeBlockHighlight.name }, + footnoteDefinition: { nodeType: FootnoteDefinition.name }, + footnoteReference: { nodeType: FootnoteReference.name }, + hardBreak: { nodeType: HardBreak.name }, + heading: { nodeType: Heading.name }, + horizontalRule: { nodeType: HorizontalRule.name }, + image: { nodeType: Image.name }, + italic: { nodeType: Italic.name }, + link: { markType: Link.name }, + listItem: { nodeType: ListItem.name }, + orderedList: { nodeType: OrderedList.name }, + paragraph: { nodeType: Paragraph.name }, + strike: { nodeType: Strike.name }, + table: { nodeType: Table.name }, + tableCell: { nodeType: TableCell.name }, + tableHeader: { nodeType: TableHeader.name }, + tableRow: { nodeType: TableRow.name }, + taskItem: { nodeType: TaskItem.name }, + taskList: { nodeType: TaskList.name }, + }, +}); + describe('Client side Markdown processing', () => { const deserialize = async (content) => { const { document } = await remarkMarkdownDeserializer().deserialize({ @@ -52,197 +127,887 @@ describe('Client side Markdown processing', () => { pristineDoc: document, }); - it.each([ + const sourceAttrs = (sourceMapKey, sourceMarkdown) => ({ + sourceMapKey, + sourceMarkdown, + }); + + const examples = [ { markdown: '__bold text__', + expectedDoc: doc( + paragraph( + sourceAttrs('0:13', '__bold text__'), + bold(sourceAttrs('0:13', '__bold text__'), 'bold text'), + ), + ), }, { markdown: '**bold text**', + expectedDoc: doc( + paragraph( + sourceAttrs('0:13', '**bold text**'), + bold(sourceAttrs('0:13', '**bold text**'), 'bold text'), + ), + ), }, { markdown: '<strong>bold text</strong>', + expectedDoc: doc( + paragraph( + sourceAttrs('0:26', '<strong>bold text</strong>'), + bold(sourceAttrs('0:26', '<strong>bold text</strong>'), 'bold text'), + ), + ), }, { markdown: '<b>bold text</b>', + expectedDoc: doc( + paragraph( + sourceAttrs('0:16', '<b>bold text</b>'), + bold(sourceAttrs('0:16', '<b>bold text</b>'), 'bold text'), + ), + ), }, { markdown: '_italic text_', + expectedDoc: doc( + paragraph( + sourceAttrs('0:13', '_italic text_'), + italic(sourceAttrs('0:13', '_italic text_'), 'italic text'), + ), + ), }, { markdown: '*italic text*', + expectedDoc: doc( + paragraph( + sourceAttrs('0:13', '*italic text*'), + italic(sourceAttrs('0:13', '*italic text*'), 'italic text'), + ), + ), }, { markdown: '<em>italic text</em>', + expectedDoc: doc( + paragraph( + sourceAttrs('0:20', '<em>italic text</em>'), + italic(sourceAttrs('0:20', '<em>italic text</em>'), 'italic text'), + ), + ), }, { markdown: '<i>italic text</i>', + expectedDoc: doc( + paragraph( + sourceAttrs('0:18', '<i>italic text</i>'), + italic(sourceAttrs('0:18', '<i>italic text</i>'), 'italic text'), + ), + ), }, { markdown: '`inline code`', + expectedDoc: doc( + paragraph( + sourceAttrs('0:13', '`inline code`'), + code(sourceAttrs('0:13', '`inline code`'), 'inline code'), + ), + ), }, { markdown: '**`inline code bold`**', + expectedDoc: doc( + paragraph( + sourceAttrs('0:22', '**`inline code bold`**'), + bold( + sourceAttrs('0:22', '**`inline code bold`**'), + code(sourceAttrs('2:20', '`inline code bold`'), 'inline code bold'), + ), + ), + ), + }, + { + markdown: '_`inline code italics`_', + expectedDoc: doc( + paragraph( + sourceAttrs('0:23', '_`inline code italics`_'), + italic( + sourceAttrs('0:23', '_`inline code italics`_'), + code(sourceAttrs('1:22', '`inline code italics`'), 'inline code italics'), + ), + ), + ), + }, + { + markdown: ` +<i class="foo"> + *bar* +</i> + `, + expectedDoc: doc( + paragraph( + sourceAttrs('0:28', '<i class="foo">\n *bar*\n</i>'), + italic(sourceAttrs('0:28', '<i class="foo">\n *bar*\n</i>'), '\n *bar*\n'), + ), + ), + }, + { + markdown: ` + +<img src="bar" alt="foo" /> + + `, + expectedDoc: doc( + paragraph( + sourceAttrs('0:27', '<img src="bar" alt="foo" />'), + image({ ...sourceAttrs('0:27', '<img src="bar" alt="foo" />'), alt: 'foo', src: 'bar' }), + ), + ), }, { - markdown: '__`inline code italics`__', + markdown: ` +- List item 1 + +<img src="bar" alt="foo" /> + + `, + expectedDoc: doc( + bulletList( + sourceAttrs('0:13', '- List item 1'), + listItem( + sourceAttrs('0:13', '- List item 1'), + paragraph(sourceAttrs('2:13', 'List item 1'), 'List item 1'), + ), + ), + paragraph( + sourceAttrs('15:42', '<img src="bar" alt="foo" />'), + image({ ...sourceAttrs('15:42', '<img src="bar" alt="foo" />'), alt: 'foo', src: 'bar' }), + ), + ), }, { markdown: '[GitLab](https://gitlab.com "Go to GitLab")', + expectedDoc: doc( + paragraph( + sourceAttrs('0:43', '[GitLab](https://gitlab.com "Go to GitLab")'), + link( + { + ...sourceAttrs('0:43', '[GitLab](https://gitlab.com "Go to GitLab")'), + href: 'https://gitlab.com', + title: 'Go to GitLab', + }, + 'GitLab', + ), + ), + ), }, { markdown: '**[GitLab](https://gitlab.com "Go to GitLab")**', + expectedDoc: doc( + paragraph( + sourceAttrs('0:47', '**[GitLab](https://gitlab.com "Go to GitLab")**'), + bold( + sourceAttrs('0:47', '**[GitLab](https://gitlab.com "Go to GitLab")**'), + link( + { + ...sourceAttrs('2:45', '[GitLab](https://gitlab.com "Go to GitLab")'), + href: 'https://gitlab.com', + title: 'Go to GitLab', + }, + 'GitLab', + ), + ), + ), + ), + }, + { + markdown: 'www.commonmark.org', + expectedDoc: doc( + paragraph( + sourceAttrs('0:18', 'www.commonmark.org'), + link( + { + ...sourceAttrs('0:18', 'www.commonmark.org'), + href: 'http://www.commonmark.org', + }, + 'www.commonmark.org', + ), + ), + ), + }, + { + markdown: 'Visit www.commonmark.org/help for more information.', + expectedDoc: doc( + paragraph( + sourceAttrs('0:51', 'Visit www.commonmark.org/help for more information.'), + 'Visit ', + link( + { + ...sourceAttrs('6:29', 'www.commonmark.org/help'), + href: 'http://www.commonmark.org/help', + }, + 'www.commonmark.org/help', + ), + ' for more information.', + ), + ), + }, + { + markdown: 'hello@mail+xyz.example isn’t valid, but hello+xyz@mail.example is.', + expectedDoc: doc( + paragraph( + sourceAttrs('0:66', 'hello@mail+xyz.example isn’t valid, but hello+xyz@mail.example is.'), + 'hello@mail+xyz.example isn’t valid, but ', + link( + { + ...sourceAttrs('40:62', 'hello+xyz@mail.example'), + href: 'mailto:hello+xyz@mail.example', + }, + 'hello+xyz@mail.example', + ), + ' is.', + ), + ), + }, + { + markdown: '[https://gitlab.com>', + expectedDoc: doc( + paragraph( + sourceAttrs('0:20', '[https://gitlab.com>'), + '[', + link( + { + ...sourceAttrs(), + href: 'https://gitlab.com', + }, + 'https://gitlab.com', + ), + '>', + ), + ), }, { markdown: ` This is a paragraph with a\\ hard line break`, + expectedDoc: doc( + paragraph( + sourceAttrs('0:43', 'This is a paragraph with a\\\nhard line break'), + 'This is a paragraph with a', + hardBreak(sourceAttrs('26:28', '\\\n')), + '\nhard line break', + ), + ), }, { markdown: '![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")', + expectedDoc: doc( + paragraph( + sourceAttrs('0:57', '![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")'), + image({ + ...sourceAttrs('0:57', '![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")'), + alt: 'GitLab Logo', + src: 'https://gitlab.com/logo.png', + title: 'GitLab Logo', + }), + ), + ), }, { markdown: '---', + expectedDoc: doc(horizontalRule(sourceAttrs('0:3', '---'))), }, { markdown: '***', + expectedDoc: doc(horizontalRule(sourceAttrs('0:3', '***'))), }, { markdown: '___', + expectedDoc: doc(horizontalRule(sourceAttrs('0:3', '___'))), }, { markdown: '<hr>', + expectedDoc: doc(horizontalRule(sourceAttrs('0:4', '<hr>'))), }, { markdown: '# Heading 1', + expectedDoc: doc(heading({ ...sourceAttrs('0:11', '# Heading 1'), level: 1 }, 'Heading 1')), }, { markdown: '## Heading 2', + expectedDoc: doc(heading({ ...sourceAttrs('0:12', '## Heading 2'), level: 2 }, 'Heading 2')), }, { markdown: '### Heading 3', + expectedDoc: doc(heading({ ...sourceAttrs('0:13', '### Heading 3'), level: 3 }, 'Heading 3')), }, { markdown: '#### Heading 4', + expectedDoc: doc( + heading({ ...sourceAttrs('0:14', '#### Heading 4'), level: 4 }, 'Heading 4'), + ), }, { markdown: '##### Heading 5', + expectedDoc: doc( + heading({ ...sourceAttrs('0:15', '##### Heading 5'), level: 5 }, 'Heading 5'), + ), }, { markdown: '###### Heading 6', + expectedDoc: doc( + heading({ ...sourceAttrs('0:16', '###### Heading 6'), level: 6 }, 'Heading 6'), + ), }, - { markdown: ` - Heading - one - ====== - `, +Heading +one +====== + `, + expectedDoc: doc( + heading({ ...sourceAttrs('0:18', 'Heading\none\n======'), level: 1 }, 'Heading\none'), + ), }, { markdown: ` - Heading - two - ------- - `, +Heading +two +------- + `, + expectedDoc: doc( + heading({ ...sourceAttrs('0:19', 'Heading\ntwo\n-------'), level: 2 }, 'Heading\ntwo'), + ), }, { markdown: ` - - List item 1 - - List item 2 - `, +- List item 1 +- List item 2 + `, + expectedDoc: doc( + bulletList( + sourceAttrs('0:27', '- List item 1\n- List item 2'), + listItem( + sourceAttrs('0:13', '- List item 1'), + paragraph(sourceAttrs('2:13', 'List item 1'), 'List item 1'), + ), + listItem( + sourceAttrs('14:27', '- List item 2'), + paragraph(sourceAttrs('16:27', 'List item 2'), 'List item 2'), + ), + ), + ), }, { markdown: ` - * List item 1 - * List item 2 - `, +* List item 1 +* List item 2 + `, + expectedDoc: doc( + bulletList( + sourceAttrs('0:27', '* List item 1\n* List item 2'), + listItem( + sourceAttrs('0:13', '* List item 1'), + paragraph(sourceAttrs('2:13', 'List item 1'), 'List item 1'), + ), + listItem( + sourceAttrs('14:27', '* List item 2'), + paragraph(sourceAttrs('16:27', 'List item 2'), 'List item 2'), + ), + ), + ), }, { markdown: ` - + List item 1 - + List item 2 - `, ++ List item 1 ++ List item 2 + `, + expectedDoc: doc( + bulletList( + sourceAttrs('0:27', '+ List item 1\n+ List item 2'), + listItem( + sourceAttrs('0:13', '+ List item 1'), + paragraph(sourceAttrs('2:13', 'List item 1'), 'List item 1'), + ), + listItem( + sourceAttrs('14:27', '+ List item 2'), + paragraph(sourceAttrs('16:27', 'List item 2'), 'List item 2'), + ), + ), + ), }, { markdown: ` - 1. List item 1 - 1. List item 2 - `, +1. List item 1 +1. List item 2 + `, + expectedDoc: doc( + orderedList( + sourceAttrs('0:29', '1. List item 1\n1. List item 2'), + listItem( + sourceAttrs('0:14', '1. List item 1'), + paragraph(sourceAttrs('3:14', 'List item 1'), 'List item 1'), + ), + listItem( + sourceAttrs('15:29', '1. List item 2'), + paragraph(sourceAttrs('18:29', 'List item 2'), 'List item 2'), + ), + ), + ), }, { markdown: ` - 1. List item 1 - 2. List item 2 - `, +1. List item 1 +2. List item 2 + `, + expectedDoc: doc( + orderedList( + sourceAttrs('0:29', '1. List item 1\n2. List item 2'), + listItem( + sourceAttrs('0:14', '1. List item 1'), + paragraph(sourceAttrs('3:14', 'List item 1'), 'List item 1'), + ), + listItem( + sourceAttrs('15:29', '2. List item 2'), + paragraph(sourceAttrs('18:29', 'List item 2'), 'List item 2'), + ), + ), + ), }, { markdown: ` - 1) List item 1 - 2) List item 2 - `, +1) List item 1 +2) List item 2 + `, + expectedDoc: doc( + orderedList( + sourceAttrs('0:29', '1) List item 1\n2) List item 2'), + listItem( + sourceAttrs('0:14', '1) List item 1'), + paragraph(sourceAttrs('3:14', 'List item 1'), 'List item 1'), + ), + listItem( + sourceAttrs('15:29', '2) List item 2'), + paragraph(sourceAttrs('18:29', 'List item 2'), 'List item 2'), + ), + ), + ), }, { markdown: ` - - List item 1 - - Sub list item 1 - `, +- List item 1 + - Sub list item 1 + `, + expectedDoc: doc( + bulletList( + sourceAttrs('0:33', '- List item 1\n - Sub list item 1'), + listItem( + sourceAttrs('0:33', '- List item 1\n - Sub list item 1'), + paragraph(sourceAttrs('2:13', 'List item 1'), 'List item 1'), + bulletList( + sourceAttrs('16:33', '- Sub list item 1'), + listItem( + sourceAttrs('16:33', '- Sub list item 1'), + paragraph(sourceAttrs('18:33', 'Sub list item 1'), 'Sub list item 1'), + ), + ), + ), + ), + ), }, { markdown: ` - - List item 1 paragraph 1 +- List item 1 paragraph 1 - List item 1 paragraph 2 - - List item 2 - `, + List item 1 paragraph 2 +- List item 2 + `, + expectedDoc: doc( + bulletList( + sourceAttrs( + '0:66', + '- List item 1 paragraph 1\n\n List item 1 paragraph 2\n- List item 2', + ), + listItem( + sourceAttrs('0:52', '- List item 1 paragraph 1\n\n List item 1 paragraph 2'), + paragraph(sourceAttrs('2:25', 'List item 1 paragraph 1'), 'List item 1 paragraph 1'), + paragraph(sourceAttrs('29:52', 'List item 1 paragraph 2'), 'List item 1 paragraph 2'), + ), + listItem( + sourceAttrs('53:66', '- List item 2'), + paragraph(sourceAttrs('55:66', 'List item 2'), 'List item 2'), + ), + ), + ), }, { markdown: ` - > This is a blockquote - `, +- List item with an image ![bar](foo.png) +`, + expectedDoc: doc( + bulletList( + sourceAttrs('0:41', '- List item with an image ![bar](foo.png)'), + listItem( + sourceAttrs('0:41', '- List item with an image ![bar](foo.png)'), + paragraph( + sourceAttrs('2:41', 'List item with an image ![bar](foo.png)'), + 'List item with an image', + image({ ...sourceAttrs('26:41', '![bar](foo.png)'), alt: 'bar', src: 'foo.png' }), + ), + ), + ), + ), }, { markdown: ` - > - List item 1 - > - List item 2 - `, +> This is a blockquote + `, + expectedDoc: doc( + blockquote( + sourceAttrs('0:22', '> This is a blockquote'), + paragraph(sourceAttrs('2:22', 'This is a blockquote'), 'This is a blockquote'), + ), + ), }, { markdown: ` - const fn = () => 'GitLab'; - `, +> - List item 1 +> - List item 2 + `, + expectedDoc: doc( + blockquote( + sourceAttrs('0:31', '> - List item 1\n> - List item 2'), + bulletList( + sourceAttrs('2:31', '- List item 1\n> - List item 2'), + listItem( + sourceAttrs('2:15', '- List item 1'), + paragraph(sourceAttrs('4:15', 'List item 1'), 'List item 1'), + ), + listItem( + sourceAttrs('18:31', '- List item 2'), + paragraph(sourceAttrs('20:31', 'List item 2'), 'List item 2'), + ), + ), + ), + ), }, { markdown: ` - \`\`\`javascript - const fn = () => 'GitLab'; - \`\`\`\ - `, +code block + + const fn = () => 'GitLab'; + + `, + expectedDoc: doc( + paragraph(sourceAttrs('0:10', 'code block'), 'code block'), + codeBlock( + { + ...sourceAttrs('12:42', " const fn = () => 'GitLab';"), + class: 'code highlight', + language: null, + }, + "const fn = () => 'GitLab';", + ), + ), }, { markdown: ` - ~~~javascript - const fn = () => 'GitLab'; - ~~~ - `, +\`\`\`javascript +const fn = () => 'GitLab'; +\`\`\`\ + `, + expectedDoc: doc( + codeBlock( + { + ...sourceAttrs('0:44', "```javascript\nconst fn = () => 'GitLab';\n```"), + class: 'code highlight', + language: 'javascript', + }, + "const fn = () => 'GitLab';", + ), + ), }, { markdown: ` - \`\`\` - \`\`\`\ - `, +~~~javascript +const fn = () => 'GitLab'; +~~~ + `, + expectedDoc: doc( + codeBlock( + { + ...sourceAttrs('0:44', "~~~javascript\nconst fn = () => 'GitLab';\n~~~"), + class: 'code highlight', + language: 'javascript', + }, + "const fn = () => 'GitLab';", + ), + ), }, { markdown: ` - \`\`\`javascript - const fn = () => 'GitLab'; +\`\`\` +\`\`\`\ + `, + expectedDoc: doc( + codeBlock( + { + ...sourceAttrs('0:7', '```\n```'), + class: 'code highlight', + language: null, + }, + '', + ), + ), + }, + { + markdown: ` +\`\`\`javascript +const fn = () => 'GitLab'; - \`\`\`\ - `, +\`\`\`\ + `, + expectedDoc: doc( + codeBlock( + { + ...sourceAttrs('0:45', "```javascript\nconst fn = () => 'GitLab';\n\n```"), + class: 'code highlight', + language: 'javascript', + }, + "const fn = () => 'GitLab';\n", + ), + ), + }, + { + markdown: '~~Strikedthrough text~~', + expectedDoc: doc( + paragraph( + sourceAttrs('0:23', '~~Strikedthrough text~~'), + strike(sourceAttrs('0:23', '~~Strikedthrough text~~'), 'Strikedthrough text'), + ), + ), + }, + { + markdown: '<del>Strikedthrough text</del>', + expectedDoc: doc( + paragraph( + sourceAttrs('0:30', '<del>Strikedthrough text</del>'), + strike(sourceAttrs('0:30', '<del>Strikedthrough text</del>'), 'Strikedthrough text'), + ), + ), + }, + { + markdown: '<strike>Strikedthrough text</strike>', + expectedDoc: doc( + paragraph( + sourceAttrs('0:36', '<strike>Strikedthrough text</strike>'), + strike( + sourceAttrs('0:36', '<strike>Strikedthrough text</strike>'), + 'Strikedthrough text', + ), + ), + ), + }, + { + markdown: '<s>Strikedthrough text</s>', + expectedDoc: doc( + paragraph( + sourceAttrs('0:26', '<s>Strikedthrough text</s>'), + strike(sourceAttrs('0:26', '<s>Strikedthrough text</s>'), 'Strikedthrough text'), + ), + ), }, - ])('processes %s correctly', async ({ markdown }) => { + { + markdown: ` +- [ ] task list item 1 +- [ ] task list item 2 + `, + expectedDoc: doc( + taskList( + { + numeric: false, + ...sourceAttrs('0:45', '- [ ] task list item 1\n- [ ] task list item 2'), + }, + taskItem( + { + checked: false, + ...sourceAttrs('0:22', '- [ ] task list item 1'), + }, + paragraph(sourceAttrs('6:22', 'task list item 1'), 'task list item 1'), + ), + taskItem( + { + checked: false, + ...sourceAttrs('23:45', '- [ ] task list item 2'), + }, + paragraph(sourceAttrs('29:45', 'task list item 2'), 'task list item 2'), + ), + ), + ), + }, + { + markdown: ` +- [x] task list item 1 +- [x] task list item 2 + `, + expectedDoc: doc( + taskList( + { + numeric: false, + ...sourceAttrs('0:45', '- [x] task list item 1\n- [x] task list item 2'), + }, + taskItem( + { + checked: true, + ...sourceAttrs('0:22', '- [x] task list item 1'), + }, + paragraph(sourceAttrs('6:22', 'task list item 1'), 'task list item 1'), + ), + taskItem( + { + checked: true, + ...sourceAttrs('23:45', '- [x] task list item 2'), + }, + paragraph(sourceAttrs('29:45', 'task list item 2'), 'task list item 2'), + ), + ), + ), + }, + { + markdown: ` +1. [ ] task list item 1 +2. [ ] task list item 2 + `, + expectedDoc: doc( + taskList( + { + numeric: true, + ...sourceAttrs('0:47', '1. [ ] task list item 1\n2. [ ] task list item 2'), + }, + taskItem( + { + checked: false, + ...sourceAttrs('0:23', '1. [ ] task list item 1'), + }, + paragraph(sourceAttrs('7:23', 'task list item 1'), 'task list item 1'), + ), + taskItem( + { + checked: false, + ...sourceAttrs('24:47', '2. [ ] task list item 2'), + }, + paragraph(sourceAttrs('31:47', 'task list item 2'), 'task list item 2'), + ), + ), + ), + }, + { + markdown: ` +| a | b | +|---|---| +| c | d | +`, + expectedDoc: doc( + table( + sourceAttrs('0:29', '| a | b |\n|---|---|\n| c | d |'), + tableRow( + sourceAttrs('0:9', '| a | b |'), + tableHeader(sourceAttrs('0:5', '| a |'), paragraph(sourceAttrs('2:3', 'a'), 'a')), + tableHeader(sourceAttrs('5:9', ' b |'), paragraph(sourceAttrs('6:7', 'b'), 'b')), + ), + tableRow( + sourceAttrs('20:29', '| c | d |'), + tableCell(sourceAttrs('20:25', '| c |'), paragraph(sourceAttrs('22:23', 'c'), 'c')), + tableCell(sourceAttrs('25:29', ' d |'), paragraph(sourceAttrs('26:27', 'd'), 'd')), + ), + ), + ), + }, + { + markdown: ` +<table> + <tr> + <th colspan="2" rowspan="5">Header</th> + </tr> + <tr> + <td colspan="2" rowspan="5">Body</td> + </tr> +</table> +`, + expectedDoc: doc( + table( + sourceAttrs( + '0:132', + '<table>\n <tr>\n <th colspan="2" rowspan="5">Header</th>\n </tr>\n <tr>\n <td colspan="2" rowspan="5">Body</td>\n </tr>\n</table>', + ), + tableRow( + sourceAttrs('10:66', '<tr>\n <th colspan="2" rowspan="5">Header</th>\n </tr>'), + tableHeader( + { + ...sourceAttrs('19:58', '<th colspan="2" rowspan="5">Header</th>'), + colspan: 2, + rowspan: 5, + }, + paragraph(sourceAttrs('47:53', 'Header'), 'Header'), + ), + ), + tableRow( + sourceAttrs('69:123', '<tr>\n <td colspan="2" rowspan="5">Body</td>\n </tr>'), + tableCell( + { + ...sourceAttrs('78:115', '<td colspan="2" rowspan="5">Body</td>'), + colspan: 2, + rowspan: 5, + }, + paragraph(sourceAttrs('106:110', 'Body'), 'Body'), + ), + ), + ), + ), + }, + { + markdown: ` +This is a footnote [^footnote] + +Paragraph + +[^footnote]: Footnote definition + +Paragraph +`, + expectedDoc: doc( + paragraph( + sourceAttrs('0:30', 'This is a footnote [^footnote]'), + 'This is a footnote ', + footnoteReference({ + ...sourceAttrs('19:30', '[^footnote]'), + identifier: 'footnote', + label: 'footnote', + }), + ), + paragraph(sourceAttrs('32:41', 'Paragraph'), 'Paragraph'), + footnoteDefinition( + { + ...sourceAttrs('43:75', '[^footnote]: Footnote definition'), + identifier: 'footnote', + label: 'footnote', + }, + paragraph(sourceAttrs('56:75', 'Footnote definition'), 'Footnote definition'), + ), + paragraph(sourceAttrs('77:86', 'Paragraph'), 'Paragraph'), + ), + }, + ]; + + const runOnly = examples.find((example) => example.only === true); + const runExamples = runOnly ? [runOnly] : examples; + + it.each(runExamples)('processes %s correctly', async ({ markdown, expectedDoc }) => { const trimmed = markdown.trim(); const document = await deserialize(trimmed); + expect(expectedDoc).not.toBeFalsy(); + expect(document.toJSON()).toEqual(expectedDoc.toJSON()); expect(serialize(document)).toEqual(trimmed); }); }); diff --git a/spec/frontend/content_editor/services/asset_resolver_spec.js b/spec/frontend/content_editor/services/asset_resolver_spec.js index f4e7d9bf881..0a99f823be3 100644 --- a/spec/frontend/content_editor/services/asset_resolver_spec.js +++ b/spec/frontend/content_editor/services/asset_resolver_spec.js @@ -20,4 +20,14 @@ describe('content_editor/services/asset_resolver', () => { ); }); }); + + describe('renderDiagram', () => { + it('resolves a diagram code to a url containing the diagram image', async () => { + renderMarkdown.mockResolvedValue( + '<p><img data-diagram="nomnoml" src="url/to/some/diagram"></p>', + ); + + expect(await assetResolver.renderDiagram('test')).toBe('url/to/some/diagram'); + }); + }); }); diff --git a/spec/frontend/content_editor/services/code_block_language_loader_spec.js b/spec/frontend/content_editor/services/code_block_language_loader_spec.js index 943de327762..795f5219a3f 100644 --- a/spec/frontend/content_editor/services/code_block_language_loader_spec.js +++ b/spec/frontend/content_editor/services/code_block_language_loader_spec.js @@ -18,25 +18,32 @@ describe('content_editor/services/code_block_language_loader', () => { languageLoader.lowlight = lowlight; }); - describe('findLanguageBySyntax', () => { + describe('findOrCreateLanguageBySyntax', () => { it.each` syntax | language ${'javascript'} | ${{ syntax: 'javascript', label: 'Javascript' }} ${'js'} | ${{ syntax: 'javascript', label: 'Javascript' }} ${'jsx'} | ${{ syntax: 'javascript', label: 'Javascript' }} `('returns a language by syntax and its variants', ({ syntax, language }) => { - expect(languageLoader.findLanguageBySyntax(syntax)).toMatchObject(language); + expect(languageLoader.findOrCreateLanguageBySyntax(syntax)).toMatchObject(language); }); it('returns Custom (syntax) if the language does not exist', () => { - expect(languageLoader.findLanguageBySyntax('foobar')).toMatchObject({ + expect(languageLoader.findOrCreateLanguageBySyntax('foobar')).toMatchObject({ syntax: 'foobar', label: 'Custom (foobar)', }); }); + it('returns Diagram (syntax) if the language does not exist, and isDiagram = true', () => { + expect(languageLoader.findOrCreateLanguageBySyntax('foobar', true)).toMatchObject({ + syntax: 'foobar', + label: 'Diagram (foobar)', + }); + }); + it('returns plaintext if no syntax is passed', () => { - expect(languageLoader.findLanguageBySyntax('')).toMatchObject({ + expect(languageLoader.findOrCreateLanguageBySyntax('')).toMatchObject({ syntax: 'plaintext', label: 'Plain text', }); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 25b7483f234..13e9efaea59 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -13,7 +13,6 @@ import Figure from '~/content_editor/extensions/figure'; import FigureCaption from '~/content_editor/extensions/figure_caption'; import FootnoteDefinition from '~/content_editor/extensions/footnote_definition'; import FootnoteReference from '~/content_editor/extensions/footnote_reference'; -import FootnotesSection from '~/content_editor/extensions/footnotes_section'; import HardBreak from '~/content_editor/extensions/hard_break'; import Heading from '~/content_editor/extensions/heading'; import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; @@ -53,7 +52,6 @@ const tiptapEditor = createTestEditor({ Emoji, FootnoteDefinition, FootnoteReference, - FootnotesSection, Figure, FigureCaption, HardBreak, @@ -92,7 +90,6 @@ const { emoji, footnoteDefinition, footnoteReference, - footnotesSection, figure, figureCaption, heading, @@ -131,7 +128,6 @@ const { figureCaption: { nodeType: FigureCaption.name }, footnoteDefinition: { nodeType: FootnoteDefinition.name }, footnoteReference: { nodeType: FootnoteReference.name }, - footnotesSection: { nodeType: FootnotesSection.name }, hardBreak: { nodeType: HardBreak.name }, heading: { nodeType: Heading.name }, horizontalRule: { nodeType: HorizontalRule.name }, @@ -200,7 +196,7 @@ describe('markdownSerializer', () => { it('correctly serializes a plain URL link', () => { expect(serialize(paragraph(link({ href: 'https://example.com' }, 'https://example.com')))).toBe( - '<https://example.com>', + 'https://example.com', ); }); @@ -1147,49 +1143,75 @@ there it('correctly serializes footnotes', () => { expect( serialize( - paragraph( - 'Oranges are orange ', - footnoteReference({ footnoteId: '1', footnoteNumber: '1' }), - ), - footnotesSection(footnoteDefinition(paragraph('Oranges are fruits'))), + paragraph('Oranges are orange ', footnoteReference({ label: '1', identifier: '1' })), + footnoteDefinition({ label: '1', identifier: '1' }, 'Oranges are fruits'), ), ).toBe( ` Oranges are orange [^1] [^1]: Oranges are fruits - `.trim(), +`.trimLeft(), ); }); + const defaultEditAction = (initialContent) => { + tiptapEditor.chain().setContent(initialContent.toJSON()).insertContent(' modified').run(); + }; + + const prependContentEditAction = (initialContent) => { + tiptapEditor + .chain() + .setContent(initialContent.toJSON()) + .setTextSelection(0) + .insertContent('modified ') + .run(); + }; + it.each` - mark | content | modifiedContent - ${'bold'} | ${'**bold**'} | ${'**bold modified**'} - ${'bold'} | ${'__bold__'} | ${'__bold modified__'} - ${'bold'} | ${'<strong>bold</strong>'} | ${'<strong>bold modified</strong>'} - ${'bold'} | ${'<b>bold</b>'} | ${'<b>bold modified</b>'} - ${'italic'} | ${'_italic_'} | ${'_italic modified_'} - ${'italic'} | ${'*italic*'} | ${'*italic modified*'} - ${'italic'} | ${'<em>italic</em>'} | ${'<em>italic modified</em>'} - ${'italic'} | ${'<i>italic</i>'} | ${'<i>italic modified</i>'} - ${'link'} | ${'[gitlab](https://gitlab.com)'} | ${'[gitlab modified](https://gitlab.com)'} - ${'link'} | ${'<a href="https://gitlab.com">link</a>'} | ${'<a href="https://gitlab.com">link modified</a>'} - ${'code'} | ${'`code`'} | ${'`code modified`'} - ${'code'} | ${'<code>code</code>'} | ${'<code>code modified</code>'} + mark | content | modifiedContent | editAction + ${'bold'} | ${'**bold**'} | ${'**bold modified**'} | ${defaultEditAction} + ${'bold'} | ${'__bold__'} | ${'__bold modified__'} | ${defaultEditAction} + ${'bold'} | ${'<strong>bold</strong>'} | ${'<strong>bold modified</strong>'} | ${defaultEditAction} + ${'bold'} | ${'<b>bold</b>'} | ${'<b>bold modified</b>'} | ${defaultEditAction} + ${'italic'} | ${'_italic_'} | ${'_italic modified_'} | ${defaultEditAction} + ${'italic'} | ${'*italic*'} | ${'*italic modified*'} | ${defaultEditAction} + ${'italic'} | ${'<em>italic</em>'} | ${'<em>italic modified</em>'} | ${defaultEditAction} + ${'italic'} | ${'<i>italic</i>'} | ${'<i>italic modified</i>'} | ${defaultEditAction} + ${'link'} | ${'[gitlab](https://gitlab.com)'} | ${'[gitlab modified](https://gitlab.com)'} | ${defaultEditAction} + ${'link'} | ${'<a href="https://gitlab.com">link</a>'} | ${'<a href="https://gitlab.com">link modified</a>'} | ${defaultEditAction} + ${'link'} | ${'link www.gitlab.com'} | ${'modified link www.gitlab.com'} | ${prependContentEditAction} + ${'link'} | ${'link https://www.gitlab.com'} | ${'modified link https://www.gitlab.com'} | ${prependContentEditAction} + ${'link'} | ${'link(https://www.gitlab.com)'} | ${'modified link(https://www.gitlab.com)'} | ${prependContentEditAction} + ${'link'} | ${'link(engineering@gitlab.com)'} | ${'modified link(engineering@gitlab.com)'} | ${prependContentEditAction} + ${'link'} | ${'link <https://www.gitlab.com>'} | ${'modified link <https://www.gitlab.com>'} | ${prependContentEditAction} + ${'link'} | ${'link [https://www.gitlab.com>'} | ${'modified link \\[https://www.gitlab.com>'} | ${prependContentEditAction} + ${'link'} | ${'link <https://www.gitlab.com'} | ${'modified link <https://www.gitlab.com'} | ${prependContentEditAction} + ${'link'} | ${'link https://www.gitlab.com>'} | ${'modified link https://www.gitlab.com>'} | ${prependContentEditAction} + ${'link'} | ${'link **https://www.gitlab.com]**'} | ${'modified link [**https://www.gitlab.com\\]**](https://www.gitlab.com%5D)'} | ${prependContentEditAction} + ${'code'} | ${'`code`'} | ${'`code modified`'} | ${defaultEditAction} + ${'code'} | ${'<code>code</code>'} | ${'<code>code modified</code>'} | ${defaultEditAction} + ${'strike'} | ${'~~striked~~'} | ${'~~striked modified~~'} | ${defaultEditAction} + ${'strike'} | ${'<del>striked</del>'} | ${'<del>striked modified</del>'} | ${defaultEditAction} + ${'strike'} | ${'<strike>striked</strike>'} | ${'<strike>striked modified</strike>'} | ${defaultEditAction} + ${'strike'} | ${'<s>striked</s>'} | ${'<s>striked modified</s>'} | ${defaultEditAction} + ${'list'} | ${'- list item'} | ${'- list item modified'} | ${defaultEditAction} + ${'list'} | ${'* list item'} | ${'* list item modified'} | ${defaultEditAction} + ${'list'} | ${'+ list item'} | ${'+ list item modified'} | ${defaultEditAction} + ${'list'} | ${'- list item 1\n- list item 2'} | ${'- list item 1\n- list item 2 modified'} | ${defaultEditAction} + ${'list'} | ${'2) list item'} | ${'2) list item modified'} | ${defaultEditAction} + ${'list'} | ${'1. list item'} | ${'1. list item modified'} | ${defaultEditAction} + ${'taskList'} | ${'2) [ ] task list item'} | ${'2) [ ] task list item modified'} | ${defaultEditAction} + ${'taskList'} | ${'2) [x] task list item'} | ${'2) [x] task list item modified'} | ${defaultEditAction} `( - 'preserves original $mark syntax when sourceMarkdown is available', - async ({ content, modifiedContent }) => { + 'preserves original $mark syntax when sourceMarkdown is available for $content', + async ({ content, modifiedContent, editAction }) => { const { document } = await remarkMarkdownDeserializer().deserialize({ schema: tiptapEditor.schema, content, }); - tiptapEditor - .chain() - .setContent(document.toJSON()) - // changing the document ensures that block preservation doesn’t yield false positives - .insertContent(' modified') - .run(); + editAction(document); const serialized = markdownSerializer({}).serialize({ pristineDoc: document, diff --git a/spec/frontend/custom_metrics/components/custom_metrics_form_spec.js b/spec/frontend/custom_metrics/components/custom_metrics_form_spec.js index 384d6699150..af56b94f90b 100644 --- a/spec/frontend/custom_metrics/components/custom_metrics_form_spec.js +++ b/spec/frontend/custom_metrics/components/custom_metrics_form_spec.js @@ -18,7 +18,7 @@ describe('CustomMetricsForm', () => { wrapper = shallowMount(CustomMetricsForm, { propsData: { customMetricsPath: '', - editProjectServicePath: '', + editIntegrationPath: '', metricPersisted, validateQueryPath: '', formData, diff --git a/spec/frontend/cycle_analytics/path_navigation_spec.js b/spec/frontend/cycle_analytics/path_navigation_spec.js index c6d72d3b571..e8c4ebd3a38 100644 --- a/spec/frontend/cycle_analytics/path_navigation_spec.js +++ b/spec/frontend/cycle_analytics/path_navigation_spec.js @@ -1,4 +1,4 @@ -import { GlPath, GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; +import { GlPath, GlSkeletonLoader } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -73,7 +73,7 @@ describe('Project PathNavigation', () => { }); it('hides the gl-skeleton-loading component', () => { - expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false); + expect(wrapper.find(GlSkeletonLoader).exists()).toBe(false); }); it('renders each stage', () => { @@ -116,7 +116,7 @@ describe('Project PathNavigation', () => { }); it('displays the gl-skeleton-loading component', () => { - expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true); + expect(wrapper.find(GlSkeletonLoader).exists()).toBe(true); }); }); }); diff --git a/spec/frontend/cycle_analytics/stage_table_spec.js b/spec/frontend/cycle_analytics/stage_table_spec.js index 0d15d67866d..473e1d5b664 100644 --- a/spec/frontend/cycle_analytics/stage_table_spec.js +++ b/spec/frontend/cycle_analytics/stage_table_spec.js @@ -27,6 +27,7 @@ const findTableHeadColumns = () => findTableHead().findAll('th'); const findStageEventTitle = (ev) => extendedWrapper(ev).findByTestId('vsa-stage-event-title'); const findStageEventLink = (ev) => extendedWrapper(ev).findByTestId('vsa-stage-event-link'); const findStageTime = () => wrapper.findByTestId('vsa-stage-event-time'); +const findStageLastEvent = () => wrapper.findByTestId('vsa-stage-last-event'); const findIcon = (name) => wrapper.findByTestId(`${name}-icon`); function createComponent(props = {}, shallow = false) { @@ -128,6 +129,10 @@ describe('StageTable', () => { expect(findStageTime().text()).toBe(createdAt); }); + it('will render the end event', () => { + expect(findStageLastEvent().text()).toBe(firstIssueEvent.endEventTimestamp); + }); + it('will render the author', () => { expect(wrapper.findByTestId('vsa-stage-event-author').text()).toContain( firstIssueEvent.author.name, @@ -303,10 +308,20 @@ describe('StageTable', () => { wrapper.destroy(); }); - it('can sort the table by each column', () => { - findTableHeadColumns().wrappers.forEach((w) => { - expect(w.attributes('aria-sort')).toBe('none'); - }); + it('can sort the end event or duration', () => { + findTableHeadColumns() + .wrappers.slice(1) + .forEach((w) => { + expect(w.attributes('aria-sort')).toBe('none'); + }); + }); + + it('cannot be sorted by title', () => { + findTableHeadColumns() + .wrappers.slice(0, 1) + .forEach((w) => { + expect(w.attributes('aria-sort')).toBeUndefined(); + }); }); it('clicking a table column will send tracking information', () => { diff --git a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js index 4a3e8146b13..df86b10cba3 100644 --- a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js +++ b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js @@ -1,4 +1,4 @@ -import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; +import { GlSkeletonLoader } from '@gitlab/ui'; import { nextTick } from 'vue'; import metricsData from 'test_fixtures/projects/analytics/value_stream_analytics/summary.json'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -61,7 +61,7 @@ describe('ValueStreamMetrics', () => { it('will display a loader with pending requests', async () => { await nextTick(); - expect(wrapper.findComponent(GlSkeletonLoading).exists()).toBe(true); + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true); }); describe('with data loaded', () => { @@ -88,7 +88,7 @@ describe('ValueStreamMetrics', () => { }); it('will not display a loading icon', () => { - expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false); + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false); }); describe('filterFn', () => { diff --git a/spec/frontend/design_management/components/design_presentation_spec.js b/spec/frontend/design_management/components/design_presentation_spec.js index d79dde84d46..30eddcee86a 100644 --- a/spec/frontend/design_management/components/design_presentation_spec.js +++ b/spec/frontend/design_management/components/design_presentation_spec.js @@ -36,6 +36,7 @@ describe('Design management design presentation component', () => { discussions, isAnnotating, resolvedDiscussionsExpanded, + isLoading: false, }, stubs, }); diff --git a/spec/frontend/design_management/components/design_sidebar_spec.js b/spec/frontend/design_management/components/design_sidebar_spec.js index e8426216c1c..40968d9204a 100644 --- a/spec/frontend/design_management/components/design_sidebar_spec.js +++ b/spec/frontend/design_management/components/design_sidebar_spec.js @@ -52,6 +52,7 @@ describe('Design management design sidebar component', () => { design, resolvedDiscussionsExpanded: false, markdownPreviewPath: '', + isLoading: false, ...props, }, mocks: { diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap index 3cb48d7632f..b5a69b28a88 100644 --- a/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap +++ b/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap @@ -18,7 +18,7 @@ exports[`Design management pagination component renders navigation buttons 1`] = category="primary" class="js-previous-design" disabled="true" - icon="angle-left" + icon="chevron-lg-left" size="medium" title="Go to previous design" variant="default" @@ -29,7 +29,7 @@ exports[`Design management pagination component renders navigation buttons 1`] = buttontextclasses="" category="primary" class="js-next-design" - icon="angle-right" + icon="chevron-lg-right" size="medium" title="Go to next design" variant="default" diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap index 6dfd57906d8..3c4aa0f4d3c 100644 --- a/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap @@ -56,7 +56,7 @@ exports[`Design management toolbar component renders design and updated data 1`] buttonclass="" buttonicon="archive" buttonsize="medium" - buttonvariant="warning" + buttonvariant="default" class="gl-ml-3" hasselecteddesigns="true" title="Archive design" diff --git a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap index 243cc9d891d..be736184e60 100644 --- a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap @@ -45,7 +45,7 @@ exports[`Design management index page designs renders loading icon 1`] = ` <gl-loading-icon-stub color="dark" label="Loading" - size="md" + size="lg" /> </div> diff --git a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap index 8f12dc8fb06..0f2857821ea 100644 --- a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap @@ -99,7 +99,7 @@ exports[`Design management design index page renders design index 1`] = ` variant="link" > Resolved Comments (1) - + </gl-button-stub> <gl-popover-stub @@ -112,8 +112,8 @@ exports[`Design management design index page renders design index 1`] = ` > <p> - Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below - + Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below + </p> <a @@ -144,19 +144,6 @@ exports[`Design management design index page renders design index 1`] = ` </div> `; -exports[`Design management design index page sets loading state 1`] = ` -<div - class="design-detail js-design-detail fixed-top gl-w-full gl-bottom-0 gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row" -> - <gl-loading-icon-stub - class="gl-align-self-center" - color="dark" - label="Loading" - size="xl" - /> -</div> -`; - exports[`Design management design index page with error GlAlert is rendered in correct position with correct content 1`] = ` <div class="design-detail js-design-detail fixed-top gl-w-full gl-bottom-0 gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row" @@ -185,8 +172,8 @@ exports[`Design management design index page with error GlAlert is rendered in c variant="danger" > - woops - + woops + </gl-alert-stub> </div> diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js index 55d0fabe402..17a299c5de1 100644 --- a/spec/frontend/design_management/pages/design/index_spec.js +++ b/spec/frontend/design_management/pages/design/index_spec.js @@ -91,7 +91,12 @@ describe('Design management design index page', () => { function createComponent( { loading = false } = {}, - { data = {}, intialRouteOptions = {}, provide = {} } = {}, + { + data = {}, + intialRouteOptions = {}, + provide = {}, + stubs = { ApolloMutation, DesignSidebar, DesignReplyForm }, + } = {}, ) { const $apollo = { queries: { @@ -109,11 +114,7 @@ describe('Design management design index page', () => { wrapper = shallowMount(DesignIndex, { propsData: { id: '1' }, mocks: { $apollo }, - stubs: { - ApolloMutation, - DesignSidebar, - DesignReplyForm, - }, + stubs, provide: { issueIid: '1', projectPath: 'project-path', @@ -139,7 +140,7 @@ describe('Design management design index page', () => { describe('when navigating to component', () => { it('applies fullscreen layout class', () => { jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockPageLayoutElement); - createComponent({ loading: true }); + createComponent({}, { stubs: {} }); expect(mockPageLayoutElement.classList.add).toHaveBeenCalledTimes(1); expect(mockPageLayoutElement.classList.add).toHaveBeenCalledWith( @@ -151,7 +152,7 @@ describe('Design management design index page', () => { describe('when navigating within the component', () => { it('`scale` prop of DesignPresentation component is 1', async () => { jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockPageLayoutElement); - createComponent({ loading: false }, { data: { design, scale: 2 } }); + createComponent({}, { data: { design, scale: 2 } }); await nextTick(); expect(findDesignPresentation().props('scale')).toBe(2); @@ -180,7 +181,8 @@ describe('Design management design index page', () => { it('sets loading state', () => { createComponent({ loading: true }); - expect(wrapper.element).toMatchSnapshot(); + expect(wrapper.find(DesignPresentation).props('isLoading')).toBe(true); + expect(wrapper.find(DesignSidebar).props('isLoading')).toBe(true); }); it('renders design index', () => { @@ -197,6 +199,7 @@ describe('Design management design index page', () => { design, markdownPreviewPath: '/project-path/preview_markdown?target_type=Issue', resolvedDiscussionsExpanded: false, + isLoading: false, }); }); diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index 87531e8b645..087655d10f7 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -4,6 +4,7 @@ import Vue, { nextTick } from 'vue'; import VueApollo, { ApolloMutation } from 'vue-apollo'; import VueRouter from 'vue-router'; +import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/utils'; import VueDraggable from 'vuedraggable'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -762,6 +763,25 @@ describe('Design management index page', () => { expect(findDesigns().at(0).props('id')).toBe('2'); }); + it.each` + breakpoint | reorderDisabled + ${'xs'} | ${true} + ${'sm'} | ${false} + ${'md'} | ${false} + ${'lg'} | ${false} + ${'xl'} | ${false} + `( + 'sets draggable disabled value to $reorderDisabled when breakpoint is $breakpoint', + async ({ breakpoint, reorderDisabled }) => { + jest.spyOn(breakpointInstance, 'getBreakpointSize').mockReturnValue(breakpoint); + + createComponentWithApollo({}); + await waitForPromises(); + + expect(draggableAttributes().disabled).toBe(reorderDisabled); + }, + ); + it('prevents reordering when reorderDesigns mutation is in progress', async () => { createComponentWithApollo({}); await moveDesigns(wrapper); diff --git a/spec/frontend/design_management/router_spec.js b/spec/frontend/design_management/router_spec.js index 03ab79712a4..b9c62334223 100644 --- a/spec/frontend/design_management/router_spec.js +++ b/spec/frontend/design_management/router_spec.js @@ -20,6 +20,8 @@ function factory(routeArg) { return mount(App, { router, + provide: { issueIid: '1' }, + stubs: { Toolbar: true }, mocks: { $apollo: { queries: { diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js index eee17e118a0..e52c5abbc7b 100644 --- a/spec/frontend/diffs/components/commit_item_spec.js +++ b/spec/frontend/diffs/components/commit_item_spec.js @@ -6,8 +6,6 @@ import Component from '~/diffs/components/commit_item.vue'; import { getTimeago } from '~/lib/utils/datetime_utility'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; -jest.mock('~/user_popovers'); - const TEST_AUTHOR_NAME = 'test'; const TEST_AUTHOR_EMAIL = 'test+test@gitlab.com'; const TEST_AUTHOR_GRAVATAR = `${TEST_HOST}/avatar/test?s=40`; diff --git a/spec/frontend/diffs/components/diff_expansion_cell_spec.js b/spec/frontend/diffs/components/diff_expansion_cell_spec.js index bd538996349..5ff0728b358 100644 --- a/spec/frontend/diffs/components/diff_expansion_cell_spec.js +++ b/spec/frontend/diffs/components/diff_expansion_cell_spec.js @@ -1,4 +1,3 @@ -import { getByText } from '@testing-library/dom'; import { mount } from '@vue/test-utils'; import { cloneDeep } from 'lodash'; import DiffExpansionCell from '~/diffs/components/diff_expansion_cell.vue'; @@ -81,7 +80,7 @@ describe('DiffExpansionCell', () => { const findExpandUp = (wrapper) => wrapper.find(EXPAND_UP_CLASS); const findExpandDown = (wrapper) => wrapper.find(EXPAND_DOWN_CLASS); - const findExpandAll = ({ element }) => getByText(element, 'Show all unchanged lines'); + const findExpandAll = (wrapper) => wrapper.find('.js-unfold-all'); describe('top row', () => { it('should have "expand up" and "show all" option', () => { @@ -90,9 +89,7 @@ describe('DiffExpansionCell', () => { }); expect(findExpandUp(wrapper).exists()).toBe(true); - expect(findExpandDown(wrapper).exists()).toBe(true); expect(findExpandUp(wrapper).attributes('disabled')).not.toBeDefined(); - expect(findExpandDown(wrapper).attributes('disabled')).toBeDefined(); expect(findExpandAll(wrapper)).not.toBe(null); }); }); @@ -114,9 +111,7 @@ describe('DiffExpansionCell', () => { }); expect(findExpandDown(wrapper).exists()).toBe(true); - expect(findExpandUp(wrapper).exists()).toBe(true); expect(findExpandDown(wrapper).attributes('disabled')).not.toBeDefined(); - expect(findExpandUp(wrapper).attributes('disabled')).toBeDefined(); expect(findExpandAll(wrapper)).not.toBe(null); }); }); @@ -144,9 +139,9 @@ describe('DiffExpansionCell', () => { newLineNumber, }); - const wrapper = createComponent({ file }); + const wrapper = createComponent({ file, lineCountBetween: 10 }); - findExpandAll(wrapper).click(); + findExpandAll(wrapper).trigger('click'); expect(store.dispatch).toHaveBeenCalledWith( 'diffs/loadMoreLines', diff --git a/spec/frontend/diffs/components/diff_file_header_spec.js b/spec/frontend/diffs/components/diff_file_header_spec.js index f22bd312a6d..d90afeb6b82 100644 --- a/spec/frontend/diffs/components/diff_file_header_spec.js +++ b/spec/frontend/diffs/components/diff_file_header_spec.js @@ -14,7 +14,6 @@ import { scrollToElement } from '~/lib/utils/common_utils'; import { truncateSha } from '~/lib/utils/text_utility'; import { __, sprintf } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import FileIcon from '~/vue_shared/components/file_icon.vue'; import testAction from '../../__helpers__/vuex_action_helper'; import diffDiscussionsMockData from '../mock_data/diff_discussions'; @@ -224,16 +223,6 @@ describe('DiffFileHeader component', () => { }); expect(findFileActions().exists()).toBe(false); }); - - it('renders submodule icon', () => { - createComponent({ - props: { - diffFile: submoduleDiffFile, - }, - }); - - expect(wrapper.find(FileIcon).props('submodule')).toBe(true); - }); }); describe('for any file', () => { diff --git a/spec/frontend/diffs/components/diff_line_note_form_spec.js b/spec/frontend/diffs/components/diff_line_note_form_spec.js index fb9dc22ce25..b59043168b8 100644 --- a/spec/frontend/diffs/components/diff_line_note_form_spec.js +++ b/spec/frontend/diffs/components/diff_line_note_form_spec.js @@ -64,6 +64,16 @@ describe('DiffLineNoteForm', () => { expect(confirmAction).toHaveBeenCalled(); }); + it('should only ask for confirmation once', () => { + // Never resolve so we can test what happens when triggered while "confirmAction" is loading + confirmAction.mockImplementation(() => new Promise(() => {})); + + findNoteForm().vm.$emit('cancelForm', true, true); + findNoteForm().vm.$emit('cancelForm', true, true); + + expect(confirmAction).toHaveBeenCalledTimes(1); + }); + it('should not ask for confirmation when one of the params false', () => { confirmAction.mockResolvedValueOnce(false); diff --git a/spec/frontend/diffs/components/diff_view_spec.js b/spec/frontend/diffs/components/diff_view_spec.js index f982749d1de..dfbe30e460b 100644 --- a/spec/frontend/diffs/components/diff_view_spec.js +++ b/spec/frontend/diffs/components/diff_view_spec.js @@ -49,22 +49,6 @@ describe('DiffView', () => { return shallowMount(DiffView, { propsData, store, stubs }); }; - it('renders a match line', () => { - const wrapper = createWrapper({ - diffLines: [ - { - isMatchLineLeft: true, - left: { - rich_text: '@@ -4,12 +4,12 @@ import createFlash from '~/flash';', - lineDraft: {}, - }, - }, - ], - }); - expect(wrapper.find(DiffExpansionCell).exists()).toBe(true); - expect(wrapper.text()).toContain("@@ -4,12 +4,12 @@ import createFlash from '~/flash';"); - }); - it.each` type | side | container | sides | total ${'parallel'} | ${'left'} | ${'.old'} | ${{ left: { lineDraft: {}, renderDiscussion: true }, right: { lineDraft: {}, renderDiscussion: true } }} | ${2} diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index 8ae51a58819..6f55f76d7b5 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -30,13 +30,6 @@ describe('DiffsStoreUtils', () => { }); }); - describe('getReversePosition', () => { - it('should return correct line position name', () => { - expect(utils.getReversePosition(LINE_POSITION_RIGHT)).toEqual(LINE_POSITION_LEFT); - expect(utils.getReversePosition(LINE_POSITION_LEFT)).toEqual(LINE_POSITION_RIGHT); - }); - }); - describe('findIndexInInlineLines', () => { const expectSet = (method, lines, invalidLines) => { expect(method(lines, { oldLineNumber: 3, newLineNumber: 5 })).toEqual(4); diff --git a/spec/frontend/editor/helpers.js b/spec/frontend/editor/helpers.js index 252d783ad6d..48d83a87a6e 100644 --- a/spec/frontend/editor/helpers.js +++ b/spec/frontend/editor/helpers.js @@ -49,6 +49,12 @@ export const SEConstExt = () => { }; }; +export const SEExtWithoutAPI = () => { + return { + extensionName: 'SEExtWithoutAPI', + }; +}; + export class SEWithSetupExt { static get extensionName() { return 'SEWithSetupExt'; diff --git a/spec/frontend/editor/schema/ci/ci_schema_spec.js b/spec/frontend/editor/schema/ci/ci_schema_spec.js index 628c34a27c1..c59806a5d60 100644 --- a/spec/frontend/editor/schema/ci/ci_schema_spec.js +++ b/spec/frontend/editor/schema/ci/ci_schema_spec.js @@ -38,6 +38,7 @@ const ajv = new Ajv({ strictTuples: false, allowMatchingProperties: true, }); +ajv.addKeyword('markdownDescription'); AjvFormats(ajv); const schema = ajv.compile(CiSchema); diff --git a/spec/frontend/editor/source_editor_extension_spec.js b/spec/frontend/editor/source_editor_extension_spec.js index c5fa795f3b7..78453aaa491 100644 --- a/spec/frontend/editor/source_editor_extension_spec.js +++ b/spec/frontend/editor/source_editor_extension_spec.js @@ -54,6 +54,7 @@ describe('Editor Extension', () => { ${helpers.SEClassExtension} | ${['shared', 'classExtMethod']} ${helpers.SEFnExtension} | ${['fnExtMethod']} ${helpers.SEConstExt} | ${['constExtMethod']} + ${helpers.SEExtWithoutAPI} | ${[]} `('correctly returns API for $definition', ({ definition, expectedKeys }) => { const extension = new EditorExtension({ definition }); const expectedApi = Object.fromEntries( diff --git a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js index 1926f3e268e..fe20c23e4d7 100644 --- a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js +++ b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js @@ -1,4 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; +import { Emitter } from 'monaco-editor'; +import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; import { @@ -64,7 +66,6 @@ describe('Markdown Live Preview Extension for Source Editor', () => { afterEach(() => { instance.dispose(); - editorEl.remove(); mockAxios.restore(); resetHTMLFixture(); }); @@ -75,11 +76,47 @@ describe('Markdown Live Preview Extension for Source Editor', () => { actions: expect.any(Object), shown: false, modelChangeListener: undefined, + layoutChangeListener: { + dispose: expect.anything(), + }, path: previewMarkdownPath, actionShowPreviewCondition: expect.any(Object), }); }); + describe('onDidLayoutChange', () => { + const emitter = new Emitter(); + let layoutSpy; + + useFakeRequestAnimationFrame(); + + beforeEach(() => { + instance.unuse(extension); + instance.onDidLayoutChange = emitter.event; + extension = instance.use({ + definition: EditorMarkdownPreviewExtension, + setupOptions: { previewMarkdownPath }, + }); + layoutSpy = jest.spyOn(instance, 'layout'); + }); + + it('does not trigger the layout when the preview is not active [default]', async () => { + expect(instance.markdownPreview.shown).toBe(false); + expect(layoutSpy).not.toHaveBeenCalled(); + await emitter.fire(); + expect(layoutSpy).not.toHaveBeenCalled(); + }); + + it('triggers the layout if the preview panel is opened', async () => { + expect(layoutSpy).not.toHaveBeenCalled(); + instance.togglePreview(); + layoutSpy.mockReset(); + + await emitter.fire(); + expect(layoutSpy).toHaveBeenCalledTimes(1); + }); + }); + describe('model change listener', () => { let cleanupSpy; let actionSpy; @@ -111,6 +148,9 @@ describe('Markdown Live Preview Extension for Source Editor', () => { mockAxios.onPost().reply(200, { body: responseData }); await togglePreview(); }); + afterEach(() => { + jest.clearAllMocks(); + }); it('removes the registered buttons from the toolbar', () => { expect(instance.toolbar.removeItems).not.toHaveBeenCalled(); @@ -175,6 +215,31 @@ describe('Markdown Live Preview Extension for Source Editor', () => { instance.unuse(extension); expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true); }); + + it('disposes the layoutChange listener and does not re-layout on layout changes', () => { + expect(instance.markdownPreview.layoutChangeListener).toBeDefined(); + instance.unuse(extension); + + expect(instance.markdownPreview?.layoutChangeListener).toBeUndefined(); + }); + + it('does not trigger the re-layout after instance is unused', async () => { + const emitter = new Emitter(); + + instance.unuse(extension); + instance.onDidLayoutChange = emitter.event; + + // we have to re-use the extension to pick up the emitter + extension = instance.use({ + definition: EditorMarkdownPreviewExtension, + setupOptions: { previewMarkdownPath }, + }); + instance.unuse(extension); + const layoutSpy = jest.spyOn(instance, 'layout'); + + await emitter.fire(); + expect(layoutSpy).not.toHaveBeenCalled(); + }); }); describe('fetchPreview', () => { diff --git a/spec/frontend/editor/source_editor_spec.js b/spec/frontend/editor/source_editor_spec.js index b3d914e6755..74aae7b899b 100644 --- a/spec/frontend/editor/source_editor_spec.js +++ b/spec/frontend/editor/source_editor_spec.js @@ -92,7 +92,7 @@ describe('Base editor', () => { expect(monacoEditor.createModel).toHaveBeenCalledWith( blobContent, - undefined, + 'markdown', expect.objectContaining({ path: uriFilePath, }), @@ -117,7 +117,7 @@ describe('Base editor', () => { expect(modelSpy).toHaveBeenCalledWith( blobContent, - undefined, + 'markdown', expect.objectContaining({ path: uriFilePath, }), @@ -177,6 +177,29 @@ describe('Base editor', () => { expect(layoutSpy).toHaveBeenCalled(); }); + + it.each` + params | expectedLanguage + ${{}} | ${'markdown'} + ${{ blobPath: undefined }} | ${'plaintext'} + ${{ blobPath: undefined, language: 'ruby' }} | ${'ruby'} + ${{ language: 'go' }} | ${'go'} + ${{ blobPath: undefined, language: undefined }} | ${'plaintext'} + `( + 'correctly sets $expectedLanguage on the model when $params are passed', + ({ params, expectedLanguage }) => { + jest.spyOn(monacoEditor, 'createModel'); + editor.createInstance({ + ...defaultArguments, + ...params, + }); + expect(monacoEditor.createModel).toHaveBeenCalledWith( + expect.anything(), + expectedLanguage, + expect.anything(), + ); + }, + ); }); describe('instance of the Diff Editor', () => { @@ -210,7 +233,7 @@ describe('Base editor', () => { expect(modelSpy).toHaveBeenCalledTimes(2); expect(modelSpy.mock.calls[0]).toEqual([ blobContent, - undefined, + 'markdown', expect.objectContaining({ path: uriFilePath, }), diff --git a/spec/frontend/editor/source_editor_webide_ext_spec.js b/spec/frontend/editor/source_editor_webide_ext_spec.js new file mode 100644 index 00000000000..096b6b1646f --- /dev/null +++ b/spec/frontend/editor/source_editor_webide_ext_spec.js @@ -0,0 +1,55 @@ +import { Emitter } from 'monaco-editor'; +import { setHTMLFixture } from 'helpers/fixtures'; +import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext'; +import SourceEditor from '~/editor/source_editor'; + +describe('Source Editor Web IDE Extension', () => { + let editorEl; + let editor; + let instance; + + beforeEach(() => { + setHTMLFixture('<div id="editor" data-editor-loading></div>'); + editorEl = document.getElementById('editor'); + editor = new SourceEditor(); + }); + afterEach(() => {}); + + describe('onSetup', () => { + it.each` + width | renderSideBySide + ${'0'} | ${false} + ${'699px'} | ${false} + ${'700px'} | ${true} + `( + "correctly renders the Diff Editor when the parent element's width is $width", + ({ width, renderSideBySide }) => { + editorEl.style.width = width; + instance = editor.createDiffInstance({ el: editorEl }); + + const sideBySideSpy = jest.spyOn(instance, 'updateOptions'); + instance.use({ definition: EditorWebIdeExtension }); + + expect(sideBySideSpy).toBeCalledWith({ renderSideBySide }); + }, + ); + + it('re-renders the Diff Editor when layout of the modified editor is changed', async () => { + const emitter = new Emitter(); + editorEl.style.width = '700px'; + + instance = editor.createDiffInstance({ el: editorEl }); + instance.getModifiedEditor().onDidLayoutChange = emitter.event; + instance.use({ definition: EditorWebIdeExtension }); + + const sideBySideSpy = jest.spyOn(instance, 'updateOptions'); + await emitter.fire(); + + expect(sideBySideSpy).toBeCalledWith({ renderSideBySide: true }); + + editorEl.style.width = '0px'; + await emitter.fire(); + expect(sideBySideSpy).toBeCalledWith({ renderSideBySide: false }); + }); + }); +}); diff --git a/spec/frontend/emoji/index_spec.js b/spec/frontend/emoji/index_spec.js index cc037586496..dc8f50e0e4b 100644 --- a/spec/frontend/emoji/index_spec.js +++ b/spec/frontend/emoji/index_spec.js @@ -24,6 +24,7 @@ import isEmojiUnicodeSupported, { isHorceRacingSkinToneComboEmoji, isPersonZwjEmoji, } from '~/emoji/support/is_emoji_unicode_supported'; +import { NEUTRAL_INTENT_MULTIPLIER } from '~/emoji/constants'; const emptySupportMap = { personZwj: false, @@ -436,14 +437,28 @@ describe('emoji', () => { it.each([undefined, null, ''])("should return all emoji when the input is '%s'", (input) => { const search = searchEmoji(input); - const expected = Object.keys(validEmoji).map((name) => { - return { - emoji: mockEmojiData[name], - field: 'd', - fieldValue: mockEmojiData[name].d, - score: 0, - }; - }); + const expected = Object.keys(validEmoji) + .map((name) => { + let score = NEUTRAL_INTENT_MULTIPLIER; + + // Positive intent value retrieved from ~/emoji/intents.json + if (name === 'thumbsup') { + score = 0.5; + } + + // Negative intent value retrieved from ~/emoji/intents.json + if (name === 'thumbsdown') { + score = 1.5; + } + + return { + emoji: mockEmojiData[name], + field: 'd', + fieldValue: mockEmojiData[name].d, + score, + }; + }) + .sort(sortEmoji); expect(search).toEqual(expected); }); @@ -457,7 +472,7 @@ describe('emoji', () => { name: 'atom', field: 'e', fieldValue: 'atom', - score: 0, + score: NEUTRAL_INTENT_MULTIPLIER, }, ], ], @@ -469,7 +484,7 @@ describe('emoji', () => { name: 'atom', field: 'alias', fieldValue: 'atom_symbol', - score: 4, + score: 16, }, ], ], @@ -481,7 +496,7 @@ describe('emoji', () => { name: 'atom', field: 'alias', fieldValue: 'atom_symbol', - score: 0, + score: NEUTRAL_INTENT_MULTIPLIER, }, ], ], @@ -509,7 +524,7 @@ describe('emoji', () => { { name: 'atom', field: 'd', - score: 0, + score: NEUTRAL_INTENT_MULTIPLIER, }, ], ], @@ -521,7 +536,7 @@ describe('emoji', () => { { name: 'atom', field: 'd', - score: 0, + score: NEUTRAL_INTENT_MULTIPLIER, }, ], ], @@ -533,7 +548,7 @@ describe('emoji', () => { { name: 'grey_question', field: 'name', - score: 5, + score: 32, }, ], ], @@ -544,7 +559,7 @@ describe('emoji', () => { { name: 'grey_question', field: 'd', - score: 24, + score: 16777216, }, ], ], @@ -553,14 +568,14 @@ describe('emoji', () => { 'heart', [ { - name: 'black_heart', - field: 'd', - score: 6, - }, - { name: 'heart', field: 'name', - score: 0, + score: NEUTRAL_INTENT_MULTIPLIER, + }, + { + name: 'black_heart', + field: 'd', + score: 64, }, ], ], @@ -569,14 +584,14 @@ describe('emoji', () => { 'HEART', [ { - name: 'black_heart', - field: 'd', - score: 6, - }, - { name: 'heart', field: 'name', - score: 0, + score: NEUTRAL_INTENT_MULTIPLIER, + }, + { + name: 'black_heart', + field: 'd', + score: 64, }, ], ], @@ -585,14 +600,30 @@ describe('emoji', () => { 'star', [ { + name: 'star', + field: 'name', + score: NEUTRAL_INTENT_MULTIPLIER, + }, + { name: 'custard', field: 'd', - score: 2, + score: 4, + }, + ], + ], + [ + 'searching for emoji with intentions assigned', + 'thumbs', + [ + { + name: 'thumbsup', + field: 'd', + score: 0.5, }, { - name: 'star', - field: 'name', - score: 0, + name: 'thumbsdown', + field: 'd', + score: 1.5, }, ], ], @@ -619,10 +650,10 @@ describe('emoji', () => { [ { score: 10, fieldValue: '', emoji: { name: 'a' } }, { score: 5, fieldValue: '', emoji: { name: 'b' } }, - { score: 0, fieldValue: '', emoji: { name: 'c' } }, + { score: 1, fieldValue: '', emoji: { name: 'c' } }, ], [ - { score: 0, fieldValue: '', emoji: { name: 'c' } }, + { score: 1, fieldValue: '', emoji: { name: 'c' } }, { score: 5, fieldValue: '', emoji: { name: 'b' } }, { score: 10, fieldValue: '', emoji: { name: 'a' } }, ], @@ -630,25 +661,25 @@ describe('emoji', () => { [ 'should correctly sort by fieldValue', [ - { score: 0, fieldValue: 'y', emoji: { name: 'b' } }, - { score: 0, fieldValue: 'x', emoji: { name: 'a' } }, - { score: 0, fieldValue: 'z', emoji: { name: 'c' } }, + { score: 1, fieldValue: 'y', emoji: { name: 'b' } }, + { score: 1, fieldValue: 'x', emoji: { name: 'a' } }, + { score: 1, fieldValue: 'z', emoji: { name: 'c' } }, ], [ - { score: 0, fieldValue: 'x', emoji: { name: 'a' } }, - { score: 0, fieldValue: 'y', emoji: { name: 'b' } }, - { score: 0, fieldValue: 'z', emoji: { name: 'c' } }, + { score: 1, fieldValue: 'x', emoji: { name: 'a' } }, + { score: 1, fieldValue: 'y', emoji: { name: 'b' } }, + { score: 1, fieldValue: 'z', emoji: { name: 'c' } }, ], ], [ 'should correctly sort by score and then by fieldValue (in order)', [ { score: 5, fieldValue: 'y', emoji: { name: 'c' } }, - { score: 0, fieldValue: 'z', emoji: { name: 'a' } }, + { score: 1, fieldValue: 'z', emoji: { name: 'a' } }, { score: 5, fieldValue: 'x', emoji: { name: 'b' } }, ], [ - { score: 0, fieldValue: 'z', emoji: { name: 'a' } }, + { score: 1, fieldValue: 'z', emoji: { name: 'a' } }, { score: 5, fieldValue: 'x', emoji: { name: 'b' } }, { score: 5, fieldValue: 'y', emoji: { name: 'c' } }, ], @@ -656,7 +687,7 @@ describe('emoji', () => { ]; it.each(testCases)('%s', (_, scoredItems, expected) => { - expect(sortEmoji(scoredItems)).toEqual(expected); + expect(scoredItems.sort(sortEmoji)).toEqual(expected); }); }); }); diff --git a/spec/frontend/emoji/utils_spec.js b/spec/frontend/emoji/utils_spec.js new file mode 100644 index 00000000000..397388ca0ae --- /dev/null +++ b/spec/frontend/emoji/utils_spec.js @@ -0,0 +1,15 @@ +import { getEmojiScoreWithIntent } from '~/emoji/utils'; + +describe('Utils', () => { + describe('getEmojiScoreWithIntent', () => { + it.each` + emojiName | baseScore | finalScore + ${'thumbsup'} | ${1} | ${1} + ${'thumbsdown'} | ${1} | ${3} + ${'neutralemoji'} | ${1} | ${2} + ${'zerobaseemoji'} | ${0} | ${1} + `('returns the correct score for $emojiName', ({ emojiName, baseScore, finalScore }) => { + expect(getEmojiScoreWithIntent(emojiName, baseScore)).toBe(finalScore); + }); + }); +}); diff --git a/spec/frontend/environments/deploy_board_wrapper_spec.js b/spec/frontend/environments/deploy_board_wrapper_spec.js index c8e6df4d324..49eed68fa11 100644 --- a/spec/frontend/environments/deploy_board_wrapper_spec.js +++ b/spec/frontend/environments/deploy_board_wrapper_spec.js @@ -57,7 +57,7 @@ describe('~/environments/components/deploy_board_wrapper.vue', () => { it('is collapsed by default', () => { expect(collapse.attributes('visible')).toBeUndefined(); - expect(icon.props('name')).toBe('angle-right'); + expect(icon.props('name')).toBe('chevron-lg-right'); }); it('opens on click', async () => { @@ -65,7 +65,7 @@ describe('~/environments/components/deploy_board_wrapper.vue', () => { expect(button.attributes('aria-label')).toBe(__('Collapse')); expect(collapse.attributes('visible')).toBe('visible'); - expect(icon.props('name')).toBe('angle-down'); + expect(icon.props('name')).toBe('chevron-lg-down'); const deployBoard = findDeployBoard(); expect(deployBoard.exists()).toBe(true); diff --git a/spec/frontend/environments/environment_folder_spec.js b/spec/frontend/environments/environment_folder_spec.js index 37b897bf65d..48624f2324b 100644 --- a/spec/frontend/environments/environment_folder_spec.js +++ b/spec/frontend/environments/environment_folder_spec.js @@ -82,7 +82,7 @@ describe('~/environments/components/environments_folder.vue', () => { expect(collapse.attributes('visible')).toBeUndefined(); const iconNames = icons.wrappers.map((i) => i.props('name')).slice(0, 2); - expect(iconNames).toEqual(['angle-right', 'folder-o']); + expect(iconNames).toEqual(['chevron-lg-right', 'folder-o']); expect(folderName.classes('gl-font-weight-bold')).toBe(false); expect(link.exists()).toBe(false); }); @@ -95,7 +95,7 @@ describe('~/environments/components/environments_folder.vue', () => { expect(button.attributes('aria-label')).toBe(__('Collapse')); expect(collapse.attributes('visible')).toBe('visible'); const iconNames = icons.wrappers.map((i) => i.props('name')).slice(0, 2); - expect(iconNames).toEqual(['angle-down', 'folder-open']); + expect(iconNames).toEqual(['chevron-lg-down', 'folder-open']); expect(folderName.classes('gl-font-weight-bold')).toBe(true); expect(link.attributes('href')).toBe(nestedEnvironment.latest.folderPath); diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js index cf0c8a7e7ca..a151595bf64 100644 --- a/spec/frontend/environments/new_environment_item_spec.js +++ b/spec/frontend/environments/new_environment_item_spec.js @@ -374,7 +374,7 @@ describe('~/environments/components/new_environment_item.vue', () => { it('is collapsed by default', () => { expect(collapse.attributes('visible')).toBeUndefined(); - expect(icon.props('name')).toEqual('angle-right'); + expect(icon.props('name')).toBe('chevron-lg-right'); expect(environmentName.classes('gl-font-weight-bold')).toBe(false); }); @@ -385,7 +385,7 @@ describe('~/environments/components/new_environment_item.vue', () => { expect(button.attributes('aria-label')).toBe(__('Collapse')); expect(collapse.attributes('visible')).toBe('visible'); - expect(icon.props('name')).toEqual('angle-down'); + expect(icon.props('name')).toBe('chevron-lg-down'); expect(environmentName.classes('gl-font-weight-bold')).toBe(true); expect(findDeployment().isVisible()).toBe(true); }); diff --git a/spec/frontend/error_tracking_settings/components/app_spec.js b/spec/frontend/error_tracking_settings/components/app_spec.js index 4a0bbb1acbe..c660c9c4a99 100644 --- a/spec/frontend/error_tracking_settings/components/app_spec.js +++ b/spec/frontend/error_tracking_settings/components/app_spec.js @@ -177,7 +177,7 @@ describe('error tracking settings app', () => { const clipBoardButton = findDsnSettings().findComponent(ClipboardButton); expect(clipBoardInput.props('value')).toBe(TEST_GITLAB_DSN); - expect(clipBoardInput.attributes('readonly')).toBeTruthy(); + expect(clipBoardInput.attributes('readonly')).toBe(''); expect(clipBoardButton.props('text')).toBe(TEST_GITLAB_DSN); }); }); diff --git a/spec/frontend/feature_flags/components/new_feature_flag_spec.js b/spec/frontend/feature_flags/components/new_feature_flag_spec.js index 9c1657bc0d2..688ba54f919 100644 --- a/spec/frontend/feature_flags/components/new_feature_flag_spec.js +++ b/spec/frontend/feature_flags/components/new_feature_flag_spec.js @@ -61,7 +61,7 @@ describe('New feature flag form', () => { }); it('renders form title', () => { - expect(wrapper.find('h3').text()).toEqual('New feature flag'); + expect(wrapper.text()).toContain('New feature flag'); }); it('should render feature flag form', () => { diff --git a/spec/frontend/fixtures/services.rb b/spec/frontend/fixtures/integrations.rb index f0bb8fb962f..1bafb0bfe78 100644 --- a/spec/frontend/fixtures/services.rb +++ b/spec/frontend/fixtures/integrations.rb @@ -2,11 +2,11 @@ require 'spec_helper' -RSpec.describe Projects::ServicesController, '(JavaScript fixtures)', type: :controller do +RSpec.describe Projects::Settings::IntegrationsController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} - let(:project) { create(:project_empty_repo, namespace: namespace, path: 'services-project') } + let(:project) { create(:project_empty_repo, namespace: namespace, path: 'integrations-project') } let!(:service) { create(:custom_issue_tracker_integration, project: project) } let(:user) { project.first_owner } @@ -20,7 +20,7 @@ RSpec.describe Projects::ServicesController, '(JavaScript fixtures)', type: :con remove_repository(project) end - it 'services/edit_service.html' do + it 'settings/integrations/edit.html' do get :edit, params: { namespace_id: namespace, project_id: project, diff --git a/spec/frontend/fixtures/prometheus_service.rb b/spec/frontend/fixtures/prometheus_integration.rb index aed73dc1096..883dbb929a2 100644 --- a/spec/frontend/fixtures/prometheus_service.rb +++ b/spec/frontend/fixtures/prometheus_integration.rb @@ -2,11 +2,11 @@ require 'spec_helper' -RSpec.describe Projects::ServicesController, '(JavaScript fixtures)', type: :controller do +RSpec.describe Projects::Settings::IntegrationsController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} - let(:project) { create(:project_empty_repo, namespace: namespace, path: 'services-project') } + let(:project) { create(:project_empty_repo, namespace: namespace, path: 'integrations-project') } let!(:integration) { create(:prometheus_integration, project: project) } let(:user) { project.first_owner } @@ -20,7 +20,7 @@ RSpec.describe Projects::ServicesController, '(JavaScript fixtures)', type: :con remove_repository(project) end - it 'services/prometheus/prometheus_service.html' do + it 'integrations/prometheus/prometheus_integration.html' do get :edit, params: { namespace_id: namespace, project_id: project, diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb index e17e73a93c4..a79982fa647 100644 --- a/spec/frontend/fixtures/runner.rb +++ b/spec/frontend/fixtures/runner.rb @@ -26,6 +26,12 @@ RSpec.describe 'Runner (JavaScript fixtures)' do remove_repository(project) end + before do + allow(Gitlab::Ci::RunnerUpgradeCheck.instance) + .to receive(:check_runner_upgrade_status) + .and_return(:not_available) + end + describe do before do sign_in(admin) diff --git a/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js b/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js index 8220ea16342..eef5dc86c1a 100644 --- a/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js +++ b/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js @@ -117,7 +117,7 @@ describe('FrequentItemsListItemComponent', () => { link.vm.$emit('click'); expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_link', { - label: 'projects_dropdown_frequent_items_list_item', + label: 'projects_dropdown_frequent_items_list_item_git_lab_community_edition', }); }); }); diff --git a/spec/frontend/google_tag_manager/index_spec.js b/spec/frontend/google_tag_manager/index_spec.js index 6412fe8bb33..50811f43fc3 100644 --- a/spec/frontend/google_tag_manager/index_spec.js +++ b/spec/frontend/google_tag_manager/index_spec.js @@ -13,6 +13,7 @@ import { trackCheckout, trackTransaction, trackAddToCartUsageTab, + getNamespaceId, } from '~/google_tag_manager'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { logError } from '~/lib/logger'; @@ -401,6 +402,7 @@ describe('~/google_tag_manager/index', () => { { brand: 'GitLab', category: 'DevOps', + dimension36: 'not available', id, name, price: revenue.toString(), @@ -478,4 +480,26 @@ describe('~/google_tag_manager/index', () => { resetHTMLFixture(); }); }); + + describe('when getting the namespace_id from Snowplow standard context', () => { + describe('when window.gl.snowplowStandardContext.data.namespace_id has a value', () => { + beforeEach(() => { + window.gl = { snowplowStandardContext: { data: { namespace_id: '321' } } }; + }); + + it('returns the value', () => { + expect(getNamespaceId()).toBe('321'); + }); + }); + + describe('when window.gl.snowplowStandardContext.data.namespace_id is undefined', () => { + beforeEach(() => { + window.gl = {}; + }); + + it('returns a placeholder value', () => { + expect(getNamespaceId()).toBe('not available'); + }); + }); + }); }); diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js index 848e50c86ba..9e4666ffc70 100644 --- a/spec/frontend/groups/components/app_spec.js +++ b/spec/frontend/groups/components/app_spec.js @@ -10,8 +10,10 @@ import groupItemComponent from '~/groups/components/group_item.vue'; import eventHub from '~/groups/event_hub'; import GroupsService from '~/groups/service/groups_service'; import GroupsStore from '~/groups/store/groups_store'; +import EmptyState from '~/groups/components/empty_state.vue'; import axios from '~/lib/utils/axios_utils'; import * as urlUtilities from '~/lib/utils/url_utility'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { mockEndpoint, @@ -38,17 +40,23 @@ describe('AppComponent', () => { const store = new GroupsStore({ hideProjects: false }); const service = new GroupsService(mockEndpoint); - const createShallowComponent = (hideProjects = false) => { + const createShallowComponent = ({ propsData = {}, provide = {} } = {}) => { store.state.pageInfo = mockPageInfo; wrapper = shallowMount(appComponent, { propsData: { store, service, - hideProjects, + hideProjects: false, + containerId: 'js-groups-tree', + ...propsData, }, mocks: { $toast, }, + provide: { + renderEmptyState: false, + ...provide, + }, }); vm = wrapper.vm; }; @@ -64,6 +72,14 @@ describe('AppComponent', () => { Vue.component('GroupFolder', groupFolderComponent); Vue.component('GroupItem', groupItemComponent); + document.body.innerHTML = ` + <div id="js-groups-tree"> + <div class="empty-state hidden" data-testid="legacy-empty-state"> + <p>There are no projects shared with this group yet</p> + </div> + </div> + `; + createShallowComponent(); getGroupsSpy = jest.spyOn(vm.service, 'getGroups'); await nextTick(); @@ -386,7 +402,10 @@ describe('AppComponent', () => { expect(vm.store.setSearchedGroups).toHaveBeenCalledWith(mockGroups); }); - it('should set `isSearchEmpty` prop based on groups count', () => { + it('should set `isSearchEmpty` prop based on groups count and `filter` query param', () => { + setWindowLocation('?filter=foobar'); + createShallowComponent(); + vm.updateGroups(mockGroups); expect(vm.isSearchEmpty).toBe(false); @@ -395,6 +414,47 @@ describe('AppComponent', () => { expect(vm.isSearchEmpty).toBe(true); }); + + describe.each` + action | groups | fromSearch | renderEmptyState | expected + ${'subgroups_and_projects'} | ${[]} | ${false} | ${true} | ${true} + ${''} | ${[]} | ${false} | ${true} | ${false} + ${'subgroups_and_projects'} | ${mockGroups} | ${false} | ${true} | ${false} + ${'subgroups_and_projects'} | ${[]} | ${true} | ${true} | ${false} + `( + 'when `action` is $action, `groups` is $groups, `fromSearch` is $fromSearch, and `renderEmptyState` is $renderEmptyState', + ({ action, groups, fromSearch, renderEmptyState, expected }) => { + it(expected ? 'renders empty state' : 'does not render empty state', async () => { + createShallowComponent({ + propsData: { action }, + provide: { renderEmptyState }, + }); + + vm.updateGroups(groups, fromSearch); + + await nextTick(); + + expect(wrapper.findComponent(EmptyState).exists()).toBe(expected); + }); + }, + ); + }); + + describe('when `action` is subgroups_and_projects, `groups` is [], `fromSearch` is `false`, and `renderEmptyState` is `false`', () => { + it('renders legacy empty state', async () => { + createShallowComponent({ + propsData: { action: 'subgroups_and_projects' }, + provide: { renderEmptyState: false }, + }); + + vm.updateGroups([], false); + + await nextTick(); + + expect( + document.querySelector('[data-testid="legacy-empty-state"]').classList.contains('hidden'), + ).toBe(false); + }); }); }); @@ -419,7 +479,7 @@ describe('AppComponent', () => { }); it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', async () => { - createShallowComponent(true); + createShallowComponent({ propsData: { hideProjects: true } }); await nextTick(); expect(vm.searchEmptyMessage).toBe('No groups matched your search'); }); diff --git a/spec/frontend/groups/components/empty_state_spec.js b/spec/frontend/groups/components/empty_state_spec.js new file mode 100644 index 00000000000..c0e71e814d0 --- /dev/null +++ b/spec/frontend/groups/components/empty_state_spec.js @@ -0,0 +1,78 @@ +import { GlEmptyState } from '@gitlab/ui'; + +import { mountExtended } from 'jest/__helpers__/vue_test_utils_helper'; +import EmptyState from '~/groups/components/empty_state.vue'; + +let wrapper; + +const defaultProvide = { + newProjectIllustration: '/assets/illustrations/project-create-new-sm.svg', + newProjectPath: '/projects/new?namespace_id=231', + newSubgroupIllustration: '/assets/illustrations/group-new.svg', + newSubgroupPath: '/groups/new?parent_id=231', + emptySubgroupIllustration: '/assets/illustrations/empty-state/empty-subgroup-md.svg', + canCreateSubgroups: true, + canCreateProjects: true, +}; + +const createComponent = ({ provide = {} } = {}) => { + wrapper = mountExtended(EmptyState, { + provide: { + ...defaultProvide, + ...provide, + }, + }); +}; + +afterEach(() => { + wrapper.destroy(); +}); + +const findNewSubgroupLink = () => + wrapper.findByRole('link', { + name: new RegExp(EmptyState.i18n.withLinks.subgroup.title), + }); +const findNewProjectLink = () => + wrapper.findByRole('link', { + name: new RegExp(EmptyState.i18n.withLinks.project.title), + }); +const findNewSubgroupIllustration = () => + wrapper.findByRole('img', { name: EmptyState.i18n.withLinks.subgroup.title }); +const findNewProjectIllustration = () => + wrapper.findByRole('img', { name: EmptyState.i18n.withLinks.project.title }); + +describe('EmptyState', () => { + describe('when user has permission to create a subgroup', () => { + it('renders `Create new subgroup` link', () => { + createComponent(); + + expect(findNewSubgroupLink().attributes('href')).toBe(defaultProvide.newSubgroupPath); + expect(findNewSubgroupIllustration().attributes('src')).toBe( + defaultProvide.newSubgroupIllustration, + ); + }); + }); + + describe('when user has permission to create a project', () => { + it('renders `Create new project` link', () => { + createComponent(); + + expect(findNewProjectLink().attributes('href')).toBe(defaultProvide.newProjectPath); + expect(findNewProjectIllustration().attributes('src')).toBe( + defaultProvide.newProjectIllustration, + ); + }); + }); + + describe('when user does not have permissions to create a project or a subgroup', () => { + it('renders empty state', () => { + createComponent({ provide: { canCreateSubgroups: false, canCreateProjects: false } }); + + expect(wrapper.find(GlEmptyState).props()).toMatchObject({ + title: EmptyState.i18n.withoutLinks.title, + description: EmptyState.i18n.withoutLinks.description, + svgPath: defaultProvide.emptySubgroupIllustration, + }); + }); + }); +}); diff --git a/spec/frontend/groups/components/group_name_and_path_spec.js b/spec/frontend/groups/components/group_name_and_path_spec.js new file mode 100644 index 00000000000..eaa0801ab50 --- /dev/null +++ b/spec/frontend/groups/components/group_name_and_path_spec.js @@ -0,0 +1,347 @@ +import { merge } from 'lodash'; +import { GlAlert } from '@gitlab/ui'; +import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import GroupNameAndPath from '~/groups/components/group_name_and_path.vue'; +import { getGroupPathAvailability } from '~/rest_api'; +import { createAlert } from '~/flash'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +jest.mock('~/flash'); +jest.mock('~/rest_api', () => ({ + getGroupPathAvailability: jest.fn(), +})); + +describe('GroupNameAndPath', () => { + let wrapper; + + const mockGroupName = 'My awesome group'; + const mockGroupUrl = 'my-awesome-group'; + const mockGroupUrlSuggested = 'my-awesome-group1'; + + const defaultProvide = { + basePath: 'http://gitlab.com/', + fields: { + name: { name: 'group[name]', id: 'group_name', value: '' }, + path: { + name: 'group[path]', + id: 'group_path', + value: '', + maxLength: 255, + pattern: '[a-zA-Z0-9_\\.][a-zA-Z0-9_\\-\\.]*[a-zA-Z0-9_\\-]|[a-zA-Z0-9_]', + }, + parentId: { name: 'group[parent_id]', id: 'group_parent_id', value: '1' }, + groupId: { name: 'group[id]', id: 'group_id', value: '' }, + }, + mattermostEnabled: false, + }; + + const createComponent = ({ provide = {} } = {}) => { + wrapper = mountExtended(GroupNameAndPath, { provide: merge({}, defaultProvide, provide) }); + }; + const createComponentEditGroup = ({ path = mockGroupUrl } = {}) => { + createComponent({ + provide: { fields: { groupId: { value: '1' }, path: { value: path } } }, + }); + }; + + const findGroupNameField = () => wrapper.findByLabelText(GroupNameAndPath.i18n.inputs.name.label); + const findGroupUrlField = () => wrapper.findByLabelText(GroupNameAndPath.i18n.inputs.path.label); + const findAlert = () => extendedWrapper(wrapper.findComponent(GlAlert)); + + const apiMockAvailablePath = () => { + getGroupPathAvailability.mockResolvedValue({ + data: { exists: false, suggests: [] }, + }); + }; + const apiMockUnavailablePath = (suggests = [mockGroupUrlSuggested]) => { + getGroupPathAvailability.mockResolvedValue({ + data: { exists: true, suggests }, + }); + }; + const apiMockLoading = () => { + getGroupPathAvailability.mockImplementation(() => new Promise(() => {})); + }; + + const expectLoadingMessageExists = () => { + expect(wrapper.findByText(GroupNameAndPath.i18n.apiLoadingMessage).exists()).toBe(true); + }; + + describe('when user types in the `Group name` field', () => { + describe('when creating a new group', () => { + it('updates `Group URL` field as user types', async () => { + createComponent(); + + await findGroupNameField().setValue(mockGroupName); + + expect(findGroupUrlField().element.value).toBe(mockGroupUrl); + }); + }); + + describe('when editing a group', () => { + it('does not update `Group URL` field and does not call API', async () => { + const groupUrl = 'foo-bar'; + + createComponentEditGroup({ path: groupUrl }); + + await findGroupNameField().setValue(mockGroupName); + + expect(findGroupUrlField().element.value).toBe(groupUrl); + expect(getGroupPathAvailability).not.toHaveBeenCalled(); + }); + }); + + describe('when `Group URL` field has been manually entered', () => { + it('does not update `Group URL` field and does not call API', async () => { + apiMockAvailablePath(); + + createComponent(); + + await findGroupUrlField().setValue(mockGroupUrl); + await waitForPromises(); + + getGroupPathAvailability.mockClear(); + + await findGroupNameField().setValue('Foo bar'); + + expect(findGroupUrlField().element.value).toBe(mockGroupUrl); + expect(getGroupPathAvailability).not.toHaveBeenCalled(); + }); + }); + + it('shows loading message', async () => { + apiMockLoading(); + + createComponent(); + + await findGroupNameField().setValue(mockGroupName); + + expectLoadingMessageExists(); + }); + + describe('when path is available', () => { + it('does not update `Group URL` field', async () => { + apiMockAvailablePath(); + + createComponent(); + + await findGroupNameField().setValue(mockGroupName); + + expect(getGroupPathAvailability).toHaveBeenCalledWith( + mockGroupUrl, + defaultProvide.fields.parentId.value, + { signal: expect.any(AbortSignal) }, + ); + + await waitForPromises(); + + expect(findGroupUrlField().element.value).toBe(mockGroupUrl); + }); + }); + + describe('when path is not available', () => { + it('updates `Group URL` field', async () => { + apiMockUnavailablePath(); + + createComponent(); + + await findGroupNameField().setValue(mockGroupName); + await waitForPromises(); + + expect(findGroupUrlField().element.value).toBe(mockGroupUrlSuggested); + }); + }); + + describe('when API returns no suggestions', () => { + it('calls `createAlert`', async () => { + apiMockUnavailablePath([]); + + createComponent(); + + await findGroupNameField().setValue(mockGroupName); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: GroupNameAndPath.i18n.apiErrorMessage, + }); + }); + }); + + describe('when API call fails', () => { + it('calls `createAlert`', async () => { + getGroupPathAvailability.mockRejectedValue({}); + + createComponent(); + + await findGroupNameField().setValue(mockGroupName); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: GroupNameAndPath.i18n.apiErrorMessage, + }); + }); + }); + + describe('when multiple API calls are in-flight', () => { + it('aborts the first API call and resolves second API call', async () => { + apiMockLoading(); + apiMockUnavailablePath(); + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + + createComponent(); + + await findGroupNameField().setValue('Foo'); + await findGroupNameField().setValue(mockGroupName); + await waitForPromises(); + + expect(createAlert).not.toHaveBeenCalled(); + expect(findGroupUrlField().element.value).toBe(mockGroupUrlSuggested); + expect(abortSpy).toHaveBeenCalled(); + }); + }); + + describe('when `Group URL` is empty', () => { + it('does not call API', async () => { + createComponent({ + provide: { fields: { name: { value: mockGroupName }, path: mockGroupUrl } }, + }); + + await findGroupNameField().setValue(''); + + expect(getGroupPathAvailability).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when `Group name` field is invalid', () => { + it('shows error message', async () => { + createComponent(); + + await findGroupNameField().trigger('invalid'); + + expect(wrapper.findByText(GroupNameAndPath.i18n.inputs.name.invalidFeedback).exists()).toBe( + true, + ); + }); + }); + + describe('when user types in `Group URL` field', () => { + it('shows loading message', async () => { + apiMockLoading(); + + createComponent(); + + await findGroupUrlField().setValue(mockGroupUrl); + + expectLoadingMessageExists(); + }); + + describe('when path is available', () => { + it('displays success message', async () => { + apiMockAvailablePath(); + + createComponent(); + + await findGroupUrlField().setValue(mockGroupUrl); + await waitForPromises(); + + expect(wrapper.findByText(GroupNameAndPath.i18n.inputs.path.validFeedback).exists()).toBe( + true, + ); + }); + }); + + describe('when path is not available', () => { + it('displays error message and updates `Group URL` field', async () => { + apiMockUnavailablePath(); + + createComponent(); + + await findGroupUrlField().setValue(mockGroupUrl); + await waitForPromises(); + + expect( + wrapper + .findByText(GroupNameAndPath.i18n.inputs.path.invalidFeedbackPathUnavailable) + .exists(), + ).toBe(true); + expect(findGroupUrlField().element.value).toBe(mockGroupUrlSuggested); + }); + }); + + describe('when editing a group', () => { + it('calls API if `Group URL` does not equal the original `Group URL`', async () => { + const groupUrl = 'foo-bar'; + + apiMockAvailablePath(); + + createComponentEditGroup({ path: groupUrl }); + + await findGroupUrlField().setValue('foo-bar1'); + await waitForPromises(); + + expect(getGroupPathAvailability).toHaveBeenCalled(); + expect(wrapper.findByText(GroupNameAndPath.i18n.inputs.path.validFeedback).exists()).toBe( + true, + ); + + getGroupPathAvailability.mockClear(); + + await findGroupUrlField().setValue('foo-bar'); + + expect(getGroupPathAvailability).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when `Group URL` field is invalid', () => { + it('shows error message', async () => { + createComponent(); + + await findGroupUrlField().trigger('invalid'); + + expect( + wrapper + .findByText(GroupNameAndPath.i18n.inputs.path.invalidFeedbackInvalidPattern) + .exists(), + ).toBe(true); + }); + }); + + describe('mattermost', () => { + it('adds `data-bind-in` attribute when enabled', () => { + createComponent({ provide: { mattermostEnabled: true } }); + + expect(findGroupUrlField().attributes('data-bind-in')).toBe( + GroupNameAndPath.mattermostDataBindName, + ); + }); + + it('does not add `data-bind-in` attribute when disabled', () => { + createComponent(); + + expect(findGroupUrlField().attributes('data-bind-in')).toBeUndefined(); + }); + }); + + describe('when editing a group', () => { + it('shows warning alert with `Learn more` link', () => { + createComponentEditGroup(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().findByRole('link', { name: 'Learn more' }).attributes('href')).toBe( + helpPagePath('user/group/index', { + anchor: 'change-a-groups-path', + }), + ); + }); + + it('shows `Group ID` field', () => { + createComponentEditGroup(); + + expect( + wrapper.findByLabelText(GroupNameAndPath.i18n.inputs.groupId.label).element.value, + ).toBe('1'); + }); + }); +}); diff --git a/spec/frontend/groups/components/item_caret_spec.js b/spec/frontend/groups/components/item_caret_spec.js index cbe1f21d6e2..4bf92bb5642 100644 --- a/spec/frontend/groups/components/item_caret_spec.js +++ b/spec/frontend/groups/components/item_caret_spec.js @@ -35,8 +35,8 @@ describe('ItemCaret', () => { it.each` isGroupOpen | icon - ${true} | ${'angle-down'} - ${false} | ${'angle-right'} + ${true} | ${'chevron-down'} + ${false} | ${'chevron-right'} `('renders "$icon" icon when `isGroupOpen` is $isGroupOpen', ({ isGroupOpen, icon }) => { createComponent({ isGroupOpen, diff --git a/spec/frontend/helpers/startup_css_helper_spec.js b/spec/frontend/helpers/startup_css_helper_spec.js index 2236b5aa261..05161437c22 100644 --- a/spec/frontend/helpers/startup_css_helper_spec.js +++ b/spec/frontend/helpers/startup_css_helper_spec.js @@ -59,9 +59,10 @@ describe('waitForCSSLoaded', () => { <link href="two.css" data-startupcss="loading"> `); const events = waitForCSSLoaded(mockedCallback); - document - .querySelectorAll('[data-startupcss="loading"]') - .forEach((elem) => elem.setAttribute('data-startupcss', 'loaded')); + document.querySelectorAll('[data-startupcss="loading"]').forEach((elem) => { + // eslint-disable-next-line no-param-reassign + elem.dataset.startupcss = 'loaded'; + }); document.dispatchEvent(new CustomEvent('CSSStartupLinkLoaded')); await events; diff --git a/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js b/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js index 6e4c66cb780..d77e8e3d04c 100644 --- a/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js @@ -68,7 +68,7 @@ describe('IDE commit editor header', () => { it('calls discardFileChanges if dialog result is confirmed', () => { expect(store.dispatch).not.toHaveBeenCalled(); - findDiscardModal().vm.$emit('ok'); + findDiscardModal().vm.$emit('primary'); expect(store.dispatch).toHaveBeenCalledWith('discardFileChanges', TEST_FILE_PATH); }); diff --git a/spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js b/spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js index 64b53264b4d..2a455c9d7c1 100644 --- a/spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js @@ -1,193 +1,97 @@ -import Vue, { nextTick } from 'vue'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import { projectData, branches } from 'jest/ide/mock_data'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { GlFormCheckbox } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import NewMergeRequestOption from '~/ide/components/commit_sidebar/new_merge_request_option.vue'; -import { PERMISSION_CREATE_MR } from '~/ide/constants'; import { createStore } from '~/ide/stores'; -import { - COMMIT_TO_CURRENT_BRANCH, - COMMIT_TO_NEW_BRANCH, -} from '~/ide/stores/modules/commit/constants'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -describe('create new MR checkbox', () => { - let store; - let vm; - - const setMR = () => { - vm.$store.state.currentMergeRequestId = '1'; - vm.$store.state.projects[store.state.currentProjectId].mergeRequests[ - store.state.currentMergeRequestId - ] = { foo: 'bar' }; - }; - - const setPermissions = (permissions) => { - store.state.projects[store.state.currentProjectId].userPermissions = permissions; - }; - - const createComponent = ({ currentBranchId = 'main', createNewBranch = false } = {}) => { - const Component = Vue.extend(NewMergeRequestOption); - - vm = createComponentWithStore(Component, store); - - vm.$store.state.commit.commitAction = createNewBranch - ? COMMIT_TO_NEW_BRANCH - : COMMIT_TO_CURRENT_BRANCH; +Vue.use(Vuex); - vm.$store.state.currentBranchId = currentBranchId; - - store.state.projects.abcproject.branches[currentBranchId] = branches.find( - (branch) => branch.name === currentBranchId, - ); - - return vm.$mount(); - }; +describe('NewMergeRequestOption component', () => { + let store; + let wrapper; - const findInput = () => vm.$el.querySelector('input[type="checkbox"]'); - const findLabel = () => vm.$el.querySelector('.js-ide-commit-new-mr'); + const findCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findFieldset = () => wrapper.findByTestId('new-merge-request-fieldset'); + const findTooltip = () => getBinding(findFieldset().element, 'gl-tooltip'); - beforeEach(() => { + const createComponent = ({ + shouldHideNewMrOption = false, + shouldDisableNewMrOption = false, + shouldCreateMR = false, + } = {}) => { store = createStore(); - store.state.currentProjectId = 'abcproject'; - - const proj = JSON.parse(JSON.stringify(projectData)); - proj.userPermissions[PERMISSION_CREATE_MR] = true; - Vue.set(store.state.projects, 'abcproject', proj); - }); + wrapper = shallowMountExtended(NewMergeRequestOption, { + store: { + ...store, + getters: { + 'commit/shouldHideNewMrOption': shouldHideNewMrOption, + 'commit/shouldDisableNewMrOption': shouldDisableNewMrOption, + 'commit/shouldCreateMR': shouldCreateMR, + }, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - describe('for default branch', () => { - describe('is rendered when pushing to a new branch', () => { - beforeEach(() => { - createComponent({ - currentBranchId: 'main', - createNewBranch: true, - }); - }); - - it('has NO new MR', () => { - expect(vm.$el.textContent).not.toBe(''); - }); - - it('has new MR', async () => { - setMR(); - - await nextTick(); - expect(vm.$el.textContent).not.toBe(''); - }); + describe('when the `shouldHideNewMrOption` getter returns false', () => { + beforeEach(() => { + createComponent(); + jest.spyOn(store, 'dispatch').mockImplementation(); }); - describe('is NOT rendered when pushing to the same branch', () => { - beforeEach(() => { - createComponent({ - currentBranchId: 'main', - createNewBranch: false, - }); - }); - - it('has NO new MR', () => { - expect(vm.$el.textContent).toBe(''); - }); - - it('has new MR', async () => { - setMR(); - - await nextTick(); - expect(vm.$el.textContent).toBe(''); - }); + it('renders an enabled new MR checkbox', () => { + expect(findCheckbox().attributes('disabled')).toBeUndefined(); }); - }); - describe('for protected branch', () => { - describe('when user does not have the write access', () => { - beforeEach(() => { - createComponent({ - currentBranchId: 'protected/no-access', - }); - }); - - it('is rendered if MR does not exists', () => { - expect(vm.$el.textContent).not.toBe(''); - }); + it("doesn't add `is-disabled` class to the fieldset", () => { + expect(findFieldset().classes()).not.toContain('is-disabled'); + }); - it('is rendered if MR exists', async () => { - setMR(); + it('dispatches toggleShouldCreateMR when clicking checkbox', () => { + findCheckbox().vm.$emit('change'); - await nextTick(); - expect(vm.$el.textContent).not.toBe(''); - }); + expect(store.dispatch).toHaveBeenCalledWith('commit/toggleShouldCreateMR', undefined); }); - describe('when user has the write access', () => { + describe('when user cannot create an MR', () => { beforeEach(() => { createComponent({ - currentBranchId: 'protected/access', + shouldDisableNewMrOption: true, }); }); - it('is rendered if MR does not exist', () => { - expect(vm.$el.textContent).not.toBe(''); + it('disables the new MR checkbox', () => { + expect(findCheckbox().attributes('disabled')).toBe('true'); }); - it('is hidden if MR exists', async () => { - setMR(); + it('adds `is-disabled` class to the fieldset', () => { + expect(findFieldset().classes()).toContain('is-disabled'); + }); - await nextTick(); - expect(vm.$el.textContent).toBe(''); + it('shows a tooltip', () => { + expect(findTooltip().value).toBe(wrapper.vm.$options.i18n.tooltipText); }); }); }); - describe('for regular branch', () => { + describe('when the `shouldHideNewMrOption` getter returns true', () => { beforeEach(() => { createComponent({ - currentBranchId: 'regular', + shouldHideNewMrOption: true, }); }); - it('is rendered if no MR exists', () => { - expect(vm.$el.textContent).not.toBe(''); - }); - - it('is hidden if MR exists', async () => { - setMR(); - - await nextTick(); - expect(vm.$el.textContent).toBe(''); - }); - - it('shows enablded checkbox', () => { - expect(findLabel().classList.contains('is-disabled')).toBe(false); - expect(findInput().disabled).toBe(false); + it("doesn't render the new MR checkbox", () => { + expect(findCheckbox().exists()).toBe(false); }); }); - - describe('when user cannot create MR', () => { - beforeEach(() => { - setPermissions({ [PERMISSION_CREATE_MR]: false }); - - createComponent({ currentBranchId: 'regular' }); - }); - - it('disabled checkbox', () => { - expect(findLabel().classList.contains('is-disabled')).toBe(true); - expect(findInput().disabled).toBe(true); - }); - }); - - it('dispatches toggleShouldCreateMR when clicking checkbox', () => { - createComponent({ - currentBranchId: 'regular', - }); - const el = vm.$el.querySelector('input[type="checkbox"]'); - jest.spyOn(vm.$store, 'dispatch').mockImplementation(() => {}); - el.dispatchEvent(new Event('change')); - - expect(vm.$store.dispatch.mock.calls).toEqual( - expect.arrayContaining([['commit/toggleShouldCreateMR', expect.any(Object)]]), - ); - }); }); diff --git a/spec/frontend/ide/components/ide_side_bar_spec.js b/spec/frontend/ide/components/ide_side_bar_spec.js index ace8988b8c9..4469c3fc901 100644 --- a/spec/frontend/ide/components/ide_side_bar_spec.js +++ b/spec/frontend/ide/components/ide_side_bar_spec.js @@ -1,4 +1,4 @@ -import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; +import { GlSkeletonLoader } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; @@ -47,7 +47,7 @@ describe('IdeSidebar', () => { await nextTick(); - expect(wrapper.findAll(GlSkeletonLoading)).toHaveLength(3); + expect(wrapper.findAll(GlSkeletonLoader)).toHaveLength(3); }); describe('deferred rendering components', () => { diff --git a/spec/frontend/ide/components/ide_status_bar_spec.js b/spec/frontend/ide/components/ide_status_bar_spec.js index 00ef75fcf3a..17a5aa17b1f 100644 --- a/spec/frontend/ide/components/ide_status_bar_spec.js +++ b/spec/frontend/ide/components/ide_status_bar_spec.js @@ -11,6 +11,8 @@ const TEST_PROJECT_ID = 'abcproject'; const TEST_MERGE_REQUEST_ID = '9001'; const TEST_MERGE_REQUEST_URL = `${TEST_HOST}merge-requests/${TEST_MERGE_REQUEST_ID}`; +jest.mock('~/lib/utils/poll'); + describe('ideStatusBar', () => { let store; let vm; diff --git a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap index d1cf9f2e248..45444166a50 100644 --- a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap +++ b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap @@ -35,7 +35,7 @@ exports[`IDE pipeline stage renders stage details & icon 1`] = ` <gl-icon-stub class="ide-stage-collapse-icon" - name="angle-down" + name="chevron-lg-down" size="16" /> </div> diff --git a/spec/frontend/ide/components/jobs/detail/description_spec.js b/spec/frontend/ide/components/jobs/detail/description_spec.js index 786a7661d97..128ccff6568 100644 --- a/spec/frontend/ide/components/jobs/detail/description_spec.js +++ b/spec/frontend/ide/components/jobs/detail/description_spec.js @@ -28,6 +28,12 @@ describe('IDE job description', () => { ).not.toBe(null); }); + it('renders a borderless CI icon', () => { + expect( + vm.$el.querySelector('.borderless [data-testid="status_success_borderless-icon"]'), + ).not.toBe(null); + }); + it('renders bridge job details without the job link', () => { vm = mountComponent(Component, { job: { ...jobs[0], path: undefined }, diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index 9a30fd5f5c3..b44651481e9 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -1,8 +1,8 @@ -import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { editor as monacoEditor, Range } from 'monaco-editor'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; +import { shallowMount } from '@vue/test-utils'; import '~/behaviors/markdown/render_gfm'; import waitForPromises from 'helpers/wait_for_promises'; import { exampleConfigs, exampleFiles } from 'jest/ide/lib/editorconfig/mock_data'; @@ -11,57 +11,54 @@ import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markd import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext'; import SourceEditor from '~/editor/source_editor'; import RepoEditor from '~/ide/components/repo_editor.vue'; -import { - leftSidebarViews, - FILE_VIEW_MODE_EDITOR, - FILE_VIEW_MODE_PREVIEW, - viewerTypes, -} from '~/ide/constants'; +import { leftSidebarViews, FILE_VIEW_MODE_PREVIEW, viewerTypes } from '~/ide/constants'; import ModelManager from '~/ide/lib/common/model_manager'; import service from '~/ide/services'; import { createStoreOptions } from '~/ide/stores'; import axios from '~/lib/utils/axios_utils'; import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import SourceEditorInstance from '~/editor/source_editor_instance'; -import { spyOnApi } from 'jest/editor/helpers'; import { file } from '../helpers'; const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown'; const CURRENT_PROJECT_ID = 'gitlab-org/gitlab'; -const defaultFileProps = { - ...file('file.txt'), - content: 'hello world', - active: true, - tempFile: true, +const dummyFile = { + text: { + ...file('file.txt'), + content: 'hello world', + active: true, + tempFile: true, + }, + markdown: { + ...file('sample.md'), + projectId: 'namespace/project', + path: 'sample.md', + content: 'hello world', + tempFile: true, + active: true, + }, + binary: { + ...file('file.dat'), + content: '🐱', // non-ascii binary content, + tempFile: true, + active: true, + }, + empty: { + ...file('empty'), + tempFile: false, + content: '', + raw: '', + }, }; + const createActiveFile = (props) => { return { - ...defaultFileProps, + ...dummyFile.text, ...props, }; }; -const dummyFile = { - markdown: (() => - createActiveFile({ - projectId: 'namespace/project', - path: 'sample.md', - name: 'sample.md', - }))(), - binary: (() => - createActiveFile({ - name: 'file.dat', - content: '🐱', // non-ascii binary content, - }))(), - empty: (() => - createActiveFile({ - tempFile: false, - content: '', - raw: '', - }))(), -}; - const prepareStore = (state, activeFile) => { const localState = { openFiles: [activeFile], @@ -109,7 +106,7 @@ describe('RepoEditor', () => { vm.$once('editorSetup', resolve); }); - const createComponent = async ({ state = {}, activeFile = defaultFileProps } = {}) => { + const createComponent = async ({ state = {}, activeFile = dummyFile.text } = {}) => { const store = prepareStore(state, activeFile); wrapper = shallowMount(RepoEditor, { store, @@ -187,7 +184,7 @@ describe('RepoEditor', () => { mock = new MockAdapter(axios); mock.onPost(/(.*)\/preview_markdown/).reply(200, { - body: `<p>${defaultFileProps.content}</p>`, + body: `<p>${dummyFile.text.content}</p>`, }); }); @@ -196,11 +193,8 @@ describe('RepoEditor', () => { }); describe('when files is markdown', () => { - let layoutSpy; - beforeEach(async () => { await createComponent({ activeFile }); - layoutSpy = jest.spyOn(wrapper.vm.editor, 'layout'); }); it('renders an Edit and a Preview Tab', () => { @@ -214,11 +208,7 @@ describe('RepoEditor', () => { it('renders markdown for tempFile', async () => { findPreviewTab().trigger('click'); await waitForPromises(); - expect(wrapper.find(ContentViewer).html()).toContain(defaultFileProps.content); - }); - - it('should not trigger layout', async () => { - expect(layoutSpy).not.toHaveBeenCalled(); + expect(wrapper.find(ContentViewer).html()).toContain(dummyFile.text.content); }); describe('when file changes to non-markdown file', () => { @@ -229,10 +219,6 @@ describe('RepoEditor', () => { it('should hide tabs', () => { expect(findTabs()).toHaveLength(0); }); - - it('should trigger refresh dimensions', async () => { - expect(layoutSpy).toHaveBeenCalledTimes(1); - }); }); }); @@ -292,55 +278,20 @@ describe('RepoEditor', () => { expect(vm.editor.methods[fn]).toBe('EditorWebIde'); }); }); - - it.each` - prefix | activeFile | viewer | shouldHaveMarkdownExtension - ${'Should not'} | ${createActiveFile()} | ${viewerTypes.edit} | ${false} - ${'Should'} | ${dummyFile.markdown} | ${viewerTypes.edit} | ${true} - ${'Should not'} | ${dummyFile.empty} | ${viewerTypes.edit} | ${false} - ${'Should not'} | ${createActiveFile()} | ${viewerTypes.diff} | ${false} - ${'Should not'} | ${dummyFile.markdown} | ${viewerTypes.diff} | ${false} - ${'Should not'} | ${dummyFile.empty} | ${viewerTypes.diff} | ${false} - ${'Should not'} | ${createActiveFile()} | ${viewerTypes.mr} | ${false} - ${'Should not'} | ${dummyFile.markdown} | ${viewerTypes.mr} | ${false} - ${'Should not'} | ${dummyFile.empty} | ${viewerTypes.mr} | ${false} - `( - '$prefix install markdown extension for $activeFile.name in $viewer viewer', - async ({ activeFile, viewer, shouldHaveMarkdownExtension } = {}) => { - await createComponent({ state: { viewer }, activeFile }); - - if (shouldHaveMarkdownExtension) { - expect(applyExtensionSpy).toHaveBeenCalledWith({ - definition: EditorMarkdownPreviewExtension, - setupOptions: { previewMarkdownPath: PREVIEW_MARKDOWN_PATH }, - }); - // TODO: spying on extensions causes Jest to blow up, so we have to assert on - // the public property the extension adds, as opposed to the args passed to the ctor - expect(wrapper.vm.editor.markdownPreview.path).toBe(PREVIEW_MARKDOWN_PATH); - } else { - expect(applyExtensionSpy).not.toHaveBeenCalledWith( - wrapper.vm.editor, - expect.any(EditorMarkdownExtension), - ); - } - }, - ); }); describe('setupEditor', () => { - beforeEach(async () => { + it('creates new model on load', async () => { await createComponent(); - }); - - it('creates new model on load', () => { // We always create two models per file to be able to build a diff of changes expect(createModelSpy).toHaveBeenCalledTimes(2); // The model with the most recent changes is the last one const [content] = createModelSpy.mock.calls[1]; - expect(content).toBe(defaultFileProps.content); + expect(content).toBe(dummyFile.text.content); }); - it('does not create a new model on subsequent calls to setupEditor and re-uses the already-existing model', () => { + it('does not create a new model on subsequent calls to setupEditor and re-uses the already-existing model', async () => { + await createComponent(); const existingModel = vm.model; createModelSpy.mockClear(); @@ -350,7 +301,8 @@ describe('RepoEditor', () => { expect(vm.model).toBe(existingModel); }); - it('updates state with the value of the model', () => { + it('updates state with the value of the model', async () => { + await createComponent(); const newContent = 'As Gregor Samsa\n awoke one morning\n'; vm.model.setValue(newContent); @@ -359,7 +311,8 @@ describe('RepoEditor', () => { expect(vm.file.content).toBe(newContent); }); - it('sets head model as staged file', () => { + it('sets head model as staged file', async () => { + await createComponent(); vm.modelManager.dispose(); const addModelSpy = jest.spyOn(ModelManager.prototype, 'addModel'); @@ -371,52 +324,54 @@ describe('RepoEditor', () => { expect(addModelSpy).toHaveBeenCalledWith(vm.file, vm.$store.state.stagedFiles[0]); }); - }); - - describe('editor updateDimensions', () => { - let updateDimensionsSpy; - beforeEach(async () => { - await createComponent(); - const ext = extensionsStore.get('EditorWebIde'); - updateDimensionsSpy = jest.fn(); - spyOnApi(ext, { - updateDimensions: updateDimensionsSpy, - }); - }); - it('calls updateDimensions only when panelResizing is false', async () => { - expect(updateDimensionsSpy).not.toHaveBeenCalled(); - expect(vm.$store.state.panelResizing).toBe(false); // default value - - vm.$store.state.panelResizing = true; - await nextTick(); - - expect(updateDimensionsSpy).not.toHaveBeenCalled(); - - vm.$store.state.panelResizing = false; - await nextTick(); - - expect(updateDimensionsSpy).toHaveBeenCalledTimes(1); - - vm.$store.state.panelResizing = true; - await nextTick(); - - expect(updateDimensionsSpy).toHaveBeenCalledTimes(1); - }); - - it('calls updateDimensions when rightPane is toggled', async () => { - expect(updateDimensionsSpy).not.toHaveBeenCalled(); - expect(vm.$store.state.rightPane.isOpen).toBe(false); // default value - - vm.$store.state.rightPane.isOpen = true; - await nextTick(); + it.each` + prefix | activeFile | viewer | shouldHaveMarkdownExtension + ${'Should not'} | ${dummyFile.text} | ${viewerTypes.edit} | ${false} + ${'Should'} | ${dummyFile.markdown} | ${viewerTypes.edit} | ${true} + ${'Should not'} | ${dummyFile.empty} | ${viewerTypes.edit} | ${false} + ${'Should not'} | ${dummyFile.text} | ${viewerTypes.diff} | ${false} + ${'Should not'} | ${dummyFile.markdown} | ${viewerTypes.diff} | ${false} + ${'Should not'} | ${dummyFile.empty} | ${viewerTypes.diff} | ${false} + ${'Should not'} | ${dummyFile.text} | ${viewerTypes.mr} | ${false} + ${'Should not'} | ${dummyFile.markdown} | ${viewerTypes.mr} | ${false} + ${'Should not'} | ${dummyFile.empty} | ${viewerTypes.mr} | ${false} + `( + '$prefix install markdown extension for $activeFile.name in $viewer viewer', + async ({ activeFile, viewer, shouldHaveMarkdownExtension } = {}) => { + await createComponent({ state: { viewer }, activeFile }); - expect(updateDimensionsSpy).toHaveBeenCalledTimes(1); + if (shouldHaveMarkdownExtension) { + expect(applyExtensionSpy).toHaveBeenCalledWith({ + definition: EditorMarkdownPreviewExtension, + setupOptions: { previewMarkdownPath: PREVIEW_MARKDOWN_PATH }, + }); + // TODO: spying on extensions causes Jest to blow up, so we have to assert on + // the public property the extension adds, as opposed to the args passed to the ctor + expect(wrapper.vm.editor.markdownPreview.path).toBe(PREVIEW_MARKDOWN_PATH); + } else { + expect(applyExtensionSpy).not.toHaveBeenCalledWith( + wrapper.vm.editor, + expect.any(EditorMarkdownExtension), + ); + } + }, + ); - vm.$store.state.rightPane.isOpen = false; - await nextTick(); + it('fetches the live preview extension even if markdown is not the first opened file', async () => { + const textFile = dummyFile.text; + const mdFile = dummyFile.markdown; + const previewExtConfig = { + definition: EditorMarkdownPreviewExtension, + setupOptions: { previewMarkdownPath: PREVIEW_MARKDOWN_PATH }, + }; + await createComponent({ activeFile: textFile }); + applyExtensionSpy.mockClear(); + + await wrapper.setProps({ file: mdFile }); + await waitForPromises(); - expect(updateDimensionsSpy).toHaveBeenCalledTimes(2); + expect(applyExtensionSpy).toHaveBeenCalledWith(previewExtConfig); }); }); @@ -439,7 +394,6 @@ describe('RepoEditor', () => { }); describe('files in preview mode', () => { - let updateDimensionsSpy; const changeViewMode = (viewMode) => vm.$store.dispatch('editor/updateFileEditor', { path: vm.file.path, @@ -451,12 +405,6 @@ describe('RepoEditor', () => { activeFile: dummyFile.markdown, }); - const ext = extensionsStore.get('EditorWebIde'); - updateDimensionsSpy = jest.fn(); - spyOnApi(ext, { - updateDimensions: updateDimensionsSpy, - }); - changeViewMode(FILE_VIEW_MODE_PREVIEW); await nextTick(); }); @@ -465,15 +413,6 @@ describe('RepoEditor', () => { expect(vm.showEditor).toBe(false); expect(findEditor().isVisible()).toBe(false); }); - - it('updates dimensions when switching view back to edit', async () => { - expect(updateDimensionsSpy).not.toHaveBeenCalled(); - - changeViewMode(FILE_VIEW_MODE_EDITOR); - await nextTick(); - - expect(updateDimensionsSpy).toHaveBeenCalled(); - }); }); describe('initEditor', () => { @@ -487,7 +426,7 @@ describe('RepoEditor', () => { it('does not fetch file information for temp entries', async () => { await createComponent({ - activeFile: createActiveFile(), + activeFile: dummyFile.text, }); expect(vm.getFileData).not.toHaveBeenCalled(); @@ -506,7 +445,7 @@ describe('RepoEditor', () => { it('does not initialize editor for files already with content when shouldHideEditor is `true`', async () => { await createComponent({ - activeFile: createActiveFile(), + activeFile: dummyFile.text, }); await hideEditorAndRunFn(); @@ -677,9 +616,6 @@ describe('RepoEditor', () => { activeFile: setFileName('bar.md'), }); - vm.setupEditor(); - - await waitForPromises(); // set cursor to line 2, column 1 vm.editor.setSelection(new Range(2, 1, 2, 1)); vm.editor.focus(); diff --git a/spec/frontend/import_entities/import_groups/components/import_table_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_spec.js index 1939e43e5dc..0279ad454d2 100644 --- a/spec/frontend/import_entities/import_groups/components/import_table_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_table_spec.js @@ -122,7 +122,7 @@ describe('import table', () => { }); await waitForPromises(); - expect(wrapper.find(GlEmptyState).props().title).toBe('You have no groups to import'); + expect(wrapper.find(GlEmptyState).props().title).toBe(i18n.NO_GROUPS_FOUND); }); }); @@ -297,7 +297,7 @@ describe('import table', () => { wrapper.find(PaginationLinks).props().change(REQUESTED_PAGE); await waitForPromises(); - expect(wrapper.text()).toContain('Showing 21-21 of 38 groups from'); + expect(wrapper.text()).toContain('Showing 21-21 of 38 groups that you own from'); }); }); @@ -349,7 +349,9 @@ describe('import table', () => { await setFilter(FILTER_VALUE); await waitForPromises(); - expect(wrapper.text()).toContain('Showing 1-1 of 40 groups matching filter "foo" from'); + expect(wrapper.text()).toContain( + 'Showing 1-1 of 40 groups that you own matching filter "foo" from', + ); }); it('properly resets filter in graphql query when search box is cleared', async () => { diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js index a556f3c17f3..356480f931e 100644 --- a/spec/frontend/incidents/components/incidents_list_spec.js +++ b/spec/frontend/incidents/components/incidents_list_spec.js @@ -85,7 +85,6 @@ describe('Incidents List', () => { assigneeUsernameQuery: '', slaFeatureAvailable: true, canCreateIncident: true, - incidentEscalationsAvailable: true, ...provide, }, stubs: { @@ -211,20 +210,6 @@ describe('Incidents List', () => { expect(status.classes('gl-text-truncate')).toBe(true); }); }); - - describe('when feature is disabled', () => { - beforeEach(() => { - mountComponent({ - data: { incidents: { list: mockIncidents }, incidentsCount }, - provide: { incidentEscalationsAvailable: false }, - loading: false, - }); - }); - - it('is absent if feature flag is disabled', () => { - expect(findEscalationStatus().length).toBe(0); - }); - }); }); it('contains a link to the incident details page', async () => { diff --git a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap index 7e24aa439d4..fae93196d2c 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap +++ b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap @@ -57,12 +57,12 @@ exports[`Alert integration settings form should match the default snapshot 1`] = </gl-button-stub> <gl-modal-stub + actioncancel="[object Object]" + actionprimary="[object Object]" arialabel="" dismisslabel="Close" modalclass="" modalid="resetWebhookModal" - ok-title="Reset webhook URL" - ok-variant="danger" size="md" title="Reset webhook URL" titletag="h4" diff --git a/spec/frontend/incidents_settings/components/pagerduty_form_spec.js b/spec/frontend/incidents_settings/components/pagerduty_form_spec.js index d2b591d427d..521a861829b 100644 --- a/spec/frontend/incidents_settings/components/pagerduty_form_spec.js +++ b/spec/frontend/incidents_settings/components/pagerduty_form_spec.js @@ -47,7 +47,7 @@ describe('Alert integration settings form', () => { resetWebhookUrl.mockResolvedValueOnce({ data: { pagerduty_webhook_url: newWebhookUrl }, }); - findModal().vm.$emit('ok'); + findModal().vm.$emit('primary'); await waitForPromises(); expect(resetWebhookUrl).toHaveBeenCalled(); expect(findWebhookInput().attributes('value')).toBe(newWebhookUrl); @@ -56,7 +56,7 @@ describe('Alert integration settings form', () => { it('should show error message and NOT reset webhook url', async () => { resetWebhookUrl.mockRejectedValueOnce(); - findModal().vm.$emit('ok'); + findModal().vm.$emit('primary'); await waitForPromises(); expect(findAlert().attributes('variant')).toBe('danger'); }); diff --git a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js index b4c5d4f9957..fa91f8de45a 100644 --- a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js @@ -11,7 +11,6 @@ describe('JiraIssuesFields', () => { const defaultProps = { showJiraVulnerabilitiesIntegration: true, - upgradePlanPath: 'https://gitlab.com', }; const createComponent = ({ diff --git a/spec/frontend/integrations/edit/components/jira_upgrade_cta_spec.js b/spec/frontend/integrations/edit/components/jira_upgrade_cta_spec.js deleted file mode 100644 index e90e9a5d2ac..00000000000 --- a/spec/frontend/integrations/edit/components/jira_upgrade_cta_spec.js +++ /dev/null @@ -1,31 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; - -import JiraUpgradeCta from '~/integrations/edit/components/jira_upgrade_cta.vue'; - -describe('JiraUpgradeCta', () => { - let wrapper; - - const contentMessage = 'Upgrade your plan to enable this feature of the Jira Integration.'; - - const createComponent = (propsData) => { - wrapper = shallowMount(JiraUpgradeCta, { - propsData, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it('displays the correct message for premium and lower users', () => { - createComponent({ showPremiumMessage: true }); - expect(wrapper.text()).toContain('This is a Premium feature'); - expect(wrapper.text()).toContain(contentMessage); - }); - - it('displays the correct message for ultimate and lower users', () => { - createComponent({ showUltimateMessage: true }); - expect(wrapper.text()).toContain('This is an Ultimate feature'); - expect(wrapper.text()).toContain(contentMessage); - }); -}); diff --git a/spec/frontend/integrations/edit/components/sections/configuration_spec.js b/spec/frontend/integrations/edit/components/sections/configuration_spec.js new file mode 100644 index 00000000000..e697212ea0b --- /dev/null +++ b/spec/frontend/integrations/edit/components/sections/configuration_spec.js @@ -0,0 +1,57 @@ +import { shallowMount } from '@vue/test-utils'; + +import IntegrationSectionCoonfiguration from '~/integrations/edit/components/sections/configuration.vue'; +import DynamicField from '~/integrations/edit/components/dynamic_field.vue'; +import { createStore } from '~/integrations/edit/store'; + +import { mockIntegrationProps } from '../../mock_data'; + +describe('IntegrationSectionCoonfiguration', () => { + let wrapper; + + const createComponent = ({ customStateProps = {}, props = {} } = {}) => { + const store = createStore({ + customState: { ...mockIntegrationProps, ...customStateProps }, + }); + wrapper = shallowMount(IntegrationSectionCoonfiguration, { + propsData: { ...props }, + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findAllDynamicFields = () => wrapper.findAllComponents(DynamicField); + + describe('template', () => { + describe('DynamicField', () => { + it('renders DynamicField for each field', () => { + const fields = [ + { name: 'username', type: 'text' }, + { name: 'API token', type: 'password' }, + ]; + + createComponent({ + props: { + fields, + }, + }); + + const dynamicFields = findAllDynamicFields(); + + expect(dynamicFields).toHaveLength(2); + dynamicFields.wrappers.forEach((field, index) => { + expect(field.props()).toMatchObject(fields[index]); + }); + }); + + it('does not render DynamicField when field is empty', () => { + createComponent(); + + expect(findAllDynamicFields()).toHaveLength(0); + }); + }); + }); +}); diff --git a/spec/frontend/integrations/edit/components/sections/trigger_spec.js b/spec/frontend/integrations/edit/components/sections/trigger_spec.js new file mode 100644 index 00000000000..883f5c7bf79 --- /dev/null +++ b/spec/frontend/integrations/edit/components/sections/trigger_spec.js @@ -0,0 +1,38 @@ +import { shallowMount } from '@vue/test-utils'; + +import IntegrationSectionTrigger from '~/integrations/edit/components/sections/trigger.vue'; +import TriggerField from '~/integrations/edit/components/trigger_field.vue'; +import { createStore } from '~/integrations/edit/store'; + +import { mockIntegrationProps } from '../../mock_data'; + +describe('IntegrationSectionTrigger', () => { + let wrapper; + + const createComponent = () => { + const store = createStore({ + customState: { ...mockIntegrationProps }, + }); + wrapper = shallowMount(IntegrationSectionTrigger, { + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findAllTriggerFields = () => wrapper.findAllComponents(TriggerField); + + describe('template', () => { + it('renders correct number of TriggerField components', () => { + createComponent(); + + const fields = findAllTriggerFields(); + expect(fields.length).toBe(mockIntegrationProps.triggerEvents.length); + fields.wrappers.forEach((field, index) => { + expect(field.props('event')).toBe(mockIntegrationProps.triggerEvents[index]); + }); + }); + }); +}); diff --git a/spec/frontend/integrations/edit/components/trigger_field_spec.js b/spec/frontend/integrations/edit/components/trigger_field_spec.js new file mode 100644 index 00000000000..6a68337813e --- /dev/null +++ b/spec/frontend/integrations/edit/components/trigger_field_spec.js @@ -0,0 +1,71 @@ +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { GlFormCheckbox } from '@gitlab/ui'; + +import TriggerField from '~/integrations/edit/components/trigger_field.vue'; +import { integrationTriggerEventTitles } from '~/integrations/constants'; + +describe('TriggerField', () => { + let wrapper; + + const defaultProps = { + event: { name: 'push_events' }, + }; + + const createComponent = ({ props = {}, isInheriting = false } = {}) => { + wrapper = shallowMount(TriggerField, { + propsData: { ...defaultProps, ...props }, + computed: { + isInheriting: () => isInheriting, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findGlFormCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findHiddenInput = () => wrapper.find('input[type="hidden"]'); + + describe('template', () => { + it('renders enabled GlFormCheckbox', () => { + createComponent(); + + expect(findGlFormCheckbox().attributes('disabled')).toBeUndefined(); + }); + + it('when isInheriting is true, renders disabled GlFormCheckbox', () => { + createComponent({ isInheriting: true }); + + expect(findGlFormCheckbox().attributes('disabled')).toBe('true'); + }); + + it('renders correct title', () => { + createComponent(); + + expect(findGlFormCheckbox().text()).toMatchInterpolatedText( + integrationTriggerEventTitles[defaultProps.event.name], + ); + }); + + it('sets default value for hidden input', () => { + createComponent(); + + expect(findHiddenInput().attributes('value')).toBe('false'); + }); + + it('toggles value of hidden input on checkbox input', async () => { + createComponent({ + props: { event: { name: 'push_events', value: true } }, + }); + await nextTick; + + expect(findHiddenInput().attributes('value')).toBe('true'); + + await findGlFormCheckbox().vm.$emit('input', false); + + expect(findHiddenInput().attributes('value')).toBe('false'); + }); + }); +}); diff --git a/spec/frontend/integrations/edit/mock_data.js b/spec/frontend/integrations/edit/mock_data.js index ac0c7d244e3..c276d2e7364 100644 --- a/spec/frontend/integrations/edit/mock_data.js +++ b/spec/frontend/integrations/edit/mock_data.js @@ -9,7 +9,10 @@ export const mockIntegrationProps = { initialEnableComments: false, }, jiraIssuesProps: {}, - triggerEvents: [], + triggerEvents: [ + { name: 'push_events', title: 'Push', value: true }, + { name: 'issues_events', title: 'Issue', value: true }, + ], sections: [], fields: [], type: '', diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js index 28402c8331c..c522abe63c5 100644 --- a/spec/frontend/invite_members/components/invite_members_trigger_spec.js +++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js @@ -2,7 +2,11 @@ import { GlButton, GlLink, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; import eventHub from '~/invite_members/event_hub'; -import { TRIGGER_ELEMENT_BUTTON, TRIGGER_ELEMENT_SIDE_NAV } from '~/invite_members/constants'; +import { + TRIGGER_ELEMENT_BUTTON, + TRIGGER_ELEMENT_SIDE_NAV, + TRIGGER_DEFAULT_QA_SELECTOR, +} from '~/invite_members/constants'; jest.mock('~/experimentation/experiment_tracking'); @@ -50,12 +54,24 @@ describe.each(triggerItems)('with triggerElement as %s', (triggerItem) => { wrapper.destroy(); }); - describe('displayText', () => { + describe('configurable attributes', () => { it('includes the correct displayText for the button', () => { createComponent(); expect(findButton().text()).toBe(displayText); }); + + it('uses the default qa selector value', () => { + createComponent(); + + expect(findButton().attributes('data-qa-selector')).toBe(TRIGGER_DEFAULT_QA_SELECTOR); + }); + + it('sets the qa selector value', () => { + createComponent({ qaSelector: '_qaSelector_' }); + + expect(findButton().attributes('data-qa-selector')).toBe('_qaSelector_'); + }); }); describe('clicking the link', () => { diff --git a/spec/frontend/invite_members/components/invite_modal_base_spec.js b/spec/frontend/invite_members/components/invite_modal_base_spec.js index 010f7b999fc..cc19e90a5fa 100644 --- a/spec/frontend/invite_members/components/invite_modal_base_spec.js +++ b/spec/frontend/invite_members/components/invite_modal_base_spec.js @@ -200,6 +200,30 @@ describe('InviteModalBase', () => { }); }); + describe('when user limit is close on a personal namespace', () => { + beforeEach(() => { + createComponent( + { + closeToLimit: true, + reachedLimit: false, + usersLimitDataset: { membersPath, userNamespace: true }, + }, + { GlModal, GlFormGroup }, + ); + }); + + it('renders correct buttons', () => { + const cancelButton = findCancelButton(); + const actionButton = findActionButton(); + + expect(cancelButton.text()).toBe(INVITE_BUTTON_TEXT_DISABLED); + expect(cancelButton.attributes('href')).toBe(membersPath); + + expect(actionButton.text()).toBe(INVITE_BUTTON_TEXT); + expect(actionButton.attributes('href')).toBe(); // default submit button + }); + }); + describe('when users limit is not reached', () => { const textRegex = /Select a role.+Read more about role permissions Access expiration date \(optional\)/; diff --git a/spec/frontend/invite_members/components/user_limit_notification_spec.js b/spec/frontend/invite_members/components/user_limit_notification_spec.js index 4c9adbfcc44..bbc17932a49 100644 --- a/spec/frontend/invite_members/components/user_limit_notification_spec.js +++ b/spec/frontend/invite_members/components/user_limit_notification_spec.js @@ -14,9 +14,15 @@ describe('UserLimitNotification', () => { const findAlert = () => wrapper.findComponent(GlAlert); - const createComponent = (reachedLimit = false, usersLimitDataset = {}) => { + const createComponent = ( + closeToLimit = false, + reachedLimit = false, + usersLimitDataset = {}, + props = {}, + ) => { wrapper = shallowMountExtended(UserLimitNotification, { propsData: { + closeToLimit, reachedLimit, usersLimitDataset: { freeUsersLimit, @@ -25,6 +31,7 @@ describe('UserLimitNotification', () => { purchasePath: 'purchasePath', ...usersLimitDataset, }, + ...props, }, provide: { name: 'my group' }, stubs: { GlSprintf }, @@ -43,9 +50,26 @@ describe('UserLimitNotification', () => { }); }); + describe('when close to limit with a personal namepace', () => { + beforeEach(() => { + createComponent(true, false, { membersCount: 3, userNamespace: true }); + }); + + it('renders the limit for a personal namespace', () => { + const alert = findAlert(); + + expect(alert.attributes('title')).toEqual( + 'You only have space for 2 more members in your personal projects', + ); + expect(alert.text()).toEqual( + 'To make more space, you can remove members who no longer need access.', + ); + }); + }); + describe('when close to limit', () => { it("renders user's limit notification", () => { - createComponent(false, { membersCount: 3 }); + createComponent(true, false, { membersCount: 3 }); const alert = findAlert(); @@ -61,7 +85,7 @@ describe('UserLimitNotification', () => { describe('when limit is reached', () => { it("renders user's limit notification", () => { - createComponent(true); + createComponent(true, true); const alert = findAlert(); @@ -71,12 +95,12 @@ describe('UserLimitNotification', () => { describe('when free user namespace', () => { it("renders user's limit notification", () => { - createComponent(true, { userNamespace: true }); + createComponent(true, true, { userNamespace: true }); const alert = findAlert(); expect(alert.attributes('title')).toEqual( - "You've reached your 5 members limit for my group", + "You've reached your 5 members limit for your personal projects", ); expect(alert.text()).toEqual(REACHED_LIMIT_MESSAGE); diff --git a/spec/frontend/issuable/components/csv_export_modal_spec.js b/spec/frontend/issuable/components/csv_export_modal_spec.js index ad4abda6912..f798f87b6b2 100644 --- a/spec/frontend/issuable/components/csv_export_modal_spec.js +++ b/spec/frontend/issuable/components/csv_export_modal_spec.js @@ -1,7 +1,8 @@ -import { GlModal, GlIcon, GlButton } from '@gitlab/ui'; +import { GlModal, GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { stubComponent } from 'helpers/stub_component'; import CsvExportModal from '~/issuable/components/csv_export_modal.vue'; +import { __ } from '~/locale'; describe('CsvExportModal', () => { let wrapper; @@ -34,7 +35,6 @@ describe('CsvExportModal', () => { const findModal = () => wrapper.findComponent(GlModal); const findIcon = () => wrapper.findComponent(GlIcon); - const findButton = () => wrapper.findComponent(GlButton); describe('template', () => { describe.each` @@ -47,11 +47,25 @@ describe('CsvExportModal', () => { }); it('displays the modal title "$modalTitle"', () => { - expect(findModal().text()).toContain(modalTitle); + expect(findModal().props('title')).toBe(modalTitle); }); - it('displays the button with title "$modalTitle"', () => { - expect(findButton().text()).toBe(modalTitle); + it('displays the primary button with title "$modalTitle" and href', () => { + expect(findModal().props('actionPrimary')).toMatchObject({ + text: modalTitle, + attributes: { + href: 'export/csv/path', + variant: 'confirm', + 'data-method': 'post', + 'data-qa-selector': `export_${issuableType}_button`, + 'data-track-action': 'click_button', + 'data-track-label': `export_${issuableType}_csv`, + }, + }); + }); + + it('displays the cancel button', () => { + expect(findModal().props('actionCancel')).toEqual({ text: __('Cancel') }); }); }); @@ -72,13 +86,5 @@ describe('CsvExportModal', () => { ); }); }); - - describe('primary button', () => { - it('passes the exportCsvPath to the button', () => { - const exportCsvPath = '/gitlab-org/gitlab-test/-/issues/export_csv'; - wrapper = createComponent({ props: { exportCsvPath } }); - expect(findButton().attributes('href')).toBe(exportCsvPath); - }); - }); }); }); diff --git a/spec/frontend/issuable/components/csv_import_modal_spec.js b/spec/frontend/issuable/components/csv_import_modal_spec.js index f4636fd7e6a..6e954c91f46 100644 --- a/spec/frontend/issuable/components/csv_import_modal_spec.js +++ b/spec/frontend/issuable/components/csv_import_modal_spec.js @@ -76,6 +76,10 @@ describe('CsvImportModal', () => { expect(formSubmitSpy).toHaveBeenCalled(); }); + + it('displays the cancel button', () => { + expect(findModal().props('actionCancel')).toEqual({ text: __('Cancel') }); + }); }); }); }); diff --git a/spec/frontend/issuable/popover/components/issue_popover_spec.js b/spec/frontend/issuable/popover/components/issue_popover_spec.js new file mode 100644 index 00000000000..3e77e750f3a --- /dev/null +++ b/spec/frontend/issuable/popover/components/issue_popover_spec.js @@ -0,0 +1,81 @@ +import { GlSkeletonLoader } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import StatusBox from '~/issuable/components/status_box.vue'; +import IssuePopover from '~/issuable/popover/components/issue_popover.vue'; +import issueQuery from '~/issuable/popover/queries/issue.query.graphql'; + +describe('Issue Popover', () => { + let wrapper; + + Vue.use(VueApollo); + + const issueQueryResponse = { + data: { + project: { + __typename: 'Project', + id: '1', + issue: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/1', + createdAt: '2020-07-01T04:08:01Z', + state: 'opened', + title: 'Issue title', + }, + }, + }, + }; + + const mountComponent = ({ + queryResponse = jest.fn().mockResolvedValue(issueQueryResponse), + } = {}) => { + wrapper = shallowMount(IssuePopover, { + apolloProvider: createMockApollo([[issueQuery, queryResponse]]), + propsData: { + target: document.createElement('a'), + projectPath: 'foo/bar', + iid: '1', + cachedTitle: 'Cached title', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('shows skeleton-loader while apollo is loading', () => { + mountComponent(); + + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true); + }); + + describe('when loaded', () => { + beforeEach(() => { + mountComponent(); + return waitForPromises(); + }); + + it('shows status badge', () => { + expect(wrapper.findComponent(StatusBox).props()).toEqual({ + issuableType: 'issue', + initialState: issueQueryResponse.data.project.issue.state, + }); + }); + + it('shows opened time', () => { + expect(wrapper.text()).toContain('Opened 4 days ago'); + }); + + it('shows title', () => { + expect(wrapper.find('h5').text()).toBe(issueQueryResponse.data.project.issue.title); + }); + + it('shows reference', () => { + expect(wrapper.text()).toContain('foo/bar#1'); + }); + }); +}); diff --git a/spec/frontend/issuable/popover/components/mr_popover_spec.js b/spec/frontend/issuable/popover/components/mr_popover_spec.js new file mode 100644 index 00000000000..5fdd1e6e8fc --- /dev/null +++ b/spec/frontend/issuable/popover/components/mr_popover_spec.js @@ -0,0 +1,119 @@ +import { GlSkeletonLoader } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import MRPopover from '~/issuable/popover/components/mr_popover.vue'; +import mergeRequestQuery from '~/issuable/popover/queries/merge_request.query.graphql'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; + +describe('MR Popover', () => { + let wrapper; + + Vue.use(VueApollo); + + const mrQueryResponse = { + data: { + project: { + __typename: 'Project', + id: '1', + mergeRequest: { + __typename: 'Merge Request', + id: 'gid://gitlab/Merge_Request/1', + createdAt: '2020-07-01T04:08:01Z', + state: 'opened', + title: 'MR title', + headPipeline: { + id: '1', + detailedStatus: { + id: '1', + icon: 'status_success', + group: 'success', + }, + }, + }, + }, + }, + }; + + const mrQueryResponseWithoutDetailedStatus = { + data: { + project: { + __typename: 'Project', + id: '1', + mergeRequest: { + __typename: 'Merge Request', + id: 'gid://gitlab/Merge_Request/1', + createdAt: '2020-07-01T04:08:01Z', + state: 'opened', + title: 'MR title', + headPipeline: { + id: '1', + detailedStatus: null, + }, + }, + }, + }, + }; + + const mountComponent = ({ + queryResponse = jest.fn().mockResolvedValue(mrQueryResponse), + } = {}) => { + wrapper = shallowMount(MRPopover, { + apolloProvider: createMockApollo([[mergeRequestQuery, queryResponse]]), + propsData: { + target: document.createElement('a'), + projectPath: 'foo/bar', + iid: '1', + cachedTitle: 'Cached Title', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('shows skeleton-loader while apollo is loading', () => { + mountComponent(); + + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true); + }); + + describe('when loaded', () => { + beforeEach(() => { + mountComponent(); + return waitForPromises(); + }); + + it('shows opened time', () => { + expect(wrapper.text()).toContain('Opened 4 days ago'); + }); + + it('shows title', () => { + expect(wrapper.find('h5').text()).toBe(mrQueryResponse.data.project.mergeRequest.title); + }); + + it('shows reference', () => { + expect(wrapper.text()).toContain('foo/bar!1'); + }); + + it('shows CI Icon if there is pipeline data', async () => { + expect(wrapper.findComponent(CiIcon).exists()).toBe(true); + }); + }); + + describe('without detailed status', () => { + beforeEach(() => { + mountComponent({ + queryResponse: jest.fn().mockResolvedValue(mrQueryResponseWithoutDetailedStatus), + }); + return waitForPromises(); + }); + + it('does not show CI icon if there is no pipeline data', async () => { + expect(wrapper.findComponent(CiIcon).exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/mr_popover/index_spec.js b/spec/frontend/issuable/popover/index_spec.js index fd8ced17aea..b1aa7f0f0b0 100644 --- a/spec/frontend/mr_popover/index_spec.js +++ b/spec/frontend/issuable/popover/index_spec.js @@ -1,45 +1,52 @@ import { setHTMLFixture } from 'helpers/fixtures'; import * as createDefaultClient from '~/lib/graphql'; -import initMRPopovers from '~/mr_popover/index'; +import initIssuablePopovers from '~/issuable/popover/index'; createDefaultClient.default = jest.fn(); -describe('initMRPopovers', () => { +describe('initIssuablePopovers', () => { let mr1; let mr2; let mr3; + let issue1; beforeEach(() => { setHTMLFixture(` - <div id="one" class="gfm-merge_request" data-mr-title="title" data-iid="1" data-project-path="group/project"> + <div id="one" class="gfm-merge_request" data-mr-title="title" data-iid="1" data-project-path="group/project" data-reference-type="merge_request"> MR1 </div> - <div id="two" class="gfm-merge_request" data-mr-title="title" data-iid="1" data-project-path="group/project"> + <div id="two" class="gfm-merge_request" title="title" data-iid="1" data-project-path="group/project" data-reference-type="merge_request"> MR2 </div> <div id="three" class="gfm-merge_request"> MR3 </div> + <div id="four" class="gfm-issue" title="title" data-iid="1" data-project-path="group/project" data-reference-type="issue"> + MR3 + </div> `); mr1 = document.querySelector('#one'); mr2 = document.querySelector('#two'); mr3 = document.querySelector('#three'); + issue1 = document.querySelector('#four'); mr1.addEventListener = jest.fn(); mr2.addEventListener = jest.fn(); mr3.addEventListener = jest.fn(); + issue1.addEventListener = jest.fn(); }); it('does not add the same event listener twice', () => { - initMRPopovers([mr1, mr1, mr2]); + initIssuablePopovers([mr1, mr1, mr2, issue1]); expect(mr1.addEventListener).toHaveBeenCalledTimes(1); expect(mr2.addEventListener).toHaveBeenCalledTimes(1); + expect(issue1.addEventListener).toHaveBeenCalledTimes(1); }); it('does not add listener if it does not have the necessary data attributes', () => { - initMRPopovers([mr1, mr2, mr3]); + initIssuablePopovers([mr1, mr2, mr3]); expect(mr3.addEventListener).not.toHaveBeenCalled(); }); diff --git a/spec/frontend/issues/create_merge_request_dropdown_spec.js b/spec/frontend/issues/create_merge_request_dropdown_spec.js index 20b26f5abba..cb7173c56a8 100644 --- a/spec/frontend/issues/create_merge_request_dropdown_spec.js +++ b/spec/frontend/issues/create_merge_request_dropdown_spec.js @@ -84,7 +84,7 @@ describe('CreateMergeRequestDropdown', () => { }); it('enables when can create confidential issue', () => { - document.querySelector('.js-create-mr').setAttribute('data-is-confidential', 'true'); + document.querySelector('.js-create-mr').dataset.isConfidential = 'true'; confidentialState.selectedProject = { name: 'test' }; dropdown.enable(); @@ -93,7 +93,7 @@ describe('CreateMergeRequestDropdown', () => { }); it('does not enable when can not create confidential issue', () => { - document.querySelector('.js-create-mr').setAttribute('data-is-confidential', 'true'); + document.querySelector('.js-create-mr').dataset.isConfidential = 'true'; dropdown.enable(); diff --git a/spec/frontend/issues/list/components/issues_list_app_spec.js b/spec/frontend/issues/list/components/issues_list_app_spec.js index d92ba527b5c..3f2c3c3ec5f 100644 --- a/spec/frontend/issues/list/components/issues_list_app_spec.js +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -8,8 +8,6 @@ import VueApollo from 'vue-apollo'; import VueRouter from 'vue-router'; import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql'; -import getIssuesWithoutCrmQuery from 'ee_else_ce/issues/list/queries/get_issues_without_crm.query.graphql'; -import getIssuesCountsWithoutCrmQuery from 'ee_else_ce/issues/list/queries/get_issues_counts_without_crm.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; @@ -38,9 +36,11 @@ import { TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, + TOKEN_TYPE_CONTACT, TOKEN_TYPE_LABEL, TOKEN_TYPE_MILESTONE, TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_ORGANIZATION, TOKEN_TYPE_RELEASE, TOKEN_TYPE_TYPE, urlSortParams, @@ -67,6 +67,9 @@ describe('CE IssuesListApp component', () => { autocompleteAwardEmojisPath: 'autocomplete/award/emojis/path', calendarPath: 'calendar/path', canBulkUpdate: false, + canCreateProjects: false, + canReadCrmContact: false, + canReadCrmOrganization: false, emptyStateSvgPath: 'empty-state.svg', exportCsvPath: 'export/csv/path', fullPath: 'path/to/project', @@ -77,6 +80,7 @@ describe('CE IssuesListApp component', () => { hasIssueWeightsFeature: true, hasIterationsFeature: true, hasMultipleIssueAssigneesFeature: true, + hasScopedLabelsFeature: true, initialEmail: 'email@example.com', initialSort: CREATED_DESC, isAnonymousSearchDisabled: false, @@ -86,6 +90,7 @@ describe('CE IssuesListApp component', () => { isSignedIn: true, jiraIntegrationPath: 'jira/integration/path', newIssuePath: 'new/issue/path', + newProjectPath: 'new/project/path', releasesPath: 'releases/path', rssPath: 'rss/path', showNewIssueLink: true, @@ -100,6 +105,9 @@ describe('CE IssuesListApp component', () => { defaultQueryResponse.data.project.issues.nodes[0].weight = 5; } + const mockIssuesQueryResponse = jest.fn().mockResolvedValue(defaultQueryResponse); + const mockIssuesCountsQueryResponse = jest.fn().mockResolvedValue(getIssuesCountsQueryResponse); + const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons); const findIssuableByEmail = () => wrapper.findComponent(IssuableByEmail); const findGlButton = () => wrapper.findComponent(GlButton); @@ -113,16 +121,15 @@ describe('CE IssuesListApp component', () => { const mountComponent = ({ provide = {}, data = {}, - issuesQueryResponse = jest.fn().mockResolvedValue(defaultQueryResponse), - issuesCountsQueryResponse = jest.fn().mockResolvedValue(getIssuesCountsQueryResponse), + issuesQueryResponse = mockIssuesQueryResponse, + issuesCountsQueryResponse = mockIssuesCountsQueryResponse, sortPreferenceMutationResponse = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse), + stubs = {}, mountFn = shallowMount, } = {}) => { const requestHandlers = [ [getIssuesQuery, issuesQueryResponse], [getIssuesCountsQuery, issuesCountsQueryResponse], - [getIssuesWithoutCrmQuery, issuesQueryResponse], - [getIssuesCountsWithoutCrmQuery, issuesCountsQueryResponse], [setSortPreferenceMutation, sortPreferenceMutationResponse], ]; @@ -136,6 +143,7 @@ describe('CE IssuesListApp component', () => { data() { return data; }, + stubs, }); }; @@ -156,6 +164,22 @@ describe('CE IssuesListApp component', () => { return waitForPromises(); }); + it('queries list with types `ISSUE` and `INCIDENT', () => { + const expectedTypes = ['ISSUE', 'INCIDENT', 'TEST_CASE']; + + expect(mockIssuesQueryResponse).toHaveBeenCalledWith( + expect.objectContaining({ + types: expectedTypes, + }), + ); + + expect(mockIssuesCountsQueryResponse).toHaveBeenCalledWith( + expect.objectContaining({ + types: expectedTypes, + }), + ); + }); + it('renders', () => { expect(findIssuableList().props()).toMatchObject({ namespace: defaultProvide.fullPath, @@ -301,17 +325,23 @@ describe('CE IssuesListApp component', () => { describe('initial url params', () => { describe('page', () => { it('page_after is set from the url params', () => { - setWindowLocation('?page_after=randomCursorString'); + setWindowLocation('?page_after=randomCursorString&first_page_size=20'); wrapper = mountComponent(); - expect(wrapper.vm.$route.query).toMatchObject({ page_after: 'randomCursorString' }); + expect(wrapper.vm.$route.query).toMatchObject({ + page_after: 'randomCursorString', + first_page_size: '20', + }); }); it('page_before is set from the url params', () => { - setWindowLocation('?page_before=anotherRandomCursorString'); + setWindowLocation('?page_before=anotherRandomCursorString&last_page_size=20'); wrapper = mountComponent(); - expect(wrapper.vm.$route.query).toMatchObject({ page_before: 'anotherRandomCursorString' }); + expect(wrapper.vm.$route.query).toMatchObject({ + page_before: 'anotherRandomCursorString', + last_page_size: '20', + }); }); }); @@ -515,10 +545,12 @@ describe('CE IssuesListApp component', () => { it('shows empty state', () => { expect(findGlEmptyState().props()).toMatchObject({ - description: IssuesListApp.i18n.noIssuesSignedInDescription, title: IssuesListApp.i18n.noIssuesSignedInTitle, svgPath: defaultProvide.emptyStateSvgPath, }); + expect(findGlEmptyState().text()).toContain( + IssuesListApp.i18n.noIssuesSignedInDescription, + ); }); it('shows "New issue" and import/export buttons', () => { @@ -532,11 +564,11 @@ describe('CE IssuesListApp component', () => { it('shows Jira integration information', () => { const paragraphs = wrapper.findAll('p'); - expect(paragraphs.at(1).text()).toContain(IssuesListApp.i18n.jiraIntegrationTitle); - expect(paragraphs.at(2).text()).toContain( + expect(paragraphs.at(2).text()).toContain(IssuesListApp.i18n.jiraIntegrationTitle); + expect(paragraphs.at(3).text()).toContain( 'Enable the Jira integration to view your Jira issues in GitLab.', ); - expect(paragraphs.at(3).text()).toContain( + expect(paragraphs.at(4).text()).toContain( IssuesListApp.i18n.jiraIntegrationSecondaryMessage, ); expect(findGlLink().text()).toBe('Enable the Jira integration'); @@ -544,6 +576,29 @@ describe('CE IssuesListApp component', () => { }); }); + describe('when user is logged in and can create projects', () => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { canCreateProjects: true, hasAnyIssues: false, isSignedIn: true }, + stubs: { GlEmptyState }, + }); + }); + + it('shows empty state with additional description about creating projects', () => { + expect(findGlEmptyState().text()).toContain( + IssuesListApp.i18n.noIssuesSignedInDescription, + ); + expect(findGlEmptyState().text()).toContain( + IssuesListApp.i18n.noGroupIssuesSignedInDescription, + ); + }); + + it('shows "New project" button', () => { + expect(findGlButton().text()).toBe(IssuesListApp.i18n.newProjectLabel); + expect(findGlButton().attributes('href')).toBe(defaultProvide.newProjectPath); + }); + }); + describe('when user is logged out', () => { beforeEach(() => { wrapper = mountComponent({ @@ -587,6 +642,21 @@ describe('CE IssuesListApp component', () => { }); }); + describe('when user does not have CRM enabled', () => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { canReadCrmContact: false, canReadCrmOrganization: false }, + }); + }); + + it('does not render Contact or Organization tokens', () => { + expect(findIssuableList().props('searchTokens')).not.toMatchObject([ + { type: TOKEN_TYPE_CONTACT }, + { type: TOKEN_TYPE_ORGANIZATION }, + ]); + }); + }); + describe('when all tokens are available', () => { const originalGon = window.gon; @@ -599,7 +669,13 @@ describe('CE IssuesListApp component', () => { current_user_avatar_url: mockCurrentUser.avatar_url, }; - wrapper = mountComponent({ provide: { isSignedIn: true } }); + wrapper = mountComponent({ + provide: { + canReadCrmContact: true, + canReadCrmOrganization: true, + isSignedIn: true, + }, + }); }); afterEach(() => { @@ -615,9 +691,11 @@ describe('CE IssuesListApp component', () => { { type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors }, { type: TOKEN_TYPE_AUTHOR, preloadedAuthors }, { type: TOKEN_TYPE_CONFIDENTIAL }, + { type: TOKEN_TYPE_CONTACT }, { type: TOKEN_TYPE_LABEL }, { type: TOKEN_TYPE_MILESTONE }, { type: TOKEN_TYPE_MY_REACTION }, + { type: TOKEN_TYPE_ORGANIZATION }, { type: TOKEN_TYPE_RELEASE }, { type: TOKEN_TYPE_TYPE }, ]); @@ -675,10 +753,10 @@ describe('CE IssuesListApp component', () => { }); describe.each` - event | paramName | paramValue - ${'next-page'} | ${'page_after'} | ${'endCursor'} - ${'previous-page'} | ${'page_before'} | ${'startCursor'} - `('when "$event" event is emitted by IssuableList', ({ event, paramName, paramValue }) => { + event | params + ${'next-page'} | ${{ page_after: 'endCursor', page_before: undefined, first_page_size: 20, last_page_size: undefined }} + ${'previous-page'} | ${{ page_after: undefined, page_before: 'startCursor', first_page_size: undefined, last_page_size: 20 }} + `('when "$event" event is emitted by IssuableList', ({ event, params }) => { beforeEach(() => { wrapper = mountComponent({ data: { @@ -697,9 +775,9 @@ describe('CE IssuesListApp component', () => { expect(scrollUp).toHaveBeenCalled(); }); - it(`updates url with "${paramName}" param`, () => { + it(`updates url`, () => { expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ - query: expect.objectContaining({ [paramName]: paramValue }), + query: expect.objectContaining(params), }); }); }); diff --git a/spec/frontend/issues/list/utils_spec.js b/spec/frontend/issues/list/utils_spec.js index ce0477883d7..e8ffba9bc80 100644 --- a/spec/frontend/issues/list/utils_spec.js +++ b/spec/frontend/issues/list/utils_spec.js @@ -42,27 +42,37 @@ describe('getInitialPageParams', () => { 'returns the correct page params for sort key %s with afterCursor', (sortKey) => { const firstPageSize = sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE; + const lastPageSize = undefined; const afterCursor = 'randomCursorString'; const beforeCursor = undefined; - - expect(getInitialPageParams(sortKey, afterCursor, beforeCursor)).toEqual({ + const pageParams = getInitialPageParams( + sortKey, firstPageSize, + lastPageSize, afterCursor, - }); + beforeCursor, + ); + + expect(pageParams).toEqual({ firstPageSize, afterCursor }); }, ); it.each(Object.keys(urlSortParams))( 'returns the correct page params for sort key %s with beforeCursor', (sortKey) => { - const firstPageSize = sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE; + const firstPageSize = undefined; + const lastPageSize = PAGE_SIZE; const afterCursor = undefined; const beforeCursor = 'anotherRandomCursorString'; - - expect(getInitialPageParams(sortKey, afterCursor, beforeCursor)).toEqual({ + const pageParams = getInitialPageParams( + sortKey, firstPageSize, + lastPageSize, + afterCursor, beforeCursor, - }); + ); + + expect(pageParams).toEqual({ lastPageSize, beforeCursor }); }, ); }); diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js index 1ae04531a6b..2cc27309e59 100644 --- a/spec/frontend/issues/show/components/description_spec.js +++ b/spec/frontend/issues/show/components/description_spec.js @@ -17,6 +17,7 @@ import { updateHistory } from '~/lib/utils/url_utility'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import TaskList from '~/task_list'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; +import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; import { descriptionProps as initialProps, @@ -370,10 +371,10 @@ describe('Description component', () => { await findTaskLink().trigger('click'); expect(trackingSpy).toHaveBeenCalledWith( - 'workItems:show', + TRACKING_CATEGORY_SHOW, 'viewed_work_item_from_modal', { - category: 'workItems:show', + category: TRACKING_CATEGORY_SHOW, label: 'work_item_view', property: 'type_task', }, diff --git a/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js index 35acca60de7..8e090645be2 100644 --- a/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js +++ b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js @@ -5,6 +5,7 @@ import { trackIncidentDetailsViewsOptions } from '~/incidents/constants'; import DescriptionComponent from '~/issues/show/components/description.vue'; import HighlightBar from '~/issues/show/components/incidents/highlight_bar.vue'; import IncidentTabs from '~/issues/show/components/incidents/incident_tabs.vue'; +import TimelineTab from '~/issues/show/components/incidents/timeline_events_tab.vue'; import INVALID_URL from '~/lib/utils/invalid_url'; import Tracking from '~/tracking'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; @@ -35,8 +36,9 @@ describe('Incident Tabs component', () => { fullPath: '', iid: '', projectId: '', + issuableId: '', uploadMetricsFeatureAvailable: true, - glFeatures: { incidentTimeline: true, incidentTimelineEvents: true }, + glFeatures: { incidentTimeline: true }, }, data() { return { alert: mockAlert, ...data }; @@ -47,6 +49,9 @@ describe('Incident Tabs component', () => { alert: { loading: true, }, + timelineEvents: { + loading: false, + }, }, }, }, @@ -62,6 +67,7 @@ describe('Incident Tabs component', () => { const findAlertDetailsComponent = () => wrapper.find(AlertDetailsTable); const findDescriptionComponent = () => wrapper.find(DescriptionComponent); const findHighlightBarComponent = () => wrapper.find(HighlightBar); + const findTimelineTab = () => wrapper.findComponent(TimelineTab); describe('empty state', () => { beforeEach(() => { @@ -122,4 +128,20 @@ describe('Incident Tabs component', () => { expect(Tracking.event).toHaveBeenCalledWith(category, action); }); }); + + describe('incident timeline tab', () => { + beforeEach(() => { + mountComponent(); + }); + + it('renders the timeline tab when feature flag is enabled', () => { + expect(findTimelineTab().exists()).toBe(true); + }); + + it('does not render timeline tab when feature flag is disabled', () => { + mountComponent({}, { provide: { glFeatures: { incidentTimeline: false } } }); + + expect(findTimelineTab().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/issues/show/components/incidents/mock_data.js b/spec/frontend/issues/show/components/incidents/mock_data.js new file mode 100644 index 00000000000..b5346a6089a --- /dev/null +++ b/spec/frontend/issues/show/components/incidents/mock_data.js @@ -0,0 +1,72 @@ +export const mockEvents = [ + { + action: 'comment', + author: { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + name: 'Administrator', + username: 'root', + }, + createdAt: '2022-03-22T15:59:08Z', + id: 'gid://gitlab/IncidentManagement::TimelineEvent/132', + note: 'Dummy event 1', + noteHtml: '<p>Dummy event 1</p>', + occurredAt: '2022-03-22T15:59:00Z', + updatedAt: '2022-03-22T15:59:08Z', + __typename: 'TimelineEventType', + }, + { + action: 'comment', + author: { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + name: 'Administrator', + username: 'root', + }, + createdAt: '2022-03-23T14:57:08Z', + id: 'gid://gitlab/IncidentManagement::TimelineEvent/131', + note: 'Dummy event 2', + noteHtml: '<p>Dummy event 2</p>', + occurredAt: '2022-03-23T14:57:00Z', + updatedAt: '2022-03-23T14:57:08Z', + __typename: 'TimelineEventType', + }, + { + action: 'comment', + author: { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + name: 'Administrator', + username: 'root', + }, + createdAt: '2022-03-23T15:59:08Z', + id: 'gid://gitlab/IncidentManagement::TimelineEvent/132', + note: 'Dummy event 3', + noteHtml: '<p>Dummy event 3</p>', + occurredAt: '2022-03-23T15:59:00Z', + updatedAt: '2022-03-23T15:59:08Z', + __typename: 'TimelineEventType', + }, +]; + +export const timelineEventsQueryListResponse = { + data: { + project: { + id: 'gid://gitlab/Project/8', + incidentManagementTimelineEvents: { + nodes: mockEvents, + }, + }, + }, +}; + +export const timelineEventsQueryEmptyResponse = { + data: { + project: { + id: 'gid://gitlab/Project/8', + incidentManagementTimelineEvents: { + nodes: [], + }, + }, + }, +}; diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_list_item_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_list_item_spec.js new file mode 100644 index 00000000000..7e51219ffa7 --- /dev/null +++ b/spec/frontend/issues/show/components/incidents/timeline_events_list_item_spec.js @@ -0,0 +1,87 @@ +import timezoneMock from 'timezone-mock'; +import merge from 'lodash/merge'; +import { GlIcon } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import IncidentTimelineEventListItem from '~/issues/show/components/incidents/timeline_events_list_item.vue'; +import { mockEvents } from './mock_data'; + +describe('IncidentTimelineEventList', () => { + let wrapper; + + const mountComponent = (propsData) => { + const { action, noteHtml, occurredAt } = mockEvents[0]; + wrapper = mountExtended( + IncidentTimelineEventListItem, + merge({ + propsData: { + action, + noteHtml, + occurredAt, + isLastItem: false, + ...propsData, + }, + }), + ); + }; + + const findCommentIcon = () => wrapper.findComponent(GlIcon); + const findTextContainer = () => wrapper.findByTestId('event-text-container'); + const findEventTime = () => wrapper.findByTestId('event-time'); + + describe('template', () => { + it('shows comment icon', () => { + mountComponent(); + + expect(findCommentIcon().exists()).toBe(true); + }); + + it('sets correct props for icon', () => { + mountComponent(); + + expect(findCommentIcon().props('name')).toBe(mockEvents[0].action); + }); + + it('displays the correct time', () => { + mountComponent(); + + expect(findEventTime().text()).toBe('15:59 UTC'); + }); + + describe('last item in list', () => { + it('shows a bottom border when not the last item', () => { + mountComponent(); + + expect(findTextContainer().classes()).toContain('gl-border-1'); + }); + + it('does not show a bottom border when the last item', () => { + mountComponent({ isLastItem: true }); + + expect(wrapper.classes()).not.toContain('gl-border-1'); + }); + }); + + describe.each` + timezone + ${'Europe/London'} + ${'US/Pacific'} + ${'Australia/Adelaide'} + `('when viewing in timezone', ({ timezone }) => { + describe(timezone, () => { + beforeEach(() => { + timezoneMock.register(timezone); + + mountComponent(); + }); + + afterEach(() => { + timezoneMock.unregister(); + }); + + it('displays the correct time', () => { + expect(findEventTime().text()).toBe('15:59 UTC'); + }); + }); + }); + }); +}); diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js new file mode 100644 index 00000000000..6610ea0b832 --- /dev/null +++ b/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js @@ -0,0 +1,87 @@ +import timezoneMock from 'timezone-mock'; +import merge from 'lodash/merge'; +import { shallowMountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; +import IncidentTimelineEventList from '~/issues/show/components/incidents/timeline_events_list.vue'; +import { mockEvents } from './mock_data'; + +describe('IncidentTimelineEventList', () => { + let wrapper; + + const mountComponent = () => { + wrapper = shallowMountExtended( + IncidentTimelineEventList, + merge({ + provide: { + fullPath: 'group/project', + issuableId: '1', + }, + propsData: { + timelineEvents: mockEvents, + }, + }), + ); + }; + + const findGroups = () => wrapper.findAllByTestId('timeline-group'); + const findItems = (base = wrapper) => base.findAllByTestId('timeline-event'); + const findFirstGroup = () => extendedWrapper(findGroups().at(0)); + const findSecondGroup = () => extendedWrapper(findGroups().at(1)); + const findDates = () => wrapper.findAllByTestId('event-date'); + + describe('template', () => { + it('groups items correctly', () => { + mountComponent(); + + expect(findGroups()).toHaveLength(2); + + expect(findItems(findFirstGroup())).toHaveLength(1); + expect(findItems(findSecondGroup())).toHaveLength(2); + }); + + it('sets the isLastItem prop correctly', () => { + mountComponent(); + + expect(findItems().at(0).props('isLastItem')).toBe(false); + expect(findItems().at(1).props('isLastItem')).toBe(false); + expect(findItems().at(2).props('isLastItem')).toBe(true); + }); + + it('sets the event props correctly', () => { + mountComponent(); + + expect(findItems().at(1).props('occurredAt')).toBe(mockEvents[1].occurredAt); + expect(findItems().at(1).props('action')).toBe(mockEvents[1].action); + expect(findItems().at(1).props('noteHtml')).toBe(mockEvents[1].noteHtml); + }); + + it('formats dates correctly', () => { + mountComponent(); + + expect(findDates().at(0).text()).toBe('2022-03-22'); + expect(findDates().at(1).text()).toBe('2022-03-23'); + }); + + describe.each` + timezone + ${'Europe/London'} + ${'US/Pacific'} + ${'Australia/Adelaide'} + `('when viewing in timezone', ({ timezone }) => { + describe(timezone, () => { + beforeEach(() => { + timezoneMock.register(timezone); + + mountComponent(); + }); + + afterEach(() => { + timezoneMock.unregister(); + }); + + it('displays the correct time', () => { + expect(findDates().at(0).text()).toBe('2022-03-22'); + }); + }); + }); + }); +}); diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_tab_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_tab_spec.js new file mode 100644 index 00000000000..cf81f4cdf66 --- /dev/null +++ b/spec/frontend/issues/show/components/incidents/timeline_events_tab_spec.js @@ -0,0 +1,105 @@ +import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import TimelineEventsTab from '~/issues/show/components/incidents/timeline_events_tab.vue'; +import IncidentTimelineEventsList from '~/issues/show/components/incidents/timeline_events_list.vue'; +import timelineEventsQuery from '~/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { createAlert } from '~/flash'; +import { timelineEventsQueryListResponse, timelineEventsQueryEmptyResponse } from './mock_data'; + +Vue.use(VueApollo); + +jest.mock('~/flash'); + +const graphQLError = new Error('GraphQL error'); +const listResponse = jest.fn().mockResolvedValue(timelineEventsQueryListResponse); +const emptyResponse = jest.fn().mockResolvedValue(timelineEventsQueryEmptyResponse); +const errorResponse = jest.fn().mockRejectedValue(graphQLError); + +function createMockApolloProvider(response = listResponse) { + const requestHandlers = [[timelineEventsQuery, response]]; + return createMockApollo(requestHandlers); +} + +describe('TimelineEventsTab', () => { + let wrapper; + + const mountComponent = (options = {}) => { + const { mockApollo, mountMethod = shallowMountExtended } = options; + + wrapper = mountMethod(TimelineEventsTab, { + provide: { + fullPath: 'group/project', + issuableId: '1', + }, + apolloProvider: mockApollo, + }); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findTimelineEventsList = () => wrapper.findComponent(IncidentTimelineEventsList); + + describe('Timeline events tab', () => { + describe('empty state', () => { + let mockApollo; + + it('should show an empty list', async () => { + mockApollo = createMockApolloProvider(emptyResponse); + mountComponent({ mockApollo }); + await waitForPromises(); + + expect(findEmptyState().exists()).toBe(true); + }); + }); + + describe('error state', () => { + let mockApollo; + + it('should show an error state', async () => { + mockApollo = createMockApolloProvider(errorResponse); + mountComponent({ mockApollo }); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + captureError: true, + error: graphQLError, + message: 'Something went wrong while fetching incident timeline events.', + }); + }); + }); + }); + + describe('timelineEventsQuery', () => { + let mockApollo; + + beforeEach(() => { + mockApollo = createMockApolloProvider(); + mountComponent({ mockApollo }); + }); + + it('should request data', () => { + expect(listResponse).toHaveBeenCalled(); + }); + + it('should show the loading state', () => { + expect(findEmptyState().exists()).toBe(false); + expect(findLoadingSpinner().exists()).toBe(true); + }); + + it('should render the list', async () => { + await waitForPromises(); + expect(findEmptyState().exists()).toBe(false); + expect(findTimelineEventsList().props('timelineEvents')).toHaveLength(3); + }); + }); +}); diff --git a/spec/frontend/issues/show/components/incidents/utils_spec.js b/spec/frontend/issues/show/components/incidents/utils_spec.js new file mode 100644 index 00000000000..e6f7082d280 --- /dev/null +++ b/spec/frontend/issues/show/components/incidents/utils_spec.js @@ -0,0 +1,31 @@ +import { displayAndLogError, getEventIcon } from '~/issues/show/components/incidents/utils'; +import { createAlert } from '~/flash'; + +jest.mock('~/flash'); + +describe('incident utils', () => { + describe('display and log error', () => { + it('displays and logs an error', () => { + const error = new Error('test'); + displayAndLogError(error); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'Something went wrong while fetching incident timeline events.', + captureError: true, + error, + }); + }); + }); + + describe('get event icon', () => { + it('should display a matching event icon name', () => { + const name = 'comment'; + + expect(getEventIcon(name)).toBe(name); + }); + + it('should return a default icon name', () => { + expect(getEventIcon('non-existent-icon-name')).toBe('comment'); + }); + }); +}); diff --git a/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js b/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js index 8730e124ae7..8f79c74368f 100644 --- a/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js @@ -35,7 +35,7 @@ describe('SignInOauthButton', () => { let mockAxios; let store; - const createComponent = ({ slots } = {}) => { + const createComponent = ({ slots, props } = {}) => { store = createStore(); jest.spyOn(store, 'dispatch').mockImplementation(); jest.spyOn(store, 'commit').mockImplementation(); @@ -46,6 +46,7 @@ describe('SignInOauthButton', () => { provide: { oauthMetadata: mockOauthMetadata, }, + propsData: props, }); }; @@ -65,6 +66,7 @@ describe('SignInOauthButton', () => { expect(findButton().exists()).toBe(true); expect(findButton().text()).toBe(I18N_DEFAULT_SIGN_IN_BUTTON_TEXT); + expect(findButton().props('category')).toBe('primary'); }); it.each` @@ -208,4 +210,11 @@ describe('SignInOauthButton', () => { }); }); }); + + describe('when `category` prop is set', () => { + it('sets the `category` prop on the GlButton', () => { + createComponent({ props: { category: 'tertiary' } }); + expect(findButton().props('category')).toBe('tertiary'); + }); + }); }); diff --git a/spec/frontend/jira_connect/subscriptions/components/user_link_spec.js b/spec/frontend/jira_connect/subscriptions/components/user_link_spec.js index 2f5e47d1ae4..e16121243a0 100644 --- a/spec/frontend/jira_connect/subscriptions/components/user_link_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/user_link_spec.js @@ -1,5 +1,7 @@ import { GlSprintf } from '@gitlab/ui'; import UserLink from '~/jira_connect/subscriptions/components/user_link.vue'; +import SignInOauthButton from '~/jira_connect/subscriptions/components/sign_in_oauth_button.vue'; + import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -16,6 +18,7 @@ describe('UserLink', () => { provide, stubs: { GlSprintf, + SignInOauthButton, }, }); }; @@ -23,28 +26,48 @@ describe('UserLink', () => { const findSignInLink = () => wrapper.findByTestId('sign-in-link'); const findGitlabUserLink = () => wrapper.findByTestId('gitlab-user-link'); const findSprintf = () => wrapper.findComponent(GlSprintf); + const findOauthButton = () => wrapper.findComponent(SignInOauthButton); afterEach(() => { wrapper.destroy(); }); describe.each` - userSignedIn | hasSubscriptions | expectGlSprintf | expectGlLink - ${true} | ${false} | ${true} | ${false} - ${false} | ${true} | ${false} | ${true} - ${true} | ${true} | ${true} | ${false} - ${false} | ${false} | ${false} | ${false} + userSignedIn | hasSubscriptions | expectGlSprintf | expectGlLink | expectOauthButton | jiraConnectOauthEnabled + ${true} | ${false} | ${true} | ${false} | ${false} | ${false} + ${false} | ${true} | ${false} | ${true} | ${false} | ${false} + ${true} | ${true} | ${true} | ${false} | ${false} | ${false} + ${false} | ${false} | ${false} | ${false} | ${false} | ${false} + ${false} | ${true} | ${false} | ${false} | ${true} | ${true} `( - 'when `userSignedIn` is $userSignedIn and `hasSubscriptions` is $hasSubscriptions', - ({ userSignedIn, hasSubscriptions, expectGlSprintf, expectGlLink }) => { + 'when `userSignedIn` is $userSignedIn, `hasSubscriptions` is $hasSubscriptions, `jiraConnectOauthEnabled` is $jiraConnectOauthEnabled', + ({ + userSignedIn, + hasSubscriptions, + expectGlSprintf, + expectGlLink, + expectOauthButton, + jiraConnectOauthEnabled, + }) => { it('renders template correctly', () => { - createComponent({ - userSignedIn, - hasSubscriptions, - }); + createComponent( + { + userSignedIn, + hasSubscriptions, + }, + { + provide: { + glFeatures: { + jiraConnectOauth: jiraConnectOauthEnabled, + }, + oauthMetadata: {}, + }, + }, + ); expect(findSprintf().exists()).toBe(expectGlSprintf); expect(findSignInLink().exists()).toBe(expectGlLink); + expect(findOauthButton().exists()).toBe(expectOauthButton); }); }, ); diff --git a/spec/frontend/jobs/components/log/collapsible_section_spec.js b/spec/frontend/jobs/components/log/collapsible_section_spec.js index 22ddc8b1c2d..2ab7f5fe22d 100644 --- a/spec/frontend/jobs/components/log/collapsible_section_spec.js +++ b/spec/frontend/jobs/components/log/collapsible_section_spec.js @@ -45,7 +45,7 @@ describe('Job Log Collapsible Section', () => { }); it('renders an icon with the closed state', () => { - expect(findCollapsibleLineSvg().attributes('data-testid')).toBe('angle-right-icon'); + expect(findCollapsibleLineSvg().attributes('data-testid')).toBe('chevron-lg-right-icon'); }); }); @@ -62,7 +62,7 @@ describe('Job Log Collapsible Section', () => { }); it('renders an icon with the open state', () => { - expect(findCollapsibleLineSvg().attributes('data-testid')).toBe('angle-down-icon'); + expect(findCollapsibleLineSvg().attributes('data-testid')).toBe('chevron-lg-down-icon'); }); it('renders collapsible lines content', () => { diff --git a/spec/frontend/jobs/components/log/line_header_spec.js b/spec/frontend/jobs/components/log/line_header_spec.js index 8055fe64d95..bdc8ae0eef0 100644 --- a/spec/frontend/jobs/components/log/line_header_spec.js +++ b/spec/frontend/jobs/components/log/line_header_spec.js @@ -56,8 +56,8 @@ describe('Job Log Header Line', () => { createComponent({ ...data, isClosed: true }); }); - it('sets icon name to be angle-right', () => { - expect(wrapper.vm.iconName).toEqual('angle-right'); + it('sets icon name to be chevron-lg-right', () => { + expect(wrapper.vm.iconName).toEqual('chevron-lg-right'); }); }); @@ -66,8 +66,8 @@ describe('Job Log Header Line', () => { createComponent({ ...data, isClosed: false }); }); - it('sets icon name to be angle-down', () => { - expect(wrapper.vm.iconName).toEqual('angle-down'); + it('sets icon name to be chevron-lg-down', () => { + expect(wrapper.vm.iconName).toEqual('chevron-lg-down'); }); }); diff --git a/spec/frontend/jobs/components/log/log_spec.js b/spec/frontend/jobs/components/log/log_spec.js index 7e11738f82e..9cc56cce9b3 100644 --- a/spec/frontend/jobs/components/log/log_spec.js +++ b/spec/frontend/jobs/components/log/log_spec.js @@ -68,7 +68,9 @@ describe('Job Log', () => { }); it('renders an icon with the open state', () => { - expect(findCollapsibleLine().find('[data-testid="angle-down-icon"]').exists()).toBe(true); + expect(findCollapsibleLine().find('[data-testid="chevron-lg-down-icon"]').exists()).toBe( + true, + ); }); describe('on click header section', () => { @@ -146,7 +148,9 @@ describe('Job Log, infinitelyCollapsibleSections feature flag enabled', () => { }); it('renders an icon with the open state', () => { - expect(findCollapsibleLine().find('[data-testid="angle-down-icon"]').exists()).toBe(true); + expect(findCollapsibleLine().find('[data-testid="chevron-lg-down-icon"]').exists()).toBe( + true, + ); }); describe('on click header section', () => { diff --git a/spec/frontend/labels/delete_label_modal_spec.js b/spec/frontend/labels/delete_label_modal_spec.js index 98049538948..67220821fe0 100644 --- a/spec/frontend/labels/delete_label_modal_spec.js +++ b/spec/frontend/labels/delete_label_modal_spec.js @@ -25,11 +25,11 @@ describe('DeleteLabelModal', () => { buttons.forEach((x) => { const button = document.createElement('button'); button.setAttribute('class', 'js-delete-label-modal-button'); - button.setAttribute('data-label-name', x.labelName); - button.setAttribute('data-destroy-path', x.destroyPath); + button.dataset.labelName = x.labelName; + button.dataset.destroyPath = x.destroyPath; if (x.subjectName) { - button.setAttribute('data-subject-name', x.subjectName); + button.dataset.subjectName = x.subjectName; } button.innerHTML = 'Action'; diff --git a/spec/frontend/lazy_loader_spec.js b/spec/frontend/lazy_loader_spec.js index 3d8b0d9c307..e0b6c7119f9 100644 --- a/spec/frontend/lazy_loader_spec.js +++ b/spec/frontend/lazy_loader_spec.js @@ -27,7 +27,7 @@ describe('LazyLoader', () => { const createLazyLoadImage = () => { const newImg = document.createElement('img'); newImg.className = 'lazy'; - newImg.setAttribute('data-src', TEST_PATH); + newImg.dataset.src = TEST_PATH; document.body.appendChild(newImg); triggerChildMutation(); @@ -108,7 +108,7 @@ describe('LazyLoader', () => { expect(LazyLoader.loadImage).toHaveBeenCalledWith(img); expect(img.getAttribute('src')).toBe(TEST_PATH); - expect(img.getAttribute('data-src')).toBe(null); + expect(img.dataset.src).toBeUndefined(); expect(img).toHaveClass('js-lazy-loaded'); }); diff --git a/spec/frontend/lib/gfm/index_spec.js b/spec/frontend/lib/gfm/index_spec.js index c9a480e9943..7aab0072364 100644 --- a/spec/frontend/lib/gfm/index_spec.js +++ b/spec/frontend/lib/gfm/index_spec.js @@ -1,35 +1,48 @@ import { render } from '~/lib/gfm'; describe('gfm', () => { + const markdownToAST = async (markdown) => { + let result; + + await render({ + markdown, + renderer: (tree) => { + result = tree; + }, + }); + + return result; + }; + + const expectInRoot = (result, ...nodes) => { + expect(result).toEqual( + expect.objectContaining({ + children: expect.arrayContaining(nodes), + }), + ); + }; + describe('render', () => { it('processes Commonmark and provides an ast to the renderer function', async () => { - let result; - - await render({ - markdown: 'This is text', - renderer: (tree) => { - result = tree; - }, - }); + const result = await markdownToAST('This is text'); expect(result.type).toBe('root'); }); it('transforms raw HTML into individual nodes in the AST', async () => { - let result; - - await render({ - markdown: '<strong>This is bold text</strong>', - renderer: (tree) => { - result = tree; - }, - }); + const result = await markdownToAST('<strong>This is bold text</strong>'); - expect(result.children[0].children[0]).toMatchObject({ - type: 'element', - tagName: 'strong', - properties: {}, - }); + expectInRoot( + result, + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + type: 'element', + tagName: 'strong', + }), + ]), + }), + ); }); it('returns the result of executing the renderer function', async () => { @@ -44,5 +57,40 @@ describe('gfm', () => { expect(result).toEqual(rendered); }); + + it('transforms footnotes into footnotedefinition and footnotereference tags', async () => { + const result = await markdownToAST( + `footnote reference [^footnote] + +[^footnote]: Footnote definition`, + ); + + expectInRoot( + result, + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + type: 'element', + tagName: 'footnotereference', + properties: { + identifier: 'footnote', + label: 'footnote', + }, + }), + ]), + }), + ); + + expectInRoot( + result, + expect.objectContaining({ + tagName: 'footnotedefinition', + properties: { + identifier: 'footnote', + label: 'footnote', + }, + }), + ); + }); }); }); diff --git a/spec/frontend/lib/utils/dom_utils_spec.js b/spec/frontend/lib/utils/dom_utils_spec.js index 88dac449527..b537e6b2bf8 100644 --- a/spec/frontend/lib/utils/dom_utils_spec.js +++ b/spec/frontend/lib/utils/dom_utils_spec.js @@ -5,7 +5,6 @@ import { canScrollDown, parseBooleanDataAttributes, isElementVisible, - isElementHidden, getParents, getParentByTagName, setAttributes, @@ -181,30 +180,21 @@ describe('DOM Utils', () => { ${1} | ${0} | ${0} | ${true} ${0} | ${1} | ${0} | ${true} ${0} | ${0} | ${1} | ${true} - `( - 'isElementVisible and isElementHidden', - ({ offsetWidth, offsetHeight, clientRectsLength, visible }) => { - const element = { - offsetWidth, - offsetHeight, - getClientRects: () => new Array(clientRectsLength), - }; - - const paramDescription = `offsetWidth=${offsetWidth}, offsetHeight=${offsetHeight}, and getClientRects().length=${clientRectsLength}`; - - describe('isElementVisible', () => { - it(`returns ${visible} when ${paramDescription}`, () => { - expect(isElementVisible(element)).toBe(visible); - }); + `('isElementVisible', ({ offsetWidth, offsetHeight, clientRectsLength, visible }) => { + const element = { + offsetWidth, + offsetHeight, + getClientRects: () => new Array(clientRectsLength), + }; + + const paramDescription = `offsetWidth=${offsetWidth}, offsetHeight=${offsetHeight}, and getClientRects().length=${clientRectsLength}`; + + describe('isElementVisible', () => { + it(`returns ${visible} when ${paramDescription}`, () => { + expect(isElementVisible(element)).toBe(visible); }); - - describe('isElementHidden', () => { - it(`returns ${!visible} when ${paramDescription}`, () => { - expect(isElementHidden(element)).toBe(!visible); - }); - }); - }, - ); + }); + }); describe('getParents', () => { it('gets all parents of an element', () => { diff --git a/spec/frontend/lib/utils/forms_spec.js b/spec/frontend/lib/utils/forms_spec.js index 123d36ac5d5..2f71b26b29a 100644 --- a/spec/frontend/lib/utils/forms_spec.js +++ b/spec/frontend/lib/utils/forms_spec.js @@ -157,7 +157,7 @@ describe('lib/utils/forms', () => { mountEl.innerHTML = ` <input type="text" placeholder="Name" value="Administrator" name="user[name]" id="user_name" data-js-name="name"> <input type="text" placeholder="Email" value="foo@bar.com" name="user[contact_info][email]" id="user_contact_info_email" data-js-name="contactInfoEmail"> - <input type="text" placeholder="Phone" value="(123) 456-7890" name="user[contact_info][phone]" id="user_contact_info_phone" data-js-name="contact_info_phone"> + <input type="text" placeholder="Phone" value="(123) 456-7890" name="user[contact_info][phone]" id="user_contact_info_phone" maxlength="12" pattern="mockPattern" data-js-name="contact_info_phone"> <input type="hidden" placeholder="Job title" value="" name="user[job_title]" id="user_job_title" data-js-name="jobTitle"> <textarea name="user[bio]" id="user_bio" data-js-name="bio">Foo bar</textarea> <select name="user[timezone]" id="user_timezone" data-js-name="timezone"> @@ -192,6 +192,8 @@ describe('lib/utils/forms', () => { id: 'user_contact_info_phone', value: '(123) 456-7890', placeholder: 'Phone', + maxLength: 12, + pattern: 'mockPattern', }, jobTitle: { name: 'user[job_title]', diff --git a/spec/frontend/lib/utils/rails_ujs_spec.js b/spec/frontend/lib/utils/rails_ujs_spec.js new file mode 100644 index 00000000000..00c29b72e73 --- /dev/null +++ b/spec/frontend/lib/utils/rails_ujs_spec.js @@ -0,0 +1,78 @@ +import { setHTMLFixture } from 'helpers/fixtures'; +import waitForPromises from 'helpers/wait_for_promises'; + +beforeAll(async () => { + // @rails/ujs expects jQuery.ajaxPrefilter to exist if jQuery exists at + // import time. This is only a problem in tests, since we expose jQuery + // globally earlier than in production builds. Work around this by pretending + // that jQuery isn't available *before* we import @rails/ujs. + delete global.jQuery; + + const { initRails } = await import('~/lib/utils/rails_ujs.js'); + initRails(); +}); + +function mockXHRResponse({ responseText, responseContentType } = {}) { + jest + .spyOn(global.XMLHttpRequest.prototype, 'getResponseHeader') + .mockReturnValue(responseContentType); + + jest.spyOn(global.XMLHttpRequest.prototype, 'send').mockImplementation(function send() { + requestAnimationFrame(() => { + Object.defineProperties(this, { + readyState: { value: XMLHttpRequest.DONE }, + status: { value: 200 }, + response: { value: responseText }, + }); + this.onreadystatechange(); + }); + }); +} + +// This is a test to make sure that the patch-package patch correctly disables +// script execution for data-remote attributes. +it('does not perform script execution via data-remote', async () => { + global.scriptExecutionSpy = jest.fn(); + + mockXHRResponse({ + responseText: 'scriptExecutionSpy();', + responseContentType: 'application/javascript', + }); + + setHTMLFixture(` + <a href="/foo/evil.js" + data-remote="true" + data-method="get" + data-type="script" + data-testid="evil-link" + >XSS</a> + `); + + const link = document.querySelector('[data-testid="evil-link"]'); + const ajaxSuccessSpy = jest.fn(); + link.addEventListener('ajax:success', ajaxSuccessSpy); + + link.click(); + + await waitForPromises(); + + // Make sure Rails ajax machinery finished working as expected to avoid false + // positives + expect(ajaxSuccessSpy).toHaveBeenCalledTimes(1); + + // If @rails/ujs has been patched correctly, this next assertion should pass. + // + // Because it's asserting something didn't happen, it is possible for it to + // pass for the wrong reason. So, to verify that this test correctly fails + // when @rails/ujs has not been patched, run: + // + // yarn patch-package --reverse + // + // And then re-run this test. The spy should now be called, and correctly + // fail the test. + // + // To restore the patch(es), run: + // + // yarn install + expect(global.scriptExecutionSpy).not.toHaveBeenCalled(); +}); diff --git a/spec/frontend/lib/utils/table_utility_spec.js b/spec/frontend/lib/utils/table_utility_spec.js index 0ceccbe4c74..df9006f4909 100644 --- a/spec/frontend/lib/utils/table_utility_spec.js +++ b/spec/frontend/lib/utils/table_utility_spec.js @@ -9,6 +9,13 @@ describe('table_utility', () => { }); }); + describe('thWidthPercent', () => { + it('returns the width class including default table header classes', () => { + const width = 50; + expect(tableUtils.thWidthPercent(width)).toBe(`gl-w-${width}p`); + }); + }); + describe('sortObjectToString', () => { it('returns the expected sorting string ending in "DESC" when sortDesc is true', () => { expect(tableUtils.sortObjectToString({ sortBy: 'mergedAt', sortDesc: true })).toBe( diff --git a/spec/frontend/lib/utils/users_cache_spec.js b/spec/frontend/lib/utils/users_cache_spec.js index d35ba20f570..5a55874b5fa 100644 --- a/spec/frontend/lib/utils/users_cache_spec.js +++ b/spec/frontend/lib/utils/users_cache_spec.js @@ -154,8 +154,8 @@ describe('UsersCache', () => { }; const user = await UsersCache.retrieveById(dummyUserId); - expect(user).toBe(dummyUser); - expect(UsersCache.internalStorage[dummyUserId]).toBe(dummyUser); + expect(user).toEqual(dummyUser); + expect(UsersCache.internalStorage[dummyUserId]).toEqual(dummyUser); }); it('returns undefined if Ajax call fails and cache is empty', async () => { @@ -180,6 +180,29 @@ describe('UsersCache', () => { const user = await UsersCache.retrieveById(dummyUserId); expect(user).toBe(dummyUser); }); + + it('does not clobber existing cached values', async () => { + UsersCache.internalStorage[dummyUserId] = { + status: dummyUserStatus, + }; + + apiSpy = (id) => { + expect(id).toBe(dummyUserId); + + return Promise.resolve({ + data: dummyUser, + }); + }; + + const user = await UsersCache.retrieveById(dummyUserId); + const expectedUser = { + status: dummyUserStatus, + ...dummyUser, + }; + + expect(user).toEqual(expectedUser); + expect(UsersCache.internalStorage[dummyUserId]).toEqual(expectedUser); + }); }); describe('retrieveStatusById', () => { diff --git a/spec/frontend/logs/utils_spec.js b/spec/frontend/logs/utils_spec.js deleted file mode 100644 index 986fe320363..00000000000 --- a/spec/frontend/logs/utils_spec.js +++ /dev/null @@ -1,38 +0,0 @@ -import { getTimeRange } from '~/logs/utils'; - -describe('logs/utils', () => { - describe('getTimeRange', () => { - const nowTimestamp = 1577836800000; - const nowString = '2020-01-01T00:00:00.000Z'; - - beforeEach(() => { - jest.spyOn(Date, 'now').mockImplementation(() => nowTimestamp); - }); - - afterEach(() => { - Date.now.mockRestore(); - }); - - it('returns the right values', () => { - expect(getTimeRange(0)).toEqual({ - start: '2020-01-01T00:00:00.000Z', - end: nowString, - }); - - expect(getTimeRange(60 * 30)).toEqual({ - start: '2019-12-31T23:30:00.000Z', - end: nowString, - }); - - expect(getTimeRange(60 * 60 * 24 * 7 * 1)).toEqual({ - start: '2019-12-25T00:00:00.000Z', - end: nowString, - }); - - expect(getTimeRange(60 * 60 * 24 * 7 * 4)).toEqual({ - start: '2019-12-04T00:00:00.000Z', - end: nowString, - }); - }); - }); -}); diff --git a/spec/frontend/members/components/members_tabs_spec.js b/spec/frontend/members/components/members_tabs_spec.js index 1d882e5ef09..1354b938d77 100644 --- a/spec/frontend/members/components/members_tabs_spec.js +++ b/spec/frontend/members/components/members_tabs_spec.js @@ -9,6 +9,7 @@ import { MEMBER_TYPES, TAB_QUERY_PARAM_VALUES, ACTIVE_TAB_QUERY_PARAM_NAME, + FILTERED_SEARCH_TOKEN_GROUPS_WITH_INHERITED_PERMISSIONS, } from '~/members/constants'; import { pagination } from '../mock_data'; @@ -42,6 +43,7 @@ describe('MembersTabs', () => { }, filteredSearchBar: { searchParam: 'search_groups', + tokens: [FILTERED_SEARCH_TOKEN_GROUPS_WITH_INHERITED_PERMISSIONS.type], }, }, }, @@ -163,6 +165,18 @@ describe('MembersTabs', () => { expect(findTabByText('Groups')).not.toBeUndefined(); }); }); + + describe('when url param matches `filteredSearchBar.tokens`', () => { + beforeEach(() => { + setWindowLocation('?groups_with_inherited_permissions=exclude'); + }); + + it('shows tab that corresponds to filtered search token', async () => { + await createComponent({ totalItems: 0 }); + + expect(findTabByText('Groups')).not.toBeUndefined(); + }); + }); }); describe('when `canManageMembers` is `false`', () => { diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js index 298a01e4f4d..08baa663bf0 100644 --- a/spec/frontend/members/components/table/members_table_spec.js +++ b/spec/frontend/members/components/table/members_table_spec.js @@ -16,12 +16,11 @@ import { MEMBER_STATE_CREATED, MEMBER_STATE_AWAITING, MEMBER_STATE_ACTIVE, - USER_STATE_BLOCKED_PENDING_APPROVAL, - BADGE_LABELS_AWAITING_USER_SIGNUP, - BADGE_LABELS_PENDING_OWNER_APPROVAL, + USER_STATE_BLOCKED, + BADGE_LABELS_AWAITING_SIGNUP, + BADGE_LABELS_PENDING, TAB_QUERY_PARAM_VALUES, } from '~/members/constants'; -import * as initUserPopovers from '~/user_popovers'; import { member as memberMock, directMember, @@ -134,14 +133,14 @@ describe('MembersTable', () => { describe('Invited column', () => { describe.each` - state | userState | expectedBadgeLabel - ${MEMBER_STATE_CREATED} | ${null} | ${BADGE_LABELS_AWAITING_USER_SIGNUP} - ${MEMBER_STATE_CREATED} | ${USER_STATE_BLOCKED_PENDING_APPROVAL} | ${BADGE_LABELS_PENDING_OWNER_APPROVAL} - ${MEMBER_STATE_AWAITING} | ${''} | ${BADGE_LABELS_AWAITING_USER_SIGNUP} - ${MEMBER_STATE_AWAITING} | ${USER_STATE_BLOCKED_PENDING_APPROVAL} | ${BADGE_LABELS_PENDING_OWNER_APPROVAL} - ${MEMBER_STATE_AWAITING} | ${'something_else'} | ${BADGE_LABELS_PENDING_OWNER_APPROVAL} - ${MEMBER_STATE_ACTIVE} | ${null} | ${''} - ${MEMBER_STATE_ACTIVE} | ${'something_else'} | ${''} + state | userState | expectedBadgeLabel + ${MEMBER_STATE_CREATED} | ${null} | ${BADGE_LABELS_AWAITING_SIGNUP} + ${MEMBER_STATE_CREATED} | ${USER_STATE_BLOCKED} | ${BADGE_LABELS_PENDING} + ${MEMBER_STATE_AWAITING} | ${''} | ${BADGE_LABELS_AWAITING_SIGNUP} + ${MEMBER_STATE_AWAITING} | ${USER_STATE_BLOCKED} | ${BADGE_LABELS_PENDING} + ${MEMBER_STATE_AWAITING} | ${'something_else'} | ${BADGE_LABELS_PENDING} + ${MEMBER_STATE_ACTIVE} | ${null} | ${''} + ${MEMBER_STATE_ACTIVE} | ${'something_else'} | ${''} `('Invited Badge', ({ state, userState, expectedBadgeLabel }) => { it(`${ expectedBadgeLabel ? 'shows' : 'hides' @@ -257,14 +256,6 @@ describe('MembersTable', () => { }); }); - it('initializes user popovers when mounted', () => { - const initUserPopoversMock = jest.spyOn(initUserPopovers, 'default'); - - createComponent(); - - expect(initUserPopoversMock).toHaveBeenCalled(); - }); - it('adds QA selector to table', () => { createComponent(); diff --git a/spec/frontend/members/index_spec.js b/spec/frontend/members/index_spec.js index efabe54f238..251a8b0b774 100644 --- a/spec/frontend/members/index_spec.js +++ b/spec/frontend/members/index_spec.js @@ -24,7 +24,7 @@ describe('initMembersApp', () => { beforeEach(() => { el = document.createElement('div'); - el.setAttribute('data-members-data', dataAttribute); + el.dataset.membersData = dataAttribute; window.gon = { current_user_id: 123 }; }); diff --git a/spec/frontend/members/utils_spec.js b/spec/frontend/members/utils_spec.js index a157cfa1c1d..b0c9459ff4f 100644 --- a/spec/frontend/members/utils_spec.js +++ b/spec/frontend/members/utils_spec.js @@ -256,7 +256,7 @@ describe('Members Utils', () => { beforeEach(() => { el = document.createElement('div'); - el.setAttribute('data-members-data', dataAttribute); + el.dataset.membersData = dataAttribute; }); afterEach(() => { diff --git a/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js b/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js index 55e666609bd..4fdc4024e10 100644 --- a/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js +++ b/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js @@ -59,7 +59,7 @@ describe('Merge Conflict Resolver App', () => { const title = findConflictsCount(); expect(title.exists()).toBe(true); - expect(title.text().trim()).toBe('Showing 3 conflicts between test-conflicts and main'); + expect(title.text().trim()).toBe('Showing 3 conflicts'); }); it('shows a loading spinner while loading', () => { diff --git a/spec/frontend/merge_request_tabs_spec.js b/spec/frontend/merge_request_tabs_spec.js index ccbc61ea658..f0f051cbc8b 100644 --- a/spec/frontend/merge_request_tabs_spec.js +++ b/spec/frontend/merge_request_tabs_spec.js @@ -325,6 +325,28 @@ describe('MergeRequestTabs', () => { expect(window.scrollTo.mock.calls[0]).toEqual([0, 39]); }); + it.each` + tab | hides | hidesText + ${'show'} | ${false} | ${'shows'} + ${'diffs'} | ${true} | ${'hides'} + ${'commits'} | ${true} | ${'hides'} + `('it $hidesText expand button on $tab tab', ({ tab, hides }) => { + const expandButton = document.createElement('div'); + expandButton.classList.add('js-expand-sidebar'); + + const tabsContainer = document.createElement('div'); + tabsContainer.innerHTML = + '<div class="tab-content"><div id="diff-notes-app"></div><div class="commits tab-pane"></div></div>'; + tabsContainer.classList.add('merge-request-tabs-container'); + tabsContainer.appendChild(expandButton); + document.body.appendChild(tabsContainer); + + testContext.class = new MergeRequestTabs({ stubLocation }); + testContext.class.tabShown(tab, 'foobar'); + + expect(testContext.class.expandSidebar.classList.contains('gl-display-none!')).toBe(hides); + }); + describe('when switching tabs', () => { const SCROLL_TOP = 100; diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap index a93035cc53a..a9f37f90561 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -151,7 +151,7 @@ exports[`Dashboard template matches the default snapshot 1`] = ` emptynodatasvgpath="/images/illustrations/monitoring/no_data.svg" emptyunabletoconnectsvgpath="/images/illustrations/monitoring/unable_to_connect.svg" selectedstate="gettingStarted" - settingspath="/monitoring/monitor-project/-/integrations/prometheus/edit" + settingspath="/monitoring/monitor-project/-/settings/integrations/prometheus/edit" /> </div> `; diff --git a/spec/frontend/monitoring/components/graph_group_spec.js b/spec/frontend/monitoring/components/graph_group_spec.js index c5b45564089..31f52f6627b 100644 --- a/spec/frontend/monitoring/components/graph_group_spec.js +++ b/spec/frontend/monitoring/components/graph_group_spec.js @@ -34,17 +34,17 @@ describe('Graph group component', () => { expect(findLoadingIcon().exists()).toBe(false); }); - it('should show the angle-down caret icon', () => { + it('should show the chevron-lg-down caret icon', () => { expect(findContent().isVisible()).toBe(true); - expect(findCaretIcon().props('name')).toBe('angle-down'); + expect(findCaretIcon().props('name')).toBe('chevron-lg-down'); }); - it('should show the angle-right caret icon when the user collapses the group', async () => { + it('should show the chevron-lg-right caret icon when the user collapses the group', async () => { findToggleButton().trigger('click'); await nextTick(); expect(findContent().isVisible()).toBe(false); - expect(findCaretIcon().props('name')).toBe('angle-right'); + expect(findCaretIcon().props('name')).toBe('chevron-lg-right'); }); it('should contain a tab index for the collapse button', () => { @@ -60,7 +60,7 @@ describe('Graph group component', () => { await nextTick(); expect(findContent().isVisible()).toBe(true); - expect(findCaretIcon().props('name')).toBe('angle-down'); + expect(findCaretIcon().props('name')).toBe('chevron-lg-down'); }); }); @@ -72,15 +72,15 @@ describe('Graph group component', () => { }); }); - it('should show the angle-down caret icon when collapseGroup is true', () => { - expect(findCaretIcon().props('name')).toBe('angle-right'); + it('should show the chevron-lg-down caret icon when collapseGroup is true', () => { + expect(findCaretIcon().props('name')).toBe('chevron-lg-right'); }); - it('should show the angle-right caret icon when collapseGroup is false', async () => { + it('should show the chevron-lg-right caret icon when collapseGroup is false', async () => { findToggleButton().trigger('click'); await nextTick(); - expect(findCaretIcon().props('name')).toBe('angle-down'); + expect(findCaretIcon().props('name')).toBe('chevron-lg-down'); }); it('should call collapse the graph group content when enter is pressed on the caret icon', () => { diff --git a/spec/frontend/monitoring/fixture_data.js b/spec/frontend/monitoring/fixture_data.js index 6a19815883a..f4062adea81 100644 --- a/spec/frontend/monitoring/fixture_data.js +++ b/spec/frontend/monitoring/fixture_data.js @@ -14,13 +14,12 @@ const datasetState = stateAndPropsFromDataset( convertObjectPropsToCamelCase(metricsDashboardResponse.metrics_data), ); -// new properties like addDashboardDocumentationPath prop and alertsEndpoint +// new properties like addDashboardDocumentationPath prop // was recently added to dashboard.vue component this needs to be // added to fixtures data // https://gitlab.com/gitlab-org/gitlab/-/issues/229256 export const dashboardProps = { ...datasetState.dataProps, - alertsEndpoint: null, }; export const metricsDashboardViewModel = mapToDashboardViewModel(metricsDashboardPayload); diff --git a/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap b/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap deleted file mode 100644 index 5d84b4660c9..00000000000 --- a/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap +++ /dev/null @@ -1,91 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`MR Popover loaded state matches the snapshot 1`] = ` -<gl-popover-stub - boundary="viewport" - cssclasses="" - placement="top" - show="" - target="" -> - <div - class="mr-popover" - > - <div - class="d-flex align-items-center justify-content-between" - > - <div - class="d-inline-flex align-items-center" - > - <div - class="issuable-status-box status-box status-box-open" - > - - Open - - </div> - - <span - class="gl-text-secondary" - > - Opened - <time> - just now - </time> - </span> - </div> - - <ci-icon-stub - cssclasses="" - size="16" - status="[object Object]" - /> - </div> - - <h5 - class="my-2" - > - Updated Title - </h5> - - <div - class="gl-text-secondary" - > - - foo/bar!1 - - </div> - </div> -</gl-popover-stub> -`; - -exports[`MR Popover shows skeleton-loader while apollo is loading 1`] = ` -<gl-popover-stub - boundary="viewport" - cssclasses="" - placement="top" - show="" - target="" -> - <div - class="mr-popover" - > - <div> - <gl-skeleton-loading-stub - class="animation-container-small mt-1" - lines="1" - /> - </div> - - <!----> - - <div - class="gl-text-secondary" - > - - foo/bar!1 - - </div> - </div> -</gl-popover-stub> -`; diff --git a/spec/frontend/mr_popover/mr_popover_spec.js b/spec/frontend/mr_popover/mr_popover_spec.js deleted file mode 100644 index 23f97073e9e..00000000000 --- a/spec/frontend/mr_popover/mr_popover_spec.js +++ /dev/null @@ -1,80 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import MRPopover from '~/mr_popover/components/mr_popover.vue'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; - -describe('MR Popover', () => { - let wrapper; - - beforeEach(() => { - wrapper = shallowMount(MRPopover, { - propsData: { - target: document.createElement('a'), - projectPath: 'foo/bar', - mergeRequestIID: '1', - mergeRequestTitle: 'MR Title', - }, - mocks: { - $apollo: { - queries: { - mergeRequest: { - loading: false, - }, - }, - }, - }, - }); - }); - - it('shows skeleton-loader while apollo is loading', async () => { - wrapper.vm.$apollo.queries.mergeRequest.loading = true; - - await nextTick(); - expect(wrapper.element).toMatchSnapshot(); - }); - - describe('loaded state', () => { - it('matches the snapshot', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - mergeRequest: { - title: 'Updated Title', - state: 'opened', - createdAt: new Date(), - headPipeline: { - detailedStatus: { - group: 'success', - status: 'status_success', - }, - }, - }, - }); - - await nextTick(); - expect(wrapper.element).toMatchSnapshot(); - }); - - it('does not show CI Icon if there is no pipeline data', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - mergeRequest: { - state: 'opened', - headPipeline: null, - stateHumanName: 'Open', - title: 'Merge Request Title', - createdAt: new Date(), - }, - }); - - await nextTick(); - expect(wrapper.find(CiIcon).exists()).toBe(false); - }); - - it('falls back to cached MR title when request fails', async () => { - await nextTick(); - expect(wrapper.text()).toContain('MR Title'); - }); - }); -}); diff --git a/spec/frontend/nav/components/responsive_header_spec.js b/spec/frontend/nav/components/responsive_header_spec.js index 937c44727c7..f87de0afb14 100644 --- a/spec/frontend/nav/components/responsive_header_spec.js +++ b/spec/frontend/nav/components/responsive_header_spec.js @@ -43,7 +43,7 @@ describe('~/nav/components/top_nav_menu_sections.vue', () => { menuItem: { id: 'home', view: 'home', - icon: 'angle-left', + icon: 'chevron-lg-left', }, iconOnly: true, }); @@ -60,7 +60,7 @@ describe('~/nav/components/top_nav_menu_sections.vue', () => { it('emits menu-item-click', () => { expect(wrapper.emitted()).toEqual({ - 'menu-item-click': [[{ id: 'home', view: 'home', icon: 'angle-left' }]], + 'menu-item-click': [[{ id: 'home', view: 'home', icon: 'chevron-lg-left' }]], }); }); }); diff --git a/spec/frontend/notebook/cells/markdown_spec.js b/spec/frontend/notebook/cells/markdown_spec.js index 7dc6f90d202..de415b5bfe0 100644 --- a/spec/frontend/notebook/cells/markdown_spec.js +++ b/spec/frontend/notebook/cells/markdown_spec.js @@ -78,8 +78,8 @@ describe('Markdown component', () => { }); await nextTick(); - expect(findLink().getAttribute('data-remote')).toBe(null); - expect(findLink().getAttribute('data-type')).toBe(null); + expect(findLink().dataset.remote).toBeUndefined(); + expect(findLink().dataset.type).toBeUndefined(); }); describe('When parsing images', () => { diff --git a/spec/frontend/notes/components/comment_field_layout_spec.js b/spec/frontend/notes/components/comment_field_layout_spec.js index 90c989540b9..d69c2c4adfa 100644 --- a/spec/frontend/notes/components/comment_field_layout_spec.js +++ b/spec/frontend/notes/components/comment_field_layout_spec.js @@ -135,14 +135,14 @@ describe('Comment Field Layout Component', () => { }); }); - describe('issue has email participants, but note is confidential', () => { + describe('issue has email participants, but note is internal', () => { it('does not show EmailParticipantsWarning', () => { createWrapper({ noteableData: { ...noteableDataMock, issue_email_participants: [{ email: 'someone@gitlab.com' }], }, - noteIsConfidential: true, + isInternalNote: true, }); expect(findEmailParticipantsWarning().exists()).toBe(false); diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index ba5d4d27e55..116016ecae2 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -32,7 +32,7 @@ describe('issue_comment_form component', () => { const findTextArea = () => wrapper.findByTestId('comment-field'); const findAddToReviewButton = () => wrapper.findByTestId('add-to-review-button'); const findAddCommentNowButton = () => wrapper.findByTestId('add-comment-now-button'); - const findConfidentialNoteCheckbox = () => wrapper.findByTestId('confidential-note-checkbox'); + const findConfidentialNoteCheckbox = () => wrapper.findByTestId('internal-note-checkbox'); const findCommentTypeDropdown = () => wrapper.findComponent(CommentTypeDropdown); const findCommentButton = () => findCommentTypeDropdown().find('button'); const findErrorAlerts = () => wrapper.findAllComponents(GlAlert).wrappers; @@ -249,15 +249,15 @@ describe('issue_comment_form component', () => { describe('textarea', () => { describe('general', () => { it.each` - noteType | confidential | placeholder - ${'comment'} | ${false} | ${'Write a comment or drag your files here…'} - ${'internal note'} | ${true} | ${'Write an internal note or drag your files here…'} + noteType | noteIsInternal | placeholder + ${'comment'} | ${false} | ${'Write a comment or drag your files here…'} + ${'internal note'} | ${true} | ${'Write an internal note or drag your files here…'} `( 'should render textarea with placeholder for $noteType', - ({ confidential, placeholder }) => { + ({ noteIsInternal, placeholder }) => { mountComponent({ mountFunction: mount, - initialData: { noteIsConfidential: confidential }, + initialData: { noteIsInternal }, }); expect(findTextArea().attributes('placeholder')).toBe(placeholder); @@ -389,14 +389,14 @@ describe('issue_comment_form component', () => { }); it.each` - confidential | buttonText - ${false} | ${'Comment'} - ${true} | ${'Add internal note'} - `('renders comment button with text "$buttonText"', ({ confidential, buttonText }) => { + noteIsInternal | buttonText + ${false} | ${'Comment'} + ${true} | ${'Add internal note'} + `('renders comment button with text "$buttonText"', ({ noteIsInternal, buttonText }) => { mountComponent({ mountFunction: mount, - noteableData: createNotableDataMock({ confidential }), - initialData: { noteIsConfidential: confidential }, + noteableData: createNotableDataMock({ confidential: noteIsInternal }), + initialData: { noteIsInternal }, }); expect(findCommentButton().text()).toBe(buttonText); @@ -487,8 +487,8 @@ describe('issue_comment_form component', () => { await findCloseReopenButton().trigger('click'); - await nextTick; - await nextTick; + await nextTick(); + await nextTick(); expect(createFlash).toHaveBeenCalledWith({ message: `Something went wrong while closing the ${type}. Please try again later.`, @@ -523,8 +523,8 @@ describe('issue_comment_form component', () => { await findCloseReopenButton().trigger('click'); - await nextTick; - await nextTick; + await nextTick(); + await nextTick(); expect(createFlash).toHaveBeenCalledWith({ message: `Something went wrong while reopening the ${type}. Please try again later.`, diff --git a/spec/frontend/notes/components/note_body_spec.js b/spec/frontend/notes/components/note_body_spec.js index 378dcb97fab..0f765a8da87 100644 --- a/spec/frontend/notes/components/note_body_spec.js +++ b/spec/frontend/notes/components/note_body_spec.js @@ -1,5 +1,5 @@ -import { shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { suggestionCommitMessage } from '~/diffs/store/getters'; import NoteBody from '~/notes/components/note_body.vue'; @@ -7,6 +7,7 @@ import NoteAwardsList from '~/notes/components/note_awards_list.vue'; import NoteForm from '~/notes/components/note_form.vue'; import createStore from '~/notes/stores'; import notes from '~/notes/stores/modules/index'; +import { INTERNAL_NOTE_CLASSES } from '~/notes/constants'; import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; @@ -27,7 +28,7 @@ const createComponent = ({ mockStore.dispatch('setNotesData', notesData); } - return shallowMount(NoteBody, { + return shallowMountExtended(NoteBody, { store: mockStore || store, propsData: { note, @@ -58,6 +59,24 @@ describe('issue_note_body component', () => { expect(wrapper.findComponent(NoteAwardsList).exists()).toBe(true); }); + it('should not have internal note classes', () => { + expect(wrapper.findByTestId('note-internal-container').classes()).not.toEqual( + INTERNAL_NOTE_CLASSES, + ); + }); + + describe('isInternalNote', () => { + beforeEach(() => { + wrapper = createComponent({ props: { isInternalNote: true } }); + }); + + it('should have internal note classes', () => { + expect(wrapper.findByTestId('note-internal-container').classes()).toEqual( + INTERNAL_NOTE_CLASSES, + ); + }); + }); + describe('isEditing', () => { beforeEach(() => { wrapper = createComponent({ props: { isEditing: true } }); @@ -86,6 +105,18 @@ describe('issue_note_body component', () => { // which is defined in `app/assets/javascripts/notes/mixins/autosave.js` expect(wrapper.vm.autosave.key).toEqual(autosaveKey); }); + + describe('isInternalNote', () => { + beforeEach(() => { + wrapper.setProps({ isInternalNote: true }); + }); + + it('should not have internal note classes', () => { + expect(wrapper.findByTestId('note-internal-container').classes()).not.toEqual( + INTERNAL_NOTE_CLASSES, + ); + }); + }); }); describe('commitMessage', () => { diff --git a/spec/frontend/notes/components/note_header_spec.js b/spec/frontend/notes/components/note_header_spec.js index 310a470aa18..ad2cf1c5a35 100644 --- a/spec/frontend/notes/components/note_header_spec.js +++ b/spec/frontend/notes/components/note_header_spec.js @@ -21,7 +21,7 @@ describe('NoteHeader component', () => { const findActionText = () => wrapper.find({ ref: 'actionText' }); const findTimestampLink = () => wrapper.find({ ref: 'noteTimestampLink' }); const findTimestamp = () => wrapper.find({ ref: 'noteTimestamp' }); - const findConfidentialIndicator = () => wrapper.findByTestId('internalNoteIndicator'); + const findInternalNoteIndicator = () => wrapper.findByTestId('internalNoteIndicator'); const findSpinner = () => wrapper.find({ ref: 'spinner' }); const findAuthorStatus = () => wrapper.find({ ref: 'authorStatus' }); @@ -283,20 +283,20 @@ describe('NoteHeader component', () => { }); }); - describe('with confidentiality indicator', () => { + describe('with internal note badge', () => { it.each` status | condition ${true} | ${'shows'} ${false} | ${'hides'} - `('$condition icon indicator when isConfidential is $status', ({ status }) => { - createComponent({ isConfidential: status }); - expect(findConfidentialIndicator().exists()).toBe(status); + `('$condition badge when isInternalNote is $status', ({ status }) => { + createComponent({ isInternalNote: status }); + expect(findInternalNoteIndicator().exists()).toBe(status); }); - it('shows confidential indicator tooltip for project context', () => { - createComponent({ isConfidential: true, noteableType: 'issue' }); + it('shows internal note badge tooltip for project context', () => { + createComponent({ isInternalNote: true, noteableType: 'issue' }); - expect(findConfidentialIndicator().attributes('title')).toBe( + expect(findInternalNoteIndicator().attributes('title')).toBe( 'This internal note will always remain confidential', ); }); diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js index c46d3bbe5b2..ddfa77117ca 100644 --- a/spec/frontend/notes/components/noteable_discussion_spec.js +++ b/spec/frontend/notes/components/noteable_discussion_spec.js @@ -87,10 +87,27 @@ describe('noteable_discussion component', () => { expect(noteFormProps.discussion).toBe(discussionMock); expect(noteFormProps.line).toBe(null); - expect(noteFormProps.saveButtonTitle).toBe('Comment'); expect(noteFormProps.autosaveKey).toBe(`Note/Issue/${discussionMock.id}/Reply`); }); + it.each` + noteType | isNoteInternal | saveButtonTitle + ${'public'} | ${false} | ${'Reply'} + ${'internal'} | ${true} | ${'Reply internally'} + `( + 'reply button on form should have title "$saveButtonTitle" when note is $noteType', + async ({ isNoteInternal, saveButtonTitle }) => { + wrapper.setProps({ discussion: { ...discussionMock, confidential: isNoteInternal } }); + await nextTick(); + + const replyPlaceholder = wrapper.find(ReplyPlaceholder); + replyPlaceholder.vm.$emit('focus'); + await nextTick(); + + expect(wrapper.find(NoteForm).props('saveButtonTitle')).toBe(saveButtonTitle); + }, + ); + it('should expand discussion', async () => { const discussion = { ...discussionMock, expanded: false }; diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js index 413ee815906..f4eb69e0d49 100644 --- a/spec/frontend/notes/components/notes_app_spec.js +++ b/spec/frontend/notes/components/notes_app_spec.js @@ -19,8 +19,6 @@ import '~/behaviors/markdown/render_gfm'; import OrderedLayout from '~/vue_shared/components/ordered_layout.vue'; import * as mockData from '../mock_data'; -jest.mock('~/user_popovers', () => jest.fn()); - setTestTimeout(1000); const TYPE_COMMENT_FORM = 'comment-form'; @@ -224,7 +222,7 @@ describe('note_app', () => { }); it('renders skeleton notes', () => { - expect(wrapper.find('.animation-container').exists()).toBe(true); + expect(wrapper.find('.gl-skeleton-loader-default-container').exists()).toBe(true); }); it('should render form', () => { diff --git a/spec/frontend/notes/mock_data.js b/spec/frontend/notes/mock_data.js index c7a6ca5eae3..9fa7166474a 100644 --- a/spec/frontend/notes/mock_data.js +++ b/spec/frontend/notes/mock_data.js @@ -785,7 +785,7 @@ export const notesWithDescriptionChanges = [ current_user: { can_edit: false, can_award_emoji: true }, resolved: false, resolved_by: null, - system_note_icon_name: 'pencil-square', + system_note_icon_name: 'pencil', discussion_id: '7f1feda384083eb31763366e6392399fde6f3f31', emoji_awardable: false, report_abuse_path: @@ -874,7 +874,7 @@ export const notesWithDescriptionChanges = [ current_user: { can_edit: false, can_award_emoji: true }, resolved: false, resolved_by: null, - system_note_icon_name: 'pencil-square', + system_note_icon_name: 'pencil', discussion_id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044', emoji_awardable: false, report_abuse_path: @@ -918,7 +918,7 @@ export const notesWithDescriptionChanges = [ current_user: { can_edit: false, can_award_emoji: true }, resolved: false, resolved_by: null, - system_note_icon_name: 'pencil-square', + system_note_icon_name: 'pencil', discussion_id: '70411b08cdfc01f24187a06d77daa33464cb2620', emoji_awardable: false, report_abuse_path: @@ -1105,7 +1105,7 @@ export const collapsedSystemNotes = [ current_user: { can_edit: false, can_award_emoji: true }, resolved: false, resolved_by: null, - system_note_icon_name: 'pencil-square', + system_note_icon_name: 'pencil', discussion_id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044', emoji_awardable: false, report_abuse_path: @@ -1149,7 +1149,7 @@ export const collapsedSystemNotes = [ current_user: { can_edit: false, can_award_emoji: true }, resolved: false, resolved_by: null, - system_note_icon_name: 'pencil-square', + system_note_icon_name: 'pencil', discussion_id: '70411b08cdfc01f24187a06d77daa33464cb2620', emoji_awardable: false, report_abuse_path: diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index ecb213590ad..38f29ac2559 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -404,13 +404,13 @@ describe('Actions Notes Store', () => { beforeEach(() => { axiosMock.onDelete(endpoint).replyOnce(200, {}); - document.body.setAttribute('data-page', ''); + document.body.dataset.page = ''; }); afterEach(() => { axiosMock.restore(); - document.body.setAttribute('data-page', ''); + document.body.dataset.page = ''; }); it('commits DELETE_NOTE and dispatches updateMergeRequestWidget', () => { @@ -440,7 +440,7 @@ describe('Actions Notes Store', () => { it('dispatches removeDiscussionsFromDiff on merge request page', () => { const note = { path: endpoint, id: 1 }; - document.body.setAttribute('data-page', 'projects:merge_requests:show'); + document.body.dataset.page = 'projects:merge_requests:show'; return testAction( actions.removeNote, @@ -473,13 +473,13 @@ describe('Actions Notes Store', () => { beforeEach(() => { axiosMock.onDelete(endpoint).replyOnce(200, {}); - document.body.setAttribute('data-page', ''); + document.body.dataset.page = ''; }); afterEach(() => { axiosMock.restore(); - document.body.setAttribute('data-page', ''); + document.body.dataset.page = ''; }); it('dispatches removeNote', () => { @@ -1382,6 +1382,29 @@ describe('Actions Notes Store', () => { ], ); }); + + it('dispatches `fetchDiscussionsBatch` action if `paginatedMrDiscussions` feature flag is enabled', () => { + window.gon = { features: { paginatedMrDiscussions: true } }; + + return testAction( + actions.fetchDiscussions, + { path: 'test-path', filter: 'test-filter', persistFilter: 'test-persist-filter' }, + null, + [], + [ + { + type: 'fetchDiscussionsBatch', + payload: { + config: { + params: { notes_filter: 'test-filter', persist_filter: 'test-persist-filter' }, + }, + path: 'test-path', + perPage: 20, + }, + }, + ], + ); + }); }); describe('fetchDiscussionsBatch', () => { @@ -1401,6 +1424,7 @@ describe('Actions Notes Store', () => { null, [ { type: mutationTypes.ADD_OR_UPDATE_DISCUSSIONS, payload: { discussion } }, + { type: mutationTypes.SET_DONE_FETCHING_BATCH_DISCUSSIONS, payload: true }, { type: mutationTypes.SET_FETCHING_DISCUSSIONS, payload: false }, ], [{ type: 'updateResolvableDiscussionsCounts' }], diff --git a/spec/frontend/notes/stores/mutation_spec.js b/spec/frontend/notes/stores/mutation_spec.js index da1547ab6e7..e0a0fc43ffe 100644 --- a/spec/frontend/notes/stores/mutation_spec.js +++ b/spec/frontend/notes/stores/mutation_spec.js @@ -883,4 +883,16 @@ describe('Notes Store mutations', () => { expect(state.discussions[0].position).toEqual(position); }); }); + + describe('SET_DONE_FETCHING_BATCH_DISCUSSIONS', () => { + it('should set doneFetchingBatchDiscussions', () => { + const state = { + doneFetchingBatchDiscussions: false, + }; + + mutations.SET_DONE_FETCHING_BATCH_DISCUSSIONS(state, true); + + expect(state.doneFetchingBatchDiscussions).toEqual(true); + }); + }); }); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js index 057312828ff..84f01f10f21 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js @@ -10,6 +10,7 @@ import { MISSING_MANIFEST_WARNING_TOOLTIP, NOT_AVAILABLE_TEXT, NOT_AVAILABLE_SIZE, + COPY_IMAGE_PATH_TITLE, } from '~/packages_and_registries/container_registry/explorer/constants/index'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; @@ -150,7 +151,7 @@ describe('tags list row', () => { expect(findClipboardButton().attributes()).toMatchObject({ text: tag.location, - title: tag.location, + title: COPY_IMAGE_PATH_TITLE, }); }); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js index af5723267f4..0581a40b6a2 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js @@ -1,4 +1,4 @@ -import { GlLink, GlPopover, GlSprintf } from '@gitlab/ui'; +import { GlIcon, GlLink, GlPopover, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { helpPagePath } from '~/helpers/help_page_helper'; import CleanupStatus from '~/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue'; @@ -16,6 +16,7 @@ describe('cleanup_status', () => { let wrapper; const findMainIcon = () => wrapper.findByTestId('main-icon'); + const findMainIconName = () => wrapper.findByTestId('main-icon').find(GlIcon); const findExtraInfoIcon = () => wrapper.findByTestId('extra-info'); const findPopover = () => wrapper.findComponent(GlPopover); @@ -61,6 +62,23 @@ describe('cleanup_status', () => { expect(findMainIcon().exists()).toBe(true); }); + + it.each` + status | visible | iconName + ${UNFINISHED_STATUS} | ${true} | ${'expire'} + ${SCHEDULED_STATUS} | ${true} | ${'clock'} + ${ONGOING_STATUS} | ${true} | ${'clock'} + ${UNSCHEDULED_STATUS} | ${false} | ${''} + `('matches "$iconName" when the status is "$status"', ({ status, visible, iconName }) => { + mountComponent({ status }); + + expect(findMainIcon().exists()).toBe(visible); + if (visible) { + const actualIcon = findMainIconName(); + expect(actualIcon.exists()).toBe(true); + expect(actualIcon.props('name')).toBe(iconName); + } + }); }); describe('extra info icon', () => { diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js index 690d827ec67..979e1500d7d 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js @@ -13,6 +13,7 @@ import { IMAGE_MIGRATING_STATE, SCHEDULED_STATUS, ROOT_IMAGE_TEXT, + COPY_IMAGE_PATH_TITLE, } from '~/packages_and_registries/container_registry/explorer/constants'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; @@ -106,7 +107,7 @@ describe('Image List Row', () => { const button = findClipboardButton(); expect(button.exists()).toBe(true); expect(button.props('text')).toBe(item.location); - expect(button.props('title')).toBe(item.location); + expect(button.props('title')).toBe(COPY_IMAGE_PATH_TITLE); }); describe('cleanup status component', () => { diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js index f811468550d..a006de9f00c 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js @@ -93,7 +93,7 @@ describe('registry_header', () => { expect(text.exists()).toBe(true); expect(text.props()).toMatchObject({ text: EXPIRATION_POLICY_DISABLED_TEXT, - icon: 'expire', + icon: 'clock', size: 'xl', }); }); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js index 6b6c33b7561..95de2f0bb0b 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js @@ -206,19 +206,19 @@ describe('Package Files', () => { it('toggles the details row', async () => { createComponent(); - expect(findFirstToggleDetailsButton().props('icon')).toBe('angle-down'); + expect(findFirstToggleDetailsButton().props('icon')).toBe('chevron-lg-down'); findFirstToggleDetailsButton().vm.$emit('click'); await nextTick(); expect(findFirstRowShaComponent('sha-256').exists()).toBe(true); - expect(findFirstToggleDetailsButton().props('icon')).toBe('angle-up'); + expect(findFirstToggleDetailsButton().props('icon')).toBe('chevron-lg-up'); findFirstToggleDetailsButton().vm.$emit('click'); await nextTick(); expect(findFirstRowShaComponent('sha-256').exists()).toBe(false); - expect(findFirstToggleDetailsButton().props('icon')).toBe('angle-down'); + expect(findFirstToggleDetailsButton().props('icon')).toBe('chevron-lg-down'); }); }); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_search_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_search_spec.js index e5230417c78..a086c20a5e7 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_search_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_search_spec.js @@ -65,7 +65,7 @@ describe('Infrastructure Search', () => { expect(findRegistrySearch().exists()).toBe(true); expect(findRegistrySearch().props()).toMatchObject({ - filter: store.state.filter, + filters: store.state.filter, sorting: store.state.sorting, tokens: [], sortableFields: sortableFields(), @@ -80,7 +80,7 @@ describe('Infrastructure Search', () => { mountComponent(isGroupPage); expect(findRegistrySearch().props()).toMatchObject({ - filter: store.state.filter, + filters: store.state.filter, sorting: store.state.sorting, tokens: [], sortableFields: fields, diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js index 7a71a1cea0f..4f3d780b149 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js @@ -1,4 +1,9 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlAlert } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { conanMetadata, mavenMetadata, @@ -6,9 +11,11 @@ import { packageData, composerMetadata, pypiMetadata, + packageMetadataQuery, } from 'jest/packages_and_registries/package_registry/mock_data'; import component from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue'; import { + FETCH_PACKAGE_METADATA_ERROR_MESSAGE, PACKAGE_TYPE_NUGET, PACKAGE_TYPE_CONAN, PACKAGE_TYPE_MAVEN, @@ -16,6 +23,9 @@ import { PACKAGE_TYPE_COMPOSER, PACKAGE_TYPE_PYPI, } from '~/packages_and_registries/package_registry/constants'; +import AdditionalMetadataLoader from '~/packages_and_registries/package_registry/components/details/additional_metadata_loader.vue'; +import waitForPromises from 'helpers/wait_for_promises'; +import getPackageMetadata from '~/packages_and_registries/package_registry/graphql/queries/get_package_metadata.query.graphql'; const mavenPackage = { packageType: PACKAGE_TYPE_MAVEN, metadata: mavenMetadata() }; const conanPackage = { packageType: PACKAGE_TYPE_CONAN, metadata: conanMetadata() }; @@ -24,16 +34,26 @@ const composerPackage = { packageType: PACKAGE_TYPE_COMPOSER, metadata: composer const pypiPackage = { packageType: PACKAGE_TYPE_PYPI, metadata: pypiMetadata() }; const npmPackage = { packageType: PACKAGE_TYPE_NPM, metadata: {} }; -describe('Package Additional Metadata', () => { +Vue.use(VueApollo); + +describe('Package Additional metadata', () => { let wrapper; + let apolloProvider; + const defaultProps = { - packageEntity: { - ...packageData(mavenPackage), - }, + packageId: packageData().id, + packageType: PACKAGE_TYPE_MAVEN, }; - const mountComponent = (props) => { + const mountComponent = ({ + props = {}, + resolver = jest.fn().mockResolvedValue(packageMetadataQuery(mavenPackage)), + } = {}) => { + const requestHandlers = [[getPackageMetadata, resolver]]; + apolloProvider = createMockApollo(requestHandlers); + wrapper = shallowMountExtended(component, { + apolloProvider, propsData: { ...defaultProps, ...props }, stubs: { component: { template: '<div data-testid="component-is"></div>' }, @@ -41,6 +61,10 @@ describe('Package Additional Metadata', () => { }); }; + beforeEach(() => { + jest.spyOn(Sentry, 'captureException').mockImplementation(); + }); + afterEach(() => { wrapper.destroy(); wrapper = null; @@ -49,6 +73,22 @@ describe('Package Additional Metadata', () => { const findTitle = () => wrapper.findByTestId('title'); const findMainArea = () => wrapper.findByTestId('main'); const findComponentIs = () => wrapper.findByTestId('component-is'); + const findAdditionalMetadataLoader = () => wrapper.findComponent(AdditionalMetadataLoader); + const findPackageMetadataAlert = () => wrapper.findComponent(GlAlert); + + it('renders the loading container when loading', () => { + mountComponent(); + + expect(findAdditionalMetadataLoader().exists()).toBe(true); + }); + + it('does not render the loading container once resolved', async () => { + mountComponent(); + await waitForPromises(); + + expect(findAdditionalMetadataLoader().exists()).toBe(false); + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); it('has the correct title', () => { mountComponent(); @@ -56,7 +96,25 @@ describe('Package Additional Metadata', () => { const title = findTitle(); expect(title.exists()).toBe(true); - expect(title.text()).toBe('Additional Metadata'); + expect(title.text()).toMatchInterpolatedText(component.i18n.componentTitle); + }); + + it('does not render gl-alert', () => { + mountComponent(); + + expect(findPackageMetadataAlert().exists()).toBe(false); + }); + + it('renders gl-alert if load fails', async () => { + mountComponent({ resolver: jest.fn().mockRejectedValue() }); + + await waitForPromises(); + + expect(findPackageMetadataAlert().exists()).toBe(true); + expect(findPackageMetadataAlert().text()).toMatchInterpolatedText( + FETCH_PACKAGE_METADATA_ERROR_MESSAGE, + ); + expect(Sentry.captureException).toHaveBeenCalled(); }); it.each` @@ -68,16 +126,22 @@ describe('Package Additional Metadata', () => { ${pypiPackage} | ${true} | ${PACKAGE_TYPE_PYPI} ${npmPackage} | ${false} | ${PACKAGE_TYPE_NPM} `( - `It is $visible that the component is visible when the package is $packageType`, - ({ packageEntity, visible }) => { - mountComponent({ packageEntity }); + `component visibility is $visible when the package is $packageType`, + async ({ packageEntity, visible, packageType }) => { + const resolved = packageMetadataQuery(packageType); + const resolver = jest.fn().mockResolvedValue(resolved); + + mountComponent({ props: { packageType }, resolver }); + + await waitForPromises(); + await nextTick(); expect(findTitle().exists()).toBe(visible); expect(findMainArea().exists()).toBe(visible); expect(findComponentIs().exists()).toBe(visible); if (visible) { - expect(findComponentIs().props('packageEntity')).toEqual(packageEntity); + expect(findComponentIs().props('packageMetadata')).toEqual(packageEntity.metadata); } }, ); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/composer_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/composer_spec.js index e744680cb9a..bb6846d354f 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/composer_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/composer_spec.js @@ -1,22 +1,16 @@ import { GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { - packageData, - composerMetadata, -} from 'jest/packages_and_registries/package_registry/mock_data'; +import { composerMetadata } from 'jest/packages_and_registries/package_registry/mock_data'; import component from '~/packages_and_registries/package_registry/components/details/metadata/composer.vue'; -import { PACKAGE_TYPE_COMPOSER } from '~/packages_and_registries/package_registry/constants'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; -const composerPackage = { packageType: PACKAGE_TYPE_COMPOSER, metadata: composerMetadata() }; - describe('Composer Metadata', () => { let wrapper; const mountComponent = () => { wrapper = shallowMountExtended(component, { - propsData: { packageEntity: packageData(composerPackage) }, + propsData: { packageMetadata: composerMetadata() }, stubs: { DetailsRow, GlSprintf, diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/conan_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/conan_spec.js index 46593047f1f..e7e47401aa1 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/conan_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/conan_spec.js @@ -1,22 +1,16 @@ import { GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { - conanMetadata, - packageData, -} from 'jest/packages_and_registries/package_registry/mock_data'; +import { conanMetadata } from 'jest/packages_and_registries/package_registry/mock_data'; import component from '~/packages_and_registries/package_registry/components/details/metadata/conan.vue'; -import { PACKAGE_TYPE_CONAN } from '~/packages_and_registries/package_registry/constants'; import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; -const conanPackage = { packageType: PACKAGE_TYPE_CONAN, metadata: conanMetadata() }; - describe('Conan Metadata', () => { let wrapper; const mountComponent = () => { wrapper = shallowMountExtended(component, { propsData: { - packageEntity: packageData(conanPackage), + packageMetadata: conanMetadata(), }, stubs: { DetailsRow, diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/maven_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/maven_spec.js index bc54cf1cb98..8680d983042 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/maven_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/maven_spec.js @@ -1,24 +1,16 @@ import { GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { - mavenMetadata, - packageData, -} from 'jest/packages_and_registries/package_registry/mock_data'; +import { mavenMetadata } from 'jest/packages_and_registries/package_registry/mock_data'; import component from '~/packages_and_registries/package_registry/components/details/metadata/maven.vue'; -import { PACKAGE_TYPE_MAVEN } from '~/packages_and_registries/package_registry/constants'; import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; -const mavenPackage = { packageType: PACKAGE_TYPE_MAVEN, metadata: mavenMetadata() }; - describe('Maven Metadata', () => { let wrapper; const mountComponent = () => { wrapper = shallowMountExtended(component, { propsData: { - packageEntity: { - ...packageData(mavenPackage), - }, + packageMetadata: mavenMetadata(), }, stubs: { DetailsRow, diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js index f759fe7a81c..af3692023f0 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js @@ -1,25 +1,17 @@ import { GlLink, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { - nugetMetadata, - packageData, -} from 'jest/packages_and_registries/package_registry/mock_data'; +import { nugetMetadata } from 'jest/packages_and_registries/package_registry/mock_data'; import component from '~/packages_and_registries/package_registry/components/details/metadata/nuget.vue'; -import { PACKAGE_TYPE_NUGET } from '~/packages_and_registries/package_registry/constants'; import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; describe('Nuget Metadata', () => { - let nugetPackage = { packageType: PACKAGE_TYPE_NUGET, metadata: nugetMetadata() }; + let nugetPackageMetadata = { ...nugetMetadata() }; let wrapper; - const mountComponent = () => { + const mountComponent = (props) => { wrapper = shallowMountExtended(component, { - propsData: { - packageEntity: { - ...packageData(nugetPackage), - }, - }, + propsData: { ...props }, stubs: { DetailsRow, GlSprintf, @@ -37,7 +29,7 @@ describe('Nuget Metadata', () => { const findElementLink = (container) => container.findComponent(GlLink); beforeEach(() => { - mountComponent({ packageEntity: nugetPackage }); + mountComponent({ packageMetadata: nugetPackageMetadata }); }); it.each` @@ -49,14 +41,14 @@ describe('Nuget Metadata', () => { expect(element.exists()).toBe(true); expect(element.text()).toBe(text); expect(element.props('icon')).toBe(icon); - expect(findElementLink(element).attributes('href')).toBe(nugetPackage.metadata[link]); + expect(findElementLink(element).attributes('href')).toBe(nugetPackageMetadata[link]); }); describe('without source', () => { beforeAll(() => { - nugetPackage = { - packageType: PACKAGE_TYPE_NUGET, - metadata: { iconUrl: 'iconUrl', licenseUrl: 'licenseUrl' }, + nugetPackageMetadata = { + iconUrl: 'iconUrl', + licenseUrl: 'licenseUrl', }; }); @@ -67,9 +59,9 @@ describe('Nuget Metadata', () => { describe('without license', () => { beforeAll(() => { - nugetPackage = { - packageType: PACKAGE_TYPE_NUGET, - metadata: { iconUrl: 'iconUrl', projectUrl: 'projectUrl' }, + nugetPackageMetadata = { + iconUrl: 'iconUrl', + projectUrl: 'projectUrl', }; }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js index c4481c3f20b..d7c6ea8379d 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js @@ -1,22 +1,17 @@ import { GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { packageData, pypiMetadata } from 'jest/packages_and_registries/package_registry/mock_data'; +import { pypiMetadata } from 'jest/packages_and_registries/package_registry/mock_data'; import component from '~/packages_and_registries/package_registry/components/details/metadata/pypi.vue'; -import { PACKAGE_TYPE_PYPI } from '~/packages_and_registries/package_registry/constants'; import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; -const pypiPackage = { packageType: PACKAGE_TYPE_PYPI, metadata: pypiMetadata() }; - describe('Package Additional Metadata', () => { let wrapper; const mountComponent = () => { wrapper = shallowMountExtended(component, { propsData: { - packageEntity: { - ...packageData(pypiPackage), - }, + packageMetadata: pypiMetadata(), }, stubs: { DetailsRow, diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js index f8a4ba8f3bc..0447ead0830 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js @@ -34,7 +34,7 @@ describe('Package Files', () => { }, stubs: { ...stubChildren(PackageFiles), - GlTable: false, + GlTableLite: false, }, }); }; @@ -219,19 +219,19 @@ describe('Package Files', () => { it('toggles the details row', async () => { createComponent(); - expect(findFirstToggleDetailsButton().props('icon')).toBe('angle-down'); + expect(findFirstToggleDetailsButton().props('icon')).toBe('chevron-down'); findFirstToggleDetailsButton().vm.$emit('click'); await nextTick(); expect(findFirstRowShaComponent('sha-256').exists()).toBe(true); - expect(findFirstToggleDetailsButton().props('icon')).toBe('angle-up'); + expect(findFirstToggleDetailsButton().props('icon')).toBe('chevron-up'); findFirstToggleDetailsButton().vm.$emit('click'); await nextTick(); expect(findFirstRowShaComponent('sha-256').exists()).toBe(false); - expect(findFirstToggleDetailsButton().props('icon')).toBe('angle-down'); + expect(findFirstToggleDetailsButton().props('icon')).toBe('chevron-down'); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js index 57b8be40a7c..f4e6d43812d 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js @@ -1,17 +1,29 @@ -import { GlLink, GlSprintf } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { stubComponent } from 'helpers/stub_component'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { packageData, packagePipelines, + packagePipelinesQuery, } from 'jest/packages_and_registries/package_registry/mock_data'; import { HISTORY_PIPELINES_LIMIT } from '~/packages_and_registries/shared/constants'; import component from '~/packages_and_registries/package_registry/components/details/package_history.vue'; +import PackageHistoryLoader from '~/packages_and_registries/package_registry/components/details/package_history_loader.vue'; import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import waitForPromises from 'helpers/wait_for_promises'; +import getPackagePipelines from '~/packages_and_registries/package_registry/graphql/queries/get_package_pipelines.query.graphql'; + +Vue.use(VueApollo); describe('Package History', () => { let wrapper; + let apolloProvider; + const defaultProps = { projectName: 'baz project', packageEntity: { ...packageData() }, @@ -22,8 +34,15 @@ describe('Package History', () => { const createPipelines = (amount) => [...Array(amount)].map((x, index) => packagePipelines({ id: index + 1 })[0]); - const mountComponent = (props) => { + const mountComponent = ({ + props = {}, + resolver = jest.fn().mockResolvedValue(packagePipelinesQuery()), + } = {}) => { + const requestHandlers = [[getPackagePipelines, resolver]]; + apolloProvider = createMockApollo(requestHandlers); + wrapper = shallowMountExtended(component, { + apolloProvider, propsData: { ...defaultProps, ...props }, stubs: { HistoryItem: stubComponent(HistoryItem, { @@ -34,18 +53,40 @@ describe('Package History', () => { }); }; + beforeEach(() => { + jest.spyOn(Sentry, 'captureException').mockImplementation(); + }); + afterEach(() => { wrapper.destroy(); + wrapper = null; }); + const findPackageHistoryLoader = () => wrapper.findComponent(PackageHistoryLoader); const findHistoryElement = (testId) => wrapper.findByTestId(testId); const findElementLink = (container) => container.findComponent(GlLink); const findElementTimeAgo = (container) => container.findComponent(TimeAgoTooltip); + const findPackageHistoryAlert = () => wrapper.findComponent(GlAlert); const findTitle = () => wrapper.findByTestId('title'); const findTimeline = () => wrapper.findByTestId('timeline'); - it('has the correct title', () => { + it('renders the loading container when loading', () => { + mountComponent(); + + expect(findPackageHistoryLoader().exists()).toBe(true); + }); + + it('does not render the loading container once resolved', async () => { + mountComponent(); + await waitForPromises(); + + expect(findPackageHistoryLoader().exists()).toBe(false); + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('has the correct title', async () => { mountComponent(); + await waitForPromises(); const title = findTitle(); @@ -53,8 +94,9 @@ describe('Package History', () => { expect(title.text()).toBe('History'); }); - it('has a timeline container', () => { + it('has a timeline container', async () => { mountComponent(); + await waitForPromises(); const title = findTimeline(); @@ -64,6 +106,24 @@ describe('Package History', () => { ); }); + it('does not render gl-alert', () => { + mountComponent(); + + expect(findPackageHistoryAlert().exists()).toBe(false); + }); + + it('renders gl-alert if load fails', async () => { + mountComponent({ resolver: jest.fn().mockRejectedValue() }); + + await waitForPromises(); + + expect(findPackageHistoryAlert().exists()).toBe(true); + expect(findPackageHistoryAlert().text()).toEqual( + 'Something went wrong while fetching the package history.', + ); + expect(Sentry.captureException).toHaveBeenCalled(); + }); + describe.each` name | amount | icon | text | timeAgoTooltip | link ${'created-on'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'clock'} | ${'@gitlab-org/package-15 version 1.0.0 was first created'} | ${packageData().createdAt} | ${null} @@ -78,11 +138,21 @@ describe('Package History', () => { ({ name, icon, text, timeAgoTooltip, link, amount }) => { let element; - beforeEach(() => { - const packageEntity = { ...packageData(), pipelines: { nodes: createPipelines(amount) } }; + beforeEach(async () => { + const packageEntity = { ...packageData() }; + const pipelinesResolver = jest + .fn() + .mockResolvedValue(packagePipelinesQuery(createPipelines(amount))); + mountComponent({ - packageEntity, + props: { + packageEntity, + }, + resolver: pipelinesResolver, }); + + await waitForPromises(); + element = findHistoryElement(name); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js index 3670cfca8ea..19505618ff7 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js @@ -134,7 +134,7 @@ describe('Package Search', () => { await nextTick(); - expect(findRegistrySearch().props('filter')).toEqual(['foo']); + expect(findRegistrySearch().props('filters')).toEqual(['foo']); }); it('on filter:submit emits update event', async () => { @@ -175,7 +175,7 @@ describe('Package Search', () => { expect(getQueryParams).toHaveBeenCalled(); expect(findRegistrySearch().props()).toMatchObject({ - filter: defaultQueryParamsMock.filters, + filters: defaultQueryParamsMock.filters, sorting: defaultQueryParamsMock.sorting, }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js index 0a4747fc9ec..d40feee582f 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -148,6 +148,8 @@ export const conanMetadata = () => ({ recipePath: 'package-8/1.0.0/gitlab-org+gitlab-test/stable', }); +const conanMetadataQuery = () => ({ ...conanMetadata(), __typename: 'ConanMetadata' }); + export const composerMetadata = () => ({ targetSha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0', composerJson: { @@ -156,23 +158,45 @@ export const composerMetadata = () => ({ }, }); +const composerMetadataQuery = () => ({ + ...composerMetadata(), + __typename: 'ComposerMetadata', +}); + export const pypiMetadata = () => ({ + id: 'pypi-1', requiredPython: '1.0.0', }); +const pypiMetadataQuery = () => ({ ...pypiMetadata(), __typename: 'PypiMetadata' }); + export const mavenMetadata = () => ({ + id: 'maven-1', appName: 'appName', appGroup: 'appGroup', appVersion: 'appVersion', path: 'path', }); +const mavenMetadataQuery = () => ({ ...mavenMetadata(), __typename: 'MavenMetadata' }); + export const nugetMetadata = () => ({ + id: 'nuget-1', iconUrl: 'iconUrl', licenseUrl: 'licenseUrl', projectUrl: 'projectUrl', }); +const nugetMetadataQuery = () => ({ ...nugetMetadata(), __typename: 'NugetMetadata' }); + +const packageTypeMetadataQueryMapping = { + CONAN: conanMetadataQuery, + COMPOSER: composerMetadataQuery, + PYPI: pypiMetadataQuery, + MAVEN: mavenMetadataQuery, + NUGET: nugetMetadataQuery, +}; + export const pagination = (extend) => ({ endCursor: 'eyJpZCI6IjIwNSIsIm5hbWUiOiJteS9jb21wYW55L2FwcC9teS1hcHAifQ', hasNextPage: true, @@ -223,6 +247,19 @@ export const packageDetailsQuery = (extendPackage) => ({ }, }); +export const packagePipelinesQuery = (pipelines = packagePipelines()) => ({ + data: { + package: { + id: 'gid://gitlab/Packages::Package/111', + pipelines: { + nodes: pipelines, + __typename: 'PipelineConnection', + }, + __typename: 'PackageDetailsType', + }, + }, +}); + export const emptyPackageDetailsQuery = () => ({ data: { package: { @@ -231,6 +268,21 @@ export const emptyPackageDetailsQuery = () => ({ }, }); +export const packageMetadataQuery = (packageType) => { + return { + data: { + package: { + id: 'gid://gitlab/Packages::Package/111', + packageType, + metadata: { + ...(packageTypeMetadataQueryMapping[packageType]?.() ?? {}), + }, + __typename: 'PackageDetailsType', + }, + }, + }; +}; + export const packageDestroyMutation = () => ({ data: { destroyPackage: { diff --git a/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js index a7e31d42c9e..3cadb001c58 100644 --- a/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js @@ -23,6 +23,10 @@ import { DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, DELETE_PACKAGE_FILE_ERROR_MESSAGE, PACKAGE_TYPE_NUGET, + PACKAGE_TYPE_MAVEN, + PACKAGE_TYPE_CONAN, + PACKAGE_TYPE_PYPI, + PACKAGE_TYPE_NPM, } from '~/packages_and_registries/package_registry/constants'; import destroyPackageFileMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql'; @@ -160,15 +164,38 @@ describe('PackagesApp', () => { }); }); - it('renders additional metadata and has the right props', async () => { - createComponent(); + describe('additional metadata', () => { + it.each` + packageType | visible + ${PACKAGE_TYPE_MAVEN} | ${true} + ${PACKAGE_TYPE_CONAN} | ${true} + ${PACKAGE_TYPE_NUGET} | ${true} + ${PACKAGE_TYPE_COMPOSER} | ${true} + ${PACKAGE_TYPE_PYPI} | ${true} + ${PACKAGE_TYPE_NPM} | ${false} + `( + `It is $visible that the component is visible when the package is $packageType`, + async ({ packageType, visible }) => { + createComponent({ + resolver: jest.fn().mockResolvedValue( + packageDetailsQuery({ + packageType, + }), + ), + }); - await waitForPromises(); + await waitForPromises(); - expect(findAdditionalMetadata().exists()).toBe(true); - expect(findAdditionalMetadata().props()).toMatchObject({ - packageEntity: expect.objectContaining(packageWithoutTypename), - }); + expect(findAdditionalMetadata().exists()).toBe(visible); + + if (visible) { + expect(findAdditionalMetadata().props()).toMatchObject({ + packageId: packageWithoutTypename.id, + packageType, + }); + } + }, + ); }); it('renders installation commands and has the right props', async () => { diff --git a/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js index 22754d31f93..e60989b0949 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js @@ -134,12 +134,6 @@ describe('DependencyProxySettings', () => { mountComponent(); }); - it('has the help prop correctly set', () => { - expect(findEnableProxyToggle().props()).toMatchObject({ - help: component.i18n.enabledProxyHelpText, - }); - }); - it('has help text with a link', () => { expect(findEnableProxyToggle().text()).toContain( 'To see the image prefix and what is in the cache, visit the Dependency Proxy', @@ -157,12 +151,6 @@ describe('DependencyProxySettings', () => { }); }); - it('has the help prop set to empty', () => { - expect(findEnableProxyToggle().props()).toMatchObject({ - help: '', - }); - }); - it('the help text is not visible', () => { expect(findToggleHelpLink().exists()).toBe(false); }); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap b/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/container_expiration_policy_form_spec.js.snap index 841a9bf8290..faa313118f3 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/container_expiration_policy_form_spec.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Settings Form Cadence matches snapshot 1`] = ` +exports[`Container Expiration Policy Settings Form Cadence matches snapshot 1`] = ` <expiration-dropdown-stub class="gl-mr-7 gl-mb-0!" data-testid="cadence-dropdown" @@ -11,7 +11,7 @@ exports[`Settings Form Cadence matches snapshot 1`] = ` /> `; -exports[`Settings Form Enable matches snapshot 1`] = ` +exports[`Container Expiration Policy Settings Form Enable matches snapshot 1`] = ` <expiration-toggle-stub class="gl-mb-0!" data-testid="enable-toggle" @@ -19,7 +19,7 @@ exports[`Settings Form Enable matches snapshot 1`] = ` /> `; -exports[`Settings Form Keep N matches snapshot 1`] = ` +exports[`Container Expiration Policy Settings Form Keep N matches snapshot 1`] = ` <expiration-dropdown-stub data-testid="keep-n-dropdown" formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]" @@ -29,7 +29,7 @@ exports[`Settings Form Keep N matches snapshot 1`] = ` /> `; -exports[`Settings Form Keep Regex matches snapshot 1`] = ` +exports[`Container Expiration Policy Settings Form Keep Regex matches snapshot 1`] = ` <expiration-input-stub data-testid="keep-regex-input" description="Tags with names that match this regex pattern are kept. %{linkStart}View regex examples.%{linkEnd}" @@ -41,7 +41,7 @@ exports[`Settings Form Keep Regex matches snapshot 1`] = ` /> `; -exports[`Settings Form OlderThan matches snapshot 1`] = ` +exports[`Container Expiration Policy Settings Form OlderThan matches snapshot 1`] = ` <expiration-dropdown-stub data-testid="older-than-dropdown" formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]" @@ -51,7 +51,7 @@ exports[`Settings Form OlderThan matches snapshot 1`] = ` /> `; -exports[`Settings Form Remove regex matches snapshot 1`] = ` +exports[`Container Expiration Policy Settings Form Remove regex matches snapshot 1`] = ` <expiration-input-stub data-testid="remove-regex-input" description="Tags with names that match this regex pattern are removed. %{linkStart}View regex examples.%{linkEnd}" diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js index 465e6dc73e2..ca44e77e694 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js @@ -4,7 +4,7 @@ import Vue, { nextTick } from 'vue'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { GlCard, GlLoadingIcon } from 'jest/packages_and_registries/shared/stubs'; -import component from '~/packages_and_registries/settings/project/components/settings_form.vue'; +import component from '~/packages_and_registries/settings/project/components/container_expiration_policy_form.vue'; import { UPDATE_SETTINGS_ERROR_MESSAGE, UPDATE_SETTINGS_SUCCESS_MESSAGE, @@ -14,7 +14,7 @@ import expirationPolicyQuery from '~/packages_and_registries/settings/project/gr import Tracking from '~/tracking'; import { expirationPolicyPayload, expirationPolicyMutationPayload } from '../mock_data'; -describe('Settings Form', () => { +describe('Container Expiration Policy Settings Form', () => { let wrapper; let fakeApollo; diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js new file mode 100644 index 00000000000..aa3506771fa --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js @@ -0,0 +1,167 @@ +import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import component from '~/packages_and_registries/settings/project/components/container_expiration_policy.vue'; +import ContainerExpirationPolicyForm from '~/packages_and_registries/settings/project/components/container_expiration_policy_form.vue'; +import { + FETCH_SETTINGS_ERROR_MESSAGE, + UNAVAILABLE_FEATURE_INTRO_TEXT, + UNAVAILABLE_USER_FEATURE_TEXT, +} from '~/packages_and_registries/settings/project/constants'; +import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; + +import { + expirationPolicyPayload, + emptyExpirationPolicyPayload, + containerExpirationPolicyData, +} from '../mock_data'; + +describe('Container expiration policy project settings', () => { + let wrapper; + let fakeApollo; + + const defaultProvidedValues = { + projectPath: 'path', + isAdmin: false, + adminSettingsPath: 'settingsPath', + enableHistoricEntries: false, + helpPagePath: 'helpPagePath', + showCleanupPolicyLink: false, + }; + + const findFormComponent = () => wrapper.find(ContainerExpirationPolicyForm); + const findAlert = () => wrapper.find(GlAlert); + const findSettingsBlock = () => wrapper.find(SettingsBlock); + + const mountComponent = (provide = defaultProvidedValues, config) => { + wrapper = shallowMount(component, { + stubs: { + GlSprintf, + SettingsBlock, + }, + mocks: { + $toast: { + show: jest.fn(), + }, + }, + provide, + ...config, + }); + }; + + const mountComponentWithApollo = ({ provide = defaultProvidedValues, resolver } = {}) => { + Vue.use(VueApollo); + + const requestHandlers = [[expirationPolicyQuery, resolver]]; + + fakeApollo = createMockApollo(requestHandlers); + mountComponent(provide, { + apolloProvider: fakeApollo, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('isEdited status', () => { + it.each` + description | apiResponse | workingCopy | result + ${'empty response and no changes from user'} | ${emptyExpirationPolicyPayload()} | ${{}} | ${false} + ${'empty response and changes from user'} | ${emptyExpirationPolicyPayload()} | ${{ enabled: true }} | ${true} + ${'response and no changes'} | ${expirationPolicyPayload()} | ${containerExpirationPolicyData()} | ${false} + ${'response and changes'} | ${expirationPolicyPayload()} | ${{ ...containerExpirationPolicyData(), nameRegex: '12345' }} | ${true} + ${'response and empty'} | ${expirationPolicyPayload()} | ${{}} | ${true} + `('$description', async ({ apiResponse, workingCopy, result }) => { + mountComponentWithApollo({ + provide: { ...defaultProvidedValues, enableHistoricEntries: true }, + resolver: jest.fn().mockResolvedValue(apiResponse), + }); + await waitForPromises(); + + findFormComponent().vm.$emit('input', workingCopy); + + await waitForPromises(); + + expect(findFormComponent().props('isEdited')).toBe(result); + }); + }); + + it('renders the setting form', async () => { + mountComponentWithApollo({ + resolver: jest.fn().mockResolvedValue(expirationPolicyPayload()), + }); + await waitForPromises(); + + expect(findFormComponent().exists()).toBe(true); + expect(findSettingsBlock().props('collapsible')).toBe(false); + }); + + describe('the form is disabled', () => { + it('the form is hidden', () => { + mountComponent(); + + expect(findFormComponent().exists()).toBe(false); + }); + + it('shows an alert', () => { + mountComponent(); + + const text = findAlert().text(); + expect(text).toContain(UNAVAILABLE_FEATURE_INTRO_TEXT); + expect(text).toContain(UNAVAILABLE_USER_FEATURE_TEXT); + }); + + describe('an admin is visiting the page', () => { + it('shows the admin part of the alert message', () => { + mountComponent({ ...defaultProvidedValues, isAdmin: true }); + + const sprintf = findAlert().find(GlSprintf); + expect(sprintf.text()).toBe('administration settings'); + expect(sprintf.find(GlLink).attributes('href')).toBe( + defaultProvidedValues.adminSettingsPath, + ); + }); + }); + }); + + describe('fetchSettingsError', () => { + beforeEach(async () => { + mountComponentWithApollo({ + resolver: jest.fn().mockRejectedValue(new Error('GraphQL error')), + }); + await waitForPromises(); + }); + + it('the form is hidden', () => { + expect(findFormComponent().exists()).toBe(false); + }); + + it('shows an alert', () => { + expect(findAlert().html()).toContain(FETCH_SETTINGS_ERROR_MESSAGE); + }); + }); + + describe('empty API response', () => { + it.each` + enableHistoricEntries | isShown + ${true} | ${true} + ${false} | ${false} + `('is $isShown that the form is shown', async ({ enableHistoricEntries, isShown }) => { + mountComponentWithApollo({ + provide: { + ...defaultProvidedValues, + enableHistoricEntries, + }, + resolver: jest.fn().mockResolvedValue(emptyExpirationPolicyPayload()), + }); + await waitForPromises(); + + expect(findFormComponent().exists()).toBe(isShown); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js index 0a72f0269ee..337991dfae0 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js @@ -1,165 +1,19 @@ -import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; import component from '~/packages_and_registries/settings/project/components/registry_settings_app.vue'; -import SettingsForm from '~/packages_and_registries/settings/project/components/settings_form.vue'; -import { - FETCH_SETTINGS_ERROR_MESSAGE, - UNAVAILABLE_FEATURE_INTRO_TEXT, - UNAVAILABLE_USER_FEATURE_TEXT, -} from '~/packages_and_registries/settings/project/constants'; -import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql'; -import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; +import ContainerExpirationPolicy from '~/packages_and_registries/settings/project/components/container_expiration_policy.vue'; -import { - expirationPolicyPayload, - emptyExpirationPolicyPayload, - containerExpirationPolicyData, -} from '../mock_data'; - -describe('Registry Settings App', () => { +describe('Registry Settings app', () => { let wrapper; - let fakeApollo; - - const defaultProvidedValues = { - projectPath: 'path', - isAdmin: false, - adminSettingsPath: 'settingsPath', - enableHistoricEntries: false, - helpPagePath: 'helpPagePath', - showCleanupPolicyLink: false, - }; - - const findSettingsComponent = () => wrapper.find(SettingsForm); - const findAlert = () => wrapper.find(GlAlert); - - const mountComponent = (provide = defaultProvidedValues, config) => { - wrapper = shallowMount(component, { - stubs: { - GlSprintf, - SettingsBlock, - }, - mocks: { - $toast: { - show: jest.fn(), - }, - }, - provide, - ...config, - }); - }; - - const mountComponentWithApollo = ({ provide = defaultProvidedValues, resolver } = {}) => { - Vue.use(VueApollo); - - const requestHandlers = [[expirationPolicyQuery, resolver]]; - - fakeApollo = createMockApollo(requestHandlers); - mountComponent(provide, { - apolloProvider: fakeApollo, - }); - }; + const findContainerExpirationPolicy = () => wrapper.find(ContainerExpirationPolicy); afterEach(() => { wrapper.destroy(); + wrapper = null; }); - describe('isEdited status', () => { - it.each` - description | apiResponse | workingCopy | result - ${'empty response and no changes from user'} | ${emptyExpirationPolicyPayload()} | ${{}} | ${false} - ${'empty response and changes from user'} | ${emptyExpirationPolicyPayload()} | ${{ enabled: true }} | ${true} - ${'response and no changes'} | ${expirationPolicyPayload()} | ${containerExpirationPolicyData()} | ${false} - ${'response and changes'} | ${expirationPolicyPayload()} | ${{ ...containerExpirationPolicyData(), nameRegex: '12345' }} | ${true} - ${'response and empty'} | ${expirationPolicyPayload()} | ${{}} | ${true} - `('$description', async ({ apiResponse, workingCopy, result }) => { - mountComponentWithApollo({ - provide: { ...defaultProvidedValues, enableHistoricEntries: true }, - resolver: jest.fn().mockResolvedValue(apiResponse), - }); - await waitForPromises(); - - findSettingsComponent().vm.$emit('input', workingCopy); - - await waitForPromises(); - - expect(findSettingsComponent().props('isEdited')).toBe(result); - }); - }); - - it('renders the setting form', async () => { - mountComponentWithApollo({ - resolver: jest.fn().mockResolvedValue(expirationPolicyPayload()), - }); - await waitForPromises(); - - expect(findSettingsComponent().exists()).toBe(true); - }); - - describe('the form is disabled', () => { - it('the form is hidden', () => { - mountComponent(); - - expect(findSettingsComponent().exists()).toBe(false); - }); - - it('shows an alert', () => { - mountComponent(); - - const text = findAlert().text(); - expect(text).toContain(UNAVAILABLE_FEATURE_INTRO_TEXT); - expect(text).toContain(UNAVAILABLE_USER_FEATURE_TEXT); - }); - - describe('an admin is visiting the page', () => { - it('shows the admin part of the alert message', () => { - mountComponent({ ...defaultProvidedValues, isAdmin: true }); - - const sprintf = findAlert().find(GlSprintf); - expect(sprintf.text()).toBe('administration settings'); - expect(sprintf.find(GlLink).attributes('href')).toBe( - defaultProvidedValues.adminSettingsPath, - ); - }); - }); - }); - - describe('fetchSettingsError', () => { - beforeEach(async () => { - mountComponentWithApollo({ - resolver: jest.fn().mockRejectedValue(new Error('GraphQL error')), - }); - await waitForPromises(); - }); - - it('the form is hidden', () => { - expect(findSettingsComponent().exists()).toBe(false); - }); - - it('shows an alert', () => { - expect(findAlert().html()).toContain(FETCH_SETTINGS_ERROR_MESSAGE); - }); - }); - - describe('empty API response', () => { - it.each` - enableHistoricEntries | isShown - ${true} | ${true} - ${false} | ${false} - `('is $isShown that the form is shown', async ({ enableHistoricEntries, isShown }) => { - mountComponentWithApollo({ - provide: { - ...defaultProvidedValues, - enableHistoricEntries, - }, - resolver: jest.fn().mockResolvedValue(emptyExpirationPolicyPayload()), - }); - await waitForPromises(); + it('renders container expiration policy component', () => { + wrapper = shallowMount(component); - expect(findSettingsComponent().exists()).toBe(isShown); - }); + expect(findContainerExpirationPolicy().exists()).toBe(true); }); }); diff --git a/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap b/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap index 3dd6023140f..e6e89806ce0 100644 --- a/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap +++ b/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap @@ -30,11 +30,11 @@ exports[`Registry Breadcrumb when is not rootRoute renders 1`] = ` <svg aria-hidden="true" class="gl-icon s8" - data-testid="angle-right-icon" + data-testid="chevron-lg-right-icon" role="img" > <use - href="#angle-right" + href="#chevron-lg-right" /> </svg> </span> diff --git a/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js b/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js index bd492a5ae8f..db9f96bff39 100644 --- a/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js +++ b/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js @@ -100,7 +100,7 @@ describe('Persisted Search', () => { await nextTick(); - expect(findRegistrySearch().props('filter')).toEqual(['foo']); + expect(findRegistrySearch().props('filters')).toEqual(['foo']); }); it('on filter:submit emits update event', async () => { @@ -138,7 +138,7 @@ describe('Persisted Search', () => { expect(getQueryParams).toHaveBeenCalled(); expect(findRegistrySearch().props()).toMatchObject({ - filter: defaultQueryParamsMock.filters, + filters: defaultQueryParamsMock.filters, sorting: defaultQueryParamsMock.sorting, }); }); diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js index ae5404f2d13..d5b4b3c22d8 100644 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js @@ -190,7 +190,7 @@ describe('Interval Pattern Input Component', () => { findCustomInput().setValue(newValue); - await nextTick; + await nextTick(); expect(findSelectedRadioKey()).toBe(customKey); }); diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js index 30d5f89d2f6..46f83ac89e5 100644 --- a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js +++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js @@ -53,11 +53,13 @@ const defaultProps = { showVisibilityConfirmModal: false, }; +const FEATURE_ACCESS_LEVEL_ANONYMOUS = 30; + describe('Settings Panel', () => { let wrapper; const mountComponent = ( - { currentSettings = {}, ...customProps } = {}, + { currentSettings = {}, glFeatures = {}, ...customProps } = {}, mountFn = shallowMount, ) => { const propsData = { @@ -68,6 +70,12 @@ describe('Settings Panel', () => { return mountFn(settingsPanel, { propsData, + provide: { + glFeatures: { + packageRegistryAccessLevel: false, + ...glFeatures, + }, + }, }); }; @@ -95,6 +103,10 @@ describe('Settings Panel', () => { const findContainerRegistryAccessLevelInput = () => wrapper.find('[name="project[project_feature_attributes][container_registry_access_level]"]'); const findPackageSettings = () => wrapper.find({ ref: 'package-settings' }); + const findPackageAccessLevel = () => + wrapper.find('[data-testid="package-registry-access-level"]'); + const findPackageAccessLevels = () => + wrapper.find('[name="project[project_feature_attributes][package_registry_access_level]"]'); const findPackagesEnabledInput = () => wrapper.find('[name="project[packages_enabled]"]'); const findPagesSettings = () => wrapper.find({ ref: 'pages-settings' }); const findPagesAccessLevels = () => @@ -521,6 +533,101 @@ describe('Settings Panel', () => { settingsPanel.i18n.packagesLabel, ); }); + + it('should hide the package access level settings', () => { + wrapper = mountComponent(); + + expect(findPackageAccessLevel().exists()).toBe(false); + }); + + describe('packageRegistryAccessLevel feature flag = true', () => { + it('should hide the packages settings', () => { + wrapper = mountComponent({ + glFeatures: { packageRegistryAccessLevel: true }, + packagesAvailable: true, + }); + + expect(findPackageSettings().exists()).toBe(false); + }); + + it('should hide the package access level settings with packagesAvailable = false', () => { + wrapper = mountComponent({ glFeatures: { packageRegistryAccessLevel: true } }); + + expect(findPackageAccessLevel().exists()).toBe(false); + }); + + it('renders the package access level settings with packagesAvailable = true', () => { + wrapper = mountComponent({ + glFeatures: { packageRegistryAccessLevel: true }, + packagesAvailable: true, + }); + + expect(findPackageAccessLevel().exists()).toBe(true); + }); + + it.each` + visibilityLevel | output + ${visibilityOptions.PRIVATE} | ${[[featureAccessLevel.PROJECT_MEMBERS, 'Only Project Members'], [30, 'Everyone']]} + ${visibilityOptions.INTERNAL} | ${[[featureAccessLevel.EVERYONE, 'Everyone With Access'], [30, 'Everyone']]} + ${visibilityOptions.PUBLIC} | ${[[30, 'Everyone']]} + `( + 'renders correct options when visibilityLevel is $visibilityLevel', + async ({ visibilityLevel, output }) => { + wrapper = mountComponent({ + glFeatures: { packageRegistryAccessLevel: true }, + packagesAvailable: true, + currentSettings: { + visibilityLevel, + }, + }); + + expect(findPackageAccessLevels().props('options')).toStrictEqual(output); + }, + ); + + it.each` + initialProjectVisibilityLevel | newProjectVisibilityLevel | initialPackageRegistryOption | expectedPackageRegistryOption + ${visibilityOptions.PRIVATE} | ${visibilityOptions.INTERNAL} | ${featureAccessLevel.NOT_ENABLED} | ${featureAccessLevel.NOT_ENABLED} + ${visibilityOptions.PRIVATE} | ${visibilityOptions.INTERNAL} | ${featureAccessLevel.PROJECT_MEMBERS} | ${featureAccessLevel.EVERYONE} + ${visibilityOptions.PRIVATE} | ${visibilityOptions.INTERNAL} | ${FEATURE_ACCESS_LEVEL_ANONYMOUS} | ${FEATURE_ACCESS_LEVEL_ANONYMOUS} + ${visibilityOptions.PRIVATE} | ${visibilityOptions.PUBLIC} | ${featureAccessLevel.NOT_ENABLED} | ${featureAccessLevel.NOT_ENABLED} + ${visibilityOptions.PRIVATE} | ${visibilityOptions.PUBLIC} | ${featureAccessLevel.PROJECT_MEMBERS} | ${FEATURE_ACCESS_LEVEL_ANONYMOUS} + ${visibilityOptions.PRIVATE} | ${visibilityOptions.PUBLIC} | ${FEATURE_ACCESS_LEVEL_ANONYMOUS} | ${FEATURE_ACCESS_LEVEL_ANONYMOUS} + ${visibilityOptions.INTERNAL} | ${visibilityOptions.PRIVATE} | ${featureAccessLevel.NOT_ENABLED} | ${featureAccessLevel.NOT_ENABLED} + ${visibilityOptions.INTERNAL} | ${visibilityOptions.PRIVATE} | ${featureAccessLevel.EVERYONE} | ${featureAccessLevel.PROJECT_MEMBERS} + ${visibilityOptions.INTERNAL} | ${visibilityOptions.PRIVATE} | ${FEATURE_ACCESS_LEVEL_ANONYMOUS} | ${FEATURE_ACCESS_LEVEL_ANONYMOUS} + ${visibilityOptions.INTERNAL} | ${visibilityOptions.PUBLIC} | ${featureAccessLevel.NOT_ENABLED} | ${featureAccessLevel.NOT_ENABLED} + ${visibilityOptions.INTERNAL} | ${visibilityOptions.PUBLIC} | ${featureAccessLevel.EVERYONE} | ${FEATURE_ACCESS_LEVEL_ANONYMOUS} + ${visibilityOptions.INTERNAL} | ${visibilityOptions.PUBLIC} | ${FEATURE_ACCESS_LEVEL_ANONYMOUS} | ${FEATURE_ACCESS_LEVEL_ANONYMOUS} + ${visibilityOptions.PUBLIC} | ${visibilityOptions.PRIVATE} | ${featureAccessLevel.NOT_ENABLED} | ${featureAccessLevel.NOT_ENABLED} + ${visibilityOptions.PUBLIC} | ${visibilityOptions.PRIVATE} | ${FEATURE_ACCESS_LEVEL_ANONYMOUS} | ${featureAccessLevel.PROJECT_MEMBERS} + ${visibilityOptions.PUBLIC} | ${visibilityOptions.INTERNAL} | ${featureAccessLevel.NOT_ENABLED} | ${featureAccessLevel.NOT_ENABLED} + ${visibilityOptions.PUBLIC} | ${visibilityOptions.INTERNAL} | ${FEATURE_ACCESS_LEVEL_ANONYMOUS} | ${featureAccessLevel.EVERYONE} + `( + 'changes option from $initialPackageRegistryOption to $expectedPackageRegistryOption when visibilityLevel changed from $initialProjectVisibilityLevel to $newProjectVisibilityLevel', + async ({ + initialProjectVisibilityLevel, + newProjectVisibilityLevel, + initialPackageRegistryOption, + expectedPackageRegistryOption, + }) => { + wrapper = mountComponent({ + glFeatures: { packageRegistryAccessLevel: true }, + packagesAvailable: true, + currentSettings: { + visibilityLevel: initialProjectVisibilityLevel, + packageRegistryAccessLevel: initialPackageRegistryOption, + }, + }); + + await findProjectVisibilityLevelInput().setValue(newProjectVisibilityLevel); + + expect(findPackageAccessLevels().props('value')).toStrictEqual( + expectedPackageRegistryOption, + ); + }, + ); + }); }); describe('Pages', () => { diff --git a/spec/frontend/performance_bar/components/add_request_spec.js b/spec/frontend/performance_bar/components/add_request_spec.js index 5422481439e..627e004ce3e 100644 --- a/spec/frontend/performance_bar/components/add_request_spec.js +++ b/spec/frontend/performance_bar/components/add_request_spec.js @@ -1,12 +1,16 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { GlFormInput, GlButton } from '@gitlab/ui'; import AddRequest from '~/performance_bar/components/add_request.vue'; describe('add request form', () => { let wrapper; + const findGlFormInput = () => wrapper.findComponent(GlFormInput); + const findGlButton = () => wrapper.findComponent(GlButton); + beforeEach(() => { - wrapper = shallowMount(AddRequest); + wrapper = mount(AddRequest); }); afterEach(() => { @@ -14,35 +18,35 @@ describe('add request form', () => { }); it('hides the input on load', () => { - expect(wrapper.find('input').exists()).toBe(false); + expect(findGlFormInput().exists()).toBe(false); }); describe('when clicking the button', () => { beforeEach(async () => { - wrapper.find('button').trigger('click'); + findGlButton().trigger('click'); await nextTick(); }); it('shows the form', () => { - expect(wrapper.find('input').exists()).toBe(true); + expect(findGlFormInput().exists()).toBe(true); }); describe('when pressing escape', () => { beforeEach(async () => { - wrapper.find('input').trigger('keyup.esc'); + findGlFormInput().trigger('keyup.esc'); await nextTick(); }); it('hides the input', () => { - expect(wrapper.find('input').exists()).toBe(false); + expect(findGlFormInput().exists()).toBe(false); }); }); describe('when submitting the form', () => { beforeEach(async () => { - wrapper.find('input').setValue('http://gitlab.example.com/users/root/calendar.json'); + findGlFormInput().setValue('http://gitlab.example.com/users/root/calendar.json'); await nextTick(); - wrapper.find('input').trigger('keyup.enter'); + findGlFormInput().trigger('keyup.enter'); await nextTick(); }); @@ -54,13 +58,13 @@ describe('add request form', () => { }); it('hides the input', () => { - expect(wrapper.find('input').exists()).toBe(false); + expect(findGlFormInput().exists()).toBe(false); }); it('clears the value for next time', async () => { - wrapper.find('button').trigger('click'); + findGlButton().trigger('click'); await nextTick(); - expect(wrapper.find('input').text()).toEqual(''); + expect(findGlFormInput().text()).toEqual(''); }); }); }); diff --git a/spec/frontend/performance_bar/index_spec.js b/spec/frontend/performance_bar/index_spec.js index 6c1cbfa70a1..2da176dbfe4 100644 --- a/spec/frontend/performance_bar/index_spec.js +++ b/spec/frontend/performance_bar/index_spec.js @@ -17,11 +17,11 @@ describe('performance bar wrapper', () => { performance.getEntriesByType = jest.fn().mockReturnValue([]); peekWrapper.setAttribute('id', 'js-peek'); - peekWrapper.setAttribute('data-env', 'development'); - peekWrapper.setAttribute('data-request-id', '123'); - peekWrapper.setAttribute('data-peek-url', '/-/peek/results'); - peekWrapper.setAttribute('data-stats-url', 'https://log.gprd.gitlab.net/app/dashboards#/view/'); - peekWrapper.setAttribute('data-profile-url', '?lineprofiler=true'); + peekWrapper.dataset.env = 'development'; + peekWrapper.dataset.requestId = '123'; + peekWrapper.dataset.peekUrl = '/-/peek/results'; + peekWrapper.dataset.statsUrl = 'https://log.gprd.gitlab.net/app/dashboards#/view/'; + peekWrapper.dataset.profileUrl = '?lineprofiler=true'; mock = new MockAdapter(axios); @@ -69,7 +69,7 @@ describe('performance bar wrapper', () => { it('adds the request immediately', () => { vm.addRequest('123', 'https://gitlab.com/'); - expect(vm.store.addRequest).toHaveBeenCalledWith('123', 'https://gitlab.com/'); + expect(vm.store.addRequest).toHaveBeenCalledWith('123', 'https://gitlab.com/', undefined); }); }); diff --git a/spec/frontend/performance_bar/services/performance_bar_service_spec.js b/spec/frontend/performance_bar/services/performance_bar_service_spec.js index 36bfd575c12..1bb70a43a1b 100644 --- a/spec/frontend/performance_bar/services/performance_bar_service_spec.js +++ b/spec/frontend/performance_bar/services/performance_bar_service_spec.js @@ -63,5 +63,17 @@ describe('PerformanceBarService', () => { ); }); }); + + describe('operationName', () => { + function requestUrl(response, peekUrl) { + return PerformanceBarService.callbackParams(response, peekUrl)[3]; + } + + it('gets the operation name from response.config', () => { + expect( + requestUrl({ headers: {}, config: { operationName: 'someOperation' } }, '/peek'), + ).toBe('someOperation'); + }); + }); }); }); diff --git a/spec/frontend/performance_bar/stores/performance_bar_store_spec.js b/spec/frontend/performance_bar/stores/performance_bar_store_spec.js index b7324ba2f6e..7d5c5031792 100644 --- a/spec/frontend/performance_bar/stores/performance_bar_store_spec.js +++ b/spec/frontend/performance_bar/stores/performance_bar_store_spec.js @@ -1,9 +1,9 @@ import PerformanceBarStore from '~/performance_bar/stores/performance_bar_store'; describe('PerformanceBarStore', () => { - describe('truncateUrl', () => { + describe('displayName', () => { let store; - const findUrl = (id) => store.findRequest(id).truncatedUrl; + const findUrl = (id) => store.findRequest(id).displayName; beforeEach(() => { store = new PerformanceBarStore(); @@ -41,6 +41,11 @@ describe('PerformanceBarStore', () => { store.addRequest('id', 'http://localhost:3001/h5bp/html5-boilerplate/#frag/ment'); expect(findUrl('id')).toEqual('html5-boilerplate'); }); + + it('appends the GraphQL operation name', () => { + store.addRequest('id', 'http://localhost:3001/api/graphql', 'someOperation'); + expect(findUrl('id')).toBe('graphql (someOperation)'); + }); }); describe('setRequestDetailsData', () => { diff --git a/spec/frontend/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js b/spec/frontend/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js index e435c0dcc08..bf5d15516c2 100644 --- a/spec/frontend/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js +++ b/spec/frontend/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js @@ -1,9 +1,12 @@ import { getByRole } from '@testing-library/dom'; import { mount } from '@vue/test-utils'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import FirstPipelineCard from '~/pipeline_editor/components/drawer/cards/first_pipeline_card.vue'; +import { pipelineEditorTrackingOptions } from '~/pipeline_editor/constants'; describe('First pipeline card', () => { let wrapper; + let trackingSpy; const defaultProvide = { runnerHelpPagePath: '/help/runners', @@ -17,7 +20,7 @@ describe('First pipeline card', () => { }); }; - const getLinkByName = (name) => getByRole(wrapper.element, 'link', { name }).href; + const getLinkByName = (name) => getByRole(wrapper.element, 'link', { name }); const findRunnersLink = () => getLinkByName(/make sure your instance has runners available/i); const findInstructionsList = () => wrapper.find('ol'); const findAllInstructions = () => findInstructionsList().findAll('li'); @@ -40,6 +43,26 @@ describe('First pipeline card', () => { }); it('renders the link', () => { - expect(findRunnersLink()).toContain(defaultProvide.runnerHelpPagePath); + expect(findRunnersLink().href).toContain(defaultProvide.runnerHelpPagePath); + }); + + describe('tracking', () => { + beforeEach(() => { + createComponent(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('tracks runners help page click', async () => { + const { label } = pipelineEditorTrackingOptions; + const { runners } = pipelineEditorTrackingOptions.actions.helpDrawerLinks; + + await findRunnersLink().click(); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, runners, { label }); + }); }); }); diff --git a/spec/frontend/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js b/spec/frontend/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js index 3c8821d05a7..49177befe0e 100644 --- a/spec/frontend/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js +++ b/spec/frontend/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js @@ -1,9 +1,12 @@ import { getByRole } from '@testing-library/dom'; import { mount } from '@vue/test-utils'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import PipelineConfigReferenceCard from '~/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue'; +import { pipelineEditorTrackingOptions } from '~/pipeline_editor/constants'; describe('Pipeline config reference card', () => { let wrapper; + let trackingSpy; const defaultProvide = { ciExamplesHelpPagePath: 'help/ci/examples/', @@ -20,7 +23,7 @@ describe('Pipeline config reference card', () => { }); }; - const getLinkByName = (name) => getByRole(wrapper.element, 'link', { name }).href; + const getLinkByName = (name) => getByRole(wrapper.element, 'link', { name }); const findCiExamplesLink = () => getLinkByName(/CI\/CD examples and templates/i); const findCiIntroLink = () => getLinkByName(/GitLab CI\/CD concepts/i); const findNeedsLink = () => getLinkByName(/Needs keyword/i); @@ -43,9 +46,44 @@ describe('Pipeline config reference card', () => { }); it('renders the links', () => { - expect(findCiExamplesLink()).toContain(defaultProvide.ciExamplesHelpPagePath); - expect(findCiIntroLink()).toContain(defaultProvide.ciHelpPagePath); - expect(findNeedsLink()).toContain(defaultProvide.needsHelpPagePath); - expect(findYmlSyntaxLink()).toContain(defaultProvide.ymlHelpPagePath); + expect(findCiExamplesLink().href).toContain(defaultProvide.ciExamplesHelpPagePath); + expect(findCiIntroLink().href).toContain(defaultProvide.ciHelpPagePath); + expect(findNeedsLink().href).toContain(defaultProvide.needsHelpPagePath); + expect(findYmlSyntaxLink().href).toContain(defaultProvide.ymlHelpPagePath); + }); + + describe('tracking', () => { + beforeEach(() => { + createComponent(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + const testTracker = async (element, expectedAction) => { + const { label } = pipelineEditorTrackingOptions; + + await element.click(); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, expectedAction, { + label, + }); + }; + + it('tracks help page links', async () => { + const { + CI_EXAMPLES_LINK, + CI_HELP_LINK, + CI_NEEDS_LINK, + CI_YAML_LINK, + } = pipelineEditorTrackingOptions.actions.helpDrawerLinks; + + testTracker(findCiExamplesLink(), CI_EXAMPLES_LINK); + testTracker(findCiIntroLink(), CI_HELP_LINK); + testTracker(findNeedsLink(), CI_NEEDS_LINK); + testTracker(findYmlSyntaxLink(), CI_YAML_LINK); + }); }); }); diff --git a/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js b/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js index 8f50325295e..930f08ef545 100644 --- a/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js +++ b/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js @@ -29,6 +29,17 @@ describe('CI Editor Header', () => { unmockTracking(); }); + const testTracker = async (element, expectedAction) => { + const { label } = pipelineEditorTrackingOptions; + + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + await element.vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, expectedAction, { + label, + }); + }; + describe('link button', () => { beforeEach(() => { createComponent(); @@ -48,13 +59,9 @@ describe('CI Editor Header', () => { }); it('tracks the click on the browse button', async () => { - const { label, actions } = pipelineEditorTrackingOptions; - - await findLinkBtn().vm.$emit('click'); + const { browseTemplates } = pipelineEditorTrackingOptions.actions; - expect(trackingSpy).toHaveBeenCalledWith(undefined, actions.browse_templates, { - label, - }); + testTracker(findLinkBtn(), browseTemplates); }); }); @@ -72,21 +79,31 @@ describe('CI Editor Header', () => { }); describe('when pipeline editor drawer is closed', () => { - it('emits open drawer event when clicked', () => { + beforeEach(() => { createComponent({ showDrawer: false }); + }); + it('emits open drawer event when clicked', () => { expect(wrapper.emitted('open-drawer')).toBeUndefined(); findHelpBtn().vm.$emit('click'); expect(wrapper.emitted('open-drawer')).toHaveLength(1); }); + + it('tracks open help drawer action', async () => { + const { actions } = pipelineEditorTrackingOptions; + + testTracker(findHelpBtn(), actions.openHelpDrawer); + }); }); describe('when pipeline editor drawer is open', () => { - it('emits close drawer event when clicked', () => { + beforeEach(() => { createComponent({ showDrawer: true }); + }); + it('emits close drawer event when clicked', () => { expect(wrapper.emitted('close-drawer')).toBeUndefined(); findHelpBtn().vm.$emit('click'); diff --git a/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js b/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js index a61796dbed2..d503aff40b8 100644 --- a/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js +++ b/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js @@ -23,7 +23,6 @@ describe('Pipeline editor file nav', () => { const createComponent = ({ appStatus = EDITOR_APP_STATUS_VALID, isNewCiConfigFile = false, - pipelineEditorFileTree = false, } = {}) => { mockApollo.clients.defaultClient.cache.writeQuery({ query: getAppStatus, @@ -38,11 +37,6 @@ describe('Pipeline editor file nav', () => { wrapper = extendedWrapper( shallowMount(PipelineEditorFileNav, { apolloProvider: mockApollo, - provide: { - glFeatures: { - pipelineEditorFileTree, - }, - }, propsData: { isNewCiConfigFile, }, @@ -66,24 +60,12 @@ describe('Pipeline editor file nav', () => { it('renders the branch switcher', () => { expect(findBranchSwitcher().exists()).toBe(true); }); - - it('does not render the file tree button', () => { - expect(findFileTreeBtn().exists()).toBe(false); - }); - - it('does not render the file tree popover', () => { - expect(findPopoverContainer().exists()).toBe(false); - }); }); - describe('with pipelineEditorFileTree feature flag ON', () => { + describe('file tree', () => { describe('when editor is in the empty state', () => { beforeEach(() => { - createComponent({ - appStatus: EDITOR_APP_STATUS_EMPTY, - isNewCiConfigFile: false, - pipelineEditorFileTree: true, - }); + createComponent({ appStatus: EDITOR_APP_STATUS_EMPTY, isNewCiConfigFile: false }); }); it('does not render the file tree button', () => { @@ -97,11 +79,7 @@ describe('Pipeline editor file nav', () => { describe('when user is about to create their config file for the first time', () => { beforeEach(() => { - createComponent({ - appStatus: EDITOR_APP_STATUS_VALID, - isNewCiConfigFile: true, - pipelineEditorFileTree: true, - }); + createComponent({ appStatus: EDITOR_APP_STATUS_VALID, isNewCiConfigFile: true }); }); it('does not render the file tree button', () => { @@ -115,11 +93,7 @@ describe('Pipeline editor file nav', () => { describe('when app is in a global loading state', () => { it('renders the file tree button with a loading icon', () => { - createComponent({ - appStatus: EDITOR_APP_STATUS_LOADING, - isNewCiConfigFile: false, - pipelineEditorFileTree: true, - }); + createComponent({ appStatus: EDITOR_APP_STATUS_LOADING, isNewCiConfigFile: false }); expect(findFileTreeBtn().exists()).toBe(true); expect(findFileTreeBtn().attributes('loading')).toBe('true'); @@ -128,11 +102,7 @@ describe('Pipeline editor file nav', () => { describe('when editor has a non-empty config file open', () => { beforeEach(() => { - createComponent({ - appStatus: EDITOR_APP_STATUS_VALID, - isNewCiConfigFile: false, - pipelineEditorFileTree: true, - }); + createComponent({ appStatus: EDITOR_APP_STATUS_VALID, isNewCiConfigFile: false }); }); it('renders the file tree button', () => { diff --git a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js index d159a20a8d6..3ecf6472544 100644 --- a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js +++ b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js @@ -3,8 +3,9 @@ import { shallowMount, mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import setWindowLocation from 'helpers/set_window_location_helper'; import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue'; -import WalkthroughPopover from '~/pipeline_editor/components/popovers/walkthrough_popover.vue'; import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue'; +import CiValidate from '~/pipeline_editor/components/validate/ci_validate.vue'; +import WalkthroughPopover from '~/pipeline_editor/components/popovers/walkthrough_popover.vue'; import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue'; import { @@ -13,9 +14,7 @@ import { EDITOR_APP_STATUS_LOADING, EDITOR_APP_STATUS_INVALID, EDITOR_APP_STATUS_VALID, - MERGED_TAB, TAB_QUERY_PARAM, - TABS_INDEX, } from '~/pipeline_editor/constants'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import { mockLintResponse, mockLintResponseWithoutMerged, mockCiYml } from '../mock_data'; @@ -60,10 +59,12 @@ describe('Pipeline editor tabs component', () => { const findEditorTab = () => wrapper.find('[data-testid="editor-tab"]'); const findLintTab = () => wrapper.find('[data-testid="lint-tab"]'); const findMergedTab = () => wrapper.find('[data-testid="merged-tab"]'); + const findValidateTab = () => wrapper.find('[data-testid="validate-tab"]'); const findVisualizationTab = () => wrapper.find('[data-testid="visualization-tab"]'); const findAlert = () => wrapper.findComponent(GlAlert); const findCiLint = () => wrapper.findComponent(CiLint); + const findCiValidate = () => wrapper.findComponent(CiValidate); const findGlTabs = () => wrapper.findComponent(GlTabs); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findPipelineGraph = () => wrapper.findComponent(PipelineGraph); @@ -111,6 +112,61 @@ describe('Pipeline editor tabs component', () => { }); }); + describe('validate tab', () => { + describe('with simulatePipeline feature flag ON', () => { + describe('while loading', () => { + beforeEach(() => { + createComponent({ + appStatus: EDITOR_APP_STATUS_LOADING, + provide: { + glFeatures: { + simulatePipeline: true, + }, + }, + }); + }); + + it('displays a loading icon if the lint query is loading', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not display the validate component', () => { + expect(findCiValidate().exists()).toBe(false); + }); + }); + + describe('after loading', () => { + beforeEach(() => { + createComponent({ + provide: { glFeatures: { simulatePipeline: true } }, + }); + }); + + it('displays the tab and the validate component', () => { + expect(findValidateTab().exists()).toBe(true); + expect(findCiValidate().exists()).toBe(true); + }); + }); + }); + + describe('with simulatePipeline feature flag OFF', () => { + beforeEach(() => { + createComponent({ + provide: { + glFeatures: { + simulatePipeline: false, + }, + }, + }); + }); + + it('does not render the tab and the validate component', () => { + expect(findValidateTab().exists()).toBe(false); + expect(findCiValidate().exists()).toBe(false); + }); + }); + }); + describe('lint tab', () => { describe('while loading', () => { beforeEach(() => { @@ -125,6 +181,7 @@ describe('Pipeline editor tabs component', () => { expect(findCiLint().exists()).toBe(false); }); }); + describe('after loading', () => { beforeEach(() => { createComponent(); @@ -135,8 +192,24 @@ describe('Pipeline editor tabs component', () => { expect(findCiLint().exists()).toBe(true); }); }); - }); + describe('with simulatePipeline feature flag ON', () => { + beforeEach(() => { + createComponent({ + provide: { + glFeatures: { + simulatePipeline: true, + }, + }, + }); + }); + + it('does not render the tab and the lint component', () => { + expect(findLintTab().exists()).toBe(false); + expect(findCiLint().exists()).toBe(false); + }); + }); + }); describe('merged tab', () => { describe('while loading', () => { beforeEach(() => { @@ -221,18 +294,6 @@ describe('Pipeline editor tabs component', () => { search: `?${TAB_QUERY_PARAM}=${queryValue}`, }); }); - - it('is the tab specified in query param and transform it into an index value', async () => { - setWindowLocation(`${gitlabUrl}?${TAB_QUERY_PARAM}=${MERGED_TAB}`); - createComponent(); - - // If the query param has changed to an index, it means we have synced the - // query with. - expect(window.location).toMatchObject({ - ...matchObject, - search: `?${TAB_QUERY_PARAM}=${TABS_INDEX[MERGED_TAB]}`, - }); - }); }); describe('glTabs', () => { diff --git a/spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js b/spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js new file mode 100644 index 00000000000..25972317593 --- /dev/null +++ b/spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js @@ -0,0 +1,40 @@ +import { GlButton, GlDropdown } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import CiValidate, { i18n } from '~/pipeline_editor/components/validate/ci_validate.vue'; + +describe('Pipeline Editor Validate Tab', () => { + let wrapper; + + const createComponent = ({ stubs } = {}) => { + wrapper = shallowMount(CiValidate, { + provide: { + validateTabIllustrationPath: '/path/to/img', + }, + stubs, + }); + }; + + const findCta = () => wrapper.findComponent(GlButton); + const findPipelineSource = () => wrapper.findComponent(GlDropdown); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders disabled pipeline source dropdown', () => { + expect(findPipelineSource().exists()).toBe(true); + expect(findPipelineSource().attributes('text')).toBe(i18n.pipelineSourceDefault); + expect(findPipelineSource().attributes('disabled')).toBe('true'); + }); + + it('renders CTA', () => { + expect(findCta().exists()).toBe(true); + expect(findCta().text()).toBe(i18n.cta); + }); + }); +}); diff --git a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js index bf0f7fd8c9f..c6964f190b4 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js @@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { GlButton, GlDrawer, GlModal } from '@gitlab/ui'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import setWindowLocation from 'helpers/set_window_location_helper'; import CiEditorHeader from '~/pipeline_editor/components/editor/ci_editor_header.vue'; import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue'; import PipelineEditorDrawer from '~/pipeline_editor/components/drawer/pipeline_editor_drawer.vue'; @@ -11,11 +12,12 @@ import BranchSwitcher from '~/pipeline_editor/components/file_nav/branch_switche import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue'; import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; import { - MERGED_TAB, - VISUALIZE_TAB, CREATE_TAB, - LINT_TAB, FILE_TREE_DISPLAY_KEY, + LINT_TAB, + MERGED_TAB, + TABS_INDEX, + VISUALIZE_TAB, } from '~/pipeline_editor/constants'; import PipelineEditorHome from '~/pipeline_editor/pipeline_editor_home.vue'; @@ -162,6 +164,24 @@ describe('Pipeline editor home wrapper', () => { await nextTick(); expect(findCommitSection().exists()).toBe(true); }); + + describe('rendering with tab params', () => { + it.each` + tab | shouldShow + ${MERGED_TAB} | ${false} + ${VISUALIZE_TAB} | ${false} + ${LINT_TAB} | ${false} + ${CREATE_TAB} | ${true} + `( + 'when the tab query param is $tab the commit form is shown: $shouldShow', + async ({ tab, shouldShow }) => { + setWindowLocation(`https://gitlab.test/ci/editor/?tab=${TABS_INDEX[tab]}`); + await createComponent({ stubs: { PipelineEditorTabs } }); + + expect(findCommitSection().exists()).toBe(shouldShow); + }, + ); + }); }); describe('WalkthroughPopover events', () => { @@ -247,81 +267,63 @@ describe('Pipeline editor home wrapper', () => { await nextTick(); }; - describe('with pipelineEditorFileTree feature flag OFF', () => { + describe('button toggle', () => { beforeEach(() => { - createComponent(); + createComponent({ + stubs: { + GlButton, + PipelineEditorFileNav, + }, + }); }); - it('hides the file tree', () => { - expect(findFileTreeBtn().exists()).toBe(false); - expect(findPipelineEditorFileTree().exists()).toBe(false); + it('shows button toggle', () => { + expect(findFileTreeBtn().exists()).toBe(true); }); - }); - - describe('with pipelineEditorFileTree feature flag ON', () => { - describe('button toggle', () => { - beforeEach(() => { - createComponent({ - glFeatures: { - pipelineEditorFileTree: true, - }, - stubs: { - GlButton, - PipelineEditorFileNav, - }, - }); - }); - - it('shows button toggle', () => { - expect(findFileTreeBtn().exists()).toBe(true); - }); - it('toggles the drawer on button click', async () => { - await toggleFileTree(); + it('toggles the drawer on button click', async () => { + await toggleFileTree(); - expect(findPipelineEditorFileTree().exists()).toBe(true); + expect(findPipelineEditorFileTree().exists()).toBe(true); - await toggleFileTree(); + await toggleFileTree(); - expect(findPipelineEditorFileTree().exists()).toBe(false); - }); + expect(findPipelineEditorFileTree().exists()).toBe(false); + }); - it('sets the display state in local storage', async () => { - await toggleFileTree(); + it('sets the display state in local storage', async () => { + await toggleFileTree(); - expect(localStorage.getItem(FILE_TREE_DISPLAY_KEY)).toBe('true'); + expect(localStorage.getItem(FILE_TREE_DISPLAY_KEY)).toBe('true'); - await toggleFileTree(); + await toggleFileTree(); - expect(localStorage.getItem(FILE_TREE_DISPLAY_KEY)).toBe('false'); - }); + expect(localStorage.getItem(FILE_TREE_DISPLAY_KEY)).toBe('false'); }); + }); - describe('when file tree display state is saved in local storage', () => { - beforeEach(() => { - localStorage.setItem(FILE_TREE_DISPLAY_KEY, 'true'); - createComponent({ - glFeatures: { pipelineEditorFileTree: true }, - stubs: { PipelineEditorFileNav }, - }); + describe('when file tree display state is saved in local storage', () => { + beforeEach(() => { + localStorage.setItem(FILE_TREE_DISPLAY_KEY, 'true'); + createComponent({ + stubs: { PipelineEditorFileNav }, }); + }); - it('shows the file tree by default', () => { - expect(findPipelineEditorFileTree().exists()).toBe(true); - }); + it('shows the file tree by default', () => { + expect(findPipelineEditorFileTree().exists()).toBe(true); }); + }); - describe('when file tree display state is not saved in local storage', () => { - beforeEach(() => { - createComponent({ - glFeatures: { pipelineEditorFileTree: true }, - stubs: { PipelineEditorFileNav }, - }); + describe('when file tree display state is not saved in local storage', () => { + beforeEach(() => { + createComponent({ + stubs: { PipelineEditorFileNav }, }); + }); - it('hides the file tree by default', () => { - expect(findPipelineEditorFileTree().exists()).toBe(false); - }); + it('hides the file tree by default', () => { + expect(findPipelineEditorFileTree().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/pipeline_wizard/components/input_spec.js b/spec/frontend/pipeline_wizard/components/input_wrapper_spec.js index ee1f3fe70ff..ea2448b1362 100644 --- a/spec/frontend/pipeline_wizard/components/input_spec.js +++ b/spec/frontend/pipeline_wizard/components/input_wrapper_spec.js @@ -1,6 +1,6 @@ import { mount, shallowMount } from '@vue/test-utils'; import { Document } from 'yaml'; -import InputWrapper from '~/pipeline_wizard/components/input.vue'; +import InputWrapper from '~/pipeline_wizard/components/input_wrapper.vue'; import TextWidget from '~/pipeline_wizard/components/widgets/text.vue'; describe('Pipeline Wizard -- Input Wrapper', () => { diff --git a/spec/frontend/pipeline_wizard/components/step_spec.js b/spec/frontend/pipeline_wizard/components/step_spec.js index 2289a349318..aa87b1d0b04 100644 --- a/spec/frontend/pipeline_wizard/components/step_spec.js +++ b/spec/frontend/pipeline_wizard/components/step_spec.js @@ -3,7 +3,7 @@ import { omit } from 'lodash'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import PipelineWizardStep from '~/pipeline_wizard/components/step.vue'; -import InputWrapper from '~/pipeline_wizard/components/input.vue'; +import InputWrapper from '~/pipeline_wizard/components/input_wrapper.vue'; import StepNav from '~/pipeline_wizard/components/step_nav.vue'; import { stepInputs, diff --git a/spec/frontend/pipeline_wizard/components/widgets_spec.js b/spec/frontend/pipeline_wizard/components/widgets_spec.js index 5944c76c5d0..6bd858e746c 100644 --- a/spec/frontend/pipeline_wizard/components/widgets_spec.js +++ b/spec/frontend/pipeline_wizard/components/widgets_spec.js @@ -1,7 +1,7 @@ import fs from 'fs'; import { mount } from '@vue/test-utils'; import { Document } from 'yaml'; -import InputWrapper from '~/pipeline_wizard/components/input.vue'; +import InputWrapper from '~/pipeline_wizard/components/input_wrapper.vue'; describe('Test all widgets in ./widgets/* whether they provide a minimal api', () => { const createComponent = (props = {}, mountFunc = mount) => { diff --git a/spec/frontend/pipelines/components/pipeline_tabs_spec.js b/spec/frontend/pipelines/components/pipeline_tabs_spec.js index 89002ee47a8..e0210307823 100644 --- a/spec/frontend/pipelines/components/pipeline_tabs_spec.js +++ b/spec/frontend/pipelines/components/pipeline_tabs_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { GlTab } from '@gitlab/ui'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import PipelineTabs from '~/pipelines/components/pipeline_tabs.vue'; import PipelineGraphWrapper from '~/pipelines/components/graph/graph_component_wrapper.vue'; @@ -21,35 +22,35 @@ describe('The Pipeline Tabs', () => { const findPipelineApp = () => wrapper.findComponent(PipelineGraphWrapper); const findTestsApp = () => wrapper.findComponent(TestReports); + const findFailedJobsBadge = () => wrapper.findByTestId('failed-builds-counter'); + const findJobsBadge = () => wrapper.findByTestId('builds-counter'); + const defaultProvide = { defaultTabValue: '', + failedJobsCount: 1, + failedJobsSummary: [], + totalJobCount: 10, }; - const createComponent = (propsData = {}) => { + const createComponent = (provide = {}) => { wrapper = extendedWrapper( shallowMount(PipelineTabs, { - propsData, provide: { ...defaultProvide, + ...provide, }, stubs: { - JobsApp: { template: '<div class="jobs" />' }, + GlTab, TestReports: { template: '<div id="tests" />' }, }, }), ); }; - beforeEach(() => { - createComponent(); - }); - afterEach(() => { wrapper.destroy(); }); - // The failed jobs MUST be removed from here and tested individually once - // the logic for the tab is implemented. describe('Tabs', () => { it.each` tabName | tabComponent | appComponent @@ -58,9 +59,34 @@ describe('The Pipeline Tabs', () => { ${'Jobs'} | ${findJobsTab} | ${findJobsApp} ${'Failed Jobs'} | ${findFailedJobsTab} | ${findFailedJobsApp} ${'Tests'} | ${findTestsTab} | ${findTestsApp} - `('shows $tabName tab and its associated component', ({ appComponent, tabComponent }) => { + `('shows $tabName tab with its associated component', ({ appComponent, tabComponent }) => { + createComponent(); + expect(tabComponent().exists()).toBe(true); expect(appComponent().exists()).toBe(true); }); + + describe('with no failed jobs', () => { + beforeEach(() => { + createComponent({ failedJobsCount: 0 }); + }); + + it('hides the failed jobs tab', () => { + expect(findFailedJobsTab().exists()).toBe(false); + }); + }); + }); + + describe('Tabs badges', () => { + it.each` + tabName | badgeComponent | badgeText + ${'Jobs'} | ${findJobsBadge} | ${String(defaultProvide.totalJobCount)} + ${'Failed Jobs'} | ${findFailedJobsBadge} | ${String(defaultProvide.failedJobsCount)} + `('shows badge for $tabName with the correct text', ({ badgeComponent, badgeText }) => { + createComponent(); + + expect(badgeComponent().exists()).toBe(true); + expect(badgeComponent().text()).toBe(badgeText); + }); }); }); diff --git a/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js b/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js index 6d0e99ff63e..1ff32b03344 100644 --- a/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js +++ b/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js @@ -5,6 +5,7 @@ import CiIcon from '~/vue_shared/components/ci_icon.vue'; import axios from '~/lib/utils/axios_utils'; import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue'; import eventHub from '~/pipelines/event_hub'; +import waitForPromises from 'helpers/wait_for_promises'; import { stageReply } from '../../mock_data'; const dropdownPath = 'path.json'; @@ -55,7 +56,10 @@ describe('Pipelines stage component', () => { const findDropdownToggle = () => wrapper.find('button.dropdown-toggle'); const findDropdownMenu = () => wrapper.find('[data-testid="mini-pipeline-graph-dropdown-menu-list"]'); + const findDropdownMenuTitle = () => + wrapper.find('[data-testid="pipeline-stage-dropdown-menu-title"]'); const findMergeTrainWarning = () => wrapper.find('[data-testid="warning-message-merge-trains"]'); + const findLoadingState = () => wrapper.find('[data-testid="pipeline-stage-loading-state"]'); const openStageDropdown = () => { findDropdownToggle().trigger('click'); @@ -64,6 +68,27 @@ describe('Pipelines stage component', () => { }); }; + describe('loading state', () => { + beforeEach(async () => { + createComponent({ updateDropdown: true }); + + mock.onGet(dropdownPath).reply(200, stageReply); + + await openStageDropdown(); + }); + + it('displays loading state while jobs are being fetched', () => { + expect(findLoadingState().exists()).toBe(true); + expect(findLoadingState().text()).toBe(PipelineStage.i18n.loadingText); + }); + + it('does not display loading state after jobs have been fetched', async () => { + await waitForPromises(); + + expect(findLoadingState().exists()).toBe(false); + }); + }); + describe('default appearance', () => { beforeEach(() => { createComponent(); @@ -78,6 +103,17 @@ describe('Pipelines stage component', () => { expect(findDropdownToggle().exists()).toBe(true); expect(findCiIcon().exists()).toBe(true); }); + + it('should render a borderless ci-icon', () => { + expect(findCiIcon().exists()).toBe(true); + expect(findCiIcon().props('isBorderless')).toBe(true); + expect(findCiIcon().classes('borderless')).toBe(true); + }); + + it('should render a ci-icon with a custom border class', () => { + expect(findCiIcon().exists()).toBe(true); + expect(findCiIcon().classes('gl-border')).toBe(true); + }); }); describe('when update dropdown is changed', () => { @@ -97,6 +133,7 @@ describe('Pipelines stage component', () => { it('should render the received data and emit `clickedDropdown` event', async () => { expect(findDropdownMenu().text()).toContain(stageReply.latest_statuses[0].name); + expect(findDropdownMenuTitle().text()).toContain(stageReply.name); expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown'); }); diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js index 06fd970778c..fd97c2dbe77 100644 --- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js @@ -47,17 +47,12 @@ describe('Linked pipeline', () => { const findPipelineLink = () => wrapper.findByTestId('pipelineLink'); const findRetryButton = () => wrapper.findByLabelText('Retry downstream pipeline'); - const createWrapper = ({ propsData, downstreamRetryAction = false }) => { + const createWrapper = ({ propsData }) => { const mockApollo = createMockApollo(); wrapper = extendedWrapper( mount(LinkedPipelineComponent, { propsData, - provide: { - glFeatures: { - downstreamRetryAction, - }, - }, apolloProvider: mockApollo, }), ); @@ -164,197 +159,188 @@ describe('Linked pipeline', () => { }); describe('action button', () => { - describe('with the `downstream_retry_action` flag on', () => { - describe('with permissions', () => { - describe('on an upstream', () => { - describe('when retryable', () => { - beforeEach(() => { - const retryablePipeline = { - ...upstreamProps, - pipeline: { ...mockPipeline, retryable: true }, - }; - - createWrapper({ propsData: retryablePipeline, downstreamRetryAction: true }); - }); + describe('with permissions', () => { + describe('on an upstream', () => { + describe('when retryable', () => { + beforeEach(() => { + const retryablePipeline = { + ...upstreamProps, + pipeline: { ...mockPipeline, retryable: true }, + }; + + createWrapper({ propsData: retryablePipeline }); + }); - it('does not show the retry or cancel button', () => { - expect(findCancelButton().exists()).toBe(false); - expect(findRetryButton().exists()).toBe(false); - }); + it('does not show the retry or cancel button', () => { + expect(findCancelButton().exists()).toBe(false); + expect(findRetryButton().exists()).toBe(false); }); }); + }); - describe('on a downstream', () => { - describe('when retryable', () => { - beforeEach(() => { - const retryablePipeline = { - ...downstreamProps, - pipeline: { ...mockPipeline, retryable: true }, - }; + describe('on a downstream', () => { + describe('when retryable', () => { + beforeEach(() => { + const retryablePipeline = { + ...downstreamProps, + pipeline: { ...mockPipeline, retryable: true }, + }; - createWrapper({ propsData: retryablePipeline, downstreamRetryAction: true }); - }); + createWrapper({ propsData: retryablePipeline }); + }); - it('shows only the retry button', () => { - expect(findCancelButton().exists()).toBe(false); - expect(findRetryButton().exists()).toBe(true); - }); + it('shows only the retry button', () => { + expect(findCancelButton().exists()).toBe(false); + expect(findRetryButton().exists()).toBe(true); + }); - it('hides the card tooltip when the action button tooltip is hovered', async () => { - expect(findCardTooltip().exists()).toBe(true); + it.each` + findElement | name + ${findRetryButton} | ${'retry button'} + ${findExpandButton} | ${'expand button'} + `('hides the card tooltip when $name is hovered', async ({ findElement }) => { + expect(findCardTooltip().exists()).toBe(true); - await findRetryButton().trigger('mouseover'); + await findElement().trigger('mouseover'); - expect(findCardTooltip().exists()).toBe(false); - }); + expect(findCardTooltip().exists()).toBe(false); + }); - describe('and the retry button is clicked', () => { - describe('on success', () => { - beforeEach(async () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); - jest.spyOn(wrapper.vm, '$emit'); - await findRetryButton().trigger('click'); - }); + describe('and the retry button is clicked', () => { + describe('on success', () => { + beforeEach(async () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); + jest.spyOn(wrapper.vm, '$emit'); + await findRetryButton().trigger('click'); + }); - it('calls the retry mutation ', () => { - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: RetryPipelineMutation, - variables: { - id: convertToGraphQLId(PIPELINE_GRAPHQL_TYPE, mockPipeline.id), - }, - }); + it('calls the retry mutation ', () => { + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: RetryPipelineMutation, + variables: { + id: convertToGraphQLId(PIPELINE_GRAPHQL_TYPE, mockPipeline.id), + }, }); + }); - it('emits the refreshPipelineGraph event', () => { - expect(wrapper.vm.$emit).toHaveBeenCalledWith('refreshPipelineGraph'); - }); + it('emits the refreshPipelineGraph event', () => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('refreshPipelineGraph'); }); + }); - describe('on failure', () => { - beforeEach(async () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({ errors: [] }); - jest.spyOn(wrapper.vm, '$emit'); - await findRetryButton().trigger('click'); - }); + describe('on failure', () => { + beforeEach(async () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({ errors: [] }); + jest.spyOn(wrapper.vm, '$emit'); + await findRetryButton().trigger('click'); + }); - it('emits an error event', () => { - expect(wrapper.vm.$emit).toHaveBeenCalledWith('error', { - type: ACTION_FAILURE, - }); + it('emits an error event', () => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('error', { + type: ACTION_FAILURE, }); }); }); }); + }); - describe('when cancelable', () => { - beforeEach(() => { - const cancelablePipeline = { - ...downstreamProps, - pipeline: { ...mockPipeline, cancelable: true }, - }; + describe('when cancelable', () => { + beforeEach(() => { + const cancelablePipeline = { + ...downstreamProps, + pipeline: { ...mockPipeline, cancelable: true }, + }; - createWrapper({ propsData: cancelablePipeline, downstreamRetryAction: true }); - }); + createWrapper({ propsData: cancelablePipeline }); + }); - it('shows only the cancel button ', () => { - expect(findCancelButton().exists()).toBe(true); - expect(findRetryButton().exists()).toBe(false); - }); + it('shows only the cancel button ', () => { + expect(findCancelButton().exists()).toBe(true); + expect(findRetryButton().exists()).toBe(false); + }); - it('hides the card tooltip when the action button tooltip is hovered', async () => { - expect(findCardTooltip().exists()).toBe(true); + it.each` + findElement | name + ${findCancelButton} | ${'cancel button'} + ${findExpandButton} | ${'expand button'} + `('hides the card tooltip when $name is hovered', async ({ findElement }) => { + expect(findCardTooltip().exists()).toBe(true); - await findCancelButton().trigger('mouseover'); + await findElement().trigger('mouseover'); - expect(findCardTooltip().exists()).toBe(false); - }); + expect(findCardTooltip().exists()).toBe(false); + }); - describe('and the cancel button is clicked', () => { - describe('on success', () => { - beforeEach(async () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); - jest.spyOn(wrapper.vm, '$emit'); - await findCancelButton().trigger('click'); - }); + describe('and the cancel button is clicked', () => { + describe('on success', () => { + beforeEach(async () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); + jest.spyOn(wrapper.vm, '$emit'); + await findCancelButton().trigger('click'); + }); - it('calls the cancel mutation', () => { - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: CancelPipelineMutation, - variables: { - id: convertToGraphQLId(PIPELINE_GRAPHQL_TYPE, mockPipeline.id), - }, - }); - }); - it('emits the refreshPipelineGraph event', () => { - expect(wrapper.vm.$emit).toHaveBeenCalledWith('refreshPipelineGraph'); + it('calls the cancel mutation', () => { + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: CancelPipelineMutation, + variables: { + id: convertToGraphQLId(PIPELINE_GRAPHQL_TYPE, mockPipeline.id), + }, }); }); - describe('on failure', () => { - beforeEach(async () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({ errors: [] }); - jest.spyOn(wrapper.vm, '$emit'); - await findCancelButton().trigger('click'); - }); - it('emits an error event', () => { - expect(wrapper.vm.$emit).toHaveBeenCalledWith('error', { - type: ACTION_FAILURE, - }); + it('emits the refreshPipelineGraph event', () => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('refreshPipelineGraph'); + }); + }); + describe('on failure', () => { + beforeEach(async () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({ errors: [] }); + jest.spyOn(wrapper.vm, '$emit'); + await findCancelButton().trigger('click'); + }); + it('emits an error event', () => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('error', { + type: ACTION_FAILURE, }); }); }); }); + }); - describe('when both cancellable and retryable', () => { - beforeEach(() => { - const pipelineWithTwoActions = { - ...downstreamProps, - pipeline: { ...mockPipeline, cancelable: true, retryable: true }, - }; - - createWrapper({ propsData: pipelineWithTwoActions, downstreamRetryAction: true }); - }); + describe('when both cancellable and retryable', () => { + beforeEach(() => { + const pipelineWithTwoActions = { + ...downstreamProps, + pipeline: { ...mockPipeline, cancelable: true, retryable: true }, + }; - it('only shows the cancel button', () => { - expect(findRetryButton().exists()).toBe(false); - expect(findCancelButton().exists()).toBe(true); - }); + createWrapper({ propsData: pipelineWithTwoActions }); }); - }); - }); - - describe('without permissions', () => { - beforeEach(() => { - const pipelineWithTwoActions = { - ...downstreamProps, - pipeline: { - ...mockPipeline, - cancelable: true, - retryable: true, - userPermissions: { updatePipeline: false }, - }, - }; - - createWrapper({ propsData: pipelineWithTwoActions }); - }); - it('does not show any action button', () => { - expect(findRetryButton().exists()).toBe(false); - expect(findCancelButton().exists()).toBe(false); + it('only shows the cancel button', () => { + expect(findRetryButton().exists()).toBe(false); + expect(findCancelButton().exists()).toBe(true); + }); }); }); }); - describe('with the `downstream_retry_action` flag off', () => { + describe('without permissions', () => { beforeEach(() => { const pipelineWithTwoActions = { ...downstreamProps, - pipeline: { ...mockPipeline, cancelable: true, retryable: true }, + pipeline: { + ...mockPipeline, + cancelable: true, + retryable: true, + userPermissions: { updatePipeline: false }, + }, }; createWrapper({ propsData: pipelineWithTwoActions }); }); + it('does not show any action button', () => { expect(findRetryButton().exists()).toBe(false); expect(findCancelButton().exists()).toBe(false); @@ -365,19 +351,44 @@ describe('Linked pipeline', () => { describe('expand button', () => { it.each` - pipelineType | anglePosition | buttonBorderClasses | expanded - ${downstreamProps} | ${'angle-right'} | ${'gl-border-l-0!'} | ${false} - ${downstreamProps} | ${'angle-left'} | ${'gl-border-l-0!'} | ${true} - ${upstreamProps} | ${'angle-left'} | ${'gl-border-r-0!'} | ${false} - ${upstreamProps} | ${'angle-right'} | ${'gl-border-r-0!'} | ${true} + pipelineType | chevronPosition | buttonBorderClasses | expanded + ${downstreamProps} | ${'chevron-lg-right'} | ${'gl-border-l-0!'} | ${false} + ${downstreamProps} | ${'chevron-lg-left'} | ${'gl-border-l-0!'} | ${true} + ${upstreamProps} | ${'chevron-lg-left'} | ${'gl-border-r-0!'} | ${false} + ${upstreamProps} | ${'chevron-lg-right'} | ${'gl-border-r-0!'} | ${true} `( - '$pipelineType.columnTitle pipeline button icon should be $anglePosition with $buttonBorderClasses if expanded state is $expanded', - ({ pipelineType, anglePosition, buttonBorderClasses, expanded }) => { + '$pipelineType.columnTitle pipeline button icon should be $chevronPosition with $buttonBorderClasses if expanded state is $expanded', + ({ pipelineType, chevronPosition, buttonBorderClasses, expanded }) => { createWrapper({ propsData: { ...pipelineType, expanded } }); - expect(findExpandButton().props('icon')).toBe(anglePosition); + expect(findExpandButton().props('icon')).toBe(chevronPosition); expect(findExpandButton().classes()).toContain(buttonBorderClasses); }, ); + + describe('shadow border', () => { + beforeEach(() => { + createWrapper({ propsData: downstreamProps }); + }); + + it.each` + activateEventName | deactivateEventName + ${'mouseover'} | ${'mouseout'} + ${'focus'} | ${'blur'} + `( + 'applies the class on $activateEventName and removes it on $deactivateEventName ', + async ({ activateEventName, deactivateEventName }) => { + const shadowClass = 'gl-shadow-none!'; + + expect(findExpandButton().classes()).toContain(shadowClass); + + await findExpandButton().vm.$emit(activateEventName); + expect(findExpandButton().classes()).not.toContain(shadowClass); + + await findExpandButton().vm.$emit(deactivateEventName); + expect(findExpandButton().classes()).toContain(shadowClass); + }, + ); + }); }); describe('when isLoading is true', () => { diff --git a/spec/frontend/pipelines/notification/deprecated_type_keyword_notification_spec.js b/spec/frontend/pipelines/notification/deprecated_type_keyword_notification_spec.js deleted file mode 100644 index f626652a944..00000000000 --- a/spec/frontend/pipelines/notification/deprecated_type_keyword_notification_spec.js +++ /dev/null @@ -1,146 +0,0 @@ -import VueApollo from 'vue-apollo'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import { GlAlert, GlSprintf } from '@gitlab/ui'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import DeprecatedTypeKeywordNotification from '~/pipelines/components/notification/deprecated_type_keyword_notification.vue'; -import getPipelineWarnings from '~/pipelines/graphql/queries/get_pipeline_warnings.query.graphql'; -import { - mockWarningsWithoutDeprecation, - mockWarningsRootType, - mockWarningsType, - mockWarningsTypesAll, -} from './mock_data'; - -const defaultProvide = { - deprecatedKeywordsDocPath: '/help/ci/yaml/index.md#deprecated-keywords', - fullPath: '/namespace/my-project', - pipelineIid: 4, -}; - -let wrapper; - -const mockWarnings = jest.fn(); - -const createComponent = ({ isLoading = false, options = {} } = {}) => { - return shallowMount(DeprecatedTypeKeywordNotification, { - stubs: { - GlSprintf, - }, - provide: { - ...defaultProvide, - }, - mocks: { - $apollo: { - queries: { - warnings: { - loading: isLoading, - }, - }, - }, - }, - ...options, - }); -}; - -const createComponentWithApollo = () => { - const localVue = createLocalVue(); - localVue.use(VueApollo); - - const handlers = [[getPipelineWarnings, mockWarnings]]; - const mockApollo = createMockApollo(handlers); - - return createComponent({ - options: { - localVue, - apolloProvider: mockApollo, - mocks: {}, - }, - }); -}; - -const findAlert = () => wrapper.findComponent(GlAlert); -const findAlertItems = () => findAlert().findAll('li'); - -afterEach(() => { - wrapper.destroy(); -}); - -describe('Deprecated keyword notification', () => { - describe('while loading the pipeline warnings', () => { - beforeEach(() => { - wrapper = createComponent({ isLoading: true }); - }); - - it('does not display the notification', () => { - expect(findAlert().exists()).toBe(false); - }); - }); - - describe('if there is an error in the query', () => { - beforeEach(async () => { - mockWarnings.mockResolvedValue({ errors: ['It didnt work'] }); - wrapper = createComponentWithApollo(); - await waitForPromises(); - }); - - it('does not display the notification', () => { - expect(findAlert().exists()).toBe(false); - }); - }); - - describe('with a valid query result', () => { - describe('if there are no deprecation warnings', () => { - beforeEach(async () => { - mockWarnings.mockResolvedValue(mockWarningsWithoutDeprecation); - wrapper = createComponentWithApollo(); - await waitForPromises(); - }); - it('does not show the notification', () => { - expect(findAlert().exists()).toBe(false); - }); - }); - - describe('with a root type deprecation message', () => { - beforeEach(async () => { - mockWarnings.mockResolvedValue(mockWarningsRootType); - wrapper = createComponentWithApollo(); - await waitForPromises(); - }); - it('shows the notification with one item', () => { - expect(findAlert().exists()).toBe(true); - expect(findAlertItems()).toHaveLength(1); - expect(findAlertItems().at(0).text()).toContain('types'); - }); - }); - - describe('with a job type deprecation message', () => { - beforeEach(async () => { - mockWarnings.mockResolvedValue(mockWarningsType); - wrapper = createComponentWithApollo(); - await waitForPromises(); - }); - it('shows the notification with one item', () => { - expect(findAlert().exists()).toBe(true); - expect(findAlertItems()).toHaveLength(1); - expect(findAlertItems().at(0).text()).toContain('type'); - expect(findAlertItems().at(0).text()).not.toContain('types'); - }); - }); - - describe('with both the root types and job type deprecation message', () => { - beforeEach(async () => { - mockWarnings.mockResolvedValue(mockWarningsTypesAll); - wrapper = createComponentWithApollo(); - await waitForPromises(); - }); - it('shows the notification with two items', () => { - expect(findAlert().exists()).toBe(true); - expect(findAlertItems()).toHaveLength(2); - expect(findAlertItems().at(0).text()).toContain('types'); - expect(findAlertItems().at(1).text()).toContain('type'); - expect(findAlertItems().at(1).text()).not.toContain('types'); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/pipeline_tabs_spec.js b/spec/frontend/pipelines/pipeline_tabs_spec.js new file mode 100644 index 00000000000..b184ce31d20 --- /dev/null +++ b/spec/frontend/pipelines/pipeline_tabs_spec.js @@ -0,0 +1,95 @@ +import { createAppOptions, createPipelineTabs } from '~/pipelines/pipeline_tabs'; +import { updateHistory } from '~/lib/utils/url_utility'; + +jest.mock('~/lib/utils/url_utility', () => ({ + removeParams: () => 'gitlab.com', + updateHistory: jest.fn(), + joinPaths: () => {}, + setUrlFragment: () => {}, +})); + +jest.mock('~/pipelines/utils', () => ({ + getPipelineDefaultTab: () => '', +})); + +describe('~/pipelines/pipeline_tabs.js', () => { + describe('createAppOptions', () => { + const SELECTOR = 'SELECTOR'; + + let el; + + const createElement = () => { + el = document.createElement('div'); + el.id = SELECTOR; + el.dataset.canGenerateCodequalityReports = 'true'; + el.dataset.codequalityReportDownloadPath = 'codequalityReportDownloadPath'; + el.dataset.downloadablePathForReportType = 'downloadablePathForReportType'; + el.dataset.exposeSecurityDashboard = 'true'; + el.dataset.exposeLicenseScanningData = 'true'; + el.dataset.failedJobsCount = 1; + el.dataset.failedJobsSummary = '[]'; + el.dataset.graphqlResourceEtag = 'graphqlResourceEtag'; + el.dataset.pipelineIid = '123'; + el.dataset.pipelineProjectPath = 'pipelineProjectPath'; + + document.body.appendChild(el); + }; + + afterEach(() => { + el = null; + }); + + it("extracts the properties from the element's dataset", () => { + createElement(); + const options = createAppOptions(`#${SELECTOR}`, null); + + expect(options).toMatchObject({ + el, + provide: { + canGenerateCodequalityReports: true, + codequalityReportDownloadPath: 'codequalityReportDownloadPath', + downloadablePathForReportType: 'downloadablePathForReportType', + exposeSecurityDashboard: true, + exposeLicenseScanningData: true, + failedJobsCount: '1', + failedJobsSummary: [], + graphqlResourceEtag: 'graphqlResourceEtag', + pipelineIid: '123', + pipelineProjectPath: 'pipelineProjectPath', + }, + }); + }); + + it('returns `null` if el does not exist', () => { + expect(createAppOptions('foo', null)).toBe(null); + }); + }); + + describe('createPipelineTabs', () => { + const title = 'Pipeline Tabs'; + + beforeAll(() => { + document.title = title; + }); + + afterAll(() => { + document.title = ''; + }); + + it('calls `updateHistory` with correct params', () => { + createPipelineTabs({}); + + expect(updateHistory).toHaveBeenCalledWith({ + title, + url: 'gitlab.com', + replace: true, + }); + }); + + it("returns early if options aren't provided", () => { + createPipelineTabs(); + + expect(updateHistory).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/test_case_details_spec.js b/spec/frontend/pipelines/test_reports/test_case_details_spec.js index 4b33c1522a5..29c07e5e9f8 100644 --- a/spec/frontend/pipelines/test_reports/test_case_details_spec.js +++ b/spec/frontend/pipelines/test_reports/test_case_details_spec.js @@ -1,4 +1,4 @@ -import { GlModal } from '@gitlab/ui'; +import { GlModal, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import TestCaseDetails from '~/pipelines/components/test_reports/test_case_details.vue'; @@ -9,6 +9,8 @@ describe('Test case details', () => { const defaultTestCase = { classname: 'spec.test_spec', name: 'Test#something cool', + file: '~/index.js', + filePath: '/src/javascripts/index.js', formattedTime: '10.04ms', recent_failures: { count: 2, @@ -19,6 +21,8 @@ describe('Test case details', () => { const findModal = () => wrapper.findComponent(GlModal); const findName = () => wrapper.findByTestId('test-case-name'); + const findFile = () => wrapper.findByTestId('test-case-file'); + const findFileLink = () => wrapper.findComponent(GlLink); const findDuration = () => wrapper.findByTestId('test-case-duration'); const findRecentFailures = () => wrapper.findByTestId('test-case-recent-failures'); const findAttachmentUrl = () => wrapper.findByTestId('test-case-attachment-url'); @@ -57,11 +61,26 @@ describe('Test case details', () => { expect(findName().text()).toBe(defaultTestCase.name); }); + it('renders the test case file', () => { + expect(findFile().text()).toBe(defaultTestCase.file); + expect(findFileLink().attributes('href')).toBe(defaultTestCase.filePath); + }); + it('renders the test case duration', () => { expect(findDuration().text()).toBe(defaultTestCase.formattedTime); }); }); + describe('when test case has execution time instead of formatted time', () => { + beforeEach(() => { + createComponent({ ...defaultTestCase, formattedTime: null, execution_time: 17 }); + }); + + it('renders the test case duration', () => { + expect(findDuration().text()).toBe('17 s'); + }); + }); + describe('when test case has recent failures', () => { describe('has only 1 recent failure', () => { it('renders the recent failure', () => { diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js index dc72fa31ace..25650b24705 100644 --- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js +++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js @@ -1,9 +1,9 @@ -import { GlButton, GlFriendlyWrap, GlLink, GlPagination } from '@gitlab/ui'; +import { GlButton, GlFriendlyWrap, GlLink, GlPagination, GlEmptyState } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; import testReports from 'test_fixtures/pipelines/test_report.json'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import SuiteTable from '~/pipelines/components/test_reports/test_suite_table.vue'; +import SuiteTable, { i18n } from '~/pipelines/components/test_reports/test_suite_table.vue'; import { TestStatus } from '~/pipelines/constants'; import * as getters from '~/pipelines/stores/test_reports/getters'; import { formatFilePath } from '~/pipelines/stores/test_reports/utils'; @@ -26,6 +26,7 @@ describe('Test reports suite table', () => { const noCasesMessage = () => wrapper.findByTestId('no-test-cases'); const artifactsExpiredMessage = () => wrapper.findByTestId('artifacts-expired'); + const artifactsExpiredEmptyState = () => wrapper.find(GlEmptyState); const allCaseRows = () => wrapper.findAllByTestId('test-case-row'); const findCaseRowAtIndex = (index) => wrapper.findAllByTestId('test-case-row').at(index); const findLinkForRow = (row) => row.find(GlLink); @@ -65,11 +66,15 @@ describe('Test reports suite table', () => { expect(artifactsExpiredMessage().exists()).toBe(false); }); - it('should render a message when artifacts have expired', () => { + it('should render an empty state when artifacts have expired', () => { createComponent({ suite: [], errorMessage: ARTIFACTS_EXPIRED_ERROR_MESSAGE }); + const emptyState = artifactsExpiredEmptyState(); - expect(noCasesMessage().exists()).toBe(true); + expect(noCasesMessage().exists()).toBe(false); expect(artifactsExpiredMessage().exists()).toBe(true); + + expect(emptyState.exists()).toBe(true); + expect(emptyState.props('title')).toBe(i18n.expiredArtifactsTitle); }); describe('when a test suite is supplied', () => { diff --git a/spec/frontend/profile/account/components/update_username_spec.js b/spec/frontend/profile/account/components/update_username_spec.js index e342b7c4ba1..0e56bccf27e 100644 --- a/spec/frontend/profile/account/components/update_username_spec.js +++ b/spec/frontend/profile/account/components/update_username_spec.js @@ -52,7 +52,7 @@ describe('UpdateUsername component', () => { openModalBtn: wrapper.find('[data-testid="username-change-confirmation-modal"]'), modalBody: modal.find('.modal-body'), modalHeader: modal.find('.modal-title'), - confirmModalBtn: wrapper.find('.btn-warning'), + confirmModalBtn: wrapper.find('.btn-confirm'), }; }; diff --git a/spec/frontend/projects/clusters_deprecation_slert/components/clusters_deprecation_alert_spec.js b/spec/frontend/projects/clusters_deprecation_slert/components/clusters_deprecation_alert_spec.js new file mode 100644 index 00000000000..d230b96ad82 --- /dev/null +++ b/spec/frontend/projects/clusters_deprecation_slert/components/clusters_deprecation_alert_spec.js @@ -0,0 +1,45 @@ +import { GlAlert, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import ClustersDeprecationAlert from '~/projects/clusters_deprecation_alert/components/clusters_deprecation_alert.vue'; + +const message = 'Alert message'; + +describe('ClustersDeprecationAlert', () => { + let wrapper; + + const provideData = { + message, + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + + const createComponent = () => { + wrapper = shallowMount(ClustersDeprecationAlert, { + provide: provideData, + stubs: { + GlSprintf, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + it('should render a non-dismissible warning alert', () => { + expect(findAlert().props()).toMatchObject({ + dismissible: false, + variant: 'warning', + }); + }); + + it('should display the correct message', () => { + expect(findAlert().text()).toBe(message); + }); + }); +}); diff --git a/spec/frontend/projects/compare/components/revision_card_spec.js b/spec/frontend/projects/compare/components/revision_card_spec.js index 57906045337..a741393fcf3 100644 --- a/spec/frontend/projects/compare/components/revision_card_spec.js +++ b/spec/frontend/projects/compare/components/revision_card_spec.js @@ -1,4 +1,3 @@ -import { GlCard } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import RepoDropdown from '~/projects/compare/components/repo_dropdown.vue'; import RevisionCard from '~/projects/compare/components/revision_card.vue'; @@ -14,9 +13,6 @@ describe('RepoDropdown component', () => { ...defaultProps, ...props, }, - stubs: { - GlCard, - }, }); }; @@ -29,8 +25,10 @@ describe('RepoDropdown component', () => { createComponent(); }); + const RevisionCardWrapper = () => wrapper.find('.revision-card'); + it('displays revision text', () => { - expect(wrapper.find(GlCard).text()).toContain(defaultProps.revisionText); + expect(RevisionCardWrapper().text()).toContain(defaultProps.revisionText); }); it('renders RepoDropdown component', () => { diff --git a/spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js b/spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js index 42259a5c392..f50dd393174 100644 --- a/spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js +++ b/spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js @@ -57,7 +57,7 @@ describe('New project push tip popover', () => { }); expect(findFormInput().attributes()).toMatchObject({ 'aria-label': 'Push project from command line', - readonly: 'readonly', + readonly: '', }); }); diff --git a/spec/frontend/projects/pipelines/charts/components/app_spec.js b/spec/frontend/projects/pipelines/charts/components/app_spec.js index 9c94925c817..98c7856a61a 100644 --- a/spec/frontend/projects/pipelines/charts/components/app_spec.js +++ b/spec/frontend/projects/pipelines/charts/components/app_spec.js @@ -13,6 +13,7 @@ jest.mock('~/lib/utils/url_utility'); const DeploymentFrequencyChartsStub = { name: 'DeploymentFrequencyCharts', render: () => {} }; const LeadTimeChartsStub = { name: 'LeadTimeCharts', render: () => {} }; +const TimeToRestoreServiceChartsStub = { name: 'TimeToRestoreServiceCharts', render: () => {} }; const ProjectQualitySummaryStub = { name: 'ProjectQualitySummary', render: () => {} }; describe('ProjectsPipelinesChartsApp', () => { @@ -31,6 +32,7 @@ describe('ProjectsPipelinesChartsApp', () => { stubs: { DeploymentFrequencyCharts: DeploymentFrequencyChartsStub, LeadTimeCharts: LeadTimeChartsStub, + TimeToRestoreServiceCharts: TimeToRestoreServiceChartsStub, ProjectQualitySummary: ProjectQualitySummaryStub, }, }, @@ -47,6 +49,7 @@ describe('ProjectsPipelinesChartsApp', () => { const findAllGlTabs = () => wrapper.findAll(GlTab); const findGlTabAtIndex = (index) => findAllGlTabs().at(index); const findLeadTimeCharts = () => wrapper.find(LeadTimeChartsStub); + const findTimeToRestoreServiceCharts = () => wrapper.find(TimeToRestoreServiceChartsStub); const findDeploymentFrequencyCharts = () => wrapper.find(DeploymentFrequencyChartsStub); const findPipelineCharts = () => wrapper.find(PipelineCharts); const findProjectQualitySummary = () => wrapper.find(ProjectQualitySummaryStub); @@ -62,6 +65,7 @@ describe('ProjectsPipelinesChartsApp', () => { expect(findGlTabAtIndex(0).attributes('title')).toBe('Pipelines'); expect(findGlTabAtIndex(1).attributes('title')).toBe('Deployment frequency'); expect(findGlTabAtIndex(2).attributes('title')).toBe('Lead time'); + expect(findGlTabAtIndex(3).attributes('title')).toBe('Time to restore service'); }); it('renders the pipeline charts', () => { @@ -76,6 +80,10 @@ describe('ProjectsPipelinesChartsApp', () => { expect(findLeadTimeCharts().exists()).toBe(true); }); + it('renders the time to restore service charts', () => { + expect(findTimeToRestoreServiceCharts().exists()).toBe(true); + }); + it('renders the project quality summary', () => { expect(findProjectQualitySummary().exists()).toBe(true); }); @@ -123,10 +131,11 @@ describe('ProjectsPipelinesChartsApp', () => { describe('event tracking', () => { it.each` - testId | event - ${'pipelines-tab'} | ${'p_analytics_ci_cd_pipelines'} - ${'deployment-frequency-tab'} | ${'p_analytics_ci_cd_deployment_frequency'} - ${'lead-time-tab'} | ${'p_analytics_ci_cd_lead_time'} + testId | event + ${'pipelines-tab'} | ${'p_analytics_ci_cd_pipelines'} + ${'deployment-frequency-tab'} | ${'p_analytics_ci_cd_deployment_frequency'} + ${'lead-time-tab'} | ${'p_analytics_ci_cd_lead_time'} + ${'time-to-restore-service-tab'} | ${'p_analytics_ci_cd_time_to_restore_service'} `('tracks the $event event when clicked', ({ testId, event }) => { jest.spyOn(API, 'trackRedisHllUserEvent'); @@ -141,12 +150,13 @@ describe('ProjectsPipelinesChartsApp', () => { describe('when provided with a query param', () => { it.each` - chart | tab - ${'lead-time'} | ${'2'} - ${'deployment-frequency'} | ${'1'} - ${'pipelines'} | ${'0'} - ${'fake'} | ${'0'} - ${''} | ${'0'} + chart | tab + ${'time-to-restore-service'} | ${'3'} + ${'lead-time'} | ${'2'} + ${'deployment-frequency'} | ${'1'} + ${'pipelines'} | ${'0'} + ${'fake'} | ${'0'} + ${''} | ${'0'} `('shows the correct tab for URL parameter "$chart"', ({ chart, tab }) => { setWindowLocation(`${TEST_HOST}/gitlab-org/gitlab-test/-/pipelines/charts?chart=${chart}`); getParameterValues.mockImplementation((name) => { diff --git a/spec/frontend/projects/project_new_spec.js b/spec/frontend/projects/project_new_spec.js index fe325343da8..3034037fb1d 100644 --- a/spec/frontend/projects/project_new_spec.js +++ b/spec/frontend/projects/project_new_spec.js @@ -1,4 +1,3 @@ -import $ from 'jquery'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import projectNew from '~/projects/project_new'; @@ -8,6 +7,9 @@ describe('New Project', () => { let $projectPath; let $projectName; + const mockKeyup = (el) => el.dispatchEvent(new KeyboardEvent('keyup')); + const mockChange = (el) => el.dispatchEvent(new Event('change')); + beforeEach(() => { setHTMLFixture(` <div class='toggle-import-form'> @@ -29,122 +31,127 @@ describe('New Project', () => { </div> `); - $projectImportUrl = $('#project_import_url'); - $projectPath = $('#project_path'); - $projectName = $('#project_name'); + $projectImportUrl = document.querySelector('#project_import_url'); + $projectPath = document.querySelector('#project_path'); + $projectName = document.querySelector('#project_name'); }); afterEach(() => { resetHTMLFixture(); }); + const setValueAndTriggerEvent = (el, value, event) => { + event(el); + el.value = value; + }; + describe('deriveProjectPathFromUrl', () => { const dummyImportUrl = `${TEST_HOST}/dummy/import/url.git`; beforeEach(() => { projectNew.bindEvents(); - $projectPath.val('').keyup().val(dummyImportUrl); + setValueAndTriggerEvent($projectPath, dummyImportUrl, mockKeyup); }); it('does not change project path for disabled $projectImportUrl', () => { - $projectImportUrl.prop('disabled', true); + $projectImportUrl.setAttribute('disabled', true); projectNew.deriveProjectPathFromUrl($projectImportUrl); - expect($projectPath.val()).toEqual(dummyImportUrl); + expect($projectPath.value).toEqual(dummyImportUrl); }); describe('for enabled $projectImportUrl', () => { beforeEach(() => { - $projectImportUrl.prop('disabled', false); + $projectImportUrl.setAttribute('disabled', false); }); it('does not change project path if it is set by user', () => { - $projectPath.keyup(); + mockKeyup($projectPath); projectNew.deriveProjectPathFromUrl($projectImportUrl); - expect($projectPath.val()).toEqual(dummyImportUrl); + expect($projectPath.value).toEqual(dummyImportUrl); }); it('does not change project path for empty $projectImportUrl', () => { - $projectImportUrl.val(''); + $projectImportUrl.value = ''; projectNew.deriveProjectPathFromUrl($projectImportUrl); - expect($projectPath.val()).toEqual(dummyImportUrl); + expect($projectPath.value).toEqual(dummyImportUrl); }); it('does not change project path for whitespace $projectImportUrl', () => { - $projectImportUrl.val(' '); + $projectImportUrl.value = ' '; projectNew.deriveProjectPathFromUrl($projectImportUrl); - expect($projectPath.val()).toEqual(dummyImportUrl); + expect($projectPath.value).toEqual(dummyImportUrl); }); it('does not change project path for $projectImportUrl without slashes', () => { - $projectImportUrl.val('has-no-slash'); + $projectImportUrl.value = 'has-no-slash'; projectNew.deriveProjectPathFromUrl($projectImportUrl); - expect($projectPath.val()).toEqual(dummyImportUrl); + expect($projectPath.value).toEqual(dummyImportUrl); }); it('changes project path to last $projectImportUrl component', () => { - $projectImportUrl.val('/this/is/last'); + $projectImportUrl.value = '/this/is/last'; projectNew.deriveProjectPathFromUrl($projectImportUrl); - expect($projectPath.val()).toEqual('last'); + expect($projectPath.value).toEqual('last'); }); it('ignores trailing slashes in $projectImportUrl', () => { - $projectImportUrl.val('/has/trailing/slash/'); + $projectImportUrl.value = '/has/trailing/slash/'; projectNew.deriveProjectPathFromUrl($projectImportUrl); - expect($projectPath.val()).toEqual('slash'); + expect($projectPath.value).toEqual('slash'); }); it('ignores fragment identifier in $projectImportUrl', () => { - $projectImportUrl.val('/this/has/a#fragment-identifier/'); + $projectImportUrl.value = '/this/has/a#fragment-identifier/'; projectNew.deriveProjectPathFromUrl($projectImportUrl); - expect($projectPath.val()).toEqual('a'); + expect($projectPath.value).toEqual('a'); }); it('ignores query string in $projectImportUrl', () => { - $projectImportUrl.val('/url/with?query=string'); + $projectImportUrl.value = '/url/with?query=string'; projectNew.deriveProjectPathFromUrl($projectImportUrl); - expect($projectPath.val()).toEqual('with'); + expect($projectPath.value).toEqual('with'); }); it('ignores trailing .git in $projectImportUrl', () => { - $projectImportUrl.val('/repository.git'); + $projectImportUrl.value = '/repository.git'; projectNew.deriveProjectPathFromUrl($projectImportUrl); - expect($projectPath.val()).toEqual('repository'); + expect($projectPath.value).toEqual('repository'); }); it('changes project path for HTTPS URL in $projectImportUrl', () => { - $projectImportUrl.val('https://gitlab.company.com/group/project.git'); + $projectImportUrl.value = 'https://gitlab.company.com/group/project.git'; projectNew.deriveProjectPathFromUrl($projectImportUrl); - expect($projectPath.val()).toEqual('project'); + expect($projectPath.value).toEqual('project'); }); it('changes project path for SSH URL in $projectImportUrl', () => { - $projectImportUrl.val('git@gitlab.com:gitlab-org/gitlab-ce.git'); + $projectImportUrl.value = 'git@gitlab.com:gitlab-org/gitlab-ce.git'; projectNew.deriveProjectPathFromUrl($projectImportUrl); - expect($projectPath.val()).toEqual('gitlab-ce'); + expect($projectPath.value).toEqual('gitlab-ce'); }); }); }); @@ -152,27 +159,27 @@ describe('New Project', () => { describe('deriveSlugFromProjectName', () => { beforeEach(() => { projectNew.bindEvents(); - $projectName.val('').keyup(); + setValueAndTriggerEvent($projectName, '', mockKeyup); }); it('converts project name to lower case and dash-limited slug', () => { const dummyProjectName = 'My Awesome Project'; - $projectName.val(dummyProjectName); + $projectName.value = dummyProjectName; projectNew.onProjectNameChange($projectName, $projectPath); - expect($projectPath.val()).toEqual('my-awesome-project'); + expect($projectPath.value).toEqual('my-awesome-project'); }); it('does not add additional dashes in the slug if the project name already contains dashes', () => { const dummyProjectName = 'My-Dash-Delimited Awesome Project'; - $projectName.val(dummyProjectName); + $projectName.value = dummyProjectName; projectNew.onProjectNameChange($projectName, $projectPath); - expect($projectPath.val()).toEqual('my-dash-delimited-awesome-project'); + expect($projectPath.value).toEqual('my-dash-delimited-awesome-project'); }); }); @@ -182,27 +189,28 @@ describe('New Project', () => { beforeEach(() => { projectNew.bindEvents(); - $projectPath.val('').change(); + setValueAndTriggerEvent($projectPath, '', mockChange); }); it('converts slug to humanized project name', () => { - $projectPath.val(dummyProjectPath); + $projectPath.value = dummyProjectPath; + mockChange($projectPath); projectNew.onProjectPathChange($projectName, $projectPath); - expect($projectName.val()).toEqual('My Awesome Project'); + expect($projectName.value).toEqual('My Awesome Project'); }); it('does not convert slug to humanized project name if a project name already exists', () => { - $projectName.val(dummyProjectName); - $projectPath.val(dummyProjectPath); + $projectName.value = dummyProjectName; + $projectPath.value = dummyProjectPath; projectNew.onProjectPathChange( $projectName, $projectPath, - $projectName.val().trim().length > 0, + $projectName.value.trim().length > 0, ); - expect($projectName.val()).toEqual(dummyProjectName); + expect($projectName.value).toEqual(dummyProjectName); }); }); }); diff --git a/spec/frontend/projects/settings/branch_rules/branch_dropdown_spec.js b/spec/frontend/projects/settings/branch_rules/branch_dropdown_spec.js new file mode 100644 index 00000000000..5997c2a083c --- /dev/null +++ b/spec/frontend/projects/settings/branch_rules/branch_dropdown_spec.js @@ -0,0 +1,101 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlDropdown, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import BranchDropdown, { + i18n, +} from '~/projects/settings/branch_rules/components/branch_dropdown.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import branchesQuery from '~/projects/settings/branch_rules/queries/branches.query.graphql'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/flash'; + +Vue.use(VueApollo); +jest.mock('~/flash'); + +describe('Branch dropdown', () => { + let wrapper; + + const projectPath = 'test/project'; + const value = 'main'; + const mockBranchNames = ['test 1', 'test 2']; + + const createComponent = async ({ branchNames = mockBranchNames, resolver } = {}) => { + const mockResolver = + resolver || + jest.fn().mockResolvedValue({ + data: { project: { id: '1', repository: { branchNames } } }, + }); + const apolloProvider = createMockApollo([[branchesQuery, mockResolver]]); + + wrapper = shallowMountExtended(BranchDropdown, { + apolloProvider, + propsData: { projectPath, value }, + }); + + await waitForPromises(); + }; + + const findGlDropdown = () => wrapper.find(GlDropdown); + const findAllBranches = () => wrapper.findAll(GlDropdownItem); + const findNoDataMsg = () => wrapper.findByTestId('no-data'); + const findGlSearchBoxByType = () => wrapper.find(GlSearchBoxByType); + const findWildcardButton = () => wrapper.findByTestId('create-wildcard-button'); + const setSearchTerm = (searchTerm) => findGlSearchBoxByType().vm.$emit('input', searchTerm); + + beforeEach(() => createComponent()); + + it('renders a GlDropdown component with the correct props', () => { + expect(findGlDropdown().props()).toMatchObject({ text: value }); + }); + + it('renders GlDropdownItem components for each branch', () => { + expect(findAllBranches().length).toBe(mockBranchNames.length); + + mockBranchNames.forEach((branchName, index) => + expect(findAllBranches().at(index).text()).toBe(branchName), + ); + }); + + it('emits `select` with the branch name when a branch is clicked', () => { + findAllBranches().at(0).vm.$emit('click'); + expect(wrapper.emitted('input')).toEqual([[mockBranchNames[0]]]); + }); + + describe('branch searching', () => { + it('displays a message if no branches can be found', async () => { + await createComponent({ branchNames: [] }); + + expect(findNoDataMsg().text()).toBe(i18n.noMatch); + }); + + it('displays a loading state while search request is in flight', async () => { + setSearchTerm('test'); + await nextTick(); + + expect(findGlSearchBoxByType().props()).toMatchObject({ isLoading: true }); + }); + + it('renders a wildcard button', async () => { + const searchTerm = 'test-*'; + setSearchTerm(searchTerm); + await nextTick(); + + expect(findWildcardButton().exists()).toBe(true); + findWildcardButton().vm.$emit('click'); + expect(wrapper.emitted('createWildcard')).toEqual([[searchTerm]]); + }); + }); + + it('displays an error message if fetch failed', async () => { + const error = new Error('an error occurred'); + const resolver = jest.fn().mockRejectedValueOnce(error); + await createComponent({ resolver }); + + expect(createAlert).toHaveBeenCalledWith({ + message: i18n.fetchBranchesError, + captureError: true, + error, + }); + }); +}); diff --git a/spec/frontend/projects/settings/branch_rules/rule_edit_spec.js b/spec/frontend/projects/settings/branch_rules/rule_edit_spec.js new file mode 100644 index 00000000000..66ae6ddc02d --- /dev/null +++ b/spec/frontend/projects/settings/branch_rules/rule_edit_spec.js @@ -0,0 +1,49 @@ +import { nextTick } from 'vue'; +import { getParameterByName } from '~/lib/utils/url_utility'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RuleEdit from '~/projects/settings/branch_rules/components/rule_edit.vue'; +import BranchDropdown from '~/projects/settings/branch_rules/components/branch_dropdown.vue'; + +jest.mock('~/lib/utils/url_utility', () => ({ + getParameterByName: jest.fn().mockImplementation(() => 'main'), +})); + +describe('Edit branch rule', () => { + let wrapper; + const projectPath = 'test/testing'; + + const createComponent = () => { + wrapper = shallowMountExtended(RuleEdit, { propsData: { projectPath } }); + }; + + const findBranchDropdown = () => wrapper.find(BranchDropdown); + + beforeEach(() => createComponent()); + + it('gets the branch param from url', () => { + expect(getParameterByName).toHaveBeenCalledWith('branch'); + }); + + describe('BranchDropdown', () => { + it('renders a BranchDropdown component with the correct props', () => { + expect(findBranchDropdown().props()).toMatchObject({ + projectPath, + value: 'main', + }); + }); + + it('sets the correct value when `input` is emitted', async () => { + const branch = 'test'; + findBranchDropdown().vm.$emit('input', branch); + await nextTick(); + expect(findBranchDropdown().props('value')).toBe(branch); + }); + + it('sets the correct value when `createWildcard` is emitted', async () => { + const wildcard = 'test-*'; + findBranchDropdown().vm.$emit('createWildcard', wildcard); + await nextTick(); + expect(findBranchDropdown().props('value')).toBe(wildcard); + }); + }); +}); diff --git a/spec/frontend/projects/settings/repository/branch_rules/app_spec.js b/spec/frontend/projects/settings/repository/branch_rules/app_spec.js new file mode 100644 index 00000000000..e12c3aeedd6 --- /dev/null +++ b/spec/frontend/projects/settings/repository/branch_rules/app_spec.js @@ -0,0 +1,18 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import BranchRules from '~/projects/settings/repository/branch_rules/app.vue'; + +describe('Branch rules app', () => { + let wrapper; + + const createComponent = () => { + wrapper = mountExtended(BranchRules); + }; + + const findTitle = () => wrapper.find('strong'); + + beforeEach(() => createComponent()); + + it('renders a title', () => { + expect(findTitle().text()).toBe('Branch'); + }); +}); diff --git a/spec/frontend/prometheus_metrics/custom_metrics_spec.js b/spec/frontend/prometheus_metrics/custom_metrics_spec.js index 473327bf5e1..fc906194059 100644 --- a/spec/frontend/prometheus_metrics/custom_metrics_spec.js +++ b/spec/frontend/prometheus_metrics/custom_metrics_spec.js @@ -6,9 +6,9 @@ import CustomMetrics from '~/prometheus_metrics/custom_metrics'; import { metrics1 as metrics } from './mock_data'; describe('PrometheusMetrics', () => { - const FIXTURE = 'services/prometheus/prometheus_service.html'; + const FIXTURE = 'integrations/prometheus/prometheus_integration.html'; const customMetricsEndpoint = - 'http://test.host/frontend-fixtures/services-project/prometheus/metrics'; + 'http://test.host/frontend-fixtures/integrations-project/prometheus/metrics'; let mock; beforeEach(() => { diff --git a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js index 1151c0b3769..0df2aad5882 100644 --- a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js +++ b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js @@ -7,7 +7,7 @@ import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics'; import { metrics2 as metrics, missingVarMetrics } from './mock_data'; describe('PrometheusMetrics', () => { - const FIXTURE = 'services/prometheus/prometheus_service.html'; + const FIXTURE = 'integrations/prometheus/prometheus_integration.html'; beforeEach(() => { loadHTMLFixture(FIXTURE); diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index 2ab4afbffbe..d498b6f0c4f 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -22,7 +22,7 @@ import userInfoQuery from '~/repository/queries/user_info.query.graphql'; import applicationInfoQuery from '~/repository/queries/application_info.query.graphql'; import CodeIntelligence from '~/code_navigation/components/app.vue'; import { redirectTo } from '~/lib/utils/url_utility'; -import { isLoggedIn } from '~/lib/utils/common_utils'; +import { isLoggedIn, handleLocationHash } from '~/lib/utils/common_utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import httpStatusCodes from '~/lib/utils/http_status'; import LineHighlighter from '~/blob/line_highlighter'; @@ -163,6 +163,14 @@ describe('Blob content viewer component', () => { expect(findBlobHeader().props('blob')).toEqual(simpleViewerMock); }); + it('copies blob text to clipboard', async () => { + jest.spyOn(navigator.clipboard, 'writeText'); + await createComponent(); + + findBlobHeader().vm.$emit('copy'); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(simpleViewerMock.rawTextBlob); + }); + it('renders a BlobContent component', async () => { await createComponent(); @@ -209,6 +217,12 @@ describe('Blob content viewer component', () => { await createComponent({ blob: { ...simpleViewerMock, fileType, highlightJs } }); expect(LineHighlighter).toHaveBeenCalled(); }); + + it('scrolls to the hash', async () => { + mockAxios.onGet(legacyViewerUrl).replyOnce(httpStatusCodes.OK, 'test'); + await createComponent({ blob: { ...simpleViewerMock, fileType, highlightJs } }); + expect(handleLocationHash).toHaveBeenCalled(); + }); }); }); diff --git a/spec/frontend/repository/components/blob_viewers/sketch_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/sketch_viewer_spec.js new file mode 100644 index 00000000000..b5c8c02c4a0 --- /dev/null +++ b/spec/frontend/repository/components/blob_viewers/sketch_viewer_spec.js @@ -0,0 +1,32 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import SketchViewer from '~/repository/components/blob_viewers/sketch_viewer.vue'; +import SketchLoader from '~/blob/sketch'; + +jest.mock('~/blob/sketch'); + +describe('Sketch Viewer', () => { + let wrapper; + + const DEFAULT_BLOB_DATA = { + rawPath: 'some/file.sketch', + }; + + const createComponent = () => { + wrapper = shallowMountExtended(SketchViewer, { + propsData: { blob: DEFAULT_BLOB_DATA }, + }); + }; + + const findSketchWrapper = () => wrapper.findByTestId('sketch'); + + beforeEach(() => createComponent()); + + it('inits the sketch loader', () => { + expect(SketchLoader).toHaveBeenCalledWith(wrapper.vm.$refs.viewer); + }); + + it('renders the sketch viewer', () => { + expect(findSketchWrapper().exists()).toBe(true); + expect(findSketchWrapper().attributes('data-endpoint')).toBe(DEFAULT_BLOB_DATA.rawPath); + }); +}); diff --git a/spec/frontend/repository/components/new_directory_modal_spec.js b/spec/frontend/repository/components/new_directory_modal_spec.js index fe7f024e3ea..e1c50d63851 100644 --- a/spec/frontend/repository/components/new_directory_modal_spec.js +++ b/spec/frontend/repository/components/new_directory_modal_spec.js @@ -67,7 +67,7 @@ describe('NewDirectoryModal', () => { await findBranchName().vm.$emit('input', branchName); await findCommitMessage().vm.$emit('input', commitMessage); await findMrToggle().vm.$emit('change', createNewMr); - await nextTick; + await nextTick(); }; const submitForm = async () => { diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js index 07c151ad935..ff0371b5c07 100644 --- a/spec/frontend/repository/components/table/index_spec.js +++ b/spec/frontend/repository/components/table/index_spec.js @@ -1,4 +1,4 @@ -import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlButton } from '@gitlab/ui'; +import { GlSkeletonLoader, GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import Table from '~/repository/components/table/index.vue'; @@ -103,7 +103,7 @@ describe('Repository table component', () => { it('shows loading icon', () => { factory({ path: '/', isLoading: true }); - expect(vm.find(GlSkeletonLoading).exists()).toBe(true); + expect(vm.findComponent(GlSkeletonLoader).exists()).toBe(true); }); it('renders table rows', () => { diff --git a/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js b/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js index 07259ec3538..28e7d192938 100644 --- a/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js +++ b/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js @@ -1,6 +1,7 @@ import Vue from 'vue'; -import { mount, shallowMount } from '@vue/test-utils'; +import { GlTab, GlTabs } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; @@ -11,6 +12,7 @@ import RunnerHeader from '~/runner/components/runner_header.vue'; import RunnerPauseButton from '~/runner/components/runner_pause_button.vue'; import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue'; import RunnerEditButton from '~/runner/components/runner_edit_button.vue'; +import RunnersJobs from '~/runner/components/runner_jobs.vue'; import runnerQuery from '~/runner/graphql/show/runner.query.graphql'; import AdminRunnerShowApp from '~/runner/admin_runner_show/admin_runner_show_app.vue'; import { captureException } from '~/runner/sentry_utils'; @@ -38,6 +40,8 @@ describe('AdminRunnerShowApp', () => { const findRunnerDeleteButton = () => wrapper.findComponent(RunnerDeleteButton); const findRunnerEditButton = () => wrapper.findComponent(RunnerEditButton); const findRunnerPauseButton = () => wrapper.findComponent(RunnerPauseButton); + const findRunnersJobs = () => wrapper.findComponent(RunnersJobs); + const findJobCountBadge = () => wrapper.findByTestId('job-count-badge'); const mockRunnerQueryResult = (runner = {}) => { mockRunnerQuery = jest.fn().mockResolvedValue({ @@ -47,7 +51,7 @@ describe('AdminRunnerShowApp', () => { }); }; - const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { + const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => { wrapper = mountFn(AdminRunnerShowApp, { apolloProvider: createMockApollo([[runnerQuery, mockRunnerQuery]]), propsData: { @@ -55,6 +59,7 @@ describe('AdminRunnerShowApp', () => { runnersPath: mockRunnersPath, ...props, }, + ...options, }); return waitForPromises(); @@ -69,7 +74,7 @@ describe('AdminRunnerShowApp', () => { beforeEach(async () => { mockRunnerQueryResult(); - await createComponent({ mountFn: mount }); + await createComponent({ mountFn: mountExtended }); }); it('expect GraphQL ID to be requested', async () => { @@ -110,7 +115,7 @@ describe('AdminRunnerShowApp', () => { }); await createComponent({ - mountFn: mount, + mountFn: mountExtended, }); }); @@ -129,7 +134,7 @@ describe('AdminRunnerShowApp', () => { }); await createComponent({ - mountFn: mount, + mountFn: mountExtended, }); }); @@ -141,7 +146,7 @@ describe('AdminRunnerShowApp', () => { describe('when runner is deleted', () => { beforeEach(async () => { await createComponent({ - mountFn: mount, + mountFn: mountExtended, }); }); @@ -163,7 +168,7 @@ describe('AdminRunnerShowApp', () => { }); await createComponent({ - mountFn: mount, + mountFn: mountExtended, }); }); @@ -191,4 +196,49 @@ describe('AdminRunnerShowApp', () => { expect(createAlert).toHaveBeenCalled(); }); }); + + describe('Jobs tab', () => { + const stubs = { + GlTab, + GlTabs, + RunnerDetails: { + template: ` + <div> + <slot name="jobs-tab"></slot> + </div> + `, + }, + }; + + it('without a runner, shows no jobs', () => { + mockRunnerQuery = jest.fn().mockResolvedValue({ + data: { + runner: null, + }, + }); + + createComponent({ stubs }); + + expect(findJobCountBadge().exists()).toBe(false); + expect(findRunnersJobs().exists()).toBe(false); + }); + + it('without a job count, shows no jobs count', async () => { + mockRunnerQueryResult({ jobCount: null }); + + await createComponent({ stubs }); + + expect(findJobCountBadge().exists()).toBe(false); + }); + + it('with a job count, shows jobs count', async () => { + const runner = { jobCount: 3 }; + mockRunnerQueryResult(runner); + + await createComponent({ stubs }); + + expect(findJobCountBadge().text()).toBe('3'); + expect(findRunnersJobs().props('runner')).toEqual({ ...mockRunner, ...runner }); + }); + }); }); diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js index 405813be4e3..3d25ad075de 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -18,6 +18,7 @@ import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerList from '~/runner/components/runner_list.vue'; +import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vue'; import RunnerStats from '~/runner/components/stat/runner_stats.vue'; import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue'; import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; @@ -50,6 +51,8 @@ import { runnersDataPaginated, onlineContactTimeoutSecs, staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, } from '../mock_data'; const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; @@ -78,6 +81,7 @@ describe('AdminRunnersApp', () => { const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); const findRunnerList = () => wrapper.findComponent(RunnerList); + const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page'); const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); @@ -106,6 +110,8 @@ describe('AdminRunnersApp', () => { localMutations, onlineContactTimeoutSecs, staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, ...provide, }, ...options, @@ -457,12 +463,28 @@ describe('AdminRunnersApp', () => { runners: { nodes: [] }, }, }); + createComponent(); await waitForPromises(); }); - it('shows a message for no results', async () => { - expect(wrapper.text()).toContain('No runners found'); + it('shows an empty state', () => { + expect(findRunnerListEmptyState().props('isSearchFiltered')).toBe(false); + }); + + describe('when a filter is selected by the user', () => { + beforeEach(async () => { + findRunnerFilteredSearchBar().vm.$emit('input', { + runnerType: null, + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], + sort: CREATED_ASC, + }); + await waitForPromises(); + }); + + it('shows an empty state for a filtered search', () => { + expect(findRunnerListEmptyState().props('isSearchFiltered')).toBe(true); + }); }); }); diff --git a/spec/frontend/runner/components/__snapshots__/runner_status_popover_spec.js.snap b/spec/frontend/runner/components/__snapshots__/runner_status_popover_spec.js.snap index 80a04401760..b27a1adf01b 100644 --- a/spec/frontend/runner/components/__snapshots__/runner_status_popover_spec.js.snap +++ b/spec/frontend/runner/components/__snapshots__/runner_status_popover_spec.js.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RunnerStatusPopover renders complete text 1`] = `"Never contacted: Runner has never contacted GitLab (when you register a runner, use gitlab-runner run to bring it online) Online: Runner has contacted GitLab within the last 2 hours Offline: Runner has not contacted GitLab in more than 2 hours Stale: Runner has not contacted GitLab in more than 2 months"`; +exports[`RunnerStatusPopover renders complete text 1`] = `"Never contacted: Runner has never contacted GitLab (when you register a runner, use gitlab-runner run to bring it online) Online: Runner has contacted GitLab within the last 2 hours Offline: Runner has not contacted GitLab in more than 2 hours Stale: Runner has not contacted GitLab in more than 3 months"`; diff --git a/spec/frontend/runner/components/cells/runner_status_cell_spec.js b/spec/frontend/runner/components/cells/runner_status_cell_spec.js index 20a1cdf7236..0f5133d0ae2 100644 --- a/spec/frontend/runner/components/cells/runner_status_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_status_cell_spec.js @@ -1,12 +1,15 @@ -import { GlBadge } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import RunnerStatusCell from '~/runner/components/cells/runner_status_cell.vue'; + +import RunnerStatusBadge from '~/runner/components/runner_status_badge.vue'; +import RunnerPausedBadge from '~/runner/components/runner_paused_badge.vue'; import { INSTANCE_TYPE, STATUS_ONLINE, STATUS_OFFLINE } from '~/runner/constants'; -describe('RunnerTypeCell', () => { +describe('RunnerStatusCell', () => { let wrapper; - const findBadgeAt = (i) => wrapper.findAllComponents(GlBadge).at(i); + const findStatusBadge = () => wrapper.findComponent(RunnerStatusBadge); + const findPausedBadge = () => wrapper.findComponent(RunnerPausedBadge); const createComponent = ({ runner = {} } = {}) => { wrapper = mount(RunnerStatusCell, { @@ -29,7 +32,7 @@ describe('RunnerTypeCell', () => { createComponent(); expect(wrapper.text()).toMatchInterpolatedText('online'); - expect(findBadgeAt(0).text()).toBe('online'); + expect(findStatusBadge().text()).toBe('online'); }); it('Displays offline status', () => { @@ -40,7 +43,7 @@ describe('RunnerTypeCell', () => { }); expect(wrapper.text()).toMatchInterpolatedText('offline'); - expect(findBadgeAt(0).text()).toBe('offline'); + expect(findStatusBadge().text()).toBe('offline'); }); it('Displays paused status', () => { @@ -52,9 +55,7 @@ describe('RunnerTypeCell', () => { }); expect(wrapper.text()).toMatchInterpolatedText('online paused'); - - expect(findBadgeAt(0).text()).toBe('online'); - expect(findBadgeAt(1).text()).toBe('paused'); + expect(findPausedBadge().text()).toBe('paused'); }); it('Is empty when data is missing', () => { diff --git a/spec/frontend/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/runner/components/registration/registration_dropdown_spec.js index 81c2788f084..d3f38bc1d26 100644 --- a/spec/frontend/runner/components/registration/registration_dropdown_spec.js +++ b/spec/frontend/runner/components/registration/registration_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem, GlDropdownForm } from '@gitlab/ui'; +import { GlModal, GlDropdown, GlDropdownItem, GlDropdownForm } from '@gitlab/ui'; import { mount, shallowMount, createWrapper } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; @@ -24,6 +24,8 @@ import { const mockToken = '0123456789'; const maskToken = '**********'; +Vue.use(VueApollo); + describe('RegistrationDropdown', () => { let wrapper; @@ -32,9 +34,11 @@ describe('RegistrationDropdown', () => { const findRegistrationInstructionsDropdownItem = () => wrapper.findComponent(GlDropdownItem); const findTokenDropdownItem = () => wrapper.findComponent(GlDropdownForm); const findRegistrationToken = () => wrapper.findComponent(RegistrationToken); - const findRegistrationTokenInput = () => wrapper.findByTestId('token-value').find('input'); + const findRegistrationTokenInput = () => + wrapper.findByLabelText(RegistrationToken.i18n.registrationToken); const findTokenResetDropdownItem = () => wrapper.findComponent(RegistrationTokenResetDropdownItem); + const findModal = () => wrapper.findComponent(GlModal); const findModalContent = () => createWrapper(document.body) .find('[data-testid="runner-instructions-modal"]') @@ -43,6 +47,8 @@ describe('RegistrationDropdown', () => { const openModal = async () => { await findRegistrationInstructionsDropdownItem().trigger('click'); + findModal().vm.$emit('shown'); + await waitForPromises(); }; @@ -60,8 +66,6 @@ describe('RegistrationDropdown', () => { }; const createComponentWithModal = () => { - Vue.use(VueApollo); - const requestHandlers = [ [getRunnerPlatformsQuery, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)], [getRunnerSetupInstructionsQuery, jest.fn().mockResolvedValue(mockGraphqlInstructions)], @@ -169,10 +173,10 @@ describe('RegistrationDropdown', () => { await nextTick(); }; - it('Updates token in input', async () => { + it('Updates token input', async () => { createComponent({}, mount); - expect(findRegistrationTokenInput().props('value')).not.toBe(newToken); + expect(findRegistrationToken().props('value')).not.toBe(newToken); await resetToken(); diff --git a/spec/frontend/runner/components/registration/registration_token_spec.js b/spec/frontend/runner/components/registration/registration_token_spec.js index cb42c7c8493..ed1a698d36f 100644 --- a/spec/frontend/runner/components/registration/registration_token_spec.js +++ b/spec/frontend/runner/components/registration/registration_token_spec.js @@ -29,6 +29,7 @@ describe('RegistrationToken', () => { wrapper = mountFn(RegistrationToken, { propsData: { value: mockToken, + inputId: 'token-value', ...props, }, localVue, diff --git a/spec/frontend/runner/components/runner_details_spec.js b/spec/frontend/runner/components/runner_details_spec.js index 162d21febfd..9e0f7014750 100644 --- a/spec/frontend/runner/components/runner_details_spec.js +++ b/spec/frontend/runner/components/runner_details_spec.js @@ -1,14 +1,13 @@ -import { GlSprintf, GlIntersperse, GlTab } from '@gitlab/ui'; -import { createWrapper, ErrorWrapper } from '@vue/test-utils'; +import { GlSprintf, GlIntersperse } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { useFakeDate } from 'helpers/fake_date'; +import { findDd } from 'helpers/dl_locator_helper'; import { ACCESS_LEVEL_REF_PROTECTED, ACCESS_LEVEL_NOT_PROTECTED } from '~/runner/constants'; import RunnerDetails from '~/runner/components/runner_details.vue'; import RunnerDetail from '~/runner/components/runner_detail.vue'; import RunnerGroups from '~/runner/components/runner_groups.vue'; -import RunnersJobs from '~/runner/components/runner_jobs.vue'; import RunnerTags from '~/runner/components/runner_tags.vue'; import RunnerTag from '~/runner/components/runner_tag.vue'; @@ -24,25 +23,14 @@ describe('RunnerDetails', () => { useFakeDate(mockNow); - /** - * Find the definition (<dd>) that corresponds to this term (<dt>) - * @param {string} dtLabel - Label for this value - * @returns Wrapper - */ - const findDd = (dtLabel) => { - const dt = wrapper.findByText(dtLabel).element; - const dd = dt.nextElementSibling; - if (dt.tagName === 'DT' && dd.tagName === 'DD') { - return createWrapper(dd, {}); - } - return ErrorWrapper(dtLabel); - }; - const findDetailGroups = () => wrapper.findComponent(RunnerGroups); - const findRunnersJobs = () => wrapper.findComponent(RunnersJobs); - const findJobCountBadge = () => wrapper.findByTestId('job-count-badge'); - const createComponent = ({ props = {}, mountFn = shallowMountExtended, stubs } = {}) => { + const createComponent = ({ + props = {}, + stubs, + mountFn = shallowMountExtended, + ...options + } = {}) => { wrapper = mountFn(RunnerDetails, { propsData: { ...props, @@ -51,6 +39,7 @@ describe('RunnerDetails', () => { RunnerDetail, ...stubs, }, + ...options, }); }; @@ -108,7 +97,7 @@ describe('RunnerDetails', () => { }); it(`displays expected value "${expectedValue}"`, () => { - expect(findDd(field).text()).toBe(expectedValue); + expect(findDd(field, wrapper).text()).toBe(expectedValue); }); }); @@ -123,7 +112,7 @@ describe('RunnerDetails', () => { stubs, }); - expect(findDd('Tags').text().replace(/\s+/g, ' ')).toBe('tag-1 tag-2'); + expect(findDd('Tags', wrapper).text().replace(/\s+/g, ' ')).toBe('tag-1 tag-2'); }); it('displays "None" when runner has no tags', () => { @@ -134,7 +123,7 @@ describe('RunnerDetails', () => { stubs, }); - expect(findDd('Tags').text().replace(/\s+/g, ' ')).toBe('None'); + expect(findDd('Tags', wrapper).text().replace(/\s+/g, ' ')).toBe('None'); }); }); @@ -153,40 +142,17 @@ describe('RunnerDetails', () => { }); }); - describe('Jobs tab', () => { - const stubs = { GlTab }; - - it('without a runner, shows no jobs', () => { - createComponent({ - props: { runner: null }, - stubs, - }); - - expect(findJobCountBadge().exists()).toBe(false); - expect(findRunnersJobs().exists()).toBe(false); - }); + describe('Jobs tab slot', () => { + it('shows job tab slot', () => { + const JOBS_TAB = '<div>Jobs Tab</div>'; - it('without a job count, shows no jobs count', () => { createComponent({ - props: { - runner: { ...mockRunner, jobCount: undefined }, + slots: { + 'jobs-tab': JOBS_TAB, }, - stubs, - }); - - expect(findJobCountBadge().exists()).toBe(false); - }); - - it('with a job count, shows jobs count', () => { - const runner = { ...mockRunner, jobCount: 3 }; - - createComponent({ - props: { runner }, - stubs, }); - expect(findJobCountBadge().text()).toBe('3'); - expect(findRunnersJobs().props('runner')).toBe(runner); + expect(wrapper.html()).toContain(JOBS_TAB); }); }); }); diff --git a/spec/frontend/runner/components/runner_jobs_spec.js b/spec/frontend/runner/components/runner_jobs_spec.js index 8ac5685a0dd..20582aaaf40 100644 --- a/spec/frontend/runner/components/runner_jobs_spec.js +++ b/spec/frontend/runner/components/runner_jobs_spec.js @@ -1,4 +1,4 @@ -import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; +import { GlSkeletonLoader } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -28,7 +28,7 @@ describe('RunnerJobs', () => { let wrapper; let mockRunnerJobsQuery; - const findGlSkeletonLoading = () => wrapper.findComponent(GlSkeletonLoading); + const findGlSkeletonLoading = () => wrapper.findComponent(GlSkeletonLoader); const findRunnerJobsTable = () => wrapper.findComponent(RunnerJobsTable); const findRunnerPagination = () => wrapper.findComponent(RunnerPagination); diff --git a/spec/frontend/runner/components/runner_list_empty_state_spec.js b/spec/frontend/runner/components/runner_list_empty_state_spec.js new file mode 100644 index 00000000000..59cff863106 --- /dev/null +++ b/spec/frontend/runner/components/runner_list_empty_state_spec.js @@ -0,0 +1,76 @@ +import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; + +import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vue'; + +const mockSvgPath = 'mock-svg-path.svg'; +const mockFilteredSvgPath = 'mock-filtered-svg-path.svg'; + +describe('RunnerListEmptyState', () => { + let wrapper; + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findLink = () => wrapper.findComponent(GlLink); + const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal); + + const createComponent = ({ props, mountFn = shallowMountExtended } = {}) => { + wrapper = mountFn(RunnerListEmptyState, { + propsData: { + svgPath: mockSvgPath, + filteredSvgPath: mockFilteredSvgPath, + ...props, + }, + directives: { + GlModal: createMockDirective(), + }, + stubs: { + GlEmptyState, + GlSprintf, + GlLink, + }, + }); + }; + + describe('when search is not filtered', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders an illustration', () => { + expect(findEmptyState().props('svgPath')).toBe(mockSvgPath); + }); + + it('displays "no results" text', () => { + const title = s__('Runners|Get started with runners'); + const desc = s__( + 'Runners|Runners are the agents that run your CI/CD jobs. Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.', + ); + + expect(findEmptyState().text()).toMatchInterpolatedText(`${title} ${desc}`); + }); + + it('opens a runner registration instructions modal with a link', () => { + const { value } = getBinding(findLink().element, 'gl-modal'); + + expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); + }); + }); + + describe('when search is filtered', () => { + beforeEach(() => { + createComponent({ props: { isSearchFiltered: true } }); + }); + + it('renders a "filtered search" illustration', () => { + expect(findEmptyState().props('svgPath')).toBe(mockFilteredSvgPath); + }); + + it('displays "no filtered results" text', () => { + expect(findEmptyState().text()).toContain(s__('Runners|No results found')); + expect(findEmptyState().text()).toContain(s__('Runners|Edit your search and try again')); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_projects_spec.js b/spec/frontend/runner/components/runner_projects_spec.js index 04627e2307b..6932b3b5197 100644 --- a/spec/frontend/runner/components/runner_projects_spec.js +++ b/spec/frontend/runner/components/runner_projects_spec.js @@ -1,4 +1,4 @@ -import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; +import { GlSkeletonLoader } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -34,7 +34,7 @@ describe('RunnerProjects', () => { let mockRunnerProjectsQuery; const findHeading = () => wrapper.find('h3'); - const findGlSkeletonLoading = () => wrapper.findComponent(GlSkeletonLoading); + const findGlSkeletonLoading = () => wrapper.findComponent(GlSkeletonLoader); const findRunnerAssignedItems = () => wrapper.findAllComponents(RunnerAssignedItem); const findRunnerPagination = () => wrapper.findComponent(RunnerPagination); diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js index 52bd51a974b..eb9f85a7d0f 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -16,6 +16,7 @@ import { updateHistory } from '~/lib/utils/url_utility'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerList from '~/runner/components/runner_list.vue'; +import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vue'; import RunnerStats from '~/runner/components/stat/runner_stats.vue'; import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue'; import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; @@ -48,6 +49,8 @@ import { groupRunnersCountData, onlineContactTimeoutSecs, staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, } from '../mock_data'; Vue.use(VueApollo); @@ -75,6 +78,7 @@ describe('GroupRunnersApp', () => { const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); const findRunnerList = () => wrapper.findComponent(RunnerList); + const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState); const findRunnerRow = (id) => extendedWrapper(wrapper.findByTestId(`runner-row-${id}`)); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page'); @@ -103,6 +107,8 @@ describe('GroupRunnersApp', () => { provide: { onlineContactTimeoutSecs, staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, }, }); }; @@ -388,8 +394,8 @@ describe('GroupRunnersApp', () => { await waitForPromises(); }); - it('shows a message for no results', async () => { - expect(wrapper.text()).toContain('No runners found'); + it('shows an empty state', async () => { + expect(findRunnerListEmptyState().exists()).toBe(true); }); }); diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js index 1c2333b552c..3368fc21544 100644 --- a/spec/frontend/runner/mock_data.js +++ b/spec/frontend/runner/mock_data.js @@ -19,7 +19,10 @@ import groupRunnersCountData from 'test_fixtures/graphql/runner/list/group_runne // Other mock data export const onlineContactTimeoutSecs = 2 * 60 * 60; -export const staleTimeoutSecs = 5259492; // Ruby's `2.months` +export const staleTimeoutSecs = 7889238; // Ruby's `3.months` + +export const emptyStateSvgPath = 'emptyStateSvgPath.svg'; +export const emptyStateFilteredSvgPath = 'emptyStateFilteredSvgPath.svg'; export { runnersData, diff --git a/spec/frontend/runner/runner_search_utils_spec.js b/spec/frontend/runner/runner_search_utils_spec.js index a3c1458ed26..1f102f86b2a 100644 --- a/spec/frontend/runner/runner_search_utils_spec.js +++ b/spec/frontend/runner/runner_search_utils_spec.js @@ -5,6 +5,7 @@ import { fromUrlQueryToSearch, fromSearchToUrl, fromSearchToVariables, + isSearchFiltered, } from '~/runner/runner_search_utils'; describe('search_params.js', () => { @@ -14,6 +15,7 @@ describe('search_params.js', () => { urlQuery: '', search: { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }, graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + isDefault: true, }, { name: 'a single status', @@ -268,7 +270,7 @@ describe('search_params.js', () => { describe('fromSearchToUrl', () => { examples.forEach(({ name, urlQuery, search }) => { it(`Converts ${name} to a url`, () => { - expect(fromSearchToUrl(search)).toEqual(`http://test.host/${urlQuery}`); + expect(fromSearchToUrl(search)).toBe(`http://test.host/${urlQuery}`); }); }); @@ -280,7 +282,7 @@ describe('search_params.js', () => { const search = { filters: [], sort: 'CREATED_DESC' }; const expectedUrl = `http://test.host/`; - expect(fromSearchToUrl(search, initalUrl)).toEqual(expectedUrl); + expect(fromSearchToUrl(search, initalUrl)).toBe(expectedUrl); }); it('When unrelated search parameter is present, it does not get removed', () => { @@ -288,7 +290,7 @@ describe('search_params.js', () => { const search = { filters: [], sort: 'CREATED_DESC' }; const expectedUrl = `http://test.host/?unrelated=UNRELATED`; - expect(fromSearchToUrl(search, initialUrl)).toEqual(expectedUrl); + expect(fromSearchToUrl(search, initialUrl)).toBe(expectedUrl); }); }); @@ -331,4 +333,16 @@ describe('search_params.js', () => { }); }); }); + + describe('isSearchFiltered', () => { + examples.forEach(({ name, search, isDefault }) => { + it(`Given ${name}, evaluates to ${isDefault ? 'not ' : ''}filtered`, () => { + expect(isSearchFiltered(search)).toBe(!isDefault); + }); + }); + + it('given a missing pagination, evaluates as not filtered', () => { + expect(isSearchFiltered({ pagination: null })).toBe(false); + }); + }); }); diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js index 67bd3194f20..2f93d3f6805 100644 --- a/spec/frontend/search/store/actions_spec.js +++ b/spec/frontend/search/store/actions_spec.js @@ -121,19 +121,12 @@ describe('Global Search Store Actions', () => { describe('when groupId is set', () => { it('calls Api.groupProjects with expected parameters', () => { - const callbackTest = jest.fn(); - actions.fetchProjects({ commit: mockCommit, state }, undefined, callbackTest); - expect(Api.groupProjects).toHaveBeenCalledWith( - state.query.group_id, - state.query.search, - { - order_by: 'similarity', - include_subgroups: true, - with_shared: false, - }, - callbackTest, - true, - ); + actions.fetchProjects({ commit: mockCommit, state }, undefined); + expect(Api.groupProjects).toHaveBeenCalledWith(state.query.group_id, state.query.search, { + order_by: 'similarity', + include_subgroups: true, + with_shared: false, + }); expect(Api.projects).not.toHaveBeenCalled(); }); }); diff --git a/spec/frontend/search_autocomplete_spec.js b/spec/frontend/search_autocomplete_spec.js index 4639552b4d3..266f047e9dc 100644 --- a/spec/frontend/search_autocomplete_spec.js +++ b/spec/frontend/search_autocomplete_spec.js @@ -53,7 +53,7 @@ describe('Search autocomplete dropdown', () => { }; const disableProjectIssues = () => { - document.querySelector('.js-search-project-options').setAttribute('data-issues-disabled', true); + document.querySelector('.js-search-project-options').dataset.issuesDisabled = true; }; // Mock `gl` object in window for dashboard specific page. App code will need it. diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index d7d46d0d415..de91e51924d 100644 --- a/spec/frontend/security_configuration/components/app_spec.js +++ b/spec/frontend/security_configuration/components/app_spec.js @@ -2,7 +2,6 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { GlTab, GlTabs, GlLink } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; - import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; import stubChildren from 'helpers/stub_children'; @@ -20,22 +19,14 @@ import { LICENSE_COMPLIANCE_DESCRIPTION, LICENSE_COMPLIANCE_HELP_PATH, AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY, - LICENSE_ULTIMATE, - LICENSE_PREMIUM, - LICENSE_FREE, } from '~/security_configuration/components/constants'; import FeatureCard from '~/security_configuration/components/feature_card.vue'; import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import currentLicenseQuery from '~/security_configuration/graphql/current_license.query.graphql'; -import waitForPromises from 'helpers/wait_for_promises'; - import UpgradeBanner from '~/security_configuration/components/upgrade_banner.vue'; import { REPORT_TYPE_LICENSE_COMPLIANCE, REPORT_TYPE_SAST, } from '~/vue_shared/security_reports/constants'; -import { getCurrentLicensePlanResponse } from '../mock_data'; const upgradePath = '/upgrade'; const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath'; @@ -50,31 +41,16 @@ Vue.use(VueApollo); describe('App component', () => { let wrapper; let userCalloutDismissSpy; - let mockApollo; - const createComponent = ({ - shouldShowCallout = true, - licenseQueryResponse = LICENSE_ULTIMATE, - ...propsData - }) => { + const createComponent = ({ shouldShowCallout = true, ...propsData }) => { userCalloutDismissSpy = jest.fn(); - mockApollo = createMockApollo([ - [ - currentLicenseQuery, - jest - .fn() - .mockResolvedValue( - licenseQueryResponse instanceof Error - ? licenseQueryResponse - : getCurrentLicensePlanResponse(licenseQueryResponse), - ), - ], - ]); - wrapper = extendedWrapper( mount(SecurityConfigurationApp, { - propsData, + propsData: { + securityTrainingEnabled: true, + ...propsData, + }, provide: { upgradePath, autoDevopsHelpPagePath, @@ -82,7 +58,6 @@ describe('App component', () => { projectFullPath, vulnerabilityTrainingDocsPath, }, - apolloProvider: mockApollo, stubs: { ...stubChildren(SecurityConfigurationApp), GlLink: false, @@ -157,7 +132,6 @@ describe('App component', () => { afterEach(() => { wrapper.destroy(); - mockApollo = null; }); describe('basic structure', () => { @@ -166,7 +140,6 @@ describe('App component', () => { augmentedSecurityFeatures: securityFeaturesMock, augmentedComplianceFeatures: complianceFeaturesMock, }); - await waitForPromises(); }); it('renders main-heading with correct text', () => { @@ -469,47 +442,42 @@ describe('App component', () => { }); describe('Vulnerability management', () => { - beforeEach(async () => { + it('does not show tab if security training is disabled', () => { createComponent({ augmentedSecurityFeatures: securityFeaturesMock, augmentedComplianceFeatures: complianceFeaturesMock, + securityTrainingEnabled: false, }); - await waitForPromises(); - }); - it('renders TrainingProviderList component', () => { - expect(findTrainingProviderList().exists()).toBe(true); + expect(findVulnerabilityManagementTab().exists()).toBe(false); }); - it('renders security training description', () => { - expect(findVulnerabilityManagementTab().text()).toContain(i18n.securityTrainingDescription); - }); - - it('renders link to help docs', () => { - const trainingLink = findVulnerabilityManagementTab().findComponent(GlLink); - - expect(trainingLink.text()).toBe('Learn more about vulnerability training'); - expect(trainingLink.attributes('href')).toBe(vulnerabilityTrainingDocsPath); - }); - - it.each` - licenseQueryResponse | display - ${LICENSE_ULTIMATE} | ${true} - ${LICENSE_PREMIUM} | ${false} - ${LICENSE_FREE} | ${false} - ${null} | ${true} - ${new Error()} | ${true} - `( - 'displays $display for license $licenseQueryResponse', - async ({ licenseQueryResponse, display }) => { + describe('security training enabled', () => { + beforeEach(async () => { createComponent({ - licenseQueryResponse, augmentedSecurityFeatures: securityFeaturesMock, augmentedComplianceFeatures: complianceFeaturesMock, }); - await waitForPromises(); - expect(findVulnerabilityManagementTab().exists()).toBe(display); - }, - ); + }); + + it('shows the tab if security training is enabled', () => { + expect(findVulnerabilityManagementTab().exists()).toBe(true); + }); + + it('renders TrainingProviderList component', () => { + expect(findTrainingProviderList().exists()).toBe(true); + }); + + it('renders security training description', () => { + expect(findVulnerabilityManagementTab().text()).toContain(i18n.securityTrainingDescription); + }); + + it('renders link to help docs', () => { + const trainingLink = findVulnerabilityManagementTab().findComponent(GlLink); + + expect(trainingLink.text()).toBe('Learn more about vulnerability training'); + expect(trainingLink.attributes('href')).toBe(vulnerabilityTrainingDocsPath); + }); + }); }); }); diff --git a/spec/frontend/security_configuration/mock_data.js b/spec/frontend/security_configuration/mock_data.js index 94a36472a1d..18a480bf082 100644 --- a/spec/frontend/security_configuration/mock_data.js +++ b/spec/frontend/security_configuration/mock_data.js @@ -111,12 +111,3 @@ export const tempProviderLogos = { svg: `<svg>${[testProviderName[1]]}</svg>`, }, }; - -export const getCurrentLicensePlanResponse = (plan) => ({ - data: { - currentLicense: { - id: 'gid://gitlab/License/1', - plan, - }, - }, -}); diff --git a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js index 69f6a6e6e04..a286eeef14f 100644 --- a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js +++ b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js @@ -1,5 +1,8 @@ import { shallowMount } from '@vue/test-utils'; +import { GlLink } from '@gitlab/ui'; import { TEST_HOST } from 'helpers/test_constants'; +import { TYPE_USER } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import AssigneeAvatar from '~/sidebar/components/assignees/assignee_avatar.vue'; import AssigneeAvatarLink from '~/sidebar/components/assignees/assignee_avatar_link.vue'; import userDataMock from '../../user_data_mock'; @@ -32,6 +35,7 @@ describe('AssigneeAvatarLink component', () => { }); const findTooltipText = () => wrapper.attributes('title'); + const findUserLink = () => wrapper.findComponent(GlLink); it('has the root url present in the assigneeUrl method', () => { createComponent(); @@ -112,4 +116,24 @@ describe('AssigneeAvatarLink component', () => { }); }, ); + + it('passes the correct user id for REST API', () => { + createComponent({ + tooltipHasName: true, + user: userDataMock(), + }); + + expect(findUserLink().attributes('data-user-id')).toBe(String(userDataMock().id)); + }); + + it('passes the correct user id for GraphQL API', () => { + const userId = userDataMock().id; + + createComponent({ + tooltipHasName: true, + user: { ...userDataMock(), id: convertToGraphQLId(TYPE_USER, userId) }, + }); + + expect(findUserLink().attributes('data-user-id')).toBe(String(userId)); + }); }); diff --git a/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js index c870bbecd76..724fba62479 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js @@ -72,7 +72,7 @@ describe('boards sidebar remove issue', () => { createComponent({ canUpdate: true, slots }); findEditButton().vm.$emit('click'); - await nextTick; + await nextTick(); expect(findCollapsed().isVisible()).toBe(false); expect(findExpanded().isVisible()).toBe(true); diff --git a/spec/frontend/sidebar/components/attention_requested_toggle_spec.js b/spec/frontend/sidebar/components/attention_requested_toggle_spec.js index 959fa799eb7..58fa878a189 100644 --- a/spec/frontend/sidebar/components/attention_requested_toggle_spec.js +++ b/spec/frontend/sidebar/components/attention_requested_toggle_spec.js @@ -41,18 +41,18 @@ describe('Attention require toggle', () => { ); it.each` - attentionRequested | variant - ${true} | ${'warning'} - ${false} | ${'default'} + attentionRequested | selected + ${true} | ${true} + ${false} | ${false} `( - 'renders button with variant $variant when attention_requested is $attentionRequested', - ({ attentionRequested, variant }) => { + 'renders button with as selected when $selected when attention_requested is $attentionRequested', + ({ attentionRequested, selected }) => { factory({ type: 'reviewer', user: { attention_requested: attentionRequested, can_update_merge_request: true }, }); - expect(findToggle().props('variant')).toBe(variant); + expect(findToggle().props('selected')).toBe(selected); }, ); diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js index ab45fdf03bc..81354d64a90 100644 --- a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js +++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js @@ -69,14 +69,14 @@ describe('Sidebar Confidentiality Content', () => { variant: 'warning', }); expect(alertEl.text()).toBe( - 'Only project members with at least Reporter role can view or be notified about this issue.', + 'Only project members with at least the Reporter role, the author, and assignees can view or be notified about this issue.', ); }); it('displays a correct confidential text for epic', () => { createComponent({ confidential: true, issuableType: 'epic' }); expect(findText().findComponent(GlAlert).text()).toBe( - 'Only group members with at least Reporter role can view or be notified about this epic.', + 'Only group members with at least the Reporter role can view or be notified about this epic.', ); }); }); diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js index 85d6bc7b782..1ea035c7184 100644 --- a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js +++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js @@ -89,7 +89,7 @@ describe('Sidebar Confidentiality Form', () => { it('renders a message about making an issue confidential', () => { expect(findWarningMessage().text()).toBe( - 'You are going to turn on confidentiality. Only project members with at least Reporter role can view or be notified about this issue.', + 'You are going to turn on confidentiality. Only project members with at least the Reporter role, the author, and assignees can view or be notified about this issue.', ); }); diff --git a/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js b/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js index a8dc610672c..88a4913a27f 100644 --- a/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js +++ b/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js @@ -1,6 +1,12 @@ import { createLocalVue } from '@vue/test-utils'; import { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; +import { + fetchData, + fetchError, + mutationData, + mutationError, +} from 'ee_else_ce_jest/sidebar/components/incidents/mock_data'; import createMockApollo from 'helpers/mock_apollo_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { mountExtended } from 'helpers/vue_test_utils_helper'; @@ -12,7 +18,6 @@ import EscalationStatus from 'ee_else_ce/sidebar/components/incidents/escalation import { STATUS_ACKNOWLEDGED } from '~/sidebar/components/incidents/constants'; import { createAlert } from '~/flash'; import { logError } from '~/lib/logger'; -import { fetchData, fetchError, mutationData, mutationError } from './mock_data'; jest.mock('~/lib/logger'); jest.mock('~/flash'); diff --git a/spec/frontend/sidebar/components/time_tracking/mock_data.js b/spec/frontend/sidebar/components/time_tracking/mock_data.js index ba2781118d9..f161ae677d0 100644 --- a/spec/frontend/sidebar/components/time_tracking/mock_data.js +++ b/spec/frontend/sidebar/components/time_tracking/mock_data.js @@ -1,3 +1,5 @@ +export const timelogToRemoveId = 'gid://gitlab/Timelog/18'; + export const getIssueTimelogsQueryResponse = { data: { issuable: { @@ -9,7 +11,7 @@ export const getIssueTimelogsQueryResponse = { nodes: [ { __typename: 'Timelog', - id: 'gid://gitlab/Timelog/18', + id: timelogToRemoveId, timeSpent: 14400, user: { id: 'user-1', @@ -23,6 +25,10 @@ export const getIssueTimelogsQueryResponse = { __typename: 'Note', }, summary: 'A summary', + userPermissions: { + adminTimelog: true, + __typename: 'TimelogPermissions', + }, }, { __typename: 'Timelog', @@ -36,6 +42,10 @@ export const getIssueTimelogsQueryResponse = { spentAt: '2021-05-07T13:19:01Z', note: null, summary: 'A summary', + userPermissions: { + adminTimelog: false, + __typename: 'TimelogPermissions', + }, }, { __typename: 'Timelog', @@ -53,6 +63,10 @@ export const getIssueTimelogsQueryResponse = { __typename: 'Note', }, summary: null, + userPermissions: { + adminTimelog: false, + __typename: 'TimelogPermissions', + }, }, ], __typename: 'TimelogConnection', @@ -85,6 +99,10 @@ export const getMrTimelogsQueryResponse = { __typename: 'Note', }, summary: null, + userPermissions: { + adminTimelog: true, + __typename: 'TimelogPermissions', + }, }, { __typename: 'Timelog', @@ -98,6 +116,10 @@ export const getMrTimelogsQueryResponse = { spentAt: '2021-05-07T14:44:39Z', note: null, summary: null, + userPermissions: { + adminTimelog: true, + __typename: 'TimelogPermissions', + }, }, { __typename: 'Timelog', @@ -115,6 +137,10 @@ export const getMrTimelogsQueryResponse = { __typename: 'Note', }, summary: null, + userPermissions: { + adminTimelog: true, + __typename: 'TimelogPermissions', + }, }, ], __typename: 'TimelogConnection', diff --git a/spec/frontend/sidebar/components/time_tracking/report_spec.js b/spec/frontend/sidebar/components/time_tracking/report_spec.js index 2b17e6dd6c3..5ed8810e95e 100644 --- a/spec/frontend/sidebar/components/time_tracking/report_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/report_spec.js @@ -1,15 +1,21 @@ import { GlLoadingIcon } from '@gitlab/ui'; -import { getAllByRole, getByRole } from '@testing-library/dom'; +import { getAllByRole, getByRole, getAllByTestId } from '@testing-library/dom'; import { shallowMount, mount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import Report from '~/sidebar/components/time_tracking/report.vue'; import getIssueTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql'; import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql'; -import { getIssueTimelogsQueryResponse, getMrTimelogsQueryResponse } from './mock_data'; +import deleteTimelogMutation from '~/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql'; +import { + getIssueTimelogsQueryResponse, + getMrTimelogsQueryResponse, + timelogToRemoveId, +} from './mock_data'; jest.mock('~/flash'); @@ -18,6 +24,7 @@ describe('Issuable Time Tracking Report', () => { let wrapper; let fakeApollo; const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findDeleteButton = () => wrapper.findByTestId('deleteButton'); const successIssueQueryHandler = jest.fn().mockResolvedValue(getIssueTimelogsQueryResponse); const successMrQueryHandler = jest.fn().mockResolvedValue(getMrTimelogsQueryResponse); @@ -31,14 +38,16 @@ describe('Issuable Time Tracking Report', () => { [getIssueTimelogsQuery, queryHandler], [getMrTimelogsQuery, queryHandler], ]); - wrapper = mountFunction(Report, { - provide: { - issuableId: 1, - issuableType, - }, - propsData: { limitToHours, issuableId: '1' }, - apolloProvider: fakeApollo, - }); + wrapper = extendedWrapper( + mountFunction(Report, { + provide: { + issuableId: 1, + issuableType, + }, + propsData: { limitToHours, issuableId: '1' }, + apolloProvider: fakeApollo, + }), + ); }; afterEach(() => { @@ -75,6 +84,7 @@ describe('Issuable Time Tracking Report', () => { expect(getAllByRole(wrapper.element, 'row', { name: /Administrator/i })).toHaveLength(2); expect(getAllByRole(wrapper.element, 'row', { name: /A note/i })).toHaveLength(1); expect(getAllByRole(wrapper.element, 'row', { name: /A summary/i })).toHaveLength(2); + expect(getAllByTestId(wrapper.element, 'deleteButton')).toHaveLength(1); }); }); @@ -95,6 +105,7 @@ describe('Issuable Time Tracking Report', () => { await waitForPromises(); expect(getAllByRole(wrapper.element, 'row', { name: /Administrator/i })).toHaveLength(3); + expect(getAllByTestId(wrapper.element, 'deleteButton')).toHaveLength(3); }); }); @@ -123,4 +134,59 @@ describe('Issuable Time Tracking Report', () => { }); }); }); + + describe('when clicking on the delete timelog button', () => { + beforeEach(() => { + mountComponent({ mountFunction: mount }); + }); + + it('calls `$apollo.mutate` with deleteTimelogMutation mutation and removes the row', async () => { + const mutateSpy = jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ + data: { + timelogDelete: { + errors: [], + }, + }, + }); + + await waitForPromises(); + await findDeleteButton().trigger('click'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(mutateSpy).toHaveBeenCalledWith({ + mutation: deleteTimelogMutation, + variables: { + input: { + id: timelogToRemoveId, + }, + }, + update: expect.anything(), + }); + }); + + it('calls `createFlash` with errorMessage and does not remove the row on promise reject', async () => { + const mutateSpy = jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({}); + + await waitForPromises(); + await findDeleteButton().trigger('click'); + await waitForPromises(); + + expect(mutateSpy).toHaveBeenCalledWith({ + mutation: deleteTimelogMutation, + variables: { + input: { + id: timelogToRemoveId, + }, + }, + update: expect.anything(), + }); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'An error occurred while removing the timelog.', + captureError: true, + error: expect.any(Object), + }); + }); + }); }); diff --git a/spec/frontend/static_site_editor/components/app_spec.js b/spec/frontend/static_site_editor/components/app_spec.js deleted file mode 100644 index bbdffeae68f..00000000000 --- a/spec/frontend/static_site_editor/components/app_spec.js +++ /dev/null @@ -1,34 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import App from '~/static_site_editor/components/app.vue'; - -describe('static_site_editor/components/app', () => { - const mergeRequestsIllustrationPath = 'illustrations/merge_requests.svg'; - const RouterView = { - template: '<div></div>', - }; - let wrapper; - - const buildWrapper = () => { - wrapper = shallowMount(App, { - stubs: { - RouterView, - }, - propsData: { - mergeRequestsIllustrationPath, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('passes merge request illustration path to the router view component', () => { - buildWrapper(); - - expect(wrapper.find(RouterView).attributes()).toMatchObject({ - 'merge-requests-illustration-path': mergeRequestsIllustrationPath, - }); - }); -}); diff --git a/spec/frontend/static_site_editor/components/edit_area_spec.js b/spec/frontend/static_site_editor/components/edit_area_spec.js deleted file mode 100644 index a833fd9ff9e..00000000000 --- a/spec/frontend/static_site_editor/components/edit_area_spec.js +++ /dev/null @@ -1,264 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { stubComponent } from 'helpers/stub_component'; - -import EditArea from '~/static_site_editor/components/edit_area.vue'; -import EditDrawer from '~/static_site_editor/components/edit_drawer.vue'; -import EditHeader from '~/static_site_editor/components/edit_header.vue'; -import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue'; -import UnsavedChangesConfirmDialog from '~/static_site_editor/components/unsaved_changes_confirm_dialog.vue'; -import { EDITOR_TYPES } from '~/static_site_editor/rich_content_editor/constants'; -import RichContentEditor from '~/static_site_editor/rich_content_editor/rich_content_editor.vue'; - -import { - sourceContentTitle as title, - sourceContentYAML as content, - sourceContentHeaderObjYAML as headerSettings, - sourceContentBody as body, - returnUrl, - mounts, - project, - branch, - baseUrl, - imageRoot, -} from '../mock_data'; - -jest.mock('~/static_site_editor/services/formatter', () => jest.fn((str) => `${str} format-pass`)); - -describe('~/static_site_editor/components/edit_area.vue', () => { - let wrapper; - const formattedBody = `${body} format-pass`; - const savingChanges = true; - const newBody = `new ${body}`; - - const RichContentEditorStub = stubComponent(RichContentEditor, { - methods: { - resetInitialValue: jest.fn(), - }, - }); - - const buildWrapper = (propsData = {}) => { - wrapper = shallowMount(EditArea, { - propsData: { - title, - content, - returnUrl, - mounts, - project, - branch, - baseUrl, - imageRoot, - savingChanges, - ...propsData, - }, - stubs: { RichContentEditor: RichContentEditorStub }, - }); - }; - - const findEditHeader = () => wrapper.find(EditHeader); - const findEditDrawer = () => wrapper.find(EditDrawer); - const findRichContentEditor = () => wrapper.find(RichContentEditor); - const findPublishToolbar = () => wrapper.find(PublishToolbar); - const findUnsavedChangesConfirmDialog = () => wrapper.find(UnsavedChangesConfirmDialog); - - beforeEach(() => { - buildWrapper(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('renders edit header', () => { - expect(findEditHeader().exists()).toBe(true); - expect(findEditHeader().props('title')).toBe(title); - }); - - it('renders edit drawer', () => { - expect(findEditDrawer().exists()).toBe(true); - }); - - it('renders rich content editor with a format pass', () => { - expect(findRichContentEditor().exists()).toBe(true); - expect(findRichContentEditor().props('content')).toBe(formattedBody); - }); - - it('renders publish toolbar', () => { - expect(findPublishToolbar().exists()).toBe(true); - expect(findPublishToolbar().props()).toMatchObject({ - returnUrl, - savingChanges, - saveable: false, - }); - }); - - it('renders unsaved changes confirm dialog', () => { - expect(findUnsavedChangesConfirmDialog().exists()).toBe(true); - expect(findUnsavedChangesConfirmDialog().props('modified')).toBe(false); - }); - - describe('when content changes', () => { - beforeEach(() => { - findRichContentEditor().vm.$emit('input', newBody); - - return nextTick(); - }); - - it('updates parsedSource with new content', () => { - const newContent = 'New content'; - const spySyncParsedSource = jest.spyOn(wrapper.vm.parsedSource, 'syncContent'); - - findRichContentEditor().vm.$emit('input', newContent); - - expect(spySyncParsedSource).toHaveBeenCalledWith(newContent, true); - }); - - it('sets publish toolbar as saveable', () => { - expect(findPublishToolbar().props('saveable')).toBe(true); - }); - - it('sets unsaved changes confirm dialog as modified', () => { - expect(findUnsavedChangesConfirmDialog().props('modified')).toBe(true); - }); - - it('sets publish toolbar as not saveable when content changes are rollback', async () => { - findRichContentEditor().vm.$emit('input', formattedBody); - - await nextTick(); - expect(findPublishToolbar().props('saveable')).toBe(false); - }); - }); - - describe('when the mode changes', () => { - const setInitialMode = (mode) => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ editorMode: mode }); - }; - - afterEach(() => { - setInitialMode(EDITOR_TYPES.wysiwyg); - }); - - it.each` - initialMode | targetMode | resetValue - ${EDITOR_TYPES.wysiwyg} | ${EDITOR_TYPES.markdown} | ${`${content} format-pass format-pass`} - ${EDITOR_TYPES.markdown} | ${EDITOR_TYPES.wysiwyg} | ${`${body} format-pass format-pass`} - `( - 'sets editorMode from $initialMode to $targetMode', - ({ initialMode, targetMode, resetValue }) => { - setInitialMode(initialMode); - - findRichContentEditor().vm.$emit('modeChange', targetMode); - - expect(RichContentEditorStub.methods.resetInitialValue).toHaveBeenCalledWith(resetValue); - expect(wrapper.vm.editorMode).toBe(targetMode); - }, - ); - - it('should format the content', () => { - findRichContentEditor().vm.$emit('modeChange', EDITOR_TYPES.markdown); - - expect(RichContentEditorStub.methods.resetInitialValue).toHaveBeenCalledWith( - `${content} format-pass format-pass`, - ); - }); - }); - - describe('when content has front matter', () => { - it('renders a closed edit drawer', () => { - expect(findEditDrawer().exists()).toBe(true); - expect(findEditDrawer().props('isOpen')).toBe(false); - }); - - it('opens the edit drawer', async () => { - findPublishToolbar().vm.$emit('editSettings'); - - await nextTick(); - expect(findEditDrawer().props('isOpen')).toBe(true); - }); - - it('closes the edit drawer', async () => { - findEditDrawer().vm.$emit('close'); - - await nextTick(); - expect(findEditDrawer().props('isOpen')).toBe(false); - }); - - it('forwards the matter settings when the drawer is open', async () => { - findPublishToolbar().vm.$emit('editSettings'); - - jest.spyOn(wrapper.vm.parsedSource, 'matter').mockReturnValueOnce(headerSettings); - - await nextTick(); - expect(findEditDrawer().props('settings')).toEqual(headerSettings); - }); - - it('enables toolbar submit button', () => { - expect(findPublishToolbar().props('hasSettings')).toBe(true); - }); - - it('syncs matter changes regardless of edit mode', () => { - const newSettings = { title: 'test' }; - const spySyncParsedSource = jest.spyOn(wrapper.vm.parsedSource, 'syncMatter'); - - findEditDrawer().vm.$emit('updateSettings', newSettings); - - expect(spySyncParsedSource).toHaveBeenCalledWith(newSettings); - }); - - it('syncs matter changes to content in markdown mode', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ editorMode: EDITOR_TYPES.markdown }); - - const newSettings = { title: 'test' }; - - findEditDrawer().vm.$emit('updateSettings', newSettings); - - await nextTick(); - expect(findRichContentEditor().props('content')).toContain('title: test'); - }); - }); - - describe('when content lacks front matter', () => { - beforeEach(() => { - buildWrapper({ content: body }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('does not render edit drawer', () => { - expect(findEditDrawer().exists()).toBe(false); - }); - - it('does not enable toolbar submit button', () => { - expect(findPublishToolbar().props('hasSettings')).toBe(false); - }); - }); - - describe('when content is submitted', () => { - it('should format the content', () => { - findPublishToolbar().vm.$emit('submit', content); - - expect(wrapper.emitted('submit')[0][0].content).toBe(`${content} format-pass format-pass`); - expect(wrapper.emitted('submit').length).toBe(1); - }); - }); - - describe('when RichContentEditor component triggers load event', () => { - it('stores formatted markdown provided in the event data', () => { - const data = { formattedMarkdown: 'formatted markdown' }; - - findRichContentEditor().vm.$emit('load', data); - - // We can access the formatted markdown when submitting changes - findPublishToolbar().vm.$emit('submit'); - - expect(wrapper.emitted('submit')[0][0]).toMatchObject(data); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/components/edit_drawer_spec.js b/spec/frontend/static_site_editor/components/edit_drawer_spec.js deleted file mode 100644 index 402dfe441c5..00000000000 --- a/spec/frontend/static_site_editor/components/edit_drawer_spec.js +++ /dev/null @@ -1,67 +0,0 @@ -import { GlDrawer } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; - -import EditDrawer from '~/static_site_editor/components/edit_drawer.vue'; -import FrontMatterControls from '~/static_site_editor/components/front_matter_controls.vue'; - -describe('~/static_site_editor/components/edit_drawer.vue', () => { - let wrapper; - - const buildWrapper = (propsData = {}) => { - wrapper = shallowMount(EditDrawer, { - propsData: { - isOpen: false, - settings: { title: 'Some title' }, - ...propsData, - }, - }); - }; - - const findFrontMatterControls = () => wrapper.find(FrontMatterControls); - const findGlDrawer = () => wrapper.find(GlDrawer); - - beforeEach(() => { - buildWrapper(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('renders the GlDrawer', () => { - expect(findGlDrawer().exists()).toBe(true); - }); - - it('renders the FrontMatterControls', () => { - expect(findFrontMatterControls().exists()).toBe(true); - }); - - it('forwards the settings to FrontMatterControls', () => { - expect(findFrontMatterControls().props('settings')).toBe(wrapper.props('settings')); - }); - - it('is closed by default', () => { - expect(findGlDrawer().props('open')).toBe(false); - }); - - it('can open', () => { - buildWrapper({ isOpen: true }); - - expect(findGlDrawer().props('open')).toBe(true); - }); - - it.each` - event | payload | finderFn - ${'close'} | ${undefined} | ${findGlDrawer} - ${'updateSettings'} | ${{ some: 'data' }} | ${findFrontMatterControls} - `( - 'forwards the emitted $event event from the $finderFn with $payload', - ({ event, payload, finderFn }) => { - finderFn().vm.$emit(event, payload); - - expect(wrapper.emitted(event)[0][0]).toBe(payload); - expect(wrapper.emitted(event).length).toBe(1); - }, - ); -}); diff --git a/spec/frontend/static_site_editor/components/edit_header_spec.js b/spec/frontend/static_site_editor/components/edit_header_spec.js deleted file mode 100644 index 2b0fe226a0b..00000000000 --- a/spec/frontend/static_site_editor/components/edit_header_spec.js +++ /dev/null @@ -1,38 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; - -import EditHeader from '~/static_site_editor/components/edit_header.vue'; -import { DEFAULT_HEADING } from '~/static_site_editor/constants'; - -import { sourceContentTitle } from '../mock_data'; - -describe('~/static_site_editor/components/edit_header.vue', () => { - let wrapper; - - const buildWrapper = (propsData = {}) => { - wrapper = shallowMount(EditHeader, { - propsData: { - ...propsData, - }, - }); - }; - - const findHeading = () => wrapper.find({ ref: 'sseHeading' }); - - beforeEach(() => { - buildWrapper(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders the default heading if there is no title prop', () => { - expect(findHeading().text()).toBe(DEFAULT_HEADING); - }); - - it('renders the title prop value in the heading', () => { - buildWrapper({ title: sourceContentTitle }); - - expect(findHeading().text()).toBe(sourceContentTitle); - }); -}); diff --git a/spec/frontend/static_site_editor/components/edit_meta_controls_spec.js b/spec/frontend/static_site_editor/components/edit_meta_controls_spec.js deleted file mode 100644 index f6b29e98e5f..00000000000 --- a/spec/frontend/static_site_editor/components/edit_meta_controls_spec.js +++ /dev/null @@ -1,115 +0,0 @@ -import { GlDropdown, GlDropdownItem, GlFormInput, GlFormTextarea } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; - -import { nextTick } from 'vue'; -import EditMetaControls from '~/static_site_editor/components/edit_meta_controls.vue'; - -import { mergeRequestMeta, mergeRequestTemplates } from '../mock_data'; - -describe('~/static_site_editor/components/edit_meta_controls.vue', () => { - let wrapper; - let mockSelect; - let mockGlFormInputTitleInstance; - const { title, description } = mergeRequestMeta; - const newTitle = 'New title'; - const newDescription = 'New description'; - - const buildWrapper = (propsData = {}) => { - wrapper = shallowMount(EditMetaControls, { - propsData: { - title, - description, - templates: mergeRequestTemplates, - currentTemplate: null, - ...propsData, - }, - }); - }; - - const buildMocks = () => { - mockSelect = jest.fn(); - mockGlFormInputTitleInstance = { $el: { select: mockSelect } }; - wrapper.vm.$refs.title = mockGlFormInputTitleInstance; - }; - - const findGlFormInputTitle = () => wrapper.find(GlFormInput); - const findGlDropdownDescriptionTemplate = () => wrapper.find(GlDropdown); - const findAllDropdownItems = () => wrapper.findAll(GlDropdownItem); - const findDropdownItemByIndex = (index) => findAllDropdownItems().at(index); - - const findGlFormTextAreaDescription = () => wrapper.find(GlFormTextarea); - - beforeEach(async () => { - buildWrapper(); - buildMocks(); - - await nextTick(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('renders the title input', () => { - expect(findGlFormInputTitle().exists()).toBe(true); - }); - - it('renders the description template dropdown', () => { - expect(findGlDropdownDescriptionTemplate().exists()).toBe(true); - }); - - it('renders the description input', () => { - expect(findGlFormTextAreaDescription().exists()).toBe(true); - }); - - it('forwards the title prop to the title input', () => { - expect(findGlFormInputTitle().attributes().value).toBe(title); - }); - - it('forwards the description prop to the description input', () => { - expect(findGlFormTextAreaDescription().attributes().value).toBe(description); - }); - - it('calls select on the title input when mounted', () => { - expect(mockGlFormInputTitleInstance.$el.select).toHaveBeenCalled(); - }); - - it('renders a GlDropdownItem per template plus one (for the starting none option)', () => { - expect(findDropdownItemByIndex(0).text()).toBe('None'); - expect(findAllDropdownItems().length).toBe(mergeRequestTemplates.length + 1); - }); - - describe('when inputs change', () => { - const storageKey = 'sse-merge-request-meta-local-storage-editable'; - - afterEach(() => { - localStorage.removeItem(storageKey); - }); - - it.each` - findFn | key | value - ${findGlFormInputTitle} | ${'title'} | ${newTitle} - ${findGlFormTextAreaDescription} | ${'description'} | ${newDescription} - `('emits updated settings when $findFn input updates', ({ key, value, findFn }) => { - findFn().vm.$emit('input', value); - - const newSettings = { ...mergeRequestMeta, [key]: value }; - - expect(wrapper.emitted('updateSettings')[0][0]).toMatchObject(newSettings); - }); - }); - - describe('when templates change', () => { - it.each` - index | value - ${0} | ${null} - ${1} | ${mergeRequestTemplates[0]} - ${2} | ${mergeRequestTemplates[1]} - `('emits a change template event when $index is clicked', ({ index, value }) => { - findDropdownItemByIndex(index).vm.$emit('click'); - - expect(wrapper.emitted('changeTemplate')[0][0]).toBe(value); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/components/edit_meta_modal_spec.js b/spec/frontend/static_site_editor/components/edit_meta_modal_spec.js deleted file mode 100644 index bf3f8b7f571..00000000000 --- a/spec/frontend/static_site_editor/components/edit_meta_modal_spec.js +++ /dev/null @@ -1,172 +0,0 @@ -import { GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; -import { useLocalStorageSpy } from 'helpers/local_storage_helper'; -import axios from '~/lib/utils/axios_utils'; -import EditMetaControls from '~/static_site_editor/components/edit_meta_controls.vue'; -import EditMetaModal from '~/static_site_editor/components/edit_meta_modal.vue'; -import { MR_META_LOCAL_STORAGE_KEY } from '~/static_site_editor/constants'; -import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import { - sourcePath, - mergeRequestMeta, - mergeRequestTemplates, - project as namespaceProject, -} from '../mock_data'; - -describe('~/static_site_editor/components/edit_meta_modal.vue', () => { - useLocalStorageSpy(); - - let wrapper; - let mockAxios; - const { title, description } = mergeRequestMeta; - const [namespace, project] = namespaceProject.split('/'); - - const buildWrapper = (propsData = {}, data = {}) => { - wrapper = shallowMount(EditMetaModal, { - propsData: { - sourcePath, - namespace, - project, - ...propsData, - }, - data: () => data, - }); - }; - - const buildMockAxios = () => { - mockAxios = new MockAdapter(axios); - const templatesMergeRequestsPath = `templates/merge_request`; - mockAxios - .onGet(`${namespace}/${project}/${templatesMergeRequestsPath}`) - .reply(200, mergeRequestTemplates); - }; - - const buildMockRefs = () => { - wrapper.vm.$refs.editMetaControls = { resetCachedEditable: jest.fn() }; - }; - - const findGlModal = () => wrapper.find(GlModal); - const findEditMetaControls = () => wrapper.find(EditMetaControls); - const findLocalStorageSync = () => wrapper.find(LocalStorageSync); - - beforeEach(async () => { - localStorage.setItem(MR_META_LOCAL_STORAGE_KEY); - - buildMockAxios(); - buildWrapper(); - buildMockRefs(); - - await nextTick(); - }); - - afterEach(() => { - mockAxios.restore(); - - wrapper.destroy(); - wrapper = null; - }); - - it('initializes initial merge request meta with local storage data', async () => { - const localStorageMeta = { - title: 'stored title', - description: 'stored description', - templates: null, - currentTemplate: null, - }; - - findLocalStorageSync().vm.$emit('input', localStorageMeta); - - await nextTick(); - - expect(findEditMetaControls().props()).toEqual(localStorageMeta); - }); - - it('renders the modal', () => { - expect(findGlModal().exists()).toBe(true); - }); - - it('renders the edit meta controls', () => { - expect(findEditMetaControls().exists()).toBe(true); - }); - - it('contains the sourcePath in the title', () => { - expect(findEditMetaControls().props('title')).toContain(sourcePath); - }); - - it('forwards the title prop', () => { - expect(findEditMetaControls().props('title')).toBe(title); - }); - - it('forwards the description prop', () => { - expect(findEditMetaControls().props('description')).toBe(description); - }); - - it('forwards the templates prop', () => { - expect(findEditMetaControls().props('templates')).toBe(null); - }); - - it('forwards the currentTemplate prop', () => { - expect(findEditMetaControls().props('currentTemplate')).toBe(null); - }); - - describe('when save button is clicked', () => { - beforeEach(() => { - findGlModal().vm.$emit('primary', mergeRequestMeta); - }); - - it('removes merge request meta from local storage', () => { - expect(findLocalStorageSync().props().clear).toBe(true); - }); - - it('emits the primary event with mergeRequestMeta', () => { - expect(wrapper.emitted('primary')).toEqual([[mergeRequestMeta]]); - }); - }); - - describe('when templates exist', () => { - const template1 = mergeRequestTemplates[0]; - - beforeEach(() => { - buildWrapper({}, { templates: mergeRequestTemplates, currentTemplate: null }); - }); - - it('sets the currentTemplate on the changeTemplate event', async () => { - findEditMetaControls().vm.$emit('changeTemplate', template1); - - await nextTick(); - - expect(findEditMetaControls().props().currentTemplate).toBe(template1); - - findEditMetaControls().vm.$emit('changeTemplate', null); - - await nextTick(); - - expect(findEditMetaControls().props().currentTemplate).toBe(null); - }); - - it('updates the description on the changeTemplate event', async () => { - findEditMetaControls().vm.$emit('changeTemplate', template1); - - await nextTick(); - - expect(findEditMetaControls().props().description).toEqual(template1.content); - }); - }); - - it('emits the hide event', () => { - findGlModal().vm.$emit('hide'); - expect(wrapper.emitted('hide')).toEqual([[]]); - }); - - it('stores merge request meta changes in local storage when changes happen', async () => { - const newMeta = { title: 'new title', description: 'new description' }; - - findEditMetaControls().vm.$emit('updateSettings', newMeta); - - await nextTick(); - - expect(findLocalStorageSync().props('value')).toEqual(newMeta); - }); -}); diff --git a/spec/frontend/static_site_editor/components/front_matter_controls_spec.js b/spec/frontend/static_site_editor/components/front_matter_controls_spec.js deleted file mode 100644 index 5fda3b40306..00000000000 --- a/spec/frontend/static_site_editor/components/front_matter_controls_spec.js +++ /dev/null @@ -1,71 +0,0 @@ -import { GlFormGroup } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; - -import { humanize } from '~/lib/utils/text_utility'; - -import FrontMatterControls from '~/static_site_editor/components/front_matter_controls.vue'; - -import { sourceContentHeaderObjYAML as settings } from '../mock_data'; - -describe('~/static_site_editor/components/front_matter_controls.vue', () => { - let wrapper; - - const buildWrapper = (propsData = {}) => { - wrapper = shallowMount(FrontMatterControls, { - propsData: { - settings, - ...propsData, - }, - }); - }; - - beforeEach(() => { - buildWrapper(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('should render only the supported GlFormGroup types', () => { - expect(wrapper.findAll(GlFormGroup)).toHaveLength(3); - }); - - it.each` - key - ${'layout'} - ${'title'} - ${'twitter_image'} - `('renders field when key is $key', ({ key }) => { - const glFormGroup = wrapper.find(`#sse-front-matter-form-group-${key}`); - const glFormInput = wrapper.find(`#sse-front-matter-control-${key}`); - - expect(glFormGroup.exists()).toBe(true); - expect(glFormGroup.attributes().label).toBe(humanize(key)); - - expect(glFormInput.exists()).toBe(true); - expect(glFormInput.attributes().value).toBe(settings[key]); - }); - - it.each` - key - ${'suppress_header'} - ${'extra_css'} - `('does not render field when key is $key', ({ key }) => { - const glFormInput = wrapper.find(`#sse-front-matter-control-${key}`); - - expect(glFormInput.exists()).toBe(false); - }); - - it('emits updated settings when nested control updates', () => { - const elId = `#sse-front-matter-control-title`; - const glFormInput = wrapper.find(elId); - const newTitle = 'New title'; - - glFormInput.vm.$emit('input', newTitle); - - const newSettings = { ...settings, title: newTitle }; - - expect(wrapper.emitted('updateSettings')[0][0]).toMatchObject(newSettings); - }); -}); diff --git a/spec/frontend/static_site_editor/components/invalid_content_message_spec.js b/spec/frontend/static_site_editor/components/invalid_content_message_spec.js deleted file mode 100644 index 7e699e9451c..00000000000 --- a/spec/frontend/static_site_editor/components/invalid_content_message_spec.js +++ /dev/null @@ -1,23 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; - -import InvalidContentMessage from '~/static_site_editor/components/invalid_content_message.vue'; - -describe('~/static_site_editor/components/invalid_content_message.vue', () => { - let wrapper; - const findDocumentationButton = () => wrapper.find({ ref: 'documentationButton' }); - const documentationUrl = - 'https://gitlab.com/gitlab-org/project-templates/static-site-editor-middleman'; - - beforeEach(() => { - wrapper = shallowMount(InvalidContentMessage); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders the configuration button link', () => { - expect(findDocumentationButton().exists()).toBe(true); - expect(findDocumentationButton().attributes('href')).toBe(documentationUrl); - }); -}); diff --git a/spec/frontend/static_site_editor/components/publish_toolbar_spec.js b/spec/frontend/static_site_editor/components/publish_toolbar_spec.js deleted file mode 100644 index 9ba7e4a94d1..00000000000 --- a/spec/frontend/static_site_editor/components/publish_toolbar_spec.js +++ /dev/null @@ -1,92 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; - -import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue'; - -import { returnUrl } from '../mock_data'; - -describe('Static Site Editor Toolbar', () => { - let wrapper; - - const buildWrapper = (propsData = {}) => { - wrapper = shallowMount(PublishToolbar, { - propsData: { - hasSettings: false, - saveable: false, - ...propsData, - }, - }); - }; - - const findReturnUrlLink = () => wrapper.find({ ref: 'returnUrlLink' }); - const findSaveChangesButton = () => wrapper.find({ ref: 'submit' }); - const findEditSettingsButton = () => wrapper.find({ ref: 'settings' }); - - beforeEach(() => { - buildWrapper(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('does not render Settings button', () => { - expect(findEditSettingsButton().exists()).toBe(false); - }); - - it('renders Submit Changes button', () => { - expect(findSaveChangesButton().exists()).toBe(true); - }); - - it('disables Submit Changes button', () => { - expect(findSaveChangesButton().attributes('disabled')).toBe('true'); - }); - - it('does not render the Submit Changes button with a loader', () => { - expect(findSaveChangesButton().props('loading')).toBe(false); - }); - - it('does not render returnUrl link', () => { - expect(findReturnUrlLink().exists()).toBe(false); - }); - - it('renders returnUrl link when returnUrl prop exists', () => { - buildWrapper({ returnUrl }); - - expect(findReturnUrlLink().exists()).toBe(true); - expect(findReturnUrlLink().attributes('href')).toBe(returnUrl); - }); - - describe('when providing settings CTA', () => { - it('enables Submit Changes button', () => { - buildWrapper({ hasSettings: true }); - - expect(findEditSettingsButton().exists()).toBe(true); - }); - }); - - describe('when saveable', () => { - it('enables Submit Changes button', () => { - buildWrapper({ saveable: true }); - - expect(findSaveChangesButton().attributes('disabled')).toBeFalsy(); - }); - }); - - describe('when saving changes', () => { - beforeEach(() => { - buildWrapper({ savingChanges: true }); - }); - - it('renders the Submit Changes button with a loading indicator', () => { - expect(findSaveChangesButton().props('loading')).toBe(true); - }); - }); - - it('emits submit event when submit button is clicked', () => { - buildWrapper({ saveable: true }); - - findSaveChangesButton().vm.$emit('click'); - - expect(wrapper.emitted('submit')).toHaveLength(1); - }); -}); diff --git a/spec/frontend/static_site_editor/components/submit_changes_error_spec.js b/spec/frontend/static_site_editor/components/submit_changes_error_spec.js deleted file mode 100644 index 82a5c5f624a..00000000000 --- a/spec/frontend/static_site_editor/components/submit_changes_error_spec.js +++ /dev/null @@ -1,48 +0,0 @@ -import { GlButton, GlAlert } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; - -import SubmitChangesError from '~/static_site_editor/components/submit_changes_error.vue'; - -import { submitChangesError as error } from '../mock_data'; - -describe('Submit Changes Error', () => { - let wrapper; - - const buildWrapper = (propsData = {}) => { - wrapper = shallowMount(SubmitChangesError, { - propsData: { - ...propsData, - }, - stubs: { - GlAlert, - }, - }); - }; - - const findRetryButton = () => wrapper.find(GlButton); - const findAlert = () => wrapper.find(GlAlert); - - beforeEach(() => { - buildWrapper({ error }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders error message', () => { - expect(findAlert().text()).toContain(error); - }); - - it('emits dismiss event when alert emits dismiss event', () => { - findAlert().vm.$emit('dismiss'); - - expect(wrapper.emitted('dismiss')).toHaveLength(1); - }); - - it('emits retry event when retry button is clicked', () => { - findRetryButton().vm.$emit('click'); - - expect(wrapper.emitted('retry')).toHaveLength(1); - }); -}); diff --git a/spec/frontend/static_site_editor/components/unsaved_changes_confirm_dialog_spec.js b/spec/frontend/static_site_editor/components/unsaved_changes_confirm_dialog_spec.js deleted file mode 100644 index 9b8b22da693..00000000000 --- a/spec/frontend/static_site_editor/components/unsaved_changes_confirm_dialog_spec.js +++ /dev/null @@ -1,44 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; - -import UnsavedChangesConfirmDialog from '~/static_site_editor/components/unsaved_changes_confirm_dialog.vue'; - -describe('static_site_editor/components/unsaved_changes_confirm_dialog', () => { - let wrapper; - let event; - let returnValueSetter; - - const buildWrapper = (propsData = {}) => { - wrapper = shallowMount(UnsavedChangesConfirmDialog, { - propsData, - }); - }; - - beforeEach(() => { - event = new Event('beforeunload'); - - jest.spyOn(event, 'preventDefault'); - returnValueSetter = jest.spyOn(event, 'returnValue', 'set'); - }); - - afterEach(() => { - event.preventDefault.mockRestore(); - returnValueSetter.mockRestore(); - wrapper.destroy(); - }); - - it('displays confirmation dialog when modified = true', () => { - buildWrapper({ modified: true }); - window.dispatchEvent(event); - - expect(event.preventDefault).toHaveBeenCalled(); - expect(returnValueSetter).toHaveBeenCalledWith(''); - }); - - it('does not display confirmation dialog when modified = false', () => { - buildWrapper(); - window.dispatchEvent(event); - - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(returnValueSetter).not.toHaveBeenCalled(); - }); -}); diff --git a/spec/frontend/static_site_editor/graphql/resolvers/file_spec.js b/spec/frontend/static_site_editor/graphql/resolvers/file_spec.js deleted file mode 100644 index 83ad23f7dcf..00000000000 --- a/spec/frontend/static_site_editor/graphql/resolvers/file_spec.js +++ /dev/null @@ -1,25 +0,0 @@ -import fileResolver from '~/static_site_editor/graphql/resolvers/file'; -import loadSourceContent from '~/static_site_editor/services/load_source_content'; - -import { - projectId, - sourcePath, - sourceContentTitle as title, - sourceContentYAML as content, -} from '../../mock_data'; - -jest.mock('~/static_site_editor/services/load_source_content', () => jest.fn()); - -describe('static_site_editor/graphql/resolvers/file', () => { - it('returns file content and title when fetching file successfully', () => { - loadSourceContent.mockResolvedValueOnce({ title, content }); - - return fileResolver({ fullPath: projectId }, { path: sourcePath }).then((file) => { - expect(file).toEqual({ - __typename: 'File', - title, - content, - }); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/graphql/resolvers/has_submitted_changes_spec.js b/spec/frontend/static_site_editor/graphql/resolvers/has_submitted_changes_spec.js deleted file mode 100644 index 0670b240a3f..00000000000 --- a/spec/frontend/static_site_editor/graphql/resolvers/has_submitted_changes_spec.js +++ /dev/null @@ -1,27 +0,0 @@ -import appDataQuery from '~/static_site_editor/graphql/queries/app_data.query.graphql'; -import hasSubmittedChanges from '~/static_site_editor/graphql/resolvers/has_submitted_changes'; - -describe('static_site_editor/graphql/resolvers/has_submitted_changes', () => { - it('updates the cache with the data passed in input', () => { - const cachedData = { appData: { original: 'foo' } }; - const newValue = { input: { hasSubmittedChanges: true } }; - - const cache = { - readQuery: jest.fn().mockReturnValue(cachedData), - writeQuery: jest.fn(), - }; - hasSubmittedChanges(null, newValue, { cache }); - - expect(cache.readQuery).toHaveBeenCalledWith({ query: appDataQuery }); - expect(cache.writeQuery).toHaveBeenCalledWith({ - query: appDataQuery, - data: { - appData: { - __typename: 'AppData', - original: 'foo', - hasSubmittedChanges: true, - }, - }, - }); - }); -}); diff --git a/spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js b/spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js deleted file mode 100644 index a0529f5f945..00000000000 --- a/spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js +++ /dev/null @@ -1,37 +0,0 @@ -import savedContentMetaQuery from '~/static_site_editor/graphql/queries/saved_content_meta.query.graphql'; -import submitContentChangesResolver from '~/static_site_editor/graphql/resolvers/submit_content_changes'; -import submitContentChanges from '~/static_site_editor/services/submit_content_changes'; - -import { - projectId as project, - sourcePath, - username, - sourceContentYAML as content, - savedContentMeta, -} from '../../mock_data'; - -jest.mock('~/static_site_editor/services/submit_content_changes', () => jest.fn()); - -describe('static_site_editor/graphql/resolvers/submit_content_changes', () => { - it('writes savedContentMeta query with the data returned by the submitContentChanges service', () => { - const cache = { writeQuery: jest.fn() }; - - submitContentChanges.mockResolvedValueOnce(savedContentMeta); - - return submitContentChangesResolver( - {}, - { input: { path: sourcePath, project, sourcePath, content, username } }, - { cache }, - ).then(() => { - expect(cache.writeQuery).toHaveBeenCalledWith({ - query: savedContentMetaQuery, - data: { - savedContentMeta: { - __typename: 'SavedContentMeta', - ...savedContentMeta, - }, - }, - }); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/mock_data.js b/spec/frontend/static_site_editor/mock_data.js deleted file mode 100644 index 8d64e1799b8..00000000000 --- a/spec/frontend/static_site_editor/mock_data.js +++ /dev/null @@ -1,91 +0,0 @@ -export const sourceContentHeaderYAML = `--- -layout: handbook-page-toc -title: Handbook -twitter_image: /images/tweets/handbook-gitlab.png -suppress_header: true -extra_css: - - sales-and-free-trial-common.css - - form-to-resource.css ----`; -export const sourceContentHeaderObjYAML = { - layout: 'handbook-page-toc', - title: 'Handbook', - twitter_image: '/images/tweets/handbook-gitlab.png', - suppress_header: true, - extra_css: ['sales-and-free-trial-common.css', 'form-to-resource.css'], -}; -export const sourceContentSpacing = `\n`; -export const sourceContentBody = `## On this page -{:.no_toc .hidden-md .hidden-lg} - -- TOC -{:toc .hidden-md .hidden-lg} - -![image](path/to/image1.png)`; -export const sourceContentYAML = `${sourceContentHeaderYAML}${sourceContentSpacing}${sourceContentBody}`; -export const sourceContentTitle = 'Handbook'; - -export const username = 'gitlabuser'; -export const projectId = '123456'; -export const project = 'user1/project1'; -export const returnUrl = 'https://www.gitlab.com'; -export const sourcePath = 'foobar.md.html'; -export const mergeRequestMeta = { - title: `Update ${sourcePath} file`, - description: 'Copy update', -}; -export const savedContentMeta = { - branch: { - label: 'foobar', - url: 'foobar/-/tree/foobar', - }, - commit: { - label: 'c1461b08', - url: 'foobar/-/c1461b08', - }, - mergeRequest: { - label: '123', - url: 'foobar/-/merge_requests/123', - }, -}; -export const mergeRequestTemplates = [ - { key: 'Template1', name: 'Template 1', content: 'This is template 1!' }, - { key: 'Template2', name: 'Template 2', content: 'This is template 2!' }, -]; - -export const submitChangesError = 'Could not save changes'; -export const commitBranchResponse = { - web_url: '/tree/root-main-patch-88195', -}; -export const commitMultipleResponse = { - short_id: 'ed899a2f4b5', - web_url: '/commit/ed899a2f4b5', -}; -export const createMergeRequestResponse = { - iid: '123', - web_url: '/merge_requests/123', -}; - -export const trackingCategory = 'projects:static_site_editor:show'; - -export const images = new Map([ - ['path/to/image1.png', 'image1-content'], - ['path/to/image2.png', 'image2-content'], -]); - -export const mounts = [ - { - source: 'default/source/', - target: '', - }, - { - source: 'source/with/target', - target: 'target', - }, -]; - -export const branch = 'main'; - -export const baseUrl = '/user1/project1/-/sse/main%2Ftest.md'; - -export const imageRoot = 'source/images/'; diff --git a/spec/frontend/static_site_editor/pages/home_spec.js b/spec/frontend/static_site_editor/pages/home_spec.js deleted file mode 100644 index 6571d295c36..00000000000 --- a/spec/frontend/static_site_editor/pages/home_spec.js +++ /dev/null @@ -1,301 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import EditArea from '~/static_site_editor/components/edit_area.vue'; -import EditMetaModal from '~/static_site_editor/components/edit_meta_modal.vue'; -import InvalidContentMessage from '~/static_site_editor/components/invalid_content_message.vue'; -import SkeletonLoader from '~/static_site_editor/components/skeleton_loader.vue'; -import SubmitChangesError from '~/static_site_editor/components/submit_changes_error.vue'; -import { TRACKING_ACTION_INITIALIZE_EDITOR } from '~/static_site_editor/constants'; -import hasSubmittedChangesMutation from '~/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql'; -import submitContentChangesMutation from '~/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql'; -import Home from '~/static_site_editor/pages/home.vue'; -import { SUCCESS_ROUTE } from '~/static_site_editor/router/constants'; - -import { - project, - returnUrl, - sourceContentYAML as content, - sourceContentTitle as title, - sourcePath, - username, - mergeRequestMeta, - savedContentMeta, - submitChangesError, - trackingCategory, - images, - mounts, - branch, - baseUrl, - imageRoot, -} from '../mock_data'; - -describe('static_site_editor/pages/home', () => { - let wrapper; - let store; - let $apollo; - let $router; - let mutateMock; - let trackingSpy; - const defaultAppData = { - isSupportedContent: true, - hasSubmittedChanges: false, - returnUrl, - project, - username, - sourcePath, - mounts, - branch, - baseUrl, - imageUploadPath: imageRoot, - }; - const hasSubmittedChangesMutationPayload = { - data: { - appData: { ...defaultAppData, hasSubmittedChanges: true }, - }, - }; - - const buildApollo = (queries = {}) => { - mutateMock = jest.fn(); - - $apollo = { - queries: { - sourceContent: { - loading: false, - }, - ...queries, - }, - mutate: mutateMock, - }; - }; - - const buildRouter = () => { - $router = { - push: jest.fn(), - }; - }; - - const buildWrapper = (data = {}) => { - wrapper = shallowMount(Home, { - store, - mocks: { - $apollo, - $router, - }, - data() { - return { - appData: { ...defaultAppData }, - sourceContent: { title, content }, - ...data, - }; - }, - }); - }; - - const findEditArea = () => wrapper.find(EditArea); - const findEditMetaModal = () => wrapper.find(EditMetaModal); - const findInvalidContentMessage = () => wrapper.find(InvalidContentMessage); - const findSkeletonLoader = () => wrapper.find(SkeletonLoader); - const findSubmitChangesError = () => wrapper.find(SubmitChangesError); - - beforeEach(() => { - buildApollo(); - buildRouter(); - - document.body.dataset.page = trackingCategory; - trackingSpy = mockTracking(document.body.dataset.page, undefined, jest.spyOn); - }); - - afterEach(() => { - wrapper.destroy(); - unmockTracking(); - wrapper = null; - $apollo = null; - }); - - describe('when content is loaded', () => { - beforeEach(() => { - buildWrapper(); - }); - - it('renders edit area', () => { - expect(findEditArea().exists()).toBe(true); - }); - - it('provides source content, returnUrl, and isSavingChanges to the edit area', () => { - expect(findEditArea().props()).toMatchObject({ - title, - mounts, - content, - returnUrl, - savingChanges: false, - }); - }); - }); - - it('does not render edit area when content is not loaded', () => { - buildWrapper({ sourceContent: null }); - - expect(findEditArea().exists()).toBe(false); - }); - - it('renders skeleton loader when content is not loading', () => { - buildApollo({ - sourceContent: { - loading: true, - }, - }); - buildWrapper(); - - expect(findSkeletonLoader().exists()).toBe(true); - }); - - it('does not render skeleton loader when content is not loading', () => { - buildApollo({ - sourceContent: { - loading: false, - }, - }); - buildWrapper(); - - expect(findSkeletonLoader().exists()).toBe(false); - }); - - it('displays invalid content message when content is not supported', () => { - buildWrapper({ appData: { ...defaultAppData, isSupportedContent: false } }); - - expect(findInvalidContentMessage().exists()).toBe(true); - }); - - it('does not display invalid content message when content is supported', () => { - buildWrapper(); - - expect(findInvalidContentMessage().exists()).toBe(false); - }); - - it('renders an EditMetaModal component', () => { - buildWrapper(); - - expect(findEditMetaModal().exists()).toBe(true); - }); - - describe('when preparing submission', () => { - it('calls the show method when the edit-area submit event is emitted', async () => { - buildWrapper(); - - const mockInstance = { show: jest.fn() }; - wrapper.vm.$refs.editMetaModal = mockInstance; - - findEditArea().vm.$emit('submit', { content }); - - await nextTick(); - expect(mockInstance.show).toHaveBeenCalled(); - }); - }); - - describe('when submitting changes fails', () => { - const setupMutateMock = () => { - mutateMock - .mockResolvedValueOnce(hasSubmittedChangesMutationPayload) - .mockRejectedValueOnce(new Error(submitChangesError)); - }; - - beforeEach(async () => { - setupMutateMock(); - - buildWrapper({ content }); - findEditMetaModal().vm.$emit('primary', mergeRequestMeta); - - await nextTick(); - }); - - it('displays submit changes error message', () => { - expect(findSubmitChangesError().exists()).toBe(true); - }); - - it('retries submitting changes when retry button is clicked', () => { - setupMutateMock(); - - findSubmitChangesError().vm.$emit('retry'); - - expect(mutateMock).toHaveBeenCalled(); - }); - - it('hides submit changes error message when dismiss button is clicked', async () => { - findSubmitChangesError().vm.$emit('dismiss'); - - await nextTick(); - expect(findSubmitChangesError().exists()).toBe(false); - }); - }); - - describe('when submitting changes succeeds', () => { - const newContent = `new ${content}`; - const formattedMarkdown = `formatted ${content}`; - - beforeEach(async () => { - mutateMock.mockResolvedValueOnce(hasSubmittedChangesMutationPayload).mockResolvedValueOnce({ - data: { - submitContentChanges: savedContentMeta, - }, - }); - - buildWrapper(); - - findEditMetaModal().vm.show = jest.fn(); - - findEditArea().vm.$emit('submit', { content: newContent, images, formattedMarkdown }); - - findEditMetaModal().vm.$emit('primary', mergeRequestMeta); - - await nextTick(); - }); - - it('dispatches hasSubmittedChanges mutation', () => { - expect(mutateMock).toHaveBeenNthCalledWith(1, { - mutation: hasSubmittedChangesMutation, - variables: { - input: { - hasSubmittedChanges: true, - }, - }, - }); - }); - - it('dispatches submitContentChanges mutation', () => { - expect(mutateMock).toHaveBeenNthCalledWith(2, { - mutation: submitContentChangesMutation, - variables: { - input: { - content: newContent, - formattedMarkdown, - project, - sourcePath, - targetBranch: branch, - username, - images, - mergeRequestMeta, - }, - }, - }); - }); - - it('transitions to the SUCCESS route', () => { - expect($router.push).toHaveBeenCalledWith(SUCCESS_ROUTE); - }); - }); - - it('does not display submit changes error when an error does not exist', () => { - buildWrapper(); - - expect(findSubmitChangesError().exists()).toBe(false); - }); - - it('tracks when editor is initialized on the mounted lifecycle hook', () => { - buildWrapper(); - expect(trackingSpy).toHaveBeenCalledWith( - document.body.dataset.page, - TRACKING_ACTION_INITIALIZE_EDITOR, - ); - }); -}); diff --git a/spec/frontend/static_site_editor/pages/success_spec.js b/spec/frontend/static_site_editor/pages/success_spec.js deleted file mode 100644 index fbdc2c435a0..00000000000 --- a/spec/frontend/static_site_editor/pages/success_spec.js +++ /dev/null @@ -1,131 +0,0 @@ -import { GlButton, GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Success from '~/static_site_editor/pages/success.vue'; -import { HOME_ROUTE } from '~/static_site_editor/router/constants'; -import { savedContentMeta, returnUrl, sourcePath } from '../mock_data'; - -describe('~/static_site_editor/pages/success.vue', () => { - const mergeRequestsIllustrationPath = 'illustrations/merge_requests.svg'; - let wrapper; - let router; - - const buildRouter = () => { - router = { - push: jest.fn(), - }; - }; - - const buildWrapper = (data = {}, appData = {}) => { - wrapper = shallowMount(Success, { - mocks: { - $router: router, - }, - stubs: { - GlButton, - GlEmptyState, - GlLoadingIcon, - }, - propsData: { - mergeRequestsIllustrationPath, - }, - data() { - return { - savedContentMeta, - appData: { - returnUrl, - sourcePath, - hasSubmittedChanges: true, - ...appData, - }, - ...data, - }; - }, - }); - }; - - const findReturnUrlButton = () => wrapper.find(GlButton); - const findEmptyState = () => wrapper.find(GlEmptyState); - const findLoadingIcon = () => wrapper.find(GlLoadingIcon); - - beforeEach(() => { - buildRouter(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('when savedContentMeta is valid', () => { - it('renders empty state with a link to the created merge request', () => { - buildWrapper(); - - expect(findEmptyState().exists()).toBe(true); - expect(findEmptyState().props()).toMatchObject({ - primaryButtonText: 'View merge request', - primaryButtonLink: savedContentMeta.mergeRequest.url, - title: 'Your merge request has been created', - svgPath: mergeRequestsIllustrationPath, - svgHeight: 146, - }); - }); - - it('displays merge request instructions in the empty state', () => { - buildWrapper(); - - expect(findEmptyState().text()).toContain( - 'To see your changes live you will need to do the following things:', - ); - expect(findEmptyState().text()).toContain('1. Add a clear title to describe the change.'); - expect(findEmptyState().text()).toContain( - '2. Add a description to explain why the change is being made.', - ); - expect(findEmptyState().text()).toContain( - '3. Assign a person to review and accept the merge request.', - ); - }); - - it('displays return to site button', () => { - buildWrapper(); - - expect(findReturnUrlButton().text()).toBe('Return to site'); - expect(findReturnUrlButton().attributes().href).toBe(returnUrl); - }); - - it('displays source path', () => { - buildWrapper(); - - expect(wrapper.text()).toContain(`Update ${sourcePath} file`); - }); - }); - - describe('when savedContentMeta is invalid', () => { - it('renders empty state with a loader', () => { - buildWrapper({ savedContentMeta: null }); - - expect(findEmptyState().exists()).toBe(true); - expect(findEmptyState().props()).toMatchObject({ - title: 'Creating your merge request', - svgPath: mergeRequestsIllustrationPath, - }); - expect(findLoadingIcon().exists()).toBe(true); - }); - - it('displays helper info in the empty state', () => { - buildWrapper({ savedContentMeta: null }); - - expect(findEmptyState().text()).toContain( - 'You can set an assignee to get your changes reviewed and deployed once your merge request is created', - ); - expect(findEmptyState().text()).toContain( - 'A link to view the merge request will appear once ready', - ); - }); - - it('redirects to the HOME route when content has not been submitted', () => { - buildWrapper({ savedContentMeta: null }, { hasSubmittedChanges: false }); - - expect(router.push).toHaveBeenCalledWith(HOME_ROUTE); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/editor_service_spec.js b/spec/frontend/static_site_editor/rich_content_editor/editor_service_spec.js deleted file mode 100644 index cd0d09c085f..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/editor_service_spec.js +++ /dev/null @@ -1,214 +0,0 @@ -import buildCustomRenderer from '~/static_site_editor/rich_content_editor/services/build_custom_renderer'; -import buildHTMLToMarkdownRenderer from '~/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer'; -import { - generateToolbarItem, - addCustomEventListener, - removeCustomEventListener, - registerHTMLToMarkdownRenderer, - addImage, - insertVideo, - getMarkdown, - getEditorOptions, -} from '~/static_site_editor/rich_content_editor/services/editor_service'; -import sanitizeHTML from '~/static_site_editor/rich_content_editor/services/sanitize_html'; - -jest.mock('~/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer'); -jest.mock('~/static_site_editor/rich_content_editor/services/build_custom_renderer'); -jest.mock('~/static_site_editor/rich_content_editor/services/sanitize_html'); - -describe('Editor Service', () => { - let mockInstance; - let event; - let handler; - const parseHtml = (str) => { - const wrapper = document.createElement('div'); - wrapper.innerHTML = str; - return wrapper.firstChild; - }; - - beforeEach(() => { - mockInstance = { - eventManager: { addEventType: jest.fn(), removeEventHandler: jest.fn(), listen: jest.fn() }, - editor: { - exec: jest.fn(), - isWysiwygMode: jest.fn(), - getSquire: jest.fn(), - insertText: jest.fn(), - }, - invoke: jest.fn(), - toMarkOptions: { - renderer: { - constructor: { - factory: jest.fn(), - }, - }, - }, - }; - event = 'someCustomEvent'; - handler = jest.fn(); - }); - - describe('generateToolbarItem', () => { - const config = { - icon: 'bold', - command: 'some-command', - tooltip: 'Some Tooltip', - event: 'some-event', - }; - - const generatedItem = generateToolbarItem(config); - - it('generates the correct command', () => { - expect(generatedItem.options.command).toBe(config.command); - }); - - it('generates the correct event', () => { - expect(generatedItem.options.event).toBe(config.event); - }); - - it('generates a divider when isDivider is set to true', () => { - const isDivider = true; - - expect(generateToolbarItem({ isDivider })).toBe('divider'); - }); - }); - - describe('addCustomEventListener', () => { - it('registers an event type on the instance and adds an event handler', () => { - addCustomEventListener(mockInstance, event, handler); - - expect(mockInstance.eventManager.addEventType).toHaveBeenCalledWith(event); - expect(mockInstance.eventManager.listen).toHaveBeenCalledWith(event, handler); - }); - }); - - describe('removeCustomEventListener', () => { - it('removes an event handler from the instance', () => { - removeCustomEventListener(mockInstance, event, handler); - - expect(mockInstance.eventManager.removeEventHandler).toHaveBeenCalledWith(event, handler); - }); - }); - - describe('addImage', () => { - const file = new File([], 'some-file.jpg'); - const mockImage = { imageUrl: 'some/url.png', altText: 'some alt text' }; - - it('calls the insertElement method on the squire instance when in WYSIWYG mode', () => { - jest.spyOn(URL, 'createObjectURL'); - mockInstance.editor.isWysiwygMode.mockReturnValue(true); - mockInstance.editor.getSquire.mockReturnValue({ insertElement: jest.fn() }); - - addImage(mockInstance, mockImage, file); - - expect(mockInstance.editor.getSquire().insertElement).toHaveBeenCalled(); - expect(global.URL.createObjectURL).toHaveBeenLastCalledWith(file); - }); - - it('calls the insertText method on the instance when in Markdown mode', () => { - mockInstance.editor.isWysiwygMode.mockReturnValue(false); - addImage(mockInstance, mockImage, file); - - expect(mockInstance.editor.insertText).toHaveBeenCalledWith('![some alt text](some/url.png)'); - }); - }); - - describe('insertVideo', () => { - const mockUrl = 'some/url'; - const htmlString = `<figure contenteditable="false" class="gl-relative gl-h-0 video_container"><iframe class="gl-absolute gl-top-0 gl-left-0 gl-w-full gl-h-full" width="560" height="315" frameborder="0" src="some/url"></iframe></figure>`; - const mockInsertElement = jest.fn(); - - beforeEach(() => - mockInstance.editor.getSquire.mockReturnValue({ insertElement: mockInsertElement }), - ); - - describe('WYSIWYG mode', () => { - it('calls the insertElement method on the squire instance with an iFrame element', () => { - mockInstance.editor.isWysiwygMode.mockReturnValue(true); - - insertVideo(mockInstance, mockUrl); - - expect(mockInstance.editor.getSquire().insertElement).toHaveBeenCalledWith( - parseHtml(htmlString), - ); - }); - }); - - describe('Markdown mode', () => { - it('calls the insertText method on the editor instance with the iFrame element HTML', () => { - mockInstance.editor.isWysiwygMode.mockReturnValue(false); - - insertVideo(mockInstance, mockUrl); - - expect(mockInstance.editor.insertText).toHaveBeenCalledWith(htmlString); - }); - }); - }); - - describe('getMarkdown', () => { - it('calls the invoke method on the instance', () => { - getMarkdown(mockInstance); - - expect(mockInstance.invoke).toHaveBeenCalledWith('getMarkdown'); - }); - }); - - describe('registerHTMLToMarkdownRenderer', () => { - let baseRenderer; - const htmlToMarkdownRenderer = {}; - const extendedRenderer = {}; - - beforeEach(() => { - baseRenderer = mockInstance.toMarkOptions.renderer; - buildHTMLToMarkdownRenderer.mockReturnValueOnce(htmlToMarkdownRenderer); - baseRenderer.constructor.factory.mockReturnValueOnce(extendedRenderer); - - registerHTMLToMarkdownRenderer(mockInstance); - }); - - it('builds a new instance of the HTML to Markdown renderer', () => { - expect(buildHTMLToMarkdownRenderer).toHaveBeenCalledWith(baseRenderer); - }); - - it('extends base renderer with the HTML to Markdown renderer', () => { - expect(baseRenderer.constructor.factory).toHaveBeenCalledWith( - baseRenderer, - htmlToMarkdownRenderer, - ); - }); - - it('replaces the default renderer with extended renderer', () => { - expect(mockInstance.toMarkOptions.renderer).toBe(extendedRenderer); - }); - }); - - describe('getEditorOptions', () => { - const externalOptions = { - customRenderers: {}, - }; - const renderer = {}; - - beforeEach(() => { - buildCustomRenderer.mockReturnValueOnce(renderer); - }); - - it('generates a configuration object with a custom HTML renderer and toolbarItems', () => { - expect(getEditorOptions()).toHaveProp('customHTMLRenderer', renderer); - expect(getEditorOptions()).toHaveProp('toolbarItems'); - }); - - it('passes external renderers to the buildCustomRenderers function', () => { - getEditorOptions(externalOptions); - expect(buildCustomRenderer).toHaveBeenCalledWith(externalOptions.customRenderers); - }); - - it('uses the internal sanitizeHTML service for HTML sanitization', () => { - const options = getEditorOptions(); - const html = '<div></div>'; - - options.customHTMLSanitizer(html); - - expect(sanitizeHTML).toHaveBeenCalledWith(html); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js b/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js deleted file mode 100644 index c8c9f45618d..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js +++ /dev/null @@ -1,77 +0,0 @@ -import { GlModal, GlTabs } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { IMAGE_TABS } from '~/static_site_editor/rich_content_editor/constants'; -import AddImageModal from '~/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue'; -import UploadImageTab from '~/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab.vue'; - -describe('Add Image Modal', () => { - let wrapper; - const propsData = { imageRoot: 'path/to/root/' }; - - const findModal = () => wrapper.find(GlModal); - const findTabs = () => wrapper.find(GlTabs); - const findUploadImageTab = () => wrapper.find(UploadImageTab); - const findUrlInput = () => wrapper.find({ ref: 'urlInput' }); - const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' }); - - beforeEach(() => { - wrapper = shallowMount(AddImageModal, { propsData }); - }); - - describe('when content is loaded', () => { - it('renders a modal component', () => { - expect(findModal().exists()).toBe(true); - }); - - it('renders a Tabs component', () => { - expect(findTabs().exists()).toBe(true); - }); - - it('renders an upload image tab', () => { - expect(findUploadImageTab().exists()).toBe(true); - }); - - it('renders an input to add an image URL', () => { - expect(findUrlInput().exists()).toBe(true); - }); - - it('renders an input to add an image description', () => { - expect(findDescriptionInput().exists()).toBe(true); - }); - }); - - describe('add image', () => { - describe('Upload', () => { - it('validates the file', () => { - const preventDefault = jest.fn(); - const description = 'some description'; - const file = { name: 'some_file.png' }; - - wrapper.vm.$refs.uploadImageTab = { validateFile: jest.fn() }; - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ file, description, tabIndex: IMAGE_TABS.UPLOAD_TAB }); - - findModal().vm.$emit('ok', { preventDefault }); - - expect(wrapper.vm.$refs.uploadImageTab.validateFile).toHaveBeenCalled(); - }); - }); - - describe('URL', () => { - it('emits an addImage event when a valid URL is specified', () => { - const preventDefault = jest.fn(); - const mockImage = { imageUrl: '/some/valid/url.png', description: 'some description' }; - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ ...mockImage, tabIndex: IMAGE_TABS.URL_TAB }); - - findModal().vm.$emit('ok', { preventDefault }); - expect(preventDefault).not.toHaveBeenCalled(); - expect(wrapper.emitted('addImage')).toEqual([ - [{ imageUrl: mockImage.imageUrl, altText: mockImage.description }], - ]); - }); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab_spec.js b/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab_spec.js deleted file mode 100644 index 11b73d58259..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab_spec.js +++ /dev/null @@ -1,41 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import UploadImageTab from '~/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab.vue'; - -describe('Upload Image Tab', () => { - let wrapper; - - beforeEach(() => { - wrapper = shallowMount(UploadImageTab); - }); - - afterEach(() => wrapper.destroy()); - - const triggerInputEvent = (size) => { - const file = { size, name: 'file-name.png' }; - const mockEvent = new Event('input'); - - Object.defineProperty(mockEvent, 'target', { value: { files: [file] } }); - - wrapper.find({ ref: 'fileInput' }).element.dispatchEvent(mockEvent); - - return file; - }; - - describe('onInput', () => { - it.each` - size | fileError - ${2000000000} | ${'Maximum file size is 2MB. Please select a smaller file.'} - ${200} | ${null} - `('validates the file correctly', ({ size, fileError }) => { - triggerInputEvent(size); - - expect(wrapper.vm.fileError).toBe(fileError); - }); - }); - - it('emits input event when file is valid', () => { - const file = triggerInputEvent(200); - - expect(wrapper.emitted('input')).toEqual([[file]]); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/modals/insert_video_modal_spec.js b/spec/frontend/static_site_editor/rich_content_editor/modals/insert_video_modal_spec.js deleted file mode 100644 index 392d31bf039..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/modals/insert_video_modal_spec.js +++ /dev/null @@ -1,44 +0,0 @@ -import { GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import InsertVideoModal from '~/static_site_editor/rich_content_editor/modals/insert_video_modal.vue'; - -describe('Insert Video Modal', () => { - let wrapper; - - const findModal = () => wrapper.find(GlModal); - const findUrlInput = () => wrapper.find({ ref: 'urlInput' }); - - const triggerInsertVideo = (url) => { - const preventDefault = jest.fn(); - findUrlInput().vm.$emit('input', url); - findModal().vm.$emit('primary', { preventDefault }); - }; - - beforeEach(() => { - wrapper = shallowMount(InsertVideoModal); - }); - - afterEach(() => wrapper.destroy()); - - describe('when content is loaded', () => { - it('renders a modal component', () => { - expect(findModal().exists()).toBe(true); - }); - - it('renders an input to add a URL', () => { - expect(findUrlInput().exists()).toBe(true); - }); - }); - - describe('insert video', () => { - it.each` - url | emitted - ${'https://www.youtube.com/embed/someId'} | ${[['https://www.youtube.com/embed/someId']]} - ${'https://www.youtube.com/watch?v=1234'} | ${[['https://www.youtube.com/embed/1234']]} - ${'::youtube.com/invalid/url'} | ${undefined} - `('formats the url correctly', ({ url, emitted }) => { - triggerInsertVideo(url); - expect(wrapper.emitted('insertVideo')).toEqual(emitted); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_integration_spec.js b/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_integration_spec.js deleted file mode 100644 index 6c02ec506c6..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_integration_spec.js +++ /dev/null @@ -1,69 +0,0 @@ -import Editor from '@toast-ui/editor'; -import buildMarkdownToHTMLRenderer from '~/static_site_editor/rich_content_editor/services/build_custom_renderer'; -import { registerHTMLToMarkdownRenderer } from '~/static_site_editor/rich_content_editor/services/editor_service'; - -describe('static_site_editor/rich_content_editor', () => { - let editor; - - const buildEditor = () => { - editor = new Editor({ - el: document.body, - customHTMLRenderer: buildMarkdownToHTMLRenderer(), - }); - - registerHTMLToMarkdownRenderer(editor); - }; - - beforeEach(() => { - buildEditor(); - }); - - describe('HTML to Markdown', () => { - it('uses "-" character list marker in unordered lists', () => { - editor.setHtml('<ul><li>List item 1</li><li>List item 2</li></ul>'); - - const markdown = editor.getMarkdown(); - - expect(markdown).toBe('- List item 1\n- List item 2'); - }); - - it('does not increment the list marker in ordered lists', () => { - editor.setHtml('<ol><li>List item 1</li><li>List item 2</li></ol>'); - - const markdown = editor.getMarkdown(); - - expect(markdown).toBe('1. List item 1\n1. List item 2'); - }); - - it('indents lists using four spaces', () => { - editor.setHtml('<ul><li>List item 1</li><ul><li>List item 2</li></ul></ul>'); - - const markdown = editor.getMarkdown(); - - expect(markdown).toBe('- List item 1\n - List item 2'); - }); - - it('uses * for strong and _ for emphasis text', () => { - editor.setHtml('<strong>strong text</strong><i>emphasis text</i>'); - - const markdown = editor.getMarkdown(); - - expect(markdown).toBe('**strong text**_emphasis text_'); - }); - }); - - describe('Markdown to HTML', () => { - it.each` - input | output - ${'markdown with _emphasized\ntext_'} | ${'<p>markdown with <em>emphasized text</em></p>\n'} - ${'markdown with **strong\ntext**'} | ${'<p>markdown with <strong>strong text</strong></p>\n'} - `( - 'does not transform softbreaks inside (_) and strong (**) nodes into <br/> tags', - ({ input, output }) => { - editor.setMarkdown(input); - - expect(editor.getHtml()).toBe(output); - }, - ); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_spec.js deleted file mode 100644 index 3b0d2993a5d..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_spec.js +++ /dev/null @@ -1,222 +0,0 @@ -import { Editor, mockEditorApi } from '@toast-ui/vue-editor'; -import { shallowMount } from '@vue/test-utils'; -import { - EDITOR_TYPES, - EDITOR_HEIGHT, - EDITOR_PREVIEW_STYLE, - CUSTOM_EVENTS, -} from '~/static_site_editor/rich_content_editor/constants'; -import AddImageModal from '~/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue'; -import InsertVideoModal from '~/static_site_editor/rich_content_editor/modals/insert_video_modal.vue'; -import RichContentEditor from '~/static_site_editor/rich_content_editor/rich_content_editor.vue'; - -import { - addCustomEventListener, - removeCustomEventListener, - addImage, - insertVideo, - registerHTMLToMarkdownRenderer, - getEditorOptions, - getMarkdown, -} from '~/static_site_editor/rich_content_editor/services/editor_service'; - -jest.mock('~/static_site_editor/rich_content_editor/services/editor_service', () => ({ - addCustomEventListener: jest.fn(), - removeCustomEventListener: jest.fn(), - addImage: jest.fn(), - insertVideo: jest.fn(), - registerHTMLToMarkdownRenderer: jest.fn(), - getEditorOptions: jest.fn(), - getMarkdown: jest.fn(), -})); - -describe('Rich Content Editor', () => { - let wrapper; - - const content = '## Some Markdown'; - const imageRoot = 'path/to/root/'; - const findEditor = () => wrapper.find({ ref: 'editor' }); - const findAddImageModal = () => wrapper.find(AddImageModal); - const findInsertVideoModal = () => wrapper.find(InsertVideoModal); - - const buildWrapper = async () => { - wrapper = shallowMount(RichContentEditor, { - propsData: { content, imageRoot }, - stubs: { - ToastEditor: Editor, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('when content is loaded', () => { - const editorOptions = {}; - - beforeEach(() => { - getEditorOptions.mockReturnValueOnce(editorOptions); - buildWrapper(); - }); - - it('renders an editor', () => { - expect(findEditor().exists()).toBe(true); - }); - - it('renders the correct content', () => { - expect(findEditor().props().initialValue).toBe(content); - }); - - it('provides options generated by the getEditorOptions service', () => { - expect(findEditor().props().options).toBe(editorOptions); - }); - - it('has the correct preview style', () => { - expect(findEditor().props().previewStyle).toBe(EDITOR_PREVIEW_STYLE); - }); - - it('has the correct initial edit type', () => { - expect(findEditor().props().initialEditType).toBe(EDITOR_TYPES.wysiwyg); - }); - - it('has the correct height', () => { - expect(findEditor().props().height).toBe(EDITOR_HEIGHT); - }); - }); - - describe('when content is changed', () => { - beforeEach(() => { - buildWrapper(); - }); - - it('emits an input event with the changed content', () => { - const changedMarkdown = '## Changed Markdown'; - getMarkdown.mockReturnValueOnce(changedMarkdown); - - findEditor().vm.$emit('change'); - - expect(wrapper.emitted().input[0][0]).toBe(changedMarkdown); - }); - }); - - describe('when content is reset', () => { - beforeEach(() => { - buildWrapper(); - }); - - it('should reset the content via setMarkdown', () => { - const newContent = 'Just the body content excluding the front matter for example'; - const mockInstance = { invoke: jest.fn() }; - wrapper.vm.$refs.editor = mockInstance; - - wrapper.vm.resetInitialValue(newContent); - - expect(mockInstance.invoke).toHaveBeenCalledWith('setMarkdown', newContent); - }); - }); - - describe('when editor is loaded', () => { - const formattedMarkdown = 'formatted markdown'; - - beforeEach(() => { - mockEditorApi.getMarkdown.mockReturnValueOnce(formattedMarkdown); - buildWrapper(); - }); - - afterEach(() => { - mockEditorApi.getMarkdown.mockReset(); - }); - - it('adds the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { - expect(addCustomEventListener).toHaveBeenCalledWith( - wrapper.vm.editorApi, - CUSTOM_EVENTS.openAddImageModal, - wrapper.vm.onOpenAddImageModal, - ); - }); - - it('adds the CUSTOM_EVENTS.openInsertVideoModal custom event listener', () => { - expect(addCustomEventListener).toHaveBeenCalledWith( - wrapper.vm.editorApi, - CUSTOM_EVENTS.openInsertVideoModal, - wrapper.vm.onOpenInsertVideoModal, - ); - }); - - it('registers HTML to markdown renderer', () => { - expect(registerHTMLToMarkdownRenderer).toHaveBeenCalledWith(wrapper.vm.editorApi); - }); - - it('emits load event with the markdown formatted by Toast UI', () => { - mockEditorApi.getMarkdown.mockReturnValueOnce(formattedMarkdown); - expect(mockEditorApi.getMarkdown).toHaveBeenCalled(); - expect(wrapper.emitted('load')[0]).toEqual([{ formattedMarkdown }]); - }); - }); - - describe('when editor is destroyed', () => { - beforeEach(() => { - buildWrapper(); - }); - - it('removes the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { - wrapper.vm.$destroy(); - - expect(removeCustomEventListener).toHaveBeenCalledWith( - wrapper.vm.editorApi, - CUSTOM_EVENTS.openAddImageModal, - wrapper.vm.onOpenAddImageModal, - ); - }); - - it('removes the CUSTOM_EVENTS.openInsertVideoModal custom event listener', () => { - wrapper.vm.$destroy(); - - expect(removeCustomEventListener).toHaveBeenCalledWith( - wrapper.vm.editorApi, - CUSTOM_EVENTS.openInsertVideoModal, - wrapper.vm.onOpenInsertVideoModal, - ); - }); - }); - - describe('add image modal', () => { - beforeEach(() => { - buildWrapper(); - }); - - it('renders an addImageModal component', () => { - expect(findAddImageModal().exists()).toBe(true); - }); - - it('calls the onAddImage method when the addImage event is emitted', () => { - const mockImage = { imageUrl: 'some/url.png', altText: 'some description' }; - const mockInstance = { exec: jest.fn() }; - wrapper.vm.$refs.editor = mockInstance; - - findAddImageModal().vm.$emit('addImage', mockImage); - expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage, undefined); - }); - }); - - describe('insert video modal', () => { - beforeEach(() => { - buildWrapper(); - }); - - it('renders an insertVideoModal component', () => { - expect(findInsertVideoModal().exists()).toBe(true); - }); - - it('calls the onInsertVideo method when the insertVideo event is emitted', () => { - const mockUrl = 'https://www.youtube.com/embed/someId'; - const mockInstance = { exec: jest.fn() }; - wrapper.vm.$refs.editor = mockInstance; - - findInsertVideoModal().vm.$emit('insertVideo', mockUrl); - expect(insertVideo).toHaveBeenCalledWith(mockInstance, mockUrl); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/build_custom_renderer_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/build_custom_renderer_spec.js deleted file mode 100644 index 202e13e8bff..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/services/build_custom_renderer_spec.js +++ /dev/null @@ -1,32 +0,0 @@ -import buildCustomHTMLRenderer from '~/static_site_editor/rich_content_editor/services/build_custom_renderer'; - -describe('Build Custom Renderer Service', () => { - describe('buildCustomHTMLRenderer', () => { - it('should return an object with the default renderer functions when lacking arguments', () => { - expect(buildCustomHTMLRenderer()).toEqual( - expect.objectContaining({ - htmlBlock: expect.any(Function), - htmlInline: expect.any(Function), - heading: expect.any(Function), - item: expect.any(Function), - paragraph: expect.any(Function), - text: expect.any(Function), - softbreak: expect.any(Function), - }), - ); - }); - - it('should return an object with both custom and default renderer functions when passed customRenderers', () => { - const mockHtmlCustomRenderer = jest.fn(); - const customRenderers = { - html: [mockHtmlCustomRenderer], - }; - - expect(buildCustomHTMLRenderer(customRenderers)).toEqual( - expect.objectContaining({ - html: expect.any(Function), - }), - ); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer_spec.js deleted file mode 100644 index c9cba3e8689..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer_spec.js +++ /dev/null @@ -1,218 +0,0 @@ -import buildHTMLToMarkdownRenderer from '~/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer'; -import { attributeDefinition } from './renderers/mock_data'; - -describe('rich_content_editor/services/html_to_markdown_renderer', () => { - let baseRenderer; - let htmlToMarkdownRenderer; - let fakeNode; - - beforeEach(() => { - baseRenderer = { - trim: jest.fn((input) => `trimmed ${input}`), - getSpaceCollapsedText: jest.fn((input) => `space collapsed ${input}`), - getSpaceControlled: jest.fn((input) => `space controlled ${input}`), - convert: jest.fn(), - }; - - fakeNode = { nodeValue: 'mock_node', dataset: {} }; - }); - - afterEach(() => { - htmlToMarkdownRenderer = null; - }); - - describe('TEXT_NODE visitor', () => { - it('composes getSpaceControlled, getSpaceCollapsedText, and trim services', () => { - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); - - expect(htmlToMarkdownRenderer.TEXT_NODE(fakeNode)).toBe( - `space controlled trimmed space collapsed ${fakeNode.nodeValue}`, - ); - }); - }); - - describe('LI OL, LI UL visitor', () => { - const oneLevelNestedList = '\n * List item 1\n * List item 2'; - const twoLevelNestedList = '\n * List item 1\n * List item 2'; - const spaceInContentList = '\n * List item 1\n * List item 2'; - - it.each` - list | indentSpaces | result - ${oneLevelNestedList} | ${2} | ${'\n * List item 1\n * List item 2'} - ${oneLevelNestedList} | ${3} | ${'\n * List item 1\n * List item 2'} - ${oneLevelNestedList} | ${6} | ${'\n * List item 1\n * List item 2'} - ${twoLevelNestedList} | ${4} | ${'\n * List item 1\n * List item 2'} - ${spaceInContentList} | ${1} | ${'\n * List item 1\n * List item 2'} - `('changes the list indentation to $indentSpaces spaces', ({ list, indentSpaces, result }) => { - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { - subListIndentSpaces: indentSpaces, - }); - - baseRenderer.convert.mockReturnValueOnce(list); - - expect(htmlToMarkdownRenderer['LI OL, LI UL'](fakeNode, list)).toBe(result); - expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, list); - }); - }); - - describe('UL LI visitor', () => { - it.each` - listItem | unorderedListBulletChar | result | bulletChar - ${'* list item'} | ${undefined} | ${'- list item'} | ${'default'} - ${' - list item'} | ${'*'} | ${' * list item'} | ${'*'} - ${' * list item'} | ${'-'} | ${' - list item'} | ${'-'} - `( - 'uses $bulletChar bullet char in unordered list items when $unorderedListBulletChar is set in config', - ({ listItem, unorderedListBulletChar, result }) => { - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { - unorderedListBulletChar, - }); - baseRenderer.convert.mockReturnValueOnce(listItem); - - expect(htmlToMarkdownRenderer['UL LI'](fakeNode, listItem)).toBe(result); - expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, listItem); - }, - ); - - it('detects attribute definitions and attaches them to the list item', () => { - const listItem = '- list item'; - const result = `${listItem}\n${attributeDefinition}\n`; - - fakeNode.dataset.attributeDefinition = attributeDefinition; - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); - baseRenderer.convert.mockReturnValueOnce(`${listItem}\n`); - - expect(htmlToMarkdownRenderer['UL LI'](fakeNode, listItem)).toBe(result); - }); - }); - - describe('OL LI visitor', () => { - it.each` - listItem | result | incrementListMarker | action - ${'2. list item'} | ${'1. list item'} | ${false} | ${'increments'} - ${' 3. list item'} | ${' 1. list item'} | ${false} | ${'increments'} - ${' 123. list item'} | ${' 1. list item'} | ${false} | ${'increments'} - ${'3. list item'} | ${'3. list item'} | ${true} | ${'does not increment'} - `( - '$action a list item counter when incrementListMaker is $incrementListMarker', - ({ listItem, result, incrementListMarker }) => { - const subContent = null; - - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { - incrementListMarker, - }); - baseRenderer.convert.mockReturnValueOnce(listItem); - - expect(htmlToMarkdownRenderer['OL LI'](fakeNode, subContent)).toBe(result); - expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, subContent); - }, - ); - }); - - describe('STRONG, B visitor', () => { - it.each` - input | strongCharacter | result - ${'**strong text**'} | ${'_'} | ${'__strong text__'} - ${'__strong text__'} | ${'*'} | ${'**strong text**'} - `( - 'converts $input to $result when strong character is $strongCharacter', - ({ input, strongCharacter, result }) => { - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { - strong: strongCharacter, - }); - - baseRenderer.convert.mockReturnValueOnce(input); - - expect(htmlToMarkdownRenderer['STRONG, B'](fakeNode, input)).toBe(result); - expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, input); - }, - ); - }); - - describe('EM, I visitor', () => { - it.each` - input | emphasisCharacter | result - ${'*strong text*'} | ${'_'} | ${'_strong text_'} - ${'_strong text_'} | ${'*'} | ${'*strong text*'} - `( - 'converts $input to $result when emphasis character is $emphasisCharacter', - ({ input, emphasisCharacter, result }) => { - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { - emphasis: emphasisCharacter, - }); - - baseRenderer.convert.mockReturnValueOnce(input); - - expect(htmlToMarkdownRenderer['EM, I'](fakeNode, input)).toBe(result); - expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, input); - }, - ); - }); - - describe('H1, H2, H3, H4, H5, H6 visitor', () => { - it('detects attribute definitions and attaches them to the heading', () => { - const heading = 'heading text'; - const result = `${heading.trimRight()}\n${attributeDefinition}\n\n`; - - fakeNode.dataset.attributeDefinition = attributeDefinition; - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); - baseRenderer.convert.mockReturnValueOnce(`${heading}\n\n`); - - expect(htmlToMarkdownRenderer['H1, H2, H3, H4, H5, H6'](fakeNode, heading)).toBe(result); - }); - }); - - describe('PRE CODE', () => { - let node; - const subContent = 'sub content'; - const originalConverterResult = 'base result'; - - beforeEach(() => { - node = document.createElement('PRE'); - - node.innerText = 'reference definition content'; - node.dataset.sseReferenceDefinition = true; - - baseRenderer.convert.mockReturnValueOnce(originalConverterResult); - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); - }); - - it('returns raw text when pre node has sse-reference-definitions class', () => { - expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe( - `\n\n${node.innerText}\n\n`, - ); - }); - - it('returns base result when pre node does not have sse-reference-definitions class', () => { - delete node.dataset.sseReferenceDefinition; - - expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe(originalConverterResult); - }); - }); - - describe('IMG', () => { - const originalSrc = 'path/to/image.png'; - const alt = 'alt text'; - let node; - - beforeEach(() => { - node = document.createElement('img'); - node.alt = alt; - node.src = originalSrc; - }); - - it('returns an image with its original src of the `original-src` attribute is preset', () => { - node.dataset.originalSrc = originalSrc; - node.src = 'modified/path/to/image.png'; - - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); - - expect(htmlToMarkdownRenderer.IMG(node)).toBe(`![${alt}](${originalSrc})`); - }); - - it('fallback to `src` if no `original-src` is specified on the image', () => { - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); - expect(htmlToMarkdownRenderer.IMG(node)).toBe(`![${alt}](${originalSrc})`); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token_spec.js deleted file mode 100644 index ef3ff052cb2..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token_spec.js +++ /dev/null @@ -1,88 +0,0 @@ -import { - buildTextToken, - buildUneditableOpenTokens, - buildUneditableCloseToken, - buildUneditableCloseTokens, - buildUneditableBlockTokens, - buildUneditableInlineTokens, - buildUneditableHtmlAsTextTokens, -} from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token'; - -import { - originInlineToken, - originToken, - uneditableOpenTokens, - uneditableCloseToken, - uneditableCloseTokens, - uneditableBlockTokens, - uneditableInlineTokens, - uneditableTokens, -} from './mock_data'; - -describe('Build Uneditable Token renderer helper', () => { - describe('buildTextToken', () => { - it('returns an object literal representing a text token', () => { - const text = originToken.content; - expect(buildTextToken(text)).toStrictEqual(originToken); - }); - }); - - describe('buildUneditableOpenTokens', () => { - it('returns a 2-item array of tokens with the originToken appended to an open token', () => { - const result = buildUneditableOpenTokens(originToken); - - expect(result).toHaveLength(2); - expect(result).toStrictEqual(uneditableOpenTokens); - }); - }); - - describe('buildUneditableCloseToken', () => { - it('returns an object literal representing the uneditable close token', () => { - expect(buildUneditableCloseToken()).toStrictEqual(uneditableCloseToken); - }); - }); - - describe('buildUneditableCloseTokens', () => { - it('returns a 2-item array of tokens with the originToken prepended to a close token', () => { - const result = buildUneditableCloseTokens(originToken); - - expect(result).toHaveLength(2); - expect(result).toStrictEqual(uneditableCloseTokens); - }); - }); - - describe('buildUneditableBlockTokens', () => { - it('returns a 3-item array of tokens with the originToken wrapped in the middle of block tokens', () => { - const result = buildUneditableBlockTokens(originToken); - - expect(result).toHaveLength(3); - expect(result).toStrictEqual(uneditableTokens); - }); - }); - - describe('buildUneditableInlineTokens', () => { - it('returns a 3-item array of tokens with the originInlineToken wrapped in the middle of inline tokens', () => { - const result = buildUneditableInlineTokens(originInlineToken); - - expect(result).toHaveLength(3); - expect(result).toStrictEqual(uneditableInlineTokens); - }); - }); - - describe('buildUneditableHtmlAsTextTokens', () => { - it('returns a 3-item array of tokens with the htmlBlockNode wrapped as a text token in the middle of block tokens', () => { - const htmlBlockNode = { - type: 'htmlBlock', - literal: '<div data-tomark-pass ><h1>Some header</h1><p>Some paragraph</p></div>', - }; - const result = buildUneditableHtmlAsTextTokens(htmlBlockNode); - const { type, content } = result[1]; - - expect(type).toBe('text'); - expect(content).not.toMatch(/ data-tomark-pass /); - - expect(result).toHaveLength(3); - expect(result).toStrictEqual(uneditableBlockTokens); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/mock_data.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/mock_data.js deleted file mode 100644 index 407072fb596..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/mock_data.js +++ /dev/null @@ -1,54 +0,0 @@ -// Node spec helpers - -export const buildMockTextNode = (literal) => ({ literal, type: 'text' }); - -export const normalTextNode = buildMockTextNode('This is just normal text.'); - -// Token spec helpers - -const buildMockUneditableOpenToken = (type) => { - return { - type: 'openTag', - tagName: type, - attributes: { contenteditable: false }, - classNames: [ - 'gl-px-4 gl-py-2 gl-my-5 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed', - ], - }; -}; - -const buildMockTextToken = (content) => { - return { - type: 'text', - tagName: null, - content, - }; -}; - -const buildMockUneditableCloseToken = (type) => ({ type: 'closeTag', tagName: type }); - -export const originToken = buildMockTextToken('{:.no_toc .hidden-md .hidden-lg}'); -const uneditableOpenToken = buildMockUneditableOpenToken('div'); -export const uneditableOpenTokens = [uneditableOpenToken, originToken]; -export const uneditableCloseToken = buildMockUneditableCloseToken('div'); -export const uneditableCloseTokens = [originToken, uneditableCloseToken]; -export const uneditableTokens = [...uneditableOpenTokens, uneditableCloseToken]; - -export const originInlineToken = { - type: 'text', - content: '<i>Inline</i> content', -}; - -export const uneditableInlineTokens = [ - buildMockUneditableOpenToken('a'), - originInlineToken, - buildMockUneditableCloseToken('a'), -]; - -export const uneditableBlockTokens = [ - uneditableOpenToken, - buildMockTextToken('<div><h1>Some header</h1><p>Some paragraph</p></div>'), - uneditableCloseToken, -]; - -export const attributeDefinition = '{:.no_toc .hidden-md .hidden-lg}'; diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition_spec.js deleted file mode 100644 index 6d96dd3bbca..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition_spec.js +++ /dev/null @@ -1,25 +0,0 @@ -import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition'; -import { attributeDefinition } from './mock_data'; - -describe('rich_content_editor/renderers/render_attribute_definition', () => { - describe('canRender', () => { - it.each` - input | result - ${{ literal: attributeDefinition }} | ${true} - ${{ literal: `FOO${attributeDefinition}` }} | ${false} - ${{ literal: `${attributeDefinition}BAR` }} | ${false} - ${{ literal: 'foobar' }} | ${false} - `('returns $result when input is $input', ({ input, result }) => { - expect(renderer.canRender(input)).toBe(result); - }); - }); - - describe('render', () => { - it('returns an empty HTML comment', () => { - expect(renderer.render()).toEqual({ - type: 'html', - content: '<!-- sse-attribute-definition -->', - }); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_spec.js deleted file mode 100644 index 29e2b5b3b16..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_spec.js +++ /dev/null @@ -1,24 +0,0 @@ -import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text'; -import { renderUneditableLeaf } from '~/static_site_editor/rich_content_editor/services/renderers/render_utils'; - -import { buildMockTextNode, normalTextNode } from './mock_data'; - -const embeddedRubyTextNode = buildMockTextNode('<%= partial("some/path") %>'); - -describe('Render Embedded Ruby Text renderer', () => { - describe('canRender', () => { - it('should return true when the argument `literal` has embedded ruby syntax', () => { - expect(renderer.canRender(embeddedRubyTextNode)).toBe(true); - }); - - it('should return false when the argument `literal` lacks embedded ruby syntax', () => { - expect(renderer.canRender(normalTextNode)).toBe(false); - }); - }); - - describe('render', () => { - it('should delegate rendering to the renderUneditableLeaf util', () => { - expect(renderer.render).toBe(renderUneditableLeaf); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js deleted file mode 100644 index 0fda847b688..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js +++ /dev/null @@ -1,33 +0,0 @@ -import { buildUneditableInlineTokens } from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token'; -import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline'; - -import { normalTextNode } from './mock_data'; - -const fontAwesomeInlineHtmlNode = { - firstChild: null, - literal: '<i class="far fa-paper-plane" id="biz-tech-icons">', - type: 'html', -}; - -describe('Render Font Awesome Inline HTML renderer', () => { - describe('canRender', () => { - it('should return true when the argument `literal` has font awesome inline html syntax', () => { - expect(renderer.canRender(fontAwesomeInlineHtmlNode)).toBe(true); - }); - - it('should return false when the argument `literal` lacks font awesome inline html syntax', () => { - expect(renderer.canRender(normalTextNode)).toBe(false); - }); - }); - - describe('render', () => { - it('should return uneditable inline tokens', () => { - const token = { type: 'text', tagName: null, content: fontAwesomeInlineHtmlNode.literal }; - const context = { origin: () => token }; - - expect(renderer.render(fontAwesomeInlineHtmlNode, context)).toStrictEqual( - buildUneditableInlineTokens(token), - ); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_heading_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_heading_spec.js deleted file mode 100644 index cf4a90885df..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_heading_spec.js +++ /dev/null @@ -1,12 +0,0 @@ -import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_heading'; -import * as renderUtils from '~/static_site_editor/rich_content_editor/services/renderers/render_utils'; - -describe('rich_content_editor/renderers/render_heading', () => { - it('canRender delegates to renderUtils.willAlwaysRender', () => { - expect(renderer.canRender).toBe(renderUtils.willAlwaysRender); - }); - - it('render delegates to renderUtils.renderWithAttributeDefinitions', () => { - expect(renderer.render).toBe(renderUtils.renderWithAttributeDefinitions); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_html_block_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_html_block_spec.js deleted file mode 100644 index 9c937ac22f4..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_html_block_spec.js +++ /dev/null @@ -1,37 +0,0 @@ -import { buildUneditableHtmlAsTextTokens } from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token'; -import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_html_block'; - -describe('rich_content_editor/services/renderers/render_html_block', () => { - const htmlBlockNode = { - literal: '<div><h1>Heading</h1><p>Paragraph.</p></div>', - type: 'htmlBlock', - }; - - describe('canRender', () => { - it.each` - input | result - ${htmlBlockNode} | ${true} - ${{ literal: '<iframe></iframe>', type: 'htmlBlock' }} | ${true} - ${{ literal: '<iframe src="https://www.youtube.com"></iframe>', type: 'htmlBlock' }} | ${false} - ${{ literal: '<iframe></iframe>', type: 'text' }} | ${false} - `('returns $result when input=$input', ({ input, result }) => { - expect(renderer.canRender(input)).toBe(result); - }); - }); - - describe('render', () => { - const htmlBlockNodeToMark = { - firstChild: null, - literal: '<div data-to-mark ></div>', - type: 'htmlBlock', - }; - - it.each` - node - ${htmlBlockNode} - ${htmlBlockNodeToMark} - `('should return uneditable tokens wrapping the $node as a token', ({ node }) => { - expect(renderer.render(node)).toStrictEqual(buildUneditableHtmlAsTextTokens(node)); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js deleted file mode 100644 index 15fb2c3a430..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js +++ /dev/null @@ -1,55 +0,0 @@ -import { buildUneditableInlineTokens } from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token'; -import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text'; - -import { buildMockTextNode, normalTextNode } from './mock_data'; - -const mockTextStart = 'Majority example '; -const mockTextMiddle = '[environment terraform plans][terraform]'; -const mockTextEnd = '.'; -const identifierInstanceStartTextNode = buildMockTextNode(mockTextStart); -const identifierInstanceEndTextNode = buildMockTextNode(mockTextEnd); - -describe('Render Identifier Instance Text renderer', () => { - describe('canRender', () => { - it.each` - node | target - ${normalTextNode} | ${false} - ${identifierInstanceStartTextNode} | ${false} - ${identifierInstanceEndTextNode} | ${false} - ${buildMockTextNode(mockTextMiddle)} | ${true} - ${buildMockTextNode('Minority example [environment terraform plans][]')} | ${true} - ${buildMockTextNode('Minority example [environment terraform plans]')} | ${true} - `( - 'should return $target when the $node validates against identifier instance syntax', - ({ node, target }) => { - expect(renderer.canRender(node)).toBe(target); - }, - ); - }); - - describe('render', () => { - it.each` - start | middle | end - ${mockTextStart} | ${mockTextMiddle} | ${mockTextEnd} - ${mockTextStart} | ${'[environment terraform plans][]'} | ${mockTextEnd} - ${mockTextStart} | ${'[environment terraform plans]'} | ${mockTextEnd} - `( - 'should return inline editable, uneditable, and editable tokens in sequence', - ({ start, middle, end }) => { - const buildMockTextToken = (content) => ({ type: 'text', tagName: null, content }); - - const startToken = buildMockTextToken(start); - const middleToken = buildMockTextToken(middle); - const endToken = buildMockTextToken(end); - - const content = `${start}${middle}${end}`; - const contentToken = buildMockTextToken(content); - const contentNode = buildMockTextNode(content); - const context = { origin: jest.fn().mockReturnValueOnce(contentToken) }; - expect(renderer.render(contentNode, context)).toStrictEqual( - [startToken, buildUneditableInlineTokens(middleToken), endToken].flat(), - ); - }, - ); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js deleted file mode 100644 index ddc96ed6832..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js +++ /dev/null @@ -1,84 +0,0 @@ -import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph'; - -import { buildMockTextNode } from './mock_data'; - -const buildMockParagraphNode = (literal) => { - return { - firstChild: buildMockTextNode(literal), - type: 'paragraph', - }; -}; - -const normalParagraphNode = buildMockParagraphNode( - 'This is just normal paragraph. It has multiple sentences.', -); -const identifierParagraphNode = buildMockParagraphNode( - `[another-identifier]: https://example.com "This example has a title" [identifier]: http://example1.com [this link]: http://example.org`, -); - -describe('rich_content_editor/renderers_render_identifier_paragraph', () => { - describe('canRender', () => { - it.each` - node | paragraph | target - ${identifierParagraphNode} | ${'[Some text]: https://link.com'} | ${true} - ${normalParagraphNode} | ${'Normal non-identifier text. Another sentence.'} | ${false} - `( - 'should return $target when the $node matches $paragraph syntax', - ({ node, paragraph, target }) => { - const context = { - entering: true, - getChildrenText: jest.fn().mockReturnValueOnce(paragraph), - }; - - expect(renderer.canRender(node, context)).toBe(target); - }, - ); - }); - - describe('render', () => { - let context; - let result; - - beforeEach(() => { - const node = { - firstChild: { - type: 'text', - literal: '[Some text]: https://link.com', - next: { - type: 'linebreak', - next: { - type: 'text', - literal: '[identifier]: http://example1.com "title"', - }, - }, - }, - }; - context = { skipChildren: jest.fn() }; - result = renderer.render(node, context); - }); - - it('renders the reference definitions as a code block', () => { - expect(result).toEqual([ - { - type: 'openTag', - tagName: 'pre', - classNames: ['code-block', 'language-markdown'], - attributes: { - 'data-sse-reference-definition': true, - }, - }, - { type: 'openTag', tagName: 'code' }, - { - type: 'text', - content: '[Some text]: https://link.com\n[identifier]: http://example1.com "title"', - }, - { type: 'closeTag', tagName: 'code' }, - { type: 'closeTag', tagName: 'pre' }, - ]); - }); - - it('skips the reference definition node children from rendering', () => { - expect(context.skipChildren).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_list_item_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_list_item_spec.js deleted file mode 100644 index 1e8e62b9dd2..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_list_item_spec.js +++ /dev/null @@ -1,12 +0,0 @@ -import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_list_item'; -import * as renderUtils from '~/static_site_editor/rich_content_editor/services/renderers/render_utils'; - -describe('rich_content_editor/renderers/render_list_item', () => { - it('canRender delegates to renderUtils.willAlwaysRender', () => { - expect(renderer.canRender).toBe(renderUtils.willAlwaysRender); - }); - - it('render delegates to renderUtils.renderWithAttributeDefinitions', () => { - expect(renderer.render).toBe(renderUtils.renderWithAttributeDefinitions); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_softbreak_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_softbreak_spec.js deleted file mode 100644 index d8d1e6ff295..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_softbreak_spec.js +++ /dev/null @@ -1,23 +0,0 @@ -import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_softbreak'; - -describe('Render softbreak renderer', () => { - describe('canRender', () => { - it.each` - node | parentType | result - ${{ parent: { type: 'emph' } }} | ${'emph'} | ${true} - ${{ parent: { type: 'strong' } }} | ${'strong'} | ${true} - ${{ parent: { type: 'paragraph' } }} | ${'paragraph'} | ${false} - `('returns $result when node parent type is $parentType ', ({ node, result }) => { - expect(renderer.canRender(node)).toBe(result); - }); - }); - - describe('render', () => { - it('returns text node with a break line', () => { - expect(renderer.render()).toEqual({ - type: 'text', - content: ' ', - }); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_utils_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_utils_spec.js deleted file mode 100644 index 49b8936a9f7..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_utils_spec.js +++ /dev/null @@ -1,109 +0,0 @@ -import { - buildUneditableBlockTokens, - buildUneditableOpenTokens, -} from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token'; -import { - renderUneditableLeaf, - renderUneditableBranch, - renderWithAttributeDefinitions, - willAlwaysRender, -} from '~/static_site_editor/rich_content_editor/services/renderers/render_utils'; - -import { originToken, uneditableCloseToken, attributeDefinition } from './mock_data'; - -describe('rich_content_editor/renderers/render_utils', () => { - describe('renderUneditableLeaf', () => { - it('should return uneditable block tokens around an origin token', () => { - const context = { origin: jest.fn().mockReturnValueOnce(originToken) }; - const result = renderUneditableLeaf({}, context); - - expect(result).toStrictEqual(buildUneditableBlockTokens(originToken)); - }); - }); - - describe('renderUneditableBranch', () => { - let origin; - - beforeEach(() => { - origin = jest.fn().mockReturnValueOnce(originToken); - }); - - it('should return uneditable block open token followed by the origin token when entering', () => { - const context = { entering: true, origin }; - const result = renderUneditableBranch({}, context); - - expect(result).toStrictEqual(buildUneditableOpenTokens(originToken)); - }); - - it('should return uneditable block closing token when exiting', () => { - const context = { entering: false, origin }; - const result = renderUneditableBranch({}, context); - - expect(result).toStrictEqual(uneditableCloseToken); - }); - }); - - describe('willAlwaysRender', () => { - it('always returns true', () => { - expect(willAlwaysRender()).toBe(true); - }); - }); - - describe('renderWithAttributeDefinitions', () => { - let openTagToken; - let closeTagToken; - let node; - const attributes = { - 'data-attribute-definition': attributeDefinition, - }; - - beforeEach(() => { - openTagToken = { type: 'openTag' }; - closeTagToken = { type: 'closeTag' }; - node = { - next: { - firstChild: { - literal: attributeDefinition, - }, - }, - }; - }); - - describe('when token type is openTag', () => { - it('attaches attributes when attributes exist in the node’s next sibling', () => { - const context = { origin: () => openTagToken }; - - expect(renderWithAttributeDefinitions(node, context)).toEqual({ - ...openTagToken, - attributes, - }); - }); - - it('attaches attributes when attributes exist in the node’s children', () => { - const context = { origin: () => openTagToken }; - node = { - firstChild: { - firstChild: { - next: { - next: { - literal: attributeDefinition, - }, - }, - }, - }, - }; - - expect(renderWithAttributeDefinitions(node, context)).toEqual({ - ...openTagToken, - attributes, - }); - }); - }); - - it('does not attach attributes when token type is "closeTag"', () => { - const context = { origin: () => closeTagToken }; - - expect(renderWithAttributeDefinitions({}, context)).toBe(closeTagToken); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/sanitize_html_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/sanitize_html_spec.js deleted file mode 100644 index 2f2d3beb53d..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/services/sanitize_html_spec.js +++ /dev/null @@ -1,11 +0,0 @@ -import sanitizeHTML from '~/static_site_editor/rich_content_editor/services/sanitize_html'; - -describe('rich_content_editor/services/sanitize_html', () => { - it.each` - input | result - ${'<iframe src="https://www.youtube.com"></iframe>'} | ${'<iframe src="https://www.youtube.com"></iframe>'} - ${'<iframe src="https://gitlab.com"></iframe>'} | ${''} - `('removes iframes if the iframe source origin is not allowed', ({ input, result }) => { - expect(sanitizeHTML(input)).toBe(result); - }); -}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/toolbar_item_spec.js b/spec/frontend/static_site_editor/rich_content_editor/toolbar_item_spec.js deleted file mode 100644 index c9dcf9cfe2e..00000000000 --- a/spec/frontend/static_site_editor/rich_content_editor/toolbar_item_spec.js +++ /dev/null @@ -1,57 +0,0 @@ -import { GlIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import ToolbarItem from '~/static_site_editor/rich_content_editor/toolbar_item.vue'; - -describe('Toolbar Item', () => { - let wrapper; - - const findIcon = () => wrapper.find(GlIcon); - const findButton = () => wrapper.find('button'); - - const buildWrapper = (propsData) => { - wrapper = shallowMount(ToolbarItem, { - propsData, - directives: { - GlTooltip: createMockDirective(), - }, - }); - }; - - describe.each` - icon | tooltip - ${'heading'} | ${'Headings'} - ${'bold'} | ${'Add bold text'} - ${'italic'} | ${'Add italic text'} - ${'strikethrough'} | ${'Add strikethrough text'} - ${'quote'} | ${'Insert a quote'} - ${'link'} | ${'Add a link'} - ${'doc-code'} | ${'Insert a code block'} - ${'list-bulleted'} | ${'Add a bullet list'} - ${'list-numbered'} | ${'Add a numbered list'} - ${'list-task'} | ${'Add a task list'} - ${'list-indent'} | ${'Indent'} - ${'list-outdent'} | ${'Outdent'} - ${'dash'} | ${'Add a line'} - ${'table'} | ${'Add a table'} - ${'code'} | ${'Insert an image'} - ${'code'} | ${'Insert inline code'} - `('toolbar item component', ({ icon, tooltip }) => { - beforeEach(() => buildWrapper({ icon, tooltip })); - - it('renders a toolbar button', () => { - expect(findButton().exists()).toBe(true); - }); - - it('renders the correct tooltip', () => { - const buttonTooltip = getBinding(wrapper.element, 'gl-tooltip'); - expect(buttonTooltip).toBeDefined(); - expect(buttonTooltip.value.title).toBe(tooltip); - }); - - it(`renders the ${icon} icon`, () => { - expect(findIcon().exists()).toBe(true); - expect(findIcon().props().name).toBe(icon); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/services/formatter_spec.js b/spec/frontend/static_site_editor/services/formatter_spec.js deleted file mode 100644 index 9e9c4bbd171..00000000000 --- a/spec/frontend/static_site_editor/services/formatter_spec.js +++ /dev/null @@ -1,39 +0,0 @@ -import formatter from '~/static_site_editor/services/formatter'; - -describe('static_site_editor/services/formatter', () => { - const source = `Some text -<br> - -And some more text - - -<br> - - -And even more text`; - const sourceWithoutBrTags = `Some text - -And some more text - - - - -And even more text`; - - it('removes extraneous <br> tags', () => { - expect(formatter(source)).toMatch(sourceWithoutBrTags); - }); - - describe('ordered lists with incorrect content indentation', () => { - it.each` - input | result - ${'12. ordered list item\n13.Next ordered list item'} | ${'12. ordered list item\n13.Next ordered list item'} - ${'12. ordered list item\n - Next ordered list item'} | ${'12. ordered list item\n - Next ordered list item'} - ${'12. ordered list item\n - Next ordered list item'} | ${'12. ordered list item\n - Next ordered list item'} - ${'12. ordered list item\n Next ordered list item'} | ${'12. ordered list item\n Next ordered list item'} - ${'1. ordered list item\n Next ordered list item'} | ${'1. ordered list item\n Next ordered list item'} - `('\ntransforms\n$input \nto\n$result', ({ input, result }) => { - expect(formatter(input)).toBe(result); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/services/front_matterify_spec.js b/spec/frontend/static_site_editor/services/front_matterify_spec.js deleted file mode 100644 index ec3752b30c6..00000000000 --- a/spec/frontend/static_site_editor/services/front_matterify_spec.js +++ /dev/null @@ -1,54 +0,0 @@ -import { frontMatterify, stringify } from '~/static_site_editor/services/front_matterify'; -import { - sourceContentYAML as content, - sourceContentHeaderObjYAML as yamlFrontMatterObj, - sourceContentSpacing as spacing, - sourceContentBody as body, -} from '../mock_data'; - -describe('static_site_editor/services/front_matterify', () => { - const frontMatterifiedContent = { - source: content, - matter: yamlFrontMatterObj, - hasMatter: true, - spacing, - content: body, - delimiter: '---', - type: 'yaml', - }; - const frontMatterifiedBody = { - source: body, - matter: null, - hasMatter: false, - spacing: null, - content: body, - delimiter: null, - type: null, - }; - - describe('frontMatterify', () => { - it.each` - frontMatterified | target - ${frontMatterify(content)} | ${frontMatterifiedContent} - ${frontMatterify(body)} | ${frontMatterifiedBody} - `('returns $target from $frontMatterified', ({ frontMatterified, target }) => { - expect(frontMatterified).toEqual(target); - }); - - it('should throw when matter is invalid', () => { - const invalidContent = `---\nkey: val\nkeyNoVal\n---\n${body}`; - - expect(() => frontMatterify(invalidContent)).toThrow(); - }); - }); - - describe('stringify', () => { - it.each` - stringified | target - ${stringify(frontMatterifiedContent)} | ${content} - ${stringify(frontMatterifiedBody)} | ${body} - `('returns $target from $stringified', ({ stringified, target }) => { - expect(stringified).toBe(target); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/services/generate_branch_name_spec.js b/spec/frontend/static_site_editor/services/generate_branch_name_spec.js deleted file mode 100644 index 7e437506a16..00000000000 --- a/spec/frontend/static_site_editor/services/generate_branch_name_spec.js +++ /dev/null @@ -1,22 +0,0 @@ -import { BRANCH_SUFFIX_COUNT } from '~/static_site_editor/constants'; -import generateBranchName from '~/static_site_editor/services/generate_branch_name'; - -import { username, branch as targetBranch } from '../mock_data'; - -describe('generateBranchName', () => { - const timestamp = 12345678901234; - - beforeEach(() => { - jest.spyOn(Date, 'now').mockReturnValueOnce(timestamp); - }); - - it('generates a name that includes the username and target branch', () => { - expect(generateBranchName(username, targetBranch)).toMatch(`${username}-${targetBranch}`); - }); - - it(`adds the first ${BRANCH_SUFFIX_COUNT} numbers of the current timestamp`, () => { - expect(generateBranchName(username, targetBranch)).toMatch( - timestamp.toString().substring(BRANCH_SUFFIX_COUNT), - ); - }); -}); diff --git a/spec/frontend/static_site_editor/services/load_source_content_spec.js b/spec/frontend/static_site_editor/services/load_source_content_spec.js deleted file mode 100644 index 98d437698c4..00000000000 --- a/spec/frontend/static_site_editor/services/load_source_content_spec.js +++ /dev/null @@ -1,36 +0,0 @@ -import Api from '~/api'; - -import loadSourceContent from '~/static_site_editor/services/load_source_content'; - -import { - sourceContentYAML as sourceContent, - sourceContentTitle, - projectId, - sourcePath, -} from '../mock_data'; - -describe('loadSourceContent', () => { - describe('requesting source content succeeds', () => { - let result; - - beforeEach(() => { - jest.spyOn(Api, 'getRawFile').mockResolvedValue({ data: sourceContent }); - - return loadSourceContent({ projectId, sourcePath }).then((_result) => { - result = _result; - }); - }); - - it('calls getRawFile API with project id and source path', () => { - expect(Api.getRawFile).toHaveBeenCalledWith(projectId, sourcePath); - }); - - it('extracts page title from source content', () => { - expect(result.title).toBe(sourceContentTitle); - }); - - it('returns raw content', () => { - expect(result.content).toBe(sourceContent); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/services/parse_source_file_spec.js b/spec/frontend/static_site_editor/services/parse_source_file_spec.js deleted file mode 100644 index fdd11297e09..00000000000 --- a/spec/frontend/static_site_editor/services/parse_source_file_spec.js +++ /dev/null @@ -1,101 +0,0 @@ -import parseSourceFile from '~/static_site_editor/services/parse_source_file'; -import { - sourceContentYAML as content, - sourceContentHeaderYAML as yamlFrontMatter, - sourceContentHeaderObjYAML as yamlFrontMatterObj, - sourceContentBody as body, -} from '../mock_data'; - -describe('static_site_editor/services/parse_source_file', () => { - const contentComplex = [content, content, content].join(''); - const complexBody = [body, content, content].join(''); - const edit = 'and more'; - const newContent = `${content} ${edit}`; - const newContentComplex = `${contentComplex} ${edit}`; - - describe('unmodified front matter', () => { - it.each` - parsedSource - ${parseSourceFile(content)} - ${parseSourceFile(contentComplex)} - `('returns $targetFrontMatter when frontMatter queried', ({ parsedSource }) => { - expect(parsedSource.matter()).toEqual(yamlFrontMatterObj); - }); - }); - - describe('unmodified content', () => { - it.each` - parsedSource - ${parseSourceFile(content)} - ${parseSourceFile(contentComplex)} - `('returns false by default', ({ parsedSource }) => { - expect(parsedSource.isModified()).toBe(false); - }); - - it.each` - parsedSource | isBody | target - ${parseSourceFile(content)} | ${undefined} | ${content} - ${parseSourceFile(content)} | ${false} | ${content} - ${parseSourceFile(content)} | ${true} | ${body} - ${parseSourceFile(contentComplex)} | ${undefined} | ${contentComplex} - ${parseSourceFile(contentComplex)} | ${false} | ${contentComplex} - ${parseSourceFile(contentComplex)} | ${true} | ${complexBody} - `( - 'returns only the $target content when the `isBody` parameter argument is $isBody', - ({ parsedSource, isBody, target }) => { - expect(parsedSource.content(isBody)).toBe(target); - }, - ); - }); - - describe('modified front matter', () => { - const newYamlFrontMatter = '---\nnewKey: newVal\n---'; - const newYamlFrontMatterObj = { newKey: 'newVal' }; - const contentWithNewFrontMatter = content.replace(yamlFrontMatter, newYamlFrontMatter); - const contentComplexWithNewFrontMatter = contentComplex.replace( - yamlFrontMatter, - newYamlFrontMatter, - ); - - it.each` - parsedSource | targetContent - ${parseSourceFile(content)} | ${contentWithNewFrontMatter} - ${parseSourceFile(contentComplex)} | ${contentComplexWithNewFrontMatter} - `( - 'returns the correct front matter and modified content', - ({ parsedSource, targetContent }) => { - expect(parsedSource.matter()).toMatchObject(yamlFrontMatterObj); - - parsedSource.syncMatter(newYamlFrontMatterObj); - - expect(parsedSource.matter()).toMatchObject(newYamlFrontMatterObj); - expect(parsedSource.content()).toBe(targetContent); - }, - ); - }); - - describe('modified content', () => { - const newBody = `${body} ${edit}`; - const newComplexBody = `${complexBody} ${edit}`; - - it.each` - parsedSource | hasMatter | isModified | targetRaw | targetBody - ${parseSourceFile(content)} | ${true} | ${false} | ${content} | ${body} - ${parseSourceFile(content)} | ${true} | ${true} | ${newContent} | ${newBody} - ${parseSourceFile(contentComplex)} | ${true} | ${false} | ${contentComplex} | ${complexBody} - ${parseSourceFile(contentComplex)} | ${true} | ${true} | ${newContentComplex} | ${newComplexBody} - ${parseSourceFile(body)} | ${false} | ${false} | ${body} | ${body} - ${parseSourceFile(body)} | ${false} | ${true} | ${newBody} | ${newBody} - `( - 'returns $isModified after a $targetRaw sync', - ({ parsedSource, hasMatter, isModified, targetRaw, targetBody }) => { - parsedSource.syncContent(targetRaw); - - expect(parsedSource.hasMatter()).toBe(hasMatter); - expect(parsedSource.isModified()).toBe(isModified); - expect(parsedSource.content()).toBe(targetRaw); - expect(parsedSource.content(true)).toBe(targetBody); - }, - ); - }); -}); diff --git a/spec/frontend/static_site_editor/services/renderers/render_image_spec.js b/spec/frontend/static_site_editor/services/renderers/render_image_spec.js deleted file mode 100644 index d3298aa0b26..00000000000 --- a/spec/frontend/static_site_editor/services/renderers/render_image_spec.js +++ /dev/null @@ -1,96 +0,0 @@ -import imageRenderer from '~/static_site_editor/services/renderers/render_image'; -import { mounts, project, branch, baseUrl } from '../../mock_data'; - -describe('rich_content_editor/renderers/render_image', () => { - let renderer; - let imageRepository; - - beforeEach(() => { - renderer = imageRenderer.build(mounts, project, branch, baseUrl, imageRepository); - imageRepository = { get: () => null }; - }); - - describe('build', () => { - it('builds a renderer object containing `canRender` and `render` functions', () => { - expect(renderer).toHaveProperty('canRender', expect.any(Function)); - expect(renderer).toHaveProperty('render', expect.any(Function)); - }); - }); - - describe('canRender', () => { - it.each` - input | result - ${{ type: 'image' }} | ${true} - ${{ type: 'text' }} | ${false} - ${{ type: 'htmlBlock' }} | ${false} - `('returns $result when input is $input', ({ input, result }) => { - expect(renderer.canRender(input)).toBe(result); - }); - }); - - describe('render', () => { - let skipChildren; - let context; - let node; - - beforeEach(() => { - skipChildren = jest.fn(); - context = { skipChildren }; - node = { - firstChild: { - type: 'img', - literal: 'Some Image', - }, - }; - }); - - it.each` - destination | isAbsolute | src - ${'http://test.host/absolute/path/to/image.png'} | ${true} | ${'http://test.host/absolute/path/to/image.png'} - ${'/relative/path/to/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/main/default/source/relative/path/to/image.png'} - ${'/target/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/main/source/with/target/image.png'} - ${'relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/main/relative/to/current/image.png'} - ${'./relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/main/./relative/to/current/image.png'} - ${'../relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/main/../relative/to/current/image.png'} - `('returns an image with the correct attributes', ({ destination, isAbsolute, src }) => { - node.destination = destination; - - const result = renderer.render(node, context); - - expect(result).toEqual({ - type: 'openTag', - tagName: 'img', - selfClose: true, - attributes: { - 'data-original-src': !isAbsolute ? destination : '', - src, - alt: 'Some Image', - }, - }); - - expect(skipChildren).toHaveBeenCalled(); - }); - - it('renders an image if a cached image is found in the repository, use the base64 content as the source', () => { - const imageContent = 'some-content'; - const originalSrc = 'path/to/image.png'; - - imageRepository.get = () => imageContent; - renderer = imageRenderer.build(mounts, project, branch, baseUrl, imageRepository); - node.destination = originalSrc; - - const result = renderer.render(node, context); - - expect(result).toEqual({ - type: 'openTag', - tagName: 'img', - selfClose: true, - attributes: { - 'data-original-src': originalSrc, - src: `data:image;base64,${imageContent}`, - alt: 'Some Image', - }, - }); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/services/submit_content_changes_spec.js b/spec/frontend/static_site_editor/services/submit_content_changes_spec.js deleted file mode 100644 index 757611166d7..00000000000 --- a/spec/frontend/static_site_editor/services/submit_content_changes_spec.js +++ /dev/null @@ -1,261 +0,0 @@ -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import Api from '~/api'; -import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils'; - -import { - SUBMIT_CHANGES_BRANCH_ERROR, - SUBMIT_CHANGES_COMMIT_ERROR, - SUBMIT_CHANGES_MERGE_REQUEST_ERROR, - TRACKING_ACTION_CREATE_COMMIT, - TRACKING_ACTION_CREATE_MERGE_REQUEST, - SERVICE_PING_TRACKING_ACTION_CREATE_COMMIT, - SERVICE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST, - DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE, - DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION, -} from '~/static_site_editor/constants'; -import generateBranchName from '~/static_site_editor/services/generate_branch_name'; -import submitContentChanges from '~/static_site_editor/services/submit_content_changes'; - -import { - username, - projectId, - commitBranchResponse, - commitMultipleResponse, - createMergeRequestResponse, - mergeRequestMeta, - sourcePath, - branch as targetBranch, - sourceContentYAML as content, - trackingCategory, - images, -} from '../mock_data'; - -jest.mock('~/static_site_editor/services/generate_branch_name'); - -describe('submitContentChanges', () => { - const sourceBranch = 'branch-name'; - let trackingSpy; - let origPage; - - const buildPayload = (overrides = {}) => ({ - username, - projectId, - sourcePath, - targetBranch, - content, - images, - mergeRequestMeta, - ...overrides, - }); - - beforeEach(() => { - jest.spyOn(Api, 'createBranch').mockResolvedValue({ data: commitBranchResponse }); - jest.spyOn(Api, 'commitMultiple').mockResolvedValue({ data: commitMultipleResponse }); - jest - .spyOn(Api, 'createProjectMergeRequest') - .mockResolvedValue({ data: createMergeRequestResponse }); - - generateBranchName.mockReturnValue(sourceBranch); - - origPage = document.body.dataset.page; - document.body.dataset.page = trackingCategory; - trackingSpy = mockTracking(document.body.dataset.page, undefined, jest.spyOn); - }); - - afterEach(() => { - document.body.dataset.page = origPage; - unmockTracking(); - }); - - it('creates a branch named after the username and target branch', () => { - return submitContentChanges(buildPayload()).then(() => { - expect(Api.createBranch).toHaveBeenCalledWith(projectId, { - ref: targetBranch, - branch: sourceBranch, - }); - }); - }); - - it('notifies error when branch could not be created', () => { - Api.createBranch.mockRejectedValueOnce(); - - return expect(submitContentChanges(buildPayload())).rejects.toThrow( - SUBMIT_CHANGES_BRANCH_ERROR, - ); - }); - - describe('committing markdown formatting changes', () => { - const formattedMarkdown = `formatted ${content}`; - const commitPayload = { - branch: sourceBranch, - commit_message: `${DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE}\n\n${DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION}`, - actions: [ - { - action: 'update', - file_path: sourcePath, - content: formattedMarkdown, - }, - ], - }; - - it('commits markdown formatting changes in a separate commit', () => { - return submitContentChanges(buildPayload({ formattedMarkdown })).then(() => { - expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, commitPayload); - }); - }); - - it('does not commit markdown formatting changes when there are none', () => { - return submitContentChanges(buildPayload()).then(() => { - expect(Api.commitMultiple.mock.calls).toHaveLength(1); - expect(Api.commitMultiple.mock.calls[0][1]).not.toMatchObject({ - actions: commitPayload.actions, - }); - }); - }); - }); - - it('commits the content changes to the branch when creating branch succeeds', () => { - return submitContentChanges(buildPayload()).then(() => { - expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, { - branch: sourceBranch, - commit_message: mergeRequestMeta.title, - actions: [ - { - action: 'update', - file_path: sourcePath, - content, - }, - { - action: 'create', - content: 'image1-content', - encoding: 'base64', - file_path: 'path/to/image1.png', - }, - ], - }); - }); - }); - - it('does not commit an image if it has been removed from the content', () => { - const contentWithoutImages = '## Content without images'; - const payload = buildPayload({ content: contentWithoutImages }); - return submitContentChanges(payload).then(() => { - expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, { - branch: sourceBranch, - commit_message: mergeRequestMeta.title, - actions: [ - { - action: 'update', - file_path: sourcePath, - content: contentWithoutImages, - }, - ], - }); - }); - }); - - it('notifies error when content could not be committed', () => { - Api.commitMultiple.mockRejectedValueOnce(); - - return expect(submitContentChanges(buildPayload())).rejects.toThrow( - SUBMIT_CHANGES_COMMIT_ERROR, - ); - }); - - it('creates a merge request when committing changes succeeds', () => { - return submitContentChanges(buildPayload()).then(() => { - const { title, description } = mergeRequestMeta; - expect(Api.createProjectMergeRequest).toHaveBeenCalledWith( - projectId, - convertObjectPropsToSnakeCase({ - title, - description, - targetBranch, - sourceBranch, - }), - ); - }); - }); - - it('notifies error when merge request could not be created', () => { - Api.createProjectMergeRequest.mockRejectedValueOnce(); - - return expect(submitContentChanges(buildPayload())).rejects.toThrow( - SUBMIT_CHANGES_MERGE_REQUEST_ERROR, - ); - }); - - describe('when changes are submitted successfully', () => { - let result; - - beforeEach(() => { - return submitContentChanges(buildPayload()).then((_result) => { - result = _result; - }); - }); - - it('returns the branch name', () => { - expect(result).toMatchObject({ branch: { label: sourceBranch } }); - }); - - it('returns commit short id and web url', () => { - expect(result).toMatchObject({ - commit: { - label: commitMultipleResponse.short_id, - url: commitMultipleResponse.web_url, - }, - }); - }); - - it('returns merge request iid and web url', () => { - expect(result).toMatchObject({ - mergeRequest: { - label: createMergeRequestResponse.iid, - url: createMergeRequestResponse.web_url, - }, - }); - }); - }); - - describe('sends the correct tracking event', () => { - beforeEach(() => { - return submitContentChanges(buildPayload()); - }); - - it('for committing changes', () => { - expect(trackingSpy).toHaveBeenCalledWith( - document.body.dataset.page, - TRACKING_ACTION_CREATE_COMMIT, - ); - }); - - it('for creating a merge request', () => { - expect(trackingSpy).toHaveBeenCalledWith( - document.body.dataset.page, - TRACKING_ACTION_CREATE_MERGE_REQUEST, - ); - }); - }); - - describe('sends the correct Service Ping tracking event', () => { - beforeEach(() => { - jest.spyOn(Api, 'trackRedisCounterEvent').mockResolvedValue({ data: '' }); - }); - - it('for commiting changes', () => { - return submitContentChanges(buildPayload()).then(() => { - expect(Api.trackRedisCounterEvent).toHaveBeenCalledWith( - SERVICE_PING_TRACKING_ACTION_CREATE_COMMIT, - ); - }); - }); - - it('for creating a merge request', () => { - return submitContentChanges(buildPayload()).then(() => { - expect(Api.trackRedisCounterEvent).toHaveBeenCalledWith( - SERVICE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST, - ); - }); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/services/templater_spec.js b/spec/frontend/static_site_editor/services/templater_spec.js deleted file mode 100644 index cb3a0a0c106..00000000000 --- a/spec/frontend/static_site_editor/services/templater_spec.js +++ /dev/null @@ -1,112 +0,0 @@ -/* eslint-disable no-useless-escape */ -import templater from '~/static_site_editor/services/templater'; - -describe('templater', () => { - const source = `Below this line is a simple ERB (single-line erb block) example. - -<% some erb code %> - -Below this line is a complex ERB (multi-line erb block) example. - -<% if apptype.maturity && (apptype.maturity != "planned") %> - <% maturity = "This application type is at the \"#{apptype.maturity}\" level of maturity." %> -<% end %> - -Below this line is a non-erb (single-line HTML) markup example that also has erb. - -<a href="<%= compensation_roadmap.role_path %>"><%= compensation_roadmap.role_path %></a> - -Below this line is a non-erb (multi-line HTML block) markup example that also has erb. - -<ul> -<% compensation_roadmap.recommendation.recommendations.each do |recommendation| %> - <li><%= recommendation %></li> -<% end %> -</ul> - -Below this line is a block of HTML. - -<div> - <h1>Heading</h1> - <p>Some paragraph...</p> -</div> - -Below this line is a codeblock of the same HTML that should be ignored and preserved. - -\`\`\` html -<div> - <h1>Heading</h1> - <p>Some paragraph...</p> -</div> -\`\`\` - -Below this line is a iframe that should be ignored and preserved - -<iframe></iframe> -`; - const sourceTemplated = `Below this line is a simple ERB (single-line erb block) example. - -\`\`\` sse -<% some erb code %> -\`\`\` - -Below this line is a complex ERB (multi-line erb block) example. - -\`\`\` sse -<% if apptype.maturity && (apptype.maturity != "planned") %> - <% maturity = "This application type is at the \"#{apptype.maturity}\" level of maturity." %> -<% end %> -\`\`\` - -Below this line is a non-erb (single-line HTML) markup example that also has erb. - -\`\`\` sse -<a href="<%= compensation_roadmap.role_path %>"><%= compensation_roadmap.role_path %></a> -\`\`\` - -Below this line is a non-erb (multi-line HTML block) markup example that also has erb. - -\`\`\` sse -<ul> -<% compensation_roadmap.recommendation.recommendations.each do |recommendation| %> - <li><%= recommendation %></li> -<% end %> -</ul> -\`\`\` - -Below this line is a block of HTML. - -\`\`\` sse -<div> - <h1>Heading</h1> - <p>Some paragraph...</p> -</div> -\`\`\` - -Below this line is a codeblock of the same HTML that should be ignored and preserved. - -\`\`\` html -<div> - <h1>Heading</h1> - <p>Some paragraph...</p> -</div> -\`\`\` - -Below this line is a iframe that should be ignored and preserved - -<iframe></iframe> -`; - - it.each` - fn | initial | target - ${'wrap'} | ${source} | ${sourceTemplated} - ${'wrap'} | ${sourceTemplated} | ${sourceTemplated} - ${'unwrap'} | ${sourceTemplated} | ${source} - ${'unwrap'} | ${source} | ${source} - `( - 'wraps $initial in a templated sse codeblocks if $fn is wrap, unwraps otherwise', - ({ fn, initial, target }) => { - expect(templater[fn](initial)).toMatch(target); - }, - ); -}); diff --git a/spec/frontend/tags/components/delete_tag_modal_spec.js b/spec/frontend/tags/components/delete_tag_modal_spec.js new file mode 100644 index 00000000000..b1726a2c0ef --- /dev/null +++ b/spec/frontend/tags/components/delete_tag_modal_spec.js @@ -0,0 +1,138 @@ +import { GlButton, GlModal, GlFormInput, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import DeleteTagModal from '~/tags/components/delete_tag_modal.vue'; +import eventHub from '~/tags/event_hub'; + +let wrapper; + +const tagName = 'test-tag'; +const path = '/path/to/tag'; +const isProtected = false; + +const createComponent = (data = {}) => { + wrapper = extendedWrapper( + shallowMount(DeleteTagModal, { + data() { + return { + tagName, + path, + isProtected, + ...data, + }; + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: + '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>', + }), + GlButton, + GlFormInput, + GlSprintf, + }, + }), + ); +}; + +const findModal = () => wrapper.findComponent(GlModal); +const findModalMessage = () => wrapper.findByTestId('modal-message'); +const findDeleteButton = () => wrapper.findByTestId('delete-tag-confirmation-button'); +const findCancelButton = () => wrapper.findByTestId('delete-tag-cancel-button'); +const findFormInput = () => wrapper.findComponent(GlFormInput); +const findForm = () => wrapper.find('form'); + +describe('Delete tag modal', () => { + afterEach(() => { + wrapper.destroy(); + }); + + describe('Deleting a regular tag', () => { + const expectedTitle = 'Delete tag. Are you ABSOLUTELY SURE?'; + const expectedMessage = "You're about to permanently delete the tag test-tag."; + + beforeEach(() => { + createComponent(); + }); + + it('renders the modal correctly', () => { + expect(findModal().props('title')).toBe(expectedTitle); + expect(findModalMessage().text()).toMatchInterpolatedText(expectedMessage); + expect(findCancelButton().text()).toBe('Cancel, keep tag'); + expect(findDeleteButton().text()).toBe('Yes, delete tag'); + expect(findForm().attributes('action')).toBe(path); + }); + + it('submits the form when the delete button is clicked', () => { + const submitFormSpy = jest.spyOn(wrapper.vm.$refs.form, 'submit'); + + findDeleteButton().trigger('click'); + + expect(findForm().attributes('action')).toBe(path); + expect(submitFormSpy).toHaveBeenCalled(); + }); + + it('calls show on the modal when a `openModal` event is received through the event hub', async () => { + const showSpy = jest.spyOn(wrapper.vm.$refs.modal, 'show'); + + eventHub.$emit('openModal', { + isProtected, + tagName, + path, + }); + + expect(showSpy).toHaveBeenCalled(); + }); + + it('calls hide on the modal when cancel button is clicked', () => { + const closeModalSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide'); + + findCancelButton().trigger('click'); + + expect(closeModalSpy).toHaveBeenCalled(); + }); + }); + + describe('Deleting a protected tag (for owner or maintainer)', () => { + const expectedTitleProtected = 'Delete protected tag. Are you ABSOLUTELY SURE?'; + const expectedMessageProtected = + "You're about to permanently delete the protected tag test-tag."; + const expectedConfirmationText = + 'After you confirm and select Yes, delete protected tag, you cannot recover this tag. Please type the following to confirm: test-tag'; + + beforeEach(() => { + createComponent({ isProtected: true }); + }); + + describe('rendering the modal correctly for a protected tag', () => { + it('sets the modal title for a protected tag', () => { + expect(findModal().props('title')).toBe(expectedTitleProtected); + }); + + it('renders the correct text in the modal message', () => { + expect(findModalMessage().text()).toMatchInterpolatedText(expectedMessageProtected); + }); + + it('renders the protected tag name confirmation form with expected text and action', () => { + expect(findForm().text()).toMatchInterpolatedText(expectedConfirmationText); + expect(findForm().attributes('action')).toBe(path); + }); + + it('renders the buttons with the correct button text', () => { + expect(findCancelButton().text()).toBe('Cancel, keep tag'); + expect(findDeleteButton().text()).toBe('Yes, delete protected tag'); + }); + }); + + it('opens with the delete button disabled and enables it when tag name is confirmed', async () => { + expect(findDeleteButton().props('disabled')).toBe(true); + + findFormInput().vm.$emit('input', tagName); + + await waitForPromises(); + + expect(findDeleteButton().props('disabled')).not.toBe(true); + }); + }); +}); diff --git a/spec/frontend/tags/init_delete_tag_modal_spec.js b/spec/frontend/tags/init_delete_tag_modal_spec.js new file mode 100644 index 00000000000..537df4fac52 --- /dev/null +++ b/spec/frontend/tags/init_delete_tag_modal_spec.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import { resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures'; +import initDeleteTagModal from '../../../app/assets/javascripts/tags/init_delete_tag_modal'; + +describe('initDeleteTagModal', () => { + beforeEach(() => { + setHTMLFixture('<div class="js-delete-tag-modal"></div>'); + }); + + afterEach(() => { + resetHTMLFixture(); + }); + + it('should mount the delete tag modal', () => { + expect(initDeleteTagModal()).toBeInstanceOf(Vue); + expect(document.querySelector('.js-delete-tag-modal')).toBeNull(); + }); + + it('should return false if the mounting element is missing', () => { + document.querySelector('.js-delete-tag-modal').remove(); + expect(initDeleteTagModal()).toBe(false); + }); +}); diff --git a/spec/frontend/terraform/components/states_table_actions_spec.js b/spec/frontend/terraform/components/states_table_actions_spec.js index d01f6af9023..40b7448d78d 100644 --- a/spec/frontend/terraform/components/states_table_actions_spec.js +++ b/spec/frontend/terraform/components/states_table_actions_spec.js @@ -69,6 +69,7 @@ describe('StatesTableActions', () => { wrapper = shallowMount(StateActions, { apolloProvider, propsData, + provide: { projectPath: 'path/to/project' }, mocks: { $toast: { show: toast } }, stubs: { GlDropdown, GlModal, GlSprintf }, }); diff --git a/spec/frontend/terraform/components/states_table_spec.js b/spec/frontend/terraform/components/states_table_spec.js index fa9c8320b4f..16ffd2b7013 100644 --- a/spec/frontend/terraform/components/states_table_spec.js +++ b/spec/frontend/terraform/components/states_table_spec.js @@ -2,6 +2,8 @@ import { GlBadge, GlLoadingIcon, GlTooltip } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { useFakeDate } from 'helpers/fake_date'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import StatesTable from '~/terraform/components/states_table.vue'; import StateActions from '~/terraform/components/states_table_actions.vue'; @@ -104,11 +106,31 @@ describe('StatesTable', () => { updatedAt: '2020-10-10T00:00:00Z', latestVersion: null, }, + { + _showDetails: false, + errorMessages: [], + name: 'state-6', + loadingLock: false, + loadingRemove: false, + lockedAt: null, + lockedByUser: null, + updatedAt: '2020-10-10T00:00:00Z', + deletedAt: '2022-02-02T00:00:00Z', + latestVersion: null, + }, ], }; const createComponent = async (propsData = defaultProps) => { - wrapper = mount(StatesTable, { propsData }); + wrapper = extendedWrapper( + mount(StatesTable, { + propsData, + provide: { projectPath: 'path/to/project' }, + directives: { + GlTooltip: createMockDirective(), + }, + }), + ); await nextTick(); }; @@ -124,27 +146,28 @@ describe('StatesTable', () => { }); it.each` - name | toolTipText | locked | loading | lineNumber + name | toolTipText | hasBadge | loading | lineNumber ${'state-1'} | ${'Locked by user-1 2 days ago'} | ${true} | ${false} | ${0} ${'state-2'} | ${'Locking state'} | ${false} | ${true} | ${1} ${'state-3'} | ${'Unlocking state'} | ${false} | ${true} | ${2} ${'state-4'} | ${'Locked by Unknown User 5 days ago'} | ${true} | ${false} | ${3} ${'state-5'} | ${'Removing'} | ${false} | ${true} | ${4} + ${'state-6'} | ${'Deletion in progress'} | ${true} | ${false} | ${5} `( 'displays the name and locked information "$name" for line "$lineNumber"', - ({ name, toolTipText, locked, loading, lineNumber }) => { + ({ name, toolTipText, hasBadge, loading, lineNumber }) => { const states = wrapper.findAll('[data-testid="terraform-states-table-name"]'); - const state = states.at(lineNumber); - const toolTip = state.find(GlTooltip); expect(state.text()).toContain(name); - expect(state.find(GlBadge).exists()).toBe(locked); + expect(state.find(GlBadge).exists()).toBe(hasBadge); expect(state.find(GlLoadingIcon).exists()).toBe(loading); - expect(toolTip.exists()).toBe(locked); - if (locked) { - expect(toolTip.text()).toMatchInterpolatedText(toolTipText); + if (hasBadge) { + const badge = wrapper.findByTestId(`state-badge-${name}`); + + expect(getBinding(badge.element, 'gl-tooltip')).toBeDefined(); + expect(badge.attributes('title')).toMatchInterpolatedText(toolTipText); } }, ); diff --git a/spec/frontend/terraform/components/terraform_list_spec.js b/spec/frontend/terraform/components/terraform_list_spec.js index c8b4cd564d9..cfd82768098 100644 --- a/spec/frontend/terraform/components/terraform_list_spec.js +++ b/spec/frontend/terraform/components/terraform_list_spec.js @@ -16,6 +16,9 @@ describe('TerraformList', () => { const propsData = { emptyStateImage: '/path/to/image', + }; + + const provide = { projectPath: 'path/to/project', }; @@ -47,6 +50,7 @@ describe('TerraformList', () => { wrapper = shallowMount(TerraformList, { apolloProvider, propsData, + provide, stubs: { GlTab, }, diff --git a/spec/frontend/user_popovers_spec.js b/spec/frontend/user_popovers_spec.js index fa598716645..1544fed5240 100644 --- a/spec/frontend/user_popovers_spec.js +++ b/spec/frontend/user_popovers_spec.js @@ -22,16 +22,17 @@ describe('User Popovers', () => { const link = document.createElement('a'); link.classList.add('js-user-link'); - link.setAttribute('data-user', '1'); + link.dataset.user = '1'; return link; }; + const findPopovers = () => { + return Array.from(document.querySelectorAll('[data-testid="user-popover"]')); + }; const dummyUser = { name: 'root', username: 'root', is_followed: false }; const dummyUserStatus = { message: 'active' }; - let popovers; - const triggerEvent = (eventName, el) => { const event = new MouseEvent(eventName, { bubbles: true, @@ -54,56 +55,73 @@ describe('User Popovers', () => { .mockImplementation((userId) => userStatusCacheSpy(userId)); jest.spyOn(UsersCache, 'updateById'); - popovers = initUserPopovers(document.querySelectorAll(selector)); + initUserPopovers((popoverInstance) => { + const mountingRoot = document.createElement('div'); + document.body.appendChild(mountingRoot); + popoverInstance.$mount(mountingRoot); + }); }); afterEach(() => { resetHTMLFixture(); }); - it('initializes a popover for each user link with a user id', () => { - const linksWithUsers = findFixtureLinks(); + describe('shows a placeholder popover on hover', () => { + let linksWithUsers; + beforeEach(() => { + linksWithUsers = findFixtureLinks(); + linksWithUsers.forEach((el) => { + triggerEvent('mouseover', el); + }); + }); - expect(linksWithUsers.length).toBe(popovers.length); - }); + it('for initial links', () => { + expect(findPopovers().length).toBe(linksWithUsers.length); + }); - it('adds popovers to user links added to the DOM tree after the initial call', async () => { - document.body.appendChild(createUserLink()); - document.body.appendChild(createUserLink()); + it('for elements added after initial load', async () => { + const addedLinks = [createUserLink(), createUserLink()]; + addedLinks.forEach((link) => { + document.body.appendChild(link); + }); - const linksWithUsers = findFixtureLinks(); + jest.runOnlyPendingTimers(); - expect(linksWithUsers.length).toBe(popovers.length + 2); + addedLinks.forEach((link) => { + triggerEvent('mouseover', link); + }); + + expect(findPopovers().length).toBe(linksWithUsers.length + addedLinks.length); + }); }); - it('does not initialize the user popovers twice for the same element', () => { - const newPopovers = initUserPopovers(document.querySelectorAll(selector)); - const samePopovers = popovers.every((popover, index) => newPopovers[index] === popover); + it('does not initialize the user popovers twice for the same element', async () => { + const [firstUserLink] = findFixtureLinks(); + triggerEvent('mouseover', firstUserLink); + jest.runOnlyPendingTimers(); + triggerEvent('mouseleave', firstUserLink); + jest.runOnlyPendingTimers(); + triggerEvent('mouseover', firstUserLink); + jest.runOnlyPendingTimers(); - expect(samePopovers).toBe(true); + expect(findPopovers().length).toBe(1); }); - describe('when user link emits mouseenter event', () => { + describe('when user link emits mouseenter event with empty user cache', () => { let userLink; beforeEach(() => { UsersCache.retrieveById.mockReset(); - userLink = document.querySelector(selector); - - triggerEvent('mouseenter', userLink); - }); + [userLink] = findFixtureLinks(); - it('removes title attribute from user links', () => { - expect(userLink.getAttribute('title')).toBeFalsy(); - expect(userLink.dataset.originalTitle).toBeFalsy(); + triggerEvent('mouseover', userLink); }); - it('populates popovers with preloaded user data', () => { + it('populates popover with preloaded user data', () => { const { name, userId, username } = userLink.dataset; - const [firstPopover] = popovers; - expect(firstPopover.$props.user).toEqual( + expect(userLink.user).toEqual( expect.objectContaining({ name, userId, @@ -111,6 +129,21 @@ describe('User Popovers', () => { }), ); }); + }); + + describe('when user link emits mouseenter event', () => { + let userLink; + + beforeEach(() => { + [userLink] = findFixtureLinks(); + + triggerEvent('mouseover', userLink); + }); + + it('removes title attribute from user links', () => { + expect(userLink.getAttribute('title')).toBeFalsy(); + expect(userLink.dataset.originalTitle).toBeFalsy(); + }); it('fetches user info and status from the user cache', () => { const { userId } = userLink.dataset; @@ -118,42 +151,38 @@ describe('User Popovers', () => { expect(UsersCache.retrieveById).toHaveBeenCalledWith(userId); expect(UsersCache.retrieveStatusById).toHaveBeenCalledWith(userId); }); - }); - - it('removes aria-describedby attribute from the user link on mouseleave', () => { - const userLink = document.querySelector(selector); - userLink.setAttribute('aria-describedby', 'popover'); - triggerEvent('mouseleave', userLink); + it('removes aria-describedby attribute from the user link on mouseleave', () => { + userLink.setAttribute('aria-describedby', 'popover'); + triggerEvent('mouseleave', userLink); - expect(userLink.getAttribute('aria-describedby')).toBe(null); - }); - - it('updates toggle follow button and `UsersCache` when toggle follow button is clicked', async () => { - const [firstPopover] = popovers; - const withinFirstPopover = within(firstPopover.$el); - const findFollowButton = () => withinFirstPopover.queryByRole('button', { name: 'Follow' }); - const findUnfollowButton = () => withinFirstPopover.queryByRole('button', { name: 'Unfollow' }); + expect(userLink.getAttribute('aria-describedby')).toBe(null); + }); - const userLink = document.querySelector(selector); - triggerEvent('mouseenter', userLink); + it('updates toggle follow button and `UsersCache` when toggle follow button is clicked', async () => { + const [firstPopover] = findPopovers(); + const withinFirstPopover = within(firstPopover); + const findFollowButton = () => withinFirstPopover.queryByRole('button', { name: 'Follow' }); + const findUnfollowButton = () => + withinFirstPopover.queryByRole('button', { name: 'Unfollow' }); - await waitForPromises(); + jest.runOnlyPendingTimers(); - const { userId } = document.querySelector(selector).dataset; + const { userId } = document.querySelector(selector).dataset; - triggerEvent('click', findFollowButton()); + triggerEvent('click', findFollowButton()); - await waitForPromises(); + await waitForPromises(); - expect(findUnfollowButton()).not.toBe(null); - expect(UsersCache.updateById).toHaveBeenCalledWith(userId, { is_followed: true }); + expect(findUnfollowButton()).not.toBe(null); + expect(UsersCache.updateById).toHaveBeenCalledWith(userId, { is_followed: true }); - triggerEvent('click', findUnfollowButton()); + triggerEvent('click', findUnfollowButton()); - await waitForPromises(); + await waitForPromises(); - expect(findFollowButton()).not.toBe(null); - expect(UsersCache.updateById).toHaveBeenCalledWith(userId, { is_followed: false }); + expect(findFollowButton()).not.toBe(null); + expect(UsersCache.updateById).toHaveBeenCalledWith(userId, { is_followed: false }); + }); }); }); diff --git a/spec/frontend/users_select/test_helper.js b/spec/frontend/users_select/test_helper.js index 59edde48eab..9231e38ea90 100644 --- a/spec/frontend/users_select/test_helper.js +++ b/spec/frontend/users_select/test_helper.js @@ -95,10 +95,10 @@ export const setAssignees = (...users) => { const input = document.createElement('input'); input.name = 'merge_request[assignee_ids][]'; input.value = user.id.toString(); - input.setAttribute('data-avatar-url', user.avatar_url); - input.setAttribute('data-name', user.name); - input.setAttribute('data-username', user.username); - input.setAttribute('data-can-merge', user.can_merge); + input.dataset.avatarUrl = user.avatar_url; + input.dataset.name = user.name; + input.dataset.username = user.username; + input.dataset.canMerge = user.can_merge; return input; }), ); diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js index 4985417ad99..05cd1bb5b3d 100644 --- a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js @@ -1,5 +1,5 @@ import { nextTick } from 'vue'; -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import createFlash from '~/flash'; import Approvals from '~/vue_merge_request_widget/components/approvals/approvals.vue'; @@ -15,6 +15,7 @@ import eventHub from '~/vue_merge_request_widget/event_hub'; jest.mock('~/flash'); +const RULE_NAME = 'first_rule'; const TEST_HELP_PATH = 'help/path'; const testApprovedBy = () => [1, 7, 10].map((id) => ({ id })); const testApprovals = () => ({ @@ -26,6 +27,7 @@ const testApprovals = () => ({ user_can_approve: true, user_has_approved: true, require_password_to_approve: false, + invalid_approvers_rules: [], }); const testApprovalRulesResponse = () => ({ rules: [{ id: 2 }] }); @@ -41,6 +43,9 @@ describe('MRWidget approvals', () => { service, ...props, }, + stubs: { + GlSprintf, + }, }); }; @@ -58,6 +63,7 @@ describe('MRWidget approvals', () => { }; const findSummary = () => wrapper.find(ApprovalsSummary); const findOptionalSummary = () => wrapper.find(ApprovalsSummaryOptional); + const findInvalidRules = () => wrapper.find('[data-testid="invalid-rules"]'); beforeEach(() => { service = { @@ -171,7 +177,7 @@ describe('MRWidget approvals', () => { it('approve action is rendered', () => { expect(findActionData()).toEqual({ - variant: 'info', + variant: 'confirm', text: 'Approve', category: 'primary', }); @@ -192,7 +198,7 @@ describe('MRWidget approvals', () => { it('approve action (with inverted style) is rendered', () => { expect(findActionData()).toEqual({ - variant: 'info', + variant: 'confirm', text: 'Approve', category: 'secondary', }); @@ -208,7 +214,7 @@ describe('MRWidget approvals', () => { it('approve additionally action is rendered', () => { expect(findActionData()).toEqual({ - variant: 'info', + variant: 'confirm', text: 'Approve additionally', category: 'secondary', }); @@ -279,9 +285,9 @@ describe('MRWidget approvals', () => { it('revoke action is rendered', () => { expect(findActionData()).toEqual({ - variant: 'warning', + category: 'primary', + variant: 'default', text: 'Revoke approval', - category: 'secondary', }); }); @@ -383,4 +389,36 @@ describe('MRWidget approvals', () => { }); }); }); + + describe('invalid rules', () => { + beforeEach(() => { + mr.approvals.merge_request_approvers_available = true; + createComponent(); + }); + + it('does not render related components', () => { + expect(findInvalidRules().exists()).toBe(false); + }); + + describe('when invalid rules are present', () => { + beforeEach(() => { + mr.approvals.invalid_approvers_rules = [{ name: RULE_NAME }]; + createComponent(); + }); + + it('renders related components', () => { + const invalidRules = findInvalidRules(); + + expect(invalidRules.exists()).toBe(true); + + const invalidRulesText = invalidRules.text(); + + expect(invalidRulesText).toContain(RULE_NAME); + expect(invalidRulesText).toContain( + 'GitLab has approved this rule automatically to unblock the merge request.', + ); + expect(invalidRulesText).toContain('Learn more.'); + }); + }); + }); }); diff --git a/spec/frontend/vue_mr_widget/components/approvals/humanized_text_spec.js b/spec/frontend/vue_mr_widget/components/approvals/humanized_text_spec.js new file mode 100644 index 00000000000..d6776c00b29 --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/approvals/humanized_text_spec.js @@ -0,0 +1,18 @@ +import { humanizeInvalidApproversRules } from '~/vue_merge_request_widget/components/approvals/humanized_text'; + +const testRules = [{ name: 'Lorem' }, { name: 'Ipsum' }, { name: 'Dolar' }]; + +describe('humanizeInvalidApproversRules', () => { + it('returns text in regards to a single rule', () => { + const [singleRule] = testRules; + expect(humanizeInvalidApproversRules([singleRule])).toBe('"Lorem"'); + }); + + it('returns empty text when there is no rule', () => { + expect(humanizeInvalidApproversRules([])).toBe(''); + }); + + it('returns text in regards to multiple rules', () => { + expect(humanizeInvalidApproversRules(testRules)).toBe('"Lorem", "Ipsum" and "Dolar"'); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/extensions/index_spec.js b/spec/frontend/vue_mr_widget/components/extensions/index_spec.js index 63df63a9b00..dc25596655a 100644 --- a/spec/frontend/vue_mr_widget/components/extensions/index_spec.js +++ b/spec/frontend/vue_mr_widget/components/extensions/index_spec.js @@ -21,8 +21,8 @@ describe('MR widget extension registering', () => { expect.objectContaining({ extends: ExtensionBase, name: 'Test', - props: ['helloWorld'], computed: { + helloWorld: expect.any(Function), test: expect.any(Function), }, methods: { diff --git a/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js b/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js index 82526af7afa..01fbcb2154f 100644 --- a/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js @@ -42,8 +42,8 @@ describe('Merge Request Collapsible Extension', () => { expect(wrapper.find('[data-testid="collapsed-header"]').text()).toBe('hello there'); }); - it('renders angle-right icon', () => { - expect(findIcon().props('name')).toBe('angle-right'); + it('renders chevron-lg-right icon', () => { + expect(findIcon().props('name')).toBe('chevron-lg-right'); }); describe('onClick', () => { @@ -60,8 +60,8 @@ describe('Merge Request Collapsible Extension', () => { expect(findTitle().text()).toBe('Collapse'); }); - it('renders angle-down icon', () => { - expect(findIcon().props('name')).toBe('angle-down'); + it('renders chevron-lg-down icon', () => { + expect(findIcon().props('name')).toBe('chevron-lg-down'); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js deleted file mode 100644 index ed6dc598845..00000000000 --- a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js +++ /dev/null @@ -1,176 +0,0 @@ -import { shallowMount, mount } from '@vue/test-utils'; -import Header from '~/vue_merge_request_widget/components/mr_widget_header.vue'; - -describe('MRWidgetHeader', () => { - let wrapper; - - const createComponent = (propsData = {}) => { - wrapper = shallowMount(Header, { - propsData, - }); - }; - - afterEach(() => { - wrapper.destroy(); - gon.relative_url_root = ''; - }); - - const commonMrProps = { - divergedCommitsCount: 1, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', - targetBranch: 'main', - targetBranchPath: '/foo/bar/main', - statusPath: 'abc', - }; - - describe('computed', () => { - describe('shouldShowCommitsBehindText', () => { - it('return true when there are divergedCommitsCount', () => { - createComponent({ - mr: { - divergedCommitsCount: 12, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', - targetBranch: 'main', - statusPath: 'abc', - }, - }); - - expect(wrapper.vm.shouldShowCommitsBehindText).toBe(true); - }); - - it('returns false where there are no divergedComits count', () => { - createComponent({ - mr: { - divergedCommitsCount: 0, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', - targetBranch: 'main', - statusPath: 'abc', - }, - }); - - expect(wrapper.vm.shouldShowCommitsBehindText).toBe(false); - }); - }); - - describe('commitsBehindText', () => { - it('returns singular when there is one commit', () => { - wrapper = mount(Header, { - propsData: { - mr: commonMrProps, - }, - }); - - expect(wrapper.find('.diverged-commits-count').element.innerHTML).toBe( - 'The source branch is <a href="/foo/bar/main" class="gl-link">1 commit behind</a> the target branch', - ); - }); - - it('returns plural when there is more than one commit', () => { - wrapper = mount(Header, { - propsData: { - mr: { - ...commonMrProps, - divergedCommitsCount: 2, - }, - }, - }); - expect(wrapper.find('.diverged-commits-count').element.innerHTML).toBe( - 'The source branch is <a href="/foo/bar/main" class="gl-link">2 commits behind</a> the target branch', - ); - }); - }); - }); - - describe('template', () => { - describe('common elements', () => { - beforeEach(() => { - createComponent({ - mr: { - divergedCommitsCount: 12, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', - sourceBranchRemoved: false, - targetBranchPath: 'foo/bar/commits-path', - targetBranchTreePath: 'foo/bar/tree/path', - targetBranch: 'main', - isOpen: true, - emailPatchesPath: '/mr/email-patches', - plainDiffPath: '/mr/plainDiffPath', - statusPath: 'abc', - }, - }); - }); - - it('renders source branch link', () => { - expect(wrapper.find('.js-source-branch').html()).toContain( - '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', - ); - }); - - it('renders clipboard button', () => { - expect(wrapper.find('[data-testid="mr-widget-copy-clipboard"]')).not.toBe(null); - }); - - it('renders target branch', () => { - expect(wrapper.find('.js-target-branch').text().trim()).toBe('main'); - }); - }); - - describe('without diverged commits', () => { - beforeEach(() => { - createComponent({ - mr: { - divergedCommitsCount: 0, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', - sourceBranchRemoved: false, - targetBranchPath: 'foo/bar/commits-path', - targetBranchTreePath: 'foo/bar/tree/path', - targetBranch: 'main', - isOpen: true, - emailPatchesPath: '/mr/email-patches', - plainDiffPath: '/mr/plainDiffPath', - statusPath: 'abc', - }, - }); - }); - - it('does not render diverged commits info', () => { - expect(wrapper.find('.diverged-commits-count').exists()).toBe(false); - }); - }); - - describe('with diverged commits', () => { - beforeEach(() => { - wrapper = mount(Header, { - propsData: { - mr: { - ...commonMrProps, - divergedCommitsCount: 12, - sourceBranchRemoved: false, - targetBranchPath: 'foo/bar/commits-path', - targetBranchTreePath: 'foo/bar/tree/path', - isOpen: true, - emailPatchesPath: '/mr/email-patches', - plainDiffPath: '/mr/plainDiffPath', - }, - }, - }); - }); - - it('renders diverged commits info', () => { - expect(wrapper.find('.diverged-commits-count').text().trim()).toBe( - 'The source branch is 12 commits behind the target branch', - ); - - expect(wrapper.find('.diverged-commits-count a').text().trim()).toBe('12 commits behind'); - expect(wrapper.find('.diverged-commits-count a').attributes('href')).toBe( - wrapper.vm.mr.targetBranchPath, - ); - }); - }); - }); -}); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_suggest_pipeline_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_suggest_pipeline_spec.js index 8e710b6d65f..352bc1a08ea 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_suggest_pipeline_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_suggest_pipeline_spec.js @@ -71,7 +71,7 @@ describe('MRWidgetSuggestPipeline', () => { const button = findOkBtn(); expect(button.exists()).toBe(true); - expect(button.classes('btn-info')).toEqual(true); + expect(button.classes('btn-confirm')).toEqual(true); expect(button.attributes('href')).toBe(suggestProps.pipelinePath); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js index 8efc4d84624..29ee7e0010f 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js @@ -193,9 +193,7 @@ describe('MRWidgetMerged', () => { it('shows button to copy commit SHA to clipboard', () => { expect(selectors.copyMergeShaButton).not.toBe(null); - expect(selectors.copyMergeShaButton.getAttribute('data-clipboard-text')).toBe( - vm.mr.mergeCommitSha, - ); + expect(selectors.copyMergeShaButton.dataset.clipboardText).toBe(vm.mr.mergeCommitSha); }); it('hides button to copy commit SHA if SHA does not exist', async () => { diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js index da3a323e8ea..46d90ddc83c 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -87,7 +87,11 @@ const createReadyToMergeResponse = (customMr) => { }); }; -const createComponent = (customConfig = {}, mergeRequestWidgetGraphql = false) => { +const createComponent = ( + customConfig = {}, + mergeRequestWidgetGraphql = false, + restructuredMrWidget = false, +) => { wrapper = shallowMount(ReadyToMerge, { localVue, propsData: { @@ -97,6 +101,7 @@ const createComponent = (customConfig = {}, mergeRequestWidgetGraphql = false) = provide: { glFeatures: { mergeRequestWidgetGraphql, + restructuredMrWidget, }, }, stubs: { @@ -307,6 +312,20 @@ describe('ReadyToMerge', () => { }, }); + beforeEach(() => { + readyToMergeResponseSpy = jest + .fn() + .mockResolvedValueOnce(createReadyToMergeResponse({ squash: true, squashOnMerge: true })) + .mockResolvedValue( + createReadyToMergeResponse({ + squash: true, + squashOnMerge: true, + defaultMergeCommitMessage: '', + defaultSquashCommitMessage: '', + }), + ); + }); + it('should handle merge when pipeline succeeds', async () => { createComponent(); @@ -379,6 +398,27 @@ describe('ReadyToMerge', () => { expect(params.should_remove_source_branch).toBeTruthy(); expect(params.auto_merge_strategy).toBeUndefined(); }); + + it('hides edit commit message', async () => { + createComponent({}, true, true); + + await waitForPromises(); + + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + jest.spyOn(wrapper.vm.service, 'merge').mockResolvedValue(response('success')); + + await wrapper + .findComponent('[data-testid="widget_edit_commit_message"]') + .vm.$emit('input', true); + + expect(wrapper.findComponent('[data-testid="edit_commit_message"]').exists()).toBe(true); + + wrapper.vm.handleMergeButtonClick(); + + await waitForPromises(); + + expect(wrapper.findComponent('[data-testid="edit_commit_message"]').exists()).toBe(false); + }); }); describe('initiateRemoveSourceBranchPolling', () => { diff --git a/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js b/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js index b7c22b403aa..8f20d6a8fc9 100644 --- a/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js +++ b/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js @@ -1,4 +1,4 @@ -import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlSprintf } from '@gitlab/ui'; +import { GlSkeletonLoader, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; @@ -51,7 +51,7 @@ describe('MrWidgetTerraformConainer', () => { }); it('diplays loading skeleton', () => { - expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true); + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true); expect(wrapper.find(MrWidgetExpanableSection).exists()).toBe(false); }); }); @@ -63,7 +63,7 @@ describe('MrWidgetTerraformConainer', () => { }); it('displays terraform content', () => { - expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false); + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false); expect(wrapper.find(MrWidgetExpanableSection).exists()).toBe(true); expect(findPlans()).toEqual(Object.values(plans)); }); @@ -158,7 +158,7 @@ describe('MrWidgetTerraformConainer', () => { }); it('stops loading', () => { - expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false); + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false); }); it('generates one broken plan', () => { diff --git a/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js b/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js index 2bc6860743a..da4b990c078 100644 --- a/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js +++ b/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js @@ -9,6 +9,7 @@ import axios from '~/lib/utils/axios_utils'; import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container'; import { registerExtension } from '~/vue_merge_request_widget/components/extensions'; import httpStatusCodes from '~/lib/utils/http_status'; +import TestCaseDetails from '~/pipelines/components/test_reports/test_case_details.vue'; import { failedReport } from 'jest/reports/mock_data/mock_data'; import mixedResultsTestReports from 'jest/reports/mock_data/new_and_fixed_failures_report.json'; @@ -39,6 +40,7 @@ describe('Test report extension', () => { const findToggleCollapsedButton = () => wrapper.findByTestId('toggle-button'); const findTertiaryButton = () => wrapper.find(GlButton); const findAllExtensionListItems = () => wrapper.findAllByTestId('extension-list-item'); + const findModal = () => wrapper.find(TestCaseDetails); const createComponent = () => { wrapper = mountExtended(extensionsContainer, { @@ -190,4 +192,19 @@ describe('Test report extension', () => { ); }); }); + + describe('modal link', () => { + beforeEach(async () => { + await createExpandedWidgetWithData(); + + wrapper.findByTestId('modal-link').trigger('click'); + }); + + it('opens a modal to display test case details', () => { + expect(findModal().exists()).toBe(true); + expect(findModal().props('testCase')).toMatchObject( + mixedResultsTestReports.suites[0].new_failures[0], + ); + }); + }); }); diff --git a/spec/frontend/vue_mr_widget/extentions/terraform/index_spec.js b/spec/frontend/vue_mr_widget/extentions/terraform/index_spec.js index f8ea6fc23a2..77b3576a3d3 100644 --- a/spec/frontend/vue_mr_widget/extentions/terraform/index_spec.js +++ b/spec/frontend/vue_mr_widget/extentions/terraform/index_spec.js @@ -1,6 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import api from '~/api'; import axios from '~/lib/utils/axios_utils'; import Poll from '~/lib/utils/poll'; import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container'; @@ -14,6 +15,8 @@ import { invalidPlanWithoutName, } from '../../components/terraform/mock_data'; +jest.mock('~/api.js'); + describe('Terraform extension', () => { let wrapper; let mock; @@ -130,20 +133,33 @@ describe('Terraform extension', () => { } }); }); + + it('responds with the correct telemetry when the deeply nested "Full log" link is clicked', () => { + api.trackRedisHllUserEvent.mockClear(); + api.trackRedisCounterEvent.mockClear(); + + findListItem(0).find('[data-testid="extension-actions-button"]').trigger('click'); + + expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); + expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( + 'i_merge_request_widget_terraform_click_full_report', + ); + expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(1); + expect(api.trackRedisCounterEvent).toHaveBeenCalledWith( + 'i_merge_request_widget_terraform_count_click_full_report', + ); + }); }); describe('polling', () => { let pollRequest; - let pollStop; beforeEach(() => { pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); - pollStop = jest.spyOn(Poll.prototype, 'stop'); }); afterEach(() => { pollRequest.mockRestore(); - pollStop.mockRestore(); }); describe('successful poll', () => { @@ -155,7 +171,6 @@ describe('Terraform extension', () => { it('does not make additional requests after poll is successful', () => { expect(pollRequest).toHaveBeenCalledTimes(1); - expect(pollStop).toHaveBeenCalledTimes(1); }); }); @@ -171,7 +186,6 @@ describe('Terraform extension', () => { it('does not make additional requests after poll is unsuccessful', () => { expect(pollRequest).toHaveBeenCalledTimes(1); - expect(pollStop).toHaveBeenCalledTimes(1); }); }); }); diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js index 9719e81fe12..6abbb052aef 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -29,8 +29,11 @@ import { workingExtension, collapsedDataErrorExtension, fullDataErrorExtension, + fullReportExtension, + noTelemetryExtension, pollingExtension, pollingErrorExtension, + multiPollingExtension, } from './test_extensions'; jest.mock('~/api.js'); @@ -48,6 +51,8 @@ describe('MrWidgetOptions', () => { const COLLABORATION_MESSAGE = 'Members who can merge are allowed to add commits'; const findExtensionToggleButton = () => wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]'); + const findExtensionLink = (linkHref) => + wrapper.find(`[data-testid="widget-extension"] [href="${linkHref}"]`); beforeEach(() => { gl.mrWidgetData = { ...mockData }; @@ -67,7 +72,7 @@ describe('MrWidgetOptions', () => { gon.features = {}; }); - const createComponent = (mrData = mockData, options = {}) => { + const createComponent = (mrData = mockData, options = {}, glFeatures = {}) => { if (wrapper) { wrapper.destroy(); } @@ -76,6 +81,9 @@ describe('MrWidgetOptions', () => { propsData: { mrData: { ...mrData }, }, + provide: { + glFeatures, + }, ...options, }); @@ -423,7 +431,7 @@ describe('MrWidgetOptions', () => { beforeEach(() => { const favicon = document.createElement('link'); favicon.setAttribute('id', 'favicon'); - favicon.setAttribute('data-original-href', faviconDataUrl); + favicon.dataset.originalHref = faviconDataUrl; document.body.appendChild(favicon); faviconElement = document.getElementById('favicon'); @@ -621,7 +629,16 @@ describe('MrWidgetOptions', () => { }); describe('code quality widget', () => { - it('renders the component', () => { + beforeEach(() => { + jest.spyOn(document, 'dispatchEvent'); + }); + it('renders the component when refactorCodeQualityExtension is false', () => { + createComponent(mockData, {}, { refactorCodeQualityExtension: false }); + expect(wrapper.find('.js-codequality-widget').exists()).toBe(true); + }); + + it('does not render the component when refactorCodeQualityExtension is true', () => { + createComponent(mockData, {}, { refactorCodeQualityExtension: true }); expect(wrapper.find('.js-codequality-widget').exists()).toBe(true); }); }); @@ -911,18 +928,6 @@ describe('MrWidgetOptions', () => { expect(wrapper.text()).toContain('Test extension summary count: 1'); }); - it('triggers trackRedisHllUserEvent API call', async () => { - await waitForPromises(); - - wrapper - .find('[data-testid="widget-extension"] [data-testid="toggle-button"]') - .trigger('click'); - - await nextTick(); - - expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith('test_expand_event'); - }); - it('renders full data', async () => { await waitForPromises(); @@ -982,31 +987,98 @@ describe('MrWidgetOptions', () => { describe('mock polling extension', () => { let pollRequest; - let pollStop; + + const findWidgetTestExtension = () => wrapper.find('[data-testid="widget-extension"]'); beforeEach(() => { pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); - pollStop = jest.spyOn(Poll.prototype, 'stop'); + + registeredExtensions.extensions = []; }); afterEach(() => { pollRequest.mockRestore(); - pollStop.mockRestore(); registeredExtensions.extensions = []; + + // Clear all left-over timeouts that may be registered in the poll class + let id = window.setTimeout(() => {}, 0); + + while (id > 0) { + window.clearTimeout(id); + id -= 1; + } }); - describe('success', () => { - beforeEach(() => { - registerExtension(pollingExtension); + describe('success - multi polling', () => { + it('sets data when polling is complete', async () => { + registerExtension( + multiPollingExtension([ + () => + Promise.resolve({ + headers: { 'poll-interval': 0 }, + status: 200, + data: { reports: 'parsed' }, + }), + () => + Promise.resolve({ + status: 200, + data: { reports: 'parsed' }, + }), + ]), + ); - createComponent(); + await createComponent(); + expect(findWidgetTestExtension().html()).toContain( + 'Multi polling test extension reports: parsed, count: 2', + ); }); - it('does not make additional requests after poll is successful', () => { + it('shows loading state until polling is complete', async () => { + registerExtension( + multiPollingExtension([ + () => + Promise.resolve({ + headers: { 'poll-interval': 1 }, + status: 204, + }), + () => + Promise.resolve({ + status: 200, + data: { reports: 'parsed' }, + }), + ]), + ); + + await createComponent(); + expect(findWidgetTestExtension().html()).toContain('Test extension loading...'); + }); + }); + + describe('success', () => { + it('does not make additional requests after poll is successful', async () => { + registerExtension(pollingExtension); + await createComponent(); // called two times due to parent component polling (mount) and extension polling expect(pollRequest).toHaveBeenCalledTimes(2); - expect(pollStop).toHaveBeenCalledTimes(1); + }); + + it('keeps polling when poll-interval header is provided', async () => { + registerExtension({ + ...pollingExtension, + methods: { + ...pollingExtension.methods, + fetchCollapsedData() { + return Promise.resolve({ + data: {}, + headers: { 'poll-interval': 1 }, + status: 204, + }); + }, + }, + }); + await createComponent(); + expect(findWidgetTestExtension().html()).toContain('Test extension loading...'); }); }); @@ -1024,7 +1096,6 @@ describe('MrWidgetOptions', () => { it('does not make additional requests after poll has failed', () => { // called two times due to parent component polling (mount) and extension polling expect(pollRequest).toHaveBeenCalledTimes(2); - expect(pollStop).toHaveBeenCalledTimes(1); }); it('captures sentry error and displays error when poll has failed', () => { @@ -1080,4 +1151,119 @@ describe('MrWidgetOptions', () => { itHandlesTheException(); }); }); + + describe('telemetry', () => { + afterEach(() => { + registeredExtensions.extensions = []; + }); + + it('triggers view events when mounted', () => { + registerExtension(workingExtension()); + createComponent(); + + expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); + expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( + 'i_merge_request_widget_test_extension_view', + ); + expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(1); + expect(api.trackRedisCounterEvent).toHaveBeenCalledWith( + 'i_merge_request_widget_test_extension_count_view', + ); + }); + + describe('expand button', () => { + it('triggers expand events when clicked', async () => { + registerExtension(workingExtension()); + createComponent(); + + await waitForPromises(); + + api.trackRedisHllUserEvent.mockClear(); + api.trackRedisCounterEvent.mockClear(); + + findExtensionToggleButton().trigger('click'); + + // The default working extension is a "warning" type, which generates a second - more specific - telemetry event for expansions + expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(2); + expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( + 'i_merge_request_widget_test_extension_expand', + ); + expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( + 'i_merge_request_widget_test_extension_expand_warning', + ); + expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(2); + expect(api.trackRedisCounterEvent).toHaveBeenCalledWith( + 'i_merge_request_widget_test_extension_count_expand', + ); + expect(api.trackRedisCounterEvent).toHaveBeenCalledWith( + 'i_merge_request_widget_test_extension_count_expand_warning', + ); + }); + + it.each` + widgetName | nonStandardEvent + ${'WidgetCodeQuality'} | ${'i_testing_code_quality_widget_total'} + ${'WidgetTerraform'} | ${'i_testing_terraform_widget_total'} + ${'WidgetIssues'} | ${'i_testing_load_performance_widget_total'} + ${'WidgetTestReport'} | ${'i_testing_summary_widget_total'} + `( + "sends non-standard events for the '$widgetName' widget", + async ({ widgetName, nonStandardEvent }) => { + const definition = { + ...workingExtension(), + name: widgetName, + }; + + registerExtension(definition); + createComponent(); + + await waitForPromises(); + + api.trackRedisHllUserEvent.mockClear(); + + findExtensionToggleButton().trigger('click'); + + expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(nonStandardEvent); + }, + ); + }); + + it('triggers the "full report clicked" events when the appropriate button is clicked', () => { + registerExtension(fullReportExtension); + createComponent(); + + api.trackRedisHllUserEvent.mockClear(); + api.trackRedisCounterEvent.mockClear(); + + findExtensionLink('testref').trigger('click'); + + expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); + expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( + 'i_merge_request_widget_test_extension_click_full_report', + ); + expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(1); + expect(api.trackRedisCounterEvent).toHaveBeenCalledWith( + 'i_merge_request_widget_test_extension_count_click_full_report', + ); + }); + + describe('when disabled', () => { + afterEach(() => { + registeredExtensions.extensions = []; + }); + + it("doesn't emit any telemetry events", async () => { + registerExtension(noTelemetryExtension); + createComponent(); + + await waitForPromises(); + + findExtensionToggleButton().trigger('click'); + findExtensionLink('testref').trigger('click'); // The "full report" link + + expect(api.trackRedisHllUserEvent).not.toHaveBeenCalled(); + expect(api.trackRedisCounterEvent).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/spec/frontend/vue_mr_widget/test_extensions.js b/spec/frontend/vue_mr_widget/test_extensions.js index 6344636873f..76644e0be77 100644 --- a/spec/frontend/vue_mr_widget/test_extensions.js +++ b/spec/frontend/vue_mr_widget/test_extensions.js @@ -4,11 +4,14 @@ export const workingExtension = (shouldCollapse = true) => ({ name: 'WidgetTestExtension', props: ['targetProjectFullPath'], expandEvent: 'test_expand_event', + i18n: { + loading: 'Test extension loading...', + }, computed: { - summary({ count, targetProjectFullPath }) { + summary({ count, targetProjectFullPath } = {}) { return `Test extension summary count: ${count} & ${targetProjectFullPath}`; }, - statusIcon({ count }) { + statusIcon({ count } = {}) { return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success; }, shouldCollapse() { @@ -106,6 +109,50 @@ export const pollingExtension = { enablePolling: true, }; +export const fullReportExtension = { + ...workingExtension(), + computed: { + ...workingExtension().computed, + tertiaryButtons() { + return [ + { + text: 'test', + href: `testref`, + target: '_blank', + fullReport: true, + }, + ]; + }, + }, +}; + +export const noTelemetryExtension = { + ...fullReportExtension, + telemetry: false, +}; + +export const multiPollingExtension = (endpointsToBePolled) => ({ + name: 'WidgetTestMultiPollingExtension', + props: [], + i18n: { + loading: 'Test extension loading...', + }, + computed: { + summary(data) { + return `Multi polling test extension reports: ${data?.[0]?.reports}, count: ${data.length}`; + }, + statusIcon(data) { + return data?.[0]?.reports === 'parsed' ? EXTENSION_ICONS.success : EXTENSION_ICONS.warning; + }, + }, + enablePolling: true, + methods: { + fetchMultiData() { + return endpointsToBePolled; + }, + }, +}); + export const pollingErrorExtension = { ...collapsedDataErrorExtension, enablePolling: true, 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 7aa54a1c55a..ce51af31a70 100644 --- a/spec/frontend/vue_shared/alert_details/alert_details_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_details_spec.js @@ -201,28 +201,6 @@ describe('AlertDetails', () => { }); }); - describe('Threat Monitoring details', () => { - it('should not render the metrics tab', () => { - mountComponent({ - data: { alert: mockAlert }, - provide: { isThreatMonitoringPage: true }, - }); - expect(findMetricsTab().exists()).toBe(false); - }); - - it('should display "View incident" button that links the issues page when incident exists', () => { - const iid = '3'; - mountComponent({ - data: { alert: { ...mockAlert, issue: { iid } }, sidebarStatus: false }, - provide: { isThreatMonitoringPage: true }, - }); - - expect(findViewIncidentBtn().exists()).toBe(true); - expect(findViewIncidentBtn().attributes('href')).toBe(joinPaths(projectIssuesPath, iid)); - expect(findCreateIncidentBtn().exists()).toBe(false); - }); - }); - describe('Create incident from alert', () => { it('should display "View incident" button that links the incident page when incident exists', () => { const iid = '3'; 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 44b4c0398cd..30e15595193 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 @@ -12,7 +12,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` right="true" size="medium" text="Clone" - variant="info" + variant="confirm" > <div class="pb-2 mx-1" @@ -24,41 +24,38 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` <div class="mx-3" > - <div - readonly="readonly" + <b-input-group-stub + readonly="" + tag="div" > - <b-input-group-stub + <!----> + + <b-form-input-stub + class="gl-form-input" + debounce="0" + formatter="[Function]" + readonly="true" + type="text" + value="ssh://foo.bar" + /> + + <b-input-group-append-stub tag="div" > - <!----> - - <b-form-input-stub - class="gl-form-input" - debounce="0" - formatter="[Function]" - readonly="true" - type="text" - value="ssh://foo.bar" + <gl-button-stub + aria-label="Copy URL" + buttontextclasses="" + category="primary" + class="d-inline-flex" + data-clipboard-text="ssh://foo.bar" + data-qa-selector="copy_ssh_url_button" + icon="copy-to-clipboard" + size="medium" + title="Copy URL" + variant="default" /> - - <b-input-group-append-stub - tag="div" - > - <gl-button-stub - aria-label="Copy URL" - buttontextclasses="" - category="primary" - class="d-inline-flex" - data-clipboard-text="ssh://foo.bar" - data-qa-selector="copy_ssh_url_button" - icon="copy-to-clipboard" - size="medium" - title="Copy URL" - variant="default" - /> - </b-input-group-append-stub> - </b-input-group-stub> - </div> + </b-input-group-append-stub> + </b-input-group-stub> </div> <gl-dropdown-section-header-stub> @@ -68,41 +65,38 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` <div class="mx-3" > - <div - readonly="readonly" + <b-input-group-stub + readonly="" + tag="div" > - <b-input-group-stub + <!----> + + <b-form-input-stub + class="gl-form-input" + debounce="0" + formatter="[Function]" + readonly="true" + type="text" + value="http://foo.bar" + /> + + <b-input-group-append-stub tag="div" > - <!----> - - <b-form-input-stub - class="gl-form-input" - debounce="0" - formatter="[Function]" - readonly="true" - type="text" - value="http://foo.bar" + <gl-button-stub + aria-label="Copy URL" + buttontextclasses="" + category="primary" + class="d-inline-flex" + data-clipboard-text="http://foo.bar" + data-qa-selector="copy_http_url_button" + icon="copy-to-clipboard" + size="medium" + title="Copy URL" + variant="default" /> - - <b-input-group-append-stub - tag="div" - > - <gl-button-stub - aria-label="Copy URL" - buttontextclasses="" - category="primary" - class="d-inline-flex" - data-clipboard-text="http://foo.bar" - data-qa-selector="copy_http_url_button" - icon="copy-to-clipboard" - size="medium" - title="Copy URL" - variant="default" - /> - </b-input-group-append-stub> - </b-input-group-stub> - </div> + </b-input-group-append-stub> + </b-input-group-stub> </div> </div> </gl-dropdown-stub> diff --git a/spec/frontend/vue_shared/components/ci_icon_spec.js b/spec/frontend/vue_shared/components/ci_icon_spec.js index 6d52db7ae65..1b502f9587c 100644 --- a/spec/frontend/vue_shared/components/ci_icon_spec.js +++ b/spec/frontend/vue_shared/components/ci_icon_spec.js @@ -5,6 +5,8 @@ import ciIcon from '~/vue_shared/components/ci_icon.vue'; describe('CI Icon component', () => { let wrapper; + const findIconWrapper = () => wrapper.find('[data-testid="ci-icon-wrapper"]'); + afterEach(() => { wrapper.destroy(); wrapper = null; @@ -23,6 +25,52 @@ describe('CI Icon component', () => { expect(wrapper.find(GlIcon).exists()).toBe(true); }); + describe('active icons', () => { + it.each` + isActive | cssClass + ${true} | ${'active'} + ${false} | ${'active'} + `('active should be $isActive', ({ isActive, cssClass }) => { + wrapper = shallowMount(ciIcon, { + propsData: { + status: { + icon: 'status_success', + }, + isActive, + }, + }); + + if (isActive) { + expect(findIconWrapper().classes()).toContain(cssClass); + } else { + expect(findIconWrapper().classes()).not.toContain(cssClass); + } + }); + }); + + describe('interactive icons', () => { + it.each` + isInteractive | cssClass + ${true} | ${'interactive'} + ${false} | ${'interactive'} + `('interactive should be $isInteractive', ({ isInteractive, cssClass }) => { + wrapper = shallowMount(ciIcon, { + propsData: { + status: { + icon: 'status_success', + }, + isInteractive, + }, + }); + + if (isInteractive) { + expect(findIconWrapper().classes()).toContain(cssClass); + } else { + expect(findIconWrapper().classes()).not.toContain(cssClass); + } + }); + }); + describe('rendering a status', () => { it.each` icon | group | cssClass diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/color_item_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/color_item_spec.js new file mode 100644 index 00000000000..fe614f03119 --- /dev/null +++ b/spec/frontend/vue_shared/components/color_select_dropdown/color_item_spec.js @@ -0,0 +1,35 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { hexToRgb } from '~/lib/utils/color_utils'; +import ColorItem from '~/vue_shared/components/color_select_dropdown/color_item.vue'; +import { color } from './mock_data'; + +describe('ColorItem', () => { + let wrapper; + + const propsData = color; + + const createComponent = () => { + wrapper = shallowMountExtended(ColorItem, { + propsData, + }); + }; + + const findColorItem = () => wrapper.findByTestId('color-item'); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the correct title', () => { + expect(wrapper.text()).toBe(propsData.title); + }); + + it('renders the correct background color for the color item', () => { + const convertedColor = hexToRgb(propsData.color).join(', '); + expect(findColorItem().attributes('style')).toBe(`background-color: rgb(${convertedColor});`); + }); +}); diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js new file mode 100644 index 00000000000..93b59800c27 --- /dev/null +++ b/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js @@ -0,0 +1,192 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import DropdownContents from '~/vue_shared/components/color_select_dropdown/dropdown_contents.vue'; +import DropdownValue from '~/vue_shared/components/color_select_dropdown/dropdown_value.vue'; +import epicColorQuery from '~/vue_shared/components/color_select_dropdown/graphql/epic_color.query.graphql'; +import updateEpicColorMutation from '~/vue_shared/components/color_select_dropdown/graphql/epic_update_color.mutation.graphql'; +import ColorSelectRoot from '~/vue_shared/components/color_select_dropdown/color_select_root.vue'; +import { DROPDOWN_VARIANT } from '~/vue_shared/components/color_select_dropdown/constants'; +import { colorQueryResponse, updateColorMutationResponse, color } from './mock_data'; + +jest.mock('~/flash'); + +Vue.use(VueApollo); + +const successfulQueryHandler = jest.fn().mockResolvedValue(colorQueryResponse); +const successfulMutationHandler = jest.fn().mockResolvedValue(updateColorMutationResponse); +const errorQueryHandler = jest.fn().mockRejectedValue('Error fetching epic color.'); +const errorMutationHandler = jest.fn().mockRejectedValue('An error occurred while updating color.'); + +const defaultProps = { + allowEdit: true, + iid: '1', + fullPath: 'workspace-1', +}; + +describe('LabelsSelectRoot', () => { + let wrapper; + + const findSidebarEditableItem = () => wrapper.findComponent(SidebarEditableItem); + const findDropdownValue = () => wrapper.findComponent(DropdownValue); + const findDropdownContents = () => wrapper.findComponent(DropdownContents); + + const createComponent = ({ + queryHandler = successfulQueryHandler, + mutationHandler = successfulMutationHandler, + propsData, + } = {}) => { + const mockApollo = createMockApollo([ + [epicColorQuery, queryHandler], + [updateEpicColorMutation, mutationHandler], + ]); + + wrapper = shallowMount(ColorSelectRoot, { + apolloProvider: mockApollo, + propsData: { + ...defaultProps, + ...propsData, + }, + provide: { + canUpdate: true, + }, + stubs: { + SidebarEditableItem, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + const defaultClasses = ['labels-select-wrapper', 'gl-relative']; + + it.each` + variant | cssClass + ${'sidebar'} | ${defaultClasses} + ${'embedded'} | ${[...defaultClasses, 'is-embedded']} + `( + 'renders component root element with CSS class `$cssClass` when variant is "$variant"', + async ({ variant, cssClass }) => { + createComponent({ + propsData: { variant }, + }); + + expect(wrapper.classes()).toEqual(cssClass); + }, + ); + }); + + describe('if the variant is `sidebar`', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders SidebarEditableItem component', () => { + expect(findSidebarEditableItem().exists()).toBe(true); + }); + + it('renders correct props for the SidebarEditableItem component', () => { + expect(findSidebarEditableItem().props()).toMatchObject({ + title: wrapper.vm.$options.i18n.widgetTitle, + canEdit: defaultProps.allowEdit, + loading: true, + }); + }); + + describe('when colors are loaded', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('passes false `loading` prop to sidebar editable item', () => { + expect(findSidebarEditableItem().props('loading')).toBe(false); + }); + + it('renders dropdown value component when query colors is resolved', () => { + expect(findDropdownValue().props('selectedColor')).toMatchObject(color); + }); + }); + }); + + describe('if the variant is `embedded`', () => { + beforeEach(() => { + createComponent({ propsData: { iid: undefined, variant: DROPDOWN_VARIANT.Embedded } }); + }); + + it('renders DropdownContents component', () => { + expect(findDropdownContents().exists()).toBe(true); + }); + + it('renders correct props for the DropdownContents component', () => { + expect(findDropdownContents().props()).toMatchObject({ + variant: DROPDOWN_VARIANT.Embedded, + dropdownTitle: wrapper.vm.$options.i18n.assignColor, + dropdownButtonText: wrapper.vm.$options.i18n.dropdownButtonText, + }); + }); + + it('handles DropdownContents setColor', () => { + findDropdownContents().vm.$emit('setColor', color); + expect(wrapper.emitted('updateSelectedColor')).toEqual([[color]]); + }); + }); + + describe('when epicColorQuery errored', () => { + beforeEach(async () => { + createComponent({ queryHandler: errorQueryHandler }); + await waitForPromises(); + }); + + it('creates flash with error message', () => { + expect(createFlash).toHaveBeenCalledWith({ + captureError: true, + message: 'Error fetching epic color.', + }); + }); + }); + + it('emits `updateSelectedColor` event on dropdown contents `setColor` event if iid is not set', () => { + createComponent({ propsData: { iid: undefined } }); + + findDropdownContents().vm.$emit('setColor', color); + expect(wrapper.emitted('updateSelectedColor')).toEqual([[color]]); + }); + + describe('when updating color for epic', () => { + beforeEach(() => { + createComponent(); + findDropdownContents().vm.$emit('setColor', color); + }); + + it('sets the loading state', () => { + expect(findSidebarEditableItem().props('loading')).toBe(true); + }); + + it('updates color correctly after successful mutation', async () => { + await waitForPromises(); + expect(findDropdownValue().props('selectedColor').color).toEqual( + updateColorMutationResponse.data.updateIssuableColor.issuable.color, + ); + }); + + it('displays an error if mutation was rejected', async () => { + createComponent({ mutationHandler: errorMutationHandler }); + findDropdownContents().vm.$emit('setColor', color); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + captureError: true, + error: expect.anything(), + message: 'An error occurred while updating color.', + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_color_view_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_color_view_spec.js new file mode 100644 index 00000000000..303824c77b3 --- /dev/null +++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_color_view_spec.js @@ -0,0 +1,43 @@ +import { GlDropdownForm } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import DropdownContentsColorView from '~/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue'; +import ColorItem from '~/vue_shared/components/color_select_dropdown/color_item.vue'; +import { ISSUABLE_COLORS } from '~/vue_shared/components/color_select_dropdown/constants'; +import { color as defaultColor } from './mock_data'; + +const propsData = { + selectedColor: defaultColor, +}; + +describe('DropdownContentsColorView', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(DropdownContentsColorView, { + propsData, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findColors = () => wrapper.findAllComponents(ColorItem); + const findColorList = () => wrapper.findComponent(GlDropdownForm); + + it('renders color list', async () => { + expect(findColorList().exists()).toBe(true); + expect(findColors()).toHaveLength(ISSUABLE_COLORS.length); + }); + + it.each(ISSUABLE_COLORS)('emits an `input` event with %o on click on the option %#', (color) => { + const colorIndex = ISSUABLE_COLORS.indexOf(color); + findColors().at(colorIndex).trigger('click'); + + expect(wrapper.emitted('input')[0][0]).toMatchObject(color); + }); +}); diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js new file mode 100644 index 00000000000..74f50b878e2 --- /dev/null +++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js @@ -0,0 +1,113 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { DROPDOWN_VARIANT } from '~/vue_shared/components/color_select_dropdown/constants'; +import DropdownContents from '~/vue_shared/components/color_select_dropdown/dropdown_contents.vue'; +import DropdownContentsColorView from '~/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue'; + +import { color } from './mock_data'; + +const showDropdown = jest.fn(); +const focusInput = jest.fn(); + +const defaultProps = { + dropdownTitle: '', + selectedColor: color, + dropdownButtonText: '', + variant: '', + isVisible: false, +}; + +const GlDropdownStub = { + template: ` + <div> + <slot name="header"></slot> + <slot></slot> + </div> + `, + methods: { + show: showDropdown, + hide: jest.fn(), + }, +}; + +const DropdownHeaderStub = { + template: ` + <div>Hello, I am a header</div> + `, + methods: { + focusInput, + }, +}; + +describe('DropdownContent', () => { + let wrapper; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = shallowMount(DropdownContents, { + propsData: { + ...defaultProps, + ...propsData, + }, + stubs: { + GlDropdown: GlDropdownStub, + DropdownHeader: DropdownHeaderStub, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findColorView = () => wrapper.findComponent(DropdownContentsColorView); + const findDropdownHeader = () => wrapper.findComponent(DropdownHeaderStub); + const findDropdown = () => wrapper.findComponent(GlDropdownStub); + + it('calls dropdown `show` method on `isVisible` prop change', async () => { + createComponent(); + await wrapper.setProps({ + isVisible: true, + }); + + expect(showDropdown).toHaveBeenCalledTimes(1); + }); + + it('does not emit `setColor` event on dropdown hide if color did not change', () => { + createComponent(); + findDropdown().vm.$emit('hide'); + + expect(wrapper.emitted('setColor')).toBeUndefined(); + }); + + it('emits `setColor` event on dropdown hide if color changed on non-sidebar widget', async () => { + createComponent({ propsData: { variant: DROPDOWN_VARIANT.Embedded } }); + const updatedColor = { + title: 'Blue-gray', + color: '#6699cc', + }; + findColorView().vm.$emit('input', updatedColor); + await nextTick(); + findDropdown().vm.$emit('hide'); + + expect(wrapper.emitted('setColor')).toEqual([[updatedColor]]); + }); + + it('emits `setColor` event on visibility change if color changed on sidebar widget', async () => { + createComponent({ propsData: { variant: DROPDOWN_VARIANT.Sidebar, isVisible: true } }); + const updatedColor = { + title: 'Blue-gray', + color: '#6699cc', + }; + findColorView().vm.$emit('input', updatedColor); + wrapper.setProps({ isVisible: false }); + await nextTick(); + + expect(wrapper.emitted('setColor')).toEqual([[updatedColor]]); + }); + + it('renders header', () => { + createComponent(); + + expect(findDropdownHeader().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_header_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_header_spec.js new file mode 100644 index 00000000000..d203d78477f --- /dev/null +++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_header_spec.js @@ -0,0 +1,40 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import DropdownHeader from '~/vue_shared/components/color_select_dropdown/dropdown_header.vue'; + +const propsData = { + dropdownTitle: 'Epic color', +}; + +describe('DropdownHeader', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(DropdownHeader, { propsData }); + }; + + const findButton = () => wrapper.findComponent(GlButton); + + afterEach(() => { + wrapper.destroy(); + }); + + beforeEach(() => { + createComponent(); + }); + + it('renders the correct title', () => { + expect(wrapper.text()).toBe(propsData.dropdownTitle); + }); + + it('renders a close button', () => { + expect(findButton().attributes('aria-label')).toBe('Close'); + }); + + it('emits `closeDropdown` event on button click', () => { + expect(wrapper.emitted('closeDropdown')).toBeUndefined(); + findButton().vm.$emit('click'); + + expect(wrapper.emitted('closeDropdown')).toEqual([[]]); + }); +}); diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js new file mode 100644 index 00000000000..f22592dd604 --- /dev/null +++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js @@ -0,0 +1,46 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import ColorItem from '~/vue_shared/components/color_select_dropdown/color_item.vue'; +import DropdownValue from '~/vue_shared/components/color_select_dropdown/dropdown_value.vue'; + +import { color } from './mock_data'; + +const propsData = { + selectedColor: color, +}; + +describe('DropdownValue', () => { + let wrapper; + + const findColorItems = () => wrapper.findAllComponents(ColorItem); + + const createComponent = () => { + wrapper = shallowMountExtended(DropdownValue, { propsData }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when there is a color set', () => { + it('renders the color', () => { + expect(findColorItems()).toHaveLength(2); + }); + + it.each` + index | cssClass + ${0} | ${['gl-font-base', 'gl-line-height-24']} + ${1} | ${['hide-collapsed']} + `( + 'passes correct props to the ColorItem with CSS class `$cssClass`', + async ({ index, cssClass }) => { + expect(findColorItems().at(index).props()).toMatchObject(propsData.selectedColor); + expect(findColorItems().at(index).classes()).toEqual(cssClass); + }, + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/mock_data.js b/spec/frontend/vue_shared/components/color_select_dropdown/mock_data.js new file mode 100644 index 00000000000..097f47cc731 --- /dev/null +++ b/spec/frontend/vue_shared/components/color_select_dropdown/mock_data.js @@ -0,0 +1,30 @@ +export const color = { + color: '#217645', + title: 'Green', +}; + +export const colorQueryResponse = { + data: { + workspace: { + id: 'gid://gitlab/Workspace/1', + issuable: { + __typename: 'Epic', + id: 'gid://gitlab/Epic/1', + color: '#217645', + }, + }, + }, +}; + +export const updateColorMutationResponse = { + data: { + updateIssuableColor: { + issuable: { + __typename: 'Epic', + id: 'gid://gitlab/Epic/1', + color: '#217645', + }, + errors: [], + }, + }, +}; diff --git a/spec/frontend/vue_shared/components/confidentiality_badge_spec.js b/spec/frontend/vue_shared/components/confidentiality_badge_spec.js index 9d11fbbaf55..e1860d3399b 100644 --- a/spec/frontend/vue_shared/components/confidentiality_badge_spec.js +++ b/spec/frontend/vue_shared/components/confidentiality_badge_spec.js @@ -29,8 +29,8 @@ describe('ConfidentialityBadge', () => { it.each` workspaceType | issuableType | expectedTooltip - ${WorkspaceType.project} | ${IssuableType.Issue} | ${'Only project members with at least Reporter role can view or be notified about this issue.'} - ${WorkspaceType.group} | ${IssuableType.Epic} | ${'Only group members with at least Reporter role can view or be notified about this epic.'} + ${WorkspaceType.project} | ${IssuableType.Issue} | ${'Only project members with at least the Reporter role, the author, and assignees can view or be notified about this issue.'} + ${WorkspaceType.group} | ${IssuableType.Epic} | ${'Only group members with at least the Reporter role can view or be notified about this epic.'} `( 'should render gl-badge with correct tooltip when workspaceType is $workspaceType and issuableType is $issuableType', ({ workspaceType, issuableType, expectedTooltip }) => { diff --git a/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js index 1397fb0405e..01ef52c6af9 100644 --- a/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js +++ b/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js @@ -1,3 +1,4 @@ +import { GlSkeletonLoader } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; @@ -39,10 +40,10 @@ describe('MarkdownViewer', () => { }); }); - it('renders an animation container while the markdown is loading', () => { + it('renders a skeleton loader while the markdown is loading', () => { createComponent(); - expect(wrapper.find('.animation-container').exists()).toBe(true); + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true); }); it('renders markdown preview preview renders and loads rendered markdown from server', () => { 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 f03a2e7934f..51161a1a0ef 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 @@ -77,7 +77,7 @@ describe('LabelToken', () => { describe('getActiveLabel', () => { it('returns label object from labels array based on provided `currentValue` param', () => { - expect(wrapper.vm.getActiveLabel(mockLabels, 'foo label')).toEqual(mockRegularLabel); + expect(wrapper.vm.getActiveLabel(mockLabels, 'Foo Label')).toEqual(mockRegularLabel); }); }); diff --git a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js index e636f58d868..e1da8b690af 100644 --- a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js +++ b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js @@ -66,7 +66,7 @@ describe('InputCopyToggleVisibility', () => { }); it('displays value as hidden', () => { - expect(findFormInputGroup().props('value')).toBe('********************'); + expect(findFormInput().element.value).toBe('********************'); }); it('saves actual value to clipboard when manually copied', () => { @@ -77,6 +77,16 @@ describe('InputCopyToggleVisibility', () => { expect(event.preventDefault).toHaveBeenCalled(); }); + it('emits `copy` event when manually copied the token', () => { + expect(wrapper.emitted('copy')).toBeUndefined(); + + findFormInput().element.dispatchEvent(createCopyEvent()); + + expect(wrapper.emitted()).toHaveProperty('copy'); + expect(wrapper.emitted('copy')).toHaveLength(1); + expect(wrapper.emitted('copy')[0]).toEqual([]); + }); + describe('visibility toggle button', () => { it('renders a reveal button', () => { const revealButton = findRevealButton(); @@ -97,7 +107,7 @@ describe('InputCopyToggleVisibility', () => { }); it('displays value', () => { - expect(findFormInputGroup().props('value')).toBe(valueProp); + expect(findFormInput().element.value).toBe(valueProp); }); it('renders a hide button', () => { @@ -135,6 +145,8 @@ describe('InputCopyToggleVisibility', () => { }); it('emits `copy` event', () => { + expect(wrapper.emitted()).toHaveProperty('copy'); + expect(wrapper.emitted('copy')).toHaveLength(1); expect(wrapper.emitted('copy')[0]).toEqual([]); }); }); @@ -147,25 +159,52 @@ describe('InputCopyToggleVisibility', () => { }); it('displays value as hidden with 20 asterisks', () => { - expect(findFormInputGroup().props('value')).toBe('********************'); + expect(findFormInput().element.value).toBe('********************'); }); }); describe('when `initialVisibility` prop is `true`', () => { + const label = 'My label'; + beforeEach(() => { createComponent({ propsData: { value: valueProp, initialVisibility: true, + label, + 'label-for': 'my-input', + formInputGroupProps: { + id: 'my-input', + }, }, }); }); it('displays value', () => { - expect(findFormInputGroup().props('value')).toBe(valueProp); + expect(findFormInput().element.value).toBe(valueProp); }); itDoesNotModifyCopyEvent(); + + describe('when input is clicked', () => { + it('selects input value', async () => { + const mockSelect = jest.fn(); + wrapper.vm.$refs.input.$el.select = mockSelect; + await wrapper.findByLabelText(label).trigger('click'); + + expect(mockSelect).toHaveBeenCalled(); + }); + }); + + describe('when label is clicked', () => { + it('selects input value', async () => { + const mockSelect = jest.fn(); + wrapper.vm.$refs.input.$el.select = mockSelect; + await wrapper.find('label').trigger('click'); + + expect(mockSelect).toHaveBeenCalled(); + }); + }); }); describe('when `showToggleVisibilityButton` is `false`', () => { @@ -184,7 +223,7 @@ describe('InputCopyToggleVisibility', () => { }); it('displays value', () => { - expect(findFormInputGroup().props('value')).toBe(valueProp); + expect(findFormInput().element.value).toBe(valueProp); }); itDoesNotModifyCopyEvent(); @@ -204,16 +243,30 @@ describe('InputCopyToggleVisibility', () => { }); }); - it('passes `formInputGroupProps` prop to `GlFormInputGroup`', () => { + it('passes `formInputGroupProps` prop only to the input', () => { createComponent({ propsData: { formInputGroupProps: { - label: 'Foo bar', + name: 'Foo bar', + 'data-qa-selector': 'Foo bar', + class: 'Foo bar', + id: 'Foo bar', }, }, }); - expect(findFormInputGroup().props('label')).toBe('Foo bar'); + expect(findFormInput().attributes()).toMatchObject({ + name: 'Foo bar', + 'data-qa-selector': 'Foo bar', + class: expect.stringContaining('Foo bar'), + id: 'Foo bar', + }); + + const attributesInputGroup = findFormInputGroup().attributes(); + expect(attributesInputGroup.name).toBeUndefined(); + expect(attributesInputGroup['data-qa-selector']).toBeUndefined(); + expect(attributesInputGroup.class).not.toContain('Foo bar'); + expect(attributesInputGroup.id).toBeUndefined(); }); it('passes `copyButtonTitle` prop to `ClipboardButton`', () => { diff --git a/spec/frontend/vue_shared/components/notes/__snapshots__/noteable_warning_spec.js.snap b/spec/frontend/vue_shared/components/notes/__snapshots__/noteable_warning_spec.js.snap index f878d685b6d..8a187f3cb1f 100644 --- a/spec/frontend/vue_shared/components/notes/__snapshots__/noteable_warning_spec.js.snap +++ b/spec/frontend/vue_shared/components/notes/__snapshots__/noteable_warning_spec.js.snap @@ -10,7 +10,7 @@ exports[`Issue Warning Component when issue is locked but not confidential rende href="locked-path" target="_blank" > - Learn more + Learn more. </gl-link-stub> </span> `; @@ -25,7 +25,7 @@ exports[`Issue Warning Component when noteable is confidential but not locked re href="confidential-path" target="_blank" > - Learn more + Learn more. </gl-link-stub> </span> `; diff --git a/spec/frontend/vue_shared/components/notes/system_note_spec.js b/spec/frontend/vue_shared/components/notes/system_note_spec.js index 65f79bab005..98b04ede943 100644 --- a/spec/frontend/vue_shared/components/notes/system_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/system_note_spec.js @@ -1,13 +1,11 @@ import MockAdapter from 'axios-mock-adapter'; import { mount } from '@vue/test-utils'; +import $ from 'jquery'; import waitForPromises from 'helpers/wait_for_promises'; -import initMRPopovers from '~/mr_popover/index'; import createStore from '~/notes/stores'; import IssueSystemNote from '~/vue_shared/components/notes/system_note.vue'; import axios from '~/lib/utils/axios_utils'; -jest.mock('~/mr_popover/index', () => jest.fn()); - describe('system note component', () => { let vm; let props; @@ -76,10 +74,12 @@ describe('system note component', () => { expect(vm.find('.system-note-message').html()).toContain('<span>closed</span>'); }); - it('should initMRPopovers onMount', () => { + it('should renderGFM onMount', () => { + const renderGFMSpy = jest.spyOn($.fn, 'renderGFM'); + createComponent(props); - expect(initMRPopovers).toHaveBeenCalled(); + expect(renderGFMSpy).toHaveBeenCalled(); }); it('renders outdated code lines', async () => { diff --git a/spec/frontend/vue_shared/components/papa_parse_alert_spec.js b/spec/frontend/vue_shared/components/papa_parse_alert_spec.js index 9be2de17d01..ff4febd647e 100644 --- a/spec/frontend/vue_shared/components/papa_parse_alert_spec.js +++ b/spec/frontend/vue_shared/components/papa_parse_alert_spec.js @@ -22,7 +22,7 @@ describe('app/assets/javascripts/vue_shared/components/papa_parse_alert.vue', () it('should render alert with correct props', async () => { createComponent({ errorMessages: [{ code: 'MissingQuotes' }] }); - await nextTick; + await nextTick(); expect(findAlert().props()).toMatchObject({ variant: 'danger', @@ -37,7 +37,7 @@ describe('app/assets/javascripts/vue_shared/components/papa_parse_alert.vue', () createComponent({ errorMessages: [{ code: 'NotDefined', message: 'Error code is undefined' }], }); - await nextTick; + await nextTick(); expect(findAlert().text()).toContain('Error code is undefined'); }); 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 f5ef5b3d443..20716e79a04 100644 --- a/spec/frontend/vue_shared/components/registry/registry_search_spec.js +++ b/spec/frontend/vue_shared/components/registry/registry_search_spec.js @@ -11,7 +11,7 @@ describe('Registry Search', () => { const findFilteredSearch = () => wrapper.find(GlFilteredSearch); const defaultProps = { - filter: [], + filters: [], sorting: { sort: 'asc', orderBy: 'name' }, tokens: [{ type: 'foo' }], sortableFields: [ @@ -123,7 +123,7 @@ describe('Registry Search', () => { }); describe('query string calculation', () => { - const filter = [ + const filters = [ { type: FILTERED_SEARCH_TERM, value: { data: 'one' } }, { type: FILTERED_SEARCH_TERM, value: { data: 'two' } }, { type: 'typeOne', value: { data: 'value_one' } }, @@ -131,7 +131,7 @@ describe('Registry Search', () => { ]; it('aggregates the filter in the correct object', () => { - mountComponent({ ...defaultProps, filter }); + mountComponent({ ...defaultProps, filters }); findFilteredSearch().vm.$emit('submit'); 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 001b6ee4a6f..7173abe1316 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 @@ -48,12 +48,12 @@ describe('RunnerInstructionsModal component', () => { const findModal = () => wrapper.findComponent(GlModal); const findPlatformButtonGroup = () => wrapper.findByTestId('platform-buttons'); const findPlatformButtons = () => findPlatformButtonGroup().findAllComponents(GlButton); - const findOsxPlatformButton = () => wrapper.find({ ref: 'osx' }); 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 createComponent = ({ props, shown = true, ...options } = {}) => { const requestHandlers = [ [getRunnerPlatformsQuery, runnerPlatformsHandler], [getRunnerSetupInstructionsQuery, runnerSetupInstructionsHandler], @@ -72,169 +72,202 @@ describe('RunnerInstructionsModal component', () => { ...options, }), ); + + // trigger open modal + if (shown) { + findModal().vm.$emit('shown'); + } }; beforeEach(async () => { runnerPlatformsHandler = jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms); runnerSetupInstructionsHandler = jest.fn().mockResolvedValue(mockGraphqlInstructions); - - createComponent(); - await waitForPromises(); }); afterEach(() => { wrapper.destroy(); }); - it('should not show alert', () => { - expect(findAlert().exists()).toBe(false); - }); - - it('should contain a number of platforms buttons', () => { - expect(runnerPlatformsHandler).toHaveBeenCalledWith({}); + describe('when the modal is shown', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); - const buttons = findPlatformButtons(); + it('should not show alert', async () => { + expect(findAlert().exists()).toBe(false); + }); - expect(buttons).toHaveLength(mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes.length); - }); + it('should contain a number of platforms buttons', () => { + expect(runnerPlatformsHandler).toHaveBeenCalledWith({}); - it('should contain a number of dropdown items for the architecture options', () => { - expect(findArchitectureDropdownItems()).toHaveLength( - mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes[0].architectures.nodes.length, - ); - }); + const buttons = findPlatformButtons(); - describe('should display default instructions', () => { - const { installInstructions, registerInstructions } = mockGraphqlInstructions.data.runnerSetup; + expect(buttons).toHaveLength(mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes.length); + }); - it('runner instructions are requested', () => { - expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({ - platform: 'linux', - architecture: 'amd64', - }); + it('should contain a number of dropdown items for the architecture options', () => { + expect(findArchitectureDropdownItems()).toHaveLength( + mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes[0].architectures.nodes.length, + ); }); - it('binary instructions are shown', async () => { - await waitForPromises(); - const instructions = findBinaryInstructions().text(); + describe('should display default instructions', () => { + const { + installInstructions, + registerInstructions, + } = mockGraphqlInstructions.data.runnerSetup; - expect(instructions).toBe(installInstructions); - }); + it('runner instructions are requested', () => { + expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({ + platform: 'linux', + architecture: 'amd64', + }); + }); - it('register command is shown with a replaced token', async () => { - await waitForPromises(); - const instructions = findRegisterCommand().text(); + it('binary instructions are shown', async () => { + const instructions = findBinaryInstructions().text(); - expect(instructions).toBe( - 'sudo gitlab-runner register --url http://gdk.test:3000/ --registration-token MY_TOKEN', - ); - }); + expect(instructions).toBe(installInstructions); + }); - describe('when a register token is not shown', () => { - beforeEach(async () => { - createComponent({ props: { registrationToken: undefined } }); - await waitForPromises(); + it('register command is shown with a replaced token', async () => { + const command = findRegisterCommand().text(); + + expect(command).toBe( + 'sudo gitlab-runner register --url http://gdk.test:3000/ --registration-token MY_TOKEN', + ); }); - it('register command is shown without a defined registration token', () => { - const instructions = findRegisterCommand().text(); + describe('when a register token is not shown', () => { + 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(registerInstructions); + expect(instructions).toBe(registerInstructions); + }); }); - }); - describe('when the modal is shown', () => { - it('sets the focus on the selected platform', () => { - findPlatformButtons().at(0).element.focus = jest.fn(); + describe('when providing a defaultPlatformName', () => { + beforeEach(async () => { + createComponent({ props: { defaultPlatformName: 'osx' } }); + await waitForPromises(); + }); + + it('runner instructions for the default selected platform are requested', () => { + expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({ + platform: 'osx', + architecture: 'amd64', + }); + }); + + it('sets the focus on the default selected platform', () => { + const findOsxPlatformButton = () => wrapper.find({ ref: 'osx' }); + + findOsxPlatformButton().element.focus = jest.fn(); - findModal().vm.$emit('shown'); + findModal().vm.$emit('shown'); - expect(findPlatformButtons().at(0).element.focus).toHaveBeenCalled(); + expect(findOsxPlatformButton().element.focus).toHaveBeenCalled(); + }); }); }); - describe('when providing a defaultPlatformName', () => { + describe('after a platform and architecture are selected', () => { + const windowsIndex = 2; + const { installInstructions } = mockGraphqlInstructionsWindows.data.runnerSetup; + beforeEach(async () => { - createComponent({ props: { defaultPlatformName: 'osx' } }); + runnerSetupInstructionsHandler.mockResolvedValue(mockGraphqlInstructionsWindows); + + findPlatformButtons().at(windowsIndex).vm.$emit('click'); await waitForPromises(); }); - it('runner instructions for the default selected platform are requested', () => { - expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({ - platform: 'osx', + it('runner instructions are requested', () => { + expect(runnerSetupInstructionsHandler).toHaveBeenLastCalledWith({ + platform: 'windows', architecture: 'amd64', }); }); - it('sets the focus on the default selected platform', () => { - findOsxPlatformButton().element.focus = jest.fn(); - - findModal().vm.$emit('shown'); + it('architecture download link is updated', () => { + const architectures = + mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes[windowsIndex].architectures.nodes; - expect(findOsxPlatformButton().element.focus).toHaveBeenCalled(); + expect(findBinaryDownloadButton().attributes('href')).toBe( + architectures[0].downloadLocation, + ); }); - }); - }); - describe('after a platform and architecture are selected', () => { - const { installInstructions } = mockGraphqlInstructionsWindows.data.runnerSetup; + it('other binary instructions are shown', () => { + const instructions = findBinaryInstructions().text(); - beforeEach(async () => { - runnerSetupInstructionsHandler.mockResolvedValue(mockGraphqlInstructionsWindows); + expect(instructions).toBe(installInstructions); + }); - findPlatformButtons().at(2).vm.$emit('click'); // another option, happens to be windows - await nextTick(); + it('register command is shown', () => { + const command = findRegisterCommand().text(); - findArchitectureDropdownItems().at(1).vm.$emit('click'); // another option - await nextTick(); - }); + expect(command).toBe( + './gitlab-runner.exe register --url http://gdk.test:3000/ --registration-token MY_TOKEN', + ); + }); + + it('runner instructions are requested with another architecture', async () => { + findArchitectureDropdownItems().at(1).vm.$emit('click'); + await waitForPromises(); - it('runner instructions are requested', () => { - expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({ - platform: 'windows', - architecture: '386', + expect(runnerSetupInstructionsHandler).toHaveBeenLastCalledWith({ + platform: 'windows', + architecture: '386', + }); }); }); - it('other binary instructions are shown', () => { - const instructions = findBinaryInstructions().text(); + describe('when the modal resizes', () => { + it('to an xs viewport', async () => { + MockResizeObserver.mockResize('xs'); + await nextTick(); - expect(instructions).toBe(installInstructions); - }); + expect(findPlatformButtonGroup().attributes('vertical')).toBeTruthy(); + }); - it('register command is shown', () => { - const command = findRegisterCommand().text(); + it('to a non-xs viewport', async () => { + MockResizeObserver.mockResize('sm'); + await nextTick(); - expect(command).toBe( - './gitlab-runner.exe register --url http://gdk.test:3000/ --registration-token MY_TOKEN', - ); + expect(findPlatformButtonGroup().props('vertical')).toBeFalsy(); + }); }); }); - describe('when the modal resizes', () => { - it('to an xs viewport', async () => { - MockResizeObserver.mockResize('xs'); - await nextTick(); - - expect(findPlatformButtonGroup().attributes('vertical')).toBeTruthy(); + describe('when the modal is not shown', () => { + beforeEach(async () => { + createComponent({ shown: false }); + await waitForPromises(); }); - it('to a non-xs viewport', async () => { - MockResizeObserver.mockResize('sm'); - await nextTick(); - - expect(findPlatformButtonGroup().props('vertical')).toBeFalsy(); + it('does not fetch instructions', () => { + expect(runnerPlatformsHandler).not.toHaveBeenCalled(); + expect(runnerSetupInstructionsHandler).not.toHaveBeenCalled(); }); }); describe('when apollo is loading', () => { - it('should show a skeleton loader', async () => { + beforeEach(() => { createComponent(); + }); + + it('should show a skeleton loader', async () => { expect(findSkeletonLoader().exists()).toBe(true); expect(findGlLoadingIcon().exists()).toBe(false); - await nextTick(); - jest.runOnlyPendingTimers(); + // wait on fetch of both `platforms` and `instructions` await nextTick(); await nextTick(); @@ -242,7 +275,6 @@ describe('RunnerInstructionsModal component', () => { }); it('once loaded, should not show a loading state', async () => { - createComponent(); await waitForPromises(); expect(findSkeletonLoader().exists()).toBe(false); @@ -255,7 +287,6 @@ describe('RunnerInstructionsModal component', () => { runnerSetupInstructionsHandler.mockRejectedValue(); createComponent(); - await waitForPromises(); }); @@ -287,6 +318,7 @@ describe('RunnerInstructionsModal component', () => { mockShow = jest.fn(); createComponent({ + shown: false, stubs: { GlModal: getGlModalStub({ show: mockShow }), }, 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 9a95a838291..986d76d2b95 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,6 +1,5 @@ -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue'; import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; @@ -11,7 +10,11 @@ describe('RunnerInstructions component', () => { const findModal = () => wrapper.findComponent(RunnerInstructionsModal); const createComponent = () => { - wrapper = extendedWrapper(shallowMount(RunnerInstructions)); + wrapper = shallowMountExtended(RunnerInstructions, { + directives: { + GlModal: createMockDirective(), + }, + }); }; beforeEach(() => { @@ -23,19 +26,12 @@ describe('RunnerInstructions component', () => { }); it('should show the "Show runner installation instructions" button', () => { - expect(findModalButton().exists()).toBe(true); expect(findModalButton().text()).toBe('Show runner installation instructions'); }); - it('should not render the modal once mounted', () => { - expect(findModal().exists()).toBe(false); - }); - - it('should render the modal once clicked', async () => { - findModalButton().vm.$emit('click'); - - await nextTick(); + it('should render the modal', () => { + const modalId = getBinding(findModal().element, 'gl-modal'); - expect(findModal().exists()).toBe(true); + expect(findModalButton().attributes('modal-id')).toBe(modalId); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js index 42202db4935..00c8e3a814a 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -226,12 +226,7 @@ describe('DropdownContentsLabelsView', () => { preventDefault: fakePreventDefault, }); - expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([ - { - ...mockLabels[2], - set: true, - }, - ]); + expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([mockLabels[2]]); }); it('calls action `toggleDropdownContents` when Esc key is pressed', () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js index bd1705e7693..bedb6204088 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js @@ -1,4 +1,4 @@ -import { GlIcon, GlLink } from '@gitlab/ui'; +import { GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import LabelItem from '~/vue_shared/components/sidebar/labels_select_vue/label_item.vue'; @@ -45,18 +45,26 @@ describe('LabelItem', () => { wrapperTemp.destroy(); }); - it('renders visible gl-icon component when `isLabelSet` prop is true', () => { - const wrapperTemp = createComponent({ - isLabelSet: true, - }); - - const iconEl = wrapperTemp.find(GlIcon); - - expect(iconEl.isVisible()).toBe(true); - expect(iconEl.props('name')).toBe('mobile-issue-close'); - - wrapperTemp.destroy(); - }); + it.each` + isLabelSet | isLabelIndeterminate | testId | iconName + ${true} | ${false} | ${'checked-icon'} | ${'mobile-issue-close'} + ${false} | ${true} | ${'indeterminate-icon'} | ${'dash'} + `( + 'renders visible gl-icon component when `isLabelSet` prop is $isLabelSet and `isLabelIndeterminate` is $isLabelIndeterminate', + ({ isLabelSet, isLabelIndeterminate, testId, iconName }) => { + const wrapperTemp = createComponent({ + isLabelSet, + isLabelIndeterminate, + }); + + const iconEl = wrapperTemp.find(`[data-testid="${testId}"]`); + + expect(iconEl.isVisible()).toBe(true); + expect(iconEl.props('name')).toBe(iconName); + + wrapperTemp.destroy(); + }, + ); it('renders visible span element as placeholder instead of gl-icon when `isLabelSet` prop is false', () => { const wrapperTemp = createComponent({ diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js index 31819d0e2f7..c150410ff8e 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js @@ -46,9 +46,15 @@ describe('LabelsSelectRoot', () => { describe('methods', () => { describe('handleVuexActionDispatch', () => { + const touchedLabels = [ + { + id: 2, + touched: true, + }, + ]; + it('calls `handleDropdownClose` when params `action.type` is `toggleDropdownContents` and state has `showDropdownButton` & `showDropdownContents` props `false`', () => { createComponent(); - jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation(); wrapper.vm.handleVuexActionDispatch( { type: 'toggleDropdownContents' }, @@ -59,14 +65,12 @@ describe('LabelsSelectRoot', () => { }, ); - expect(wrapper.vm.handleDropdownClose).toHaveBeenCalledWith( - expect.arrayContaining([ - { - id: 2, - touched: true, - }, - ]), - ); + // We're utilizing `onDropdownClose` event emitted from the component to always include `touchedLabels` + // while the first param of the method is the labels list which were added/removed. + expect(wrapper.emitted('updateSelectedLabels')).toBeTruthy(); + expect(wrapper.emitted('updateSelectedLabels')[0]).toEqual([touchedLabels]); + expect(wrapper.emitted('onDropdownClose')).toBeTruthy(); + expect(wrapper.emitted('onDropdownClose')[0]).toEqual([touchedLabels]); }); it('calls `handleDropdownClose` with state.labels filterd using `set` prop when dropdown variant is `embedded`', () => { @@ -75,8 +79,6 @@ describe('LabelsSelectRoot', () => { variant: 'embedded', }); - jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation(); - wrapper.vm.handleVuexActionDispatch( { type: 'toggleDropdownContents' }, { @@ -86,34 +88,17 @@ describe('LabelsSelectRoot', () => { }, ); - expect(wrapper.vm.handleDropdownClose).toHaveBeenCalledWith( - expect.arrayContaining([ + expect(wrapper.emitted('updateSelectedLabels')).toBeTruthy(); + expect(wrapper.emitted('updateSelectedLabels')[0]).toEqual([ + [ { id: 2, set: true, }, - ]), - ); - }); - }); - - describe('handleDropdownClose', () => { - beforeEach(() => { - createComponent(); - }); - - it('emits `updateSelectedLabels` & `onDropdownClose` events on component when provided `labels` param is not empty', () => { - wrapper.vm.handleDropdownClose([{ id: 1 }, { id: 2 }]); - - expect(wrapper.emitted().updateSelectedLabels).toBeTruthy(); - expect(wrapper.emitted().onDropdownClose).toBeTruthy(); - }); - - it('emits only `onDropdownClose` event on component when provided `labels` param is empty', () => { - wrapper.vm.handleDropdownClose([]); - - expect(wrapper.emitted().updateSelectedLabels).toBeFalsy(); - expect(wrapper.emitted().onDropdownClose).toBeTruthy(); + ], + ]); + expect(wrapper.emitted('onDropdownClose')).toBeTruthy(); + expect(wrapper.emitted('onDropdownClose')[0]).toEqual([[]]); }); }); @@ -152,13 +137,13 @@ describe('LabelsSelectRoot', () => { it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', async () => { createComponent(); - await nextTick; + await nextTick(); expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true); }); it('renders `dropdown-title` component', async () => { createComponent(); - await nextTick; + await nextTick(); expect(wrapper.find(DropdownTitle).exists()).toBe(true); }); @@ -166,7 +151,7 @@ describe('LabelsSelectRoot', () => { createComponent(mockConfig, { default: 'None', }); - await nextTick; + await nextTick(); const valueComp = wrapper.find(DropdownValue); @@ -177,14 +162,14 @@ describe('LabelsSelectRoot', () => { it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', async () => { createComponent(); wrapper.vm.$store.dispatch('toggleDropdownButton'); - await nextTick; + await nextTick(); expect(wrapper.find(DropdownButton).exists()).toBe(true); }); it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', async () => { createComponent(); wrapper.vm.$store.dispatch('toggleDropdownContents'); - await nextTick; + await nextTick(); expect(wrapper.find(DropdownContents).exists()).toBe(true); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js index 1f899e84897..6ad46dbe898 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js @@ -17,24 +17,39 @@ describe('LabelsSelect Getters', () => { }, ); - it('returns label title when state.labels has only 1 label', () => { - const labels = [{ id: 1, title: 'Foobar', set: true }]; + describe.each` + dropdownVariant | isDropdownVariantSidebar | isDropdownVariantEmbedded + ${'sidebar'} | ${true} | ${false} + ${'embedded'} | ${false} | ${true} + `( + 'when dropdown variant is $dropdownVariant', + ({ isDropdownVariantSidebar, isDropdownVariantEmbedded }) => { + it('returns label title when state.labels has only 1 label', () => { + const labels = [{ id: 1, title: 'Foobar', set: true }]; - expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe( - 'Foobar', - ); - }); + expect( + getters.dropdownButtonText( + { labels }, + { isDropdownVariantSidebar, isDropdownVariantEmbedded }, + ), + ).toBe('Foobar'); + }); - it('returns first label title and remaining labels count when state.labels has more than 1 label', () => { - const labels = [ - { id: 1, title: 'Foo', set: true }, - { id: 2, title: 'Bar', set: true }, - ]; + it('returns first label title and remaining labels count when state.labels has more than 1 label', () => { + const labels = [ + { id: 1, title: 'Foo', set: true }, + { id: 2, title: 'Bar', set: true }, + ]; - expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe( - 'Foo +1 more', - ); - }); + expect( + getters.dropdownButtonText( + { labels }, + { isDropdownVariantSidebar, isDropdownVariantEmbedded }, + ), + ).toBe('Foo +1 more'); + }); + }, + ); }); describe('selectedLabelsList', () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js index a60e6f52862..1819e750324 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js @@ -80,7 +80,10 @@ describe('LabelsSelect Mutations', () => { }); describe(`${types.RECEIVE_SET_LABELS_SUCCESS}`, () => { - const selectedLabels = [{ id: 2 }, { id: 4 }]; + const selectedLabels = [ + { id: 2, set: true }, + { id: 4, set: true }, + ]; const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; it('sets value of `state.labelsFetchInProgress` to false', () => { @@ -196,20 +199,23 @@ describe('LabelsSelect Mutations', () => { it('updates labels `set` state to match selected labels', () => { const state = { labels: [ - { id: 1, title: 'scoped::test', set: false }, - { id: 2, set: true, title: 'scoped::one', touched: true }, - { id: 3, title: '' }, - { id: 4, title: '' }, + { id: 1, title: 'scoped::test', set: false, indeterminate: false }, + { id: 2, title: 'scoped::one', set: true, indeterminate: false, touched: true }, + { id: 3, title: '', set: false, indeterminate: false }, + { id: 4, title: '', set: false, indeterminate: false }, + ], + selectedLabels: [ + { id: 1, set: true }, + { id: 3, set: true }, ], - selectedLabels: [{ id: 1 }, { id: 3 }], }; mutations[types.UPDATE_LABELS_SET_STATE](state); expect(state.labels).toEqual([ - { id: 1, title: 'scoped::test', set: true }, - { id: 2, set: false, title: 'scoped::one', touched: true }, - { id: 3, title: '', set: true }, - { id: 4, title: '', set: false }, + { id: 1, title: 'scoped::test', set: true, indeterminate: false }, + { id: 2, title: 'scoped::one', set: false, indeterminate: false, touched: true }, + { id: 3, title: '', set: true, indeterminate: false }, + { id: 4, title: '', set: false, indeterminate: false }, ]); }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/index_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/index_spec.js new file mode 100644 index 00000000000..83fdc5d669d --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/index_spec.js @@ -0,0 +1,14 @@ +import { registerPlugins } from '~/vue_shared/components/source_viewer/plugins/index'; +import { HLJS_ON_AFTER_HIGHLIGHT } from '~/vue_shared/components/source_viewer/constants'; +import wrapComments from '~/vue_shared/components/source_viewer/plugins/wrap_comments'; + +jest.mock('~/vue_shared/components/source_viewer/plugins/wrap_comments'); +const hljsMock = { addPlugin: jest.fn() }; + +describe('Highlight.js plugin registration', () => { + beforeEach(() => registerPlugins(hljsMock)); + + it('registers our plugins', () => { + expect(hljsMock.addPlugin).toHaveBeenCalledWith({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapComments }); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_comments_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_comments_spec.js new file mode 100644 index 00000000000..5fd4182da29 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_comments_spec.js @@ -0,0 +1,29 @@ +import { HLJS_COMMENT_SELECTOR } from '~/vue_shared/components/source_viewer/constants'; +import wrapComments from '~/vue_shared/components/source_viewer/plugins/wrap_comments'; + +describe('Highlight.js plugin for wrapping comments', () => { + it('mutates the input value by wrapping each line in a span tag', () => { + const inputValue = `<span class="${HLJS_COMMENT_SELECTOR}">/* Line 1 \n* Line 2 \n*/</span>`; + const outputValue = `<span class="${HLJS_COMMENT_SELECTOR}">/* Line 1 \n<span class="${HLJS_COMMENT_SELECTOR}">* Line 2 </span>\n<span class="${HLJS_COMMENT_SELECTOR}">*/</span>`; + const hljsResultMock = { value: inputValue }; + + wrapComments(hljsResultMock); + expect(hljsResultMock.value).toBe(outputValue); + }); + + it('does not mutate the input value if the hljs comment selector is not present', () => { + const inputValue = '<span class="hljs-keyword">const</span>'; + const hljsResultMock = { value: inputValue }; + + wrapComments(hljsResultMock); + expect(hljsResultMock.value).toBe(inputValue); + }); + + it('does not mutate the input value if the hljs comment line includes a closing tag', () => { + const inputValue = `<span class="${HLJS_COMMENT_SELECTOR}">/* Line 1 </span> \n* Line 2 \n*/`; + const hljsResultMock = { value: inputValue }; + + wrapComments(hljsResultMock); + expect(hljsResultMock.value).toBe(inputValue); + }); +}); 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 6a9ea75127d..bb0945a1f3e 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 @@ -3,6 +3,7 @@ import Vue from 'vue'; import VueRouter from 'vue-router'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue'; +import { registerPlugins } from '~/vue_shared/components/source_viewer/plugins/index'; import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue'; import { ROUGE_TO_HLJS_LANGUAGE_MAP } from '~/vue_shared/components/source_viewer/constants'; import waitForPromises from 'helpers/wait_for_promises'; @@ -11,6 +12,7 @@ import eventHub from '~/notes/event_hub'; jest.mock('~/blob/line_highlighter'); jest.mock('highlight.js/lib/core'); +jest.mock('~/vue_shared/components/source_viewer/plugins/index'); Vue.use(VueRouter); const router = new VueRouter(); @@ -59,6 +61,10 @@ describe('Source Viewer component', () => { describe('highlight.js', () => { beforeEach(() => createComponent({ language: mappedLanguage })); + it('registers our plugins for Highlight.js', () => { + expect(registerPlugins).toHaveBeenCalledWith(hljs); + }); + it('registers the language definition', async () => { const languageDefinition = await import(`highlight.js/lib/languages/${mappedLanguage}`); diff --git a/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap b/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap index a613b325462..1798ca5ccde 100644 --- a/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap +++ b/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap @@ -5,7 +5,7 @@ exports[`Upload dropzone component correctly overrides description and drop mess class="gl-w-full gl-relative" > <button - class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4" type="button" > <div @@ -41,7 +41,7 @@ exports[`Upload dropzone component correctly overrides description and drop mess name="upload-dropzone-fade" > <div - class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-4" style="display: none;" > <div @@ -86,7 +86,7 @@ exports[`Upload dropzone component when dragging renders correct template when d class="gl-w-full gl-relative" > <button - class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4" type="button" > <div @@ -126,7 +126,7 @@ exports[`Upload dropzone component when dragging renders correct template when d name="upload-dropzone-fade" > <div - class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-4" style="" > <div @@ -171,7 +171,7 @@ exports[`Upload dropzone component when dragging renders correct template when d class="gl-w-full gl-relative" > <button - class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4" type="button" > <div @@ -211,7 +211,7 @@ exports[`Upload dropzone component when dragging renders correct template when d name="upload-dropzone-fade" > <div - class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-4" style="" > <div @@ -256,7 +256,7 @@ exports[`Upload dropzone component when dragging renders correct template when d class="gl-w-full gl-relative" > <button - class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4" type="button" > <div @@ -296,7 +296,7 @@ exports[`Upload dropzone component when dragging renders correct template when d name="upload-dropzone-fade" > <div - class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-4" style="" > <div @@ -342,7 +342,7 @@ exports[`Upload dropzone component when dragging renders correct template when d class="gl-w-full gl-relative" > <button - class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4" type="button" > <div @@ -382,7 +382,7 @@ exports[`Upload dropzone component when dragging renders correct template when d name="upload-dropzone-fade" > <div - class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-4" style="" > <div @@ -428,7 +428,7 @@ exports[`Upload dropzone component when dragging renders correct template when d class="gl-w-full gl-relative" > <button - class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4" type="button" > <div @@ -468,7 +468,7 @@ exports[`Upload dropzone component when dragging renders correct template when d name="upload-dropzone-fade" > <div - class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-4" style="display: none;" > <div @@ -514,7 +514,7 @@ exports[`Upload dropzone component when no slot provided renders default dropzon class="gl-w-full gl-relative" > <button - class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4" type="button" > <div @@ -554,7 +554,7 @@ exports[`Upload dropzone component when no slot provided renders default dropzon name="upload-dropzone-fade" > <div - class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-4" style="display: none;" > <div @@ -606,7 +606,7 @@ exports[`Upload dropzone component when slot provided renders dropzone with slot name="upload-dropzone-fade" > <div - class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-4" style="display: none;" > <div diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js index 65eb42ef053..70017903079 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js @@ -5,9 +5,10 @@ import { shallowMountExtended as shallowMount } from 'helpers/vue_test_utils_hel import IssuableItem from '~/vue_shared/issuable/list/components/issuable_item.vue'; import IssuableAssignees from '~/issuable/components/issue_assignees.vue'; -import { mockIssuable, mockRegularLabel, mockScopedLabel } from '../mock_data'; +import { mockIssuable, mockRegularLabel } from '../mock_data'; const createComponent = ({ + hasScopedLabelsFeature = false, issuableSymbol = '#', issuable = mockIssuable, showCheckbox = true, @@ -15,6 +16,7 @@ const createComponent = ({ } = {}) => shallowMount(IssuableItem, { propsData: { + hasScopedLabelsFeature, issuableSymbol, issuable, showDiscussions: true, @@ -182,21 +184,6 @@ describe('IssuableItem', () => { }); describe('methods', () => { - describe('scopedLabel', () => { - it.each` - label | labelType | returnValue - ${mockRegularLabel} | ${'regular'} | ${false} - ${mockScopedLabel} | ${'scoped'} | ${true} - `( - 'return $returnValue when provided label param is a $labelType label', - ({ label, returnValue }) => { - wrapper = createComponent(); - - expect(wrapper.vm.scopedLabel(label)).toBe(returnValue); - }, - ); - }); - describe('labelTitle', () => { it.each` label | propWithTitle | returnValue @@ -500,5 +487,21 @@ describe('IssuableItem', () => { expect(wrapper.classes()).not.toContain('today'); }); }); + + describe('scoped labels', () => { + describe.each` + description | labelPosition | hasScopedLabelsFeature | scoped + ${'when label is not scoped and there is no scoped_labels feature'} | ${0} | ${false} | ${false} + ${'when label is scoped and there is no scoped_labels feature'} | ${1} | ${false} | ${false} + ${'when label is not scoped and there is scoped_labels feature'} | ${0} | ${true} | ${false} + ${'when label is scoped and there is scoped_labels feature'} | ${1} | ${true} | ${true} + `('$description', ({ hasScopedLabelsFeature, labelPosition, scoped }) => { + it(`${scoped ? 'renders' : 'does not render'} as scoped label`, () => { + wrapper = createComponent({ hasScopedLabelsFeature }); + + expect(wrapper.findAllComponents(GlLabel).at(labelPosition).props('scoped')).toBe(scoped); + }); + }); + }); }); }); diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js index 058cb30c1d5..66f71c0b028 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js @@ -1,9 +1,4 @@ -import { - GlAlert, - GlKeysetPagination, - GlDeprecatedSkeletonLoading as GlSkeletonLoading, - GlPagination, -} from '@gitlab/ui'; +import { GlAlert, GlKeysetPagination, GlSkeletonLoader, GlPagination } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import VueDraggable from 'vuedraggable'; @@ -263,7 +258,7 @@ describe('IssuableListRoot', () => { it('renders gl-loading-icon when `issuablesLoading` prop is true', () => { wrapper = createComponent({ props: { issuablesLoading: true } }); - expect(wrapper.findAllComponents(GlSkeletonLoading)).toHaveLength( + expect(wrapper.findAllComponents(GlSkeletonLoader)).toHaveLength( wrapper.vm.skeletonItemCount, ); }); diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js index 1a93838b03f..7c582360637 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js @@ -159,7 +159,6 @@ describe('IssuableBody', () => { expect(titleEl.exists()).toBe(true); expect(titleEl.props()).toMatchObject({ issuable: issuableBodyProps.issuable, - statusBadgeClass: issuableBodyProps.statusBadgeClass, statusIcon: issuableBodyProps.statusIcon, enableEdit: issuableBodyProps.enableEdit, }); diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js index 544db891a13..e00bb184535 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js @@ -1,4 +1,4 @@ -import { GlIcon, GlAvatarLabeled } from '@gitlab/ui'; +import { GlBadge, GlIcon, GlAvatarLabeled } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import IssuableHeader from '~/vue_shared/issuable/show/components/issuable_header.vue'; @@ -69,7 +69,7 @@ describe('IssuableHeader', () => { describe('template', () => { it('renders issuable status icon and text', () => { createComponent(); - const statusBoxEl = wrapper.findByTestId('status'); + const statusBoxEl = wrapper.findComponent(GlBadge); const statusIconEl = statusBoxEl.findComponent(GlIcon); expect(statusBoxEl.exists()).toBe(true); diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js index 8b027f990a2..f56064ed8e1 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js @@ -47,7 +47,6 @@ describe('IssuableShowRoot', () => { describe('template', () => { const { - statusBadgeClass, statusIcon, statusIconClass, enableEdit, @@ -69,7 +68,6 @@ describe('IssuableShowRoot', () => { expect(issuableHeader.exists()).toBe(true); expect(issuableHeader.props()).toMatchObject({ issuableState: state, - statusBadgeClass, statusIcon, statusIconClass, blocked, @@ -91,7 +89,6 @@ describe('IssuableShowRoot', () => { expect(issuableBody.exists()).toBe(true); expect(issuableBody.props()).toMatchObject({ issuable: mockIssuable, - statusBadgeClass, statusIcon, enableEdit, enableAutocomplete, diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js index 11e3302d409..5aa67667033 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js @@ -1,4 +1,4 @@ -import { GlIcon, GlButton, GlIntersectionObserver } from '@gitlab/ui'; +import { GlIcon, GlBadge, GlButton, GlIntersectionObserver } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; @@ -40,7 +40,7 @@ describe('IssuableTitle', () => { describe('methods', () => { describe('handleTitleAppear', () => { it('sets value of `stickyTitleVisible` prop to false', () => { - wrapper.find(GlIntersectionObserver).vm.$emit('appear'); + wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear'); expect(wrapper.vm.stickyTitleVisible).toBe(false); }); @@ -48,7 +48,7 @@ describe('IssuableTitle', () => { describe('handleTitleDisappear', () => { it('sets value of `stickyTitleVisible` prop to true', () => { - wrapper.find(GlIntersectionObserver).vm.$emit('disappear'); + wrapper.findComponent(GlIntersectionObserver).vm.$emit('disappear'); expect(wrapper.vm.stickyTitleVisible).toBe(true); }); @@ -70,14 +70,14 @@ describe('IssuableTitle', () => { expect(titleEl.exists()).toBe(true); expect(titleEl.html()).toBe( - '<h1 dir="auto" data-testid="title" class="title qa-title"><b>Sample</b> title</h1>', + '<h1 dir="auto" data-testid="title" class="title qa-title gl-font-size-h-display"><b>Sample</b> title</h1>', ); wrapperWithTitle.destroy(); }); it('renders edit button', () => { - const editButtonEl = wrapper.find(GlButton); + const editButtonEl = wrapper.findComponent(GlButton); const tooltip = getBinding(editButtonEl.element, 'gl-tooltip'); expect(editButtonEl.exists()).toBe(true); @@ -97,7 +97,10 @@ describe('IssuableTitle', () => { const stickyHeaderEl = wrapper.find('[data-testid="header"]'); expect(stickyHeaderEl.exists()).toBe(true); - expect(stickyHeaderEl.find(GlIcon).props('name')).toBe(issuableTitleProps.statusIcon); + expect(stickyHeaderEl.findComponent(GlBadge).props('variant')).toBe('success'); + expect(stickyHeaderEl.findComponent(GlIcon).props('name')).toBe( + issuableTitleProps.statusIcon, + ); expect(stickyHeaderEl.text()).toContain('Open'); expect(stickyHeaderEl.text()).toContain(issuableTitleProps.issuable.title); }); diff --git a/spec/frontend/vue_shared/issuable/show/mock_data.js b/spec/frontend/vue_shared/issuable/show/mock_data.js index 32bb9edfe08..5ec205a2d5c 100644 --- a/spec/frontend/vue_shared/issuable/show/mock_data.js +++ b/spec/frontend/vue_shared/issuable/show/mock_data.js @@ -36,7 +36,6 @@ export const mockIssuableShowProps = { enableTaskList: true, enableEdit: true, showFieldTitle: false, - statusBadgeClass: 'issuable-status-badge-open', statusIcon: 'issues', statusIconClass: 'gl-sm-display-none', taskCompletionStatus: { diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js index 0d85df25b4f..2c3f6ef8634 100644 --- a/spec/frontend/work_items/components/item_title_spec.js +++ b/spec/frontend/work_items/components/item_title_spec.js @@ -15,7 +15,7 @@ const createComponent = ({ title = 'Sample title', disabled = false } = {}) => describe('ItemTitle', () => { let wrapper; const mockUpdatedTitle = 'Updated title'; - const findInputEl = () => wrapper.find('span#item-title'); + const findInputEl = () => wrapper.find('[aria-label="Title"]'); beforeEach(() => { wrapper = createComponent(); diff --git a/spec/frontend/work_items/components/work_item_assignees_spec.js b/spec/frontend/work_items/components/work_item_assignees_spec.js new file mode 100644 index 00000000000..0552fe5050e --- /dev/null +++ b/spec/frontend/work_items/components/work_item_assignees_spec.js @@ -0,0 +1,93 @@ +import { GlLink, GlTokenSelector } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; +import localUpdateWorkItemMutation from '~/work_items/graphql/local_update_work_item.mutation.graphql'; + +const mockAssignees = [ + { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + avatarUrl: '', + webUrl: '', + name: 'John Doe', + username: 'doe_I', + }, + { + __typename: 'UserCore', + id: 'gid://gitlab/User/2', + avatarUrl: '', + webUrl: '', + name: 'Marcus Rutherford', + username: 'ruthfull', + }, +]; + +const workItemId = 'gid://gitlab/WorkItem/1'; + +const mutate = jest.fn(); + +describe('WorkItemAssignees component', () => { + let wrapper; + + const findAssigneeLinks = () => wrapper.findAllComponents(GlLink); + const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); + + const findEmptyState = () => wrapper.findByTestId('empty-state'); + + const createComponent = ({ assignees = mockAssignees } = {}) => { + wrapper = mountExtended(WorkItemAssignees, { + propsData: { + assignees, + workItemId, + }, + mocks: { + $apollo: { + mutate, + }, + }, + attachTo: document.body, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('should pass the correct data-user-id attribute', () => { + createComponent(); + + expect(findAssigneeLinks().at(0).attributes('data-user-id')).toBe('1'); + }); + + describe('when there are assignees', () => { + beforeEach(() => { + createComponent(); + }); + + it('should focus token selector on token removal', async () => { + findTokenSelector().vm.$emit('token-remove', mockAssignees[0].id); + await nextTick(); + + expect(findEmptyState().exists()).toBe(false); + expect(findTokenSelector().element.contains(document.activeElement)).toBe(true); + }); + + it('should call a mutation on clicking outside the token selector', async () => { + findTokenSelector().vm.$emit('input', [mockAssignees[0]]); + findTokenSelector().vm.$emit('token-remove'); + await nextTick(); + expect(mutate).not.toHaveBeenCalled(); + + findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); + await nextTick(); + + expect(mutate).toHaveBeenCalledWith({ + mutation: localUpdateWorkItemMutation, + variables: { + input: { id: workItemId, assigneeIds: [mockAssignees[0].id] }, + }, + }); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_description_spec.js b/spec/frontend/work_items/components/work_item_description_spec.js new file mode 100644 index 00000000000..8017c46dea8 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_description_spec.js @@ -0,0 +1,222 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mockTracking } from 'helpers/tracking_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { updateDraft } from '~/lib/utils/autosave'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import WorkItemDescription from '~/work_items/components/work_item_description.vue'; +import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import updateWorkItemWidgetsMutation from '~/work_items/graphql/update_work_item_widgets.mutation.graphql'; +import { + updateWorkItemWidgetsResponse, + workItemResponseFactory, + workItemQueryResponse, +} from '../mock_data'; + +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal', () => { + return { + confirmAction: jest.fn(), + }; +}); +jest.mock('~/lib/utils/autosave'); + +const workItemId = workItemQueryResponse.data.workItem.id; + +describe('WorkItemDescription', () => { + let wrapper; + + Vue.use(VueApollo); + + const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemWidgetsResponse); + + const findEditButton = () => wrapper.find('[data-testid="edit-description"]'); + const findMarkdownField = () => wrapper.findComponent(MarkdownField); + + const editDescription = (newText) => wrapper.find('textarea').setValue(newText); + + const clickCancel = () => wrapper.find('[data-testid="cancel"]').vm.$emit('click'); + const clickSave = () => wrapper.find('[data-testid="save-description"]').vm.$emit('click', {}); + + const createComponent = async ({ + mutationHandler = mutationSuccessHandler, + canUpdate = true, + isEditing = false, + } = {}) => { + const workItemResponse = workItemResponseFactory({ canUpdate }); + const workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse); + + const { id } = workItemQueryResponse.data.workItem; + wrapper = shallowMount(WorkItemDescription, { + apolloProvider: createMockApollo([ + [workItemQuery, workItemResponseHandler], + [updateWorkItemWidgetsMutation, mutationHandler], + ]), + propsData: { + workItemId: id, + }, + provide: { + fullPath: '/group/project', + }, + stubs: { + MarkdownField, + }, + }); + + await waitForPromises(); + + if (isEditing) { + findEditButton().vm.$emit('click'); + + await nextTick(); + } + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Edit button', () => { + it('is not visible when canUpdate = false', async () => { + await createComponent({ + canUpdate: false, + }); + + expect(findEditButton().exists()).toBe(false); + }); + + it('toggles edit mode', async () => { + await createComponent({ + canUpdate: true, + }); + + findEditButton().vm.$emit('click'); + + await nextTick(); + + expect(findMarkdownField().exists()).toBe(true); + }); + }); + + describe('editing description', () => { + it('cancels when clicking cancel', async () => { + await createComponent({ + isEditing: true, + }); + + clickCancel(); + + await nextTick(); + + expect(confirmAction).not.toHaveBeenCalled(); + expect(findMarkdownField().exists()).toBe(false); + }); + + it('prompts for confirmation when clicking cancel after changes', async () => { + await createComponent({ + isEditing: true, + }); + + editDescription('updated desc'); + + clickCancel(); + + await nextTick(); + + expect(confirmAction).toHaveBeenCalled(); + }); + + it('calls update widgets mutation', async () => { + await createComponent({ + isEditing: true, + }); + + editDescription('updated desc'); + + clickSave(); + + await waitForPromises(); + + expect(mutationSuccessHandler).toHaveBeenCalledWith({ + input: { + id: workItemId, + descriptionWidget: { + description: 'updated desc', + }, + }, + }); + }); + + it('tracks editing description', async () => { + await createComponent({ + isEditing: true, + markdownPreviewPath: '/preview', + }); + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + clickSave(); + + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_description', { + category: TRACKING_CATEGORY_SHOW, + label: 'item_description', + property: 'type_Task', + }); + }); + + it('emits error when mutation returns error', async () => { + const error = 'eror'; + + await createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockResolvedValue({ + data: { + workItemUpdateWidgets: { + workItem: {}, + errors: [error], + }, + }, + }), + }); + + editDescription('updated desc'); + + clickSave(); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[error]]); + }); + + it('emits error when mutation fails', async () => { + const error = 'eror'; + + await createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockRejectedValue(new Error(error)), + }); + + editDescription('updated desc'); + + clickSave(); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[error]]); + }); + + it('autosaves description', async () => { + await createComponent({ + isEditing: true, + }); + + editDescription('updated desc'); + + expect(updateDraft).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_detail_modal_spec.js b/spec/frontend/work_items/components/work_item_detail_modal_spec.js index aaabdbc82d9..d55ba318e46 100644 --- a/spec/frontend/work_items/components/work_item_detail_modal_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_modal_spec.js @@ -29,7 +29,7 @@ describe('WorkItemDetailModal component', () => { const findAlert = () => wrapper.findComponent(GlAlert); const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail); - const createComponent = ({ workItemId = '1', error = false } = {}) => { + const createComponent = ({ workItemId = '1', issueGid = '2', error = false } = {}) => { const apolloProvider = createMockApollo([ [ deleteWorkItemFromTaskMutation, @@ -46,7 +46,7 @@ describe('WorkItemDetailModal component', () => { wrapper = shallowMount(WorkItemDetailModal, { apolloProvider, - propsData: { workItemId }, + propsData: { workItemId, issueGid }, data() { return { error, @@ -67,6 +67,7 @@ describe('WorkItemDetailModal component', () => { expect(findWorkItemDetail().props()).toEqual({ workItemId: '1', + workItemParentId: '2', }); }); @@ -97,13 +98,6 @@ describe('WorkItemDetailModal component', () => { expect(wrapper.emitted('close')).toBeTruthy(); }); - it('emits `workItemUpdated` event on updating work item', () => { - createComponent(); - findWorkItemDetail().vm.$emit('workItemUpdated'); - - expect(wrapper.emitted('workItemUpdated')).toBeTruthy(); - }); - describe('delete work item', () => { it('emits workItemDeleted and closes modal', async () => { createComponent(); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js new file mode 100644 index 00000000000..774e9198992 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js @@ -0,0 +1,88 @@ +import Vue, { nextTick } from 'vue'; +import { GlBadge } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import WorkItemLinks from '~/work_items/components/work_item_links/work_item_links.vue'; +import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.query.graphql'; +import { workItemHierarchyResponse, workItemHierarchyEmptyResponse } from '../../mock_data'; + +Vue.use(VueApollo); + +describe('WorkItemLinks', () => { + let wrapper; + + const createComponent = async ({ response = workItemHierarchyResponse } = {}) => { + wrapper = shallowMountExtended(WorkItemLinks, { + apolloProvider: createMockApollo([ + [getWorkItemLinksQuery, jest.fn().mockResolvedValue(response)], + ]), + propsData: { issuableId: 1 }, + }); + + await waitForPromises(); + }; + + const findToggleButton = () => wrapper.findByTestId('toggle-links'); + const findLinksBody = () => wrapper.findByTestId('links-body'); + const findEmptyState = () => wrapper.findByTestId('links-empty'); + const findToggleAddFormButton = () => wrapper.findByTestId('toggle-add-form'); + const findAddLinksForm = () => wrapper.findByTestId('add-links-form'); + + beforeEach(async () => { + await createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('is expanded by default', () => { + expect(findToggleButton().props('icon')).toBe('chevron-lg-up'); + expect(findLinksBody().exists()).toBe(true); + }); + + it('expands on click toggle button', async () => { + findToggleButton().vm.$emit('click'); + await nextTick(); + + expect(findToggleButton().props('icon')).toBe('chevron-lg-down'); + expect(findLinksBody().exists()).toBe(false); + }); + + describe('when no child links', () => { + beforeEach(async () => { + await createComponent({ response: workItemHierarchyEmptyResponse }); + }); + + it('displays empty state if there are no children', () => { + expect(findEmptyState().exists()).toBe(true); + }); + + describe('add link form', () => { + it('displays form on click add button and hides form on cancel', async () => { + expect(findEmptyState().exists()).toBe(true); + + findToggleAddFormButton().vm.$emit('click'); + await nextTick(); + + expect(findAddLinksForm().exists()).toBe(true); + + findAddLinksForm().vm.$emit('cancel'); + await nextTick(); + + expect(findAddLinksForm().exists()).toBe(false); + }); + }); + }); + + it('renders all hierarchy widget children', () => { + expect(findLinksBody().exists()).toBe(true); + + const children = wrapper.findAll('[data-testid="links-child"]'); + + expect(children).toHaveLength(4); + expect(children.at(0).findComponent(GlBadge).text()).toBe('Open'); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_state_spec.js b/spec/frontend/work_items/components/work_item_state_spec.js index 9e48f56d9e9..b379d1fc846 100644 --- a/spec/frontend/work_items/components/work_item_state_spec.js +++ b/spec/frontend/work_items/components/work_item_state_spec.js @@ -12,6 +12,7 @@ import { STATE_CLOSED, STATE_EVENT_CLOSE, STATE_EVENT_REOPEN, + TRACKING_CATEGORY_SHOW, } from '~/work_items/constants'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import { updateWorkItemMutationResponse, workItemQueryResponse } from '../mock_data'; @@ -81,15 +82,6 @@ describe('WorkItemState component', () => { }); }); - it('emits updated event', async () => { - createComponent(); - - findItemState().vm.$emit('changed', STATE_CLOSED); - await waitForPromises(); - - expect(wrapper.emitted('updated')).toEqual([[]]); - }); - it('emits an error message when the mutation was unsuccessful', async () => { createComponent({ mutationHandler: jest.fn().mockRejectedValue('Error!') }); @@ -107,8 +99,8 @@ describe('WorkItemState component', () => { findItemState().vm.$emit('changed', STATE_CLOSED); await waitForPromises(); - expect(trackingSpy).toHaveBeenCalledWith('workItems:show', 'updated_state', { - category: 'workItems:show', + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_state', { + category: TRACKING_CATEGORY_SHOW, label: 'item_state', property: 'type_Task', }); diff --git a/spec/frontend/work_items/components/work_item_title_spec.js b/spec/frontend/work_items/components/work_item_title_spec.js index 19b56362ac0..a48449bb636 100644 --- a/spec/frontend/work_items/components/work_item_title_spec.js +++ b/spec/frontend/work_items/components/work_item_title_spec.js @@ -6,8 +6,9 @@ import { mockTracking } from 'helpers/tracking_helper'; import waitForPromises from 'helpers/wait_for_promises'; import ItemTitle from '~/work_items/components/item_title.vue'; import WorkItemTitle from '~/work_items/components/work_item_title.vue'; -import { i18n } from '~/work_items/constants'; +import { i18n, TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql'; import { updateWorkItemMutationResponse, workItemQueryResponse } from '../mock_data'; describe('WorkItemTitle component', () => { @@ -19,14 +20,18 @@ describe('WorkItemTitle component', () => { const findItemTitle = () => wrapper.findComponent(ItemTitle); - const createComponent = ({ mutationHandler = mutationSuccessHandler } = {}) => { + const createComponent = ({ workItemParentId, mutationHandler = mutationSuccessHandler } = {}) => { const { id, title, workItemType } = workItemQueryResponse.data.workItem; wrapper = shallowMount(WorkItemTitle, { - apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), + apolloProvider: createMockApollo([ + [updateWorkItemMutation, mutationHandler], + [updateWorkItemTaskMutation, mutationHandler], + ]), propsData: { workItemId: id, workItemTitle: title, workItemType: workItemType.name, + workItemParentId, }, }); }; @@ -57,13 +62,25 @@ describe('WorkItemTitle component', () => { }); }); - it('emits updated event', async () => { - createComponent(); + it('calls WorkItemTaskUpdate if passed workItemParentId prop', () => { + const title = 'new title!'; + const workItemParentId = '1234'; - findItemTitle().vm.$emit('title-changed', 'new title'); - await waitForPromises(); + createComponent({ + workItemParentId, + }); - expect(wrapper.emitted('updated')).toEqual([[]]); + findItemTitle().vm.$emit('title-changed', title); + + expect(mutationSuccessHandler).toHaveBeenCalledWith({ + input: { + id: workItemParentId, + taskData: { + id: workItemQueryResponse.data.workItem.id, + title, + }, + }, + }); }); it('does not call a mutation when the title has not changed', () => { @@ -91,8 +108,8 @@ describe('WorkItemTitle component', () => { findItemTitle().vm.$emit('title-changed', 'new title'); await waitForPromises(); - expect(trackingSpy).toHaveBeenCalledWith('workItems:show', 'updated_title', { - category: 'workItems:show', + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_title', { + category: TRACKING_CATEGORY_SHOW, label: 'item_title', property: 'type_Task', }); diff --git a/spec/frontend/work_items/components/work_item_weight_spec.js b/spec/frontend/work_items/components/work_item_weight_spec.js new file mode 100644 index 00000000000..80a1d032ad7 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_weight_spec.js @@ -0,0 +1,47 @@ +import { shallowMount } from '@vue/test-utils'; +import WorkItemWeight from '~/work_items/components/work_item_weight.vue'; + +describe('WorkItemAssignees component', () => { + let wrapper; + + const createComponent = ({ weight, hasIssueWeightsFeature = true } = {}) => { + wrapper = shallowMount(WorkItemWeight, { + propsData: { + weight, + }, + provide: { + hasIssueWeightsFeature, + }, + }); + }; + + describe('weight licensed feature', () => { + describe.each` + description | hasIssueWeightsFeature | exists + ${'when available'} | ${true} | ${true} + ${'when not available'} | ${false} | ${false} + `('$description', ({ hasIssueWeightsFeature, exists }) => { + it(hasIssueWeightsFeature ? 'renders component' : 'does not render component', () => { + createComponent({ hasIssueWeightsFeature }); + + expect(wrapper.find('div').exists()).toBe(exists); + }); + }); + }); + + describe('weight text', () => { + describe.each` + description | weight | text + ${'renders 1'} | ${1} | ${'1'} + ${'renders 0'} | ${0} | ${'0'} + ${'renders None'} | ${null} | ${'None'} + ${'renders None'} | ${undefined} | ${'None'} + `('when weight is $weight', ({ description, weight, text }) => { + it(description, () => { + createComponent({ weight }); + + expect(wrapper.text()).toContain(text); + }); + }); + }); +}); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index f3483550013..bf3f4e1364d 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -15,6 +15,15 @@ export const workItemQueryResponse = { deleteWorkItem: false, updateWorkItem: false, }, + widgets: [ + { + __typename: 'WorkItemWidgetDescription', + type: 'DESCRIPTION', + description: 'some **great** text', + descriptionHtml: + '<p data-sourcepos="1:1-1:19" dir="auto">some <strong>great</strong> text</p>', + }, + ], }, }, }; @@ -38,11 +47,53 @@ export const updateWorkItemMutationResponse = { deleteWorkItem: false, updateWorkItem: false, }, + widgets: [], }, }, }, }; +export const workItemResponseFactory = ({ canUpdate } = {}) => ({ + data: { + workItem: { + __typename: 'WorkItem', + id: 'gid://gitlab/WorkItem/1', + title: 'Updated title', + state: 'OPEN', + description: 'description', + workItemType: { + __typename: 'WorkItemType', + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + }, + userPermissions: { + deleteWorkItem: false, + updateWorkItem: canUpdate, + }, + widgets: [ + { + __typename: 'WorkItemWidgetDescription', + type: 'DESCRIPTION', + description: 'some **great** text', + descriptionHtml: + '<p data-sourcepos="1:1-1:19" dir="auto">some <strong>great</strong> text</p>', + }, + ], + }, + }, +}); + +export const updateWorkItemWidgetsResponse = { + data: { + workItemUpdateWidgets: { + workItem: { + id: 1234, + }, + errors: [], + }, + }, +}; + export const projectWorkItemTypesQueryResponse = { data: { workspace: { @@ -77,6 +128,7 @@ export const createWorkItemMutationResponse = { deleteWorkItem: false, updateWorkItem: false, }, + widgets: [], }, }, }, @@ -124,3 +176,102 @@ export const workItemTitleSubscriptionResponse = { }, }, }; + +export const workItemHierarchyEmptyResponse = { + data: { + workItem: { + id: 'gid://gitlab/WorkItem/1', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/6', + __typename: 'WorkItemType', + }, + title: 'New title', + widgets: [ + { + type: 'DESCRIPTION', + __typename: 'WorkItemWidgetDescription', + }, + { + type: 'HIERARCHY', + parent: null, + children: { + nodes: [], + __typename: 'WorkItemConnection', + }, + __typename: 'WorkItemWidgetHierarchy', + }, + ], + __typename: 'WorkItem', + }, + }, +}; + +export const workItemHierarchyResponse = { + data: { + workItem: { + id: 'gid://gitlab/WorkItem/1', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/6', + __typename: 'WorkItemType', + }, + title: 'New title', + widgets: [ + { + type: 'DESCRIPTION', + __typename: 'WorkItemWidgetDescription', + }, + { + type: 'HIERARCHY', + parent: null, + children: { + nodes: [ + { + id: 'gid://gitlab/WorkItem/2', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/5', + __typename: 'WorkItemType', + }, + title: 'xyz', + state: 'OPEN', + __typename: 'WorkItem', + }, + { + id: 'gid://gitlab/WorkItem/3', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/5', + __typename: 'WorkItemType', + }, + title: 'abc', + state: 'CLOSED', + __typename: 'WorkItem', + }, + { + id: 'gid://gitlab/WorkItem/4', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/5', + __typename: 'WorkItemType', + }, + title: 'bar', + state: 'OPEN', + __typename: 'WorkItem', + }, + { + id: 'gid://gitlab/WorkItem/5', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/5', + __typename: 'WorkItemType', + }, + title: 'foobar', + state: 'OPEN', + __typename: 'WorkItem', + }, + ], + __typename: 'WorkItemConnection', + }, + __typename: 'WorkItemWidgetHierarchy', + }, + ], + __typename: 'WorkItem', + }, + }, +}; diff --git a/spec/frontend/work_items/pages/work_item_detail_spec.js b/spec/frontend/work_items/pages/work_item_detail_spec.js index 9f87655175c..b9724034cb4 100644 --- a/spec/frontend/work_items/pages/work_item_detail_spec.js +++ b/spec/frontend/work_items/pages/work_item_detail_spec.js @@ -5,11 +5,15 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; +import WorkItemDescription from '~/work_items/components/work_item_description.vue'; import WorkItemState from '~/work_items/components/work_item_state.vue'; import WorkItemTitle from '~/work_items/components/work_item_title.vue'; +import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; +import WorkItemWeight from '~/work_items/components/work_item_weight.vue'; import { i18n } from '~/work_items/constants'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql'; +import { temporaryConfig } from '~/work_items/graphql/provider'; import { workItemTitleSubscriptionResponse, workItemQueryResponse } from '../mock_data'; describe('WorkItemDetail component', () => { @@ -24,18 +28,34 @@ describe('WorkItemDetail component', () => { const findSkeleton = () => wrapper.findComponent(GlSkeletonLoader); const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle); const findWorkItemState = () => wrapper.findComponent(WorkItemState); + const findWorkItemDescription = () => wrapper.findComponent(WorkItemDescription); + const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees); + const findWorkItemWeight = () => wrapper.findComponent(WorkItemWeight); const createComponent = ({ workItemId = workItemQueryResponse.data.workItem.id, handler = successHandler, subscriptionHandler = initialSubscriptionHandler, + workItemsMvc2Enabled = false, + includeWidgets = false, } = {}) => { wrapper = shallowMount(WorkItemDetail, { - apolloProvider: createMockApollo([ - [workItemQuery, handler], - [workItemTitleSubscription, subscriptionHandler], - ]), + apolloProvider: createMockApollo( + [ + [workItemQuery, handler], + [workItemTitleSubscription, subscriptionHandler], + ], + {}, + { + typePolicies: includeWidgets ? temporaryConfig.cacheConfig.typePolicies : {}, + }, + ), propsData: { workItemId }, + provide: { + glFeatures: { + workItemsMvc2: workItemsMvc2Enabled, + }, + }, }); }; @@ -78,6 +98,22 @@ describe('WorkItemDetail component', () => { }); }); + describe('description', () => { + it('does not show description widget if loading description fails', () => { + createComponent(); + + expect(findWorkItemDescription().exists()).toBe(false); + }); + + it('shows description widget if description loads', async () => { + createComponent(); + + await waitForPromises(); + + expect(findWorkItemDescription().exists()).toBe(true); + }); + }); + it('shows an error message when the work item query was unsuccessful', async () => { const errorHandler = jest.fn().mockRejectedValue('Oops'); createComponent({ handler: errorHandler }); @@ -105,17 +141,64 @@ describe('WorkItemDetail component', () => { }); }); - it('emits workItemUpdated event when fields updated', async () => { - createComponent(); + describe('when work_items_mvc_2 feature flag is enabled', () => { + it('renders assignees component when assignees widget is returned from the API', async () => { + createComponent({ + workItemsMvc2Enabled: true, + includeWidgets: true, + }); + await waitForPromises(); - await waitForPromises(); + expect(findWorkItemAssignees().exists()).toBe(true); + }); - findWorkItemState().vm.$emit('updated'); + it('does not render assignees component when assignees widget is not returned from the API', async () => { + createComponent({ + workItemsMvc2Enabled: true, + includeWidgets: false, + }); + await waitForPromises(); - expect(wrapper.emitted('workItemUpdated')).toEqual([[]]); + expect(findWorkItemAssignees().exists()).toBe(false); + }); + }); - findWorkItemTitle().vm.$emit('updated'); + it('does not render assignees component when assignees feature flag is disabled', async () => { + createComponent(); + await waitForPromises(); - expect(wrapper.emitted('workItemUpdated')).toEqual([[], []]); + expect(findWorkItemAssignees().exists()).toBe(false); + }); + + describe('weight widget', () => { + describe('when work_items_mvc_2 feature flag is enabled', () => { + describe.each` + description | includeWidgets | exists + ${'when widget is returned from API'} | ${true} | ${true} + ${'when widget is not returned from API'} | ${false} | ${false} + `('$description', ({ includeWidgets, exists }) => { + it(`${includeWidgets ? 'renders' : 'does not render'} weight component`, async () => { + createComponent({ includeWidgets, workItemsMvc2Enabled: true }); + await waitForPromises(); + + expect(findWorkItemWeight().exists()).toBe(exists); + }); + }); + }); + + describe('when work_items_mvc_2 feature flag is disabled', () => { + describe.each` + description | includeWidgets | exists + ${'when widget is returned from API'} | ${true} | ${false} + ${'when widget is not returned from API'} | ${false} | ${false} + `('$description', ({ includeWidgets, exists }) => { + it(`${includeWidgets ? 'renders' : 'does not render'} weight component`, async () => { + createComponent({ includeWidgets, workItemsMvc2Enabled: false }); + await waitForPromises(); + + expect(findWorkItemWeight().exists()).toBe(exists); + }); + }); + }); }); }); diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js index 85096392e84..3c5da94114e 100644 --- a/spec/frontend/work_items/pages/work_item_root_spec.js +++ b/spec/frontend/work_items/pages/work_item_root_spec.js @@ -11,6 +11,7 @@ import deleteWorkItem from '~/work_items/graphql/delete_work_item.mutation.graph import { deleteWorkItemResponse, deleteWorkItemFailureResponse } from '../mock_data'; jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), visitUrl: jest.fn(), })); @@ -52,6 +53,7 @@ describe('Work items root component', () => { expect(findWorkItemDetail().props()).toEqual({ workItemId: 'gid://gitlab/WorkItem/1', + workItemParentId: null, }); }); |