diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-20 09:40:42 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-20 09:40:42 +0000 |
commit | ee664acb356f8123f4f6b00b73c1e1cf0866c7fb (patch) | |
tree | f8479f94a28f66654c6a4f6fb99bad6b4e86a40e /spec/frontend | |
parent | 62f7d5c5b69180e82ae8196b7b429eeffc8e7b4f (diff) | |
download | gitlab-ce-ee664acb356f8123f4f6b00b73c1e1cf0866c7fb.tar.gz |
Add latest changes from gitlab-org/gitlab@15-5-stable-eev15.5.0-rc42
Diffstat (limited to 'spec/frontend')
526 files changed, 10013 insertions, 5794 deletions
diff --git a/spec/frontend/__helpers__/class_spec_helper.js b/spec/frontend/__helpers__/class_spec_helper.js deleted file mode 100644 index b26f087f0c5..00000000000 --- a/spec/frontend/__helpers__/class_spec_helper.js +++ /dev/null @@ -1,10 +0,0 @@ -// eslint-disable-next-line jest/no-export -export default class ClassSpecHelper { - static itShouldBeAStaticMethod(base, method) { - return it('should be a static method', () => { - expect(Object.prototype.hasOwnProperty.call(base, method)).toBeTruthy(); - }); - } -} - -window.ClassSpecHelper = ClassSpecHelper; diff --git a/spec/frontend/__helpers__/class_spec_helper_spec.js b/spec/frontend/__helpers__/class_spec_helper_spec.js deleted file mode 100644 index 533d5687bde..00000000000 --- a/spec/frontend/__helpers__/class_spec_helper_spec.js +++ /dev/null @@ -1,26 +0,0 @@ -/* global ClassSpecHelper */ - -import './class_spec_helper'; - -describe('ClassSpecHelper', () => { - let testContext; - - beforeEach(() => { - testContext = {}; - }); - - describe('itShouldBeAStaticMethod', () => { - beforeEach(() => { - class TestClass { - instanceMethod() { - this.prop = 'val'; - } - static staticMethod() {} - } - - testContext.TestClass = TestClass; - }); - - ClassSpecHelper.itShouldBeAStaticMethod(ClassSpecHelper, 'itShouldBeAStaticMethod'); - }); -}); diff --git a/spec/frontend/__helpers__/dom_shims/index.js b/spec/frontend/__helpers__/dom_shims/index.js index 742d55196b4..3b41e2ca2a7 100644 --- a/spec/frontend/__helpers__/dom_shims/index.js +++ b/spec/frontend/__helpers__/dom_shims/index.js @@ -11,3 +11,4 @@ import './window_scroll_to'; import './scroll_by'; import './size_properties'; import './image_element_properties'; +import './text_encoder'; diff --git a/spec/frontend/__helpers__/dom_shims/text_encoder.js b/spec/frontend/__helpers__/dom_shims/text_encoder.js new file mode 100644 index 00000000000..d3d5221a003 --- /dev/null +++ b/spec/frontend/__helpers__/dom_shims/text_encoder.js @@ -0,0 +1,4 @@ +import { TextEncoder, TextDecoder } from 'util'; + +global.TextEncoder = TextEncoder; +global.TextDecoder = TextDecoder; diff --git a/spec/frontend/__helpers__/graphql_transformer.js b/spec/frontend/__helpers__/graphql_transformer.js new file mode 100644 index 00000000000..e776e2ea6ac --- /dev/null +++ b/spec/frontend/__helpers__/graphql_transformer.js @@ -0,0 +1,8 @@ +/* eslint-disable import/no-commonjs */ +const loader = require('graphql-tag/loader'); + +module.exports = { + process(src) { + return loader.call({ cacheable() {} }, src); + }, +}; diff --git a/spec/frontend/__helpers__/shared_test_setup.js b/spec/frontend/__helpers__/shared_test_setup.js index 45a7b8e0352..2fe9fe89a90 100644 --- a/spec/frontend/__helpers__/shared_test_setup.js +++ b/spec/frontend/__helpers__/shared_test_setup.js @@ -1,7 +1,7 @@ /* Common setup for both unit and integration test environments */ -import { config as testUtilsConfig } from '@vue/test-utils'; import * as jqueryMatchers from 'custom-jquery-matchers'; import Vue from 'vue'; +import { enableAutoDestroy } from '@vue/test-utils'; import 'jquery'; import Translate from '~/vue_shared/translate'; import setWindowLocation from './set_window_location_helper'; @@ -13,6 +13,8 @@ import './dom_shims'; import './jquery'; import '~/commons/bootstrap'; +enableAutoDestroy(afterEach); + // This module has some fairly decent visual test coverage in it's own repository. jest.mock('@gitlab/favicon-overlay'); jest.mock('~/lib/utils/axios_utils', () => jest.requireActual('helpers/mocks/axios_utils')); @@ -44,16 +46,6 @@ Object.entries(jqueryMatchers).forEach(([matcherName, matcherFactory]) => { expect.extend(customMatchers); -testUtilsConfig.deprecationWarningHandler = (method, message) => { - const ALLOWED_DEPRECATED_METHODS = [ - // https://gitlab.com/gitlab-org/gitlab/-/issues/295679 - 'finding components with `find` or `get`', - ]; - if (!ALLOWED_DEPRECATED_METHODS.includes(method)) { - global.console.error(message); - } -}; - Object.assign(global, { requestIdleCallback(cb) { const start = Date.now(); @@ -72,6 +64,7 @@ Object.assign(global, { beforeEach(() => { // make sure that each test actually tests something // see https://jestjs.io/docs/en/expect#expecthasassertions + // eslint-disable-next-line jest/no-standalone-expect expect.hasAssertions(); // Reset the mocked window.location. This ensures tests don't interfere with diff --git a/spec/frontend/__helpers__/stub_component.js b/spec/frontend/__helpers__/stub_component.js index 4f9d1ee6f5d..3e9af994ee3 100644 --- a/spec/frontend/__helpers__/stub_component.js +++ b/spec/frontend/__helpers__/stub_component.js @@ -38,7 +38,7 @@ export function stubComponent(Component, options = {}) { // Do not render any slots/scoped slots except default // This differs from VTU behavior which renders all slots template: '<div><slot></slot></div>', - // allows wrapper.find(Component) to work for stub + // allows wrapper.findComponent(Component) to work for stub $_vueTestUtils_original: Component, ...options, }; diff --git a/spec/frontend/__helpers__/vue_mount_component_helper.js b/spec/frontend/__helpers__/vue_mount_component_helper.js deleted file mode 100644 index ed43355ea5b..00000000000 --- a/spec/frontend/__helpers__/vue_mount_component_helper.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Deprecated. Please do not use. - * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445 - */ -const mountComponent = (Component, props = {}, el = null) => - new Component({ - propsData: props, - }).$mount(el); - -/** - * Deprecated. Please do not use. - * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445 - */ -export const createComponentWithStore = (Component, store, propsData = {}) => - new Component({ - store, - propsData, - }); - -/** - * Deprecated. Please do not use. - * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445 - */ -export const mountComponentWithStore = (Component, { el, props, store }) => - new Component({ - store, - propsData: props || {}, - }).$mount(el); - -/** - * Deprecated. Please do not use. - * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445 - */ -export default mountComponent; diff --git a/spec/frontend/__helpers__/vue_test_utils_helper_spec.js b/spec/frontend/__helpers__/vue_test_utils_helper_spec.js index ae180c3b49d..466333f8a89 100644 --- a/spec/frontend/__helpers__/vue_test_utils_helper_spec.js +++ b/spec/frontend/__helpers__/vue_test_utils_helper_spec.js @@ -140,11 +140,12 @@ describe('Vue test utils helpers', () => { const text = 'foo bar'; const options = { selector: 'div' }; const mockDiv = document.createElement('div'); - const mockVm = new Vue({ render: (h) => h('div') }).$mount(); + let mockVm; let wrapper; beforeEach(() => { jest.spyOn(vtu, 'createWrapper'); + mockVm = new Vue({ render: (h) => h('div') }).$mount(); wrapper = extendedWrapper( shallowMount({ diff --git a/spec/frontend/__mocks__/monaco-editor/index.js b/spec/frontend/__mocks__/monaco-editor/index.js index 384f9993150..d09672a4ecf 100644 --- a/spec/frontend/__mocks__/monaco-editor/index.js +++ b/spec/frontend/__mocks__/monaco-editor/index.js @@ -8,10 +8,8 @@ import 'monaco-editor/esm/vs/language/css/monaco.contribution'; import 'monaco-editor/esm/vs/language/json/monaco.contribution'; import 'monaco-editor/esm/vs/language/html/monaco.contribution'; import 'monaco-editor/esm/vs/basic-languages/monaco.contribution'; -import 'monaco-yaml/lib/esm/monaco.contribution'; // This language starts trying to spin up web workers which obviously breaks in Jest environment jest.mock('monaco-editor/esm/vs/language/typescript/tsMode'); -jest.mock('monaco-yaml/lib/esm/yamlMode'); export * from 'monaco-editor/esm/vs/editor/editor.api'; diff --git a/spec/frontend/__mocks__/monaco-yaml/index.js b/spec/frontend/__mocks__/monaco-yaml/index.js new file mode 100644 index 00000000000..36681854d0b --- /dev/null +++ b/spec/frontend/__mocks__/monaco-yaml/index.js @@ -0,0 +1,4 @@ +const setDiagnosticsOptions = jest.fn(); +const yamlDefaults = {}; + +export { setDiagnosticsOptions, yamlDefaults }; 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 index aed3db4aa4c..2fa14810578 100644 --- a/spec/frontend/access_tokens/components/access_token_table_app_spec.js +++ b/spec/frontend/access_tokens/components/access_token_table_app_spec.js @@ -1,6 +1,6 @@ import { GlButton, GlPagination, GlTable } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; 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'; @@ -11,7 +11,7 @@ describe('~/access_tokens/components/access_token_table_app', () => { const accessTokenType = 'personal access token'; const accessTokenTypePlural = 'personal access tokens'; - const initialActiveAccessTokens = []; + const information = undefined; const noActiveTokensMessage = 'This user has no active personal access tokens.'; const showRole = false; @@ -43,11 +43,12 @@ describe('~/access_tokens/components/access_token_table_app', () => { ]; const createComponent = (props = {}) => { - wrapper = mount(AccessTokenTableApp, { + wrapper = mountExtended(AccessTokenTableApp, { provide: { accessTokenType, accessTokenTypePlural, - initialActiveAccessTokens, + information, + initialActiveAccessTokens: defaultActiveAccessTokens, noActiveTokensMessage, showRole, ...props, @@ -71,8 +72,8 @@ describe('~/access_tokens/components/access_token_table_app', () => { wrapper?.destroy(); }); - it('should render the `GlTable` with default empty message', () => { - createComponent(); + it('should render an empty table with a default message', () => { + createComponent({ initialActiveAccessTokens: [] }); const cells = findCells(); expect(cells).toHaveLength(1); @@ -81,58 +82,61 @@ describe('~/access_tokens/components/access_token_table_app', () => { ); }); - it('should render the `GlTable` with custom empty message', () => { + it('should render an empty table with a custom message', () => { const noTokensMessage = 'This group has no active access tokens.'; - createComponent({ noActiveTokensMessage: noTokensMessage }); + createComponent({ initialActiveAccessTokens: [], noActiveTokensMessage: noTokensMessage }); const cells = findCells(); expect(cells).toHaveLength(1); expect(cells.at(0).text()).toBe(noTokensMessage); }); - it('should render an h5 element', () => { + it('should show a title indicating the amount of tokens', () => { createComponent(); expect(wrapper.find('h5').text()).toBe( sprintf(__('Active %{accessTokenTypePlural} (%{totalAccessTokens})'), { accessTokenTypePlural, - totalAccessTokens: initialActiveAccessTokens.length, + totalAccessTokens: defaultActiveAccessTokens.length, }), ); }); - it('should render the `GlTable` component with default 6 column headers', () => { - createComponent(); + it('should render information section', () => { + const info = 'This is my information'; + createComponent({ information: info }); - 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); - }); + expect(wrapper.findByTestId('information-section').text()).toBe(info); }); - it('should render the `GlTable` component with 7 headers', () => { - createComponent({ showRole: true }); + describe('table headers', () => { + it('should include `Action` column', () => { + createComponent(); + + const headers = findHeaders(); + expect(headers.wrappers.map((header) => header.text())).toStrictEqual([ + __('Token name'), + __('Scopes'), + s__('AccessTokens|Created'), + __('Last Used'), + __('Expires'), + __('Action'), + ]); + }); - 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('should include `Role` column', () => { + createComponent({ showRole: true }); + + const headers = findHeaders(); + expect(headers.wrappers.map((header) => header.text())).toStrictEqual([ + __('Token name'), + __('Scopes'), + s__('AccessTokens|Created'), + __('Last Used'), + __('Expires'), + __('Role'), + __('Action'), + ]); }); }); @@ -150,8 +154,8 @@ describe('~/access_tokens/components/access_token_table_app', () => { 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 }); + it('updates the table after new tokens are created', async () => { + createComponent({ initialActiveAccessTokens: [], showRole: true }); await triggerSuccess(); const cells = findCells(); @@ -190,16 +194,43 @@ describe('~/access_tokens/components/access_token_table_app', () => { expect(button.props('category')).toBe('tertiary'); }); - describe('revoke path', () => { - beforeEach(() => { - createComponent({ showRole: true }); + describe('when revoke_path is', () => { + describe('absent in all tokens', () => { + it('should not include `Action` column', () => { + createComponent({ + initialActiveAccessTokens: defaultActiveAccessTokens.map( + ({ revoke_path, ...rest }) => rest, + ), + showRole: true, + }); + + const headers = findHeaders(); + expect(headers).toHaveLength(6); + [ + __('Token name'), + __('Scopes'), + s__('AccessTokens|Created'), + __('Last Used'), + __('Expires'), + __('Role'), + ].forEach((text, index) => { + expect(headers.at(index).text()).toBe(text); + }); + }); }); it.each([{ revoke_path: null }, { revoke_path: undefined }])( - 'with %p, does not show revoke button', - async (input) => { - await triggerSuccess(defaultActiveAccessTokens.map((data) => ({ ...data, ...input }))); - + '%p in some tokens, does not show revoke button', + (input) => { + createComponent({ + initialActiveAccessTokens: [ + defaultActiveAccessTokens.map((data) => ({ ...data, ...input }))[0], + defaultActiveAccessTokens[1], + ], + showRole: true, + }); + + expect(findHeaders().at(6).text()).toBe(__('Action')); expect(findCells().at(6).findComponent(GlButton).exists()).toBe(false); }, ); @@ -207,7 +238,6 @@ describe('~/access_tokens/components/access_token_table_app', () => { it('sorts rows alphabetically', async () => { createComponent({ showRole: true }); - await triggerSuccess(); const cells = findCells(); @@ -226,7 +256,6 @@ describe('~/access_tokens/components/access_token_table_app', () => { it('sorts rows by date', async () => { createComponent({ showRole: true }); - await triggerSuccess(); const cells = findCells(); @@ -242,14 +271,20 @@ describe('~/access_tokens/components/access_token_table_app', () => { expect(cells.at(10).text()).toBe('Never'); }); - it('should show the pagination component when needed', async () => { - createComponent(); - expect(findPagination().exists()).toBe(false); + describe('pagination', () => { + it('does not show pagination component', () => { + createComponent({ + initialActiveAccessTokens: Array(PAGE_SIZE).fill(defaultActiveAccessTokens[0]), + }); - await triggerSuccess(Array(PAGE_SIZE).fill(defaultActiveAccessTokens[0])); - expect(findPagination().exists()).toBe(false); + expect(findPagination().exists()).toBe(false); + }); - await triggerSuccess(Array(PAGE_SIZE + 1).fill(defaultActiveAccessTokens[0])); - expect(findPagination().exists()).toBe(true); + it('shows the pagination component', () => { + createComponent({ + initialActiveAccessTokens: Array(PAGE_SIZE + 1).fill(defaultActiveAccessTokens[0]), + }); + expect(findPagination().exists()).toBe(true); + }); }); }); 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 index d12d200d214..b4af11169ad 100644 --- a/spec/frontend/access_tokens/components/new_access_token_app_spec.js +++ b/spec/frontend/access_tokens/components/new_access_token_app_spec.js @@ -22,6 +22,8 @@ describe('~/access_tokens/components/new_access_token_app', () => { }); }; + const findButtonEl = () => document.querySelector('[type=submit]'); + const triggerSuccess = async (newToken = 'new token') => { wrapper .findComponent(DomElementListener) @@ -41,7 +43,7 @@ describe('~/access_tokens/components/new_access_token_app', () => { <input type="text" id="expires_at" value="2022-01-01"/> <input type="text" value='1'/> <input type="checkbox" checked/> - <input type="submit" value="Create"/> + <button type="submit" value="Create" class="disabled" disabled="disabled"/> </form>`, ); @@ -120,10 +122,10 @@ describe('~/access_tokens/components/new_access_token_app', () => { }); it('should not reset the submit button value', async () => { - expect(document.querySelector('input[type=submit]').value).toBe('Create'); + expect(findButtonEl().value).toBe('Create'); await triggerSuccess(); - expect(document.querySelector('input[type=submit]').value).toBe('Create'); + expect(findButtonEl().value).toBe('Create'); }); }); }); @@ -162,6 +164,17 @@ describe('~/access_tokens/components/new_access_token_app', () => { expect(wrapper.findComponent(GlAlert).exists()).toBe(false); }); + + it('should enable the submit button', async () => { + const button = findButtonEl(); + expect(button).toBeDisabled(); + expect(button.className).toBe('disabled'); + + await triggerError(); + + expect(button).not.toBeDisabled(); + expect(button.className).toBe(''); + }); }); describe('before error or success', () => { diff --git a/spec/frontend/access_tokens/index_spec.js b/spec/frontend/access_tokens/index_spec.js index 55575ab25fc..1157e44f41a 100644 --- a/spec/frontend/access_tokens/index_spec.js +++ b/spec/frontend/access_tokens/index_spec.js @@ -1,7 +1,4 @@ -/* 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 { @@ -10,10 +7,11 @@ import { initNewAccessTokenApp, initTokensApp, } from '~/access_tokens'; -import * as AccessTokenTableApp from '~/access_tokens/components/access_token_table_app.vue'; +import AccessTokenTableApp from '~/access_tokens/components/access_token_table_app.vue'; import ExpiresAtField from '~/access_tokens/components/expires_at_field.vue'; -import * as NewAccessTokenApp from '~/access_tokens/components/new_access_token_app.vue'; -import * as TokensApp from '~/access_tokens/components/tokens_app.vue'; +import NewAccessTokenApp from '~/access_tokens/components/new_access_token_app.vue'; +import TokensApp from '~/access_tokens/components/tokens_app.vue'; +import { FORM_SELECTOR } from '~/access_tokens/components/constants'; import { FEED_TOKEN, INCOMING_EMAIL_TOKEN, STATIC_OBJECT_TOKEN } from '~/access_tokens/constants'; import { __, sprintf } from '~/locale'; @@ -28,26 +26,7 @@ describe('access tokens', () => { 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; + const initialActiveAccessTokens = [{ revoked_path: '1' }]; it('mounts the component and provides required values', () => { setHTMLFixture( @@ -60,19 +39,18 @@ describe('access tokens', () => { ); const vueInstance = initAccessTokenTableApp(); - wrapper = createWrapper(vueInstance); - const component = wrapper.findComponent(FakeAccessTokenTableApp); + const component = wrapper.findComponent({ name: 'AccessTokenTableRoot' }); expect(component.exists()).toBe(true); - - expect(component.props()).toMatchObject({ + expect(wrapper.findComponent(AccessTokenTableApp).vm).toMatchObject({ // Required value accessTokenType, accessTokenTypePlural, initialActiveAccessTokens, // Default values + information: undefined, noActiveTokensMessage: sprintf(__('This user has no active %{accessTokenTypePlural}.'), { accessTokenTypePlural, }), @@ -81,12 +59,14 @@ describe('access tokens', () => { }); it('mounts the component and provides all values', () => { + const information = 'Additional information'; 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-information="${information}" data-no-active-tokens-message="${noActiveTokensMessage}" data-show-role > @@ -94,15 +74,15 @@ describe('access tokens', () => { ); const vueInstance = initAccessTokenTableApp(); - wrapper = createWrapper(vueInstance); - const component = wrapper.findComponent(FakeAccessTokenTableApp); + const component = wrapper.findComponent({ name: 'AccessTokenTableRoot' }); expect(component.exists()).toBe(true); - expect(component.props()).toMatchObject({ + expect(component.findComponent(AccessTokenTableApp).vm).toMatchObject({ accessTokenType, accessTokenTypePlural, initialActiveAccessTokens, + information, noActiveTokensMessage, showRole: true, }); @@ -157,23 +137,16 @@ describe('access tokens', () => { 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>`, + `<div id="js-new-access-token-app" data-access-token-type="${accessTokenType}"></div> + <form id="${FORM_SELECTOR.slice(1)}"></form>`, ); - 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); + const component = wrapper.findComponent({ name: 'NewAccessTokenRoot' }); expect(component.exists()).toBe(true); - expect(component.props('accessTokenType')).toEqual(accessTokenType); + expect(component.findComponent(NewAccessTokenApp).vm).toMatchObject({ accessTokenType }); }); it('returns `null`', () => { @@ -192,20 +165,12 @@ describe('access tokens', () => { `<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); + const component = wrapper.findComponent(TokensApp); expect(component.exists()).toBe(true); - expect(component.props('tokenTypes')).toEqual(tokensData); + expect(component.vm).toMatchObject({ tokenTypes: tokensData }); }); it('returns `null`', () => { diff --git a/spec/frontend/admin/broadcast_messages/components/base_spec.js b/spec/frontend/admin/broadcast_messages/components/base_spec.js new file mode 100644 index 00000000000..020e1c1d7c1 --- /dev/null +++ b/spec/frontend/admin/broadcast_messages/components/base_spec.js @@ -0,0 +1,112 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlPagination } from '@gitlab/ui'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'helpers/test_constants'; +import waitForPromises from 'helpers/wait_for_promises'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; +import { createAlert } from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { redirectTo } from '~/lib/utils/url_utility'; +import BroadcastMessagesBase from '~/admin/broadcast_messages/components/base.vue'; +import MessagesTable from '~/admin/broadcast_messages/components/messages_table.vue'; +import { generateMockMessages, MOCK_MESSAGES } from '../mock_data'; + +jest.mock('~/flash'); +jest.mock('~/lib/utils/url_utility'); + +describe('BroadcastMessagesBase', () => { + let wrapper; + let axiosMock; + + useMockLocationHelper(); + + const findTable = () => wrapper.findComponent(MessagesTable); + const findPagination = () => wrapper.findComponent(GlPagination); + + function createComponent(props = {}) { + wrapper = shallowMount(BroadcastMessagesBase, { + propsData: { + page: 1, + messagesCount: MOCK_MESSAGES.length, + messages: MOCK_MESSAGES, + ...props, + }, + }); + } + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + axiosMock.restore(); + wrapper.destroy(); + }); + + it('renders the table and pagination when there are existing messages', () => { + createComponent(); + + expect(findTable().exists()).toBe(true); + expect(findPagination().exists()).toBe(true); + }); + + it('does not render the table when there are no visible messages', () => { + createComponent({ messages: [] }); + + expect(findTable().exists()).toBe(false); + expect(findPagination().exists()).toBe(true); + }); + + it('does not remove a deleted message if it was not in visibleMessages', async () => { + createComponent(); + + findTable().vm.$emit('delete-message', -1); + await waitForPromises(); + + expect(axiosMock.history.delete).toHaveLength(0); + expect(wrapper.vm.visibleMessages.length).toBe(MOCK_MESSAGES.length); + }); + + it('does not remove a deleted message if the request fails', async () => { + createComponent(); + const { id, delete_path } = MOCK_MESSAGES[0]; + axiosMock.onDelete(delete_path).replyOnce(500); + + findTable().vm.$emit('delete-message', id); + await waitForPromises(); + + expect(wrapper.vm.visibleMessages.find((m) => m.id === id)).not.toBeUndefined(); + expect(createAlert).toHaveBeenCalledWith( + expect.objectContaining({ + message: BroadcastMessagesBase.i18n.deleteError, + }), + ); + }); + + it('removes a deleted message from visibleMessages on success', async () => { + createComponent(); + const { id, delete_path } = MOCK_MESSAGES[0]; + axiosMock.onDelete(delete_path).replyOnce(200); + + findTable().vm.$emit('delete-message', id); + await waitForPromises(); + + expect(wrapper.vm.visibleMessages.find((m) => m.id === id)).toBeUndefined(); + expect(wrapper.vm.totalMessages).toBe(MOCK_MESSAGES.length - 1); + }); + + it('redirects to the first page when totalMessages changes from 21 to 20', async () => { + window.location.pathname = `${TEST_HOST}/admin/broadcast_messages`; + + const messages = generateMockMessages(21); + const { id, delete_path } = messages[0]; + createComponent({ messages, messagesCount: messages.length }); + + axiosMock.onDelete(delete_path).replyOnce(200); + + findTable().vm.$emit('delete-message', id); + await waitForPromises(); + + expect(redirectTo).toHaveBeenCalledWith(`${TEST_HOST}/admin/broadcast_messages?page=1`); + }); +}); diff --git a/spec/frontend/admin/broadcast_messages/components/messages_table_spec.js b/spec/frontend/admin/broadcast_messages/components/messages_table_spec.js new file mode 100644 index 00000000000..349fab03853 --- /dev/null +++ b/spec/frontend/admin/broadcast_messages/components/messages_table_spec.js @@ -0,0 +1,51 @@ +import { mount } from '@vue/test-utils'; +import MessagesTable from '~/admin/broadcast_messages/components/messages_table.vue'; +import { MOCK_MESSAGES } from '../mock_data'; + +describe('MessagesTable', () => { + let wrapper; + + const findRows = () => wrapper.findAll('[data-testid="message-row"]'); + const findTargetRoles = () => wrapper.find('[data-testid="target-roles-th"]'); + const findDeleteButton = (id) => wrapper.find(`[data-testid="delete-message-${id}"]`); + + function createComponent(props = {}, glFeatures = {}) { + wrapper = mount(MessagesTable, { + provide: { + glFeatures, + }, + propsData: { + messages: MOCK_MESSAGES, + ...props, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a table row for each message', () => { + createComponent(); + + expect(findRows()).toHaveLength(MOCK_MESSAGES.length); + }); + + it('renders the "Target Roles" column when roleTargetedBroadcastMessages is enabled', () => { + createComponent({}, { roleTargetedBroadcastMessages: true }); + expect(findTargetRoles().exists()).toBe(true); + }); + + it('does not render the "Target Roles" column when roleTargetedBroadcastMessages is disabled', () => { + createComponent(); + expect(findTargetRoles().exists()).toBe(false); + }); + + it('emits a delete-message event when a delete button is clicked', () => { + const { id } = MOCK_MESSAGES[0]; + createComponent(); + findDeleteButton(id).element.click(); + expect(wrapper.emitted('delete-message')).toHaveLength(1); + expect(wrapper.emitted('delete-message')[0]).toEqual([id]); + }); +}); diff --git a/spec/frontend/admin/broadcast_messages/mock_data.js b/spec/frontend/admin/broadcast_messages/mock_data.js new file mode 100644 index 00000000000..8dd98c2319d --- /dev/null +++ b/spec/frontend/admin/broadcast_messages/mock_data.js @@ -0,0 +1,17 @@ +const generateMockMessage = (id) => ({ + id, + delete_path: `/admin/broadcast_messages/${id}.js`, + edit_path: `/admin/broadcast_messages/${id}/edit`, + starts_at: new Date().toISOString(), + ends_at: new Date().toISOString(), + preview: '<div>YEET</div>', + status: 'Expired', + target_path: '*/welcome', + target_roles: 'Maintainer, Owner', + type: 'Banner', +}); + +export const generateMockMessages = (n) => + [...Array(n).keys()].map((id) => generateMockMessage(id + 1)); + +export const MOCK_MESSAGES = generateMockMessages(5).map((id) => generateMockMessage(id)); diff --git a/spec/frontend/admin/deploy_keys/components/table_spec.js b/spec/frontend/admin/deploy_keys/components/table_spec.js index a18506c0916..4d4a2caedde 100644 --- a/spec/frontend/admin/deploy_keys/components/table_spec.js +++ b/spec/frontend/admin/deploy_keys/components/table_spec.js @@ -9,7 +9,7 @@ import { stubComponent } from 'helpers/stub_component'; import DeployKeysTable from '~/admin/deploy_keys/components/table.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import Api, { DEFAULT_PER_PAGE } from '~/api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; jest.mock('~/api'); jest.mock('~/flash'); @@ -243,7 +243,7 @@ describe('DeployKeysTable', () => { itRendersTheEmptyState(); it('displays flash', () => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: DeployKeysTable.i18n.apiErrorMessage, captureError: true, error, diff --git a/spec/frontend/admin/users/components/users_table_spec.js b/spec/frontend/admin/users/components/users_table_spec.js index fe07f0fce00..a0aec347b6b 100644 --- a/spec/frontend/admin/users/components/users_table_spec.js +++ b/spec/frontend/admin/users/components/users_table_spec.js @@ -10,7 +10,7 @@ import AdminUserActions from '~/admin/users/components/user_actions.vue'; import AdminUserAvatar from '~/admin/users/components/user_avatar.vue'; import AdminUsersTable from '~/admin/users/components/users_table.vue'; import getUsersGroupCountsQuery from '~/admin/users/graphql/queries/get_users_group_counts.query.graphql'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import AdminUserDate from '~/vue_shared/components/user_date.vue'; import { users, paths, createGroupCountResponse } from '../mock_data'; @@ -135,7 +135,7 @@ describe('AdminUsersTable component', () => { }); it('creates a flash message and captures the error', () => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'Could not load user group counts. Please refresh the page to try again.', captureError: true, error: expect.any(Error), diff --git a/spec/frontend/alert_management/components/alert_management_table_spec.js b/spec/frontend/alert_management/components/alert_management_table_spec.js index 3e1438c37d6..7fb4f2d2463 100644 --- a/spec/frontend/alert_management/components/alert_management_table_spec.js +++ b/spec/frontend/alert_management/components/alert_management_table_spec.js @@ -1,4 +1,4 @@ -import { GlTable, GlAlert, GlLoadingIcon, GlDropdown, GlIcon, GlAvatar } from '@gitlab/ui'; +import { GlTable, GlAlert, GlLoadingIcon, GlDropdown, GlIcon, GlAvatar, GlLink } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; @@ -31,6 +31,7 @@ describe('AlertManagementTable', () => { const findSearch = () => wrapper.findComponent(FilteredSearchBar); const findSeverityColumnHeader = () => wrapper.findByTestId('alert-management-severity-sort'); const findFirstIDField = () => wrapper.findAllByTestId('idField').at(0); + const findFirstIDLink = () => wrapper.findAllByTestId('idField').at(0).findComponent(GlLink); const findAssignees = () => wrapper.findAllByTestId('assigneesField'); const findSeverityFields = () => wrapper.findAllByTestId('severityField'); const findIssueFields = () => wrapper.findAllByTestId('issueField'); @@ -135,10 +136,11 @@ describe('AlertManagementTable', () => { expect(findLoader().exists()).toBe(false); expect(findAlertsTable().exists()).toBe(true); expect(findAlerts()).toHaveLength(mockAlerts.length); - expect(findAlerts().at(0).classes()).toContain('gl-hover-bg-blue-50'); + expect(findAlerts().at(0).classes()).toContain('gl-hover-bg-gray-50'); + expect(findAlerts().at(0).classes()).not.toContain('gl-hover-border-blue-200'); }); - it('displays the alert ID and title formatted correctly', () => { + it('displays the alert ID and title as a link', () => { mountComponent({ data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, loading: false, @@ -146,6 +148,8 @@ describe('AlertManagementTable', () => { expect(findFirstIDField().exists()).toBe(true); expect(findFirstIDField().text()).toBe(`#${mockAlerts[0].iid} ${mockAlerts[0].title}`); + expect(findFirstIDLink().text()).toBe(`#${mockAlerts[0].iid} ${mockAlerts[0].title}`); + expect(findFirstIDLink().attributes('href')).toBe('/1527542/details'); }); it('displays status dropdown', () => { @@ -266,7 +270,8 @@ describe('AlertManagementTable', () => { alerts: { list: [ { - iid: 1, + iid: '1', + title: 'SyntaxError: Invalid or unexpected token', status: 'acknowledged', startedAt: '2020-03-17T23:18:14.996Z', severity: 'high', diff --git a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js index fb9e97e7505..e0075aa71d9 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js @@ -304,12 +304,12 @@ describe('AlertsSettingsForm', () => { }); describe.each` - payload | resetPayloadAndMappingConfirmed | disabled - ${validSamplePayload} | ${true} | ${undefined} - ${emptySamplePayload} | ${true} | ${undefined} - ${validSamplePayload} | ${false} | ${'disabled'} - ${emptySamplePayload} | ${false} | ${undefined} - `('', ({ payload, resetPayloadAndMappingConfirmed, disabled }) => { + context | payload | resetPayloadAndMappingConfirmed | disabled + ${'valid payload, confirmed and enabled'} | ${validSamplePayload} | ${true} | ${undefined} + ${'empty payload, confirmed and enabled'} | ${emptySamplePayload} | ${true} | ${undefined} + ${'valid payload, unconfirmed and disabled'} | ${validSamplePayload} | ${false} | ${'disabled'} + ${'empty payload, unconfirmed and enabled'} | ${emptySamplePayload} | ${false} | ${undefined} + `('given $context', ({ payload, resetPayloadAndMappingConfirmed, disabled }) => { const payloadResetMsg = resetPayloadAndMappingConfirmed ? 'was confirmed' : 'was not confirmed'; @@ -333,12 +333,12 @@ describe('AlertsSettingsForm', () => { describe('action buttons for sample payload', () => { describe.each` - resetPayloadAndMappingConfirmed | payloadExample | caption - ${false} | ${validSamplePayload} | ${'Edit payload'} - ${true} | ${emptySamplePayload} | ${'Parse payload fields'} - ${true} | ${validSamplePayload} | ${'Parse payload fields'} - ${false} | ${emptySamplePayload} | ${'Parse payload fields'} - `('', ({ resetPayloadAndMappingConfirmed, payloadExample, caption }) => { + context | resetPayloadAndMappingConfirmed | payloadExample | caption + ${'valid payload, unconfirmed'} | ${false} | ${validSamplePayload} | ${'Edit payload'} + ${'empty payload, confirmed'} | ${true} | ${emptySamplePayload} | ${'Parse payload fields'} + ${'valid payload, confirmed'} | ${true} | ${validSamplePayload} | ${'Parse payload fields'} + ${'empty payload, unconfirmed'} | ${false} | ${emptySamplePayload} | ${'Parse payload fields'} + `('given $context', ({ resetPayloadAndMappingConfirmed, payloadExample, caption }) => { const samplePayloadMsg = payloadExample ? 'was provided' : 'was not provided'; const payloadResetMsg = resetPayloadAndMappingConfirmed ? 'was confirmed' @@ -402,24 +402,27 @@ describe('AlertsSettingsForm', () => { ${true} | ${true} | ${2} | ${false} ${true} | ${false} | ${1} | ${false} ${false} | ${true} | ${1} | ${false} - `('', ({ alertFieldsProvided, multiIntegrations, integrationOption, visible }) => { - const visibleMsg = visible ? 'rendered' : 'not rendered'; - const alertFieldsMsg = alertFieldsProvided ? 'provided' : 'not provided'; - const integrationType = integrationOption === 1 ? typeSet.http : typeSet.prometheus; - const multiIntegrationsEnabled = multiIntegrations ? 'enabled' : 'not enabled'; + `( + 'given alertFieldsProvided: $alertFieldsProvided, multiIntegrations: $multiIntegrations, integrationOption: $integrationOption, visible: $visible', + ({ alertFieldsProvided, multiIntegrations, integrationOption, visible }) => { + const visibleMsg = visible ? 'rendered' : 'not rendered'; + const alertFieldsMsg = alertFieldsProvided ? 'provided' : 'not provided'; + const integrationType = integrationOption === 1 ? typeSet.http : typeSet.prometheus; + const multiIntegrationsEnabled = multiIntegrations ? 'enabled' : 'not enabled'; + + it(`is ${visibleMsg} when multiIntegrations are ${multiIntegrationsEnabled}, integration type is ${integrationType} and alert fields are ${alertFieldsMsg}`, async () => { + createComponent({ + multiIntegrations, + props: { + alertFields: alertFieldsProvided ? alertFields : [], + }, + }); + await selectOptionAtIndex(integrationOption); - it(`is ${visibleMsg} when multiIntegrations are ${multiIntegrationsEnabled}, integration type is ${integrationType} and alert fields are ${alertFieldsMsg}`, async () => { - createComponent({ - multiIntegrations, - props: { - alertFields: alertFieldsProvided ? alertFields : [], - }, + expect(findMappingBuilder().exists()).toBe(visible); }); - await selectOptionAtIndex(integrationOption); - - expect(findMappingBuilder().exists()).toBe(visible); - }); - }); + }, + ); }); describe('Form validation', () => { diff --git a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js index 0266adeb6c7..fcefcb7cf66 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js @@ -30,7 +30,7 @@ import { INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR, DELETE_INTEGRATION_ERROR, } from '~/alerts_settings/utils/error_messages'; -import createFlash, { FLASH_TYPES } from '~/flash'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import httpStatusCodes from '~/lib/utils/http_status'; import { @@ -327,7 +327,7 @@ describe('AlertsSettingsWrapper', () => { await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ message: ADD_INTEGRATION_ERROR }); + expect(createAlert).toHaveBeenCalledWith({ message: ADD_INTEGRATION_ERROR }); }); it('shows an error alert when integration token reset fails', async () => { @@ -336,7 +336,7 @@ describe('AlertsSettingsWrapper', () => { findAlertsSettingsForm().vm.$emit('reset-token', {}); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ message: RESET_INTEGRATION_TOKEN_ERROR }); + expect(createAlert).toHaveBeenCalledWith({ message: RESET_INTEGRATION_TOKEN_ERROR }); }); it('shows an error alert when integration update fails', async () => { @@ -345,7 +345,7 @@ describe('AlertsSettingsWrapper', () => { findAlertsSettingsForm().vm.$emit('update-integration', {}); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ message: UPDATE_INTEGRATION_ERROR }); + expect(createAlert).toHaveBeenCalledWith({ message: UPDATE_INTEGRATION_ERROR }); }); describe('Test alert failure', () => { @@ -360,17 +360,17 @@ describe('AlertsSettingsWrapper', () => { it('shows an error alert when integration test payload is invalid', async () => { mock.onPost(/(.*)/).replyOnce(httpStatusCodes.UNPROCESSABLE_ENTITY); await wrapper.vm.testAlertPayload({ endpoint: '', data: '', token: '' }); - expect(createFlash).toHaveBeenCalledWith({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); + expect(createAlert).toHaveBeenCalledTimes(1); }); it('shows an error alert when integration is not activated', async () => { mock.onPost(/(.*)/).replyOnce(httpStatusCodes.FORBIDDEN); await wrapper.vm.testAlertPayload({ endpoint: '', data: '', token: '' }); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR, }); - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); }); }); @@ -444,9 +444,9 @@ describe('AlertsSettingsWrapper', () => { jest.spyOn(alertsUpdateService, 'updateTestAlert').mockResolvedValueOnce({}); findAlertsSettingsForm().vm.$emit('test-alert-payload', ''); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: i18n.alertSent, - type: FLASH_TYPES.SUCCESS, + variant: VARIANT_SUCCESS, }); }); @@ -454,7 +454,7 @@ describe('AlertsSettingsWrapper', () => { jest.spyOn(alertsUpdateService, 'updateTestAlert').mockRejectedValueOnce({}); findAlertsSettingsForm().vm.$emit('test-alert-payload', ''); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: INTEGRATION_PAYLOAD_TEST_ERROR, }); }); @@ -486,7 +486,7 @@ describe('AlertsSettingsWrapper', () => { await destroyHttpIntegration(wrapper); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ message: 'Houston, we have a problem' }); + expect(createAlert).toHaveBeenCalledWith({ message: 'Houston, we have a problem' }); }); it('displays flash if mutation had a non-recoverable error', async () => { @@ -497,7 +497,7 @@ describe('AlertsSettingsWrapper', () => { await destroyHttpIntegration(wrapper); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: DELETE_INTEGRATION_ERROR, }); }); diff --git a/spec/frontend/api/projects_api_spec.js b/spec/frontend/api/projects_api_spec.js index 8f40b557e1f..8459021421f 100644 --- a/spec/frontend/api/projects_api_spec.js +++ b/spec/frontend/api/projects_api_spec.js @@ -1,5 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; +import getTransferLocationsResponse from 'test_fixtures/api/projects/transfer_locations_page_1.json'; import * as projectsApi from '~/api/projects_api'; +import { DEFAULT_PER_PAGE } from '~/api'; import axios from '~/lib/utils/axios_utils'; describe('~/api/projects_api.js', () => { @@ -59,4 +61,25 @@ describe('~/api/projects_api.js', () => { }); }); }); + + describe('getTransferLocations', () => { + beforeEach(() => { + jest.spyOn(axios, 'get'); + }); + + it('retrieves transfer locations from the correct URL and returns them in the response data', async () => { + const params = { page: 1 }; + const expectedUrl = '/api/v7/projects/1/transfer_locations'; + + mock.onGet(expectedUrl).replyOnce(200, { data: getTransferLocationsResponse }); + + await expect(projectsApi.getTransferLocations(projectId, params)).resolves.toMatchObject({ + data: { data: getTransferLocationsResponse }, + }); + + expect(axios.get).toHaveBeenCalledWith(expectedUrl, { + params: { ...params, per_page: DEFAULT_PER_PAGE }, + }); + }); + }); }); diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js index b14bc5122b9..1a54b9909ba 100644 --- a/spec/frontend/awards_handler_spec.js +++ b/spec/frontend/awards_handler_spec.js @@ -185,7 +185,9 @@ describe('AwardsHandler', () => { describe('::getAwardUrl', () => { it('returns the url for request', () => { - expect(awardsHandler.getAwardUrl()).toBe('http://test.host/-/snippets/1/toggle_award_emoji'); + expect(awardsHandler.getAwardUrl()).toBe( + document.querySelector('.js-awards-block').dataset.awardUrl, + ); }); }); diff --git a/spec/frontend/badges/components/badge_form_spec.js b/spec/frontend/badges/components/badge_form_spec.js index 6d8a00eb50b..0a736df7075 100644 --- a/spec/frontend/badges/components/badge_form_spec.js +++ b/spec/frontend/badges/components/badge_form_spec.js @@ -1,195 +1,183 @@ import MockAdapter from 'axios-mock-adapter'; -import Vue, { nextTick } from 'vue'; -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { mount } from '@vue/test-utils'; import { DUMMY_IMAGE_URL, TEST_HOST } from 'helpers/test_constants'; -import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; import BadgeForm from '~/badges/components/badge_form.vue'; import createEmptyBadge from '~/badges/empty_badge'; -import store from '~/badges/store'; + +import createState from '~/badges/store/state'; +import mutations from '~/badges/store/mutations'; +import actions from '~/badges/store/actions'; + import axios from '~/lib/utils/axios_utils'; -// avoid preview background process -BadgeForm.methods.debouncedPreview = () => {}; +Vue.use(Vuex); describe('BadgeForm component', () => { - const Component = Vue.extend(BadgeForm); let axiosMock; - let vm; + let mockedActions; + let wrapper; + + const createComponent = (propsData, customState = {}) => { + mockedActions = Object.fromEntries(Object.keys(actions).map((name) => [name, jest.fn()])); + + const store = new Vuex.Store({ + state: { + ...createState(), + ...customState, + }, + mutations, + actions: mockedActions, + }); - beforeEach(() => { - setHTMLFixture(` - <div id="dummy-element"></div> - `); + wrapper = mount(BadgeForm, { + store, + propsData, + attachTo: document.body, + }); + }; + beforeEach(() => { axiosMock = new MockAdapter(axios); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); axiosMock.restore(); - resetHTMLFixture(); }); - describe('methods', () => { - beforeEach(() => { - vm = mountComponentWithStore(Component, { - el: '#dummy-element', - store, - props: { - isEditing: false, - }, - }); - }); + it('stops editing when cancel button is clicked', async () => { + createComponent({ isEditing: true }); - describe('onCancel', () => { - it('calls stopEditing', () => { - jest.spyOn(vm, 'stopEditing').mockImplementation(() => {}); + const cancelButton = wrapper.find('.row-content-block button'); - vm.onCancel(); + await cancelButton.trigger('click'); - expect(vm.stopEditing).toHaveBeenCalled(); - }); - }); + expect(mockedActions.stopEditing).toHaveBeenCalled(); }); - const sharedSubmitTests = (submitAction) => { + const sharedSubmitTests = (submitAction, props) => { const nameSelector = '#badge-name'; const imageUrlSelector = '#badge-image-url'; - const findImageUrlElement = () => vm.$el.querySelector(imageUrlSelector); + const findImageUrl = () => wrapper.find(imageUrlSelector); const linkUrlSelector = '#badge-link-url'; - const findLinkUrlElement = () => vm.$el.querySelector(linkUrlSelector); + const findLinkUrl = () => wrapper.find(linkUrlSelector); const setValue = (inputElementSelector, value) => { - const inputElement = vm.$el.querySelector(inputElementSelector); - inputElement.value = value; - inputElement.dispatchEvent(new Event('input')); + const input = wrapper.find(inputElementSelector); + return input.setValue(value); }; const submitForm = () => { - const submitButton = vm.$el.querySelector('button[type="submit"]'); - submitButton.click(); + const submitButton = wrapper.find('button[type="submit"]'); + return submitButton.trigger('click'); }; const expectInvalidInput = (inputElementSelector) => { - const inputElement = vm.$el.querySelector(inputElementSelector); + const input = wrapper.find(inputElementSelector); - expect(inputElement.checkValidity()).toBe(false); - const feedbackElement = vm.$el.querySelector(`${inputElementSelector} + .invalid-feedback`); + expect(input.element.checkValidity()).toBe(false); + const feedbackElement = wrapper.find(`${inputElementSelector} + .invalid-feedback`); - expect(feedbackElement).toBeVisible(); + expect(feedbackElement.isVisible()).toBe(true); }; - beforeEach(async () => { - jest.spyOn(vm, submitAction).mockReturnValue(Promise.resolve()); - store.replaceState({ - ...store.state, + beforeEach(() => { + createComponent(props, { badgeInAddForm: createEmptyBadge(), badgeInEditForm: createEmptyBadge(), isSaving: false, }); - await nextTick(); setValue(nameSelector, 'TestBadge'); setValue(linkUrlSelector, `${TEST_HOST}/link/url`); setValue(imageUrlSelector, `${window.location.origin}${DUMMY_IMAGE_URL}`); }); - it('returns immediately if imageUrl is empty', () => { - setValue(imageUrlSelector, ''); + it('returns immediately if imageUrl is empty', async () => { + await setValue(imageUrlSelector, ''); - submitForm(); + await submitForm(); expectInvalidInput(imageUrlSelector); - expect(vm[submitAction]).not.toHaveBeenCalled(); + expect(mockedActions[submitAction]).not.toHaveBeenCalled(); }); - it('returns immediately if imageUrl is malformed', () => { - setValue(imageUrlSelector, 'not-a-url'); + it('returns immediately if imageUrl is malformed', async () => { + await setValue(imageUrlSelector, 'not-a-url'); - submitForm(); + await submitForm(); expectInvalidInput(imageUrlSelector); - expect(vm[submitAction]).not.toHaveBeenCalled(); + expect(mockedActions[submitAction]).not.toHaveBeenCalled(); }); - it('returns immediately if linkUrl is empty', () => { - setValue(linkUrlSelector, ''); + it('returns immediately if linkUrl is empty', async () => { + await setValue(linkUrlSelector, ''); - submitForm(); + await submitForm(); expectInvalidInput(linkUrlSelector); - expect(vm[submitAction]).not.toHaveBeenCalled(); + expect(mockedActions[submitAction]).not.toHaveBeenCalled(); }); - it('returns immediately if linkUrl is malformed', () => { - setValue(linkUrlSelector, 'not-a-url'); + it('returns immediately if linkUrl is malformed', async () => { + await setValue(linkUrlSelector, 'not-a-url'); - submitForm(); + await submitForm(); expectInvalidInput(linkUrlSelector); - expect(vm[submitAction]).not.toHaveBeenCalled(); + expect(mockedActions[submitAction]).not.toHaveBeenCalled(); }); - it(`calls ${submitAction}`, () => { - submitForm(); + it(`calls ${submitAction}`, async () => { + await submitForm(); - expect(findImageUrlElement().checkValidity()).toBe(true); - expect(findLinkUrlElement().checkValidity()).toBe(true); - expect(vm[submitAction]).toHaveBeenCalled(); + expect(findImageUrl().element.checkValidity()).toBe(true); + expect(findLinkUrl().element.checkValidity()).toBe(true); + expect(mockedActions[submitAction]).toHaveBeenCalled(); }); }; describe('if isEditing is false', () => { - beforeEach(() => { - vm = mountComponentWithStore(Component, { - el: '#dummy-element', - store, - props: { - isEditing: false, - }, - }); - }); + const props = { isEditing: false }; it('renders one button', () => { - expect(vm.$el.querySelector('.row-content-block')).toBeNull(); - const buttons = vm.$el.querySelectorAll('.form-group:last-of-type button'); + createComponent(props); + + expect(wrapper.find('.row-content-block').exists()).toBe(false); + const buttons = wrapper.findAll('.form-group:last-of-type button'); - expect(buttons.length).toBe(1); - const buttonAddElement = buttons[0]; + expect(buttons).toHaveLength(1); + const buttonAddWrapper = buttons.at(0); - expect(buttonAddElement).toBeVisible(); - expect(buttonAddElement).toHaveText('Add badge'); + expect(buttonAddWrapper.isVisible()).toBe(true); + expect(buttonAddWrapper.text()).toBe('Add badge'); }); - sharedSubmitTests('addBadge'); + sharedSubmitTests('addBadge', props); }); describe('if isEditing is true', () => { - beforeEach(() => { - vm = mountComponentWithStore(Component, { - el: '#dummy-element', - store, - props: { - isEditing: true, - }, - }); - }); + const props = { isEditing: true }; it('renders two buttons', () => { - const buttons = vm.$el.querySelectorAll('.row-content-block button'); + createComponent(props); + const buttons = wrapper.findAll('.row-content-block button'); - expect(buttons.length).toBe(2); - const buttonSaveElement = buttons[1]; + expect(buttons).toHaveLength(2); - expect(buttonSaveElement).toBeVisible(); - expect(buttonSaveElement).toHaveText('Save changes'); - const buttonCancelElement = buttons[0]; + const saveButton = buttons.at(1); + expect(saveButton.isVisible()).toBe(true); + expect(saveButton.text()).toBe('Save changes'); - expect(buttonCancelElement).toBeVisible(); - expect(buttonCancelElement).toHaveText('Cancel'); + const cancelButton = buttons.at(0); + expect(cancelButton.isVisible()).toBe(true); + expect(cancelButton.text()).toBe('Cancel'); }); - sharedSubmitTests('saveBadge'); + sharedSubmitTests('saveBadge', props); }); }); diff --git a/spec/frontend/badges/components/badge_list_row_spec.js b/spec/frontend/badges/components/badge_list_row_spec.js index ad8426f3168..ee7ccac974a 100644 --- a/spec/frontend/badges/components/badge_list_row_spec.js +++ b/spec/frontend/badges/components/badge_list_row_spec.js @@ -1,103 +1,118 @@ -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { mount } from '@vue/test-utils'; + import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; import BadgeListRow from '~/badges/components/badge_list_row.vue'; import { GROUP_BADGE, PROJECT_BADGE } from '~/badges/constants'; -import store from '~/badges/store'; + +import createState from '~/badges/store/state'; +import mutations from '~/badges/store/mutations'; +import actions from '~/badges/store/actions'; + import { createDummyBadge } from '../dummy_badge'; +Vue.use(Vuex); + describe('BadgeListRow component', () => { - const Component = Vue.extend(BadgeListRow); let badge; - let vm; - - beforeEach(() => { - setHTMLFixture(` - <div id="delete-badge-modal" class="modal"></div> - <div id="dummy-element"></div> - `); - store.replaceState({ - ...store.state, - kind: PROJECT_BADGE, + let wrapper; + let mockedActions; + + const createComponent = (kind) => { + setHTMLFixture(`<div id="delete-badge-modal" class="modal"></div>`); + + mockedActions = Object.fromEntries(Object.keys(actions).map((name) => [name, jest.fn()])); + + const store = new Vuex.Store({ + state: { + ...createState(), + kind: PROJECT_BADGE, + }, + mutations, + actions: mockedActions, }); + badge = createDummyBadge(); - vm = mountComponentWithStore(Component, { - el: '#dummy-element', + badge.kind = kind; + wrapper = mount(BadgeListRow, { + attachTo: document.body, store, - props: { badge }, + propsData: { badge }, }); - }); + }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); resetHTMLFixture(); }); - it('renders the badge', () => { - const badgeElement = vm.$el.querySelector('.project-badge'); + describe('for a project badge', () => { + beforeEach(() => { + createComponent(PROJECT_BADGE); + }); - expect(badgeElement).not.toBeNull(); - expect(badgeElement.getAttribute('src')).toBe(badge.renderedImageUrl); - }); + it('renders the badge', () => { + const badgeImage = wrapper.find('.project-badge'); - it('renders the badge name', () => { - expect(vm.$el.innerText).toMatch(badge.name); - }); + expect(badgeImage.exists()).toBe(true); + expect(badgeImage.attributes('src')).toBe(badge.renderedImageUrl); + }); - it('renders the badge link', () => { - expect(vm.$el.innerText).toMatch(badge.linkUrl); - }); + it('renders the badge name', () => { + expect(wrapper.text()).toMatch(badge.name); + }); - it('renders the badge kind', () => { - expect(vm.$el.innerText).toMatch('Project Badge'); - }); + it('renders the badge link', () => { + expect(wrapper.text()).toMatch(badge.linkUrl); + }); - it('shows edit and delete buttons', () => { - const buttons = vm.$el.querySelectorAll('.table-button-footer button'); + it('renders the badge kind', () => { + expect(wrapper.text()).toMatch('Project Badge'); + }); - expect(buttons).toHaveLength(2); - const buttonEditElement = buttons[0]; + it('shows edit and delete buttons', () => { + const buttons = wrapper.findAll('.table-button-footer button'); - expect(buttonEditElement).toBeVisible(); - expect(buttonEditElement).toHaveSpriteIcon('pencil'); - const buttonDeleteElement = buttons[1]; + expect(buttons).toHaveLength(2); + const editButton = buttons.at(0); - expect(buttonDeleteElement).toBeVisible(); - expect(buttonDeleteElement).toHaveSpriteIcon('remove'); - }); + expect(editButton.isVisible()).toBe(true); + expect(editButton.element).toHaveSpriteIcon('pencil'); - it('calls editBadge when clicking then edit button', () => { - jest.spyOn(vm, 'editBadge').mockImplementation(() => {}); + const deleteButton = buttons.at(1); + expect(deleteButton.isVisible()).toBe(true); + expect(deleteButton.element).toHaveSpriteIcon('remove'); + }); - const editButton = vm.$el.querySelector('.table-button-footer button:first-of-type'); - editButton.click(); + it('calls editBadge when clicking then edit button', async () => { + const editButton = wrapper.find('.table-button-footer button:first-of-type'); - expect(vm.editBadge).toHaveBeenCalled(); - }); + await editButton.trigger('click'); + + expect(mockedActions.editBadge).toHaveBeenCalled(); + }); - it('calls updateBadgeInModal and shows modal when clicking then delete button', async () => { - jest.spyOn(vm, 'updateBadgeInModal').mockImplementation(() => {}); + it('calls updateBadgeInModal and shows modal when clicking then delete button', async () => { + const deleteButton = wrapper.find('.table-button-footer button:last-of-type'); - const deleteButton = vm.$el.querySelector('.table-button-footer button:last-of-type'); - deleteButton.click(); + await deleteButton.trigger('click'); - await nextTick(); - expect(vm.updateBadgeInModal).toHaveBeenCalled(); + expect(mockedActions.updateBadgeInModal).toHaveBeenCalled(); + }); }); describe('for a group badge', () => { - beforeEach(async () => { - badge.kind = GROUP_BADGE; - - await nextTick(); + beforeEach(() => { + createComponent(GROUP_BADGE); }); it('renders the badge kind', () => { - expect(vm.$el.innerText).toMatch('Group Badge'); + expect(wrapper.text()).toMatch('Group Badge'); }); it('hides edit and delete buttons', () => { - const buttons = vm.$el.querySelectorAll('.table-button-footer button'); + const buttons = wrapper.findAll('.table-button-footer button'); expect(buttons).toHaveLength(0); }); diff --git a/spec/frontend/badges/components/badge_list_spec.js b/spec/frontend/badges/components/badge_list_spec.js index 32cd9483ef8..606b1bc9cce 100644 --- a/spec/frontend/badges/components/badge_list_spec.js +++ b/spec/frontend/badges/components/badge_list_spec.js @@ -1,83 +1,96 @@ -import Vue, { nextTick } from 'vue'; -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { mount } from '@vue/test-utils'; + import BadgeList from '~/badges/components/badge_list.vue'; import { GROUP_BADGE, PROJECT_BADGE } from '~/badges/constants'; -import store from '~/badges/store'; + +import createState from '~/badges/store/state'; +import mutations from '~/badges/store/mutations'; +import actions from '~/badges/store/actions'; + import { createDummyBadge } from '../dummy_badge'; -describe('BadgeList component', () => { - const Component = Vue.extend(BadgeList); - const numberOfDummyBadges = 3; - let vm; - - beforeEach(() => { - setHTMLFixture('<div id="dummy-element"></div>'); - const badges = []; - for (let id = 0; id < numberOfDummyBadges; id += 1) { - badges.push({ id, ...createDummyBadge() }); - } - store.replaceState({ - ...store.state, - badges, - kind: PROJECT_BADGE, - isLoading: false, - }); +Vue.use(Vuex); - // Can be removed once GlLoadingIcon no longer throws a warning - jest.spyOn(global.console, 'warn').mockImplementation(() => jest.fn()); +const numberOfDummyBadges = 3; +const badges = Array.from({ length: numberOfDummyBadges }).map((_, idx) => ({ + ...createDummyBadge(), + id: idx, +})); - vm = mountComponentWithStore(Component, { - el: '#dummy-element', - store, +describe('BadgeList component', () => { + let wrapper; + + const createComponent = (customState) => { + const mockedActions = Object.fromEntries(Object.keys(actions).map((name) => [name, jest.fn()])); + + const store = new Vuex.Store({ + state: { + ...createState(), + isLoading: false, + ...customState, + }, + mutations, + actions: mockedActions, }); - }); + + wrapper = mount(BadgeList, { store }); + }; afterEach(() => { - vm.$destroy(); - resetHTMLFixture(); + wrapper.destroy(); }); - it('renders a header with the badge count', () => { - const header = vm.$el.querySelector('.card-header'); + describe('for project badges', () => { + it('renders a header with the badge count', () => { + createComponent({ + kind: PROJECT_BADGE, + badges, + }); - expect(header).toHaveText(new RegExp(`Your badges\\s+${numberOfDummyBadges}`)); - }); + const header = wrapper.find('.card-header'); - it('renders a row for each badge', () => { - const rows = vm.$el.querySelectorAll('.gl-responsive-table-row'); + expect(header.text()).toMatchInterpolatedText('Your badges 3'); + }); - expect(rows).toHaveLength(numberOfDummyBadges); - }); + it('renders a row for each badge', () => { + createComponent({ + kind: PROJECT_BADGE, + badges, + }); - it('renders a message if no badges exist', async () => { - store.state.badges = []; + const rows = wrapper.findAll('.gl-responsive-table-row'); - await nextTick(); - expect(vm.$el.innerText).toMatch('This project has no badges'); - }); + expect(rows).toHaveLength(numberOfDummyBadges); + }); - it('shows a loading icon when loading', async () => { - store.state.isLoading = true; + it('renders a message if no badges exist', () => { + createComponent({ + kind: PROJECT_BADGE, + badges: [], + }); - await nextTick(); - const loadingIcon = vm.$el.querySelector('.gl-spinner'); + expect(wrapper.text()).toMatch('This project has no badges'); + }); - expect(loadingIcon).toBeVisible(); - }); + it('shows a loading icon when loading', () => { + createComponent({ isLoading: true }); - describe('for group badges', () => { - beforeEach(async () => { - store.state.kind = GROUP_BADGE; + const loadingIcon = wrapper.find('.gl-spinner'); - await nextTick(); + expect(loadingIcon.isVisible()).toBe(true); }); + }); - it('renders a message if no badges exist', async () => { - store.state.badges = []; + describe('for group badges', () => { + it('renders a message if no badges exist', () => { + createComponent({ + kind: GROUP_BADGE, + badges: [], + }); - await nextTick(); - expect(vm.$el.innerText).toMatch('This group has no badges'); + expect(wrapper.text()).toMatch('This group has no badges'); }); }); }); diff --git a/spec/frontend/badges/components/badge_spec.js b/spec/frontend/badges/components/badge_spec.js index 19b3a9f23a6..b468e38f19e 100644 --- a/spec/frontend/badges/components/badge_spec.js +++ b/spec/frontend/badges/components/badge_spec.js @@ -1,138 +1,78 @@ -import Vue, { nextTick } from 'vue'; -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; + import { DUMMY_IMAGE_URL, TEST_HOST } from 'spec/test_constants'; import Badge from '~/badges/components/badge.vue'; describe('Badge component', () => { - const Component = Vue.extend(Badge); const dummyProps = { imageUrl: DUMMY_IMAGE_URL, linkUrl: `${TEST_HOST}/badge/link/url`, }; - let vm; + let wrapper; const findElements = () => { - const buttons = vm.$el.querySelectorAll('button'); + const buttons = wrapper.findAll('button'); return { - badgeImage: vm.$el.querySelector('img.project-badge'), - loadingIcon: vm.$el.querySelector('.gl-spinner'), - reloadButton: buttons[buttons.length - 1], + badgeImage: wrapper.find('img.project-badge'), + loadingIcon: wrapper.find('.gl-spinner'), + reloadButton: buttons.at(buttons.length - 1), }; }; - const createComponent = (props, el = null) => { - vm = mountComponent(Component, props, el); - const { badgeImage } = findElements(); - return new Promise((resolve) => { - badgeImage.addEventListener('load', resolve); - // Manually dispatch load event as it is not triggered - badgeImage.dispatchEvent(new Event('load')); - }).then(() => nextTick()); + const createComponent = (propsData) => { + wrapper = mount(Badge, { propsData }); }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - describe('watchers', () => { - describe('imageUrl', () => { - it('sets isLoading and resets numRetries and hasError', async () => { - const props = { ...dummyProps }; - await createComponent(props); - expect(vm.isLoading).toBe(false); - vm.hasError = true; - vm.numRetries = 42; - - vm.imageUrl = `${props.imageUrl}#something/else`; - await nextTick(); - expect(vm.isLoading).toBe(true); - expect(vm.numRetries).toBe(0); - expect(vm.hasError).toBe(false); - }); - }); + beforeEach(() => { + return createComponent({ ...dummyProps }, '#dummy-element'); }); - describe('methods', () => { - beforeEach(async () => { - await createComponent({ ...dummyProps }); - }); + it('shows a badge image after loading', async () => { + const { badgeImage, loadingIcon, reloadButton } = findElements(); + badgeImage.element.dispatchEvent(new Event('load')); - it('onError resets isLoading and sets hasError', () => { - vm.hasError = false; - vm.isLoading = true; + await nextTick(); - vm.onError(); + expect(badgeImage.isVisible()).toBe(true); + expect(loadingIcon.isVisible()).toBe(false); + expect(reloadButton.isVisible()).toBe(false); + expect(wrapper.find('.btn-group').isVisible()).toBe(false); + }); - expect(vm.hasError).toBe(true); - expect(vm.isLoading).toBe(false); - }); + it('shows a loading icon when loading', () => { + const { badgeImage, loadingIcon, reloadButton } = findElements(); - it('onLoad sets isLoading', () => { - vm.isLoading = true; + expect(badgeImage.isVisible()).toBe(false); + expect(loadingIcon.isVisible()).toBe(true); + expect(reloadButton.isVisible()).toBe(false); + expect(wrapper.find('.btn-group').isVisible()).toBe(false); + }); - vm.onLoad(); + it('shows an error and reload button if loading failed', async () => { + const { badgeImage, loadingIcon, reloadButton } = findElements(); + badgeImage.element.dispatchEvent(new Event('error')); - expect(vm.isLoading).toBe(false); - }); + await nextTick(); - it('reloadImage resets isLoading and hasError and increases numRetries', () => { - vm.hasError = true; - vm.isLoading = false; - vm.numRetries = 0; + expect(badgeImage.isVisible()).toBe(false); + expect(loadingIcon.isVisible()).toBe(false); + expect(reloadButton.isVisible()).toBe(true); + expect(reloadButton.element).toHaveSpriteIcon('retry'); + expect(wrapper.text()).toBe('No badge image'); + }); - vm.reloadImage(); + it('retries an image when loading failed and reload button is clicked', async () => { + const { badgeImage, reloadButton } = findElements(); + badgeImage.element.dispatchEvent(new Event('error')); + await nextTick(); - expect(vm.hasError).toBe(false); - expect(vm.isLoading).toBe(true); - expect(vm.numRetries).toBe(1); - }); - }); + await reloadButton.trigger('click'); - describe('behavior', () => { - beforeEach(() => { - setHTMLFixture('<div id="dummy-element"></div>'); - return createComponent({ ...dummyProps }, '#dummy-element'); - }); - - afterEach(() => { - resetHTMLFixture(); - }); - - it('shows a badge image after loading', () => { - expect(vm.isLoading).toBe(false); - expect(vm.hasError).toBe(false); - const { badgeImage, loadingIcon, reloadButton } = findElements(); - - expect(badgeImage).toBeVisible(); - expect(loadingIcon).toBeHidden(); - expect(reloadButton).toBeHidden(); - expect(vm.$el.querySelector('.btn-group')).toBeHidden(); - }); - - it('shows a loading icon when loading', async () => { - vm.isLoading = true; - - await nextTick(); - const { badgeImage, loadingIcon, reloadButton } = findElements(); - - expect(badgeImage).toBeHidden(); - expect(loadingIcon).toBeVisible(); - expect(reloadButton).toBeHidden(); - expect(vm.$el.querySelector('.btn-group')).toBeHidden(); - }); - - it('shows an error and reload button if loading failed', async () => { - vm.hasError = true; - - await nextTick(); - const { badgeImage, loadingIcon, reloadButton } = findElements(); - - expect(badgeImage).toBeHidden(); - expect(loadingIcon).toBeHidden(); - expect(reloadButton).toBeVisible(); - expect(reloadButton).toHaveSpriteIcon('retry'); - expect(vm.$el.innerText.trim()).toBe('No badge image'); - }); + expect(badgeImage.attributes('src')).toBe(`${dummyProps.imageUrl}#retries=1`); }); }); diff --git a/spec/frontend/batch_comments/components/drafts_count_spec.js b/spec/frontend/batch_comments/components/drafts_count_spec.js index 390ef21929c..c3a7946c85c 100644 --- a/spec/frontend/batch_comments/components/drafts_count_spec.js +++ b/spec/frontend/batch_comments/components/drafts_count_spec.js @@ -1,40 +1,36 @@ -import Vue, { nextTick } from 'vue'; -import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; import DraftsCount from '~/batch_comments/components/drafts_count.vue'; import { createStore } from '~/batch_comments/stores'; describe('Batch comments drafts count component', () => { - let vm; - let Component; - - beforeAll(() => { - Component = Vue.extend(DraftsCount); - }); + let store; + let wrapper; beforeEach(() => { - const store = createStore(); + store = createStore(); store.state.batchComments.drafts.push('comment'); - vm = mountComponentWithStore(Component, { store }); + wrapper = mount(DraftsCount, { store }); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders count', () => { - expect(vm.$el.textContent).toContain('1'); + expect(wrapper.text()).toContain('1'); }); it('renders screen reader text', async () => { - const el = vm.$el.querySelector('.sr-only'); + const el = wrapper.find('.sr-only'); - expect(el.textContent).toContain('draft'); - - vm.$store.state.batchComments.drafts.push('comment 2'); + expect(el.text()).toContain('draft'); + store.state.batchComments.drafts.push('comment 2'); await nextTick(); - expect(el.textContent).toContain('drafts'); + + expect(el.text()).toContain('drafts'); }); }); diff --git a/spec/frontend/batch_comments/components/preview_item_spec.js b/spec/frontend/batch_comments/components/preview_item_spec.js index 91e6b84a216..6a104f0c787 100644 --- a/spec/frontend/batch_comments/components/preview_item_spec.js +++ b/spec/frontend/batch_comments/components/preview_item_spec.js @@ -1,5 +1,4 @@ -import Vue from 'vue'; -import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { mount } from '@vue/test-utils'; import PreviewItem from '~/batch_comments/components/preview_item.vue'; import { createStore } from '~/batch_comments/stores'; import diffsModule from '~/diffs/store/modules'; @@ -8,8 +7,7 @@ import '~/behaviors/markdown/render_gfm'; import { createDraft } from '../mock_data'; describe('Batch comments draft preview item component', () => { - let vm; - let Component; + let wrapper; let draft; function createComponent(isLast = false, extra = {}, extendStore = () => {}) { @@ -24,21 +22,17 @@ describe('Batch comments draft preview item component', () => { ...extra, }; - vm = mountComponentWithStore(Component, { store, props: { draft, isLast } }); + wrapper = mount(PreviewItem, { store, propsData: { draft, isLast } }); } - beforeAll(() => { - Component = Vue.extend(PreviewItem); - }); - afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders text content', () => { createComponent(false, { note_html: '<img src="" /><p>Hello world</p>' }); - expect(vm.$el.querySelector('.review-preview-item-content').innerHTML).toEqual( + expect(wrapper.find('.review-preview-item-content').element.innerHTML).toBe( '<p>Hello world</p>', ); }); @@ -47,9 +41,7 @@ describe('Batch comments draft preview item component', () => { it('renders file path', () => { createComponent(false, { file_path: 'index.js', file_hash: 'abc', position: {} }); - expect(vm.$el.querySelector('.review-preview-item-header-text').textContent).toContain( - 'index.js', - ); + expect(wrapper.find('.review-preview-item-header-text').text()).toContain('index.js'); }); it('renders new line position', () => { @@ -66,7 +58,7 @@ describe('Batch comments draft preview item component', () => { }, }); - expect(vm.$el.querySelector('.bold').textContent).toContain(':+1'); + expect(wrapper.find('.bold').text()).toContain(':+1'); }); it('renders old line position', () => { @@ -82,7 +74,7 @@ describe('Batch comments draft preview item component', () => { }, }); - expect(vm.$el.querySelector('.bold').textContent).toContain(':2'); + expect(wrapper.find('.bold').text()).toContain(':2'); }); it('renders image position', () => { @@ -92,7 +84,7 @@ describe('Batch comments draft preview item component', () => { position: { position_type: 'image', x: 10, y: 20 }, }); - expect(vm.$el.querySelector('.bold').textContent).toContain('10x 20y'); + expect(wrapper.find('.bold').text()).toContain('10x 20y'); }); }); @@ -113,15 +105,13 @@ describe('Batch comments draft preview item component', () => { }); it('renders title', () => { - expect(vm.$el.querySelector('.review-preview-item-header-text').textContent).toContain( + expect(wrapper.find('.review-preview-item-header-text').text()).toContain( "Author 'Nick' Name's thread", ); }); it('renders thread resolved text', () => { - expect(vm.$el.querySelector('.draft-note-resolution').textContent).toContain( - 'Thread will be resolved', - ); + expect(wrapper.find('.draft-note-resolution').text()).toContain('Thread will be resolved'); }); }); @@ -131,9 +121,7 @@ describe('Batch comments draft preview item component', () => { store.state.notes.discussions.push({}); }); - expect(vm.$el.querySelector('.review-preview-item-header-text').textContent).toContain( - 'Your new comment', - ); + expect(wrapper.find('.review-preview-item-header-text').text()).toContain('Your new comment'); }); }); }); diff --git a/spec/frontend/batch_comments/components/publish_button_spec.js b/spec/frontend/batch_comments/components/publish_button_spec.js index 9a782ec09b6..5e3fa3e9446 100644 --- a/spec/frontend/batch_comments/components/publish_button_spec.js +++ b/spec/frontend/batch_comments/components/publish_button_spec.js @@ -1,38 +1,34 @@ -import Vue, { nextTick } from 'vue'; -import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; import PublishButton from '~/batch_comments/components/publish_button.vue'; import { createStore } from '~/batch_comments/stores'; describe('Batch comments publish button component', () => { - let vm; - let Component; - - beforeAll(() => { - Component = Vue.extend(PublishButton); - }); + let wrapper; + let store; beforeEach(() => { - const store = createStore(); + store = createStore(); - vm = mountComponentWithStore(Component, { store, props: { shouldPublish: true } }); + wrapper = mount(PublishButton, { store, propsData: { shouldPublish: true } }); - jest.spyOn(vm.$store, 'dispatch').mockImplementation(); + jest.spyOn(store, 'dispatch').mockImplementation(); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - it('dispatches publishReview on click', () => { - vm.$el.click(); + it('dispatches publishReview on click', async () => { + await wrapper.trigger('click'); - expect(vm.$store.dispatch).toHaveBeenCalledWith('batchComments/publishReview', undefined); + expect(store.dispatch).toHaveBeenCalledWith('batchComments/publishReview', undefined); }); it('sets loading when isPublishing is true', async () => { - vm.$store.state.batchComments.isPublishing = true; + store.state.batchComments.isPublishing = true; await nextTick(); - expect(vm.$el.getAttribute('disabled')).toBe('disabled'); + expect(wrapper.attributes('disabled')).toBe('disabled'); }); }); diff --git a/spec/frontend/behaviors/bind_in_out_spec.js b/spec/frontend/behaviors/bind_in_out_spec.js index 4d958e30b4d..7b40b1d3cd7 100644 --- a/spec/frontend/behaviors/bind_in_out_spec.js +++ b/spec/frontend/behaviors/bind_in_out_spec.js @@ -1,4 +1,3 @@ -import ClassSpecHelper from 'helpers/class_spec_helper'; import BindInOut from '~/behaviors/bind_in_out'; describe('BindInOut', () => { @@ -142,7 +141,9 @@ describe('BindInOut', () => { testContext.initAll = BindInOut.initAll(); }); - ClassSpecHelper.itShouldBeAStaticMethod(BindInOut, 'initAll'); + it('should be a static method', () => { + expect(BindInOut.initAll).toEqual(expect.any(Function)); + }); it('should call .querySelectorAll', () => { expect(document.querySelectorAll).toHaveBeenCalledWith('*[data-bind-in]'); @@ -169,7 +170,9 @@ describe('BindInOut', () => { testContext.init = BindInOut.init({}, {}); }); - ClassSpecHelper.itShouldBeAStaticMethod(BindInOut, 'init'); + it('should be a static method', () => { + expect(BindInOut.init).toEqual(expect.any(Function)); + }); it('should call .addEvents', () => { expect(BindInOut.prototype.addEvents).toHaveBeenCalled(); diff --git a/spec/frontend/blame/blame_redirect_spec.js b/spec/frontend/blame/blame_redirect_spec.js new file mode 100644 index 00000000000..beb10139b3a --- /dev/null +++ b/spec/frontend/blame/blame_redirect_spec.js @@ -0,0 +1,70 @@ +import redirectToCorrectPage from '~/blame/blame_redirect'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { createAlert } from '~/flash'; + +jest.mock('~/flash'); + +describe('Blame page redirect', () => { + beforeEach(() => { + global.window = Object.create(window); + const url = 'https://gitlab.com/flightjs/Flight/-/blame/master/file.json'; + Object.defineProperty(window, 'location', { + writable: true, + value: { + href: url, + hash: '', + search: '', + }, + }); + + setHTMLFixture(`<div class="js-per-page" data-per-page="1000"></div>`); + }); + + afterEach(() => { + createAlert.mockClear(); + resetHTMLFixture(); + }); + + it('performs redirect to further pages when needed', () => { + window.location.hash = '#L1001'; + redirectToCorrectPage(); + expect(window.location.href).toMatch('?page=2'); + }); + + it('performs redirect back to first page when needed', () => { + window.location.href = 'https://gitlab.com/flightjs/Flight/-/blame/master/file.json'; + window.location.search = '?page=200'; + window.location.hash = '#L999'; + redirectToCorrectPage(); + expect(window.location.href).toMatch('?page=1'); + }); + + it('doesn`t perform redirect when the line is still on page 1', () => { + window.location.hash = '#L1000'; + redirectToCorrectPage(); + expect(window.location.href).not.toMatch('?page'); + }); + + it('doesn`t perform redirect when "no_pagination" param is present', () => { + window.location.href = 'https://gitlab.com/flightjs/Flight/-/blame/master/file.json'; + window.location.search = '?no_pagination=true'; + window.location.hash = '#L1001'; + redirectToCorrectPage(); + expect(window.location.href).not.toMatch('?page'); + }); + + it('doesn`t perform redirect when perPage is not present', () => { + setHTMLFixture(`<div class="js-per-page"></div>`); + window.location.hash = '#L1001'; + redirectToCorrectPage(); + expect(window.location.href).not.toMatch('?page'); + }); + + it('shows alert with a message', () => { + window.location.hash = '#L1001'; + redirectToCorrectPage(); + expect(createAlert).toHaveBeenCalledWith({ + message: 'Please wait a few moments while we load the file history for this line.', + }); + }); +}); diff --git a/spec/frontend/blob/3d_viewer/mesh_object_spec.js b/spec/frontend/blob/3d_viewer/mesh_object_spec.js index 3014af073f5..1b0fd362778 100644 --- a/spec/frontend/blob/3d_viewer/mesh_object_spec.js +++ b/spec/frontend/blob/3d_viewer/mesh_object_spec.js @@ -1,4 +1,4 @@ -import { BoxGeometry } from 'three/build/three.module'; +import { BoxGeometry } from 'three'; import MeshObject from '~/blob/3d_viewer/mesh_object'; describe('Mesh object', () => { diff --git a/spec/frontend/blob/blob_blame_link_spec.js b/spec/frontend/blob/blob_blame_link_spec.js index 0d19177a11f..060e8803520 100644 --- a/spec/frontend/blob/blob_blame_link_spec.js +++ b/spec/frontend/blob/blob_blame_link_spec.js @@ -29,19 +29,19 @@ describe('Blob links', () => { it('adds wrapper elements with correct classes', () => { const wrapper = document.querySelector('.line-links'); - expect(wrapper).toBeTruthy(); + expect(wrapper).not.toBeNull(); expect(wrapper.classList).toContain('diff-line-num'); }); it('adds blame link with correct classes and path', () => { const blameLink = document.querySelector('.file-line-blame'); - expect(blameLink).toBeTruthy(); + expect(blameLink).not.toBeNull(); expect(blameLink.getAttribute('href')).toBe('/blamePath#L5'); }); it('adds line link within wraper with correct classes and path', () => { const lineLink = document.querySelector('.file-line-num'); - expect(lineLink).toBeTruthy(); + expect(lineLink).not.toBeNull(); expect(lineLink.getAttribute('href')).toBe('#L5'); }); }); diff --git a/spec/frontend/blob/components/blob_content_spec.js b/spec/frontend/blob/components/blob_content_spec.js index 788ee0a86ab..f7b819b6e94 100644 --- a/spec/frontend/blob/components/blob_content_spec.js +++ b/spec/frontend/blob/components/blob_content_spec.js @@ -91,13 +91,13 @@ describe('Blob Content component', () => { it(`properly proxies ${BLOB_RENDER_EVENT_LOAD} event`, () => { expect(wrapper.emitted(BLOB_RENDER_EVENT_LOAD)).toBeUndefined(); findErrorEl().vm.$emit(BLOB_RENDER_EVENT_LOAD); - expect(wrapper.emitted(BLOB_RENDER_EVENT_LOAD)).toBeTruthy(); + expect(wrapper.emitted(BLOB_RENDER_EVENT_LOAD)).toHaveLength(1); }); it(`properly proxies ${BLOB_RENDER_EVENT_SHOW_SOURCE} event`, () => { expect(wrapper.emitted(BLOB_RENDER_EVENT_SHOW_SOURCE)).toBeUndefined(); findErrorEl().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE); - expect(wrapper.emitted(BLOB_RENDER_EVENT_SHOW_SOURCE)).toBeTruthy(); + expect(wrapper.emitted(BLOB_RENDER_EVENT_SHOW_SOURCE)).toHaveLength(1); }); }); }); diff --git a/spec/frontend/blob/components/table_contents_spec.js b/spec/frontend/blob/components/table_contents_spec.js index 2cbac809a0d..5fe328b65ff 100644 --- a/spec/frontend/blob/components/table_contents_spec.js +++ b/spec/frontend/blob/components/table_contents_spec.js @@ -71,6 +71,11 @@ describe('Markdown table of contents component', () => { expect(dropdownItems.exists()).toBe(true); expect(dropdownItems.length).toBe(4); + + // make sure that this only happens once + await setLoaded(true); + + expect(wrapper.findAllComponents(GlDropdownItem).length).toBe(4); }); it('sets padding for dropdown items', async () => { diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js index 2c3ec69f9ae..3ebc51c4bcb 100644 --- a/spec/frontend/boards/board_card_inner_spec.js +++ b/spec/frontend/boards/board_card_inner_spec.js @@ -5,7 +5,7 @@ import { nextTick } from 'vue'; import setWindowLocation from 'helpers/set_window_location_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import BoardBlockedIcon from '~/boards/components/board_blocked_icon.vue'; +import IssuableBlockedIcon from '~/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue'; import BoardCardInner from '~/boards/components/board_card_inner.vue'; import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; @@ -39,7 +39,7 @@ describe('Board card component', () => { let list; let store; - const findBoardBlockedIcon = () => wrapper.findComponent(BoardBlockedIcon); + const findIssuableBlockedIcon = () => wrapper.findComponent(IssuableBlockedIcon); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findEpicCountablesTotalTooltip = () => wrapper.findComponent(GlTooltip); const findEpicCountables = () => wrapper.findByTestId('epic-countables'); @@ -189,7 +189,7 @@ describe('Board card component', () => { }, }); - expect(findBoardBlockedIcon().exists()).toBe(true); + expect(findIssuableBlockedIcon().exists()).toBe(true); }); it('does not show blocked icon if issue is not blocked', () => { @@ -200,7 +200,7 @@ describe('Board card component', () => { }, }); - expect(findBoardBlockedIcon().exists()).toBe(false); + expect(findIssuableBlockedIcon().exists()).toBe(false); }); }); @@ -595,5 +595,10 @@ describe('Board card component', () => { expect(findEpicCountablesTotalWeight().text()).toBe('15'); expect(findEpicProgressTooltip().text()).toBe('10 of 15 weight completed'); }); + + it('does not render the move to position icon', () => { + createWrapper(); + expect(findMoveToPositionComponent().exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js index 731578e15a3..1a07b9f0b78 100644 --- a/spec/frontend/boards/components/board_filtered_search_spec.js +++ b/spec/frontend/boards/components/board_filtered_search_spec.js @@ -126,6 +126,7 @@ describe('BoardFilteredSearch', () => { { type: 'weight', value: { data: '2', operator: '=' } }, { type: 'iteration', value: { data: 'Any&3', operator: '=' } }, { type: 'release', value: { data: 'v1.0.0', operator: '=' } }, + { type: 'health_status', value: { data: 'onTrack', operator: '=' } }, ]; jest.spyOn(urlUtility, 'updateHistory'); findFilteredSearch().vm.$emit('onFilter', mockFilters); @@ -134,7 +135,7 @@ describe('BoardFilteredSearch', () => { title: '', replace: true, url: - 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label%262&assignee_username=root&milestone_title=New%20Milestone&iteration_id=Any&iteration_cadence_id=3&types=INCIDENT&weight=2&release_tag=v1.0.0', + 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label%262&assignee_username=root&milestone_title=New%20Milestone&iteration_id=Any&iteration_cadence_id=3&types=INCIDENT&weight=2&release_tag=v1.0.0&health_status=onTrack', }); }); @@ -160,7 +161,9 @@ describe('BoardFilteredSearch', () => { describe('when url params are already set', () => { beforeEach(() => { - createComponent({ initialFilterParams: { authorUsername: 'root', labelName: ['label'] } }); + createComponent({ + initialFilterParams: { authorUsername: 'root', labelName: ['label'], healthStatus: 'Any' }, + }); jest.spyOn(store, 'dispatch'); }); @@ -169,6 +172,7 @@ describe('BoardFilteredSearch', () => { expect(findFilteredSearch().props('initialFilterValue')).toEqual([ { type: 'author', value: { data: 'root', operator: '=' } }, { type: 'label', value: { data: 'label', operator: '=' } }, + { type: 'health_status', value: { data: 'Any', operator: '=' } }, ]); }); }); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index e919300228a..78859525a63 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -1047,60 +1047,58 @@ describe('moveIssueCard and undoMoveIssueCard', () => { let undoMutations; describe('when re-ordering card', () => { - beforeEach( - ({ - itemId = 123, - fromListId = 'gid://gitlab/List/1', - toListId = 'gid://gitlab/List/1', - originalIssue = { foo: 'bar' }, - originalIndex = 0, - moveBeforeId = undefined, - moveAfterId = undefined, - allItemsLoadedInList = true, - listPosition = undefined, - } = {}) => { - state = { - boardLists: { - [toListId]: { listType: ListType.backlog }, - [fromListId]: { listType: ListType.backlog }, - }, - boardItems: { [itemId]: originalIssue }, - boardItemsByListId: { [fromListId]: [123] }, - }; - params = { - itemId, - fromListId, - toListId, - moveBeforeId, - moveAfterId, - listPosition, - allItemsLoadedInList, - }; - moveMutations = [ - { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: fromListId } }, - { - type: types.ADD_BOARD_ITEM_TO_LIST, - payload: { - itemId, - listId: toListId, - moveBeforeId, - moveAfterId, - listPosition, - allItemsLoadedInList, - atIndex: originalIndex, - }, - }, - ]; - undoMutations = [ - { type: types.UPDATE_BOARD_ITEM, payload: originalIssue }, - { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: fromListId } }, - { - type: types.ADD_BOARD_ITEM_TO_LIST, - payload: { itemId, listId: fromListId, atIndex: originalIndex }, + beforeEach(() => { + const itemId = 123; + const fromListId = 'gid://gitlab/List/1'; + const toListId = 'gid://gitlab/List/1'; + const originalIssue = { foo: 'bar' }; + const originalIndex = 0; + const moveBeforeId = undefined; + const moveAfterId = undefined; + const allItemsLoadedInList = true; + const listPosition = undefined; + + state = { + boardLists: { + [toListId]: { listType: ListType.backlog }, + [fromListId]: { listType: ListType.backlog }, + }, + boardItems: { [itemId]: originalIssue }, + boardItemsByListId: { [fromListId]: [123] }, + }; + params = { + itemId, + fromListId, + toListId, + moveBeforeId, + moveAfterId, + listPosition, + allItemsLoadedInList, + }; + moveMutations = [ + { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: fromListId } }, + { + type: types.ADD_BOARD_ITEM_TO_LIST, + payload: { + itemId, + listId: toListId, + moveBeforeId, + moveAfterId, + listPosition, + allItemsLoadedInList, + atIndex: originalIndex, }, - ]; - }, - ); + }, + ]; + undoMutations = [ + { type: types.UPDATE_BOARD_ITEM, payload: originalIssue }, + { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: fromListId } }, + { + type: types.ADD_BOARD_ITEM_TO_LIST, + payload: { itemId, listId: fromListId, atIndex: originalIndex }, + }, + ]; + }); it('moveIssueCard commits a correct set of actions', () => { testAction({ @@ -1144,42 +1142,40 @@ describe('moveIssueCard and undoMoveIssueCard', () => { }, ], ])('when %s', (_, { toListType, fromListType }) => { - beforeEach( - ({ - itemId = 123, - fromListId = 'gid://gitlab/List/1', - toListId = 'gid://gitlab/List/2', - originalIssue = { foo: 'bar' }, - originalIndex = 0, - moveBeforeId = undefined, - moveAfterId = undefined, - } = {}) => { - state = { - boardLists: { - [fromListId]: { listType: fromListType }, - [toListId]: { listType: toListType }, - }, - boardItems: { [itemId]: originalIssue }, - boardItemsByListId: { [fromListId]: [123], [toListId]: [] }, - }; - params = { itemId, fromListId, toListId, moveBeforeId, moveAfterId }; - moveMutations = [ - { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: fromListId } }, - { - type: types.ADD_BOARD_ITEM_TO_LIST, - payload: { itemId, listId: toListId, moveBeforeId, moveAfterId }, - }, - ]; - undoMutations = [ - { type: types.UPDATE_BOARD_ITEM, payload: originalIssue }, - { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: toListId } }, - { - type: types.ADD_BOARD_ITEM_TO_LIST, - payload: { itemId, listId: fromListId, atIndex: originalIndex }, - }, - ]; - }, - ); + beforeEach(() => { + const itemId = 123; + const fromListId = 'gid://gitlab/List/1'; + const toListId = 'gid://gitlab/List/2'; + const originalIssue = { foo: 'bar' }; + const originalIndex = 0; + const moveBeforeId = undefined; + const moveAfterId = undefined; + + state = { + boardLists: { + [fromListId]: { listType: fromListType }, + [toListId]: { listType: toListType }, + }, + boardItems: { [itemId]: originalIssue }, + boardItemsByListId: { [fromListId]: [123], [toListId]: [] }, + }; + params = { itemId, fromListId, toListId, moveBeforeId, moveAfterId }; + moveMutations = [ + { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: fromListId } }, + { + type: types.ADD_BOARD_ITEM_TO_LIST, + payload: { itemId, listId: toListId, moveBeforeId, moveAfterId }, + }, + ]; + undoMutations = [ + { type: types.UPDATE_BOARD_ITEM, payload: originalIssue }, + { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: toListId } }, + { + type: types.ADD_BOARD_ITEM_TO_LIST, + payload: { itemId, listId: fromListId, atIndex: originalIndex }, + }, + ]; + }); it('moveIssueCard commits a correct set of actions', () => { testAction({ @@ -1216,47 +1212,45 @@ describe('moveIssueCard and undoMoveIssueCard', () => { }, ], ])('when %s', (_, { toListType, fromListType }) => { - beforeEach( - ({ - itemId = 123, - fromListId = 'gid://gitlab/List/1', - toListId = 'gid://gitlab/List/2', - originalIssue = { foo: 'bar' }, - originalIndex = 0, - moveBeforeId = undefined, - moveAfterId = undefined, - } = {}) => { - state = { - boardLists: { - [fromListId]: { listType: fromListType }, - [toListId]: { listType: toListType }, - }, - boardItems: { [itemId]: originalIssue }, - boardItemsByListId: { [fromListId]: [123], [toListId]: [] }, - }; - params = { itemId, fromListId, toListId, moveBeforeId, moveAfterId }; - moveMutations = [ - { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: fromListId } }, - { - type: types.ADD_BOARD_ITEM_TO_LIST, - payload: { itemId, listId: toListId, moveBeforeId, moveAfterId }, - }, - { - type: types.ADD_BOARD_ITEM_TO_LIST, - payload: { itemId, listId: fromListId, atIndex: originalIndex }, - }, - ]; - undoMutations = [ - { type: types.UPDATE_BOARD_ITEM, payload: originalIssue }, - { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: fromListId } }, - { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: toListId } }, - { - type: types.ADD_BOARD_ITEM_TO_LIST, - payload: { itemId, listId: fromListId, atIndex: originalIndex }, - }, - ]; - }, - ); + beforeEach(() => { + const itemId = 123; + const fromListId = 'gid://gitlab/List/1'; + const toListId = 'gid://gitlab/List/2'; + const originalIssue = { foo: 'bar' }; + const originalIndex = 0; + const moveBeforeId = undefined; + const moveAfterId = undefined; + + state = { + boardLists: { + [fromListId]: { listType: fromListType }, + [toListId]: { listType: toListType }, + }, + boardItems: { [itemId]: originalIssue }, + boardItemsByListId: { [fromListId]: [123], [toListId]: [] }, + }; + params = { itemId, fromListId, toListId, moveBeforeId, moveAfterId }; + moveMutations = [ + { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: fromListId } }, + { + type: types.ADD_BOARD_ITEM_TO_LIST, + payload: { itemId, listId: toListId, moveBeforeId, moveAfterId }, + }, + { + type: types.ADD_BOARD_ITEM_TO_LIST, + payload: { itemId, listId: fromListId, atIndex: originalIndex }, + }, + ]; + undoMutations = [ + { type: types.UPDATE_BOARD_ITEM, payload: originalIssue }, + { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: fromListId } }, + { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: toListId } }, + { + type: types.ADD_BOARD_ITEM_TO_LIST, + payload: { itemId, listId: fromListId, atIndex: originalIndex }, + }, + ]; + }); it('moveIssueCard commits a correct set of actions', () => { testAction({ diff --git a/spec/frontend/captcha/init_recaptcha_script_spec.js b/spec/frontend/captcha/init_recaptcha_script_spec.js index af07c9e474e..78480821d95 100644 --- a/spec/frontend/captcha/init_recaptcha_script_spec.js +++ b/spec/frontend/captcha/init_recaptcha_script_spec.js @@ -1,5 +1,4 @@ import { - RECAPTCHA_API_URL_PREFIX, RECAPTCHA_ONLOAD_CALLBACK_NAME, clearMemoizeCache, initRecaptchaScript, @@ -26,7 +25,7 @@ describe('initRecaptchaScript', () => { <head> <script class="js-recaptcha-script" - src="${RECAPTCHA_API_URL_PREFIX}?onload=${RECAPTCHA_ONLOAD_CALLBACK_NAME}&render=explicit" + src="undefined?onload=recaptchaOnloadCallback&render=explicit" /> </head> `); diff --git a/spec/frontend/ci_variable_list/components/ci_admin_variables_spec.js b/spec/frontend/ci_variable_list/components/ci_admin_variables_spec.js index 920ceaefb70..864041141b8 100644 --- a/spec/frontend/ci_variable_list/components/ci_admin_variables_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_admin_variables_spec.js @@ -4,8 +4,8 @@ import { GlLoadingIcon, GlTable } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; -import { resolvers } from '~/ci_variable_list/graphql/resolvers'; +import { createAlert } from '~/flash'; +import { resolvers } from '~/ci_variable_list/graphql/settings'; import ciAdminVariables from '~/ci_variable_list/components/ci_admin_variables.vue'; import ciVariableSettings from '~/ci_variable_list/components/ci_variable_settings.vue'; @@ -92,8 +92,8 @@ describe('Ci Admin Variable list', () => { ); }); - it('createFlash was not called', () => { - expect(createFlash).not.toHaveBeenCalled(); + it('createAlert was not called', () => { + expect(createAlert).not.toHaveBeenCalled(); }); }); @@ -104,8 +104,8 @@ describe('Ci Admin Variable list', () => { await createComponentWithApollo(); }); - it('calls createFlash with the expected error message', () => { - expect(createFlash).toHaveBeenCalledWith({ message: variableFetchErrorText }); + it('calls createAlert with the expected error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: variableFetchErrorText }); }); }); }); @@ -153,7 +153,7 @@ describe('Ci Admin Variable list', () => { await nextTick(); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledWith({ message: graphQLErrorMessage }); + expect(createAlert).toHaveBeenCalledWith({ message: graphQLErrorMessage }); }, ); @@ -171,7 +171,7 @@ describe('Ci Admin Variable list', () => { await findCiSettings().vm.$emit(event, newVariable); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledWith({ message: genericMutationErrorText }); + expect(createAlert).toHaveBeenCalledWith({ message: genericMutationErrorText }); }, ); }); diff --git a/spec/frontend/ci_variable_list/components/ci_group_variables_spec.js b/spec/frontend/ci_variable_list/components/ci_group_variables_spec.js index e45656acfd8..8a48e73eb9f 100644 --- a/spec/frontend/ci_variable_list/components/ci_group_variables_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_group_variables_spec.js @@ -4,8 +4,8 @@ import { GlLoadingIcon, GlTable } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; -import { resolvers } from '~/ci_variable_list/graphql/resolvers'; +import { createAlert } from '~/flash'; +import { resolvers } from '~/ci_variable_list/graphql/settings'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import ciGroupVariables from '~/ci_variable_list/components/ci_group_variables.vue'; @@ -95,8 +95,8 @@ describe('Ci Group Variable list', () => { ); }); - it('createFlash was not called', () => { - expect(createFlash).not.toHaveBeenCalled(); + it('createAlert was not called', () => { + expect(createAlert).not.toHaveBeenCalled(); }); }); @@ -107,8 +107,8 @@ describe('Ci Group Variable list', () => { await createComponentWithApollo(); }); - it('calls createFlash with the expected error message', () => { - expect(createFlash).toHaveBeenCalledWith({ message: variableFetchErrorText }); + it('calls createAlert with the expected error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: variableFetchErrorText }); }); }); }); @@ -158,7 +158,7 @@ describe('Ci Group Variable list', () => { await nextTick(); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledWith({ message: graphQLErrorMessage }); + expect(createAlert).toHaveBeenCalledWith({ message: graphQLErrorMessage }); }, ); @@ -176,7 +176,7 @@ describe('Ci Group Variable list', () => { await findCiSettings().vm.$emit(event, newVariable); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledWith({ message: genericMutationErrorText }); + expect(createAlert).toHaveBeenCalledWith({ message: genericMutationErrorText }); }, ); }); diff --git a/spec/frontend/ci_variable_list/components/ci_project_variables_spec.js b/spec/frontend/ci_variable_list/components/ci_project_variables_spec.js index 867f8e0cf8f..c630278fbde 100644 --- a/spec/frontend/ci_variable_list/components/ci_project_variables_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_project_variables_spec.js @@ -4,8 +4,8 @@ import { GlLoadingIcon, GlTable } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; -import { resolvers } from '~/ci_variable_list/graphql/resolvers'; +import { createAlert } from '~/flash'; +import { resolvers } from '~/ci_variable_list/graphql/settings'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import ciProjectVariables from '~/ci_variable_list/components/ci_project_variables.vue'; @@ -112,8 +112,8 @@ describe('Ci Project Variable list', () => { ); }); - it('createFlash was not called', () => { - expect(createFlash).not.toHaveBeenCalled(); + it('createAlert was not called', () => { + expect(createAlert).not.toHaveBeenCalled(); }); }); @@ -125,8 +125,8 @@ describe('Ci Project Variable list', () => { await createComponentWithApollo(); }); - it('calls createFlash with the expected error message', () => { - expect(createFlash).toHaveBeenCalledWith({ message: variableFetchErrorText }); + it('calls createAlert with the expected error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: variableFetchErrorText }); }); }); @@ -138,8 +138,8 @@ describe('Ci Project Variable list', () => { await createComponentWithApollo(); }); - it('calls createFlash with the expected error message', () => { - expect(createFlash).toHaveBeenCalledWith({ message: environmentFetchErrorText }); + it('calls createAlert with the expected error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: environmentFetchErrorText }); }); }); }); @@ -190,7 +190,7 @@ describe('Ci Project Variable list', () => { await nextTick(); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledWith({ message: graphQLErrorMessage }); + expect(createAlert).toHaveBeenCalledWith({ message: graphQLErrorMessage }); }, ); @@ -208,7 +208,7 @@ describe('Ci Project Variable list', () => { await findCiSettings().vm.$emit(event, newVariable); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledWith({ message: genericMutationErrorText }); + expect(createAlert).toHaveBeenCalledWith({ message: genericMutationErrorText }); }, ); }); diff --git a/spec/frontend/ci_variable_list/components/legacy_ci_variable_settings_spec.js b/spec/frontend/ci_variable_list/components/legacy_ci_variable_settings_spec.js index 9c941f99982..7def4dd4f29 100644 --- a/spec/frontend/ci_variable_list/components/legacy_ci_variable_settings_spec.js +++ b/spec/frontend/ci_variable_list/components/legacy_ci_variable_settings_spec.js @@ -9,11 +9,11 @@ Vue.use(Vuex); describe('Ci variable table', () => { let wrapper; let store; - let isGroup; + let isProject; - const createComponent = (groupState) => { + const createComponent = (projectState) => { store = createStore(); - store.state.isGroup = groupState; + store.state.isProject = projectState; jest.spyOn(store, 'dispatch').mockImplementation(); wrapper = shallowMount(LegacyCiVariableSettings, { store, @@ -25,14 +25,14 @@ describe('Ci variable table', () => { }); it('dispatches fetchEnvironments when mounted', () => { - isGroup = false; - createComponent(isGroup); + isProject = true; + createComponent(isProject); expect(store.dispatch).toHaveBeenCalledWith('fetchEnvironments'); }); it('does not dispatch fetchenvironments when in group context', () => { - isGroup = true; - createComponent(isGroup); + isProject = false; + createComponent(isProject); expect(store.dispatch).not.toHaveBeenCalled(); }); }); diff --git a/spec/frontend/ci_variable_list/mocks.js b/spec/frontend/ci_variable_list/mocks.js index 6d633c8b740..6f3e73f8b83 100644 --- a/spec/frontend/ci_variable_list/mocks.js +++ b/spec/frontend/ci_variable_list/mocks.js @@ -45,6 +45,12 @@ const createDefaultVars = ({ withScope = true, kind } = {}) => { return { __typename: `Ci${kind}VariableConnection`, + pageInfo: { + startCursor: 'adsjsd12kldpsa', + endCursor: 'adsjsd12kldpsa', + hasPreviousPage: false, + hasNextPage: true, + }, nodes: base, }; }; diff --git a/spec/frontend/ci_variable_list/store/actions_spec.js b/spec/frontend/ci_variable_list/store/actions_spec.js index eb31fcd3ef4..e8c81a53a55 100644 --- a/spec/frontend/ci_variable_list/store/actions_spec.js +++ b/spec/frontend/ci_variable_list/store/actions_spec.js @@ -5,7 +5,7 @@ import * as actions from '~/ci_variable_list/store/actions'; import * as types from '~/ci_variable_list/store/mutation_types'; import getInitialState from '~/ci_variable_list/store/state'; import { prepareDataForDisplay, prepareEnvironments } from '~/ci_variable_list/store/utils'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import mockData from '../services/mock_data'; @@ -118,7 +118,7 @@ describe('CI variable list store actions', () => { }, ], ); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); @@ -155,7 +155,7 @@ describe('CI variable list store actions', () => { }, ], ); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); @@ -192,7 +192,7 @@ describe('CI variable list store actions', () => { }, ], ); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); @@ -219,7 +219,7 @@ describe('CI variable list store actions', () => { mock.onGet(state.endpoint).reply(500); await testAction(actions.fetchVariables, {}, state, [], [{ type: 'requestVariables' }]); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was an error fetching the variables.', }); }); @@ -249,7 +249,7 @@ describe('CI variable list store actions', () => { await testAction(actions.fetchEnvironments, {}, state, [], [{ type: 'requestEnvironments' }]); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was an error fetching the environments information.', }); }); diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js index 7663f329b3f..09b1f80ff9b 100644 --- a/spec/frontend/clusters_list/store/actions_spec.js +++ b/spec/frontend/clusters_list/store/actions_spec.js @@ -5,7 +5,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { MAX_REQUESTS } from '~/clusters_list/constants'; import * as actions from '~/clusters_list/store/actions'; import * as types from '~/clusters_list/store/mutation_types'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import Poll from '~/lib/utils/poll'; import { apiData } from '../mock_data'; @@ -98,7 +98,7 @@ describe('Clusters store actions', () => { }, ], ); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: expect.stringMatching('error'), }); }); diff --git a/spec/frontend/code_navigation/utils/index_spec.js b/spec/frontend/code_navigation/utils/index_spec.js index 700c912029c..6f0d93c466c 100644 --- a/spec/frontend/code_navigation/utils/index_spec.js +++ b/spec/frontend/code_navigation/utils/index_spec.js @@ -87,5 +87,13 @@ describe('addInteractionClass', () => { expect(spans[1].textContent).toBe('Text'); expect(spans[2].textContent).toBe(' '); }); + + it('adds the correct class names to wrapped nodes', () => { + setHTMLFixture( + '<div data-path="index.js"><div class="blob-content"><div id="LC1" class="line"><span class="test"> Text </span></div></div></div>', + ); + addInteractionClass({ ...params, wrapTextNodes: true }); + expect(findAllSpans()[1].classList.contains('test')).toBe(true); + }); }); }); diff --git a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js index fddc767953a..16737003fa0 100644 --- a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js +++ b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js @@ -5,7 +5,7 @@ import { shallowMount } from '@vue/test-utils'; import createMockApollo from 'helpers/mock_apollo_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import CommitBoxPipelineMiniGraph from '~/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue'; import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; import { COMMIT_BOX_POLL_INTERVAL } from '~/projects/commit_box/info/constants'; @@ -178,12 +178,12 @@ describe('Commit box pipeline mini graph', () => { }); describe('error state', () => { - it('createFlash should show if there is an error fetching the data', async () => { + it('createAlert should show if there is an error fetching the data', async () => { createComponent({ handler: failedHandler }); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching linked pipelines.', }); }); diff --git a/spec/frontend/commit/commit_pipeline_status_component_spec.js b/spec/frontend/commit/commit_pipeline_status_component_spec.js index 73720c1cc88..e75fb697a7b 100644 --- a/spec/frontend/commit/commit_pipeline_status_component_spec.js +++ b/spec/frontend/commit/commit_pipeline_status_component_spec.js @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import Visibility from 'visibilityjs'; import { nextTick } from 'vue'; import fixture from 'test_fixtures/pipelines/pipelines.json'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import Poll from '~/lib/utils/poll'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; @@ -170,7 +170,7 @@ describe('Commit pipeline status component', () => { }); it('displays flash error message', () => { - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/commit/components/commit_box_pipeline_status_spec.js b/spec/frontend/commit/components/commit_box_pipeline_status_spec.js index db7b7b45397..8d455f8a3d7 100644 --- a/spec/frontend/commit/components/commit_box_pipeline_status_spec.js +++ b/spec/frontend/commit/components/commit_box_pipeline_status_spec.js @@ -4,7 +4,7 @@ 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 { createAlert } from '~/flash'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import CommitBoxPipelineStatus from '~/projects/commit_box/info/components/commit_box_pipeline_status.vue'; import { @@ -78,7 +78,7 @@ describe('Commit box pipeline status', () => { expect(findStatusIcon().exists()).toBe(true); expect(findLoadingIcon().exists()).toBe(false); - expect(createFlash).toHaveBeenCalledTimes(0); + expect(createAlert).toHaveBeenCalledTimes(0); }); it('should link to the latest pipeline', () => { @@ -97,12 +97,12 @@ describe('Commit box pipeline status', () => { }); describe('error state', () => { - it('createFlash should show if there is an error fetching the pipeline status', async () => { + it('createAlert should show if there is an error fetching the pipeline status', async () => { createComponent(failedHandler); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: PIPELINE_STATUS_FETCH_ERROR, }); }); diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index ae52cb05eaf..c1c2a125515 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -13,6 +13,7 @@ import MediaBubbleMenu from '~/content_editor/components/bubble_menus/media_bubb import TopToolbar from '~/content_editor/components/top_toolbar.vue'; import LoadingIndicator from '~/content_editor/components/loading_indicator.vue'; import waitForPromises from 'helpers/wait_for_promises'; +import { KEYDOWN_EVENT } from '~/content_editor/constants'; jest.mock('~/emoji'); @@ -26,12 +27,13 @@ describe('ContentEditor', () => { const findEditorStateObserver = () => wrapper.findComponent(EditorStateObserver); const findLoadingIndicator = () => wrapper.findComponent(LoadingIndicator); const findContentEditorAlert = () => wrapper.findComponent(ContentEditorAlert); - const createWrapper = ({ markdown } = {}) => { + const createWrapper = ({ markdown, autofocus } = {}) => { wrapper = shallowMountExtended(ContentEditor, { propsData: { renderMarkdown, uploadsPath, markdown, + autofocus, }, stubs: { EditorStateObserver, @@ -70,14 +72,22 @@ describe('ContentEditor', () => { expect(editorContent.classes()).toContain('md'); }); - it('renders ContentEditorProvider component', async () => { - await createWrapper(); + it('allows setting the tiptap editor to autofocus', async () => { + createWrapper({ autofocus: 'start' }); + + await nextTick(); + + expect(findEditorContent().props().editor.options.autofocus).toBe('start'); + }); + + it('renders ContentEditorProvider component', () => { + createWrapper(); expect(wrapper.findComponent(ContentEditorProvider).exists()).toBe(true); }); - it('renders top toolbar component', async () => { - await createWrapper(); + it('renders top toolbar component', () => { + createWrapper(); expect(wrapper.findComponent(TopToolbar).exists()).toBe(true); }); @@ -213,6 +223,17 @@ describe('ContentEditor', () => { }); }); + describe('when editorStateObserver emits keydown event', () => { + it('bubbles up event', () => { + const event = new Event('keydown'); + + createWrapper(); + + findEditorStateObserver().vm.$emit(KEYDOWN_EVENT, event); + expect(wrapper.emitted(KEYDOWN_EVENT)).toEqual([[event]]); + }); + }); + it.each` name | component ${'formatting'} | ${FormattingBubbleMenu} diff --git a/spec/frontend/content_editor/components/editor_state_observer_spec.js b/spec/frontend/content_editor/components/editor_state_observer_spec.js index e8c2d8c8793..9b42f61c98c 100644 --- a/spec/frontend/content_editor/components/editor_state_observer_spec.js +++ b/spec/frontend/content_editor/components/editor_state_observer_spec.js @@ -4,7 +4,7 @@ import EditorStateObserver, { tiptapToComponentMap, } from '~/content_editor/components/editor_state_observer.vue'; import eventHubFactory from '~/helpers/event_hub_factory'; -import { ALERT_EVENT } from '~/content_editor/constants'; +import { ALERT_EVENT, KEYDOWN_EVENT } from '~/content_editor/constants'; import { createTestEditor } from '../test_utils'; describe('content_editor/components/editor_state_observer', () => { @@ -14,6 +14,7 @@ describe('content_editor/components/editor_state_observer', () => { let onSelectionUpdateListener; let onTransactionListener; let onAlertListener; + let onKeydownListener; let eventHub; const buildEditor = () => { @@ -30,6 +31,7 @@ describe('content_editor/components/editor_state_observer', () => { selectionUpdate: onSelectionUpdateListener, transaction: onTransactionListener, [ALERT_EVENT]: onAlertListener, + [KEYDOWN_EVENT]: onKeydownListener, }, }); }; @@ -39,6 +41,7 @@ describe('content_editor/components/editor_state_observer', () => { onSelectionUpdateListener = jest.fn(); onTransactionListener = jest.fn(); onAlertListener = jest.fn(); + onKeydownListener = jest.fn(); buildEditor(); }); @@ -67,8 +70,9 @@ describe('content_editor/components/editor_state_observer', () => { }); it.each` - event | listener - ${ALERT_EVENT} | ${() => onAlertListener} + event | listener + ${ALERT_EVENT} | ${() => onAlertListener} + ${KEYDOWN_EVENT} | ${() => onKeydownListener} `('listens to $event event in the eventBus object', ({ event, listener }) => { const args = {}; @@ -97,6 +101,7 @@ describe('content_editor/components/editor_state_observer', () => { it.each` event ${ALERT_EVENT} + ${KEYDOWN_EVENT} `('removes $event event hook from eventHub', ({ event }) => { jest.spyOn(eventHub, '$off'); jest.spyOn(eventHub, '$on'); diff --git a/spec/frontend/content_editor/components/suggestions_dropdown_spec.js b/spec/frontend/content_editor/components/suggestions_dropdown_spec.js new file mode 100644 index 00000000000..e72eb892e74 --- /dev/null +++ b/spec/frontend/content_editor/components/suggestions_dropdown_spec.js @@ -0,0 +1,286 @@ +import { GlAvatarLabeled, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import SuggestionsDropdown from '~/content_editor/components/suggestions_dropdown.vue'; + +describe('~/content_editor/components/suggestions_dropdown', () => { + let wrapper; + + const buildWrapper = ({ propsData } = {}) => { + wrapper = extendedWrapper( + shallowMount(SuggestionsDropdown, { + propsData: { + nodeType: 'reference', + command: jest.fn(), + ...propsData, + }, + }), + ); + }; + + const exampleUser = { username: 'root', avatar_url: 'root_avatar.png', type: 'User' }; + const exampleIssue = { iid: 123, title: 'Test Issue' }; + const exampleMergeRequest = { iid: 224, title: 'Test MR' }; + const exampleMilestone1 = { iid: 21, title: '13' }; + const exampleMilestone2 = { iid: 24, title: 'Milestone with spaces' }; + + const exampleCommand = { + name: 'due', + description: 'Set due date', + params: ['<in 2 days | this Friday | December 31st>'], + }; + const exampleEpic = { + iid: 8884, + title: '❓ Remote Development | Solution validation', + reference: 'gitlab-org&8884', + }; + const exampleLabel1 = { + title: 'Create', + color: '#E44D2A', + type: 'GroupLabel', + textColor: '#FFFFFF', + }; + const exampleLabel2 = { + title: 'Weekly Team Announcement', + color: '#E44D2A', + type: 'GroupLabel', + textColor: '#FFFFFF', + }; + const exampleLabel3 = { + title: 'devops::create', + color: '#E44D2A', + type: 'GroupLabel', + textColor: '#FFFFFF', + }; + const exampleVulnerability = { + id: 60850147, + title: 'System procs network activity', + }; + const exampleSnippet = { + id: 2420859, + title: 'Project creation QueryRecorder logs', + }; + const exampleEmoji = { + c: 'people', + e: '😃', + d: 'smiling face with open mouth', + u: '6.0', + name: 'smiley', + }; + + const insertedEmojiProps = { + name: 'smiley', + title: 'smiling face with open mouth', + moji: '😃', + unicodeVersion: '6.0', + }; + + describe('on item select', () => { + it.each` + nodeType | referenceType | char | reference | insertedText | insertedProps + ${'reference'} | ${'user'} | ${'@'} | ${exampleUser} | ${`@root`} | ${{}} + ${'reference'} | ${'issue'} | ${'#'} | ${exampleIssue} | ${`#123`} | ${{}} + ${'reference'} | ${'merge_request'} | ${'!'} | ${exampleMergeRequest} | ${`!224`} | ${{}} + ${'reference'} | ${'milestone'} | ${'%'} | ${exampleMilestone1} | ${`%13`} | ${{}} + ${'reference'} | ${'milestone'} | ${'%'} | ${exampleMilestone2} | ${`%Milestone with spaces`} | ${{ originalText: '%"Milestone with spaces"' }} + ${'reference'} | ${'command'} | ${'/'} | ${exampleCommand} | ${'/due'} | ${{}} + ${'reference'} | ${'epic'} | ${'&'} | ${exampleEpic} | ${`gitlab-org&8884`} | ${{}} + ${'reference'} | ${'label'} | ${'~'} | ${exampleLabel1} | ${`Create`} | ${{}} + ${'reference'} | ${'label'} | ${'~'} | ${exampleLabel2} | ${`Weekly Team Announcement`} | ${{ originalText: '~"Weekly Team Announcement"' }} + ${'reference'} | ${'label'} | ${'~'} | ${exampleLabel3} | ${`devops::create`} | ${{ originalText: '~"devops::create"', text: 'devops::create' }} + ${'reference'} | ${'vulnerability'} | ${'[vulnerability:'} | ${exampleVulnerability} | ${`[vulnerability:60850147]`} | ${{}} + ${'reference'} | ${'snippet'} | ${'$'} | ${exampleSnippet} | ${`$2420859`} | ${{}} + ${'emoji'} | ${'emoji'} | ${':'} | ${exampleEmoji} | ${`😃`} | ${insertedEmojiProps} + `( + 'runs a command to insert the selected $referenceType', + ({ char, nodeType, referenceType, reference, insertedText, insertedProps }) => { + const commandSpy = jest.fn(); + + buildWrapper({ + propsData: { + char, + command: commandSpy, + nodeType, + nodeProps: { + referenceType, + test: 'prop', + }, + items: [reference], + }, + }); + + wrapper.findComponent(GlDropdownItem).vm.$emit('click'); + + expect(commandSpy).toHaveBeenCalledWith( + expect.objectContaining({ + text: insertedText, + test: 'prop', + ...insertedProps, + }), + ); + }, + ); + }); + + describe('rendering user references', () => { + it('displays avatar labeled component', () => { + buildWrapper({ + propsData: { + char: '@', + nodeProps: { + referenceType: 'user', + }, + items: [exampleUser], + }, + }); + + expect(wrapper.findComponent(GlAvatarLabeled).attributes()).toEqual( + expect.objectContaining({ + label: exampleUser.username, + shape: 'circle', + src: exampleUser.avatar_url, + }), + ); + }); + + describe.each` + referenceType | char | reference | displaysID + ${'issue'} | ${'#'} | ${exampleIssue} | ${true} + ${'merge_request'} | ${'!'} | ${exampleMergeRequest} | ${true} + ${'milestone'} | ${'%'} | ${exampleMilestone1} | ${false} + `('rendering $referenceType references', ({ referenceType, char, reference, displaysID }) => { + it(`displays ${referenceType} ID and title`, () => { + buildWrapper({ + propsData: { + char, + nodeType: 'reference', + nodeProps: { + referenceType, + }, + items: [reference], + }, + }); + + if (displaysID) expect(wrapper.text()).toContain(`${reference.iid}`); + else expect(wrapper.text()).not.toContain(`${reference.iid}`); + expect(wrapper.text()).toContain(`${reference.title}`); + }); + }); + + describe.each` + referenceType | char | reference + ${'snippet'} | ${'$'} | ${exampleSnippet} + ${'vulnerability'} | ${'[vulnerability:'} | ${exampleVulnerability} + `('rendering $referenceType references', ({ referenceType, char, reference }) => { + it(`displays ${referenceType} ID and title`, () => { + buildWrapper({ + propsData: { + char, + nodeProps: { + referenceType, + }, + items: [reference], + }, + }); + + expect(wrapper.text()).toContain(`${reference.id}`); + expect(wrapper.text()).toContain(`${reference.title}`); + }); + }); + + describe('rendering label references', () => { + it.each` + label | displayedTitle | displayedColor + ${exampleLabel1} | ${'Create'} | ${'rgb(228, 77, 42)' /* #E44D2A */} + ${exampleLabel2} | ${'Weekly Team Announcement'} | ${'rgb(228, 77, 42)' /* #E44D2A */} + ${exampleLabel3} | ${'devops::create'} | ${'rgb(228, 77, 42)' /* #E44D2A */} + `('displays label title and color', ({ label, displayedTitle, displayedColor }) => { + buildWrapper({ + propsData: { + char: '~', + nodeProps: { + referenceType: 'label', + }, + items: [label], + }, + }); + + expect(wrapper.text()).toContain(displayedTitle); + expect(wrapper.text()).not.toContain('"'); // no quotes in the dropdown list + expect(wrapper.findByTestId('label-color-box').attributes().style).toEqual( + `background-color: ${displayedColor};`, + ); + }); + }); + + describe('rendering epic references', () => { + it('displays epic title and reference', () => { + buildWrapper({ + propsData: { + char: '&', + nodeProps: { + referenceType: 'epic', + }, + items: [exampleEpic], + }, + }); + + expect(wrapper.text()).toContain(`${exampleEpic.reference}`); + expect(wrapper.text()).toContain(`${exampleEpic.title}`); + }); + }); + + describe('rendering a command (quick action)', () => { + it('displays command name with a slash', () => { + buildWrapper({ + propsData: { + char: '/', + nodeProps: { + referenceType: 'command', + }, + items: [exampleCommand], + }, + }); + + expect(wrapper.text()).toContain(`${exampleCommand.name} `); + }); + }); + + describe('rendering emoji references', () => { + it('displays emoji', () => { + const testEmojis = [ + { + c: 'people', + e: '😄', + d: 'smiling face with open mouth and smiling eyes', + u: '6.0', + name: 'smile', + }, + { + c: 'people', + e: '😸', + d: 'grinning cat face with smiling eyes', + u: '6.0', + name: 'smile_cat', + }, + { c: 'people', e: '😃', d: 'smiling face with open mouth', u: '6.0', name: 'smiley' }, + ]; + + buildWrapper({ + propsData: { + char: ':', + nodeType: 'emoji', + nodeProps: {}, + items: testEmojis, + }, + }); + + testEmojis.forEach((testEmoji) => { + expect(wrapper.text()).toContain(testEmoji.e); + expect(wrapper.text()).toContain(testEmoji.d); + expect(wrapper.text()).toContain(testEmoji.name); + }); + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/wrappers/label_spec.js b/spec/frontend/content_editor/components/wrappers/label_spec.js new file mode 100644 index 00000000000..9e58669b0ea --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/label_spec.js @@ -0,0 +1,36 @@ +import { GlLabel } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import LabelWrapper from '~/content_editor/components/wrappers/label.vue'; + +describe('content/components/wrappers/label', () => { + let wrapper; + + const createWrapper = async (node = {}) => { + wrapper = shallowMountExtended(LabelWrapper, { + propsData: { node }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it("renders a GlLabel with the node's text and color", () => { + createWrapper({ attrs: { color: '#ff0000', text: 'foo bar', originalText: '~"foo bar"' } }); + + const glLabel = wrapper.findComponent(GlLabel); + + expect(glLabel.props()).toMatchObject( + expect.objectContaining({ + title: 'foo bar', + backgroundColor: '#ff0000', + }), + ); + }); + + it('renders a scoped label if there is a "::" in the label', () => { + createWrapper({ attrs: { color: '#ff0000', text: 'foo::bar', originalText: '~"foo::bar"' } }); + + expect(wrapper.findComponent(GlLabel).props().scoped).toBe(true); + }); +}); diff --git a/spec/frontend/content_editor/extensions/heading_spec.js b/spec/frontend/content_editor/extensions/heading_spec.js new file mode 100644 index 00000000000..2fa25e03cdc --- /dev/null +++ b/spec/frontend/content_editor/extensions/heading_spec.js @@ -0,0 +1,54 @@ +import Heading from '~/content_editor/extensions/heading'; +import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; + +describe('content_editor/extensions/heading', () => { + let tiptapEditor; + let doc; + let p; + let heading; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [Heading] }); + ({ + builders: { doc, p, heading }, + } = createDocBuilder({ + tiptapEditor, + names: { + heading: { nodeType: Heading.name }, + }, + })); + }); + + describe('when typing a valid heading input rule', () => { + it.each` + level | inputRuleText + ${1} | ${'# '} + ${2} | ${'## '} + ${3} | ${'### '} + ${4} | ${'#### '} + ${5} | ${'##### '} + ${6} | ${'###### '} + `('inserts a heading node for $inputRuleText', ({ level, inputRuleText }) => { + const expectedDoc = doc(heading({ level })); + + triggerNodeInputRule({ tiptapEditor, inputRuleText }); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + + describe('when typing a invalid heading input rule', () => { + it.each` + inputRuleText + ${'#hi'} + ${'#\n'} + `('does not insert a heading node for $inputRuleText', ({ inputRuleText }) => { + const expectedDoc = doc(p()); + + triggerNodeInputRule({ tiptapEditor, inputRuleText }); + + // no change to the document + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); +}); diff --git a/spec/frontend/content_editor/markdown_processing_spec_helper.js b/spec/frontend/content_editor/markdown_processing_spec_helper.js index 228d009e42c..6f10f294fb0 100644 --- a/spec/frontend/content_editor/markdown_processing_spec_helper.js +++ b/spec/frontend/content_editor/markdown_processing_spec_helper.js @@ -1,7 +1,10 @@ import fs from 'fs'; import jsYaml from 'js-yaml'; import { memoize } from 'lodash'; +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; import { createContentEditor } from '~/content_editor'; +import httpStatus from '~/lib/utils/http_status'; const getFocusedMarkdownExamples = memoize( () => process.env.FOCUSED_MARKDOWN_EXAMPLES?.split(',') || [], @@ -42,6 +45,11 @@ const loadMarkdownApiExamples = (markdownYamlPath) => { }; const testSerializesHtmlToMarkdownForElement = async ({ markdown, html }) => { + const mock = new MockAdapter(axios); + + // Ignore any API requests from the suggestions plugin + mock.onGet().reply(httpStatus.OK, []); + const contentEditor = createContentEditor({ // Overwrite renderMarkdown to always return this specific html renderMarkdown: () => html, @@ -55,6 +63,8 @@ const testSerializesHtmlToMarkdownForElement = async ({ markdown, html }) => { // Assert that the markdown we ended up with after sending it through all the ContentEditor // plumbing matches the original markdown from the YAML. expect(serializedContent.trim()).toBe(markdown.trim()); + + mock.restore(); }; // describeMarkdownProcesssing @@ -74,7 +84,7 @@ export const describeMarkdownProcessing = (description, markdownYamlPath) => { return; } - it(exampleName, async () => { + it(`${exampleName}`, async () => { await testSerializesHtmlToMarkdownForElement(example); }); }); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 56394c85e8b..32193d97fd8 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -1204,6 +1204,24 @@ Oranges are orange [^1] ); }); + it('correctly adds a space between a preceding block element and a markdown table', () => { + expect( + serialize( + bulletList(listItem(paragraph('List item 1')), listItem(paragraph('List item 2'))), + table(tableRow(tableHeader(paragraph('header'))), tableRow(tableCell(paragraph('cell')))), + ).trim(), + ).toBe( + ` +* List item 1 +* List item 2 + +| header | +|--------| +| cell | + `.trim(), + ); + }); + it('correctly serializes reference definition', () => { expect( serialize( diff --git a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js index 459780cc7cf..8c1a3831a74 100644 --- a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js +++ b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js @@ -44,7 +44,7 @@ describe('content_editor/services/track_input_rules_and_shortcuts', () => { describe('when creating a heading using an keyboard shortcut', () => { it('sends a tracking event indicating that a heading was created using an input rule', async () => { - const shortcuts = Heading.config.addKeyboardShortcuts.call(Heading); + const shortcuts = Heading.parent.config.addKeyboardShortcuts.call(Heading); const [firstShortcut] = Object.keys(shortcuts); const nodeName = Heading.name; diff --git a/spec/frontend/contributors/component/contributors_spec.js b/spec/frontend/contributors/component/contributors_spec.js index bdf3b3636ed..2f0b5719326 100644 --- a/spec/frontend/contributors/component/contributors_spec.js +++ b/spec/frontend/contributors/component/contributors_spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; import ContributorsCharts from '~/contributors/components/contributors.vue'; @@ -52,14 +53,14 @@ describe('Contributors charts', () => { it('should display loader whiled loading data', async () => { wrapper.vm.$store.state.loading = true; await nextTick(); - expect(wrapper.find('.contributors-loader').exists()).toBe(true); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); it('should render charts when loading completed and there is chart data', async () => { wrapper.vm.$store.state.loading = false; wrapper.vm.$store.state.chartData = chartData; await nextTick(); - expect(wrapper.find('.contributors-loader').exists()).toBe(false); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); expect(wrapper.find('.contributors-charts').exists()).toBe(true); expect(wrapper.element).toMatchSnapshot(); }); diff --git a/spec/frontend/contributors/store/actions_spec.js b/spec/frontend/contributors/store/actions_spec.js index ef0ff8ca208..865f683a91a 100644 --- a/spec/frontend/contributors/store/actions_spec.js +++ b/spec/frontend/contributors/store/actions_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import * as actions from '~/contributors/stores/actions'; import * as types from '~/contributors/stores/mutation_types'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; jest.mock('~/flash.js'); @@ -47,7 +47,7 @@ describe('Contributors store actions', () => { [{ type: types.SET_LOADING_STATE, payload: true }], [], ); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: expect.stringMatching('error'), }); }); diff --git a/spec/frontend/crm/contacts_root_spec.js b/spec/frontend/crm/contacts_root_spec.js index 7aaaf480c44..ec7172434bf 100644 --- a/spec/frontend/crm/contacts_root_spec.js +++ b/spec/frontend/crm/contacts_root_spec.js @@ -87,7 +87,7 @@ describe('Customer relations contacts root app', () => { editButtonLabel: 'Edit', title: 'Customer relations contacts', newContact: 'New contact', - errorText: 'Something went wrong. Please try again.', + errorMsg: 'Something went wrong. Please try again.', }, serverErrorMessage: '', filterSearchKey: 'contacts', @@ -117,6 +117,18 @@ describe('Customer relations contacts root app', () => { expect(wrapper.text()).toContain('Something went wrong. Please try again.'); }); + + it('should be removed on error-alert-dismissed event', async () => { + mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') }); + await waitForPromises(); + + expect(wrapper.text()).toContain('Something went wrong. Please try again.'); + + findTable().vm.$emit('error-alert-dismissed'); + await waitForPromises(); + + expect(wrapper.text()).not.toContain('Something went wrong. Please try again.'); + }); }); describe('on successful load', () => { diff --git a/spec/frontend/crm/organizations_root_spec.js b/spec/frontend/crm/organizations_root_spec.js index a0b56596177..1fcf6aa8f50 100644 --- a/spec/frontend/crm/organizations_root_spec.js +++ b/spec/frontend/crm/organizations_root_spec.js @@ -91,7 +91,7 @@ describe('Customer relations organizations root app', () => { editButtonLabel: 'Edit', title: 'Customer relations organizations', newOrganization: 'New organization', - errorText: 'Something went wrong. Please try again.', + errorMsg: 'Something went wrong. Please try again.', }, serverErrorMessage: '', filterSearchKey: 'organizations', diff --git a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js index 9c8cd6a3dbc..948dc5c9be2 100644 --- a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js +++ b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js @@ -8,7 +8,7 @@ import { METRIC_TYPE_SUMMARY } from '~/api/analytics_api'; import { VSA_METRICS_GROUPS, METRICS_POPOVER_CONTENT } from '~/analytics/shared/constants'; import { prepareTimeMetricsData } from '~/analytics/shared/utils'; import MetricTile from '~/analytics/shared/components/metric_tile.vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { group } from './mock_data'; jest.mock('~/flash'); @@ -177,7 +177,7 @@ describe('ValueStreamMetrics', () => { }); it('should render an error message', () => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: `There was an error while fetching value stream analytics ${fakeReqName} data.`, }); }); diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js index bbafdc000db..113e0d8f60d 100644 --- a/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js +++ b/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js @@ -5,8 +5,9 @@ import Vuex from 'vuex'; import Api from '~/api'; import DeployFreezeModal from '~/deploy_freeze/components/deploy_freeze_modal.vue'; import createStore from '~/deploy_freeze/store'; -import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown.vue'; -import { freezePeriodsFixture, timezoneDataFixture } from '../helpers'; +import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue'; +import { freezePeriodsFixture } from '../helpers'; +import { timezoneDataFixture } from '../../vue_shared/components/timezone_dropdown/helpers'; jest.mock('~/api'); @@ -52,7 +53,7 @@ describe('Deploy freeze modal', () => { describe('Basic interactions', () => { it('button is disabled when freeze period is invalid', () => { - expect(submitDeployFreezeButton().attributes('disabled')).toBeTruthy(); + expect(submitDeployFreezeButton().attributes('disabled')).toBe('true'); }); }); @@ -92,7 +93,7 @@ describe('Deploy freeze modal', () => { }); it('disables the add deploy freeze button', () => { - expect(submitDeployFreezeButton().attributes('disabled')).toBeTruthy(); + expect(submitDeployFreezeButton().attributes('disabled')).toBe('true'); }); }); @@ -103,7 +104,7 @@ describe('Deploy freeze modal', () => { }); it('does not disable the submit button', () => { - expect(submitDeployFreezeButton().attributes('disabled')).toBeFalsy(); + expect(submitDeployFreezeButton().attributes('disabled')).toBeUndefined(); }); }); }); diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js index 637efe30022..27d8fea9d5e 100644 --- a/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js +++ b/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js @@ -5,7 +5,7 @@ import DeployFreezeModal from '~/deploy_freeze/components/deploy_freeze_modal.vu import DeployFreezeSettings from '~/deploy_freeze/components/deploy_freeze_settings.vue'; import DeployFreezeTable from '~/deploy_freeze/components/deploy_freeze_table.vue'; import createStore from '~/deploy_freeze/store'; -import { timezoneDataFixture } from '../helpers'; +import { timezoneDataFixture } from '../../vue_shared/components/timezone_dropdown/helpers'; Vue.use(Vuex); diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js index 137776edfab..c2d6eb399bc 100644 --- a/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js +++ b/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js @@ -5,7 +5,8 @@ import Vuex from 'vuex'; import DeployFreezeTable from '~/deploy_freeze/components/deploy_freeze_table.vue'; import createStore from '~/deploy_freeze/store'; import { RECEIVE_FREEZE_PERIODS_SUCCESS } from '~/deploy_freeze/store/mutation_types'; -import { freezePeriodsFixture, timezoneDataFixture } from '../helpers'; +import { freezePeriodsFixture } from '../helpers'; +import { timezoneDataFixture } from '../../vue_shared/components/timezone_dropdown/helpers'; Vue.use(Vuex); diff --git a/spec/frontend/deploy_freeze/helpers.js b/spec/frontend/deploy_freeze/helpers.js index 43e66183ab5..920901c97a8 100644 --- a/spec/frontend/deploy_freeze/helpers.js +++ b/spec/frontend/deploy_freeze/helpers.js @@ -1,10 +1,3 @@ import freezePeriodsFixture from 'test_fixtures/api/freeze-periods/freeze_periods.json'; -import timezoneDataFixture from 'test_fixtures/timezones/short.json'; -import { secondsToHours } from '~/lib/utils/datetime_utility'; -export { freezePeriodsFixture, timezoneDataFixture }; - -export const findTzByName = (identifier = '') => - timezoneDataFixture.find(({ name }) => name.toLowerCase() === identifier.toLowerCase()); - -export const formatTz = ({ offset, name }) => `[UTC ${secondsToHours(offset)}] ${name}`; +export { freezePeriodsFixture }; diff --git a/spec/frontend/deploy_freeze/store/actions_spec.js b/spec/frontend/deploy_freeze/store/actions_spec.js index ad67afdce75..ce0c924bed2 100644 --- a/spec/frontend/deploy_freeze/store/actions_spec.js +++ b/spec/frontend/deploy_freeze/store/actions_spec.js @@ -7,7 +7,8 @@ import getInitialState from '~/deploy_freeze/store/state'; import createFlash from '~/flash'; import * as logger from '~/lib/logger'; import axios from '~/lib/utils/axios_utils'; -import { freezePeriodsFixture, timezoneDataFixture } from '../helpers'; +import { freezePeriodsFixture } from '../helpers'; +import { timezoneDataFixture } from '../../vue_shared/components/timezone_dropdown/helpers'; jest.mock('~/api.js'); jest.mock('~/flash.js'); diff --git a/spec/frontend/deploy_freeze/store/mutations_spec.js b/spec/frontend/deploy_freeze/store/mutations_spec.js index 878a755088c..984105d6655 100644 --- a/spec/frontend/deploy_freeze/store/mutations_spec.js +++ b/spec/frontend/deploy_freeze/store/mutations_spec.js @@ -2,7 +2,12 @@ import * as types from '~/deploy_freeze/store/mutation_types'; import mutations from '~/deploy_freeze/store/mutations'; import state from '~/deploy_freeze/store/state'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { findTzByName, formatTz, freezePeriodsFixture, timezoneDataFixture } from '../helpers'; +import { formatTimezone } from '~/lib/utils/datetime_utility'; +import { freezePeriodsFixture } from '../helpers'; +import { + timezoneDataFixture, + findTzByName, +} from '../../vue_shared/components/timezone_dropdown/helpers'; describe('Deploy freeze mutations', () => { let stateCopy; @@ -28,9 +33,9 @@ describe('Deploy freeze mutations', () => { describe('RECEIVE_FREEZE_PERIODS_SUCCESS', () => { it('should set freeze periods and format timezones from identifiers to names', () => { const timezoneNames = { - 'Europe/Berlin': '[UTC 2] Berlin', + 'Europe/Berlin': '[UTC + 2] Berlin', 'Etc/UTC': '[UTC 0] UTC', - 'America/New_York': '[UTC -4] Eastern Time (US & Canada)', + 'America/New_York': '[UTC - 4] Eastern Time (US & Canada)', }; mutations[types.RECEIVE_FREEZE_PERIODS_SUCCESS](stateCopy, freezePeriodsFixture); @@ -51,7 +56,7 @@ describe('Deploy freeze mutations', () => { it('should set the cron timezone', () => { const selectedTz = findTzByName('Pacific Time (US & Canada)'); const timezone = { - formattedTimezone: formatTz(selectedTz), + formattedTimezone: formatTimezone(selectedTz), identifier: selectedTz.identifier, }; mutations[types.SET_SELECTED_TIMEZONE](stateCopy, timezone); diff --git a/spec/frontend/deploy_tokens/components/new_deploy_token_spec.js b/spec/frontend/deploy_tokens/components/new_deploy_token_spec.js new file mode 100644 index 00000000000..19e9ba8b268 --- /dev/null +++ b/spec/frontend/deploy_tokens/components/new_deploy_token_spec.js @@ -0,0 +1,103 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { GlButton, GlFormCheckbox, GlFormInput, GlFormInputGroup, GlDatepicker } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import { TEST_HOST } from 'helpers/test_constants'; +import NewDeployToken from '~/deploy_tokens/components/new_deploy_token.vue'; +import waitForPromises from 'helpers/wait_for_promises'; + +const createNewTokenPath = `${TEST_HOST}/create`; +const deployTokensHelpUrl = `${TEST_HOST}/help`; +describe('New Deploy Token', () => { + let wrapper; + + const factory = (options = {}) => { + const defaults = { + containerRegistryEnabled: true, + packagesRegistryEnabled: true, + tokenType: 'project', + }; + const { containerRegistryEnabled, packagesRegistryEnabled, tokenType } = { + ...defaults, + ...options, + }; + return shallowMount(NewDeployToken, { + propsData: { + deployTokensHelpUrl, + containerRegistryEnabled, + packagesRegistryEnabled, + createNewTokenPath, + tokenType, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('without a container registry', () => { + beforeEach(() => { + wrapper = factory({ containerRegistryEnabled: false }); + }); + + it('should not show the read registry scope', () => { + wrapper + .findAllComponents(GlFormCheckbox) + .wrappers.forEach((checkbox) => expect(checkbox.text()).not.toBe('read_registry')); + }); + }); + + describe('with a container registry', () => { + beforeEach(() => { + wrapper = factory(); + }); + + it('should show the read registry scope', () => { + const checkbox = wrapper.findAllComponents(GlFormCheckbox).at(1); + expect(checkbox.text()).toBe('read_registry'); + }); + + it('should make a request to create a token on submit', () => { + const mockAxios = new MockAdapter(axios); + + const date = new Date(); + const formInputs = wrapper.findAllComponents(GlFormInput); + const name = formInputs.at(0); + const username = formInputs.at(2); + name.vm.$emit('input', 'test name'); + username.vm.$emit('input', 'test username'); + + const datepicker = wrapper.findAllComponents(GlDatepicker).at(0); + datepicker.vm.$emit('input', date); + + const [readRepo, readRegistry] = wrapper.findAllComponents(GlFormCheckbox).wrappers; + readRepo.vm.$emit('input', true); + readRegistry.vm.$emit('input', true); + + mockAxios + .onPost(createNewTokenPath, { + deploy_token: { + name: 'test name', + expires_at: date.toISOString(), + username: 'test username', + read_repository: true, + read_registry: true, + }, + }) + .replyOnce(200, { username: 'test token username', token: 'test token' }); + + wrapper.findAllComponents(GlButton).at(0).vm.$emit('click'); + + return waitForPromises() + .then(() => nextTick()) + .then(() => { + const [tokenUsername, tokenValue] = wrapper.findAllComponents(GlFormInputGroup).wrappers; + + expect(tokenUsername.props('value')).toBe('test token username'); + expect(tokenValue.props('value')).toBe('test token'); + }); + }); + }); +}); diff --git a/spec/frontend/design_management/components/delete_button_spec.js b/spec/frontend/design_management/components/delete_button_spec.js index cee1eec792d..426a61f5a47 100644 --- a/spec/frontend/design_management/components/delete_button_spec.js +++ b/spec/frontend/design_management/components/delete_button_spec.js @@ -29,12 +29,12 @@ describe('Batch delete button component', () => { createComponent(); expect(findButton().exists()).toBe(true); - expect(findButton().attributes('disabled')).toBeFalsy(); + expect(findButton().attributes('disabled')).toBeUndefined(); }); it('renders disabled button when design is deleting', () => { createComponent({ isDeleting: true }); - expect(findButton().attributes('disabled')).toBeTruthy(); + expect(findButton().attributes('disabled')).toBe('true'); }); it('emits `delete-selected-designs` event on modal ok click', async () => { @@ -45,7 +45,7 @@ describe('Batch delete button component', () => { findModal().vm.$emit('ok'); await nextTick(); - expect(wrapper.emitted('delete-selected-designs')).toBeTruthy(); + expect(wrapper.emitted('delete-selected-designs')).toHaveLength(1); }); it('renders slot content', () => { diff --git a/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js index e36f5c79e3e..5fd61b25edc 100644 --- a/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js @@ -1,16 +1,10 @@ import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import Autosave from '~/autosave'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue'; -const showModal = jest.fn(); - -const GlModal = { - template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>', - methods: { - show: showModal, - }, -}; +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); describe('Design reply form component', () => { let wrapper; @@ -19,7 +13,6 @@ describe('Design reply form component', () => { const findTextarea = () => wrapper.find('textarea'); const findSubmitButton = () => wrapper.findComponent({ ref: 'submitButton' }); const findCancelButton = () => wrapper.findComponent({ ref: 'cancelButton' }); - const findModal = () => wrapper.findComponent({ ref: 'cancelCommentModal' }); function createComponent(props = {}, mountOptions = {}) { wrapper = mount(DesignReplyForm, { @@ -29,7 +22,6 @@ describe('Design reply form component', () => { noteableId: 'gid://gitlab/DesignManagement::Design/6', ...props, }, - stubs: { GlModal }, ...mountOptions, }); } @@ -42,6 +34,7 @@ describe('Design reply form component', () => { afterEach(() => { wrapper.destroy(); window.gon = originalGon; + confirmAction.mockReset(); }); it('textarea has focus after component mount', () => { @@ -102,7 +95,7 @@ describe('Design reply form component', () => { }); it('submit button is disabled', () => { - expect(findSubmitButton().attributes().disabled).toBeTruthy(); + expect(findSubmitButton().attributes().disabled).toBe('disabled'); }); it('does not emit submitForm event on textarea ctrl+enter keydown', async () => { @@ -111,7 +104,7 @@ describe('Design reply form component', () => { }); await nextTick(); - expect(wrapper.emitted('submit-form')).toBeFalsy(); + expect(wrapper.emitted('submit-form')).toBeUndefined(); }); it('does not emit submitForm event on textarea meta+enter keydown', async () => { @@ -120,13 +113,13 @@ describe('Design reply form component', () => { }); await nextTick(); - expect(wrapper.emitted('submit-form')).toBeFalsy(); + expect(wrapper.emitted('submit-form')).toBeUndefined(); }); it('emits cancelForm event on pressing escape button on textarea', () => { findTextarea().trigger('keyup.esc'); - expect(wrapper.emitted('cancel-form')).toBeTruthy(); + expect(wrapper.emitted('cancel-form')).toHaveLength(1); }); it('emits cancelForm event on clicking Cancel button', () => { @@ -144,7 +137,7 @@ describe('Design reply form component', () => { }); it('submit button is enabled', () => { - expect(findSubmitButton().attributes().disabled).toBeFalsy(); + expect(findSubmitButton().attributes().disabled).toBeUndefined(); }); it('emits submitForm event on Comment button click', async () => { @@ -153,7 +146,7 @@ describe('Design reply form component', () => { findSubmitButton().vm.$emit('click'); await nextTick(); - expect(wrapper.emitted('submit-form')).toBeTruthy(); + expect(wrapper.emitted('submit-form')).toHaveLength(1); expect(autosaveResetSpy).toHaveBeenCalled(); }); @@ -165,7 +158,7 @@ describe('Design reply form component', () => { }); await nextTick(); - expect(wrapper.emitted('submit-form')).toBeTruthy(); + expect(wrapper.emitted('submit-form')).toHaveLength(1); expect(autosaveResetSpy).toHaveBeenCalled(); }); @@ -177,7 +170,7 @@ describe('Design reply form component', () => { }); await nextTick(); - expect(wrapper.emitted('submit-form')).toBeTruthy(); + expect(wrapper.emitted('submit-form')).toHaveLength(1); expect(autosaveResetSpy).toHaveBeenCalled(); }); @@ -185,13 +178,13 @@ describe('Design reply form component', () => { findTextarea().setValue('test2'); await nextTick(); - expect(wrapper.emitted('input')).toBeTruthy(); + expect(wrapper.emitted('input')).toEqual([['test'], ['test2']]); }); it('emits cancelForm event on Escape key if text was not changed', () => { findTextarea().trigger('keyup.esc'); - expect(wrapper.emitted('cancel-form')).toBeTruthy(); + expect(wrapper.emitted('cancel-form')).toHaveLength(1); }); it('opens confirmation modal on Escape key when text has changed', async () => { @@ -199,13 +192,13 @@ describe('Design reply form component', () => { await nextTick(); findTextarea().trigger('keyup.esc'); - expect(showModal).toHaveBeenCalled(); + expect(confirmAction).toHaveBeenCalled(); }); it('emits cancelForm event on Cancel button click if text was not changed', () => { findCancelButton().trigger('click'); - expect(wrapper.emitted('cancel-form')).toBeTruthy(); + expect(wrapper.emitted('cancel-form')).toHaveLength(1); }); it('opens confirmation modal on Cancel button click when text has changed', async () => { @@ -213,17 +206,41 @@ describe('Design reply form component', () => { await nextTick(); findCancelButton().trigger('click'); - expect(showModal).toHaveBeenCalled(); + expect(confirmAction).toHaveBeenCalled(); }); - it('emits cancelForm event on modal Ok button click', () => { + it('emits cancelForm event when confirmed', async () => { + confirmAction.mockResolvedValueOnce(true); const autosaveResetSpy = jest.spyOn(wrapper.vm.autosaveDiscussion, 'reset'); + wrapper.setProps({ value: 'test3' }); + await nextTick(); + findTextarea().trigger('keyup.esc'); - findModal().vm.$emit('ok'); + await nextTick(); + + expect(confirmAction).toHaveBeenCalled(); + await nextTick(); - expect(wrapper.emitted('cancel-form')).toBeTruthy(); + expect(wrapper.emitted('cancel-form')).toHaveLength(1); expect(autosaveResetSpy).toHaveBeenCalled(); }); + + it("doesn't emit cancelForm event when not confirmed", async () => { + confirmAction.mockResolvedValueOnce(false); + const autosaveResetSpy = jest.spyOn(wrapper.vm.autosaveDiscussion, 'reset'); + + wrapper.setProps({ value: 'test3' }); + await nextTick(); + + findTextarea().trigger('keyup.esc'); + await nextTick(); + + expect(confirmAction).toHaveBeenCalled(); + await nextTick(); + + expect(wrapper.emitted('cancel-form')).toBeUndefined(); + expect(autosaveResetSpy).not.toHaveBeenCalled(); + }); }); }); diff --git a/spec/frontend/design_management/components/design_overlay_spec.js b/spec/frontend/design_management/components/design_overlay_spec.js index 056959425a6..169f2dbdccb 100644 --- a/spec/frontend/design_management/components/design_overlay_spec.js +++ b/spec/frontend/design_management/components/design_overlay_spec.js @@ -170,6 +170,14 @@ describe('Design overlay component', () => { }); it('should call an update active discussion mutation when clicking a note without moving it', async () => { + createComponent({ + notes, + dimensions: { + width: 400, + height: 400, + }, + }); + const note = notes[0]; const { position } = note; const mutationVariables = { diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js index 774e37a8b21..a11463ab663 100644 --- a/spec/frontend/design_management/pages/design/index_spec.js +++ b/spec/frontend/design_management/pages/design/index_spec.js @@ -23,7 +23,7 @@ import { DESIGN_SNOWPLOW_EVENT_TYPES, DESIGN_SERVICE_PING_EVENT_TYPES, } from '~/design_management/utils/tracking'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import mockAllVersions from '../../mock_data/all_versions'; import design from '../../mock_data/design'; import mockResponseWithDesigns from '../../mock_data/designs'; @@ -301,8 +301,8 @@ describe('Design management design index page', () => { wrapper.vm.onDesignQueryResult({ data: mockResponseNoDesigns, loading: false }); await nextTick(); - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ message: DESIGN_NOT_FOUND_ERROR }); + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ message: DESIGN_NOT_FOUND_ERROR }); expect(router.push).toHaveBeenCalledTimes(1); expect(router.push).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME }); }); @@ -323,8 +323,8 @@ describe('Design management design index page', () => { wrapper.vm.onDesignQueryResult({ data: mockResponseWithDesigns, loading: false }); await nextTick(); - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ message: DESIGN_VERSION_NOT_EXIST_ERROR }); + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ message: DESIGN_VERSION_NOT_EXIST_ERROR }); expect(router.push).toHaveBeenCalledTimes(1); expect(router.push).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME }); }); diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index 1033b509419..76ece922ded 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -29,7 +29,7 @@ import { DESIGN_TRACKING_PAGE_NAME, DESIGN_SNOWPLOW_EVENT_TYPES, } from '~/design_management/utils/tracking'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import DesignDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; import { designListQueryResponse, @@ -808,7 +808,7 @@ describe('Design management index page', () => { await moveDesigns(wrapper); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ message: 'Houston, we have a problem' }); + expect(createAlert).toHaveBeenCalledWith({ message: 'Houston, we have a problem' }); }); it('displays alert if mutation had a non-recoverable error', async () => { diff --git a/spec/frontend/design_management/utils/cache_update_spec.js b/spec/frontend/design_management/utils/cache_update_spec.js index 5e2c37e24a1..42777adfd58 100644 --- a/spec/frontend/design_management/utils/cache_update_spec.js +++ b/spec/frontend/design_management/utils/cache_update_spec.js @@ -10,7 +10,7 @@ import { ADD_IMAGE_DIFF_NOTE_ERROR, UPDATE_IMAGE_DIFF_NOTE_ERROR, } from '~/design_management/utils/error_messages'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import design from '../mock_data/design'; jest.mock('~/flash.js'); @@ -32,10 +32,10 @@ describe('Design Management cache update', () => { ${'updateStoreAfterUploadDesign'} | ${updateStoreAfterUploadDesign} | ${mockErrors[0]} | ${[]} ${'updateStoreAfterUpdateImageDiffNote'} | ${updateStoreAfterRepositionImageDiffNote} | ${UPDATE_IMAGE_DIFF_NOTE_ERROR} | ${[]} `('$fnName handles errors in response', ({ subject, extraArgs, errorMessage }) => { - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(() => subject(mockStore, { errors: mockErrors }, {}, ...extraArgs)).toThrow(); - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ message: errorMessage }); + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ message: errorMessage }); }); }); }); diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index b88206c3b9a..936f4744e94 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -152,6 +152,30 @@ describe('diffs/components/app', () => { }); }); + describe('fetch diff with no changes', () => { + beforeEach(() => { + const fetchResolver = () => { + store.state.diffs.retrievingBatches = false; + return Promise.resolve({ real_size: null }); + }; + + createComponent(); + jest.spyOn(wrapper.vm, 'fetchDiffFilesMeta').mockImplementation(fetchResolver); + + return nextTick(); + }); + + it('diff counter to be 0 after fetch', async () => { + expect(wrapper.vm.diffFilesLength).toEqual(0); + wrapper.vm.fetchData(false); + + await nextTick(); + + expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled(); + expect(wrapper.vm.diffFilesLength).toEqual(0); + }); + }); + describe('codequality diff', () => { it('does not fetch code quality data on FOSS', async () => { createComponent(); diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js index 440f169be86..75d55376d09 100644 --- a/spec/frontend/diffs/components/commit_item_spec.js +++ b/spec/frontend/diffs/components/commit_item_spec.js @@ -82,7 +82,7 @@ describe('diffs/components/commit_item', () => { const imgElement = avatarElement.find('img'); expect(avatarElement.attributes('href')).toBe(commit.author.web_url); - expect(imgElement.classes()).toContain('s32'); + expect(imgElement.classes()).toContain('gl-avatar-s32'); expect(imgElement.attributes('alt')).toBe(commit.author.name); expect(imgElement.attributes('src')).toBe(commit.author.avatar_url); }); diff --git a/spec/frontend/diffs/components/diff_content_spec.js b/spec/frontend/diffs/components/diff_content_spec.js index 9f593ee0d49..0bce6451ce4 100644 --- a/spec/frontend/diffs/components/diff_content_spec.js +++ b/spec/frontend/diffs/components/diff_content_spec.js @@ -53,7 +53,7 @@ describe('DiffContent', () => { namespaced: true, getters: { draftsForFile: () => () => true, - draftForLine: () => () => true, + draftsForLine: () => () => true, shouldRenderDraftRow: () => () => true, hasParallelDraftLeft: () => () => true, hasParallelDraftRight: () => () => true, diff --git a/spec/frontend/diffs/components/diff_row_spec.js b/spec/frontend/diffs/components/diff_row_spec.js index a74013dc2d4..a7a95ed2f35 100644 --- a/spec/frontend/diffs/components/diff_row_spec.js +++ b/spec/frontend/diffs/components/diff_row_spec.js @@ -219,7 +219,7 @@ describe('DiffRow', () => { shouldRenderDraftRow: jest.fn(), hasParallelDraftLeft: jest.fn(), hasParallelDraftRight: jest.fn(), - draftForLine: jest.fn(), + draftsForLine: jest.fn().mockReturnValue([]), }; const applyMap = mapParallel(mockDiffContent); diff --git a/spec/frontend/diffs/components/diff_row_utils_spec.js b/spec/frontend/diffs/components/diff_row_utils_spec.js index 930b8bcdb08..8b25691ce34 100644 --- a/spec/frontend/diffs/components/diff_row_utils_spec.js +++ b/spec/frontend/diffs/components/diff_row_utils_spec.js @@ -216,7 +216,7 @@ describe('mapParallel', () => { diffFile: {}, hasParallelDraftLeft: () => false, hasParallelDraftRight: () => false, - draftForLine: () => ({}), + draftsForLine: () => [], }; const line = { left: side, right: side }; const expectation = { @@ -234,13 +234,13 @@ describe('mapParallel', () => { const leftExpectation = { renderDiscussion: true, hasDraft: false, - lineDraft: {}, + lineDrafts: [], hasCommentForm: true, }; const rightExpectation = { renderDiscussion: false, hasDraft: false, - lineDraft: {}, + lineDrafts: [], hasCommentForm: false, }; const mapped = utils.mapParallel(content)(line); diff --git a/spec/frontend/diffs/components/diff_view_spec.js b/spec/frontend/diffs/components/diff_view_spec.js index 1dd4a2f6c23..9bff6bd14f1 100644 --- a/spec/frontend/diffs/components/diff_view_spec.js +++ b/spec/frontend/diffs/components/diff_view_spec.js @@ -21,7 +21,7 @@ describe('DiffView', () => { getters: { shouldRenderDraftRow: () => false, shouldRenderParallelDraftRow: () => () => true, - draftForLine: () => false, + draftsForLine: () => false, draftsForFile: () => false, hasParallelDraftLeft: () => false, hasParallelDraftRight: () => false, @@ -75,12 +75,12 @@ describe('DiffView', () => { }); it.each` - type | side | container | sides | total - ${'parallel'} | ${'left'} | ${'.old'} | ${{ left: { lineDraft: {}, renderDiscussion: true }, right: { lineDraft: {}, renderDiscussion: true } }} | ${2} - ${'parallel'} | ${'right'} | ${'.new'} | ${{ left: { lineDraft: {}, renderDiscussion: true }, right: { lineDraft: {}, renderDiscussion: true } }} | ${2} - ${'inline'} | ${'left'} | ${'.old'} | ${{ left: { lineDraft: {}, renderDiscussion: true } }} | ${1} - ${'inline'} | ${'left'} | ${'.old'} | ${{ left: { lineDraft: {}, renderDiscussion: true } }} | ${1} - ${'inline'} | ${'left'} | ${'.old'} | ${{ left: { lineDraft: {}, renderDiscussion: true } }} | ${1} + type | side | container | sides | total + ${'parallel'} | ${'left'} | ${'.old'} | ${{ left: { lineDrafts: [], renderDiscussion: true }, right: { lineDrafts: [], renderDiscussion: true } }} | ${2} + ${'parallel'} | ${'right'} | ${'.new'} | ${{ left: { lineDrafts: [], renderDiscussion: true }, right: { lineDrafts: [], renderDiscussion: true } }} | ${2} + ${'inline'} | ${'left'} | ${'.old'} | ${{ left: { lineDrafts: [], renderDiscussion: true } }} | ${1} + ${'inline'} | ${'left'} | ${'.old'} | ${{ left: { lineDrafts: [], renderDiscussion: true } }} | ${1} + ${'inline'} | ${'left'} | ${'.old'} | ${{ left: { lineDrafts: [], renderDiscussion: true } }} | ${1} `( 'renders a $type comment row with comment cell on $side', ({ type, container, sides, total }) => { @@ -95,7 +95,7 @@ describe('DiffView', () => { it('renders a draft row', () => { const wrapper = createWrapper({ - diffLines: [{ renderCommentRow: true, left: { lineDraft: { isDraft: true } } }], + diffLines: [{ renderCommentRow: true, left: { lineDrafts: [{ isDraft: true }] } }], }); expect(wrapper.findComponent(DraftNote).exists()).toBe(true); }); diff --git a/spec/frontend/diffs/components/file_row_stats_spec.js b/spec/frontend/diffs/components/file_row_stats_spec.js index 3f5a63c19e5..7d3b60d2ba4 100644 --- a/spec/frontend/diffs/components/file_row_stats_spec.js +++ b/spec/frontend/diffs/components/file_row_stats_spec.js @@ -2,13 +2,21 @@ import { mount } from '@vue/test-utils'; import FileRowStats from '~/diffs/components/file_row_stats.vue'; describe('Diff file row stats', () => { - const wrapper = mount(FileRowStats, { - propsData: { - file: { - addedLines: 20, - removedLines: 10, + let wrapper; + + const createComponent = () => { + wrapper = mount(FileRowStats, { + propsData: { + file: { + addedLines: 20, + removedLines: 10, + }, }, - }, + }); + }; + + beforeEach(() => { + createComponent(); }); it('renders added lines count', () => { diff --git a/spec/frontend/diffs/mock_data/diff_code_quality.js b/spec/frontend/diffs/mock_data/diff_code_quality.js index 2ca421a20b4..befab3b676b 100644 --- a/spec/frontend/diffs/mock_data/diff_code_quality.js +++ b/spec/frontend/diffs/mock_data/diff_code_quality.js @@ -36,7 +36,7 @@ export const diffCodeQuality = { old_line: 1, new_line: null, codequality: [], - lineDraft: {}, + lineDrafts: [], }, }, { @@ -45,7 +45,7 @@ export const diffCodeQuality = { old_line: 2, new_line: 1, codequality: [], - lineDraft: {}, + lineDrafts: [], }, }, { @@ -55,7 +55,7 @@ export const diffCodeQuality = { new_line: 2, codequality: [multipleFindingsArr[0]], - lineDraft: {}, + lineDrafts: [], }, }, ], diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index 346e43e5a72..bf75f956d7f 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -13,7 +13,7 @@ import * as diffActions from '~/diffs/store/actions'; import * as types from '~/diffs/store/mutation_types'; import * as utils from '~/diffs/store/utils'; import * as treeWorkerUtils from '~/diffs/utils/tree_worker_utils'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as commonUtils from '~/lib/utils/common_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; @@ -54,7 +54,7 @@ describe('DiffsStoreActions', () => { ['requestAnimationFrame', 'requestIdleCallback'].forEach((method) => { global[method] = originalMethods[method]; }); - createFlash.mockClear(); + createAlert.mockClear(); mock.restore(); }); @@ -175,35 +175,10 @@ describe('DiffsStoreActions', () => { [{ type: 'startRenderDiffsQueue' }, { type: 'startRenderDiffsQueue' }], ); }); - - it.each` - viewStyle | otherView - ${'inline'} | ${'parallel'} - ${'parallel'} | ${'inline'} - `( - 'should make a request with the view parameter "$viewStyle" when the batchEndpoint already contains "$otherView"', - ({ viewStyle, otherView }) => { - const endpointBatch = '/fetch/diffs_batch'; - - diffActions - .fetchDiffFilesBatch({ - commit: () => {}, - state: { - endpointBatch: `${endpointBatch}?view=${otherView}`, - diffViewType: viewStyle, - }, - }) - .then(() => { - expect(mock.history.get[0].url).toContain(`view=${viewStyle}`); - expect(mock.history.get[0].url).not.toContain(`view=${otherView}`); - }) - .catch(() => {}); - }, - ); }); describe('fetchDiffFilesMeta', () => { - const endpointMetadata = '/fetch/diffs_metadata.json?view=inline'; + const endpointMetadata = '/fetch/diffs_metadata.json?view=inline&w=0'; const noFilesData = { ...diffMetadata }; beforeEach(() => { @@ -216,7 +191,7 @@ describe('DiffsStoreActions', () => { return testAction( diffActions.fetchDiffFilesMeta, {}, - { endpointMetadata, diffViewType: 'inline' }, + { endpointMetadata, diffViewType: 'inline', showWhitespace: true }, [ { type: types.SET_LOADING, payload: true }, { type: types.SET_LOADING, payload: false }, @@ -254,8 +229,8 @@ describe('DiffsStoreActions', () => { mock.onGet(endpointCoverage).reply(400); await testAction(diffActions.fetchCoverageFiles, {}, { endpointCoverage }, [], []); - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ message: expect.stringMatching('Something went wrong'), }); }); diff --git a/spec/frontend/editor/schema/ci/ci_schema_spec.js b/spec/frontend/editor/schema/ci/ci_schema_spec.js index c9010fbec0c..fc86907c144 100644 --- a/spec/frontend/editor/schema/ci/ci_schema_spec.js +++ b/spec/frontend/editor/schema/ci/ci_schema_spec.js @@ -29,12 +29,36 @@ import CacheYaml from './yaml_tests/positive_tests/cache.yml'; import FilterYaml from './yaml_tests/positive_tests/filter.yml'; import IncludeYaml from './yaml_tests/positive_tests/include.yml'; import RulesYaml from './yaml_tests/positive_tests/rules.yml'; +import ProjectPathYaml from './yaml_tests/positive_tests/project_path.yml'; +import VariablesYaml from './yaml_tests/positive_tests/variables.yml'; // YAML NEGATIVE TEST import ArtifactsNegativeYaml from './yaml_tests/negative_tests/artifacts.yml'; import CacheNegativeYaml from './yaml_tests/negative_tests/cache.yml'; import IncludeNegativeYaml from './yaml_tests/negative_tests/include.yml'; import RulesNegativeYaml from './yaml_tests/negative_tests/rules.yml'; +import VariablesNegativeYaml from './yaml_tests/negative_tests/variables.yml'; + +import ProjectPathIncludeEmptyYaml from './yaml_tests/negative_tests/project_path/include/empty.yml'; +import ProjectPathIncludeInvalidVariableYaml from './yaml_tests/negative_tests/project_path/include/invalid_variable.yml'; +import ProjectPathIncludeLeadSlashYaml from './yaml_tests/negative_tests/project_path/include/leading_slash.yml'; +import ProjectPathIncludeNoSlashYaml from './yaml_tests/negative_tests/project_path/include/no_slash.yml'; +import ProjectPathIncludeTailSlashYaml from './yaml_tests/negative_tests/project_path/include/tailing_slash.yml'; +import ProjectPathTriggerIncludeEmptyYaml from './yaml_tests/negative_tests/project_path/trigger/include/empty.yml'; +import ProjectPathTriggerIncludeInvalidVariableYaml from './yaml_tests/negative_tests/project_path/trigger/include/invalid_variable.yml'; +import ProjectPathTriggerIncludeLeadSlashYaml from './yaml_tests/negative_tests/project_path/trigger/include/leading_slash.yml'; +import ProjectPathTriggerIncludeNoSlashYaml from './yaml_tests/negative_tests/project_path/trigger/include/no_slash.yml'; +import ProjectPathTriggerIncludeTailSlashYaml from './yaml_tests/negative_tests/project_path/trigger/include/tailing_slash.yml'; +import ProjectPathTriggerMinimalEmptyYaml from './yaml_tests/negative_tests/project_path/trigger/minimal/empty.yml'; +import ProjectPathTriggerMinimalInvalidVariableYaml from './yaml_tests/negative_tests/project_path/trigger/minimal/invalid_variable.yml'; +import ProjectPathTriggerMinimalLeadSlashYaml from './yaml_tests/negative_tests/project_path/trigger/minimal/leading_slash.yml'; +import ProjectPathTriggerMinimalNoSlashYaml from './yaml_tests/negative_tests/project_path/trigger/minimal/no_slash.yml'; +import ProjectPathTriggerMinimalTailSlashYaml from './yaml_tests/negative_tests/project_path/trigger/minimal/tailing_slash.yml'; +import ProjectPathTriggerProjectEmptyYaml from './yaml_tests/negative_tests/project_path/trigger/project/empty.yml'; +import ProjectPathTriggerProjectInvalidVariableYaml from './yaml_tests/negative_tests/project_path/trigger/project/invalid_variable.yml'; +import ProjectPathTriggerProjectLeadSlashYaml from './yaml_tests/negative_tests/project_path/trigger/project/leading_slash.yml'; +import ProjectPathTriggerProjectNoSlashYaml from './yaml_tests/negative_tests/project_path/trigger/project/no_slash.yml'; +import ProjectPathTriggerProjectTailSlashYaml from './yaml_tests/negative_tests/project_path/trigger/project/tailing_slash.yml'; const ajv = new Ajv({ strictTypes: false, @@ -67,6 +91,8 @@ describe('positive tests', () => { FilterYaml, IncludeYaml, RulesYaml, + VariablesYaml, + ProjectPathYaml, }), )('schema validates %s', (_, input) => { expect(input).toValidateJsonSchema(schema); @@ -90,6 +116,27 @@ describe('negative tests', () => { CacheNegativeYaml, IncludeNegativeYaml, RulesNegativeYaml, + VariablesNegativeYaml, + ProjectPathIncludeEmptyYaml, + ProjectPathIncludeInvalidVariableYaml, + ProjectPathIncludeLeadSlashYaml, + ProjectPathIncludeNoSlashYaml, + ProjectPathIncludeTailSlashYaml, + ProjectPathTriggerIncludeEmptyYaml, + ProjectPathTriggerIncludeInvalidVariableYaml, + ProjectPathTriggerIncludeLeadSlashYaml, + ProjectPathTriggerIncludeNoSlashYaml, + ProjectPathTriggerIncludeTailSlashYaml, + ProjectPathTriggerMinimalEmptyYaml, + ProjectPathTriggerMinimalInvalidVariableYaml, + ProjectPathTriggerMinimalLeadSlashYaml, + ProjectPathTriggerMinimalNoSlashYaml, + ProjectPathTriggerMinimalTailSlashYaml, + ProjectPathTriggerProjectEmptyYaml, + ProjectPathTriggerProjectInvalidVariableYaml, + ProjectPathTriggerProjectLeadSlashYaml, + ProjectPathTriggerProjectNoSlashYaml, + ProjectPathTriggerProjectTailSlashYaml, }), )('schema validates %s', (_, input) => { expect(input).not.toValidateJsonSchema(schema); diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/include/empty.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/include/empty.yml new file mode 100644 index 00000000000..d9838fbb6fd --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/include/empty.yml @@ -0,0 +1,3 @@ +include: + - project: '' + file: '/templates/.gitlab-ci-template.yml' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/include/invalid_variable.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/include/invalid_variable.yml new file mode 100644 index 00000000000..32933f856c7 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/include/invalid_variable.yml @@ -0,0 +1,3 @@ +include: + - project: 'slug#' + file: '/templates/.gitlab-ci-template.yml' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/include/leading_slash.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/include/leading_slash.yml new file mode 100644 index 00000000000..c463318be31 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/include/leading_slash.yml @@ -0,0 +1,3 @@ +include: + - project: '/slug' + file: '/templates/.gitlab-ci-template.yml' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/include/no_slash.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/include/no_slash.yml new file mode 100644 index 00000000000..51194a1d40c --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/include/no_slash.yml @@ -0,0 +1,3 @@ +include: + - project: 'slug' + file: '/templates/.gitlab-ci-template.yml' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/include/tailing_slash.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/include/tailing_slash.yml new file mode 100644 index 00000000000..91f258888d8 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/include/tailing_slash.yml @@ -0,0 +1,3 @@ +include: + - project: 'slug/' + file: '/templates/.gitlab-ci-template.yml' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/include/empty.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/include/empty.yml new file mode 100644 index 00000000000..ee2bb3e8ace --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/include/empty.yml @@ -0,0 +1,5 @@ +trigger-include: + trigger: + include: + - file: '/path/to/child-pipeline.yml' + project: '' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/include/invalid_variable.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/include/invalid_variable.yml new file mode 100644 index 00000000000..770305be0dc --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/include/invalid_variable.yml @@ -0,0 +1,5 @@ +trigger-include: + trigger: + include: + - file: '/path/to/child-pipeline.yml' + project: 'slug#' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/include/leading_slash.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/include/leading_slash.yml new file mode 100644 index 00000000000..82fd77cf0d3 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/include/leading_slash.yml @@ -0,0 +1,5 @@ +trigger-include: + trigger: + include: + - file: '/path/to/child-pipeline.yml' + project: '/slug' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/include/no_slash.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/include/no_slash.yml new file mode 100644 index 00000000000..f4ea59c7945 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/include/no_slash.yml @@ -0,0 +1,5 @@ +trigger-include: + trigger: + include: + - file: '/path/to/child-pipeline.yml' + project: 'slug' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/include/tailing_slash.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/include/tailing_slash.yml new file mode 100644 index 00000000000..a0195c03352 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/include/tailing_slash.yml @@ -0,0 +1,5 @@ +trigger-include: + trigger: + include: + - file: '/path/to/child-pipeline.yml' + project: 'slug/' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/minimal/empty.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/minimal/empty.yml new file mode 100644 index 00000000000..cad8dbbf430 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/minimal/empty.yml @@ -0,0 +1,2 @@ +trigger-minimal: + trigger: '' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/minimal/invalid_variable.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/minimal/invalid_variable.yml new file mode 100644 index 00000000000..6ca37666d09 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/minimal/invalid_variable.yml @@ -0,0 +1,2 @@ +trigger-minimal: + trigger: 'slug#' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/minimal/leading_slash.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/minimal/leading_slash.yml new file mode 100644 index 00000000000..9d7c6b44125 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/minimal/leading_slash.yml @@ -0,0 +1,2 @@ +trigger-minimal: + trigger: '/slug' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/minimal/no_slash.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/minimal/no_slash.yml new file mode 100644 index 00000000000..acd047477c8 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/minimal/no_slash.yml @@ -0,0 +1,2 @@ +trigger-minimal: + trigger: 'slug' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/minimal/tailing_slash.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/minimal/tailing_slash.yml new file mode 100644 index 00000000000..0fdd00da3de --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/minimal/tailing_slash.yml @@ -0,0 +1,2 @@ +trigger-minimal: + trigger: 'slug/' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/project/empty.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/project/empty.yml new file mode 100644 index 00000000000..0aa2330cecb --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/project/empty.yml @@ -0,0 +1,3 @@ +trigger-project: + trigger: + project: '' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/project/invalid_variable.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/project/invalid_variable.yml new file mode 100644 index 00000000000..3c17ec62039 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/project/invalid_variable.yml @@ -0,0 +1,3 @@ +trigger-project: + trigger: + project: 'slug#' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/project/leading_slash.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/project/leading_slash.yml new file mode 100644 index 00000000000..f9884603171 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/project/leading_slash.yml @@ -0,0 +1,3 @@ +trigger-project: + trigger: + project: '/slug' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/project/no_slash.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/project/no_slash.yml new file mode 100644 index 00000000000..d89e09756eb --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/project/no_slash.yml @@ -0,0 +1,3 @@ +trigger-project: + trigger: + project: 'slug' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/project/tailing_slash.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/project/tailing_slash.yml new file mode 100644 index 00000000000..3c39d6be4cb --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/project_path/trigger/project/tailing_slash.yml @@ -0,0 +1,3 @@ +trigger-project: + trigger: + project: 'slug/' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/variables.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/variables.yml new file mode 100644 index 00000000000..a7f23cf0d73 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/variables.yml @@ -0,0 +1,5 @@ +# invalid variable (unknown keyword is used) +variables: + FOO: + value: BAR + desc: A single value variable diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/project_path.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/project_path.yml new file mode 100644 index 00000000000..8a12cdf4f15 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/project_path.yml @@ -0,0 +1,101 @@ +# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95469 +# Test cases: +# - include file from project +# - trigger pipeline from project, 3 forms (see schema at ci.json) +# +# Sub-cases - forms of project path: +# - common case: group/project +# - sub-group: group/sub-group/project +# - variable: $FOO +# - variable in string: group/$VAR/project +# - invalid variable: $. +# (testing regex, that does not validate variable names) + +# BEGIN CASE: include yml from project +include: + - project: 'group/project' + file: '/templates/.gitlab-ci-template.yml' + + - project: 'group/sub-group/project' + file: '/templates/.gitlab-ci-template.yml' + + - project: '$FOO' + file: '/templates/.gitlab-ci-template.yml' + + - project: 'group/$VAR/project' + file: '/templates/.gitlab-ci-template.yml' + + - project: '$.' + file: '/templates/.gitlab-ci-template.yml' +# END CASE + +# BEGIN CASE: trigger minimal +trigger-minimal: + trigger: 'group/project' + +trigger-minimal-sub-group: + trigger: 'group/sub-group/project' + +trigger-minimal-variable: + trigger: '$FOO' + +trigger-minimal-variable-in-string: + trigger: 'group/$VAR/project' + +trigger-minimal-invalid-variable: + trigger: '$.' +# END CASE + +# BEGIN CASE: trigger project +trigger-project: + trigger: + project: 'group/project' + +trigger-project-sub-group: + trigger: + project: 'group/sub-group/project' + +trigger-project-variable: + trigger: + project: '$FOO' + +trigger-project-variable-in-string: + trigger: + project: 'group/$VAR/project' + +trigger-project-invalid-variable: + trigger: + project: '$.' +# END CASE + +# BEGIN CASE: trigger file +trigger-include: + trigger: + include: + - project: 'group/project' + file: '/path/to/child-pipeline.yml' + +trigger-include-sub-group: + trigger: + include: + - project: 'group/sub-group/project' + file: '/path/to/child-pipeline.yml' + +trigger-include-variable: + trigger: + include: + - project: '$FOO' + file: '/path/to/child-pipeline.yml' + +trigger-include-variable-in-string: + trigger: + include: + - project: 'group/$VAR/project' + file: '/path/to/child-pipeline.yml' + +trigger-include-invalid-variable: + trigger: + include: + - project: '$.' + file: '/path/to/child-pipeline.yml' +# END CASE diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml index 37cae6b4264..ef604f707b5 100644 --- a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml @@ -15,7 +15,9 @@ rules:changes as array of strings: # valid workflow:rules:exists # valid rules:changes:path +# valid workflow:name workflow: + name: 'Pipeline name' rules: - changes: paths: diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/variables.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/variables.yml new file mode 100644 index 00000000000..ee71087a72e --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/variables.yml @@ -0,0 +1,8 @@ +variables: + TEST_VAR: "hello world!" + 123456: "123456" + FOO: + value: "BAR" + description: "A single value variable" + DEPLOY_ENVIRONMENT: + description: "A multi-value variable" diff --git a/spec/frontend/editor/source_editor_ci_schema_ext_spec.js b/spec/frontend/editor/source_editor_ci_schema_ext_spec.js index 9a14e1a55eb..21f8979f1a9 100644 --- a/spec/frontend/editor/source_editor_ci_schema_ext_spec.js +++ b/spec/frontend/editor/source_editor_ci_schema_ext_spec.js @@ -1,4 +1,4 @@ -import { languages } from 'monaco-editor'; +import { setDiagnosticsOptions } from 'monaco-yaml'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext'; @@ -52,16 +52,12 @@ describe('~/editor/editor_ci_config_ext', () => { }); describe('registerCiSchema', () => { - beforeEach(() => { - jest.spyOn(languages.yaml.yamlDefaults, 'setDiagnosticsOptions'); - }); - describe('register validations options with monaco for yaml language', () => { const mockProjectNamespace = 'namespace1'; const mockProjectPath = 'project1'; const getConfiguredYmlSchema = () => { - return languages.yaml.yamlDefaults.setDiagnosticsOptions.mock.calls[0][0].schemas[0]; + return setDiagnosticsOptions.mock.calls[0][0].schemas[0]; }; it('with expected basic validation configuration', () => { @@ -77,8 +73,8 @@ describe('~/editor/editor_ci_config_ext', () => { completion: true, }; - expect(languages.yaml.yamlDefaults.setDiagnosticsOptions).toHaveBeenCalledTimes(1); - expect(languages.yaml.yamlDefaults.setDiagnosticsOptions).toHaveBeenCalledWith( + expect(setDiagnosticsOptions).toHaveBeenCalledTimes(1); + expect(setDiagnosticsOptions).toHaveBeenCalledWith( expect.objectContaining(expectedOptions), ); }); diff --git a/spec/frontend/editor/source_editor_instance_spec.js b/spec/frontend/editor/source_editor_instance_spec.js index 20ba23d56ff..89b5ad27690 100644 --- a/spec/frontend/editor/source_editor_instance_spec.js +++ b/spec/frontend/editor/source_editor_instance_spec.js @@ -160,7 +160,7 @@ describe('Source Editor Instance', () => { }); describe('public API', () => { - it.each(['use', 'unuse'], 'provides "%s" as public method by default', (method) => { + it.each(['use', 'unuse'])('provides "%s" as public method by default', (method) => { seInstance = new SourceEditorInstance(); expect(seInstance[method]).toBeDefined(); }); 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 fe20c23e4d7..1ff351b6554 100644 --- a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js +++ b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js @@ -12,7 +12,7 @@ import { } from '~/editor/constants'; import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext'; import SourceEditor from '~/editor/source_editor'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import syntaxHighlight from '~/syntax_highlight'; import { spyOnApi } from './helpers'; @@ -279,7 +279,7 @@ describe('Markdown Live Preview Extension for Source Editor', () => { mockAxios.onPost().reply(500); await fetchPreview(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/environments/delete_environment_modal_spec.js b/spec/frontend/environments/delete_environment_modal_spec.js index 48e4f661c1d..cc18bf754eb 100644 --- a/spec/frontend/environments/delete_environment_modal_spec.js +++ b/spec/frontend/environments/delete_environment_modal_spec.js @@ -6,7 +6,7 @@ import { s__, sprintf } from '~/locale'; import DeleteEnvironmentModal from '~/environments/components/delete_environment_modal.vue'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { resolvedEnvironment } from './graphql/mock_data'; jest.mock('~/flash'); @@ -57,7 +57,7 @@ describe('~/environments/components/delete_environment_modal.vue', () => { await nextTick(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(deleteResolver).toHaveBeenCalledWith( expect.anything(), @@ -76,7 +76,7 @@ describe('~/environments/components/delete_environment_modal.vue', () => { await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith( + expect(createAlert).toHaveBeenCalledWith( expect.objectContaining({ message: s__( 'Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again.', diff --git a/spec/frontend/environments/edit_environment_spec.js b/spec/frontend/environments/edit_environment_spec.js index 0f2d6e95bf0..5ea23af4c16 100644 --- a/spec/frontend/environments/edit_environment_spec.js +++ b/spec/frontend/environments/edit_environment_spec.js @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import EditEnvironment from '~/environments/components/edit_environment.vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -85,7 +85,7 @@ describe('~/environments/components/edit.vue', () => { await submitForm(expected, [400, { message: ['uh oh!'] }]); - expect(createFlash).toHaveBeenCalledWith({ message: 'uh oh!' }); + expect(createAlert).toHaveBeenCalledWith({ message: 'uh oh!' }); expect(showsLoading()).toBe(false); }); diff --git a/spec/frontend/environments/empty_state_spec.js b/spec/frontend/environments/empty_state_spec.js index 974afc6d032..02cf2dc3c68 100644 --- a/spec/frontend/environments/empty_state_spec.js +++ b/spec/frontend/environments/empty_state_spec.js @@ -4,10 +4,21 @@ import EmptyState from '~/environments/components/empty_state.vue'; import { ENVIRONMENTS_SCOPE } from '~/environments/constants'; const HELP_PATH = '/help'; +const NEW_PATH = '/new'; describe('~/environments/components/empty_state.vue', () => { let wrapper; + const findNewEnvironmentLink = () => + wrapper.findByRole('link', { + name: s__('Environments|New environment'), + }); + + const findDocsLink = () => + wrapper.findByRole('link', { + name: s__('Environments|How do I create an environment?'), + }); + const createWrapper = ({ propsData = {} } = {}) => mountExtended(EmptyState, { propsData: { @@ -15,6 +26,7 @@ describe('~/environments/components/empty_state.vue', () => { helpPath: HELP_PATH, ...propsData, }, + provide: { newEnvironmentPath: NEW_PATH }, }); afterEach(() => { @@ -44,10 +56,44 @@ describe('~/environments/components/empty_state.vue', () => { it('shows a link to the the help path', () => { wrapper = createWrapper(); - const link = wrapper.findByRole('link', { - name: s__('Environments|How do I create an environment?'), - }); + const link = findDocsLink(); expect(link.attributes('href')).toBe(HELP_PATH); }); + + it('hides a link to creating a new environment', () => { + const link = findNewEnvironmentLink(); + + expect(link.exists()).toBe(false); + }); + + describe('with search term', () => { + beforeEach(() => { + wrapper = createWrapper({ propsData: { hasTerm: true } }); + }); + + it('should show text about searching', () => { + const header = wrapper.findByRole('heading', { + name: s__('Environments|No results found'), + }); + + expect(header.exists()).toBe(true); + + const text = wrapper.findByText(s__('Environments|Edit your search and try again')); + + expect(text.exists()).toBe(true); + }); + + it('hides the documentation link', () => { + const link = findDocsLink(); + + expect(link.exists()).toBe(false); + }); + + it('shows a link to create a new environment', () => { + const link = findNewEnvironmentLink(); + + expect(link.attributes('href')).toBe(NEW_PATH); + }); + }); }); diff --git a/spec/frontend/environments/enable_review_app_modal_spec.js b/spec/frontend/environments/enable_review_app_modal_spec.js index b6dac811ea6..7939bd600dc 100644 --- a/spec/frontend/environments/enable_review_app_modal_spec.js +++ b/spec/frontend/environments/enable_review_app_modal_spec.js @@ -1,7 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import { GlModal } from '@gitlab/ui'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import EnableReviewAppButton from '~/environments/components/enable_review_app_modal.vue'; +import EnableReviewAppModal from '~/environments/components/enable_review_app_modal.vue'; +import { REVIEW_APP_MODAL_I18N as i18n } from '~/environments/constants'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; // hardcode uniqueId for determinism @@ -9,10 +10,12 @@ jest.mock('lodash/uniqueId', () => (x) => `${x}77`); const EXPECTED_COPY_PRE_ID = 'enable-review-app-copy-string-77'; -describe('Enable Review App Button', () => { +describe('Enable Review App Modal', () => { let wrapper; let modal; + const findInstructions = () => wrapper.findAll('ol li'); + const findInstructionAt = (i) => wrapper.findAll('ol li').at(i); const findCopyString = () => wrapper.find(`#${EXPECTED_COPY_PRE_ID}`); afterEach(() => { @@ -22,29 +25,31 @@ describe('Enable Review App Button', () => { describe('renders the modal', () => { beforeEach(() => { wrapper = extendedWrapper( - shallowMount(EnableReviewAppButton, { + shallowMount(EnableReviewAppModal, { propsData: { modalId: 'fake-id', visible: true, }, - provide: { - defaultBranchName: 'main', - }, }), ); modal = wrapper.findComponent(GlModal); }); - it('renders the defaultBranchName copy', () => { - expect(findCopyString().text()).toContain('- main'); + it('displays instructions', () => { + expect(findInstructions().length).toBe(7); + expect(findInstructionAt(0).text()).toContain(i18n.instructions.step1); + }); + + it('renders the snippet to copy', () => { + expect(findCopyString().text()).toBe(wrapper.vm.modalInfoCopyStr); }); it('renders the copyToClipboard button', () => { expect(wrapper.findComponent(ModalCopyButton).props()).toMatchObject({ modalId: 'fake-id', target: `#${EXPECTED_COPY_PRE_ID}`, - title: 'Copy snippet text', + title: i18n.copyToClipboardText, }); }); diff --git a/spec/frontend/environments/environment_external_url_spec.js b/spec/frontend/environments/environment_external_url_spec.js index 4c133665979..5966993166b 100644 --- a/spec/frontend/environments/environment_external_url_spec.js +++ b/spec/frontend/environments/environment_external_url_spec.js @@ -1,16 +1,35 @@ import { mount } from '@vue/test-utils'; +import { s__, __ } from '~/locale'; import ExternalUrlComp from '~/environments/components/environment_external_url.vue'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; describe('External URL Component', () => { let wrapper; - const externalUrl = 'https://gitlab.com'; + let externalUrl; - beforeEach(() => { - wrapper = mount(ExternalUrlComp, { propsData: { externalUrl } }); + describe('with safe link', () => { + beforeEach(() => { + externalUrl = 'https://gitlab.com'; + wrapper = mount(ExternalUrlComp, { propsData: { externalUrl } }); + }); + + it('should link to the provided externalUrl prop', () => { + expect(wrapper.attributes('href')).toBe(externalUrl); + expect(wrapper.find('a').exists()).toBe(true); + }); }); - it('should link to the provided externalUrl prop', () => { - expect(wrapper.attributes('href')).toEqual(externalUrl); - expect(wrapper.find('a').exists()).toBe(true); + describe('with unsafe link', () => { + beforeEach(() => { + externalUrl = 'postgres://gitlab'; + wrapper = mount(ExternalUrlComp, { propsData: { externalUrl } }); + }); + + it('should show a copy button instead', () => { + const button = wrapper.findComponent(ModalCopyButton); + expect(button.props('text')).toBe(externalUrl); + expect(button.text()).toBe(__('Copy URL')); + expect(button.props('title')).toBe(s__('Environments|Copy live environment URL')); + }); }); }); diff --git a/spec/frontend/environments/environment_folder_spec.js b/spec/frontend/environments/environment_folder_spec.js index 48624f2324b..a37515bc3f7 100644 --- a/spec/frontend/environments/environment_folder_spec.js +++ b/spec/frontend/environments/environment_folder_spec.js @@ -31,6 +31,7 @@ describe('~/environments/components/environments_folder.vue', () => { apolloProvider, propsData: { scope: 'available', + search: '', ...propsData, }, stubs: { transition: stubTransition() }, @@ -137,13 +138,26 @@ describe('~/environments/components/environments_folder.vue', () => { expect(environmentFolderMock).toHaveBeenCalledTimes(1); expect(environmentFolderMock).toHaveBeenCalledWith( {}, - { - environment: nestedEnvironment.latest, - scope, - }, + expect.objectContaining({ scope }), expect.anything(), expect.anything(), ); }, ); + + it('should query for the entered parameter', async () => { + const search = 'hello'; + + wrapper = createWrapper({ nestedEnvironment, search }, createApolloProvider()); + + await nextTick(); + await waitForPromises(); + + expect(environmentFolderMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ search }), + expect.anything(), + expect.anything(), + ); + }); }); diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js index aff54107d6b..65a9f2907d2 100644 --- a/spec/frontend/environments/environments_app_spec.js +++ b/spec/frontend/environments/environments_app_spec.js @@ -71,7 +71,7 @@ describe('~/environments/components/environments_app.vue', () => { previousPage: 1, __typename: 'LocalPageInfo', }, - location = '?scope=available&page=2', + location = '?scope=available&page=2&search=prod', }) => { setWindowLocation(location); environmentAppMock.mockReturnValue(environmentsApp); @@ -104,7 +104,7 @@ describe('~/environments/components/environments_app.vue', () => { await createWrapperWithMocked({ environmentsApp: resolvedEnvironmentsApp, folder: resolvedFolder, - location: '?scope=bad&page=2', + location: '?scope=bad&page=2&search=prod', }); expect(environmentAppMock).toHaveBeenCalledWith( @@ -350,7 +350,54 @@ describe('~/environments/components/environments_app.vue', () => { next.trigger('click'); await nextTick(); - expect(window.location.search).toBe('?scope=available&page=3'); + expect(window.location.search).toBe('?scope=available&page=3&search=prod'); + }); + }); + + describe('search', () => { + let searchBox; + + const waitForDebounce = async () => { + await nextTick(); + jest.runOnlyPendingTimers(); + }; + + beforeEach(async () => { + await createWrapperWithMocked({ + environmentsApp: resolvedEnvironmentsApp, + folder: resolvedFolder, + }); + searchBox = wrapper.findByRole('searchbox', { + name: s__('Environments|Search by environment name'), + }); + }); + + it('should sync the query params to the new search', async () => { + searchBox.setValue('hello'); + + await waitForDebounce(); + + expect(window.location.search).toBe('?scope=available&page=1&search=hello'); + }); + + it('should query for the entered parameter', async () => { + const search = 'hello'; + + searchBox.setValue(search); + + await waitForDebounce(); + await waitForPromises(); + + expect(environmentAppMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ search }), + expect.anything(), + expect.anything(), + ); + }); + + it('should sync search term from query params on load', async () => { + expect(searchBox.element.value).toBe('prod'); }); }); }); diff --git a/spec/frontend/environments/environments_detail_header_spec.js b/spec/frontend/environments/environments_detail_header_spec.js index 4687119127d..1f233c05fbf 100644 --- a/spec/frontend/environments/environments_detail_header_spec.js +++ b/spec/frontend/environments/environments_detail_header_spec.js @@ -1,10 +1,12 @@ import { GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { __, s__ } from '~/locale'; import DeleteEnvironmentModal from '~/environments/components/delete_environment_modal.vue'; import EnvironmentsDetailHeader from '~/environments/components/environments_detail_header.vue'; import StopEnvironmentModal from '~/environments/components/stop_environment_modal.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; import { createEnvironment } from './mock_data'; describe('Environments detail header component', () => { @@ -243,4 +245,23 @@ describe('Environments detail header component', () => { expect(findDeleteEnvironmentModal().exists()).toBe(true); }); }); + + describe('when the environment has an unsafe external url', () => { + const externalUrl = 'postgres://staging'; + + beforeEach(() => { + createWrapper({ + props: { + environment: createEnvironment({ externalUrl }), + }, + }); + }); + + it('should show a copy button instead', () => { + const button = wrapper.findComponent(ModalCopyButton); + expect(button.props('title')).toBe(s__('Environments|Copy live environment URL')); + expect(button.props('text')).toBe(externalUrl); + expect(button.text()).toBe(__('Copy URL')); + }); + }); }); diff --git a/spec/frontend/environments/graphql/resolvers_spec.js b/spec/frontend/environments/graphql/resolvers_spec.js index 26f0659204a..7684cca2303 100644 --- a/spec/frontend/environments/graphql/resolvers_spec.js +++ b/spec/frontend/environments/graphql/resolvers_spec.js @@ -41,11 +41,16 @@ describe('~/frontend/environments/graphql/resolvers', () => { it('should fetch environments and map them to frontend data', async () => { const cache = { writeQuery: jest.fn() }; const scope = 'available'; + const search = ''; mock - .onGet(ENDPOINT, { params: { nested: true, scope, page: 1 } }) + .onGet(ENDPOINT, { params: { nested: true, scope, page: 1, search } }) .reply(200, environmentsApp, {}); - const app = await mockResolvers.Query.environmentApp(null, { scope, page: 1 }, { cache }); + const app = await mockResolvers.Query.environmentApp( + null, + { scope, page: 1, search }, + { cache }, + ); expect(app).toEqual(resolvedEnvironmentsApp); expect(cache.writeQuery).toHaveBeenCalledWith({ query: pollIntervalQuery, @@ -57,12 +62,12 @@ describe('~/frontend/environments/graphql/resolvers', () => { const scope = 'stopped'; const interval = 3000; mock - .onGet(ENDPOINT, { params: { nested: true, scope, page: 1 } }) + .onGet(ENDPOINT, { params: { nested: true, scope, page: 1, search: '' } }) .reply(200, environmentsApp, { 'poll-interval': interval, }); - await mockResolvers.Query.environmentApp(null, { scope, page: 1 }, { cache }); + await mockResolvers.Query.environmentApp(null, { scope, page: 1, search: '' }, { cache }); expect(cache.writeQuery).toHaveBeenCalledWith({ query: pollIntervalQuery, data: { interval }, @@ -72,7 +77,7 @@ describe('~/frontend/environments/graphql/resolvers', () => { const cache = { writeQuery: jest.fn() }; const scope = 'stopped'; mock - .onGet(ENDPOINT, { params: { nested: true, scope, page: 1 } }) + .onGet(ENDPOINT, { params: { nested: true, scope, page: 1, search: '' } }) .reply(200, environmentsApp, { 'x-next-page': '2', 'x-page': '1', @@ -82,7 +87,7 @@ describe('~/frontend/environments/graphql/resolvers', () => { 'X-Total-Pages': '5', }); - await mockResolvers.Query.environmentApp(null, { scope, page: 1 }, { cache }); + await mockResolvers.Query.environmentApp(null, { scope, page: 1, search: '' }, { cache }); expect(cache.writeQuery).toHaveBeenCalledWith({ query: pageInfoQuery, data: { @@ -102,10 +107,10 @@ describe('~/frontend/environments/graphql/resolvers', () => { const cache = { writeQuery: jest.fn() }; const scope = 'stopped'; mock - .onGet(ENDPOINT, { params: { nested: true, scope, page: 1 } }) + .onGet(ENDPOINT, { params: { nested: true, scope, page: 1, search: '' } }) .reply(200, environmentsApp, {}); - await mockResolvers.Query.environmentApp(null, { scope, page: 1 }, { cache }); + await mockResolvers.Query.environmentApp(null, { scope, page: 1, search: '' }, { cache }); expect(cache.writeQuery).toHaveBeenCalledWith({ query: pageInfoQuery, data: { @@ -124,11 +129,14 @@ describe('~/frontend/environments/graphql/resolvers', () => { }); describe('folder', () => { it('should fetch the folder url passed to it', async () => { - mock.onGet(ENDPOINT, { params: { per_page: 3, scope: 'available' } }).reply(200, folder); + mock + .onGet(ENDPOINT, { params: { per_page: 3, scope: 'available', search: '' } }) + .reply(200, folder); const environmentFolder = await mockResolvers.Query.folder(null, { environment: { folderPath: ENDPOINT }, scope: 'available', + search: '', }); expect(environmentFolder).toEqual(resolvedFolder); diff --git a/spec/frontend/environments/new_environment_spec.js b/spec/frontend/environments/new_environment_spec.js index 2405cb82eac..6dd4eea7437 100644 --- a/spec/frontend/environments/new_environment_spec.js +++ b/spec/frontend/environments/new_environment_spec.js @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import NewEnvironment from '~/environments/components/new_environment.vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -94,7 +94,7 @@ describe('~/environments/components/new.vue', () => { await submitForm(expected, [400, { message: ['name taken'] }]); - expect(createFlash).toHaveBeenCalledWith({ message: 'name taken' }); + expect(createAlert).toHaveBeenCalledWith({ message: 'name taken' }); expect(showsLoading()).toBe(false); }); }); diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js index 732eff65495..9d6e46be8c4 100644 --- a/spec/frontend/error_tracking/components/error_details_spec.js +++ b/spec/frontend/error_tracking/components/error_details_spec.js @@ -18,7 +18,7 @@ import { trackErrorDetailsViewsOptions, trackErrorStatusUpdateOptions, } from '~/error_tracking/utils'; -import createFlash from '~/flash'; +import { createAlert, VARIANT_WARNING } from '~/flash'; import { __ } from '~/locale'; import Tracking from '~/tracking'; @@ -144,7 +144,7 @@ describe('ErrorDetails', () => { await nextTick(); expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(mocks.$apollo.queries.error.stopPolling).not.toHaveBeenCalled(); }); @@ -156,9 +156,9 @@ describe('ErrorDetails', () => { await nextTick(); expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); expect(wrapper.findComponent(GlLink).exists()).toBe(false); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'Could not connect to Sentry. Refresh the page to try again.', - type: 'warning', + variant: VARIANT_WARNING, }); expect(mocks.$apollo.queries.error.stopPolling).toHaveBeenCalled(); }); diff --git a/spec/frontend/error_tracking/store/actions_spec.js b/spec/frontend/error_tracking/store/actions_spec.js index 6bac21341a7..8f085282f80 100644 --- a/spec/frontend/error_tracking/store/actions_spec.js +++ b/spec/frontend/error_tracking/store/actions_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import * as actions from '~/error_tracking/store/actions'; import * as types from '~/error_tracking/store/mutation_types'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -20,7 +20,7 @@ describe('Sentry common store actions', () => { afterEach(() => { mock.restore(); - createFlash.mockClear(); + createAlert.mockClear(); }); const endpoint = '123/stacktrace'; const redirectUrl = '/list'; @@ -49,7 +49,7 @@ describe('Sentry common store actions', () => { mock.onPut().reply(400, {}); await testAction(actions.updateStatus, params, {}, [], []); expect(visitUrl).not.toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); }); }); diff --git a/spec/frontend/error_tracking/store/details/actions_spec.js b/spec/frontend/error_tracking/store/details/actions_spec.js index a3a6f7cc309..1893d226270 100644 --- a/spec/frontend/error_tracking/store/details/actions_spec.js +++ b/spec/frontend/error_tracking/store/details/actions_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import * as actions from '~/error_tracking/store/details/actions'; import * as types from '~/error_tracking/store/details/mutation_types'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import Poll from '~/lib/utils/poll'; @@ -19,7 +19,7 @@ describe('Sentry error details store actions', () => { afterEach(() => { mockedAdapter.restore(); - createFlash.mockClear(); + createAlert.mockClear(); if (mockedRestart) { mockedRestart.mockRestore(); mockedRestart = null; @@ -53,7 +53,7 @@ describe('Sentry error details store actions', () => { [{ type: types.SET_LOADING_STACKTRACE, payload: false }], [], ); - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); }); it('should not restart polling when receiving an empty 204 response', async () => { diff --git a/spec/frontend/error_tracking/store/list/actions_spec.js b/spec/frontend/error_tracking/store/list/actions_spec.js index 7173f68bb96..2809bbe834e 100644 --- a/spec/frontend/error_tracking/store/list/actions_spec.js +++ b/spec/frontend/error_tracking/store/list/actions_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import * as actions from '~/error_tracking/store/list/actions'; import * as types from '~/error_tracking/store/list/mutation_types'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import httpStatusCodes from '~/lib/utils/http_status'; @@ -51,7 +51,7 @@ describe('error tracking actions', () => { ], [], ); - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); }); }); diff --git a/spec/frontend/feature_highlight/feature_highlight_helper_spec.js b/spec/frontend/feature_highlight/feature_highlight_helper_spec.js index b87571830ca..22bac3fca15 100644 --- a/spec/frontend/feature_highlight/feature_highlight_helper_spec.js +++ b/spec/frontend/feature_highlight/feature_highlight_helper_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import { dismiss } from '~/feature_highlight/feature_highlight_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import httpStatusCodes from '~/lib/utils/http_status'; @@ -32,7 +32,7 @@ describe('feature highlight helper', () => { await dismiss(endpoint, highlightId); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again.', }); diff --git a/spec/frontend/fixtures/namespaces.rb b/spec/frontend/fixtures/namespaces.rb index b11f661fe09..a3f295f4e66 100644 --- a/spec/frontend/fixtures/namespaces.rb +++ b/spec/frontend/fixtures/namespaces.rb @@ -7,38 +7,43 @@ RSpec.describe 'Jobs (JavaScript fixtures)' do include JavaScriptFixturesHelpers include GraphqlHelpers - describe GraphQL::Query, type: :request do + describe API::Projects, type: :request do let_it_be(:user) { create(:user) } - let_it_be(:groups) { create_list(:group, 4) } - before_all do - groups.each { |group| group.add_owner(user) } - end + describe 'transfer_locations' do + let_it_be(:groups) { create_list(:group, 4) } + let_it_be(:project) { create(:project, namespace: user.namespace) } - query_name = 'search_namespaces_where_user_can_transfer_projects' - query_extension = '.query.graphql' + before_all do + groups.each { |group| group.add_owner(user) } + end - full_input_path = "projects/settings/graphql/queries/#{query_name}#{query_extension}" - base_output_path = "graphql/projects/settings/#{query_name}" + it 'api/projects/transfer_locations_page_1.json' do + get api("/projects/#{project.id}/transfer_locations?per_page=2", user) - it "#{base_output_path}_page_1#{query_extension}.json" do - query = get_graphql_query_as_string(full_input_path) + expect(response).to be_successful + end - post_graphql(query, current_user: user, variables: { first: 2 }) + it 'api/projects/transfer_locations_page_2.json' do + get api("/projects/#{project.id}/transfer_locations?per_page=2&page=2", user) - expect_graphql_errors_to_be_empty + expect(response).to be_successful + end end + end + + describe GraphQL::Query, type: :request do + let_it_be(:user) { create(:user) } + + query_name = 'current_user_namespace.query.graphql' - it "#{base_output_path}_page_2#{query_extension}.json" do - query = get_graphql_query_as_string(full_input_path) + input_path = "projects/settings/graphql/queries/#{query_name}" + output_path = "graphql/projects/settings/#{query_name}.json" - post_graphql(query, current_user: user, variables: { first: 2 }) + it output_path do + query = get_graphql_query_as_string(input_path) - post_graphql( - query, - current_user: user, - variables: { first: 2, after: graphql_data_at('currentUser', 'groups', 'pageInfo', 'endCursor') } - ) + post_graphql(query, current_user: user) expect_graphql_errors_to_be_empty end diff --git a/spec/frontend/fixtures/pipeline_schedules.rb b/spec/frontend/fixtures/pipeline_schedules.rb index 5b7a445557e..4de0bd762f8 100644 --- a/spec/frontend/fixtures/pipeline_schedules.rb +++ b/spec/frontend/fixtures/pipeline_schedules.rb @@ -2,40 +2,74 @@ require 'spec_helper' -RSpec.describe Projects::PipelineSchedulesController, '(JavaScript fixtures)', type: :controller do +RSpec.describe 'Pipeline schedules (JavaScript fixtures)' do + include ApiHelpers include JavaScriptFixturesHelpers + include GraphqlHelpers let(:namespace) { create(:namespace, name: 'frontend-fixtures' ) } let(:project) { create(:project, :public, :repository) } let(:user) { project.first_owner } let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: user) } + let!(:pipeline_schedule_inactive) { create(:ci_pipeline_schedule, :inactive, project: project, owner: user) } let!(:pipeline_schedule_populated) { create(:ci_pipeline_schedule, project: project, owner: user) } let!(:pipeline_schedule_variable1) { create(:ci_pipeline_schedule_variable, key: 'foo', value: 'foovalue', pipeline_schedule: pipeline_schedule_populated) } let!(:pipeline_schedule_variable2) { create(:ci_pipeline_schedule_variable, key: 'bar', value: 'barvalue', pipeline_schedule: pipeline_schedule_populated) } - render_views + describe Projects::PipelineSchedulesController, type: :controller do + render_views - before do - sign_in(user) - end + before do + sign_in(user) + stub_feature_flags(pipeline_schedules_vue: false) + end + + it 'pipeline_schedules/edit.html' do + get :edit, params: { + namespace_id: project.namespace.to_param, + project_id: project, + id: pipeline_schedule.id + } + + expect(response).to be_successful + end - it 'pipeline_schedules/edit.html' do - get :edit, params: { - namespace_id: project.namespace.to_param, - project_id: project, - id: pipeline_schedule.id - } + it 'pipeline_schedules/edit_with_variables.html' do + get :edit, params: { + namespace_id: project.namespace.to_param, + project_id: project, + id: pipeline_schedule_populated.id + } - expect(response).to be_successful + expect(response).to be_successful + end end - it 'pipeline_schedules/edit_with_variables.html' do - get :edit, params: { - namespace_id: project.namespace.to_param, - project_id: project, - id: pipeline_schedule_populated.id - } + describe GraphQL::Query, type: :request do + before do + pipeline_schedule.pipelines << build(:ci_pipeline, project: project) + end + + fixtures_path = 'graphql/pipeline_schedules/' + get_pipeline_schedules_query = 'get_pipeline_schedules.query.graphql' + + let_it_be(:query) do + get_graphql_query_as_string("pipeline_schedules/graphql/queries/#{get_pipeline_schedules_query}") + end + + it "#{fixtures_path}#{get_pipeline_schedules_query}.json" do + post_graphql(query, current_user: user, variables: { projectPath: project.full_path }) + + expect_graphql_errors_to_be_empty + end + + it "#{fixtures_path}#{get_pipeline_schedules_query}.as_guest.json" do + guest = create(:user) + project.add_guest(user) + + post_graphql(query, current_user: guest, variables: { projectPath: project.full_path }) - expect(response).to be_successful + expect_graphql_errors_to_be_empty + end end end diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js index e26c52f0bf7..a809bf248bf 100644 --- a/spec/frontend/flash_spec.js +++ b/spec/frontend/flash_spec.js @@ -285,6 +285,13 @@ describe('Flash', () => { expect(document.querySelector('.gl-alert')).toBeNull(); }); + it('does not crash if calling .dismiss() twice', () => { + alert = createAlert({ message: mockMessage }); + + alert.dismiss(); + expect(() => alert.dismiss()).not.toThrow(); + }); + it('calls onDismiss when dismissed', () => { const dismissHandler = jest.fn(); diff --git a/spec/frontend/grafana_integration/components/grafana_integration_spec.js b/spec/frontend/grafana_integration/components/grafana_integration_spec.js index d2111194097..021a3aa41ed 100644 --- a/spec/frontend/grafana_integration/components/grafana_integration_spec.js +++ b/spec/frontend/grafana_integration/components/grafana_integration_spec.js @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { TEST_HOST } from 'helpers/test_constants'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import GrafanaIntegration from '~/grafana_integration/components/grafana_integration.vue'; import { createStore } from '~/grafana_integration/store'; import axios from '~/lib/utils/axios_utils'; @@ -30,7 +30,7 @@ describe('grafana integration component', () => { afterEach(() => { if (wrapper.destroy) { wrapper.destroy(); - createFlash.mockReset(); + createAlert.mockReset(); refreshCurrentPage.mockReset(); } }); @@ -113,7 +113,7 @@ describe('grafana integration component', () => { await nextTick(); await jest.runAllTicks(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: `There was an error saving your changes. ${message}`, }); }); diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js index a4a7530184d..091ec17d58e 100644 --- a/spec/frontend/groups/components/app_spec.js +++ b/spec/frontend/groups/components/app_spec.js @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import appComponent from '~/groups/components/app.vue'; import groupFolderComponent from '~/groups/components/group_folder.vue'; import groupItemComponent from '~/groups/components/group_item.vue'; @@ -11,6 +11,7 @@ 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 GroupsComponent from '~/groups/components/groups.vue'; import axios from '~/lib/utils/axios_utils'; import * as urlUtilities from '~/lib/utils/url_utility'; import setWindowLocation from 'helpers/set_window_location_helper'; @@ -115,7 +116,7 @@ describe('AppComponent', () => { return vm.fetchGroups({}).then(() => { expect(vm.isLoading).toBe(false); expect(window.scrollTo).toHaveBeenCalledWith({ behavior: 'smooth', top: 0 }); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'An error occurred. Please try again.', }); }); @@ -326,7 +327,7 @@ describe('AppComponent', () => { expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath); return waitForPromises().then(() => { expect(vm.store.removeGroup).not.toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledWith({ message }); + expect(createAlert).toHaveBeenCalledWith({ message }); expect(vm.targetGroup.isBeingRemoved).toBe(false); }); }); @@ -341,7 +342,7 @@ describe('AppComponent', () => { expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath); return waitForPromises().then(() => { expect(vm.store.removeGroup).not.toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledWith({ message }); + expect(createAlert).toHaveBeenCalledWith({ message }); expect(vm.targetGroup.isBeingRemoved).toBe(false); }); }); @@ -388,24 +389,27 @@ describe('AppComponent', () => { }); 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} + action | groups | fromSearch | shouldRenderEmptyState | searchEmpty + ${'subgroups_and_projects'} | ${[]} | ${false} | ${true} | ${false} + ${''} | ${[]} | ${false} | ${false} | ${false} + ${'subgroups_and_projects'} | ${mockGroups} | ${false} | ${false} | ${false} + ${'subgroups_and_projects'} | ${[]} | ${true} | ${false} | ${true} `( - '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 () => { + 'when `action` is $action, `groups` is $groups, and `fromSearch` is $fromSearch', + ({ action, groups, fromSearch, shouldRenderEmptyState, searchEmpty }) => { + it(`${shouldRenderEmptyState ? 'renders' : 'does not render'} empty state`, async () => { createShallowComponent({ - propsData: { action, renderEmptyState }, + propsData: { action, renderEmptyState: true }, }); + await waitForPromises(); + vm.updateGroups(groups, fromSearch); await nextTick(); - expect(wrapper.findComponent(EmptyState).exists()).toBe(expected); + expect(wrapper.findComponent(EmptyState).exists()).toBe(shouldRenderEmptyState); + expect(wrapper.findComponent(GroupsComponent).props('searchEmpty')).toBe(searchEmpty); }); }, ); @@ -440,18 +444,10 @@ describe('AppComponent', () => { expect(eventHub.$on).toHaveBeenCalledWith('showLeaveGroupModal', expect.any(Function)); expect(eventHub.$on).toHaveBeenCalledWith('updatePagination', expect.any(Function)); expect(eventHub.$on).toHaveBeenCalledWith('updateGroups', expect.any(Function)); - }); - - it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', async () => { - createShallowComponent(); - await nextTick(); - expect(vm.searchEmptyMessage).toBe('No groups or projects matched your search'); - }); - - it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', async () => { - createShallowComponent({ propsData: { hideProjects: true } }); - await nextTick(); - expect(vm.searchEmptyMessage).toBe('No groups matched your search'); + expect(eventHub.$on).toHaveBeenCalledWith( + 'fetchFilteredAndSortedGroups', + expect.any(Function), + ); }); }); @@ -468,6 +464,46 @@ describe('AppComponent', () => { expect(eventHub.$off).toHaveBeenCalledWith('showLeaveGroupModal', expect.any(Function)); expect(eventHub.$off).toHaveBeenCalledWith('updatePagination', expect.any(Function)); expect(eventHub.$off).toHaveBeenCalledWith('updateGroups', expect.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith( + 'fetchFilteredAndSortedGroups', + expect.any(Function), + ); + }); + }); + + describe('when `fetchFilteredAndSortedGroups` event is emitted', () => { + const search = 'Foo bar'; + const sort = 'created_asc'; + const emitFetchFilteredAndSortedGroups = () => { + eventHub.$emit('fetchFilteredAndSortedGroups', { + filterGroupsBy: search, + sortBy: sort, + }); + }; + let setPaginationInfoSpy; + + beforeEach(() => { + setPaginationInfoSpy = jest.spyOn(GroupsStore.prototype, 'setPaginationInfo'); + createShallowComponent(); + }); + + it('renders loading icon', async () => { + emitFetchFilteredAndSortedGroups(); + await nextTick(); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + + it('calls API with expected params', () => { + emitFetchFilteredAndSortedGroups(); + + expect(getGroupsSpy).toHaveBeenCalledWith(undefined, undefined, search, sort, undefined); + }); + + it('updates pagination', () => { + emitFetchFilteredAndSortedGroups(); + + expect(setPaginationInfoSpy).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js index 3aa66644c19..4570aa33a6c 100644 --- a/spec/frontend/groups/components/group_item_spec.js +++ b/spec/frontend/groups/components/group_item_spec.js @@ -245,19 +245,14 @@ describe('GroupItemComponent', () => { expect(vm.$el.querySelector('.group-list-tree')).toBeDefined(); }); }); + describe('schema.org props', () => { describe('when showSchemaMarkup is disabled on the group', () => { - it.each(['itemprop', 'itemtype', 'itemscope'], 'it does not set %s', (attr) => { + it.each(['itemprop', 'itemtype', 'itemscope'])('does not set %s', (attr) => { expect(wrapper.attributes(attr)).toBeUndefined(); }); - it.each( - ['.js-group-avatar', '.js-group-name', '.js-group-description'], - 'it does not set `itemprop` on sub-nodes', - (selector) => { - expect(wrapper.find(selector).attributes('itemprop')).toBeUndefined(); - }, - ); }); + describe('when group has microdata', () => { beforeEach(() => { const group = withMicrodata({ diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js index 866868eff36..0cbb6cc8309 100644 --- a/spec/frontend/groups/components/groups_spec.js +++ b/spec/frontend/groups/components/groups_spec.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { GlEmptyState } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import GroupFolderComponent from '~/groups/components/group_folder.vue'; @@ -15,7 +16,6 @@ describe('GroupsComponent', () => { const defaultPropsData = { groups: mockGroups, pageInfo: mockPageInfo, - searchEmptyMessage: 'No matching results', searchEmpty: false, }; @@ -67,13 +67,16 @@ describe('GroupsComponent', () => { expect(wrapper.findComponent(GroupFolderComponent).exists()).toBe(true); expect(findPaginationLinks().exists()).toBe(true); - expect(wrapper.findByText(defaultPropsData.searchEmptyMessage).exists()).toBe(false); + expect(wrapper.findComponent(GlEmptyState).exists()).toBe(false); }); it('should render empty search message when `searchEmpty` is `true`', () => { createComponent({ propsData: { searchEmpty: true } }); - expect(wrapper.findByText(defaultPropsData.searchEmptyMessage).exists()).toBe(true); + expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({ + title: GroupsComponent.i18n.emptyStateTitle, + description: GroupsComponent.i18n.emptyStateDescription, + }); }); }); }); diff --git a/spec/frontend/groups/components/new_top_level_group_alert_spec.js b/spec/frontend/groups/components/new_top_level_group_alert_spec.js new file mode 100644 index 00000000000..db9a5c7b16b --- /dev/null +++ b/spec/frontend/groups/components/new_top_level_group_alert_spec.js @@ -0,0 +1,75 @@ +import { shallowMount } from '@vue/test-utils'; +import NewTopLevelGroupAlert from '~/groups/components/new_top_level_group_alert.vue'; +import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +describe('NewTopLevelGroupAlert', () => { + let wrapper; + let userCalloutDismissSpy; + + const findAlert = () => wrapper.findComponent({ ref: 'newTopLevelAlert' }); + const createSubGroupPath = '/groups/new?parent_id=1#create-group-pane'; + + const createComponent = ({ shouldShowCallout = true } = {}) => { + userCalloutDismissSpy = jest.fn(); + + wrapper = shallowMount(NewTopLevelGroupAlert, { + provide: { + createSubGroupPath, + }, + stubs: { + UserCalloutDismisser: makeMockUserCalloutDismisser({ + dismiss: userCalloutDismissSpy, + shouldShowCallout, + }), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when the component is created', () => { + beforeEach(() => { + createComponent({ + shouldShowCallout: true, + }); + }); + + it('renders a button with a link to create a new sub-group', () => { + expect(findAlert().props('primaryButtonText')).toBe( + NewTopLevelGroupAlert.i18n.primaryBtnText, + ); + expect(findAlert().props('primaryButtonLink')).toBe( + helpPagePath('user/group/subgroups/index'), + ); + }); + }); + + describe('dismissing the alert', () => { + beforeEach(() => { + findAlert().vm.$emit('dismiss'); + }); + + it('calls the dismiss callback', () => { + expect(userCalloutDismissSpy).toHaveBeenCalled(); + }); + }); + + describe('when the alert has been dismissed', () => { + beforeEach(() => { + createComponent({ + shouldShowCallout: false, + }); + }); + + it('does not show the alert', () => { + expect(findAlert().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/groups/components/overview_tabs_spec.js b/spec/frontend/groups/components/overview_tabs_spec.js index 352bf25b84f..93e087e10f2 100644 --- a/spec/frontend/groups/components/overview_tabs_spec.js +++ b/spec/frontend/groups/components/overview_tabs_spec.js @@ -1,28 +1,46 @@ -import { GlTab } from '@gitlab/ui'; +import { GlSorting, GlSortingItem, GlTab } from '@gitlab/ui'; import { nextTick } from 'vue'; +import { createLocalVue } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import OverviewTabs from '~/groups/components/overview_tabs.vue'; import GroupsApp from '~/groups/components/app.vue'; +import GroupFolderComponent from '~/groups/components/group_folder.vue'; import GroupsStore from '~/groups/store/groups_store'; import GroupsService from '~/groups/service/groups_service'; import { createRouter } from '~/groups/init_overview_tabs'; +import eventHub from '~/groups/event_hub'; import { ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED, + OVERVIEW_TABS_SORTING_ITEMS, } from '~/groups/constants'; import axios from '~/lib/utils/axios_utils'; +const localVue = createLocalVue(); +localVue.component('GroupFolder', GroupFolderComponent); const router = createRouter(); +const [SORTING_ITEM_NAME, , SORTING_ITEM_UPDATED] = OVERVIEW_TABS_SORTING_ITEMS; describe('OverviewTabs', () => { let wrapper; + let axiosMock; - const endpoints = { - subgroups_and_projects: '/groups/foobar/-/children.json', - shared: '/groups/foobar/-/shared_projects.json', - archived: '/groups/foobar/-/children.json?archived=only', + const defaultProvide = { + endpoints: { + subgroups_and_projects: '/groups/foobar/-/children.json', + shared: '/groups/foobar/-/shared_projects.json', + archived: '/groups/foobar/-/children.json?archived=only', + }, + newSubgroupPath: '/groups/new', + newProjectPath: 'projects/new', + newSubgroupIllustration: '', + newProjectIllustration: '', + emptySubgroupIllustration: '', + canCreateSubgroups: false, + canCreateProjects: false, + initialSort: 'name_asc', }; const routerMock = { @@ -31,12 +49,15 @@ describe('OverviewTabs', () => { const createComponent = async ({ route = { name: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, params: { group: 'foo/bar/baz' } }, + provide = {}, } = {}) => { wrapper = mountExtended(OverviewTabs, { router, provide: { - endpoints, + ...defaultProvide, + ...provide, }, + localVue, mocks: { $route: route, $router: routerMock }, }); @@ -47,13 +68,13 @@ describe('OverviewTabs', () => { const findTab = (name) => wrapper.findByRole('tab', { name }); const findSelectedTab = () => wrapper.findByRole('tab', { selected: true }); - afterEach(() => { - wrapper.destroy(); + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); }); - beforeEach(async () => { - // eslint-disable-next-line no-new - new AxiosMockAdapter(axios); + afterEach(() => { + wrapper.destroy(); + axiosMock.restore(); }); it('renders `Subgroups and projects` tab with `GroupsApp` component', async () => { @@ -68,7 +89,7 @@ describe('OverviewTabs', () => { expect(tabPanel.findComponent(GroupsApp).props()).toMatchObject({ action: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, store: new GroupsStore({ showSchemaMarkup: true }), - service: new GroupsService(endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]), + service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]), hideProjects: false, renderEmptyState: true, }); @@ -89,7 +110,7 @@ describe('OverviewTabs', () => { expect(tabPanel.findComponent(GroupsApp).props()).toMatchObject({ action: ACTIVE_TAB_SHARED, store: new GroupsStore(), - service: new GroupsService(endpoints[ACTIVE_TAB_SHARED]), + service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_SHARED]), hideProjects: false, renderEmptyState: false, }); @@ -112,7 +133,7 @@ describe('OverviewTabs', () => { expect(tabPanel.findComponent(GroupsApp).props()).toMatchObject({ action: ACTIVE_TAB_ARCHIVED, store: new GroupsStore(), - service: new GroupsService(endpoints[ACTIVE_TAB_ARCHIVED]), + service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_ARCHIVED]), hideProjects: false, renderEmptyState: false, }); @@ -120,6 +141,14 @@ describe('OverviewTabs', () => { expect(tabPanel.vm.$attrs.lazy).toBe(false); }); + it('sets `lazy` prop to `false` for initially active tab and `true` for all other tabs', async () => { + await createComponent({ route: { name: ACTIVE_TAB_SHARED, params: { group: 'foo/bar' } } }); + + expect(findTabPanels().at(0).vm.$attrs.lazy).toBe(true); + expect(findTabPanels().at(1).vm.$attrs.lazy).toBe(false); + expect(findTabPanels().at(2).vm.$attrs.lazy).toBe(true); + }); + describe.each([ [ { name: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, params: { group: 'foo/bar/baz' } }, @@ -184,4 +213,109 @@ describe('OverviewTabs', () => { expect(routerMock.push).toHaveBeenCalledWith(expectedRoute); }); }); + + describe('searching and sorting', () => { + const setup = async () => { + jest.spyOn(eventHub, '$emit'); + await createComponent(); + + // Click through tabs so they are all loaded + await findTab(OverviewTabs.i18n[ACTIVE_TAB_SHARED]).trigger('click'); + await findTab(OverviewTabs.i18n[ACTIVE_TAB_ARCHIVED]).trigger('click'); + await findTab(OverviewTabs.i18n[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]).trigger('click'); + }; + + const sharedAssertions = ({ search, sort }) => { + it('sets `lazy` prop to `true` for all of the non-active tabs so they are reloaded after sort or search is applied', () => { + expect(findTabPanels().at(0).vm.$attrs.lazy).toBe(false); + expect(findTabPanels().at(1).vm.$attrs.lazy).toBe(true); + expect(findTabPanels().at(2).vm.$attrs.lazy).toBe(true); + }); + + it('emits `fetchFilteredAndSortedGroups` event from `eventHub`', () => { + expect(eventHub.$emit).toHaveBeenCalledWith( + `${ACTIVE_TAB_SUBGROUPS_AND_PROJECTS}fetchFilteredAndSortedGroups`, + { + filterGroupsBy: search, + sortBy: sort, + }, + ); + }); + }; + + describe('when search is typed in', () => { + const search = 'Foo bar'; + + beforeEach(async () => { + await setup(); + await wrapper.findByPlaceholderText(OverviewTabs.i18n.searchPlaceholder).setValue(search); + }); + + it('updates query string with `filter` key', () => { + expect(routerMock.push).toHaveBeenCalledWith({ query: { filter: search } }); + }); + + sharedAssertions({ search, sort: defaultProvide.initialSort }); + }); + + describe('when sort is changed', () => { + beforeEach(async () => { + await setup(); + wrapper.findAllComponents(GlSortingItem).at(2).vm.$emit('click'); + await nextTick(); + }); + + it('updates query string with `sort` key', () => { + expect(routerMock.push).toHaveBeenCalledWith({ + query: { sort: SORTING_ITEM_UPDATED.asc }, + }); + }); + + sharedAssertions({ search: '', sort: SORTING_ITEM_UPDATED.asc }); + }); + + describe('when sort direction is changed', () => { + beforeEach(async () => { + await setup(); + await wrapper + .findByRole('button', { name: 'Sorting Direction: Ascending' }) + .trigger('click'); + }); + + it('updates query string with `sort` key', () => { + expect(routerMock.push).toHaveBeenCalledWith({ + query: { sort: SORTING_ITEM_NAME.desc }, + }); + }); + + sharedAssertions({ search: '', sort: SORTING_ITEM_NAME.desc }); + }); + + describe('when `filter` and `sort` query strings are set', () => { + beforeEach(async () => { + await createComponent({ + route: { + name: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, + params: { group: 'foo/bar/baz' }, + query: { filter: 'Foo bar', sort: SORTING_ITEM_UPDATED.desc }, + }, + }); + }); + + it('sets value of search input', () => { + expect( + wrapper.findByPlaceholderText(OverviewTabs.i18n.searchPlaceholder).element.value, + ).toBe('Foo bar'); + }); + + it('sets sort dropdown', () => { + expect(wrapper.findComponent(GlSorting).props()).toMatchObject({ + text: SORTING_ITEM_UPDATED.label, + isAscending: false, + }); + + expect(wrapper.findAllComponents(GlSortingItem).at(2).vm.$attrs.active).toBe(true); + }); + }); + }); }); diff --git a/spec/frontend/groups/components/transfer_group_form_spec.js b/spec/frontend/groups/components/transfer_group_form_spec.js index 8cfe8ce8e18..7cbe6e5bbab 100644 --- a/spec/frontend/groups/components/transfer_group_form_spec.js +++ b/spec/frontend/groups/components/transfer_group_form_spec.js @@ -2,7 +2,7 @@ import { GlAlert, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import Component from '~/groups/components/transfer_group_form.vue'; import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue'; -import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue'; +import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select_deprecated.vue'; describe('Transfer group form', () => { let wrapper; diff --git a/spec/frontend/groups/store/groups_store_spec.js b/spec/frontend/groups/store/groups_store_spec.js index 8ac5d7099f1..ce1791d0062 100644 --- a/spec/frontend/groups/store/groups_store_spec.js +++ b/spec/frontend/groups/store/groups_store_spec.js @@ -16,13 +16,13 @@ describe('ProjectsStore', () => { store = new GroupsStore(); expect(Object.keys(store.state).length).toBe(2); - expect(Array.isArray(store.state.groups)).toBeTruthy(); + expect(Array.isArray(store.state.groups)).toBe(true); expect(Object.keys(store.state.pageInfo).length).toBe(0); - expect(store.hideProjects).toBeFalsy(); + expect(store.hideProjects).toBe(false); store = new GroupsStore({ hideProjects: true }); - expect(store.hideProjects).toBeTruthy(); + expect(store.hideProjects).toBe(true); }); }); @@ -65,8 +65,8 @@ describe('ProjectsStore', () => { expect(store.formatGroupItem).toHaveBeenCalledWith(expect.any(Object)); expect(mockParentGroupItem.children.length).toBe(1); expect(Object.keys(mockParentGroupItem.children[0]).indexOf('fullName')).toBeGreaterThan(-1); - expect(mockParentGroupItem.isOpen).toBeTruthy(); - expect(mockParentGroupItem.isChildrenLoading).toBeFalsy(); + expect(mockParentGroupItem.isOpen).toBe(true); + expect(mockParentGroupItem.isChildrenLoading).toBe(false); }); }); diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js index 6a138f9a247..b0bfe2b45f0 100644 --- a/spec/frontend/header_search/components/app_spec.js +++ b/spec/frontend/header_search/components/app_spec.js @@ -2,6 +2,7 @@ import { GlSearchBoxByType, GlToken, GlIcon } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking } from 'helpers/tracking_helper'; import { s__, sprintf } from '~/locale'; import HeaderSearchApp from '~/header_search/components/app.vue'; import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue'; @@ -360,22 +361,43 @@ describe('HeaderSearchApp', () => { describe('Header Search Input', () => { describe('when dropdown is closed', () => { - it('onFocus opens dropdown', async () => { + let trackingSpy; + + beforeEach(() => { + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + it('onFocus opens dropdown and triggers snowplow event', async () => { expect(findHeaderSearchDropdown().exists()).toBe(false); findHeaderSearchInput().vm.$emit('focus'); await nextTick(); expect(findHeaderSearchDropdown().exists()).toBe(true); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'focus_input', { + label: 'global_search', + property: 'top_navigation', + }); }); - it('onClick opens dropdown', async () => { + it('onClick opens dropdown and triggers snowplow event', async () => { expect(findHeaderSearchDropdown().exists()).toBe(false); findHeaderSearchInput().vm.$emit('click'); await nextTick(); expect(findHeaderSearchDropdown().exists()).toBe(true); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'focus_input', { + label: 'global_search', + property: 'top_navigation', + }); + }); + + it('onClick followed by onFocus only triggers a single snowplow event', async () => { + findHeaderSearchInput().vm.$emit('click'); + findHeaderSearchInput().vm.$emit('focus'); + + expect(trackingSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/spec/frontend/ide/components/commit_sidebar/actions_spec.js b/spec/frontend/ide/components/commit_sidebar/actions_spec.js index c9425f6c9cd..dc103fec5d0 100644 --- a/spec/frontend/ide/components/commit_sidebar/actions_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/actions_spec.js @@ -1,7 +1,7 @@ import Vue, { nextTick } from 'vue'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { mount } from '@vue/test-utils'; import { projectData, branches } from 'jest/ide/mock_data'; -import commitActions from '~/ide/components/commit_sidebar/actions.vue'; +import CommitActions from '~/ide/components/commit_sidebar/actions.vue'; import { createStore } from '~/ide/stores'; import { COMMIT_TO_NEW_BRANCH, @@ -18,32 +18,27 @@ const BRANCH_REGULAR_NO_ACCESS = 'regular/no-access'; describe('IDE commit sidebar actions', () => { let store; - let vm; + let wrapper; const createComponent = ({ hasMR = false, currentBranchId = 'main', emptyRepo = false } = {}) => { - const Component = Vue.extend(commitActions); - - vm = createComponentWithStore(Component, store); - - vm.$store.state.currentBranchId = currentBranchId; - vm.$store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = currentBranchId; + store.state.currentProjectId = 'abcproject'; const proj = { ...projectData }; proj.branches[currentBranchId] = branches.find((branch) => branch.name === currentBranchId); proj.empty_repo = emptyRepo; - Vue.set(vm.$store.state.projects, 'abcproject', proj); + Vue.set(store.state.projects, 'abcproject', proj); if (hasMR) { - vm.$store.state.currentMergeRequestId = '1'; - vm.$store.state.projects[store.state.currentProjectId].mergeRequests[ + store.state.currentMergeRequestId = '1'; + store.state.projects[store.state.currentProjectId].mergeRequests[ store.state.currentMergeRequestId ] = { foo: 'bar' }; } - vm.$mount(); - - return vm; + wrapper = mount(CommitActions, { store }); + return wrapper; }; beforeEach(() => { @@ -52,17 +47,16 @@ describe('IDE commit sidebar actions', () => { }); afterEach(() => { - vm.$destroy(); - vm = null; + wrapper.destroy(); }); - const findText = () => vm.$el.textContent; - const findRadios = () => Array.from(vm.$el.querySelectorAll('input[type="radio"]')); + const findText = () => wrapper.text(); + const findRadios = () => wrapper.findAll('input[type="radio"]'); it('renders 2 groups', () => { createComponent(); - expect(findRadios().length).toBe(2); + expect(findRadios()).toHaveLength(2); }); it('renders current branch text', () => { @@ -79,41 +73,38 @@ describe('IDE commit sidebar actions', () => { expect(findText()).not.toContain('Create a new branch and merge request'); }); - describe('currentBranchText', () => { - it('escapes current branch', () => { - const injectedSrc = '<img src="x" />'; - createComponent({ currentBranchId: injectedSrc }); + it('escapes current branch name', () => { + const injectedSrc = '<img src="x" />'; + const escapedSrc = '<img src="x" />'; + createComponent({ currentBranchId: injectedSrc }); - expect(vm.currentBranchText).not.toContain(injectedSrc); - }); + expect(wrapper.text()).not.toContain(injectedSrc); + expect(wrapper.text).not.toContain(escapedSrc); }); describe('updateSelectedCommitAction', () => { it('does not return anything if currentBranch does not exist', () => { createComponent({ currentBranchId: null }); - expect(vm.$store.dispatch).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); }); it('is not called on mount if there is already a selected commitAction', () => { store.state.commitAction = '1'; createComponent({ currentBranchId: null }); - expect(vm.$store.dispatch).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); }); it('calls again after staged changes', async () => { createComponent({ currentBranchId: null }); - vm.$store.state.currentBranchId = 'main'; - vm.$store.state.changedFiles.push({}); - vm.$store.state.stagedFiles.push({}); + store.state.currentBranchId = 'main'; + store.state.changedFiles.push({}); + store.state.stagedFiles.push({}); await nextTick(); - expect(vm.$store.dispatch).toHaveBeenCalledWith( - ACTION_UPDATE_COMMIT_ACTION, - expect.anything(), - ); + expect(store.dispatch).toHaveBeenCalledWith(ACTION_UPDATE_COMMIT_ACTION, expect.anything()); }); it.each` @@ -133,9 +124,7 @@ describe('IDE commit sidebar actions', () => { ({ input, expectedOption }) => { createComponent(input); - expect(vm.$store.dispatch.mock.calls).toEqual([ - [ACTION_UPDATE_COMMIT_ACTION, expectedOption], - ]); + expect(store.dispatch.mock.calls).toEqual([[ACTION_UPDATE_COMMIT_ACTION, expectedOption]]); }, ); }); diff --git a/spec/frontend/ide/components/commit_sidebar/list_item_spec.js b/spec/frontend/ide/components/commit_sidebar/list_item_spec.js index dea920ecb5e..c9571d39acb 100644 --- a/spec/frontend/ide/components/commit_sidebar/list_item_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/list_item_spec.js @@ -1,133 +1,136 @@ +import { mount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import { trimText } from 'helpers/text_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import listItem from '~/ide/components/commit_sidebar/list_item.vue'; +import ListItem from '~/ide/components/commit_sidebar/list_item.vue'; import { createRouter } from '~/ide/ide_router'; import { createStore } from '~/ide/stores'; import { file } from '../../helpers'; describe('Multi-file editor commit sidebar list item', () => { - let vm; - let f; + let wrapper; + let testFile; let findPathEl; let store; let router; beforeEach(() => { store = createStore(); - router = createRouter(store); + jest.spyOn(store, 'dispatch'); - const Component = Vue.extend(listItem); + router = createRouter(store); - f = file('test-file'); + testFile = file('test-file'); - store.state.entries[f.path] = f; + store.state.entries[testFile.path] = testFile; - vm = createComponentWithStore(Component, store, { - file: f, - activeFileKey: `staged-${f.key}`, - }).$mount(); + wrapper = mount(ListItem, { + store, + propsData: { + file: testFile, + activeFileKey: `staged-${testFile.key}`, + }, + }); - findPathEl = vm.$el.querySelector('.multi-file-commit-list-path'); + findPathEl = wrapper.find('.multi-file-commit-list-path'); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - const findPathText = () => trimText(findPathEl.textContent); + const findPathText = () => trimText(findPathEl.text()); it('renders file path', () => { - expect(findPathText()).toContain(f.path); + expect(findPathText()).toContain(testFile.path); }); it('correctly renders renamed entries', async () => { - Vue.set(vm.file, 'prevName', 'Old name'); - + Vue.set(testFile, 'prevName', 'Old name'); await nextTick(); - expect(findPathText()).toEqual(`Old name → ${f.name}`); + + expect(findPathText()).toEqual(`Old name → ${testFile.name}`); }); it('correctly renders entry, the name of which did not change after rename (as within a folder)', async () => { - Vue.set(vm.file, 'prevName', f.name); - + Vue.set(testFile, 'prevName', testFile.name); await nextTick(); - expect(findPathText()).toEqual(f.name); + + expect(findPathText()).toEqual(testFile.name); }); it('opens a closed file in the editor when clicking the file path', async () => { - jest.spyOn(vm, 'openPendingTab'); jest.spyOn(router, 'push').mockImplementation(() => {}); - findPathEl.click(); - - await nextTick(); + await findPathEl.trigger('click'); - expect(vm.openPendingTab).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith('openPendingTab', expect.anything()); expect(router.push).toHaveBeenCalled(); }); it('calls updateViewer with diff when clicking file', async () => { - jest.spyOn(vm, 'openFileInEditor'); - jest.spyOn(vm, 'updateViewer'); jest.spyOn(router, 'push').mockImplementation(() => {}); - findPathEl.click(); - + await findPathEl.trigger('click'); await waitForPromises(); - expect(vm.updateViewer).toHaveBeenCalledWith('diff'); + expect(store.dispatch).toHaveBeenCalledWith('updateViewer', 'diff'); }); - describe('computed', () => { - describe('iconName', () => { - it('returns modified when not a tempFile', () => { - expect(vm.iconName).toBe('file-modified'); - }); + describe('icon name', () => { + const getIconName = () => wrapper.findComponent(GlIcon).props('name'); + + it('is modified when not a tempFile', () => { + expect(getIconName()).toBe('file-modified'); + }); - it('returns addition when not a tempFile', () => { - f.tempFile = true; + it('is addition when is a tempFile', async () => { + testFile.tempFile = true; + await nextTick(); - expect(vm.iconName).toBe('file-addition'); - }); + expect(getIconName()).toBe('file-addition'); + }); - it('returns deletion', () => { - f.deleted = true; + it('is deletion when is deleted', async () => { + testFile.deleted = true; + await nextTick(); - expect(vm.iconName).toBe('file-deletion'); - }); + expect(getIconName()).toBe('file-deletion'); }); + }); - describe('iconClass', () => { - it('returns modified when not a tempFile', () => { - expect(vm.iconClass).toContain('ide-file-modified'); - }); + describe('icon class', () => { + const getIconClass = () => wrapper.findComponent(GlIcon).classes(); - it('returns addition when not a tempFile', () => { - f.tempFile = true; + it('is modified when not a tempFile', () => { + expect(getIconClass()).toContain('ide-file-modified'); + }); - expect(vm.iconClass).toContain('ide-file-addition'); - }); + it('is addition when is a tempFile', async () => { + testFile.tempFile = true; + await nextTick(); - it('returns deletion', () => { - f.deleted = true; + expect(getIconClass()).toContain('ide-file-addition'); + }); - expect(vm.iconClass).toContain('ide-file-deletion'); - }); + it('returns deletion when is deleted', async () => { + testFile.deleted = true; + await nextTick(); + + expect(getIconClass()).toContain('ide-file-deletion'); }); }); describe('is active', () => { it('does not add active class when dont keys match', () => { - expect(vm.$el.querySelector('.is-active')).toBe(null); + expect(wrapper.find('.is-active').exists()).toBe(false); }); it('adds active class when keys match', async () => { - vm.keyPrefix = 'staged'; + await wrapper.setProps({ keyPrefix: 'staged' }); - await nextTick(); - expect(vm.$el.querySelector('.is-active')).not.toBe(null); + expect(wrapper.find('.is-active').exists()).toBe(true); }); }); }); diff --git a/spec/frontend/ide/components/commit_sidebar/message_field_spec.js b/spec/frontend/ide/components/commit_sidebar/message_field_spec.js index ace266aec5e..c2ef29c1059 100644 --- a/spec/frontend/ide/components/commit_sidebar/message_field_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/message_field_spec.js @@ -1,135 +1,121 @@ -import Vue, { nextTick } from 'vue'; -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import createComponent from 'helpers/vue_mount_component_helper'; +import { nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; import CommitMessageField from '~/ide/components/commit_sidebar/message_field.vue'; describe('IDE commit message field', () => { - const Component = Vue.extend(CommitMessageField); - let vm; + let wrapper; beforeEach(() => { - setHTMLFixture('<div id="app"></div>'); - - vm = createComponent( - Component, - { + wrapper = mount(CommitMessageField, { + propsData: { text: '', placeholder: 'testing', }, - '#app', - ); + attachTo: document.body, + }); }); afterEach(() => { - vm.$destroy(); - - resetHTMLFixture(); + wrapper.destroy(); }); + const findMessage = () => wrapper.find('textarea'); + const findHighlights = () => wrapper.findAll('.highlights span'); + const findMarks = () => wrapper.findAll('mark'); + it('adds is-focused class on focus', async () => { - vm.$el.querySelector('textarea').focus(); + await findMessage().trigger('focus'); - await nextTick(); - expect(vm.$el.querySelector('.is-focused')).not.toBeNull(); + expect(wrapper.find('.is-focused').exists()).toBe(true); }); it('removed is-focused class on blur', async () => { - vm.$el.querySelector('textarea').focus(); + await findMessage().trigger('focus'); - await nextTick(); - expect(vm.$el.querySelector('.is-focused')).not.toBeNull(); + expect(wrapper.find('.is-focused').exists()).toBe(true); - vm.$el.querySelector('textarea').blur(); + await findMessage().trigger('blur'); - await nextTick(); - expect(vm.$el.querySelector('.is-focused')).toBeNull(); + expect(wrapper.find('.is-focused').exists()).toBe(false); }); - it('emits input event on input', () => { - jest.spyOn(vm, '$emit').mockImplementation(); - - const textarea = vm.$el.querySelector('textarea'); - textarea.value = 'testing'; - - textarea.dispatchEvent(new Event('input')); + it('emits input event on input', async () => { + await findMessage().setValue('testing'); - expect(vm.$emit).toHaveBeenCalledWith('input', 'testing'); + expect(wrapper.emitted('input')[0]).toStrictEqual(['testing']); }); describe('highlights', () => { describe('subject line', () => { it('does not highlight less than 50 characters', async () => { - vm.text = 'text less than 50 chars'; + await wrapper.setProps({ text: 'text less than 50 chars' }); - await nextTick(); - expect(vm.$el.querySelector('.highlights span').textContent).toContain( - 'text less than 50 chars', - ); + expect(findHighlights()).toHaveLength(1); + expect(findHighlights().at(0).text()).toContain('text less than 50 chars'); - expect(vm.$el.querySelector('mark').style.display).toBe('none'); + expect(findMarks()).toHaveLength(1); + expect(findMarks().at(0).isVisible()).toBe(false); }); it('highlights characters over 50 length', async () => { - vm.text = - 'text less than 50 chars that should not highlighted. text more than 50 should be highlighted'; + await wrapper.setProps({ + text: + 'text less than 50 chars that should not highlighted. text more than 50 should be highlighted', + }); - await nextTick(); - expect(vm.$el.querySelector('.highlights span').textContent).toContain( + expect(findHighlights()).toHaveLength(1); + expect(findHighlights().at(0).text()).toContain( 'text less than 50 chars that should not highlighte', ); - expect(vm.$el.querySelector('mark').style.display).not.toBe('none'); - expect(vm.$el.querySelector('mark').textContent).toBe( - 'd. text more than 50 should be highlighted', - ); + expect(findMarks()).toHaveLength(1); + expect(findMarks().at(0).isVisible()).toBe(true); + expect(findMarks().at(0).text()).toBe('d. text more than 50 should be highlighted'); }); }); describe('body text', () => { it('does not highlight body text less tan 72 characters', async () => { - vm.text = 'subject line\nbody content'; + await wrapper.setProps({ text: 'subject line\nbody content' }); - await nextTick(); - expect(vm.$el.querySelectorAll('.highlights span').length).toBe(2); - expect(vm.$el.querySelectorAll('mark')[1].style.display).toBe('none'); + expect(findHighlights()).toHaveLength(2); + expect(findMarks().at(1).isVisible()).toBe(false); }); it('highlights body text more than 72 characters', async () => { - vm.text = - 'subject line\nbody content that will be highlighted when it is more than 72 characters in length'; - - await nextTick(); - expect(vm.$el.querySelectorAll('.highlights span').length).toBe(2); - expect(vm.$el.querySelectorAll('mark')[1].style.display).not.toBe('none'); - expect(vm.$el.querySelectorAll('mark')[1].textContent).toBe(' in length'); + await wrapper.setProps({ + text: + 'subject line\nbody content that will be highlighted when it is more than 72 characters in length', + }); + + expect(findHighlights()).toHaveLength(2); + expect(findMarks().at(1).isVisible()).toBe(true); + expect(findMarks().at(1).text()).toBe('in length'); }); it('highlights body text & subject line', async () => { - vm.text = - 'text less than 50 chars that should not highlighted\nbody content that will be highlighted when it is more than 72 characters in length'; + await wrapper.setProps({ + text: + 'text less than 50 chars that should not highlighted\nbody content that will be highlighted when it is more than 72 characters in length', + }); - await nextTick(); - expect(vm.$el.querySelectorAll('.highlights span').length).toBe(2); - expect(vm.$el.querySelectorAll('mark').length).toBe(2); + expect(findHighlights()).toHaveLength(2); + expect(findMarks()).toHaveLength(2); - expect(vm.$el.querySelectorAll('mark')[0].textContent).toContain('d'); - expect(vm.$el.querySelectorAll('mark')[1].textContent).toBe(' in length'); + expect(findMarks().at(0).text()).toContain('d'); + expect(findMarks().at(1).text()).toBe('in length'); }); }); }); describe('scrolling textarea', () => { it('updates transform of highlights', async () => { - vm.text = 'subject line\n\n\n\n\n\n\n\n\n\n\nbody content'; + await wrapper.setProps({ text: 'subject line\n\n\n\n\n\n\n\n\n\n\nbody content' }); + findMessage().element.scrollTo(0, 50); await nextTick(); - vm.$el.querySelector('textarea').scrollTo(0, 50); - vm.handleScroll(); - - await nextTick(); - expect(vm.scrollTop).toBe(50); - expect(vm.$el.querySelector('.highlights').style.transform).toBe('translate3d(0, -50px, 0)'); + expect(wrapper.find('.highlights').element.style.transform).toBe('translate3d(0, -50px, 0)'); }); }); }); diff --git a/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js b/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js index ee6ed694285..a3fa03a4aa5 100644 --- a/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js @@ -1,123 +1,116 @@ -import Vue, { nextTick } from 'vue'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { GlFormRadioGroup } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; import RadioGroup from '~/ide/components/commit_sidebar/radio_group.vue'; import { createStore } from '~/ide/stores'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; describe('IDE commit sidebar radio group', () => { - let vm; + let wrapper; let store; - beforeEach(async () => { + const createComponent = (config = {}) => { store = createStore(); - const Component = Vue.extend(RadioGroup); - store.state.commit.commitAction = '2'; + store.state.commit.newBranchName = 'test-123'; - vm = createComponentWithStore(Component, store, { - value: '1', - label: 'test', - checked: true, + wrapper = mount(RadioGroup, { + store, + propsData: config.props, + slots: config.slots, + directives: { + GlTooltip: createMockDirective(), + }, }); - - vm.$mount(); - - await nextTick(); - }); + }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - it('uses label if present', () => { - expect(vm.$el.textContent).toContain('test'); - }); + describe('without input', () => { + const props = { + value: '1', + label: 'test', + checked: true, + }; - it('uses slot if label is not present', async () => { - vm.$destroy(); + it('uses label if present', () => { + createComponent({ props }); - vm = new Vue({ - components: { - RadioGroup, - }, - store, - render: (createElement) => - createElement('radio-group', { props: { value: '1' } }, 'Testing slot'), + expect(wrapper.text()).toContain('test'); }); - vm.$mount(); + it('uses slot if label is not present', () => { + createComponent({ props: { value: '1', checked: true }, slots: { default: 'Testing slot' } }); - await nextTick(); - expect(vm.$el.textContent).toContain('Testing slot'); - }); + expect(wrapper.text()).toContain('Testing slot'); + }); - it('updates store when changing radio button', async () => { - vm.$el.querySelector('input').dispatchEvent(new Event('change')); + it('updates store when changing radio button', async () => { + createComponent({ props }); - await nextTick(); - expect(store.state.commit.commitAction).toBe('1'); + await wrapper.find('input').trigger('change'); + + expect(store.state.commit.commitAction).toBe('1'); + }); }); describe('with input', () => { - beforeEach(async () => { - vm.$destroy(); - - const Component = Vue.extend(RadioGroup); - - store.state.commit.commitAction = '1'; - store.state.commit.newBranchName = 'test-123'; - - vm = createComponentWithStore(Component, store, { - value: '1', - label: 'test', - checked: true, - showInput: true, - }); - - vm.$mount(); - - await nextTick(); - }); + const props = { + value: '2', + label: 'test', + checked: true, + showInput: true, + }; it('renders input box when commitAction matches value', () => { - expect(vm.$el.querySelector('.form-control')).not.toBeNull(); + createComponent({ props: { ...props, value: '2' } }); + + expect(wrapper.find('.form-control').exists()).toBe(true); }); - it('hides input when commitAction doesnt match value', async () => { - store.state.commit.commitAction = '2'; + it('hides input when commitAction doesnt match value', () => { + createComponent({ props: { ...props, value: '1' } }); - await nextTick(); - expect(vm.$el.querySelector('.form-control')).toBeNull(); + expect(wrapper.find('.form-control').exists()).toBe(false); }); it('updates branch name in store on input', async () => { - const input = vm.$el.querySelector('.form-control'); - input.value = 'testing-123'; - input.dispatchEvent(new Event('input')); + createComponent({ props }); + + await wrapper.find('.form-control').setValue('testing-123'); - await nextTick(); expect(store.state.commit.newBranchName).toBe('testing-123'); }); it('renders newBranchName if present', () => { - const input = vm.$el.querySelector('.form-control'); + createComponent({ props }); - expect(input.value).toBe('test-123'); + const input = wrapper.find('.form-control'); + + expect(input.element.value).toBe('test-123'); }); }); describe('tooltipTitle', () => { it('returns title when disabled', () => { - vm.title = 'test title'; - vm.disabled = true; + createComponent({ + props: { value: '1', label: 'test', disabled: true, title: 'test title' }, + }); - expect(vm.tooltipTitle).toBe('test title'); + const tooltip = getBinding(wrapper.findComponent(GlFormRadioGroup).element, 'gl-tooltip'); + expect(tooltip.value).toBe('test title'); }); it('returns blank when not disabled', () => { - vm.title = 'test title'; + createComponent({ + props: { value: '1', label: 'test', title: 'test title' }, + }); + + const tooltip = getBinding(wrapper.findComponent(GlFormRadioGroup).element, 'gl-tooltip'); - expect(vm.tooltipTitle).not.toBe('test title'); + expect(tooltip.value).toBe(''); }); }); }); diff --git a/spec/frontend/ide/components/file_row_extra_spec.js b/spec/frontend/ide/components/file_row_extra_spec.js index 5a7a1fe7db0..281c549a1b4 100644 --- a/spec/frontend/ide/components/file_row_extra_spec.js +++ b/spec/frontend/ide/components/file_row_extra_spec.js @@ -1,146 +1,146 @@ -import Vue, { nextTick } from 'vue'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import Vuex from 'vuex'; +import { mount } from '@vue/test-utils'; import FileRowExtra from '~/ide/components/file_row_extra.vue'; -import { createStore } from '~/ide/stores'; +import { createStoreOptions } from '~/ide/stores'; import { file } from '../helpers'; describe('IDE extra file row component', () => { - let Component; - let vm; + let wrapper; + let store; let unstagedFilesCount = 0; let stagedFilesCount = 0; let changesCount = 0; - beforeAll(() => { - Component = Vue.extend(FileRowExtra); - }); + const createComponent = (fileProps) => { + const storeConfig = createStoreOptions(); - beforeEach(() => { - vm = createComponentWithStore(Component, createStore(), { - file: { - ...file('test'), + store = new Vuex.Store({ + ...storeConfig, + getters: { + getUnstagedFilesCountForPath: () => () => unstagedFilesCount, + getStagedFilesCountForPath: () => () => stagedFilesCount, + getChangesInFolder: () => () => changesCount, }, - dropdownOpen: false, }); - jest.spyOn(vm, 'getUnstagedFilesCountForPath', 'get').mockReturnValue(() => unstagedFilesCount); - jest.spyOn(vm, 'getStagedFilesCountForPath', 'get').mockReturnValue(() => stagedFilesCount); - jest.spyOn(vm, 'getChangesInFolder', 'get').mockReturnValue(() => changesCount); - - vm.$mount(); - }); + wrapper = mount(FileRowExtra, { + store, + propsData: { + file: { + ...file('test'), + type: 'tree', + ...fileProps, + }, + dropdownOpen: false, + }, + }); + }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); stagedFilesCount = 0; unstagedFilesCount = 0; changesCount = 0; }); - describe('folderChangesTooltip', () => { - it('returns undefined when changes count is 0', () => { - changesCount = 0; - - expect(vm.folderChangesTooltip).toBe(undefined); - }); - + describe('folder changes tooltip', () => { [ { input: 1, output: '1 changed file' }, { input: 2, output: '2 changed files' }, ].forEach(({ input, output }) => { - it('returns changed files count if changes count is not 0', () => { + it('shows changed files count if changes count is not 0', () => { changesCount = input; + createComponent(); - expect(vm.folderChangesTooltip).toBe(output); + expect(wrapper.find('.ide-file-modified').attributes('title')).toBe(output); }); }); }); describe('show tree changes count', () => { + const findTreeChangesCount = () => wrapper.find('.ide-tree-changes'); + it('does not show for blobs', () => { - vm.file.type = 'blob'; + createComponent({ type: 'blob' }); - expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null); + expect(findTreeChangesCount().exists()).toBe(false); }); it('does not show when changes count is 0', () => { - vm.file.type = 'tree'; + createComponent({ type: 'tree' }); - expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null); + expect(findTreeChangesCount().exists()).toBe(false); }); - it('does not show when tree is open', async () => { - vm.file.type = 'tree'; - vm.file.opened = true; + it('does not show when tree is open', () => { changesCount = 1; + createComponent({ type: 'tree', opened: true }); - await nextTick(); - expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null); + expect(findTreeChangesCount().exists()).toBe(false); }); - it('shows for trees with changes', async () => { - vm.file.type = 'tree'; - vm.file.opened = false; + it('shows for trees with changes', () => { changesCount = 1; + createComponent({ type: 'tree', opened: false }); - await nextTick(); - expect(vm.$el.querySelector('.ide-tree-changes')).not.toBe(null); + expect(findTreeChangesCount().exists()).toBe(true); }); }); describe('changes file icon', () => { + const findChangedFileIcon = () => wrapper.find('.file-changed-icon'); + it('hides when file is not changed', () => { - expect(vm.$el.querySelector('.file-changed-icon')).toBe(null); + createComponent(); + + expect(findChangedFileIcon().exists()).toBe(false); }); - it('shows when file is changed', async () => { - vm.file.changed = true; + it('shows when file is changed', () => { + createComponent({ type: 'blob', changed: true }); - await nextTick(); - expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null); + expect(findChangedFileIcon().exists()).toBe(true); }); - it('shows when file is staged', async () => { - vm.file.staged = true; + it('shows when file is staged', () => { + createComponent({ type: 'blob', staged: true }); - await nextTick(); - expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null); + expect(findChangedFileIcon().exists()).toBe(true); }); - it('shows when file is a tempFile', async () => { - vm.file.tempFile = true; + it('shows when file is a tempFile', () => { + createComponent({ type: 'blob', tempFile: true }); - await nextTick(); - expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null); + expect(findChangedFileIcon().exists()).toBe(true); }); - it('shows when file is renamed', async () => { - vm.file.prevPath = 'original-file'; + it('shows when file is renamed', () => { + createComponent({ type: 'blob', prevPath: 'original-file' }); - await nextTick(); - expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null); + expect(findChangedFileIcon().exists()).toBe(true); }); - it('hides when file is renamed', async () => { - vm.file.prevPath = 'original-file'; - vm.file.type = 'tree'; + it('hides when tree is renamed', () => { + createComponent({ type: 'tree', prevPath: 'original-path' }); - await nextTick(); - expect(vm.$el.querySelector('.file-changed-icon')).toBe(null); + expect(findChangedFileIcon().exists()).toBe(false); }); }); describe('merge request icon', () => { + const findMergeRequestIcon = () => wrapper.find('[data-testid="git-merge-icon"]'); + it('hides when not a merge request change', () => { - expect(vm.$el.querySelector('[data-testid="git-merge-icon"]')).toBe(null); + createComponent(); + + expect(findMergeRequestIcon().exists()).toBe(false); }); - it('shows when a merge request change', async () => { - vm.file.mrChange = true; + it('shows when a merge request change', () => { + createComponent({ mrChange: true }); - await nextTick(); - expect(vm.$el.querySelector('[data-testid="git-merge-icon"]')).not.toBe(null); + expect(findMergeRequestIcon().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/ide/components/file_templates/bar_spec.js b/spec/frontend/ide/components/file_templates/bar_spec.js index aaf9c17ccbf..60f37260393 100644 --- a/spec/frontend/ide/components/file_templates/bar_spec.js +++ b/spec/frontend/ide/components/file_templates/bar_spec.js @@ -1,19 +1,16 @@ -import Vue, { nextTick } from 'vue'; -import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; import Bar from '~/ide/components/file_templates/bar.vue'; import { createStore } from '~/ide/stores'; import { file } from '../../helpers'; describe('IDE file templates bar component', () => { - let Component; - let vm; - - beforeAll(() => { - Component = Vue.extend(Bar); - }); + let wrapper; + let store; beforeEach(() => { - const store = createStore(); + store = createStore(); + jest.spyOn(store, 'dispatch').mockImplementation(); store.state.openFiles.push({ ...file('file'), @@ -21,24 +18,22 @@ describe('IDE file templates bar component', () => { active: true, }); - vm = mountComponentWithStore(Component, { store }); + wrapper = mount(Bar, { store }); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); describe('template type dropdown', () => { it('renders dropdown component', () => { - expect(vm.$el.querySelector('.dropdown').textContent).toContain('Choose a type'); + expect(wrapper.find('.dropdown').text()).toContain('Choose a type'); }); - it('calls setSelectedTemplateType when clicking item', () => { - jest.spyOn(vm, 'setSelectedTemplateType').mockImplementation(); - - vm.$el.querySelector('.dropdown-menu button').click(); + it('calls setSelectedTemplateType when clicking item', async () => { + await wrapper.find('.dropdown-menu button').trigger('click'); - expect(vm.setSelectedTemplateType).toHaveBeenCalledWith({ + expect(store.dispatch).toHaveBeenCalledWith('fileTemplates/setSelectedTemplateType', { name: '.gitlab-ci.yml', key: 'gitlab_ci_ymls', }); @@ -46,60 +41,52 @@ describe('IDE file templates bar component', () => { }); describe('template dropdown', () => { - beforeEach(async () => { - vm.$store.state.fileTemplates.templates = [ + beforeEach(() => { + store.state.fileTemplates.templates = [ { name: 'test', }, ]; - vm.$store.state.fileTemplates.selectedTemplateType = { + store.state.fileTemplates.selectedTemplateType = { name: '.gitlab-ci.yml', key: 'gitlab_ci_ymls', }; - - await nextTick(); }); it('renders dropdown component', () => { - expect(vm.$el.querySelectorAll('.dropdown')[1].textContent).toContain('Choose a template'); + expect(wrapper.findAll('.dropdown').at(1).text()).toContain('Choose a template'); }); - it('calls fetchTemplate on dropdown open', () => { - jest.spyOn(vm, 'fetchTemplate').mockImplementation(); - - vm.$el.querySelectorAll('.dropdown-menu')[1].querySelector('button').click(); + it('calls fetchTemplate on dropdown open', async () => { + await wrapper.findAll('.dropdown-menu').at(1).find('button').trigger('click'); - expect(vm.fetchTemplate).toHaveBeenCalledWith({ + expect(store.dispatch).toHaveBeenCalledWith('fileTemplates/fetchTemplate', { name: 'test', }); }); }); + const findUndoButton = () => wrapper.find('.btn-default-secondary'); it('shows undo button if updateSuccess is true', async () => { - vm.$store.state.fileTemplates.updateSuccess = true; - + store.state.fileTemplates.updateSuccess = true; await nextTick(); - expect(vm.$el.querySelector('.btn-default').style.display).not.toBe('none'); - }); - it('calls undoFileTemplate when clicking undo button', () => { - jest.spyOn(vm, 'undoFileTemplate').mockImplementation(); + expect(findUndoButton().isVisible()).toBe(true); + }); - vm.$el.querySelector('.btn-default-secondary').click(); + it('calls undoFileTemplate when clicking undo button', async () => { + await findUndoButton().trigger('click'); - expect(vm.undoFileTemplate).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith('fileTemplates/undoFileTemplate', undefined); }); it('calls setSelectedTemplateType if activeFile name matches a template', async () => { const fileName = '.gitlab-ci.yml'; - - jest.spyOn(vm, 'setSelectedTemplateType').mockImplementation(() => {}); - vm.$store.state.openFiles[0].name = fileName; - - vm.setInitialType(); + store.state.openFiles = [{ ...file(fileName), opened: true, active: true }]; await nextTick(); - expect(vm.setSelectedTemplateType).toHaveBeenCalledWith({ + + expect(store.dispatch).toHaveBeenCalledWith('fileTemplates/setSelectedTemplateType', { name: fileName, key: 'gitlab_ci_ymls', }); diff --git a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap deleted file mode 100644 index 45444166a50..00000000000 --- a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap +++ /dev/null @@ -1,60 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`IDE pipeline stage renders stage details & icon 1`] = ` -<div - class="ide-stage card gl-mt-3" -> - <div - class="card-header" - > - <ci-icon-stub - cssclasses="" - size="24" - status="[object Object]" - /> - - <strong - class="gl-ml-3 text-truncate" - data-container="body" - > - - build - - </strong> - - <div - class="gl-mr-3 gl-ml-2" - > - <gl-badge-stub - size="md" - variant="muted" - > - 4 - </gl-badge-stub> - </div> - - <gl-icon-stub - class="ide-stage-collapse-icon" - name="chevron-lg-down" - size="16" - /> - </div> - - <div - class="card-body p-0" - > - <item-stub - job="[object Object]" - /> - <item-stub - job="[object Object]" - /> - <item-stub - job="[object Object]" - /> - <item-stub - job="[object Object]" - /> - </div> -</div> -`; diff --git a/spec/frontend/ide/components/jobs/detail/description_spec.js b/spec/frontend/ide/components/jobs/detail/description_spec.js index 128ccff6568..629c4424314 100644 --- a/spec/frontend/ide/components/jobs/detail/description_spec.js +++ b/spec/frontend/ide/components/jobs/detail/description_spec.js @@ -1,44 +1,43 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { mount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; import Description from '~/ide/components/jobs/detail/description.vue'; import { jobs } from '../../../mock_data'; describe('IDE job description', () => { - const Component = Vue.extend(Description); - let vm; + let wrapper; beforeEach(() => { - vm = mountComponent(Component, { - job: jobs[0], + wrapper = mount(Description, { + propsData: { + job: jobs[0], + }, }); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders job details', () => { - expect(vm.$el.textContent).toContain('#1'); - expect(vm.$el.textContent).toContain('test'); + expect(wrapper.text()).toContain('#1'); + expect(wrapper.text()).toContain('test'); }); it('renders CI icon', () => { - expect( - vm.$el.querySelector('.ci-status-icon [data-testid="status_success_borderless-icon"]'), - ).not.toBe(null); + expect(wrapper.find('.ci-status-icon').findComponent(GlIcon).exists()).toBe(true); }); it('renders a borderless CI icon', () => { - expect( - vm.$el.querySelector('.borderless [data-testid="status_success_borderless-icon"]'), - ).not.toBe(null); + expect(wrapper.find('.borderless').findComponent(GlIcon).exists()).toBe(true); }); it('renders bridge job details without the job link', () => { - vm = mountComponent(Component, { - job: { ...jobs[0], path: undefined }, + wrapper = mount(Description, { + propsData: { + job: { ...jobs[0], path: undefined }, + }, }); - expect(vm.$el.querySelector('[data-testid="description-detail-link"]')).toBe(null); + expect(wrapper.find('[data-testid="description-detail-link"]').exists()).toBe(false); }); }); diff --git a/spec/frontend/ide/components/jobs/detail_spec.js b/spec/frontend/ide/components/jobs/detail_spec.js index 9122471d421..bf2be3aa595 100644 --- a/spec/frontend/ide/components/jobs/detail_spec.js +++ b/spec/frontend/ide/components/jobs/detail_spec.js @@ -1,15 +1,17 @@ -import Vue, { nextTick } from 'vue'; +import { nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; + import { TEST_HOST } from 'helpers/test_constants'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; import JobDetail from '~/ide/components/jobs/detail.vue'; import { createStore } from '~/ide/stores'; import { jobs } from '../../mock_data'; describe('IDE jobs detail view', () => { - let vm; + let wrapper; + let store; const createComponent = () => { - const store = createStore(); + store = createStore(); store.state.pipelines.detailJob = { ...jobs[0], @@ -18,163 +20,129 @@ describe('IDE jobs detail view', () => { rawPath: `${TEST_HOST}/raw`, }; - return createComponentWithStore(Vue.extend(JobDetail), store); + jest.spyOn(store, 'dispatch'); + store.dispatch.mockResolvedValue(); + + wrapper = mount(JobDetail, { store }); }; - beforeEach(() => { - vm = createComponent(); + const findBuildJobLog = () => wrapper.find('pre'); + const findScrollToBottomButton = () => wrapper.find('button[aria-label="Scroll to bottom"]'); + const findScrollToTopButton = () => wrapper.find('button[aria-label="Scroll to top"]'); - jest.spyOn(vm, 'fetchJobLogs').mockResolvedValue(); + beforeEach(() => { + createComponent(); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); describe('mounted', () => { - beforeEach(() => { - vm = vm.$mount(); - }); + const findJobOutput = () => wrapper.find('.bash'); + const findBuildLoaderAnimation = () => wrapper.find('.build-loader-animation'); it('calls fetchJobLogs', () => { - expect(vm.fetchJobLogs).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith('pipelines/fetchJobLogs', undefined); }); it('scrolls to bottom', () => { - expect(vm.$refs.buildJobLog.scrollTo).toHaveBeenCalled(); + expect(findBuildJobLog().element.scrollTo).toHaveBeenCalled(); }); it('renders job output', () => { - expect(vm.$el.querySelector('.bash').textContent).toContain('testing'); + expect(findJobOutput().text()).toContain('testing'); }); it('renders empty message output', async () => { - vm.$store.state.pipelines.detailJob.output = ''; - + store.state.pipelines.detailJob.output = ''; await nextTick(); - expect(vm.$el.querySelector('.bash').textContent).toContain('No messages were logged'); + + expect(findJobOutput().text()).toContain('No messages were logged'); }); it('renders loading icon', () => { - expect(vm.$el.querySelector('.build-loader-animation')).not.toBe(null); - expect(vm.$el.querySelector('.build-loader-animation').style.display).toBe(''); + expect(findBuildLoaderAnimation().exists()).toBe(true); + expect(findBuildLoaderAnimation().isVisible()).toBe(true); }); it('hides output when loading', () => { - expect(vm.$el.querySelector('.bash')).not.toBe(null); - expect(vm.$el.querySelector('.bash').style.display).toBe('none'); + expect(findJobOutput().exists()).toBe(true); + expect(findJobOutput().isVisible()).toBe(false); }); it('hide loading icon when isLoading is false', async () => { - vm.$store.state.pipelines.detailJob.isLoading = false; - + store.state.pipelines.detailJob.isLoading = false; await nextTick(); - expect(vm.$el.querySelector('.build-loader-animation').style.display).toBe('none'); - }); - it('resets detailJob when clicking header button', () => { - jest.spyOn(vm, 'setDetailJob').mockImplementation(); + expect(findBuildLoaderAnimation().isVisible()).toBe(false); + }); - vm.$el.querySelector('.btn').click(); + it('resets detailJob when clicking header button', async () => { + await wrapper.find('.btn').trigger('click'); - expect(vm.setDetailJob).toHaveBeenCalledWith(null); + expect(store.dispatch).toHaveBeenCalledWith('pipelines/setDetailJob', null); }); it('renders raw path link', () => { - expect(vm.$el.querySelector('.controllers-buttons').getAttribute('href')).toBe( - `${TEST_HOST}/raw`, - ); + expect(wrapper.find('.controllers-buttons').attributes('href')).toBe(`${TEST_HOST}/raw`); }); }); describe('scroll buttons', () => { beforeEach(() => { - vm = createComponent(); - jest.spyOn(vm, 'fetchJobLogs').mockResolvedValue(); - }); - - afterEach(() => { - vm.$destroy(); + createComponent(); }); it.each` - fnName | btnName | scrollPos - ${'scrollDown'} | ${'down'} | ${0} - ${'scrollUp'} | ${'up'} | ${1} - `('triggers $fnName when clicking $btnName button', async ({ fnName, scrollPos }) => { - jest.spyOn(vm, fnName).mockImplementation(); - - vm = vm.$mount(); + fnName | btnName | scrollPos | targetScrollPos + ${'scroll down'} | ${'down'} | ${0} | ${200} + ${'scroll up'} | ${'up'} | ${200} | ${0} + `('triggers $fnName when clicking $btnName button', async ({ scrollPos, targetScrollPos }) => { + jest.spyOn(findBuildJobLog().element, 'offsetHeight', 'get').mockReturnValue(0); + jest.spyOn(findBuildJobLog().element, 'scrollHeight', 'get').mockReturnValue(200); + jest.spyOn(findBuildJobLog().element, 'scrollTop', 'get').mockReturnValue(scrollPos); + findBuildJobLog().element.scrollTo.mockReset(); - vm.scrollPos = scrollPos; - - await nextTick(); - vm.$el.querySelector('.btn-scroll:not([disabled])').click(); - expect(vm[fnName]).toHaveBeenCalled(); - }); - }); - - describe('scrollDown', () => { - beforeEach(() => { - vm = vm.$mount(); - - jest.spyOn(vm.$refs.buildJobLog, 'scrollTo').mockImplementation(); - }); - - it('scrolls build trace to bottom', () => { - jest.spyOn(vm.$refs.buildJobLog, 'scrollHeight', 'get').mockReturnValue(1000); - - vm.scrollDown(); - - expect(vm.$refs.buildJobLog.scrollTo).toHaveBeenCalledWith(0, 1000); - }); - }); - - describe('scrollUp', () => { - beforeEach(() => { - vm = vm.$mount(); - - jest.spyOn(vm.$refs.buildJobLog, 'scrollTo').mockImplementation(); - }); + await findBuildJobLog().trigger('scroll'); // trigger button updates - it('scrolls build trace to top', () => { - vm.scrollUp(); + await wrapper.find('.controllers button:not(:disabled)').trigger('click'); - expect(vm.$refs.buildJobLog.scrollTo).toHaveBeenCalledWith(0, 0); + expect(findBuildJobLog().element.scrollTo).toHaveBeenCalledWith(0, targetScrollPos); }); }); - describe('scrollBuildLog', () => { + describe('scrolling build log', () => { beforeEach(() => { - vm = vm.$mount(); - jest.spyOn(vm.$refs.buildJobLog, 'scrollTo').mockImplementation(); - jest.spyOn(vm.$refs.buildJobLog, 'offsetHeight', 'get').mockReturnValue(100); - jest.spyOn(vm.$refs.buildJobLog, 'scrollHeight', 'get').mockReturnValue(200); + jest.spyOn(findBuildJobLog().element, 'offsetHeight', 'get').mockReturnValue(100); + jest.spyOn(findBuildJobLog().element, 'scrollHeight', 'get').mockReturnValue(200); }); - it('sets scrollPos to bottom when at the bottom', () => { - jest.spyOn(vm.$refs.buildJobLog, 'scrollTop', 'get').mockReturnValue(100); + it('keeps scroll at bottom when already at the bottom', async () => { + jest.spyOn(findBuildJobLog().element, 'scrollTop', 'get').mockReturnValue(100); - vm.scrollBuildLog(); + await findBuildJobLog().trigger('scroll'); - expect(vm.scrollPos).toBe(1); + expect(findScrollToBottomButton().attributes('disabled')).toBe('disabled'); + expect(findScrollToTopButton().attributes('disabled')).not.toBe('disabled'); }); - it('sets scrollPos to top when at the top', () => { - jest.spyOn(vm.$refs.buildJobLog, 'scrollTop', 'get').mockReturnValue(0); - vm.scrollPos = 1; + it('keeps scroll at top when already at top', async () => { + jest.spyOn(findBuildJobLog().element, 'scrollTop', 'get').mockReturnValue(0); - vm.scrollBuildLog(); + await findBuildJobLog().trigger('scroll'); - expect(vm.scrollPos).toBe(0); + expect(findScrollToBottomButton().attributes('disabled')).not.toBe('disabled'); + expect(findScrollToTopButton().attributes('disabled')).toBe('disabled'); }); - it('resets scrollPos when not at top or bottom', () => { - jest.spyOn(vm.$refs.buildJobLog, 'scrollTop', 'get').mockReturnValue(10); + it('resets scroll when not at top or bottom', async () => { + jest.spyOn(findBuildJobLog().element, 'scrollTop', 'get').mockReturnValue(10); - vm.scrollBuildLog(); + await findBuildJobLog().trigger('scroll'); - expect(vm.scrollPos).toBe(''); + expect(findScrollToBottomButton().attributes('disabled')).not.toBe('disabled'); + expect(findScrollToTopButton().attributes('disabled')).not.toBe('disabled'); }); }); }); diff --git a/spec/frontend/ide/components/jobs/item_spec.js b/spec/frontend/ide/components/jobs/item_spec.js index c76760a5522..32e27333e42 100644 --- a/spec/frontend/ide/components/jobs/item_spec.js +++ b/spec/frontend/ide/components/jobs/item_spec.js @@ -1,36 +1,38 @@ -import Vue, { nextTick } from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { mount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; + import JobItem from '~/ide/components/jobs/item.vue'; import { jobs } from '../../mock_data'; describe('IDE jobs item', () => { - const Component = Vue.extend(JobItem); const job = jobs[0]; - let vm; + let wrapper; beforeEach(() => { - vm = mountComponent(Component, { - job, - }); + wrapper = mount(JobItem, { propsData: { job } }); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders job details', () => { - expect(vm.$el.textContent).toContain(job.name); - expect(vm.$el.textContent).toContain(`#${job.id}`); + expect(wrapper.text()).toContain(job.name); + expect(wrapper.text()).toContain(`#${job.id}`); }); it('renders CI icon', () => { - expect(vm.$el.querySelector('[data-testid="status_success_borderless-icon"]')).not.toBe(null); + expect(wrapper.find('[data-testid="status_success_borderless-icon"]').exists()).toBe(true); }); it('does not render view logs button if not started', async () => { - vm.job.started = false; + await wrapper.setProps({ + job: { + ...jobs[0], + started: false, + }, + }); - await nextTick(); - expect(vm.$el.querySelector('.btn')).toBe(null); + expect(wrapper.findComponent(GlButton).exists()).toBe(false); }); }); diff --git a/spec/frontend/ide/components/jobs/stage_spec.js b/spec/frontend/ide/components/jobs/stage_spec.js index 1d5e5743a4d..52fbff2f497 100644 --- a/spec/frontend/ide/components/jobs/stage_spec.js +++ b/spec/frontend/ide/components/jobs/stage_spec.js @@ -18,8 +18,9 @@ describe('IDE pipeline stage', () => { }, }; - const findHeader = () => wrapper.findComponent({ ref: 'cardHeader' }); - const findJobList = () => wrapper.findComponent({ ref: 'jobList' }); + const findHeader = () => wrapper.find('[data-testid="card-header"]'); + const findJobList = () => wrapper.find('[data-testid="job-list"]'); + const findStageTitle = () => wrapper.find('[data-testid="stage-title"]'); const createComponent = (props) => { wrapper = shallowMount(Stage, { @@ -65,9 +66,9 @@ describe('IDE pipeline stage', () => { expect(wrapper.emitted().clickViewLog[0][0]).toBe(job); }); - it('renders stage details & icon', () => { + it('renders stage title', () => { createComponent(); - expect(wrapper.element).toMatchSnapshot(); + expect(findStageTitle().isVisible()).toBe(true); }); describe('when collapsed', () => { diff --git a/spec/frontend/ide/components/new_dropdown/button_spec.js b/spec/frontend/ide/components/new_dropdown/button_spec.js index 298d7b810e1..a9cfdfd20c1 100644 --- a/spec/frontend/ide/components/new_dropdown/button_spec.js +++ b/spec/frontend/ide/components/new_dropdown/button_spec.js @@ -1,59 +1,60 @@ -import Vue, { nextTick } from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { mount } from '@vue/test-utils'; import Button from '~/ide/components/new_dropdown/button.vue'; describe('IDE new entry dropdown button component', () => { - let Component; - let vm; - - beforeAll(() => { - Component = Vue.extend(Button); - }); - - beforeEach(() => { - vm = mountComponent(Component, { - label: 'Testing', - icon: 'doc-new', + let wrapper; + + const createComponent = (props = {}) => { + wrapper = mount(Button, { + propsData: { + label: 'Testing', + icon: 'doc-new', + ...props, + }, }); - - jest.spyOn(vm, '$emit').mockImplementation(() => {}); - }); + }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders button with label', () => { - expect(vm.$el.textContent).toContain('Testing'); + createComponent(); + + expect(wrapper.text()).toContain('Testing'); }); it('renders icon', () => { - expect(vm.$el.querySelector('[data-testid="doc-new-icon"]')).not.toBe(null); + createComponent(); + + expect(wrapper.find('[data-testid="doc-new-icon"]').exists()).toBe(true); }); - it('emits click event', () => { - vm.$el.click(); + it('emits click event', async () => { + createComponent(); - expect(vm.$emit).toHaveBeenCalledWith('click'); + await wrapper.trigger('click'); + + expect(wrapper.emitted('click')).toHaveLength(1); }); - it('hides label if showLabel is false', async () => { - vm.showLabel = false; + it('hides label if showLabel is false', () => { + createComponent({ showLabel: false }); - await nextTick(); - expect(vm.$el.textContent).not.toContain('Testing'); + expect(wrapper.text()).not.toContain('Testing'); }); - describe('tooltipTitle', () => { + describe('tooltip title', () => { it('returns empty string when showLabel is true', () => { - expect(vm.tooltipTitle).toBe(''); + createComponent({ showLabel: true }); + + expect(wrapper.attributes('title')).toBe(''); }); - it('returns label', async () => { - vm.showLabel = false; + it('returns label', () => { + createComponent({ showLabel: false }); - await nextTick(); - expect(vm.tooltipTitle).toBe('Testing'); + expect(wrapper.attributes('title')).toBe('Testing'); }); }); }); diff --git a/spec/frontend/ide/components/new_dropdown/modal_spec.js b/spec/frontend/ide/components/new_dropdown/modal_spec.js index 68cc08d2ebc..c6f9fd0c4ea 100644 --- a/spec/frontend/ide/components/new_dropdown/modal_spec.js +++ b/spec/frontend/ide/components/new_dropdown/modal_spec.js @@ -1,6 +1,6 @@ import { GlButton, GlModal } from '@gitlab/ui'; import { nextTick } from 'vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import Modal from '~/ide/components/new_dropdown/modal.vue'; import { createStore } from '~/ide/stores'; import { stubComponent } from 'helpers/stub_component'; @@ -341,7 +341,7 @@ describe('new file modal component', () => { }); it('does not trigger flash', () => { - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); }); @@ -360,7 +360,7 @@ describe('new file modal component', () => { }); it('does not trigger flash', () => { - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); }); }); @@ -380,7 +380,7 @@ describe('new file modal component', () => { }); it('creates flash', () => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'The name "src" is already taken in this directory.', fadeTransition: false, addBodyClass: true, @@ -405,7 +405,7 @@ describe('new file modal component', () => { }); it('does not create flash', () => { - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); it('dispatches event', () => { diff --git a/spec/frontend/ide/components/new_dropdown/upload_spec.js b/spec/frontend/ide/components/new_dropdown/upload_spec.js index 3eafe9e7ccb..fc643589d51 100644 --- a/spec/frontend/ide/components/new_dropdown/upload_spec.js +++ b/spec/frontend/ide/components/new_dropdown/upload_spec.js @@ -1,39 +1,34 @@ -import Vue from 'vue'; -import createComponent from 'helpers/vue_mount_component_helper'; -import upload from '~/ide/components/new_dropdown/upload.vue'; +import { mount } from '@vue/test-utils'; +import Upload from '~/ide/components/new_dropdown/upload.vue'; describe('new dropdown upload', () => { - let vm; + let wrapper; beforeEach(() => { - const Component = Vue.extend(upload); - - vm = createComponent(Component, { - path: '', + wrapper = mount(Upload, { + propsData: { + path: '', + }, }); - - vm.entryName = 'testing'; - - jest.spyOn(vm, '$emit'); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); describe('openFile', () => { it('calls for each file', () => { const files = ['test', 'test2', 'test3']; - jest.spyOn(vm, 'readFile').mockImplementation(() => {}); - jest.spyOn(vm.$refs.fileUpload, 'files', 'get').mockReturnValue(files); + jest.spyOn(wrapper.vm, 'readFile').mockImplementation(() => {}); + jest.spyOn(wrapper.vm.$refs.fileUpload, 'files', 'get').mockReturnValue(files); - vm.openFile(); + wrapper.vm.openFile(); - expect(vm.readFile.mock.calls.length).toBe(3); + expect(wrapper.vm.readFile.mock.calls.length).toBe(3); files.forEach((file, i) => { - expect(vm.readFile.mock.calls[i]).toEqual([file]); + expect(wrapper.vm.readFile.mock.calls[i]).toEqual([file]); }); }); }); @@ -48,7 +43,7 @@ describe('new dropdown upload', () => { type: 'images/png', }; - vm.readFile(file); + wrapper.vm.readFile(file); expect(FileReader.prototype.readAsDataURL).toHaveBeenCalledWith(file); }); @@ -71,35 +66,39 @@ describe('new dropdown upload', () => { it('calls readAsText and creates file in plain text (without encoding) if the file content is plain text', async () => { const waitForCreate = new Promise((resolve) => { - vm.$on('create', resolve); + wrapper.vm.$on('create', resolve); }); - vm.createFile(textTarget, textFile); + wrapper.vm.createFile(textTarget, textFile); expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(textFile); await waitForCreate; - expect(vm.$emit).toHaveBeenCalledWith('create', { - name: textFile.name, - type: 'blob', - content: 'plain text', - rawPath: '', - mimeType: 'test/mime-text', - }); + expect(wrapper.emitted('create')[0]).toStrictEqual([ + { + name: textFile.name, + type: 'blob', + content: 'plain text', + rawPath: '', + mimeType: 'test/mime-text', + }, + ]); }); it('creates a blob URL for the content if binary', () => { - vm.createFile(binaryTarget, binaryFile); + wrapper.vm.createFile(binaryTarget, binaryFile); expect(FileReader.prototype.readAsText).not.toHaveBeenCalled(); - expect(vm.$emit).toHaveBeenCalledWith('create', { - name: binaryFile.name, - type: 'blob', - content: 'ðððð', - rawPath: 'blob:https://gitlab.com/048c7ac1-98de-4a37-ab1b-0206d0ea7e1b', - mimeType: 'test/mime-binary', - }); + expect(wrapper.emitted('create')[0]).toStrictEqual([ + { + name: binaryFile.name, + type: 'blob', + content: 'ðððð', + rawPath: 'blob:https://gitlab.com/048c7ac1-98de-4a37-ab1b-0206d0ea7e1b', + mimeType: 'test/mime-binary', + }, + ]); }); }); }); diff --git a/spec/frontend/ide/components/shared/tokened_input_spec.js b/spec/frontend/ide/components/shared/tokened_input_spec.js index 2efef9918b1..b70c9659e46 100644 --- a/spec/frontend/ide/components/shared/tokened_input_spec.js +++ b/spec/frontend/ide/components/shared/tokened_input_spec.js @@ -1,5 +1,4 @@ -import Vue, { nextTick } from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { mount } from '@vue/test-utils'; import TokenedInput from '~/ide/components/shared/tokened_input.vue'; const TEST_PLACEHOLDER = 'Searching in test'; @@ -10,120 +9,106 @@ const TEST_TOKENS = [ ]; const TEST_VALUE = 'lorem'; -function getTokenElements(vm) { - return Array.from(vm.$el.querySelectorAll('.filtered-search-token button')); -} - -function createBackspaceEvent() { - const e = new Event('keyup'); - e.keyCode = 8; - e.which = e.keyCode; - e.altKey = false; - e.ctrlKey = true; - e.shiftKey = false; - e.metaKey = false; - return e; +function getTokenElements(wrapper) { + return wrapper.findAll('.filtered-search-token button'); } describe('IDE shared/TokenedInput', () => { - const Component = Vue.extend(TokenedInput); - let vm; - - beforeEach(() => { - vm = mountComponent(Component, { - tokens: TEST_TOKENS, - placeholder: TEST_PLACEHOLDER, - value: TEST_VALUE, + let wrapper; + + const createComponent = (props = {}) => { + wrapper = mount(TokenedInput, { + propsData: { + tokens: TEST_TOKENS, + placeholder: TEST_PLACEHOLDER, + value: TEST_VALUE, + ...props, + }, + attachTo: document.body, }); - - jest.spyOn(vm, '$emit').mockImplementation(() => {}); - }); + }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders tokens', () => { - const renderedTokens = getTokenElements(vm).map((x) => x.textContent.trim()); + createComponent(); + const renderedTokens = getTokenElements(wrapper).wrappers.map((w) => w.text()); expect(renderedTokens).toEqual(TEST_TOKENS.map((x) => x.label)); }); it('renders input', () => { - expect(vm.$refs.input).toBeInstanceOf(HTMLInputElement); - expect(vm.$refs.input).toHaveValue(TEST_VALUE); - }); - - it('renders placeholder, when tokens are empty', async () => { - vm.tokens = []; + createComponent(); - await nextTick(); - expect(vm.$refs.input).toHaveAttr('placeholder', TEST_PLACEHOLDER); + expect(wrapper.find('input').element).toBeInstanceOf(HTMLInputElement); + expect(wrapper.find('input').element).toHaveValue(TEST_VALUE); }); - it('triggers "removeToken" on token click', () => { - getTokenElements(vm)[0].click(); + it('renders placeholder, when tokens are empty', () => { + createComponent({ tokens: [] }); - expect(vm.$emit).toHaveBeenCalledWith('removeToken', TEST_TOKENS[0]); + expect(wrapper.find('input').attributes('placeholder')).toBe(TEST_PLACEHOLDER); }); - it('when input triggers backspace event, it calls "onBackspace"', () => { - jest.spyOn(vm, 'onBackspace').mockImplementation(() => {}); + it('triggers "removeToken" on token click', async () => { + createComponent(); + await getTokenElements(wrapper).at(0).trigger('click'); - vm.$refs.input.dispatchEvent(createBackspaceEvent()); - vm.$refs.input.dispatchEvent(createBackspaceEvent()); - - expect(vm.onBackspace).toHaveBeenCalledTimes(2); + expect(wrapper.emitted('removeToken')[0]).toStrictEqual([TEST_TOKENS[0]]); }); - it('triggers "removeToken" on backspaces when value is empty', () => { - vm.value = ''; - - vm.onBackspace(); + it('removes token on backspace when value is empty', async () => { + createComponent({ value: '' }); - expect(vm.$emit).not.toHaveBeenCalled(); - expect(vm.backspaceCount).toEqual(1); + expect(wrapper.emitted('removeToken')).toBeUndefined(); - vm.onBackspace(); + await wrapper.find('input').trigger('keyup.delete'); + await wrapper.find('input').trigger('keyup.delete'); - expect(vm.$emit).toHaveBeenCalledWith('removeToken', TEST_TOKENS[TEST_TOKENS.length - 1]); - expect(vm.backspaceCount).toEqual(0); + expect(wrapper.emitted('removeToken')[0]).toStrictEqual([TEST_TOKENS[TEST_TOKENS.length - 1]]); }); - it('does not trigger "removeToken" on backspaces when value is not empty', () => { - vm.onBackspace(); - vm.onBackspace(); + it('does not trigger "removeToken" on backspaces when value is not empty', async () => { + createComponent({ value: 'SOMETHING' }); + + await wrapper.find('input').trigger('keyup.delete'); + await wrapper.find('input').trigger('keyup.delete'); - expect(vm.backspaceCount).toEqual(0); - expect(vm.$emit).not.toHaveBeenCalled(); + expect(wrapper.emitted('removeToken')).toBeUndefined(); }); - it('does not trigger "removeToken" on backspaces when tokens are empty', () => { - vm.tokens = []; + it('does not trigger "removeToken" on backspaces when tokens are empty', async () => { + createComponent({ value: '', tokens: [] }); - vm.onBackspace(); - vm.onBackspace(); + await wrapper.find('input').trigger('keyup.delete'); + await wrapper.find('input').trigger('keyup.delete'); - expect(vm.backspaceCount).toEqual(0); - expect(vm.$emit).not.toHaveBeenCalled(); + expect(wrapper.emitted('removeToken')).toBeUndefined(); }); - it('triggers "focus" on input focus', () => { - vm.$refs.input.dispatchEvent(new Event('focus')); + it('triggers "focus" on input focus', async () => { + createComponent(); - expect(vm.$emit).toHaveBeenCalledWith('focus'); + await wrapper.find('input').trigger('focus'); + + expect(wrapper.emitted('focus')).toHaveLength(1); }); - it('triggers "blur" on input blur', () => { - vm.$refs.input.dispatchEvent(new Event('blur')); + it('triggers "blur" on input blur', async () => { + createComponent(); + + await wrapper.find('input').trigger('blur'); - expect(vm.$emit).toHaveBeenCalledWith('blur'); + expect(wrapper.emitted('blur')).toHaveLength(1); }); - it('triggers "input" with value on input change', () => { - vm.$refs.input.value = 'something-else'; - vm.$refs.input.dispatchEvent(new Event('input')); + it('triggers "input" with value on input change', async () => { + createComponent(); + + await wrapper.find('input').setValue('something-else'); - expect(vm.$emit).toHaveBeenCalledWith('input', 'something-else'); + expect(wrapper.emitted('input')[0]).toStrictEqual(['something-else']); }); }); diff --git a/spec/frontend/ide/components/terminal/terminal_spec.js b/spec/frontend/ide/components/terminal/terminal_spec.js index 4da3e1910e9..0d22f7f73fe 100644 --- a/spec/frontend/ide/components/terminal/terminal_spec.js +++ b/spec/frontend/ide/components/terminal/terminal_spec.js @@ -171,7 +171,7 @@ describe('IDE Terminal', () => { it('creates the terminal', () => { expect(GLTerminal).toHaveBeenCalledWith(wrapper.vm.$refs.terminal); - expect(wrapper.vm.glterminal).toBeTruthy(); + expect(wrapper.vm.glterminal).toBeInstanceOf(GLTerminal); }); describe('scroll listener', () => { diff --git a/spec/frontend/ide/init_gitlab_web_ide_spec.js b/spec/frontend/ide/init_gitlab_web_ide_spec.js index ec8559f1b56..067da25cb52 100644 --- a/spec/frontend/ide/init_gitlab_web_ide_spec.js +++ b/spec/frontend/ide/init_gitlab_web_ide_spec.js @@ -6,7 +6,7 @@ jest.mock('@gitlab/web-ide'); const ROOT_ELEMENT_ID = 'ide'; const TEST_NONCE = 'test123nonce'; -const TEST_PROJECT = { path_with_namespace: 'group1/project1' }; +const TEST_PROJECT_PATH = 'group1/project1'; const TEST_BRANCH_NAME = '12345-foo-patch'; const TEST_GITLAB_URL = 'https://test-gitlab/'; const TEST_GITLAB_WEB_IDE_PUBLIC_PATH = 'test/webpack/assets/gitlab-web-ide/public/path'; @@ -18,7 +18,7 @@ describe('ide/init_gitlab_web_ide', () => { el.id = ROOT_ELEMENT_ID; // why: We'll test that this class is removed later el.classList.add('ide-loading'); - el.dataset.project = JSON.stringify(TEST_PROJECT); + el.dataset.projectPath = TEST_PROJECT_PATH; el.dataset.cspNonce = TEST_NONCE; el.dataset.branchName = TEST_BRANCH_NAME; @@ -43,7 +43,7 @@ describe('ide/init_gitlab_web_ide', () => { it('calls start with element', () => { expect(start).toHaveBeenCalledWith(findRootElement(), { baseUrl: `${TEST_HOST}/${TEST_GITLAB_WEB_IDE_PUBLIC_PATH}`, - projectPath: TEST_PROJECT.path_with_namespace, + projectPath: TEST_PROJECT_PATH, ref: TEST_BRANCH_NAME, gitlabUrl: TEST_GITLAB_URL, nonce: TEST_NONCE, diff --git a/spec/frontend/ide/stores/actions/merge_request_spec.js b/spec/frontend/ide/stores/actions/merge_request_spec.js index abc3ba5b0a2..f1b2a7b881a 100644 --- a/spec/frontend/ide/stores/actions/merge_request_spec.js +++ b/spec/frontend/ide/stores/actions/merge_request_spec.js @@ -3,7 +3,7 @@ import { range } from 'lodash'; import { stubPerformanceWebAPI } from 'helpers/performance'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { leftSidebarViews, PERMISSION_READ_MR, MAX_MR_FILES_AUTO_OPEN } from '~/ide/constants'; import service from '~/ide/services'; import { createStore } from '~/ide/stores'; @@ -139,8 +139,8 @@ describe('IDE store merge request actions', () => { branchId: 'bar', }) .catch(() => { - expect(createFlash).toHaveBeenCalled(); - expect(createFlash.mock.calls[0][0].message).toBe( + expect(createAlert).toHaveBeenCalled(); + expect(createAlert.mock.calls[0][0].message).toBe( 'Error fetching merge requests for bar', ); }); @@ -520,7 +520,7 @@ describe('IDE store merge request actions', () => { store.dispatch.mockRejectedValue(); return openMergeRequest(store, mr).catch(() => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: expect.any(String), }); }); diff --git a/spec/frontend/ide/stores/actions/project_spec.js b/spec/frontend/ide/stores/actions/project_spec.js index cc7d39b4d43..5a5ead4c544 100644 --- a/spec/frontend/ide/stores/actions/project_spec.js +++ b/spec/frontend/ide/stores/actions/project_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import testAction from 'helpers/vuex_action_helper'; import api from '~/api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import service from '~/ide/services'; import { createStore } from '~/ide/stores'; import { @@ -97,7 +97,7 @@ describe('IDE store project actions', () => { }); afterEach(() => { - createFlash.mockRestore(); + createAlert.mockRestore(); }); it.each` @@ -122,7 +122,7 @@ describe('IDE store project actions', () => { if (!responseSuccess) { expect(logError).toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); } }); }); diff --git a/spec/frontend/ide/stores/actions_spec.js b/spec/frontend/ide/stores/actions_spec.js index f6d54491d77..fd2c3d18813 100644 --- a/spec/frontend/ide/stores/actions_spec.js +++ b/spec/frontend/ide/stores/actions_spec.js @@ -4,6 +4,7 @@ import testAction from 'helpers/vuex_action_helper'; import eventHub from '~/ide/eventhub'; import { createRouter } from '~/ide/ide_router'; import { createStore } from '~/ide/stores'; +import { createAlert } from '~/flash'; import { init, stageAllChanges, @@ -29,6 +30,7 @@ jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn(), joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths, })); +jest.mock('~/flash'); describe('Multi-file store actions', () => { let store; @@ -138,7 +140,7 @@ describe('Multi-file store actions', () => { name: 'testing/test', type: 'tree', }); - expect(tree.tree[0].tempFile).toBeTruthy(); + expect(tree.tree[0].tempFile).toBe(true); expect(tree.tree[0].name).toBe('test'); expect(tree.tree[0].type).toBe('tree'); }); @@ -158,7 +160,7 @@ describe('Multi-file store actions', () => { type: 'tree', }); expect(store.state.entries[tree.path].tempFile).toEqual(false); - expect(document.querySelector('.flash-alert')).not.toBeNull(); + expect(createAlert).toHaveBeenCalled(); }); }); @@ -173,7 +175,7 @@ describe('Multi-file store actions', () => { }); const f = store.state.entries[name]; - expect(f.tempFile).toBeTruthy(); + expect(f.tempFile).toBe(true); expect(f.mimeType).toBe('test/mime'); expect(store.state.trees['abcproject/mybranch'].tree.length).toBe(1); }); @@ -216,8 +218,10 @@ describe('Multi-file store actions', () => { name: 'test', type: 'blob', }); - expect(document.querySelector('.flash-alert')?.textContent.trim()).toEqual( - `The name "${f.name}" is already taken in this directory.`, + expect(createAlert).toHaveBeenCalledWith( + expect.objectContaining({ + message: `The name "${f.name}" is already taken in this directory.`, + }), ); }); }); @@ -930,7 +934,7 @@ describe('Multi-file store actions', () => { ); expect(dispatch.mock.calls).toHaveLength(0); - expect(document.querySelector('.flash-alert')).not.toBeNull(); + expect(createAlert).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/ide/stores/modules/commit/mutations_spec.js b/spec/frontend/ide/stores/modules/commit/mutations_spec.js index 50342832d75..d277157e737 100644 --- a/spec/frontend/ide/stores/modules/commit/mutations_spec.js +++ b/spec/frontend/ide/stores/modules/commit/mutations_spec.js @@ -37,7 +37,7 @@ describe('IDE commit module mutations', () => { it('updates submitCommitLoading', () => { mutations.UPDATE_LOADING(state, true); - expect(state.submitCommitLoading).toBeTruthy(); + expect(state.submitCommitLoading).toBe(true); }); }); diff --git a/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js b/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js index ecda7f304ba..f48797415df 100644 --- a/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js +++ b/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import * as actions from '~/ide/stores/modules/terminal/actions/session_controls'; import { STARTING, PENDING, STOPPING, STOPPED } from '~/ide/stores/modules/terminal/constants'; import * as messages from '~/ide/stores/modules/terminal/messages'; @@ -89,7 +89,7 @@ describe('IDE store terminal session controls actions', () => { it('flashes message', () => { actions.receiveStartSessionError({ dispatch }); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: messages.UNEXPECTED_ERROR_STARTING, }); }); @@ -163,7 +163,7 @@ describe('IDE store terminal session controls actions', () => { it('flashes message', () => { actions.receiveStopSessionError({ dispatch }); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: messages.UNEXPECTED_ERROR_STOPPING, }); }); diff --git a/spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js b/spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js index eabc69b23aa..fe2328f25c2 100644 --- a/spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js +++ b/spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import * as actions from '~/ide/stores/modules/terminal/actions/session_status'; import { PENDING, RUNNING, STOPPING, STOPPED } from '~/ide/stores/modules/terminal/constants'; import * as messages from '~/ide/stores/modules/terminal/messages'; @@ -115,7 +115,7 @@ describe('IDE store terminal session controls actions', () => { it('flashes message', () => { actions.receiveSessionStatusError({ dispatch }); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: messages.UNEXPECTED_ERROR_STATUS, }); }); diff --git a/spec/frontend/ide/stores/mutations/tree_spec.js b/spec/frontend/ide/stores/mutations/tree_spec.js index 6935e57578f..a8c0d7ba2c8 100644 --- a/spec/frontend/ide/stores/mutations/tree_spec.js +++ b/spec/frontend/ide/stores/mutations/tree_spec.js @@ -17,11 +17,11 @@ describe('Multi-file store tree mutations', () => { it('toggles tree open', () => { mutations.TOGGLE_TREE_OPEN(localState, localTree.path); - expect(localTree.opened).toBeTruthy(); + expect(localTree.opened).toBe(true); mutations.TOGGLE_TREE_OPEN(localState, localTree.path); - expect(localTree.opened).toBeFalsy(); + expect(localTree.opened).toBe(false); }); }); diff --git a/spec/frontend/ide/stores/mutations_spec.js b/spec/frontend/ide/stores/mutations_spec.js index 4602a0837e0..4117f2648bd 100644 --- a/spec/frontend/ide/stores/mutations_spec.js +++ b/spec/frontend/ide/stores/mutations_spec.js @@ -30,13 +30,13 @@ describe('Multi-file store mutations', () => { entry, }); - expect(entry.loading).toBeTruthy(); + expect(entry.loading).toBe(true); mutations.TOGGLE_LOADING(localState, { entry, }); - expect(entry.loading).toBeFalsy(); + expect(entry.loading).toBe(false); }); it('toggles loading of entry and sets specific value', () => { @@ -44,14 +44,14 @@ describe('Multi-file store mutations', () => { entry, }); - expect(entry.loading).toBeTruthy(); + expect(entry.loading).toBe(true); mutations.TOGGLE_LOADING(localState, { entry, forceValue: true, }); - expect(entry.loading).toBeTruthy(); + expect(entry.loading).toBe(true); }); }); diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js index fd9d481251d..4efc0ac6028 100644 --- a/spec/frontend/ide/utils_spec.js +++ b/spec/frontend/ide/utils_spec.js @@ -1,4 +1,5 @@ import { languages } from 'monaco-editor'; +import { setDiagnosticsOptions as yamlDiagnosticsOptions } from 'monaco-yaml'; import { isTextFile, registerLanguages, @@ -203,7 +204,6 @@ describe('WebIDE utils', () => { }; jest.spyOn(languages.json.jsonDefaults, 'setDiagnosticsOptions'); - jest.spyOn(languages.yaml.yamlDefaults, 'setDiagnosticsOptions'); }); it('registers the given schemas with monaco for both json and yaml languages', () => { @@ -212,7 +212,7 @@ describe('WebIDE utils', () => { expect(languages.json.jsonDefaults.setDiagnosticsOptions).toHaveBeenCalledWith( expect.objectContaining({ schemas: [schema] }), ); - expect(languages.yaml.yamlDefaults.setDiagnosticsOptions).toHaveBeenCalledWith( + expect(yamlDiagnosticsOptions).toHaveBeenCalledWith( expect.objectContaining({ schemas: [schema] }), ); }); 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 f97ea046cbe..a0115cb9349 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 @@ -5,13 +5,14 @@ import VueApollo from 'vue-apollo'; import MockAdapter from 'axios-mock-adapter'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import httpStatus from '~/lib/utils/http_status'; import axios from '~/lib/utils/axios_utils'; import { STATUSES } from '~/import_entities/constants'; import { i18n, ROOT_NAMESPACE } from '~/import_entities/import_groups/constants'; import ImportTable from '~/import_entities/import_groups/components/import_table.vue'; import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql'; +import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import { availableNamespacesFixture, generateFakeEntry } from '../graphql/fixtures'; @@ -246,7 +247,7 @@ describe('import table', () => { await findImportButtons()[0].trigger('click'); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith( + expect(createAlert).toHaveBeenCalledWith( expect.objectContaining({ message: i18n.ERROR_IMPORT, }), @@ -528,6 +529,17 @@ describe('import table', () => { }); }); + it('renders pagination bar with storage key', async () => { + createComponent({ + bulkImportSourceGroups: () => new Promise(() => {}), + }); + await waitForPromises(); + + expect(wrapper.getComponent(PaginationBar).props('storageKey')).toBe( + ImportTable.LOCAL_STORAGE_KEY, + ); + }); + describe('unavailable features warning', () => { it('renders alert when there are unavailable features', async () => { createComponent({ diff --git a/spec/frontend/import_entities/import_groups/services/status_poller_spec.js b/spec/frontend/import_entities/import_groups/services/status_poller_spec.js index 01f976562c6..13d2a95ca14 100644 --- a/spec/frontend/import_entities/import_groups/services/status_poller_spec.js +++ b/spec/frontend/import_entities/import_groups/services/status_poller_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import Visibility from 'visibilityjs'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { STATUSES } from '~/import_entities/constants'; import { StatusPoller } from '~/import_entities/import_groups/services/status_poller'; import axios from '~/lib/utils/axios_utils'; @@ -83,7 +83,7 @@ describe('Bulk import status poller', () => { it('when error occurs shows flash with error', () => { const [[pollConfig]] = Poll.mock.calls; pollConfig.errorCallback(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); it('when success response arrives updates relevant group status', () => { diff --git a/spec/frontend/import_entities/import_projects/components/advanced_settings_spec.js b/spec/frontend/import_entities/import_projects/components/advanced_settings_spec.js new file mode 100644 index 00000000000..68716600592 --- /dev/null +++ b/spec/frontend/import_entities/import_projects/components/advanced_settings_spec.js @@ -0,0 +1,60 @@ +import { mount } from '@vue/test-utils'; +import { GlFormCheckbox } from '@gitlab/ui'; +import AdvancedSettingsPanel from '~/import_entities/import_projects/components/advanced_settings.vue'; + +describe('Import Advanced Settings', () => { + let wrapper; + const OPTIONAL_STAGES = [ + { name: 'stage1', label: 'Stage 1' }, + { name: 'stage2', label: 'Stage 2', details: 'Extra details' }, + ]; + + const createComponent = () => { + wrapper = mount(AdvancedSettingsPanel, { + propsData: { + stages: OPTIONAL_STAGES, + value: { + stage1: false, + stage2: false, + }, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders GLFormCheckbox for each optional stage', () => { + expect(wrapper.findAllComponents(GlFormCheckbox)).toHaveLength(OPTIONAL_STAGES.length); + }); + + it('renders label for each optional stage', () => { + wrapper.findAllComponents(GlFormCheckbox).wrappers.forEach((w, idx) => { + expect(w.text()).toContain(OPTIONAL_STAGES[idx].label); + }); + }); + + it('renders details for stage with details', () => { + expect(wrapper.findAllComponents(GlFormCheckbox).at(1).text()).toContain( + OPTIONAL_STAGES[1].details, + ); + }); + + it('emits new stages selection state when checkbox is changed', () => { + const firstCheckbox = wrapper.findComponent(GlFormCheckbox); + + firstCheckbox.vm.$emit('change', true); + + expect(wrapper.emitted('input')[0]).toStrictEqual([ + { + stage1: true, + stage2: false, + }, + ]); + }); +}); diff --git a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js index c0ae4294e3d..53807167fe8 100644 --- a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js +++ b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js @@ -5,6 +5,7 @@ import Vuex from 'vuex'; import { STATUSES } from '~/import_entities/constants'; import ImportProjectsTable from '~/import_entities/import_projects/components/import_projects_table.vue'; import ProviderRepoTableRow from '~/import_entities/import_projects/components/provider_repo_table_row.vue'; +import AdvancedSettingsPanel from '~/import_entities/import_projects/components/advanced_settings.vue'; import * as getters from '~/import_entities/import_projects/store/getters'; import state from '~/import_entities/import_projects/store/state'; @@ -45,6 +46,7 @@ describe('ImportProjectsTable', () => { slots, filterable, paginatable, + optionalStages, } = {}) { Vue.use(Vuex); @@ -71,6 +73,7 @@ describe('ImportProjectsTable', () => { providerTitle, filterable, paginatable, + optionalStages, }, slots, stubs: { @@ -271,4 +274,23 @@ describe('ImportProjectsTable', () => { expect(wrapper.text().includes(INCOMPATIBLE_TEXT)).toBe(shouldRenderSlot); }, ); + + it('should not render advanced settings panel when no optional steps are passed', () => { + createComponent({ state: { providerRepos: [providerRepo] } }); + + expect(wrapper.findComponent(AdvancedSettingsPanel).exists()).toBe(false); + }); + + it('should render advanced settings panel when no optional steps are passed', () => { + const OPTIONAL_STAGES = [{ name: 'step1', label: 'Step 1' }]; + createComponent({ state: { providerRepos: [providerRepo] }, optionalStages: OPTIONAL_STAGES }); + + expect(wrapper.findComponent(AdvancedSettingsPanel).exists()).toBe(true); + expect(wrapper.findComponent(AdvancedSettingsPanel).props('stages')).toStrictEqual( + OPTIONAL_STAGES, + ); + expect(wrapper.findComponent(AdvancedSettingsPanel).props('value')).toStrictEqual({ + step1: false, + }); + }); }); diff --git a/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js index 17a07b1e9f9..40934e90b78 100644 --- a/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js +++ b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js @@ -44,7 +44,7 @@ describe('ProviderRepoTableRow', () => { wrapper = shallowMount(ProviderRepoTableRow, { store, - propsData: { availableNamespaces, userNamespace, ...props }, + propsData: { availableNamespaces, userNamespace, optionalStages: {}, ...props }, }); } @@ -92,10 +92,24 @@ describe('ProviderRepoTableRow', () => { await nextTick(); - const { calls } = fetchImport.mock; + expect(fetchImport).toHaveBeenCalledWith(expect.anything(), { + repoId: repo.importSource.id, + optionalStages: {}, + }); + }); + + it('includes optionalStages to import', async () => { + const OPTIONAL_STAGES = { stage1: true, stage2: false }; + await wrapper.setProps({ optionalStages: OPTIONAL_STAGES }); + + findImportButton().vm.$emit('click'); + + await nextTick(); - expect(calls).toHaveLength(1); - expect(calls[0][1]).toBe(repo.importSource.id); + expect(fetchImport).toHaveBeenCalledWith(expect.anything(), { + repoId: repo.importSource.id, + optionalStages: OPTIONAL_STAGES, + }); }); }); diff --git a/spec/frontend/import_entities/import_projects/store/actions_spec.js b/spec/frontend/import_entities/import_projects/store/actions_spec.js index 0ebe8525b5a..e154863f339 100644 --- a/spec/frontend/import_entities/import_projects/store/actions_spec.js +++ b/spec/frontend/import_entities/import_projects/store/actions_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { STATUSES } from '~/import_entities/constants'; import actionsFactory from '~/import_entities/import_projects/store/actions'; import { getImportTarget } from '~/import_entities/import_projects/store/getters'; @@ -155,7 +155,7 @@ describe('import_projects store actions', () => { [], ); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'Provider rate limit exceeded. Try again later', }); }); @@ -198,7 +198,7 @@ describe('import_projects store actions', () => { return testAction( fetchImport, - importRepoId, + { repoId: importRepoId, optionalStages: {} }, localState, [ { @@ -222,7 +222,7 @@ describe('import_projects store actions', () => { await testAction( fetchImport, - importRepoId, + { repoId: importRepoId, optionalStages: {} }, localState, [ { @@ -234,7 +234,7 @@ describe('import_projects store actions', () => { [], ); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'Importing the project failed', }); }); @@ -245,7 +245,7 @@ describe('import_projects store actions', () => { await testAction( fetchImport, - importRepoId, + { repoId: importRepoId, optionalStages: {} }, localState, [ { @@ -257,7 +257,7 @@ describe('import_projects store actions', () => { [], ); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: `Importing the project failed: ${ERROR_MESSAGE}`, }); }); @@ -358,7 +358,7 @@ describe('import_projects store actions', () => { [], ); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'Requesting namespaces failed', }); }); @@ -366,14 +366,22 @@ describe('import_projects store actions', () => { describe('importAll', () => { it('dispatches multiple fetchImport actions', async () => { + const OPTIONAL_STAGES = { stage1: true, stage2: false }; + await testAction( importAll, - null, + { optionalStages: OPTIONAL_STAGES }, localState, [], [ - { type: 'fetchImport', payload: importRepoId }, - { type: 'fetchImport', payload: otherImportRepoId }, + { + type: 'fetchImport', + payload: { repoId: importRepoId, optionalStages: OPTIONAL_STAGES }, + }, + { + type: 'fetchImport', + payload: { repoId: otherImportRepoId, optionalStages: OPTIONAL_STAGES }, + }, ], ); }); diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index 21e57a2e33c..0a3beee0507 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -18,6 +18,7 @@ import { integrationLevels, I18N_SUCCESSFUL_CONNECTION_MESSAGE, I18N_DEFAULT_ERROR_MESSAGE, + INTEGRATION_FORM_TYPE_SLACK, billingPlans, billingPlanNames, } from '~/integrations/constants'; @@ -88,6 +89,7 @@ describe('IntegrationForm', () => { const findConnectionSection = () => findAllSections().at(0); const findConnectionSectionComponent = () => findConnectionSection().findComponent(IntegrationSectionConnection); + const findHelpHtml = () => wrapper.findByTestId('help-html'); beforeEach(() => { mockAxios = new MockAdapter(axios); @@ -712,5 +714,48 @@ describe('IntegrationForm', () => { expect(refreshCurrentPage).toHaveBeenCalledTimes(1); }); }); + + describe('Help and sections rendering', () => { + const dummyHelp = 'Foo Help'; + + it.each` + integration | flagIsOn | helpHtml | sections | shouldShowSections | shouldShowHelp + ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${''} | ${[]} | ${false} | ${false} + ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${dummyHelp} | ${[]} | ${false} | ${true} + ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${undefined} | ${[mockSectionConnection]} | ${false} | ${false} + ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${dummyHelp} | ${[mockSectionConnection]} | ${false} | ${true} + ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${''} | ${[]} | ${false} | ${false} + ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${dummyHelp} | ${[]} | ${false} | ${true} + ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false} + ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${true} + ${'foo'} | ${false} | ${''} | ${[]} | ${false} | ${false} + ${'foo'} | ${false} | ${dummyHelp} | ${[]} | ${false} | ${true} + ${'foo'} | ${false} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false} + ${'foo'} | ${false} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${false} + ${'foo'} | ${true} | ${''} | ${[]} | ${false} | ${false} + ${'foo'} | ${true} | ${dummyHelp} | ${[]} | ${false} | ${true} + ${'foo'} | ${true} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false} + ${'foo'} | ${true} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${false} + `( + '$sections sections, and "$helpHtml" helpHtml when the FF is "$flagIsOn" for "$integration" integration', + ({ integration, flagIsOn, helpHtml, sections, shouldShowSections, shouldShowHelp }) => { + createComponent({ + provide: { + helpHtml, + glFeatures: { integrationSlackAppNotifications: flagIsOn }, + }, + customStateProps: { + sections, + type: integration, + }, + }); + expect(findAllSections().length > 0).toEqual(shouldShowSections); + expect(findHelpHtml().exists()).toBe(shouldShowHelp); + if (shouldShowHelp) { + expect(findHelpHtml().html()).toContain(helpHtml); + } + }, + ); + }); }); }); diff --git a/spec/frontend/issuable/bulk_update_sidebar/components/status_select_spec.js b/spec/frontend/issuable/bulk_update_sidebar/components/status_dropdown_spec.js index 8ecbf41ce56..2f281cb88f9 100644 --- a/spec/frontend/issuable/bulk_update_sidebar/components/status_select_spec.js +++ b/spec/frontend/issuable/bulk_update_sidebar/components/status_dropdown_spec.js @@ -1,9 +1,9 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import StatusSelect from '~/issuable/bulk_update_sidebar/components/status_select.vue'; -import { ISSUE_STATUS_SELECT_OPTIONS } from '~/issuable/bulk_update_sidebar/constants'; +import StatusDropdown from '~/issuable/bulk_update_sidebar/components/status_dropdown.vue'; +import { statusDropdownOptions } from '~/issuable/bulk_update_sidebar/constants'; -describe('StatusSelect', () => { +describe('SubscriptionsDropdown component', () => { let wrapper; const findDropdown = () => wrapper.findComponent(GlDropdown); @@ -11,7 +11,7 @@ describe('StatusSelect', () => { const findHiddenInput = () => wrapper.find('input'); function createComponent() { - wrapper = shallowMount(StatusSelect); + wrapper = shallowMount(StatusDropdown); } afterEach(() => { @@ -45,14 +45,12 @@ describe('StatusSelect', () => { it('updates value of the hidden input', () => { expect(findHiddenInput().attributes('value')).toBe( - ISSUE_STATUS_SELECT_OPTIONS[selectItemAtIndex].value, + statusDropdownOptions[selectItemAtIndex].value, ); }); it('updates the dropdown text prop', () => { - expect(findDropdown().props('text')).toBe( - ISSUE_STATUS_SELECT_OPTIONS[selectItemAtIndex].text, - ); + expect(findDropdown().props('text')).toBe(statusDropdownOptions[selectItemAtIndex].text); }); it('sets dropdown item `is-checked` prop to `true`', () => { diff --git a/spec/frontend/issuable/bulk_update_sidebar/components/subscriptions_dropdown_spec.js b/spec/frontend/issuable/bulk_update_sidebar/components/subscriptions_dropdown_spec.js new file mode 100644 index 00000000000..56ef7a1ed39 --- /dev/null +++ b/spec/frontend/issuable/bulk_update_sidebar/components/subscriptions_dropdown_spec.js @@ -0,0 +1,76 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import SubscriptionsDropdown from '~/issuable/bulk_update_sidebar/components/subscriptions_dropdown.vue'; +import { subscriptionsDropdownOptions } from '~/issuable/bulk_update_sidebar/constants'; + +describe('SubscriptionsDropdown component', () => { + let wrapper; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findHiddenInput = () => wrapper.find('input'); + + function createComponent() { + wrapper = shallowMount(SubscriptionsDropdown); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('with no value selected', () => { + beforeEach(() => { + createComponent(); + }); + + it('hidden input value is undefined', () => { + expect(findHiddenInput().attributes('value')).toBeUndefined(); + }); + + it('renders default text', () => { + expect(findDropdown().props('text')).toBe(SubscriptionsDropdown.i18n.defaultDropdownText); + }); + + it('renders dropdown items with `is-checked` prop set to `false`', () => { + const dropdownItems = findAllDropdownItems(); + + expect(dropdownItems.at(0).props('isChecked')).toBe(false); + expect(dropdownItems.at(1).props('isChecked')).toBe(false); + }); + }); + + describe('when selecting a value', () => { + beforeEach(() => { + createComponent(); + findAllDropdownItems().at(0).vm.$emit('click'); + }); + + it('updates value of the hidden input', () => { + expect(findHiddenInput().attributes('value')).toBe(subscriptionsDropdownOptions[0].value); + }); + + it('updates the dropdown text prop', () => { + expect(findDropdown().props('text')).toBe(subscriptionsDropdownOptions[0].text); + }); + + it('sets dropdown item `is-checked` prop to `true`', () => { + const dropdownItems = findAllDropdownItems(); + + expect(dropdownItems.at(0).props('isChecked')).toBe(true); + expect(dropdownItems.at(1).props('isChecked')).toBe(false); + }); + + describe('when selecting the value that is already selected', () => { + it('clears dropdown selection', async () => { + findAllDropdownItems().at(0).vm.$emit('click'); + await nextTick(); + const dropdownItems = findAllDropdownItems(); + + expect(dropdownItems.at(0).props('isChecked')).toBe(false); + expect(dropdownItems.at(1).props('isChecked')).toBe(false); + expect(findDropdown().props('text')).toBe(SubscriptionsDropdown.i18n.defaultDropdownText); + }); + }); + }); +}); diff --git a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js index b518d2fbdec..680dbd68493 100644 --- a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js +++ b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js @@ -7,7 +7,7 @@ import { issuable1, issuable2, } from 'jest/issuable/components/related_issuable_mock_data'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { linkedIssueTypesMap } from '~/related_issues/constants'; import RelatedIssuesBlock from '~/related_issues/components/related_issues_block.vue'; @@ -136,7 +136,7 @@ describe('RelatedIssuesRoot', () => { await createComponent(); jest.spyOn(wrapper.vm, 'processAllReferences'); jest.spyOn(wrapper.vm.service, 'addRelatedIssues'); - createFlash.mockClear(); + createAlert.mockClear(); }); it('processes references before submitting', () => { @@ -207,12 +207,12 @@ describe('RelatedIssuesRoot', () => { mock.onPost(defaultProps.endpoint).reply(409, { message }); wrapper.vm.store.setPendingReferences([issuable1.reference, issuable2.reference]); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', input); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ message }); + expect(createAlert).toHaveBeenCalledWith({ message }); }); }); diff --git a/spec/frontend/issues/show/components/edited_spec.js b/spec/frontend/issues/show/components/edited_spec.js index 8a240c38b5f..aa6e0a9dceb 100644 --- a/spec/frontend/issues/show/components/edited_spec.js +++ b/spec/frontend/issues/show/components/edited_spec.js @@ -1,7 +1,10 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; +import { getTimeago } from '~/lib/utils/datetime_utility'; import Edited from '~/issues/show/components/edited.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +const timeago = getTimeago(); + describe('Edited component', () => { let wrapper; @@ -9,7 +12,8 @@ describe('Edited component', () => { const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip); const formatText = (text) => text.trim().replace(/\s\s+/g, ' '); - const mountComponent = (propsData) => shallowMount(Edited, { propsData }); + const mountComponent = (propsData) => mount(Edited, { propsData }); + const updatedAt = '2017-05-15T12:31:04.428Z'; afterEach(() => { wrapper.destroy(); @@ -17,12 +21,12 @@ describe('Edited component', () => { it('renders an edited at+by string', () => { wrapper = mountComponent({ - updatedAt: '2017-05-15T12:31:04.428Z', + updatedAt, updatedByName: 'Some User', updatedByPath: '/some_user', }); - expect(formatText(wrapper.text())).toBe('Edited by Some User'); + expect(formatText(wrapper.text())).toBe(`Edited ${timeago.format(updatedAt)} by Some User`); expect(findAuthorLink().attributes('href')).toBe('/some_user'); expect(findTimeAgoTooltip().exists()).toBe(true); }); @@ -40,10 +44,10 @@ describe('Edited component', () => { it('if no updatedByName and updatedByPath is provided, no user element will be rendered', () => { wrapper = mountComponent({ - updatedAt: '2017-05-15T12:31:04.428Z', + updatedAt, }); - expect(formatText(wrapper.text())).toBe('Edited'); + expect(formatText(wrapper.text())).toBe(`Edited ${timeago.format(updatedAt)}`); expect(findAuthorLink().exists()).toBe(false); expect(findTimeAgoTooltip().exists()).toBe(true); }); diff --git a/spec/frontend/issues/show/components/fields/description_spec.js b/spec/frontend/issues/show/components/fields/description_spec.js index 61433607a2b..cd4d422583b 100644 --- a/spec/frontend/issues/show/components/fields/description_spec.js +++ b/spec/frontend/issues/show/components/fields/description_spec.js @@ -2,13 +2,15 @@ import { shallowMount } from '@vue/test-utils'; import DescriptionField from '~/issues/show/components/fields/description.vue'; import eventHub from '~/issues/show/event_hub'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; describe('Description field component', () => { let wrapper; const findTextarea = () => wrapper.findComponent({ ref: 'textarea' }); + const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor); - const mountComponent = (description = 'test') => + const mountComponent = ({ description = 'test', contentEditorOnIssues = false } = {}) => shallowMount(DescriptionField, { attachTo: document.body, propsData: { @@ -17,6 +19,11 @@ describe('Description field component', () => { quickActionsDocsPath: '/', value: description, }, + provide: { + glFeatures: { + contentEditorOnIssues, + }, + }, stubs: { MarkdownField, }, @@ -40,7 +47,7 @@ describe('Description field component', () => { it('renders markdown field with a markdown description', () => { const markdown = '**test**'; - wrapper = mountComponent(markdown); + wrapper = mountComponent({ description: markdown }); expect(findTextarea().element.value).toBe(markdown); }); @@ -66,4 +73,52 @@ describe('Description field component', () => { expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); }); + + describe('when contentEditorOnIssues feature flag is on', () => { + beforeEach(() => { + wrapper = mountComponent({ contentEditorOnIssues: true }); + }); + + it('uses the MarkdownEditor component to edit markdown', () => { + expect(findMarkdownEditor().props()).toEqual( + expect.objectContaining({ + value: 'test', + renderMarkdownPath: '/', + markdownDocsPath: '/', + quickActionsDocsPath: expect.any(String), + initOnAutofocus: true, + supportsQuickActions: true, + enableAutocomplete: true, + }), + ); + }); + + it('triggers update with meta+enter', () => { + findMarkdownEditor().vm.$emit('keydown', { + type: 'keydown', + keyCode: 13, + metaKey: true, + }); + + expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); + }); + + it('triggers update with ctrl+enter', () => { + findMarkdownEditor().vm.$emit('keydown', { + type: 'keydown', + keyCode: 13, + ctrlKey: true, + }); + + expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); + }); + + it('emits input event when MarkdownEditor emits input event', () => { + const markdown = 'markdown'; + + findMarkdownEditor().vm.$emit('input', markdown); + + expect(wrapper.emitted('input')).toEqual([[markdown]]); + }); + }); }); diff --git a/spec/frontend/issues/show/components/form_spec.js b/spec/frontend/issues/show/components/form_spec.js index 5c0fe991b22..aedb974cbd0 100644 --- a/spec/frontend/issues/show/components/form_spec.js +++ b/spec/frontend/issues/show/components/form_spec.js @@ -1,14 +1,16 @@ import { GlAlert } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; -import Autosave from '~/autosave'; +import { getDraft, updateDraft, clearDraft, getLockVersion } from '~/lib/utils/autosave'; import DescriptionTemplate from '~/issues/show/components/fields/description_template.vue'; +import IssuableTitleField from '~/issues/show/components/fields/title.vue'; +import DescriptionField from '~/issues/show/components/fields/description.vue'; import IssueTypeField from '~/issues/show/components/fields/type.vue'; import formComponent from '~/issues/show/components/form.vue'; import LockedWarning from '~/issues/show/components/locked_warning.vue'; import eventHub from '~/issues/show/event_hub'; -jest.mock('~/autosave'); +jest.mock('~/lib/utils/autosave'); describe('Inline edit form component', () => { let wrapper; @@ -38,9 +40,14 @@ describe('Inline edit form component', () => { ...defaultProps, ...props, }, + stubs: { + DescriptionField, + }, }); }; + const findTitleField = () => wrapper.findComponent(IssuableTitleField); + const findDescriptionField = () => wrapper.findComponent(DescriptionField); const findDescriptionTemplate = () => wrapper.findComponent(DescriptionTemplate); const findIssuableTypeField = () => wrapper.findComponent(IssueTypeField); const findLockedWarning = () => wrapper.findComponent(LockedWarning); @@ -108,16 +115,34 @@ describe('Inline edit form component', () => { }); describe('autosave', () => { - let spy; - beforeEach(() => { - spy = jest.spyOn(Autosave.prototype, 'reset'); + getDraft.mockImplementation((autosaveKey) => { + return autosaveKey[autosaveKey.length - 1]; + }); }); - it('initialized Autosave on mount', () => { + it('initializes title and description fields with saved drafts', () => { createComponent(); - expect(Autosave).toHaveBeenCalledTimes(2); + expect(findTitleField().props().value).toBe('title'); + expect(findDescriptionField().props().value).toBe('description'); + }); + + it('updates local storage drafts when title and description change', () => { + const updatedTitle = 'updated title'; + const updatedDescription = 'updated description'; + + createComponent(); + + findTitleField().vm.$emit('input', updatedTitle); + findDescriptionField().vm.$emit('input', updatedDescription); + + expect(updateDraft).toHaveBeenCalledWith(expect.any(Array), updatedTitle); + expect(updateDraft).toHaveBeenCalledWith( + expect.any(Array), + updatedDescription, + defaultProps.formState.lock_version, + ); }); it('calls reset on autosave when eventHub emits appropriate events', () => { @@ -125,33 +150,60 @@ describe('Inline edit form component', () => { eventHub.$emit('close.form'); - expect(spy).toHaveBeenCalledTimes(2); + expect(clearDraft).toHaveBeenCalledTimes(2); eventHub.$emit('delete.issuable'); - expect(spy).toHaveBeenCalledTimes(4); + expect(clearDraft).toHaveBeenCalledTimes(4); eventHub.$emit('update.issuable'); - expect(spy).toHaveBeenCalledTimes(6); + expect(clearDraft).toHaveBeenCalledTimes(6); }); describe('outdated description', () => { + const clientSideMockVersion = 'lock version from local storage'; + const serverSideMockVersion = 'lock version from server'; + + const mockGetLockVersion = () => getLockVersion.mockResolvedValue(clientSideMockVersion); + it('does not show warning if lock version from server is the same as the local lock version', () => { createComponent(); expect(findAlert().exists()).toBe(false); }); it('shows warning if lock version from server differs than the local lock version', async () => { - Autosave.prototype.getSavedLockVersion.mockResolvedValue('lock version from local storage'); + mockGetLockVersion(); createComponent({ - formState: { ...defaultProps.formState, lock_version: 'lock version from server' }, + formState: { ...defaultProps.formState, lock_version: serverSideMockVersion }, }); await nextTick(); expect(findAlert().exists()).toBe(true); }); + + describe('when saved draft is discarded', () => { + beforeEach(async () => { + mockGetLockVersion(); + + createComponent({ + formState: { ...defaultProps.formState, lock_version: serverSideMockVersion }, + }); + + await nextTick(); + + findAlert().vm.$emit('secondaryAction'); + }); + + it('hides the warning alert', () => { + expect(findAlert().exists()).toBe(false); + }); + + it('clears the description draft', () => { + expect(clearDraft).toHaveBeenCalledWith(expect.any(Array)); + }); + }); }); }); }); 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 d92aeabba0f..458c1c3f858 100644 --- a/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js +++ b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js @@ -5,7 +5,6 @@ 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'; @@ -38,7 +37,6 @@ describe('Incident Tabs component', () => { projectId: '', issuableId: '', uploadMetricsFeatureAvailable: true, - glFeatures: { incidentTimeline: true }, }, data() { return { alert: mockAlert, ...data }; @@ -67,7 +65,6 @@ describe('Incident Tabs component', () => { const findAlertDetailsComponent = () => wrapper.findComponent(AlertDetailsTable); const findDescriptionComponent = () => wrapper.findComponent(DescriptionComponent); const findHighlightBarComponent = () => wrapper.findComponent(HighlightBar); - const findTimelineTab = () => wrapper.findComponent(TimelineTab); describe('empty state', () => { beforeEach(() => { @@ -128,20 +125,4 @@ 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/timeline_events_form_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js index 7f086a276f7..2e7449974e5 100644 --- a/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js +++ b/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js @@ -22,12 +22,15 @@ describe('Timeline events form', () => { useFakeDate(fakeDate); let wrapper; - const mountComponent = ({ mountMethod = shallowMountExtended }) => { + const mountComponent = ({ mountMethod = shallowMountExtended } = {}) => { wrapper = mountMethod(TimelineEventsForm, { propsData: { showSaveAndAdd: true, isEventProcessed: false, }, + stubs: { + GlButton: true, + }, }); }; @@ -48,17 +51,18 @@ describe('Timeline events form', () => { findHourInput().setValue(5); findMinuteInput().setValue(45); }; + const findTextarea = () => wrapper.findByTestId('input-note'); const submitForm = async () => { - findSubmitButton().trigger('click'); + findSubmitButton().vm.$emit('click'); await waitForPromises(); }; const submitFormAndAddAnother = async () => { - findSubmitAndAddButton().trigger('click'); + findSubmitAndAddButton().vm.$emit('click'); await waitForPromises(); }; const cancelForm = async () => { - findCancelButton().trigger('click'); + findCancelButton().vm.$emit('click'); await waitForPromises(); }; @@ -118,5 +122,17 @@ describe('Timeline events form', () => { expect(findHourInput().element.value).toBe('0'); expect(findMinuteInput().element.value).toBe('0'); }); + + it('should disable the save buttons when event content does not exist', async () => { + expect(findSubmitButton().props('disabled')).toBe(true); + expect(findSubmitAndAddButton().props('disabled')).toBe(true); + }); + + it('should enable the save buttons when event content exists', async () => { + await findTextarea().setValue('hello'); + + expect(findSubmitButton().props('disabled')).toBe(false); + expect(findSubmitAndAddButton().props('disabled')).toBe(false); + }); }); }); diff --git a/spec/frontend/jira_connect/branches/components/new_branch_form_spec.js b/spec/frontend/jira_connect/branches/components/new_branch_form_spec.js index cc8346253ee..d41031f9eaa 100644 --- a/spec/frontend/jira_connect/branches/components/new_branch_form_spec.js +++ b/spec/frontend/jira_connect/branches/components/new_branch_form_spec.js @@ -238,7 +238,7 @@ describe('NewBranchForm', () => { scenario | mutation | alertTitle | alertText ${'with errors-as-data'} | ${mockCreateBranchMutationWithErrors} | ${CREATE_BRANCH_ERROR_WITH_CONTEXT} | ${mockCreateBranchMutationResponseWithErrors.data.createBranch.errors[0]} ${'top-level error'} | ${mockCreateBranchMutationFailed} | ${''} | ${CREATE_BRANCH_ERROR_GENERIC} - `('', ({ mutation, alertTitle, alertText }) => { + `('given $scenario', ({ mutation, alertTitle, alertText }) => { beforeEach(async () => { createComponent({ mockApollo: createMockApolloProvider({ diff --git a/spec/frontend/jira_connect/subscriptions/pkce_spec.js b/spec/frontend/jira_connect/subscriptions/pkce_spec.js index 4ee88059b7a..671922c36d8 100644 --- a/spec/frontend/jira_connect/subscriptions/pkce_spec.js +++ b/spec/frontend/jira_connect/subscriptions/pkce_spec.js @@ -1,11 +1,7 @@ import crypto from 'crypto'; -import { TextEncoder, TextDecoder } from 'util'; import { createCodeVerifier, createCodeChallenge } from '~/jira_connect/subscriptions/pkce'; -global.TextEncoder = TextEncoder; -global.TextDecoder = TextDecoder; - describe('pkce', () => { beforeAll(() => { Object.defineProperty(global.self, 'crypto', { diff --git a/spec/frontend/jobs/components/table/job_table_app_spec.js b/spec/frontend/jobs/components/table/job_table_app_spec.js index 8c724a8030b..109cef6f817 100644 --- a/spec/frontend/jobs/components/table/job_table_app_spec.js +++ b/spec/frontend/jobs/components/table/job_table_app_spec.js @@ -12,7 +12,7 @@ import { s__ } from '~/locale'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'spec/test_constants'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import getJobsQuery from '~/jobs/components/table/graphql/queries/get_jobs.query.graphql'; import JobsTable from '~/jobs/components/table/jobs_table.vue'; import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue'; @@ -229,7 +229,7 @@ describe('Job table app', () => { await findFilteredSearch().vm.$emit('filterJobsBySearch', ['raw text']); - expect(createFlash).toHaveBeenCalledWith(expectedWarning); + expect(createAlert).toHaveBeenCalledWith(expectedWarning); expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); }); diff --git a/spec/frontend/labels/components/promote_label_modal_spec.js b/spec/frontend/labels/components/promote_label_modal_spec.js index 8cfaba6f98a..8953e3cbcd8 100644 --- a/spec/frontend/labels/components/promote_label_modal_spec.js +++ b/spec/frontend/labels/components/promote_label_modal_spec.js @@ -1,98 +1,100 @@ -import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { GlModal, GlSprintf } from '@gitlab/ui'; +import AxiosMockAdapter from 'axios-mock-adapter'; + import { TEST_HOST } from 'helpers/test_constants'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { stubComponent } from 'helpers/stub_component'; + import axios from '~/lib/utils/axios_utils'; -import promoteLabelModal from '~/labels/components/promote_label_modal.vue'; +import PromoteLabelModal from '~/labels/components/promote_label_modal.vue'; import eventHub from '~/labels/event_hub'; describe('Promote label modal', () => { - let vm; - const Component = Vue.extend(promoteLabelModal); + let wrapper; + let axiosMock; + const labelMockData = { labelTitle: 'Documentation', - labelColor: '#5cb85c', - labelTextColor: '#ffffff', + labelColor: 'rgb(92, 184, 92)', + labelTextColor: 'rgb(255, 255, 255)', url: `${TEST_HOST}/dummy/promote/labels`, groupName: 'group', }; - describe('Modal title and description', () => { - beforeEach(() => { - vm = mountComponent(Component, labelMockData); + const createComponent = () => { + wrapper = shallowMount(PromoteLabelModal, { + propsData: labelMockData, + stubs: { + GlSprintf, + GlModal: stubComponent(GlModal, { + template: `<div><slot name="modal-title"></slot><slot></slot></div>`, + }), + }, }); + }; - afterEach(() => { - vm.$destroy(); - }); + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + createComponent(); + }); + afterEach(() => { + axiosMock.reset(); + wrapper.destroy(); + }); + + describe('Modal title and description', () => { it('contains the proper description', () => { - expect(vm.text).toContain( + expect(wrapper.text()).toContain( `Promoting ${labelMockData.labelTitle} will make it available for all projects inside ${labelMockData.groupName}`, ); }); it('contains a label span with the color', () => { - expect(vm.labelColor).not.toBe(null); - expect(vm.labelColor).toBe(labelMockData.labelColor); - expect(vm.labelTitle).toBe(labelMockData.labelTitle); + const label = wrapper.find('.modal-title-with-label .label'); + + expect(label.element.style.backgroundColor).toBe(labelMockData.labelColor); + expect(label.element.style.color).toBe(labelMockData.labelTextColor); + expect(label.text()).toBe(labelMockData.labelTitle); }); }); describe('When requesting a label promotion', () => { beforeEach(() => { - vm = mountComponent(Component, { - ...labelMockData, - }); jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); }); - afterEach(() => { - vm.$destroy(); - }); - - it('redirects when a label is promoted', () => { + it('redirects when a label is promoted', async () => { const responseURL = `${TEST_HOST}/dummy/endpoint`; - jest.spyOn(axios, 'post').mockImplementation((url) => { - expect(url).toBe(labelMockData.url); - expect(eventHub.$emit).toHaveBeenCalledWith( - 'promoteLabelModal.requestStarted', - labelMockData.url, - ); - return Promise.resolve({ - request: { - responseURL, - }, - }); - }); + axiosMock.onPost(labelMockData.url).reply(200, { url: responseURL }); - return vm.onSubmit().then(() => { - expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', { - labelUrl: labelMockData.url, - successful: true, - }); + wrapper.findComponent(GlModal).vm.$emit('primary'); + + expect(eventHub.$emit).toHaveBeenCalledWith( + 'promoteLabelModal.requestStarted', + labelMockData.url, + ); + + await axios.waitForAll(); + + expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', { + labelUrl: labelMockData.url, + successful: true, }); }); - it('displays an error if promoting a label failed', () => { + it('displays an error if promoting a label failed', async () => { const dummyError = new Error('promoting label failed'); dummyError.response = { status: 500 }; + axiosMock.onPost(labelMockData.url).reply(500, { error: dummyError }); - jest.spyOn(axios, 'post').mockImplementation((url) => { - expect(url).toBe(labelMockData.url); - expect(eventHub.$emit).toHaveBeenCalledWith( - 'promoteLabelModal.requestStarted', - labelMockData.url, - ); + wrapper.findComponent(GlModal).vm.$emit('primary'); - return Promise.reject(dummyError); - }); + await axios.waitForAll(); - return vm.onSubmit().catch((error) => { - expect(error).toBe(dummyError); - expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', { - labelUrl: labelMockData.url, - successful: false, - }); + expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', { + labelUrl: labelMockData.url, + successful: false, }); }); }); diff --git a/spec/frontend/lib/dompurify_spec.js b/spec/frontend/lib/dompurify_spec.js index 5523cc0606e..412408ce377 100644 --- a/spec/frontend/lib/dompurify_spec.js +++ b/spec/frontend/lib/dompurify_spec.js @@ -1,4 +1,4 @@ -import { sanitize } from '~/lib/dompurify'; +import { sanitize, defaultConfig } from '~/lib/dompurify'; // GDK const rootGon = { @@ -45,7 +45,7 @@ const invalidProtocolUrls = [ /* eslint-enable no-script-url */ const validProtocolUrls = ['slack://open', 'x-devonthink-item://90909', 'x-devonthink-item:90909']; -const forbiddenDataAttrs = ['data-remote', 'data-url', 'data-type', 'data-method']; +const forbiddenDataAttrs = defaultConfig.FORBID_ATTR; const acceptedDataAttrs = ['data-random', 'data-custom']; describe('~/lib/dompurify', () => { diff --git a/spec/frontend/lib/utils/autosave_spec.js b/spec/frontend/lib/utils/autosave_spec.js index 12e97f6cdec..afb49dd6db4 100644 --- a/spec/frontend/lib/utils/autosave_spec.js +++ b/spec/frontend/lib/utils/autosave_spec.js @@ -1,32 +1,42 @@ -import { clearDraft, getDraft, updateDraft } from '~/lib/utils/autosave'; +import { clearDraft, getDraft, updateDraft, getLockVersion } from '~/lib/utils/autosave'; describe('autosave utils', () => { const autosaveKey = 'dummy-autosave-key'; const text = 'some dummy text'; + const lockVersion = '2'; + const normalizedAutosaveKey = `autosave/${autosaveKey}`; + const lockVersionKey = `autosave/${autosaveKey}/lockVersion`; describe('clearDraft', () => { beforeEach(() => { - localStorage.setItem(`autosave/${autosaveKey}`, text); + localStorage.setItem(normalizedAutosaveKey, text); + localStorage.setItem(lockVersionKey, lockVersion); }); afterEach(() => { - localStorage.removeItem(`autosave/${autosaveKey}`); + localStorage.removeItem(normalizedAutosaveKey); }); it('removes the draft from localStorage', () => { clearDraft(autosaveKey); - expect(localStorage.getItem(`autosave/${autosaveKey}`)).toBe(null); + expect(localStorage.getItem(normalizedAutosaveKey)).toBe(null); + }); + + it('removes the lockVersion from localStorage', () => { + clearDraft(autosaveKey); + + expect(localStorage.getItem(lockVersionKey)).toBe(null); }); }); describe('getDraft', () => { beforeEach(() => { - localStorage.setItem(`autosave/${autosaveKey}`, text); + localStorage.setItem(normalizedAutosaveKey, text); }); afterEach(() => { - localStorage.removeItem(`autosave/${autosaveKey}`); + localStorage.removeItem(normalizedAutosaveKey); }); it('returns the draft from localStorage', () => { @@ -36,7 +46,7 @@ describe('autosave utils', () => { }); it('returns null if no entry exists in localStorage', () => { - localStorage.removeItem(`autosave/${autosaveKey}`); + localStorage.removeItem(normalizedAutosaveKey); const result = getDraft(autosaveKey); @@ -46,19 +56,44 @@ describe('autosave utils', () => { describe('updateDraft', () => { beforeEach(() => { - localStorage.setItem(`autosave/${autosaveKey}`, text); + localStorage.setItem(normalizedAutosaveKey, text); }); afterEach(() => { - localStorage.removeItem(`autosave/${autosaveKey}`); + localStorage.removeItem(normalizedAutosaveKey); }); - it('removes the draft from localStorage', () => { + it('updates the stored draft', () => { const newText = 'new text'; updateDraft(autosaveKey, newText); - expect(localStorage.getItem(`autosave/${autosaveKey}`)).toBe(newText); + expect(localStorage.getItem(normalizedAutosaveKey)).toBe(newText); + }); + + describe('when lockVersion is provided', () => { + it('updates the stored lockVersion', () => { + const newText = 'new text'; + const newLockVersion = '2'; + + updateDraft(autosaveKey, newText, lockVersion); + + expect(localStorage.getItem(lockVersionKey)).toBe(newLockVersion); + }); + }); + }); + + describe('getLockVersion', () => { + beforeEach(() => { + localStorage.setItem(lockVersionKey, lockVersion); + }); + + afterEach(() => { + localStorage.removeItem(lockVersionKey); + }); + + it('returns the lockVersion from localStorage', () => { + expect(getLockVersion(autosaveKey)).toBe(lockVersion); }); }); }); diff --git a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js index 018ae12c908..2e0bb6a8dcd 100644 --- a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js +++ b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js @@ -145,3 +145,22 @@ describe('durationTimeFormatted', () => { expect(utils.durationTimeFormatted(duration)).toBe(expectedOutput); }); }); + +describe('formatUtcOffset', () => { + it.each` + offset | expected + ${-32400} | ${'- 9'} + ${'-12600'} | ${'- 3.5'} + ${0} | ${'0'} + ${'10800'} | ${'+ 3'} + ${19800} | ${'+ 5.5'} + ${0} | ${'0'} + ${[]} | ${'0'} + ${{}} | ${'0'} + ${true} | ${'0'} + ${null} | ${'0'} + ${undefined} | ${'0'} + `('returns $expected given $offset', ({ offset, expected }) => { + expect(utils.formatUtcOffset(offset)).toEqual(expected); + }); +}); diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js index 8d179baa505..9fbb3d0a660 100644 --- a/spec/frontend/lib/utils/text_markdown_spec.js +++ b/spec/frontend/lib/utils/text_markdown_spec.js @@ -4,15 +4,30 @@ import { keypressNoteText, compositionStartNoteText, compositionEndNoteText, + updateTextForToolbarBtn, } from '~/lib/utils/text_markdown'; import '~/lib/utils/jquery_at_who'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; describe('init markdown', () => { + let mdArea; let textArea; + let indentButton; + let outdentButton; beforeAll(() => { - textArea = document.createElement('textarea'); - document.querySelector('body').appendChild(textArea); + setHTMLFixture( + `<div class='md-area'> + <textarea></textarea> + <button data-md-command="indentLines" id="indentButton"></button> + <button data-md-command="outdentLines" id="outdentButton"></button> + </div>`, + ); + mdArea = document.querySelector('.md-area'); + textArea = mdArea.querySelector('textarea'); + indentButton = mdArea.querySelector('#indentButton'); + outdentButton = mdArea.querySelector('#outdentButton'); + textArea.focus(); // needed for the underlying insertText to work @@ -20,7 +35,7 @@ describe('init markdown', () => { }); afterAll(() => { - textArea.parentNode.removeChild(textArea); + resetHTMLFixture(); }); describe('insertMarkdownText', () => { @@ -183,6 +198,7 @@ describe('init markdown', () => { textArea.addEventListener('keydown', keypressNoteText); textArea.addEventListener('compositionstart', compositionStartNoteText); textArea.addEventListener('compositionend', compositionEndNoteText); + gon.markdown_automatic_lists = true; }); it.each` @@ -302,19 +318,22 @@ describe('init markdown', () => { expect(textArea.value).toEqual(expected); expect(textArea.selectionStart).toBe(expected.length); }); - }); - }); - describe('shifting selected lines left or right', () => { - const indentEvent = new KeyboardEvent('keydown', { key: ']', metaKey: true }); - const outdentEvent = new KeyboardEvent('keydown', { key: '[', metaKey: true }); + it('does nothing if user preference disabled', () => { + const text = '- test'; - beforeEach(() => { - textArea.addEventListener('keydown', keypressNoteText); - textArea.addEventListener('compositionstart', compositionStartNoteText); - textArea.addEventListener('compositionend', compositionEndNoteText); + gon.markdown_automatic_lists = false; + + textArea.value = text; + textArea.setSelectionRange(text.length, text.length); + textArea.dispatchEvent(enterEvent); + + expect(textArea.value).toEqual(text); + }); }); + }); + describe('shifting selected lines left or right', () => { it.each` selectionStart | selectionEnd | expected | expectedSelectionStart | expectedSelectionEnd ${0} | ${0} | ${' 012\n456\n89'} | ${2} | ${2} @@ -338,7 +357,7 @@ describe('init markdown', () => { textArea.value = text; textArea.setSelectionRange(selectionStart, selectionEnd); - textArea.dispatchEvent(indentEvent); + updateTextForToolbarBtn($(indentButton)); expect(textArea.value).toEqual(expected); expect(textArea.selectionStart).toEqual(expectedSelectionStart); @@ -350,7 +369,7 @@ describe('init markdown', () => { textArea.value = '012\n\n89'; textArea.setSelectionRange(4, 4); - textArea.dispatchEvent(indentEvent); + updateTextForToolbarBtn($(indentButton)); expect(textArea.value).toEqual('012\n \n89'); expect(textArea.selectionStart).toEqual(6); @@ -381,7 +400,7 @@ describe('init markdown', () => { textArea.value = text; textArea.setSelectionRange(selectionStart, selectionEnd); - textArea.dispatchEvent(outdentEvent); + updateTextForToolbarBtn($(outdentButton)); expect(textArea.value).toEqual(expected); expect(textArea.selectionStart).toEqual(expectedSelectionStart); @@ -393,7 +412,7 @@ describe('init markdown', () => { textArea.value = '012\n\n89'; textArea.setSelectionRange(4, 4); - textArea.dispatchEvent(outdentEvent); + updateTextForToolbarBtn($(outdentButton)); expect(textArea.value).toEqual('012\n\n89'); expect(textArea.selectionStart).toEqual(4); diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js index 49a160c9f23..f2572ca0ad2 100644 --- a/spec/frontend/lib/utils/text_utility_spec.js +++ b/spec/frontend/lib/utils/text_utility_spec.js @@ -386,4 +386,16 @@ describe('text_utility', () => { expect(textUtils.limitedCounterWithDelimiter(120)).toBe(120); }); }); + + describe('base64EncodeUnicode', () => { + it('encodes unicode characters', () => { + expect(textUtils.base64EncodeUnicode('😀')).toBe('8J+YgA=='); + }); + }); + + describe('base64DecodeUnicode', () => { + it('decodes unicode characters', () => { + expect(textUtils.base64DecodeUnicode('8J+YgA==')).toBe('😀'); + }); + }); }); diff --git a/spec/frontend/listbox/index_spec.js b/spec/frontend/listbox/index_spec.js index 07c6cca535a..fd41531796b 100644 --- a/spec/frontend/listbox/index_spec.js +++ b/spec/frontend/listbox/index_spec.js @@ -1,6 +1,6 @@ import { nextTick } from 'vue'; -import { getAllByRole, getByRole } from '@testing-library/dom'; -import { GlDropdown } from '@gitlab/ui'; +import { getAllByRole, getByTestId } from '@testing-library/dom'; +import { GlListbox } from '@gitlab/ui'; import { createWrapper } from '@vue/test-utils'; import { initListbox, parseAttributes } from '~/listbox'; import { getFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; @@ -28,20 +28,6 @@ describe('initListbox', () => { instance = initListbox(...args); }; - // TODO: Rewrite these finders to use better semantics once the - // implementation is switched to GlListbox - // https://gitlab.com/gitlab-org/gitlab/-/issues/348738 - const findToggleButton = () => document.body.querySelector('.gl-dropdown-toggle'); - const findItem = (text) => getByRole(document.body, 'menuitem', { name: text }); - const findItems = () => getAllByRole(document.body, 'menuitem'); - const findSelectedItems = () => - findItems().filter( - (menuitem) => - !menuitem - .querySelector('.gl-new-dropdown-item-check-icon') - .classList.contains('gl-visibility-hidden'), - ); - it('returns null given no element', () => { setup(); @@ -55,6 +41,10 @@ describe('initListbox', () => { describe('given a valid element', () => { let onChangeSpy; + const listbox = () => createWrapper(instance).findComponent(GlListbox); + const findToggleButton = () => getByTestId(document.body, 'base-dropdown-toggle'); + const findSelectedItems = () => getAllByRole(document.body, 'option', { selected: true }); + beforeEach(async () => { setHTMLFixture(fixture); onChangeSpy = jest.fn(); @@ -85,10 +75,9 @@ describe('initListbox', () => { expect(instance.$el.classList).toContain('test-class-1', 'test-class-2'); }); - describe.each(parsedAttributes.items)('clicking on an item', (item) => { + describe.each(parsedAttributes.items)('selecting an item', (item) => { beforeEach(async () => { - findItem(item.text).click(); - + listbox().vm.$emit('select', item.value); await nextTick(); }); @@ -108,8 +97,7 @@ describe('initListbox', () => { }); it('passes the "right" prop through to the underlying component', () => { - const wrapper = createWrapper(instance).findComponent(GlDropdown); - expect(wrapper.props('right')).toBe(parsedAttributes.right); + expect(listbox().props('right')).toBe(parsedAttributes.right); }); }); }); diff --git a/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js b/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js index 5581fd52458..ef3c8bde3cf 100644 --- a/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js +++ b/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js @@ -45,7 +45,7 @@ describe('SortDropdown', () => { const findSortingComponent = () => wrapper.findComponent(GlSorting); const findSortDirectionToggle = () => - findSortingComponent().find('button[title="Sort direction"]'); + findSortingComponent().find('button[title^="Sort direction"]'); const findDropdownToggle = () => wrapper.find('button[aria-haspopup="true"]'); const findDropdownItemByText = (text) => wrapper diff --git a/spec/frontend/members/components/table/member_action_buttons_spec.js b/spec/frontend/members/components/table/member_action_buttons_spec.js index f3f50bf620a..03cfc6ca0f6 100644 --- a/spec/frontend/members/components/table/member_action_buttons_spec.js +++ b/spec/frontend/members/components/table/member_action_buttons_spec.js @@ -27,7 +27,7 @@ describe('MemberActionButtons', () => { wrapper.destroy(); }); - test.each` + it.each` memberType | member | expectedComponent | expectedComponentName ${MEMBER_TYPES.user} | ${memberMock} | ${UserActionButtons} | ${'UserActionButtons'} ${MEMBER_TYPES.group} | ${group} | ${GroupActionButtons} | ${'GroupActionButtons'} diff --git a/spec/frontend/members/components/table/member_avatar_spec.js b/spec/frontend/members/components/table/member_avatar_spec.js index 35f82c28fc5..dc5c97f41df 100644 --- a/spec/frontend/members/components/table/member_avatar_spec.js +++ b/spec/frontend/members/components/table/member_avatar_spec.js @@ -22,7 +22,7 @@ describe('MemberList', () => { wrapper.destroy(); }); - test.each` + it.each` memberType | member | expectedComponent | expectedComponentName ${MEMBER_TYPES.user} | ${memberMock} | ${UserAvatar} | ${'UserAvatar'} ${MEMBER_TYPES.group} | ${group} | ${GroupAvatar} | ${'GroupAvatar'} diff --git a/spec/frontend/members/components/table/members_table_cell_spec.js b/spec/frontend/members/components/table/members_table_cell_spec.js index fd56699602e..0b0140b0cdb 100644 --- a/spec/frontend/members/components/table/members_table_cell_spec.js +++ b/spec/frontend/members/components/table/members_table_cell_spec.js @@ -95,7 +95,7 @@ describe('MembersTableCell', () => { wrapper = null; }); - test.each` + it.each` member | expectedMemberType ${memberMock} | ${MEMBER_TYPES.user} ${group} | ${MEMBER_TYPES.group} diff --git a/spec/frontend/members/utils_spec.js b/spec/frontend/members/utils_spec.js index 0271483801c..8bef2096a2a 100644 --- a/spec/frontend/members/utils_spec.js +++ b/spec/frontend/members/utils_spec.js @@ -89,7 +89,7 @@ describe('Members Utils', () => { }); describe('isGroup', () => { - test.each` + it.each` member | expected ${group} | ${true} ${memberMock} | ${false} @@ -99,7 +99,7 @@ describe('Members Utils', () => { }); describe('isDirectMember', () => { - test.each` + it.each` member | expected ${directMember} | ${true} ${inheritedMember} | ${false} @@ -109,7 +109,7 @@ describe('Members Utils', () => { }); describe('isCurrentUser', () => { - test.each` + it.each` currentUserId | expected ${IS_CURRENT_USER_ID} | ${true} ${IS_NOT_CURRENT_USER_ID} | ${false} @@ -119,7 +119,7 @@ describe('Members Utils', () => { }); describe('canRemove', () => { - test.each` + it.each` member | expected ${{ ...directMember, canRemove: true }} | ${true} ${{ ...inheritedMember, canRemove: true }} | ${false} @@ -130,7 +130,7 @@ describe('Members Utils', () => { }); describe('canResend', () => { - test.each` + it.each` member | expected ${invite} | ${true} ${{ ...invite, invite: { ...invite.invite, canResend: false } }} | ${false} @@ -140,7 +140,7 @@ describe('Members Utils', () => { }); describe('canUpdate', () => { - test.each` + it.each` member | currentUserId | expected ${{ ...directMember, canUpdate: true }} | ${IS_NOT_CURRENT_USER_ID} | ${true} ${{ ...directMember, canUpdate: true }} | ${IS_CURRENT_USER_ID} | ${false} diff --git a/spec/frontend/merge_conflicts/store/actions_spec.js b/spec/frontend/merge_conflicts/store/actions_spec.js index e73769cba51..50eac982e20 100644 --- a/spec/frontend/merge_conflicts/store/actions_spec.js +++ b/spec/frontend/merge_conflicts/store/actions_spec.js @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import Cookies from '~/lib/utils/cookies'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import testAction from 'helpers/vuex_action_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { INTERACTIVE_RESOLVE_MODE, EDIT_RESOLVE_MODE } from '~/merge_conflicts/constants'; import * as actions from '~/merge_conflicts/store/actions'; import * as types from '~/merge_conflicts/store/mutation_types'; @@ -125,7 +125,7 @@ describe('merge conflicts actions', () => { ], [], ); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'Failed to save merge conflicts resolutions. Please try again!', }); }); diff --git a/spec/frontend/merge_request_spec.js b/spec/frontend/merge_request_spec.js index bcf64204c7a..16e3e49a297 100644 --- a/spec/frontend/merge_request_spec.js +++ b/spec/frontend/merge_request_spec.js @@ -3,9 +3,12 @@ import $ from 'jquery'; import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'spec/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import MergeRequest from '~/merge_request'; +jest.mock('~/flash'); + describe('MergeRequest', () => { const test = {}; describe('task lists', () => { @@ -95,8 +98,11 @@ describe('MergeRequest', () => { await waitForPromises(); - expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe( - 'Someone edited this merge request at the same time you did. Please refresh the page to see changes.', + expect(createAlert).toHaveBeenCalledWith( + expect.objectContaining({ + message: + 'Someone edited this merge request at the same time you did. Please refresh the page to see changes.', + }), ); }); }); diff --git a/spec/frontend/milestones/components/promote_milestone_modal_spec.js b/spec/frontend/milestones/components/promote_milestone_modal_spec.js index 11eaa92f2b0..60657fbc9b8 100644 --- a/spec/frontend/milestones/components/promote_milestone_modal_spec.js +++ b/spec/frontend/milestones/components/promote_milestone_modal_spec.js @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import { setHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as urlUtils from '~/lib/utils/url_utility'; import PromoteMilestoneModal from '~/milestones/components/promote_milestone_modal.vue'; @@ -103,7 +103,7 @@ describe('Promote milestone modal', () => { wrapper.findComponent(GlModal).vm.$emit('primary'); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ message: dummyError }); + expect(createAlert).toHaveBeenCalledWith({ message: dummyError }); }); }); }); diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index 1de6b6e3e98..1d17a9116df 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -4,7 +4,7 @@ import { nextTick } from 'vue'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { ESC_KEY } from '~/lib/utils/keys'; import { objectToQuery } from '~/lib/utils/url_utility'; @@ -198,7 +198,7 @@ describe('Dashboard', () => { ); await nextTick(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); it('does not display a warning if there are no validation warnings', async () => { @@ -210,7 +210,7 @@ describe('Dashboard', () => { ); await nextTick(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); }); @@ -275,7 +275,7 @@ describe('Dashboard', () => { setupStoreWithData(store); await nextTick(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); expect(store.dispatch).not.toHaveBeenCalledWith( 'monitoringDashboard/setExpandedPanel', expect.anything(), diff --git a/spec/frontend/monitoring/components/dashboard_url_time_spec.js b/spec/frontend/monitoring/components/dashboard_url_time_spec.js index a327e234581..9873654bdda 100644 --- a/spec/frontend/monitoring/components/dashboard_url_time_spec.js +++ b/spec/frontend/monitoring/components/dashboard_url_time_spec.js @@ -1,7 +1,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { queryToObject, @@ -115,7 +115,7 @@ describe('dashboard invalid url parameters', () => { createMountedWrapper(); await nextTick(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); expect(findDateTimePicker().props('value')).toEqual(defaultTimeRange); diff --git a/spec/frontend/monitoring/requests/index_spec.js b/spec/frontend/monitoring/requests/index_spec.js index 03bf5d70153..6f9af911a9f 100644 --- a/spec/frontend/monitoring/requests/index_spec.js +++ b/spec/frontend/monitoring/requests/index_spec.js @@ -129,7 +129,7 @@ describe('monitoring metrics_requests', () => { }); }); - test.each` + it.each` code | reason ${statusCodes.BAD_REQUEST} | ${'Parameters are missing or incorrect'} ${statusCodes.UNPROCESSABLE_ENTITY} | ${"Expression can't be executed"} diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index a872a7780eb..ca66768c3cc 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import { backoffMockImplementation } from 'helpers/backoff_helper'; import testAction from 'helpers/vuex_action_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as commonUtils from '~/lib/utils/common_utils'; import statusCodes from '~/lib/utils/http_status'; @@ -82,7 +82,7 @@ describe('Monitoring store actions', () => { mock.reset(); commonUtils.backOff.mockReset(); - createFlash.mockReset(); + createAlert.mockReset(); }); // Setup @@ -241,7 +241,7 @@ describe('Monitoring store actions', () => { 'receiveMetricsDashboardFailure', new Error('Request failed with status code 500'), ); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); it('dispatches a failure action when a message is returned', async () => { @@ -250,7 +250,7 @@ describe('Monitoring store actions', () => { 'receiveMetricsDashboardFailure', new Error('Request failed with status code 500'), ); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: expect.stringContaining(mockDashboardsErrorResponse.message), }); }); @@ -263,7 +263,7 @@ describe('Monitoring store actions', () => { 'receiveMetricsDashboardFailure', new Error('Request failed with status code 500'), ); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); }); }); @@ -328,7 +328,7 @@ describe('Monitoring store actions', () => { }, }); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); it('dispatches fetchPrometheusMetric for each panel query', async () => { @@ -385,7 +385,7 @@ describe('Monitoring store actions', () => { defaultQueryParams, }); - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); }); }); @@ -570,7 +570,7 @@ describe('Monitoring store actions', () => { [], [{ type: 'receiveDeploymentsDataFailure' }], () => { - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }, ); }); @@ -1084,8 +1084,8 @@ describe('Monitoring store actions', () => { return testAction(fetchVariableMetricLabelValues, { defaultQueryParams }, state, [], []).then( () => { - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ message: expect.stringContaining('error getting options for variable "label1"'), }); }, diff --git a/spec/frontend/monitoring/utils_spec.js b/spec/frontend/monitoring/utils_spec.js index 31975052077..6c6c3d6b90f 100644 --- a/spec/frontend/monitoring/utils_spec.js +++ b/spec/frontend/monitoring/utils_spec.js @@ -290,7 +290,7 @@ describe('monitoring/utils', () => { expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow(); }); - test.each` + it.each` group | title | yLabel | missingField ${'NOT_A_GROUP'} | ${title} | ${yLabel} | ${'group'} ${group} | ${'NOT_A_TITLE'} | ${yLabel} | ${'title'} @@ -367,7 +367,7 @@ describe('monitoring/utils', () => { ], }; - [ + it.each([ { input: { metrics: undefined }, output: {}, @@ -393,12 +393,10 @@ describe('monitoring/utils', () => { output: multipleMetricExpected, testCase: 'barChartsDataParser returns multiple series object with multiple metrics', }, - ].forEach(({ input, output, testCase }) => { - it(testCase, () => { - expect(monitoringUtils.barChartsDataParser(input.metrics)).toEqual( - expect.objectContaining(output), - ); - }); + ])('$testCase', ({ input, output }) => { + expect(monitoringUtils.barChartsDataParser(input.metrics)).toEqual( + expect.objectContaining(output), + ); }); }); diff --git a/spec/frontend/nav/components/top_nav_app_spec.js b/spec/frontend/nav/components/top_nav_app_spec.js index 745707c1d28..b32ab5ebe09 100644 --- a/spec/frontend/nav/components/top_nav_app_spec.js +++ b/spec/frontend/nav/components/top_nav_app_spec.js @@ -1,5 +1,6 @@ import { GlNavItemDropdown } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { mount, shallowMount } from '@vue/test-utils'; +import { mockTracking } from 'helpers/tracking_helper'; import TopNavApp from '~/nav/components/top_nav_app.vue'; import TopNavDropdownMenu from '~/nav/components/top_nav_dropdown_menu.vue'; import { TEST_NAV_DATA } from '../mock_data'; @@ -8,6 +9,14 @@ describe('~/nav/components/top_nav_app.vue', () => { let wrapper; const createComponent = () => { + wrapper = mount(TopNavApp, { + propsData: { + navData: TEST_NAV_DATA, + }, + }); + }; + + const createComponentShallow = () => { wrapper = shallowMount(TopNavApp, { propsData: { navData: TEST_NAV_DATA, @@ -16,6 +25,7 @@ describe('~/nav/components/top_nav_app.vue', () => { }; const findNavItemDropdown = () => wrapper.findComponent(GlNavItemDropdown); + const findNavItemDropdowToggle = () => findNavItemDropdown().find('.js-top-nav-dropdown-toggle'); const findMenu = () => wrapper.findComponent(TopNavDropdownMenu); afterEach(() => { @@ -24,7 +34,7 @@ describe('~/nav/components/top_nav_app.vue', () => { describe('default', () => { beforeEach(() => { - createComponent(); + createComponentShallow(); }); it('renders nav item dropdown', () => { @@ -45,4 +55,18 @@ describe('~/nav/components/top_nav_app.vue', () => { }); }); }); + + describe('tracking', () => { + it('emits a tracking event when the toggle is clicked', () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + createComponent(); + + findNavItemDropdowToggle().trigger('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_nav', { + label: 'hamburger_menu', + property: 'top_navigation', + }); + }); + }); }); diff --git a/spec/frontend/notebook/cells/output/index_spec.js b/spec/frontend/notebook/cells/output/index_spec.js index 97a7e22be60..8bf049235a9 100644 --- a/spec/frontend/notebook/cells/output/index_spec.js +++ b/spec/frontend/notebook/cells/output/index_spec.js @@ -53,6 +53,7 @@ describe('Output component', () => { expect(iframe.exists()).toBe(true); expect(iframe.element.getAttribute('sandbox')).toBe(''); expect(iframe.element.getAttribute('srcdoc')).toBe('<p>test</p>'); + expect(iframe.element.getAttribute('scrolling')).toBe('auto'); }); it('renders multiple raw HTML outputs', () => { diff --git a/spec/frontend/notes/components/__snapshots__/notes_app_spec.js.snap b/spec/frontend/notes/components/__snapshots__/notes_app_spec.js.snap index 5f4b3e04a79..bc29903d4bf 100644 --- a/spec/frontend/notes/components/__snapshots__/notes_app_spec.js.snap +++ b/spec/frontend/notes/components/__snapshots__/notes_app_spec.js.snap @@ -3,15 +3,15 @@ exports[`note_app when sort direction is asc shows skeleton notes after the loaded discussions 1`] = ` "<ul id=\\"notes-list\\" class=\\"notes main-notes-list timeline\\"> <noteable-discussion-stub discussion=\\"[object Object]\\" renderdifffile=\\"true\\" helppagepath=\\"\\" isoverviewtab=\\"true\\"></noteable-discussion-stub> - <skeleton-loading-container-stub></skeleton-loading-container-stub> - <discussion-filter-note-stub style=\\"display: none;\\"></discussion-filter-note-stub> + <skeleton-loading-container-stub class=\\"note-skeleton\\"></skeleton-loading-container-stub> + <!----> </ul>" `; exports[`note_app when sort direction is desc shows skeleton notes before the loaded discussions 1`] = ` "<ul id=\\"notes-list\\" class=\\"notes main-notes-list timeline\\"> - <skeleton-loading-container-stub></skeleton-loading-container-stub> + <skeleton-loading-container-stub class=\\"note-skeleton\\"></skeleton-loading-container-stub> <noteable-discussion-stub discussion=\\"[object Object]\\" renderdifffile=\\"true\\" helppagepath=\\"\\" isoverviewtab=\\"true\\"></noteable-discussion-stub> - <discussion-filter-note-stub style=\\"display: none;\\"></discussion-filter-note-stub> + <!----> </ul>" `; diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 55e4ef42e37..701ff492702 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -7,7 +7,7 @@ import Vuex from 'vuex'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import batchComments from '~/batch_comments/stores/modules/batch_comments'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import CommentForm from '~/notes/components/comment_form.vue'; import CommentTypeDropdown from '~/notes/components/comment_type_dropdown.vue'; @@ -71,11 +71,19 @@ describe('issue_comment_form component', () => { }; const notableDataMockCanUpdateIssuable = createNotableDataMock({ - current_user: { can_update: true, can_create_note: true }, + current_user: { can_update: true, can_create_note: true, can_create_confidential_note: true }, }); const notableDataMockCannotUpdateIssuable = createNotableDataMock({ - current_user: { can_update: false, can_create_note: true }, + current_user: { + can_update: false, + can_create_note: false, + can_create_confidential_note: false, + }, + }); + + const notableDataMockCannotCreateConfidentialNote = createNotableDataMock({ + current_user: { can_update: false, can_create_note: true, can_create_confidential_note: false }, }); const mountComponent = ({ @@ -490,7 +498,7 @@ describe('issue_comment_form component', () => { await nextTick(); await nextTick(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: `Something went wrong while closing the ${type}. Please try again later.`, }); }); @@ -526,7 +534,7 @@ describe('issue_comment_form component', () => { await nextTick(); await nextTick(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: `Something went wrong while reopening the ${type}. Please try again later.`, }); }); @@ -562,6 +570,17 @@ describe('issue_comment_form component', () => { expect(checkbox.element.checked).toBe(false); }); + it('should not render checkbox if user is not at least a reporter', () => { + mountComponent({ + mountFunction: mount, + initialData: { note: 'confidential note' }, + noteableData: { ...notableDataMockCannotCreateConfidentialNote }, + }); + + const checkbox = findConfidentialNoteCheckbox(); + expect(checkbox.exists()).toBe(false); + }); + it.each` noteableType | rendered | message ${'Issue'} | ${true} | ${'render'} diff --git a/spec/frontend/notes/components/diff_discussion_header_spec.js b/spec/frontend/notes/components/diff_discussion_header_spec.js index 5800f68b114..bb44563b87a 100644 --- a/spec/frontend/notes/components/diff_discussion_header_spec.js +++ b/spec/frontend/notes/components/diff_discussion_header_spec.js @@ -42,7 +42,7 @@ describe('diff_discussion_header component', () => { expect(props).toMatchObject({ src: firstNoteAuthor.avatar_url, alt: firstNoteAuthor.name, - size: { default: 24, md: 32 }, + size: 32, }); }); }); diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js index d16c13d6fd3..e414ada1854 100644 --- a/spec/frontend/notes/components/discussion_actions_spec.js +++ b/spec/frontend/notes/components/discussion_actions_spec.js @@ -81,7 +81,7 @@ describe('DiscussionActions', () => { }); }); - it(shouldRender ? 'renders resolve buttons' : 'does not render resolve buttons', () => { + it(`${shouldRender ? 'renders' : 'does not render'} resolve buttons`, () => { expect(wrapper.findComponent(ResolveDiscussionButton).exists()).toBe(shouldRender); expect(wrapper.findComponent(ResolveWithIssueButton).exists()).toBe(shouldRender); }); diff --git a/spec/frontend/notes/components/note_header_spec.js b/spec/frontend/notes/components/note_header_spec.js index 76177229cff..b870cda2a24 100644 --- a/spec/frontend/notes/components/note_header_spec.js +++ b/spec/frontend/notes/components/note_header_spec.js @@ -1,10 +1,7 @@ -import { GlSprintf } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import NoteHeader from '~/notes/components/note_header.vue'; -import { AVAILABILITY_STATUS } from '~/set_status_modal/constants'; -import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; Vue.use(Vuex); @@ -23,7 +20,6 @@ describe('NoteHeader component', () => { const findTimestamp = () => wrapper.findComponent({ ref: 'noteTimestamp' }); const findInternalNoteIndicator = () => wrapper.findByTestId('internalNoteIndicator'); const findSpinner = () => wrapper.findComponent({ ref: 'spinner' }); - const findAuthorStatus = () => wrapper.findComponent({ ref: 'authorStatus' }); const statusHtml = '"<span class="user-status-emoji has-tooltip" title="foo bar" data-html="true" data-placement="top"><gl-emoji title="basketball and hoop" data-name="basketball" data-unicode-version="6.0">🏀</gl-emoji></span>"'; @@ -37,22 +33,14 @@ describe('NoteHeader component', () => { username: 'root', show_status: true, status_tooltip_html: statusHtml, - availability: '', }; - const createComponent = (props, userAttributes = false) => { + const createComponent = (props) => { wrapper = shallowMountExtended(NoteHeader, { store: new Vuex.Store({ actions, }), propsData: { ...props }, - stubs: { GlSprintf, UserNameWithStatus }, - provide: { - glFeatures: { - removeUserAttributesProjects: userAttributes, - removeUserAttributesGroups: userAttributes, - }, - }, }); }; @@ -61,26 +49,6 @@ describe('NoteHeader component', () => { wrapper = null; }); - describe('when removeUserAttributesProjects feature flag is enabled', () => { - it('does not render busy status', () => { - createComponent({ author: { ...author, availability: AVAILABILITY_STATUS.BUSY } }, true); - - expect(wrapper.find('.note-header-info').text()).not.toContain('(Busy)'); - }); - - it('does not render author status', () => { - createComponent({ author }, true); - - expect(findAuthorStatus().exists()).toBe(false); - }); - - it('does not render username', () => { - createComponent({ author }, true); - - expect(wrapper.find('.note-header-info').text()).not.toContain('@'); - }); - }); - it('does not render discussion actions when includeToggle is false', () => { createComponent({ includeToggle: false, @@ -145,39 +113,6 @@ describe('NoteHeader component', () => { expect(wrapper.find('.js-user-link').exists()).toBe(true); }); - - it('renders busy status if author availability is set', () => { - createComponent({ author: { ...author, availability: AVAILABILITY_STATUS.BUSY } }); - - expect(wrapper.find('.js-user-link').text()).toContain('(Busy)'); - }); - - it('renders author status', () => { - createComponent({ author }); - - expect(findAuthorStatus().exists()).toBe(true); - }); - - it('does not render author status if show_status=false', () => { - createComponent({ - author: { ...author, status: { availability: AVAILABILITY_STATUS.BUSY }, show_status: false }, - }); - - expect(findAuthorStatus().exists()).toBe(false); - }); - - it('does not render author status if status_tooltip_html=null', () => { - createComponent({ - author: { - ...author, - status: { availability: AVAILABILITY_STATUS.BUSY }, - status_tooltip_html: null, - }, - }); - - expect(findAuthorStatus().exists()).toBe(false); - }); - it('renders deleted user text if author is not passed as a prop', () => { createComponent(); @@ -270,24 +205,6 @@ describe('NoteHeader component', () => { }); }); - describe('when author status tooltip is opened', () => { - it('removes `title` attribute from emoji to prevent duplicate tooltips', () => { - createComponent({ - author: { - ...author, - status_tooltip_html: statusHtml, - }, - }); - - return nextTick().then(() => { - const authorStatus = findAuthorStatus(); - authorStatus.trigger('mouseenter'); - - expect(authorStatus.find('gl-emoji').attributes('title')).toBeUndefined(); - }); - }); - }); - describe('when author username link is hovered', () => { it('toggles hover specific CSS classes on author name link', async () => { createComponent({ author }); @@ -327,4 +244,18 @@ describe('NoteHeader component', () => { ); }); }); + + it('does render username', () => { + createComponent({ author }, true); + + expect(wrapper.find('.note-header-info').text()).toContain('@'); + }); + + describe('with system note', () => { + it('does not render username', () => { + createComponent({ author, isSystemNote: true }, true); + + expect(wrapper.find('.note-header-info').text()).not.toContain('@'); + }); + }); }); diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js index b044d40cbe4..3d7195752d3 100644 --- a/spec/frontend/notes/components/noteable_note_spec.js +++ b/spec/frontend/notes/components/noteable_note_spec.js @@ -214,7 +214,7 @@ describe('issue_note', () => { expect(avatarProps.src).toBe(author.avatar_url); expect(avatarProps.entityName).toBe(author.username); expect(avatarProps.alt).toBe(author.name); - expect(avatarProps.size).toEqual({ default: 24, md: 32 }); + expect(avatarProps.size).toEqual(32); }); it('should render note header content', () => { diff --git a/spec/frontend/notes/components/notes_activity_header_spec.js b/spec/frontend/notes/components/notes_activity_header_spec.js new file mode 100644 index 00000000000..5b3165bf401 --- /dev/null +++ b/spec/frontend/notes/components/notes_activity_header_spec.js @@ -0,0 +1,67 @@ +import { shallowMount } from '@vue/test-utils'; +import { __ } from '~/locale'; +import NotesActivityHeader from '~/notes/components/notes_activity_header.vue'; +import DiscussionFilter from '~/notes/components/discussion_filter.vue'; +import TimelineToggle from '~/notes/components/timeline_toggle.vue'; +import createStore from '~/notes/stores'; +import waitForPromises from 'helpers/wait_for_promises'; +import { notesFilters } from '../mock_data'; + +describe('~/notes/components/notes_activity_header.vue', () => { + let wrapper; + + const findTitle = () => wrapper.find('h2'); + + const createComponent = ({ props = {}, ...options } = {}) => { + wrapper = shallowMount(NotesActivityHeader, { + propsData: { + notesFilters, + ...props, + }, + // why: Rendering async timeline toggle requires store + store: createStore(), + ...options, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders title', () => { + expect(findTitle().text()).toBe(__('Activity')); + }); + + it('renders discussion filter', () => { + expect(wrapper.findComponent(DiscussionFilter).props()).toEqual({ + filters: notesFilters, + selectedValue: 0, + }); + }); + + it('does not render timeline toggle', () => { + expect(wrapper.findComponent(TimelineToggle).exists()).toBe(false); + }); + }); + + it('with notesFilterValue prop, passes to discussion filter', () => { + createComponent({ props: { notesFilterValue: 1 } }); + + expect(wrapper.findComponent(DiscussionFilter).props('selectedValue')).toBe(1); + }); + + it('with showTimelineViewToggle injected, renders timeline toggle asynchronously', async () => { + createComponent({ provide: { showTimelineViewToggle: () => true } }); + + expect(wrapper.findComponent(TimelineToggle).exists()).toBe(false); + + await waitForPromises(); + + expect(wrapper.findComponent(TimelineToggle).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js index d4cb07d97dc..9051fcab97f 100644 --- a/spec/frontend/notes/components/notes_app_spec.js +++ b/spec/frontend/notes/components/notes_app_spec.js @@ -11,6 +11,7 @@ import axios from '~/lib/utils/axios_utils'; import * as urlUtility from '~/lib/utils/url_utility'; import CommentForm from '~/notes/components/comment_form.vue'; import NotesApp from '~/notes/components/notes_app.vue'; +import NotesActivityHeader from '~/notes/components/notes_activity_header.vue'; import * as constants from '~/notes/constants'; import createStore from '~/notes/stores'; import '~/behaviors/markdown/render_gfm'; @@ -20,11 +21,14 @@ import * as mockData from '../mock_data'; const TYPE_COMMENT_FORM = 'comment-form'; const TYPE_NOTES_LIST = 'notes-list'; +const TEST_NOTES_FILTER_VALUE = 1; const propsData = { noteableData: mockData.noteableDataMock, notesData: mockData.notesDataMock, userData: mockData.userDataMock, + notesFilters: mockData.notesFilters, + notesFilterValue: TEST_NOTES_FILTER_VALUE, }; describe('note_app', () => { @@ -47,7 +51,7 @@ describe('note_app', () => { axiosMock = new AxiosMockAdapter(axios); store = createStore(); - mountComponent = () => { + mountComponent = ({ props = {} } = {}) => { return mount( { components: { @@ -58,7 +62,10 @@ describe('note_app', () => { </div>`, }, { - propsData, + propsData: { + ...propsData, + ...props, + }, store, }, ); @@ -144,6 +151,13 @@ describe('note_app', () => { it('updates discussions badge', () => { expect(document.querySelector('.js-discussions-count').textContent).toEqual('2'); }); + + it('should render notes activity header', () => { + expect(wrapper.findComponent(NotesActivityHeader).props()).toEqual({ + notesFilterValue: TEST_NOTES_FILTER_VALUE, + notesFilters: mockData.notesFilters, + }); + }); }); describe('render with comments disabled', () => { @@ -151,8 +165,15 @@ describe('note_app', () => { setHTMLFixture('<div class="js-discussions-count"></div>'); axiosMock.onAny().reply(mockData.getIndividualNoteResponse); - store.state.commentsDisabled = true; - wrapper = mountComponent(); + wrapper = mountComponent({ + // why: In this integration test, previously we manually set store.state.commentsDisabled + // This stopped working when we added `<discussion-filter>` into the component tree. + // Let's lean into the integration scope and use a prop that "disables comments". + props: { + notesFilterValue: constants.HISTORY_ONLY_FILTER_VALUE, + }, + }); + return waitForPromises(); }); @@ -358,7 +379,7 @@ describe('note_app', () => { it('should listen hashchange event', () => { const notesApp = wrapper.findComponent(NotesApp); const hash = 'some dummy hash'; - jest.spyOn(urlUtility, 'getLocationHash').mockReturnValueOnce(hash); + jest.spyOn(urlUtility, 'getLocationHash').mockReturnValue(hash); const setTargetNoteHash = jest.spyOn(notesApp.vm, 'setTargetNoteHash'); window.dispatchEvent(new Event('hashchange'), hash); diff --git a/spec/frontend/notes/mixins/discussion_navigation_spec.js b/spec/frontend/notes/mixins/discussion_navigation_spec.js index 1b4e8026d84..45625d0a23f 100644 --- a/spec/frontend/notes/mixins/discussion_navigation_spec.js +++ b/spec/frontend/notes/mixins/discussion_navigation_spec.js @@ -4,7 +4,6 @@ import Vuex from 'vuex'; import { setHTMLFixture } from 'helpers/fixtures'; import createEventHub from '~/helpers/event_hub_factory'; import * as utils from '~/lib/utils/common_utils'; -import eventHub from '~/notes/event_hub'; import discussionNavigation from '~/notes/mixins/discussion_navigation'; import notesModule from '~/notes/stores/modules'; @@ -35,13 +34,15 @@ describe('Discussion navigation mixin', () => { beforeEach(() => { setHTMLFixture( - [...'abcde'] + `<div class="notes"> + ${[...'abcde'] .map( (id) => `<ul class="notes" data-discussion-id="${id}"></ul> <div class="discussion" data-discussion-id="${id}"></div>`, ) - .join(''), + .join('')} + </div>`, ); jest.spyOn(utils, 'scrollToElementWithContext'); @@ -58,7 +59,7 @@ describe('Discussion navigation mixin', () => { }, diffs: { namespaced: true, - actions: { scrollToFile }, + actions: { scrollToFile, disableVirtualScroller: () => {} }, state: { diffFiles: [] }, }, }, @@ -73,9 +74,6 @@ describe('Discussion navigation mixin', () => { jest.clearAllMocks(); }); - const findDiscussion = (selector, id) => - document.querySelector(`${selector}[data-discussion-id="${id}"]`); - describe('jumpToFirstUnresolvedDiscussion method', () => { let vm; @@ -110,14 +108,14 @@ describe('Discussion navigation mixin', () => { }); describe.each` - fn | args | currentId | expected - ${'jumpToNextDiscussion'} | ${[]} | ${null} | ${'a'} - ${'jumpToNextDiscussion'} | ${[]} | ${'a'} | ${'c'} - ${'jumpToNextDiscussion'} | ${[]} | ${'e'} | ${'a'} - ${'jumpToPreviousDiscussion'} | ${[]} | ${null} | ${'e'} - ${'jumpToPreviousDiscussion'} | ${[]} | ${'e'} | ${'c'} - ${'jumpToPreviousDiscussion'} | ${[]} | ${'c'} | ${'a'} - `('$fn (args = $args, currentId = $currentId)', ({ fn, args, currentId, expected }) => { + fn | args | currentId + ${'jumpToNextDiscussion'} | ${[]} | ${null} + ${'jumpToNextDiscussion'} | ${[]} | ${'a'} + ${'jumpToNextDiscussion'} | ${[]} | ${'e'} + ${'jumpToPreviousDiscussion'} | ${[]} | ${null} + ${'jumpToPreviousDiscussion'} | ${[]} | ${'e'} + ${'jumpToPreviousDiscussion'} | ${[]} | ${'c'} + `('$fn (args = $args, currentId = $currentId)', ({ fn, args, currentId }) => { beforeEach(() => { store.state.notes.currentDiscussionId = currentId; }); @@ -130,125 +128,18 @@ describe('Discussion navigation mixin', () => { await nextTick(); }); - it('expands discussion', () => { - expect(expandDiscussion).toHaveBeenCalled(); - }); - - it('scrolls to element', () => { - expect(utils.scrollToElement).toHaveBeenCalled(); - }); - }); - - describe('on `diffs` active tab', () => { - beforeEach(async () => { - window.mrTabs.currentAction = 'diffs'; - wrapper.vm[fn](...args); - + it('expands discussion', async () => { await nextTick(); - }); - it('sets current discussion', () => { - expect(store.state.notes.currentDiscussionId).toEqual(expected); - }); - - it('expands discussion', () => { expect(expandDiscussion).toHaveBeenCalled(); }); - it('scrolls when scrollToDiscussion is emitted', () => { - expect(utils.scrollToElementWithContext).not.toHaveBeenCalled(); - - eventHub.$emit('scrollToDiscussion'); - - expect(utils.scrollToElementWithContext).toHaveBeenCalledWith( - findDiscussion('ul.notes', expected), - { behavior: 'auto', offset: 0 }, - ); - }); - }); - - describe('on `other` active tab', () => { - beforeEach(async () => { - window.mrTabs.currentAction = 'other'; - wrapper.vm[fn](...args); - + it('scrolls to element', async () => { await nextTick(); - }); - it('sets current discussion', () => { - expect(store.state.notes.currentDiscussionId).toEqual(expected); - }); - - it('does not expand discussion yet', () => { - expect(expandDiscussion).not.toHaveBeenCalled(); - }); - - it('shows mrTabs', () => { - expect(window.mrTabs.tabShown).toHaveBeenCalledWith('show'); - }); - - describe('when tab is changed', () => { - beforeEach(() => { - window.mrTabs.eventHub.$emit('MergeRequestTabChange'); - - jest.runAllTimers(); - }); - - it('expands discussion', () => { - expect(expandDiscussion).toHaveBeenCalledWith(expect.anything(), { - discussionId: expected, - }); - }); - - it('scrolls to discussion', () => { - expect(utils.scrollToElement).toHaveBeenCalledWith( - findDiscussion('div.discussion', expected), - { behavior: 'auto', offset: 0 }, - ); - }); + expect(utils.scrollToElement).toHaveBeenCalled(); }); }); }); - - describe('virtual scrolling feature', () => { - beforeEach(() => { - jest.spyOn(store, 'dispatch'); - - store.state.notes.currentDiscussionId = 'a'; - window.location.hash = 'test'; - }); - - afterEach(() => { - window.gon = {}; - window.location.hash = ''; - }); - - it('resets location hash', async () => { - wrapper.vm.jumpToNextDiscussion(); - - await nextTick(); - - expect(window.location.hash).toBe(''); - }); - - it.each` - tabValue - ${'diffs'} - ${'other'} - `( - 'calls scrollToFile with setHash as $hashValue when the tab is $tabValue', - async ({ tabValue }) => { - window.mrTabs.currentAction = tabValue; - - wrapper.vm.jumpToNextDiscussion(); - - await nextTick(); - - expect(store.dispatch).toHaveBeenCalledWith('diffs/scrollToFile', { - path: 'test.js', - }); - }, - ); - }); }); }); diff --git a/spec/frontend/notes/mock_data.js b/spec/frontend/notes/mock_data.js index 9fa7166474a..286f2adc1d8 100644 --- a/spec/frontend/notes/mock_data.js +++ b/spec/frontend/notes/mock_data.js @@ -1,4 +1,5 @@ // Copied to ee/spec/frontend/notes/mock_data.js +import { __ } from '~/locale'; export const notesDataMock = { discussionsPath: '/gitlab-org/gitlab-foss/issues/26/discussions.json', @@ -35,6 +36,7 @@ export const noteableDataMock = { can_create_note: true, can_update: true, can_award_emoji: true, + can_create_confidential_note: true, }, description: '', due_date: null, @@ -1292,3 +1294,18 @@ export const draftDiffDiscussion = { file_path: 'lib/foo.rb', isDraft: true, }; + +export const notesFilters = [ + { + title: __('Show all activity'), + value: 0, + }, + { + title: __('Show comments only'), + value: 1, + }, + { + title: __('Show history only'), + value: 2, + }, +]; diff --git a/spec/frontend/notes/utils/get_notes_filter_data_spec.js b/spec/frontend/notes/utils/get_notes_filter_data_spec.js new file mode 100644 index 00000000000..c3a8d3bc619 --- /dev/null +++ b/spec/frontend/notes/utils/get_notes_filter_data_spec.js @@ -0,0 +1,44 @@ +import { getNotesFilterData } from '~/notes/utils/get_notes_filter_data'; +import { notesFilters } from '../mock_data'; + +// what: This is the format we expect the element attribute to be in +// why: For readability, we make this clear by hardcoding the indecise instead of using `reduce`. +const TEST_NOTES_FILTERS_ATTR = { + [notesFilters[0].title]: notesFilters[0].value, + [notesFilters[1].title]: notesFilters[1].value, + [notesFilters[2].title]: notesFilters[2].value, +}; + +describe('~/notes/utils/get_notes_filter_data', () => { + it.each([ + { + desc: 'empty', + attributes: {}, + expectation: { + notesFilters: [], + notesFilterValue: undefined, + }, + }, + { + desc: 'valid attributes', + attributes: { + 'data-notes-filters': JSON.stringify(TEST_NOTES_FILTERS_ATTR), + 'data-notes-filter-value': '1', + }, + expectation: { + notesFilters, + notesFilterValue: 1, + }, + }, + ])('with $desc, parses data from element attributes', ({ attributes, expectation }) => { + const el = document.createElement('div'); + + Object.entries(attributes).forEach(([key, value]) => { + el.setAttribute(key, value); + }); + + const actual = getNotesFilterData(el); + + expect(actual).toStrictEqual(expectation); + }); +}); diff --git a/spec/frontend/operation_settings/components/metrics_settings_spec.js b/spec/frontend/operation_settings/components/metrics_settings_spec.js index 810049220ae..732dfdd42fb 100644 --- a/spec/frontend/operation_settings/components/metrics_settings_spec.js +++ b/spec/frontend/operation_settings/components/metrics_settings_spec.js @@ -2,7 +2,7 @@ import { GlButton, GlLink, GlFormGroup, GlFormInput, GlFormSelect } from '@gitla import { mount, shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { TEST_HOST } from 'helpers/test_constants'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; import { timezones } from '~/monitoring/format_date'; @@ -52,7 +52,7 @@ describe('operation settings external dashboard component', () => { } axios.patch.mockReset(); refreshCurrentPage.mockReset(); - createFlash.mockReset(); + createAlert.mockReset(); }); it('renders header text', () => { @@ -208,7 +208,7 @@ describe('operation settings external dashboard component', () => { await nextTick(); await jest.runAllTicks(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: `There was an error saving your changes. ${message}`, }); }); diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_list_row_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_list_row_spec.js index 6fe3dabc603..849215e286b 100644 --- a/spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_list_row_spec.js +++ b/spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_list_row_spec.js @@ -8,8 +8,8 @@ import { defaultConfig, harborTagsList } from '../../mock_data'; describe('Harbor tag list row', () => { let wrapper; - const findListItem = () => wrapper.find(ListItem); - const findClipboardButton = () => wrapper.find(ClipboardButton); + const findListItem = () => wrapper.findComponent(ListItem); + const findClipboardButton = () => wrapper.findComponent(ClipboardButton); const findByTestId = (testId) => wrapper.findByTestId(testId); const $route = { @@ -58,7 +58,7 @@ describe('Harbor tag list row', () => { expect(findByTestId('name').text()).toBe(harborTagsList[0].name); }); - describe(' clipboard button', () => { + describe('clipboard button', () => { it('exists', () => { expect(findClipboardButton().exists()).toBe(true); }); diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_list_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_list_spec.js index 6bcf6611d07..4c6b2b6daaa 100644 --- a/spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_list_spec.js +++ b/spec/frontend/packages_and_registries/harbor_registry/components/tags/tags_list_spec.js @@ -8,9 +8,9 @@ import { defaultConfig, harborTagsResponse } from '../../mock_data'; describe('Harbor Tags List', () => { let wrapper; - const findTagsLoader = () => wrapper.find(TagsLoader); + const findTagsLoader = () => wrapper.findComponent(TagsLoader); const findTagsListRows = () => wrapper.findAllComponents(TagsListRow); - const findRegistryList = () => wrapper.find(RegistryList); + const findRegistryList = () => wrapper.findComponent(RegistryList); const mountComponent = ({ propsData, config = defaultConfig }) => { wrapper = shallowMount(TagsList, { diff --git a/spec/frontend/packages_and_registries/harbor_registry/pages/tags_spec.js b/spec/frontend/packages_and_registries/harbor_registry/pages/tags_spec.js index 7e0f05e736b..10901c6ec1e 100644 --- a/spec/frontend/packages_and_registries/harbor_registry/pages/tags_spec.js +++ b/spec/frontend/packages_and_registries/harbor_registry/pages/tags_spec.js @@ -15,8 +15,8 @@ jest.mock('~/rest_api', () => ({ describe('Harbor Tags page', () => { let wrapper; - const findTagsHeader = () => wrapper.find(TagsHeader); - const findTagsList = () => wrapper.find(TagsList); + const findTagsHeader = () => wrapper.findComponent(TagsHeader); + const findTagsList = () => wrapper.findComponent(TagsList); const waitForHarborTagsRequest = async () => { await waitForPromises(); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js index 31ab108558c..bb970336b94 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js @@ -1,6 +1,6 @@ import testAction from 'helpers/vuex_action_helper'; import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash'; import { FETCH_PACKAGE_VERSIONS_ERROR } from '~/packages_and_registries/infrastructure_registry/details/constants'; import { fetchPackageVersions, @@ -67,9 +67,9 @@ describe('Actions Package details store', () => { [], ); expect(Api.projectPackage).toHaveBeenCalledWith(packageEntity.project_id, packageEntity.id); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: FETCH_PACKAGE_VERSIONS_ERROR, - type: 'warning', + variant: VARIANT_WARNING, }); }); }); @@ -87,9 +87,9 @@ describe('Actions Package details store', () => { Api.deleteProjectPackage = jest.fn().mockRejectedValue(); await testAction(deletePackage, undefined, { packageEntity }, [], []); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: DELETE_PACKAGE_ERROR_MESSAGE, - type: 'warning', + variant: VARIANT_WARNING, }); }); }); @@ -112,18 +112,18 @@ describe('Actions Package details store', () => { packageEntity.id, fileId, ); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, - type: 'success', + variant: VARIANT_SUCCESS, }); }); it('should create flash on API error', async () => { Api.deleteProjectPackageFile = jest.fn().mockRejectedValue(); await testAction(deletePackageFile, fileId, { packageEntity }, [], []); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: DELETE_PACKAGE_FILE_ERROR_MESSAGE, - type: 'warning', + variant: VARIANT_WARNING, }); }); }); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_title_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_title_spec.js index 93d013bb458..aca6b0942cc 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_title_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_title_spec.js @@ -74,7 +74,7 @@ describe('Infrastructure Title', () => { mountComponent({ ...exampleProps, count }); }); - it(exist ? 'exists' : 'does not exist', () => { + it(`${exist ? 'exists' : 'does not exist'}`, () => { expect(findMetadataItem().exists()).toBe(exist); }); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_app_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_app_spec.js index db1d3f3f633..dff95364d7d 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_app_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_app_spec.js @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; import setWindowLocation from 'helpers/set_window_location_helper'; -import createFlash from '~/flash'; +import { createAlert, VARIANT_INFO } from '~/flash'; import * as commonUtils from '~/lib/utils/common_utils'; import PackageListApp from '~/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue'; import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages_and_registries/infrastructure_registry/list/constants'; @@ -222,9 +222,9 @@ describe('packages_list_app', () => { it(`creates a flash if the query string contains ${SHOW_DELETE_SUCCESS_ALERT}`, () => { mountComponent(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: DELETE_PACKAGE_SUCCESS_MESSAGE, - type: 'notice', + variant: VARIANT_INFO, }); }); @@ -238,7 +238,7 @@ describe('packages_list_app', () => { setWindowLocation('?'); mountComponent(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(commonUtils.historyReplaceState).not.toHaveBeenCalled(); }); }); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js index d596f2dae33..36417eaf793 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js @@ -2,7 +2,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { MISSING_DELETE_PATH_ERROR } from '~/packages_and_registries/infrastructure_registry/list/constants'; import * as actions from '~/packages_and_registries/infrastructure_registry/list/stores/actions'; import * as types from '~/packages_and_registries/infrastructure_registry/list/stores/mutation_types'; @@ -107,7 +107,7 @@ describe('Actions Package list store', () => { { type: 'setLoading', payload: false }, ], ); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); it('should force the terraform_module type when forceTerraform is true', async () => { @@ -209,17 +209,17 @@ describe('Actions Package list store', () => { { type: 'setLoading', payload: false }, ], ); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); it.each` property | actionPayload ${'_links'} | ${{}} ${'delete_api_path'} | ${{ _links: {} }} - `('should reject and createFlash when $property is missing', ({ actionPayload }) => { + `('should reject and createAlert when $property is missing', ({ actionPayload }) => { return testAction(actions.requestDeletePackage, actionPayload, null, [], []).catch((e) => { expect(e).toEqual(new Error(MISSING_DELETE_PATH_ERROR)); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: DELETE_PACKAGE_ERROR_MESSAGE, }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap index 61923233d2e..047fa04947c 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap @@ -79,6 +79,18 @@ exports[`PackageTitle renders with tags 1`] = ` texttooltip="" /> </div> + <div + class="gl-display-flex gl-align-items-center gl-mr-5" + > + <metadata-item-stub + data-testid="package-last-downloaded-at" + icon="download" + link="" + size="m" + text="Last downloaded Aug 17, 2021" + texttooltip="" + /> + </div> </div> </div> @@ -164,6 +176,18 @@ exports[`PackageTitle renders without tags 1`] = ` texttooltip="" /> </div> + <div + class="gl-display-flex gl-align-items-center gl-mr-5" + > + <metadata-item-stub + data-testid="package-last-downloaded-at" + icon="download" + link="" + size="m" + text="Last downloaded Aug 17, 2021" + texttooltip="" + /> + </div> </div> </div> diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js index 37416dcd4e7..1fda77f2aaa 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js @@ -49,6 +49,7 @@ describe('PackageTitle', () => { const findPackageSize = () => wrapper.findByTestId('package-size'); const findPipelineProject = () => wrapper.findByTestId('pipeline-project'); const findPackageRef = () => wrapper.findByTestId('package-ref'); + const findPackageLastDownloadedAt = () => wrapper.findByTestId('package-last-downloaded-at'); const findPackageTags = () => wrapper.findComponent(PackageTags); const findPackageBadges = () => wrapper.findAllByTestId('tag-badge'); const findSubHeaderText = () => wrapper.findByTestId('sub-header'); @@ -227,4 +228,25 @@ describe('PackageTitle', () => { }); }); }); + + describe('package last downloaded at', () => { + it('does not display the data if missing', async () => { + await createComponent({ + ...packageData(), + lastDownloadedAt: null, + }); + + expect(findPackageLastDownloadedAt().exists()).toBe(false); + }); + + it('correctly shows the data if present', async () => { + await createComponent(); + + expect(findPackageLastDownloadedAt().props()).toMatchObject({ + text: 'Last downloaded Aug 17, 2021', + icon: 'download', + size: 'm', + }); + }); + }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/functional/delete_package_spec.js b/spec/frontend/packages_and_registries/package_registry/components/functional/delete_package_spec.js index 14a70def7d0..93c2196b210 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/functional/delete_package_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/functional/delete_package_spec.js @@ -3,7 +3,7 @@ import VueApollo from 'vue-apollo'; import waitForPromises from 'helpers/wait_for_promises'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; -import createFlash from '~/flash'; +import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash'; import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue'; import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql'; @@ -104,22 +104,22 @@ describe('DeletePackage', () => { expect(wrapper.emitted('end')).toEqual([[]]); }); - it('does not call createFlash', async () => { + it('does not call createAlert', async () => { createComponent(); await clickOnButtonAndWait(eventPayload); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); - it('calls createFlash with the success message when showSuccessAlert is true', async () => { + it('calls createAlert with the success message when showSuccessAlert is true', async () => { createComponent({ showSuccessAlert: true }); await clickOnButtonAndWait(eventPayload); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: DeletePackage.i18n.successMessage, - type: 'success', + variant: VARIANT_SUCCESS, }); }); }); @@ -141,14 +141,14 @@ describe('DeletePackage', () => { expect(wrapper.emitted('end')).toEqual([[]]); }); - it('calls createFlash with the error message', async () => { + it('calls createAlert with the error message', async () => { createComponent({ showSuccessAlert: true }); await clickOnButtonAndWait(eventPayload); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: DeletePackage.i18n.errorMessage, - type: 'warning', + variant: VARIANT_WARNING, captureError: true, error: expect.any(Error), }); 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 22236424e6a..c2b6fb734d6 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -127,6 +127,7 @@ export const packageData = (extend) => ({ version: '1.0.0', createdAt: '2020-08-17T14:23:32Z', updatedAt: '2020-08-17T14:23:32Z', + lastDownloadedAt: '2021-08-17T14:23:32Z', status: 'DEFAULT', mavenUrl: 'http://gdk.test:3000/api/v4/projects/1/packages/maven', npmUrl: 'http://gdk.test:3000/api/v4/projects/1/packages/npm', 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 83158d1cc5e..a32e76a132e 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 @@ -1,4 +1,4 @@ -import { GlEmptyState, GlBadge, GlTabs, GlTab } from '@gitlab/ui'; +import { GlEmptyState, GlBadge, GlTabs, GlTab, GlSprintf } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; @@ -6,7 +6,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import AdditionalMetadata from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue'; import PackagesApp from '~/packages_and_registries/package_registry/pages/details.vue'; @@ -86,11 +86,17 @@ describe('PackagesApp', () => { PackageTitle, DeletePackage, GlModal: { - template: '<div></div>', + template: ` + <div> + <slot name="modal-title"></slot> + <p><slot></slot></p> + </div> + `, methods: { show: jest.fn(), }, }, + GlSprintf, GlTabs, GlTab, }, @@ -149,7 +155,7 @@ describe('PackagesApp', () => { await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith( + expect(createAlert).toHaveBeenCalledWith( expect.objectContaining({ message: FETCH_PACKAGE_DETAILS_ERROR_MESSAGE, }), @@ -245,7 +251,9 @@ describe('PackagesApp', () => { await findDeleteButton().trigger('click'); - expect(findDeleteModal().exists()).toBe(true); + expect(findDeleteModal().find('p').text()).toBe( + 'You are about to delete version 1.0.0 of @gitlab-org/package-15. Are you sure?', + ); }); describe('successful request', () => { @@ -359,6 +367,12 @@ describe('PackagesApp', () => { expect(showDeletePackageSpy).toHaveBeenCalled(); expect(showDeleteFileSpy).not.toHaveBeenCalled(); + + await waitForPromises(); + + expect(findDeleteModal().find('p').text()).toBe( + 'Deleting the last package asset will remove version 1.0.0 of @gitlab-org/package-15. Are you sure?', + ); }); it('confirming on the modal sets the loading state', async () => { @@ -383,7 +397,7 @@ describe('PackagesApp', () => { await doDeleteFile(); - expect(createFlash).toHaveBeenCalledWith( + expect(createAlert).toHaveBeenCalledWith( expect.objectContaining({ message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, }), @@ -399,7 +413,7 @@ describe('PackagesApp', () => { await doDeleteFile(); - expect(createFlash).toHaveBeenCalledWith( + expect(createAlert).toHaveBeenCalledWith( expect.objectContaining({ message: DELETE_PACKAGE_FILE_ERROR_MESSAGE, }), @@ -416,7 +430,7 @@ describe('PackagesApp', () => { await doDeleteFile(); - expect(createFlash).toHaveBeenCalledWith( + expect(createAlert).toHaveBeenCalledWith( expect.objectContaining({ message: DELETE_PACKAGE_FILE_ERROR_MESSAGE, }), @@ -468,7 +482,7 @@ describe('PackagesApp', () => { await doDeleteFiles(); - expect(createFlash).toHaveBeenCalledWith( + expect(createAlert).toHaveBeenCalledWith( expect.objectContaining({ message: DELETE_PACKAGE_FILES_SUCCESS_MESSAGE, }), @@ -484,7 +498,7 @@ describe('PackagesApp', () => { await doDeleteFiles(); - expect(createFlash).toHaveBeenCalledWith( + expect(createAlert).toHaveBeenCalledWith( expect.objectContaining({ message: DELETE_PACKAGE_FILES_ERROR_MESSAGE, }), @@ -501,7 +515,7 @@ describe('PackagesApp', () => { await doDeleteFiles(); - expect(createFlash).toHaveBeenCalledWith( + expect(createAlert).toHaveBeenCalledWith( expect.objectContaining({ message: DELETE_PACKAGE_FILES_ERROR_MESSAGE, }), @@ -533,6 +547,12 @@ describe('PackagesApp', () => { findPackageFiles().vm.$emit('delete-files', packageFiles()); expect(showDeletePackageSpy).toHaveBeenCalled(); + + await waitForPromises(); + + expect(findDeleteModal().find('p').text()).toBe( + 'Deleting all package assets will remove version 1.0.0 of @gitlab-org/package-15. Are you sure?', + ); }); }); }); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/cleanup_image_tags_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/cleanup_image_tags_spec.js index 8b60f31512b..2bb99fb8e8f 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/cleanup_image_tags_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/cleanup_image_tags_spec.js @@ -19,6 +19,7 @@ import { expirationPolicyPayload, emptyExpirationPolicyPayload, containerExpirationPolicyData, + nullExpirationPolicyPayload, } from '../mock_data'; describe('Cleanup image tags project settings', () => { @@ -98,15 +99,30 @@ describe('Cleanup image tags project settings', () => { expect(findDescription().text()).toMatchInterpolatedText(CONTAINER_CLEANUP_POLICY_DESCRIPTION); }); + it('when loading does not render form or alert components', () => { + mountComponentWithApollo({ + resolver: jest.fn().mockResolvedValue(), + }); + + expect(findFormComponent().exists()).toBe(false); + expect(findAlert().exists()).toBe(false); + }); + describe('the form is disabled', () => { - it('hides the form', () => { - mountComponent(); + it('hides the form', async () => { + mountComponentWithApollo({ + resolver: jest.fn().mockResolvedValue(nullExpirationPolicyPayload()), + }); + await waitForPromises(); expect(findFormComponent().exists()).toBe(false); }); - it('shows an alert', () => { - mountComponent(); + it('shows an alert', async () => { + mountComponentWithApollo({ + resolver: jest.fn().mockResolvedValue(nullExpirationPolicyPayload()), + }); + await waitForPromises(); const text = findAlert().text(); expect(text).toContain(UNAVAILABLE_FEATURE_INTRO_TEXT); @@ -114,8 +130,12 @@ describe('Cleanup image tags project settings', () => { }); describe('an admin is visiting the page', () => { - it('shows the admin part of the alert message', () => { - mountComponent({ ...defaultProvidedValues, isAdmin: true }); + it('shows the admin part of the alert message', async () => { + mountComponentWithApollo({ + provide: { ...defaultProvidedValues, isAdmin: true }, + resolver: jest.fn().mockResolvedValue(nullExpirationPolicyPayload()), + }); + await waitForPromises(); const sprintf = findAlert().findComponent(GlSprintf); expect(sprintf.text()).toBe('administration settings'); 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 index 35baeaeac61..43484d26d76 100644 --- 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 @@ -16,7 +16,11 @@ import { 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 } from '../mock_data'; +import { + expirationPolicyPayload, + emptyExpirationPolicyPayload, + nullExpirationPolicyPayload, +} from '../mock_data'; describe('Container expiration policy project settings', () => { let wrapper; @@ -78,15 +82,30 @@ describe('Container expiration policy project settings', () => { expect(findButton().attributes('href')).toBe(defaultProvidedValues.cleanupSettingsPath); }); + it('when loading does not render form or alert components', () => { + mountComponentWithApollo({ + resolver: jest.fn().mockResolvedValue(), + }); + + expect(findFormComponent().exists()).toBe(false); + expect(findAlert().exists()).toBe(false); + }); + describe('the form is disabled', () => { - it('the form is hidden', () => { - mountComponent(); + it('hides the form', async () => { + mountComponentWithApollo({ + resolver: jest.fn().mockResolvedValue(nullExpirationPolicyPayload()), + }); + await waitForPromises(); expect(findFormComponent().exists()).toBe(false); }); - it('shows an alert', () => { - mountComponent(); + it('shows an alert', async () => { + mountComponentWithApollo({ + resolver: jest.fn().mockResolvedValue(nullExpirationPolicyPayload()), + }); + await waitForPromises(); const text = findAlert().text(); expect(text).toContain(UNAVAILABLE_FEATURE_INTRO_TEXT); @@ -94,8 +113,12 @@ describe('Container expiration policy project settings', () => { }); describe('an admin is visiting the page', () => { - it('shows the admin part of the alert message', () => { - mountComponent({ ...defaultProvidedValues, isAdmin: true }); + it('shows the admin part of the alert message', async () => { + mountComponentWithApollo({ + provide: { ...defaultProvidedValues, isAdmin: true }, + resolver: jest.fn().mockResolvedValue(nullExpirationPolicyPayload()), + }); + await waitForPromises(); const sprintf = findAlert().findComponent(GlSprintf); expect(sprintf.text()).toBe('administration settings'); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js index 0696144215c..3204ca01f99 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js @@ -29,6 +29,15 @@ export const emptyExpirationPolicyPayload = () => ({ }, }); +export const nullExpirationPolicyPayload = () => ({ + data: { + project: { + id: '1', + containerExpirationPolicy: null, + }, + }, +}); + export const expirationPolicyMutationPayload = ({ override, errors = [] } = {}) => ({ data: { updateContainerExpirationPolicy: { diff --git a/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js b/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js index ebf21c01324..17669331370 100644 --- a/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js +++ b/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js @@ -1,9 +1,10 @@ -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; import { TEST_HOST } from 'helpers/test_constants'; -import mountComponent from 'helpers/vue_mount_component_helper'; import axios from '~/lib/utils/axios_utils'; import { redirectTo } from '~/lib/utils/url_utility'; -import stopJobsModal from '~/pages/admin/jobs/index/components/stop_jobs_modal.vue'; +import StopJobsModal from '~/pages/admin/jobs/index/components/stop_jobs_modal.vue'; jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), @@ -14,20 +15,23 @@ describe('stop_jobs_modal.vue', () => { const props = { url: `${TEST_HOST}/stop_jobs_modal.vue/stopAll`, }; - let vm; + let wrapper; - afterEach(() => { - vm.$destroy(); + beforeEach(() => { + wrapper = mount(StopJobsModal, { propsData: props }); }); - beforeEach(() => { - const Component = Vue.extend(stopJobsModal); - vm = mountComponent(Component, props); + afterEach(() => { + wrapper.destroy(); }); - describe('onSubmit', () => { + describe('on submit', () => { it('stops jobs and redirects to overview page', async () => { const responseURL = `${TEST_HOST}/stop_jobs_modal.vue/jobs`; + // TODO: We can't use axios-mock-adapter because our current version + // does not support responseURL + // + // see https://gitlab.com/gitlab-org/gitlab/-/issues/375308 for details jest.spyOn(axios, 'post').mockImplementation((url) => { expect(url).toBe(props.url); return Promise.resolve({ @@ -37,18 +41,28 @@ describe('stop_jobs_modal.vue', () => { }); }); - await vm.onSubmit(); + wrapper.findComponent(GlModal).vm.$emit('primary'); + await nextTick(); + expect(redirectTo).toHaveBeenCalledWith(responseURL); }); it('displays error if stopping jobs failed', async () => { + Vue.config.errorHandler = () => {}; // silencing thrown error + const dummyError = new Error('stopping jobs failed'); + // TODO: We can't use axios-mock-adapter because our current version + // does not support responseURL + // + // see https://gitlab.com/gitlab-org/gitlab/-/issues/375308 for details jest.spyOn(axios, 'post').mockImplementation((url) => { expect(url).toBe(props.url); return Promise.reject(dummyError); }); - await expect(vm.onSubmit()).rejects.toEqual(dummyError); + wrapper.findComponent(GlModal).vm.$emit('primary'); + await nextTick(); + expect(redirectTo).not.toHaveBeenCalled(); }); }); diff --git a/spec/frontend/pages/import/fogbugz/new_user_map/components/user_select_spec.js b/spec/frontend/pages/import/fogbugz/new_user_map/components/user_select_spec.js new file mode 100644 index 00000000000..c1e1545944b --- /dev/null +++ b/spec/frontend/pages/import/fogbugz/new_user_map/components/user_select_spec.js @@ -0,0 +1,81 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlListbox } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import searchUsersQuery from '~/graphql_shared/queries/users_search_all.query.graphql'; + +import createMockApollo from 'helpers/mock_apollo_helper'; +import UserSelect from '~/pages/import/fogbugz/new_user_map/components/user_select.vue'; + +Vue.use(VueApollo); + +const USERS_RESPONSE = { + data: { + users: { + nodes: [ + { + id: 'gid://gitlab/User/44', + avatarUrl: '/avatar1', + webUrl: '/reported_user_22', + name: 'Birgit Steuber', + username: 'reported_user_22', + __typename: 'UserCore', + }, + { + id: 'gid://gitlab/User/43', + avatarUrl: '/avatar2', + webUrl: '/reported_user_21', + name: 'Luke Spinka', + username: 'reported_user_21', + __typename: 'UserCore', + }, + ], + __typename: 'UserCoreConnection', + }, + }, +}; + +describe('fogbugz user select component', () => { + let wrapper; + const searchQueryHandlerSuccess = jest.fn().mockResolvedValue(USERS_RESPONSE); + + const createComponent = (propsData = { name: 'demo' }) => { + const fakeApollo = createMockApollo([[searchUsersQuery, searchQueryHandlerSuccess]]); + + wrapper = shallowMount(UserSelect, { + apolloProvider: fakeApollo, + propsData, + }); + }; + + it('renders hidden input with name from props', () => { + const name = 'test'; + createComponent({ name }); + expect(wrapper.find('input').attributes('name')).toBe(name); + }); + + it('syncs input value with value emitted from listbox', async () => { + createComponent(); + + const id = 8; + + wrapper.findComponent(GlListbox).vm.$emit('select', `gid://gitlab/User/${id}`); + await nextTick(); + + expect(wrapper.get('input').attributes('value')).toBe(id.toString()); + }); + + it('filters users when search is performed in listbox', async () => { + createComponent(); + jest.runOnlyPendingTimers(); + + wrapper.findComponent(GlListbox).vm.$emit('search', 'test'); + await nextTick(); + jest.runOnlyPendingTimers(); + + expect(searchQueryHandlerSuccess).toHaveBeenCalledWith({ + first: expect.anything(), + search: 'test', + }); + }); +}); diff --git a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js index f221a90da61..727c5164cdc 100644 --- a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js @@ -6,7 +6,7 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import { kebabCase } from 'lodash'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import * as urlUtility from '~/lib/utils/url_utility'; import ForkForm from '~/pages/projects/forks/new/components/fork_form.vue'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -449,7 +449,7 @@ describe('ForkForm component', () => { await submitForm(); expect(urlUtility.redirectTo).not.toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'An error occurred while forking the project. Please try again.', }); }); diff --git a/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js b/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js index 1a88aebae32..f6d3957115f 100644 --- a/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js @@ -10,7 +10,7 @@ import { mount, shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import searchQuery from '~/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql'; import ProjectNamespace from '~/pages/projects/forks/new/components/project_namespace.vue'; @@ -167,7 +167,7 @@ describe('ProjectNamespace component', () => { }); it('creates a flash message and captures the error', () => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'Something went wrong while loading data. Please refresh the page to try again.', captureError: true, error: expect.any(Error), diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js index 5b9c48f0d9b..f54d56c3af4 100644 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js @@ -1,8 +1,7 @@ import $ from 'jquery'; import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { formatUtcOffset, formatTimezone } from '~/lib/utils/datetime_utility'; import TimezoneDropdown, { - formatUtcOffset, - formatTimezone, findTimezoneByIdentifier, } from '~/pages/projects/pipeline_schedules/shared/components/timezone_dropdown'; diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js index b37d2f06191..0f947e84e0f 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -1,15 +1,12 @@ import { nextTick } from 'vue'; -import { GlAlert, GlButton, GlFormInput, GlFormGroup, GlSegmentedControl } from '@gitlab/ui'; +import { GlAlert, GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { mockTracking } from 'helpers/tracking_helper'; -import { stubComponent } from 'helpers/stub_component'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import ContentEditor from '~/content_editor/components/content_editor.vue'; -import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import WikiForm from '~/pages/shared/wikis/components/wiki_form.vue'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import { CONTENT_EDITOR_LOADED_ACTION, SAVED_USING_CONTENT_EDITOR_ACTION, @@ -18,8 +15,6 @@ import { WIKI_FORMAT_UPDATED_ACTION, } from '~/pages/shared/wikis/constants'; -import MarkdownField from '~/vue_shared/components/markdown/field.vue'; - jest.mock('~/emoji'); describe('WikiForm', () => { @@ -30,16 +25,12 @@ describe('WikiForm', () => { const findForm = () => wrapper.find('form'); const findTitle = () => wrapper.find('#wiki_title'); const findFormat = () => wrapper.find('#wiki_format'); - const findContent = () => wrapper.find('#wiki_content'); const findMessage = () => wrapper.find('#wiki_message'); + const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor); const findSubmitButton = () => wrapper.findByTestId('wiki-submit-button'); const findCancelButton = () => wrapper.findByTestId('wiki-cancel-button'); - const findToggleEditingModeButton = () => wrapper.findByTestId('toggle-editing-mode-button'); const findTitleHelpLink = () => wrapper.findByText('Learn more.'); const findMarkdownHelpLink = () => wrapper.findByTestId('wiki-markdown-help-link'); - const findContentEditor = () => wrapper.findComponent(ContentEditor); - const findClassicEditor = () => wrapper.findComponent(MarkdownField); - const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); const setFormat = (value) => { const format = findFormat(); @@ -53,13 +44,6 @@ describe('WikiForm', () => { await nextTick(); }; - const dispatchBeforeUnload = () => { - const e = new Event('beforeunload'); - jest.spyOn(e, 'preventDefault'); - window.dispatchEvent(e); - return e; - }; - const pageInfoNew = { persisted: false, uploadsPath: '/project/path/-/wikis/attachments', @@ -103,11 +87,8 @@ describe('WikiForm', () => { }, }, stubs: { - MarkdownField, GlAlert, GlButton, - GlSegmentedControl, - LocalStorageSync: stubComponent(LocalStorageSync), GlFormInput, GlFormGroup, }, @@ -126,6 +107,22 @@ describe('WikiForm', () => { wrapper = null; }); + it('displays markdown editor', () => { + createWrapper({ persisted: true }); + + expect(findMarkdownEditor().props()).toEqual( + expect.objectContaining({ + value: pageInfoPersisted.content, + renderMarkdownPath: pageInfoPersisted.markdownPreviewPath, + markdownDocsPath: pageInfoPersisted.markdownHelpPath, + uploadsPath: pageInfoPersisted.uploadsPath, + initOnAutofocus: pageInfoPersisted.persisted, + formFieldId: 'wiki_content', + formFieldName: 'wiki[content]', + }), + ); + }); + it.each` title | persisted | message ${'my page'} | ${false} | ${'Create my page'} @@ -154,7 +151,7 @@ describe('WikiForm', () => { it('does not trim page content by default', () => { createWrapper({ persisted: true }); - expect(findContent().element.value).toBe(' My page content '); + expect(findMarkdownEditor().props().value).toBe(' My page content '); }); it.each` @@ -168,7 +165,9 @@ describe('WikiForm', () => { await setFormat(format); - expect(findClassicEditor().props('enablePreview')).toBe(enabled); + nextTick(); + + expect(findMarkdownEditor().props('enablePreview')).toBe(enabled); }); it.each` @@ -185,14 +184,6 @@ describe('WikiForm', () => { expect(wrapper.text()).toContain(text); }); - it('starts with no unload warning', () => { - createWrapper(); - - const e = dispatchBeforeUnload(); - expect(typeof e.returnValue).not.toBe('string'); - expect(e.preventDefault).not.toHaveBeenCalled(); - }); - it.each` persisted | titleHelpText | titleHelpLink ${true} | ${'You can move this page by adding the path to the beginning of the title.'} | ${'/help/user/project/wiki/index#move-a-wiki-page'} @@ -219,15 +210,7 @@ describe('WikiForm', () => { beforeEach(async () => { createWrapper({ mountFn: mount, persisted: true }); - const input = findContent(); - - await input.setValue(' Lorem ipsum dolar sit! '); - }); - - it('sets before unload warning', () => { - const e = dispatchBeforeUnload(); - - expect(e.preventDefault).toHaveBeenCalledTimes(1); + await findMarkdownEditor().vm.$emit('input', ' Lorem ipsum dolar sit! '); }); describe('form submit', () => { @@ -235,17 +218,12 @@ describe('WikiForm', () => { await triggerFormSubmit(); }); - it('when form submitted, unsets before unload warning', () => { - const e = dispatchBeforeUnload(); - expect(e.preventDefault).not.toHaveBeenCalled(); - }); - it('triggers wiki format tracking event', () => { expect(trackingSpy).toHaveBeenCalledTimes(1); }); it('does not trim page content', () => { - expect(findContent().element.value).toBe(' Lorem ipsum dolar sit! '); + expect(findMarkdownEditor().props().value).toBe(' Lorem ipsum dolar sit! '); }); }); }); @@ -264,7 +242,7 @@ describe('WikiForm', () => { createWrapper({ mountFn: mount }); await findTitle().setValue(title); - await findContent().setValue(content); + await findMarkdownEditor().vm.$emit('input', content); expect(findSubmitButton().props().disabled).toBe(disabledAttr); }, @@ -296,208 +274,59 @@ describe('WikiForm', () => { ); }); - describe('toggle editing mode control', () => { - beforeEach(() => { - createWrapper({ mountFn: mount }); - }); + it.each` + format | enabled | action + ${'markdown'} | ${true} | ${'enables'} + ${'rdoc'} | ${false} | ${'disables'} + ${'asciidoc'} | ${false} | ${'disables'} + ${'org'} | ${false} | ${'disables'} + `('$action content editor when format is $format', async ({ format, enabled }) => { + createWrapper({ mountFn: mount }); - it.each` - format | exists | action - ${'markdown'} | ${true} | ${'displays'} - ${'rdoc'} | ${false} | ${'hides'} - ${'asciidoc'} | ${false} | ${'hides'} - ${'org'} | ${false} | ${'hides'} - `('$action toggle editing mode button when format is $format', async ({ format, exists }) => { - await setFormat(format); - - expect(findToggleEditingModeButton().exists()).toBe(exists); - }); + setFormat(format); - describe('when content editor is not active', () => { - it('displays "Source" label in the toggle editing mode button', () => { - expect(findToggleEditingModeButton().props().checked).toBe('source'); - }); + await nextTick(); - describe('when clicking the toggle editing mode button', () => { - beforeEach(async () => { - await findToggleEditingModeButton().vm.$emit('input', 'richText'); - }); + expect(findMarkdownEditor().props().enableContentEditor).toBe(enabled); + }); - it('hides the classic editor', () => { - expect(findClassicEditor().exists()).toBe(false); - }); + describe('when markdown editor activates the content editor', () => { + beforeEach(async () => { + createWrapper({ mountFn: mount, persisted: true }); - it('shows the content editor', () => { - expect(findContentEditor().exists()).toBe(true); - }); - }); + await findMarkdownEditor().vm.$emit('contentEditor'); }); - describe('markdown editor type persistance', () => { - it('loads content editor by default if it is persisted in local storage', async () => { - expect(findClassicEditor().exists()).toBe(true); - expect(findContentEditor().exists()).toBe(false); - - // enable content editor - await findLocalStorageSync().vm.$emit('input', 'richText'); - - expect(findContentEditor().exists()).toBe(true); - expect(findClassicEditor().exists()).toBe(false); - }); + it('disables the format dropdown', () => { + expect(findFormat().element.getAttribute('disabled')).toBeDefined(); }); - describe('when content editor is active', () => { - beforeEach(() => { - createWrapper(); - findToggleEditingModeButton().vm.$emit('input', 'richText'); - }); - - it('displays "Edit Rich" label in the toggle editing mode button', () => { - expect(findToggleEditingModeButton().props().checked).toBe('richText'); - }); - - describe('when clicking the toggle editing mode button', () => { - beforeEach(async () => { - await findToggleEditingModeButton().vm.$emit('input', 'source'); - await nextTick(); - }); - - it('hides the content editor', () => { - expect(findContentEditor().exists()).toBe(false); - }); - - it('displays the classic editor', () => { - expect(findClassicEditor().exists()).toBe(true); - }); - }); - - describe('when content editor is loading', () => { - beforeEach(async () => { - findContentEditor().vm.$emit('loading'); - - await nextTick(); - }); - - it('disables toggle editing mode button', () => { - expect(findToggleEditingModeButton().attributes().disabled).toBe('true'); - }); - - describe('when content editor loads successfully', () => { - it('enables toggle editing mode button', async () => { - findContentEditor().vm.$emit('loadingSuccess'); - - await nextTick(); - - expect(findToggleEditingModeButton().attributes().disabled).not.toBeDefined(); - }); - }); - - describe('when content editor fails to load', () => { - it('enables toggle editing mode button', async () => { - findContentEditor().vm.$emit('loadingError'); - - await nextTick(); - - expect(findToggleEditingModeButton().attributes().disabled).not.toBeDefined(); - }); - }); + it('sends tracking event when editor loads', async () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, CONTENT_EDITOR_LOADED_ACTION, { + label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, }); }); - }); - - describe('wiki content editor', () => { - describe('clicking "Edit rich text": editor fails to load', () => { - beforeEach(async () => { - createWrapper({ mountFn: mount }); - mock.onPost(/preview-markdown/).reply(400); - - await findToggleEditingModeButton().vm.$emit('input', 'richText'); - - // try waiting for content editor to load (but it will never actually load) - await waitForPromises(); - }); - - it('disables the submit button', () => { - expect(findSubmitButton().props('disabled')).toBe(true); - }); - - describe('toggling editing modes to the classic editor', () => { - beforeEach(() => { - return findToggleEditingModeButton().vm.$emit('input', 'source'); - }); - it('switches to classic editor', () => { - expect(findContentEditor().exists()).toBe(false); - expect(findClassicEditor().exists()).toBe(true); - }); - }); - }); + describe('when triggering form submit', () => { + const updatedMarkdown = 'hello **world**'; - describe('clicking "Edit rich text": editor loads successfully', () => { beforeEach(async () => { - createWrapper({ persisted: true, mountFn: mount }); - - mock.onPost(/preview-markdown/).reply(200, { body: '<p>hello <strong>world</strong></p>' }); - - await findToggleEditingModeButton().vm.$emit('input', 'richText'); - await waitForPromises(); - }); - - it('shows the rich text editor when loading finishes', async () => { - expect(findContentEditor().exists()).toBe(true); + findMarkdownEditor().vm.$emit('input', updatedMarkdown); + await triggerFormSubmit(); }); - it('sends tracking event when editor loads', async () => { - expect(trackingSpy).toHaveBeenCalledWith(undefined, CONTENT_EDITOR_LOADED_ACTION, { + it('triggers tracking events on form submit', async () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, SAVED_USING_CONTENT_EDITOR_ACTION, { label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, }); - }); - - it('disables the format dropdown', () => { - expect(findFormat().element.getAttribute('disabled')).toBeDefined(); - }); - describe('when wiki content is updated', () => { - const updatedMarkdown = 'hello **world**'; - - beforeEach(() => { - findContentEditor().vm.$emit('change', { - empty: false, - changed: true, - markdown: updatedMarkdown, - }); - }); - - it('sets before unload warning', () => { - const e = dispatchBeforeUnload(); - expect(e.preventDefault).toHaveBeenCalledTimes(1); - }); - - it('unsets before unload warning on form submit', async () => { - await triggerFormSubmit(); - - const e = dispatchBeforeUnload(); - expect(e.preventDefault).not.toHaveBeenCalled(); - }); - - it('triggers tracking events on form submit', async () => { - await triggerFormSubmit(); - expect(trackingSpy).toHaveBeenCalledWith(undefined, SAVED_USING_CONTENT_EDITOR_ACTION, { - label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, - }); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, WIKI_FORMAT_UPDATED_ACTION, { - label: WIKI_FORMAT_LABEL, - extra: { - value: findFormat().element.value, - old_format: pageInfoPersisted.format, - project_path: pageInfoPersisted.path, - }, - }); - }); - - it('sets content field to the content editor updated markdown', async () => { - expect(findContent().element.value).toBe(updatedMarkdown); + expect(trackingSpy).toHaveBeenCalledWith(undefined, WIKI_FORMAT_UPDATED_ACTION, { + label: WIKI_FORMAT_LABEL, + extra: { + value: findFormat().element.value, + old_format: pageInfoPersisted.format, + project_path: pageInfoPersisted.path, + }, }); }); }); diff --git a/spec/frontend/pdf/page_spec.js b/spec/frontend/pdf/page_spec.js index 07a7f1bb2ff..4cf83a3252d 100644 --- a/spec/frontend/pdf/page_spec.js +++ b/spec/frontend/pdf/page_spec.js @@ -1,17 +1,16 @@ -import Vue, { nextTick } from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; import PageComponent from '~/pdf/page/index.vue'; jest.mock('pdfjs-dist/webpack', () => { - return { default: jest.requireActual('pdfjs-dist/build/pdf') }; + return { default: jest.requireActual('pdfjs-dist/legacy/build/pdf') }; }); describe('Page component', () => { - const Component = Vue.extend(PageComponent); - let vm; + let wrapper; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders the page when mounting', async () => { @@ -20,16 +19,18 @@ describe('Page component', () => { getViewport: jest.fn().mockReturnValue({}), }; - vm = mountComponent(Component, { - page: testPage, - number: 1, + wrapper = mount(PageComponent, { + propsData: { + page: testPage, + number: 1, + }, }); - expect(vm.rendering).toBe(true); - await nextTick(); - expect(testPage.render).toHaveBeenCalledWith(vm.renderContext); - expect(vm.rendering).toBe(false); + expect(testPage.render).toHaveBeenCalledWith({ + canvasContext: wrapper.find('canvas').element.getContext('2d'), + viewport: testPage.getViewport(), + }); }); }); diff --git a/spec/frontend/performance_bar/components/request_warning_spec.js b/spec/frontend/performance_bar/components/request_warning_spec.js index d558c7b018a..9dd8ea9f933 100644 --- a/spec/frontend/performance_bar/components/request_warning_spec.js +++ b/spec/frontend/performance_bar/components/request_warning_spec.js @@ -2,14 +2,21 @@ import { shallowMount } from '@vue/test-utils'; import RequestWarning from '~/performance_bar/components/request_warning.vue'; describe('request warning', () => { + let wrapper; const htmlId = 'request-123'; + afterEach(() => { + wrapper.destroy(); + }); + describe('when the request has warnings', () => { - const wrapper = shallowMount(RequestWarning, { - propsData: { - htmlId, - warnings: ['gitaly calls: 30 over 10', 'gitaly duration: 1500 over 1000'], - }, + beforeEach(() => { + wrapper = shallowMount(RequestWarning, { + propsData: { + htmlId, + warnings: ['gitaly calls: 30 over 10', 'gitaly duration: 1500 over 1000'], + }, + }); }); it('adds a warning emoji with the correct ID', () => { @@ -19,11 +26,13 @@ describe('request warning', () => { }); describe('when the request does not have warnings', () => { - const wrapper = shallowMount(RequestWarning, { - propsData: { - htmlId, - warnings: [], - }, + beforeEach(() => { + wrapper = shallowMount(RequestWarning, { + propsData: { + htmlId, + warnings: [], + }, + }); }); it('does nothing', () => { diff --git a/spec/frontend/persistent_user_callout_spec.js b/spec/frontend/persistent_user_callout_spec.js index 9cd5bb9e9a1..c9574208900 100644 --- a/spec/frontend/persistent_user_callout_spec.js +++ b/spec/frontend/persistent_user_callout_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import PersistentUserCallout from '~/persistent_user_callout'; @@ -108,7 +108,7 @@ describe('PersistentUserCallout', () => { await waitForPromises(); expect(persistentUserCallout.container.remove).not.toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'An error occurred while dismissing the alert. Refresh the page and try again.', }); }); @@ -214,7 +214,7 @@ describe('PersistentUserCallout', () => { await waitForPromises(); expect(window.location.assign).not.toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'An error occurred while acknowledging the notification. Refresh the page and try again.', }); diff --git a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js index 8f6f4d8cff9..f0347ad19ac 100644 --- a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js +++ b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js @@ -360,7 +360,7 @@ describe('Pipeline editor branch switcher', () => { }); describe('loading icon', () => { - test.each` + it.each` isQueryLoading | isRendered ${true} | ${true} ${false} | ${false} diff --git a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js index 8e0a73b6e7c..c76c3460e99 100644 --- a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js +++ b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js @@ -7,6 +7,7 @@ describe('Pipeline editor empty state', () => { let wrapper; const defaultProvide = { emptyStateIllustrationPath: 'my/svg/path', + usesExternalConfig: false, }; const createComponent = ({ provide } = {}) => { @@ -18,6 +19,7 @@ describe('Pipeline editor empty state', () => { const findFileNav = () => wrapper.findComponent(PipelineEditorFileNav); const findSvgImage = () => wrapper.find('img'); const findTitle = () => wrapper.find('h1'); + const findExternalCiInstructions = () => wrapper.find('p'); const findConfirmButton = () => wrapper.findComponent(GlButton); const findDescription = () => wrapper.findComponent(GlSprintf); @@ -25,7 +27,33 @@ describe('Pipeline editor empty state', () => { wrapper.destroy(); }); - describe('template', () => { + describe('when project uses an external CI config', () => { + beforeEach(() => { + createComponent({ + provide: { usesExternalConfig: true }, + }); + }); + + it('renders an svg image', () => { + expect(findSvgImage().exists()).toBe(true); + }); + + it('renders the correct title and instructions', () => { + expect(findTitle().exists()).toBe(true); + expect(findExternalCiInstructions().exists()).toBe(true); + + expect(findExternalCiInstructions().html()).toContain( + wrapper.vm.$options.i18n.externalCiInstructions, + ); + expect(findTitle().text()).toBe(wrapper.vm.$options.i18n.externalCiNote); + }); + + it('does not render the CTA button', () => { + expect(findConfirmButton().exists()).toBe(false); + }); + }); + + describe('when project uses an accessible CI config', () => { beforeEach(() => { createComponent(); }); diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index 1989f23a415..9fe1536d3f5 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -55,11 +55,12 @@ jest.mock('~/lib/utils/url_utility', () => ({ const localVue = createLocalVue(); localVue.use(VueApollo); -const mockProvide = { +const defaultProvide = { ciConfigPath: mockCiConfigPath, defaultBranch: mockDefaultBranch, newMergeRequestPath: mockNewMergeRequestPath, projectFullPath: mockProjectFullPath, + usesExternalConfig: false, }; describe('Pipeline editor app component', () => { @@ -79,7 +80,7 @@ describe('Pipeline editor app component', () => { stubs = {}, } = {}) => { wrapper = shallowMount(PipelineEditorApp, { - provide: { ...mockProvide, ...provide }, + provide: { ...defaultProvide, ...provide }, stubs, mocks: { $apollo: { @@ -229,6 +230,22 @@ describe('Pipeline editor app component', () => { mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults); }); + describe('when project uses an external CI config file', () => { + beforeEach(async () => { + await createComponentWithApollo({ + provide: { + usesExternalConfig: true, + }, + }); + }); + + it('shows an empty state and does not show editor home component', () => { + expect(findEmptyState().exists()).toBe(true); + expect(findAlert().exists()).toBe(false); + expect(findEditorHome().exists()).toBe(false); + }); + }); + describe('when file exists', () => { beforeEach(async () => { await createComponentWithApollo(); diff --git a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js index e317d1ddcc2..2b06660c4b3 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js @@ -149,20 +149,20 @@ describe('Pipeline editor home wrapper', () => { await nextTick(); - expect(findCommitSection().exists()).toBe(shouldShow); + expect(findCommitSection().isVisible()).toBe(shouldShow); }, ); it('shows the commit form again when coming back to the create tab', async () => { - expect(findCommitSection().exists()).toBe(true); + expect(findCommitSection().isVisible()).toBe(true); findPipelineEditorTabs().vm.$emit('set-current-tab', MERGED_TAB); await nextTick(); - expect(findCommitSection().exists()).toBe(false); + expect(findCommitSection().isVisible()).toBe(false); findPipelineEditorTabs().vm.$emit('set-current-tab', CREATE_TAB); await nextTick(); - expect(findCommitSection().exists()).toBe(true); + expect(findCommitSection().isVisible()).toBe(true); }); describe('rendering with tab params', () => { @@ -178,7 +178,7 @@ describe('Pipeline editor home wrapper', () => { setWindowLocation(`https://gitlab.test/ci/editor/?tab=${TABS_INDEX[tab]}`); await createComponent({ stubs: { PipelineEditorTabs } }); - expect(findCommitSection().exists()).toBe(shouldShow); + expect(findCommitSection().isVisible()).toBe(shouldShow); }, ); }); diff --git a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js index 5ce29bd6c5d..3e699b93fd3 100644 --- a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js +++ b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js @@ -1,72 +1,101 @@ -import { GlForm, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlForm, GlDropdownItem, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; import CreditCardValidationRequiredAlert from 'ee_component/billings/components/cc_validation_required_alert.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import httpStatusCodes from '~/lib/utils/http_status'; import { redirectTo } from '~/lib/utils/url_utility'; import PipelineNewForm from '~/pipeline_new/components/pipeline_new_form.vue'; +import ciConfigVariablesQuery from '~/pipeline_new/graphql/queries/ci_config_variables.graphql'; +import { resolvers } from '~/pipeline_new/graphql/resolvers'; import RefsDropdown from '~/pipeline_new/components/refs_dropdown.vue'; import { + mockCreditCardValidationRequiredError, + mockCiConfigVariablesResponse, + mockCiConfigVariablesResponseWithoutDesc, + mockEmptyCiConfigVariablesResponse, + mockError, mockQueryParams, mockPostParams, mockProjectId, - mockError, mockRefs, - mockCreditCardValidationRequiredError, + mockYamlVariables, } from '../mock_data'; +Vue.use(VueApollo); + jest.mock('~/lib/utils/url_utility', () => ({ redirectTo: jest.fn(), })); const projectRefsEndpoint = '/root/project/refs'; const pipelinesPath = '/root/project/-/pipelines'; -const configVariablesPath = '/root/project/-/pipelines/config_variables'; +const projectPath = '/root/project/-/pipelines/config_variables'; const newPipelinePostResponse = { id: 1 }; const defaultBranch = 'main'; describe('Pipeline New Form', () => { let wrapper; let mock; + let mockApollo; + let mockCiConfigVariables; let dummySubmitEvent; const findForm = () => wrapper.findComponent(GlForm); const findRefsDropdown = () => wrapper.findComponent(RefsDropdown); - const findSubmitButton = () => wrapper.find('[data-testid="run_pipeline_button"]'); - const findVariableRows = () => wrapper.findAll('[data-testid="ci-variable-row"]'); - const findRemoveIcons = () => wrapper.findAll('[data-testid="remove-ci-variable-row"]'); - const findDropdowns = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-type"]'); - const findKeyInputs = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-key"]'); - const findValueInputs = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-value"]'); - const findErrorAlert = () => wrapper.find('[data-testid="run-pipeline-error-alert"]'); - const findWarningAlert = () => wrapper.find('[data-testid="run-pipeline-warning-alert"]'); + const findSubmitButton = () => wrapper.findByTestId('run_pipeline_button'); + const findVariableRows = () => wrapper.findAllByTestId('ci-variable-row'); + const findRemoveIcons = () => wrapper.findAllByTestId('remove-ci-variable-row'); + const findVariableTypes = () => wrapper.findAllByTestId('pipeline-form-ci-variable-type'); + const findKeyInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-key'); + const findValueInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-value'); + const findValueDropdowns = () => + wrapper.findAllByTestId('pipeline-form-ci-variable-value-dropdown'); + const findValueDropdownItems = (dropdown) => dropdown.findAllComponents(GlDropdownItem); + const findErrorAlert = () => wrapper.findByTestId('run-pipeline-error-alert'); + const findWarningAlert = () => wrapper.findByTestId('run-pipeline-warning-alert'); const findWarningAlertSummary = () => findWarningAlert().findComponent(GlSprintf); - const findWarnings = () => wrapper.findAll('[data-testid="run-pipeline-warning"]'); + const findWarnings = () => wrapper.findAllByTestId('run-pipeline-warning'); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findCCAlert = () => wrapper.findComponent(CreditCardValidationRequiredAlert); const getFormPostParams = () => JSON.parse(mock.history.post[0].data); - const selectBranch = (branch) => { + const selectBranch = async (branch) => { // Select a branch in the dropdown findRefsDropdown().vm.$emit('input', { shortName: branch, fullName: `refs/heads/${branch}`, }); + + await waitForPromises(); + }; + + const changeKeyInputValue = async (keyInputIndex, value) => { + const input = findKeyInputs().at(keyInputIndex); + input.element.value = value; + input.trigger('change'); + + await nextTick(); }; - const createComponent = (props = {}, method = shallowMount) => { + const createComponentWithApollo = ({ method = shallowMountExtended, props = {} } = {}) => { + const handlers = [[ciConfigVariablesQuery, mockCiConfigVariables]]; + mockApollo = createMockApollo(handlers, resolvers); + wrapper = method(PipelineNewForm, { + apolloProvider: mockApollo, provide: { projectRefsEndpoint, }, propsData: { projectId: mockProjectId, pipelinesPath, - configVariablesPath, + projectPath, defaultBranch, refParam: defaultBranch, settingsLink: '', @@ -78,7 +107,7 @@ describe('Pipeline New Form', () => { beforeEach(() => { mock = new MockAdapter(axios); - mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {}); + mockCiConfigVariables = jest.fn(); mock.onGet(projectRefsEndpoint).reply(httpStatusCodes.OK, mockRefs); dummySubmitEvent = { @@ -87,24 +116,20 @@ describe('Pipeline New Form', () => { }); afterEach(() => { - wrapper.destroy(); - wrapper = null; - mock.restore(); + wrapper.destroy(); }); describe('Form', () => { beforeEach(async () => { - createComponent(mockQueryParams, mount); - - mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, newPipelinePostResponse); - + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); + createComponentWithApollo({ props: mockQueryParams, method: mountExtended }); await waitForPromises(); }); it('displays the correct values for the provided query params', async () => { - expect(findDropdowns().at(0).props('text')).toBe('Variable'); - expect(findDropdowns().at(1).props('text')).toBe('File'); + expect(findVariableTypes().at(0).props('text')).toBe('Variable'); + expect(findVariableTypes().at(1).props('text')).toBe('File'); expect(findRefsDropdown().props('value')).toEqual({ shortName: 'tag-1' }); expect(findVariableRows()).toHaveLength(3); }); @@ -117,7 +142,7 @@ describe('Pipeline New Form', () => { it('displays an empty variable for the user to fill out', async () => { expect(findKeyInputs().at(2).element.value).toBe(''); expect(findValueInputs().at(2).element.value).toBe(''); - expect(findDropdowns().at(2).props('text')).toBe('Variable'); + expect(findVariableTypes().at(2).props('text')).toBe('Variable'); }); it('does not display remove icon for last row', () => { @@ -147,13 +172,12 @@ describe('Pipeline New Form', () => { describe('Pipeline creation', () => { beforeEach(async () => { + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, newPipelinePostResponse); - - await waitForPromises(); }); it('does not submit the native HTML form', async () => { - createComponent(); + createComponentWithApollo(); findForm().vm.$emit('submit', dummySubmitEvent); @@ -161,7 +185,7 @@ describe('Pipeline New Form', () => { }); it('disables the submit button immediately after submitting', async () => { - createComponent(); + createComponentWithApollo(); expect(findSubmitButton().props('disabled')).toBe(false); @@ -172,7 +196,7 @@ describe('Pipeline New Form', () => { }); it('creates pipeline with full ref and variables', async () => { - createComponent(); + createComponentWithApollo(); findForm().vm.$emit('submit', dummySubmitEvent); await waitForPromises(); @@ -182,7 +206,7 @@ describe('Pipeline New Form', () => { }); it('creates a pipeline with short ref and variables from the query params', async () => { - createComponent(mockQueryParams); + createComponentWithApollo({ props: mockQueryParams }); await waitForPromises(); @@ -197,64 +221,51 @@ describe('Pipeline New Form', () => { describe('When the ref has been changed', () => { beforeEach(async () => { - createComponent({}, mount); + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); + createComponentWithApollo({ method: mountExtended }); await waitForPromises(); }); - it('variables persist between ref changes', async () => { - selectBranch('main'); - - await waitForPromises(); - const mainInput = findKeyInputs().at(0); - mainInput.element.value = 'build_var'; - mainInput.trigger('change'); + it('variables persist between ref changes', async () => { + await selectBranch('main'); + await changeKeyInputValue(0, 'build_var'); - await nextTick(); + await selectBranch('branch-1'); + await changeKeyInputValue(0, 'deploy_var'); - selectBranch('branch-1'); + await selectBranch('main'); - await waitForPromises(); + expect(findKeyInputs().at(0).element.value).toBe('build_var'); + expect(findVariableRows().length).toBe(2); - const branchOneInput = findKeyInputs().at(0); - branchOneInput.element.value = 'deploy_var'; - branchOneInput.trigger('change'); + await selectBranch('branch-1'); - await nextTick(); + expect(findKeyInputs().at(0).element.value).toBe('deploy_var'); + expect(findVariableRows().length).toBe(2); + }); - selectBranch('main'); + it('skips query call when form variables are already cached', async () => { + await selectBranch('main'); + await changeKeyInputValue(0, 'build_var'); - await waitForPromises(); + expect(mockCiConfigVariables).toHaveBeenCalledTimes(1); - expect(findKeyInputs().at(0).element.value).toBe('build_var'); - expect(findVariableRows().length).toBe(2); + await selectBranch('branch-1'); - selectBranch('branch-1'); + expect(mockCiConfigVariables).toHaveBeenCalledTimes(2); - await waitForPromises(); + // no additional call since `main` form values have been cached + await selectBranch('main'); - expect(findKeyInputs().at(0).element.value).toBe('deploy_var'); - expect(findVariableRows().length).toBe(2); + expect(mockCiConfigVariables).toHaveBeenCalledTimes(2); }); }); describe('when yml defines a variable', () => { - const mockYmlKey = 'yml_var'; - const mockYmlValue = 'yml_var_val'; - const mockYmlMultiLineValue = `A value - with multiple - lines`; - const mockYmlDesc = 'A var from yml.'; - it('loading icon is shown when content is requested and hidden when received', async () => { - createComponent(mockQueryParams, mount); - - mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { - [mockYmlKey]: { - value: mockYmlValue, - description: mockYmlDesc, - }, - }); + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); + createComponentWithApollo({ props: mockQueryParams, method: mountExtended }); expect(findLoadingIcon().exists()).toBe(true); @@ -263,51 +274,62 @@ describe('Pipeline New Form', () => { expect(findLoadingIcon().exists()).toBe(false); }); - it('multi-line strings are added to the value field without removing line breaks', async () => { - createComponent(mockQueryParams, mount); + describe('with different predefined values', () => { + beforeEach(async () => { + mockCiConfigVariables.mockResolvedValue(mockCiConfigVariablesResponse); + createComponentWithApollo({ method: mountExtended }); + await waitForPromises(); + }); + + it('multi-line strings are added to the value field without removing line breaks', () => { + expect(findValueInputs().at(1).element.value).toBe(mockYamlVariables[1].value); + }); - mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { - [mockYmlKey]: { - value: mockYmlMultiLineValue, - description: mockYmlDesc, - }, + it('multiple predefined values are rendered as a dropdown', () => { + const dropdown = findValueDropdowns().at(0); + const dropdownItems = findValueDropdownItems(dropdown); + const { valueOptions } = mockYamlVariables[2]; + + expect(dropdownItems.at(0).text()).toBe(valueOptions[0]); + expect(dropdownItems.at(1).text()).toBe(valueOptions[1]); + expect(dropdownItems.at(2).text()).toBe(valueOptions[2]); }); - await waitForPromises(); + it('variables with multiple predefined values sets the first option as the default', () => { + const dropdown = findValueDropdowns().at(0); + const { valueOptions } = mockYamlVariables[2]; - expect(findValueInputs().at(0).element.value).toBe(mockYmlMultiLineValue); + expect(dropdown.props('text')).toBe(valueOptions[0]); + }); }); describe('with description', () => { beforeEach(async () => { - createComponent(mockQueryParams, mount); - - mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { - [mockYmlKey]: { - value: mockYmlValue, - description: mockYmlDesc, - }, - }); - + mockCiConfigVariables.mockResolvedValue(mockCiConfigVariablesResponse); + createComponentWithApollo({ props: mockQueryParams, method: mountExtended }); await waitForPromises(); }); it('displays all the variables', async () => { - expect(findVariableRows()).toHaveLength(4); + expect(findVariableRows()).toHaveLength(6); }); it('displays a variable from yml', () => { - expect(findKeyInputs().at(0).element.value).toBe(mockYmlKey); - expect(findValueInputs().at(0).element.value).toBe(mockYmlValue); + expect(findKeyInputs().at(0).element.value).toBe(mockYamlVariables[0].key); + expect(findValueInputs().at(0).element.value).toBe(mockYamlVariables[0].value); }); it('displays a variable from provided query params', () => { - expect(findKeyInputs().at(1).element.value).toBe('test_var'); - expect(findValueInputs().at(1).element.value).toBe('test_var_val'); + expect(findKeyInputs().at(3).element.value).toBe( + Object.keys(mockQueryParams.variableParams)[0], + ); + expect(findValueInputs().at(3).element.value).toBe( + Object.values(mockQueryParams.fileParams)[0], + ); }); it('adds a description to the first variable from yml', () => { - expect(findVariableRows().at(0).text()).toContain(mockYmlDesc); + expect(findVariableRows().at(0).text()).toContain(mockYamlVariables[0].description); }); it('removes the description when a variable key changes', async () => { @@ -316,39 +338,27 @@ describe('Pipeline New Form', () => { await nextTick(); - expect(findVariableRows().at(0).text()).not.toContain(mockYmlDesc); + expect(findVariableRows().at(0).text()).not.toContain(mockYamlVariables[0].description); }); }); describe('without description', () => { beforeEach(async () => { - createComponent(mockQueryParams, mount); - - mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { - [mockYmlKey]: { - value: mockYmlValue, - description: null, - }, - yml_var2: { - value: 'yml_var2_val', - }, - yml_var3: { - description: '', - }, - }); - + mockCiConfigVariables.mockResolvedValue(mockCiConfigVariablesResponseWithoutDesc); + createComponentWithApollo({ method: mountExtended }); await waitForPromises(); }); - it('displays all the variables', async () => { - expect(findVariableRows()).toHaveLength(3); + it('displays variables with description only', async () => { + expect(findVariableRows()).toHaveLength(2); // extra empty variable is added at the end }); }); }); describe('Form errors and warnings', () => { beforeEach(() => { - createComponent(); + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); + createComponentWithApollo(); }); describe('when the refs cannot be loaded', () => { diff --git a/spec/frontend/pipeline_new/mock_data.js b/spec/frontend/pipeline_new/mock_data.js index e99684ff417..e95a65171fc 100644 --- a/spec/frontend/pipeline_new/mock_data.js +++ b/spec/frontend/pipeline_new/mock_data.js @@ -65,3 +65,62 @@ export const mockVariables = [ }, { uniqueId: 'var-refs/heads/main4', variable_type: 'env_var', key: '', value: '' }, ]; + +export const mockYamlVariables = [ + { + description: 'This is a variable with a value.', + key: 'VAR_WITH_VALUE', + value: 'test_value', + valueOptions: null, + }, + { + description: 'This is a variable with a multi-line value.', + key: 'VAR_WITH_MULTILINE', + value: `this is + a multiline value`, + valueOptions: null, + }, + { + description: 'This is a variable with predefined values.', + key: 'VAR_WITH_OPTIONS', + value: 'development', + valueOptions: ['development', 'staging', 'production'], + }, +]; + +export const mockYamlVariablesWithoutDesc = [ + { + description: 'This is a variable with a value.', + key: 'VAR_WITH_VALUE', + value: 'test_value', + valueOptions: null, + }, + { + description: null, + key: 'VAR_WITH_MULTILINE', + value: `this is + a multiline value`, + valueOptions: null, + }, + { + description: null, + key: 'VAR_WITH_OPTIONS', + value: 'development', + valueOptions: ['development', 'staging', 'production'], + }, +]; + +export const mockCiConfigVariablesQueryResponse = (ciConfigVariables) => ({ + data: { + project: { + id: 1, + ciConfigVariables, + }, + }, +}); + +export const mockCiConfigVariablesResponse = mockCiConfigVariablesQueryResponse(mockYamlVariables); +export const mockEmptyCiConfigVariablesResponse = mockCiConfigVariablesQueryResponse([]); +export const mockCiConfigVariablesResponseWithoutDesc = mockCiConfigVariablesQueryResponse( + mockYamlVariablesWithoutDesc, +); diff --git a/spec/frontend/pipeline_schedules/components/pipeline_schedules_form_spec.js b/spec/frontend/pipeline_schedules/components/pipeline_schedules_form_spec.js new file mode 100644 index 00000000000..4b5a9611251 --- /dev/null +++ b/spec/frontend/pipeline_schedules/components/pipeline_schedules_form_spec.js @@ -0,0 +1,25 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlForm } from '@gitlab/ui'; +import PipelineSchedulesForm from '~/pipeline_schedules/components/pipeline_schedules_form.vue'; + +describe('Pipeline schedules form', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(PipelineSchedulesForm); + }; + + const findForm = () => wrapper.findComponent(GlForm); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays form', () => { + expect(findForm().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/pipeline_schedules/components/pipeline_schedules_spec.js b/spec/frontend/pipeline_schedules/components/pipeline_schedules_spec.js new file mode 100644 index 00000000000..cce8f480928 --- /dev/null +++ b/spec/frontend/pipeline_schedules/components/pipeline_schedules_spec.js @@ -0,0 +1,161 @@ +import { GlAlert, GlLoadingIcon, GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import PipelineSchedules from '~/pipeline_schedules/components/pipeline_schedules.vue'; +import PipelineSchedulesTable from '~/pipeline_schedules/components/table/pipeline_schedules_table.vue'; +import deletePipelineScheduleMutation from '~/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql'; +import getPipelineSchedulesQuery from '~/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql'; +import { + mockGetPipelineSchedulesGraphQLResponse, + mockPipelineScheduleNodes, + deleteMutationResponse, +} from '../mock_data'; + +Vue.use(VueApollo); + +describe('Pipeline schedules app', () => { + let wrapper; + + const successHandler = jest.fn().mockResolvedValue(mockGetPipelineSchedulesGraphQLResponse); + const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + + const deleteMutationHandlerSuccess = jest.fn().mockResolvedValue(deleteMutationResponse); + const deleteMutationHandlerFailed = jest.fn().mockRejectedValue(new Error('GraphQL error')); + + const createMockApolloProvider = ( + requestHandlers = [[getPipelineSchedulesQuery, successHandler]], + ) => { + return createMockApollo(requestHandlers); + }; + + const createComponent = (requestHandlers) => { + wrapper = shallowMount(PipelineSchedules, { + provide: { + fullPath: 'gitlab-org/gitlab', + }, + apolloProvider: createMockApolloProvider(requestHandlers), + }); + }; + + const findTable = () => wrapper.findComponent(PipelineSchedulesTable); + const findAlert = () => wrapper.findComponent(GlAlert); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findModal = () => wrapper.findComponent(GlModal); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays table', async () => { + createComponent(); + + await waitForPromises(); + + expect(findTable().exists()).toBe(true); + expect(findAlert().exists()).toBe(false); + }); + + it('fetches query and passes an array of pipeline schedules', async () => { + createComponent(); + + expect(successHandler).toHaveBeenCalled(); + + await waitForPromises(); + + expect(findTable().props('schedules')).toEqual(mockPipelineScheduleNodes); + }); + + it('handles loading state', async () => { + createComponent(); + + expect(findLoadingIcon().exists()).toBe(true); + + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('shows query error alert', async () => { + createComponent([[getPipelineSchedulesQuery, failedHandler]]); + + await waitForPromises(); + + expect(findAlert().text()).toBe('There was a problem fetching pipeline schedules.'); + }); + + it('shows delete mutation error alert', async () => { + createComponent([ + [getPipelineSchedulesQuery, successHandler], + [deletePipelineScheduleMutation, deleteMutationHandlerFailed], + ]); + + await waitForPromises(); + + findModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(findAlert().text()).toBe('There was a problem deleting the pipeline schedule.'); + }); + + it('deletes pipeline schedule and refetches query', async () => { + createComponent([ + [getPipelineSchedulesQuery, successHandler], + [deletePipelineScheduleMutation, deleteMutationHandlerSuccess], + ]); + + jest.spyOn(wrapper.vm.$apollo.queries.schedules, 'refetch'); + + await waitForPromises(); + + const scheduleId = mockPipelineScheduleNodes[0].id; + + findTable().vm.$emit('showDeleteModal', scheduleId); + + expect(wrapper.vm.$apollo.queries.schedules.refetch).not.toHaveBeenCalled(); + + findModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(deleteMutationHandlerSuccess).toHaveBeenCalledWith({ + id: scheduleId, + }); + expect(wrapper.vm.$apollo.queries.schedules.refetch).toHaveBeenCalled(); + }); + + it('modal should be visible after event', async () => { + createComponent(); + + await waitForPromises(); + + expect(findModal().props('visible')).toBe(false); + + findTable().vm.$emit('showDeleteModal', mockPipelineScheduleNodes[0].id); + + await nextTick(); + + expect(findModal().props('visible')).toBe(true); + }); + + it('modal should be hidden', async () => { + createComponent(); + + await waitForPromises(); + + findTable().vm.$emit('showDeleteModal', mockPipelineScheduleNodes[0].id); + + await nextTick(); + + expect(findModal().props('visible')).toBe(true); + + findModal().vm.$emit('hide'); + + await nextTick(); + + expect(findModal().props('visible')).toBe(false); + }); +}); diff --git a/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js b/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js new file mode 100644 index 00000000000..ecc1bdeb679 --- /dev/null +++ b/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js @@ -0,0 +1,49 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PipelineScheduleActions from '~/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue'; +import { mockPipelineScheduleNodes, mockPipelineScheduleAsGuestNodes } from '../../../mock_data'; + +describe('Pipeline schedule actions', () => { + let wrapper; + + const defaultProps = { + schedule: mockPipelineScheduleNodes[0], + }; + + const createComponent = (props = defaultProps) => { + wrapper = shallowMountExtended(PipelineScheduleActions, { + propsData: { + ...props, + }, + }); + }; + + const findAllButtons = () => wrapper.findAllComponents(GlButton); + const findDeleteBtn = () => wrapper.findByTestId('delete-pipeline-schedule-btn'); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays action buttons', () => { + createComponent(); + + expect(findAllButtons()).toHaveLength(3); + }); + + it('does not display action buttons', () => { + createComponent({ schedule: mockPipelineScheduleAsGuestNodes[0] }); + + expect(findAllButtons()).toHaveLength(0); + }); + + it('delete button emits showDeleteModal event and schedule id', () => { + createComponent(); + + findDeleteBtn().vm.$emit('click'); + + expect(wrapper.emitted()).toEqual({ + showDeleteModal: [[mockPipelineScheduleNodes[0].id]], + }); + }); +}); diff --git a/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js b/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js new file mode 100644 index 00000000000..5a47b24232f --- /dev/null +++ b/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js @@ -0,0 +1,42 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import PipelineScheduleLastPipeline from '~/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue'; +import { mockPipelineScheduleNodes } from '../../../mock_data'; + +describe('Pipeline schedule last pipeline', () => { + let wrapper; + + const defaultProps = { + schedule: mockPipelineScheduleNodes[2], + }; + + const createComponent = (props = defaultProps) => { + wrapper = shallowMountExtended(PipelineScheduleLastPipeline, { + propsData: { + ...props, + }, + }); + }; + + const findCIBadge = () => wrapper.findComponent(CiBadge); + const findStatusText = () => wrapper.findByTestId('pipeline-schedule-status-text'); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays pipeline status', () => { + createComponent(); + + expect(findCIBadge().exists()).toBe(true); + expect(findCIBadge().props('status')).toBe(defaultProps.schedule.lastPipeline.detailedStatus); + expect(findStatusText().exists()).toBe(false); + }); + + it('displays "none" status text', () => { + createComponent({ schedule: mockPipelineScheduleNodes[0] }); + + expect(findStatusText().text()).toBe('None'); + expect(findCIBadge().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_next_run_spec.js b/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_next_run_spec.js new file mode 100644 index 00000000000..b1bdc1e91a0 --- /dev/null +++ b/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_next_run_spec.js @@ -0,0 +1,43 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PipelineScheduleNextRun from '~/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { mockPipelineScheduleNodes } from '../../../mock_data'; + +describe('Pipeline schedule next run', () => { + let wrapper; + + const defaultProps = { + schedule: mockPipelineScheduleNodes[0], + }; + + const createComponent = (props = defaultProps) => { + wrapper = shallowMountExtended(PipelineScheduleNextRun, { + propsData: { + ...props, + }, + }); + }; + + const findTimeAgo = () => wrapper.findComponent(TimeAgoTooltip); + const findInactive = () => wrapper.findByTestId('pipeline-schedule-inactive'); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays time ago', () => { + createComponent(); + + expect(findTimeAgo().exists()).toBe(true); + expect(findInactive().exists()).toBe(false); + expect(findTimeAgo().props('time')).toBe(defaultProps.schedule.realNextRun); + }); + + it('displays inactive state', () => { + const inactiveSchedule = mockPipelineScheduleNodes[1]; + createComponent({ schedule: inactiveSchedule }); + + expect(findInactive().text()).toBe('Inactive'); + expect(findTimeAgo().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_owner_spec.js b/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_owner_spec.js new file mode 100644 index 00000000000..3ab04958f5e --- /dev/null +++ b/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_owner_spec.js @@ -0,0 +1,40 @@ +import { GlAvatar, GlAvatarLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import PipelineScheduleOwner from '~/pipeline_schedules/components/table/cells/pipeline_schedule_owner.vue'; +import { mockPipelineScheduleNodes } from '../../../mock_data'; + +describe('Pipeline schedule owner', () => { + let wrapper; + + const defaultProps = { + schedule: mockPipelineScheduleNodes[0], + }; + + const createComponent = (props = defaultProps) => { + wrapper = shallowMount(PipelineScheduleOwner, { + propsData: { + ...props, + }, + }); + }; + + const findAvatar = () => wrapper.findComponent(GlAvatar); + const findAvatarLink = () => wrapper.findComponent(GlAvatarLink); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays avatar', () => { + expect(findAvatar().exists()).toBe(true); + expect(findAvatar().props('src')).toBe(defaultProps.schedule.owner.avatarUrl); + }); + + it('avatar links to user', () => { + expect(findAvatarLink().attributes('href')).toBe(defaultProps.schedule.owner.webPath); + }); +}); diff --git a/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_target_spec.js b/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_target_spec.js new file mode 100644 index 00000000000..6817e58790b --- /dev/null +++ b/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_target_spec.js @@ -0,0 +1,41 @@ +import { GlIcon, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import PipelineScheduleTarget from '~/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue'; +import { mockPipelineScheduleNodes } from '../../../mock_data'; + +describe('Pipeline schedule target', () => { + let wrapper; + + const defaultProps = { + schedule: mockPipelineScheduleNodes[0], + }; + + const createComponent = (props = defaultProps) => { + wrapper = shallowMount(PipelineScheduleTarget, { + propsData: { + ...props, + }, + }); + }; + + const findIcon = () => wrapper.findComponent(GlIcon); + const findLink = () => wrapper.findComponent(GlLink); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays icon', () => { + expect(findIcon().exists()).toBe(true); + expect(findIcon().props('name')).toBe('fork'); + }); + + it('displays ref link', () => { + expect(findLink().attributes('href')).toBe(defaultProps.schedule.refPath); + expect(findLink().text()).toBe(defaultProps.schedule.refForDisplay); + }); +}); diff --git a/spec/frontend/pipeline_schedules/components/table/pipeline_schedules_table_spec.js b/spec/frontend/pipeline_schedules/components/table/pipeline_schedules_table_spec.js new file mode 100644 index 00000000000..914897946ee --- /dev/null +++ b/spec/frontend/pipeline_schedules/components/table/pipeline_schedules_table_spec.js @@ -0,0 +1,39 @@ +import { GlTableLite } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import PipelineSchedulesTable from '~/pipeline_schedules/components/table/pipeline_schedules_table.vue'; +import { mockPipelineScheduleNodes } from '../../mock_data'; + +describe('Pipeline schedules table', () => { + let wrapper; + + const defaultProps = { + schedules: mockPipelineScheduleNodes, + }; + + const createComponent = (props = defaultProps) => { + wrapper = mountExtended(PipelineSchedulesTable, { + propsData: { + ...props, + }, + }); + }; + + const findTable = () => wrapper.findComponent(GlTableLite); + const findScheduleDescription = () => wrapper.findByTestId('pipeline-schedule-description'); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays table', () => { + expect(findTable().exists()).toBe(true); + }); + + it('displays schedule description', () => { + expect(findScheduleDescription().text()).toBe('pipeline schedule'); + }); +}); diff --git a/spec/frontend/pipeline_schedules/mock_data.js b/spec/frontend/pipeline_schedules/mock_data.js new file mode 100644 index 00000000000..0a60998d8fb --- /dev/null +++ b/spec/frontend/pipeline_schedules/mock_data.js @@ -0,0 +1,35 @@ +// Fixture located at spec/frontend/fixtures/pipeline_schedules.rb +import mockGetPipelineSchedulesGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.json'; +import mockGetPipelineSchedulesAsGuestGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.as_guest.json'; + +const { + data: { + project: { + pipelineSchedules: { nodes }, + }, + }, +} = mockGetPipelineSchedulesGraphQLResponse; + +const { + data: { + project: { + pipelineSchedules: { nodes: guestNodes }, + }, + }, +} = mockGetPipelineSchedulesAsGuestGraphQLResponse; + +export const mockPipelineScheduleNodes = nodes; + +export const mockPipelineScheduleAsGuestNodes = guestNodes; + +export const deleteMutationResponse = { + data: { + pipelineScheduleDelete: { + clientMutationId: null, + errors: [], + __typename: 'PipelineScheduleDeletePayload', + }, + }, +}; + +export { mockGetPipelineSchedulesGraphQLResponse }; diff --git a/spec/frontend/pipeline_wizard/components/commit_spec.js b/spec/frontend/pipeline_wizard/components/commit_spec.js index d7e019c642e..fa30b9c2b97 100644 --- a/spec/frontend/pipeline_wizard/components/commit_spec.js +++ b/spec/frontend/pipeline_wizard/components/commit_spec.js @@ -211,7 +211,7 @@ describe('Pipeline Wizard - Commit Page', () => { }) => { let consoleSpy; - beforeAll(async () => { + beforeEach(async () => { createComponent( { filename, @@ -246,7 +246,7 @@ describe('Pipeline Wizard - Commit Page', () => { await waitForPromises(); }); - afterAll(() => { + afterEach(() => { wrapper.destroy(); }); diff --git a/spec/frontend/pipeline_wizard/components/editor_spec.js b/spec/frontend/pipeline_wizard/components/editor_spec.js index 26e4b8eb0ea..dd0a609043a 100644 --- a/spec/frontend/pipeline_wizard/components/editor_spec.js +++ b/spec/frontend/pipeline_wizard/components/editor_spec.js @@ -3,12 +3,20 @@ import { Document } from 'yaml'; import YamlEditor from '~/pipeline_wizard/components/editor.vue'; describe('Pages Yaml Editor wrapper', () => { + let wrapper; + const defaultOptions = { propsData: { doc: new Document({ foo: 'bar' }), filename: 'foo.yml' }, }; + afterEach(() => { + wrapper.destroy(); + }); + describe('mount hook', () => { - const wrapper = mount(YamlEditor, defaultOptions); + beforeEach(() => { + wrapper = mount(YamlEditor, defaultOptions); + }); it('editor is mounted', () => { expect(wrapper.vm.editor).not.toBeUndefined(); @@ -19,16 +27,11 @@ describe('Pages Yaml Editor wrapper', () => { describe('watchers', () => { describe('doc', () => { const doc = new Document({ baz: ['bar'] }); - let wrapper; beforeEach(() => { wrapper = mount(YamlEditor, defaultOptions); }); - afterEach(() => { - wrapper.destroy(); - }); - it("causes the editor's value to be set to the stringified document", async () => { await wrapper.setProps({ doc }); expect(wrapper.vm.editor.getValue()).toEqual(doc.toString()); @@ -48,7 +51,10 @@ describe('Pages Yaml Editor wrapper', () => { describe('highlight', () => { const highlight = 'foo'; - const wrapper = mount(YamlEditor, defaultOptions); + + beforeEach(() => { + wrapper = mount(YamlEditor, defaultOptions); + }); it('calls editor.highlight(path, keep=true)', async () => { const highlightSpy = jest.spyOn(wrapper.vm.yamlEditorExtension.obj, 'highlight'); diff --git a/spec/frontend/pipeline_wizard/components/widgets/list_spec.js b/spec/frontend/pipeline_wizard/components/widgets/list_spec.js index 796356634bc..c9e9f5caebe 100644 --- a/spec/frontend/pipeline_wizard/components/widgets/list_spec.js +++ b/spec/frontend/pipeline_wizard/components/widgets/list_spec.js @@ -22,6 +22,9 @@ describe('Pipeline Wizard - List Widget', () => { const setValueOnInputField = (value, atIndex = 0) => { return findGlFormInputGroupByIndex(atIndex).vm.$emit('input', value); }; + const getValueOfInputField = (atIndex = 0) => { + return findGlFormInputGroupByIndex(atIndex).get('input').element.value; + }; const findAddStepButton = () => wrapper.findByTestId('add-step-button'); const addStep = () => findAddStepButton().vm.$emit('click'); @@ -103,6 +106,24 @@ describe('Pipeline Wizard - List Widget', () => { expect(addStepBtn.text()).toBe('add another step'); }); + it('deletes the correct input item', async () => { + createComponent({}, mountExtended); + + await addStep(); + await addStep(); + setValueOnInputField('foo', 0); + setValueOnInputField('bar', 1); + setValueOnInputField('baz', 2); + + const button = findAllGlFormInputGroups().at(1).find('[data-testid="remove-step-button"]'); + + button.vm.$emit('click'); + await nextTick(); + + expect(getValueOfInputField(0)).toBe('foo'); + expect(getValueOfInputField(1)).toBe('baz'); + }); + it('the "add step" button increases the number of input fields', async () => { createComponent(); diff --git a/spec/frontend/pipeline_wizard/components/wrapper_spec.js b/spec/frontend/pipeline_wizard/components/wrapper_spec.js index f064bf01c86..d5b78cebcb3 100644 --- a/spec/frontend/pipeline_wizard/components/wrapper_spec.js +++ b/spec/frontend/pipeline_wizard/components/wrapper_spec.js @@ -132,7 +132,7 @@ describe('Pipeline Wizard - wrapper.vue', () => { expectStepDef, expectProgressBarValue, }) => { - beforeAll(async () => { + beforeEach(async () => { createComponent(); for (const emittedValue of navigationEventChain) { @@ -145,7 +145,7 @@ describe('Pipeline Wizard - wrapper.vue', () => { } }); - afterAll(() => { + afterEach(() => { wrapper.destroy(); }); @@ -184,11 +184,11 @@ describe('Pipeline Wizard - wrapper.vue', () => { }); describe('editor overlay', () => { - beforeAll(() => { + beforeEach(() => { createComponent(); }); - afterAll(() => { + afterEach(() => { wrapper.destroy(); }); @@ -236,11 +236,11 @@ describe('Pipeline Wizard - wrapper.vue', () => { }); describe('line highlights', () => { - beforeAll(() => { + beforeEach(() => { createComponent(); }); - afterAll(() => { + afterEach(() => { wrapper.destroy(); }); @@ -266,7 +266,7 @@ describe('Pipeline Wizard - wrapper.vue', () => { }); describe('integration test', () => { - beforeAll(async () => { + beforeEach(async () => { createComponent({}, mountExtended); }); @@ -290,14 +290,25 @@ describe('Pipeline Wizard - wrapper.vue', () => { describe('navigating back', () => { let inputField; - beforeAll(async () => { + beforeEach(async () => { + createComponent({}, mountExtended); + + findFirstInputFieldForTarget('$FOO').setValue('fooVal'); + await nextTick(); + + findFirstVisibleStep().vm.$emit('next'); + await nextTick(); + + findFirstInputFieldForTarget('$BAR').setValue('barVal'); + await nextTick(); + findFirstVisibleStep().vm.$emit('back'); await nextTick(); inputField = findFirstInputFieldForTarget('$FOO'); }); - afterAll(() => { + afterEach(() => { wrapper.destroy(); inputField = undefined; }); diff --git a/spec/frontend/pipeline_wizard/mock/yaml.js b/spec/frontend/pipeline_wizard/mock/yaml.js index 12b6f1052b2..014a32c5700 100644 --- a/spec/frontend/pipeline_wizard/mock/yaml.js +++ b/spec/frontend/pipeline_wizard/mock/yaml.js @@ -62,8 +62,7 @@ export const steps = ` export const compiledScenario1 = `foo: fooVal `; -export const compiledScenario2 = `foo: fooVal -bar: barVal +export const compiledScenario2 = `bar: barVal `; export const compiledScenario3 = `foo: newFooVal diff --git a/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js b/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js index bfbb5f934b9..d1da7cb3acf 100644 --- a/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js +++ b/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js @@ -4,7 +4,7 @@ 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 { createAlert } from '~/flash'; import FailedJobsApp from '~/pipelines/components/jobs/failed_jobs_app.vue'; import FailedJobsTable from '~/pipelines/components/jobs/failed_jobs_table.vue'; import GetFailedJobsQuery from '~/pipelines/graphql/queries/get_failed_jobs.query.graphql'; @@ -70,7 +70,7 @@ describe('Failed Jobs App', () => { await waitForPromises(); expect(findJobsTable().exists()).toBe(true); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); it('handles query fetch error correctly', async () => { @@ -80,7 +80,7 @@ describe('Failed Jobs App', () => { await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching the failed jobs.', }); }); diff --git a/spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js b/spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js index b597a3bf4b0..0df15afd70d 100644 --- a/spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js +++ b/spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js @@ -4,7 +4,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { redirectTo } from '~/lib/utils/url_utility'; import FailedJobsTable from '~/pipelines/components/jobs/failed_jobs_table.vue'; import RetryFailedJobMutation from '~/pipelines/graphql/mutations/retry_failed_job.mutation.graphql'; @@ -88,7 +88,7 @@ describe('Failed Jobs Table', () => { await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem retrying the failed job.', }); }); diff --git a/spec/frontend/pipelines/components/jobs/jobs_app_spec.js b/spec/frontend/pipelines/components/jobs/jobs_app_spec.js index 89b6f764b2f..9bc14266593 100644 --- a/spec/frontend/pipelines/components/jobs/jobs_app_spec.js +++ b/spec/frontend/pipelines/components/jobs/jobs_app_spec.js @@ -4,7 +4,7 @@ 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 { createAlert } from '~/flash'; import JobsApp from '~/pipelines/components/jobs/jobs_app.vue'; import JobsTable from '~/jobs/components/table/jobs_table.vue'; import getPipelineJobsQuery from '~/pipelines/graphql/queries/get_pipeline_jobs.query.graphql'; @@ -88,7 +88,7 @@ describe('Jobs app', () => { expect(findJobsTable().exists()).toBe(true); expect(findSkeletonLoader().exists()).toBe(false); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); it('handles job fetch error correctly', async () => { @@ -98,7 +98,7 @@ describe('Jobs app', () => { await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'An error occurred while fetching the pipelines jobs.', }); }); diff --git a/spec/frontend/pipelines/pipeline_multi_actions_spec.js b/spec/frontend/pipelines/pipeline_multi_actions_spec.js index 149b40330e2..f0dae8ebcbe 100644 --- a/spec/frontend/pipelines/pipeline_multi_actions_spec.js +++ b/spec/frontend/pipelines/pipeline_multi_actions_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlDropdown, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; +import { GlAlert, GlDropdown, GlSprintf, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; @@ -46,6 +46,7 @@ describe('Pipeline Multi Actions Dropdown', () => { }, stubs: { GlSprintf, + GlDropdown, }, }), ); @@ -56,6 +57,7 @@ describe('Pipeline Multi Actions Dropdown', () => { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAllArtifactItems = () => wrapper.findAllByTestId(artifactItemTestId); const findFirstArtifactItem = () => wrapper.findByTestId(artifactItemTestId); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); const findEmptyMessage = () => wrapper.findByTestId('artifacts-empty-message'); beforeEach(() => { @@ -75,7 +77,7 @@ describe('Pipeline Multi Actions Dropdown', () => { }); describe('Artifacts', () => { - it('should fetch artifacts on dropdown click', async () => { + it('should fetch artifacts and show search box on dropdown click', async () => { const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId); mockAxios.onGet(endpoint).replyOnce(200, { artifacts }); createComponent(); @@ -84,6 +86,16 @@ describe('Pipeline Multi Actions Dropdown', () => { expect(mockAxios.history.get).toHaveLength(1); expect(wrapper.vm.artifacts).toEqual(artifacts); + expect(findSearchBox().exists()).toBe(true); + }); + + it('should focus the search box when opened with artifacts', () => { + createComponent({ mockData: { artifacts } }); + wrapper.vm.$refs.searchInput.focusInput = jest.fn(); + + findDropdown().vm.$emit('shown'); + + expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled(); }); it('should render all the provided artifacts when search query is empty', () => { @@ -109,10 +121,11 @@ describe('Pipeline Multi Actions Dropdown', () => { expect(findFirstArtifactItem().text()).toBe(artifacts[0].name); }); - it('should render empty message when no artifacts are found', () => { + it('should render empty message and no search box when no artifacts are found', () => { createComponent({ mockData: { artifacts: [] } }); expect(findEmptyMessage().exists()).toBe(true); + expect(findSearchBox().exists()).toBe(false); }); describe('while loading artifacts', () => { diff --git a/spec/frontend/pipelines/pipelines_actions_spec.js b/spec/frontend/pipelines/pipelines_actions_spec.js index fdfced38dca..26e61efc4f6 100644 --- a/spec/frontend/pipelines/pipelines_actions_spec.js +++ b/spec/frontend/pipelines/pipelines_actions_spec.js @@ -5,7 +5,7 @@ import { nextTick } from 'vue'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'spec/test_constants'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import PipelinesManualActions from '~/pipelines/components/pipelines_list/pipelines_manual_actions.vue'; @@ -95,7 +95,7 @@ describe('Pipelines Actions dropdown', () => { await waitForPromises(); expect(findDropdown().props('loading')).toBe(false); - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); }); }); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index cc2ff90de57..a3f15e25f36 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -11,7 +11,7 @@ import { mockTracking } from 'helpers/tracking_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert, VARIANT_WARNING } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import NavigationControls from '~/pipelines/components/pipelines_list/nav_controls.vue'; import PipelinesComponent from '~/pipelines/components/pipelines_list/pipelines.vue'; @@ -261,9 +261,14 @@ describe('Pipelines', () => { ); }); - it('tracks tab change click', () => { + it.each(['all', 'finished', 'branches', 'tags'])('tracks %p tab click', async (scope) => { + goToTab(scope); + + await waitForPromises(); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_filter_tabs', { label: TRACKING_CATEGORIES.tabs, + property: scope, }); }); }); @@ -356,8 +361,11 @@ describe('Pipelines', () => { }); it('displays a warning message if raw text search is used', () => { - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ message: RAW_TEXT_WARNING, type: 'warning' }); + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ + message: RAW_TEXT_WARNING, + variant: VARIANT_WARNING, + }); }); it('should update browser bar', () => { diff --git a/spec/frontend/pipelines/test_reports/stores/actions_spec.js b/spec/frontend/pipelines/test_reports/stores/actions_spec.js index 74a9d8c354f..6e61ef97257 100644 --- a/spec/frontend/pipelines/test_reports/stores/actions_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/actions_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import testReports from 'test_fixtures/pipelines/test_report.json'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as actions from '~/pipelines/stores/test_reports/actions'; import * as types from '~/pipelines/stores/test_reports/mutation_types'; @@ -56,7 +56,7 @@ describe('Actions TestReports Store', () => { [], [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], ); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js index f9b9da01a2b..ed0cc71eb97 100644 --- a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js @@ -1,7 +1,7 @@ import testReports from 'test_fixtures/pipelines/test_report.json'; import * as types from '~/pipelines/stores/test_reports/mutation_types'; import mutations from '~/pipelines/stores/test_reports/mutations'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; jest.mock('~/flash.js'); @@ -61,7 +61,7 @@ describe('Mutations TestReports Store', () => { it('should show a flash message otherwise', () => { mutations[types.SET_SUITE_ERROR](mockState, {}); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/profile/account/components/update_username_spec.js b/spec/frontend/profile/account/components/update_username_spec.js index e331eed1863..575df9fb3c0 100644 --- a/spec/frontend/profile/account/components/update_username_spec.js +++ b/spec/frontend/profile/account/components/update_username_spec.js @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import { TEST_HOST } from 'helpers/test_constants'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import UpdateUsername from '~/profile/account/components/update_username.vue'; @@ -149,7 +149,7 @@ describe('UpdateUsername component', () => { await expect(wrapper.vm.onConfirm()).rejects.toThrow(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'Invalid username', }); }); @@ -161,7 +161,7 @@ describe('UpdateUsername component', () => { await expect(wrapper.vm.onConfirm()).rejects.toThrow(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'An error occurred while updating your username, please try again.', }); }); diff --git a/spec/frontend/profile/preferences/components/profile_preferences_spec.js b/spec/frontend/profile/preferences/components/profile_preferences_spec.js index 89ce838a383..91cd868daac 100644 --- a/spec/frontend/profile/preferences/components/profile_preferences_spec.js +++ b/spec/frontend/profile/preferences/components/profile_preferences_spec.js @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import createFlash from '~/flash'; +import { createAlert, VARIANT_DANGER, VARIANT_INFO } from '~/flash'; import IntegrationView from '~/profile/preferences/components/integration_view.vue'; import ProfilePreferences from '~/profile/preferences/components/profile_preferences.vue'; import { i18n } from '~/profile/preferences/constants'; @@ -149,7 +149,10 @@ describe('ProfilePreferences component', () => { const successEvent = new CustomEvent('ajax:success'); form.dispatchEvent(successEvent); - expect(createFlash).toHaveBeenCalledWith({ message: i18n.defaultSuccess, type: 'notice' }); + expect(createAlert).toHaveBeenCalledWith({ + message: i18n.defaultSuccess, + variant: VARIANT_INFO, + }); }); it('displays the custom success message', () => { @@ -157,14 +160,17 @@ describe('ProfilePreferences component', () => { const successEvent = new CustomEvent('ajax:success', { detail: [{ message }] }); form.dispatchEvent(successEvent); - expect(createFlash).toHaveBeenCalledWith({ message, type: 'notice' }); + expect(createAlert).toHaveBeenCalledWith({ message, variant: VARIANT_INFO }); }); it('displays the default error message', () => { const errorEvent = new CustomEvent('ajax:error'); form.dispatchEvent(errorEvent); - expect(createFlash).toHaveBeenCalledWith({ message: i18n.defaultError, type: 'alert' }); + expect(createAlert).toHaveBeenCalledWith({ + message: i18n.defaultError, + variant: VARIANT_DANGER, + }); }); it('displays the custom error message', () => { @@ -172,7 +178,7 @@ describe('ProfilePreferences component', () => { const errorEvent = new CustomEvent('ajax:error', { detail: [{ message }] }); form.dispatchEvent(errorEvent); - expect(createFlash).toHaveBeenCalledWith({ message, type: 'alert' }); + expect(createAlert).toHaveBeenCalledWith({ message, variant: VARIANT_DANGER }); }); }); diff --git a/spec/frontend/projects/commit/store/actions_spec.js b/spec/frontend/projects/commit/store/actions_spec.js index 56dffcbd48e..008710984b9 100644 --- a/spec/frontend/projects/commit/store/actions_spec.js +++ b/spec/frontend/projects/commit/store/actions_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { PROJECT_BRANCHES_ERROR } from '~/projects/commit/constants'; import * as actions from '~/projects/commit/store/actions'; @@ -68,7 +68,7 @@ describe('Commit form modal store actions', () => { await testAction(actions.fetchBranches, {}, state, [], [{ type: 'requestBranches' }]); - expect(createFlash).toHaveBeenCalledWith({ message: PROJECT_BRANCHES_ERROR }); + expect(createAlert).toHaveBeenCalledWith({ message: PROJECT_BRANCHES_ERROR }); }); }); diff --git a/spec/frontend/projects/commits/store/actions_spec.js b/spec/frontend/projects/commits/store/actions_spec.js index fdb12640b26..930b801af71 100644 --- a/spec/frontend/projects/commits/store/actions_spec.js +++ b/spec/frontend/projects/commits/store/actions_spec.js @@ -1,7 +1,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import actions from '~/projects/commits/store/actions'; import * as types from '~/projects/commits/store/mutation_types'; import createState from '~/projects/commits/store/state'; @@ -38,8 +38,8 @@ describe('Project commits actions', () => { const mockDispatchContext = { dispatch: () => {}, commit: () => {}, state }; actions.receiveAuthorsError(mockDispatchContext); - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ message: 'An error occurred fetching the project authors.', }); }); diff --git a/spec/frontend/projects/compare/components/app_spec.js b/spec/frontend/projects/compare/components/app_spec.js index 2dbecf7cc61..9b052a17caa 100644 --- a/spec/frontend/projects/compare/components/app_spec.js +++ b/spec/frontend/projects/compare/components/app_spec.js @@ -134,6 +134,40 @@ describe('CompareApp component', () => { }); }); + describe('mode dropdown', () => { + const findModeDropdownButton = () => wrapper.find('[data-testid="modeDropdown"]'); + const findEnableStraightModeButton = () => + wrapper.find('[data-testid="enableStraightModeButton"]'); + const findDisableStraightModeButton = () => + wrapper.find('[data-testid="disableStraightModeButton"]'); + + it('renders the mode dropdown button', () => { + expect(findModeDropdownButton().exists()).toBe(true); + }); + + it('has the correct text', () => { + expect(findEnableStraightModeButton().text()).toBe('...'); + expect(findDisableStraightModeButton().text()).toBe('..'); + }); + + it('straight mode button when clicked', async () => { + expect(wrapper.props('straight')).toBe(false); + expect(wrapper.find('input[name="straight"]').attributes('value')).toBe('false'); + + findEnableStraightModeButton().vm.$emit('click'); + + await nextTick(); + + expect(wrapper.find('input[name="straight"]').attributes('value')).toBe('true'); + + findDisableStraightModeButton().vm.$emit('click'); + + await nextTick(); + + expect(wrapper.find('input[name="straight"]').attributes('value')).toBe('false'); + }); + }); + describe('merge request buttons', () => { const findProjectMrButton = () => wrapper.find('[data-testid="projectMrButton"]'); const findCreateMrButton = () => wrapper.find('[data-testid="createMrButton"]'); diff --git a/spec/frontend/projects/compare/components/mock_data.js b/spec/frontend/projects/compare/components/mock_data.js index 81d64469a2a..28d9a394038 100644 --- a/spec/frontend/projects/compare/components/mock_data.js +++ b/spec/frontend/projects/compare/components/mock_data.js @@ -17,6 +17,7 @@ export const appDefaultProps = { projects: [sourceProject], paramsFrom: 'main', paramsTo: 'target/branch', + straight: false, createMrPath: '', sourceProjectRefsPath, targetProjectRefsPath, diff --git a/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js index f64af1aa994..c21c0f4f9d1 100644 --- a/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js +++ b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js @@ -2,7 +2,7 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import RevisionDropdown from '~/projects/compare/components/revision_dropdown_legacy.vue'; @@ -79,7 +79,7 @@ describe('RevisionDropdown component', () => { axiosMock.onGet('some/invalid/path').replyOnce(404); await wrapper.vm.fetchBranchesAndTags(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); describe('GlDropdown component', () => { diff --git a/spec/frontend/projects/compare/components/revision_dropdown_spec.js b/spec/frontend/projects/compare/components/revision_dropdown_spec.js index 35e32fd3da0..d598bafea92 100644 --- a/spec/frontend/projects/compare/components/revision_dropdown_spec.js +++ b/spec/frontend/projects/compare/components/revision_dropdown_spec.js @@ -2,7 +2,7 @@ import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import RevisionDropdown from '~/projects/compare/components/revision_dropdown.vue'; import { revisionDropdownDefaultProps as defaultProps } from './mock_data'; @@ -67,7 +67,7 @@ describe('RevisionDropdown component', () => { createComponent(); await wrapper.vm.fetchBranchesAndTags(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); it('makes a new request when refsProjectPath is changed', async () => { @@ -93,7 +93,7 @@ describe('RevisionDropdown component', () => { createComponent(); await wrapper.vm.searchBranchesAndTags(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); it('makes request with search param', async () => { diff --git a/spec/frontend/projects/settings/branch_rules/branch_dropdown_spec.js b/spec/frontend/projects/settings/branch_rules/components/edit/branch_dropdown_spec.js index 79bce5a4b3f..11f219c1f90 100644 --- a/spec/frontend/projects/settings/branch_rules/branch_dropdown_spec.js +++ b/spec/frontend/projects/settings/branch_rules/components/edit/branch_dropdown_spec.js @@ -4,7 +4,7 @@ import { GlDropdown, GlSearchBoxByType, GlDropdownItem, GlSprintf } from '@gitla import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import BranchDropdown, { i18n, -} from '~/projects/settings/branch_rules/components/branch_dropdown.vue'; +} from '~/projects/settings/branch_rules/components/edit/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'; diff --git a/spec/frontend/projects/settings/branch_rules/rule_edit_spec.js b/spec/frontend/projects/settings/branch_rules/components/edit/index_spec.js index b0b2b9191d4..21e63fdb24d 100644 --- a/spec/frontend/projects/settings/branch_rules/rule_edit_spec.js +++ b/spec/frontend/projects/settings/branch_rules/components/edit/index_spec.js @@ -1,9 +1,9 @@ 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'; -import Protections from '~/projects/settings/branch_rules/components/protections/index.vue'; +import RuleEdit from '~/projects/settings/branch_rules/components/edit/index.vue'; +import BranchDropdown from '~/projects/settings/branch_rules/components/edit/branch_dropdown.vue'; +import Protections from '~/projects/settings/branch_rules/components/edit/protections/index.vue'; jest.mock('~/lib/utils/url_utility', () => ({ getParameterByName: jest.fn().mockImplementation(() => 'main'), diff --git a/spec/frontend/projects/settings/branch_rules/components/protections/index_spec.js b/spec/frontend/projects/settings/branch_rules/components/edit/protections/index_spec.js index 3592fa50622..ee90ff8318f 100644 --- a/spec/frontend/projects/settings/branch_rules/components/protections/index_spec.js +++ b/spec/frontend/projects/settings/branch_rules/components/edit/protections/index_spec.js @@ -3,10 +3,10 @@ import { GlLink } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import Protections, { i18n, -} from '~/projects/settings/branch_rules/components/protections/index.vue'; -import PushProtections from '~/projects/settings/branch_rules/components/protections/push_protections.vue'; -import MergeProtections from '~/projects/settings/branch_rules/components/protections/merge_protections.vue'; -import { protections } from '../../mock_data'; +} from '~/projects/settings/branch_rules/components/edit/protections/index.vue'; +import PushProtections from '~/projects/settings/branch_rules/components/edit/protections/push_protections.vue'; +import MergeProtections from '~/projects/settings/branch_rules/components/edit/protections/merge_protections.vue'; +import { protections } from '../../../mock_data'; describe('Branch Protections', () => { let wrapper; diff --git a/spec/frontend/projects/settings/branch_rules/components/protections/merge_protections_spec.js b/spec/frontend/projects/settings/branch_rules/components/edit/protections/merge_protections_spec.js index 0e168a2ad78..b5fdc46d600 100644 --- a/spec/frontend/projects/settings/branch_rules/components/protections/merge_protections_spec.js +++ b/spec/frontend/projects/settings/branch_rules/components/edit/protections/merge_protections_spec.js @@ -2,8 +2,8 @@ import { GlFormGroup, GlFormCheckbox } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import MergeProtections, { i18n, -} from '~/projects/settings/branch_rules/components/protections/merge_protections.vue'; -import { membersAllowedToMerge, requireCodeOwnersApproval } from '../../mock_data'; +} from '~/projects/settings/branch_rules/components/edit/protections/merge_protections.vue'; +import { membersAllowedToMerge, requireCodeOwnersApproval } from '../../../mock_data'; describe('Merge Protections', () => { let wrapper; diff --git a/spec/frontend/projects/settings/branch_rules/components/protections/push_protections_spec.js b/spec/frontend/projects/settings/branch_rules/components/edit/protections/push_protections_spec.js index d54dad08338..60bb7a51dcb 100644 --- a/spec/frontend/projects/settings/branch_rules/components/protections/push_protections_spec.js +++ b/spec/frontend/projects/settings/branch_rules/components/edit/protections/push_protections_spec.js @@ -2,8 +2,8 @@ import { GlFormGroup, GlSprintf, GlFormCheckbox } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import PushProtections, { i18n, -} from '~/projects/settings/branch_rules/components/protections/push_protections.vue'; -import { membersAllowedToPush, allowForcePush } from '../../mock_data'; +} from '~/projects/settings/branch_rules/components/edit/protections/push_protections.vue'; +import { membersAllowedToPush, allowForcePush } from '../../../mock_data'; describe('Push Protections', () => { let wrapper; diff --git a/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js b/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js new file mode 100644 index 00000000000..bf4026b65db --- /dev/null +++ b/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js @@ -0,0 +1,113 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import * as util from '~/lib/utils/url_utility'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import RuleView from '~/projects/settings/branch_rules/components/view/index.vue'; +import { + I18N, + ALL_BRANCHES_WILDCARD, +} from '~/projects/settings/branch_rules/components/view/constants'; +import Protection from '~/projects/settings/branch_rules/components/view/protection.vue'; +import branchRulesQuery from '~/projects/settings/branch_rules/queries/branch_rules_details.query.graphql'; +import { sprintf } from '~/locale'; +import { branchProtectionsMockResponse } from './mock_data'; + +jest.mock('~/lib/utils/url_utility', () => ({ + getParameterByName: jest.fn().mockReturnValue('main'), + joinPaths: jest.fn(), +})); + +Vue.use(VueApollo); + +const protectionMockProps = { + headerLinkHref: 'protected/branches', + headerLinkTitle: 'Manage in Protected Branches', + roles: [{ accessLevelDescription: 'Maintainers' }], + users: [{ avatarUrl: 'test.com/user.png', name: 'peter', webUrl: 'test.com' }], +}; + +describe('View branch rules', () => { + let wrapper; + let fakeApollo; + const projectPath = 'test/testing'; + const protectedBranchesPath = 'protected/branches'; + const approvalRulesPath = 'approval/rules'; + const branchProtectionsMockRequestHandler = jest + .fn() + .mockResolvedValue(branchProtectionsMockResponse); + + const createComponent = async () => { + fakeApollo = createMockApollo([[branchRulesQuery, branchProtectionsMockRequestHandler]]); + + wrapper = shallowMountExtended(RuleView, { + apolloProvider: fakeApollo, + provide: { projectPath, protectedBranchesPath, approvalRulesPath }, + }); + + await waitForPromises(); + }; + + beforeEach(() => createComponent()); + + afterEach(() => wrapper.destroy()); + + const findBranchName = () => wrapper.findByTestId('branch'); + const findBranchTitle = () => wrapper.findByTestId('branch-title'); + const findBranchProtectionTitle = () => wrapper.findByText(I18N.protectBranchTitle); + const findBranchProtections = () => wrapper.findAllComponents(Protection); + const findForcePushTitle = () => wrapper.findByText(I18N.allowForcePushDescription); + const findApprovalsTitle = () => wrapper.findByText(I18N.approvalsTitle); + + it('gets the branch param from url and renders it in the view', () => { + expect(util.getParameterByName).toHaveBeenCalledWith('branch'); + expect(findBranchName().text()).toBe('main'); + expect(findBranchTitle().text()).toBe(I18N.branchNameOrPattern); + }); + + it('renders the correct label if all branches are targeted', async () => { + jest.spyOn(util, 'getParameterByName').mockReturnValueOnce(ALL_BRANCHES_WILDCARD); + await createComponent(); + + expect(findBranchName().text()).toBe(I18N.allBranches); + expect(findBranchTitle().text()).toBe(I18N.targetBranch); + jest.restoreAllMocks(); + }); + + it('renders the correct branch title', () => { + expect(findBranchTitle().exists()).toBe(true); + }); + + it('renders a branch protection title', () => { + expect(findBranchProtectionTitle().exists()).toBe(true); + }); + + it('renders a branch protection component for push rules', () => { + expect(findBranchProtections().at(0).props()).toMatchObject({ + header: sprintf(I18N.allowedToPushHeader, { total: 2 }), + ...protectionMockProps, + }); + }); + + it('renders force push protection', () => { + expect(findForcePushTitle().exists()).toBe(true); + }); + + it('renders a branch protection component for merge rules', () => { + expect(findBranchProtections().at(1).props()).toMatchObject({ + header: sprintf(I18N.allowedToMergeHeader, { total: 2 }), + ...protectionMockProps, + }); + }); + + it('renders a branch protection component for approvals', () => { + expect(findApprovalsTitle().exists()).toBe(true); + + expect(findBranchProtections().at(2).props()).toMatchObject({ + header: sprintf(I18N.approvalsHeader, { total: 0 }), + headerLinkHref: approvalRulesPath, + headerLinkTitle: I18N.manageApprovalsLinkTitle, + }); + }); +}); diff --git a/spec/frontend/projects/settings/branch_rules/components/view/mock_data.js b/spec/frontend/projects/settings/branch_rules/components/view/mock_data.js new file mode 100644 index 00000000000..c3f573061da --- /dev/null +++ b/spec/frontend/projects/settings/branch_rules/components/view/mock_data.js @@ -0,0 +1,141 @@ +const usersMock = [ + { + username: 'usr1', + webUrl: 'http://test.test/usr1', + name: 'User 1', + avatarUrl: 'http://test.test/avt1.png', + }, + { + username: 'usr2', + webUrl: 'http://test.test/usr2', + name: 'User 2', + avatarUrl: 'http://test.test/avt2.png', + }, + { + username: 'usr3', + webUrl: 'http://test.test/usr3', + name: 'User 3', + avatarUrl: 'http://test.test/avt3.png', + }, + { + username: 'usr4', + webUrl: 'http://test.test/usr4', + name: 'User 4', + avatarUrl: 'http://test.test/avt4.png', + }, + { + username: 'usr5', + webUrl: 'http://test.test/usr5', + name: 'User 5', + avatarUrl: 'http://test.test/avt5.png', + }, +]; + +const accessLevelsMock = [ + { accessLevelDescription: 'Administrator' }, + { accessLevelDescription: 'Maintainer' }, +]; + +const approvalsRequired = 3; + +const groupsMock = [{ name: 'test_group_1' }, { name: 'test_group_2' }]; + +export const protectionPropsMock = { + header: 'Test protection', + headerLinkTitle: 'Test link title', + headerLinkHref: 'Test link href', + roles: accessLevelsMock, + users: usersMock, + groups: groupsMock, + approvals: [ + { + name: 'test', + eligibleApprovers: { nodes: usersMock }, + approvalsRequired, + }, + ], +}; + +export const protectionRowPropsMock = { + title: 'Test title', + users: usersMock, + accessLevels: accessLevelsMock, + approvalsRequired, +}; + +export const accessLevelsMockResponse = [ + { + __typename: 'PushAccessLevelEdge', + node: { + __typename: 'PushAccessLevel', + accessLevel: 40, + accessLevelDescription: 'Jona Langworth', + group: null, + user: { + __typename: 'UserCore', + id: '123', + webUrl: 'test.com', + name: 'peter', + avatarUrl: 'test.com/user.png', + }, + }, + }, + { + __typename: 'PushAccessLevelEdge', + node: { + __typename: 'PushAccessLevel', + accessLevel: 40, + accessLevelDescription: 'Maintainers', + group: null, + user: null, + }, + }, +]; + +export const branchProtectionsMockResponse = { + data: { + project: { + id: 'gid://gitlab/Project/6', + __typename: 'Project', + branchRules: { + __typename: 'BranchRuleConnection', + nodes: [ + { + __typename: 'BranchRule', + name: 'main', + branchProtection: { + __typename: 'BranchProtection', + allowForcePush: true, + codeOwnerApprovalRequired: true, + mergeAccessLevels: { + __typename: 'MergeAccessLevelConnection', + edges: accessLevelsMockResponse, + }, + pushAccessLevels: { + __typename: 'PushAccessLevelConnection', + edges: accessLevelsMockResponse, + }, + }, + }, + { + __typename: 'BranchRule', + name: '*', + branchProtection: { + __typename: 'BranchProtection', + allowForcePush: true, + codeOwnerApprovalRequired: true, + mergeAccessLevels: { + __typename: 'MergeAccessLevelConnection', + edges: [], + }, + pushAccessLevels: { + __typename: 'PushAccessLevelConnection', + edges: [], + }, + }, + }, + ], + }, + }, + }, +}; diff --git a/spec/frontend/projects/settings/branch_rules/components/view/protection_row_spec.js b/spec/frontend/projects/settings/branch_rules/components/view/protection_row_spec.js new file mode 100644 index 00000000000..b0a69bedd3e --- /dev/null +++ b/spec/frontend/projects/settings/branch_rules/components/view/protection_row_spec.js @@ -0,0 +1,71 @@ +import { GlAvatarsInline, GlAvatar, GlAvatarLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ProtectionRow, { + MAX_VISIBLE_AVATARS, + AVATAR_SIZE, +} from '~/projects/settings/branch_rules/components/view/protection_row.vue'; +import { protectionRowPropsMock } from './mock_data'; + +describe('Branch rule protection row', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(ProtectionRow, { + propsData: protectionRowPropsMock, + stubs: { GlAvatarsInline }, + }); + }; + + beforeEach(() => createComponent()); + + afterEach(() => wrapper.destroy()); + + const findTitle = () => wrapper.findByText(protectionRowPropsMock.title); + const findAvatarsInline = () => wrapper.findComponent(GlAvatarsInline); + const findAvatarLinks = () => wrapper.findAllComponents(GlAvatarLink); + const findAvatars = () => wrapper.findAllComponents(GlAvatar); + const findAccessLevels = () => wrapper.findAllByTestId('access-level'); + const findApprovalsRequired = () => + wrapper.findByText(`${protectionRowPropsMock.approvalsRequired} approvals required`); + + it('renders a title', () => { + expect(findTitle().exists()).toBe(true); + }); + + it('renders an avatars-inline component', () => { + expect(findAvatarsInline().props('avatars')).toMatchObject(protectionRowPropsMock.users); + expect(findAvatarsInline().props('badgeSrOnlyText')).toBe('1 additional user'); + }); + + it('renders avatar-link components', () => { + expect(findAvatarLinks().length).toBe(MAX_VISIBLE_AVATARS); + + expect(findAvatarLinks().at(1).attributes('href')).toBe(protectionRowPropsMock.users[1].webUrl); + expect(findAvatarLinks().at(1).attributes('title')).toBe(protectionRowPropsMock.users[1].name); + }); + + it('renders avatar components', () => { + expect(findAvatars().length).toBe(MAX_VISIBLE_AVATARS); + + expect(findAvatars().at(1).attributes('src')).toBe(protectionRowPropsMock.users[1].avatarUrl); + expect(findAvatars().at(1).attributes('label')).toBe(protectionRowPropsMock.users[1].name); + expect(findAvatars().at(1).props('size')).toBe(AVATAR_SIZE); + }); + + it('renders access level descriptions', () => { + expect(findAccessLevels().length).toBe(protectionRowPropsMock.accessLevels.length); + + expect(findAccessLevels().at(0).text()).toBe( + protectionRowPropsMock.accessLevels[0].accessLevelDescription, + ); + expect(findAccessLevels().at(1).text()).toContain(','); + + expect(findAccessLevels().at(1).text()).toContain( + protectionRowPropsMock.accessLevels[1].accessLevelDescription, + ); + }); + + it('renders the number of approvals required', () => { + expect(findApprovalsRequired().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/projects/settings/branch_rules/components/view/protection_spec.js b/spec/frontend/projects/settings/branch_rules/components/view/protection_spec.js new file mode 100644 index 00000000000..e2fbb4f5bbb --- /dev/null +++ b/spec/frontend/projects/settings/branch_rules/components/view/protection_spec.js @@ -0,0 +1,68 @@ +import { GlCard, GlLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Protection, { i18n } from '~/projects/settings/branch_rules/components/view/protection.vue'; +import ProtectionRow from '~/projects/settings/branch_rules/components/view/protection_row.vue'; +import { protectionPropsMock } from './mock_data'; + +describe('Branch rule protection', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(Protection, { + propsData: protectionPropsMock, + stubs: { GlCard }, + }); + }; + + beforeEach(() => createComponent()); + + afterEach(() => wrapper.destroy()); + + const findCard = () => wrapper.findComponent(GlCard); + const findHeader = () => wrapper.findByText(protectionPropsMock.header); + const findLink = () => wrapper.findComponent(GlLink); + const findProtectionRows = () => wrapper.findAllComponents(ProtectionRow); + + it('renders a card component', () => { + expect(findCard().exists()).toBe(true); + }); + + it('renders a header with a link', () => { + expect(findHeader().exists()).toBe(true); + expect(findLink().text()).toBe(protectionPropsMock.headerLinkTitle); + expect(findLink().attributes('href')).toBe(protectionPropsMock.headerLinkHref); + }); + + it('renders a protection row for roles', () => { + expect(findProtectionRows().at(0).props()).toMatchObject({ + accessLevels: protectionPropsMock.roles, + showDivider: false, + title: i18n.rolesTitle, + }); + }); + + it('renders a protection row for users', () => { + expect(findProtectionRows().at(1).props()).toMatchObject({ + users: protectionPropsMock.users, + showDivider: true, + title: i18n.usersTitle, + }); + }); + + it('renders a protection row for groups', () => { + expect(findProtectionRows().at(2).props()).toMatchObject({ + accessLevels: protectionPropsMock.groups, + showDivider: true, + title: i18n.groupsTitle, + }); + }); + + it('renders a protection row for approvals', () => { + const approval = protectionPropsMock.approvals[0]; + expect(findProtectionRows().at(3).props()).toMatchObject({ + title: approval.name, + users: approval.eligibleApprovers.nodes, + approvalsRequired: approval.approvalsRequired, + }); + }); +}); diff --git a/spec/frontend/projects/settings/components/default_branch_selector_spec.js b/spec/frontend/projects/settings/components/default_branch_selector_spec.js new file mode 100644 index 00000000000..94648d87524 --- /dev/null +++ b/spec/frontend/projects/settings/components/default_branch_selector_spec.js @@ -0,0 +1,46 @@ +import { shallowMount } from '@vue/test-utils'; +import DefaultBranchSelector from '~/projects/settings/components/default_branch_selector.vue'; +import RefSelector from '~/ref/components/ref_selector.vue'; +import { REF_TYPE_BRANCHES } from '~/ref/constants'; + +describe('projects/settings/components/default_branch_selector', () => { + const persistedDefaultBranch = 'main'; + const projectId = '123'; + let wrapper; + + const findRefSelector = () => wrapper.findComponent(RefSelector); + + const buildWrapper = () => { + wrapper = shallowMount(DefaultBranchSelector, { + propsData: { + persistedDefaultBranch, + projectId, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + beforeEach(() => { + buildWrapper(); + }); + + it('displays a RefSelector component', () => { + expect(findRefSelector().props()).toEqual({ + value: persistedDefaultBranch, + enabledRefTypes: [REF_TYPE_BRANCHES], + projectId, + state: true, + translations: { + dropdownHeader: expect.any(String), + searchPlaceholder: expect.any(String), + }, + useSymbolicRefNames: false, + name: 'project[default_branch]', + }); + + expect(findRefSelector().classes()).toContain('gl-w-full'); + }); +}); diff --git a/spec/frontend/projects/settings/components/transfer_project_form_spec.js b/spec/frontend/projects/settings/components/transfer_project_form_spec.js index bde7148078d..6e639f895a8 100644 --- a/spec/frontend/projects/settings/components/transfer_project_form_spec.js +++ b/spec/frontend/projects/settings/components/transfer_project_form_spec.js @@ -1,41 +1,65 @@ import Vue, { nextTick } from 'vue'; +import { GlAlert } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; -import searchNamespacesWhereUserCanTransferProjectsQueryResponsePage1 from 'test_fixtures/graphql/projects/settings/search_namespaces_where_user_can_transfer_projects_page_1.query.graphql.json'; -import searchNamespacesWhereUserCanTransferProjectsQueryResponsePage2 from 'test_fixtures/graphql/projects/settings/search_namespaces_where_user_can_transfer_projects_page_2.query.graphql.json'; -import { - groupNamespaces, - userNamespaces, -} from 'jest/vue_shared/components/namespace_select/mock_data'; +import currentUserNamespaceQueryResponse from 'test_fixtures/graphql/projects/settings/current_user_namespace.query.graphql.json'; +import transferLocationsResponsePage1 from 'test_fixtures/api/projects/transfer_locations_page_1.json'; +import transferLocationsResponsePage2 from 'test_fixtures/api/projects/transfer_locations_page_2.json'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import TransferProjectForm from '~/projects/settings/components/transfer_project_form.vue'; -import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue'; +import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select_deprecated.vue'; import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue'; -import searchNamespacesWhereUserCanTransferProjectsQuery from '~/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql'; +import currentUserNamespaceQuery from '~/projects/settings/graphql/queries/current_user_namespace.query.graphql'; +import { getTransferLocations } from '~/api/projects_api'; import waitForPromises from 'helpers/wait_for_promises'; +jest.mock('~/api/projects_api', () => ({ + getTransferLocations: jest.fn(), +})); + describe('Transfer project form', () => { let wrapper; + const projectId = '1'; const confirmButtonText = 'Confirm'; const confirmationPhrase = 'You must construct additional pylons!'; - const runDebounce = () => jest.runAllTimers(); - Vue.use(VueApollo); - const defaultQueryHandler = jest - .fn() - .mockResolvedValue(searchNamespacesWhereUserCanTransferProjectsQueryResponsePage1); + const defaultQueryHandler = jest.fn().mockResolvedValue(currentUserNamespaceQueryResponse); + const mockResolvedGetTransferLocations = ({ + data = transferLocationsResponsePage1, + page = '1', + nextPage = '2', + prevPage = null, + } = {}) => { + getTransferLocations.mockResolvedValueOnce({ + data, + headers: { + 'x-per-page': '2', + 'x-page': page, + 'x-total': '4', + 'x-total-pages': '2', + 'x-next-page': nextPage, + 'x-prev-page': prevPage, + }, + }); + }; + const mockRejectedGetTransferLocations = () => { + const error = new Error(); + + getTransferLocations.mockRejectedValueOnce(error); + }; const createComponent = ({ - requestHandlers = [[searchNamespacesWhereUserCanTransferProjectsQuery, defaultQueryHandler]], + requestHandlers = [[currentUserNamespaceQuery, defaultQueryHandler]], } = {}) => { wrapper = shallowMountExtended(TransferProjectForm, { + provide: { + projectId, + }, propsData: { - userNamespaces, - groupNamespaces, confirmButtonText, confirmationPhrase, }, @@ -44,7 +68,12 @@ describe('Transfer project form', () => { }; const findNamespaceSelect = () => wrapper.findComponent(NamespaceSelect); + const showNamespaceSelect = async () => { + findNamespaceSelect().vm.$emit('show'); + await waitForPromises(); + }; const findConfirmDanger = () => wrapper.findComponent(ConfirmDanger); + const findAlert = () => wrapper.findComponent(GlAlert); afterEach(() => { wrapper.destroy(); @@ -69,66 +98,113 @@ describe('Transfer project form', () => { }); describe('with a selected namespace', () => { - const [selectedItem] = groupNamespaces; + const [selectedItem] = transferLocationsResponsePage1; - beforeEach(() => { + const arrange = async () => { + mockResolvedGetTransferLocations(); createComponent(); - + await showNamespaceSelect(); findNamespaceSelect().vm.$emit('select', selectedItem); - }); + }; + + it('emits the `selectNamespace` event when a namespace is selected', async () => { + await arrange(); - it('emits the `selectNamespace` event when a namespace is selected', () => { const args = [selectedItem.id]; expect(wrapper.emitted('selectNamespace')).toEqual([args]); }); - it('enables the confirm button', () => { + it('enables the confirm button', async () => { + await arrange(); + expect(findConfirmDanger().attributes('disabled')).toBeUndefined(); }); - it('clicking the confirm button emits the `confirm` event', () => { + it('clicking the confirm button emits the `confirm` event', async () => { + await arrange(); + findConfirmDanger().vm.$emit('confirm'); expect(wrapper.emitted('confirm')).toBeDefined(); }); }); - it('passes correct props to `NamespaceSelect` component', async () => { - createComponent(); + describe('when `NamespaceSelect` is opened', () => { + it('fetches user and group namespaces and passes correct props to `NamespaceSelect` component', async () => { + mockResolvedGetTransferLocations(); + createComponent(); + await showNamespaceSelect(); + + const { namespace } = currentUserNamespaceQueryResponse.data.currentUser; + + expect(findNamespaceSelect().props()).toMatchObject({ + userNamespaces: [ + { + id: getIdFromGraphQLId(namespace.id), + humanName: namespace.fullName, + }, + ], + groupNamespaces: transferLocationsResponsePage1.map(({ id, full_name: humanName }) => ({ + id, + humanName, + })), + hasNextPageOfGroups: true, + isLoading: false, + isSearchLoading: false, + shouldFilterNamespaces: false, + }); + }); - runDebounce(); - await waitForPromises(); + describe('when namespaces have already been fetched', () => { + beforeEach(async () => { + mockResolvedGetTransferLocations(); + createComponent(); + await showNamespaceSelect(); + }); + + it('does not fetch namespaces', async () => { + getTransferLocations.mockClear(); + defaultQueryHandler.mockClear(); + + await showNamespaceSelect(); - const { - namespace, - groups, - } = searchNamespacesWhereUserCanTransferProjectsQueryResponsePage1.data.currentUser; - - expect(findNamespaceSelect().props()).toMatchObject({ - userNamespaces: [ - { - id: getIdFromGraphQLId(namespace.id), - humanName: namespace.fullName, - }, - ], - groupNamespaces: groups.nodes.map((node) => ({ - id: getIdFromGraphQLId(node.id), - humanName: node.fullName, - })), - hasNextPageOfGroups: true, - isLoadingMoreGroups: false, - isSearchLoading: false, - shouldFilterNamespaces: false, + expect(getTransferLocations).not.toHaveBeenCalled(); + expect(defaultQueryHandler).not.toHaveBeenCalled(); + }); + }); + + describe('when `getTransferLocations` API call fails', () => { + it('displays error alert', async () => { + mockRejectedGetTransferLocations(); + createComponent(); + await showNamespaceSelect(); + + expect(findAlert().exists()).toBe(true); + }); + }); + + describe('when `currentUser` GraphQL query fails', () => { + it('displays error alert', async () => { + mockResolvedGetTransferLocations(); + const error = new Error(); + createComponent({ + requestHandlers: [[currentUserNamespaceQuery, jest.fn().mockRejectedValueOnce(error)]], + }); + await showNamespaceSelect(); + + expect(findAlert().exists()).toBe(true); + }); }); }); describe('when `search` event is fired', () => { const arrange = async () => { + mockResolvedGetTransferLocations(); createComponent(); - + await showNamespaceSelect(); + mockResolvedGetTransferLocations(); findNamespaceSelect().vm.$emit('search', 'foo'); - await nextTick(); }; @@ -138,87 +214,106 @@ describe('Transfer project form', () => { expect(findNamespaceSelect().props('isSearchLoading')).toBe(true); }); - it('passes `search` variable to query', async () => { + it('passes `search` param to API call', async () => { await arrange(); - runDebounce(); await waitForPromises(); - expect(defaultQueryHandler).toHaveBeenCalledWith(expect.objectContaining({ search: 'foo' })); + expect(getTransferLocations).toHaveBeenCalledWith( + projectId, + expect.objectContaining({ search: 'foo' }), + ); + }); + + describe('when `getTransferLocations` API call fails', () => { + it('displays dismissible error alert', async () => { + mockResolvedGetTransferLocations(); + createComponent(); + await showNamespaceSelect(); + mockRejectedGetTransferLocations(); + findNamespaceSelect().vm.$emit('search', 'foo'); + await waitForPromises(); + + const alert = findAlert(); + + expect(alert.exists()).toBe(true); + + alert.vm.$emit('dismiss'); + await nextTick(); + + expect(alert.exists()).toBe(false); + }); }); }); describe('when `load-more-groups` event is fired', () => { - let queryHandler; - const arrange = async () => { - queryHandler = jest.fn(); - queryHandler.mockResolvedValueOnce( - searchNamespacesWhereUserCanTransferProjectsQueryResponsePage1, - ); - queryHandler.mockResolvedValueOnce( - searchNamespacesWhereUserCanTransferProjectsQueryResponsePage2, - ); + mockResolvedGetTransferLocations(); + createComponent(); + await showNamespaceSelect(); - createComponent({ - requestHandlers: [[searchNamespacesWhereUserCanTransferProjectsQuery, queryHandler]], + mockResolvedGetTransferLocations({ + data: transferLocationsResponsePage2, + page: '2', + nextPage: null, + prevPage: '1', }); - runDebounce(); - await waitForPromises(); - findNamespaceSelect().vm.$emit('load-more-groups'); await nextTick(); }; - it('sets `isLoadingMoreGroups` prop to `true`', async () => { + it('sets `isLoading` prop to `true`', async () => { await arrange(); - expect(findNamespaceSelect().props('isLoadingMoreGroups')).toBe(true); + expect(findNamespaceSelect().props('isLoading')).toBe(true); }); - it('passes `after` and `first` variables to query', async () => { + it('passes `page` param to API call', async () => { await arrange(); - runDebounce(); await waitForPromises(); - expect(queryHandler).toHaveBeenCalledWith( - expect.objectContaining({ - first: 25, - after: - searchNamespacesWhereUserCanTransferProjectsQueryResponsePage1.data.currentUser.groups - .pageInfo.endCursor, - }), + expect(getTransferLocations).toHaveBeenCalledWith( + projectId, + expect.objectContaining({ page: 2 }), ); }); it('updates `groupNamespaces` prop with new groups', async () => { await arrange(); - runDebounce(); await waitForPromises(); - expect(findNamespaceSelect().props('groupNamespaces')).toEqual( - [ - ...searchNamespacesWhereUserCanTransferProjectsQueryResponsePage1.data.currentUser.groups - .nodes, - ...searchNamespacesWhereUserCanTransferProjectsQueryResponsePage2.data.currentUser.groups - .nodes, - ].map((node) => ({ - id: getIdFromGraphQLId(node.id), - humanName: node.fullName, - })), + expect(findNamespaceSelect().props('groupNamespaces')).toMatchObject( + [...transferLocationsResponsePage1, ...transferLocationsResponsePage2].map( + ({ id, full_name: humanName }) => ({ + id, + humanName, + }), + ), ); }); it('updates `hasNextPageOfGroups` prop', async () => { await arrange(); - runDebounce(); await waitForPromises(); expect(findNamespaceSelect().props('hasNextPageOfGroups')).toBe(false); }); + + describe('when `getTransferLocations` API call fails', () => { + it('displays error alert', async () => { + mockResolvedGetTransferLocations(); + createComponent(); + await showNamespaceSelect(); + mockRejectedGetTransferLocations(); + findNamespaceSelect().vm.$emit('load-more-groups'); + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + }); + }); }); }); diff --git a/spec/frontend/projects/settings/repository/branch_rules/app_spec.js b/spec/frontend/projects/settings/repository/branch_rules/app_spec.js index e920cd48163..4603436c40a 100644 --- a/spec/frontend/projects/settings/repository/branch_rules/app_spec.js +++ b/spec/frontend/projects/settings/repository/branch_rules/app_spec.js @@ -6,8 +6,8 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import BranchRules, { i18n } from '~/projects/settings/repository/branch_rules/app.vue'; import BranchRule from '~/projects/settings/repository/branch_rules/components/branch_rule.vue'; import branchRulesQuery from '~/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql'; -import createFlash from '~/flash'; -import { branchRulesMockResponse, propsDataMock } from './mock_data'; +import { createAlert } from '~/flash'; +import { branchRulesMockResponse, appProvideMock } from './mock_data'; jest.mock('~/flash'); @@ -24,9 +24,7 @@ describe('Branch rules app', () => { wrapper = mountExtended(BranchRules, { apolloProvider: fakeApollo, - propsData: { - ...propsDataMock, - }, + provide: appProvideMock, }); await waitForPromises(); @@ -39,7 +37,7 @@ describe('Branch rules app', () => { it('displays an error if branch rules query fails', async () => { await createComponent({ queryHandler: jest.fn().mockRejectedValue() }); - expect(createFlash).toHaveBeenCalledWith({ message: i18n.queryError }); + expect(createAlert).toHaveBeenCalledWith({ message: i18n.queryError }); }); it('displays an empty state if no branch rules are present', async () => { @@ -49,7 +47,11 @@ describe('Branch rules app', () => { it('renders branch rules', () => { const { nodes } = branchRulesMockResponse.data.project.branchRules; - expect(findAllBranchRules().at(0).text()).toBe(nodes[0].name); - expect(findAllBranchRules().at(1).text()).toBe(nodes[1].name); + + expect(findAllBranchRules().length).toBe(nodes.length); + + expect(findAllBranchRules().at(0).props('name')).toBe(nodes[0].name); + + expect(findAllBranchRules().at(1).props('name')).toBe(nodes[1].name); }); }); diff --git a/spec/frontend/projects/settings/repository/branch_rules/components/branch_rule_spec.js b/spec/frontend/projects/settings/repository/branch_rules/components/branch_rule_spec.js index 924dab60704..2bc705f538b 100644 --- a/spec/frontend/projects/settings/repository/branch_rules/components/branch_rule_spec.js +++ b/spec/frontend/projects/settings/repository/branch_rules/components/branch_rule_spec.js @@ -2,26 +2,24 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import BranchRule, { i18n, } from '~/projects/settings/repository/branch_rules/components/branch_rule.vue'; - -const defaultProps = { - name: 'main', - isDefault: true, - isProtected: true, - approvalDetails: ['requires approval from TEST', '2 status checks'], -}; +import { branchRuleProvideMock, branchRulePropsMock } from '../mock_data'; describe('Branch rule', () => { let wrapper; const createComponent = (props = {}) => { - wrapper = shallowMountExtended(BranchRule, { propsData: { ...defaultProps, ...props } }); + wrapper = shallowMountExtended(BranchRule, { + provide: branchRuleProvideMock, + propsData: { ...branchRulePropsMock, ...props }, + }); }; const findDefaultBadge = () => wrapper.findByText(i18n.defaultLabel); const findProtectedBadge = () => wrapper.findByText(i18n.protectedLabel); - const findBranchName = () => wrapper.findByText(defaultProps.name); + const findBranchName = () => wrapper.findByText(branchRulePropsMock.name); const findProtectionDetailsList = () => wrapper.findByRole('list'); const findProtectionDetailsListItems = () => wrapper.findAllByRole('listitem'); + const findDetailsButton = () => wrapper.findByText(i18n.detailsButtonLabel); beforeEach(() => createComponent()); @@ -52,7 +50,17 @@ describe('Branch rule', () => { }); it('renders the protection details list items', () => { - expect(findProtectionDetailsListItems().at(0).text()).toBe(defaultProps.approvalDetails[0]); - expect(findProtectionDetailsListItems().at(1).text()).toBe(defaultProps.approvalDetails[1]); + expect(findProtectionDetailsListItems().at(0).text()).toBe( + branchRulePropsMock.approvalDetails[0], + ); + expect(findProtectionDetailsListItems().at(1).text()).toBe( + branchRulePropsMock.approvalDetails[1], + ); + }); + + it('renders a detail button with the correct href', () => { + expect(findDetailsButton().attributes('href')).toBe( + `${branchRuleProvideMock.branchRulesPath}?branch=${branchRulePropsMock.name}`, + ); }); }); diff --git a/spec/frontend/projects/settings/repository/branch_rules/mock_data.js b/spec/frontend/projects/settings/repository/branch_rules/mock_data.js index 14ed35f047d..bac82992c4d 100644 --- a/spec/frontend/projects/settings/repository/branch_rules/mock_data.js +++ b/spec/frontend/projects/settings/repository/branch_rules/mock_data.js @@ -20,6 +20,17 @@ export const branchRulesMockResponse = { }, }; -export const propsDataMock = { +export const appProvideMock = { projectPath: 'some/project/path', }; + +export const branchRuleProvideMock = { + branchRulesPath: 'settings/repository/branch_rules', +}; + +export const branchRulePropsMock = { + name: 'main', + isDefault: true, + isProtected: true, + approvalDetails: ['requires approval from TEST', '2 status checks'], +}; diff --git a/spec/frontend/protected_branches/protected_branch_edit_spec.js b/spec/frontend/protected_branches/protected_branch_edit_spec.js index 6ef1b58a956..0aec4fbc037 100644 --- a/spec/frontend/protected_branches/protected_branch_edit_spec.js +++ b/spec/frontend/protected_branches/protected_branch_edit_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import ProtectedBranchEdit from '~/protected_branches/protected_branch_edit'; @@ -136,7 +136,7 @@ describe('ProtectedBranchEdit', () => { expect(toggle).not.toHaveClass(IS_DISABLED_CLASS); expect(toggle.querySelector(IS_LOADING_SELECTOR)).toBe(null); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); }); @@ -149,7 +149,7 @@ describe('ProtectedBranchEdit', () => { it('flashes error', async () => { await axios.waitForAll(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js index 6c5af5a2625..96601a729b2 100644 --- a/spec/frontend/ref/components/ref_selector_spec.js +++ b/spec/frontend/ref/components/ref_selector_spec.js @@ -109,6 +109,8 @@ describe('Ref selector component', () => { const findCommitDropdownItems = () => findCommitsSection().findAllComponents(GlDropdownItem); const findFirstCommitDropdownItem = () => findCommitDropdownItems().at(0); + const findHiddenInputField = () => wrapper.find('[data-testid="selected-ref-form-field"]'); + // // Expecters // @@ -181,6 +183,24 @@ describe('Ref selector component', () => { expect(findLoadingIcon().exists()).toBe(false); }); }); + + describe('when name property is provided', () => { + it('renders an forrm input hidden field', () => { + const name = 'default_tag'; + + createComponent({ propsData: { name } }); + + expect(findHiddenInputField().attributes().name).toBe(name); + }); + }); + + describe('when name property is not provided', () => { + it('renders an forrm input hidden field', () => { + createComponent(); + + expect(findHiddenInputField().exists()).toBe(false); + }); + }); }); describe('post-initialization behavior', () => { @@ -194,7 +214,7 @@ describe('Ref selector component', () => { }); it('adds the provided ID to the GlDropdown instance', () => { - expect(wrapper.attributes().id).toBe(id); + expect(wrapper.findComponent(GlDropdown).attributes().id).toBe(id); }); }); @@ -202,7 +222,7 @@ describe('Ref selector component', () => { const preselectedRef = fixtures.branches[0].name; beforeEach(() => { - createComponent({ propsData: { value: preselectedRef } }); + createComponent({ propsData: { value: preselectedRef, name: 'selectedRef' } }); return waitForRequests(); }); @@ -210,6 +230,10 @@ describe('Ref selector component', () => { it('renders the pre-selected ref name', () => { expect(findButtonContent().text()).toBe(preselectedRef); }); + + it('binds hidden input field to the pre-selected ref', () => { + expect(findHiddenInputField().attributes().value).toBe(preselectedRef); + }); }); describe('when the selected ref is updated by the parent component', () => { diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap index 55e3dda60a0..d88d79d2cde 100644 --- a/spec/frontend/releases/__snapshots__/util_spec.js.snap +++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap @@ -155,8 +155,8 @@ Object { Object { "__typename": "ReleaseEvidence", "collectedAt": "2018-12-03T00:00:00Z", - "filepath": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/evidences/1.json", - "id": "gid://gitlab/Releases::Evidence/1", + "filepath": Any<String>, + "id": Any<String>, "sha": "760d6cdfb0879c3ffedec13af470e0f71cf52c6cde4d", }, ], @@ -198,10 +198,10 @@ Object { ], "paginationInfo": Object { "__typename": "PageInfo", - "endCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTgtMTItMTAgMDA6MDA6MDAuMDAwMDAwMDAwICswMDAwIiwiaWQiOiIxIn0", + "endCursor": Any<String>, "hasNextPage": false, "hasPreviousPage": false, - "startCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTktMDEtMTAgMDA6MDA6MDAuMDAwMDAwMDAwICswMDAwIiwiaWQiOiIyIn0", + "startCursor": Any<String>, }, } `; @@ -377,8 +377,8 @@ Object { Object { "__typename": "ReleaseEvidence", "collectedAt": "2018-12-03T00:00:00Z", - "filepath": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/evidences/1.json", - "id": "gid://gitlab/Releases::Evidence/1", + "filepath": Any<String>, + "id": Any<String>, "sha": "760d6cdfb0879c3ffedec13af470e0f71cf52c6cde4d", }, ], diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js index f64f07de90e..48589a54ec4 100644 --- a/spec/frontend/releases/components/app_index_spec.js +++ b/spec/frontend/releases/components/app_index_spec.js @@ -6,7 +6,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { historyPushState } from '~/lib/utils/common_utils'; import { sprintf, __ } from '~/locale'; import ReleasesIndexApp from '~/releases/components/app_index.vue'; @@ -161,13 +161,13 @@ describe('app_index.vue', () => { it(`${toDescription(flashMessage)} show a flash message`, async () => { await waitForPromises(); if (flashMessage) { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: ReleasesIndexApp.i18n.errorMessage, captureError: true, error: expect.any(Error), }); } else { - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); } }); diff --git a/spec/frontend/releases/components/app_show_spec.js b/spec/frontend/releases/components/app_show_spec.js index 9ca25b3b69a..c5cb8589ee8 100644 --- a/spec/frontend/releases/components/app_show_spec.js +++ b/spec/frontend/releases/components/app_show_spec.js @@ -4,7 +4,7 @@ import VueApollo from 'vue-apollo'; import oneReleaseQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release.query.graphql.json'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import ReleaseShowApp from '~/releases/components/app_show.vue'; import ReleaseBlock from '~/releases/components/release_block.vue'; import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue'; @@ -53,13 +53,13 @@ describe('Release show component', () => { const expectNoFlash = () => { it('does not show a flash message', () => { - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); }; const expectFlashWithMessage = (message) => { it(`shows a flash message that reads "${message}"`, () => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message, captureError: true, error: expect.any(Error), diff --git a/spec/frontend/releases/components/evidence_block_spec.js b/spec/frontend/releases/components/evidence_block_spec.js index 2db1e9e38a2..6f935215dd7 100644 --- a/spec/frontend/releases/components/evidence_block_spec.js +++ b/spec/frontend/releases/components/evidence_block_spec.js @@ -36,7 +36,7 @@ describe('Evidence Block', () => { }); it('renders the title for the dowload link', () => { - expect(wrapper.findComponent(GlLink).text()).toBe(`v1.1-evidences-1.json`); + expect(wrapper.findComponent(GlLink).text()).toMatch(/v1\.1-evidences-[0-9]+\.json/); }); it('renders the correct hover text for the download', () => { @@ -44,7 +44,9 @@ describe('Evidence Block', () => { }); it('renders the correct file link for download', () => { - expect(wrapper.findComponent(GlLink).attributes().download).toBe(`v1.1-evidences-1.json`); + expect(wrapper.findComponent(GlLink).attributes().download).toMatch( + /v1\.1-evidences-[0-9]+\.json/, + ); }); describe('sha text', () => { diff --git a/spec/frontend/releases/components/tag_field_new_spec.js b/spec/frontend/releases/components/tag_field_new_spec.js index b8047cae8c2..fcba0da3462 100644 --- a/spec/frontend/releases/components/tag_field_new_spec.js +++ b/spec/frontend/releases/components/tag_field_new_spec.js @@ -1,14 +1,17 @@ -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDropdownItem, GlFormGroup, GlSprintf } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; +import { trimText } from 'helpers/text_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { __ } from '~/locale'; import TagFieldNew from '~/releases/components/tag_field_new.vue'; import createStore from '~/releases/stores'; import createEditNewModule from '~/releases/stores/modules/edit_new'; const TEST_TAG_NAME = 'test-tag-name'; +const TEST_TAG_MESSAGE = 'Test tag message'; const TEST_PROJECT_ID = '1234'; const TEST_CREATE_FROM = 'test-create-from'; const NONEXISTENT_TAG_NAME = 'nonexistent-tag'; @@ -47,6 +50,8 @@ describe('releases/components/tag_field_new', () => { store, stubs: { RefSelector: RefSelectorStub, + GlFormGroup, + GlSprintf, }, }); }; @@ -61,9 +66,11 @@ describe('releases/components/tag_field_new', () => { }); store.state.editNew.createFrom = TEST_CREATE_FROM; + store.state.editNew.showCreateFrom = true; store.state.editNew.release = { tagName: TEST_TAG_NAME, + tagMessage: '', assets: { links: [], }, @@ -86,6 +93,9 @@ describe('releases/components/tag_field_new', () => { const findCreateNewTagOption = () => wrapper.findComponent(GlDropdownItem); + const findAnnotatedTagMessageFormGroup = () => + wrapper.find('[data-testid="annotated-tag-message-field"]'); + describe('"Tag name" field', () => { describe('rendering and behavior', () => { beforeEach(() => createComponent()); @@ -124,6 +134,10 @@ describe('releases/components/tag_field_new', () => { expect(findCreateFromFormGroup().exists()).toBe(false); }); + it('hides the "Tag message" field', () => { + expect(findAnnotatedTagMessageFormGroup().exists()).toBe(false); + }); + it('fetches the release notes for the tag', () => { const expectedUrl = `/api/v4/projects/1234/repository/tags/${updatedTagName}`; expect(mock.history.get).toContainEqual(expect.objectContaining({ url: expectedUrl })); @@ -230,4 +244,34 @@ describe('releases/components/tag_field_new', () => { }); }); }); + + describe('"Annotated Tag" field', () => { + beforeEach(() => { + createComponent(mountExtended); + }); + + it('renders a label', () => { + expect(wrapper.findByRole('textbox', { name: 'Set tag message' }).exists()).toBe(true); + }); + + it('renders a description', () => { + expect(trimText(findAnnotatedTagMessageFormGroup().text())).toContain( + 'Add a message to the tag. Leaving this blank creates a lightweight tag.', + ); + }); + + it('updates the store', async () => { + await findAnnotatedTagMessageFormGroup().find('textarea').setValue(TEST_TAG_MESSAGE); + + expect(store.state.editNew.release.tagMessage).toBe(TEST_TAG_MESSAGE); + }); + + it('shows a link', () => { + const link = wrapper.findByRole('link', { + name: 'lightweight tag', + }); + + expect(link.attributes('href')).toBe('https://git-scm.com/book/en/v2/Git-Basics-Tagging/'); + }); + }); }); diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js index 48fba3adb24..eeee6747349 100644 --- a/spec/frontend/releases/stores/modules/detail/actions_spec.js +++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js @@ -2,7 +2,7 @@ import { cloneDeep } from 'lodash'; import originalOneReleaseForEditingQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release_for_editing.query.graphql.json'; import testAction from 'helpers/vuex_action_helper'; import { getTag } from '~/api/tags_api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import { ASSET_LINK_TYPE } from '~/releases/constants'; @@ -59,7 +59,7 @@ describe('Release edit/new actions', () => { releaseResponse = cloneDeep(originalOneReleaseForEditingQueryResponse); gon.api_version = 'v4'; error = new Error('Yikes!'); - createFlash.mockClear(); + createAlert.mockClear(); }); describe('when creating a new release', () => { @@ -151,8 +151,8 @@ describe('Release edit/new actions', () => { it(`shows a flash message`, () => { return actions.fetchRelease({ commit: jest.fn(), state, rootState: state }).then(() => { - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ message: 'Something went wrong while getting the release details.', }); }); @@ -169,6 +169,15 @@ describe('Release edit/new actions', () => { }); }); + describe('updateReleaseTagMessage', () => { + it(`commits ${types.UPDATE_RELEASE_TAG_MESSAGE} with the updated tag name`, () => { + const newMessage = 'updated-tag-message'; + return testAction(actions.updateReleaseTagMessage, newMessage, state, [ + { type: types.UPDATE_RELEASE_TAG_MESSAGE, payload: newMessage }, + ]); + }); + }); + describe('updateReleasedAt', () => { it(`commits ${types.UPDATE_RELEASED_AT} with the updated date`, () => { const newDate = new Date(); @@ -370,8 +379,8 @@ describe('Release edit/new actions', () => { return actions .createRelease({ commit: jest.fn(), dispatch: jest.fn(), state, getters: {} }) .then(() => { - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ message: 'Yikes!', }); }); @@ -396,8 +405,8 @@ describe('Release edit/new actions', () => { return actions .createRelease({ commit: jest.fn(), dispatch: jest.fn(), state, getters: {} }) .then(() => { - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ message: 'Something went wrong while creating a new release.', }); }); @@ -527,8 +536,8 @@ describe('Release edit/new actions', () => { it('shows a flash message', async () => { await actions.updateRelease({ commit, dispatch, state, getters }); - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ message: 'Something went wrong while saving the release details.', }); }); @@ -547,8 +556,8 @@ describe('Release edit/new actions', () => { it('shows a flash message', async () => { await actions.updateRelease({ commit, dispatch, state, getters }); - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ message: 'Something went wrong while saving the release details.', }); }); @@ -700,8 +709,8 @@ describe('Release edit/new actions', () => { it('shows a flash message', async () => { await actions.deleteRelease({ commit, dispatch, state, getters }); - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ message: 'Something went wrong while deleting the release.', }); }); @@ -736,8 +745,8 @@ describe('Release edit/new actions', () => { it('shows a flash message', async () => { await actions.deleteRelease({ commit, dispatch, state, getters }); - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ message: 'Something went wrong while deleting the release.', }); }); @@ -779,7 +788,7 @@ describe('Release edit/new actions', () => { [], ); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: s__('Release|Unable to fetch the tag notes.'), }); expect(getTag).toHaveBeenCalledWith(state.projectId, tagName); diff --git a/spec/frontend/releases/stores/modules/detail/getters_spec.js b/spec/frontend/releases/stores/modules/detail/getters_spec.js index 2982dc5c46c..f8b87ec71dc 100644 --- a/spec/frontend/releases/stores/modules/detail/getters_spec.js +++ b/spec/frontend/releases/stores/modules/detail/getters_spec.js @@ -317,7 +317,7 @@ describe('Release edit/new getters', () => { { milestones: ['release.milestone[0].title'] }, ], ])('releaseUpdateMutatationVariables', (description, state, expectedVariables) => { - it(description, () => { + it(`${description}`, () => { const expectedVariablesObject = { input: expect.objectContaining(expectedVariables) }; const actualVariables = getters.releaseUpdateMutatationVariables(state, { @@ -332,6 +332,7 @@ describe('Release edit/new getters', () => { it('returns all the data needed for the releaseCreate GraphQL query', () => { const state = { createFrom: 'main', + release: { tagMessage: 'hello' }, }; const otherGetters = { @@ -352,6 +353,7 @@ describe('Release edit/new getters', () => { const expectedVariables = { input: { name: 'release.name', + tagMessage: 'hello', ref: 'main', assets: { links: [ diff --git a/spec/frontend/releases/stores/modules/detail/mutations_spec.js b/spec/frontend/releases/stores/modules/detail/mutations_spec.js index 8bbf550b77d..944769d22cc 100644 --- a/spec/frontend/releases/stores/modules/detail/mutations_spec.js +++ b/spec/frontend/releases/stores/modules/detail/mutations_spec.js @@ -26,6 +26,7 @@ describe('Release edit/new mutations', () => { expect(state.release).toEqual({ tagName: 'v1.3', + tagMessage: '', name: '', description: '', milestones: [], @@ -90,6 +91,16 @@ describe('Release edit/new mutations', () => { }); }); + describe(`${types.UPDATE_RELEASE_TAG_MESSAGE}`, () => { + it("updates the release's tag message", () => { + state.release = release; + const newMessage = 'updated-tag-message'; + mutations[types.UPDATE_RELEASE_TAG_MESSAGE](state, newMessage); + + expect(state.release.tagMessage).toBe(newMessage); + }); + }); + describe(`${types.UPDATE_RELEASED_AT}`, () => { it("updates the release's released at date", () => { state.release = release; diff --git a/spec/frontend/releases/util_spec.js b/spec/frontend/releases/util_spec.js index 055c8e8b39f..14cce8320e9 100644 --- a/spec/frontend/releases/util_spec.js +++ b/spec/frontend/releases/util_spec.js @@ -115,8 +115,18 @@ describe('releases/util.js', () => { author: { id: expect.any(String), }, + evidences: [ + { + id: expect.any(String), + filepath: expect.any(String), + }, + ], }, ], + paginationInfo: { + startCursor: expect.any(String), + endCursor: expect.any(String), + }, }); }); }); @@ -128,6 +138,12 @@ describe('releases/util.js', () => { author: { id: expect.any(String), }, + evidences: [ + { + id: expect.any(String), + filepath: expect.any(String), + }, + ], }, }); }); diff --git a/spec/frontend/reports/accessibility_report/components/accessibility_issue_body_spec.js b/spec/frontend/reports/accessibility_report/components/accessibility_issue_body_spec.js deleted file mode 100644 index d835ca4c733..00000000000 --- a/spec/frontend/reports/accessibility_report/components/accessibility_issue_body_spec.js +++ /dev/null @@ -1,112 +0,0 @@ -import { GlBadge } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import AccessibilityIssueBody from '~/reports/accessibility_report/components/accessibility_issue_body.vue'; - -const issue = { - name: - 'The accessibility scanning found 2 errors of the following type: WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent', - code: 'WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent', - message: 'This element has insufficient contrast at this conformance level.', - status: 'failed', - className: 'spec.test_spec', - learnMoreUrl: 'https://www.w3.org/TR/WCAG20-TECHS/H91.html', -}; - -describe('CustomMetricsForm', () => { - let wrapper; - - const mountComponent = ({ name, code, message, status, className }, isNew = false) => { - wrapper = shallowMount(AccessibilityIssueBody, { - propsData: { - issue: { - name, - code, - message, - status, - className, - }, - isNew, - }, - }); - }; - - const findIsNewBadge = () => wrapper.findComponent(GlBadge); - - beforeEach(() => { - mountComponent(issue); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('Displays the issue message', () => { - const description = wrapper.findComponent({ ref: 'accessibility-issue-description' }).text(); - - expect(description).toContain(`Message: ${issue.message}`); - }); - - describe('When an issue code is present', () => { - it('Creates the correct URL for learning more about the issue code', () => { - const learnMoreUrl = wrapper - .findComponent({ ref: 'accessibility-issue-learn-more' }) - .attributes('href'); - - expect(learnMoreUrl).toBe(issue.learnMoreUrl); - }); - }); - - describe('When an issue code is not present', () => { - beforeEach(() => { - mountComponent({ - ...issue, - code: undefined, - }); - }); - - it('Creates a URL leading to the overview documentation page', () => { - const learnMoreUrl = wrapper - .findComponent({ ref: 'accessibility-issue-learn-more' }) - .attributes('href'); - - expect(learnMoreUrl).toBe('https://www.w3.org/TR/WCAG20-TECHS/Overview.html'); - }); - }); - - describe('When an issue code does not contain the TECHS code', () => { - beforeEach(() => { - mountComponent({ - ...issue, - code: 'WCAG2AA.Principle4.Guideline4_1.4_1_2', - }); - }); - - it('Creates a URL leading to the overview documentation page', () => { - const learnMoreUrl = wrapper - .findComponent({ ref: 'accessibility-issue-learn-more' }) - .attributes('href'); - - expect(learnMoreUrl).toBe('https://www.w3.org/TR/WCAG20-TECHS/Overview.html'); - }); - }); - - describe('When issue is new', () => { - beforeEach(() => { - mountComponent(issue, true); - }); - - it('Renders the new badge', () => { - expect(findIsNewBadge().exists()).toBe(true); - }); - }); - - describe('When issue is not new', () => { - beforeEach(() => { - mountComponent(issue, false); - }); - - it('Does not render the new badge', () => { - expect(findIsNewBadge().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js b/spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js deleted file mode 100644 index 9d3535291eb..00000000000 --- a/spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js +++ /dev/null @@ -1,125 +0,0 @@ -import { mount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; -import AccessibilityIssueBody from '~/reports/accessibility_report/components/accessibility_issue_body.vue'; -import GroupedAccessibilityReportsApp from '~/reports/accessibility_report/grouped_accessibility_reports_app.vue'; -import { getStoreConfig } from '~/reports/accessibility_report/store'; -import { mockReport } from './mock_data'; - -Vue.use(Vuex); - -describe('Grouped accessibility reports app', () => { - let wrapper; - let mockStore; - - const mountComponent = () => { - wrapper = mount(GroupedAccessibilityReportsApp, { - store: mockStore, - propsData: { - endpoint: 'endpoint.json', - }, - }); - }; - - const findHeader = () => wrapper.find('[data-testid="report-section-code-text"]'); - - beforeEach(() => { - mockStore = new Vuex.Store({ - ...getStoreConfig(), - actions: { fetchReport: () => {}, setEndpoint: () => {} }, - }); - - mountComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('while loading', () => { - beforeEach(() => { - mockStore.state.isLoading = true; - mountComponent(); - }); - - it('renders loading state', () => { - expect(findHeader().text()).toEqual('Accessibility scanning results are being parsed'); - }); - }); - - describe('with error', () => { - beforeEach(() => { - mockStore.state.isLoading = false; - mockStore.state.hasError = true; - mountComponent(); - }); - - it('renders error state', () => { - expect(findHeader().text()).toEqual('Accessibility scanning failed loading results'); - }); - }); - - describe('with a report', () => { - describe('with no issues', () => { - beforeEach(() => { - mockStore.state.report = { - summary: { - errored: 0, - }, - }; - }); - - it('renders no issues header', () => { - expect(findHeader().text()).toContain( - 'Accessibility scanning detected no issues for the source branch only', - ); - }); - }); - - describe('with one issue', () => { - beforeEach(() => { - mockStore.state.report = { - summary: { - errored: 1, - }, - }; - }); - - it('renders one issue header', () => { - expect(findHeader().text()).toContain( - 'Accessibility scanning detected 1 issue for the source branch only', - ); - }); - }); - - describe('with multiple issues', () => { - beforeEach(() => { - mockStore.state.report = { - summary: { - errored: 2, - }, - }; - }); - - it('renders multiple issues header', () => { - expect(findHeader().text()).toContain( - 'Accessibility scanning detected 2 issues for the source branch only', - ); - }); - }); - - describe('with issues to show', () => { - beforeEach(() => { - mockStore.state.report = mockReport; - }); - - it('renders custom accessibility issue body', () => { - const issueBody = wrapper.findComponent(AccessibilityIssueBody); - - expect(issueBody.props('issue').code).toBe(mockReport.new_errors[0].code); - expect(issueBody.props('issue').message).toBe(mockReport.new_errors[0].message); - expect(issueBody.props('isNew')).toBe(true); - }); - }); - }); -}); diff --git a/spec/frontend/reports/accessibility_report/mock_data.js b/spec/frontend/reports/accessibility_report/mock_data.js deleted file mode 100644 index 9dace1e7c54..00000000000 --- a/spec/frontend/reports/accessibility_report/mock_data.js +++ /dev/null @@ -1,53 +0,0 @@ -export const mockReport = { - status: 'failed', - summary: { - total: 2, - resolved: 0, - errored: 2, - }, - new_errors: [ - { - code: 'WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail', - type: 'error', - typeCode: 1, - message: - 'This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 3.84:1. Recommendation: change text colour to #767676.', - context: '<a href="/stages-devops-lifecycle/" class="main-nav-link">Product</a>', - selector: '#main-nav > div:nth-child(2) > ul > li:nth-child(1) > a', - runner: 'htmlcs', - runnerExtras: {}, - }, - ], - new_notes: [], - new_warnings: [], - resolved_errors: [ - { - code: 'WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent', - type: 'error', - typeCode: 1, - message: - 'Anchor element found with a valid href attribute, but no link content has been supplied.', - context: '<a href="/" class="navbar-brand animated"><svg height="36" viewBox="0 0 1...</a>', - selector: '#main-nav > div:nth-child(1) > a', - runner: 'htmlcs', - runnerExtras: {}, - }, - ], - resolved_notes: [], - resolved_warnings: [], - existing_errors: [ - { - code: 'WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent', - type: 'error', - typeCode: 1, - message: - 'Anchor element found with a valid href attribute, but no link content has been supplied.', - context: '<a href="/" class="navbar-brand animated"><svg height="36" viewBox="0 0 1...</a>', - selector: '#main-nav > div:nth-child(1) > a', - runner: 'htmlcs', - runnerExtras: {}, - }, - ], - existing_notes: [], - existing_warnings: [], -}; diff --git a/spec/frontend/reports/accessibility_report/store/actions_spec.js b/spec/frontend/reports/accessibility_report/store/actions_spec.js deleted file mode 100644 index bab6c4905a7..00000000000 --- a/spec/frontend/reports/accessibility_report/store/actions_spec.js +++ /dev/null @@ -1,115 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import testAction from 'helpers/vuex_action_helper'; -import { TEST_HOST } from 'spec/test_constants'; -import axios from '~/lib/utils/axios_utils'; -import createStore from '~/reports/accessibility_report/store'; -import * as actions from '~/reports/accessibility_report/store/actions'; -import * as types from '~/reports/accessibility_report/store/mutation_types'; -import { mockReport } from '../mock_data'; - -describe('Accessibility Reports actions', () => { - let localState; - let localStore; - - beforeEach(() => { - localStore = createStore(); - localState = localStore.state; - }); - - describe('setEndpoints', () => { - it('should commit SET_ENDPOINTS mutation', () => { - const endpoint = 'endpoint.json'; - - return testAction( - actions.setEndpoint, - endpoint, - localState, - [{ type: types.SET_ENDPOINT, payload: endpoint }], - [], - ); - }); - }); - - describe('fetchReport', () => { - let mock; - - beforeEach(() => { - localState.endpoint = `${TEST_HOST}/endpoint.json`; - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - actions.stopPolling(); - actions.clearEtagPoll(); - }); - - describe('success', () => { - it('should commit REQUEST_REPORT mutation and dispatch receiveReportSuccess', () => { - const data = { report: { summary: {} } }; - mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, data); - - return testAction( - actions.fetchReport, - null, - localState, - [{ type: types.REQUEST_REPORT }], - [ - { - payload: { status: 200, data }, - type: 'receiveReportSuccess', - }, - ], - ); - }); - }); - - describe('error', () => { - it('should commit REQUEST_REPORT and RECEIVE_REPORT_ERROR mutations', () => { - mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500); - - return testAction( - actions.fetchReport, - null, - localState, - [{ type: types.REQUEST_REPORT }], - [{ type: 'receiveReportError' }], - ); - }); - }); - }); - - describe('receiveReportSuccess', () => { - it('should commit RECEIVE_REPORT_SUCCESS mutation with 200', () => { - return testAction( - actions.receiveReportSuccess, - { status: 200, data: mockReport }, - localState, - [{ type: types.RECEIVE_REPORT_SUCCESS, payload: mockReport }], - [{ type: 'stopPolling' }], - ); - }); - - it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', () => { - return testAction( - actions.receiveReportSuccess, - { status: 204, data: mockReport }, - localState, - [], - [], - ); - }); - }); - - describe('receiveReportError', () => { - it('should commit RECEIVE_REPORT_ERROR mutation', () => { - return testAction( - actions.receiveReportError, - null, - localState, - [{ type: types.RECEIVE_REPORT_ERROR }], - [{ type: 'stopPolling' }], - ); - }); - }); -}); diff --git a/spec/frontend/reports/accessibility_report/store/getters_spec.js b/spec/frontend/reports/accessibility_report/store/getters_spec.js deleted file mode 100644 index 96344596003..00000000000 --- a/spec/frontend/reports/accessibility_report/store/getters_spec.js +++ /dev/null @@ -1,149 +0,0 @@ -import createStore from '~/reports/accessibility_report/store'; -import * as getters from '~/reports/accessibility_report/store/getters'; -import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '~/reports/constants'; - -describe('Accessibility reports store getters', () => { - let localState; - let localStore; - - beforeEach(() => { - localStore = createStore(); - localState = localStore.state; - }); - - describe('summaryStatus', () => { - describe('when summary is loading', () => { - it('returns loading status', () => { - localState.isLoading = true; - - expect(getters.summaryStatus(localState)).toEqual(LOADING); - }); - }); - - describe('when summary has error', () => { - it('returns error status', () => { - localState.hasError = true; - - expect(getters.summaryStatus(localState)).toEqual(ERROR); - }); - }); - - describe('when summary has failed status', () => { - it('returns loading status', () => { - localState.status = STATUS_FAILED; - - expect(getters.summaryStatus(localState)).toEqual(ERROR); - }); - }); - - describe('when summary has successfully loaded', () => { - it('returns loading status', () => { - expect(getters.summaryStatus(localState)).toEqual(SUCCESS); - }); - }); - }); - - describe('groupedSummaryText', () => { - describe('when state is loading', () => { - it('returns the loading summary message', () => { - localState.isLoading = true; - const result = 'Accessibility scanning results are being parsed'; - - expect(getters.groupedSummaryText(localState)).toEqual(result); - }); - }); - - describe('when state has error', () => { - it('returns the error summary message', () => { - localState.hasError = true; - const result = 'Accessibility scanning failed loading results'; - - expect(getters.groupedSummaryText(localState)).toEqual(result); - }); - }); - - describe('when state has successfully loaded', () => { - describe('when report has errors', () => { - it('returns summary message containing number of errors', () => { - localState.report = { - summary: { - errored: 2, - }, - }; - const result = 'Accessibility scanning detected 2 issues for the source branch only'; - - expect(getters.groupedSummaryText(localState)).toEqual(result); - }); - }); - - describe('when report has no errors', () => { - it('returns summary message containing no errors', () => { - localState.report = { - summary: { - errored: 0, - }, - }; - const result = 'Accessibility scanning detected no issues for the source branch only'; - - expect(getters.groupedSummaryText(localState)).toEqual(result); - }); - }); - }); - }); - - describe('shouldRenderIssuesList', () => { - describe('when has issues to render', () => { - it('returns true', () => { - localState.report = { - existing_errors: [{ name: 'Issue' }], - }; - - expect(getters.shouldRenderIssuesList(localState)).toEqual(true); - }); - }); - - describe('when does not have issues to render', () => { - it('returns false', () => { - localState.report = { - status: 'success', - summary: { errored: 0 }, - }; - - expect(getters.shouldRenderIssuesList(localState)).toEqual(false); - }); - }); - }); - - describe('unresolvedIssues', () => { - it('returns the array unresolved errors', () => { - localState.report = { - existing_errors: [1], - }; - const result = [1]; - - expect(getters.unresolvedIssues(localState)).toEqual(result); - }); - }); - - describe('resolvedIssues', () => { - it('returns array of resolved errors', () => { - localState.report = { - resolved_errors: [1], - }; - const result = [1]; - - expect(getters.resolvedIssues(localState)).toEqual(result); - }); - }); - - describe('newIssues', () => { - it('returns array of new errors', () => { - localState.report = { - new_errors: [1], - }; - const result = [1]; - - expect(getters.newIssues(localState)).toEqual(result); - }); - }); -}); diff --git a/spec/frontend/reports/accessibility_report/store/mutations_spec.js b/spec/frontend/reports/accessibility_report/store/mutations_spec.js deleted file mode 100644 index b336261d804..00000000000 --- a/spec/frontend/reports/accessibility_report/store/mutations_spec.js +++ /dev/null @@ -1,64 +0,0 @@ -import createStore from '~/reports/accessibility_report/store'; -import mutations from '~/reports/accessibility_report/store/mutations'; - -describe('Accessibility Reports mutations', () => { - let localState; - let localStore; - - beforeEach(() => { - localStore = createStore(); - localState = localStore.state; - }); - - describe('SET_ENDPOINT', () => { - it('sets endpoint to given value', () => { - const endpoint = 'endpoint.json'; - mutations.SET_ENDPOINT(localState, endpoint); - - expect(localState.endpoint).toEqual(endpoint); - }); - }); - - describe('REQUEST_REPORT', () => { - it('sets isLoading to true', () => { - mutations.REQUEST_REPORT(localState); - - expect(localState.isLoading).toEqual(true); - }); - }); - - describe('RECEIVE_REPORT_SUCCESS', () => { - it('sets isLoading to false', () => { - mutations.RECEIVE_REPORT_SUCCESS(localState, {}); - - expect(localState.isLoading).toEqual(false); - }); - - it('sets hasError to false', () => { - mutations.RECEIVE_REPORT_SUCCESS(localState, {}); - - expect(localState.hasError).toEqual(false); - }); - - it('sets report to response report', () => { - const report = { data: 'testing' }; - mutations.RECEIVE_REPORT_SUCCESS(localState, report); - - expect(localState.report).toEqual(report); - }); - }); - - describe('RECEIVE_REPORT_ERROR', () => { - it('sets isLoading to false', () => { - mutations.RECEIVE_REPORT_ERROR(localState); - - expect(localState.isLoading).toEqual(false); - }); - - it('sets hasError to true', () => { - mutations.RECEIVE_REPORT_ERROR(localState); - - expect(localState.hasError).toEqual(true); - }); - }); -}); diff --git a/spec/frontend/reports/components/report_section_spec.js b/spec/frontend/reports/components/report_section_spec.js index bdfba8d6878..cc35b99a199 100644 --- a/spec/frontend/reports/components/report_section_spec.js +++ b/spec/frontend/reports/components/report_section_spec.js @@ -7,9 +7,13 @@ import ReportSection from '~/reports/components/report_section.vue'; describe('ReportSection component', () => { let wrapper; - const findButton = () => wrapper.findComponent(GlButton); + const findExpandButton = () => wrapper.findComponent(GlButton); const findPopover = () => wrapper.findComponent(HelpPopover); const findReportSection = () => wrapper.find('.js-report-section-container'); + const expectExpandButtonOpen = () => + expect(findExpandButton().props('icon')).toBe('chevron-lg-up'); + const expectExpandButtonClosed = () => + expect(findExpandButton().props('icon')).toBe('chevron-lg-down'); const resolvedIssues = [ { @@ -122,22 +126,22 @@ describe('ReportSection component', () => { it('toggles issues', async () => { createComponent({ props: { hasIssues: true } }); - await findButton().trigger('click'); + await findExpandButton().trigger('click'); expect(findReportSection().isVisible()).toBe(true); - expect(findButton().text()).toBe('Collapse'); + expectExpandButtonOpen(); - await findButton().trigger('click'); + await findExpandButton().trigger('click'); expect(findReportSection().isVisible()).toBe(false); - expect(findButton().text()).toBe('Expand'); + expectExpandButtonClosed(); }); it('is always expanded, if always-open is set to true', () => { createComponent({ props: { hasIssues: true, alwaysOpen: true } }); expect(findReportSection().isVisible()).toBe(true); - expect(findButton().exists()).toBe(false); + expect(findExpandButton().exists()).toBe(false); }); }); }); @@ -148,7 +152,7 @@ describe('ReportSection component', () => { expect(wrapper.emitted('toggleEvent')).toBeUndefined(); - findButton().trigger('click'); + findExpandButton().trigger('click'); expect(wrapper.emitted('toggleEvent')).toEqual([[]]); }); @@ -158,7 +162,7 @@ describe('ReportSection component', () => { expect(wrapper.emitted('toggleEvent')).toBeUndefined(); - findButton().trigger('click'); + findExpandButton().trigger('click'); expect(wrapper.emitted('toggleEvent')).toBeUndefined(); }); @@ -208,7 +212,7 @@ describe('ReportSection component', () => { }); it('should still render the expand/collapse button', () => { - expect(findButton().text()).toBe('Expand'); + expectExpandButtonClosed(); }); }); diff --git a/spec/frontend/repository/commits_service_spec.js b/spec/frontend/repository/commits_service_spec.js index 697fa7c4fd1..de7c56f239a 100644 --- a/spec/frontend/repository/commits_service_spec.js +++ b/spec/frontend/repository/commits_service_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import { loadCommits, isRequested, resetRequestedCommits } from '~/repository/commits_service'; import httpStatus from '~/lib/utils/http_status'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { I18N_COMMIT_DATA_FETCH_ERROR } from '~/repository/constants'; jest.mock('~/flash'); @@ -65,13 +65,13 @@ describe('commits service', () => { expect(isRequested(300)).toBe(false); }); - it('calls `createFlash` when the request fails', async () => { + it('calls `createAlert` when the request fails', async () => { const invalidPath = '/#@ some/path'; const invalidUrl = `${url}${invalidPath}`; mock.onGet(invalidUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR, [], {}); await requestCommits(1, 'my-project', invalidPath); - expect(createFlash).toHaveBeenCalledWith({ message: I18N_COMMIT_DATA_FETCH_ERROR }); + expect(createAlert).toHaveBeenCalledWith({ message: I18N_COMMIT_DATA_FETCH_ERROR }); }); }); diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap index 01494cb6a24..6fe60f3c2e6 100644 --- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap @@ -7,7 +7,7 @@ exports[`Repository last commit component renders commit widget 1`] = ` <user-avatar-link-stub class="gl-my-2 gl-mr-4" imgalt="" - imgcssclasses="gl-mr-0!" + imgcssclasses="" imgsize="32" imgsrc="https://test.com" linkhref="/test" diff --git a/spec/frontend/repository/components/blob_controls_spec.js b/spec/frontend/repository/components/blob_controls_spec.js index 6da1861ea7c..0d52542397f 100644 --- a/spec/frontend/repository/components/blob_controls_spec.js +++ b/spec/frontend/repository/components/blob_controls_spec.js @@ -8,9 +8,13 @@ import blobControlsQuery from '~/repository/queries/blob_controls.query.graphql' import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createRouter from '~/repository/router'; import { updateElementsVisibility } from '~/repository/utils/dom'; +import ShortcutsBlob from '~/behaviors/shortcuts/shortcuts_blob'; +import BlobLinePermalinkUpdater from '~/blob/blob_line_permalink_updater'; import { blobControlsDataMock, refMock } from '../mock_data'; jest.mock('~/repository/utils/dom'); +jest.mock('~/behaviors/shortcuts/shortcuts_blob'); +jest.mock('~/blob/blob_line_permalink_updater'); let router; let wrapper; @@ -82,4 +86,12 @@ describe('Blob controls component', () => { expect(updateElementsVisibility).toHaveBeenCalledWith('.tree-controls', true); }, ); + + it('loads the ShortcutsBlob', () => { + expect(ShortcutsBlob).toHaveBeenCalled(); + }); + + it('loads the BlobLinePermalinkUpdater', () => { + expect(BlobLinePermalinkUpdater).toHaveBeenCalled(); + }); }); diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js index bf9528953b6..964b135bee3 100644 --- a/spec/frontend/repository/components/last_commit_spec.js +++ b/spec/frontend/repository/components/last_commit_spec.js @@ -185,7 +185,7 @@ describe('Repository last commit component', () => { it('strips the first newline of the description', () => { expect(findCommitRowDescription().html()).toBe( - '<pre class="commit-row-description gl-mb-3">Update ADOPTERS.md</pre>', + '<pre class="commit-row-description gl-mb-3 gl-white-space-pre-line">Update ADOPTERS.md</pre>', ); }); diff --git a/spec/frontend/repository/components/new_directory_modal_spec.js b/spec/frontend/repository/components/new_directory_modal_spec.js index aaf751a9a8d..cf0d48280f4 100644 --- a/spec/frontend/repository/components/new_directory_modal_spec.js +++ b/spec/frontend/repository/components/new_directory_modal_spec.js @@ -4,7 +4,7 @@ import { nextTick } from 'vue'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import httpStatusCodes from '~/lib/utils/http_status'; import { visitUrl } from '~/lib/utils/url_utility'; import NewDirectoryModal from '~/repository/components/new_directory_modal.vue'; @@ -194,7 +194,7 @@ describe('NewDirectoryModal', () => { await fillForm({ dirName: 'foo', branchName: 'master', commitMessage: 'foo' }); await submitForm(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: NewDirectoryModal.i18n.ERROR_MESSAGE, }); }); diff --git a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap index 26064e9b248..b99d741e984 100644 --- a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap +++ b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap @@ -40,10 +40,10 @@ exports[`Repository table row component renders a symlink table row 1`] = ` </td> <td - class="d-none d-sm-table-cell tree-commit cursor-default" + class="d-none d-sm-table-cell tree-commit cursor-default gl-text-secondary" > <gl-link-stub - class="str-truncated-100 tree-commit-link" + class="str-truncated-100 tree-commit-link gl-text-secondary" /> <gl-intersection-observer-stub> @@ -52,7 +52,7 @@ exports[`Repository table row component renders a symlink table row 1`] = ` </td> <td - class="tree-time-ago text-right cursor-default" + class="tree-time-ago text-right cursor-default gl-text-secondary" > <timeago-tooltip-stub cssclass="" @@ -105,10 +105,10 @@ exports[`Repository table row component renders table row 1`] = ` </td> <td - class="d-none d-sm-table-cell tree-commit cursor-default" + class="d-none d-sm-table-cell tree-commit cursor-default gl-text-secondary" > <gl-link-stub - class="str-truncated-100 tree-commit-link" + class="str-truncated-100 tree-commit-link gl-text-secondary" /> <gl-intersection-observer-stub> @@ -117,7 +117,7 @@ exports[`Repository table row component renders table row 1`] = ` </td> <td - class="tree-time-ago text-right cursor-default" + class="tree-time-ago text-right cursor-default gl-text-secondary" > <timeago-tooltip-stub cssclass="" @@ -170,10 +170,10 @@ exports[`Repository table row component renders table row for path with special </td> <td - class="d-none d-sm-table-cell tree-commit cursor-default" + class="d-none d-sm-table-cell tree-commit cursor-default gl-text-secondary" > <gl-link-stub - class="str-truncated-100 tree-commit-link" + class="str-truncated-100 tree-commit-link gl-text-secondary" /> <gl-intersection-observer-stub> @@ -182,7 +182,7 @@ exports[`Repository table row component renders table row for path with special </td> <td - class="tree-time-ago text-right cursor-default" + class="tree-time-ago text-right cursor-default gl-text-secondary" > <timeago-tooltip-stub cssclass="" diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js index 697d2dcc7f5..2180f78a8df 100644 --- a/spec/frontend/repository/components/table/index_spec.js +++ b/spec/frontend/repository/components/table/index_spec.js @@ -159,7 +159,7 @@ describe('Repository table component', () => { }); describe('Show more button', () => { - const showMoreButton = () => vm.find(GlButton); + const showMoreButton = () => vm.findComponent(GlButton); it.each` hasMore | expectButtonToExist diff --git a/spec/frontend/repository/components/table/parent_row_spec.js b/spec/frontend/repository/components/table/parent_row_spec.js index 9daae8c36ef..03fb4242e40 100644 --- a/spec/frontend/repository/components/table/parent_row_spec.js +++ b/spec/frontend/repository/components/table/parent_row_spec.js @@ -39,7 +39,7 @@ describe('Repository parent row component', () => { `('renders link in $path to $to', ({ path, to }) => { factory(path); - expect(vm.find(RouterLinkStub).props().to).toEqual({ + expect(vm.findComponent(RouterLinkStub).props().to).toEqual({ path: to, }); }); @@ -69,6 +69,6 @@ describe('Repository parent row component', () => { it('renders loading icon when loading parent', () => { factory('app/assets', 'app'); - expect(vm.find(GlLoadingIcon).exists()).toBe(true); + expect(vm.findComponent(GlLoadingIcon).exists()).toBe(true); }); }); diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js index 13b09e57473..64aa6d179a8 100644 --- a/spec/frontend/repository/components/table/row_spec.js +++ b/spec/frontend/repository/components/table/row_spec.js @@ -47,7 +47,7 @@ function factory(propsData = {}) { } describe('Repository table row component', () => { - const findRouterLink = () => vm.find(RouterLinkStub); + const findRouterLink = () => vm.findComponent(RouterLinkStub); const findIntersectionObserver = () => vm.findComponent(GlIntersectionObserver); afterEach(() => { @@ -124,7 +124,7 @@ describe('Repository table row component', () => { }); await nextTick(); - expect(vm.find(component).exists()).toBe(true); + expect(vm.findComponent(component).exists()).toBe(true); }); it.each` @@ -141,7 +141,7 @@ describe('Repository table row component', () => { }); await nextTick(); - expect(vm.find({ ref: 'link' }).props('to')).toEqual({ + expect(vm.findComponent({ ref: 'link' }).props('to')).toEqual({ path: `/-/tree/main/${encodeURIComponent(path)}`, }); }); @@ -197,7 +197,7 @@ describe('Repository table row component', () => { }); await nextTick(); - expect(vm.find(GlBadge).exists()).toBe(true); + expect(vm.findComponent(GlBadge).exists()).toBe(true); }); it('renders commit and web links with href for submodule', async () => { @@ -213,7 +213,7 @@ describe('Repository table row component', () => { await nextTick(); expect(vm.find('a').attributes('href')).toEqual('https://test.com'); - expect(vm.find(GlLink).attributes('href')).toEqual('https://test.com/commit'); + expect(vm.findComponent(GlLink).attributes('href')).toEqual('https://test.com/commit'); }); it('renders lock icon', async () => { @@ -226,8 +226,8 @@ describe('Repository table row component', () => { }); await nextTick(); - expect(vm.find(GlIcon).exists()).toBe(true); - expect(vm.find(GlIcon).props('name')).toBe('lock'); + expect(vm.findComponent(GlIcon).exists()).toBe(true); + expect(vm.findComponent(GlIcon).props('name')).toBe('lock'); }); it('renders loading icon when path is loading', () => { @@ -240,7 +240,7 @@ describe('Repository table row component', () => { loadingPath: 'test', }); - expect(vm.find(FileIcon).props('loading')).toBe(true); + expect(vm.findComponent(FileIcon).props('loading')).toBe(true); }); describe('row visibility', () => { diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js index 9d3a5394df8..352f4314232 100644 --- a/spec/frontend/repository/components/tree_content_spec.js +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -38,7 +38,7 @@ function factory(path, data = () => ({})) { } describe('Repository table component', () => { - const findFileTable = () => vm.find(FileTable); + const findFileTable = () => vm.findComponent(FileTable); afterEach(() => { vm.destroy(); @@ -53,7 +53,7 @@ describe('Repository table component', () => { await nextTick(); - expect(vm.find(FilePreview).exists()).toBe(true); + expect(vm.findComponent(FilePreview).exists()).toBe(true); }); it('trigger fetchFiles and resetRequestedCommits when mounted', async () => { diff --git a/spec/frontend/repository/components/upload_blob_modal_spec.js b/spec/frontend/repository/components/upload_blob_modal_spec.js index 505ff7f3dd6..8db169b02b4 100644 --- a/spec/frontend/repository/components/upload_blob_modal_spec.js +++ b/spec/frontend/repository/components/upload_blob_modal_spec.js @@ -4,7 +4,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import httpStatusCodes from '~/lib/utils/http_status'; import { visitUrl } from '~/lib/utils/url_utility'; import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; @@ -47,12 +47,12 @@ describe('UploadBlobModal', () => { }); }; - const findModal = () => wrapper.find(GlModal); - const findAlert = () => wrapper.find(GlAlert); - const findCommitMessage = () => wrapper.find(GlFormTextarea); - const findBranchName = () => wrapper.find(GlFormInput); - const findMrToggle = () => wrapper.find(GlToggle); - const findUploadDropzone = () => wrapper.find(UploadDropzone); + const findModal = () => wrapper.findComponent(GlModal); + const findAlert = () => wrapper.findComponent(GlAlert); + const findCommitMessage = () => wrapper.findComponent(GlFormTextarea); + const findBranchName = () => wrapper.findComponent(GlFormInput); + const findMrToggle = () => wrapper.findComponent(GlToggle); + const findUploadDropzone = () => wrapper.findComponent(UploadDropzone); const actionButtonDisabledState = () => findModal().props('actionPrimary').attributes[0].disabled; const cancelButtonDisabledState = () => findModal().props('actionCancel').attributes[0].disabled; const actionButtonLoadingState = () => findModal().props('actionPrimary').attributes[0].loading; @@ -185,7 +185,7 @@ describe('UploadBlobModal', () => { }); it('creates a flash error', () => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'Error uploading file. Please try again.', }); }); diff --git a/spec/frontend/repository/pages/blob_spec.js b/spec/frontend/repository/pages/blob_spec.js index 41ab4d616b8..4fe6188370e 100644 --- a/spec/frontend/repository/pages/blob_spec.js +++ b/spec/frontend/repository/pages/blob_spec.js @@ -7,7 +7,7 @@ jest.mock('~/repository/utils/dom'); describe('Repository blob page component', () => { let wrapper; - const findBlobContentViewer = () => wrapper.find(BlobContentViewer); + const findBlobContentViewer = () => wrapper.findComponent(BlobContentViewer); const path = 'file.js'; beforeEach(() => { diff --git a/spec/frontend/repository/pages/index_spec.js b/spec/frontend/repository/pages/index_spec.js index c0afb7931b1..559257d414c 100644 --- a/spec/frontend/repository/pages/index_spec.js +++ b/spec/frontend/repository/pages/index_spec.js @@ -34,7 +34,7 @@ describe('Repository index page component', () => { it('renders TreePage', () => { factory(); - const child = wrapper.find(TreePage); + const child = wrapper.findComponent(TreePage); expect(child.exists()).toBe(true); expect(child.props()).toEqual({ path: '/' }); 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 7ab4aeee9bc..64f66d8f3ba 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 @@ -104,6 +104,10 @@ describe('AdminRunnerShowApp', () => { Platform darwin Configuration Runs untagged jobs Maximum job timeout None + Token expiry + Runner authentication token expiration + Runner authentication tokens will expire based on a set interval. + They will automatically rotate once expired. Learn more Never expires Tags None`.replace(/\s+/g, ' '); expect(wrapper.text().replace(/\s+/g, ' ')).toContain(expected); 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 55a298e1695..7afde3bdc96 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -20,8 +20,6 @@ import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue'; import RunnerStackedLayoutBanner from '~/runner/components/runner_stacked_layout_banner.vue'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; -import RunnerBulkDelete from '~/runner/components/runner_bulk_delete.vue'; -import RunnerBulkDeleteCheckbox from '~/runner/components/runner_bulk_delete_checkbox.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'; @@ -45,6 +43,7 @@ import { PARAM_KEY_STATUS, PARAM_KEY_TAG, STATUS_ONLINE, + DEFAULT_MEMBERSHIP, RUNNER_PAGE_SIZE, } from '~/runner/constants'; import allRunnersQuery from 'ee_else_ce/runner/graphql/list/all_runners.query.graphql'; @@ -83,8 +82,6 @@ const COUNT_QUERIES = 7; // 4 tabs + 3 status queries describe('AdminRunnersApp', () => { let wrapper; - let cacheConfig; - let localMutations; let showToast; const findRunnerStackedLayoutBanner = () => wrapper.findComponent(RunnerStackedLayoutBanner); @@ -92,8 +89,6 @@ describe('AdminRunnersApp', () => { const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell); const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); - const findRunnerBulkDelete = () => wrapper.findComponent(RunnerBulkDelete); - const findRunnerBulkDeleteCheckbox = () => wrapper.findComponent(RunnerBulkDeleteCheckbox); const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); @@ -106,7 +101,7 @@ describe('AdminRunnersApp', () => { provide, ...options } = {}) => { - ({ cacheConfig, localMutations } = createLocalState()); + const { cacheConfig, localMutations } = createLocalState(); const handlers = [ [allRunnersQuery, mockRunnersHandler], @@ -195,7 +190,7 @@ describe('AdminRunnersApp', () => { const { id, shortSha } = mockRunners[0]; const numericId = getIdFromGraphQLId(id); - const runnerLink = wrapper.find('tr [data-testid="td-summary"]').find(GlLink); + const runnerLink = wrapper.find('tr [data-testid="td-summary"]').findComponent(GlLink); expect(runnerLink.text()).toBe(`#${numericId} (${shortSha})`); expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${numericId}`); @@ -204,7 +199,9 @@ describe('AdminRunnersApp', () => { it('renders runner actions for each runner', async () => { await createComponent({ mountFn: mountExtended }); - const runnerActions = wrapper.find('tr [data-testid="td-actions"]').find(RunnerActionsCell); + const runnerActions = wrapper + .find('tr [data-testid="td-actions"]') + .findComponent(RunnerActionsCell); const runner = mockRunners[0]; expect(runnerActions.props()).toEqual({ @@ -219,6 +216,7 @@ describe('AdminRunnersApp', () => { expect(mockRunnersHandler).toHaveBeenLastCalledWith({ status: undefined, type: undefined, + membership: DEFAULT_MEMBERSHIP, sort: DEFAULT_SORT, first: RUNNER_PAGE_SIZE, }); @@ -255,7 +253,7 @@ describe('AdminRunnersApp', () => { }); it('Links to the runner page', async () => { - const runnerLink = wrapper.find('tr [data-testid="td-summary"]').find(GlLink); + const runnerLink = wrapper.find('tr [data-testid="td-summary"]').findComponent(GlLink); expect(runnerLink.text()).toBe(`#${id} (${shortSha})`); expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${id}`); @@ -288,6 +286,7 @@ describe('AdminRunnersApp', () => { it('sets the filters in the search bar', () => { expect(findRunnerFilteredSearchBar().props('value')).toEqual({ runnerType: INSTANCE_TYPE, + membership: DEFAULT_MEMBERSHIP, filters: [ { type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }, { type: PARAM_KEY_PAUSED, value: { data: 'true', operator: '=' } }, @@ -301,6 +300,7 @@ describe('AdminRunnersApp', () => { expect(mockRunnersHandler).toHaveBeenLastCalledWith({ status: STATUS_ONLINE, type: INSTANCE_TYPE, + membership: DEFAULT_MEMBERSHIP, paused: true, sort: DEFAULT_SORT, first: RUNNER_PAGE_SIZE, @@ -310,6 +310,7 @@ describe('AdminRunnersApp', () => { it('fetches count results for requested status', () => { expect(mockRunnersCountHandler).toHaveBeenCalledWith({ type: INSTANCE_TYPE, + membership: DEFAULT_MEMBERSHIP, status: STATUS_ONLINE, paused: true, }); @@ -322,6 +323,7 @@ describe('AdminRunnersApp', () => { findRunnerFilteredSearchBar().vm.$emit('input', { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], sort: CREATED_ASC, }); @@ -339,6 +341,7 @@ describe('AdminRunnersApp', () => { it('requests the runners with filters', () => { expect(mockRunnersHandler).toHaveBeenLastCalledWith({ status: STATUS_ONLINE, + membership: DEFAULT_MEMBERSHIP, sort: CREATED_ASC, first: RUNNER_PAGE_SIZE, }); @@ -347,6 +350,7 @@ describe('AdminRunnersApp', () => { it('fetches count results for requested status', () => { expect(mockRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_ONLINE, + membership: DEFAULT_MEMBERSHIP, }); }); }); @@ -357,65 +361,26 @@ describe('AdminRunnersApp', () => { expect(findRunnerPagination().attributes('disabled')).toBe('true'); }); - describe('when bulk delete is enabled', () => { + describe('Bulk delete', () => { describe('Before runners are deleted', () => { beforeEach(async () => { - await createComponent({ - mountFn: mountExtended, - provide: { - glFeatures: { adminRunnersBulkDelete: true }, - }, - }); - }); - - it('runner bulk delete is available', () => { - expect(findRunnerBulkDelete().props('runners')).toEqual(mockRunners); - }); - - it('runner bulk delete checkbox is available', () => { - expect(findRunnerBulkDeleteCheckbox().props('runners')).toEqual(mockRunners); + await createComponent({ mountFn: mountExtended }); }); it('runner list is checkable', () => { expect(findRunnerList().props('checkable')).toBe(true); }); - - it('responds to checked items by updating the local cache', () => { - const setRunnerCheckedMock = jest - .spyOn(localMutations, 'setRunnerChecked') - .mockImplementation(() => {}); - - const runner = mockRunners[0]; - - expect(setRunnerCheckedMock).toHaveBeenCalledTimes(0); - - findRunnerList().vm.$emit('checked', { - runner, - isChecked: true, - }); - - expect(setRunnerCheckedMock).toHaveBeenCalledTimes(1); - expect(setRunnerCheckedMock).toHaveBeenCalledWith({ - runner, - isChecked: true, - }); - }); }); describe('When runners are deleted', () => { beforeEach(async () => { - await createComponent({ - mountFn: mountExtended, - provide: { - glFeatures: { adminRunnersBulkDelete: true }, - }, - }); + await createComponent({ mountFn: mountExtended }); }); it('count data is refetched', async () => { expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); - findRunnerBulkDelete().vm.$emit('deleted', { message: 'Runners deleted' }); + findRunnerList().vm.$emit('deleted', { message: 'Runners deleted' }); expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES * 2); }); @@ -423,7 +388,7 @@ describe('AdminRunnersApp', () => { it('toast is shown', async () => { expect(showToast).toHaveBeenCalledTimes(0); - findRunnerBulkDelete().vm.$emit('deleted', { message: 'Runners deleted' }); + findRunnerList().vm.$emit('deleted', { message: 'Runners deleted' }); expect(showToast).toHaveBeenCalledTimes(1); expect(showToast).toHaveBeenCalledWith('Runners deleted'); @@ -457,6 +422,7 @@ describe('AdminRunnersApp', () => { beforeEach(async () => { findRunnerFilteredSearchBar().vm.$emit('input', { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], sort: CREATED_ASC, }); @@ -504,6 +470,7 @@ describe('AdminRunnersApp', () => { await findRunnerPaginationNext().trigger('click'); expect(mockRunnersHandler).toHaveBeenLastCalledWith({ + membership: DEFAULT_MEMBERSHIP, sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, after: pageInfo.endCursor, diff --git a/spec/frontend/runner/components/cells/link_cell_spec.js b/spec/frontend/runner/components/cells/link_cell_spec.js index a59a0eaa5d8..46ab1adb6b6 100644 --- a/spec/frontend/runner/components/cells/link_cell_spec.js +++ b/spec/frontend/runner/components/cells/link_cell_spec.js @@ -5,7 +5,7 @@ import LinkCell from '~/runner/components/cells/link_cell.vue'; describe('LinkCell', () => { let wrapper; - const findGlLink = () => wrapper.find(GlLink); + const findGlLink = () => wrapper.findComponent(GlLink); const findSpan = () => wrapper.find('span'); const createComponent = ({ props = {}, ...options } = {}) => { diff --git a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js index ffd6f126627..58974d4f85f 100644 --- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js @@ -122,7 +122,7 @@ describe('RunnerActionsCell', () => { expect(wrapper.emitted('deleted')).toEqual([[value]]); }); - it('Renders the runner delete disabled button when user cannot delete', () => { + it('Does not render the runner delete button when user cannot delete', () => { createComponent({ runner: { userPermissions: { @@ -132,7 +132,7 @@ describe('RunnerActionsCell', () => { }, }); - expect(findDeleteBtn().props('disabled')).toBe(true); + expect(findDeleteBtn().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/runner/components/cells/runner_owner_cell_spec.js b/spec/frontend/runner/components/cells/runner_owner_cell_spec.js new file mode 100644 index 00000000000..e9965d8855d --- /dev/null +++ b/spec/frontend/runner/components/cells/runner_owner_cell_spec.js @@ -0,0 +1,111 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +import RunnerOwnerCell from '~/runner/components/cells/runner_owner_cell.vue'; + +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; + +describe('RunnerOwnerCell', () => { + let wrapper; + + const findLink = () => wrapper.findComponent(GlLink); + const getLinkTooltip = () => getBinding(findLink().element, 'gl-tooltip').value; + + const createComponent = ({ runner } = {}) => { + wrapper = shallowMount(RunnerOwnerCell, { + directives: { + GlTooltip: createMockDirective(), + }, + propsData: { + runner, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('When its an instance runner', () => { + beforeEach(() => { + createComponent({ + runner: { + runnerType: INSTANCE_TYPE, + }, + }); + }); + + it('shows an administrator label', () => { + expect(findLink().exists()).toBe(false); + expect(wrapper.text()).toBe(s__('Runners|Administrator')); + }); + }); + + describe('When its a group runner', () => { + const mockName = 'Group 2'; + const mockFullName = 'Group 1 / Group 2'; + const mockWebUrl = '/group-1/group-2'; + + beforeEach(() => { + createComponent({ + runner: { + runnerType: GROUP_TYPE, + groups: { + nodes: [ + { + name: mockName, + fullName: mockFullName, + webUrl: mockWebUrl, + }, + ], + }, + }, + }); + }); + + it('Displays a group link', () => { + expect(findLink().attributes('href')).toBe(mockWebUrl); + expect(wrapper.text()).toBe(mockName); + expect(getLinkTooltip()).toBe(mockFullName); + }); + }); + + describe('When its a project runner', () => { + const mockName = 'Project 1'; + const mockNameWithNamespace = 'Group 1 / Project 1'; + const mockWebUrl = '/group-1/project-1'; + + beforeEach(() => { + createComponent({ + runner: { + runnerType: PROJECT_TYPE, + ownerProject: { + name: mockName, + nameWithNamespace: mockNameWithNamespace, + webUrl: mockWebUrl, + }, + }, + }); + }); + + it('Displays a project link', () => { + expect(findLink().attributes('href')).toBe(mockWebUrl); + expect(wrapper.text()).toBe(mockName); + expect(getLinkTooltip()).toBe(mockNameWithNamespace); + }); + }); + + describe('When its an empty runner', () => { + beforeEach(() => { + createComponent({ + runner: {}, + }); + }); + + it('shows no label', () => { + expect(wrapper.text()).toBe(''); + }); + }); +}); diff --git a/spec/frontend/runner/components/cells/runner_stacked_summary_cell_spec.js b/spec/frontend/runner/components/cells/runner_stacked_summary_cell_spec.js index 21ec9f61f37..e7cadefc140 100644 --- a/spec/frontend/runner/components/cells/runner_stacked_summary_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_stacked_summary_cell_spec.js @@ -85,7 +85,7 @@ describe('RunnerTypeCell', () => { contactedAt: '2022-01-02', }); - expect(findRunnerSummaryField('clock').find(TimeAgo).props('time')).toBe('2022-01-02'); + expect(findRunnerSummaryField('clock').findComponent(TimeAgo).props('time')).toBe('2022-01-02'); }); it('Displays empty last contact', () => { @@ -93,7 +93,7 @@ describe('RunnerTypeCell', () => { contactedAt: null, }); - expect(findRunnerSummaryField('clock').find(TimeAgo).exists()).toBe(false); + expect(findRunnerSummaryField('clock').findComponent(TimeAgo).exists()).toBe(false); expect(findRunnerSummaryField('clock').text()).toContain(__('Never')); }); @@ -134,7 +134,7 @@ describe('RunnerTypeCell', () => { }); it('Displays created at', () => { - expect(findRunnerSummaryField('calendar').find(TimeAgo).props('time')).toBe( + expect(findRunnerSummaryField('calendar').findComponent(TimeAgo).props('time')).toBe( mockRunner.createdAt, ); }); diff --git a/spec/frontend/runner/components/runner_bulk_delete_checkbox_spec.js b/spec/frontend/runner/components/runner_bulk_delete_checkbox_spec.js index 0ac89e82314..424a4e61ccd 100644 --- a/spec/frontend/runner/components/runner_bulk_delete_checkbox_spec.js +++ b/spec/frontend/runner/components/runner_bulk_delete_checkbox_spec.js @@ -5,11 +5,21 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import RunnerBulkDeleteCheckbox from '~/runner/components/runner_bulk_delete_checkbox.vue'; import createMockApollo from 'helpers/mock_apollo_helper'; import { createLocalState } from '~/runner/graphql/list/local_state'; -import { allRunnersData } from '../mock_data'; Vue.use(VueApollo); -jest.mock('~/flash'); +const makeRunner = (id, deleteRunner = true) => ({ + id, + userPermissions: { deleteRunner }, +}); + +// Multi-select checkbox possible states: +const stateToAttrs = { + unchecked: { disabled: undefined, checked: undefined, indeterminate: undefined }, + checked: { disabled: undefined, checked: 'true', indeterminate: undefined }, + indeterminate: { disabled: undefined, checked: undefined, indeterminate: 'true' }, + disabled: { disabled: 'true', checked: undefined, indeterminate: undefined }, +}; describe('RunnerBulkDeleteCheckbox', () => { let wrapper; @@ -18,12 +28,14 @@ describe('RunnerBulkDeleteCheckbox', () => { const findCheckbox = () => wrapper.findComponent(GlFormCheckbox); - const mockRunners = allRunnersData.data.runners.nodes; - const mockIds = allRunnersData.data.runners.nodes.map(({ id }) => id); - const mockId = mockIds[0]; - const mockIdAnotherPage = 'RUNNER_IN_ANOTHER_PAGE_ID'; + const expectCheckboxToBe = (state) => { + const expected = stateToAttrs[state]; + expect(findCheckbox().attributes('disabled')).toBe(expected.disabled); + expect(findCheckbox().attributes('checked')).toBe(expected.checked); + expect(findCheckbox().attributes('indeterminate')).toBe(expected.indeterminate); + }; - const createComponent = ({ props = {} } = {}) => { + const createComponent = ({ runners = [] } = {}) => { const { cacheConfig, localMutations } = mockState; const apolloProvider = createMockApollo(undefined, undefined, cacheConfig); @@ -33,8 +45,7 @@ describe('RunnerBulkDeleteCheckbox', () => { localMutations, }, propsData: { - runners: mockRunners, - ...props, + runners, }, }); }; @@ -49,31 +60,61 @@ describe('RunnerBulkDeleteCheckbox', () => { jest.spyOn(mockState.localMutations, 'setRunnersChecked'); }); - describe.each` - case | is | checkedRunnerIds | disabled | checked | indeterminate - ${'no runners'} | ${'unchecked'} | ${[]} | ${undefined} | ${undefined} | ${undefined} - ${'no runners in this page'} | ${'unchecked'} | ${[mockIdAnotherPage]} | ${undefined} | ${undefined} | ${undefined} - ${'all runners'} | ${'checked'} | ${mockIds} | ${undefined} | ${'true'} | ${undefined} - ${'some runners'} | ${'indeterminate'} | ${[mockId]} | ${undefined} | ${undefined} | ${'true'} - ${'all plus other runners'} | ${'checked'} | ${[...mockIds, mockIdAnotherPage]} | ${undefined} | ${'true'} | ${undefined} - `('When $case are checked', ({ is, checkedRunnerIds, disabled, checked, indeterminate }) => { - beforeEach(async () => { + describe('when all runners can be deleted', () => { + const mockIds = ['1', '2', '3']; + const mockIdAnotherPage = '4'; + const mockRunners = mockIds.map((id) => makeRunner(id)); + + it.each` + case | checkedRunnerIds | state + ${'no runners'} | ${[]} | ${'unchecked'} + ${'no runners in this page'} | ${[mockIdAnotherPage]} | ${'unchecked'} + ${'all runners'} | ${mockIds} | ${'checked'} + ${'some runners'} | ${[mockIds[0]]} | ${'indeterminate'} + ${'all plus other runners'} | ${[...mockIds, mockIdAnotherPage]} | ${'checked'} + `('if $case are checked, checkbox is $state', ({ checkedRunnerIds, state }) => { mockCheckedRunnerIds = checkedRunnerIds; - createComponent(); + createComponent({ runners: mockRunners }); + expectCheckboxToBe(state); }); + }); + + describe('when some runners cannot be deleted', () => { + it('all allowed runners are selected, checkbox is checked', () => { + mockCheckedRunnerIds = ['a', 'b', 'c']; + createComponent({ + runners: [makeRunner('a'), makeRunner('b'), makeRunner('c', false)], + }); - it(`is ${is}`, () => { - expect(findCheckbox().attributes('disabled')).toBe(disabled); - expect(findCheckbox().attributes('checked')).toBe(checked); - expect(findCheckbox().attributes('indeterminate')).toBe(indeterminate); + expectCheckboxToBe('checked'); + }); + + it('some allowed runners are selected, checkbox is indeterminate', () => { + mockCheckedRunnerIds = ['a', 'b']; + createComponent({ + runners: [makeRunner('a'), makeRunner('b'), makeRunner('c')], + }); + + expectCheckboxToBe('indeterminate'); + }); + + it('no allowed runners are selected, checkbox is disabled', () => { + mockCheckedRunnerIds = ['a', 'b']; + createComponent({ + runners: [makeRunner('a', false), makeRunner('b', false)], + }); + + expectCheckboxToBe('disabled'); }); }); describe('When user selects', () => { + const mockRunners = [makeRunner('1'), makeRunner('2')]; + beforeEach(() => { - mockCheckedRunnerIds = mockIds; - createComponent(); + mockCheckedRunnerIds = ['1', '2']; + createComponent({ runners: mockRunners }); }); it.each([[true], [false]])('sets checked to %s', (checked) => { @@ -89,13 +130,11 @@ describe('RunnerBulkDeleteCheckbox', () => { describe('When runners are loading', () => { beforeEach(() => { - createComponent({ props: { runners: [] } }); + createComponent(); }); - it(`is disabled`, () => { - expect(findCheckbox().attributes('disabled')).toBe('true'); - expect(findCheckbox().attributes('checked')).toBe(undefined); - expect(findCheckbox().attributes('indeterminate')).toBe(undefined); + it('is disabled', () => { + expectCheckboxToBe('disabled'); }); }); }); diff --git a/spec/frontend/runner/components/runner_delete_button_spec.js b/spec/frontend/runner/components/runner_delete_button_spec.js index 52fe803c536..c8fb7a69379 100644 --- a/spec/frontend/runner/components/runner_delete_button_spec.js +++ b/spec/frontend/runner/components/runner_delete_button_spec.js @@ -9,11 +9,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { captureException } from '~/runner/sentry_utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { createAlert } from '~/flash'; -import { - I18N_DELETE_RUNNER, - I18N_DELETE_DISABLED_MANY_PROJECTS, - I18N_DELETE_DISABLED_UNKNOWN_REASON, -} from '~/runner/constants'; +import { I18N_DELETE_RUNNER } from '~/runner/constants'; import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue'; import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue'; @@ -267,29 +263,4 @@ describe('RunnerDeleteButton', () => { }); }); }); - - describe.each` - reason | runner | tooltip - ${'runner belongs to more than 1 project'} | ${{ projectCount: 2 }} | ${I18N_DELETE_DISABLED_MANY_PROJECTS} - ${'unknown reason'} | ${{}} | ${I18N_DELETE_DISABLED_UNKNOWN_REASON} - `('When button is disabled because $reason', ({ runner, tooltip }) => { - beforeEach(() => { - createComponent({ - props: { - disabled: true, - runner, - }, - }); - }); - - it('Displays a disabled delete button', () => { - expect(findBtn().props('disabled')).toBe(true); - }); - - it(`Tooltip "${tooltip}" is shown`, () => { - // tabindex is required for a11y - expect(wrapper.attributes('tabindex')).toBe('0'); - expect(getTooltip()).toBe(tooltip); - }); - }); }); diff --git a/spec/frontend/runner/components/runner_details_spec.js b/spec/frontend/runner/components/runner_details_spec.js index f2281223a25..e6cc936e260 100644 --- a/spec/frontend/runner/components/runner_details_spec.js +++ b/spec/frontend/runner/components/runner_details_spec.js @@ -25,12 +25,7 @@ describe('RunnerDetails', () => { const findDetailGroups = () => wrapper.findComponent(RunnerGroups); - const createComponent = ({ - props = {}, - stubs, - mountFn = shallowMountExtended, - enforceRunnerTokenExpiresAt = false, - } = {}) => { + const createComponent = ({ props = {}, stubs, mountFn = shallowMountExtended } = {}) => { wrapper = mountFn(RunnerDetails, { propsData: { ...props, @@ -39,9 +34,6 @@ describe('RunnerDetails', () => { RunnerDetail, ...stubs, }, - provide: { - glFeatures: { enforceRunnerTokenExpiresAt }, - }, }); }; @@ -82,7 +74,6 @@ describe('RunnerDetails', () => { ...runner, }, }, - enforceRunnerTokenExpiresAt: true, stubs: { GlIntersperse, GlSprintf, @@ -135,22 +126,5 @@ describe('RunnerDetails', () => { expect(findDetailGroups().props('runner')).toEqual(mockGroupRunner); }); }); - - describe('Token expiration field', () => { - it.each` - case | flag | shown - ${'is shown when feature flag is enabled'} | ${true} | ${true} - ${'is not shown when feature flag is disabled'} | ${false} | ${false} - `('$case', ({ flag, shown }) => { - createComponent({ - props: { - runner: mockGroupRunner, - }, - enforceRunnerTokenExpiresAt: flag, - }); - - expect(findDd('Token expiry', wrapper).exists()).toBe(shown); - }); - }); }); }); diff --git a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js index e35bec3aa38..c92e19f9263 100644 --- a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js +++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js @@ -4,10 +4,26 @@ import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_ import { statusTokenConfig } from '~/runner/components/search_tokens/status_token_config'; import TagToken from '~/runner/components/search_tokens/tag_token.vue'; import { tagTokenConfig } from '~/runner/components/search_tokens/tag_token_config'; -import { PARAM_KEY_STATUS, PARAM_KEY_TAG, STATUS_ONLINE, INSTANCE_TYPE } from '~/runner/constants'; +import { + PARAM_KEY_STATUS, + PARAM_KEY_TAG, + STATUS_ONLINE, + INSTANCE_TYPE, + DEFAULT_MEMBERSHIP, + DEFAULT_SORT, + CONTACTED_DESC, +} from '~/runner/constants'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +const mockSearch = { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [], + pagination: { page: 1 }, + sort: DEFAULT_SORT, +}; + describe('RunnerList', () => { let wrapper; @@ -15,8 +31,7 @@ describe('RunnerList', () => { const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem); - const mockDefaultSort = 'CREATED_DESC'; - const mockOtherSort = 'CONTACTED_DESC'; + const mockOtherSort = CONTACTED_DESC; const mockFilters = [ { type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }, { type: 'filtered-search-term', value: { data: '' } }, @@ -32,11 +47,7 @@ describe('RunnerList', () => { propsData: { namespace: 'runners', tokens: [], - value: { - runnerType: null, - filters: [], - sort: mockDefaultSort, - }, + value: mockSearch, ...props, }, stubs: { @@ -115,6 +126,7 @@ describe('RunnerList', () => { props: { value: { runnerType: INSTANCE_TYPE, + membership: DEFAULT_MEMBERSHIP, sort: mockOtherSort, filters: mockFilters, }, @@ -141,6 +153,7 @@ describe('RunnerList', () => { expectToHaveLastEmittedInput({ runnerType: INSTANCE_TYPE, + membership: DEFAULT_MEMBERSHIP, filters: mockFilters, sort: mockOtherSort, pagination: {}, @@ -154,8 +167,9 @@ describe('RunnerList', () => { expectToHaveLastEmittedInput({ runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: mockFilters, - sort: mockDefaultSort, + sort: DEFAULT_SORT, pagination: {}, }); }); @@ -165,6 +179,7 @@ describe('RunnerList', () => { expectToHaveLastEmittedInput({ runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [], sort: mockOtherSort, pagination: {}, diff --git a/spec/frontend/runner/components/runner_list_empty_state_spec.js b/spec/frontend/runner/components/runner_list_empty_state_spec.js index 59cff863106..038162b889e 100644 --- a/spec/frontend/runner/components/runner_list_empty_state_spec.js +++ b/spec/frontend/runner/components/runner_list_empty_state_spec.js @@ -8,6 +8,7 @@ import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vu const mockSvgPath = 'mock-svg-path.svg'; const mockFilteredSvgPath = 'mock-filtered-svg-path.svg'; +const mockRegistrationToken = 'REGISTRATION_TOKEN'; describe('RunnerListEmptyState', () => { let wrapper; @@ -21,6 +22,7 @@ describe('RunnerListEmptyState', () => { propsData: { svgPath: mockSvgPath, filteredSvgPath: mockFilteredSvgPath, + registrationToken: mockRegistrationToken, ...props, }, directives: { @@ -35,27 +37,52 @@ describe('RunnerListEmptyState', () => { }; describe('when search is not filtered', () => { - beforeEach(() => { - createComponent(); - }); + const title = s__('Runners|Get started with runners'); - it('renders an illustration', () => { - expect(findEmptyState().props('svgPath')).toBe(mockSvgPath); - }); + describe('when there is a registration token', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders an illustration', () => { + expect(findEmptyState().props('svgPath')).toBe(mockSvgPath); + }); + + it('displays "no results" text with instructions', () => { + 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.', + ); - 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}`); + }); - 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); + }); }); - it('opens a runner registration instructions modal with a link', () => { - const { value } = getBinding(findLink().element, 'gl-modal'); + describe('when there is no registration token', () => { + beforeEach(() => { + createComponent({ props: { registrationToken: null } }); + }); + + it('renders an illustration', () => { + expect(findEmptyState().props('svgPath')).toBe(mockSvgPath); + }); + + it('displays "no results" text', () => { + const desc = s__( + 'Runners|Runners are the agents that run your CI/CD jobs. To register new runners, please contact your administrator.', + ); + + expect(findEmptyState().text()).toMatchInterpolatedText(`${title} ${desc}`); + }); - expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); + it('has no registration instructions link', () => { + expect(findLink().exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index 54a9e713721..a31990f8f7e 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -1,12 +1,19 @@ import { GlTableLite, GlSkeletonLoader } from '@gitlab/ui'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; import { extendedWrapper, shallowMountExtended, mountExtended, } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { createLocalState } from '~/runner/graphql/list/local_state'; + import RunnerList from '~/runner/components/runner_list.vue'; -import RunnerStatusPopover from '~/runner/components/runner_status_popover.vue'; +import RunnerBulkDelete from '~/runner/components/runner_bulk_delete.vue'; +import RunnerBulkDeleteCheckbox from '~/runner/components/runner_bulk_delete_checkbox.vue'; + import { I18N_PROJECT_TYPE, I18N_STATUS_NEVER_CONTACTED } from '~/runner/constants'; import { allRunnersData, onlineContactTimeoutSecs, staleTimeoutSecs } from '../mock_data'; @@ -15,6 +22,8 @@ const mockActiveRunnersCount = mockRunners.length; describe('RunnerList', () => { let wrapper; + let cacheConfig; + let localMutations; const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const findTable = () => wrapper.findComponent(GlTableLite); @@ -22,18 +31,24 @@ describe('RunnerList', () => { const findRows = () => wrapper.findAll('[data-testid^="runner-row-"]'); const findCell = ({ row = 0, fieldKey }) => extendedWrapper(findRows().at(row).find(`[data-testid="td-${fieldKey}"]`)); + const findRunnerBulkDelete = () => wrapper.findComponent(RunnerBulkDelete); + const findRunnerBulkDeleteCheckbox = () => wrapper.findComponent(RunnerBulkDeleteCheckbox); const createComponent = ( { props = {}, provide = {}, ...options } = {}, mountFn = shallowMountExtended, ) => { + ({ cacheConfig, localMutations } = createLocalState()); + wrapper = mountFn(RunnerList, { + apolloProvider: createMockApollo([], {}, cacheConfig), propsData: { runners: mockRunners, activeRunnersCount: mockActiveRunnersCount, ...props, }, provide: { + localMutations, onlineContactTimeoutSecs, staleTimeoutSecs, ...provide, @@ -50,7 +65,7 @@ describe('RunnerList', () => { createComponent( { stubs: { - RunnerStatusPopover: { + HelpPopover: { template: '<div/>', }, }, @@ -60,11 +75,13 @@ describe('RunnerList', () => { const headerLabels = findHeaders().wrappers.map((w) => w.text()); - expect(findHeaders().at(0).findComponent(RunnerStatusPopover).exists()).toBe(true); + expect(findHeaders().at(0).findComponent(HelpPopover).exists()).toBe(true); + expect(findHeaders().at(2).findComponent(HelpPopover).exists()).toBe(true); expect(headerLabels).toEqual([ - 'Status', - 'Runner', + s__('Runners|Status'), + s__('Runners|Runner'), + s__('Runners|Owner'), '', // actions has no label ]); }); @@ -123,21 +140,40 @@ describe('RunnerList', () => { ); }); + it('runner bulk delete is available', () => { + expect(findRunnerBulkDelete().props('runners')).toEqual(mockRunners); + }); + + it('runner bulk delete checkbox is available', () => { + expect(findRunnerBulkDeleteCheckbox().props('runners')).toEqual(mockRunners); + }); + it('Displays a checkbox field', () => { expect(findCell({ fieldKey: 'checkbox' }).find('input').exists()).toBe(true); }); - it('Emits a checked event', async () => { - const checkbox = findCell({ fieldKey: 'checkbox' }).find('input'); + it('Sets a runner as checked', async () => { + const runner = mockRunners[0]; + const setRunnerCheckedMock = jest + .spyOn(localMutations, 'setRunnerChecked') + .mockImplementation(() => {}); + const checkbox = findCell({ fieldKey: 'checkbox' }).find('input'); await checkbox.setChecked(); - expect(wrapper.emitted('checked')).toHaveLength(1); - expect(wrapper.emitted('checked')[0][0]).toEqual({ + expect(setRunnerCheckedMock).toHaveBeenCalledTimes(1); + expect(setRunnerCheckedMock).toHaveBeenCalledWith({ + runner, isChecked: true, - runner: mockRunners[0], }); }); + + it('Emits a deleted event', async () => { + const event = { message: 'Deleted!' }; + findRunnerBulkDelete().vm.$emit('deleted', event); + + expect(wrapper.emitted('deleted')).toEqual([[event]]); + }); }); describe('Scoped cell slots', () => { diff --git a/spec/frontend/runner/components/runner_membership_toggle_spec.js b/spec/frontend/runner/components/runner_membership_toggle_spec.js new file mode 100644 index 00000000000..1a7ae22618a --- /dev/null +++ b/spec/frontend/runner/components/runner_membership_toggle_spec.js @@ -0,0 +1,57 @@ +import { GlToggle } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; +import RunnerMembershipToggle from '~/runner/components/runner_membership_toggle.vue'; +import { + I18N_SHOW_ONLY_INHERITED, + MEMBERSHIP_DESCENDANTS, + MEMBERSHIP_ALL_AVAILABLE, +} from '~/runner/constants'; + +describe('RunnerMembershipToggle', () => { + let wrapper; + + const findToggle = () => wrapper.findComponent(GlToggle); + + const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { + wrapper = mountFn(RunnerMembershipToggle, { + propsData: props, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays text', () => { + createComponent({ mountFn: mount }); + + expect(wrapper.text()).toBe(I18N_SHOW_ONLY_INHERITED); + }); + + it.each` + membershipValue | toggleValue + ${MEMBERSHIP_DESCENDANTS} | ${true} + ${MEMBERSHIP_ALL_AVAILABLE} | ${false} + `( + 'Displays a membership of $membershipValue as enabled=$toggleValue', + ({ membershipValue, toggleValue }) => { + createComponent({ props: { value: membershipValue } }); + + expect(findToggle().props('value')).toBe(toggleValue); + }, + ); + + it.each` + changeEvt | membershipValue + ${true} | ${MEMBERSHIP_DESCENDANTS} + ${false} | ${MEMBERSHIP_ALL_AVAILABLE} + `( + 'Emits $changeEvt when value is changed to $membershipValue', + ({ changeEvt, membershipValue }) => { + createComponent(); + findToggle().vm.$emit('change', changeEvt); + + expect(wrapper.emitted('input')).toStrictEqual([[membershipValue]]); + }, + ); +}); diff --git a/spec/frontend/runner/components/runner_stacked_layout_banner_spec.js b/spec/frontend/runner/components/runner_stacked_layout_banner_spec.js index 1a8aced9292..d1f04f0ee37 100644 --- a/spec/frontend/runner/components/runner_stacked_layout_banner_spec.js +++ b/spec/frontend/runner/components/runner_stacked_layout_banner_spec.js @@ -29,6 +29,8 @@ describe('RunnerStackedLayoutBanner', () => { }); it('Does not display a banner when dismissed', async () => { + createComponent(); + findLocalStorageSync().vm.$emit('input', true); await nextTick(); diff --git a/spec/frontend/runner/components/runner_type_tabs_spec.js b/spec/frontend/runner/components/runner_type_tabs_spec.js index 45ab8684332..dde35533bc3 100644 --- a/spec/frontend/runner/components/runner_type_tabs_spec.js +++ b/spec/frontend/runner/components/runner_type_tabs_spec.js @@ -2,9 +2,21 @@ import { GlTab } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; import RunnerCount from '~/runner/components/stat/runner_count.vue'; -import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; - -const mockSearch = { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }; +import { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + DEFAULT_MEMBERSHIP, + DEFAULT_SORT, +} from '~/runner/constants'; + +const mockSearch = { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [], + pagination: { page: 1 }, + sort: DEFAULT_SORT, +}; const mockCount = (type, multiplier = 1) => { let count; @@ -113,7 +125,7 @@ describe('RunnerTypeTabs', () => { }); findTabs().wrappers.forEach((tab) => { - expect(tab.find(RunnerCount).props()).toEqual({ + expect(tab.findComponent(RunnerCount).props()).toEqual({ scope: INSTANCE_TYPE, skip: false, variables: expect.objectContaining(mockVariables), diff --git a/spec/frontend/runner/components/runner_update_form_spec.js b/spec/frontend/runner/components/runner_update_form_spec.js index 7b67a89f989..e12736216a0 100644 --- a/spec/frontend/runner/components/runner_update_form_spec.js +++ b/spec/frontend/runner/components/runner_update_form_spec.js @@ -145,7 +145,7 @@ describe('RunnerUpdateForm', () => { }); it('Form skeleton is shown', () => { - expect(wrapper.find(GlSkeletonLoader).exists()).toBe(true); + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true); expect(findFields()).toHaveLength(0); }); diff --git a/spec/frontend/runner/components/search_tokens/tag_token_spec.js b/spec/frontend/runner/components/search_tokens/tag_token_spec.js index 22f0561ca5f..a7363eb11cd 100644 --- a/spec/frontend/runner/components/search_tokens/tag_token_spec.js +++ b/spec/frontend/runner/components/search_tokens/tag_token_spec.js @@ -77,7 +77,7 @@ describe('TagToken', () => { const findToken = () => wrapper.findComponent(GlToken); const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - beforeEach(async () => { + beforeEach(() => { mock = new MockAdapter(axios); mock.onGet(TAG_SUGGESTIONS_PATH, { params: { search: '' } }).reply(200, mockTags); @@ -86,9 +86,6 @@ describe('TagToken', () => { .reply(200, mockTagsFiltered); getRecentlyUsedSuggestions.mockReturnValue([]); - - createComponent(); - await waitForPromises(); }); afterEach(() => { @@ -97,11 +94,17 @@ describe('TagToken', () => { }); describe('when the tags token is displayed', () => { + beforeEach(() => { + createComponent(); + }); + it('requests tags suggestions', () => { expect(mock.history.get[0].params).toEqual({ search: '' }); }); - it('displays tags suggestions', () => { + it('displays tags suggestions', async () => { + await waitForPromises(); + mockTags.forEach(({ name }, i) => { expect(findGlFilteredSearchSuggestions().at(i).text()).toBe(name); }); @@ -132,13 +135,13 @@ describe('TagToken', () => { }); describe('when the users filters suggestions', () => { - beforeEach(async () => { + beforeEach(() => { + createComponent(); + findGlFilteredSearchToken().vm.$emit('input', { data: mockSearchTerm }); }); - it('requests filtered tags suggestions', async () => { - await waitForPromises(); - + it('requests filtered tags suggestions', () => { expect(mock.history.get[1].params).toEqual({ search: mockSearchTerm }); }); @@ -166,7 +169,7 @@ describe('TagToken', () => { await waitForPromises(); }); - it('error is shown', async () => { + it('error is shown', () => { expect(createAlert).toHaveBeenCalledTimes(1); expect(createAlert).toHaveBeenCalledWith({ message: expect.any(String) }); }); @@ -180,8 +183,26 @@ describe('TagToken', () => { await waitForPromises(); }); - it('selected tag is displayed', async () => { + it('selected tag is displayed', () => { expect(findToken().exists()).toBe(true); }); }); + + describe('when suggestions are disabled', () => { + beforeEach(async () => { + createComponent({ + config: { + ...mockTagTokenConfig, + suggestionsDisabled: true, + }, + }); + + await waitForPromises(); + }); + + it('displays no suggestions', () => { + expect(findGlFilteredSearchSuggestions()).toHaveLength(0); + expect(mock.history.get).toHaveLength(0); + }); + }); }); diff --git a/spec/frontend/runner/graphql/local_state_spec.js b/spec/frontend/runner/graphql/local_state_spec.js index ae874fef00d..915170b53f9 100644 --- a/spec/frontend/runner/graphql/local_state_spec.js +++ b/spec/frontend/runner/graphql/local_state_spec.js @@ -4,6 +4,13 @@ import { createLocalState } from '~/runner/graphql/list/local_state'; import getCheckedRunnerIdsQuery from '~/runner/graphql/list/checked_runner_ids.query.graphql'; import { RUNNER_TYPENAME } from '~/runner/constants'; +const makeRunner = (id, deleteRunner = true) => ({ + id, + userPermissions: { + deleteRunner, + }, +}); + describe('~/runner/graphql/list/local_state', () => { let localState; let apolloClient; @@ -57,16 +64,21 @@ describe('~/runner/graphql/list/local_state', () => { }); it('returns checked runners that have a reference in the cache', () => { - addMockRunnerToCache('a'); - localState.localMutations.setRunnerChecked({ runner: { id: 'a' }, isChecked: true }); + const id = 'a'; + + addMockRunnerToCache(id); + localState.localMutations.setRunnerChecked({ + runner: makeRunner(id), + isChecked: true, + }); expect(queryCheckedRunnerIds()).toEqual(['a']); }); it('return checked runners that are not dangling references', () => { addMockRunnerToCache('a'); // 'b' is missing from the cache, perhaps because it was deleted - localState.localMutations.setRunnerChecked({ runner: { id: 'a' }, isChecked: true }); - localState.localMutations.setRunnerChecked({ runner: { id: 'b' }, isChecked: true }); + localState.localMutations.setRunnerChecked({ runner: makeRunner('a'), isChecked: true }); + localState.localMutations.setRunnerChecked({ runner: makeRunner('b'), isChecked: true }); expect(queryCheckedRunnerIds()).toEqual(['a']); }); @@ -81,7 +93,7 @@ describe('~/runner/graphql/list/local_state', () => { beforeEach(() => { inputs.forEach(([id, isChecked]) => { addMockRunnerToCache(id); - localState.localMutations.setRunnerChecked({ runner: { id }, isChecked }); + localState.localMutations.setRunnerChecked({ runner: makeRunner(id), isChecked }); }); }); it(`for inputs="${inputs}" has a ids="[${expected}]"`, () => { @@ -102,7 +114,7 @@ describe('~/runner/graphql/list/local_state', () => { ids.forEach(addMockRunnerToCache); localState.localMutations.setRunnersChecked({ - runners: ids.map((id) => ({ id })), + runners: ids.map((id) => makeRunner(id)), isChecked, }); }); @@ -117,7 +129,7 @@ describe('~/runner/graphql/list/local_state', () => { it('clears all checked items', () => { ['a', 'b', 'c'].forEach((id) => { addMockRunnerToCache(id); - localState.localMutations.setRunnerChecked({ runner: { id }, isChecked: true }); + localState.localMutations.setRunnerChecked({ runner: makeRunner(id), isChecked: true }); }); expect(queryCheckedRunnerIds()).toEqual(['a', 'b', 'c']); @@ -127,4 +139,29 @@ describe('~/runner/graphql/list/local_state', () => { expect(queryCheckedRunnerIds()).toEqual([]); }); }); + + describe('when some runners cannot be deleted', () => { + beforeEach(() => { + addMockRunnerToCache('a'); + addMockRunnerToCache('b'); + }); + + it('setRunnerChecked does not check runner that cannot be deleted', () => { + localState.localMutations.setRunnerChecked({ + runner: makeRunner('a', false), + isChecked: true, + }); + + expect(queryCheckedRunnerIds()).toEqual([]); + }); + + it('setRunnersChecked does not check runner that cannot be deleted', () => { + localState.localMutations.setRunnersChecked({ + runners: [makeRunner('a', false), makeRunner('b', false)], + isChecked: true, + }); + + expect(queryCheckedRunnerIds()).toEqual([]); + }); + }); }); diff --git a/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js b/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js index cee1d436942..a3b67674c94 100644 --- a/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js +++ b/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js @@ -101,6 +101,11 @@ describe('GroupRunnerShowApp', () => { Platform darwin Configuration Runs untagged jobs Maximum job timeout None + Token expiry + Runner authentication token expiration + Runner authentication tokens will expire based on a set interval. + They will automatically rotate once expired. Learn more + Never expires Tags None`.replace(/\s+/g, ' '); expect(wrapper.text().replace(/\s+/g, ' ')).toContain(expected); 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 a17502c7eec..7482926e151 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -14,6 +14,7 @@ import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; import { upgradeStatusTokenConfig } from 'ee_else_ce/runner/components/search_tokens/upgrade_status_token_config'; +import { createLocalState } from '~/runner/graphql/list/local_state'; import RunnerStackedLayoutBanner from '~/runner/components/runner_stacked_layout_banner.vue'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; @@ -24,6 +25,7 @@ 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'; import RunnerPagination from '~/runner/components/runner_pagination.vue'; +import RunnerMembershipToggle from '~/runner/components/runner_membership_toggle.vue'; import { CREATED_ASC, @@ -36,9 +38,12 @@ import { GROUP_TYPE, PARAM_KEY_PAUSED, PARAM_KEY_STATUS, + PARAM_KEY_TAG, STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE, + MEMBERSHIP_ALL_AVAILABLE, + MEMBERSHIP_DESCENDANTS, RUNNER_PAGE_SIZE, I18N_EDIT, } from '~/runner/constants'; @@ -89,15 +94,23 @@ describe('GroupRunnersApp', () => { const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); const findRunnerPaginationNext = () => findRunnerPagination().findByText(s__('Pagination|Next')); const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); + const findRunnerMembershipToggle = () => wrapper.findComponent(RunnerMembershipToggle); + + const createComponent = ({ + props = {}, + provide = {}, + mountFn = shallowMountExtended, + ...options + } = {}) => { + const { cacheConfig, localMutations } = createLocalState(); - const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => { const handlers = [ [groupRunnersQuery, mockGroupRunnersHandler], [groupRunnersCountQuery, mockGroupRunnersCountHandler], ]; wrapper = mountFn(GroupRunnersApp, { - apolloProvider: createMockApollo(handlers), + apolloProvider: createMockApollo(handlers, {}, cacheConfig), propsData: { registrationToken: mockRegistrationToken, groupFullPath: mockGroupFullPath, @@ -105,10 +118,12 @@ describe('GroupRunnersApp', () => { ...props, }, provide: { + localMutations, onlineContactTimeoutSecs, staleTimeoutSecs, emptyStateSvgPath, emptyStateFilteredSvgPath, + ...provide, }, ...options, }); @@ -147,19 +162,50 @@ describe('GroupRunnersApp', () => { expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE); }); + describe('show all available runners toggle', () => { + it('shows the membership toggle', () => { + createComponent(); + expect(findRunnerMembershipToggle().exists()).toBe(true); + }); + + it('sets the membership toggle', () => { + setWindowLocation(`?membership[]=${MEMBERSHIP_ALL_AVAILABLE}`); + + createComponent(); + + expect(findRunnerMembershipToggle().props('value')).toBe(MEMBERSHIP_ALL_AVAILABLE); + }); + + it('requests filter', async () => { + createComponent(); + findRunnerMembershipToggle().vm.$emit('input', MEMBERSHIP_ALL_AVAILABLE); + + await waitForPromises(); + + expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith( + expect.objectContaining({ + membership: MEMBERSHIP_ALL_AVAILABLE, + }), + ); + }); + }); + it('shows total runner counts', async () => { await createComponent({ mountFn: mountExtended }); expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_ONLINE, + membership: MEMBERSHIP_DESCENDANTS, groupFullPath: mockGroupFullPath, }); expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_OFFLINE, + membership: MEMBERSHIP_DESCENDANTS, groupFullPath: mockGroupFullPath, }); expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_STALE, + membership: MEMBERSHIP_DESCENDANTS, groupFullPath: mockGroupFullPath, }); @@ -183,6 +229,7 @@ describe('GroupRunnersApp', () => { groupFullPath: mockGroupFullPath, status: undefined, type: undefined, + membership: MEMBERSHIP_DESCENDANTS, sort: DEFAULT_SORT, first: RUNNER_PAGE_SIZE, }); @@ -202,6 +249,10 @@ describe('GroupRunnersApp', () => { type: PARAM_KEY_STATUS, options: expect.any(Array), }), + expect.objectContaining({ + type: PARAM_KEY_TAG, + suggestionsDisabled: true, + }), upgradeStatusTokenConfig, ]); }); @@ -213,7 +264,7 @@ describe('GroupRunnersApp', () => { const { id: graphqlId, shortSha } = node; const id = getIdFromGraphQLId(graphqlId); const COUNT_QUERIES = 6; // Smart queries that display a filtered count of runners - const FILTERED_COUNT_QUERIES = 3; // Smart queries that display a count of runners in tabs + const FILTERED_COUNT_QUERIES = 6; // Smart queries that display a count of runners in tabs and single stats beforeEach(async () => { await createComponent({ mountFn: mountExtended }); @@ -266,6 +317,7 @@ describe('GroupRunnersApp', () => { it('sets the filters in the search bar', () => { expect(findRunnerFilteredSearchBar().props('value')).toEqual({ runnerType: INSTANCE_TYPE, + membership: MEMBERSHIP_DESCENDANTS, filters: [{ type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }], sort: 'CREATED_DESC', pagination: {}, @@ -277,6 +329,7 @@ describe('GroupRunnersApp', () => { groupFullPath: mockGroupFullPath, status: STATUS_ONLINE, type: INSTANCE_TYPE, + membership: MEMBERSHIP_DESCENDANTS, sort: DEFAULT_SORT, first: RUNNER_PAGE_SIZE, }); @@ -286,6 +339,7 @@ describe('GroupRunnersApp', () => { expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ groupFullPath: mockGroupFullPath, type: INSTANCE_TYPE, + membership: MEMBERSHIP_DESCENDANTS, status: STATUS_ONLINE, }); }); @@ -297,6 +351,7 @@ describe('GroupRunnersApp', () => { findRunnerFilteredSearchBar().vm.$emit('input', { runnerType: null, + membership: MEMBERSHIP_DESCENDANTS, filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], sort: CREATED_ASC, }); @@ -315,6 +370,7 @@ describe('GroupRunnersApp', () => { expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({ groupFullPath: mockGroupFullPath, status: STATUS_ONLINE, + membership: MEMBERSHIP_DESCENDANTS, sort: CREATED_ASC, first: RUNNER_PAGE_SIZE, }); @@ -324,6 +380,7 @@ describe('GroupRunnersApp', () => { expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ groupFullPath: mockGroupFullPath, status: STATUS_ONLINE, + membership: MEMBERSHIP_DESCENDANTS, }); }); }); @@ -334,6 +391,11 @@ describe('GroupRunnersApp', () => { expect(findRunnerPagination().attributes('disabled')).toBe('true'); }); + it('runners cannot be deleted in bulk', () => { + createComponent(); + expect(findRunnerList().props('checkable')).toBe(false); + }); + describe('when no runners are found', () => { beforeEach(async () => { mockGroupRunnersHandler.mockResolvedValue({ @@ -395,6 +457,7 @@ describe('GroupRunnersApp', () => { expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({ groupFullPath: mockGroupFullPath, + membership: MEMBERSHIP_DESCENDANTS, sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, after: pageInfo.endCursor, diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js index 555ec40184f..da0c0433b3e 100644 --- a/spec/frontend/runner/mock_data.js +++ b/spec/frontend/runner/mock_data.js @@ -17,7 +17,7 @@ import groupRunnersData from 'test_fixtures/graphql/runner/list/group_runners.qu import groupRunnersDataPaginated from 'test_fixtures/graphql/runner/list/group_runners.query.graphql.paginated.json'; import groupRunnersCountData from 'test_fixtures/graphql/runner/list/group_runners_count.query.graphql.json'; -import { RUNNER_PAGE_SIZE } from '~/runner/constants'; +import { DEFAULT_MEMBERSHIP, RUNNER_PAGE_SIZE } from '~/runner/constants'; const emptyPageInfo = { __typename: 'PageInfo', @@ -34,8 +34,18 @@ export const mockSearchExamples = [ { name: 'a default query', urlQuery: '', - search: { runnerType: null, filters: [], pagination: {}, sort: 'CREATED_DESC' }, - graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + search: { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [], + pagination: {}, + sort: 'CREATED_DESC', + }, + graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, isDefault: true, }, { @@ -43,17 +53,24 @@ export const mockSearchExamples = [ urlQuery: '?status[]=ACTIVE', search: { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], pagination: {}, sort: 'CREATED_DESC', }, - graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, + status: 'ACTIVE', + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, }, { name: 'a single term text search', urlQuery: '?search=something', search: { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [ { type: 'filtered-search-term', @@ -63,13 +80,19 @@ export const mockSearchExamples = [ pagination: {}, sort: 'CREATED_DESC', }, - graphqlVariables: { search: 'something', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, + search: 'something', + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, }, { name: 'a two terms text search', urlQuery: '?search=something+else', search: { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [ { type: 'filtered-search-term', @@ -83,24 +106,36 @@ export const mockSearchExamples = [ pagination: {}, sort: 'CREATED_DESC', }, - graphqlVariables: { search: 'something else', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, + search: 'something else', + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, }, { name: 'single instance type', urlQuery: '?runner_type[]=INSTANCE_TYPE', search: { runnerType: 'INSTANCE_TYPE', + membership: DEFAULT_MEMBERSHIP, filters: [], pagination: {}, sort: 'CREATED_DESC', }, - graphqlVariables: { type: 'INSTANCE_TYPE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + graphqlVariables: { + type: 'INSTANCE_TYPE', + membership: DEFAULT_MEMBERSHIP, + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, }, { name: 'multiple runner status', urlQuery: '?status[]=ACTIVE&status[]=PAUSED', search: { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [ { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, { type: 'status', value: { data: 'PAUSED', operator: '=' } }, @@ -108,13 +143,19 @@ export const mockSearchExamples = [ pagination: {}, sort: 'CREATED_DESC', }, - graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + graphqlVariables: { + status: 'ACTIVE', + membership: DEFAULT_MEMBERSHIP, + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, }, { name: 'multiple status, a single instance type and a non default sort', urlQuery: '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC', search: { runnerType: 'INSTANCE_TYPE', + membership: DEFAULT_MEMBERSHIP, filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], pagination: {}, sort: 'CREATED_ASC', @@ -122,6 +163,7 @@ export const mockSearchExamples = [ graphqlVariables: { status: 'ACTIVE', type: 'INSTANCE_TYPE', + membership: DEFAULT_MEMBERSHIP, sort: 'CREATED_ASC', first: RUNNER_PAGE_SIZE, }, @@ -131,11 +173,13 @@ export const mockSearchExamples = [ urlQuery: '?tag[]=tag-1', search: { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [{ type: 'tag', value: { data: 'tag-1', operator: '=' } }], pagination: {}, sort: 'CREATED_DESC', }, graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, tagList: ['tag-1'], first: 20, sort: 'CREATED_DESC', @@ -146,6 +190,7 @@ export const mockSearchExamples = [ urlQuery: '?tag[]=tag-1&tag[]=tag-2', search: { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [ { type: 'tag', value: { data: 'tag-1', operator: '=' } }, { type: 'tag', value: { data: 'tag-2', operator: '=' } }, @@ -154,6 +199,7 @@ export const mockSearchExamples = [ sort: 'CREATED_DESC', }, graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, tagList: ['tag-1', 'tag-2'], first: 20, sort: 'CREATED_DESC', @@ -164,22 +210,34 @@ export const mockSearchExamples = [ urlQuery: '?after=AFTER_CURSOR', search: { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [], pagination: { after: 'AFTER_CURSOR' }, sort: 'CREATED_DESC', }, - graphqlVariables: { sort: 'CREATED_DESC', after: 'AFTER_CURSOR', first: RUNNER_PAGE_SIZE }, + graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, + sort: 'CREATED_DESC', + after: 'AFTER_CURSOR', + first: RUNNER_PAGE_SIZE, + }, }, { name: 'the previous page', urlQuery: '?before=BEFORE_CURSOR', search: { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [], pagination: { before: 'BEFORE_CURSOR' }, sort: 'CREATED_DESC', }, - graphqlVariables: { sort: 'CREATED_DESC', before: 'BEFORE_CURSOR', last: RUNNER_PAGE_SIZE }, + graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, + sort: 'CREATED_DESC', + before: 'BEFORE_CURSOR', + last: RUNNER_PAGE_SIZE, + }, }, { name: 'the next page filtered by a status, an instance type, tags and a non default sort', @@ -187,6 +245,7 @@ export const mockSearchExamples = [ '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&tag[]=tag-1&tag[]=tag-2&sort=CREATED_ASC&after=AFTER_CURSOR', search: { runnerType: 'INSTANCE_TYPE', + membership: DEFAULT_MEMBERSHIP, filters: [ { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, { type: 'tag', value: { data: 'tag-1', operator: '=' } }, @@ -198,6 +257,7 @@ export const mockSearchExamples = [ graphqlVariables: { status: 'ACTIVE', type: 'INSTANCE_TYPE', + membership: DEFAULT_MEMBERSHIP, tagList: ['tag-1', 'tag-2'], sort: 'CREATED_ASC', after: 'AFTER_CURSOR', @@ -209,22 +269,34 @@ export const mockSearchExamples = [ urlQuery: '?paused[]=true', search: { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [{ type: 'paused', value: { data: 'true', operator: '=' } }], pagination: {}, sort: 'CREATED_DESC', }, - graphqlVariables: { paused: true, sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + graphqlVariables: { + paused: true, + membership: DEFAULT_MEMBERSHIP, + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, }, { name: 'active runners', urlQuery: '?paused[]=false', search: { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [{ type: 'paused', value: { data: 'false', operator: '=' } }], pagination: {}, sort: 'CREATED_DESC', }, - graphqlVariables: { paused: false, sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + graphqlVariables: { + paused: false, + membership: DEFAULT_MEMBERSHIP, + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, }, ]; diff --git a/spec/frontend/search/sidebar/components/app_spec.js b/spec/frontend/search/sidebar/components/app_spec.js index 3bea0748c47..89959feec39 100644 --- a/spec/frontend/search/sidebar/components/app_spec.js +++ b/spec/frontend/search/sidebar/components/app_spec.js @@ -42,20 +42,39 @@ describe('GlobalSearchSidebar', () => { const findResetLinkButton = () => wrapper.findComponent(GlLink); describe('template', () => { - beforeEach(() => { - createComponent(); - }); + describe('scope=projects', () => { + beforeEach(() => { + createComponent({ urlQuery: { ...MOCK_QUERY, scope: 'projects' } }); + }); - it('renders StatusFilter always', () => { - expect(findStatusFilter().exists()).toBe(true); - }); + it("doesn't render StatusFilter", () => { + expect(findStatusFilter().exists()).toBe(false); + }); + + it("doesn't render ConfidentialityFilter", () => { + expect(findConfidentialityFilter().exists()).toBe(false); + }); - it('renders ConfidentialityFilter always', () => { - expect(findConfidentialityFilter().exists()).toBe(true); + it("doesn't render ApplyButton", () => { + expect(findApplyButton().exists()).toBe(false); + }); }); - it('renders ApplyButton always', () => { - expect(findApplyButton().exists()).toBe(true); + describe('scope=issues', () => { + beforeEach(() => { + createComponent({ urlQuery: MOCK_QUERY }); + }); + it('renders StatusFilter', () => { + expect(findStatusFilter().exists()).toBe(true); + }); + + it('renders ConfidentialityFilter', () => { + expect(findConfidentialityFilter().exists()).toBe(true); + }); + + it('renders ApplyButton', () => { + expect(findApplyButton().exists()).toBe(true); + }); }); }); @@ -115,7 +134,7 @@ describe('GlobalSearchSidebar', () => { describe('actions', () => { beforeEach(() => { - createComponent(); + createComponent({}); }); it('clicking ApplyButton calls applyQuery', () => { diff --git a/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js b/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js index a377ddae0eb..c57eabd57b9 100644 --- a/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js +++ b/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js @@ -34,7 +34,7 @@ describe('ConfidentialityFilter', () => { wrapper = null; }); - const findRadioFilter = () => wrapper.find(RadioFilter); + const findRadioFilter = () => wrapper.findComponent(RadioFilter); describe('template', () => { beforeEach(() => { diff --git a/spec/frontend/search/sidebar/components/radio_filter_spec.js b/spec/frontend/search/sidebar/components/radio_filter_spec.js index c0a8259b4fe..94d529348a9 100644 --- a/spec/frontend/search/sidebar/components/radio_filter_spec.js +++ b/spec/frontend/search/sidebar/components/radio_filter_spec.js @@ -43,7 +43,7 @@ describe('RadioFilter', () => { wrapper = null; }); - const findGlRadioButtonGroup = () => wrapper.find(GlFormRadioGroup); + const findGlRadioButtonGroup = () => wrapper.findComponent(GlFormRadioGroup); const findGlRadioButtons = () => findGlRadioButtonGroup().findAllComponents(GlFormRadio); const findGlRadioButtonsText = () => findGlRadioButtons().wrappers.map((w) => w.text()); diff --git a/spec/frontend/search/sidebar/components/status_filter_spec.js b/spec/frontend/search/sidebar/components/status_filter_spec.js index 5d8ecd8733a..f3152c014b6 100644 --- a/spec/frontend/search/sidebar/components/status_filter_spec.js +++ b/spec/frontend/search/sidebar/components/status_filter_spec.js @@ -34,7 +34,7 @@ describe('StatusFilter', () => { wrapper = null; }); - const findRadioFilter = () => wrapper.find(RadioFilter); + const findRadioFilter = () => wrapper.findComponent(RadioFilter); describe('template', () => { beforeEach(() => { diff --git a/spec/frontend/search/sort/components/app_spec.js b/spec/frontend/search/sort/components/app_spec.js index 0e8eebba3cb..a566b9b99d3 100644 --- a/spec/frontend/search/sort/components/app_spec.js +++ b/spec/frontend/search/sort/components/app_spec.js @@ -43,9 +43,9 @@ describe('GlobalSearchSort', () => { wrapper = null; }); - const findSortButtonGroup = () => wrapper.find(GlButtonGroup); - const findSortDropdown = () => wrapper.find(GlDropdown); - const findSortDirectionButton = () => wrapper.find(GlButton); + const findSortButtonGroup = () => wrapper.findComponent(GlButtonGroup); + const findSortDropdown = () => wrapper.findComponent(GlDropdown); + const findSortDirectionButton = () => wrapper.findComponent(GlButton); const findDropdownItems = () => findSortDropdown().findAllComponents(GlDropdownItem); const findDropdownItemsText = () => findDropdownItems().wrappers.map((w) => w.text()); diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js index 2f93d3f6805..c442ffa521d 100644 --- a/spec/frontend/search/store/actions_spec.js +++ b/spec/frontend/search/store/actions_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as urlUtils from '~/lib/utils/url_utility'; import * as actions from '~/search/store/actions'; @@ -37,8 +37,8 @@ describe('Global Search Store Actions', () => { let state; const flashCallback = (callCount) => { - expect(createFlash).toHaveBeenCalledTimes(callCount); - createFlash.mockClear(); + expect(createAlert).toHaveBeenCalledTimes(callCount); + createAlert.mockClear(); }; beforeEach(() => { diff --git a/spec/frontend/search/topbar/components/app_spec.js b/spec/frontend/search/topbar/components/app_spec.js index 0a44688bfe0..c7fd7084101 100644 --- a/spec/frontend/search/topbar/components/app_spec.js +++ b/spec/frontend/search/topbar/components/app_spec.js @@ -36,9 +36,9 @@ describe('GlobalSearchTopbar', () => { wrapper.destroy(); }); - const findGlSearchBox = () => wrapper.find(GlSearchBoxByClick); - const findGroupFilter = () => wrapper.find(GroupFilter); - const findProjectFilter = () => wrapper.find(ProjectFilter); + const findGlSearchBox = () => wrapper.findComponent(GlSearchBoxByClick); + const findGroupFilter = () => wrapper.findComponent(GroupFilter); + const findProjectFilter = () => wrapper.findComponent(ProjectFilter); describe('template', () => { beforeEach(() => { diff --git a/spec/frontend/search/topbar/components/group_filter_spec.js b/spec/frontend/search/topbar/components/group_filter_spec.js index bd173791fee..b2d0297fdc2 100644 --- a/spec/frontend/search/topbar/components/group_filter_spec.js +++ b/spec/frontend/search/topbar/components/group_filter_spec.js @@ -53,7 +53,7 @@ describe('GroupFilter', () => { wrapper.destroy(); }); - const findSearchableDropdown = () => wrapper.find(SearchableDropdown); + const findSearchableDropdown = () => wrapper.findComponent(SearchableDropdown); describe('template', () => { beforeEach(() => { diff --git a/spec/frontend/search/topbar/components/project_filter_spec.js b/spec/frontend/search/topbar/components/project_filter_spec.js index 5afcd281d0c..297a536e075 100644 --- a/spec/frontend/search/topbar/components/project_filter_spec.js +++ b/spec/frontend/search/topbar/components/project_filter_spec.js @@ -53,7 +53,7 @@ describe('ProjectFilter', () => { wrapper.destroy(); }); - const findSearchableDropdown = () => wrapper.find(SearchableDropdown); + const findSearchableDropdown = () => wrapper.findComponent(SearchableDropdown); describe('template', () => { beforeEach(() => { diff --git a/spec/frontend/search_settings/components/search_settings_spec.js b/spec/frontend/search_settings/components/search_settings_spec.js index d0a2018c7f0..3f856968db6 100644 --- a/spec/frontend/search_settings/components/search_settings_spec.js +++ b/spec/frontend/search_settings/components/search_settings_spec.js @@ -1,4 +1,4 @@ -import { GlSearchBoxByType } from '@gitlab/ui'; +import { GlEmptyState, GlSearchBoxByType } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { setHTMLFixture } from 'helpers/fixtures'; import SearchSettings from '~/search_settings/components/search_settings.vue'; @@ -14,7 +14,7 @@ describe('search_settings/components/search_settings.vue', () => { const EXTRA_SETTINGS_ID = 'js-extra-settings'; const TEXT_CONTAIN_SEARCH_TERM = `This text contain ${SEARCH_TERM}.`; const TEXT_WITH_SIBLING_ELEMENTS = `${SEARCH_TERM} <a data-testid="sibling" href="#">Learn more</a>.`; - + const HIDE_WHEN_EMPTY_CLASS = 'js-hide-when-nothing-matches-search'; let wrapper; const buildWrapper = () => { @@ -22,6 +22,7 @@ describe('search_settings/components/search_settings.vue', () => { propsData: { searchRoot: document.querySelector(`#${ROOT_ID}`), sectionSelector: SECTION_SELECTOR, + hideWhenEmptySelector: `.${HIDE_WHEN_EMPTY_CLASS}`, isExpandedFn: isExpanded, }, // Add real listeners so we can simplify and strengthen some tests. @@ -45,7 +46,9 @@ describe('search_settings/components/search_settings.vue', () => { }; const findMatchSiblingElement = () => document.querySelector(`[data-testid="sibling"]`); - const findSearchBox = () => wrapper.find(GlSearchBoxByType); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findHideWhenEmpty = () => document.querySelector(`.${HIDE_WHEN_EMPTY_CLASS}`); const search = (term) => { findSearchBox().vm.$emit('input', term); }; @@ -67,6 +70,9 @@ describe('search_settings/components/search_settings.vue', () => { <span>${TEXT_CONTAIN_SEARCH_TERM}</span> <span>${TEXT_WITH_SIBLING_ELEMENTS}</span> </section> + <div class="row ${HIDE_WHEN_EMPTY_CLASS}"> + <button type="submit">Save</button> + </div> </div> </div> `); @@ -93,13 +99,41 @@ describe('search_settings/components/search_settings.vue', () => { expect(wrapper.emitted('expand')).toEqual([[section]]); }); + describe('when nothing matches the search term', () => { + beforeEach(() => { + search('xxxxxxxxxxx'); + }); + + it('shows an empty state', () => { + expect(findEmptyState().exists()).toBe(true); + }); + + it('hides the form buttons', () => { + expect(findHideWhenEmpty()).toHaveClass(HIDE_CLASS); + }); + }); + + describe('when something matches the search term', () => { + beforeEach(() => { + search(SEARCH_TERM); + }); + + it('shows no empty state', () => { + expect(findEmptyState().exists()).toBe(false); + }); + + it('shows the form buttons', () => { + expect(findHideWhenEmpty()).not.toHaveClass(HIDE_CLASS); + }); + }); + it('highlight elements that match the search term', () => { search(SEARCH_TERM); expect(highlightedElementsCount()).toBe(3); }); - it('highlight only search term and not the whole line', () => { + it('highlights only search term and not the whole line', () => { search(SEARCH_TERM); expect(highlightedTextNodes()).toBe(true); @@ -142,6 +176,10 @@ describe('search_settings/components/search_settings.vue', () => { expect(visibleSectionsCount()).toBe(sectionsCount()); }); + it('hides the empty state', () => { + expect(findEmptyState().exists()).toBe(false); + }); + it('removes the highlight from all elements', () => { expect(highlightedElementsCount()).toBe(0); }); diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index 222cabc6a63..ddefda2ffc3 100644 --- a/spec/frontend/security_configuration/components/app_spec.js +++ b/spec/frontend/security_configuration/components/app_spec.js @@ -281,7 +281,7 @@ describe('App component', () => { }); }); - it(shouldRender ? 'renders' : 'does not render', () => { + it(`${shouldRender ? 'renders' : 'does not render'}`, () => { expect(findAutoDevopsEnabledAlert().exists()).toBe(shouldRender); }); }); diff --git a/spec/frontend/security_configuration/components/training_provider_list_spec.js b/spec/frontend/security_configuration/components/training_provider_list_spec.js index b6451af57d7..8f2b5383191 100644 --- a/spec/frontend/security_configuration/components/training_provider_list_spec.js +++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js @@ -193,7 +193,7 @@ describe('TrainingProviderList component', () => { }); it(`shows the learn more link for enabled card ${index}`, () => { - const learnMoreLink = findCards().at(index).find(GlLink); + const learnMoreLink = findCards().at(index).findComponent(GlLink); const tempLogo = TEMP_PROVIDER_URLS[name]; if (tempLogo) { @@ -224,7 +224,7 @@ describe('TrainingProviderList component', () => { }); it('shows a info-tooltip that describes the purpose of a primary provider', () => { - const infoIcon = findPrimaryProviderRadios().at(index).find(GlIcon); + const infoIcon = findPrimaryProviderRadios().at(index).findComponent(GlIcon); const tooltip = getBinding(infoIcon.element, 'gl-tooltip'); expect(infoIcon.props()).toMatchObject({ diff --git a/spec/frontend/security_configuration/components/upgrade_banner_spec.js b/spec/frontend/security_configuration/components/upgrade_banner_spec.js index ff44acfc4f9..c34d8e47a6c 100644 --- a/spec/frontend/security_configuration/components/upgrade_banner_spec.js +++ b/spec/frontend/security_configuration/components/upgrade_banner_spec.js @@ -79,7 +79,7 @@ describe('UpgradeBanner component', () => { expect(wrapperText).toContain('statistics in the merge request'); expect(wrapperText).toContain('statistics across projects'); expect(wrapperText).toContain('Runtime security metrics'); - expect(wrapperText).toContain('More scan types, including Container Scanning,'); + expect(wrapperText).toContain('More scan types, including DAST,'); }); describe('when user interacts', () => { diff --git a/spec/frontend/self_monitor/components/self_monitor_form_spec.js b/spec/frontend/self_monitor/components/self_monitor_form_spec.js index 89ad5a00a14..c690bbf1c57 100644 --- a/spec/frontend/self_monitor/components/self_monitor_form_spec.js +++ b/spec/frontend/self_monitor/components/self_monitor_form_spec.js @@ -42,7 +42,7 @@ describe('self monitor component', () => { it('renders as an expand button by default', () => { wrapper = shallowMount(SelfMonitor, { store }); - const button = wrapper.find(GlButton); + const button = wrapper.findComponent(GlButton); expect(button.text()).toBe('Expand'); }); @@ -79,7 +79,7 @@ describe('self monitor component', () => { wrapper = shallowMount(SelfMonitor, { store }); expect( - wrapper.find({ ref: 'selfMonitoringFormText' }).find('a').attributes('href'), + wrapper.findComponent({ ref: 'selfMonitoringFormText' }).find('a').attributes('href'), ).toEqual(`${TEST_HOST}/instance-administrators-random/gitlab-self-monitoring`); }); diff --git a/spec/frontend/set_status_modal/set_status_form_spec.js b/spec/frontend/set_status_modal/set_status_form_spec.js index 8e1623eedf5..486e06d2906 100644 --- a/spec/frontend/set_status_modal/set_status_form_spec.js +++ b/spec/frontend/set_status_modal/set_status_form_spec.js @@ -127,6 +127,8 @@ describe('SetStatusForm', () => { describe('when `Clear status after` dropdown is changed', () => { it('emits `clear-status-after-click`', async () => { + await createComponent(); + await wrapper.findByTestId('thirtyMinutes').trigger('click'); expect(wrapper.emitted('clear-status-after-click')).toEqual([[timeRanges[0]]]); diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js index c5fb590646d..53d2a9e0978 100644 --- a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js +++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js @@ -4,7 +4,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import { initEmojiMock, clearEmojiMock } from 'helpers/emoji'; import * as UserApi from '~/api/user_api'; import EmojiPicker from '~/emoji/components/picker.vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import stubChildren from 'helpers/stub_children'; import SetStatusModalWrapper from '~/set_status_modal/set_status_modal_wrapper.vue'; import { AVAILABILITY_STATUS } from '~/set_status_modal/constants'; @@ -51,11 +51,11 @@ describe('SetStatusModalWrapper', () => { }); }; - const findModal = () => wrapper.find(GlModal); + const findModal = () => wrapper.findComponent(GlModal); const findMessageField = () => wrapper.findByPlaceholderText(SetStatusForm.i18n.statusMessagePlaceholder); const findClearStatusButton = () => wrapper.find('.js-clear-user-status-button'); - const findAvailabilityCheckbox = () => wrapper.find(GlFormCheckbox); + const findAvailabilityCheckbox = () => wrapper.findComponent(GlFormCheckbox); const findClearStatusAtMessage = () => wrapper.find('[data-testid="clear-status-at-message"]'); const getEmojiPicker = () => wrapper.findComponent(EmojiPickerStub); @@ -253,7 +253,7 @@ describe('SetStatusModalWrapper', () => { findModal().vm.$emit('primary'); await nextTick(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: "Sorry, we weren't able to set your status. Please try again later.", }); }); diff --git a/spec/frontend/sidebar/assignee_title_spec.js b/spec/frontend/sidebar/assignee_title_spec.js index e29e3d489a5..14a6bdbf907 100644 --- a/spec/frontend/sidebar/assignee_title_spec.js +++ b/spec/frontend/sidebar/assignee_title_spec.js @@ -85,7 +85,7 @@ describe('AssigneeTitle component', () => { editable: false, }); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); }); it('renders spinner when loading', () => { @@ -95,7 +95,7 @@ describe('AssigneeTitle component', () => { editable: false, }); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); it('does not render edit link when not editable', () => { diff --git a/spec/frontend/sidebar/assignees_spec.js b/spec/frontend/sidebar/assignees_spec.js index c2aff456abb..7cf7fd33022 100644 --- a/spec/frontend/sidebar/assignees_spec.js +++ b/spec/frontend/sidebar/assignees_spec.js @@ -33,7 +33,7 @@ describe('Assignee component', () => { it('displays no assignee icon when collapsed', () => { createWrapper(); const collapsedChildren = findCollapsedChildren(); - const userIcon = collapsedChildren.at(0).find(GlIcon); + const userIcon = collapsedChildren.at(0).findComponent(GlIcon); expect(collapsedChildren.length).toBe(1); expect(collapsedChildren.at(0).attributes('aria-label')).toBe('None'); 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 8cde70ff8da..4764f3607bc 100644 --- a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js +++ b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js @@ -46,7 +46,7 @@ describe('AssigneeAvatarLink component', () => { it('renders assignee avatar', () => { createComponent(); - expect(wrapper.find(AssigneeAvatar).props()).toEqual( + expect(wrapper.findComponent(AssigneeAvatar).props()).toEqual( expect.objectContaining({ issuableType: TEST_ISSUABLE_TYPE, user: userDataMock(), diff --git a/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js b/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js index 81ff51133bf..7e7d4921cfa 100644 --- a/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js +++ b/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js @@ -21,7 +21,7 @@ describe('CollapsedAssigneeList component', () => { }); } - const findNoUsersIcon = () => wrapper.find(GlIcon); + const findNoUsersIcon = () => wrapper.findComponent(GlIcon); const findAvatarCounter = () => wrapper.find('.avatar-counter'); const findAssignees = () => wrapper.findAllComponents(CollapsedAssignee); const getTooltipTitle = () => wrapper.attributes('title'); diff --git a/spec/frontend/sidebar/components/assignees/collapsed_assignee_spec.js b/spec/frontend/sidebar/components/assignees/collapsed_assignee_spec.js index 2d5a3653631..4db95114b96 100644 --- a/spec/frontend/sidebar/components/assignees/collapsed_assignee_spec.js +++ b/spec/frontend/sidebar/components/assignees/collapsed_assignee_spec.js @@ -34,7 +34,7 @@ describe('CollapsedAssignee assignee component', () => { it('has assignee avatar', () => { createComponent(); - expect(wrapper.find(AssigneeAvatar).props()).toEqual({ + expect(wrapper.findComponent(AssigneeAvatar).props()).toEqual({ imgSize: 24, user: TEST_USER, issuableType: TEST_ISSUABLE_TYPE, diff --git a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js index 3644a51c7fd..cbb4c41dd14 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js @@ -5,7 +5,7 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { IssuableType } from '~/issues/constants'; import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; @@ -167,7 +167,7 @@ describe('Sidebar assignees widget', () => { }); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'An error occurred while fetching participants.', }); }); @@ -333,7 +333,7 @@ describe('Sidebar assignees widget', () => { await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'An error occurred while updating assignees.', }); }); 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 724fba62479..6c22d2f687d 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js @@ -67,15 +67,33 @@ describe('boards sidebar remove issue', () => { expect(findLoader().exists()).toBe(true); }); - it('shows expanded content and hides collapsed content when clicking edit button', async () => { - const slots = { default: '<div>Select item</div>' }; - createComponent({ canUpdate: true, slots }); - findEditButton().vm.$emit('click'); - - await nextTick(); - - expect(findCollapsed().isVisible()).toBe(false); - expect(findExpanded().isVisible()).toBe(true); + describe('when clicking edit button', () => { + describe('when can edit', () => { + it('shows expanded (editable) content', async () => { + const slots = { default: '<div>Select item</div>' }; + createComponent({ canUpdate: true, slots }); + findEditButton().vm.$emit('click'); + + await nextTick(); + + expect(findCollapsed().isVisible()).toBe(false); + expect(findExpanded().isVisible()).toBe(true); + }); + }); + + describe('when cannot edit', () => { + it('shows collapsed (non-editable) content', async () => { + const slots = { default: '<div>Select item</div>' }; + createComponent({ canUpdate: false, slots }); + // Simulate parent component calling `expand` method when user + // clicks on collapsed sidebar (e.g. in sidebar_weight_widget.vue) + wrapper.vm.expand(); + await nextTick(); + + expect(findCollapsed().isVisible()).toBe(true); + expect(findExpanded().isVisible()).toBe(false); + }); + }); }); }); diff --git a/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js b/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js index b902d7313fd..03c2e1a37a9 100644 --- a/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js +++ b/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js @@ -46,7 +46,7 @@ describe('UncollapsedAssigneeList component', () => { }); it('calls the AssigneeAvatarLink with the proper props', () => { - expect(wrapper.find(AssigneeAvatarLink).exists()).toBe(true); + expect(wrapper.findComponent(AssigneeAvatarLink).exists()).toBe(true); }); it('Shows one user with avatar, username and author name', () => { 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 1ea035c7184..b27f7c6b4e1 100644 --- a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js +++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js @@ -2,7 +2,7 @@ import { GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import SidebarConfidentialityForm from '~/sidebar/components/confidential/sidebar_confidentiality_form.vue'; import { confidentialityQueries } from '~/sidebar/constants'; @@ -63,7 +63,7 @@ describe('Sidebar Confidentiality Form', () => { findConfidentialToggle().vm.$emit('click', new MouseEvent('click')); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'Something went wrong while setting issue confidentiality.', }); }); @@ -77,7 +77,7 @@ describe('Sidebar Confidentiality Form', () => { findConfidentialToggle().vm.$emit('click', new MouseEvent('click')); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'Houston, we have a problem!', }); }); diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js index 3a3f0b1d9fa..e486a8e9ec7 100644 --- a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js +++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js @@ -4,7 +4,7 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import SidebarConfidentialityContent from '~/sidebar/components/confidential/sidebar_confidentiality_content.vue'; import SidebarConfidentialityForm from '~/sidebar/components/confidential/sidebar_confidentiality_form.vue'; import SidebarConfidentialityWidget, { @@ -126,7 +126,7 @@ describe('Sidebar Confidentiality Widget', () => { }); await waitForPromises(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); it('closes the form and dispatches an event when `closeForm` is emitted', async () => { diff --git a/spec/frontend/sidebar/components/copy_email_to_clipboard_spec.js b/spec/frontend/sidebar/components/copy_email_to_clipboard_spec.js index 699b2bbd0b1..69a8d645973 100644 --- a/spec/frontend/sidebar/components/copy_email_to_clipboard_spec.js +++ b/spec/frontend/sidebar/components/copy_email_to_clipboard_spec.js @@ -12,6 +12,6 @@ describe('CopyEmailToClipboard component', () => { }); it('sets CopyableField `value` prop to issueEmailAddress', () => { - expect(wrapper.find(CopyableField).props('value')).toBe(mockIssueEmailAddress); + expect(wrapper.findComponent(CopyableField).props('value')).toBe(mockIssueEmailAddress); }); }); diff --git a/spec/frontend/sidebar/components/crm_contacts_spec.js b/spec/frontend/sidebar/components/crm_contacts_spec.js index 6456829258f..6d76fa1f9df 100644 --- a/spec/frontend/sidebar/components/crm_contacts_spec.js +++ b/spec/frontend/sidebar/components/crm_contacts_spec.js @@ -3,7 +3,7 @@ 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 createFlash from '~/flash'; +import { createAlert } from '~/flash'; import CrmContacts from '~/sidebar/components/crm_contacts/crm_contacts.vue'; import getIssueCrmContactsQuery from '~/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql'; import issueCrmContactsSubscription from '~/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql'; @@ -47,7 +47,7 @@ describe('Issue crm contacts component', () => { mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') }); await waitForPromises(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); it('calls the query with correct variables', () => { diff --git a/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js b/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js index 1e2173e2988..67413cffdda 100644 --- a/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js +++ b/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js @@ -4,7 +4,7 @@ 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 { createAlert } from '~/flash'; import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; import SidebarFormattedDate from '~/sidebar/components/date/sidebar_formatted_date.vue'; import SidebarInheritDate from '~/sidebar/components/date/sidebar_inherit_date.vue'; @@ -28,7 +28,7 @@ describe('Sidebar date Widget', () => { const findEditableItem = () => wrapper.findComponent(SidebarEditableItem); const findPopoverIcon = () => wrapper.find('[data-testid="inherit-date-popover"]'); - const findDatePicker = () => wrapper.find(GlDatepicker); + const findDatePicker = () => wrapper.findComponent(GlDatepicker); const createComponent = ({ dueDateQueryHandler = jest.fn().mockResolvedValue(issuableDueDateResponse()), @@ -149,14 +149,14 @@ describe('Sidebar date Widget', () => { createComponent({ canInherit }); await waitForPromises(); - expect(wrapper.find(component).exists()).toBe(expected); + expect(wrapper.findComponent(component).exists()).toBe(expected); }, ); it('does not render SidebarInheritDate when canInherit is true and date is loading', async () => { createComponent({ canInherit: true }); - expect(wrapper.find(SidebarInheritDate).exists()).toBe(false); + expect(wrapper.findComponent(SidebarInheritDate).exists()).toBe(false); }); it('displays a flash message when query is rejected', async () => { @@ -165,7 +165,7 @@ describe('Sidebar date Widget', () => { }); await waitForPromises(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); it.each` diff --git a/spec/frontend/sidebar/components/date/sidebar_formatted_date_spec.js b/spec/frontend/sidebar/components/date/sidebar_formatted_date_spec.js index 1eda4ea977f..cbe01263dcd 100644 --- a/spec/frontend/sidebar/components/date/sidebar_formatted_date_spec.js +++ b/spec/frontend/sidebar/components/date/sidebar_formatted_date_spec.js @@ -5,7 +5,7 @@ import SidebarFormattedDate from '~/sidebar/components/date/sidebar_formatted_da describe('SidebarFormattedDate', () => { let wrapper; const findFormattedDate = () => wrapper.find("[data-testid='sidebar-date-value']"); - const findRemoveButton = () => wrapper.find(GlButton); + const findRemoveButton = () => wrapper.findComponent(GlButton); const createComponent = ({ hasDate = true } = {}) => { wrapper = shallowMount(SidebarFormattedDate, { diff --git a/spec/frontend/sidebar/components/severity/severity_spec.js b/spec/frontend/sidebar/components/severity/severity_spec.js index 1e4624e4dcd..2146155791e 100644 --- a/spec/frontend/sidebar/components/severity/severity_spec.js +++ b/spec/frontend/sidebar/components/severity/severity_spec.js @@ -21,7 +21,7 @@ describe('SeverityToken', () => { } }); - const findIcon = () => wrapper.find(GlIcon); + const findIcon = () => wrapper.findComponent(GlIcon); it('renders severity token for each severity type', () => { Object.values(INCIDENT_SEVERITY).forEach((severity) => { diff --git a/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js b/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js index 83eb9a18597..bdea33371d8 100644 --- a/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js +++ b/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js @@ -2,7 +2,7 @@ import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlTooltip, GlSprintf } from import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { INCIDENT_SEVERITY, ISSUABLE_TYPES } from '~/sidebar/components/severity/constants'; import updateIssuableSeverity from '~/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql'; import SeverityToken from '~/sidebar/components/severity/severity.vue'; @@ -59,7 +59,7 @@ describe('SidebarSeverity', () => { const findCriticalSeverityDropdownItem = () => wrapper.findComponent(GlDropdownItem); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findTooltip = () => wrapper.findComponent(GlTooltip); - const findCollapsedSeverity = () => wrapper.find({ ref: 'severity' }); + const findCollapsedSeverity = () => wrapper.findComponent({ ref: 'severity' }); describe('Severity widget', () => { it('renders severity dropdown and token', () => { @@ -104,7 +104,7 @@ describe('SidebarSeverity', () => { await waitForPromises(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); it('shows loading icon while updating', async () => { diff --git a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js index 6761731c093..8ab4d8ea051 100644 --- a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js +++ b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js @@ -15,7 +15,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { IssuableType } from '~/issues/constants'; import { timeFor } from '~/lib/utils/datetime_utility'; @@ -369,9 +369,9 @@ describe('SidebarDropdownWidget', () => { findDropdownItemWithText('title').vm.$emit('click'); }); - it(`calls createFlash with "${expectedMsg}"`, async () => { + it(`calls createAlert with "${expectedMsg}"`, async () => { await nextTick(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: expectedMsg, captureError: true, error: expectedMsg, @@ -455,14 +455,14 @@ describe('SidebarDropdownWidget', () => { describe('milestones', () => { let projectMilestonesSpy; - it('should call createFlash if milestones query fails', async () => { + it('should call createAlert if milestones query fails', async () => { await createComponentWithApollo({ projectMilestonesSpy: jest.fn().mockRejectedValue(error), }); await clickEdit(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: wrapper.vm.i18n.listFetchError, captureError: true, error: expect.any(Error), @@ -514,12 +514,12 @@ describe('SidebarDropdownWidget', () => { }); describe('currentAttributes', () => { - it('should call createFlash if currentAttributes query fails', async () => { + it('should call createAlert if currentAttributes query fails', async () => { await createComponentWithApollo({ currentMilestoneSpy: jest.fn().mockRejectedValue(error), }); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: wrapper.vm.i18n.currentFetchError, captureError: true, error: expect.any(Error), diff --git a/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js b/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js index 430acf9f9e7..c94f9918243 100644 --- a/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js +++ b/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js @@ -4,7 +4,7 @@ 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 { createAlert } from '~/flash'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import SidebarSubscriptionWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import issueSubscribedQuery from '~/sidebar/queries/issue_subscribed.query.graphql'; @@ -144,7 +144,7 @@ describe('Sidebar Subscriptions Widget', () => { }); await waitForPromises(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); describe('merge request', () => { diff --git a/spec/frontend/sidebar/components/time_tracking/report_spec.js b/spec/frontend/sidebar/components/time_tracking/report_spec.js index 4e619a4e609..af72122052f 100644 --- a/spec/frontend/sidebar/components/time_tracking/report_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/report_spec.js @@ -6,7 +6,7 @@ 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 { createAlert } 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'; @@ -65,7 +65,7 @@ describe('Issuable Time Tracking Report', () => { mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') }); await waitForPromises(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); describe('for issue', () => { @@ -153,7 +153,7 @@ describe('Issuable Time Tracking Report', () => { await findDeleteButton().trigger('click'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(mutateSpy).toHaveBeenCalledWith({ mutation: deleteTimelogMutation, variables: { @@ -164,7 +164,7 @@ describe('Issuable Time Tracking Report', () => { }); }); - it('calls `createFlash` with errorMessage and does not remove the row on promise reject', async () => { + it('calls `createAlert` with errorMessage and does not remove the row on promise reject', async () => { const mutateSpy = jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({}); await waitForPromises(); @@ -180,7 +180,7 @@ describe('Issuable Time Tracking Report', () => { }, }); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'An error occurred while removing the timelog.', captureError: true, error: expect.any(Object), diff --git a/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js b/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js index ea931782d1e..f73491ca95f 100644 --- a/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js +++ b/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js @@ -4,7 +4,7 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import epicTodoQuery from '~/sidebar/queries/epic_todo.query.graphql'; import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue'; @@ -83,7 +83,7 @@ describe('Sidebar Todo Widget', () => { }); await waitForPromises(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); describe('collapsed', () => { @@ -97,13 +97,13 @@ describe('Sidebar Todo Widget', () => { }); it('shows add todo icon', () => { - expect(wrapper.find(GlIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlIcon).exists()).toBe(true); - expect(wrapper.find(GlIcon).props('name')).toBe('todo-add'); + expect(wrapper.findComponent(GlIcon).props('name')).toBe('todo-add'); }); it('sets default tooltip title', () => { - expect(wrapper.find(GlButton).attributes('title')).toBe('Add a to do'); + expect(wrapper.findComponent(GlButton).attributes('title')).toBe('Add a to do'); }); it('when user has a to do', async () => { @@ -112,12 +112,12 @@ describe('Sidebar Todo Widget', () => { }); await waitForPromises(); - expect(wrapper.find(GlIcon).props('name')).toBe('todo-done'); - expect(wrapper.find(GlButton).attributes('title')).toBe('Mark as done'); + expect(wrapper.findComponent(GlIcon).props('name')).toBe('todo-done'); + expect(wrapper.findComponent(GlButton).attributes('title')).toBe('Mark as done'); }); it('emits `todoUpdated` event on click on icon', async () => { - wrapper.find(GlIcon).vm.$emit('click', event); + wrapper.findComponent(GlIcon).vm.$emit('click', event); await nextTick(); expect(wrapper.emitted('todoUpdated')).toEqual([[false]]); diff --git a/spec/frontend/sidebar/issuable_assignees_spec.js b/spec/frontend/sidebar/issuable_assignees_spec.js index dc59b68bbd4..1161fefcc64 100644 --- a/spec/frontend/sidebar/issuable_assignees_spec.js +++ b/spec/frontend/sidebar/issuable_assignees_spec.js @@ -17,7 +17,7 @@ describe('IssuableAssignees', () => { }, }); }; - const findUncollapsedAssigneeList = () => wrapper.find(UncollapsedAssigneeList); + const findUncollapsedAssigneeList = () => wrapper.findComponent(UncollapsedAssigneeList); const findEmptyAssignee = () => wrapper.find('[data-testid="none"]'); afterEach(() => { diff --git a/spec/frontend/sidebar/lock/edit_form_buttons_spec.js b/spec/frontend/sidebar/lock/edit_form_buttons_spec.js index 971744edb0f..2abb0c24d7d 100644 --- a/spec/frontend/sidebar/lock/edit_form_buttons_spec.js +++ b/spec/frontend/sidebar/lock/edit_form_buttons_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { createStore as createMrStore } from '~/mr_notes/stores'; import createStore from '~/notes/stores'; import EditFormButtons from '~/sidebar/components/lock/edit_form_buttons.vue'; @@ -129,7 +129,7 @@ describe('EditFormButtons', () => { }); it('does not flash an error message', () => { - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); }); @@ -162,7 +162,7 @@ describe('EditFormButtons', () => { }); it('calls flash with the correct message', () => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: `Something went wrong trying to change the locked state of this ${issuableDisplayName}`, }); }); diff --git a/spec/frontend/sidebar/lock/issuable_lock_form_spec.js b/spec/frontend/sidebar/lock/issuable_lock_form_spec.js index 986ccaea4b6..8f825847cfc 100644 --- a/spec/frontend/sidebar/lock/issuable_lock_form_spec.js +++ b/spec/frontend/sidebar/lock/issuable_lock_form_spec.js @@ -26,7 +26,7 @@ describe('IssuableLockForm', () => { const findSidebarCollapseIcon = () => wrapper.find('[data-testid="sidebar-collapse-icon"]'); const findLockStatus = () => wrapper.find('[data-testid="lock-status"]'); const findEditLink = () => wrapper.find('[data-testid="edit-link"]'); - const findEditForm = () => wrapper.find(EditForm); + const findEditForm = () => wrapper.findComponent(EditForm); const findSidebarLockStatusTooltip = () => getBinding(findSidebarCollapseIcon().element, 'gl-tooltip'); diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index 2afe9647cbe..391cbb1e0d5 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -283,7 +283,6 @@ export const epicParticipantsResponse = () => ({ name: 'Jacki Kub', username: 'francina.skiles', webUrl: '/franc', - status: null, }, ], }, diff --git a/spec/frontend/sidebar/participants_spec.js b/spec/frontend/sidebar/participants_spec.js index 2517b625225..f7a626a189c 100644 --- a/spec/frontend/sidebar/participants_spec.js +++ b/spec/frontend/sidebar/participants_spec.js @@ -36,7 +36,7 @@ describe('Participants', () => { loading: true, }); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); it('does not show loading spinner not loading', () => { @@ -44,7 +44,7 @@ describe('Participants', () => { loading: false, }); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); }); it('shows participant count when given', () => { @@ -73,7 +73,7 @@ describe('Participants', () => { loading: true, }); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); it('when only showing visible participants, shows an avatar only for each participant under the limit', async () => { diff --git a/spec/frontend/sidebar/reviewer_title_spec.js b/spec/frontend/sidebar/reviewer_title_spec.js index 6b4eed5ad0f..68ecd62e4c6 100644 --- a/spec/frontend/sidebar/reviewer_title_spec.js +++ b/spec/frontend/sidebar/reviewer_title_spec.js @@ -47,7 +47,7 @@ describe('ReviewerTitle component', () => { editable: false, }); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); }); it('renders spinner when loading', () => { @@ -57,7 +57,7 @@ describe('ReviewerTitle component', () => { editable: false, }); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); it('does not render edit link when not editable', () => { diff --git a/spec/frontend/sidebar/reviewers_spec.js b/spec/frontend/sidebar/reviewers_spec.js index 88bacc9b7f7..229f7ffbe04 100644 --- a/spec/frontend/sidebar/reviewers_spec.js +++ b/spec/frontend/sidebar/reviewers_spec.js @@ -43,7 +43,7 @@ describe('Reviewer component', () => { it('displays no reviewer icon when collapsed', () => { createWrapper(); const collapsedChildren = findCollapsedChildren(); - const userIcon = collapsedChildren.at(0).find(GlIcon); + const userIcon = collapsedChildren.at(0).findComponent(GlIcon); expect(collapsedChildren.length).toBe(1); expect(collapsedChildren.at(0).attributes('aria-label')).toBe('None'); diff --git a/spec/frontend/sidebar/sidebar_assignees_spec.js b/spec/frontend/sidebar/sidebar_assignees_spec.js index 68d20060c37..2cb2425532b 100644 --- a/spec/frontend/sidebar/sidebar_assignees_spec.js +++ b/spec/frontend/sidebar/sidebar_assignees_spec.js @@ -73,19 +73,19 @@ describe('sidebar assignees', () => { it('hides assignees until fetched', async () => { createComponent(); - expect(wrapper.find(Assigness).exists()).toBe(false); + expect(wrapper.findComponent(Assigness).exists()).toBe(false); wrapper.vm.store.isFetching.assignees = false; await nextTick(); - expect(wrapper.find(Assigness).exists()).toBe(true); + expect(wrapper.findComponent(Assigness).exists()).toBe(true); }); describe('when issuableType is issue', () => { it('finds AssigneesRealtime component', () => { createComponent(); - expect(wrapper.find(AssigneesRealtime).exists()).toBe(true); + expect(wrapper.findComponent(AssigneesRealtime).exists()).toBe(true); }); }); @@ -93,7 +93,7 @@ describe('sidebar assignees', () => { it('does not find AssigneesRealtime component', () => { createComponent({ issuableType: 'MR' }); - expect(wrapper.find(AssigneesRealtime).exists()).toBe(false); + expect(wrapper.findComponent(AssigneesRealtime).exists()).toBe(false); }); }); }); diff --git a/spec/frontend/sidebar/sidebar_mediator_spec.js b/spec/frontend/sidebar/sidebar_mediator_spec.js index 355f0c45bbe..bb5e7f7ff16 100644 --- a/spec/frontend/sidebar/sidebar_mediator_spec.js +++ b/spec/frontend/sidebar/sidebar_mediator_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import * as urlUtility from '~/lib/utils/url_utility'; -import SidebarService, { gqClient } from '~/sidebar/services/sidebar_service'; +import SidebarService from '~/sidebar/services/sidebar_service'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarStore from '~/sidebar/stores/sidebar_store'; import Mock from './mock_data'; @@ -42,22 +42,14 @@ describe('Sidebar mediator', () => { }); }); - it('fetches the data', () => { + it('fetches the data', async () => { const mockData = Mock.responseMap.GET[mediatorMockData.endpoint]; mock.onGet(mediatorMockData.endpoint).reply(200, mockData); - - const mockGraphQlData = Mock.graphQlResponseData; - const graphQlSpy = jest.spyOn(gqClient, 'query').mockReturnValue({ - data: mockGraphQlData, - }); const spy = jest.spyOn(mediator, 'processFetchedData').mockReturnValue(Promise.resolve()); + await mediator.fetch(); - return mediator.fetch().then(() => { - expect(spy).toHaveBeenCalledWith(mockData, mockGraphQlData); - - spy.mockRestore(); - graphQlSpy.mockRestore(); - }); + expect(spy).toHaveBeenCalledWith(mockData); + spy.mockRestore(); }); it('processes fetched data', () => { diff --git a/spec/frontend/sidebar/sidebar_move_issue_spec.js b/spec/frontend/sidebar/sidebar_move_issue_spec.js index 2e6807ed9d8..195cc6ddeeb 100644 --- a/spec/frontend/sidebar/sidebar_move_issue_spec.js +++ b/spec/frontend/sidebar/sidebar_move_issue_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import SidebarMoveIssue from '~/sidebar/lib/sidebar_move_issue'; import SidebarService from '~/sidebar/services/sidebar_service'; @@ -115,7 +115,7 @@ describe('SidebarMoveIssue', () => { // Wait for the move issue request to fail await waitForPromises(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); expect(test.$confirmButton.prop('disabled')).toBe(false); expect(test.$confirmButton.hasClass('is-loading')).toBe(false); }); diff --git a/spec/frontend/sidebar/subscriptions_spec.js b/spec/frontend/sidebar/subscriptions_spec.js index 6ab8e1e0ebc..1a1aa370eef 100644 --- a/spec/frontend/sidebar/subscriptions_spec.js +++ b/spec/frontend/sidebar/subscriptions_spec.js @@ -108,7 +108,7 @@ describe('Subscriptions', () => { expect(wrapper.findByTestId('subscription-title').text()).toContain( subscribeDisabledDescription, ); - expect(wrapper.find({ ref: 'tooltip' }).attributes('title')).toBe( + expect(wrapper.findComponent({ ref: 'tooltip' }).attributes('title')).toBe( subscribeDisabledDescription, ); }); diff --git a/spec/frontend/sidebar/todo_spec.js b/spec/frontend/sidebar/todo_spec.js index 5f696b237e0..8e6597bf80f 100644 --- a/spec/frontend/sidebar/todo_spec.js +++ b/spec/frontend/sidebar/todo_spec.js @@ -43,8 +43,8 @@ describe('SidebarTodo', () => { ({ isTodo, iconClass, label, icon }) => { createComponent({ isTodo }); - expect(wrapper.find(GlIcon).classes().join(' ')).toStrictEqual(iconClass); - expect(wrapper.find(GlIcon).props('name')).toStrictEqual(icon); + expect(wrapper.findComponent(GlIcon).classes().join(' ')).toStrictEqual(iconClass); + expect(wrapper.findComponent(GlIcon).props('name')).toStrictEqual(icon); expect(wrapper.find('button').text()).toBe(label); }, ); @@ -76,19 +76,19 @@ describe('SidebarTodo', () => { it('renders button icon when `collapsed` prop is `true`', () => { createComponent({ collapsed: true }); - expect(wrapper.find(GlIcon).props('name')).toBe('todo-done'); + expect(wrapper.findComponent(GlIcon).props('name')).toBe('todo-done'); }); it('renders loading icon when `isActionActive` prop is true', () => { createComponent({ isActionActive: true }); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); it('hides button icon when `isActionActive` prop is true', () => { createComponent({ collapsed: true, isActionActive: true }); - expect(wrapper.find(GlIcon).isVisible()).toBe(false); + expect(wrapper.findComponent(GlIcon).isVisible()).toBe(false); }); }); }); diff --git a/spec/frontend/smart_interval_spec.js b/spec/frontend/smart_interval_spec.js index 5dda097ae6a..64928fc4ae9 100644 --- a/spec/frontend/smart_interval_spec.js +++ b/spec/frontend/smart_interval_spec.js @@ -109,7 +109,7 @@ describe('SmartInterval', () => { return waitForPromises().then(() => { const { intervalId } = interval.state; - expect(intervalId).toBeTruthy(); + expect(intervalId).not.toBeUndefined(); }); }); }); @@ -130,7 +130,7 @@ describe('SmartInterval', () => { jest.runOnlyPendingTimers(); return waitForPromises().then(() => { - expect(interval.state.intervalId).toBeTruthy(); + expect(interval.state.intervalId).not.toBeUndefined(); // simulates triggering of visibilitychange event interval.onVisibilityChange({ target: { visibilityState: 'hidden' } }); @@ -148,16 +148,16 @@ describe('SmartInterval', () => { jest.runOnlyPendingTimers(); return waitForPromises().then(() => { - expect(interval.state.intervalId).toBeTruthy(); + expect(interval.state.intervalId).not.toBeUndefined(); expect( interval.getCurrentInterval() >= DEFAULT_STARTING_INTERVAL && interval.getCurrentInterval() <= DEFAULT_MAX_INTERVAL, - ).toBeTruthy(); + ).toBe(true); // simulates triggering of visibilitychange event interval.onVisibilityChange({ target: { visibilityState: 'hidden' } }); - expect(interval.state.intervalId).toBeTruthy(); + expect(interval.state.intervalId).not.toBeUndefined(); expect(interval.getCurrentInterval()).toBe(HIDDEN_INTERVAL); }); }); @@ -166,7 +166,7 @@ describe('SmartInterval', () => { jest.runOnlyPendingTimers(); return waitForPromises().then(() => { - expect(interval.state.intervalId).toBeTruthy(); + expect(interval.state.intervalId).not.toBeUndefined(); // simulates triggering of visibilitychange event interval.onVisibilityChange({ target: { visibilityState: 'hidden' } }); @@ -176,7 +176,7 @@ describe('SmartInterval', () => { // simulates triggering of visibilitychange event interval.onVisibilityChange({ target: { visibilityState: 'visible' } }); - expect(interval.state.intervalId).toBeTruthy(); + expect(interval.state.intervalId).not.toBeUndefined(); }); }); @@ -194,7 +194,7 @@ describe('SmartInterval', () => { it('should execute callback before first interval', () => { interval = createDefaultSmartInterval({ immediateExecution: true }); - expect(interval.cfg.immediateExecution).toBeFalsy(); + expect(interval.cfg.immediateExecution).toBe(false); }); }); }); diff --git a/spec/frontend/snippet/collapsible_input_spec.js b/spec/frontend/snippet/collapsible_input_spec.js index 56e64d136c2..4a6fd33b9e4 100644 --- a/spec/frontend/snippet/collapsible_input_spec.js +++ b/spec/frontend/snippet/collapsible_input_spec.js @@ -9,7 +9,7 @@ describe('~/snippet/collapsible_input', () => { beforeEach(() => { setHTMLFixture(` - <form> + <form> <div class="js-collapsible-input js-title"> <div class="js-collapsed d-none"> <input type="text" /> @@ -72,7 +72,7 @@ describe('~/snippet/collapsible_input', () => { ${'is collapsed'} | ${''} | ${true} ${'stays open if given value'} | ${'Hello world!'} | ${false} `('when loses focus', ({ desc, value, isCollapsed }) => { - it(desc, () => { + it(`${desc}`, () => { findExpandedInput(descriptionEl).value = value; focusIn(fooEl); diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index cf897414ccb..e7dab0ad79d 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -9,7 +9,7 @@ import { stubPerformanceWebAPI } from 'helpers/performance'; import waitForPromises from 'helpers/wait_for_promises'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import * as urlUtils from '~/lib/utils/url_utility'; import SnippetEditApp from '~/snippets/components/edit.vue'; import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_edit.vue'; @@ -206,7 +206,7 @@ describe('Snippet Edit app', () => { }); it('should hide loader', () => { - expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); }); }); @@ -237,7 +237,7 @@ describe('Snippet Edit app', () => { !titleHasErrors, ); - expect(wrapper.find(SnippetBlobActionsEdit).props('isValid')).toEqual( + expect(wrapper.findComponent(SnippetBlobActionsEdit).props('isValid')).toEqual( !blobActionsHasErrors, ); }, @@ -273,7 +273,7 @@ describe('Snippet Edit app', () => { selectedLevel: visibility, }); - expect(wrapper.find(SnippetVisibilityEdit).props('value')).toBe(visibility); + expect(wrapper.findComponent(SnippetVisibilityEdit).props('value')).toBe(visibility); }); describe('form submission handling', () => { @@ -361,7 +361,7 @@ describe('Snippet Edit app', () => { await waitForPromises(); expect(urlUtils.redirectTo).not.toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: `Can't create snippet: ${TEST_MUTATION_ERROR}`, }); }); @@ -385,7 +385,7 @@ describe('Snippet Edit app', () => { }); expect(urlUtils.redirectTo).not.toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: `Can't update snippet: ${TEST_MUTATION_ERROR}`, }); }, @@ -407,7 +407,7 @@ describe('Snippet Edit app', () => { it('should flash', () => { // Apollo automatically wraps the resolver's error in a NetworkError - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: `Can't update snippet: ${TEST_API_ERROR.message}`, }); }); diff --git a/spec/frontend/snippets/components/embed_dropdown_spec.js b/spec/frontend/snippets/components/embed_dropdown_spec.js index 389b1c618a3..ed5ea6cab8a 100644 --- a/spec/frontend/snippets/components/embed_dropdown_spec.js +++ b/spec/frontend/snippets/components/embed_dropdown_spec.js @@ -36,7 +36,7 @@ describe('snippets/components/embed_dropdown', () => { sections.push(current); } else { - const value = x.find(GlFormInputGroup).props('value'); + const value = x.findComponent(GlFormInputGroup).props('value'); const copyValue = x.find('button[title="Copy"]').attributes('data-clipboard-text'); Object.assign(current, { diff --git a/spec/frontend/snippets/components/snippet_blob_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_edit_spec.js index 7ea27864519..33b8e2be969 100644 --- a/spec/frontend/snippets/components/snippet_blob_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_edit_spec.js @@ -4,7 +4,7 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue'; @@ -46,9 +46,9 @@ describe('Snippet Blob Edit component', () => { }); }; - const findLoadingIcon = () => wrapper.find(GlLoadingIcon); - const findHeader = () => wrapper.find(BlobHeaderEdit); - const findContent = () => wrapper.find(SourceEditor); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findHeader = () => wrapper.findComponent(BlobHeaderEdit); + const findContent = () => wrapper.findComponent(SourceEditor); const getLastUpdatedArgs = () => { const event = wrapper.emitted()['blob-updated']; @@ -125,7 +125,7 @@ describe('Snippet Blob Edit component', () => { it('should call flash', async () => { await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: "Can't fetch content for the blob: Error: Request failed with status code 500", }); }); diff --git a/spec/frontend/snippets/components/snippet_blob_view_spec.js b/spec/frontend/snippets/components/snippet_blob_view_spec.js index aa31377f390..c7ff8c21d80 100644 --- a/spec/frontend/snippets/components/snippet_blob_view_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_view_spec.js @@ -69,13 +69,13 @@ describe('Blob Embeddable', () => { describe('rendering', () => { it('renders correct components', () => { createComponent(); - expect(wrapper.find(BlobHeader).exists()).toBe(true); - expect(wrapper.find(BlobContent).exists()).toBe(true); + expect(wrapper.findComponent(BlobHeader).exists()).toBe(true); + expect(wrapper.findComponent(BlobContent).exists()).toBe(true); }); it('sets simple viewer correctly', () => { createComponent(); - expect(wrapper.find(SimpleViewer).exists()).toBe(true); + expect(wrapper.findComponent(SimpleViewer).exists()).toBe(true); }); it('sets rich viewer correctly', () => { @@ -83,20 +83,20 @@ describe('Blob Embeddable', () => { createComponent({ data, }); - expect(wrapper.find(RichViewer).exists()).toBe(true); + expect(wrapper.findComponent(RichViewer).exists()).toBe(true); }); it('correctly switches viewer type', async () => { createComponent(); - expect(wrapper.find(SimpleViewer).exists()).toBe(true); + expect(wrapper.findComponent(SimpleViewer).exists()).toBe(true); wrapper.vm.switchViewer(RichViewerMock.type); await nextTick(); - expect(wrapper.find(RichViewer).exists()).toBe(true); + expect(wrapper.findComponent(RichViewer).exists()).toBe(true); await wrapper.vm.switchViewer(SimpleViewerMock.type); - expect(wrapper.find(SimpleViewer).exists()).toBe(true); + expect(wrapper.findComponent(SimpleViewer).exists()).toBe(true); }); it('passes information about render error down to blob header', () => { @@ -110,7 +110,7 @@ describe('Blob Embeddable', () => { }, }); - expect(wrapper.find(BlobHeader).props('hasRenderError')).toBe(true); + expect(wrapper.findComponent(BlobHeader).props('hasRenderError')).toBe(true); }); describe('bob content in multi-file scenario', () => { @@ -161,7 +161,7 @@ describe('Blob Embeddable', () => { await nextTick(); - const findContent = () => wrapper.find(BlobContent); + const findContent = () => wrapper.findComponent(BlobContent); expect(findContent().props('content')).toBe(expectedContent); }, @@ -169,36 +169,69 @@ describe('Blob Embeddable', () => { }); describe('URLS with hash', () => { - beforeEach(() => { - window.location.hash = '#LC2'; - }); - afterEach(() => { window.location.hash = ''; }); - it('renders simple viewer by default if URL contains hash', () => { - createComponent({ - data: {}, + describe('if hash starts with #LC', () => { + beforeEach(() => { + window.location.hash = '#LC2'; + }); + + it('renders simple viewer by default', () => { + createComponent({ + data: {}, + }); + + expect(wrapper.vm.activeViewerType).toBe(SimpleViewerMock.type); + expect(wrapper.findComponent(SimpleViewer).exists()).toBe(true); }); - expect(wrapper.vm.activeViewerType).toBe(SimpleViewerMock.type); - expect(wrapper.find(SimpleViewer).exists()).toBe(true); + describe('switchViewer()', () => { + it('switches to the passed viewer', async () => { + createComponent(); + + wrapper.vm.switchViewer(RichViewerMock.type); + + await nextTick(); + expect(wrapper.vm.activeViewerType).toBe(RichViewerMock.type); + expect(wrapper.findComponent(RichViewer).exists()).toBe(true); + + await wrapper.vm.switchViewer(SimpleViewerMock.type); + expect(wrapper.vm.activeViewerType).toBe(SimpleViewerMock.type); + expect(wrapper.findComponent(SimpleViewer).exists()).toBe(true); + }); + }); }); - describe('switchViewer()', () => { - it('switches to the passed viewer', async () => { - createComponent(); + describe('if hash starts with anything else', () => { + beforeEach(() => { + window.location.hash = '#last-headline'; + }); - wrapper.vm.switchViewer(RichViewerMock.type); + it('renders rich viewer by default', () => { + createComponent({ + data: {}, + }); - await nextTick(); expect(wrapper.vm.activeViewerType).toBe(RichViewerMock.type); - expect(wrapper.find(RichViewer).exists()).toBe(true); + expect(wrapper.findComponent(RichViewer).exists()).toBe(true); + }); - await wrapper.vm.switchViewer(SimpleViewerMock.type); - expect(wrapper.vm.activeViewerType).toBe(SimpleViewerMock.type); - expect(wrapper.find(SimpleViewer).exists()).toBe(true); + describe('switchViewer()', () => { + it('switches to the passed viewer', async () => { + createComponent(); + + wrapper.vm.switchViewer(SimpleViewerMock.type); + + await nextTick(); + expect(wrapper.vm.activeViewerType).toBe(SimpleViewerMock.type); + expect(wrapper.findComponent(SimpleViewer).exists()).toBe(true); + + await wrapper.vm.switchViewer(RichViewerMock.type); + expect(wrapper.vm.activeViewerType).toBe(RichViewerMock.type); + expect(wrapper.findComponent(RichViewer).exists()).toBe(true); + }); }); }); }); @@ -206,7 +239,7 @@ describe('Blob Embeddable', () => { describe('functionality', () => { describe('render error', () => { - const findContentEl = () => wrapper.find(BlobContent); + const findContentEl = () => wrapper.findComponent(BlobContent); it('correctly sets blob on the blob-content-error component', () => { createComponent(); diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js index b750225a383..c930c9f635b 100644 --- a/spec/frontend/snippets/components/snippet_header_spec.js +++ b/spec/frontend/snippets/components/snippet_header_spec.js @@ -10,7 +10,7 @@ import { differenceInMilliseconds } from '~/lib/utils/datetime_utility'; import SnippetHeader, { i18n } from '~/snippets/components/snippet_header.vue'; import DeleteSnippetMutation from '~/snippets/mutations/delete_snippet.mutation.graphql'; import axios from '~/lib/utils/axios_utils'; -import createFlash, { FLASH_TYPES } from '~/flash'; +import { createAlert, VARIANT_DANGER, VARIANT_SUCCESS } from '~/flash'; jest.mock('~/flash'); @@ -267,9 +267,9 @@ describe('Snippet header component', () => { }); it.each` - request | variant | text - ${200} | ${'SUCCESS'} | ${i18n.snippetSpamSuccess} - ${500} | ${'DANGER'} | ${i18n.snippetSpamFailure} + request | variant | text + ${200} | ${VARIANT_SUCCESS} | ${i18n.snippetSpamSuccess} + ${500} | ${VARIANT_DANGER} | ${i18n.snippetSpamFailure} `( 'renders a "$variant" flash message with "$text" message for a request with a "$request" response', async ({ request, variant, text }) => { @@ -278,9 +278,9 @@ describe('Snippet header component', () => { submitAsSpamBtn.trigger('click'); await waitForPromises(); - expect(createFlash).toHaveBeenLastCalledWith({ + expect(createAlert).toHaveBeenLastCalledWith({ message: expect.stringContaining(text), - type: FLASH_TYPES[variant], + variant, }); }, ); @@ -311,7 +311,7 @@ describe('Snippet header component', () => { it('renders modal for deletion of a snippet', () => { createComponent(); - expect(wrapper.find(GlModal).exists()).toBe(true); + expect(wrapper.findComponent(GlModal).exists()).toBe(true); }); it.each` diff --git a/spec/frontend/snippets/components/snippet_title_spec.js b/spec/frontend/snippets/components/snippet_title_spec.js index 48fb51ce703..7c40735d64e 100644 --- a/spec/frontend/snippets/components/snippet_title_spec.js +++ b/spec/frontend/snippets/components/snippet_title_spec.js @@ -39,12 +39,12 @@ describe('Snippet header component', () => { createComponent(); expect(wrapper.text().trim()).toContain(title); - expect(wrapper.find(SnippetDescription).props('description')).toBe(descriptionHtml); + expect(wrapper.findComponent(SnippetDescription).props('description')).toBe(descriptionHtml); }); it('does not render recent changes time stamp if there were no updates', () => { createComponent(); - expect(wrapper.find(GlSprintf).exists()).toBe(false); + expect(wrapper.findComponent(GlSprintf).exists()).toBe(false); }); it('does not render recent changes time stamp if the time for creation and updates match', () => { @@ -57,7 +57,7 @@ describe('Snippet header component', () => { }); createComponent({ props }); - expect(wrapper.find(GlSprintf).exists()).toBe(false); + expect(wrapper.findComponent(GlSprintf).exists()).toBe(false); }); it('renders translated string with most recent changes timestamp if changes were made', () => { @@ -70,6 +70,6 @@ describe('Snippet header component', () => { }); createComponent({ props }); - expect(wrapper.find(GlSprintf).exists()).toBe(true); + expect(wrapper.findComponent(GlSprintf).exists()).toBe(true); }); }); diff --git a/spec/frontend/snippets/components/snippet_visibility_edit_spec.js b/spec/frontend/snippets/components/snippet_visibility_edit_spec.js index 2d043a5caba..29eb002ef4a 100644 --- a/spec/frontend/snippets/components/snippet_visibility_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_visibility_edit_spec.js @@ -39,13 +39,13 @@ describe('Snippet Visibility Edit component', () => { }); } - const findLink = () => wrapper.find('label').find(GlLink); - const findRadios = () => wrapper.find(GlFormRadioGroup).findAllComponents(GlFormRadio); + const findLink = () => wrapper.find('label').findComponent(GlLink); + const findRadios = () => wrapper.findComponent(GlFormRadioGroup).findAllComponents(GlFormRadio); const findRadiosData = () => findRadios().wrappers.map((x) => { return { value: x.find('input').attributes('value'), - icon: x.find(GlIcon).props('name'), + icon: x.findComponent(GlIcon).props('name'), description: x.find('.help-text').text(), text: x.find('.js-visibility-option').text(), }; @@ -147,7 +147,7 @@ describe('Snippet Visibility Edit component', () => { createComponent({ propsData: { value } }); - expect(wrapper.find(GlFormRadioGroup).attributes('checked')).toBe(value); + expect(wrapper.findComponent(GlFormRadioGroup).attributes('checked')).toBe(value); }); }); }); diff --git a/spec/frontend/terms/components/app_spec.js b/spec/frontend/terms/components/app_spec.js index ee78b35843a..f1dbc004da8 100644 --- a/spec/frontend/terms/components/app_spec.js +++ b/spec/frontend/terms/components/app_spec.js @@ -74,7 +74,7 @@ describe('TermsApp', () => { expect(findButton(defaultProvide.paths.accept).attributes('disabled')).toBe('disabled'); - wrapper.find(GlIntersectionObserver).vm.$emit('appear'); + wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear'); await nextTick(); diff --git a/spec/frontend/terraform/components/states_table_spec.js b/spec/frontend/terraform/components/states_table_spec.js index 12a44452717..0b3b169891b 100644 --- a/spec/frontend/terraform/components/states_table_spec.js +++ b/spec/frontend/terraform/components/states_table_spec.js @@ -160,8 +160,8 @@ describe('StatesTable', () => { const state = states.at(lineNumber); expect(state.text()).toContain(name); - expect(state.find(GlBadge).exists()).toBe(hasBadge); - expect(state.find(GlLoadingIcon).exists()).toBe(loading); + expect(state.findComponent(GlBadge).exists()).toBe(hasBadge); + expect(state.findComponent(GlLoadingIcon).exists()).toBe(loading); if (hasBadge) { const badge = wrapper.findByTestId(`state-badge-${name}`); @@ -198,7 +198,7 @@ describe('StatesTable', () => { const states = wrapper.findAll('[data-testid="terraform-states-table-pipeline"]'); const state = states.at(lineNumber); - expect(state.find(GlTooltip).exists()).toBe(toolTipAdded); + expect(state.findComponent(GlTooltip).exists()).toBe(toolTipAdded); expect(state.text()).toMatchInterpolatedText(pipelineText); }, ); diff --git a/spec/frontend/terraform/components/terraform_list_spec.js b/spec/frontend/terraform/components/terraform_list_spec.js index cfd82768098..580951e799a 100644 --- a/spec/frontend/terraform/components/terraform_list_spec.js +++ b/spec/frontend/terraform/components/terraform_list_spec.js @@ -57,11 +57,11 @@ describe('TerraformList', () => { }); }; - const findBadge = () => wrapper.find(GlBadge); - const findEmptyState = () => wrapper.find(EmptyState); - const findPaginationButtons = () => wrapper.find(GlKeysetPagination); - const findStatesTable = () => wrapper.find(StatesTable); - const findTab = () => wrapper.find(GlTab); + const findBadge = () => wrapper.findComponent(GlBadge); + const findEmptyState = () => wrapper.findComponent(EmptyState); + const findPaginationButtons = () => wrapper.findComponent(GlKeysetPagination); + const findStatesTable = () => wrapper.findComponent(StatesTable); + const findTab = () => wrapper.findComponent(GlTab); afterEach(() => { wrapper.destroy(); @@ -182,7 +182,7 @@ describe('TerraformList', () => { }); it('displays an alert message', () => { - expect(wrapper.find(GlAlert).exists()).toBe(true); + expect(wrapper.findComponent(GlAlert).exists()).toBe(true); }); }); @@ -195,7 +195,7 @@ describe('TerraformList', () => { }); it('displays a loading icon', () => { - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); }); }); diff --git a/spec/frontend/toggles/index_spec.js b/spec/frontend/toggles/index_spec.js index 19c4d6f1f1d..f8c43e0ad0c 100644 --- a/spec/frontend/toggles/index_spec.js +++ b/spec/frontend/toggles/index_spec.js @@ -83,12 +83,12 @@ describe('toggles/index.js', () => { expect(listener).toHaveBeenCalledTimes(0); - wrapper.find(GlToggle).vm.$emit(event, true); + wrapper.findComponent(GlToggle).vm.$emit(event, true); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenLastCalledWith(true); - wrapper.find(GlToggle).vm.$emit(event, false); + wrapper.findComponent(GlToggle).vm.$emit(event, false); expect(listener).toHaveBeenCalledTimes(2); expect(listener).toHaveBeenLastCalledWith(false); diff --git a/spec/frontend/token_access/token_access_spec.js b/spec/frontend/token_access/token_access_spec.js index 024e7dfff8c..c55ac32b6a6 100644 --- a/spec/frontend/token_access/token_access_spec.js +++ b/spec/frontend/token_access/token_access_spec.js @@ -4,7 +4,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import TokenAccess from '~/token_access/components/token_access.vue'; import addProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/add_project_ci_job_token_scope.mutation.graphql'; import removeProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql'; @@ -40,7 +40,7 @@ describe('TokenAccess component', () => { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAddProjectBtn = () => wrapper.findByRole('button', { name: 'Add project' }); const findRemoveProjectBtn = () => wrapper.findByRole('button', { name: 'Remove access' }); - const findTokenSection = () => wrapper.find('[data-testid="token-section"]'); + const findTokenDisabledAlert = () => wrapper.findByTestId('token-disabled-alert'); const createMockApolloProvider = (requestHandlers) => { return createMockApollo(requestHandlers); @@ -80,7 +80,7 @@ describe('TokenAccess component', () => { }); describe('toggle', () => { - it('the toggle should be enabled and the token section should show', async () => { + it('the toggle is on and the alert is hidden', async () => { createComponent([ [getCIJobTokenScopeQuery, enabledJobTokenScopeHandler], [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], @@ -89,10 +89,10 @@ describe('TokenAccess component', () => { await waitForPromises(); expect(findToggle().props('value')).toBe(true); - expect(findTokenSection().exists()).toBe(true); + expect(findTokenDisabledAlert().exists()).toBe(false); }); - it('the toggle should be disabled and the token section should show', async () => { + it('the toggle is off and the alert is visible', async () => { createComponent([ [getCIJobTokenScopeQuery, disabledJobTokenScopeHandler], [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], @@ -101,7 +101,7 @@ describe('TokenAccess component', () => { await waitForPromises(); expect(findToggle().props('value')).toBe(false); - expect(findTokenSection().exists()).toBe(true); + expect(findTokenDisabledAlert().exists()).toBe(true); }); }); @@ -144,7 +144,7 @@ describe('TokenAccess component', () => { await waitForPromises(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); @@ -187,7 +187,7 @@ describe('TokenAccess component', () => { await waitForPromises(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/tooltips/components/tooltips_spec.js b/spec/frontend/tooltips/components/tooltips_spec.js index 998bb2a9ea2..d5a63a99601 100644 --- a/spec/frontend/tooltips/components/tooltips_spec.js +++ b/spec/frontend/tooltips/components/tooltips_spec.js @@ -49,7 +49,7 @@ describe('tooltips/components/tooltips.vue', () => { await nextTick(); - expect(wrapper.find(GlTooltip).props('target')).toBe(target); + expect(wrapper.findComponent(GlTooltip).props('target')).toBe(target); }); it('does not attach a tooltip to a target with empty title', async () => { @@ -59,7 +59,7 @@ describe('tooltips/components/tooltips.vue', () => { await nextTick(); - expect(wrapper.find(GlTooltip).exists()).toBe(false); + expect(wrapper.findComponent(GlTooltip).exists()).toBe(false); }); it('does not attach a tooltip twice to the same element', async () => { @@ -76,7 +76,7 @@ describe('tooltips/components/tooltips.vue', () => { await nextTick(); - expect(wrapper.find(GlTooltip).text()).toBe(target.getAttribute('title')); + expect(wrapper.findComponent(GlTooltip).text()).toBe(target.getAttribute('title')); }); it('supports HTML content', async () => { @@ -88,7 +88,7 @@ describe('tooltips/components/tooltips.vue', () => { await nextTick(); - expect(wrapper.find(GlTooltip).html()).toContain(target.getAttribute('title')); + expect(wrapper.findComponent(GlTooltip).html()).toContain(target.getAttribute('title')); }); it('sets the configuration values passed in the config object', async () => { @@ -96,7 +96,7 @@ describe('tooltips/components/tooltips.vue', () => { target = createTooltipTarget(); wrapper.vm.addTooltips([target], config); await nextTick(); - expect(wrapper.find(GlTooltip).props()).toMatchObject(config); + expect(wrapper.findComponent(GlTooltip).props()).toMatchObject(config); }); it.each` @@ -113,7 +113,7 @@ describe('tooltips/components/tooltips.vue', () => { await nextTick(); - expect(wrapper.find(GlTooltip).props(prop)).toBe(value); + expect(wrapper.findComponent(GlTooltip).props(prop)).toBe(value); }, ); }); @@ -180,7 +180,7 @@ describe('tooltips/components/tooltips.vue', () => { wrapper.vm.triggerEvent(target, event); - expect(wrapper.find(GlTooltip).emitted(event)).toHaveLength(1); + expect(wrapper.findComponent(GlTooltip).emitted(event)).toHaveLength(1); }); }); @@ -198,14 +198,14 @@ describe('tooltips/components/tooltips.vue', () => { await nextTick(); - expect(wrapper.find(GlTooltip).text()).toBe(currentTitle); + expect(wrapper.findComponent(GlTooltip).text()).toBe(currentTitle); target.setAttribute('title', newTitle); wrapper.vm.fixTitle(target); await nextTick(); - expect(wrapper.find(GlTooltip).text()).toBe(newTitle); + expect(wrapper.findComponent(GlTooltip).text()).toBe(newTitle); }); }); diff --git a/spec/frontend/user_lists/components/edit_user_list_spec.js b/spec/frontend/user_lists/components/edit_user_list_spec.js index 941c8244247..5f067d9de3c 100644 --- a/spec/frontend/user_lists/components/edit_user_list_spec.js +++ b/spec/frontend/user_lists/components/edit_user_list_spec.js @@ -47,7 +47,7 @@ describe('user_lists/components/edit_user_list', () => { }); it('should show a loading icon', () => { - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); }); @@ -60,7 +60,7 @@ describe('user_lists/components/edit_user_list', () => { factory(); await waitForPromises(); - alert = wrapper.find(GlAlert); + alert = wrapper.findComponent(GlAlert); }); it('should show a flash with the error respopnse', () => { @@ -72,7 +72,7 @@ describe('user_lists/components/edit_user_list', () => { }); it('should not show a user list form', () => { - expect(wrapper.find(UserListForm).exists()).toBe(false); + expect(wrapper.findComponent(UserListForm).exists()).toBe(false); }); }); @@ -129,7 +129,7 @@ describe('user_lists/components/edit_user_list', () => { clickSave(); await waitForPromises(); - alert = wrapper.find(GlAlert); + alert = wrapper.findComponent(GlAlert); }); it('should show a flash with the error respopnse', () => { diff --git a/spec/frontend/user_lists/components/new_user_list_spec.js b/spec/frontend/user_lists/components/new_user_list_spec.js index ace4a284347..8683cf2463c 100644 --- a/spec/frontend/user_lists/components/new_user_list_spec.js +++ b/spec/frontend/user_lists/components/new_user_list_spec.js @@ -72,7 +72,7 @@ describe('user_lists/components/new_user_list', () => { await waitForPromises(); - alert = wrapper.find(GlAlert); + alert = wrapper.findComponent(GlAlert); }); it('should show a flash with the error respopnse', () => { diff --git a/spec/frontend/user_lists/components/user_list_spec.js b/spec/frontend/user_lists/components/user_list_spec.js index f126c733dd5..e02862cad2b 100644 --- a/spec/frontend/user_lists/components/user_list_spec.js +++ b/spec/frontend/user_lists/components/user_list_spec.js @@ -50,7 +50,7 @@ describe('User List', () => { }); it('shows a loading icon', () => { - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); }); @@ -157,7 +157,7 @@ describe('User List', () => { }); describe('error', () => { - const findAlert = () => wrapper.find(GlAlert); + const findAlert = () => wrapper.findComponent(GlAlert); beforeEach(async () => { Api.fetchFeatureFlagUserList.mockRejectedValue(); @@ -190,7 +190,7 @@ describe('User List', () => { }); it('displays an empty state', () => { - expect(wrapper.find(GlEmptyState).exists()).toBe(true); + expect(wrapper.findComponent(GlEmptyState).exists()).toBe(true); }); }); }); diff --git a/spec/frontend/user_lists/components/user_lists_table_spec.js b/spec/frontend/user_lists/components/user_lists_table_spec.js index fb5093eb065..3324b040b86 100644 --- a/spec/frontend/user_lists/components/user_lists_table_spec.js +++ b/spec/frontend/user_lists/components/user_lists_table_spec.js @@ -59,7 +59,7 @@ describe('User Lists Table', () => { describe('delete button', () => { it('should display the confirmation modal', async () => { - const modal = wrapper.find(GlModal); + const modal = wrapper.findComponent(GlModal); wrapper.find('[data-testid="delete-user-list"]').trigger('click'); @@ -73,7 +73,7 @@ describe('User Lists Table', () => { let modal; beforeEach(async () => { - modal = wrapper.find(GlModal); + modal = wrapper.findComponent(GlModal); wrapper.find('button').trigger('click'); diff --git a/spec/frontend/user_popovers_spec.js b/spec/frontend/user_popovers_spec.js index 0530569c9df..8ce071c075f 100644 --- a/spec/frontend/user_popovers_spec.js +++ b/spec/frontend/user_popovers_spec.js @@ -188,8 +188,8 @@ describe('User Popovers', () => { }); it('removes title attribute from user links', () => { - expect(userLink.getAttribute('title')).toBeFalsy(); - expect(userLink.dataset.originalTitle).toBeFalsy(); + expect(userLink.getAttribute('title')).toBe(''); + expect(userLink.dataset.originalTitle).toBe(''); }); it('fetches user info and status from the user cache', () => { diff --git a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js index 05cd1bb5b3d..1f3b6dce620 100644 --- a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js @@ -1,7 +1,7 @@ import { nextTick } from 'vue'; import { GlButton, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import Approvals from '~/vue_merge_request_widget/components/approvals/approvals.vue'; import ApprovalsSummary from '~/vue_merge_request_widget/components/approvals/approvals_summary.vue'; import ApprovalsSummaryOptional from '~/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue'; @@ -49,7 +49,7 @@ describe('MRWidget approvals', () => { }); }; - const findAction = () => wrapper.find(GlButton); + const findAction = () => wrapper.findComponent(GlButton); const findActionData = () => { const action = findAction(); @@ -61,8 +61,8 @@ describe('MRWidget approvals', () => { text: action.text(), }; }; - const findSummary = () => wrapper.find(ApprovalsSummary); - const findOptionalSummary = () => wrapper.find(ApprovalsSummaryOptional); + const findSummary = () => wrapper.findComponent(ApprovalsSummary); + const findOptionalSummary = () => wrapper.findComponent(ApprovalsSummaryOptional); const findInvalidRules = () => wrapper.find('[data-testid="invalid-rules"]'); beforeEach(() => { @@ -129,7 +129,7 @@ describe('MRWidget approvals', () => { }); it('flashes error', () => { - expect(createFlash).toHaveBeenCalledWith({ message: FETCH_ERROR }); + expect(createAlert).toHaveBeenCalledWith({ message: FETCH_ERROR }); }); }); @@ -268,7 +268,7 @@ describe('MRWidget approvals', () => { }); it('flashes error message', () => { - expect(createFlash).toHaveBeenCalledWith({ message: APPROVE_ERROR }); + expect(createAlert).toHaveBeenCalledWith({ message: APPROVE_ERROR }); }); }); }); @@ -319,7 +319,7 @@ describe('MRWidget approvals', () => { }); it('flashes error message', () => { - expect(createFlash).toHaveBeenCalledWith({ message: UNAPPROVE_ERROR }); + expect(createAlert).toHaveBeenCalledWith({ message: UNAPPROVE_ERROR }); }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_optional_spec.js b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_optional_spec.js index 65cafc647e0..e6fb0495947 100644 --- a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_optional_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_optional_spec.js @@ -18,7 +18,7 @@ describe('MRWidget approvals summary optional', () => { wrapper = null; }); - const findHelpLink = () => wrapper.find(GlLink); + const findHelpLink = () => wrapper.findComponent(GlLink); describe('when can approve', () => { beforeEach(() => { diff --git a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_spec.js b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_spec.js index c2606346292..f4234083346 100644 --- a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_spec.js @@ -29,7 +29,7 @@ describe('MRWidget approvals summary', () => { }); }; - const findAvatars = () => wrapper.find(UserAvatarList); + const findAvatars = () => wrapper.findComponent(UserAvatarList); afterEach(() => { wrapper.destroy(); @@ -136,7 +136,7 @@ describe('MRWidget approvals summary', () => { }); it('does not render avatar list', () => { - expect(wrapper.find(UserAvatarList).exists()).toBe(false); + expect(wrapper.findComponent(UserAvatarList).exists()).toBe(false); }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/artifacts_list_app_spec.js b/spec/frontend/vue_merge_request_widget/components/artifacts_list_app_spec.js index e2386bc7f2b..73fa4b7b08f 100644 --- a/spec/frontend/vue_merge_request_widget/components/artifacts_list_app_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/artifacts_list_app_spec.js @@ -60,7 +60,7 @@ describe('Merge Requests Artifacts list app', () => { }); it('renders a loading icon', () => { - const loadingIcon = wrapper.find(GlLoadingIcon); + const loadingIcon = wrapper.findComponent(GlLoadingIcon); expect(loadingIcon.exists()).toBe(true); }); diff --git a/spec/frontend/vue_merge_request_widget/components/artifacts_list_spec.js b/spec/frontend/vue_merge_request_widget/components/artifacts_list_spec.js index d519ad2cdb0..b7bf72cd215 100644 --- a/spec/frontend/vue_merge_request_widget/components/artifacts_list_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/artifacts_list_spec.js @@ -31,11 +31,11 @@ describe('Artifacts List', () => { }); it('renders link for the artifact', () => { - expect(wrapper.find(GlLink).attributes('href')).toEqual(data.artifacts[0].url); + expect(wrapper.findComponent(GlLink).attributes('href')).toEqual(data.artifacts[0].url); }); it('renders artifact name', () => { - expect(wrapper.find(GlLink).text()).toEqual(data.artifacts[0].text); + expect(wrapper.findComponent(GlLink).text()).toEqual(data.artifacts[0].text); }); it('renders job url', () => { diff --git a/spec/frontend/vue_merge_request_widget/components/mr_collapsible_extension_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_collapsible_extension_spec.js index 01fbcb2154f..c253dc63f23 100644 --- a/spec/frontend/vue_merge_request_widget/components/mr_collapsible_extension_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/mr_collapsible_extension_spec.js @@ -23,7 +23,7 @@ describe('Merge Request Collapsible Extension', () => { const findTitle = () => wrapper.find('[data-testid="mr-collapsible-title"]'); const findErrorMessage = () => wrapper.find('.js-error-state'); - const findIcon = () => wrapper.find(GlIcon); + const findIcon = () => wrapper.findComponent(GlIcon); afterEach(() => { wrapper.destroy(); @@ -77,7 +77,7 @@ describe('Merge Request Collapsible Extension', () => { }); it('renders loading spinner', () => { - expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true); + expect(wrapper.findComponent(GlLoadingIcon).isVisible()).toBe(true); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_author_time_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_author_time_spec.js index 8fd93809e01..90a29d15488 100644 --- a/spec/frontend/vue_merge_request_widget/components/mr_widget_author_time_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_author_time_spec.js @@ -32,7 +32,9 @@ describe('MrWidgetAuthorTime', () => { }); it('renders author', () => { - expect(wrapper.find(MrWidgetAuthor).props('author')).toStrictEqual(defaultProps.author); + expect(wrapper.findComponent(MrWidgetAuthor).props('author')).toStrictEqual( + defaultProps.author, + ); }); it('renders provided time', () => { diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_expandable_section_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_expandable_section_spec.js index 631aef412a6..8eaed998eb5 100644 --- a/spec/frontend/vue_merge_request_widget/components/mr_widget_expandable_section_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_expandable_section_spec.js @@ -6,8 +6,8 @@ import MrCollapsibleSection from '~/vue_merge_request_widget/components/mr_widge describe('MrWidgetExpanableSection', () => { let wrapper; - const findButton = () => wrapper.find(GlButton); - const findCollapse = () => wrapper.find(GlCollapse); + const findButton = () => wrapper.findComponent(GlButton); + const findCollapse = () => wrapper.findComponent(GlCollapse); beforeEach(() => { wrapper = shallowMount(MrCollapsibleSection, { @@ -19,7 +19,7 @@ describe('MrWidgetExpanableSection', () => { }); it('renders Icon', () => { - expect(wrapper.find(GlIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlIcon).exists()).toBe(true); }); it('renders header slot', () => { diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_icon_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_icon_spec.js index ebd10f31fa7..6a9b019fb4f 100644 --- a/spec/frontend/vue_merge_request_widget/components/mr_widget_icon_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_icon_spec.js @@ -21,6 +21,6 @@ describe('MrWidgetIcon', () => { it('renders icon and container', () => { expect(wrapper.element.className).toContain('circle-icon-container'); - expect(wrapper.find(GlIcon).props('name')).toEqual(TEST_ICON); + expect(wrapper.findComponent(GlIcon).props('name')).toEqual(TEST_ICON); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_container_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_container_spec.js index efe2bf75c3f..c3f6331e560 100644 --- a/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_container_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_container_spec.js @@ -41,8 +41,8 @@ describe('MrWidgetPipelineContainer', () => { }); it('renders pipeline', () => { - expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true); - expect(wrapper.find(MrWidgetPipeline).props()).toMatchObject({ + expect(wrapper.findComponent(MrWidgetPipeline).exists()).toBe(true); + expect(wrapper.findComponent(MrWidgetPipeline).props()).toMatchObject({ pipeline: mockStore.pipeline, pipelineCoverageDelta: mockStore.pipelineCoverageDelta, ciStatus: mockStore.ciStatus, @@ -82,9 +82,9 @@ describe('MrWidgetPipelineContainer', () => { }); it('renders pipeline', () => { - expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true); + expect(wrapper.findComponent(MrWidgetPipeline).exists()).toBe(true); expect(findCIErrorMessage().exists()).toBe(false); - expect(wrapper.find(MrWidgetPipeline).props()).toMatchObject({ + expect(wrapper.findComponent(MrWidgetPipeline).props()).toMatchObject({ pipeline: mockStore.mergePipeline, pipelineCoverageDelta: mockStore.pipelineCoverageDelta, ciStatus: mockStore.mergePipeline.details.status.text, @@ -102,7 +102,7 @@ describe('MrWidgetPipelineContainer', () => { targetBranch: 'Foo<script>alert("XSS")</script>', }, }); - expect(wrapper.find(MrWidgetPipeline).props().sourceBranchLink).toBe('Foo'); + expect(wrapper.findComponent(MrWidgetPipeline).props().sourceBranchLink).toBe('Foo'); }); it('renders deployments', () => { @@ -125,7 +125,7 @@ describe('MrWidgetPipelineContainer', () => { it('renders the artifacts app', () => { factory(); - expect(wrapper.find(ArtifactsApp).isVisible()).toBe(true); + expect(wrapper.findComponent(ArtifactsApp).isVisible()).toBe(true); }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_suggest_pipeline_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_suggest_pipeline_spec.js index d6c67dab381..73358edee78 100644 --- a/spec/frontend/vue_merge_request_widget/components/mr_widget_suggest_pipeline_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_suggest_pipeline_spec.js @@ -57,7 +57,7 @@ describe('MRWidgetSuggestPipeline', () => { }); it('renders widget icon', () => { - const icon = wrapper.find(MrWidgetIcon); + const icon = wrapper.findComponent(MrWidgetIcon); expect(icon.exists()).toBe(true); expect(icon.props()).toEqual( @@ -115,7 +115,7 @@ describe('MRWidgetSuggestPipeline', () => { }); describe('dismissible', () => { - const findDismissContainer = () => wrapper.find(dismissibleContainer); + const findDismissContainer = () => wrapper.findComponent(dismissibleContainer); beforeEach(() => { wrapper = shallowMount(suggestPipelineComponent, { propsData: suggestProps }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap b/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap index 635ef0f6b0d..5f383c468d8 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap +++ b/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap @@ -72,11 +72,14 @@ exports[`MRWidgetAutoMergeEnabled when graphql is disabled template should have <div class="gl-display-flex gl-md-display-block gl-font-size-0 gl-ml-auto" > - <div> + <div + class="gl-display-flex gl-align-items-flex-start" + > <div class="dropdown b-dropdown gl-new-dropdown gl-display-block gl-md-display-none! btn-group" lazy="" no-caret="" + title="Options" > <!----> <button @@ -246,11 +249,14 @@ exports[`MRWidgetAutoMergeEnabled when graphql is enabled template should have c <div class="gl-display-flex gl-md-display-block gl-font-size-0 gl-ml-auto" > - <div> + <div + class="gl-display-flex gl-align-items-flex-start" + > <div class="dropdown b-dropdown gl-new-dropdown gl-display-block gl-md-display-none! btn-group" lazy="" no-caret="" + title="Options" > <!----> <button diff --git a/spec/frontend/vue_merge_request_widget/components/states/merge_checks_failed_spec.js b/spec/frontend/vue_merge_request_widget/components/states/merge_checks_failed_spec.js index 1900b53ac11..d85574262fe 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/merge_checks_failed_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/merge_checks_failed_spec.js @@ -15,9 +15,9 @@ describe('Merge request widget merge checks failed state component', () => { }); it.each` - mrState | displayText - ${{ approvals: true, isApproved: false }} | ${'approvalNeeded'} - ${{ blockingMergeRequests: { total_count: 1 } }} | ${'blockingMergeRequests'} + mrState | displayText + ${{ approvals: true, isApproved: false }} | ${'approvalNeeded'} + ${{ detailedMergeStatus: 'BLOCKED_STATUS' }} | ${'blockingMergeRequests'} `('display $displayText text for $mrState', ({ mrState, displayText }) => { factory({ mr: mrState }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed_spec.js index 9320e733636..398a3912882 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed_spec.js @@ -7,7 +7,7 @@ import eventHub from '~/vue_merge_request_widget/event_hub'; describe('MRWidgetAutoMergeFailed', () => { let wrapper; const mergeError = 'This is the merge error'; - const findButton = () => wrapper.find(GlButton); + const findButton = () => wrapper.findComponent(GlButton); const createComponent = (props = {}, mergeRequestWidgetGraphql = false) => { wrapper = mount(AutoMergeFailedComponent, { @@ -61,7 +61,7 @@ describe('MRWidgetAutoMergeFailed', () => { await nextTick(); expect(findButton().attributes('disabled')).toBe('disabled'); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merged_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merged_spec.js index 2606933450e..a3aa563b516 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merged_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merged_spec.js @@ -1,180 +1,172 @@ import { getByRole } from '@testing-library/dom'; -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '~/projects/commit/constants'; import modalEventHub from '~/projects/commit/event_hub'; -import mergedComponent from '~/vue_merge_request_widget/components/states/mr_widget_merged.vue'; +import MergedComponent from '~/vue_merge_request_widget/components/states/mr_widget_merged.vue'; import eventHub from '~/vue_merge_request_widget/event_hub'; describe('MRWidgetMerged', () => { - let vm; + let wrapper; const targetBranch = 'foo'; - - beforeEach(() => { - jest.spyOn(document, 'dispatchEvent'); - const Component = Vue.extend(mergedComponent); - const mr = { - isRemovingSourceBranch: false, - cherryPickInForkPath: false, - canCherryPickInCurrentMR: true, - revertInForkPath: false, - canRevertInCurrentMR: true, - canRemoveSourceBranch: true, - sourceBranchRemoved: true, - metrics: { - mergedBy: { - name: 'Administrator', - username: 'root', - webUrl: 'http://localhost:3000/root', - avatarUrl: - 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + const mr = { + isRemovingSourceBranch: false, + cherryPickInForkPath: false, + canCherryPickInCurrentMR: true, + revertInForkPath: false, + canRevertInCurrentMR: true, + canRemoveSourceBranch: true, + sourceBranchRemoved: true, + metrics: { + mergedBy: { + name: 'Administrator', + username: 'root', + webUrl: 'http://localhost:3000/root', + avatarUrl: + 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }, + mergedAt: 'Jan 24, 2018 1:02pm UTC', + readableMergedAt: '', + closedBy: {}, + closedAt: 'Jan 24, 2018 1:02pm UTC', + readableClosedAt: '', + }, + updatedAt: 'mergedUpdatedAt', + shortMergeCommitSha: '958c0475', + mergeCommitSha: '958c047516e182dfc52317f721f696e8a1ee85ed', + mergeCommitPath: + 'http://localhost:3000/root/nautilus/commit/f7ce827c314c9340b075657fd61c789fb01cf74d', + sourceBranch: 'bar', + targetBranch, + }; + + const service = { + removeSourceBranch: () => nextTick(), + }; + + const createComponent = (customMrFields = {}) => { + wrapper = mount(MergedComponent, { + propsData: { + mr: { + ...mr, + ...customMrFields, }, - mergedAt: 'Jan 24, 2018 1:02pm UTC', - readableMergedAt: '', - closedBy: {}, - closedAt: 'Jan 24, 2018 1:02pm UTC', - readableClosedAt: '', + service, }, - updatedAt: 'mergedUpdatedAt', - shortMergeCommitSha: '958c0475', - mergeCommitSha: '958c047516e182dfc52317f721f696e8a1ee85ed', - mergeCommitPath: - 'http://localhost:3000/root/nautilus/commit/f7ce827c314c9340b075657fd61c789fb01cf74d', - sourceBranch: 'bar', - targetBranch, - }; - - const service = { - removeSourceBranch() {}, - }; + }); + }; + beforeEach(() => { + jest.spyOn(document, 'dispatchEvent'); jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - - vm = mountComponent(Component, { mr, service }); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - describe('computed', () => { - describe('shouldShowRemoveSourceBranch', () => { - it('returns true when sourceBranchRemoved is false', () => { - vm.mr.sourceBranchRemoved = false; - - expect(vm.shouldShowRemoveSourceBranch).toEqual(true); - }); - - it('returns false when sourceBranchRemoved is true', () => { - vm.mr.sourceBranchRemoved = true; - - expect(vm.shouldShowRemoveSourceBranch).toEqual(false); - }); - - it('returns false when canRemoveSourceBranch is false', () => { - vm.mr.sourceBranchRemoved = false; - vm.mr.canRemoveSourceBranch = false; - - expect(vm.shouldShowRemoveSourceBranch).toEqual(false); - }); - - it('returns false when is making request', () => { - vm.mr.canRemoveSourceBranch = true; - vm.isMakingRequest = true; - - expect(vm.shouldShowRemoveSourceBranch).toEqual(false); - }); + const findButtonByText = (text) => + wrapper.findAll('button').wrappers.find((w) => w.text() === text); + const findRemoveSourceBranchButton = () => findButtonByText('Delete source branch'); - it('returns true when all are true', () => { - vm.mr.isRemovingSourceBranch = true; - vm.mr.canRemoveSourceBranch = true; - vm.isMakingRequest = true; + describe('remove source branch button', () => { + it('is displayed when sourceBranchRemoved is false', () => { + createComponent({ sourceBranchRemoved: false }); - expect(vm.shouldShowRemoveSourceBranch).toEqual(false); - }); + expect(findRemoveSourceBranchButton().exists()).toBe(true); }); - describe('shouldShowSourceBranchRemoving', () => { - it('should correct value when fields changed', () => { - vm.mr.sourceBranchRemoved = false; + it('is not displayed when sourceBranchRemoved is true', () => { + createComponent({ sourceBranchRemoved: true }); - expect(vm.shouldShowSourceBranchRemoving).toEqual(false); + expect(findRemoveSourceBranchButton()).toBe(undefined); + }); - vm.mr.sourceBranchRemoved = true; + it('is not displayed when canRemoveSourceBranch is true', () => { + createComponent({ sourceBranchRemoved: false, canRemoveSourceBranch: false }); - expect(vm.shouldShowRemoveSourceBranch).toEqual(false); + expect(findRemoveSourceBranchButton()).toBe(undefined); + }); - vm.mr.sourceBranchRemoved = false; - vm.isMakingRequest = true; + it('is not displayed when is making request', async () => { + createComponent({ sourceBranchRemoved: false, canRemoveSourceBranch: true }); - expect(vm.shouldShowSourceBranchRemoving).toEqual(true); + await findRemoveSourceBranchButton().trigger('click'); - vm.isMakingRequest = false; - vm.mr.isRemovingSourceBranch = true; + expect(findRemoveSourceBranchButton()).toBe(undefined); + }); - expect(vm.shouldShowSourceBranchRemoving).toEqual(true); + it('is not displayed when all are true', () => { + createComponent({ + isRemovingSourceBranch: true, + sourceBranchRemoved: false, + canRemoveSourceBranch: true, }); + + expect(findRemoveSourceBranchButton()).toBe(undefined); }); }); - describe('methods', () => { - describe('removeSourceBranch', () => { - it('should set flag and call service then request main component to update the widget', async () => { - jest.spyOn(vm.service, 'removeSourceBranch').mockReturnValue( - new Promise((resolve) => { - resolve({ - data: { - message: 'Branch was deleted', - }, - }); - }), - ); + it('should set flag and call service then request main component to update the widget when branch is removed', async () => { + createComponent({ sourceBranchRemoved: false }); + jest.spyOn(service, 'removeSourceBranch').mockResolvedValue({ + data: { + message: 'Branch was deleted', + }, + }); - vm.removeSourceBranch(); + await findRemoveSourceBranchButton().trigger('click'); - await waitForPromises(); + await waitForPromises(); - const args = eventHub.$emit.mock.calls[0]; + const args = eventHub.$emit.mock.calls[0]; - expect(vm.isMakingRequest).toEqual(true); - expect(args[0]).toEqual('MRWidgetUpdateRequested'); - expect(args[1]).not.toThrow(); - }); - }); + expect(args[0]).toEqual('MRWidgetUpdateRequested'); + expect(args[1]).not.toThrow(); }); it('calls dispatchDocumentEvent to load in the modal component', () => { + createComponent(); + expect(document.dispatchEvent).toHaveBeenCalledWith(new CustomEvent('merged:UpdateActions')); }); it('emits event to open the revert modal on revert button click', () => { + createComponent(); const eventHubSpy = jest.spyOn(modalEventHub, '$emit'); - getByRole(vm.$el, 'button', { name: /Revert/i }).click(); + getByRole(wrapper.element, 'button', { name: /Revert/i }).click(); expect(eventHubSpy).toHaveBeenCalledWith(OPEN_REVERT_MODAL); }); it('emits event to open the cherry-pick modal on cherry-pick button click', () => { + createComponent(); const eventHubSpy = jest.spyOn(modalEventHub, '$emit'); - getByRole(vm.$el, 'button', { name: /Cherry-pick/i }).click(); + getByRole(wrapper.element, 'button', { name: /Cherry-pick/i }).click(); expect(eventHubSpy).toHaveBeenCalledWith(OPEN_CHERRY_PICK_MODAL); }); it('has merged by information', () => { - expect(vm.$el.textContent).toContain('Merged by'); - expect(vm.$el.textContent).toContain('Administrator'); + createComponent(); + + expect(wrapper.text()).toContain('Merged by'); + expect(wrapper.text()).toContain('Administrator'); }); it('shows revert and cherry-pick buttons', () => { - expect(vm.$el.textContent).toContain('Revert'); - expect(vm.$el.textContent).toContain('Cherry-pick'); + createComponent(); + + expect(wrapper.text()).toContain('Revert'); + expect(wrapper.text()).toContain('Cherry-pick'); }); it('should use mergedEvent mergedAt as tooltip title', () => { - expect(vm.$el.querySelector('time').getAttribute('title')).toBe('Jan 24, 2018 1:02pm UTC'); + createComponent(); + + expect(wrapper.find('time').attributes('title')).toBe('Jan 24, 2018 1:02pm UTC'); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_failed_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_failed_spec.js index d5619d4996d..bd158d59d74 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_failed_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_failed_spec.js @@ -6,31 +6,42 @@ import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_i describe('PipelineFailed', () => { let wrapper; - const createComponent = () => { + const createComponent = (mr = {}) => { wrapper = shallowMount(PipelineFailed, { + propsData: { + mr, + }, stubs: { GlSprintf, }, }); }; - beforeEach(() => { - createComponent(); - }); - afterEach(() => { wrapper.destroy(); wrapper = null; }); it('should render error status icon', () => { + createComponent(); + expect(wrapper.findComponent(StatusIcon).exists()).toBe(true); expect(wrapper.findComponent(StatusIcon).props().status).toBe('failed'); }); it('should render error message with a disabled merge button', () => { + createComponent(); + expect(wrapper.text()).toContain('Merge blocked: pipeline must succeed.'); expect(wrapper.text()).toContain('Push a commit that fixes the failure'); expect(wrapper.findComponent(GlLink).text()).toContain('learn about other solutions'); }); + + it('should render pipeline blocked message', () => { + createComponent({ isPipelineBlocked: true }); + + expect(wrapper.text()).toContain( + "Merge blocked: pipeline must succeed. It's waiting for a manual action to continue.", + ); + }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js index 9a6bf66909e..48d3f15560b 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -105,16 +105,17 @@ const createComponent = ( }, stubs: { CommitEdit, + GlSprintf, }, apolloProvider: createMockApollo([[readyToMergeQuery, readyToMergeResponseSpy]]), }); }; -const findCheckboxElement = () => wrapper.find(SquashBeforeMerge); +const findCheckboxElement = () => wrapper.findComponent(SquashBeforeMerge); const findCommitEditElements = () => wrapper.findAllComponents(CommitEdit); -const findCommitDropdownElement = () => wrapper.find(CommitMessageDropdown); +const findCommitDropdownElement = () => wrapper.findComponent(CommitMessageDropdown); const findFirstCommitEditLabel = () => findCommitEditElements().at(0).props('label'); -const findTipLink = () => wrapper.find(GlSprintf); +const findTipLink = () => wrapper.findComponent(GlSprintf); const findCommitEditWithInputId = (inputId) => findCommitEditElements().wrappers.find((x) => x.props('inputId') === inputId); const findMergeCommitMessage = () => findCommitEditWithInputId('merge-message-edit').props('value'); @@ -300,6 +301,48 @@ describe('ReadyToMerge', () => { expect(wrapper.vm.isMergeButtonDisabled).toBe(true); }); }); + + describe('sourceBranchDeletedText', () => { + const should = 'Source branch will be deleted.'; + const shouldNot = 'Source branch will not be deleted.'; + const did = 'Deleted the source branch.'; + const didNot = 'Did not delete the source branch.'; + const scenarios = [ + "the MR hasn't merged yet, and the backend-provided value expects to delete the branch", + "the MR hasn't merged yet, and the backend-provided value expects to leave the branch", + "the MR hasn't merged yet, and the backend-provided value is a non-boolean falsey value", + "the MR hasn't merged yet, and the backend-provided value is a non-boolean truthy value", + 'the MR has been merged, and the backend reports that the branch has been removed', + 'the MR has been merged, and the backend reports that the branch has not been removed', + 'the MR has been merged, and the backend reports a non-boolean falsey value', + 'the MR has been merged, and the backend reports a non-boolean truthy value', + ]; + + it.each` + describe | premerge | mrShould | mrRemoved | output + ${scenarios[0]} | ${true} | ${true} | ${null} | ${should} + ${scenarios[1]} | ${true} | ${false} | ${null} | ${shouldNot} + ${scenarios[2]} | ${true} | ${null} | ${null} | ${shouldNot} + ${scenarios[3]} | ${true} | ${'yeah'} | ${null} | ${should} + ${scenarios[4]} | ${false} | ${null} | ${true} | ${did} + ${scenarios[5]} | ${false} | ${null} | ${false} | ${didNot} + ${scenarios[6]} | ${false} | ${null} | ${null} | ${didNot} + ${scenarios[7]} | ${false} | ${null} | ${'yep'} | ${did} + `( + 'in the case that $describe, returns "$output"', + ({ premerge, mrShould, mrRemoved, output }) => { + createComponent({ + mr: { + state: !premerge ? 'merged' : 'literally-anything-else', + shouldRemoveSourceBranch: mrShould, + sourceBranchRemoved: mrRemoved, + }, + }); + + expect(wrapper.vm.sourceBranchDeletedText).toBe(output); + }, + ); + }); }); describe('methods', () => { @@ -733,6 +776,34 @@ describe('ReadyToMerge', () => { }); }); + describe('source and target branches diverged', () => { + describe('when the MR is showing the Merge button', () => { + it('does not display the diverged commits message if the source branch is not behind the target', () => { + createComponent({ mr: { divergedCommitsCount: 0 } }); + + const textBody = wrapper.text(); + + expect(textBody).toEqual( + expect.not.stringContaining('The source branch is 0 commits behind the target branch'), + ); + expect(textBody).toEqual( + expect.not.stringContaining('The source branch is 0 commit behind the target branch'), + ); + expect(textBody).toEqual( + expect.not.stringContaining('The source branch is behind the target branch'), + ); + }); + + it('shows the diverged commits text when the source branch is behind the target', () => { + createComponent({ mr: { divergedCommitsCount: 9001, canMerge: false } }); + + expect(wrapper.text()).toEqual( + expect.stringContaining('The source branch is 9001 commits behind the target branch'), + ); + }); + }); + }); + describe('Merge button when pipeline has failed', () => { beforeEach(() => { createComponent({ diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_squash_before_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_squash_before_merge_spec.js index 6ea2e8675d3..c839fa17fe5 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_squash_before_merge_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_squash_before_merge_spec.js @@ -18,7 +18,7 @@ describe('Squash before merge component', () => { wrapper.destroy(); }); - const findCheckbox = () => wrapper.find(GlFormCheckbox); + const findCheckbox = () => wrapper.findComponent(GlFormCheckbox); describe('checkbox', () => { it('is unchecked if passed value prop is false', () => { diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_wip_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_wip_spec.js index af52901f508..7259f210b6e 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_wip_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_wip_spec.js @@ -38,7 +38,7 @@ describe('Wip', () => { it('should have default data', () => { const vm = createComponent(); - expect(vm.isMakingRequest).toBeFalsy(); + expect(vm.isMakingRequest).toBe(false); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/terraform/mr_widget_terraform_container_spec.js b/spec/frontend/vue_merge_request_widget/components/terraform/mr_widget_terraform_container_spec.js deleted file mode 100644 index 7a868eb8cc9..00000000000 --- a/spec/frontend/vue_merge_request_widget/components/terraform/mr_widget_terraform_container_spec.js +++ /dev/null @@ -1,175 +0,0 @@ -import { GlSkeletonLoader, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; -import axios from '~/lib/utils/axios_utils'; -import Poll from '~/lib/utils/poll'; -import MrWidgetExpanableSection from '~/vue_merge_request_widget/components/mr_widget_expandable_section.vue'; -import MrWidgetTerraformContainer from '~/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue'; -import TerraformPlan from '~/vue_merge_request_widget/components/terraform/terraform_plan.vue'; -import { invalidPlanWithName, plans, validPlanWithName } from './mock_data'; - -describe('MrWidgetTerraformConainer', () => { - let mock; - let wrapper; - - const propsData = { endpoint: '/path/to/terraform/report.json' }; - - const findHeader = () => wrapper.find('[data-testid="terraform-header-text"]'); - const findPlans = () => - wrapper.findAllComponents(TerraformPlan).wrappers.map((x) => x.props('plan')); - - const mockPollingApi = (response, body, header) => { - mock.onGet(propsData.endpoint).reply(response, body, header); - }; - - const mountWrapper = () => { - wrapper = shallowMount(MrWidgetTerraformContainer, { - propsData, - stubs: { MrWidgetExpanableSection, GlSprintf }, - }); - return axios.waitForAll(); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - wrapper.destroy(); - mock.restore(); - }); - - describe('when data is loading', () => { - beforeEach(async () => { - mockPollingApi(200, plans, {}); - - await mountWrapper(); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ loading: true }); - await nextTick(); - }); - - it('diplays loading skeleton', () => { - expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true); - expect(wrapper.find(MrWidgetExpanableSection).exists()).toBe(false); - }); - }); - - describe('when data has finished loading', () => { - beforeEach(() => { - mockPollingApi(200, plans, {}); - return mountWrapper(); - }); - - it('displays terraform content', () => { - expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false); - expect(wrapper.find(MrWidgetExpanableSection).exists()).toBe(true); - expect(findPlans()).toEqual(Object.values(plans)); - }); - - describe('when data includes one invalid plan', () => { - beforeEach(() => { - const invalidPlanGroup = { bad_plan: invalidPlanWithName }; - mockPollingApi(200, invalidPlanGroup, {}); - return mountWrapper(); - }); - - it('displays header text for one invalid plan', () => { - expect(findHeader().text()).toBe('1 Terraform report failed to generate'); - }); - }); - - describe('when data includes multiple invalid plans', () => { - beforeEach(() => { - const invalidPlanGroup = { - bad_plan_one: invalidPlanWithName, - bad_plan_two: invalidPlanWithName, - }; - - mockPollingApi(200, invalidPlanGroup, {}); - return mountWrapper(); - }); - - it('displays header text for multiple invalid plans', () => { - expect(findHeader().text()).toBe('2 Terraform reports failed to generate'); - }); - }); - - describe('when data includes one valid plan', () => { - beforeEach(() => { - const validPlanGroup = { valid_plan: validPlanWithName }; - mockPollingApi(200, validPlanGroup, {}); - return mountWrapper(); - }); - - it('displays header text for one valid plans', () => { - expect(findHeader().text()).toBe('1 Terraform report was generated in your pipelines'); - }); - }); - - describe('when data includes multiple valid plans', () => { - beforeEach(() => { - const validPlanGroup = { - valid_plan_one: validPlanWithName, - valid_plan_two: validPlanWithName, - }; - mockPollingApi(200, validPlanGroup, {}); - return mountWrapper(); - }); - - it('displays header text for multiple valid plans', () => { - expect(findHeader().text()).toBe('2 Terraform reports were generated in your pipelines'); - }); - }); - }); - - 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', () => { - beforeEach(() => { - mockPollingApi(200, plans, {}); - - return mountWrapper(); - }); - - it('does not make additional requests after poll is successful', () => { - expect(pollRequest).toHaveBeenCalledTimes(1); - expect(pollStop).toHaveBeenCalledTimes(1); - }); - }); - - describe('polling fails', () => { - beforeEach(() => { - mockPollingApi(500, null, {}); - return mountWrapper(); - }); - - it('stops loading', () => { - expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false); - }); - - it('generates one broken plan', () => { - expect(findPlans()).toEqual([{ tf_report_error: 'api_error' }]); - }); - - it('does not make additional requests after poll is unsuccessful', () => { - expect(pollRequest).toHaveBeenCalledTimes(1); - expect(pollStop).toHaveBeenCalledTimes(1); - }); - }); - }); -}); diff --git a/spec/frontend/vue_merge_request_widget/components/terraform/terraform_plan_spec.js b/spec/frontend/vue_merge_request_widget/components/terraform/terraform_plan_spec.js deleted file mode 100644 index 3c9f6c2e165..00000000000 --- a/spec/frontend/vue_merge_request_widget/components/terraform/terraform_plan_spec.js +++ /dev/null @@ -1,93 +0,0 @@ -import { GlLink, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import TerraformPlan from '~/vue_merge_request_widget/components/terraform/terraform_plan.vue'; -import { - invalidPlanWithName, - invalidPlanWithoutName, - validPlanWithName, - validPlanWithoutName, -} from './mock_data'; - -describe('TerraformPlan', () => { - let wrapper; - - const findIcon = () => wrapper.find('[data-testid="change-type-icon"]'); - const findLogButton = () => wrapper.find('[data-testid="terraform-report-link"]'); - - const mountWrapper = (propsData) => { - wrapper = shallowMount(TerraformPlan, { stubs: { GlLink, GlSprintf }, propsData }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - describe('valid plan with job_name', () => { - beforeEach(() => { - mountWrapper({ plan: validPlanWithName }); - }); - - it('displays a document icon', () => { - expect(findIcon().attributes('name')).toBe('doc-changes'); - }); - - it('diplays the header text with a name', () => { - expect(wrapper.text()).toContain(`The job ${validPlanWithName.job_name} generated a report.`); - }); - - it('diplays the reported changes', () => { - expect(wrapper.text()).toContain( - `Reported Resource Changes: ${validPlanWithName.create} to add, ${validPlanWithName.update} to change, ${validPlanWithName.delete} to delete`, - ); - }); - - it('renders button when url is found', () => { - expect(findLogButton().exists()).toBe(true); - expect(findLogButton().text()).toEqual('View full log'); - }); - }); - - describe('valid plan without job_name', () => { - beforeEach(() => { - mountWrapper({ plan: validPlanWithoutName }); - }); - - it('diplays the header text without a name', () => { - expect(wrapper.text()).toContain('A report was generated in your pipelines.'); - }); - }); - - describe('invalid plan with job_name', () => { - beforeEach(() => { - mountWrapper({ plan: invalidPlanWithName }); - }); - - it('displays a warning icon', () => { - expect(findIcon().attributes('name')).toBe('warning'); - }); - - it('diplays the header text with a name', () => { - expect(wrapper.text()).toContain( - `The job ${invalidPlanWithName.job_name} failed to generate a report.`, - ); - }); - - it('diplays generic error since report values are missing', () => { - expect(wrapper.text()).toContain('Generating the report caused an error.'); - }); - }); - - describe('invalid plan with out job_name', () => { - beforeEach(() => { - mountWrapper({ plan: invalidPlanWithoutName }); - }); - - it('diplays the header text without a name', () => { - expect(wrapper.text()).toContain('A report failed to generate.'); - }); - - it('does not render button because url is missing', () => { - expect(findLogButton().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap b/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap new file mode 100644 index 00000000000..08424077269 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`~/vue_merge_request_widget/components/widget/dynamic_content.vue renders given data 1`] = ` +"<content-row-stub level=\\"2\\" statusiconname=\\"success\\" widgetname=\\"MyWidget\\" header=\\"This is a header,This is a subheader\\"> + <div class=\\"gl-display-flex gl-flex-direction-column\\"> + <div> + <p class=\\"gl-mb-0\\">Main text for the row</p> + <gl-link-stub href=\\"https://gitlab.com\\">Optional link to display after text</gl-link-stub> + <!----> + <gl-badge-stub size=\\"md\\" variant=\\"info\\"> + Badge is optional. Text to be displayed inside badge + </gl-badge-stub> + <actions-stub widget=\\"MyWidget\\" tertiarybuttons=\\"\\" class=\\"gl-ml-auto gl-pl-3\\"></actions-stub> + <p class=\\"gl-m-0 gl-font-sm\\">Optional: Smaller sub-text to be displayed below the main text</p> + </div> + <ul class=\\"gl-m-0 gl-p-0 gl-list-style-none\\"> + <li> + <content-row-stub level=\\"3\\" statusiconname=\\"\\" widgetname=\\"MyWidget\\" header=\\"Child row header\\" data-qa-selector=\\"child_content\\"> + <div class=\\"gl-display-flex gl-flex-direction-column\\"> + <div> + <p class=\\"gl-mb-0\\">This is recursive. It will be listed in level 3.</p> + <!----> + <!----> + <!----> + <actions-stub widget=\\"MyWidget\\" tertiarybuttons=\\"\\" class=\\"gl-ml-auto gl-pl-3\\"></actions-stub> + <!----> + </div> + <!----> + </div> + </content-row-stub> + </li> + </ul> + </div> +</content-row-stub>" +`; diff --git a/spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js new file mode 100644 index 00000000000..b7753a58747 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js @@ -0,0 +1,52 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants'; +import DynamicContent from '~/vue_merge_request_widget/components/widget/dynamic_content.vue'; + +describe('~/vue_merge_request_widget/components/widget/dynamic_content.vue', () => { + let wrapper; + + const createComponent = ({ propsData } = {}) => { + wrapper = shallowMountExtended(DynamicContent, { + propsData: { + widgetName: 'MyWidget', + ...propsData, + }, + stubs: { + DynamicContent, + }, + }); + }; + + it('renders given data', () => { + createComponent({ + propsData: { + data: { + id: 'row-id', + header: ['This is a header', 'This is a subheader'], + text: 'Main text for the row', + subtext: 'Optional: Smaller sub-text to be displayed below the main text', + icon: { + name: EXTENSION_ICONS.success, + }, + badge: { + text: 'Badge is optional. Text to be displayed inside badge', + variant: 'info', + }, + link: { + text: 'Optional link to display after text', + href: 'https://gitlab.com', + }, + children: [ + { + id: 'row-id-2', + header: 'Child row header', + text: 'This is recursive. It will be listed in level 3.', + }, + ], + }, + }, + }); + + expect(wrapper.html()).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/widget/widget_content_row_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/widget_content_row_spec.js new file mode 100644 index 00000000000..9eddd091ad0 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/widget/widget_content_row_spec.js @@ -0,0 +1,65 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import WidgetContentRow from '~/vue_merge_request_widget/components/widget/widget_content_row.vue'; +import StatusIcon from '~/vue_merge_request_widget/components/widget/status_icon.vue'; + +describe('~/vue_merge_request_widget/components/widget/widget_content_row.vue', () => { + let wrapper; + + const findStatusIcon = () => wrapper.findComponent(StatusIcon); + + const createComponent = ({ propsData, slots } = {}) => { + wrapper = shallowMountExtended(WidgetContentRow, { + propsData: { + widgetName: 'MyWidget', + level: 2, + ...propsData, + }, + slots, + }); + }; + + describe('body', () => { + it('renders the status icon when provided', () => { + createComponent({ propsData: { statusIconName: 'failed' } }); + expect(findStatusIcon().exists()).toBe(true); + }); + + it('does not render the status icon when it is not provided', () => { + createComponent(); + expect(findStatusIcon().exists()).toBe(false); + }); + + it('renders slots properly', () => { + createComponent({ + propsData: { + statusIconName: 'success', + }, + slots: { + header: '<span>this is a header</span>', + body: '<span>this is a body</span>', + }, + }); + + expect(wrapper.findByText('this is a body').exists()).toBe(true); + expect(wrapper.findByText('this is a header').exists()).toBe(true); + }); + }); + + describe('header', () => { + it('renders an array of header and subheader', () => { + createComponent({ propsData: { header: ['this is a header', 'this is a subheader'] } }); + expect(wrapper.findByText('this is a header').exists()).toBe(true); + expect(wrapper.findByText('this is a subheader').exists()).toBe(true); + }); + + it('renders a string', () => { + createComponent({ propsData: { header: 'this is a header' } }); + expect(wrapper.findByText('this is a header').exists()).toBe(true); + }); + + it('escapes html injection properly', () => { + createComponent({ propsData: { header: '<b role="header">this is a header</b>' } }); + expect(wrapper.findByText('<b role="header">this is a header</b>').exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/widget/widget_content_section_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/widget_content_section_spec.js deleted file mode 100644 index c2128d3ff33..00000000000 --- a/spec/frontend/vue_merge_request_widget/components/widget/widget_content_section_spec.js +++ /dev/null @@ -1,39 +0,0 @@ -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import WidgetContentSection from '~/vue_merge_request_widget/components/widget/widget_content_section.vue'; -import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; - -describe('~/vue_merge_request_widget/components/widget/widget_content_section.vue', () => { - let wrapper; - - const findStatusIcon = () => wrapper.findComponent(StatusIcon); - - const createComponent = ({ propsData, slots } = {}) => { - wrapper = shallowMountExtended(WidgetContentSection, { - propsData: { - widgetName: 'MyWidget', - ...propsData, - }, - slots, - }); - }; - - it('does not render the status icon when it is not provided', () => { - createComponent(); - expect(findStatusIcon().exists()).toBe(false); - }); - - it('renders the status icon when provided', () => { - createComponent({ propsData: { statusIconName: 'failed' } }); - expect(findStatusIcon().exists()).toBe(true); - }); - - it('renders the default slot', () => { - createComponent({ - slots: { - default: 'Hello world', - }, - }); - - expect(wrapper.findByText('Hello world').exists()).toBe(true); - }); -}); diff --git a/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js index b67b5703ad5..4826fecf98d 100644 --- a/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js @@ -5,8 +5,9 @@ import waitForPromises from 'helpers/wait_for_promises'; import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; import ActionButtons from '~/vue_merge_request_widget/components/action_buttons.vue'; import Widget from '~/vue_merge_request_widget/components/widget/widget.vue'; +import WidgetContentRow from '~/vue_merge_request_widget/components/widget/widget_content_row.vue'; -describe('MR Widget', () => { +describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { let wrapper; const findStatusIcon = () => wrapper.findComponent(StatusIcon); @@ -27,6 +28,10 @@ describe('MR Widget', () => { ...propsData, }, slots, + stubs: { + StatusIcon, + ContentRow: WidgetContentRow, + }, }); }; diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js index 7e7438bcc0f..1bad5dacefa 100644 --- a/spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js +++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js @@ -41,7 +41,7 @@ describe('Deployment action button', () => { }); it('renders prop icon correctly', () => { - expect(wrapper.find(GlIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlIcon).exists()).toBe(true); }); }); @@ -59,7 +59,7 @@ describe('Deployment action button', () => { }); it('renders slot and icon prop correctly', () => { - expect(wrapper.find(GlIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlIcon).exists()).toBe(true); expect(wrapper.text()).toContain(actionButtonMocks[DEPLOYING].toString()); }); }); @@ -75,8 +75,8 @@ describe('Deployment action button', () => { }); it('is disabled and shows the loading icon', () => { - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); - expect(wrapper.find(GlButton).props('disabled')).toBe(true); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlButton).props('disabled')).toBe(true); }); }); @@ -90,8 +90,8 @@ describe('Deployment action button', () => { }); }); it('is disabled and does not show the loading icon', () => { - expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); - expect(wrapper.find(GlButton).props('disabled')).toBe(true); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.findComponent(GlButton).props('disabled')).toBe(true); }); }); @@ -106,8 +106,8 @@ describe('Deployment action button', () => { }); }); it('is disabled and does not show the loading icon', () => { - expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); - expect(wrapper.find(GlButton).props('disabled')).toBe(true); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.findComponent(GlButton).props('disabled')).toBe(true); }); }); @@ -118,8 +118,8 @@ describe('Deployment action button', () => { }); }); it('is not disabled nor does it show the loading icon', () => { - expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); - expect(wrapper.find(GlButton).props('disabled')).toBe(false); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.findComponent(GlButton).props('disabled')).toBe(false); }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js index a8912405fa8..58dadb2c679 100644 --- a/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js +++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { visitUrl } from '~/lib/utils/url_utility'; import { @@ -11,6 +11,7 @@ import { REDEPLOYING, STOPPING, } from '~/vue_merge_request_widget/components/deployment/constants'; +import eventHub from '~/vue_merge_request_widget/event_hub'; import DeploymentActions from '~/vue_merge_request_widget/components/deployment/deployment_actions.vue'; import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; import { @@ -167,7 +168,7 @@ describe('DeploymentAction component', () => { }); it('should not throw an error', () => { - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); describe('response includes redirect_url', () => { @@ -192,6 +193,7 @@ describe('DeploymentAction component', () => { describe('it should call the executeAction method', () => { beforeEach(async () => { jest.spyOn(wrapper.vm, 'executeAction').mockImplementation(); + jest.spyOn(eventHub, '$emit'); await waitForPromises(); @@ -206,11 +208,16 @@ describe('DeploymentAction component', () => { actionButtonMocks[configConst], ); }); + + it('emits the FetchDeployments event', () => { + expect(eventHub.$emit).toHaveBeenCalledWith('FetchDeployments'); + }); }); describe('when executeInlineAction errors', () => { beforeEach(async () => { executeActionSpy.mockRejectedValueOnce(); + jest.spyOn(eventHub, '$emit'); await waitForPromises(); @@ -218,12 +225,15 @@ describe('DeploymentAction component', () => { finderFn().trigger('click'); }); - it('should call createFlash with error message', () => { - expect(createFlash).toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledWith({ + it('should call createAlert with error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: actionButtonMocks[configConst].errorMessage, }); }); + + it('emits the FetchDeployments event', () => { + expect(eventHub.$emit).toHaveBeenCalledWith('FetchDeployments'); + }); }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_spec.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_spec.js index c27cbd8b781..f310f7669a9 100644 --- a/spec/frontend/vue_merge_request_widget/deployment/deployment_spec.js +++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_spec.js @@ -37,7 +37,7 @@ describe('Deployment component', () => { }); it('always renders DeploymentInfo', () => { - expect(wrapper.find(DeploymentInfo).exists()).toBe(true); + expect(wrapper.findComponent(DeploymentInfo).exists()).toBe(true); }); describe('status message and buttons', () => { @@ -111,7 +111,7 @@ describe('Deployment component', () => { }); it(`renders the text: ${text}`, () => { - expect(wrapper.find(DeploymentInfo).text()).toContain(text); + expect(wrapper.findComponent(DeploymentInfo).text()).toContain(text); }); if (actionButtons.length > 0) { @@ -137,9 +137,11 @@ describe('Deployment component', () => { if (actionButtons.includes(DeploymentViewButton)) { it('renders the View button with expected text', () => { if (status === SUCCESS) { - expect(wrapper.find(DeploymentViewButton).text()).toContain('View app'); + expect(wrapper.findComponent(DeploymentViewButton).text()).toContain('View app'); } else { - expect(wrapper.find(DeploymentViewButton).text()).toContain('View latest app'); + expect(wrapper.findComponent(DeploymentViewButton).text()).toContain( + 'View latest app', + ); } }); } @@ -150,7 +152,7 @@ describe('Deployment component', () => { describe('hasExternalUrls', () => { describe('when deployment has both external_url_formatted and external_url', () => { it('should render the View Button', () => { - expect(wrapper.find(DeploymentViewButton).exists()).toBe(true); + expect(wrapper.findComponent(DeploymentViewButton).exists()).toBe(true); }); }); @@ -165,7 +167,7 @@ describe('Deployment component', () => { }); it('should not render the View Button', () => { - expect(wrapper.find(DeploymentViewButton).exists()).toBe(false); + expect(wrapper.findComponent(DeploymentViewButton).exists()).toBe(false); }); }); @@ -180,7 +182,7 @@ describe('Deployment component', () => { }); it('should not render the View Button', () => { - expect(wrapper.find(DeploymentViewButton).exists()).toBe(false); + expect(wrapper.findComponent(DeploymentViewButton).exists()).toBe(false); }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_view_button_spec.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_view_button_spec.js index eb6e3711e2e..8994fa522d0 100644 --- a/spec/frontend/vue_merge_request_widget/deployment/deployment_view_button_spec.js +++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_view_button_spec.js @@ -2,6 +2,7 @@ import { GlDropdown, GlLink } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import DeploymentViewButton from '~/vue_merge_request_widget/components/deployment/deployment_view_button.vue'; import ReviewAppLink from '~/vue_merge_request_widget/components/review_app_link.vue'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; import { deploymentMockData } from './deployment_mock_data'; const appButtonText = { @@ -36,6 +37,7 @@ describe('Deployment View App button', () => { const findMrWigdetDeploymentDropdownIcon = () => wrapper.findByTestId('mr-wigdet-deployment-dropdown-icon'); const findDeployUrlMenuItems = () => wrapper.findAllComponents(GlLink); + const findCopyButton = () => wrapper.findComponent(ModalCopyButton); describe('text', () => { it('renders text as passed', () => { @@ -44,39 +46,93 @@ describe('Deployment View App button', () => { }); describe('without changes', () => { + let deployment; + beforeEach(() => { - createComponent({ - propsData: { - deployment: { ...deploymentMockData, changes: null }, - appButtonText, - }, + deployment = { ...deploymentMockData, changes: null }; + }); + + describe('with safe url', () => { + beforeEach(() => { + createComponent({ + propsData: { + deployment, + appButtonText, + }, + }); + }); + + it('renders the link to the review app without dropdown', () => { + expect(findMrWigdetDeploymentDropdown().exists()).toBe(false); + expect(findReviewAppLink().attributes('href')).toBe(deployment.external_url); }); }); - it('renders the link to the review app without dropdown', () => { - expect(findMrWigdetDeploymentDropdown().exists()).toBe(false); + describe('without safe URL', () => { + beforeEach(() => { + deployment = { ...deployment, external_url: 'postgres://example' }; + createComponent({ + propsData: { + deployment, + appButtonText, + }, + }); + }); + + it('renders the link as a copy button', () => { + expect(findMrWigdetDeploymentDropdown().exists()).toBe(false); + expect(findCopyButton().props('text')).toBe(deployment.external_url); + }); }); }); describe('with a single change', () => { + let deployment; + let change; + beforeEach(() => { - createComponent({ - propsData: { - deployment: { ...deploymentMockData, changes: [deploymentMockData.changes[0]] }, - appButtonText, - }, - }); + [change] = deploymentMockData.changes; + deployment = { ...deploymentMockData, changes: [change] }; }); - it('renders the link to the review app without dropdown', () => { - expect(findMrWigdetDeploymentDropdown().exists()).toBe(false); - expect(findMrWigdetDeploymentDropdownIcon().exists()).toBe(false); + describe('with safe URL', () => { + beforeEach(() => { + createComponent({ + propsData: { + deployment, + appButtonText, + }, + }); + }); + + it('renders the link to the review app without dropdown', () => { + expect(findMrWigdetDeploymentDropdown().exists()).toBe(false); + expect(findMrWigdetDeploymentDropdownIcon().exists()).toBe(false); + }); + + it('renders the link to the review app linked to to the first change', () => { + const expectedUrl = deploymentMockData.changes[0].external_url; + + expect(findReviewAppLink().attributes('href')).toBe(expectedUrl); + }); }); - it('renders the link to the review app linked to to the first change', () => { - const expectedUrl = deploymentMockData.changes[0].external_url; + describe('with unsafe URL', () => { + beforeEach(() => { + change = { ...change, external_url: 'postgres://example' }; + deployment = { ...deployment, changes: [change] }; + createComponent({ + propsData: { + deployment, + appButtonText, + }, + }); + }); - expect(findReviewAppLink().attributes('href')).toBe(expectedUrl); + it('renders the link as a copy button', () => { + expect(findMrWigdetDeploymentDropdown().exists()).toBe(false); + expect(findCopyButton().props('text')).toBe(change.external_url); + }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js b/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js index 82743275739..05df66165dd 100644 --- a/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js +++ b/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js @@ -42,7 +42,7 @@ describe('Test report extension', () => { const findFullReportLink = () => wrapper.findByTestId('full-report-link'); const findCopyFailedSpecsBtn = () => wrapper.findByTestId('copy-failed-specs-btn'); const findAllExtensionListItems = () => wrapper.findAllByTestId('extension-list-item'); - const findModal = () => wrapper.find(TestCaseDetails); + const findModal = () => wrapper.findComponent(TestCaseDetails); const createComponent = () => { wrapper = mountExtended(extensionsContainer, { diff --git a/spec/frontend/vue_merge_request_widget/mr_widget_how_to_merge_modal_spec.js b/spec/frontend/vue_merge_request_widget/mr_widget_how_to_merge_modal_spec.js index 295b9df30b9..d038660e6d3 100644 --- a/spec/frontend/vue_merge_request_widget/mr_widget_how_to_merge_modal_spec.js +++ b/spec/frontend/vue_merge_request_widget/mr_widget_how_to_merge_modal_spec.js @@ -24,7 +24,7 @@ describe('MRWidgetHowToMerge', () => { mountComponent(); }); - const findModal = () => wrapper.find(GlModal); + const findModal = () => wrapper.findComponent(GlModal); const findInstructionsFields = () => wrapper.findAll('[ data-testid="how-to-merge-instructions"]'); const findTipLink = () => wrapper.find("[data-testid='docs-tip']"); diff --git a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js index cc894f94f80..6622749da92 100644 --- a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js @@ -368,12 +368,13 @@ describe('MrWidgetOptions', () => { describe('bindEventHubListeners', () => { it.each` - event | method | methodArgs - ${'MRWidgetUpdateRequested'} | ${'checkStatus'} | ${(x) => [x]} - ${'MRWidgetRebaseSuccess'} | ${'checkStatus'} | ${(x) => [x, true]} - ${'FetchActionsContent'} | ${'fetchActionsContent'} | ${() => []} - ${'EnablePolling'} | ${'resumePolling'} | ${() => []} - ${'DisablePolling'} | ${'stopPolling'} | ${() => []} + event | method | methodArgs + ${'MRWidgetUpdateRequested'} | ${'checkStatus'} | ${(x) => [x]} + ${'MRWidgetRebaseSuccess'} | ${'checkStatus'} | ${(x) => [x, true]} + ${'FetchActionsContent'} | ${'fetchActionsContent'} | ${() => []} + ${'EnablePolling'} | ${'resumePolling'} | ${() => []} + ${'DisablePolling'} | ${'stopPolling'} | ${() => []} + ${'FetchDeployments'} | ${'fetchPreMergeDeployments'} | ${() => []} `('should bind to $event', ({ event, method, methodArgs }) => { jest.spyOn(wrapper.vm, method).mockImplementation(); @@ -771,34 +772,40 @@ describe('MrWidgetOptions', () => { }); describe('security widget', () => { - describe.each` - context | hasPipeline | shouldRender - ${'there is a pipeline'} | ${true} | ${true} - ${'no pipeline'} | ${false} | ${false} - `('given $context', ({ hasPipeline, shouldRender }) => { - beforeEach(() => { - const mrData = { - ...mockData, - ...(hasPipeline ? {} : { pipeline: null }), - }; + const setup = async (hasPipeline) => { + const mrData = { + ...mockData, + ...(hasPipeline ? {} : { pipeline: null }), + }; - // Override top-level mocked requests, which always use a fresh copy of - // mockData, which always includes the full pipeline object. - mock.onGet(mockData.merge_request_widget_path).reply(() => [200, mrData]); - mock.onGet(mockData.merge_request_cached_widget_path).reply(() => [200, mrData]); - - return createComponent(mrData, { - apolloProvider: createMockApollo([ - [ - securityReportMergeRequestDownloadPathsQuery, - async () => ({ data: securityReportMergeRequestDownloadPathsQueryResponse }), - ], - ]), - }); + // Override top-level mocked requests, which always use a fresh copy of + // mockData, which always includes the full pipeline object. + mock.onGet(mockData.merge_request_widget_path).reply(() => [200, mrData]); + mock.onGet(mockData.merge_request_cached_widget_path).reply(() => [200, mrData]); + + return createComponent(mrData, { + apolloProvider: createMockApollo([ + [ + securityReportMergeRequestDownloadPathsQuery, + async () => ({ data: securityReportMergeRequestDownloadPathsQueryResponse }), + ], + ]), }); + }; + + describe('with a pipeline', () => { + it('renders the security widget', async () => { + await setup(true); + + expect(findSecurityMrWidget().exists()).toBe(true); + }); + }); + + describe('with no pipeline', () => { + it('does not render the security widget', async () => { + await setup(false); - it(shouldRender ? 'renders' : 'does not render', () => { - expect(findSecurityMrWidget().exists()).toBe(shouldRender); + expect(findSecurityMrWidget().exists()).toBe(false); }); }); }); @@ -881,7 +888,10 @@ describe('MrWidgetOptions', () => { await nextTick(); expect( - wrapper.find('[data-testid="widget-extension-top-level"]').find(GlDropdown).exists(), + wrapper + .find('[data-testid="widget-extension-top-level"]') + .findComponent(GlDropdown) + .exists(), ).toBe(false); await nextTick(); @@ -891,19 +901,19 @@ describe('MrWidgetOptions', () => { expect(collapsedSection.text()).toContain('Hello world'); // Renders icon in the row - expect(collapsedSection.find(GlIcon).exists()).toBe(true); - expect(collapsedSection.find(GlIcon).props('name')).toBe('status-failed'); + expect(collapsedSection.findComponent(GlIcon).exists()).toBe(true); + expect(collapsedSection.findComponent(GlIcon).props('name')).toBe('status-failed'); // Renders badge in the row - expect(collapsedSection.find(GlBadge).exists()).toBe(true); - expect(collapsedSection.find(GlBadge).text()).toBe('Closed'); + expect(collapsedSection.findComponent(GlBadge).exists()).toBe(true); + expect(collapsedSection.findComponent(GlBadge).text()).toBe('Closed'); // Renders a link in the row - expect(collapsedSection.find(GlLink).exists()).toBe(true); - expect(collapsedSection.find(GlLink).text()).toBe('GitLab.com'); + expect(collapsedSection.findComponent(GlLink).exists()).toBe(true); + expect(collapsedSection.findComponent(GlLink).text()).toBe('GitLab.com'); - expect(collapsedSection.find(GlButton).exists()).toBe(true); - expect(collapsedSection.find(GlButton).text()).toBe('Full report'); + expect(collapsedSection.findComponent(GlButton).exists()).toBe(true); + expect(collapsedSection.findComponent(GlButton).text()).toBe('Full report'); }); it('extension polling is not called if enablePolling flag is not passed', () => { @@ -994,7 +1004,7 @@ describe('MrWidgetOptions', () => { await createComponent(); - expect(pollRequest).toHaveBeenCalledTimes(4); + expect(pollRequest).toHaveBeenCalledTimes(2); }); }); @@ -1032,7 +1042,7 @@ describe('MrWidgetOptions', () => { registerExtension(pollingErrorExtension); await createComponent(); - expect(pollRequest).toHaveBeenCalledTimes(4); + expect(pollRequest).toHaveBeenCalledTimes(2); }); it('captures sentry error and displays error when poll has failed', async () => { @@ -1134,7 +1144,7 @@ describe('MrWidgetOptions', () => { ${'WidgetCodeQuality'} | ${'i_testing_code_quality_widget_total'} ${'WidgetTerraform'} | ${'i_testing_terraform_widget_total'} ${'WidgetIssues'} | ${'i_testing_issues_widget_total'} - ${'WidgetTestReport'} | ${'i_testing_summary_widget_total'} + ${'WidgetTestSummary'} | ${'i_testing_summary_widget_total'} `( "sends non-standard events for the '$widgetName' widget", async ({ widgetName, nonStandardEvent }) => { diff --git a/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js b/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js index 0246a8d4b0f..88d9d0b4cff 100644 --- a/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js +++ b/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js @@ -16,12 +16,13 @@ describe('getStateKey', () => { commitsCount: 2, hasConflicts: false, draft: false, + detailedMergeStatus: null, }; const bound = getStateKey.bind(context); expect(bound()).toEqual(null); - context.canBeMerged = true; + context.detailedMergeStatus = 'MERGEABLE'; expect(bound()).toEqual('readyToMerge'); @@ -36,21 +37,15 @@ describe('getStateKey', () => { expect(bound()).toEqual('shaMismatch'); context.canMerge = false; - context.isPipelineBlocked = true; - - expect(bound()).toEqual('pipelineBlocked'); - - context.hasMergeableDiscussionsState = true; - context.autoMergeEnabled = false; + context.detailedMergeStatus = 'DISCUSSIONS_NOT_RESOLVED'; expect(bound()).toEqual('unresolvedDiscussions'); - context.draft = true; + context.detailedMergeStatus = 'DRAFT_STATUS'; expect(bound()).toEqual('draft'); - context.onlyAllowMergeIfPipelineSucceeds = true; - context.isPipelineFailed = true; + context.detailedMergeStatus = 'CI_MUST_PASS'; expect(bound()).toEqual('pipelineFailed'); @@ -62,7 +57,7 @@ describe('getStateKey', () => { expect(bound()).toEqual('conflicts'); - context.mergeStatus = 'unchecked'; + context.detailedMergeStatus = 'CHECKING'; expect(bound()).toEqual('checking'); diff --git a/spec/frontend/vue_shared/components/ci_badge_link_spec.js b/spec/frontend/vue_shared/components/ci_badge_link_spec.js index 27b6718fb8e..07cbfe1e79b 100644 --- a/spec/frontend/vue_shared/components/ci_badge_link_spec.js +++ b/spec/frontend/vue_shared/components/ci_badge_link_spec.js @@ -1,7 +1,7 @@ +import { GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import { visitUrl } from '~/lib/utils/url_utility'; jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn(), @@ -86,18 +86,14 @@ describe('CI Badge Link Component', () => { wrapper.destroy(); }); - it.each(Object.keys(statuses))('should render badge for status: %s', async (status) => { + it.each(Object.keys(statuses))('should render badge for status: %s', (status) => { createComponent({ status: statuses[status] }); - expect(wrapper.attributes('href')).toBe(); + expect(wrapper.attributes('href')).toBe(statuses[status].details_path); expect(wrapper.text()).toBe(statuses[status].text); expect(wrapper.classes()).toContain('ci-status'); expect(wrapper.classes()).toContain(`ci-${statuses[status].group}`); expect(findIcon().exists()).toBe(true); - - await wrapper.trigger('click'); - - expect(visitUrl).toHaveBeenCalledWith(statuses[status].details_path); }); it('should not render label', () => { @@ -109,7 +105,7 @@ describe('CI Badge Link Component', () => { it('should emit ciStatusBadgeClick event', async () => { createComponent({ status: statuses.success }); - await wrapper.trigger('click'); + await wrapper.findComponent(GlLink).vm.$emit('click'); expect(wrapper.emitted('ciStatusBadgeClick')).toEqual([[]]); }); 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 index 441e21ee905..5b0772f6e34 100644 --- 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 @@ -3,7 +3,7 @@ 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 { createAlert } 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'; @@ -146,7 +146,7 @@ describe('LabelsSelectRoot', () => { }); it('creates flash with error message', () => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ captureError: true, message: 'Error fetching epic color.', }); @@ -186,7 +186,7 @@ describe('LabelsSelectRoot', () => { findDropdownContents().vm.$emit('setColor', color); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ captureError: true, error: expect.anything(), message: 'An error occurred while updating color.', diff --git a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js index 10eacff630d..7a8f94b3746 100644 --- a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js +++ b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js @@ -121,7 +121,7 @@ describe('date time picker lib', () => { const utcResult = '2019-09-08T01:01:01Z'; const localResult = '2019-09-08T08:01:01Z'; - test.each` + it.each` val | locatTimezone | utc | result ${value} | ${'UTC'} | ${undefined} | ${utcResult} ${value} | ${'UTC'} | ${false} | ${utcResult} @@ -167,7 +167,7 @@ describe('date time picker lib', () => { const utcResult = '2019-09-08 08:01:01'; const localResult = '2019-09-08 01:01:01'; - test.each` + it.each` val | locatTimezone | utc | result ${value} | ${'UTC'} | ${undefined} | ${utcResult} ${value} | ${'UTC'} | ${false} | ${utcResult} diff --git a/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js b/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js index 68684004b82..99c973bdd26 100644 --- a/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js @@ -106,11 +106,11 @@ describe('Diff Stats Dropdown', () => { expectedAddedDeletedExpanded, expectedAddedDeletedCollapsed, }) => { - beforeAll(() => { + beforeEach(() => { createComponent({ changed, added, deleted }); }); - afterAll(() => { + afterEach(() => { wrapper.destroy(); }); diff --git a/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js b/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js index 69964b2687d..6e0717c29d7 100644 --- a/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js +++ b/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js @@ -1,8 +1,6 @@ -import Vue, { nextTick } from 'vue'; - -import mountComponent from 'helpers/vue_mount_component_helper'; +import { mount } from '@vue/test-utils'; import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants'; -import diffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; +import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; describe('DiffViewer', () => { const requiredProps = { @@ -14,37 +12,28 @@ describe('DiffViewer', () => { oldPath: RED_BOX_IMAGE_URL, oldSha: 'DEF', }; - let vm; - - function createComponent(props) { - const DiffViewer = Vue.extend(diffViewer); + let wrapper; - vm = mountComponent(DiffViewer, props); + function createComponent(propsData) { + wrapper = mount(DiffViewer, { propsData }); } afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - it('renders image diff', async () => { + it('renders image diff', () => { window.gon = { relative_url_root: '', }; createComponent({ ...requiredProps, projectPath: '' }); - await nextTick(); - - expect(vm.$el.querySelector('.deleted img').getAttribute('src')).toBe( - `//-/raw/DEF/${RED_BOX_IMAGE_URL}`, - ); - - expect(vm.$el.querySelector('.added img').getAttribute('src')).toBe( - `//-/raw/ABC/${GREEN_BOX_IMAGE_URL}`, - ); + expect(wrapper.find('.deleted img').attributes('src')).toBe(`//-/raw/DEF/${RED_BOX_IMAGE_URL}`); + expect(wrapper.find('.added img').attributes('src')).toBe(`//-/raw/ABC/${GREEN_BOX_IMAGE_URL}`); }); - it('renders fallback download diff display', async () => { + it('renders fallback download diff display', () => { createComponent({ ...requiredProps, diffViewerMode: 'added', @@ -52,18 +41,10 @@ describe('DiffViewer', () => { oldPath: 'testold.abc', }); - await nextTick(); - - expect(vm.$el.querySelector('.deleted .file-info').textContent.trim()).toContain('testold.abc'); - - expect(vm.$el.querySelector('.deleted .btn.btn-default').textContent.trim()).toContain( - 'Download', - ); - - expect(vm.$el.querySelector('.added .file-info').textContent.trim()).toContain('test.abc'); - expect(vm.$el.querySelector('.added .btn.btn-default').textContent.trim()).toContain( - 'Download', - ); + expect(wrapper.find('.deleted .file-info').text()).toContain('testold.abc'); + expect(wrapper.find('.deleted .btn.btn-default').text()).toContain('Download'); + expect(wrapper.find('.added .file-info').text()).toContain('test.abc'); + expect(wrapper.find('.added .btn.btn-default').text()).toContain('Download'); }); describe('renamed file', () => { @@ -85,7 +66,7 @@ describe('DiffViewer', () => { oldPath: 'testold.abc', }); - expect(vm.$el.textContent).toContain('File renamed with no changes.'); + expect(wrapper.text()).toContain('File renamed with no changes.'); }); }); @@ -99,6 +80,6 @@ describe('DiffViewer', () => { bMode: '321', }); - expect(vm.$el.textContent).toContain('File mode changed from 123 to 321'); + expect(wrapper.text()).toContain('File mode changed from 123 to 321'); }); }); diff --git a/spec/frontend/vue_shared/components/file_finder/item_spec.js b/spec/frontend/vue_shared/components/file_finder/item_spec.js index b69c33055c1..f0998b1b5c6 100644 --- a/spec/frontend/vue_shared/components/file_finder/item_spec.js +++ b/spec/frontend/vue_shared/components/file_finder/item_spec.js @@ -1,127 +1,119 @@ -import Vue, { nextTick } from 'vue'; -import createComponent from 'helpers/vue_mount_component_helper'; +import { mount } from '@vue/test-utils'; import { file } from 'jest/ide/helpers'; import ItemComponent from '~/vue_shared/components/file_finder/item.vue'; describe('File finder item spec', () => { - const Component = Vue.extend(ItemComponent); - let vm; - let localFile; - - beforeEach(() => { - localFile = { - ...file(), - name: 'test file', - path: 'test/file', - }; - - vm = createComponent(Component, { - file: localFile, - focused: true, - searchText: '', - index: 0, + let wrapper; + + const createComponent = ({ file: customFileFields = {}, ...otherProps } = {}) => { + wrapper = mount(ItemComponent, { + propsData: { + file: { + ...file(), + name: 'test file', + path: 'test/file', + ...customFileFields, + }, + focused: true, + searchText: '', + index: 0, + ...otherProps, + }, }); - }); + }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders file name & path', () => { - expect(vm.$el.textContent).toContain('test file'); - expect(vm.$el.textContent).toContain('test/file'); + createComponent(); + + expect(wrapper.text()).toContain('test file'); + expect(wrapper.text()).toContain('test/file'); }); describe('focused', () => { it('adds is-focused class', () => { - expect(vm.$el.classList).toContain('is-focused'); + createComponent(); + + expect(wrapper.classes()).toContain('is-focused'); }); it('does not have is-focused class when not focused', async () => { - vm.focused = false; + createComponent({ focused: false }); - await nextTick(); - expect(vm.$el.classList).not.toContain('is-focused'); + expect(wrapper.classes()).not.toContain('is-focused'); }); }); describe('changed file icon', () => { it('does not render when not a changed or temp file', () => { - expect(vm.$el.querySelector('.diff-changed-stats')).toBe(null); + createComponent(); + + expect(wrapper.find('.diff-changed-stats').exists()).toBe(false); }); it('renders when a changed file', async () => { - vm.file.changed = true; + createComponent({ file: { changed: true } }); - await nextTick(); - expect(vm.$el.querySelector('.diff-changed-stats')).not.toBe(null); + expect(wrapper.find('.diff-changed-stats').exists()).toBe(true); }); it('renders when a temp file', async () => { - vm.file.tempFile = true; + createComponent({ file: { tempFile: true } }); - await nextTick(); - expect(vm.$el.querySelector('.diff-changed-stats')).not.toBe(null); + expect(wrapper.find('.diff-changed-stats').exists()).toBe(true); }); }); - it('emits event when clicked', () => { - jest.spyOn(vm, '$emit').mockImplementation(() => {}); + it('emits event when clicked', async () => { + createComponent(); - vm.$el.click(); + await wrapper.find('*').trigger('click'); - expect(vm.$emit).toHaveBeenCalledWith('click', vm.file); + expect(wrapper.emitted('click')[0]).toStrictEqual([wrapper.props('file')]); }); describe('path', () => { - let el; - - beforeEach(async () => { - vm.searchText = 'file'; - - el = vm.$el.querySelector('.diff-changed-file-path'); - - nextTick(); - }); + const findChangedFilePath = () => wrapper.find('.diff-changed-file-path'); it('highlights text', () => { - expect(el.querySelectorAll('.highlighted').length).toBe(4); + createComponent({ searchText: 'file' }); + + expect(findChangedFilePath().findAll('.highlighted')).toHaveLength(4); }); it('adds ellipsis to long text', async () => { - vm.file.path = new Array(70) + const path = new Array(70) .fill() .map((_, i) => `${i}-`) .join(''); - await nextTick(); - expect(el.textContent).toBe(`...${vm.file.path.substr(vm.file.path.length - 60)}`); + createComponent({ searchText: 'file', file: { path } }); + + expect(findChangedFilePath().text()).toBe(`...${path.substring(path.length - 60)}`); }); }); describe('name', () => { - let el; - - beforeEach(async () => { - vm.searchText = 'file'; - - el = vm.$el.querySelector('.diff-changed-file-name'); - - await nextTick(); - }); + const findChangedFileName = () => wrapper.find('.diff-changed-file-name'); it('highlights text', () => { - expect(el.querySelectorAll('.highlighted').length).toBe(4); + createComponent({ searchText: 'file' }); + + expect(findChangedFileName().findAll('.highlighted')).toHaveLength(4); }); it('does not add ellipsis to long text', async () => { - vm.file.name = new Array(70) + const name = new Array(70) .fill() .map((_, i) => `${i}-`) .join(''); - await nextTick(); - expect(el.textContent).not.toBe(`...${vm.file.name.substr(vm.file.name.length - 60)}`); + createComponent({ searchText: 'file', file: { name } }); + + expect(findChangedFileName().text()).not.toBe(`...${name.substring(name.length - 60)}`); }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js index 4140ec09b4e..66ef473f368 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import { mockBranches } from 'jest/vue_shared/components/filtered_search_bar/mock_data'; import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import httpStatusCodes from '~/lib/utils/http_status'; import * as actions from '~/vue_shared/components/filtered_search_bar/store/modules/filters/actions'; import * as types from '~/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types'; @@ -159,7 +159,7 @@ describe('Filters actions', () => { }, ], [], - ).then(() => expect(createFlash).toHaveBeenCalled()); + ).then(() => expect(createAlert).toHaveBeenCalled()); }); }); }); @@ -233,7 +233,7 @@ describe('Filters actions', () => { [], ).then(() => { expect(mock.history.get[0].url).toBe('/api/v1/groups/fake_group_endpoint/members'); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); @@ -252,7 +252,7 @@ describe('Filters actions', () => { [], ).then(() => { expect(mock.history.get[0].url).toBe('/api/v1/projects/fake_project_endpoint/users'); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); }); @@ -298,7 +298,7 @@ describe('Filters actions', () => { }, ], [], - ).then(() => expect(createFlash).toHaveBeenCalled()); + ).then(() => expect(createAlert).toHaveBeenCalled()); }); }); }); @@ -376,7 +376,7 @@ describe('Filters actions', () => { [], ).then(() => { expect(mock.history.get[0].url).toBe('/api/v1/groups/fake_group_endpoint/members'); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); @@ -395,7 +395,7 @@ describe('Filters actions', () => { [], ).then(() => { expect(mock.history.get[0].url).toBe('/api/v1/projects/fake_project_endpoint/users'); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); }); @@ -441,7 +441,7 @@ describe('Filters actions', () => { }, ], [], - ).then(() => expect(createFlash).toHaveBeenCalled()); + ).then(() => expect(createAlert).toHaveBeenCalled()); }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js index 302dfabffb2..5371b9af475 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js @@ -8,7 +8,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -140,13 +140,13 @@ describe('AuthorToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', () => { + it('calls `createAlert` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); getBaseToken().vm.$emit('fetch-suggestions', 'root'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching users.', }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js index 1de35daa3a5..05b42011fe1 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js @@ -9,7 +9,7 @@ import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue'; @@ -87,13 +87,13 @@ describe('BranchToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', () => { + it('calls `createAlert` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchBranches').mockRejectedValue({}); wrapper.vm.fetchBranches('foo'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching branches.', }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js index c9879987931..5b744521979 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js @@ -8,7 +8,7 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -94,7 +94,7 @@ describe('CrmContactToken', () => { getBaseToken().vm.$emit('fetch-suggestions', 'foo'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchGroupCrmContactsQueryHandler).toHaveBeenCalledWith({ fullPath: 'group', isProject: false, @@ -108,7 +108,7 @@ describe('CrmContactToken', () => { getBaseToken().vm.$emit('fetch-suggestions', '5'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchGroupCrmContactsQueryHandler).toHaveBeenCalledWith({ fullPath: 'group', isProject: false, @@ -134,7 +134,7 @@ describe('CrmContactToken', () => { getBaseToken().vm.$emit('fetch-suggestions', 'foo'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchProjectCrmContactsQueryHandler).toHaveBeenCalledWith({ fullPath: 'project', isProject: true, @@ -148,7 +148,7 @@ describe('CrmContactToken', () => { getBaseToken().vm.$emit('fetch-suggestions', '5'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchProjectCrmContactsQueryHandler).toHaveBeenCalledWith({ fullPath: 'project', isProject: true, @@ -159,7 +159,7 @@ describe('CrmContactToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', async () => { + it('calls `createAlert` with flash error message when request fails', async () => { mountComponent(); jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); @@ -167,7 +167,7 @@ describe('CrmContactToken', () => { getBaseToken().vm.$emit('fetch-suggestions'); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching CRM contacts.', }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js index 16333b052e6..3a3e96032e8 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js @@ -8,7 +8,7 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -93,7 +93,7 @@ describe('CrmOrganizationToken', () => { getBaseToken().vm.$emit('fetch-suggestions', 'foo'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchGroupCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ fullPath: 'group', isProject: false, @@ -107,7 +107,7 @@ describe('CrmOrganizationToken', () => { getBaseToken().vm.$emit('fetch-suggestions', '5'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchGroupCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ fullPath: 'group', isProject: false, @@ -133,7 +133,7 @@ describe('CrmOrganizationToken', () => { getBaseToken().vm.$emit('fetch-suggestions', 'foo'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchProjectCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ fullPath: 'project', isProject: true, @@ -147,7 +147,7 @@ describe('CrmOrganizationToken', () => { getBaseToken().vm.$emit('fetch-suggestions', '5'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchProjectCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ fullPath: 'project', isProject: true, @@ -158,7 +158,7 @@ describe('CrmOrganizationToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', async () => { + it('calls `createAlert` with flash error message when request fails', async () => { mountComponent(); jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); @@ -166,7 +166,7 @@ describe('CrmOrganizationToken', () => { getBaseToken().vm.$emit('fetch-suggestions'); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching CRM organizations.', }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js index bf4a6eb7635..e8436d2db17 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js @@ -8,7 +8,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { @@ -93,13 +93,13 @@ describe('EmojiToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', () => { + it('calls `createAlert` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({}); wrapper.vm.fetchEmojis('foo'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching emojis.', }); }); 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 01e281884ed..8ca12afacec 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 @@ -11,7 +11,7 @@ import { mockRegularLabel, mockLabels, } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -116,13 +116,13 @@ describe('LabelToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', () => { + it('calls `createAlert` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchLabels').mockRejectedValue({}); wrapper.vm.fetchLabels('foo'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching labels.', }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js index f71ba51fc5b..589697fe542 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js @@ -8,7 +8,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { sortMilestonesByDueDate } from '~/milestones/utils'; @@ -112,13 +112,13 @@ describe('MilestoneToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', () => { + it('calls `createAlert` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockRejectedValue({}); wrapper.vm.fetchMilestones('foo'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching milestones.', }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js index 4bbbaab9b7a..0e5fa0f66d4 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js @@ -2,7 +2,7 @@ import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui' import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import ReleaseToken from '~/vue_shared/components/filtered_search_bar/tokens/release_token.vue'; import { mockReleaseToken } from '../mock_data'; @@ -73,7 +73,7 @@ describe('ReleaseToken', () => { }); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching releases.', }); }); diff --git a/spec/frontend/vue_shared/components/gitlab_version_check_spec.js b/spec/frontend/vue_shared/components/gitlab_version_check_spec.js index 6699ae5fb69..38f28837cc1 100644 --- a/spec/frontend/vue_shared/components/gitlab_version_check_spec.js +++ b/spec/frontend/vue_shared/components/gitlab_version_check_spec.js @@ -1,7 +1,9 @@ import { GlBadge } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { mockTracking } from 'helpers/tracking_helper'; +import { helpPagePath } from '~/helpers/help_page_helper'; import axios from '~/lib/utils/axios_utils'; import GitlabVersionCheck from '~/vue_shared/components/gitlab_version_check.vue'; @@ -9,6 +11,8 @@ describe('GitlabVersionCheck', () => { let wrapper; let mock; + const UPGRADE_DOCS_URL = helpPagePath('update/index'); + const defaultResponse = { code: 200, res: { severity: 'success' }, @@ -23,7 +27,7 @@ describe('GitlabVersionCheck', () => { mock = new MockAdapter(axios); mock.onGet().replyOnce(response.code, response.res); - wrapper = shallowMount(GitlabVersionCheck); + wrapper = shallowMountExtended(GitlabVersionCheck); }; const dummyGon = { @@ -38,6 +42,7 @@ describe('GitlabVersionCheck', () => { window.gon = originalGon; }); + const findGlBadgeClickWrapper = () => wrapper.findByTestId('badge-click-wrapper'); const findGlBadge = () => wrapper.findComponent(GlBadge); describe.each` @@ -77,7 +82,8 @@ describe('GitlabVersionCheck', () => { await waitForPromises(); // Ensure we wrap up the axios call }); - it(`does${renders ? '' : ' not'} render GlBadge`, () => { + it(`does${renders ? '' : ' not'} render Badge Click Wrapper and GlBadge`, () => { + expect(findGlBadgeClickWrapper().exists()).toBe(renders); expect(findGlBadge().exists()).toBe(renders); }); }); @@ -90,8 +96,11 @@ describe('GitlabVersionCheck', () => { ${{ code: 200, res: { severity: 'danger' } }} | ${{ title: 'Update ASAP', variant: 'danger' }} `('badge ui', ({ mockResponse, expectedUI }) => { describe(`when response is ${mockResponse.res.severity}`, () => { + let trackingSpy; + beforeEach(async () => { createComponent(mockResponse); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); await waitForPromises(); // Ensure we wrap up the axios call }); @@ -102,6 +111,24 @@ describe('GitlabVersionCheck', () => { it(`variant is ${expectedUI.variant}`, () => { expect(findGlBadge().attributes('variant')).toBe(expectedUI.variant); }); + + it(`tracks rendered_version_badge with label ${expectedUI.title}`, () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'rendered_version_badge', { + label: expectedUI.title, + }); + }); + + it(`link is ${UPGRADE_DOCS_URL}`, () => { + expect(findGlBadge().attributes('href')).toBe(UPGRADE_DOCS_URL); + }); + + it(`tracks click_version_badge with label ${expectedUI.title} when badge is clicked`, async () => { + await findGlBadgeClickWrapper().trigger('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_version_badge', { + label: expectedUI.title, + }); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/gl_countdown_spec.js b/spec/frontend/vue_shared/components/gl_countdown_spec.js index 0d1d42082ab..af53d256236 100644 --- a/spec/frontend/vue_shared/components/gl_countdown_spec.js +++ b/spec/frontend/vue_shared/components/gl_countdown_spec.js @@ -1,10 +1,9 @@ import Vue, { nextTick } from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { mount } from '@vue/test-utils'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; describe('GlCountdown', () => { - const Component = Vue.extend(GlCountdown); - let vm; + let wrapper; let now = '2000-01-01T00:00:00Z'; beforeEach(() => { @@ -12,21 +11,20 @@ describe('GlCountdown', () => { }); afterEach(() => { - vm.$destroy(); - jest.clearAllTimers(); + wrapper.destroy(); }); describe('when there is time remaining', () => { beforeEach(async () => { - vm = mountComponent(Component, { - endDateString: '2000-01-01T01:02:03Z', + wrapper = mount(GlCountdown, { + propsData: { + endDateString: '2000-01-01T01:02:03Z', + }, }); - - await nextTick(); }); it('displays remaining time', () => { - expect(vm.$el.textContent).toContain('01:02:03'); + expect(wrapper.text()).toContain('01:02:03'); }); it('updates remaining time', async () => { @@ -34,21 +32,21 @@ describe('GlCountdown', () => { jest.advanceTimersByTime(1000); await nextTick(); - expect(vm.$el.textContent).toContain('01:02:02'); + expect(wrapper.text()).toContain('01:02:02'); }); }); describe('when there is no time remaining', () => { beforeEach(async () => { - vm = mountComponent(Component, { - endDateString: '1900-01-01T00:00:00Z', + wrapper = mount(GlCountdown, { + propsData: { + endDateString: '1900-01-01T00:00:00Z', + }, }); - - await nextTick(); }); it('displays 00:00:00', () => { - expect(vm.$el.textContent).toContain('00:00:00'); + expect(wrapper.text()).toContain('00:00:00'); }); }); @@ -62,8 +60,10 @@ describe('GlCountdown', () => { }); it('throws a validation error', () => { - vm = mountComponent(Component, { - endDateString: 'this is invalid', + wrapper = mount(GlCountdown, { + propsData: { + endDateString: 'this is invalid', + }, }); expect(Vue.config.warnHandler).toHaveBeenCalledTimes(1); diff --git a/spec/frontend/vue_shared/components/group_select/utils_spec.js b/spec/frontend/vue_shared/components/group_select/utils_spec.js new file mode 100644 index 00000000000..5188e1aabf1 --- /dev/null +++ b/spec/frontend/vue_shared/components/group_select/utils_spec.js @@ -0,0 +1,24 @@ +import { groupsPath } from '~/vue_shared/components/group_select/utils'; + +describe('group_select utils', () => { + describe('groupsPath', () => { + it.each` + groupsFilter | parentGroupID | expectedPath + ${undefined} | ${undefined} | ${'/api/:version/groups.json'} + ${undefined} | ${1} | ${'/api/:version/groups.json'} + ${'descendant_groups'} | ${1} | ${'/api/:version/groups/1/descendant_groups'} + ${'subgroups'} | ${1} | ${'/api/:version/groups/1/subgroups'} + `( + 'returns $expectedPath with groupsFilter = $groupsFilter and parentGroupID = $parentGroupID', + ({ groupsFilter, parentGroupID, expectedPath }) => { + expect(groupsPath(groupsFilter, parentGroupID)).toBe(expectedPath); + }, + ); + }); + + it('throws if groupsFilter is passed but parentGroupID is undefined', () => { + expect(() => { + groupsPath('descendant_groups'); + }).toThrow('Cannot use groupsFilter without a parentGroupID'); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js index 9831908f806..ed417097e1e 100644 --- a/spec/frontend/vue_shared/components/markdown/header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/header_spec.js @@ -54,6 +54,8 @@ describe('Markdown field header component', () => { 'Add a bullet list', 'Add a numbered list', 'Add a checklist', + 'Indent line (⌘])', + 'Outdent line (⌘[)', 'Add a collapsible section', 'Add a table', 'Go full screen', @@ -140,7 +142,7 @@ describe('Markdown field header component', () => { const tableButton = findToolbarButtonByProp('icon', 'table'); expect(tableButton.props('tag')).toEqual( - '| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |', + '| header | header |\n| ------ | ------ |\n| | |\n| | |', ); }); diff --git a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js new file mode 100644 index 00000000000..f7e93f45148 --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js @@ -0,0 +1,289 @@ +import { GlSegmentedControl } from '@gitlab/ui'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { nextTick } from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { EDITING_MODE_MARKDOWN_FIELD, EDITING_MODE_CONTENT_EDITOR } from '~/vue_shared/constants'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; +import ContentEditor from '~/content_editor/components/content_editor.vue'; +import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import { stubComponent } from 'helpers/stub_component'; + +jest.mock('~/emoji'); + +describe('vue_shared/component/markdown/markdown_editor', () => { + let wrapper; + const value = 'test markdown'; + const renderMarkdownPath = '/api/markdown'; + const markdownDocsPath = '/help/markdown'; + const quickActionsDocsPath = '/help/quickactions'; + const enableAutocomplete = true; + const enablePreview = false; + const formFieldId = 'markdown_field'; + const formFieldName = 'form[markdown_field]'; + const formFieldPlaceholder = 'Write some markdown'; + const formFieldAriaLabel = 'Edit your content'; + let mock; + + const buildWrapper = ({ propsData = {}, attachTo } = {}) => { + wrapper = mountExtended(MarkdownEditor, { + attachTo, + propsData: { + value, + renderMarkdownPath, + markdownDocsPath, + quickActionsDocsPath, + enableAutocomplete, + enablePreview, + formFieldId, + formFieldName, + formFieldPlaceholder, + formFieldAriaLabel, + ...propsData, + }, + stubs: { + BubbleMenu: stubComponent(BubbleMenu), + }, + }); + }; + const findSegmentedControl = () => wrapper.findComponent(GlSegmentedControl); + const findMarkdownField = () => wrapper.findComponent(MarkdownField); + const findTextarea = () => wrapper.find('textarea'); + const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); + const findContentEditor = () => wrapper.findComponent(ContentEditor); + + beforeEach(() => { + window.uploads_path = 'uploads'; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + it('displays markdown field by default', () => { + buildWrapper({ propsData: { supportsQuickActions: true } }); + + expect(findMarkdownField().props()).toEqual( + expect.objectContaining({ + markdownPreviewPath: renderMarkdownPath, + quickActionsDocsPath, + canAttachFile: true, + enableAutocomplete, + textareaValue: value, + markdownDocsPath, + uploadsPath: window.uploads_path, + enablePreview, + }), + ); + }); + + it('renders markdown field textarea', () => { + buildWrapper(); + + expect(findTextarea().attributes()).toEqual( + expect.objectContaining({ + id: formFieldId, + name: formFieldName, + placeholder: formFieldPlaceholder, + 'aria-label': formFieldAriaLabel, + }), + ); + + expect(findTextarea().element.value).toBe(value); + }); + + it('renders switch segmented control', () => { + buildWrapper(); + + expect(findSegmentedControl().props()).toEqual({ + checked: EDITING_MODE_MARKDOWN_FIELD, + options: [ + { + text: expect.any(String), + value: EDITING_MODE_MARKDOWN_FIELD, + }, + { + text: expect.any(String), + value: EDITING_MODE_CONTENT_EDITOR, + }, + ], + }); + }); + + describe.each` + editingMode + ${EDITING_MODE_CONTENT_EDITOR} + ${EDITING_MODE_MARKDOWN_FIELD} + `('when segmented control emits change event with $editingMode value', ({ editingMode }) => { + it(`emits ${editingMode} event`, () => { + buildWrapper(); + + findSegmentedControl().vm.$emit('change', editingMode); + + expect(wrapper.emitted(editingMode)).toHaveLength(1); + }); + }); + + describe(`when editingMode is ${EDITING_MODE_MARKDOWN_FIELD}`, () => { + it('emits input event when markdown field textarea changes', async () => { + buildWrapper(); + const newValue = 'new value'; + + await findTextarea().setValue(newValue); + + expect(wrapper.emitted('input')).toEqual([[newValue]]); + }); + + describe('when initOnAutofocus is true', () => { + beforeEach(async () => { + buildWrapper({ attachTo: document.body, propsData: { initOnAutofocus: true } }); + + await nextTick(); + }); + + it('sets the markdown field as the active element in the document', () => { + expect(document.activeElement).toBe(findTextarea().element); + }); + }); + + it('bubbles up keydown event', async () => { + buildWrapper(); + + await findTextarea().trigger('keydown'); + + expect(wrapper.emitted('keydown')).toHaveLength(1); + }); + + describe(`when segmented control triggers input event with ${EDITING_MODE_CONTENT_EDITOR} value`, () => { + beforeEach(() => { + buildWrapper(); + findSegmentedControl().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR); + findSegmentedControl().vm.$emit('change', EDITING_MODE_CONTENT_EDITOR); + }); + + it('displays the content editor', () => { + expect(findContentEditor().props()).toEqual( + expect.objectContaining({ + renderMarkdown: expect.any(Function), + uploadsPath: window.uploads_path, + markdown: value, + autofocus: 'end', + }), + ); + }); + + it('adds hidden field with current markdown', () => { + const hiddenField = wrapper.find(`#${formFieldId}`); + + expect(hiddenField.attributes()).toEqual( + expect.objectContaining({ + id: formFieldId, + name: formFieldName, + }), + ); + expect(hiddenField.element.value).toBe(value); + }); + + it('hides the markdown field', () => { + expect(findMarkdownField().exists()).toBe(false); + }); + + it('updates localStorage value', () => { + expect(findLocalStorageSync().props().value).toBe(EDITING_MODE_CONTENT_EDITOR); + }); + }); + }); + + describe(`when editingMode is ${EDITING_MODE_CONTENT_EDITOR}`, () => { + beforeEach(() => { + buildWrapper(); + findSegmentedControl().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR); + }); + + describe('when initOnAutofocus is true', () => { + beforeEach(() => { + buildWrapper({ propsData: { initOnAutofocus: true } }); + findLocalStorageSync().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR); + }); + + it('sets the content editor autofocus property to end', () => { + expect(findContentEditor().props().autofocus).toBe('end'); + }); + }); + + it('emits input event when content editor emits change event', async () => { + const newValue = 'new value'; + + await findContentEditor().vm.$emit('change', { markdown: newValue }); + + expect(wrapper.emitted('input')).toEqual([[newValue]]); + }); + + it('bubbles up keydown event', () => { + const event = new Event('keydown'); + + findContentEditor().vm.$emit('keydown', event); + + expect(wrapper.emitted('keydown')).toEqual([[event]]); + }); + + describe(`when segmented control triggers input event with ${EDITING_MODE_MARKDOWN_FIELD} value`, () => { + beforeEach(() => { + findSegmentedControl().vm.$emit('input', EDITING_MODE_MARKDOWN_FIELD); + }); + + it('hides the content editor', () => { + expect(findContentEditor().exists()).toBe(false); + }); + + it('shows the markdown field', () => { + expect(findMarkdownField().exists()).toBe(true); + }); + + it('updates localStorage value', () => { + expect(findLocalStorageSync().props().value).toBe(EDITING_MODE_MARKDOWN_FIELD); + }); + + it('sets the textarea as the activeElement in the document', async () => { + // The component should be rebuilt to attach it to the document body + buildWrapper({ attachTo: document.body }); + await findSegmentedControl().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR); + + expect(findContentEditor().exists()).toBe(true); + + await findSegmentedControl().vm.$emit('input', EDITING_MODE_MARKDOWN_FIELD); + await findSegmentedControl().vm.$emit('change', EDITING_MODE_MARKDOWN_FIELD); + + expect(document.activeElement).toBe(findTextarea().element); + }); + }); + + describe('when content editor emits loading event', () => { + beforeEach(() => { + findContentEditor().vm.$emit('loading'); + }); + + it('disables switch editing mode control', () => { + // This is the only way that I found to check the segmented control is disabled + expect(findSegmentedControl().find('input[disabled]').exists()).toBe(true); + }); + + describe.each` + event + ${'loadingSuccess'} + ${'loadingError'} + `('when content editor emits $event event', ({ event }) => { + beforeEach(() => { + findContentEditor().vm.$emit(event); + }); + it('enables the switch editing mode control', () => { + expect(findSegmentedControl().find('input[disabled]').exists()).toBe(false); + }); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js b/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js index d792bd46ccd..9c91dc9b5fc 100644 --- a/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js +++ b/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js @@ -139,8 +139,7 @@ describe('Metrics upload item', () => { closeModal(); await waitForPromises(); - - expect(findModal().attributes('visible')).toBeFalsy(); + expect(findModal().attributes('visible')).toBeUndefined(); }); it('should delete the image when selected', async () => { @@ -189,8 +188,7 @@ describe('Metrics upload item', () => { closeEditModal(); await waitForPromises(); - - expect(findEditModal().attributes('visible')).toBeFalsy(); + expect(findEditModal().attributes('visible')).toBeUndefined(); }); it('should delete the image when selected', async () => { diff --git a/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js b/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js index 518cf354675..537367940e0 100644 --- a/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js +++ b/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js @@ -4,7 +4,7 @@ import actionsFactory from '~/vue_shared/components/metric_images/store/actions' import * as types from '~/vue_shared/components/metric_images/store/mutation_types'; import createStore from '~/vue_shared/components/metric_images/store'; import testAction from 'helpers/vuex_action_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { fileList, initialData } from '../mock_data'; @@ -35,7 +35,7 @@ describe('Metrics tab store actions', () => { }); afterEach(() => { - createFlash.mockClear(); + createAlert.mockClear(); }); describe('fetching metric images', () => { @@ -61,7 +61,7 @@ describe('Metrics tab store actions', () => { [{ type: types.REQUEST_METRIC_IMAGES }, { type: types.RECEIVE_METRIC_IMAGES_ERROR }], [], ); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); @@ -98,7 +98,7 @@ describe('Metrics tab store actions', () => { [{ type: types.REQUEST_METRIC_UPLOAD }, { type: types.RECEIVE_METRIC_UPLOAD_ERROR }], [], ); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); @@ -129,7 +129,7 @@ describe('Metrics tab store actions', () => { [{ type: types.REQUEST_METRIC_UPLOAD }, { type: types.RECEIVE_METRIC_UPLOAD_ERROR }], [], ); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/vue_shared/components/modal_copy_button_spec.js b/spec/frontend/vue_shared/components/modal_copy_button_spec.js index b57efc88d57..61e4e774420 100644 --- a/spec/frontend/vue_shared/components/modal_copy_button_spec.js +++ b/spec/frontend/vue_shared/components/modal_copy_button_spec.js @@ -17,9 +17,16 @@ describe('modal copy button', () => { title: 'Copy this value', id: 'test-id', }, + slots: { + default: 'test', + }, }); }); + it('should show the default slot', () => { + expect(wrapper.text()).toBe('test'); + }); + describe('clipboard', () => { it('should fire a `success` event on click', async () => { const root = createWrapper(wrapper.vm.$root); diff --git a/spec/frontend/vue_shared/components/namespace_select/namespace_select_spec.js b/spec/frontend/vue_shared/components/namespace_select/namespace_select_deprecated_spec.js index 2c14d65186b..d930ef63dad 100644 --- a/spec/frontend/vue_shared/components/namespace_select/namespace_select_spec.js +++ b/spec/frontend/vue_shared/components/namespace_select/namespace_select_deprecated_spec.js @@ -11,14 +11,14 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import NamespaceSelect, { i18n, EMPTY_NAMESPACE_ID, -} from '~/vue_shared/components/namespace_select/namespace_select.vue'; +} from '~/vue_shared/components/namespace_select/namespace_select_deprecated.vue'; import { userNamespaces, groupNamespaces } from './mock_data'; const FLAT_NAMESPACES = [...userNamespaces, ...groupNamespaces]; const EMPTY_NAMESPACE_TITLE = 'Empty namespace TEST'; const EMPTY_NAMESPACE_ITEM = { id: EMPTY_NAMESPACE_ID, humanName: EMPTY_NAMESPACE_TITLE }; -describe('Namespace Select', () => { +describe('NamespaceSelectDeprecated', () => { let wrapper; const createComponent = (props = {}) => @@ -207,9 +207,9 @@ describe('Namespace Select', () => { expect(wrapper.emitted('load-more-groups')).toEqual([[]]); }); - describe('when `isLoadingMoreGroups` prop is `true`', () => { + describe('when `isLoading` prop is `true`', () => { it('renders a loading icon', () => { - wrapper = createComponent({ hasNextPageOfGroups: true, isLoadingMoreGroups: true }); + wrapper = createComponent({ hasNextPageOfGroups: true, isLoading: true }); expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); @@ -223,4 +223,14 @@ describe('Namespace Select', () => { expect(wrapper.findComponent(GlSearchBoxByType).props('isLoading')).toBe(true); }); }); + + describe('when dropdown is opened', () => { + it('emits `show` event', () => { + wrapper = createComponent(); + + findDropdown().vm.$emit('show'); + + expect(wrapper.emitted('show')).toEqual([[]]); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap b/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap index bf6c8e8c704..3bac96069ec 100644 --- a/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap +++ b/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap @@ -2,13 +2,12 @@ exports[`Issue placeholder note component matches snapshot 1`] = ` <timeline-entry-item-stub - class="note note-wrapper being-posted fade-in-half" + class="note note-wrapper note-comment being-posted fade-in-half" > <div - class="timeline-icon" + class="timeline-avatar gl-float-left" > <gl-avatar-link-stub - class="gl-mr-3" href="/root" > <gl-avatar-stub @@ -16,7 +15,7 @@ exports[`Issue placeholder note component matches snapshot 1`] = ` entityid="0" entityname="root" shape="circle" - size="[object Object]" + size="32" src="mock_path" /> </gl-avatar-link-stub> @@ -50,16 +49,20 @@ exports[`Issue placeholder note component matches snapshot 1`] = ` </div> <div - class="note-body" + class="timeline-discussion-body" > <div - class="note-text md" + class="note-body" > - <p> - Foo - </p> - + <div + class="note-text md" + > + <p> + Foo + </p> + + </div> </div> </div> </div> diff --git a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js index b86c8946e96..8f9f1bb336f 100644 --- a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js @@ -1,5 +1,4 @@ import { shallowMount } from '@vue/test-utils'; -import { GlAvatar } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; import IssuePlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; @@ -53,17 +52,4 @@ describe('Issue placeholder note component', () => { expect(findNote().classes()).toContain('discussion'); }); - - describe('avatar size', () => { - it.each` - size | line | isOverviewTab - ${{ default: 24, md: 32 }} | ${null} | ${false} - ${24} | ${{ line_code: '123' }} | ${false} - ${{ default: 24, md: 32 }} | ${{ line_code: '123' }} | ${true} - `('renders avatar $size for $line and $isOverviewTab', ({ size, line, isOverviewTab }) => { - createComponent(false, { line, isOverviewTab }); - - expect(wrapper.findComponent(GlAvatar).props('size')).toEqual(size); - }); - }); }); diff --git a/spec/frontend/vue_shared/components/pagination_bar/pagination_bar_spec.js b/spec/frontend/vue_shared/components/pagination_bar/pagination_bar_spec.js index b3be2f8a775..112cdaf74c6 100644 --- a/spec/frontend/vue_shared/components/pagination_bar/pagination_bar_spec.js +++ b/spec/frontend/vue_shared/components/pagination_bar/pagination_bar_spec.js @@ -2,6 +2,7 @@ import { GlPagination, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; describe('Pagination bar', () => { const DEFAULT_PROPS = { @@ -20,6 +21,7 @@ describe('Pagination bar', () => { ...DEFAULT_PROPS, ...propsData, }, + stubs: { LocalStorageSync: true }, }); }; @@ -90,4 +92,28 @@ describe('Pagination bar', () => { 'Showing 21 - 40 of 1000+', ); }); + + describe('local storage sync', () => { + it('does not perform local storage sync when no storage key is provided', () => { + createComponent(); + + expect(wrapper.findComponent(LocalStorageSync).exists()).toBe(false); + }); + + it('passes current page size to local storage sync when storage key is provided', () => { + const STORAGE_KEY = 'fakeStorageKey'; + createComponent({ storageKey: STORAGE_KEY }); + + expect(wrapper.getComponent(LocalStorageSync).props('storageKey')).toBe(STORAGE_KEY); + }); + + it('emits set-page event when local storage sync provides new value', () => { + const SAVED_SIZE = 50; + createComponent({ storageKey: 'some storage key' }); + + wrapper.getComponent(LocalStorageSync).vm.$emit('input', SAVED_SIZE); + + expect(wrapper.emitted('set-page-size')).toEqual([[SAVED_SIZE]]); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/panel_resizer_spec.js b/spec/frontend/vue_shared/components/panel_resizer_spec.js index d8b903e5bfd..0e261124cbf 100644 --- a/spec/frontend/vue_shared/components/panel_resizer_spec.js +++ b/spec/frontend/vue_shared/components/panel_resizer_spec.js @@ -1,12 +1,10 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import panelResizer from '~/vue_shared/components/panel_resizer.vue'; +import { mount } from '@vue/test-utils'; +import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; describe('Panel Resizer component', () => { - let vm; - let PanelResizer; + let wrapper; - const triggerEvent = (eventName, el = vm.$el, clientX = 0) => { + const triggerEvent = (eventName, el = wrapper.element, clientX = 0) => { const event = document.createEvent('MouseEvents'); event.initMouseEvent( eventName, @@ -29,57 +27,64 @@ describe('Panel Resizer component', () => { el.dispatchEvent(event); }; - beforeEach(() => { - PanelResizer = Vue.extend(panelResizer); - }); - afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('should render a div element with the correct classes and styles', () => { - vm = mountComponent(PanelResizer, { - startSize: 100, - side: 'left', + wrapper = mount(PanelResizer, { + propsData: { + startSize: 100, + side: 'left', + }, }); - expect(vm.$el.tagName).toEqual('DIV'); - expect(vm.$el.getAttribute('class')).toBe( - 'position-absolute position-top-0 position-bottom-0 drag-handle position-left-0', - ); + expect(wrapper.element.tagName).toEqual('DIV'); + expect(wrapper.classes().sort()).toStrictEqual([ + 'drag-handle', + 'position-absolute', + 'position-bottom-0', + 'position-left-0', + 'position-top-0', + ]); - expect(vm.$el.getAttribute('style')).toBe('cursor: ew-resize;'); + expect(wrapper.element.getAttribute('style')).toBe('cursor: ew-resize;'); }); it('should render a div element with the correct classes for a right side panel', () => { - vm = mountComponent(PanelResizer, { - startSize: 100, - side: 'right', + wrapper = mount(PanelResizer, { + propsData: { + startSize: 100, + side: 'right', + }, }); - expect(vm.$el.tagName).toEqual('DIV'); - expect(vm.$el.getAttribute('class')).toBe( - 'position-absolute position-top-0 position-bottom-0 drag-handle position-right-0', - ); + expect(wrapper.element.tagName).toEqual('DIV'); + expect(wrapper.classes().sort()).toStrictEqual([ + 'drag-handle', + 'position-absolute', + 'position-bottom-0', + 'position-right-0', + 'position-top-0', + ]); }); it('drag the resizer', () => { - vm = mountComponent(PanelResizer, { - startSize: 100, - side: 'left', + wrapper = mount(PanelResizer, { + propsData: { + startSize: 100, + side: 'left', + }, }); - jest.spyOn(vm, '$emit').mockImplementation(() => {}); - triggerEvent('mousedown', vm.$el); + triggerEvent('mousedown'); triggerEvent('mousemove', document); triggerEvent('mouseup', document); - expect(vm.$emit.mock.calls).toEqual([ - ['resize-start', 100], - ['update:size', 100], - ['resize-end', 100], - ]); - - expect(vm.size).toBe(100); + expect(wrapper.emitted()).toEqual({ + 'resize-start': [[100]], + 'update:size': [[100]], + 'resize-end': [[100]], + }); }); }); diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap index 2abae33bc19..66cf2354bc7 100644 --- a/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap +++ b/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap @@ -2,7 +2,7 @@ exports[`History Item renders the correct markup 1`] = ` <li - class="timeline-entry system-note note-wrapper gl-mb-6!" + class="timeline-entry system-note note-wrapper" > <div class="timeline-entry-inner" @@ -22,11 +22,13 @@ exports[`History Item renders the correct markup 1`] = ` <div class="note-header" > - <span> + <div + class="note-header-info" + > <div data-testid="default-slot" /> - </span> + </div> </div> <div diff --git a/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js b/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js index c5672bc28cc..09b0b3d43ad 100644 --- a/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js +++ b/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js @@ -6,7 +6,7 @@ import { expectedDownloadDropdownPropsWithTitle, securityReportMergeRequestDownloadPathsQueryResponse, } from 'jest/vue_shared/security_reports/mock_data'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import Component from '~/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue'; import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue'; import { @@ -93,8 +93,8 @@ describe('Merge request artifact Download', () => { }); }); - it('calls createFlash correctly', () => { - expect(createFlash).toHaveBeenCalledWith({ + it('calls createAlert correctly', () => { + expect(createAlert).toHaveBeenCalledWith({ message: Component.i18n.apiError, captureError: true, error: expect.any(Error), 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 4c7ac6e9a6f..30c1a4b7d2f 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 @@ -67,9 +67,9 @@ describe('LabelsSelectRoot', () => { // 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')).toHaveLength(1); expect(wrapper.emitted('updateSelectedLabels')[0]).toEqual([touchedLabels]); - expect(wrapper.emitted('onDropdownClose')).toBeTruthy(); + expect(wrapper.emitted('onDropdownClose')).toHaveLength(1); expect(wrapper.emitted('onDropdownClose')[0]).toEqual([touchedLabels]); }); @@ -88,7 +88,7 @@ describe('LabelsSelectRoot', () => { }, ); - expect(wrapper.emitted('updateSelectedLabels')).toBeTruthy(); + expect(wrapper.emitted('updateSelectedLabels')).toHaveLength(1); expect(wrapper.emitted('updateSelectedLabels')[0]).toEqual([ [ { @@ -97,7 +97,7 @@ describe('LabelsSelectRoot', () => { }, ], ]); - expect(wrapper.emitted('onDropdownClose')).toBeTruthy(); + expect(wrapper.emitted('onDropdownClose')).toHaveLength(1); expect(wrapper.emitted('onDropdownClose')[0]).toEqual([[]]); }); }); @@ -106,8 +106,7 @@ describe('LabelsSelectRoot', () => { it('emits `toggleCollapse` event on component', () => { createComponent(); wrapper.vm.handleCollapsedValueClick(); - - expect(wrapper.emitted().toggleCollapse).toBeTruthy(); + expect(wrapper.emitted().toggleCollapse).toHaveLength(1); }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js index 2bc513e87bf..edd044bd754 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as actions from '~/vue_shared/components/sidebar/labels_select_vue/store/actions'; import * as types from '~/vue_shared/components/sidebar/labels_select_vue/store/mutation_types'; @@ -102,7 +102,7 @@ describe('LabelsSelect Actions', () => { it('shows flash error', () => { actions.receiveLabelsFailure({ commit: () => {} }); - expect(createFlash).toHaveBeenCalledWith({ message: 'Error fetching labels.' }); + expect(createAlert).toHaveBeenCalledWith({ message: 'Error fetching labels.' }); }); }); @@ -186,7 +186,7 @@ describe('LabelsSelect Actions', () => { it('shows flash error', () => { actions.receiveCreateLabelFailure({ commit: () => {} }); - expect(createFlash).toHaveBeenCalledWith({ message: 'Error creating label.' }); + expect(createAlert).toHaveBeenCalledWith({ message: 'Error creating label.' }); }); }); 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 1819e750324..2b2508b5e11 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 @@ -189,10 +189,20 @@ describe('LabelsSelect Mutations', () => { }); labelGroupIds.forEach((l) => { - expect(state.labels[l.id - 1].touched).toBeFalsy(); + expect(state.labels[l.id - 1].touched).toBeUndefined(); expect(state.labels[l.id - 1].set).toBe(false); }); }); + it('allows selection of multiple scoped labels', () => { + const state = { labels: cloneDeep(labels), allowMultipleScopedLabels: true }; + + mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: labels[4].id }] }); + mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: labels[5].id }] }); + + expect(state.labels[4].set).toBe(true); + expect(state.labels[5].set).toBe(true); + expect(state.labels[6].set).toBe(true); + }); }); describe(`${types.UPDATE_LABELS_SET_STATE}`, () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js index 9c29f304c71..237f174e048 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js @@ -4,7 +4,7 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { workspaceLabelsQueries } from '~/sidebar/constants'; import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; import createLabelMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql'; @@ -202,7 +202,7 @@ describe('DropdownContentsCreateView', () => { }); }); - it('calls createFlash is mutation has a user-recoverable error', async () => { + it('calls createAlert is mutation has a user-recoverable error', async () => { createComponent({ mutationHandler: createLabelUserRecoverableErrorHandler }); fillLabelAttributes(); await nextTick(); @@ -210,10 +210,10 @@ describe('DropdownContentsCreateView', () => { findCreateButton().vm.$emit('click'); await waitForPromises(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); - it('calls createFlash is mutation was rejected', async () => { + it('calls createAlert is mutation was rejected', async () => { createComponent({ mutationHandler: createLabelErrorHandler }); fillLabelAttributes(); await nextTick(); @@ -221,7 +221,7 @@ describe('DropdownContentsCreateView', () => { findCreateButton().vm.$emit('click'); await waitForPromises(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); it('displays error in alert if label title is already taken', async () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js index 7f6770e0bea..5d8ad5ddee5 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js @@ -9,7 +9,7 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue'; @@ -143,13 +143,13 @@ describe('DropdownContentsLabelsView', () => { expect(findNoResultsMessage().isVisible()).toBe(true); }); - it('calls `createFlash` when fetching labels failed', async () => { + it('calls `createAlert` when fetching labels failed', async () => { createComponent({ queryHandler: jest.fn().mockRejectedValue('Houston, we have a problem!') }); await makeObserverAppear(); jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); await waitForPromises(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); it('emits an `input` event on label click', async () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js index cad401e0013..b58c44645d6 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js @@ -3,7 +3,7 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { IssuableType } from '~/issues/constants'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue'; @@ -151,7 +151,7 @@ describe('LabelsSelectRoot', () => { it('creates flash with error message when query is rejected', async () => { createComponent({ queryHandler: errorQueryHandler }); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ message: 'Error fetching labels.' }); + expect(createAlert).toHaveBeenCalledWith({ message: 'Error fetching labels.' }); }); }); @@ -197,7 +197,7 @@ describe('LabelsSelectRoot', () => { findDropdownContents().vm.$emit('setLabels', [label]); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ captureError: true, error: expect.anything(), message: 'An error occurred while updating labels.', diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js index fd3ff9ce892..f661bd6747a 100644 --- a/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js @@ -1,10 +1,5 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue'; -import { - BIDI_CHARS, - BIDI_CHARS_CLASS_LIST, - BIDI_CHAR_TOOLTIP, -} from '~/vue_shared/components/source_viewer/constants'; const DEFAULT_PROPS = { number: 2, @@ -31,7 +26,6 @@ describe('Chunk Line component', () => { const findLineLink = () => wrapper.find('.file-line-num'); const findBlameLink = () => wrapper.find('.file-line-blame'); const findContent = () => wrapper.findByTestId('content'); - const findWrappedBidiChars = () => wrapper.findAllByTestId('bidi-wrapper'); beforeEach(() => { createComponent(); @@ -40,22 +34,6 @@ describe('Chunk Line component', () => { afterEach(() => wrapper.destroy()); describe('rendering', () => { - it('wraps BiDi characters', () => { - const content = `// some content ${BIDI_CHARS.toString()} with BiDi chars`; - createComponent({ content }); - const wrappedBidiChars = findWrappedBidiChars(); - - expect(wrappedBidiChars.length).toBe(BIDI_CHARS.length); - - wrappedBidiChars.wrappers.forEach((_, i) => { - expect(wrappedBidiChars.at(i).text()).toBe(BIDI_CHARS[i]); - expect(wrappedBidiChars.at(i).attributes()).toMatchObject({ - class: BIDI_CHARS_CLASS_LIST, - title: BIDI_CHAR_TOOLTIP, - }); - }); - }); - it('renders a blame link', () => { expect(findBlameLink().attributes()).toMatchObject({ href: `${DEFAULT_PROPS.blamePath}#L${DEFAULT_PROPS.number}`, diff --git a/spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js b/spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js new file mode 100644 index 00000000000..4a995e2fde1 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js @@ -0,0 +1,44 @@ +import hljs from 'highlight.js/lib/core'; +import languageLoader from '~/content_editor/services/highlight_js_language_loader'; +import { registerPlugins } from '~/vue_shared/components/source_viewer/plugins/index'; +import { highlight } from '~/vue_shared/components/source_viewer/workers/highlight_utils'; + +jest.mock('highlight.js/lib/core', () => ({ + highlight: jest.fn().mockReturnValue({}), + registerLanguage: jest.fn(), +})); + +jest.mock('~/content_editor/services/highlight_js_language_loader', () => ({ + javascript: jest.fn().mockReturnValue({ default: jest.fn() }), +})); + +jest.mock('~/vue_shared/components/source_viewer/plugins/index', () => ({ + registerPlugins: jest.fn(), +})); + +const fileType = 'text'; +const content = 'function test() { return true };'; +const language = 'javascript'; + +describe('Highlight utility', () => { + beforeEach(() => highlight(fileType, content, language)); + + it('loads the language', () => { + expect(languageLoader.javascript).toHaveBeenCalled(); + }); + + it('registers the plugins', () => { + expect(registerPlugins).toHaveBeenCalled(); + }); + + it('registers the language', () => { + expect(hljs.registerLanguage).toHaveBeenCalledWith( + language, + languageLoader[language]().default, + ); + }); + + it('highlights the content', () => { + expect(hljs.highlight).toHaveBeenCalledWith(content, { language }); + }); +}); 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 index 83fdc5d669d..57045ca54ae 100644 --- a/spec/frontend/vue_shared/components/source_viewer/plugins/index_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/index_spec.js @@ -1,14 +1,18 @@ -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'; +import { + registerPlugins, + HLJS_ON_AFTER_HIGHLIGHT, +} from '~/vue_shared/components/source_viewer/plugins/index'; +import wrapChildNodes from '~/vue_shared/components/source_viewer/plugins/wrap_child_nodes'; +import wrapBidiChars from '~/vue_shared/components/source_viewer/plugins/wrap_bidi_chars'; -jest.mock('~/vue_shared/components/source_viewer/plugins/wrap_comments'); +jest.mock('~/vue_shared/components/source_viewer/plugins/wrap_child_nodes'); 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 }); + expect(hljsMock.addPlugin).toHaveBeenCalledWith({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapBidiChars }); + expect(hljsMock.addPlugin).toHaveBeenCalledWith({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapChildNodes }); }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js index 8079d5ad99a..e4ce07ec668 100644 --- a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js @@ -15,7 +15,7 @@ describe('createLink', () => { it('escapes the user-controlled content', () => { const unescapedXSS = '<script>XSS</script>'; const escapedPackageName = '<script>XSS</script>'; - const escapedHref = '&lt;script&gt;XSS&lt;/script&gt;'; + const escapedHref = '<script>XSS</script>'; const href = `http://test.com/${unescapedXSS}`; const innerText = `testing${unescapedXSS}`; const result = `<a href="http://test.com/${escapedHref}" rel="nofollow noreferrer noopener">testing${escapedPackageName}</a>`; diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_bidi_chars_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_bidi_chars_spec.js new file mode 100644 index 00000000000..f40f8b22627 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_bidi_chars_spec.js @@ -0,0 +1,17 @@ +import wrapBidiChars from '~/vue_shared/components/source_viewer/plugins/wrap_bidi_chars'; +import { + BIDI_CHARS, + BIDI_CHARS_CLASS_LIST, + BIDI_CHAR_TOOLTIP, +} from '~/vue_shared/components/source_viewer/constants'; + +describe('Highlight.js plugin for wrapping BiDi characters', () => { + it.each(BIDI_CHARS)('wraps %s BiDi char', (bidiChar) => { + const inputValue = `// some content ${bidiChar} with BiDi chars`; + const outputValue = `// some content <span class="${BIDI_CHARS_CLASS_LIST}" title="${BIDI_CHAR_TOOLTIP}">${bidiChar}</span>`; + const hljsResultMock = { value: inputValue }; + + wrapBidiChars(hljsResultMock); + expect(hljsResultMock.value).toContain(outputValue); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js new file mode 100644 index 00000000000..bc6df1a2565 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js @@ -0,0 +1,22 @@ +import wrapChildNodes from '~/vue_shared/components/source_viewer/plugins/wrap_child_nodes'; + +describe('Highlight.js plugin for wrapping _emitter nodes', () => { + it('mutates the input value by wrapping each node in a span tag', () => { + const hljsResultMock = { + _emitter: { + rootNode: { + children: [ + { kind: 'string', children: ['Text 1'] }, + { kind: 'string', children: ['Text 2', { kind: 'comment', children: ['Text 3'] }] }, + 'Text4\nText5', + ], + }, + }, + }; + + const outputValue = `<span class="hljs-string">Text 1</span><span class="hljs-string"><span class="hljs-string">Text 2</span><span class="hljs-comment">Text 3</span></span><span class="">Text4</span>\n<span class="">Text5</span>`; + + wrapChildNodes(hljsResultMock); + expect(hljsResultMock.value).toBe(outputValue); + }); +}); 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 deleted file mode 100644 index 5fd4182da29..00000000000 --- a/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_comments_spec.js +++ /dev/null @@ -1,29 +0,0 @@ -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 e020d9a557e..6d319b37b02 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 @@ -22,10 +22,10 @@ jest.mock('~/vue_shared/components/source_viewer/plugins/index'); Vue.use(VueRouter); const router = new VueRouter(); -const generateContent = (content, totalLines = 1) => { +const generateContent = (content, totalLines = 1, delimiter = '\n') => { let generatedContent = ''; for (let i = 0; i < totalLines; i += 1) { - generatedContent += `Line: ${i + 1} = ${content}\n`; + generatedContent += `Line: ${i + 1} = ${content}${delimiter}`; } return generatedContent; }; @@ -38,7 +38,9 @@ describe('Source Viewer component', () => { const mappedLanguage = ROUGE_TO_HLJS_LANGUAGE_MAP[language]; const chunk1 = generateContent('// Some source code 1', 70); const chunk2 = generateContent('// Some source code 2', 70); - const content = chunk1 + chunk2; + const chunk3 = generateContent('// Some source code 3', 70, '\r\n'); + const chunk3Result = generateContent('// Some source code 3', 70, '\n'); + const content = chunk1 + chunk2 + chunk3; const path = 'some/path.js'; const blamePath = 'some/blame/path.js'; const fileType = 'javascript'; @@ -152,6 +154,19 @@ describe('Source Viewer component', () => { startingFrom: 70, }); }); + + it('renders the third chunk', async () => { + const thirdChunk = findChunks().at(2); + + expect(thirdChunk.props('content')).toContain(chunk3Result.trim()); + + expect(chunk3Result).toEqual(chunk3.replace(/\r?\n/g, '\n')); + + expect(thirdChunk.props()).toMatchObject({ + totalLines: 70, + startingFrom: 140, + }); + }); }); it('emits showBlobInteractionZones on the eventHub when chunk appears', () => { diff --git a/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js index c6f01efa71a..79b1f17afa0 100644 --- a/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js +++ b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js @@ -1,121 +1,109 @@ -import Vue from 'vue'; - -import mountComponent from 'helpers/vue_mount_component_helper'; -import stackedProgressBarComponent from '~/vue_shared/components/stacked_progress_bar.vue'; - -const createComponent = (config) => { - const Component = Vue.extend(stackedProgressBarComponent); - const defaultConfig = { - successLabel: 'Synced', - failureLabel: 'Failed', - neutralLabel: 'Out of sync', - successCount: 25, - failureCount: 10, - totalCount: 5000, - ...config, - }; - - return mountComponent(Component, defaultConfig); -}; +import { mount } from '@vue/test-utils'; +import StackedProgressBarComponent from '~/vue_shared/components/stacked_progress_bar.vue'; describe('StackedProgressBarComponent', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); + let wrapper; + + const createComponent = (config) => { + const defaultConfig = { + successLabel: 'Synced', + failureLabel: 'Failed', + neutralLabel: 'Out of sync', + successCount: 25, + failureCount: 10, + totalCount: 5000, + ...config, + }; + + wrapper = mount(StackedProgressBarComponent, { propsData: defaultConfig }); + }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - const findSuccessBarText = (wrapper) => - wrapper.$el.querySelector('.status-green').innerText.trim(); - const findNeutralBarText = (wrapper) => - wrapper.$el.querySelector('.status-neutral').innerText.trim(); - const findFailureBarText = (wrapper) => wrapper.$el.querySelector('.status-red').innerText.trim(); - const findUnavailableBarText = (wrapper) => - wrapper.$el.querySelector('.status-unavailable').innerText.trim(); - - describe('computed', () => { - describe('neutralCount', () => { - it('returns neutralCount based on totalCount, successCount and failureCount', () => { - expect(vm.neutralCount).toBe(4965); // 5000 - 25 - 10 - }); - }); - }); + const findSuccessBar = () => wrapper.find('.status-green'); + const findNeutralBar = () => wrapper.find('.status-neutral'); + const findFailureBar = () => wrapper.find('.status-red'); + const findUnavailableBar = () => wrapper.find('.status-unavailable'); describe('template', () => { it('renders container element', () => { - expect(vm.$el.classList.contains('stacked-progress-bar')).toBeTruthy(); + createComponent(); + + expect(wrapper.classes()).toContain('stacked-progress-bar'); }); it('renders empty state when count is unavailable', () => { - const vmX = createComponent({ totalCount: 0, successCount: 0, failureCount: 0 }); + createComponent({ totalCount: 0, successCount: 0, failureCount: 0 }); - expect(findUnavailableBarText(vmX)).not.toBeUndefined(); + expect(findUnavailableBar()).not.toBeUndefined(); }); it('renders bar elements when count is available', () => { - expect(findSuccessBarText(vm)).not.toBeUndefined(); - expect(findNeutralBarText(vm)).not.toBeUndefined(); - expect(findFailureBarText(vm)).not.toBeUndefined(); + createComponent(); + + expect(findSuccessBar().exists()).toBe(true); + expect(findNeutralBar().exists()).toBe(true); + expect(findFailureBar().exists()).toBe(true); }); describe('getPercent', () => { it('returns correct percentages from provided count based on `totalCount`', () => { - vm = createComponent({ totalCount: 100, successCount: 25, failureCount: 10 }); + createComponent({ totalCount: 100, successCount: 25, failureCount: 10 }); - expect(findSuccessBarText(vm)).toBe('25%'); - expect(findNeutralBarText(vm)).toBe('65%'); - expect(findFailureBarText(vm)).toBe('10%'); + expect(findSuccessBar().text()).toBe('25%'); + expect(findNeutralBar().text()).toBe('65%'); + expect(findFailureBar().text()).toBe('10%'); }); it('returns percentage with decimal place when decimal is greater than 1', () => { - vm = createComponent({ successCount: 67 }); + createComponent({ successCount: 67 }); - expect(findSuccessBarText(vm)).toBe('1.3%'); + expect(findSuccessBar().text()).toBe('1.3%'); }); it('returns percentage as `< 1%` from provided count based on `totalCount` when evaluated value is less than 1', () => { - vm = createComponent({ successCount: 10 }); + createComponent({ successCount: 10 }); - expect(findSuccessBarText(vm)).toBe('< 1%'); + expect(findSuccessBar().text()).toBe('< 1%'); }); it('returns not available if totalCount is falsy', () => { - vm = createComponent({ totalCount: 0 }); + createComponent({ totalCount: 0 }); - expect(findUnavailableBarText(vm)).toBe('Not available'); + expect(findUnavailableBar().text()).toBe('Not available'); }); it('returns 99.9% when numbers are extreme decimals', () => { - vm = createComponent({ totalCount: 1000000 }); + createComponent({ totalCount: 1000000 }); - expect(findNeutralBarText(vm)).toBe('99.9%'); + expect(findNeutralBar().text()).toBe('99.9%'); }); }); - describe('barStyle', () => { - it('returns style string based on percentage provided', () => { - expect(vm.barStyle(50)).toBe('width: 50%;'); + describe('bar style', () => { + it('renders width based on percentage provided', () => { + createComponent({ totalCount: 100, successCount: 25 }); + + expect(findSuccessBar().element.style.width).toBe('25%'); }); }); - describe('getTooltip', () => { + describe('tooltip', () => { describe('when hideTooltips is false', () => { it('returns label string based on label and count provided', () => { - expect(vm.getTooltip('Synced', 10)).toBe('Synced: 10'); + createComponent({ successCount: 10, successLabel: 'Synced', hideTooltips: false }); + + expect(findSuccessBar().attributes('title')).toBe('Synced: 10'); }); }); describe('when hideTooltips is true', () => { - beforeEach(() => { - vm = createComponent({ hideTooltips: true }); - }); - it('returns an empty string', () => { - expect(vm.getTooltip('Synced', 10)).toBe(''); + createComponent({ successCount: 10, successLabel: 'Synced', hideTooltips: true }); + + expect(findSuccessBar().attributes('title')).toBe(''); }); }); }); diff --git a/spec/frontend/vue_shared/components/timezone_dropdown/helpers.js b/spec/frontend/vue_shared/components/timezone_dropdown/helpers.js new file mode 100644 index 00000000000..dee4c92add4 --- /dev/null +++ b/spec/frontend/vue_shared/components/timezone_dropdown/helpers.js @@ -0,0 +1,6 @@ +import timezoneDataFixture from 'test_fixtures/timezones/short.json'; + +export { timezoneDataFixture }; + +export const findTzByName = (identifier = '') => + timezoneDataFixture.find(({ name }) => name.toLowerCase() === identifier.toLowerCase()); diff --git a/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js b/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js index 567d18f8b92..e5f56c63031 100644 --- a/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js @@ -1,27 +1,20 @@ import { GlDropdownItem, GlDropdown } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; -import createStore from '~/deploy_freeze/store'; -import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown.vue'; -import { findTzByName, formatTz, timezoneDataFixture } from '../helpers'; - -Vue.use(Vuex); +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue'; +import { formatTimezone } from '~/lib/utils/datetime_utility'; +import { findTzByName, timezoneDataFixture } from './helpers'; describe('Deploy freeze timezone dropdown', () => { let wrapper; let store; const createComponent = (searchTerm, selectedTimezone) => { - store = createStore({ - projectId: '8', - timezoneData: timezoneDataFixture, - }); - wrapper = shallowMount(TimezoneDropdown, { + wrapper = shallowMountExtended(TimezoneDropdown, { store, propsData: { value: selectedTimezone, timezoneData: timezoneDataFixture, + name: 'user[timezone]', }, }); @@ -32,6 +25,8 @@ describe('Deploy freeze timezone dropdown', () => { const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); const findDropdownItemByIndex = (index) => wrapper.findAllComponents(GlDropdownItem).at(index); + const findEmptyResultsItem = () => wrapper.findByTestId('noMatchingResults'); + const findHiddenInput = () => wrapper.find('input'); afterEach(() => { wrapper.destroy(); @@ -66,11 +61,11 @@ describe('Deploy freeze timezone dropdown', () => { it('renders only the time zone searched for', () => { const selectedTz = findTzByName('Alaska'); expect(findAllDropdownItems()).toHaveLength(1); - expect(findDropdownItemByIndex(0).text()).toBe(formatTz(selectedTz)); + expect(findDropdownItemByIndex(0).text()).toBe(formatTimezone(selectedTz)); }); it('should not display empty results message', () => { - expect(wrapper.find('[data-testid="noMatchingResults"]').exists()).toBe(false); + expect(findEmptyResultsItem().exists()).toBe(false); }); describe('Custom events', () => { @@ -81,7 +76,7 @@ describe('Deploy freeze timezone dropdown', () => { expect(wrapper.emitted('input')).toEqual([ [ { - formattedTimezone: formatTz(selectedTz), + formattedTimezone: formatTimezone(selectedTz), identifier: selectedTz.identifier, }, ], @@ -90,13 +85,27 @@ describe('Deploy freeze timezone dropdown', () => { }); }); - describe('Selected time zone', () => { + describe('Selected time zone not found', () => { + beforeEach(() => { + createComponent('', 'Berlin'); + }); + + it('renders empty selections', () => { + expect(wrapper.findComponent(GlDropdown).props().text).toBe('Select timezone'); + }); + + it('preserves initial value in the associated input', () => { + expect(findHiddenInput().attributes('value')).toBe('Berlin'); + }); + }); + + describe('Selected time zone found', () => { beforeEach(() => { - createComponent('', 'Alaska'); + createComponent('', 'Europe/Berlin'); }); it('renders selected time zone as dropdown label', () => { - expect(wrapper.findComponent(GlDropdown).vm.text).toBe('Alaska'); + expect(wrapper.findComponent(GlDropdown).props().text).toBe('[UTC + 2] Berlin'); }); }); }); diff --git a/spec/frontend/vue_shared/components/url_sync_spec.js b/spec/frontend/vue_shared/components/url_sync_spec.js index aefe6a5c3e8..acda1a64a75 100644 --- a/spec/frontend/vue_shared/components/url_sync_spec.js +++ b/spec/frontend/vue_shared/components/url_sync_spec.js @@ -1,10 +1,11 @@ import { shallowMount } from '@vue/test-utils'; import { historyPushState } from '~/lib/utils/common_utils'; -import { mergeUrlParams } from '~/lib/utils/url_utility'; -import UrlSyncComponent from '~/vue_shared/components/url_sync.vue'; +import { mergeUrlParams, setUrlParams } from '~/lib/utils/url_utility'; +import UrlSyncComponent, { URL_SET_PARAMS_STRATEGY } from '~/vue_shared/components/url_sync.vue'; jest.mock('~/lib/utils/url_utility', () => ({ - mergeUrlParams: jest.fn((query, url) => `urlParams: ${query} ${url}`), + mergeUrlParams: jest.fn((query, url) => `urlParams: ${JSON.stringify(query)} ${url}`), + setUrlParams: jest.fn((query, url) => `urlParams: ${JSON.stringify(query)} ${url}`), })); jest.mock('~/lib/utils/common_utils', () => ({ @@ -17,9 +18,14 @@ describe('url sync component', () => { const findButton = () => wrapper.find('button'); - const createComponent = ({ query = mockQuery, scopedSlots, slots } = {}) => { + const createComponent = ({ + query = mockQuery, + scopedSlots, + slots, + urlParamsUpdateStrategy, + } = {}) => { wrapper = shallowMount(UrlSyncComponent, { - propsData: { query }, + propsData: { query, ...(urlParamsUpdateStrategy && { urlParamsUpdateStrategy }) }, scopedSlots, slots, }); @@ -29,21 +35,39 @@ describe('url sync component', () => { wrapper.destroy(); }); - const expectUrlSync = (query, times, mergeUrlParamsReturnValue) => { - expect(mergeUrlParams).toHaveBeenCalledTimes(times); - expect(mergeUrlParams).toHaveBeenCalledWith(query, window.location.href, { - spreadArrays: true, - }); + const expectUrlSyncFactory = ( + query, + times, + urlParamsUpdateStrategy, + urlOptions, + urlReturnValue, + ) => { + expect(urlParamsUpdateStrategy).toHaveBeenCalledTimes(times); + expect(urlParamsUpdateStrategy).toHaveBeenCalledWith(query, window.location.href, urlOptions); expect(historyPushState).toHaveBeenCalledTimes(times); - expect(historyPushState).toHaveBeenCalledWith(mergeUrlParamsReturnValue); + expect(historyPushState).toHaveBeenCalledWith(urlReturnValue); + }; + + const expectUrlSyncWithMergeUrlParams = (query, times, mergeUrlParamsReturnValue) => { + expectUrlSyncFactory( + query, + times, + mergeUrlParams, + { spreadArrays: true }, + mergeUrlParamsReturnValue, + ); + }; + + const expectUrlSyncWithSetUrlParams = (query, times, setUrlParamsReturnValue) => { + expectUrlSyncFactory(query, times, setUrlParams, true, setUrlParamsReturnValue); }; describe('with query as a props', () => { it('immediately syncs the query to the URL', () => { createComponent(); - expectUrlSync(mockQuery, 1, mergeUrlParams.mock.results[0].value); + expectUrlSyncWithMergeUrlParams(mockQuery, 1, mergeUrlParams.mock.results[0].value); }); describe('when the query is modified', () => { @@ -54,11 +78,21 @@ describe('url sync component', () => { // using setProps to test the watcher await wrapper.setProps({ query: newQuery }); - expectUrlSync(mockQuery, 2, mergeUrlParams.mock.results[1].value); + expectUrlSyncWithMergeUrlParams(mockQuery, 2, mergeUrlParams.mock.results[1].value); }); }); }); + describe('with url-params-update-strategy equals to URL_SET_PARAMS_STRATEGY', () => { + it('uses setUrlParams to generate URL', () => { + createComponent({ + urlParamsUpdateStrategy: URL_SET_PARAMS_STRATEGY, + }); + + expectUrlSyncWithSetUrlParams(mockQuery, 1, setUrlParams.mock.results[0].value); + }); + }); + describe('with scoped slot', () => { const scopedSlots = { default: ` @@ -77,7 +111,7 @@ describe('url sync component', () => { findButton().trigger('click'); - expectUrlSync({ bar: 'baz' }, 1, mergeUrlParams.mock.results[0].value); + expectUrlSyncWithMergeUrlParams({ bar: 'baz' }, 1, mergeUrlParams.mock.results[0].value); }); }); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js deleted file mode 100644 index f87737ca86a..00000000000 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js +++ /dev/null @@ -1,134 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlAvatar, GlTooltip } from '@gitlab/ui'; -import defaultAvatarUrl from 'images/no_avatar.png'; -import { placeholderImage } from '~/lazy_loader'; -import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image_new.vue'; - -jest.mock('images/no_avatar.png', () => 'default-avatar-url'); - -const PROVIDED_PROPS = { - size: 32, - imgSrc: 'myavatarurl.com', - imgAlt: 'mydisplayname', - cssClasses: 'myextraavatarclass', - tooltipText: 'tooltip text', - tooltipPlacement: 'bottom', -}; - -describe('User Avatar Image Component', () => { - let wrapper; - - const findAvatar = () => wrapper.findComponent(GlAvatar); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('Initialization', () => { - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { - ...PROVIDED_PROPS, - }, - }); - }); - - it('should render `GlAvatar` and provide correct properties to it', () => { - expect(findAvatar().attributes('data-src')).toBe( - `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`, - ); - expect(findAvatar().props()).toMatchObject({ - src: `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`, - alt: PROVIDED_PROPS.imgAlt, - size: PROVIDED_PROPS.size, - }); - }); - - it('should add correct CSS classes', () => { - const classes = wrapper.findComponent(GlAvatar).classes(); - expect(classes).toContain(PROVIDED_PROPS.cssClasses); - expect(classes).not.toContain('lazy'); - }); - }); - - describe('Initialization when lazy', () => { - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { - ...PROVIDED_PROPS, - lazy: true, - }, - }); - }); - - it('should add lazy attributes', () => { - expect(findAvatar().classes()).toContain('lazy'); - expect(findAvatar().attributes()).toMatchObject({ - src: placeholderImage, - 'data-src': `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`, - }); - }); - - it('should use maximum number when size is provided as an object', () => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { - ...PROVIDED_PROPS, - size: { default: 16, md: 64, lg: 24 }, - lazy: true, - }, - }); - - expect(findAvatar().attributes('data-src')).toBe(`${PROVIDED_PROPS.imgSrc}?width=${64}`); - }); - }); - - describe('Initialization without src', () => { - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { - ...PROVIDED_PROPS, - imgSrc: null, - }, - }); - }); - - it('should have default avatar image', () => { - expect(findAvatar().props('src')).toBe(`${defaultAvatarUrl}?width=${PROVIDED_PROPS.size}`); - }); - }); - - describe('Dynamic tooltip content', () => { - const slots = { - default: ['Action!'], - }; - - describe('when `tooltipText` is provided and no default slot', () => { - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { ...PROVIDED_PROPS }, - }); - }); - - it('renders the tooltip with `tooltipText` as content', () => { - expect(wrapper.findComponent(GlTooltip).text()).toBe(PROVIDED_PROPS.tooltipText); - }); - }); - - describe('when `tooltipText` and default slot is provided', () => { - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { ...PROVIDED_PROPS }, - slots, - }); - }); - - it('does not render `tooltipText` inside the tooltip', () => { - expect(wrapper.findComponent(GlTooltip).text()).not.toBe(PROVIDED_PROPS.tooltipText); - }); - - it('renders the content provided via default slot', () => { - expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js deleted file mode 100644 index 2c1be6ec47e..00000000000 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js +++ /dev/null @@ -1,127 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlTooltip } from '@gitlab/ui'; -import defaultAvatarUrl from 'images/no_avatar.png'; -import { placeholderImage } from '~/lazy_loader'; -import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image_old.vue'; - -jest.mock('images/no_avatar.png', () => 'default-avatar-url'); - -const PROVIDED_PROPS = { - size: 32, - imgSrc: 'myavatarurl.com', - imgAlt: 'mydisplayname', - cssClasses: 'myextraavatarclass', - tooltipText: 'tooltip text', - tooltipPlacement: 'bottom', -}; - -const DEFAULT_PROPS = { - size: 20, -}; - -describe('User Avatar Image Component', () => { - let wrapper; - - afterEach(() => { - wrapper.destroy(); - }); - - describe('Initialization', () => { - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { - ...PROVIDED_PROPS, - }, - }); - }); - - it('should have <img> as a child element', () => { - const imageElement = wrapper.find('img'); - - expect(imageElement.exists()).toBe(true); - expect(imageElement.attributes('src')).toBe( - `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`, - ); - expect(imageElement.attributes('data-src')).toBe( - `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`, - ); - expect(imageElement.attributes('alt')).toBe(PROVIDED_PROPS.imgAlt); - }); - - it('should properly render img css', () => { - const classes = wrapper.find('img').classes(); - expect(classes).toEqual(['avatar', 's32', PROVIDED_PROPS.cssClasses]); - expect(classes).not.toContain('lazy'); - }); - }); - - describe('Initialization when lazy', () => { - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { - ...PROVIDED_PROPS, - lazy: true, - }, - }); - }); - - it('should add lazy attributes', () => { - const imageElement = wrapper.find('img'); - - expect(imageElement.classes()).toContain('lazy'); - expect(imageElement.attributes('src')).toBe(placeholderImage); - expect(imageElement.attributes('data-src')).toBe( - `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`, - ); - }); - }); - - describe('Initialization without src', () => { - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage); - }); - - it('should have default avatar image', () => { - const imageElement = wrapper.find('img'); - - expect(imageElement.attributes('src')).toBe( - `${defaultAvatarUrl}?width=${DEFAULT_PROPS.size}`, - ); - }); - }); - - describe('Dynamic tooltip content', () => { - const slots = { - default: ['Action!'], - }; - - describe('when `tooltipText` is provided and no default slot', () => { - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { ...PROVIDED_PROPS }, - }); - }); - - it('renders the tooltip with `tooltipText` as content', () => { - expect(wrapper.findComponent(GlTooltip).text()).toBe(PROVIDED_PROPS.tooltipText); - }); - }); - - describe('when `tooltipText` and default slot is provided', () => { - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { ...PROVIDED_PROPS }, - slots, - }); - }); - - it('does not render `tooltipText` inside the tooltip', () => { - expect(wrapper.findComponent(GlTooltip).text()).not.toBe(PROVIDED_PROPS.tooltipText); - }); - - it('renders the content provided via default slot', () => { - expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js index 6ad2ef226c2..d63b13981ac 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js @@ -1,7 +1,10 @@ import { shallowMount } from '@vue/test-utils'; +import { GlAvatar, GlTooltip } from '@gitlab/ui'; +import defaultAvatarUrl from 'images/no_avatar.png'; +import { placeholderImage } from '~/lazy_loader'; import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; -import UserAvatarImageNew from '~/vue_shared/components/user_avatar/user_avatar_image_new.vue'; -import UserAvatarImageOld from '~/vue_shared/components/user_avatar/user_avatar_image_old.vue'; + +jest.mock('images/no_avatar.png', () => 'default-avatar-url'); const PROVIDED_PROPS = { size: 32, @@ -15,37 +18,117 @@ const PROVIDED_PROPS = { describe('User Avatar Image Component', () => { let wrapper; - const createWrapper = (props = {}, { glAvatarForAllUserAvatars } = {}) => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { - ...PROVIDED_PROPS, - ...props, - }, - provide: { - glFeatures: { - glAvatarForAllUserAvatars, - }, - }, - }); - }; + const findAvatar = () => wrapper.findComponent(GlAvatar); afterEach(() => { wrapper.destroy(); }); - describe.each([ - [false, true, true], - [true, false, true], - [true, true, true], - [false, false, false], - ])( - 'when glAvatarForAllUserAvatars=%s and enforceGlAvatar=%s', - (glAvatarForAllUserAvatars, enforceGlAvatar, isUsingNewVersion) => { - it(`will render ${isUsingNewVersion ? 'new' : 'old'} version`, () => { - createWrapper({ enforceGlAvatar }, { glAvatarForAllUserAvatars }); - expect(wrapper.findComponent(UserAvatarImageNew).exists()).toBe(isUsingNewVersion); - expect(wrapper.findComponent(UserAvatarImageOld).exists()).toBe(!isUsingNewVersion); - }); - }, - ); + describe('Initialization', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { + ...PROVIDED_PROPS, + }, + }); + }); + + it('should render `GlAvatar` and provide correct properties to it', () => { + expect(findAvatar().attributes('data-src')).toBe( + `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`, + ); + expect(findAvatar().props()).toMatchObject({ + src: `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`, + alt: PROVIDED_PROPS.imgAlt, + size: PROVIDED_PROPS.size, + }); + }); + + it('should add correct CSS classes', () => { + const classes = wrapper.findComponent(GlAvatar).classes(); + expect(classes).toContain(PROVIDED_PROPS.cssClasses); + expect(classes).not.toContain('lazy'); + }); + }); + + describe('Initialization when lazy', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { + ...PROVIDED_PROPS, + lazy: true, + }, + }); + }); + + it('should add lazy attributes', () => { + expect(findAvatar().classes()).toContain('lazy'); + expect(findAvatar().attributes()).toMatchObject({ + src: placeholderImage, + 'data-src': `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`, + }); + }); + + it('should use maximum number when size is provided as an object', () => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { + ...PROVIDED_PROPS, + size: { default: 16, md: 64, lg: 24 }, + lazy: true, + }, + }); + + expect(findAvatar().attributes('data-src')).toBe(`${PROVIDED_PROPS.imgSrc}?width=${64}`); + }); + }); + + describe('Initialization without src', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { + ...PROVIDED_PROPS, + imgSrc: null, + }, + }); + }); + + it('should have default avatar image', () => { + expect(findAvatar().props('src')).toBe(`${defaultAvatarUrl}?width=${PROVIDED_PROPS.size}`); + }); + }); + + describe('Dynamic tooltip content', () => { + const slots = { + default: ['Action!'], + }; + + describe('when `tooltipText` is provided and no default slot', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { ...PROVIDED_PROPS }, + }); + }); + + it('renders the tooltip with `tooltipText` as content', () => { + expect(wrapper.findComponent(GlTooltip).text()).toBe(PROVIDED_PROPS.tooltipText); + }); + }); + + describe('when `tooltipText` and default slot is provided', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { ...PROVIDED_PROPS }, + slots, + }); + }); + + it('does not render `tooltipText` inside the tooltip', () => { + expect(wrapper.findComponent(GlTooltip).text()).not.toBe(PROVIDED_PROPS.tooltipText); + }); + + it('renders the content provided via default slot', () => { + expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]); + }); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_new_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_new_spec.js deleted file mode 100644 index f485a14cfea..00000000000 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_new_spec.js +++ /dev/null @@ -1,103 +0,0 @@ -import { GlAvatarLink } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { TEST_HOST } from 'spec/test_constants'; -import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link_new.vue'; - -describe('User Avatar Link Component', () => { - let wrapper; - - const findUserName = () => wrapper.findByTestId('user-avatar-link-username'); - - const defaultProps = { - linkHref: `${TEST_HOST}/myavatarurl.com`, - imgSize: 32, - imgSrc: `${TEST_HOST}/myavatarurl.com`, - imgAlt: 'mydisplayname', - imgCssClasses: 'myextraavatarclass', - tooltipText: 'tooltip text', - tooltipPlacement: 'bottom', - username: 'username', - }; - - const createWrapper = (props, slots) => { - wrapper = shallowMountExtended(UserAvatarLink, { - propsData: { - ...defaultProps, - ...props, - ...slots, - }, - }); - }; - - beforeEach(() => { - createWrapper(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('should render GlLink with correct props', () => { - const link = wrapper.findComponent(GlAvatarLink); - expect(link.exists()).toBe(true); - expect(link.attributes('href')).toBe(defaultProps.linkHref); - }); - - it('should render UserAvatarImage and provide correct props to it', () => { - expect(wrapper.findComponent(UserAvatarImage).exists()).toBe(true); - expect(wrapper.findComponent(UserAvatarImage).props()).toEqual({ - cssClasses: defaultProps.imgCssClasses, - imgAlt: defaultProps.imgAlt, - imgSrc: defaultProps.imgSrc, - lazy: false, - size: defaultProps.imgSize, - tooltipPlacement: defaultProps.tooltipPlacement, - tooltipText: '', - enforceGlAvatar: false, - }); - }); - - describe('when username provided', () => { - beforeEach(() => { - createWrapper({ username: defaultProps.username }); - }); - - it('should render provided username', () => { - expect(findUserName().text()).toBe(defaultProps.username); - }); - - it('should provide the tooltip data for the username', () => { - expect(findUserName().attributes()).toEqual( - expect.objectContaining({ - title: defaultProps.tooltipText, - 'tooltip-placement': defaultProps.tooltipPlacement, - }), - ); - }); - }); - - describe('when username is NOT provided', () => { - beforeEach(() => { - createWrapper({ username: '' }); - }); - - it('should NOT render username', () => { - expect(findUserName().exists()).toBe(false); - }); - }); - - describe('avatar-badge slot', () => { - const badge = '<span>User badge</span>'; - - beforeEach(() => { - createWrapper(defaultProps, { - 'avatar-badge': badge, - }); - }); - - it('should render provided `avatar-badge` slot content', () => { - expect(wrapper.html()).toContain(badge); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_old_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_old_spec.js deleted file mode 100644 index cf7a1025dba..00000000000 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_old_spec.js +++ /dev/null @@ -1,103 +0,0 @@ -import { GlLink } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { TEST_HOST } from 'spec/test_constants'; -import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link_old.vue'; - -describe('User Avatar Link Component', () => { - let wrapper; - - const findUserName = () => wrapper.find('[data-testid="user-avatar-link-username"]'); - - const defaultProps = { - linkHref: `${TEST_HOST}/myavatarurl.com`, - imgSize: 32, - imgSrc: `${TEST_HOST}/myavatarurl.com`, - imgAlt: 'mydisplayname', - imgCssClasses: 'myextraavatarclass', - tooltipText: 'tooltip text', - tooltipPlacement: 'bottom', - username: 'username', - }; - - const createWrapper = (props, slots) => { - wrapper = shallowMountExtended(UserAvatarLink, { - propsData: { - ...defaultProps, - ...props, - ...slots, - }, - }); - }; - - beforeEach(() => { - createWrapper(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('should render GlLink with correct props', () => { - const link = wrapper.findComponent(GlLink); - expect(link.exists()).toBe(true); - expect(link.attributes('href')).toBe(defaultProps.linkHref); - }); - - it('should render UserAvatarImage and povide correct props to it', () => { - expect(wrapper.findComponent(UserAvatarImage).exists()).toBe(true); - expect(wrapper.findComponent(UserAvatarImage).props()).toEqual({ - cssClasses: defaultProps.imgCssClasses, - imgAlt: defaultProps.imgAlt, - imgSrc: defaultProps.imgSrc, - lazy: false, - size: defaultProps.imgSize, - tooltipPlacement: defaultProps.tooltipPlacement, - tooltipText: '', - enforceGlAvatar: false, - }); - }); - - describe('when username provided', () => { - beforeEach(() => { - createWrapper({ username: defaultProps.username }); - }); - - it('should render provided username', () => { - expect(findUserName().text()).toBe(defaultProps.username); - }); - - it('should provide the tooltip data for the username', () => { - expect(findUserName().attributes()).toEqual( - expect.objectContaining({ - title: defaultProps.tooltipText, - 'tooltip-placement': defaultProps.tooltipPlacement, - }), - ); - }); - }); - - describe('when username is NOT provided', () => { - beforeEach(() => { - createWrapper({ username: '' }); - }); - - it('should NOT render username', () => { - expect(findUserName().exists()).toBe(false); - }); - }); - - describe('avatar-badge slot', () => { - const badge = '<span>User badge</span>'; - - beforeEach(() => { - createWrapper(defaultProps, { - 'avatar-badge': badge, - }); - }); - - it('should render provided `avatar-badge` slot content', () => { - expect(wrapper.html()).toContain(badge); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js index fd3f59008ec..df7ce449678 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js @@ -1,51 +1,102 @@ -import { shallowMount } from '@vue/test-utils'; +import { GlAvatarLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { TEST_HOST } from 'spec/test_constants'; +import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import UserAvatarLinkNew from '~/vue_shared/components/user_avatar/user_avatar_link_new.vue'; -import UserAvatarLinkOld from '~/vue_shared/components/user_avatar/user_avatar_link_old.vue'; - -const PROVIDED_PROPS = { - size: 32, - imgSrc: 'myavatarurl.com', - imgAlt: 'mydisplayname', - cssClasses: 'myextraavatarclass', - tooltipText: 'tooltip text', - tooltipPlacement: 'bottom', -}; describe('User Avatar Link Component', () => { let wrapper; - const createWrapper = (props = {}, { glAvatarForAllUserAvatars } = {}) => { - wrapper = shallowMount(UserAvatarLink, { + const findUserName = () => wrapper.findByTestId('user-avatar-link-username'); + + const defaultProps = { + linkHref: `${TEST_HOST}/myavatarurl.com`, + imgSize: 32, + imgSrc: `${TEST_HOST}/myavatarurl.com`, + imgAlt: 'mydisplayname', + imgCssClasses: 'myextraavatarclass', + tooltipText: 'tooltip text', + tooltipPlacement: 'bottom', + username: 'username', + }; + + const createWrapper = (props, slots) => { + wrapper = shallowMountExtended(UserAvatarLink, { propsData: { - ...PROVIDED_PROPS, + ...defaultProps, ...props, - }, - provide: { - glFeatures: { - glAvatarForAllUserAvatars, - }, + ...slots, }, }); }; + beforeEach(() => { + createWrapper(); + }); + afterEach(() => { wrapper.destroy(); }); - describe.each([ - [false, true, true], - [true, false, true], - [true, true, true], - [false, false, false], - ])( - 'when glAvatarForAllUserAvatars=%s and enforceGlAvatar=%s', - (glAvatarForAllUserAvatars, enforceGlAvatar, isUsingNewVersion) => { - it(`will render ${isUsingNewVersion ? 'new' : 'old'} version`, () => { - createWrapper({ enforceGlAvatar }, { glAvatarForAllUserAvatars }); - expect(wrapper.findComponent(UserAvatarLinkNew).exists()).toBe(isUsingNewVersion); - expect(wrapper.findComponent(UserAvatarLinkOld).exists()).toBe(!isUsingNewVersion); + it('should render GlLink with correct props', () => { + const link = wrapper.findComponent(GlAvatarLink); + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe(defaultProps.linkHref); + }); + + it('should render UserAvatarImage and provide correct props to it', () => { + expect(wrapper.findComponent(UserAvatarImage).exists()).toBe(true); + expect(wrapper.findComponent(UserAvatarImage).props()).toEqual({ + cssClasses: defaultProps.imgCssClasses, + imgAlt: defaultProps.imgAlt, + imgSrc: defaultProps.imgSrc, + lazy: false, + size: defaultProps.imgSize, + tooltipPlacement: defaultProps.tooltipPlacement, + tooltipText: '', + }); + }); + + describe('when username provided', () => { + beforeEach(() => { + createWrapper({ username: defaultProps.username }); + }); + + it('should render provided username', () => { + expect(findUserName().text()).toBe(defaultProps.username); + }); + + it('should provide the tooltip data for the username', () => { + expect(findUserName().attributes()).toEqual( + expect.objectContaining({ + title: defaultProps.tooltipText, + 'tooltip-placement': defaultProps.tooltipPlacement, + }), + ); + }); + }); + + describe('when username is NOT provided', () => { + beforeEach(() => { + createWrapper({ username: '' }); + }); + + it('should NOT render username', () => { + expect(findUserName().exists()).toBe(false); + }); + }); + + describe('avatar-badge slot', () => { + const badge = '<span>User badge</span>'; + + beforeEach(() => { + createWrapper(defaultProps, { + 'avatar-badge': badge, }); - }, - ); + }); + + it('should render provided `avatar-badge` slot content', () => { + expect(wrapper.html()).toContain(badge); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js index b9accbf0373..1ad6d043399 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js @@ -153,29 +153,4 @@ describe('UserAvatarList', () => { }); }); }); - - describe('additional styling for the image', () => { - it('should not add CSS class when feature flag `glAvatarForAllUserAvatars` is disabled', () => { - factory({ - propsData: { items: createList(1) }, - }); - - const link = wrapper.findComponent(UserAvatarLink); - expect(link.props('imgCssClasses')).not.toBe('gl-mr-3'); - }); - - it('should add CSS class when feature flag `glAvatarForAllUserAvatars` is enabled', () => { - factory({ - propsData: { items: createList(1) }, - provide: { - glFeatures: { - glAvatarForAllUserAvatars: true, - }, - }, - }); - - const link = wrapper.findComponent(UserAvatarLink); - expect(link.props('imgCssClasses')).toBe('gl-mr-3'); - }); - }); }); diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js index 6d48000beb0..f6316af6ad8 100644 --- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js @@ -8,10 +8,12 @@ import { I18N_USER_BLOCKED, I18N_USER_LEARN, I18N_USER_FOLLOW, + I18N_ERROR_FOLLOW, I18N_USER_UNFOLLOW, + I18N_ERROR_UNFOLLOW, } from '~/vue_shared/components/user_popover/constants'; import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { followUser, unfollowUser } from '~/api/user_api'; import { mockTracking } from 'helpers/tracking_helper'; @@ -239,6 +241,18 @@ describe('User Popover Component', () => { expect(wrapper.html()).toContain('<gl-emoji data-name="basketball_player"'); }); + it('should show only emoji', () => { + const user = { + ...DEFAULT_PROPS.user, + status: { emoji: 'basketball_player' }, + }; + + createWrapper({ user }); + + expect(findUserStatus().exists()).toBe(true); + expect(wrapper.html()).toContain('<gl-emoji data-name="basketball_player"'); + }); + it('hides the div when status is null', () => { const user = { ...DEFAULT_PROPS.user, status: null }; @@ -367,27 +381,49 @@ describe('User Popover Component', () => { itTracksToggleFollowButtonClick('follow_from_user_popover'); describe('when an error occurs', () => { - beforeEach(() => { - followUser.mockRejectedValue({}); + describe('api send error message', () => { + const mockedMessage = sprintf(I18N_ERROR_UNFOLLOW, { limit: 300 }); + const apiResponse = { response: { data: { message: mockedMessage } } }; - findToggleFollowButton().trigger('click'); - }); + beforeEach(() => { + followUser.mockRejectedValue(apiResponse); + findToggleFollowButton().trigger('click'); + }); - it('shows an error message', async () => { - await axios.waitForAll(); + it('show an error message from api response', async () => { + await axios.waitForAll(); - expect(createFlash).toHaveBeenCalledWith({ - message: 'An error occurred while trying to follow this user, please try again.', - error: {}, - captureError: true, + expect(createAlert).toHaveBeenCalledWith({ + message: mockedMessage, + error: apiResponse, + captureError: true, + }); }); }); - it('emits no events', async () => { - await axios.waitForAll(); + describe('api did not send error message', () => { + beforeEach(() => { + followUser.mockRejectedValue({}); - expect(wrapper.emitted().follow).toBeUndefined(); - expect(wrapper.emitted().unfollow).toBeUndefined(); + findToggleFollowButton().trigger('click'); + }); + + it('shows an error message', async () => { + await axios.waitForAll(); + + expect(createAlert).toHaveBeenCalledWith({ + message: I18N_ERROR_FOLLOW, + error: {}, + captureError: true, + }); + }); + + it('emits no events', async () => { + await axios.waitForAll(); + + expect(wrapper.emitted().follow).toBeUndefined(); + expect(wrapper.emitted().unfollow).toBeUndefined(); + }); }); }); }); @@ -425,8 +461,8 @@ describe('User Popover Component', () => { }); it('shows an error message', () => { - expect(createFlash).toHaveBeenCalledWith({ - message: 'An error occurred while trying to unfollow this user, please try again.', + expect(createAlert).toHaveBeenCalledWith({ + message: I18N_ERROR_UNFOLLOW, error: {}, captureError: true, }); diff --git a/spec/frontend/vue_shared/directives/safe_html_spec.js b/spec/frontend/vue_shared/directives/safe_html_spec.js new file mode 100644 index 00000000000..ba1de8e4596 --- /dev/null +++ b/spec/frontend/vue_shared/directives/safe_html_spec.js @@ -0,0 +1,116 @@ +import { shallowMount } from '@vue/test-utils'; +import safeHtml from '~/vue_shared/directives/safe_html'; +import { defaultConfig } from '~/lib/dompurify'; +/* eslint-disable no-script-url */ +const invalidProtocolUrls = [ + 'javascript:alert(1)', + 'jAvascript:alert(1)', + 'data:text/html,<script>alert(1);</script>', + ' javascript:', + 'javascript :', +]; +/* eslint-enable no-script-url */ +const validProtocolUrls = ['slack://open', 'x-devonthink-item://90909', 'x-devonthink-item:90909']; + +describe('safe html directive', () => { + let wrapper; + + const createComponent = ({ template, html, config } = {}) => { + const defaultTemplate = `<div v-safe-html="rawHtml"></div>`; + const defaultHtml = 'hello <script>alert(1)</script>world'; + + const component = { + directives: { + safeHtml, + }, + data() { + return { + rawHtml: html || defaultHtml, + config: config || {}, + }; + }, + template: template || defaultTemplate, + }; + + wrapper = shallowMount(component); + }; + + describe('default', () => { + it('should remove the script tag', () => { + createComponent(); + + expect(wrapper.html()).toEqual('<div>hello world</div>'); + }); + + it('should remove javascript hrefs', () => { + createComponent({ html: '<a href="javascript:prompt(1)">click here</a>' }); + + expect(wrapper.html()).toEqual('<div><a>click here</a></div>'); + }); + + it('should remove any existing children', () => { + createComponent({ + template: `<div v-safe-html="rawHtml">foo <i>bar</i></div>`, + }); + + expect(wrapper.html()).toEqual('<div>hello world</div>'); + }); + + describe('with non-http links', () => { + it.each(validProtocolUrls)('should allow %s', (url) => { + createComponent({ + html: `<a href="${url}">internal link</a>`, + }); + expect(wrapper.html()).toContain(`<a href="${url}">internal link</a>`); + }); + + it.each(invalidProtocolUrls)('should not allow %s', (url) => { + createComponent({ + html: `<a href="${url}">internal link</a>`, + }); + expect(wrapper.html()).toContain(`<a>internal link</a>`); + }); + }); + + describe('handles data attributes correctly', () => { + const allowedDataAttrs = ['data-safe', 'data-random']; + + it.each(defaultConfig.FORBID_ATTR)('removes dangerous `%s` attribute', (attr) => { + const html = `<a ${attr}="true"></a>`; + createComponent({ html }); + + expect(wrapper.html()).not.toContain(html); + }); + + it.each(allowedDataAttrs)('does not remove allowed `%s` attribute', (attr) => { + const html = `<a ${attr}="true"></a>`; + createComponent({ html }); + + expect(wrapper.html()).toContain(html); + }); + }); + }); + + describe('advance config', () => { + const template = '<div v-safe-html:[config]="rawHtml"></div>'; + it('should only allow <b> tags', () => { + createComponent({ + template, + html: '<a href="javascript:prompt(1)"><b>click here</b></a>', + config: { ALLOWED_TAGS: ['b'] }, + }); + + expect(wrapper.html()).toEqual('<div><b>click here</b></div>'); + }); + + it('should strip all html tags', () => { + createComponent({ + template, + html: '<a href="javascript:prompt(1)"><u>click here</u></a>', + config: { ALLOWED_TAGS: [] }, + }); + + expect(wrapper.html()).toEqual('<div>click here</div>'); + }); + }); +}); diff --git a/spec/frontend/boards/components/__snapshots__/board_blocked_icon_spec.js.snap b/spec/frontend/vue_shared/issuable/__snapshots__/issuable_blocked_icon_spec.js.snap index 34e4f996ff0..dd011b9d84e 100644 --- a/spec/frontend/boards/components/__snapshots__/board_blocked_icon_spec.js.snap +++ b/spec/frontend/vue_shared/issuable/__snapshots__/issuable_blocked_icon_spec.js.snap @@ -1,23 +1,23 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`BoardBlockedIcon on mouseenter on blocked icon with more than three blocking issues matches the snapshot 1`] = ` -"<div class=\\"gl-display-inline\\"><svg data-testid=\\"issue-blocked-icon\\" role=\\"img\\" aria-hidden=\\"true\\" class=\\"issue-blocked-icon gl-mr-2 gl-cursor-pointer gl-text-red-500 gl-icon s16\\" id=\\"blocked-icon-uniqueId\\"> +exports[`IssuableBlockedIcon on mouseenter on blocked icon with more than three blocking issues matches the snapshot 1`] = ` +"<div class=\\"gl-display-inline\\"><svg data-testid=\\"issuable-blocked-icon\\" role=\\"img\\" aria-hidden=\\"true\\" class=\\"issuable-blocked-icon gl-mr-2 gl-cursor-pointer gl-text-red-500 gl-icon s16\\" id=\\"blocked-icon-uniqueId\\"> <use href=\\"#issue-block\\"></use> </svg> <div class=\\"gl-popover\\"> - <ul class=\\"gl-list-style-none gl-p-0\\"> + <ul class=\\"gl-list-style-none gl-p-0 gl-mb-0\\"> <li><a href=\\"http://gdk.test:3000/gitlab-org/my-project-1/-/issues/6\\" class=\\"gl-link gl-text-blue-500! gl-font-sm\\">my-project-1#6</a> - <p data-testid=\\"issuable-title\\" class=\\"gl-mb-3 gl-display-block!\\"> + <p data-testid=\\"issuable-title\\" class=\\"gl-display-block! gl-mb-3\\"> blocking issue title 1 </p> </li> <li><a href=\\"http://gdk.test:3000/gitlab-org/my-project-1/-/issues/5\\" class=\\"gl-link gl-text-blue-500! gl-font-sm\\">my-project-1#5</a> - <p data-testid=\\"issuable-title\\" class=\\"gl-mb-3 gl-display-block!\\"> + <p data-testid=\\"issuable-title\\" class=\\"gl-display-block! gl-mb-3\\"> blocking issue title 2 + blocking issue title 2 + blocking issue title 2 + bloc… </p> </li> <li><a href=\\"http://gdk.test:3000/gitlab-org/my-project-1/-/issues/4\\" class=\\"gl-link gl-text-blue-500! gl-font-sm\\">my-project-1#4</a> - <p data-testid=\\"issuable-title\\" class=\\"gl-mb-3 gl-display-block!\\"> + <p data-testid=\\"issuable-title\\" class=\\"gl-display-block! gl-mb-0\\"> blocking issue title 3 </p> </li> diff --git a/spec/frontend/boards/components/board_blocked_icon_spec.js b/spec/frontend/vue_shared/issuable/issuable_blocked_icon_spec.js index ffdc0a7cecc..d59cbce6633 100644 --- a/spec/frontend/boards/components/board_blocked_icon_spec.js +++ b/spec/frontend/vue_shared/issuable/issuable_blocked_icon_spec.js @@ -5,8 +5,9 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import BoardBlockedIcon from '~/boards/components/board_blocked_icon.vue'; -import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants'; +import IssuableBlockedIcon from '~/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue'; +import { blockingIssuablesQueries } from '~/vue_shared/components/issuable_blocked_icon/constants'; +import { issuableTypes } from '~/boards/constants'; import { truncate } from '~/lib/utils/text_utility'; import { mockIssue, @@ -21,9 +22,9 @@ import { mockBlockedIssue2, mockBlockedEpic1, mockBlockingEpicIssuablesResponse1, -} from '../mock_data'; +} from '../../boards/mock_data'; -describe('BoardBlockedIcon', () => { +describe('IssuableBlockedIcon', () => { let wrapper; let mockApollo; @@ -64,7 +65,7 @@ describe('BoardBlockedIcon', () => { Vue.use(VueApollo); wrapper = extendedWrapper( - mount(BoardBlockedIcon, { + mount(IssuableBlockedIcon, { apolloProvider: mockApollo, propsData: { item: { @@ -88,7 +89,7 @@ describe('BoardBlockedIcon', () => { issuableType = issuableTypes.issue, } = {}) => { wrapper = extendedWrapper( - shallowMount(BoardBlockedIcon, { + shallowMount(IssuableBlockedIcon, { propsData: { item: { ...mockIssuable, 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 39a76a51191..6b20f0c77a3 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 @@ -138,7 +138,7 @@ describe('IssuableBody', () => { wrapper.vm.handleTaskListUpdateSuccess(updatedIssuable); - expect(wrapper.emitted('task-list-update-success')).toBeTruthy(); + expect(wrapper.emitted('task-list-update-success')).toHaveLength(1); expect(wrapper.emitted('task-list-update-success')[0]).toEqual([updatedIssuable]); }); }); @@ -147,7 +147,7 @@ describe('IssuableBody', () => { it('emits `task-list-update-failure` event on component', () => { wrapper.vm.handleTaskListUpdateFailure(); - expect(wrapper.emitted('task-list-update-failure')).toBeTruthy(); + expect(wrapper.emitted('task-list-update-failure')).toHaveLength(1); }); }); }); @@ -202,7 +202,7 @@ describe('IssuableBody', () => { issuableTitle.vm.$emit('edit-issuable'); - expect(wrapper.emitted('edit-issuable')).toBeTruthy(); + expect(wrapper.emitted('edit-issuable')).toHaveLength(1); }); it.each(['keydown-title', 'keydown-description'])( @@ -227,7 +227,7 @@ describe('IssuableBody', () => { issuableEditForm.vm.$emit(eventName, eventObj, issuableMeta); - expect(wrapper.emitted(eventName)).toBeTruthy(); + expect(wrapper.emitted(eventName)).toHaveLength(1); expect(wrapper.emitted(eventName)[0]).toMatchObject([eventObj, issuableMeta]); }, ); diff --git a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js index a9651cf8bac..43ff68e30b5 100644 --- a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js +++ b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js @@ -14,7 +14,7 @@ import { sastDiffSuccessMock, secretDetectionDiffSuccessMock, } from 'jest/vue_shared/security_reports/mock_data'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import HelpIcon from '~/vue_shared/security_reports/components/help_icon.vue'; import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue'; @@ -135,8 +135,8 @@ describe('Security reports app', () => { }); }); - it('calls createFlash correctly', () => { - expect(createFlash).toHaveBeenCalledWith({ + it('calls createAlert correctly', () => { + expect(createAlert).toHaveBeenCalledWith({ message: SecurityReportsApp.i18n.apiError, captureError: true, error: expect.any(Error), diff --git a/spec/frontend/webhooks/components/form_url_app_spec.js b/spec/frontend/webhooks/components/form_url_app_spec.js new file mode 100644 index 00000000000..16e0a3f549e --- /dev/null +++ b/spec/frontend/webhooks/components/form_url_app_spec.js @@ -0,0 +1,142 @@ +import { nextTick } from 'vue'; +import { GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui'; + +import FormUrlApp from '~/webhooks/components/form_url_app.vue'; +import FormUrlMaskItem from '~/webhooks/components/form_url_mask_item.vue'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +describe('FormUrlApp', () => { + let wrapper; + + const createComponent = ({ props } = {}) => { + wrapper = shallowMountExtended(FormUrlApp, { + propsData: { ...props }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findAllRadioButtons = () => wrapper.findAllComponents(GlFormRadio); + const findRadioGroup = () => wrapper.findComponent(GlFormRadioGroup); + const findUrlMaskDisable = () => findAllRadioButtons().at(0); + const findUrlMaskEnable = () => findAllRadioButtons().at(1); + const findAllUrlMaskItems = () => wrapper.findAllComponents(FormUrlMaskItem); + const findAddItem = () => wrapper.findComponent(GlLink); + const findFormUrl = () => wrapper.findByTestId('form-url'); + const findFormUrlPreview = () => wrapper.findByTestId('form-url-preview'); + const findUrlMaskSection = () => wrapper.findByTestId('url-mask-section'); + + describe('template', () => { + it('renders radio buttons for URL masking', () => { + createComponent(); + + expect(findAllRadioButtons()).toHaveLength(2); + expect(findUrlMaskDisable().text()).toBe(FormUrlApp.i18n.radioFullUrlText); + expect(findUrlMaskEnable().text()).toBe(FormUrlApp.i18n.radioMaskUrlText); + }); + + it('does not render mask section', () => { + createComponent(); + + expect(findUrlMaskSection().exists()).toBe(false); + }); + + describe('on radio select', () => { + beforeEach(async () => { + createComponent(); + + findRadioGroup().vm.$emit('input', true); + await nextTick(); + }); + + it('renders mask section', () => { + expect(findUrlMaskSection().exists()).toBe(true); + }); + + it('renders an empty mask item by default', () => { + expect(findAllUrlMaskItems()).toHaveLength(1); + + const firstItem = findAllUrlMaskItems().at(0); + expect(firstItem.props('itemKey')).toBeNull(); + expect(firstItem.props('itemValue')).toBeNull(); + }); + }); + + describe('with mask items', () => { + const mockItem1 = { key: 'key1', value: 'value1' }; + const mockItem2 = { key: 'key2', value: 'value2' }; + + beforeEach(() => { + createComponent({ + props: { initialUrlVariables: [mockItem1, mockItem2] }, + }); + }); + + it('renders masked URL preview', async () => { + const mockUrl = 'https://test.host/value1?secret=value2'; + + findFormUrl().vm.$emit('input', mockUrl); + await nextTick(); + + expect(findFormUrlPreview().attributes('value')).toBe( + 'https://test.host/{key1}?secret={key2}', + ); + }); + + it('renders mask items correctly', () => { + expect(findAllUrlMaskItems()).toHaveLength(2); + + const firstItem = findAllUrlMaskItems().at(0); + expect(firstItem.props('itemKey')).toBe(mockItem1.key); + expect(firstItem.props('itemValue')).toBe(mockItem1.value); + + const secondItem = findAllUrlMaskItems().at(1); + expect(secondItem.props('itemKey')).toBe(mockItem2.key); + expect(secondItem.props('itemValue')).toBe(mockItem2.value); + }); + + describe('on mask item input', () => { + const mockInput = { index: 0, key: 'display', value: 'secret' }; + + it('updates mask item', async () => { + const firstItem = findAllUrlMaskItems().at(0); + firstItem.vm.$emit('input', mockInput); + await nextTick(); + + expect(firstItem.props('itemKey')).toBe(mockInput.key); + expect(firstItem.props('itemValue')).toBe(mockInput.value); + }); + }); + + describe('when add item is clicked', () => { + it('adds mask item', async () => { + findAddItem().vm.$emit('click'); + await nextTick(); + + expect(findAllUrlMaskItems()).toHaveLength(3); + + const lastItem = findAllUrlMaskItems().at(-1); + expect(lastItem.props('itemKey')).toBeNull(); + expect(lastItem.props('itemValue')).toBeNull(); + }); + }); + + describe('when remove item is clicked', () => { + it('removes the correct mask item', async () => { + const firstItem = findAllUrlMaskItems().at(0); + firstItem.vm.$emit('remove'); + await nextTick(); + + expect(findAllUrlMaskItems()).toHaveLength(1); + + const newFirstItem = findAllUrlMaskItems().at(0); + expect(newFirstItem.props('itemKey')).toBe(mockItem2.key); + expect(newFirstItem.props('itemValue')).toBe(mockItem2.value); + }); + }); + }); + }); +}); diff --git a/spec/frontend/webhooks/components/form_url_mask_item_spec.js b/spec/frontend/webhooks/components/form_url_mask_item_spec.js new file mode 100644 index 00000000000..ab028ef2997 --- /dev/null +++ b/spec/frontend/webhooks/components/form_url_mask_item_spec.js @@ -0,0 +1,100 @@ +import { nextTick } from 'vue'; +import { GlButton, GlFormInput } from '@gitlab/ui'; + +import FormUrlMaskItem from '~/webhooks/components/form_url_mask_item.vue'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +describe('FormUrlMaskItem', () => { + let wrapper; + + const defaultProps = { + index: 0, + }; + const mockKey = 'key'; + const mockValue = 'value'; + const mockInput = 'input'; + + const createComponent = ({ props } = {}) => { + wrapper = shallowMountExtended(FormUrlMaskItem, { + propsData: { ...defaultProps, ...props }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findMaskItemKey = () => wrapper.findByTestId('mask-item-key'); + const findMaskItemValue = () => wrapper.findByTestId('mask-item-value'); + const findRemoveButton = () => wrapper.findComponent(GlButton); + + describe('template', () => { + it('renders input for key and value', () => { + createComponent(); + + const keyInput = findMaskItemKey(); + expect(keyInput.attributes('label')).toBe(FormUrlMaskItem.i18n.keyLabel); + expect(keyInput.findComponent(GlFormInput).attributes('name')).toBe( + 'hook[url_variables][][key]', + ); + + const valueInput = findMaskItemValue(); + expect(valueInput.attributes('label')).toBe(FormUrlMaskItem.i18n.valueLabel); + expect(valueInput.findComponent(GlFormInput).attributes('name')).toBe( + 'hook[url_variables][][value]', + ); + }); + + describe('on key input', () => { + beforeEach(async () => { + createComponent({ props: { itemKey: mockKey, itemValue: mockValue } }); + + findMaskItemKey().findComponent(GlFormInput).vm.$emit('input', mockInput); + await nextTick(); + }); + + it('emits input event', () => { + expect(wrapper.emitted('input')).toEqual([ + [{ index: defaultProps.index, key: mockInput, value: mockValue }], + ]); + }); + }); + + describe('on value input', () => { + beforeEach(async () => { + createComponent({ props: { itemKey: mockKey, itemValue: mockValue } }); + + findMaskItemValue().findComponent(GlFormInput).vm.$emit('input', mockInput); + await nextTick(); + }); + + it('emits input event', () => { + expect(wrapper.emitted('input')).toEqual([ + [{ index: defaultProps.index, key: mockKey, value: mockInput }], + ]); + }); + }); + + it('renders remove button', () => { + createComponent(); + + expect(findRemoveButton().props('icon')).toBe('remove'); + }); + + describe('when remove button is clicked', () => { + const mockIndex = 5; + + beforeEach(async () => { + createComponent({ props: { index: mockIndex } }); + + findRemoveButton().vm.$emit('click'); + await nextTick(); + }); + + it('emits remove event', () => { + expect(wrapper.emitted('remove')).toEqual([[mockIndex]]); + }); + }); + }); +}); diff --git a/spec/frontend/whats_new/components/app_spec.js b/spec/frontend/whats_new/components/app_spec.js index de5a814d3e7..da95b51c0b1 100644 --- a/spec/frontend/whats_new/components/app_spec.js +++ b/spec/frontend/whats_new/components/app_spec.js @@ -54,7 +54,7 @@ describe('App', () => { }); }; - const findInfiniteScroll = () => wrapper.find(GlInfiniteScroll); + const findInfiniteScroll = () => wrapper.findComponent(GlInfiniteScroll); const setup = async () => { document.body.dataset.page = 'test-page'; @@ -80,7 +80,7 @@ describe('App', () => { setup(); }); - const getDrawer = () => wrapper.find(GlDrawer); + const getDrawer = () => wrapper.findComponent(GlDrawer); const getBackdrop = () => wrapper.find('.whats-new-modal-backdrop'); it('contains a drawer', () => { @@ -173,7 +173,7 @@ describe('App', () => { value(); - expect(getDrawerBodyHeight).toHaveBeenCalledWith(wrapper.find(GlDrawer).element); + expect(getDrawerBodyHeight).toHaveBeenCalledWith(wrapper.findComponent(GlDrawer).element); expect(actions.setDrawerBodyHeight).toHaveBeenCalledWith( expect.any(Object), diff --git a/spec/frontend/work_items/components/work_item_assignees_spec.js b/spec/frontend/work_items/components/work_item_assignees_spec.js index 28231fad108..1b204b6fd60 100644 --- a/spec/frontend/work_items/components/work_item_assignees_spec.js +++ b/spec/frontend/work_items/components/work_item_assignees_spec.js @@ -157,6 +157,14 @@ describe('WorkItemAssignees component', () => { expect(findTokenSelector().props('viewOnly')).toBe(true); }); + it('has a label', () => { + createComponent(); + + expect(findTokenSelector().props('ariaLabelledby')).toEqual( + findAssigneesTitle().attributes('id'), + ); + }); + describe('when clicking outside the token selector', () => { function arrange(args) { createComponent(args); diff --git a/spec/frontend/work_items/components/work_item_description_spec.js b/spec/frontend/work_items/components/work_item_description_spec.js index d3165d8dc26..0691fe25e0d 100644 --- a/spec/frontend/work_items/components/work_item_description_spec.js +++ b/spec/frontend/work_items/components/work_item_description_spec.js @@ -4,6 +4,7 @@ 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 EditedAt from '~/issues/show/components/edited.vue'; 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'; @@ -35,6 +36,7 @@ describe('WorkItemDescription', () => { const findEditButton = () => wrapper.find('[data-testid="edit-description"]'); const findMarkdownField = () => wrapper.findComponent(MarkdownField); + const findEditedAt = () => wrapper.findComponent(EditedAt); const editDescription = (newText) => wrapper.find('textarea').setValue(newText); @@ -44,9 +46,9 @@ describe('WorkItemDescription', () => { const createComponent = async ({ mutationHandler = mutationSuccessHandler, canUpdate = true, + workItemResponse = workItemResponseFactory({ canUpdate }), isEditing = false, } = {}) => { - const workItemResponse = workItemResponseFactory({ canUpdate }); const workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse); const { id } = workItemQueryResponse.data.workItem; @@ -100,6 +102,33 @@ describe('WorkItemDescription', () => { }); describe('editing description', () => { + it('shows edited by text', async () => { + const lastEditedAt = '2022-09-21T06:18:42Z'; + const lastEditedBy = { + name: 'Administrator', + webPath: '/root', + }; + + await createComponent({ + workItemResponse: workItemResponseFactory({ + lastEditedAt, + lastEditedBy, + }), + }); + + expect(findEditedAt().props()).toEqual({ + updatedAt: lastEditedAt, + updatedByName: lastEditedBy.name, + updatedByPath: lastEditedBy.webPath, + }); + }); + + it('does not show edited by text', async () => { + await createComponent(); + + expect(findEditedAt().exists()).toBe(false); + }); + it('cancels when clicking cancel', async () => { await createComponent({ isEditing: true, diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js index b047e0dc8d7..aae61b11196 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -1,8 +1,14 @@ -import { GlAlert, GlBadge, GlLoadingIcon, GlSkeletonLoader, GlButton } from '@gitlab/ui'; +import { + GlAlert, + GlBadge, + GlLoadingIcon, + GlSkeletonLoader, + GlButton, + GlEmptyState, +} from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -import workItemWeightSubscription from 'ee_component/work_items/graphql/work_item_weight.subscription.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; @@ -14,11 +20,13 @@ 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 WorkItemLabels from '~/work_items/components/work_item_labels.vue'; +import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue'; import WorkItemInformation from '~/work_items/components/work_item_information.vue'; import { i18n } from '~/work_items/constants'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import workItemDatesSubscription from '~/work_items/graphql/work_item_dates.subscription.graphql'; import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql'; +import workItemAssigneesSubscription from '~/work_items/graphql/work_item_assignees.subscription.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql'; import { temporaryConfig } from '~/graphql_shared/issuable_client'; @@ -28,7 +36,7 @@ import { workItemDatesSubscriptionResponse, workItemResponseFactory, workItemTitleSubscriptionResponse, - workItemWeightSubscriptionResponse, + workItemAssigneesSubscriptionResponse, } from '../mock_data'; describe('WorkItemDetail component', () => { @@ -46,9 +54,12 @@ describe('WorkItemDetail component', () => { const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse); const datesSubscriptionHandler = jest.fn().mockResolvedValue(workItemDatesSubscriptionResponse); const titleSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse); - const weightSubscriptionHandler = jest.fn().mockResolvedValue(workItemWeightSubscriptionResponse); + const assigneesSubscriptionHandler = jest + .fn() + .mockResolvedValue(workItemAssigneesSubscriptionResponse); const findAlert = () => wrapper.findComponent(GlAlert); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findSkeleton = () => wrapper.findComponent(GlSkeletonLoader); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findWorkItemActions = () => wrapper.findComponent(WorkItemActions); @@ -58,6 +69,7 @@ describe('WorkItemDetail component', () => { const findWorkItemDueDate = () => wrapper.findComponent(WorkItemDueDate); const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees); const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels); + const findWorkItemMilestone = () => wrapper.findComponent(WorkItemMilestone); const findParent = () => wrapper.find('[data-testid="work-item-parent"]'); const findParentButton = () => findParent().findComponent(GlButton); const findCloseButton = () => wrapper.find('[data-testid="work-item-close"]'); @@ -72,21 +84,18 @@ describe('WorkItemDetail component', () => { handler = successHandler, subscriptionHandler = titleSubscriptionHandler, confidentialityMock = [updateWorkItemMutation, jest.fn()], - workItemsMvc2Enabled = false, - includeWidgets = false, error = undefined, + includeWidgets = false, + workItemsMvc2Enabled = false, } = {}) => { const handlers = [ [workItemQuery, handler], [workItemTitleSubscription, subscriptionHandler], [workItemDatesSubscription, datesSubscriptionHandler], + [workItemAssigneesSubscription, assigneesSubscriptionHandler], confidentialityMock, ]; - if (IS_EE) { - handlers.push([workItemWeightSubscription, weightSubscriptionHandler]); - } - wrapper = shallowMount(WorkItemDetail, { apolloProvider: createMockApollo( handlers, @@ -107,6 +116,12 @@ describe('WorkItemDetail component', () => { workItemsMvc2: workItemsMvc2Enabled, }, hasIssueWeightsFeature: true, + hasIterationsFeature: true, + projectNamespace: 'namespace', + }, + stubs: { + WorkItemWeight: true, + WorkItemIteration: true, }, }); }; @@ -384,13 +399,14 @@ describe('WorkItemDetail component', () => { }); }); - it('shows an error message when the work item query was unsuccessful', async () => { + it('shows empty state with an error message when the work item query was unsuccessful', async () => { const errorHandler = jest.fn().mockRejectedValue('Oops'); createComponent({ handler: errorHandler }); await waitForPromises(); expect(errorHandler).toHaveBeenCalled(); - expect(findAlert().text()).toBe(i18n.fetchError); + expect(findEmptyState().props('description')).toBe(i18n.fetchError); + expect(findWorkItemTitle().exists()).toBe(false); }); it('shows an error message when WorkItemTitle emits an `error` event', async () => { @@ -413,6 +429,30 @@ describe('WorkItemDetail component', () => { }); }); + describe('assignees subscription', () => { + describe('when the assignees widget exists', () => { + it('calls the assignees subscription', async () => { + createComponent(); + await waitForPromises(); + + expect(assigneesSubscriptionHandler).toHaveBeenCalledWith({ + issuableId: workItemQueryResponse.data.workItem.id, + }); + }); + }); + + describe('when the assignees widget does not exist', () => { + it('does not call the assignees subscription', async () => { + const response = workItemResponseFactory({ assigneesWidgetPresent: false }); + const handler = jest.fn().mockResolvedValue(response); + createComponent({ handler }); + await waitForPromises(); + + expect(assigneesSubscriptionHandler).not.toHaveBeenCalled(); + }); + }); + }); + describe('dates subscription', () => { describe('when the due date widget exists', () => { it('calls the dates subscription', async () => { @@ -429,7 +469,7 @@ describe('WorkItemDetail component', () => { it('does not call the dates subscription', async () => { const response = workItemResponseFactory({ datesWidgetPresent: false }); const handler = jest.fn().mockResolvedValue(response); - createComponent({ handler, workItemsMvc2Enabled: true }); + createComponent({ handler }); await waitForPromises(); expect(datesSubscriptionHandler).not.toHaveBeenCalled(); @@ -440,9 +480,7 @@ describe('WorkItemDetail component', () => { describe('assignees widget', () => { it('renders assignees component when widget is returned from the API', async () => { - createComponent({ - workItemsMvc2Enabled: true, - }); + createComponent(); await waitForPromises(); expect(findWorkItemAssignees().exists()).toBe(true); @@ -450,7 +488,6 @@ describe('WorkItemDetail component', () => { it('does not render assignees component when widget is not returned from the API', async () => { createComponent({ - workItemsMvc2Enabled: true, handler: jest .fn() .mockResolvedValue(workItemResponseFactory({ assigneesWidgetPresent: false })), @@ -463,11 +500,13 @@ describe('WorkItemDetail component', () => { describe('labels widget', () => { it.each` - description | includeWidgets | exists - ${'renders when widget is returned from API'} | ${true} | ${true} - ${'does not render when widget is not returned from API'} | ${false} | ${false} - `('$description', async ({ includeWidgets, exists }) => { - createComponent({ includeWidgets, workItemsMvc2Enabled: true }); + description | labelsWidgetPresent | exists + ${'renders when widget is returned from API'} | ${true} | ${true} + ${'does not render when widget is not returned from API'} | ${false} | ${false} + `('$description', async ({ labelsWidgetPresent, exists }) => { + const response = workItemResponseFactory({ labelsWidgetPresent }); + const handler = jest.fn().mockResolvedValue(response); + createComponent({ handler }); await waitForPromises(); expect(findWorkItemLabels().exists()).toBe(exists); @@ -483,7 +522,7 @@ describe('WorkItemDetail component', () => { it(`${datesWidgetPresent ? 'renders' : 'does not render'} due date component`, async () => { const response = workItemResponseFactory({ datesWidgetPresent }); const handler = jest.fn().mockResolvedValue(response); - createComponent({ handler, workItemsMvc2Enabled: true }); + createComponent({ handler }); await waitForPromises(); expect(findWorkItemDueDate().exists()).toBe(exists); @@ -491,7 +530,7 @@ describe('WorkItemDetail component', () => { }); it('shows an error message when it emits an `error` event', async () => { - createComponent({ workItemsMvc2Enabled: true }); + createComponent(); await waitForPromises(); const updateError = 'Failed to update'; @@ -502,6 +541,19 @@ describe('WorkItemDetail component', () => { }); }); + describe('milestone widget', () => { + it.each` + description | includeWidgets | exists + ${'renders when widget is returned from API'} | ${true} | ${true} + ${'does not render when widget is not returned from API'} | ${false} | ${false} + `('$description', async ({ includeWidgets, exists }) => { + createComponent({ includeWidgets, workItemsMvc2Enabled: true }); + await waitForPromises(); + + expect(findWorkItemMilestone().exists()).toBe(exists); + }); + }); + describe('work item information', () => { beforeEach(() => { createComponent(); diff --git a/spec/frontend/work_items/components/work_item_due_date_spec.js b/spec/frontend/work_items/components/work_item_due_date_spec.js index 1d76154a1f0..701406b9588 100644 --- a/spec/frontend/work_items/components/work_item_due_date_spec.js +++ b/spec/frontend/work_items/components/work_item_due_date_spec.js @@ -62,7 +62,7 @@ describe('WorkItemDueDate component', () => { createComponent({ canUpdate: true, startDate }); }); - it(exists ? 'renders' : 'does not render', () => { + it(`${exists ? 'renders' : 'does not render'}`, () => { expect(findStartDateButton().exists()).toBe(exists); }); }); @@ -172,7 +172,7 @@ describe('WorkItemDueDate component', () => { createComponent({ canUpdate: true, dueDate }); }); - it(exists ? 'renders' : 'does not render', () => { + it(`${exists ? 'renders' : 'does not render'}`, () => { expect(findDueDateButton().exists()).toBe(exists); }); }); diff --git a/spec/frontend/work_items/components/work_item_labels_spec.js b/spec/frontend/work_items/components/work_item_labels_spec.js index 1d976897c15..e6ff7e8502d 100644 --- a/spec/frontend/work_items/components/work_item_labels_spec.js +++ b/spec/frontend/work_items/components/work_item_labels_spec.js @@ -7,10 +7,18 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; -import { i18n } from '~/work_items/constants'; -import { temporaryConfig, resolvers } from '~/graphql_shared/issuable_client'; -import { projectLabelsResponse, mockLabels, workItemQueryResponse } from '../mock_data'; +import { i18n, I18N_WORK_ITEM_ERROR_FETCHING_LABELS } from '~/work_items/constants'; +import { + projectLabelsResponse, + mockLabels, + workItemQueryResponse, + workItemResponseFactory, + updateWorkItemMutationResponse, + workItemLabelsSubscriptionResponse, +} from '../mock_data'; Vue.use(VueApollo); @@ -21,32 +29,32 @@ describe('WorkItemLabels component', () => { const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); - const findEmptyState = () => wrapper.findByTestId('empty-state'); + const findLabelsTitle = () => wrapper.findByTestId('labels-title'); + const workItemQuerySuccess = jest.fn().mockResolvedValue(workItemQueryResponse); const successSearchQueryHandler = jest.fn().mockResolvedValue(projectLabelsResponse); + const successUpdateWorkItemMutationHandler = jest + .fn() + .mockResolvedValue(updateWorkItemMutationResponse); + const subscriptionHandler = jest.fn().mockResolvedValue(workItemLabelsSubscriptionResponse); const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); const createComponent = ({ - labels = mockLabels, canUpdate = true, + workItemQueryHandler = workItemQuerySuccess, searchQueryHandler = successSearchQueryHandler, + updateWorkItemMutationHandler = successUpdateWorkItemMutationHandler, } = {}) => { - const apolloProvider = createMockApollo([[labelSearchQuery, searchQueryHandler]], resolvers, { - typePolicies: temporaryConfig.cacheConfig.typePolicies, - }); - - apolloProvider.clients.defaultClient.writeQuery({ - query: workItemQuery, - variables: { - id: workItemId, - }, - data: workItemQueryResponse.data, - }); + const apolloProvider = createMockApollo([ + [workItemQuery, workItemQueryHandler], + [labelSearchQuery, searchQueryHandler], + [updateWorkItemMutation, updateWorkItemMutationHandler], + [workItemLabelsSubscription, subscriptionHandler], + ]); wrapper = mountExtended(WorkItemLabels, { propsData: { - labels, workItemId, canUpdate, fullPath: 'test-project-path', @@ -60,6 +68,12 @@ describe('WorkItemLabels component', () => { wrapper.destroy(); }); + it('has a label', () => { + createComponent(); + + expect(findTokenSelector().props('ariaLabelledby')).toEqual(findLabelsTitle().attributes('id')); + }); + it('focuses token selector on token selector input event', async () => { createComponent(); findTokenSelector().vm.$emit('input', [mockLabels[0]]); @@ -151,7 +165,7 @@ describe('WorkItemLabels component', () => { findTokenSelector().vm.$emit('focus'); await waitForPromises(); - expect(wrapper.emitted('error')).toEqual([[i18n.fetchError]]); + expect(wrapper.emitted('error')).toEqual([[I18N_WORK_ITEM_ERROR_FETCHING_LABELS]]); }); it('should search for with correct key after text input', async () => { @@ -163,7 +177,53 @@ describe('WorkItemLabels component', () => { await waitForPromises(); expect(successSearchQueryHandler).toHaveBeenCalledWith( - expect.objectContaining({ search: searchKey }), + expect.objectContaining({ searchTerm: searchKey }), ); }); + + describe('when clicking outside the token selector', () => { + it('calls a mutation with correct variables', () => { + createComponent(); + + findTokenSelector().vm.$emit('input', [mockLabels[0]]); + findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); + + expect(successUpdateWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { + labelsWidget: { addLabelIds: [mockLabels[0].id], removeLabelIds: [] }, + id: 'gid://gitlab/WorkItem/1', + }, + }); + }); + + it('emits an error and resets labels if mutation was rejected', async () => { + const workItemQueryHandler = jest.fn().mockResolvedValue(workItemResponseFactory()); + + createComponent({ updateWorkItemMutationHandler: errorHandler, workItemQueryHandler }); + + await waitForPromises(); + + const initialLabels = findTokenSelector().props('selectedTokens'); + + findTokenSelector().vm.$emit('input', [mockLabels[0]]); + findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); + + await waitForPromises(); + + const updatedLabels = findTokenSelector().props('selectedTokens'); + + expect(wrapper.emitted('error')).toEqual([[i18n.updateError]]); + expect(updatedLabels).toEqual(initialLabels); + }); + + it('has a subscription', async () => { + createComponent(); + + await waitForPromises(); + + expect(subscriptionHandler).toHaveBeenCalledWith({ + issuableId: workItemId, + }); + }); + }); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js index 434c1db8a2c..ab3ea623e3e 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js @@ -28,6 +28,7 @@ describe('WorkItemLinksForm', () => { listResponse = availableWorkItemsResponse, typesResponse = projectWorkItemTypesQueryResponse, parentConfidential = false, + hasIterationsFeature = false, } = {}) => { wrapper = shallowMountExtended(WorkItemLinksForm, { apolloProvider: createMockApollo([ @@ -39,6 +40,7 @@ describe('WorkItemLinksForm', () => { propsData: { issuableGid: 'gid://gitlab/WorkItem/1', parentConfidential }, provide: { projectPath: 'project/path', + hasIterationsFeature, }, }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js index 287ec022d3f..e3f3b74f296 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js @@ -10,8 +10,8 @@ describe('WorkItemLinksMenu', () => { wrapper = shallowMountExtended(WorkItemLinksMenu); }; - const findDropdown = () => wrapper.find(GlDropdown); - const findRemoveDropdownItem = () => wrapper.find(GlDropdownItem); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findRemoveDropdownItem = () => wrapper.findComponent(GlDropdownItem); beforeEach(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 index 876aedff08b..6961996f912 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js @@ -5,7 +5,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql'; +import issueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql'; import WorkItemLinks from '~/work_items/components/work_item_links/work_item_links.vue'; import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; @@ -21,16 +21,29 @@ import { Vue.use(VueApollo); -const issueConfidentialityResponse = (confidential = false) => ({ +const issueDetailsResponse = (confidential = false) => ({ data: { workspace: { - id: '1', - __typename: 'Project', + id: 'gid://gitlab/Project/1', issuable: { - __typename: 'Issue', id: 'gid://gitlab/Issue/4', confidential, + iteration: { + id: 'gid://gitlab/Iteration/1124', + title: null, + startDate: '2022-06-22', + dueDate: '2022-07-19', + webUrl: 'http://127.0.0.1:3000/groups/gitlab-org/-/iterations/1124', + iterationCadence: { + id: 'gid://gitlab/Iterations::Cadence/1101', + title: 'Quod voluptates quidem ea eaque eligendi ex corporis.', + __typename: 'IterationCadence', + }, + __typename: 'Iteration', + }, + __typename: 'Issue', }, + __typename: 'Project', }, }, }); @@ -55,14 +68,15 @@ describe('WorkItemLinks', () => { data = {}, fetchHandler = jest.fn().mockResolvedValue(workItemHierarchyResponse), mutationHandler = mutationChangeParentHandler, - confidentialQueryHandler = jest.fn().mockResolvedValue(issueConfidentialityResponse()), + issueDetailsQueryHandler = jest.fn().mockResolvedValue(issueDetailsResponse()), + hasIterationsFeature = false, } = {}) => { mockApollo = createMockApollo( [ [getWorkItemLinksQuery, fetchHandler], [changeWorkItemParentMutation, mutationHandler], [workItemQuery, childWorkItemQueryHandler], - [issueConfidentialQuery, confidentialQueryHandler], + [issueDetailsQuery, issueDetailsQueryHandler], ], {}, { addTypename: true }, @@ -77,6 +91,7 @@ describe('WorkItemLinks', () => { provide: { projectPath: 'project/path', iid: '1', + hasIterationsFeature, }, propsData: { issuableId: 1 }, apolloProvider: mockApollo, @@ -266,7 +281,7 @@ describe('WorkItemLinks', () => { describe('when parent item is confidential', () => { it('passes correct confidentiality status to form', async () => { await createComponent({ - confidentialQueryHandler: jest.fn().mockResolvedValue(issueConfidentialityResponse(true)), + issueDetailsQueryHandler: jest.fn().mockResolvedValue(issueDetailsResponse(true)), }); findToggleAddFormButton().vm.$emit('click'); await nextTick(); diff --git a/spec/frontend/work_items/components/work_item_milestone_spec.js b/spec/frontend/work_items/components/work_item_milestone_spec.js new file mode 100644 index 00000000000..08cdf62ae52 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_milestone_spec.js @@ -0,0 +1,247 @@ +import { + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + GlSkeletonLoader, + GlFormGroup, + GlDropdownText, +} from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue'; +import { resolvers, temporaryConfig } from '~/graphql_shared/issuable_client'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mockTracking } from 'helpers/tracking_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; +import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql'; +import { + projectMilestonesResponse, + projectMilestonesResponseWithNoMilestones, + mockMilestoneWidgetResponse, + workItemResponseFactory, + updateWorkItemMutationErrorResponse, +} from 'jest/work_items/mock_data'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; + +describe('WorkItemMilestone component', () => { + Vue.use(VueApollo); + + let wrapper; + + const workItemId = 'gid://gitlab/WorkItem/1'; + const workItemType = 'Task'; + const fullPath = 'full-path'; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findNoMilestoneDropdownItem = () => wrapper.findByTestId('no-milestone'); + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findFirstDropdownItem = () => findDropdownItems().at(0); + const findDropdownTexts = () => wrapper.findAllComponents(GlDropdownText); + const findDropdownItemAtIndex = (index) => findDropdownItems().at(index); + const findDisabledTextSpan = () => wrapper.findByTestId('disabled-text'); + const findDropdownTextAtIndex = (index) => findDropdownTexts().at(index); + const findInputGroup = () => wrapper.findComponent(GlFormGroup); + + const workItemQueryResponse = workItemResponseFactory({ canUpdate: true, canDelete: true }); + + const networkResolvedValue = new Error(); + + const successSearchQueryHandler = jest.fn().mockResolvedValue(projectMilestonesResponse); + const successSearchWithNoMatchingMilestones = jest + .fn() + .mockResolvedValue(projectMilestonesResponseWithNoMilestones); + + const showDropdown = () => { + findDropdown().vm.$emit('shown'); + }; + + const hideDropdown = () => { + findDropdown().vm.$emit('hide'); + }; + + const createComponent = ({ + canUpdate = true, + milestone = mockMilestoneWidgetResponse, + searchQueryHandler = successSearchQueryHandler, + } = {}) => { + const apolloProvider = createMockApollo( + [[projectMilestonesQuery, searchQueryHandler]], + resolvers, + { + typePolicies: temporaryConfig.cacheConfig.typePolicies, + }, + ); + + apolloProvider.clients.defaultClient.writeQuery({ + query: workItemQuery, + variables: { + id: workItemId, + }, + data: workItemQueryResponse.data, + }); + + wrapper = shallowMountExtended(WorkItemMilestone, { + apolloProvider, + propsData: { + canUpdate, + workItemMilestone: milestone, + workItemId, + workItemType, + fullPath, + }, + stubs: { + GlDropdown, + GlSearchBoxByType, + }, + }); + }; + + it('has "Milestone" label', () => { + createComponent(); + + expect(findInputGroup().exists()).toBe(true); + expect(findInputGroup().attributes('label')).toBe(WorkItemMilestone.i18n.MILESTONE); + }); + + describe('Default text with canUpdate false and milestone value', () => { + describe.each` + description | milestone | value + ${'when no milestone'} | ${null} | ${WorkItemMilestone.i18n.NONE} + ${'when milestone set'} | ${mockMilestoneWidgetResponse} | ${mockMilestoneWidgetResponse.title} + `('$description', ({ milestone, value }) => { + it(`has a value of "${value}"`, () => { + createComponent({ canUpdate: false, milestone }); + + expect(findDisabledTextSpan().text()).toBe(value); + expect(findDropdown().exists()).toBe(false); + }); + }); + }); + + describe('Default text value when canUpdate true and no milestone set', () => { + it(`has a value of "Add to milestone"`, () => { + createComponent({ canUpdate: true, milestone: null }); + + expect(findDropdown().props('text')).toBe(WorkItemMilestone.i18n.MILESTONE_PLACEHOLDER); + }); + }); + + describe('Dropdown search', () => { + it('has the search box', () => { + createComponent(); + + expect(findSearchBox().exists()).toBe(true); + }); + + it('shows no matching results when no items', () => { + createComponent({ + searchQueryHandler: successSearchWithNoMatchingMilestones, + }); + + expect(findDropdownTextAtIndex(0).text()).toBe(WorkItemMilestone.i18n.NO_MATCHING_RESULTS); + expect(findDropdownItems()).toHaveLength(1); + expect(findDropdownTexts()).toHaveLength(1); + }); + }); + + describe('Dropdown options', () => { + beforeEach(() => { + createComponent({ canUpdate: true }); + }); + + it('shows the skeleton loader when the items are being fetched on click', async () => { + showDropdown(); + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('shows the milestones in dropdown when the items have finished fetching', async () => { + showDropdown(); + await waitForPromises(); + + expect(findSkeletonLoader().exists()).toBe(false); + expect(findNoMilestoneDropdownItem().exists()).toBe(true); + expect(findDropdownItems()).toHaveLength( + projectMilestonesResponse.data.workspace.attributes.nodes.length + 1, + ); + }); + + it('changes the milestone to null when clicked on no milestone', async () => { + showDropdown(); + findFirstDropdownItem().vm.$emit('click'); + + hideDropdown(); + await nextTick(); + expect(findDropdown().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findDropdown().props('loading')).toBe(false); + expect(findDropdown().props('text')).toBe(WorkItemMilestone.i18n.MILESTONE_PLACEHOLDER); + }); + + it('changes the milestone to the selected milestone', async () => { + const milestoneIndex = 1; + /** the index is -1 since no matching results is also a dropdown item */ + const milestoneAtIndex = + projectMilestonesResponse.data.workspace.attributes.nodes[milestoneIndex - 1]; + showDropdown(); + + await waitForPromises(); + findDropdownItemAtIndex(milestoneIndex).vm.$emit('click'); + + hideDropdown(); + await waitForPromises(); + + expect(findDropdown().props('text')).toBe(milestoneAtIndex.title); + }); + }); + + describe('Error handlers', () => { + it.each` + errorType | expectedErrorMessage | mockValue | resolveFunction + ${'graphql error'} | ${'Something went wrong while updating the task. Please try again.'} | ${updateWorkItemMutationErrorResponse} | ${'mockResolvedValue'} + ${'network error'} | ${'Something went wrong while updating the task. Please try again.'} | ${networkResolvedValue} | ${'mockRejectedValue'} + `( + 'emits an error when there is a $errorType', + async ({ mockValue, expectedErrorMessage, resolveFunction }) => { + createComponent({ + mutationHandler: jest.fn()[resolveFunction](mockValue), + canUpdate: true, + }); + + showDropdown(); + findFirstDropdownItem().vm.$emit('click'); + hideDropdown(); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[expectedErrorMessage]]); + }, + ); + }); + + describe('Tracking event', () => { + it('tracks updating the milestone', async () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + createComponent({ canUpdate: true }); + + showDropdown(); + findFirstDropdownItem().vm.$emit('click'); + hideDropdown(); + + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_milestone', { + category: TRACKING_CATEGORY_SHOW, + label: 'item_milestone', + property: 'type_Task', + }); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_type_icon_spec.js b/spec/frontend/work_items/components/work_item_type_icon_spec.js index 95ddfc3980e..182fb0f8cb6 100644 --- a/spec/frontend/work_items/components/work_item_type_icon_spec.js +++ b/spec/frontend/work_items/components/work_item_type_icon_spec.js @@ -51,7 +51,7 @@ describe('Work Item type component', () => { }); it('renders the icon in gray color', () => { - expect(findIcon().classes()).toContain('gl-text-gray-500'); + expect(findIcon().classes()).toContain('gl-text-secondary'); }); it('shows tooltip on hover when props passed', () => { diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index e1bc8d2f6b7..ed90b11222a 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -17,6 +17,25 @@ export const mockAssignees = [ }, ]; +export const mockLabels = [ + { + __typename: 'Label', + id: 'gid://gitlab/Label/1', + title: 'Label 1', + description: '', + color: '#f00', + textColor: '#00f', + }, + { + __typename: 'Label', + id: 'gid://gitlab/Label/2', + title: 'Label::2', + description: '', + color: '#b00', + textColor: '#00b', + }, +]; + export const workItemQueryResponse = { data: { workItem: { @@ -50,6 +69,8 @@ export const workItemQueryResponse = { description: 'some **great** text', descriptionHtml: '<p data-sourcepos="1:1-1:19" dir="auto">some <strong>great</strong> text</p>', + lastEditedAt: null, + lastEditedBy: null, }, { __typename: 'WorkItemWidgetAssignees', @@ -163,9 +184,15 @@ export const workItemResponseFactory = ({ allowsMultipleAssignees = true, assigneesWidgetPresent = true, datesWidgetPresent = true, + labelsWidgetPresent = true, weightWidgetPresent = true, + milestoneWidgetPresent = true, + iterationWidgetPresent = true, confidential = false, canInviteMembers = false, + allowsScopedLabels = false, + lastEditedAt = null, + lastEditedBy = null, parent = mockParent.parent, } = {}) => ({ data: { @@ -200,6 +227,8 @@ export const workItemResponseFactory = ({ description: 'some **great** text', descriptionHtml: '<p data-sourcepos="1:1-1:19" dir="auto">some <strong>great</strong> text</p>', + lastEditedAt, + lastEditedBy, }, assigneesWidgetPresent ? { @@ -212,6 +241,16 @@ export const workItemResponseFactory = ({ }, } : { type: 'MOCK TYPE' }, + labelsWidgetPresent + ? { + __typename: 'WorkItemWidgetLabels', + type: 'LABELS', + allowsScopedLabels, + labels: { + nodes: mockLabels, + }, + } + : { type: 'MOCK TYPE' }, datesWidgetPresent ? { __typename: 'WorkItemWidgetStartAndDueDate', @@ -227,6 +266,30 @@ export const workItemResponseFactory = ({ weight: 0, } : { type: 'MOCK TYPE' }, + iterationWidgetPresent + ? { + __typename: 'WorkItemWidgetIteration', + type: 'ITERATION', + iteration: { + description: null, + id: 'gid://gitlab/Iteration/1215', + iid: '182', + title: 'Iteration default title', + startDate: '2022-09-22', + dueDate: '2022-09-30', + }, + } + : { type: 'MOCK TYPE' }, + milestoneWidgetPresent + ? { + __typename: 'WorkItemWidgetMilestone', + dueDate: null, + expired: false, + id: 'gid://gitlab/Milestone/30', + title: 'v4.0', + type: 'MILESTONE', + } + : { type: 'MOCK TYPE' }, { __typename: 'WorkItemWidgetHierarchy', type: 'HIERARCHY', @@ -331,6 +394,11 @@ export const createWorkItemFromTaskMutationResponse = { type: 'DESCRIPTION', description: 'New description', descriptionHtml: '<p>New description</p>', + lastEditedAt: '2022-09-21T06:18:42Z', + lastEditedBy: { + name: 'Administrator', + webPath: '/root', + }, }, ], }, @@ -444,6 +512,61 @@ export const workItemWeightSubscriptionResponse = { }, }; +export const workItemAssigneesSubscriptionResponse = { + data: { + issuableAssigneesUpdated: { + id: 'gid://gitlab/WorkItem/1', + widgets: [ + { + __typename: 'WorkItemAssigneesWeight', + assignees: { + nodes: [mockAssignees[0]], + }, + }, + ], + }, + }, +}; + +export const workItemLabelsSubscriptionResponse = { + data: { + issuableLabelsUpdated: { + id: 'gid://gitlab/WorkItem/1', + widgets: [ + { + __typename: 'WorkItemWidgetLabels', + type: 'LABELS', + allowsScopedLabels: false, + labels: { + nodes: mockLabels, + }, + }, + ], + }, + }, +}; + +export const workItemIterationSubscriptionResponse = { + data: { + issuableIterationUpdated: { + id: 'gid://gitlab/WorkItem/1', + widgets: [ + { + __typename: 'WorkItemWidgetIteration', + iteration: { + description: 'Iteration description', + dueDate: '2022-07-29', + id: 'gid://gitlab/Iteration/1125', + iid: '95', + startDate: '2022-06-22', + title: 'Iteration subcription title', + }, + }, + ], + }, + }, +}; + export const workItemHierarchyEmptyResponse = { data: { workItem: { @@ -857,25 +980,6 @@ export const currentUserNullResponse = { }, }; -export const mockLabels = [ - { - __typename: 'Label', - id: 'gid://gitlab/Label/1', - title: 'Label 1', - description: '', - color: '#f00', - textColor: '#00f', - }, - { - __typename: 'Label', - id: 'gid://gitlab/Label/2', - title: 'Label 2', - description: '', - color: '#b00', - textColor: '#00b', - }, -]; - export const projectLabelsResponse = { data: { workspace: { @@ -887,3 +991,134 @@ export const projectLabelsResponse = { }, }, }; + +export const mockIterationWidgetResponse = { + description: 'Iteration description', + dueDate: '2022-07-19', + id: 'gid://gitlab/Iteration/1124', + iid: '91', + startDate: '2022-06-22', + title: 'Iteration title widget', +}; + +export const groupIterationsResponse = { + data: { + workspace: { + id: 'gid://gitlab/Group/22', + attributes: { + nodes: [ + { + id: 'gid://gitlab/Iteration/1124', + title: null, + startDate: '2022-06-22', + dueDate: '2022-07-19', + webUrl: 'http://127.0.0.1:3000/groups/gitlab-org/-/iterations/1124', + iterationCadence: { + id: 'gid://gitlab/Iterations::Cadence/1101', + title: 'Quod voluptates quidem ea eaque eligendi ex corporis.', + __typename: 'IterationCadence', + }, + __typename: 'Iteration', + state: 'current', + }, + { + id: 'gid://gitlab/Iteration/1185', + title: null, + startDate: '2022-07-06', + dueDate: '2022-07-19', + webUrl: 'http://127.0.0.1:3000/groups/gitlab-org/-/iterations/1185', + iterationCadence: { + id: 'gid://gitlab/Iterations::Cadence/1144', + title: 'Quo velit perspiciatis saepe aut omnis voluptas ab eos.', + __typename: 'IterationCadence', + }, + __typename: 'Iteration', + state: 'current', + }, + { + id: 'gid://gitlab/Iteration/1194', + title: null, + startDate: '2022-07-06', + dueDate: '2022-07-19', + webUrl: 'http://127.0.0.1:3000/groups/gitlab-org/-/iterations/1194', + iterationCadence: { + id: 'gid://gitlab/Iterations::Cadence/1152', + title: + 'Minima aut consequatur magnam vero doloremque accusamus maxime repellat voluptatem qui.', + __typename: 'IterationCadence', + }, + __typename: 'Iteration', + state: 'current', + }, + ], + __typename: 'IterationConnection', + }, + __typename: 'Group', + }, + }, +}; + +export const groupIterationsResponseWithNoIterations = { + data: { + workspace: { + id: 'gid://gitlab/Group/22', + attributes: { + nodes: [], + __typename: 'IterationConnection', + }, + __typename: 'Group', + }, + }, +}; + +export const mockMilestoneWidgetResponse = { + dueDate: null, + expired: false, + id: 'gid://gitlab/Milestone/30', + title: 'v4.0', +}; + +export const projectMilestonesResponse = { + data: { + workspace: { + id: 'gid://gitlab/Project/1', + attributes: { + nodes: [ + { + id: 'gid://gitlab/Milestone/5', + title: 'v4.0', + webUrl: '/gitlab-org/gitlab-test/-/milestones/5', + dueDate: null, + expired: false, + __typename: 'Milestone', + state: 'active', + }, + { + id: 'gid://gitlab/Milestone/4', + title: 'v3.0', + webUrl: '/gitlab-org/gitlab-test/-/milestones/4', + dueDate: null, + expired: false, + __typename: 'Milestone', + state: 'active', + }, + ], + __typename: 'MilestoneConnection', + }, + __typename: 'Project', + }, + }, +}; + +export const projectMilestonesResponseWithNoMilestones = { + data: { + workspace: { + id: 'gid://gitlab/Project/1', + attributes: { + nodes: [], + __typename: 'MilestoneConnection', + }, + __typename: 'Project', + }, + }, +}; diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js index ab370e2ca8b..66a917d8052 100644 --- a/spec/frontend/work_items/router_spec.js +++ b/spec/frontend/work_items/router_spec.js @@ -4,15 +4,19 @@ import VueApollo from 'vue-apollo'; import workItemWeightSubscription from 'ee_component/work_items/graphql/work_item_weight.subscription.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import { + workItemAssigneesSubscriptionResponse, workItemDatesSubscriptionResponse, workItemResponseFactory, workItemTitleSubscriptionResponse, workItemWeightSubscriptionResponse, + workItemLabelsSubscriptionResponse, } from 'jest/work_items/mock_data'; import App from '~/work_items/components/app.vue'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import workItemDatesSubscription from '~/work_items/graphql/work_item_dates.subscription.graphql'; import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql'; +import workItemAssigneesSubscription from '~/work_items/graphql/work_item_assignees.subscription.graphql'; +import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql'; import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; import WorkItemsRoot from '~/work_items/pages/work_item_root.vue'; import { createRouter } from '~/work_items/router'; @@ -26,6 +30,10 @@ describe('Work items router', () => { const datesSubscriptionHandler = jest.fn().mockResolvedValue(workItemDatesSubscriptionResponse); const titleSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse); const weightSubscriptionHandler = jest.fn().mockResolvedValue(workItemWeightSubscriptionResponse); + const assigneesSubscriptionHandler = jest + .fn() + .mockResolvedValue(workItemAssigneesSubscriptionResponse); + const labelsSubscriptionHandler = jest.fn().mockResolvedValue(workItemLabelsSubscriptionResponse); const createComponent = async (routeArg) => { const router = createRouter('/work_item'); @@ -37,6 +45,8 @@ describe('Work items router', () => { [workItemQuery, workItemQueryHandler], [workItemDatesSubscription, datesSubscriptionHandler], [workItemTitleSubscription, titleSubscriptionHandler], + [workItemAssigneesSubscription, assigneesSubscriptionHandler], + [workItemLabelsSubscription, labelsSubscriptionHandler], ]; if (IS_EE) { diff --git a/spec/frontend/work_items_hierarchy/components/app_spec.js b/spec/frontend/work_items_hierarchy/components/app_spec.js index 1426fbfab80..124ff5f1608 100644 --- a/spec/frontend/work_items_hierarchy/components/app_spec.js +++ b/spec/frontend/work_items_hierarchy/components/app_spec.js @@ -32,7 +32,7 @@ describe('WorkItemsHierarchy App', () => { it('shows when the banner is visible', () => { createComponent({}, { bannerVisible: true }); - expect(wrapper.find(GlBanner).exists()).toBe(true); + expect(wrapper.findComponent(GlBanner).exists()).toBe(true); }); it('hide when close is called', async () => { @@ -42,7 +42,7 @@ describe('WorkItemsHierarchy App', () => { await nextTick(); - expect(wrapper.find(GlBanner).exists()).toBe(false); + expect(wrapper.findComponent(GlBanner).exists()).toBe(false); }); }); diff --git a/spec/frontend/work_items_hierarchy/components/hierarchy_spec.js b/spec/frontend/work_items_hierarchy/components/hierarchy_spec.js index dca016dc317..084aaa754ab 100644 --- a/spec/frontend/work_items_hierarchy/components/hierarchy_spec.js +++ b/spec/frontend/work_items_hierarchy/components/hierarchy_spec.js @@ -57,7 +57,7 @@ describe('WorkItemsHierarchy Hierarchy', () => { }); it('does not render badges', () => { - expect(wrapper.find(GlBadge).exists()).toBe(false); + expect(wrapper.findComponent(GlBadge).exists()).toBe(false); }); }); |