diff options
Diffstat (limited to 'spec/frontend')
489 files changed, 9850 insertions, 7051 deletions
diff --git a/spec/frontend/.eslintrc.yml b/spec/frontend/.eslintrc.yml index e12c4e5e820..45639f4c948 100644 --- a/spec/frontend/.eslintrc.yml +++ b/spec/frontend/.eslintrc.yml @@ -11,9 +11,6 @@ settings: import/resolver: jest: jestConfigFile: 'jest.config.js' -globals: - loadFixtures: false - setFixtures: false rules: jest/expect-expect: - off @@ -21,8 +18,6 @@ rules: - 'expect*' - 'assert*' - 'testAction' - jest/no-test-callback: - - off "@gitlab/no-global-event-off": - off import/no-unresolved: diff --git a/spec/frontend/__helpers__/dom_shims/clipboard.js b/spec/frontend/__helpers__/dom_shims/clipboard.js new file mode 100644 index 00000000000..3bc1b059272 --- /dev/null +++ b/spec/frontend/__helpers__/dom_shims/clipboard.js @@ -0,0 +1,5 @@ +Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: () => {}, + }, +}); diff --git a/spec/frontend/__helpers__/dom_shims/index.js b/spec/frontend/__helpers__/dom_shims/index.js index 9b70cb86b8b..742d55196b4 100644 --- a/spec/frontend/__helpers__/dom_shims/index.js +++ b/spec/frontend/__helpers__/dom_shims/index.js @@ -1,3 +1,4 @@ +import './clipboard'; import './create_object_url'; import './element_scroll_into_view'; import './element_scroll_by'; diff --git a/spec/frontend/__helpers__/fixtures.js b/spec/frontend/__helpers__/fixtures.js index d8054d32fae..a6f7b37161e 100644 --- a/spec/frontend/__helpers__/fixtures.js +++ b/spec/frontend/__helpers__/fixtures.js @@ -20,24 +20,15 @@ Did you run bin/rake frontend:fixtures?`, return fs.readFileSync(absolutePath, 'utf8'); } -/** - * @deprecated Use `import` to load a JSON fixture instead. - * See https://docs.gitlab.com/ee/development/testing_guide/frontend_testing.html#use-fixtures, - * https://gitlab.com/gitlab-org/gitlab/-/issues/339346. - */ -export const getJSONFixture = (relativePath) => JSON.parse(getFixture(relativePath)); - export const resetHTMLFixture = () => { document.head.innerHTML = ''; document.body.innerHTML = ''; }; -export const setHTMLFixture = (htmlContent, resetHook = afterEach) => { +export const setHTMLFixture = (htmlContent) => { document.body.innerHTML = htmlContent; - resetHook(resetHTMLFixture); }; -export const loadHTMLFixture = (relativePath, resetHook = afterEach) => { - const fileContent = getFixture(relativePath); - setHTMLFixture(fileContent, resetHook); +export const loadHTMLFixture = (relativePath) => { + setHTMLFixture(getFixture(relativePath)); }; diff --git a/spec/frontend/__helpers__/flush_promises.js b/spec/frontend/__helpers__/flush_promises.js deleted file mode 100644 index eefc2ed7c17..00000000000 --- a/spec/frontend/__helpers__/flush_promises.js +++ /dev/null @@ -1,4 +0,0 @@ -export default function flushPromises() { - // eslint-disable-next-line no-restricted-syntax - return new Promise(setImmediate); -} diff --git a/spec/frontend/__helpers__/shared_test_setup.js b/spec/frontend/__helpers__/shared_test_setup.js index 7b5df18ee0f..011e1142c76 100644 --- a/spec/frontend/__helpers__/shared_test_setup.js +++ b/spec/frontend/__helpers__/shared_test_setup.js @@ -6,7 +6,6 @@ import 'jquery'; import Translate from '~/vue_shared/translate'; import setWindowLocation from './set_window_location_helper'; import { setGlobalDateToFakeDate } from './fake_date'; -import { loadHTMLFixture, setHTMLFixture } from './fixtures'; import { TEST_HOST } from './test_constants'; import * as customMatchers from './matchers'; @@ -28,12 +27,6 @@ Vue.config.productionTip = false; Vue.use(Translate); -// convenience wrapper for migration from Karma -Object.assign(global, { - loadFixtures: loadHTMLFixture, - setFixtures: setHTMLFixture, -}); - const JQUERY_MATCHERS_TO_EXCLUDE = ['toHaveLength', 'toExist']; // custom-jquery-matchers was written for an old Jest version, we need to make it compatible diff --git a/spec/frontend/__helpers__/user_mock_data_helper.js b/spec/frontend/__helpers__/user_mock_data_helper.js index db747283d9e..29ce95a88e2 100644 --- a/spec/frontend/__helpers__/user_mock_data_helper.js +++ b/spec/frontend/__helpers__/user_mock_data_helper.js @@ -15,7 +15,7 @@ export default { id: id + 1, name: getRandomString(), username: getRandomString(), - user_path: getRandomUrl(), + web_url: getRandomUrl(), }); id += 1; diff --git a/spec/frontend/__helpers__/vuex_action_helper.js b/spec/frontend/__helpers__/vuex_action_helper.js index 95a811d0385..ab2637d6024 100644 --- a/spec/frontend/__helpers__/vuex_action_helper.js +++ b/spec/frontend/__helpers__/vuex_action_helper.js @@ -1,5 +1,3 @@ -const noop = () => {}; - /** * Helper for testing action with expected mutations inspired in * https://vuex.vuejs.org/en/testing.html @@ -9,7 +7,6 @@ const noop = () => {}; * @param {Object} state will be provided to the action * @param {Array} [expectedMutations=[]] mutations expected to be committed * @param {Array} [expectedActions=[]] actions expected to be dispatched - * @param {Function} [done=noop] to be executed after the tests * @return {Promise} * * @example @@ -27,20 +24,9 @@ const noop = () => {}; * { type: 'actionName', payload: {param: 'foobar'}}, * { type: 'actionName1'} * ] - * done, * ); * * @example - * testAction( - * actions.actionName, // action - * { }, // mocked payload - * state, //state - * [ { type: types.MUTATION} ], // expected mutations - * [], // expected actions - * ).then(done) - * .catch(done.fail); - * - * @example * await testAction({ * action: actions.actionName, * payload: { deleteListId: 1 }, @@ -56,24 +42,15 @@ export default ( stateArg, expectedMutationsArg = [], expectedActionsArg = [], - doneArg = noop, ) => { let action = actionArg; let payload = payloadArg; let state = stateArg; let expectedMutations = expectedMutationsArg; let expectedActions = expectedActionsArg; - let done = doneArg; if (typeof actionArg !== 'function') { - ({ - action, - payload, - state, - expectedMutations = [], - expectedActions = [], - done = noop, - } = actionArg); + ({ action, payload, state, expectedMutations = [], expectedActions = [] } = actionArg); } const mutations = []; @@ -109,7 +86,6 @@ export default ( mutations: expectedMutations, actions: expectedActions, }); - done(); }; const result = action( @@ -117,8 +93,13 @@ export default ( payload, ); - // eslint-disable-next-line no-restricted-syntax - return (result || new Promise((resolve) => setImmediate(resolve))) + return ( + result || + new Promise((resolve) => { + // eslint-disable-next-line no-restricted-syntax + setImmediate(resolve); + }) + ) .catch((error) => { validateResults(); throw error; diff --git a/spec/frontend/__helpers__/vuex_action_helper_spec.js b/spec/frontend/__helpers__/vuex_action_helper_spec.js index b4f5a291774..5bb2b3b26e2 100644 --- a/spec/frontend/__helpers__/vuex_action_helper_spec.js +++ b/spec/frontend/__helpers__/vuex_action_helper_spec.js @@ -4,8 +4,8 @@ import axios from '~/lib/utils/axios_utils'; import testActionFn from './vuex_action_helper'; const testActionFnWithOptionsArg = (...args) => { - const [action, payload, state, expectedMutations, expectedActions, done] = args; - return testActionFn({ action, payload, state, expectedMutations, expectedActions, done }); + const [action, payload, state, expectedMutations, expectedActions] = args; + return testActionFn({ action, payload, state, expectedMutations, expectedActions }); }; describe.each([testActionFn, testActionFnWithOptionsArg])( @@ -14,7 +14,6 @@ describe.each([testActionFn, testActionFnWithOptionsArg])( let originalExpect; let assertion; let mock; - const noop = () => {}; beforeEach(() => { mock = new MockAdapter(axios); @@ -48,7 +47,7 @@ describe.each([testActionFn, testActionFnWithOptionsArg])( assertion = { mutations: [], actions: [] }; - testAction(action, examplePayload, exampleState); + return testAction(action, examplePayload, exampleState); }); describe('given a sync action', () => { @@ -59,7 +58,7 @@ describe.each([testActionFn, testActionFnWithOptionsArg])( assertion = { mutations: [{ type: 'MUTATION' }], actions: [] }; - testAction(action, null, {}, assertion.mutations, assertion.actions, noop); + return testAction(action, null, {}, assertion.mutations, assertion.actions); }); it('mocks dispatching actions', () => { @@ -69,26 +68,21 @@ describe.each([testActionFn, testActionFnWithOptionsArg])( assertion = { actions: [{ type: 'ACTION' }], mutations: [] }; - testAction(action, null, {}, assertion.mutations, assertion.actions, noop); + return testAction(action, null, {}, assertion.mutations, assertion.actions); }); - it('works with done callback once finished', (done) => { + it('returns a promise', () => { assertion = { mutations: [], actions: [] }; - testAction(noop, null, {}, assertion.mutations, assertion.actions, done); - }); + const promise = testAction(() => {}, null, {}, assertion.mutations, assertion.actions); - it('returns a promise', (done) => { - assertion = { mutations: [], actions: [] }; + originalExpect(promise instanceof Promise).toBeTruthy(); - testAction(noop, null, {}, assertion.mutations, assertion.actions) - .then(done) - .catch(done.fail); + return promise; }); }); describe('given an async action (returning a promise)', () => { - let lastError; const data = { FOO: 'BAR' }; const asyncAction = ({ commit, dispatch }) => { @@ -98,7 +92,6 @@ describe.each([testActionFn, testActionFnWithOptionsArg])( .get(TEST_HOST) .catch((error) => { commit('ERROR'); - lastError = error; throw error; }) .then(() => { @@ -107,46 +100,26 @@ describe.each([testActionFn, testActionFnWithOptionsArg])( }); }; - beforeEach(() => { - lastError = null; - }); - - it('works with done callback once finished', (done) => { + it('returns original data of successful promise while checking actions/mutations', async () => { mock.onGet(TEST_HOST).replyOnce(200, 42); assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] }; - testAction(asyncAction, null, {}, assertion.mutations, assertion.actions, done); + const res = await testAction(asyncAction, null, {}, assertion.mutations, assertion.actions); + originalExpect(res).toEqual(data); }); - it('returns original data of successful promise while checking actions/mutations', (done) => { - mock.onGet(TEST_HOST).replyOnce(200, 42); - - assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] }; - - testAction(asyncAction, null, {}, assertion.mutations, assertion.actions) - .then((res) => { - originalExpect(res).toEqual(data); - done(); - }) - .catch(done.fail); - }); - - it('returns original error of rejected promise while checking actions/mutations', (done) => { + it('returns original error of rejected promise while checking actions/mutations', async () => { mock.onGet(TEST_HOST).replyOnce(500, ''); assertion = { mutations: [{ type: 'ERROR' }], actions: [{ type: 'ACTION' }] }; - testAction(asyncAction, null, {}, assertion.mutations, assertion.actions) - .then(done.fail) - .catch((error) => { - originalExpect(error).toBe(lastError); - done(); - }); + const err = testAction(asyncAction, null, {}, assertion.mutations, assertion.actions); + await originalExpect(err).rejects.toEqual(new Error('Request failed with status code 500')); }); }); - it('works with async actions not returning promises', (done) => { + it('works with actions not returning promises', () => { const data = { FOO: 'BAR' }; const asyncAction = ({ commit, dispatch }) => { @@ -168,7 +141,7 @@ describe.each([testActionFn, testActionFnWithOptionsArg])( assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] }; - testAction(asyncAction, null, {}, assertion.mutations, assertion.actions, done); + return testAction(asyncAction, null, {}, assertion.mutations, assertion.actions); }); }, ); diff --git a/spec/frontend/__helpers__/wait_for_promises.js b/spec/frontend/__helpers__/wait_for_promises.js index 2fd1cc6ba0d..753c3c5d92b 100644 --- a/spec/frontend/__helpers__/wait_for_promises.js +++ b/spec/frontend/__helpers__/wait_for_promises.js @@ -1 +1,4 @@ -export default () => new Promise((resolve) => requestAnimationFrame(resolve)); +export default () => + new Promise((resolve) => { + requestAnimationFrame(resolve); + }); diff --git a/spec/frontend/activities_spec.js b/spec/frontend/activities_spec.js index 00519148b30..ebace21217a 100644 --- a/spec/frontend/activities_spec.js +++ b/spec/frontend/activities_spec.js @@ -1,6 +1,7 @@ /* eslint-disable no-unused-expressions, no-prototype-builtins, no-new, no-shadow */ import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import Activities from '~/activities'; import Pager from '~/pager'; @@ -38,11 +39,15 @@ describe('Activities', () => { } beforeEach(() => { - loadFixtures(fixtureTemplate); + loadHTMLFixture(fixtureTemplate); jest.spyOn(Pager, 'init').mockImplementation(() => {}); new Activities(); }); + afterEach(() => { + resetHTMLFixture(); + }); + for (let i = 0; i < filters.length; i += 1) { ((i) => { describe(`when selecting ${getEventName(i)}`, () => { diff --git a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap index 82114077455..3fdbacb6efa 100644 --- a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap +++ b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap @@ -2,6 +2,7 @@ exports[`AddContextCommitsModal renders modal with 2 tabs 1`] = ` <gl-modal-stub + arialabel="" body-class="add-review-item pt-0" cancel-variant="light" dismisslabel="Close" diff --git a/spec/frontend/admin/applications/components/delete_application_spec.js b/spec/frontend/admin/applications/components/delete_application_spec.js index 20119b64952..1a400a101b5 100644 --- a/spec/frontend/admin/applications/components/delete_application_spec.js +++ b/spec/frontend/admin/applications/components/delete_application_spec.js @@ -1,5 +1,6 @@ import { GlModal, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import DeleteApplication from '~/admin/applications/components/delete_application.vue'; const path = 'application/path/1'; @@ -22,7 +23,7 @@ describe('DeleteApplication', () => { const findForm = () => wrapper.find('form'); beforeEach(() => { - setFixtures(` + setHTMLFixture(` <button class="js-application-delete-button" data-path="${path}" data-name="${name}">Destroy</button> `); @@ -31,6 +32,7 @@ describe('DeleteApplication', () => { afterEach(() => { wrapper.destroy(); + resetHTMLFixture(); }); describe('the modal component', () => { diff --git a/spec/frontend/admin/background_migrations/components/database_listbox_spec.js b/spec/frontend/admin/background_migrations/components/database_listbox_spec.js new file mode 100644 index 00000000000..3778943872e --- /dev/null +++ b/spec/frontend/admin/background_migrations/components/database_listbox_spec.js @@ -0,0 +1,57 @@ +import { GlListbox } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import BackgroundMigrationsDatabaseListbox from '~/admin/background_migrations/components/database_listbox.vue'; +import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; +import { MOCK_DATABASES, MOCK_SELECTED_DATABASE } from '../mock_data'; + +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn(), + setUrlParams: jest.fn(), +})); + +describe('BackgroundMigrationsDatabaseListbox', () => { + let wrapper; + + const defaultProps = { + databases: MOCK_DATABASES, + selectedDatabase: MOCK_SELECTED_DATABASE, + }; + + const createComponent = (props = {}) => { + wrapper = shallowMount(BackgroundMigrationsDatabaseListbox, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findGlListbox = () => wrapper.findComponent(GlListbox); + + describe('template always', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders GlListbox', () => { + expect(findGlListbox().exists()).toBe(true); + }); + }); + + describe('actions', () => { + beforeEach(() => { + createComponent(); + }); + + it('selecting a listbox item fires visitUrl with the database param', () => { + findGlListbox().vm.$emit('select', MOCK_DATABASES[1].value); + + expect(setUrlParams).toHaveBeenCalledWith({ database: MOCK_DATABASES[1].value }); + expect(visitUrl).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/admin/background_migrations/mock_data.js b/spec/frontend/admin/background_migrations/mock_data.js new file mode 100644 index 00000000000..fbb1718f6b8 --- /dev/null +++ b/spec/frontend/admin/background_migrations/mock_data.js @@ -0,0 +1,6 @@ +export const MOCK_DATABASES = [ + { value: 'main', text: 'main' }, + { value: 'ci', text: 'ci' }, +]; + +export const MOCK_SELECTED_DATABASE = 'main'; diff --git a/spec/frontend/admin/users/new_spec.js b/spec/frontend/admin/users/new_spec.js index 692c583dca8..5e5763822a8 100644 --- a/spec/frontend/admin/users/new_spec.js +++ b/spec/frontend/admin/users/new_spec.js @@ -4,6 +4,7 @@ import { ID_USER_EXTERNAL, ID_WARNING, } from '~/admin/users/new'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; describe('admin/users/new', () => { const FIXTURE = 'admin/users/new_with_internal_user_regex.html'; @@ -13,7 +14,7 @@ describe('admin/users/new', () => { let elWarningMessage; beforeEach(() => { - loadFixtures(FIXTURE); + loadHTMLFixture(FIXTURE); setupInternalUserRegexHandler(); elExternal = document.getElementById(ID_USER_EXTERNAL); @@ -23,6 +24,10 @@ describe('admin/users/new', () => { elExternal.checked = true; }); + afterEach(() => { + resetHTMLFixture(); + }); + const changeEmail = (val) => { elUserEmail.value = val; elUserEmail.dispatchEvent(new Event('input')); diff --git a/spec/frontend/alert_handler_spec.js b/spec/frontend/alert_handler_spec.js index 228053b1b2b..ba8e5bcb202 100644 --- a/spec/frontend/alert_handler_spec.js +++ b/spec/frontend/alert_handler_spec.js @@ -1,4 +1,4 @@ -import { setHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import initAlertHandler from '~/alert_handler'; describe('Alert Handler', () => { @@ -25,6 +25,10 @@ describe('Alert Handler', () => { initAlertHandler(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('should render the alert', () => { expect(findFirstAlert()).not.toBe(null); }); @@ -41,6 +45,10 @@ describe('Alert Handler', () => { initAlertHandler(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('should render two alerts', () => { expect(findAllAlerts()).toHaveLength(2); }); @@ -57,6 +65,10 @@ describe('Alert Handler', () => { initAlertHandler(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('should render the banner', () => { expect(findFirstBanner()).not.toBe(null); }); @@ -78,6 +90,10 @@ describe('Alert Handler', () => { initAlertHandler(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('should render the banner', () => { expect(findFirstAlert()).not.toBe(null); }); diff --git a/spec/frontend/analytics/shared/components/metric_popover_spec.js b/spec/frontend/analytics/shared/components/metric_popover_spec.js index b799c911488..ffec77c2708 100644 --- a/spec/frontend/analytics/shared/components/metric_popover_spec.js +++ b/spec/frontend/analytics/shared/components/metric_popover_spec.js @@ -6,7 +6,7 @@ const MOCK_METRIC = { key: 'deployment-frequency', label: 'Deployment Frequency', value: '10.0', - unit: 'per day', + unit: '/day', description: 'Average number of deployments to production per day.', links: [], }; diff --git a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js index 386fb4eb616..69918c1db65 100644 --- a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js +++ b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlTruncate } from '@gitlab/ui'; import { nextTick } from 'vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { stubComponent } from 'helpers/stub_component'; @@ -76,8 +76,8 @@ describe('ProjectsDropdownFilter component', () => { const findHighlightedItems = () => wrapper.findByTestId('vsa-highlighted-items'); const findUnhighlightedItems = () => wrapper.findByTestId('vsa-default-items'); - const findHighlightedItemsTitle = () => wrapper.findByText('Selected'); const findClearAllButton = () => wrapper.findByText('Clear all'); + const findSelectedProjectsLabel = () => wrapper.findComponent(GlTruncate); const findDropdown = () => wrapper.find(GlDropdown); @@ -158,8 +158,8 @@ describe('ProjectsDropdownFilter component', () => { expect(findSelectedDropdownItems().length).toBe(0); }); - it('does not render the highlighted items title', () => { - expect(findHighlightedItemsTitle().exists()).toBe(false); + it('renders the default project label text', () => { + expect(findSelectedProjectsLabel().text()).toBe('Select projects'); }); it('does not render the clear all button', () => { @@ -180,7 +180,7 @@ describe('ProjectsDropdownFilter component', () => { }); it('renders the highlighted items title', () => { - expect(findHighlightedItemsTitle().exists()).toBe(true); + expect(findSelectedProjectsLabel().text()).toBe(projects[0].name); }); it('renders the clear all button', () => { @@ -190,13 +190,12 @@ describe('ProjectsDropdownFilter component', () => { it('clears all selected items when the clear all button is clicked', async () => { await selectDropdownItemAtIndex(1); - expect(wrapper.text()).toContain('2 projects selected'); + expect(findSelectedProjectsLabel().text()).toBe('2 projects selected'); findClearAllButton().trigger('click'); await nextTick(); - expect(wrapper.text()).not.toContain('2 projects selected'); - expect(wrapper.text()).toContain('Select projects'); + expect(findSelectedProjectsLabel().text()).toBe('Select projects'); }); }); }); diff --git a/spec/frontend/api/tags_api_spec.js b/spec/frontend/api/tags_api_spec.js new file mode 100644 index 00000000000..a7436bf6a50 --- /dev/null +++ b/spec/frontend/api/tags_api_spec.js @@ -0,0 +1,37 @@ +import MockAdapter from 'axios-mock-adapter'; +import * as tagsApi from '~/api/tags_api'; +import axios from '~/lib/utils/axios_utils'; +import httpStatus from '~/lib/utils/http_status'; + +describe('~/api/tags_api.js', () => { + let mock; + let originalGon; + + const projectId = 1; + + beforeEach(() => { + mock = new MockAdapter(axios); + + originalGon = window.gon; + window.gon = { api_version: 'v7' }; + }); + + afterEach(() => { + mock.restore(); + window.gon = originalGon; + }); + + describe('getTag', () => { + it('fetches a tag of a given tag name of a particular project', () => { + const tagName = 'tag-name'; + const expectedUrl = `/api/v7/projects/${projectId}/repository/tags/${tagName}`; + mock.onGet(expectedUrl).reply(httpStatus.OK, { + name: tagName, + }); + + return tagsApi.getTag(projectId, tagName).then(({ data }) => { + expect(data.name).toBe(tagName); + }); + }); + }); +}); diff --git a/spec/frontend/api/user_api_spec.js b/spec/frontend/api/user_api_spec.js new file mode 100644 index 00000000000..ee7194bdf5f --- /dev/null +++ b/spec/frontend/api/user_api_spec.js @@ -0,0 +1,50 @@ +import MockAdapter from 'axios-mock-adapter'; + +import { followUser, unfollowUser } from '~/api/user_api'; +import axios from '~/lib/utils/axios_utils'; + +describe('~/api/user_api', () => { + let axiosMock; + let originalGon; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + + originalGon = window.gon; + window.gon = { api_version: 'v4' }; + }); + + afterEach(() => { + axiosMock.restore(); + axiosMock.resetHistory(); + window.gon = originalGon; + }); + + describe('followUser', () => { + it('calls correct URL and returns expected response', async () => { + const expectedUrl = '/api/v4/users/1/follow'; + const expectedResponse = { message: 'Success' }; + + axiosMock.onPost(expectedUrl).replyOnce(200, expectedResponse); + + await expect(followUser(1)).resolves.toEqual( + expect.objectContaining({ data: expectedResponse }), + ); + expect(axiosMock.history.post[0].url).toBe(expectedUrl); + }); + }); + + describe('unfollowUser', () => { + it('calls correct URL and returns expected response', async () => { + const expectedUrl = '/api/v4/users/1/unfollow'; + const expectedResponse = { message: 'Success' }; + + axiosMock.onPost(expectedUrl).replyOnce(200, expectedResponse); + + await expect(unfollowUser(1)).resolves.toEqual( + expect.objectContaining({ data: expectedResponse }), + ); + expect(axiosMock.history.post[0].url).toBe(expectedUrl); + }); + }); +}); diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index 85332bf21d8..5f162f498c4 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -1593,6 +1593,38 @@ describe('Api', () => { }); }); + describe('uploadProjectSecureFile', () => { + it('uploads a secure file to a project', async () => { + const projectId = 1; + const secureFile = { + id: projectId, + title: 'File Name', + permissions: 'read_only', + checksum: '12345', + checksum_algorithm: 'sha256', + created_at: '2022-02-21T15:27:18', + }; + + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/secure_files`; + mock.onPost(expectedUrl).reply(httpStatus.OK, secureFile); + const { data } = await Api.uploadProjectSecureFile(projectId, 'some data'); + + expect(data).toEqual(secureFile); + }); + }); + + describe('deleteProjectSecureFile', () => { + it('removes a secure file from a project', async () => { + const projectId = 1; + const secureFileId = 2; + + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/secure_files/${secureFileId}`; + mock.onDelete(expectedUrl).reply(httpStatus.NO_CONTENT, ''); + const { data } = await Api.deleteProjectSecureFile(projectId, secureFileId); + expect(data).toEqual(''); + }); + }); + describe('dependency proxy cache', () => { it('schedules the cache list for deletion', async () => { const groupId = 1; diff --git a/spec/frontend/attention_requests/components/navigation_popover_spec.js b/spec/frontend/attention_requests/components/navigation_popover_spec.js index d0231afbdc4..e4d53d5dbdb 100644 --- a/spec/frontend/attention_requests/components/navigation_popover_spec.js +++ b/spec/frontend/attention_requests/components/navigation_popover_spec.js @@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import { GlPopover, GlButton, GlSprintf, GlIcon } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import NavigationPopover from '~/attention_requests/components/navigation_popover.vue'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; let wrapper; @@ -29,13 +30,14 @@ function createComponent(provideData = {}, shouldShowCallout = true) { describe('Attention requests navigation popover', () => { beforeEach(() => { - setFixtures('<div><div class="js-test-popover"></div><div class="js-test"></div></div>'); + setHTMLFixture('<div><div class="js-test-popover"></div><div class="js-test"></div></div>'); dismiss = jest.fn(); }); afterEach(() => { wrapper.destroy(); wrapper = null; + resetHTMLFixture(); }); it('hides popover if callout is disabled', () => { diff --git a/spec/frontend/authentication/u2f/authenticate_spec.js b/spec/frontend/authentication/u2f/authenticate_spec.js index 31782899ce4..3ae7fcf1c49 100644 --- a/spec/frontend/authentication/u2f/authenticate_spec.js +++ b/spec/frontend/authentication/u2f/authenticate_spec.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import U2FAuthenticate from '~/authentication/u2f/authenticate'; import 'vendor/u2f'; import MockU2FDevice from './mock_u2f_device'; @@ -9,7 +10,7 @@ describe('U2FAuthenticate', () => { let component; beforeEach(() => { - loadFixtures('u2f/authenticate.html'); + loadHTMLFixture('u2f/authenticate.html'); u2fDevice = new MockU2FDevice(); container = $('#js-authenticate-token-2fa'); component = new U2FAuthenticate( @@ -23,6 +24,10 @@ describe('U2FAuthenticate', () => { ); }); + afterEach(() => { + resetHTMLFixture(); + }); + describe('with u2f unavailable', () => { let oldu2f; diff --git a/spec/frontend/authentication/u2f/register_spec.js b/spec/frontend/authentication/u2f/register_spec.js index 810396aa9fd..7ae3a2734cb 100644 --- a/spec/frontend/authentication/u2f/register_spec.js +++ b/spec/frontend/authentication/u2f/register_spec.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import U2FRegister from '~/authentication/u2f/register'; import 'vendor/u2f'; import MockU2FDevice from './mock_u2f_device'; @@ -9,13 +10,17 @@ describe('U2FRegister', () => { let component; beforeEach(() => { - loadFixtures('u2f/register.html'); + loadHTMLFixture('u2f/register.html'); u2fDevice = new MockU2FDevice(); container = $('#js-register-token-2fa'); component = new U2FRegister(container, {}); return component.start(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('allows registering a U2F device', () => { const setupButton = container.find('#js-setup-token-2fa-device'); diff --git a/spec/frontend/authentication/webauthn/authenticate_spec.js b/spec/frontend/authentication/webauthn/authenticate_spec.js index 8b27560bbbe..b1f4e43e56d 100644 --- a/spec/frontend/authentication/webauthn/authenticate_spec.js +++ b/spec/frontend/authentication/webauthn/authenticate_spec.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; import WebAuthnAuthenticate from '~/authentication/webauthn/authenticate'; import MockWebAuthnDevice from './mock_webauthn_device'; @@ -34,7 +35,7 @@ describe('WebAuthnAuthenticate', () => { }; beforeEach(() => { - loadFixtures('webauthn/authenticate.html'); + loadHTMLFixture('webauthn/authenticate.html'); fallbackElement = document.createElement('div'); fallbackElement.classList.add('js-2fa-form'); webAuthnDevice = new MockWebAuthnDevice(); @@ -62,6 +63,10 @@ describe('WebAuthnAuthenticate', () => { submitSpy = jest.spyOn(HTMLFormElement.prototype, 'submit'); }); + afterEach(() => { + resetHTMLFixture(); + }); + describe('with webauthn unavailable', () => { let oldGetCredentials; diff --git a/spec/frontend/authentication/webauthn/register_spec.js b/spec/frontend/authentication/webauthn/register_spec.js index 0f8ea2b635f..95cb993fc70 100644 --- a/spec/frontend/authentication/webauthn/register_spec.js +++ b/spec/frontend/authentication/webauthn/register_spec.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import setWindowLocation from 'helpers/set_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; import WebAuthnRegister from '~/authentication/webauthn/register'; @@ -23,7 +24,7 @@ describe('WebAuthnRegister', () => { let component; beforeEach(() => { - loadFixtures('webauthn/register.html'); + loadHTMLFixture('webauthn/register.html'); webAuthnDevice = new MockWebAuthnDevice(); container = $('#js-register-token-2fa'); component = new WebAuthnRegister(container, { @@ -41,6 +42,10 @@ describe('WebAuthnRegister', () => { component.start(); }); + afterEach(() => { + resetHTMLFixture(); + }); + const findSetupButton = () => container.find('#js-setup-token-2fa-device'); const findMessage = () => container.find('p'); const findDeviceResponse = () => container.find('#js-device-response'); diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js index c4002ec11f3..5d657745615 100644 --- a/spec/frontend/awards_handler_spec.js +++ b/spec/frontend/awards_handler_spec.js @@ -1,5 +1,6 @@ import $ from 'jquery'; -import Cookies from 'js-cookie'; +import Cookies from '~/lib/utils/cookies'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { initEmojiMock, clearEmojiMock } from 'helpers/emoji'; import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; import loadAwardsHandler from '~/awards_handler'; @@ -75,7 +76,7 @@ describe('AwardsHandler', () => { beforeEach(async () => { await initEmojiMock(emojiData); - loadFixtures('snippets/show.html'); + loadHTMLFixture('snippets/show.html'); awardsHandler = await loadAwardsHandler(true); jest.spyOn(awardsHandler, 'postEmoji').mockImplementation((button, url, emoji, cb) => cb()); @@ -91,6 +92,8 @@ describe('AwardsHandler', () => { $('body').removeAttr('data-page'); awardsHandler.destroy(); + + resetHTMLFixture(); }); describe('::showEmojiMenu', () => { diff --git a/spec/frontend/badges/components/badge_form_spec.js b/spec/frontend/badges/components/badge_form_spec.js index ba2ec775b61..6d8a00eb50b 100644 --- a/spec/frontend/badges/components/badge_form_spec.js +++ b/spec/frontend/badges/components/badge_form_spec.js @@ -1,5 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; 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'; @@ -16,7 +17,7 @@ describe('BadgeForm component', () => { let vm; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <div id="dummy-element"></div> `); @@ -26,6 +27,7 @@ describe('BadgeForm component', () => { afterEach(() => { vm.$destroy(); axiosMock.restore(); + resetHTMLFixture(); }); describe('methods', () => { diff --git a/spec/frontend/badges/components/badge_list_row_spec.js b/spec/frontend/badges/components/badge_list_row_spec.js index 0fb0fa86a02..ad8426f3168 100644 --- a/spec/frontend/badges/components/badge_list_row_spec.js +++ b/spec/frontend/badges/components/badge_list_row_spec.js @@ -1,4 +1,5 @@ import Vue, { nextTick } from 'vue'; +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'; @@ -11,7 +12,7 @@ describe('BadgeListRow component', () => { let vm; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <div id="delete-badge-modal" class="modal"></div> <div id="dummy-element"></div> `); @@ -29,6 +30,7 @@ describe('BadgeListRow component', () => { afterEach(() => { vm.$destroy(); + resetHTMLFixture(); }); it('renders the badge', () => { diff --git a/spec/frontend/badges/components/badge_list_spec.js b/spec/frontend/badges/components/badge_list_spec.js index 39fa502b207..32cd9483ef8 100644 --- a/spec/frontend/badges/components/badge_list_spec.js +++ b/spec/frontend/badges/components/badge_list_spec.js @@ -1,4 +1,5 @@ import Vue, { nextTick } from 'vue'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; import BadgeList from '~/badges/components/badge_list.vue'; import { GROUP_BADGE, PROJECT_BADGE } from '~/badges/constants'; @@ -11,7 +12,7 @@ describe('BadgeList component', () => { let vm; beforeEach(() => { - setFixtures('<div id="dummy-element"></div>'); + setHTMLFixture('<div id="dummy-element"></div>'); const badges = []; for (let id = 0; id < numberOfDummyBadges; id += 1) { badges.push({ id, ...createDummyBadge() }); @@ -34,6 +35,7 @@ describe('BadgeList component', () => { afterEach(() => { vm.$destroy(); + resetHTMLFixture(); }); it('renders a header with the badge count', () => { diff --git a/spec/frontend/badges/components/badge_spec.js b/spec/frontend/badges/components/badge_spec.js index fe4cf8ce8eb..19b3a9f23a6 100644 --- a/spec/frontend/badges/components/badge_spec.js +++ b/spec/frontend/badges/components/badge_spec.js @@ -1,4 +1,5 @@ import Vue, { nextTick } from 'vue'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import mountComponent from 'helpers/vue_mount_component_helper'; import { DUMMY_IMAGE_URL, TEST_HOST } from 'spec/test_constants'; import Badge from '~/badges/components/badge.vue'; @@ -90,10 +91,14 @@ describe('Badge component', () => { describe('behavior', () => { beforeEach(() => { - setFixtures('<div id="dummy-element"></div>'); + 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); diff --git a/spec/frontend/badges/store/actions_spec.js b/spec/frontend/badges/store/actions_spec.js index 02e1b8e65e4..b799273ff63 100644 --- a/spec/frontend/badges/store/actions_spec.js +++ b/spec/frontend/badges/store/actions_spec.js @@ -371,10 +371,8 @@ describe('Badges store actions', () => { const url = axios.get.mock.calls[0][0]; expect(url).toMatch(new RegExp(`^${dummyEndpointUrl}/render?`)); - expect(url).toMatch( - new RegExp('\\?link_url=%3Cscript%3EI%20am%20dangerous!%3C%2Fscript%3E&'), - ); - expect(url).toMatch(new RegExp('&image_url=%26make-sandwich%3Dtrue$')); + expect(url).toMatch(/\\?link_url=%3Cscript%3EI%20am%20dangerous!%3C%2Fscript%3E&/); + expect(url).toMatch(/&image_url=%26make-sandwich%3Dtrue$/); }); it('dispatches requestRenderedBadge and receiveRenderedBadge for successful response', async () => { diff --git a/spec/frontend/behaviors/autosize_spec.js b/spec/frontend/behaviors/autosize_spec.js index a9dbee7fd08..7008b7b2eb6 100644 --- a/spec/frontend/behaviors/autosize_spec.js +++ b/spec/frontend/behaviors/autosize_spec.js @@ -1,4 +1,5 @@ import '~/behaviors/autosize'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; jest.mock('~/helpers/startup_css_helper', () => { return { @@ -20,19 +21,22 @@ jest.mock('~/helpers/startup_css_helper', () => { describe('Autosize behavior', () => { beforeEach(() => { - setFixtures('<textarea class="js-autosize"></textarea>'); + setHTMLFixture('<textarea class="js-autosize"></textarea>'); + }); + + afterEach(() => { + resetHTMLFixture(); }); it('is applied to the textarea', () => { // This is the second part of the Hack: // Because we are forcing the mock for WaitForCSSLoaded and the very end of our callstack // to call its callback. This querySelector needs to go to the very end of our callstack - // as well, if we would not have this setTimeout Function here, the querySelector - // would run before the mockImplementation called its callBack Function - // the DOM Manipulation didn't happen yet and the test would fail. - setTimeout(() => { - const textarea = document.querySelector('textarea'); - expect(textarea.classList).toContain('js-autosize-initialized'); - }, 0); + // as well, if we would not have this jest.runOnlyPendingTimers here, the querySelector + // would not run and the test would fail. + jest.runOnlyPendingTimers(); + + const textarea = document.querySelector('textarea'); + expect(textarea.classList).toContain('js-autosize-initialized'); }); }); diff --git a/spec/frontend/behaviors/copy_as_gfm_spec.js b/spec/frontend/behaviors/copy_as_gfm_spec.js index c96db09cc76..2032faa1c33 100644 --- a/spec/frontend/behaviors/copy_as_gfm_spec.js +++ b/spec/frontend/behaviors/copy_as_gfm_spec.js @@ -8,6 +8,9 @@ describe('CopyAsGFM', () => { beforeEach(() => { target = document.createElement('input'); target.value = 'This is code: '; + + // needed for the underlying insertText to work + document.execCommand = jest.fn(() => false); }); // When GFM code is copied, we put the regular plain text diff --git a/spec/frontend/behaviors/date_picker_spec.js b/spec/frontend/behaviors/date_picker_spec.js index 9f7701a0366..363052ad7fb 100644 --- a/spec/frontend/behaviors/date_picker_spec.js +++ b/spec/frontend/behaviors/date_picker_spec.js @@ -1,4 +1,5 @@ import * as Pikaday from 'pikaday'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import initDatePickers from '~/behaviors/date_picker'; import * as utils from '~/lib/utils/datetime_utility'; @@ -12,7 +13,7 @@ describe('date_picker behavior', () => { beforeEach(() => { pikadayMock = jest.spyOn(Pikaday, 'default'); parseMock = jest.spyOn(utils, 'parsePikadayDate'); - setFixtures(` + setHTMLFixture(` <div> <input class="datepicker" value="2020-10-01" /> </div> @@ -21,6 +22,10 @@ describe('date_picker behavior', () => { </div>`); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('Instantiates Pickaday for every instance of a .datepicker class', () => { initDatePickers(); diff --git a/spec/frontend/behaviors/load_startup_css_spec.js b/spec/frontend/behaviors/load_startup_css_spec.js index 59f49585645..e9e4c06732f 100644 --- a/spec/frontend/behaviors/load_startup_css_spec.js +++ b/spec/frontend/behaviors/load_startup_css_spec.js @@ -1,4 +1,4 @@ -import { setHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { loadStartupCSS } from '~/behaviors/load_startup_css'; describe('behaviors/load_startup_css', () => { @@ -25,6 +25,10 @@ describe('behaviors/load_startup_css', () => { loadStartupCSS(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('does nothing at first', () => { expect(loadListener).not.toHaveBeenCalled(); }); diff --git a/spec/frontend/behaviors/markdown/highlight_current_user_spec.js b/spec/frontend/behaviors/markdown/highlight_current_user_spec.js index 3305ddc412d..38d19ac3808 100644 --- a/spec/frontend/behaviors/markdown/highlight_current_user_spec.js +++ b/spec/frontend/behaviors/markdown/highlight_current_user_spec.js @@ -1,3 +1,4 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; describe('highlightCurrentUser', () => { @@ -5,7 +6,7 @@ describe('highlightCurrentUser', () => { let elements; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <div id="dummy-root-element"> <div data-user="1">@first</div> <div data-user="2">@second</div> @@ -15,6 +16,10 @@ describe('highlightCurrentUser', () => { elements = rootElement.querySelectorAll('[data-user]'); }); + afterEach(() => { + resetHTMLFixture(); + }); + describe('without current user', () => { beforeEach(() => { window.gon = window.gon || {}; diff --git a/spec/frontend/behaviors/markdown/render_sandboxed_mermaid_spec.js b/spec/frontend/behaviors/markdown/render_sandboxed_mermaid_spec.js new file mode 100644 index 00000000000..2b9442162aa --- /dev/null +++ b/spec/frontend/behaviors/markdown/render_sandboxed_mermaid_spec.js @@ -0,0 +1,34 @@ +import $ from 'jquery'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import renderMermaid from '~/behaviors/markdown/render_sandboxed_mermaid'; + +describe('Render mermaid diagrams for Gitlab Flavoured Markdown', () => { + it('Does something', () => { + document.body.dataset.page = ''; + setHTMLFixture(` + <div class="gl-relative markdown-code-block js-markdown-code"> + <pre data-sourcepos="1:1-7:3" class="code highlight js-syntax-highlight language-mermaid white" lang="mermaid" id="code-4"> + <code class="js-render-mermaid"> + <span id="LC1" class="line" lang="mermaid">graph TD;</span> + <span id="LC2" class="line" lang="mermaid">A-->B</span> + <span id="LC3" class="line" lang="mermaid">A-->C</span> + <span id="LC4" class="line" lang="mermaid">B-->D</span> + <span id="LC5" class="line" lang="mermaid">C-->D</span> + </code> + </pre> + <copy-code> + <button type="button" class="btn btn-default btn-md gl-button btn-icon has-tooltip" data-title="Copy to clipboard" data-clipboard-target="pre#code-4"> + <svg><use xlink:href="/assets/icons-7f1680a3670112fe4c8ef57b9dfb93f0f61b43a2a479d7abd6c83bcb724b9201.svg#copy-to-clipboard"></use></svg> + </button> + </copy-code> + </div>`); + const els = $('pre.js-syntax-highlight').find('.js-render-mermaid'); + + renderMermaid(els); + + jest.runAllTimers(); + expect(document.querySelector('pre.js-syntax-highlight').classList).toContain('gl-sr-only'); + + resetHTMLFixture(); + }); +}); diff --git a/spec/frontend/behaviors/quick_submit_spec.js b/spec/frontend/behaviors/quick_submit_spec.js index 86a85831c6b..317c671cd2b 100644 --- a/spec/frontend/behaviors/quick_submit_spec.js +++ b/spec/frontend/behaviors/quick_submit_spec.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import '~/behaviors/quick_submit'; describe('Quick Submit behavior', () => { @@ -7,7 +8,7 @@ describe('Quick Submit behavior', () => { const keydownEvent = (options = { keyCode: 13, metaKey: true }) => $.Event('keydown', options); beforeEach(() => { - loadFixtures('snippets/show.html'); + loadHTMLFixture('snippets/show.html'); testContext = {}; @@ -24,6 +25,10 @@ describe('Quick Submit behavior', () => { testContext.textarea = $('.js-quick-submit textarea').first(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('does not respond to other keyCodes', () => { testContext.textarea.trigger( keydownEvent({ diff --git a/spec/frontend/behaviors/requires_input_spec.js b/spec/frontend/behaviors/requires_input_spec.js index bb22133ae44..f2f68f17d1c 100644 --- a/spec/frontend/behaviors/requires_input_spec.js +++ b/spec/frontend/behaviors/requires_input_spec.js @@ -1,14 +1,19 @@ import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import '~/behaviors/requires_input'; describe('requiresInput', () => { let submitButton; beforeEach(() => { - loadFixtures('branches/new_branch.html'); + loadHTMLFixture('branches/new_branch.html'); submitButton = $('button[type="submit"]'); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('disables submit when any field is required', () => { $('.js-requires-input').requiresInput(); diff --git a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js index e1811247124..e6e587ff44b 100644 --- a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js +++ b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import Mousetrap from 'mousetrap'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; @@ -12,7 +12,6 @@ jest.mock('~/lib/utils/common_utils', () => ({ describe('ShortcutsIssuable', () => { const snippetShowFixtureName = 'snippets/show.html'; - const mrShowFixtureName = 'merge_requests/merge_request_of_current_user.html'; beforeAll(() => { initCopyAsGFM(); @@ -25,7 +24,7 @@ describe('ShortcutsIssuable', () => { const FORM_SELECTOR = '.js-main-target-form .js-vue-comment-form'; beforeEach(() => { - loadFixtures(snippetShowFixtureName); + loadHTMLFixture(snippetShowFixtureName); $('body').append( `<div class="js-main-target-form"> <textarea class="js-vue-comment-form"></textarea> @@ -40,6 +39,7 @@ describe('ShortcutsIssuable', () => { $(FORM_SELECTOR).remove(); delete window.shortcut; + resetHTMLFixture(); }); // Stub getSelectedFragment to return a node with the provided HTML. @@ -280,55 +280,4 @@ describe('ShortcutsIssuable', () => { }); }); }); - - describe('copyBranchName', () => { - let sidebarCollapsedBtn; - let sidebarExpandedBtn; - - beforeEach(() => { - loadFixtures(mrShowFixtureName); - - window.shortcut = new ShortcutsIssuable(); - - [sidebarCollapsedBtn, sidebarExpandedBtn] = document.querySelectorAll( - '.js-sidebar-source-branch button', - ); - - [sidebarCollapsedBtn, sidebarExpandedBtn].forEach((btn) => jest.spyOn(btn, 'click')); - }); - - afterEach(() => { - delete window.shortcut; - }); - - describe('when the sidebar is expanded', () => { - beforeEach(() => { - // simulate the applied CSS styles when the - // sidebar is expanded - sidebarCollapsedBtn.style.display = 'none'; - - Mousetrap.trigger('b'); - }); - - it('clicks the "expanded" version of the copy source branch button', () => { - expect(sidebarExpandedBtn.click).toHaveBeenCalled(); - expect(sidebarCollapsedBtn.click).not.toHaveBeenCalled(); - }); - }); - - describe('when the sidebar is collapsed', () => { - beforeEach(() => { - // simulate the applied CSS styles when the - // sidebar is collapsed - sidebarExpandedBtn.style.display = 'none'; - - Mousetrap.trigger('b'); - }); - - it('clicks the "collapsed" version of the copy source branch button', () => { - expect(sidebarCollapsedBtn.click).toHaveBeenCalled(); - expect(sidebarExpandedBtn.click).not.toHaveBeenCalled(); - }); - }); - }); }); diff --git a/spec/frontend/blob/blob_file_dropzone_spec.js b/spec/frontend/blob/blob_file_dropzone_spec.js index 47c90030e18..d6fc824258b 100644 --- a/spec/frontend/blob/blob_file_dropzone_spec.js +++ b/spec/frontend/blob/blob_file_dropzone_spec.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import BlobFileDropzone from '~/blob/blob_file_dropzone'; describe('BlobFileDropzone', () => { @@ -6,7 +7,7 @@ describe('BlobFileDropzone', () => { let replaceFileButton; beforeEach(() => { - loadFixtures('blob/show.html'); + loadHTMLFixture('blob/show.html'); const form = $('.js-upload-blob-form'); // eslint-disable-next-line no-new new BlobFileDropzone(form, 'POST'); @@ -15,6 +16,10 @@ describe('BlobFileDropzone', () => { replaceFileButton = $('#submit-all'); }); + afterEach(() => { + resetHTMLFixture(); + }); + describe('submit button', () => { it('requires file', () => { jest.spyOn(window, 'alert').mockImplementation(() => {}); diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap index 5926836d9c1..b430dc15557 100644 --- a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap +++ b/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap @@ -18,7 +18,7 @@ exports[`Blob Header Default Actions rendering matches the snapshot 1`] = ` </div> <div - class="gl-sm-display-flex file-actions" + class="gl-display-flex gl-flex-wrap file-actions" > <viewer-switcher-stub docicon="document" diff --git a/spec/frontend/blob/components/table_contents_spec.js b/spec/frontend/blob/components/table_contents_spec.js index ade35d39b4f..358ac31819c 100644 --- a/spec/frontend/blob/components/table_contents_spec.js +++ b/spec/frontend/blob/components/table_contents_spec.js @@ -1,6 +1,7 @@ import { GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import TableContents from '~/blob/components/table_contents.vue'; let wrapper; @@ -17,7 +18,7 @@ async function setLoaded(loaded) { describe('Markdown table of contents component', () => { beforeEach(() => { - setFixtures(` + setHTMLFixture(` <div class="blob-viewer" data-type="rich" data-loaded="false"> <h1><a href="#1"></a>Hello</h1> <h2><a href="#2"></a>World</h2> @@ -29,6 +30,7 @@ describe('Markdown table of contents component', () => { afterEach(() => { wrapper.destroy(); + resetHTMLFixture(); }); describe('not loaded', () => { diff --git a/spec/frontend/blob/file_template_mediator_spec.js b/spec/frontend/blob/file_template_mediator_spec.js index 44e12deb564..907a3c97799 100644 --- a/spec/frontend/blob/file_template_mediator_spec.js +++ b/spec/frontend/blob/file_template_mediator_spec.js @@ -1,3 +1,4 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import TemplateSelectorMediator from '~/blob/file_template_mediator'; describe('Template Selector Mediator', () => { @@ -11,7 +12,7 @@ describe('Template Selector Mediator', () => { }))(); beforeEach(() => { - setFixtures('<div class="file-editor"><input class="js-file-path-name-input" /></div>'); + setHTMLFixture('<div class="file-editor"><input class="js-file-path-name-input" /></div>'); input = document.querySelector('.js-file-path-name-input'); mediator = new TemplateSelectorMediator({ editor, @@ -20,6 +21,10 @@ describe('Template Selector Mediator', () => { }); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('fills out the input field', () => { expect(input.value).toBe(''); mediator.setFilename(newFileName); diff --git a/spec/frontend/blob/file_template_selector_spec.js b/spec/frontend/blob/file_template_selector_spec.js index 2ab3b3ebc82..65444e86efd 100644 --- a/spec/frontend/blob/file_template_selector_spec.js +++ b/spec/frontend/blob/file_template_selector_spec.js @@ -1,10 +1,11 @@ -import $ from 'jquery'; import FileTemplateSelector from '~/blob/file_template_selector'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; describe('FileTemplateSelector', () => { let subject; - let dropdown; - let wrapper; + + const dropdown = '.dropdown'; + const wrapper = '.wrapper'; const createSubject = () => { subject = new FileTemplateSelector({}); @@ -17,13 +18,16 @@ describe('FileTemplateSelector', () => { afterEach(() => { subject = null; + resetHTMLFixture(); }); describe('show method', () => { beforeEach(() => { - dropdown = document.createElement('div'); - wrapper = document.createElement('div'); - wrapper.classList.add('hidden'); + setHTMLFixture(` + <div class="wrapper hidden"> + <div class="dropdown"></div> + </div> + `); createSubject(); }); @@ -37,25 +41,24 @@ describe('FileTemplateSelector', () => { it('does not call init on subsequent calls', () => { jest.spyOn(subject, 'init'); subject.show(); - subject.show(); expect(subject.init).toHaveBeenCalledTimes(1); }); - it('removes hidden class from $wrapper', () => { - expect($(wrapper).hasClass('hidden')).toBe(true); + it('removes hidden class from wrapper', () => { + subject.init(); + expect(subject.wrapper.classList.contains('hidden')).toBe(true); subject.show(); - - expect($(wrapper).hasClass('hidden')).toBe(false); + expect(subject.wrapper.classList.contains('hidden')).toBe(false); }); it('sets the focus on the dropdown', async () => { subject.show(); - jest.spyOn(subject.$dropdown, 'focus'); + jest.spyOn(subject.dropdown, 'focus'); jest.runAllTimers(); - expect(subject.$dropdown.focus).toHaveBeenCalled(); + expect(subject.dropdown.focus).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/blob/line_highlighter_spec.js b/spec/frontend/blob/line_highlighter_spec.js index 330f1f3137e..21d4e8db503 100644 --- a/spec/frontend/blob/line_highlighter_spec.js +++ b/spec/frontend/blob/line_highlighter_spec.js @@ -1,6 +1,7 @@ /* eslint-disable no-return-assign, no-new, no-underscore-dangle */ import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import LineHighlighter from '~/blob/line_highlighter'; import * as utils from '~/lib/utils/common_utils'; @@ -14,8 +15,9 @@ describe('LineHighlighter', () => { const e = $.Event('click', eventData); return $(`#L${number}`).trigger(e); }; + beforeEach(() => { - loadFixtures('static/line_highlighter.html'); + loadHTMLFixture('static/line_highlighter.html'); testContext.class = new LineHighlighter(); testContext.css = testContext.class.highlightLineClass; return (testContext.spies = { @@ -25,6 +27,10 @@ describe('LineHighlighter', () => { }); }); + afterEach(() => { + resetHTMLFixture(); + }); + describe('behavior', () => { it('highlights one line given in the URL hash', () => { new LineHighlighter({ hash: '#L13' }); diff --git a/spec/frontend/blob/openapi/index_spec.js b/spec/frontend/blob/openapi/index_spec.js new file mode 100644 index 00000000000..53220809f80 --- /dev/null +++ b/spec/frontend/blob/openapi/index_spec.js @@ -0,0 +1,28 @@ +import { SwaggerUIBundle } from 'swagger-ui-dist'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import renderOpenApi from '~/blob/openapi'; + +jest.mock('swagger-ui-dist'); + +describe('OpenAPI blob viewer', () => { + const id = 'js-openapi-viewer'; + const mockEndpoint = 'some/endpoint'; + + beforeEach(() => { + setHTMLFixture(`<div id="${id}" data-endpoint="${mockEndpoint}"></div>`); + renderOpenApi(); + }); + + afterEach(() => { + resetHTMLFixture(); + }); + + it('initializes SwaggerUI with the correct configuration', () => { + expect(SwaggerUIBundle).toHaveBeenCalledWith({ + url: mockEndpoint, + dom_id: `#${id}`, + deepLinking: true, + displayOperationId: true, + }); + }); +}); diff --git a/spec/frontend/blob/pipeline_tour_success_modal_spec.js b/spec/frontend/blob/pipeline_tour_success_modal_spec.js index f4af57de41f..750dd8f0a72 100644 --- a/spec/frontend/blob/pipeline_tour_success_modal_spec.js +++ b/spec/frontend/blob/pipeline_tour_success_modal_spec.js @@ -1,6 +1,6 @@ import { GlSprintf, GlModal, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import Cookies from 'js-cookie'; +import Cookies from '~/lib/utils/cookies'; import { stubComponent } from 'helpers/stub_component'; import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper'; import pipelineTourSuccess from '~/blob/pipeline_tour_success_modal.vue'; diff --git a/spec/frontend/blob/sketch/index_spec.js b/spec/frontend/blob/sketch/index_spec.js index 7424897b22c..5e1922a24f4 100644 --- a/spec/frontend/blob/sketch/index_spec.js +++ b/spec/frontend/blob/sketch/index_spec.js @@ -1,20 +1,34 @@ -import JSZip from 'jszip'; import SketchLoader from '~/blob/sketch'; - -jest.mock('jszip'); +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import waitForPromises from 'helpers/wait_for_promises'; + +jest.mock('jszip', () => { + return { + loadAsync: jest.fn().mockResolvedValue({ + files: { + 'previews/preview.png': { + async: jest.fn().mockResolvedValue('foo'), + }, + }, + }), + }; +}); describe('Sketch viewer', () => { beforeEach(() => { - loadFixtures('static/sketch_viewer.html'); + loadHTMLFixture('static/sketch_viewer.html'); + }); + + afterEach(() => { + resetHTMLFixture(); }); describe('with error message', () => { - beforeEach((done) => { + beforeEach(() => { jest.spyOn(SketchLoader.prototype, 'getZipFile').mockImplementation( () => new Promise((resolve, reject) => { reject(); - done(); }), ); @@ -35,26 +49,12 @@ describe('Sketch viewer', () => { }); describe('success', () => { - beforeEach((done) => { - const loadAsyncMock = { - files: { - 'previews/preview.png': { - async: jest.fn(), - }, - }, - }; - - loadAsyncMock.files['previews/preview.png'].async.mockImplementation( - () => - new Promise((resolve) => { - resolve('foo'); - done(); - }), - ); - + beforeEach(() => { jest.spyOn(SketchLoader.prototype, 'getZipFile').mockResolvedValue(); - jest.spyOn(JSZip, 'loadAsync').mockResolvedValue(loadAsyncMock); - return new SketchLoader(document.getElementById('js-sketch-viewer')); + // eslint-disable-next-line no-new + new SketchLoader(document.getElementById('js-sketch-viewer')); + + return waitForPromises(); }); it('does not render error message', () => { diff --git a/spec/frontend/blob/viewer/index_spec.js b/spec/frontend/blob/viewer/index_spec.js index fe55a537b89..5f6baf3f63d 100644 --- a/spec/frontend/blob/viewer/index_spec.js +++ b/spec/frontend/blob/viewer/index_spec.js @@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { setTestTimeout } from 'helpers/timeout'; import { BlobViewer } from '~/blob/viewer/index'; import axios from '~/lib/utils/axios_utils'; @@ -26,7 +27,7 @@ describe('Blob viewer', () => { $.fn.extend(jQueryMock); mock = new MockAdapter(axios); - loadFixtures('blob/show_readme.html'); + loadHTMLFixture('blob/show_readme.html'); $('#modal-upload-blob').remove(); mock.onGet(/blob\/.+\/README\.md/).reply(200, { @@ -39,6 +40,8 @@ describe('Blob viewer', () => { afterEach(() => { mock.restore(); window.location.hash = ''; + + resetHTMLFixture(); }); it('loads source file after switching views', async () => { diff --git a/spec/frontend/blob_edit/blob_bundle_spec.js b/spec/frontend/blob_edit/blob_bundle_spec.js index 2c9ddfaf867..644539308c2 100644 --- a/spec/frontend/blob_edit/blob_bundle_spec.js +++ b/spec/frontend/blob_edit/blob_bundle_spec.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import waitForPromises from 'helpers/wait_for_promises'; import blobBundle from '~/blob_edit/blob_bundle'; @@ -14,15 +15,17 @@ describe('BlobBundle', () => { }); it('loads SourceEditor for the edit screen', async () => { - setFixtures(`<div class="js-edit-blob-form"></div>`); + setHTMLFixture(`<div class="js-edit-blob-form"></div>`); blobBundle(); await waitForPromises(); expect(SourceEditor).toHaveBeenCalled(); + + resetHTMLFixture(); }); describe('No Suggest Popover', () => { beforeEach(() => { - setFixtures(` + setHTMLFixture(` <div class="js-edit-blob-form" data-blob-filename="blah"> <button class="js-commit-button"></button> <button id='cancel-changes'></button> @@ -31,6 +34,10 @@ describe('BlobBundle', () => { blobBundle(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('sets the window beforeunload listener to a function returning a string', () => { expect(window.onbeforeunload()).toBe(''); }); @@ -52,7 +59,7 @@ describe('BlobBundle', () => { let trackingSpy; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <div class="js-edit-blob-form" data-blob-filename="blah" id="target"> <div class="js-suggest-gitlab-ci-yml" data-target="#target" @@ -73,6 +80,7 @@ describe('BlobBundle', () => { afterEach(() => { unmockTracking(); + resetHTMLFixture(); }); it('sends a tracking event when the commit button is clicked', () => { diff --git a/spec/frontend/blob_edit/edit_blob_spec.js b/spec/frontend/blob_edit/edit_blob_spec.js index 9c974e79e6e..c031cae11df 100644 --- a/spec/frontend/blob_edit/edit_blob_spec.js +++ b/spec/frontend/blob_edit/edit_blob_spec.js @@ -1,9 +1,12 @@ +import { Emitter } from 'monaco-editor'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; import EditBlob from '~/blob_edit/edit_blob'; import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext'; import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext'; +import { ToolbarExtension } from '~/editor/extensions/source_editor_toolbar_ext'; import SourceEditor from '~/editor/source_editor'; jest.mock('~/editor/source_editor'); @@ -11,11 +14,13 @@ jest.mock('~/editor/extensions/source_editor_extension_base'); jest.mock('~/editor/extensions/source_editor_file_template_ext'); jest.mock('~/editor/extensions/source_editor_markdown_ext'); jest.mock('~/editor/extensions/source_editor_markdown_livepreview_ext'); +jest.mock('~/editor/extensions/source_editor_toolbar_ext'); const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown'; const defaultExtensions = [ { definition: SourceEditorExtension }, { definition: FileTemplateExtension }, + { definition: ToolbarExtension }, ]; const markdownExtensions = [ { definition: EditorMarkdownExtension }, @@ -26,15 +31,20 @@ const markdownExtensions = [ ]; describe('Blob Editing', () => { - const useMock = jest.fn(); + let blobInstance; + const useMock = jest.fn(() => markdownExtensions); + const unuseMock = jest.fn(); + const emitter = new Emitter(); const mockInstance = { use: useMock, + unuse: unuseMock, setValue: jest.fn(), getValue: jest.fn().mockReturnValue('test value'), focus: jest.fn(), + onDidChangeModelLanguage: emitter.event, }; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <form class="js-edit-blob-form"> <div id="file_path"></div> <div id="editor"></div> @@ -44,17 +54,18 @@ describe('Blob Editing', () => { jest.spyOn(SourceEditor.prototype, 'createInstance').mockReturnValue(mockInstance); }); afterEach(() => { - SourceEditorExtension.mockClear(); - EditorMarkdownExtension.mockClear(); - EditorMarkdownPreviewExtension.mockClear(); - FileTemplateExtension.mockClear(); + jest.clearAllMocks(); + unuseMock.mockClear(); + useMock.mockClear(); + resetHTMLFixture(); }); const editorInst = (isMarkdown) => { - return new EditBlob({ + blobInstance = new EditBlob({ isMarkdown, previewMarkdownPath: PREVIEW_MARKDOWN_PATH, }); + return blobInstance; }; const initEditor = async (isMarkdown = false) => { @@ -79,6 +90,22 @@ describe('Blob Editing', () => { expect(useMock).toHaveBeenCalledTimes(2); expect(useMock.mock.calls[1]).toEqual([markdownExtensions]); }); + + it('correctly handles switching from markdown and un-uses markdown extensions', async () => { + await initEditor(true); + expect(unuseMock).not.toHaveBeenCalled(); + await emitter.fire({ newLanguage: 'plaintext', oldLanguage: 'markdown' }); + expect(unuseMock).toHaveBeenCalledWith(markdownExtensions); + }); + + it('correctly handles switching from non-markdown to markdown extensions', async () => { + const mdSpy = jest.fn(); + await initEditor(); + blobInstance.fetchMarkdownExtension = mdSpy; + expect(mdSpy).not.toHaveBeenCalled(); + await emitter.fire({ newLanguage: 'markdown', oldLanguage: 'plaintext' }); + expect(mdSpy).toHaveBeenCalled(); + }); }); it('adds trailing newline to the blob content on submit', async () => { diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js index 677978d31ca..c6de3ee69f3 100644 --- a/spec/frontend/boards/board_card_inner_spec.js +++ b/spec/frontend/boards/board_card_inner_spec.js @@ -2,12 +2,15 @@ import { GlLabel, GlLoadingIcon, GlTooltip } from '@gitlab/ui'; import { range } from 'lodash'; import Vuex from 'vuex'; 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 BoardCardInner from '~/boards/components/board_card_inner.vue'; import { issuableTypes } from '~/boards/constants'; +import eventHub from '~/boards/eventhub'; import defaultStore from '~/boards/stores'; +import { updateHistory } from '~/lib/utils/url_utility'; import { mockLabelList, mockIssue, mockIssueFullPath } from './mock_data'; jest.mock('~/lib/utils/url_utility'); @@ -34,7 +37,7 @@ describe('Board card component', () => { let list; let store; - const findBoardBlockedIcon = () => wrapper.find(BoardBlockedIcon); + const findBoardBlockedIcon = () => wrapper.findComponent(BoardBlockedIcon); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findEpicCountablesTotalTooltip = () => wrapper.findComponent(GlTooltip); const findEpicCountables = () => wrapper.findByTestId('epic-countables'); @@ -45,9 +48,14 @@ describe('Board card component', () => { const findEpicProgressTooltip = () => wrapper.findByTestId('epic-progress-tooltip-content'); const findHiddenIssueIcon = () => wrapper.findByTestId('hidden-icon'); + const performSearchMock = jest.fn(); + const createStore = ({ isEpicBoard = false, isProjectBoard = false } = {}) => { store = new Vuex.Store({ ...defaultStore, + actions: { + performSearch: performSearchMock, + }, state: { ...defaultStore.state, issuableType: issuableTypes.issue, @@ -70,7 +78,6 @@ describe('Board card component', () => { ...props, }, stubs: { - GlLabel: true, GlLoadingIcon: true, }, directives: { @@ -179,7 +186,7 @@ describe('Board card component', () => { describe('confidential issue', () => { beforeEach(() => { - wrapper.setProps({ + createWrapper({ item: { ...wrapper.props('item'), confidential: true, @@ -194,7 +201,7 @@ describe('Board card component', () => { describe('hidden issue', () => { beforeEach(() => { - wrapper.setProps({ + createWrapper({ item: { ...wrapper.props('item'), hidden: true, @@ -219,7 +226,7 @@ describe('Board card component', () => { describe('with assignee', () => { describe('with avatar', () => { beforeEach(() => { - wrapper.setProps({ + createWrapper({ item: { ...wrapper.props('item'), assignees: [user], @@ -272,7 +279,7 @@ describe('Board card component', () => { beforeEach(() => { global.gon.default_avatar_url = 'default_avatar'; - wrapper.setProps({ + createWrapper({ item: { ...wrapper.props('item'), assignees: [ @@ -301,7 +308,7 @@ describe('Board card component', () => { describe('multiple assignees', () => { beforeEach(() => { - wrapper.setProps({ + createWrapper({ item: { ...wrapper.props('item'), assignees: [ @@ -342,7 +349,7 @@ describe('Board card component', () => { avatarUrl: 'test_image', }); - wrapper.setProps({ + createWrapper({ item: { ...wrapper.props('item'), assignees, @@ -368,7 +375,7 @@ describe('Board card component', () => { avatarUrl: 'test_image', })), ]; - wrapper.setProps({ + createWrapper({ item: { ...wrapper.props('item'), assignees, @@ -384,31 +391,74 @@ describe('Board card component', () => { describe('labels', () => { beforeEach(() => { - wrapper.setProps({ item: { ...issue, labels: [list.label, label1] } }); + createWrapper({ item: { ...issue, labels: [list.label, label1] } }); }); it('does not render list label but renders all other labels', () => { - expect(wrapper.findAll(GlLabel).length).toBe(1); - const label = wrapper.find(GlLabel); + expect(wrapper.findAllComponents(GlLabel).length).toBe(1); + const label = wrapper.findComponent(GlLabel); expect(label.props('title')).toEqual(label1.title); expect(label.props('description')).toEqual(label1.description); expect(label.props('backgroundColor')).toEqual(label1.color); }); it('does not render label if label does not have an ID', async () => { - wrapper.setProps({ item: { ...issue, labels: [label1, { title: 'closed' }] } }); + createWrapper({ item: { ...issue, labels: [label1, { title: 'closed' }] } }); await nextTick(); - expect(wrapper.findAll(GlLabel).length).toBe(1); + expect(wrapper.findAllComponents(GlLabel).length).toBe(1); expect(wrapper.text()).not.toContain('closed'); }); + }); - describe('when label params arent set', () => { - it('passes the right target to GlLabel', () => { - expect(wrapper.findAll(GlLabel).at(0).props('target')).toEqual( - '?label_name[]=testing%20123', - ); + describe('filterByLabel method', () => { + beforeEach(() => { + createWrapper({ + item: { + ...issue, + labels: [label1], + }, + updateFilters: true, + }); + }); + + describe('when selected label is not in the filter', () => { + beforeEach(() => { + setWindowLocation('?'); + wrapper.findComponent(GlLabel).vm.$emit('click', label1); + }); + + it('calls updateHistory', () => { + expect(updateHistory).toHaveBeenCalledTimes(1); + }); + + it('dispatches performSearch vuex action', () => { + expect(performSearchMock).toHaveBeenCalledTimes(1); + }); + + it('emits updateTokens event', () => { + expect(eventHub.$emit).toHaveBeenCalledTimes(1); + expect(eventHub.$emit).toHaveBeenCalledWith('updateTokens'); + }); + }); + + describe('when selected label is already in the filter', () => { + beforeEach(() => { + setWindowLocation('?label_name[]=testing%20123'); + wrapper.findComponent(GlLabel).vm.$emit('click', label1); + }); + + it('does not call updateHistory', () => { + expect(updateHistory).not.toHaveBeenCalled(); + }); + + it('does not dispatch performSearch vuex action', () => { + expect(performSearchMock).not.toHaveBeenCalled(); + }); + + it('does not emit updateTokens event', () => { + expect(eventHub.$emit).not.toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js index 14870ec76a2..2f9677680eb 100644 --- a/spec/frontend/boards/components/board_list_header_spec.js +++ b/spec/frontend/boards/components/board_list_header_spec.js @@ -132,7 +132,7 @@ describe('Board List Header Component', () => { const icon = findCaret(); - expect(icon.props('icon')).toBe('chevron-right'); + expect(icon.props('icon')).toBe('chevron-down'); }); it('should display expand icon when column is collapsed', async () => { @@ -140,7 +140,7 @@ describe('Board List Header Component', () => { const icon = findCaret(); - expect(icon.props('icon')).toBe('chevron-down'); + expect(icon.props('icon')).toBe('chevron-right'); }); it('should dispatch toggleListCollapse when clicking the collapse icon', async () => { diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index ec9342cffc2..26ad9790840 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -17,6 +17,10 @@ export const mockBoard = { id: 'gid://gitlab/Iteration/124', title: 'Iteration 9', }, + iterationCadence: { + id: 'gid://gitlab/Iteration::Cadence/134', + title: 'Cadence 3', + }, assignee: { id: 'gid://gitlab/User/1', username: 'admin', @@ -32,6 +36,7 @@ export const mockBoardConfig = { milestoneTitle: '14.9', iterationId: 'gid://gitlab/Iteration/124', iterationTitle: 'Iteration 9', + iterationCadenceId: 'gid://gitlab/Iteration::Cadence/134', assigneeId: 'gid://gitlab/User/1', assigneeUsername: 'admin', labels: [{ id: 'gid://gitlab/Label/32', title: 'Deliverable' }], diff --git a/spec/frontend/boards/project_select_spec.js b/spec/frontend/boards/project_select_spec.js index 05dc7d28eaa..bd79060c54f 100644 --- a/spec/frontend/boards/project_select_spec.js +++ b/spec/frontend/boards/project_select_spec.js @@ -1,7 +1,14 @@ -import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; +import { + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + GlLoadingIcon, + GlFormInput, +} from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; +import waitForPromises from 'helpers/wait_for_promises'; import ProjectSelect from '~/boards/components/project_select.vue'; import defaultState from '~/boards/stores/state'; @@ -61,6 +68,7 @@ describe('ProjectSelect component', () => { provide: { groupId: 1, }, + attachTo: document.body, }); }; @@ -120,6 +128,17 @@ describe('ProjectSelect component', () => { it('does not render empty search result message', () => { expect(findEmptySearchMessage().exists()).toBe(false); }); + + it('focuses on the search input', async () => { + const dropdownToggle = findGlDropdown().find('.dropdown-toggle'); + + await dropdownToggle.trigger('click'); + await waitForPromises(); + await nextTick(); + + const searchInput = findGlDropdown().findComponent(GlFormInput).element; + expect(document.activeElement).toEqual(searchInput); + }); }); describe('when no projects are being returned', () => { diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js index b30968c45d7..304f2aad98e 100644 --- a/spec/frontend/boards/stores/getters_spec.js +++ b/spec/frontend/boards/stores/getters_spec.js @@ -215,4 +215,33 @@ describe('Boards - Getters', () => { expect(getters.isEpicBoard()).toBe(false); }); }); + + describe('hasScope', () => { + const boardConfig = { + labels: [], + assigneeId: null, + iterationCadenceId: null, + iterationId: null, + milestoneId: null, + weight: null, + }; + + it('returns false when boardConfig is empty', () => { + const state = { boardConfig }; + + expect(getters.hasScope(state)).toBe(false); + }); + + it('returns true when boardScope has a label', () => { + const state = { boardConfig: { ...boardConfig, labels: ['foo'] } }; + + expect(getters.hasScope(state)).toBe(true); + }); + + it('returns true when boardConfig has a value other than null', () => { + const state = { boardConfig: { ...boardConfig, assigneeId: 3 } }; + + expect(getters.hasScope(state)).toBe(true); + }); + }); }); diff --git a/spec/frontend/bootstrap_jquery_spec.js b/spec/frontend/bootstrap_jquery_spec.js index d5d592e3839..15186600a8a 100644 --- a/spec/frontend/bootstrap_jquery_spec.js +++ b/spec/frontend/bootstrap_jquery_spec.js @@ -1,10 +1,15 @@ import $ from 'jquery'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import '~/commons/bootstrap'; describe('Bootstrap jQuery extensions', () => { describe('disable', () => { beforeEach(() => { - setFixtures('<input type="text" />'); + setHTMLFixture('<input type="text" />'); + }); + + afterEach(() => { + resetHTMLFixture(); }); it('adds the disabled attribute', () => { @@ -24,7 +29,11 @@ describe('Bootstrap jQuery extensions', () => { describe('enable', () => { beforeEach(() => { - setFixtures('<input type="text" disabled="disabled" class="disabled" />'); + setHTMLFixture('<input type="text" disabled="disabled" class="disabled" />'); + }); + + afterEach(() => { + resetHTMLFixture(); }); it('removes the disabled attribute', () => { diff --git a/spec/frontend/bootstrap_linked_tabs_spec.js b/spec/frontend/bootstrap_linked_tabs_spec.js index 30fb140bc69..5ee1ca32141 100644 --- a/spec/frontend/bootstrap_linked_tabs_spec.js +++ b/spec/frontend/bootstrap_linked_tabs_spec.js @@ -1,8 +1,13 @@ +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs'; describe('Linked Tabs', () => { beforeEach(() => { - loadFixtures('static/linked_tabs.html'); + loadHTMLFixture('static/linked_tabs.html'); + }); + + afterEach(() => { + resetHTMLFixture(); }); describe('when is initialized', () => { diff --git a/spec/frontend/branches/components/delete_branch_modal_spec.js b/spec/frontend/branches/components/delete_branch_modal_spec.js index 0c6111bda9e..2b8c8d408c4 100644 --- a/spec/frontend/branches/components/delete_branch_modal_spec.js +++ b/spec/frontend/branches/components/delete_branch_modal_spec.js @@ -49,7 +49,7 @@ const findForm = () => wrapper.find('form'); describe('Delete branch modal', () => { const expectedUnmergedWarning = - 'This branch hasn’t been merged into default. To avoid data loss, consider merging this branch before deleting it.'; + "This branch hasn't been merged into default. To avoid data loss, consider merging this branch before deleting it."; afterEach(() => { wrapper.destroy(); @@ -110,7 +110,7 @@ describe('Delete branch modal', () => { "You're about to permanently delete the protected branch test_modal."; const expectedMessageProtected = `${expectedWarningProtected} ${expectedUnmergedWarning}`; const expectedConfirmationText = - 'Once you confirm and press Yes, delete protected branch, it cannot be undone or recovered. Please type the following to confirm: test_modal'; + 'After you confirm and select Yes, delete protected branch, you cannot recover this branch. Please type the following to confirm: test_modal'; beforeEach(() => { createComponent({ isProtectedBranch: true }); diff --git a/spec/frontend/broadcast_notification_spec.js b/spec/frontend/broadcast_notification_spec.js index cd947cd417a..5b9541dedfb 100644 --- a/spec/frontend/broadcast_notification_spec.js +++ b/spec/frontend/broadcast_notification_spec.js @@ -1,4 +1,5 @@ -import Cookies from 'js-cookie'; +import Cookies from '~/lib/utils/cookies'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import initBroadcastNotifications from '~/broadcast_notification'; describe('broadcast message on dismiss', () => { @@ -9,7 +10,7 @@ describe('broadcast message on dismiss', () => { const endsAt = '2020-01-01T00:00:00Z'; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <div class="js-broadcast-notification-1"> <button class="js-dismiss-current-broadcast-notification" data-id="1" data-expire-date="${endsAt}"></button> </div> @@ -18,6 +19,10 @@ describe('broadcast message on dismiss', () => { initBroadcastNotifications(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('removes broadcast message', () => { dismiss(); diff --git a/spec/frontend/ci_secure_files/components/secure_files_list_spec.js b/spec/frontend/ci_secure_files/components/secure_files_list_spec.js index 042376c71e8..ad5f8a56ced 100644 --- a/spec/frontend/ci_secure_files/components/secure_files_list_spec.js +++ b/spec/frontend/ci_secure_files/components/secure_files_list_spec.js @@ -10,6 +10,7 @@ import { secureFiles } from '../mock_data'; const dummyApiVersion = 'v3000'; const dummyProjectId = 1; +const fileSizeLimit = 5; const dummyUrlRoot = '/gitlab'; const dummyGon = { api_version: dummyApiVersion, @@ -33,9 +34,13 @@ describe('SecureFilesList', () => { window.gon = originalGon; }); - const createWrapper = (props = {}) => { + const createWrapper = (admin = true, props = {}) => { wrapper = mount(SecureFilesList, { - provide: { projectId: dummyProjectId }, + provide: { + projectId: dummyProjectId, + admin, + fileSizeLimit, + }, ...props, }); }; @@ -46,6 +51,8 @@ describe('SecureFilesList', () => { const findHeaderAt = (i) => wrapper.findAll('thead th').at(i); const findPagination = () => wrapper.findAll('ul.pagination'); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findUploadButton = () => wrapper.findAll('span.gl-button-text'); + const findDeleteButton = () => wrapper.findAll('tbody tr td button.btn-danger'); describe('when secure files exist in a project', () => { beforeEach(async () => { @@ -57,7 +64,7 @@ describe('SecureFilesList', () => { }); it('displays a table with expected headers', () => { - const headers = ['Filename', 'Permissions', 'Uploaded']; + const headers = ['Filename', 'Uploaded']; headers.forEach((header, i) => { expect(findHeaderAt(i).text()).toBe(header); }); @@ -69,8 +76,7 @@ describe('SecureFilesList', () => { const [secureFile] = secureFiles; expect(findCell(0, 0).text()).toBe(secureFile.name); - expect(findCell(0, 1).text()).toBe(secureFile.permissions); - expect(findCell(0, 2).find(TimeAgoTooltip).props('time')).toBe(secureFile.created_at); + expect(findCell(0, 1).find(TimeAgoTooltip).props('time')).toBe(secureFile.created_at); }); }); @@ -84,7 +90,7 @@ describe('SecureFilesList', () => { }); it('displays a table with expected headers', () => { - const headers = ['Filename', 'Permissions', 'Uploaded']; + const headers = ['Filename', 'Uploaded']; headers.forEach((header, i) => { expect(findHeaderAt(i).text()).toBe(header); }); @@ -136,4 +142,42 @@ describe('SecureFilesList', () => { expect(findLoadingIcon().exists()).toBe(false); }); }); + + describe('admin permissions', () => { + describe('with admin permissions', () => { + beforeEach(async () => { + mock = new MockAdapter(axios); + mock.onGet(expectedUrl).reply(200, secureFiles); + + createWrapper(); + await waitForPromises(); + }); + + it('displays the upload button', () => { + expect(findUploadButton().exists()).toBe(true); + }); + + it('displays a delete button', () => { + expect(findDeleteButton().exists()).toBe(true); + }); + }); + + describe('without admin permissions', () => { + beforeEach(async () => { + mock = new MockAdapter(axios); + mock.onGet(expectedUrl).reply(200, secureFiles); + + createWrapper(false); + await waitForPromises(); + }); + + it('does not display the upload button', () => { + expect(findUploadButton().exists()).toBe(false); + }); + + it('does not display a delete button', () => { + expect(findDeleteButton().exists()).toBe(false); + }); + }); + }); }); diff --git a/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js index 1bca21b1d57..2210b0f48d6 100644 --- a/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js +++ b/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import VariableList from '~/ci_variable_list/ci_variable_list'; const HIDE_CLASS = 'hide'; @@ -10,7 +11,7 @@ describe('VariableList', () => { describe('with only key/value inputs', () => { describe('with no variables', () => { beforeEach(() => { - loadFixtures('pipeline_schedules/edit.html'); + loadHTMLFixture('pipeline_schedules/edit.html'); $wrapper = $('.js-ci-variable-list-section'); variableList = new VariableList({ @@ -20,6 +21,10 @@ describe('VariableList', () => { variableList.init(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('should remove the row when clicking the remove button', () => { $wrapper.find('.js-row-remove-button').trigger('click'); @@ -64,7 +69,7 @@ describe('VariableList', () => { describe('with persisted variables', () => { beforeEach(() => { - loadFixtures('pipeline_schedules/edit_with_variables.html'); + loadHTMLFixture('pipeline_schedules/edit_with_variables.html'); $wrapper = $('.js-ci-variable-list-section'); variableList = new VariableList({ @@ -74,6 +79,10 @@ describe('VariableList', () => { variableList.init(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('should have "Reveal values" button initially when there are already variables', () => { expect($wrapper.find('.js-secret-value-reveal-button').text()).toBe('Reveal values'); }); @@ -97,7 +106,7 @@ describe('VariableList', () => { describe('toggleEnableRow method', () => { beforeEach(() => { - loadFixtures('pipeline_schedules/edit_with_variables.html'); + loadHTMLFixture('pipeline_schedules/edit_with_variables.html'); $wrapper = $('.js-ci-variable-list-section'); variableList = new VariableList({ @@ -107,6 +116,10 @@ describe('VariableList', () => { variableList.init(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('should disable all key inputs', () => { expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3); diff --git a/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js index eee1362440d..57f666e29d6 100644 --- a/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js +++ b/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js @@ -1,11 +1,12 @@ import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list'; describe('NativeFormVariableList', () => { let $wrapper; beforeEach(() => { - loadFixtures('pipeline_schedules/edit.html'); + loadHTMLFixture('pipeline_schedules/edit.html'); $wrapper = $('.js-ci-variable-list-section'); setupNativeFormVariableList({ @@ -14,6 +15,10 @@ describe('NativeFormVariableList', () => { }); }); + afterEach(() => { + resetHTMLFixture(); + }); + describe('onFormSubmit', () => { it('should clear out the `name` attribute on the inputs for the last empty row on form submission (avoid BE validation)', () => { const $row = $wrapper.find('.js-row'); diff --git a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js index 2fedbbecd64..d26378d9382 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js @@ -5,7 +5,12 @@ import Vuex from 'vuex'; import { mockTracking } from 'helpers/tracking_helper'; import CiEnvironmentsDropdown from '~/ci_variable_list/components/ci_environments_dropdown.vue'; import CiVariableModal from '~/ci_variable_list/components/ci_variable_modal.vue'; -import { AWS_ACCESS_KEY_ID, EVENT_LABEL, EVENT_ACTION } from '~/ci_variable_list/constants'; +import { + AWS_ACCESS_KEY_ID, + EVENT_LABEL, + EVENT_ACTION, + ENVIRONMENT_SCOPE_LINK_TITLE, +} from '~/ci_variable_list/constants'; import createStore from '~/ci_variable_list/store'; import mockData from '../services/mock_data'; import ModalStub from '../stubs'; @@ -20,7 +25,11 @@ describe('Ci variable modal', () => { const maskableRegex = '^[a-zA-Z0-9_+=/@:.~-]{8,}$'; const createComponent = (method, options = {}) => { - store = createStore({ maskableRegex, isGroup: options.isGroup }); + store = createStore({ + maskableRegex, + isGroup: options.isGroup, + environmentScopeLink: '/help/environments', + }); wrapper = method(CiVariableModal, { attachTo: document.body, stubs: { @@ -213,6 +222,15 @@ describe('Ci variable modal', () => { }); }); }); + + it('renders a link to documentation on scopes', () => { + createComponent(mount); + + const link = wrapper.find('[data-testid="environment-scope-link"]'); + + expect(link.attributes('title')).toBe(ENVIRONMENT_SCOPE_LINK_TITLE); + expect(link.attributes('href')).toBe('/help/environments'); + }); }); describe('Validations', () => { diff --git a/spec/frontend/clusters/agents/components/revoke_token_button_spec.js b/spec/frontend/clusters/agents/components/revoke_token_button_spec.js new file mode 100644 index 00000000000..6521221cbd7 --- /dev/null +++ b/spec/frontend/clusters/agents/components/revoke_token_button_spec.js @@ -0,0 +1,239 @@ +import { GlButton, GlModal, GlFormInput, GlTooltip } from '@gitlab/ui'; +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 { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { ENTER_KEY } from '~/lib/utils/keys'; +import RevokeTokenButton from '~/clusters/agents/components/revoke_token_button.vue'; +import getClusterAgentQuery from '~/clusters/agents/graphql/queries/get_cluster_agent.query.graphql'; +import revokeTokenMutation from '~/clusters/agents/graphql/mutations/revoke_token.mutation.graphql'; +import { TOKEN_STATUS_ACTIVE, MAX_LIST_COUNT } from '~/clusters/agents/constants'; +import { getTokenResponse, mockRevokeResponse, mockErrorRevokeResponse } from '../../mock_data'; + +Vue.use(VueApollo); + +describe('RevokeTokenButton', () => { + let wrapper; + let toast; + let apolloProvider; + let revokeSpy; + + const token = { + id: 'token-id', + name: 'token-name', + }; + const cursor = { + first: MAX_LIST_COUNT, + last: null, + }; + const agentName = 'cluster-agent'; + const projectPath = 'path/to/project'; + + const defaultProvide = { + agentName, + projectPath, + canAdminCluster: true, + }; + const propsData = { + token, + cursor, + }; + + const findModal = () => wrapper.findComponent(GlModal); + const findRevokeBtn = () => wrapper.findComponent(GlButton); + const findInput = () => wrapper.findComponent(GlFormInput); + const findTooltip = () => wrapper.findComponent(GlTooltip); + const findPrimaryAction = () => findModal().props('actionPrimary'); + const findPrimaryActionAttributes = (attr) => findPrimaryAction().attributes[0][attr]; + + const createMockApolloProvider = ({ mutationResponse }) => { + revokeSpy = jest.fn().mockResolvedValue(mutationResponse); + + return createMockApollo([[revokeTokenMutation, revokeSpy]]); + }; + + const writeQuery = () => { + apolloProvider.clients.defaultClient.cache.writeQuery({ + query: getClusterAgentQuery, + variables: { + agentName, + projectPath, + tokenStatus: TOKEN_STATUS_ACTIVE, + ...cursor, + }, + data: getTokenResponse.data, + }); + }; + + const createWrapper = async ({ + mutationResponse = mockRevokeResponse, + provideData = {}, + } = {}) => { + apolloProvider = createMockApolloProvider({ mutationResponse }); + + toast = jest.fn(); + + wrapper = shallowMountExtended(RevokeTokenButton, { + apolloProvider, + provide: { + ...defaultProvide, + ...provideData, + }, + propsData, + stubs: { + GlModal, + GlTooltip, + }, + mocks: { $toast: { show: toast } }, + }); + wrapper.vm.$refs.modal.hide = jest.fn(); + + writeQuery(); + await nextTick(); + }; + + const submitTokenToRevoke = async () => { + findRevokeBtn().vm.$emit('click'); + findInput().vm.$emit('input', token.name); + await findModal().vm.$emit('primary'); + await waitForPromises(); + }; + + beforeEach(() => { + createWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + apolloProvider = null; + revokeSpy = null; + }); + + describe('revoke token action', () => { + it('displays a revoke button', () => { + expect(findRevokeBtn().attributes('aria-label')).toBe('Revoke token'); + }); + + describe('when user cannot revoke token', () => { + beforeEach(() => { + createWrapper({ provideData: { canAdminCluster: false } }); + }); + + it('disabled the button', () => { + expect(findRevokeBtn().attributes('disabled')).toBe('true'); + }); + + it('shows a disabled tooltip', () => { + expect(findTooltip().attributes('title')).toBe( + 'Requires a Maintainer or greater role to perform this action', + ); + }); + }); + + describe('when user can create a token and clicks the button', () => { + beforeEach(() => { + findRevokeBtn().vm.$emit('click'); + }); + + it('displays a delete confirmation modal', () => { + expect(findModal().isVisible()).toBe(true); + }); + + describe.each` + condition | tokenName | isDisabled | mutationCalled + ${'the input with token name is missing'} | ${''} | ${true} | ${false} + ${'the input with token name is incorrect'} | ${'wrong-name'} | ${true} | ${false} + ${'the input with token name is correct'} | ${token.name} | ${false} | ${true} + `('when $condition', ({ tokenName, isDisabled, mutationCalled }) => { + beforeEach(() => { + findRevokeBtn().vm.$emit('click'); + findInput().vm.$emit('input', tokenName); + }); + + it(`${isDisabled ? 'disables' : 'enables'} the modal primary button`, () => { + expect(findPrimaryActionAttributes('disabled')).toBe(isDisabled); + }); + + describe('when user clicks the modal primary button', () => { + beforeEach(async () => { + await findModal().vm.$emit('primary'); + }); + + if (mutationCalled) { + it('calls the revoke mutation', () => { + expect(revokeSpy).toHaveBeenCalledWith({ input: { id: token.id } }); + }); + } else { + it("doesn't call the revoke mutation", () => { + expect(revokeSpy).not.toHaveBeenCalled(); + }); + } + }); + + describe('when user presses the enter button', () => { + beforeEach(async () => { + await findInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); + }); + + if (mutationCalled) { + it('calls the revoke mutation', () => { + expect(revokeSpy).toHaveBeenCalledWith({ input: { id: token.id } }); + }); + } else { + it("doesn't call the revoke mutation", () => { + expect(revokeSpy).not.toHaveBeenCalled(); + }); + } + }); + }); + }); + + describe('when token was revoked successfully', () => { + beforeEach(async () => { + await submitTokenToRevoke(); + }); + + it('calls the toast action', () => { + expect(toast).toHaveBeenCalledWith(`${token.name} successfully revoked`); + }); + }); + + describe('when getting an error revoking token', () => { + beforeEach(async () => { + await createWrapper({ mutationResponse: mockErrorRevokeResponse }); + await submitTokenToRevoke(); + }); + + it('displays the error message', () => { + expect(toast).toHaveBeenCalledWith('could not revoke token'); + }); + }); + + describe('when the revoke modal was closed', () => { + beforeEach(async () => { + const loadingResponse = new Promise(() => {}); + await createWrapper({ mutationResponse: loadingResponse }); + await submitTokenToRevoke(); + }); + + it('reenables the button', async () => { + expect(findPrimaryActionAttributes('loading')).toBe(true); + expect(findRevokeBtn().attributes('disabled')).toBe('true'); + + await findModal().vm.$emit('hide'); + + expect(findPrimaryActionAttributes('loading')).toBe(false); + expect(findRevokeBtn().attributes('disabled')).toBeUndefined(); + }); + + it('clears the token name input', async () => { + expect(findInput().attributes('value')).toBe(token.name); + + await findModal().vm.$emit('hide'); + + expect(findInput().attributes('value')).toBeUndefined(); + }); + }); + }); +}); diff --git a/spec/frontend/clusters/clusters_bundle_spec.js b/spec/frontend/clusters/clusters_bundle_spec.js index 2a0610b1b0a..b5345ea8915 100644 --- a/spec/frontend/clusters/clusters_bundle_spec.js +++ b/spec/frontend/clusters/clusters_bundle_spec.js @@ -1,5 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; -import { loadHTMLFixture } from 'helpers/fixtures'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import { setTestTimeout } from 'helpers/timeout'; import Clusters from '~/clusters/clusters_bundle'; @@ -27,19 +27,17 @@ describe('Clusters', () => { beforeEach(() => { loadHTMLFixture('clusters/show_cluster.html'); - }); - beforeEach(() => { mockGetClusterStatusRequest(); - }); - beforeEach(() => { cluster = new Clusters(); }); afterEach(() => { cluster.destroy(); mock.restore(); + + resetHTMLFixture(); }); describe('class constructor', () => { diff --git a/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap index 0bec2a5934e..656e72baf77 100644 --- a/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap +++ b/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap @@ -3,7 +3,7 @@ exports[`NewCluster renders the cluster component correctly 1`] = ` "<div class=\\"gl-pt-4\\"> <h4>Enter your Kubernetes cluster certificate details</h4> - <p>Enter details about your cluster. <b-link-stub href=\\"/some/help/path\\" target=\\"_blank\\" event=\\"click\\" routertag=\\"a\\" class=\\"gl-link\\">How do I use a certificate to connect to my cluster?</b-link-stub> + <p>Enter details about your cluster. <b-link-stub href=\\"/help/user/project/clusters/add_existing_cluster\\" event=\\"click\\" routertag=\\"a\\" class=\\"gl-link\\">How do I use a certificate to connect to my cluster?</b-link-stub> </p> </div>" `; diff --git a/spec/frontend/clusters/components/new_cluster_spec.js b/spec/frontend/clusters/components/new_cluster_spec.js index b62e678154c..f9df70b9f87 100644 --- a/spec/frontend/clusters/components/new_cluster_spec.js +++ b/spec/frontend/clusters/components/new_cluster_spec.js @@ -2,15 +2,13 @@ import { GlLink, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import NewCluster from '~/clusters/components/new_cluster.vue'; -import createClusterStore from '~/clusters/stores/new_cluster'; +import { helpPagePath } from '~/helpers/help_page_helper'; describe('NewCluster', () => { - let store; let wrapper; const createWrapper = async () => { - store = createClusterStore({ clusterConnectHelpPath: '/some/help/path' }); - wrapper = shallowMount(NewCluster, { store, stubs: { GlLink, GlSprintf } }); + wrapper = shallowMount(NewCluster, { stubs: { GlLink, GlSprintf } }); await nextTick(); }; @@ -35,6 +33,8 @@ describe('NewCluster', () => { }); it('renders a valid help link set by the backend', () => { - expect(findLink().attributes('href')).toBe('/some/help/path'); + expect(findLink().attributes('href')).toBe( + helpPagePath('user/project/clusters/add_existing_cluster'), + ); }); }); diff --git a/spec/frontend/clusters/forms/components/integration_form_spec.js b/spec/frontend/clusters/forms/components/integration_form_spec.js index dd278bcd2ce..67d442bfdc5 100644 --- a/spec/frontend/clusters/forms/components/integration_form_spec.js +++ b/spec/frontend/clusters/forms/components/integration_form_spec.js @@ -22,7 +22,7 @@ describe('ClusterIntegrationForm', () => { store: createStore(storeValues), provide: { autoDevopsHelpPath: 'topics/autodevops/index', - externalEndpointHelpPath: 'user/clusters/applications.md', + externalEndpointHelpPath: 'user/project/clusters/index.md#base-domain', }, }); }; diff --git a/spec/frontend/create_cluster/gke_cluster_namespace/gke_cluster_namespace_spec.js b/spec/frontend/clusters/gke_cluster_namespace/gke_cluster_namespace_spec.js index c22167a078c..eeb876a608f 100644 --- a/spec/frontend/create_cluster/gke_cluster_namespace/gke_cluster_namespace_spec.js +++ b/spec/frontend/clusters/gke_cluster_namespace/gke_cluster_namespace_spec.js @@ -1,4 +1,5 @@ -import initGkeNamespace from '~/create_cluster/gke_cluster_namespace'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import initGkeNamespace from '~/clusters/gke_cluster_namespace'; describe('GKE cluster namespace', () => { const changeEvent = new Event('change'); @@ -10,7 +11,7 @@ describe('GKE cluster namespace', () => { let glManaged; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <input class="js-gl-managed" type="checkbox" value="1" checked /> <div class="js-namespace"> <input type="text" /> @@ -27,6 +28,10 @@ describe('GKE cluster namespace', () => { initGkeNamespace(); }); + afterEach(() => { + resetHTMLFixture(); + }); + describe('GKE cluster namespace toggles', () => { it('initially displays the GitLab-managed label and input', () => { expect(isHidden(glManaged)).toEqual(false); diff --git a/spec/frontend/clusters/mock_data.js b/spec/frontend/clusters/mock_data.js index 63840486d0d..f3736f03e03 100644 --- a/spec/frontend/clusters/mock_data.js +++ b/spec/frontend/clusters/mock_data.js @@ -220,3 +220,15 @@ export const getTokenResponse = { }, }, }; + +export const mockRevokeResponse = { + data: { clusterAgentTokenRevoke: { errors: [] } }, +}; + +export const mockErrorRevokeResponse = { + data: { + clusterAgentTokenRevoke: { + errors: ['could not revoke token'], + }, + }, +}; diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js index a466a35428a..2a43b45a2f5 100644 --- a/spec/frontend/clusters_list/components/agent_table_spec.js +++ b/spec/frontend/clusters_list/components/agent_table_spec.js @@ -13,6 +13,7 @@ const defaultConfigHelpUrl = const provideData = { gitlabVersion: '14.8', + kasVersion: '14.8', }; const propsData = { agents: clusterAgents, @@ -26,7 +27,7 @@ const outdatedTitle = I18N_AGENT_TABLE.versionOutdatedTitle; const mismatchTitle = I18N_AGENT_TABLE.versionMismatchTitle; const mismatchOutdatedTitle = I18N_AGENT_TABLE.versionMismatchOutdatedTitle; const outdatedText = sprintf(I18N_AGENT_TABLE.versionOutdatedText, { - version: provideData.gitlabVersion, + version: provideData.kasVersion, }); const mismatchText = I18N_AGENT_TABLE.versionMismatchText; diff --git a/spec/frontend/clusters_list/components/clusters_actions_spec.js b/spec/frontend/clusters_list/components/clusters_actions_spec.js index 21dcc66c639..f4ee3f93cb5 100644 --- a/spec/frontend/clusters_list/components/clusters_actions_spec.js +++ b/spec/frontend/clusters_list/components/clusters_actions_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem, GlTooltip } from '@gitlab/ui'; +import { GlButton, GlDropdown, GlDropdownItem, GlTooltip } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ClustersActions from '~/clusters_list/components/clusters_actions.vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; @@ -7,12 +7,10 @@ import { INSTALL_AGENT_MODAL_ID, CLUSTERS_ACTIONS } from '~/clusters_list/consta describe('ClustersActionsComponent', () => { let wrapper; - const newClusterPath = 'path/to/add/cluster'; const addClusterPath = 'path/to/connect/existing/cluster'; const newClusterDocsPath = 'path/to/create/new/cluster'; const defaultProvide = { - newClusterPath, addClusterPath, newClusterDocsPath, canAddCluster: true, @@ -20,13 +18,13 @@ describe('ClustersActionsComponent', () => { certificateBasedClustersEnabled: true, }; + const findButton = () => wrapper.findComponent(GlButton); const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); const findTooltip = () => wrapper.findComponent(GlTooltip); const findDropdownItemIds = () => findDropdownItems().wrappers.map((x) => x.attributes('data-testid')); const findDropdownItemTexts = () => findDropdownItems().wrappers.map((x) => x.text()); - const findNewClusterLink = () => wrapper.findByTestId('new-cluster-link'); const findNewClusterDocsLink = () => wrapper.findByTestId('create-cluster-link'); const findConnectClusterLink = () => wrapper.findByTestId('connect-cluster-link'); @@ -62,26 +60,6 @@ describe('ClustersActionsComponent', () => { expect(findTooltip().exists()).toBe(false); }); - describe('when user cannot add clusters', () => { - beforeEach(() => { - createWrapper({ canAddCluster: false }); - }); - - it('disables dropdown', () => { - expect(findDropdown().props('disabled')).toBe(true); - }); - - it('shows tooltip explaining why dropdown is disabled', () => { - expect(findTooltip().attributes('title')).toBe(CLUSTERS_ACTIONS.dropdownDisabledHint); - }); - - it('does not bind split dropdown button', () => { - const binding = getBinding(findDropdown().element, 'gl-modal-directive'); - - expect(binding.value).toBe(false); - }); - }); - describe('when on project level', () => { it(`displays default action as ${CLUSTERS_ACTIONS.connectWithAgent}`, () => { expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.connectWithAgent); @@ -93,27 +71,41 @@ describe('ClustersActionsComponent', () => { expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID); }); - it('renders a dropdown with 3 actions items', () => { - expect(findDropdownItemIds()).toEqual([ - 'create-cluster-link', - 'new-cluster-link', - 'connect-cluster-link', - ]); + it('renders a dropdown with 2 actions items', () => { + expect(findDropdownItemIds()).toEqual(['create-cluster-link', 'connect-cluster-link']); }); it('renders correct texts for the dropdown items', () => { expect(findDropdownItemTexts()).toEqual([ CLUSTERS_ACTIONS.createCluster, - CLUSTERS_ACTIONS.createClusterCertificate, CLUSTERS_ACTIONS.connectClusterCertificate, ]); }); it('renders correct href attributes for the links', () => { expect(findNewClusterDocsLink().attributes('href')).toBe(newClusterDocsPath); - expect(findNewClusterLink().attributes('href')).toBe(newClusterPath); expect(findConnectClusterLink().attributes('href')).toBe(addClusterPath); }); + + describe('when user cannot add clusters', () => { + beforeEach(() => { + createWrapper({ canAddCluster: false }); + }); + + it('disables dropdown', () => { + expect(findDropdown().props('disabled')).toBe(true); + }); + + it('shows tooltip explaining why dropdown is disabled', () => { + expect(findTooltip().attributes('title')).toBe(CLUSTERS_ACTIONS.actionsDisabledHint); + }); + + it('does not bind split dropdown button', () => { + const binding = getBinding(findDropdown().element, 'gl-modal-directive'); + + expect(binding.value).toBe(false); + }); + }); }); describe('when on group or admin level', () => { @@ -121,26 +113,34 @@ describe('ClustersActionsComponent', () => { createWrapper({ displayClusterAgents: false }); }); - it(`displays default action as ${CLUSTERS_ACTIONS.connectClusterDeprecated}`, () => { - expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.connectClusterDeprecated); + it("doesn't render a dropdown", () => { + expect(findDropdown().exists()).toBe(false); }); - it('renders a dropdown with 1 action item', () => { - expect(findDropdownItemIds()).toEqual(['new-cluster-link']); + it('render an action button', () => { + expect(findButton().exists()).toBe(true); }); - it('renders correct text for the dropdown item', () => { - expect(findDropdownItemTexts()).toEqual([CLUSTERS_ACTIONS.createClusterDeprecated]); + it(`displays default action as ${CLUSTERS_ACTIONS.connectClusterDeprecated}`, () => { + expect(findButton().text()).toBe(CLUSTERS_ACTIONS.connectClusterDeprecated); }); - it('renders correct href attributes for the links', () => { - expect(findNewClusterLink().attributes('href')).toBe(newClusterPath); + it('renders correct href attribute for the button', () => { + expect(findButton().attributes('href')).toBe(addClusterPath); }); - it('does not bind dropdown button to modal', () => { - const binding = getBinding(findDropdown().element, 'gl-modal-directive'); + describe('when user cannot add clusters', () => { + beforeEach(() => { + createWrapper({ displayClusterAgents: false, canAddCluster: false }); + }); + + it('disables action button', () => { + expect(findButton().props('disabled')).toBe(true); + }); - expect(binding.value).toBe(false); + it('shows tooltip explaining why dropdown is disabled', () => { + expect(findTooltip().attributes('title')).toBe(CLUSTERS_ACTIONS.actionsDisabledHint); + }); }); }); }); diff --git a/spec/frontend/code_navigation/components/app_spec.js b/spec/frontend/code_navigation/components/app_spec.js index f2f97092c5a..b85047dc816 100644 --- a/spec/frontend/code_navigation/components/app_spec.js +++ b/spec/frontend/code_navigation/components/app_spec.js @@ -1,6 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; + +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import App from '~/code_navigation/components/app.vue'; import Popover from '~/code_navigation/components/popover.vue'; import createState from '~/code_navigation/store/state'; @@ -75,12 +77,14 @@ describe('Code navigation app component', () => { }); it('calls showDefinition when clicking blob viewer', () => { - setFixtures('<div class="blob-viewer"></div>'); + setHTMLFixture('<div class="blob-viewer"></div>'); factory(); document.querySelector('.blob-viewer').click(); expect(showDefinition).toHaveBeenCalled(); + + resetHTMLFixture(); }); }); diff --git a/spec/frontend/code_navigation/store/actions_spec.js b/spec/frontend/code_navigation/store/actions_spec.js index c26416aca94..c47a9e697b6 100644 --- a/spec/frontend/code_navigation/store/actions_spec.js +++ b/spec/frontend/code_navigation/store/actions_spec.js @@ -1,4 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import testAction from 'helpers/vuex_action_helper'; import actions from '~/code_navigation/store/actions'; import { setCurrentHoverElement, addInteractionClass } from '~/code_navigation/utils'; @@ -174,12 +175,16 @@ describe('Code navigation actions', () => { let target; beforeEach(() => { - setFixtures( + setHTMLFixture( '<div data-path="index.js"><div class="line"><div class="js-test"></div></div></div>', ); target = document.querySelector('.js-test'); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('returns early when no data exists', () => { return testAction(actions.showDefinition, { target }, {}, [], []); }); diff --git a/spec/frontend/code_navigation/utils/index_spec.js b/spec/frontend/code_navigation/utils/index_spec.js index 682c8bce8c5..b8448709f0b 100644 --- a/spec/frontend/code_navigation/utils/index_spec.js +++ b/spec/frontend/code_navigation/utils/index_spec.js @@ -1,3 +1,4 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { cachedData, getCurrentHoverElement, @@ -35,11 +36,15 @@ describe('setCurrentHoverElement', () => { describe('addInteractionClass', () => { beforeEach(() => { - setFixtures( + setHTMLFixture( '<div data-path="index.js"><div class="blob-content"><div id="LC1" class="line"><span>console</span><span>.</span><span>log</span></div><div id="LC2" class="line"><span>function</span></div></div></div>', ); }); + afterEach(() => { + resetHTMLFixture(); + }); + it.each` line | char | index ${0} | ${0} | ${0} @@ -59,7 +64,7 @@ describe('addInteractionClass', () => { describe('wrapTextNodes', () => { beforeEach(() => { - setFixtures( + setHTMLFixture( '<div data-path="index.js"><div class="blob-content"><div id="LC1" class="line"> Text </div></div></div>', ); }); diff --git a/spec/frontend/commits_spec.js b/spec/frontend/commits_spec.js index a049a6997f0..db1516ed4ec 100644 --- a/spec/frontend/commits_spec.js +++ b/spec/frontend/commits_spec.js @@ -1,6 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import 'vendor/jquery.endless-scroll'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import CommitsList from '~/commits'; import axios from '~/lib/utils/axios_utils'; import Pager from '~/pager'; @@ -9,7 +10,7 @@ describe('Commits List', () => { let commitsList; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <form class="commits-search-form" action="/h5bp/html5-boilerplate/commits/main"> <input id="commits-search"> </form> @@ -19,6 +20,10 @@ describe('Commits List', () => { commitsList = new CommitsList(25); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('should be defined', () => { expect(CommitsList).toBeDefined(); }); diff --git a/spec/frontend/commons/nav/user_merge_requests_spec.js b/spec/frontend/commons/nav/user_merge_requests_spec.js index 8f974051232..f660cc8e9de 100644 --- a/spec/frontend/commons/nav/user_merge_requests_spec.js +++ b/spec/frontend/commons/nav/user_merge_requests_spec.js @@ -1,3 +1,4 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import * as UserApi from '~/api/user_api'; import { openUserCountsBroadcast, @@ -24,11 +25,15 @@ describe('User Merge Requests', () => { newBroadcastChannelMock = jest.fn().mockImplementation(() => channelMock); global.BroadcastChannel = newBroadcastChannelMock; - setFixtures( + setHTMLFixture( `<div><div class="${MR_COUNT_CLASS}">0</div><div class="js-assigned-mr-count"></div><div class="js-reviewer-mr-count"></div></div>`, ); }); + afterEach(() => { + resetHTMLFixture(); + }); + const findMRCountText = () => document.body.querySelector(`.${MR_COUNT_CLASS}`).textContent; describe('refreshUserMergeRequestCounts', () => { diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap index e508cddd6f9..a63cca006da 100644 --- a/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap +++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`content_editor/components/toolbar_button displays tertiary, small button with a provided label and icon 1`] = ` -"<b-button-stub size=\\"sm\\" variant=\\"default\\" type=\\"button\\" tag=\\"button\\" aria-label=\\"Bold\\" title=\\"Bold\\" class=\\"gl-button btn-default-tertiary btn-icon\\"> +exports[`content_editor/components/toolbar_button displays tertiary, medium button with a provided label and icon 1`] = ` +"<b-button-stub size=\\"md\\" variant=\\"default\\" type=\\"button\\" tag=\\"button\\" aria-label=\\"Bold\\" title=\\"Bold\\" class=\\"gl-button btn-default-tertiary btn-icon\\"> <!----> <gl-icon-stub name=\\"bold\\" size=\\"16\\" class=\\"gl-button-icon\\"></gl-icon-stub> <!----> diff --git a/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js index 074c311495f..3a15ea45f40 100644 --- a/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js @@ -1,14 +1,14 @@ import { BubbleMenu } from '@tiptap/vue-2'; -import { GlButton, GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; import Vue from 'vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import CodeBlockBubbleMenu from '~/content_editor/components/code_block_bubble_menu.vue'; +import CodeBlockBubbleMenu from '~/content_editor/components/bubble_menus/code_block.vue'; import eventHubFactory from '~/helpers/event_hub_factory'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader'; -import { createTestEditor, emitEditorEvent } from '../test_utils'; +import { createTestEditor, emitEditorEvent } from '../../test_utils'; -describe('content_editor/components/code_block_bubble_menu', () => { +describe('content_editor/components/bubble_menus/code_block', () => { let wrapper; let tiptapEditor; let bubbleMenu; @@ -52,7 +52,7 @@ describe('content_editor/components/code_block_bubble_menu', () => { await emitEditorEvent({ event: 'transaction', tiptapEditor }); expect(bubbleMenu.props('editor')).toBe(tiptapEditor); - expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base']); + expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']); }); it('selects plaintext language by default', async () => { @@ -82,12 +82,26 @@ describe('content_editor/components/code_block_bubble_menu', () => { expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Custom (nomnoml)'); }); - it('delete button deletes the code block', async () => { - tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); + describe('copy button', () => { + it('copies the text of the code block', async () => { + jest.spyOn(navigator.clipboard, 'writeText'); + + tiptapEditor.commands.insertContent('<pre lang="javascript">var a = Math.PI / 2;</pre>'); + + await wrapper.findByTestId('copy-code-block').vm.$emit('click'); - await wrapper.findComponent(GlButton).vm.$emit('click'); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('var a = Math.PI / 2;'); + }); + }); - expect(tiptapEditor.getText()).toBe(''); + describe('delete button', () => { + it('deletes the code block', async () => { + tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); + + await wrapper.findByTestId('delete-code-block').vm.$emit('click'); + + expect(tiptapEditor.getText()).toBe(''); + }); }); describe('when opened and search is changed', () => { @@ -110,7 +124,7 @@ describe('content_editor/components/code_block_bubble_menu', () => { describe('when dropdown item is clicked', () => { beforeEach(async () => { - jest.spyOn(codeBlockLanguageLoader, 'loadLanguages').mockResolvedValue(); + jest.spyOn(codeBlockLanguageLoader, 'loadLanguage').mockResolvedValue(); findDropdownItems().at(1).vm.$emit('click'); @@ -118,7 +132,7 @@ describe('content_editor/components/code_block_bubble_menu', () => { }); it('loads language', () => { - expect(codeBlockLanguageLoader.loadLanguages).toHaveBeenCalledWith(['java']); + expect(codeBlockLanguageLoader.loadLanguage).toHaveBeenCalledWith('java'); }); it('sets code block', () => { diff --git a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/formatting_spec.js index 192ddee78c6..6479c0ba008 100644 --- a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/formatting_spec.js @@ -1,15 +1,15 @@ import { BubbleMenu } from '@tiptap/vue-2'; import { mockTracking } from 'helpers/tracking_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import FormattingBubbleMenu from '~/content_editor/components/formatting_bubble_menu.vue'; +import FormattingBubbleMenu from '~/content_editor/components/bubble_menus/formatting.vue'; import { BUBBLE_MENU_TRACKING_ACTION, CONTENT_EDITOR_TRACKING_LABEL, } from '~/content_editor/constants'; -import { createTestEditor } from '../test_utils'; +import { createTestEditor } from '../../test_utils'; -describe('content_editor/components/formatting_bubble_menu', () => { +describe('content_editor/components/bubble_menus/formatting', () => { let wrapper; let trackingSpy; let tiptapEditor; @@ -42,15 +42,16 @@ describe('content_editor/components/formatting_bubble_menu', () => { const bubbleMenu = wrapper.findComponent(BubbleMenu); expect(bubbleMenu.props().editor).toBe(tiptapEditor); - expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base']); + expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']); }); describe.each` testId | controlProps - ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold', size: 'medium', category: 'primary' }} - ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic', size: 'medium', category: 'primary' }} - ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike', size: 'medium', category: 'primary' }} - ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode', size: 'medium', category: 'primary' }} + ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold', size: 'medium', category: 'tertiary' }} + ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic', size: 'medium', category: 'tertiary' }} + ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike', size: 'medium', category: 'tertiary' }} + ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode', size: 'medium', category: 'tertiary' }} + ${'link'} | ${{ contentType: 'link', iconName: 'link', label: 'Insert link', editorCommand: 'toggleLink', editorCommandParams: { href: '' }, size: 'medium', category: 'tertiary' }} `('given a $testId toolbar control', ({ testId, controlProps }) => { beforeEach(() => { buildWrapper(); @@ -60,7 +61,7 @@ describe('content_editor/components/formatting_bubble_menu', () => { expect(wrapper.findByTestId(testId).exists()).toBe(true); Object.keys(controlProps).forEach((propName) => { - expect(wrapper.findByTestId(testId).props(propName)).toBe(controlProps[propName]); + expect(wrapper.findByTestId(testId).props(propName)).toEqual(controlProps[propName]); }); }); diff --git a/spec/frontend/content_editor/components/bubble_menus/link_spec.js b/spec/frontend/content_editor/components/bubble_menus/link_spec.js new file mode 100644 index 00000000000..ba6d8da9584 --- /dev/null +++ b/spec/frontend/content_editor/components/bubble_menus/link_spec.js @@ -0,0 +1,227 @@ +import { GlLink, GlForm } from '@gitlab/ui'; +import { BubbleMenu } from '@tiptap/vue-2'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import LinkBubbleMenu from '~/content_editor/components/bubble_menus/link.vue'; +import eventHubFactory from '~/helpers/event_hub_factory'; +import Link from '~/content_editor/extensions/link'; +import { createTestEditor, emitEditorEvent } from '../../test_utils'; + +const createFakeEvent = () => ({ preventDefault: jest.fn(), stopPropagation: jest.fn() }); + +describe('content_editor/components/bubble_menus/link', () => { + let wrapper; + let tiptapEditor; + let contentEditor; + let bubbleMenu; + let eventHub; + + const buildEditor = () => { + tiptapEditor = createTestEditor({ extensions: [Link] }); + contentEditor = { resolveUrl: jest.fn() }; + eventHub = eventHubFactory(); + }; + + const buildWrapper = () => { + wrapper = mountExtended(LinkBubbleMenu, { + provide: { + tiptapEditor, + contentEditor, + eventHub, + }, + }); + }; + + const expectLinkButtonsToExist = (exist = true) => { + expect(wrapper.findComponent(GlLink).exists()).toBe(exist); + expect(wrapper.findByTestId('copy-link-url').exists()).toBe(exist); + expect(wrapper.findByTestId('edit-link').exists()).toBe(exist); + expect(wrapper.findByTestId('remove-link').exists()).toBe(exist); + }; + + beforeEach(async () => { + buildEditor(); + buildWrapper(); + + tiptapEditor + .chain() + .insertContent( + 'Download <a href="/path/to/project/-/wikis/uploads/my_file.pdf" data-canonical-src="uploads/my_file.pdf" title="Click here to download">PDF File</a>', + ) + .setTextSelection(14) // put cursor in the middle of the link + .run(); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + bubbleMenu = wrapper.findComponent(BubbleMenu); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders bubble menu component', async () => { + expect(bubbleMenu.props('editor')).toBe(tiptapEditor); + expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']); + }); + + it('shows a clickable link to the URL in the link node', async () => { + const link = wrapper.findComponent(GlLink); + expect(link.attributes()).toEqual( + expect.objectContaining({ + href: '/path/to/project/-/wikis/uploads/my_file.pdf', + 'aria-label': 'uploads/my_file.pdf', + title: 'uploads/my_file.pdf', + target: '_blank', + }), + ); + expect(link.text()).toBe('uploads/my_file.pdf'); + }); + + describe('copy button', () => { + it('copies the canonical link to clipboard', async () => { + jest.spyOn(navigator.clipboard, 'writeText'); + + await wrapper.findByTestId('copy-link-url').vm.$emit('click'); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('uploads/my_file.pdf'); + }); + }); + + describe('remove link button', () => { + it('removes the link', async () => { + await wrapper.findByTestId('remove-link').vm.$emit('click'); + + expect(tiptapEditor.getHTML()).toBe('<p>Download PDF File</p>'); + }); + }); + + describe('for a placeholder link', () => { + beforeEach(async () => { + tiptapEditor + .chain() + .clearContent() + .insertContent('Dummy link') + .selectAll() + .setLink({ href: '' }) + .setTextSelection(4) + .run(); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + }); + + it('directly opens the edit form for a placeholder link', async () => { + expectLinkButtonsToExist(false); + + expect(wrapper.findComponent(GlForm).exists()).toBe(true); + }); + + it('removes the link on clicking apply (if no change)', async () => { + await wrapper.findComponent(GlForm).vm.$emit('submit', createFakeEvent()); + + expect(tiptapEditor.getHTML()).toBe('<p>Dummy link</p>'); + }); + + it('removes the link on clicking cancel', async () => { + await wrapper.findByTestId('cancel-link').vm.$emit('click'); + + expect(tiptapEditor.getHTML()).toBe('<p>Dummy link</p>'); + }); + }); + + describe('edit button', () => { + let linkHrefInput; + let linkTitleInput; + + beforeEach(async () => { + await wrapper.findByTestId('edit-link').vm.$emit('click'); + + linkHrefInput = wrapper.findByTestId('link-href'); + linkTitleInput = wrapper.findByTestId('link-title'); + }); + + it('hides the link and copy/edit/remove link buttons', async () => { + expectLinkButtonsToExist(false); + }); + + it('shows a form to edit the link', () => { + expect(wrapper.findComponent(GlForm).exists()).toBe(true); + + expect(linkHrefInput.element.value).toBe('uploads/my_file.pdf'); + expect(linkTitleInput.element.value).toBe('Click here to download'); + }); + + it('extends selection to select the entire link', () => { + const { from, to } = tiptapEditor.state.selection; + + expect(from).toBe(10); + expect(to).toBe(18); + }); + + it('shows the copy/edit/remove link buttons again if selection changes to another non-link and then back again to a link', async () => { + expectLinkButtonsToExist(false); + + tiptapEditor.commands.setTextSelection(3); + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + tiptapEditor.commands.setTextSelection(14); + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expectLinkButtonsToExist(true); + expect(wrapper.findComponent(GlForm).exists()).toBe(false); + }); + + describe('after making changes in the form and clicking apply', () => { + beforeEach(async () => { + linkHrefInput.setValue('https://google.com'); + linkTitleInput.setValue('Search Google'); + + contentEditor.resolveUrl.mockResolvedValue('https://google.com'); + + await wrapper.findComponent(GlForm).vm.$emit('submit', createFakeEvent()); + }); + + it('updates prosemirror doc with new link', async () => { + expect(tiptapEditor.getHTML()).toBe( + '<p>Download <a target="_blank" rel="noopener noreferrer nofollow" href="https://google.com" title="Search Google" canonicalsrc="https://google.com">PDF File</a></p>', + ); + }); + + it('updates the link in the bubble menu', () => { + const link = wrapper.findComponent(GlLink); + expect(link.attributes()).toEqual( + expect.objectContaining({ + href: 'https://google.com', + 'aria-label': 'https://google.com', + title: 'https://google.com', + target: '_blank', + }), + ); + expect(link.text()).toBe('https://google.com'); + }); + }); + + describe('after making changes in the form and clicking cancel', () => { + beforeEach(async () => { + linkHrefInput.setValue('https://google.com'); + linkTitleInput.setValue('Search Google'); + + await wrapper.findByTestId('cancel-link').vm.$emit('click'); + }); + + it('hides the form and shows the copy/edit/remove link buttons', () => { + expectLinkButtonsToExist(); + }); + + it('resets the form with old values of the link from prosemirror', async () => { + // click edit once again to show the form back + await wrapper.findByTestId('edit-link').vm.$emit('click'); + + linkHrefInput = wrapper.findByTestId('link-href'); + linkTitleInput = wrapper.findByTestId('link-title'); + + expect(linkHrefInput.element.value).toBe('uploads/my_file.pdf'); + expect(linkTitleInput.element.value).toBe('Click here to download'); + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/bubble_menus/media_spec.js b/spec/frontend/content_editor/components/bubble_menus/media_spec.js new file mode 100644 index 00000000000..8839caea80e --- /dev/null +++ b/spec/frontend/content_editor/components/bubble_menus/media_spec.js @@ -0,0 +1,234 @@ +import { GlLink, GlForm } from '@gitlab/ui'; +import { BubbleMenu } from '@tiptap/vue-2'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import MediaBubbleMenu from '~/content_editor/components/bubble_menus/media.vue'; +import eventHubFactory from '~/helpers/event_hub_factory'; +import Image from '~/content_editor/extensions/image'; +import Audio from '~/content_editor/extensions/audio'; +import Video from '~/content_editor/extensions/video'; +import { createTestEditor, emitEditorEvent, mockChainedCommands } from '../../test_utils'; +import { + PROJECT_WIKI_ATTACHMENT_IMAGE_HTML, + PROJECT_WIKI_ATTACHMENT_AUDIO_HTML, + PROJECT_WIKI_ATTACHMENT_VIDEO_HTML, +} from '../../test_constants'; + +const TIPTAP_IMAGE_HTML = `<p> + <img src="https://gitlab.com/favicon.png" alt="gitlab favicon" title="gitlab favicon" data-canonical-src="https://gitlab.com/favicon.png"> +</p>`; + +const TIPTAP_AUDIO_HTML = `<p> + <span class="media-container audio-container"><audio src="https://gitlab.com/favicon.png" controls="true" data-setup="{}" data-title="gitlab favicon"></audio><a href="https://gitlab.com/favicon.png">gitlab favicon</a></span> +</p>`; + +const TIPTAP_VIDEO_HTML = `<p> + <span class="media-container video-container"><video src="https://gitlab.com/favicon.png" controls="true" data-setup="{}" data-title="gitlab favicon"></video><a href="https://gitlab.com/favicon.png">gitlab favicon</a></span> +</p>`; + +const createFakeEvent = () => ({ preventDefault: jest.fn(), stopPropagation: jest.fn() }); + +describe.each` + mediaType | mediaHTML | filePath | mediaOutputHTML + ${'image'} | ${PROJECT_WIKI_ATTACHMENT_IMAGE_HTML} | ${'test-file.png'} | ${TIPTAP_IMAGE_HTML} + ${'audio'} | ${PROJECT_WIKI_ATTACHMENT_AUDIO_HTML} | ${'test-file.mp3'} | ${TIPTAP_AUDIO_HTML} + ${'video'} | ${PROJECT_WIKI_ATTACHMENT_VIDEO_HTML} | ${'test-file.mp4'} | ${TIPTAP_VIDEO_HTML} +`( + 'content_editor/components/bubble_menus/media ($mediaType)', + ({ mediaType, mediaHTML, filePath, mediaOutputHTML }) => { + let wrapper; + let tiptapEditor; + let contentEditor; + let bubbleMenu; + let eventHub; + + const buildEditor = () => { + tiptapEditor = createTestEditor({ extensions: [Image, Audio, Video] }); + contentEditor = { resolveUrl: jest.fn() }; + eventHub = eventHubFactory(); + }; + + const buildWrapper = () => { + wrapper = mountExtended(MediaBubbleMenu, { + provide: { + tiptapEditor, + contentEditor, + eventHub, + }, + }); + }; + + const selectFile = async (file) => { + const input = wrapper.find({ ref: 'fileSelector' }); + + // override the property definition because `input.files` isn't directly modifyable + Object.defineProperty(input.element, 'files', { value: [file], writable: true }); + await input.trigger('change'); + }; + + const expectLinkButtonsToExist = (exist = true) => { + expect(wrapper.findComponent(GlLink).exists()).toBe(exist); + expect(wrapper.findByTestId('copy-media-src').exists()).toBe(exist); + expect(wrapper.findByTestId('edit-media').exists()).toBe(exist); + expect(wrapper.findByTestId('delete-media').exists()).toBe(exist); + }; + + beforeEach(async () => { + buildEditor(); + buildWrapper(); + + tiptapEditor + .chain() + .insertContent(mediaHTML) + .setNodeSelection(4) // select the media + .run(); + + contentEditor.resolveUrl.mockResolvedValue(`/group1/project1/-/wikis/${filePath}`); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + bubbleMenu = wrapper.findComponent(BubbleMenu); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders bubble menu component', async () => { + expect(bubbleMenu.props('editor')).toBe(tiptapEditor); + expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']); + }); + + it('shows a clickable link to the image', async () => { + const link = wrapper.findComponent(GlLink); + expect(link.attributes()).toEqual( + expect.objectContaining({ + href: `/group1/project1/-/wikis/${filePath}`, + 'aria-label': filePath, + title: filePath, + target: '_blank', + }), + ); + expect(link.text()).toBe(filePath); + }); + + describe('copy button', () => { + it(`copies the canonical link to the ${mediaType} to clipboard`, async () => { + jest.spyOn(navigator.clipboard, 'writeText'); + + await wrapper.findByTestId('copy-media-src').vm.$emit('click'); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(filePath); + }); + }); + + describe(`remove ${mediaType} button`, () => { + it(`removes the ${mediaType}`, async () => { + await wrapper.findByTestId('delete-media').vm.$emit('click'); + + expect(tiptapEditor.getHTML()).toBe('<p>\n \n</p>'); + }); + }); + + describe(`replace ${mediaType} button`, () => { + it('uploads and replaces the selected image when file input changes', async () => { + const commands = mockChainedCommands(tiptapEditor, [ + 'focus', + 'deleteSelection', + 'uploadAttachment', + 'run', + ]); + const file = new File(['foo'], 'foo.png', { type: 'image/png' }); + + await wrapper.findByTestId('replace-media').vm.$emit('click'); + await selectFile(file); + + expect(commands.focus).toHaveBeenCalled(); + expect(commands.deleteSelection).toHaveBeenCalled(); + expect(commands.uploadAttachment).toHaveBeenCalledWith({ file }); + expect(commands.run).toHaveBeenCalled(); + }); + }); + + describe('edit button', () => { + let mediaSrcInput; + let mediaTitleInput; + let mediaAltInput; + + beforeEach(async () => { + await wrapper.findByTestId('edit-media').vm.$emit('click'); + + mediaSrcInput = wrapper.findByTestId('media-src'); + mediaTitleInput = wrapper.findByTestId('media-title'); + mediaAltInput = wrapper.findByTestId('media-alt'); + }); + + it('hides the link and copy/edit/remove link buttons', async () => { + expectLinkButtonsToExist(false); + }); + + it(`shows a form to edit the ${mediaType} src/title/alt`, () => { + expect(wrapper.findComponent(GlForm).exists()).toBe(true); + + expect(mediaSrcInput.element.value).toBe(filePath); + expect(mediaTitleInput.element.value).toBe(''); + expect(mediaAltInput.element.value).toBe('test-file'); + }); + + describe('after making changes in the form and clicking apply', () => { + beforeEach(async () => { + mediaSrcInput.setValue('https://gitlab.com/favicon.png'); + mediaAltInput.setValue('gitlab favicon'); + mediaTitleInput.setValue('gitlab favicon'); + + contentEditor.resolveUrl.mockResolvedValue('https://gitlab.com/favicon.png'); + + await wrapper.findComponent(GlForm).vm.$emit('submit', createFakeEvent()); + }); + + it(`updates prosemirror doc with new src to the ${mediaType}`, async () => { + expect(tiptapEditor.getHTML()).toBe(mediaOutputHTML); + }); + + it(`updates the link to the ${mediaType} in the bubble menu`, () => { + const link = wrapper.findComponent(GlLink); + expect(link.attributes()).toEqual( + expect.objectContaining({ + href: 'https://gitlab.com/favicon.png', + 'aria-label': 'https://gitlab.com/favicon.png', + title: 'https://gitlab.com/favicon.png', + target: '_blank', + }), + ); + expect(link.text()).toBe('https://gitlab.com/favicon.png'); + }); + }); + + describe('after making changes in the form and clicking cancel', () => { + beforeEach(async () => { + mediaSrcInput.setValue('https://gitlab.com/favicon.png'); + mediaAltInput.setValue('gitlab favicon'); + mediaTitleInput.setValue('gitlab favicon'); + + await wrapper.findByTestId('cancel-editing-media').vm.$emit('click'); + }); + + it('hides the form and shows the copy/edit/remove link buttons', () => { + expectLinkButtonsToExist(); + }); + + it(`resets the form with old values of the ${mediaType} from prosemirror`, async () => { + // click edit once again to show the form back + await wrapper.findByTestId('edit-media').vm.$emit('click'); + + mediaSrcInput = wrapper.findByTestId('media-src'); + mediaTitleInput = wrapper.findByTestId('media-title'); + mediaAltInput = wrapper.findByTestId('media-alt'); + + expect(mediaSrcInput.element.value).toBe(filePath); + expect(mediaAltInput.element.value).toBe('test-file'); + expect(mediaTitleInput.element.value).toBe(''); + }); + }); + }); + }, +); diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index 73fcfeab8bc..9ee3b017831 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -4,7 +4,7 @@ import ContentEditor from '~/content_editor/components/content_editor.vue'; import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue'; import ContentEditorProvider from '~/content_editor/components/content_editor_provider.vue'; import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; -import FormattingBubbleMenu from '~/content_editor/components/formatting_bubble_menu.vue'; +import FormattingBubbleMenu from '~/content_editor/components/bubble_menus/formatting.vue'; import TopToolbar from '~/content_editor/components/top_toolbar.vue'; import LoadingIndicator from '~/content_editor/components/loading_indicator.vue'; import { emitEditorEvent } from '../test_utils'; diff --git a/spec/frontend/content_editor/components/toolbar_button_spec.js b/spec/frontend/content_editor/components/toolbar_button_spec.js index ce50482302d..1f1f7b338c6 100644 --- a/spec/frontend/content_editor/components/toolbar_button_spec.js +++ b/spec/frontend/content_editor/components/toolbar_button_spec.js @@ -46,7 +46,7 @@ describe('content_editor/components/toolbar_button', () => { wrapper.destroy(); }); - it('displays tertiary, small button with a provided label and icon', () => { + it('displays tertiary, medium button with a provided label and icon', () => { buildWrapper(); expect(findButton().html()).toMatchSnapshot(); diff --git a/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js b/spec/frontend/content_editor/components/wrappers/code_block_spec.js index 415f1314a36..a564959a3a6 100644 --- a/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js +++ b/spec/frontend/content_editor/components/wrappers/code_block_spec.js @@ -1,20 +1,33 @@ +import { nextTick } from 'vue'; import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; import { shallowMount } from '@vue/test-utils'; -import FrontmatterWrapper from '~/content_editor/components/wrappers/frontmatter.vue'; +import CodeBlockWrapper from '~/content_editor/components/wrappers/code_block.vue'; +import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader'; -describe('content/components/wrappers/frontmatter', () => { +jest.mock('~/content_editor/services/code_block_language_loader'); + +describe('content/components/wrappers/code_block', () => { + const language = 'yaml'; let wrapper; + let updateAttributesFn; + + const createWrapper = async (nodeAttrs = { language }) => { + updateAttributesFn = jest.fn(); - const createWrapper = async (nodeAttrs = { language: 'yaml' }) => { - wrapper = shallowMount(FrontmatterWrapper, { + wrapper = shallowMount(CodeBlockWrapper, { propsData: { node: { attrs: nodeAttrs, }, + updateAttributes: updateAttributesFn, }, }); }; + beforeEach(() => { + codeBlockLanguageLoader.findLanguageBySyntax.mockReturnValue({ syntax: language }); + }); + afterEach(() => { wrapper.destroy(); }); @@ -38,11 +51,21 @@ describe('content/components/wrappers/frontmatter', () => { }); it('renders label indicating that code block is frontmatter', () => { - createWrapper(); + createWrapper({ isFrontmatter: true, language }); const label = wrapper.find('[data-testid="frontmatter-label"]'); expect(label.text()).toEqual('frontmatter:yaml'); expect(label.classes()).toEqual(['gl-absolute', 'gl-top-0', 'gl-right-3']); }); + + it('loads code block’s syntax highlight language', async () => { + createWrapper(); + + expect(codeBlockLanguageLoader.loadLanguage).toHaveBeenCalledWith(language); + + await nextTick(); + + expect(updateAttributesFn).toHaveBeenCalledWith({ language }); + }); }); diff --git a/spec/frontend/content_editor/components/wrappers/media_spec.js b/spec/frontend/content_editor/components/wrappers/media_spec.js deleted file mode 100644 index 3e95e2f3914..00000000000 --- a/spec/frontend/content_editor/components/wrappers/media_spec.js +++ /dev/null @@ -1,69 +0,0 @@ -import { GlLoadingIcon } from '@gitlab/ui'; -import { NodeViewWrapper } from '@tiptap/vue-2'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import MediaWrapper from '~/content_editor/components/wrappers/media.vue'; - -describe('content/components/wrappers/media', () => { - let wrapper; - - const createWrapper = async (nodeAttrs = {}) => { - wrapper = shallowMountExtended(MediaWrapper, { - propsData: { - node: { - attrs: nodeAttrs, - type: { - name: 'image', - }, - }, - }, - }); - }; - const findMedia = () => wrapper.findByTestId('media'); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders a node-view-wrapper with display-inline-block class', () => { - createWrapper(); - - expect(wrapper.findComponent(NodeViewWrapper).classes()).toContain('gl-display-inline-block'); - }); - - it('renders an image that displays the node src', () => { - const src = 'foobar.png'; - - createWrapper({ src }); - - expect(findMedia().attributes().src).toBe(src); - }); - - describe('when uploading', () => { - beforeEach(() => { - createWrapper({ uploading: true }); - }); - - it('renders a gl-loading-icon component', () => { - expect(findLoadingIcon().exists()).toBe(true); - }); - - it('adds gl-opacity-5 class selector to the media tag', () => { - expect(findMedia().classes()).toContain('gl-opacity-5'); - }); - }); - - describe('when not uploading', () => { - beforeEach(() => { - createWrapper({ uploading: false }); - }); - - it('does not render a gl-loading-icon component', () => { - expect(findLoadingIcon().exists()).toBe(false); - }); - - it('does not add gl-opacity-5 class selector to the media tag', () => { - expect(findMedia().classes()).not.toContain('gl-opacity-5'); - }); - }); -}); diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js index d3c42104e47..d528096be34 100644 --- a/spec/frontend/content_editor/extensions/attachment_spec.js +++ b/spec/frontend/content_editor/extensions/attachment_spec.js @@ -11,32 +11,12 @@ import { VARIANT_DANGER } from '~/flash'; import httpStatus from '~/lib/utils/http_status'; import eventHubFactory from '~/helpers/event_hub_factory'; import { createTestEditor, createDocBuilder } from '../test_utils'; - -const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `<p data-sourcepos="1:1-1:27" dir="auto"> - <a class="no-attachment-icon" href="/group1/project1/-/wikis/test-file.png" target="_blank" rel="noopener noreferrer" data-canonical-src="test-file.png"> - <img alt="test-file" class="lazy" data-src="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png"> - </a> -</p>`; - -const PROJECT_WIKI_ATTACHMENT_VIDEO_HTML = `<p data-sourcepos="1:1-1:132" dir="auto"> - <span class="media-container video-container"> - <video src="/group1/project1/-/wikis/test-file.mp4" controls="true" data-setup="{}" data-title="test-file" width="400" preload="metadata" data-canonical-src="test-file.mp4"> - </video> - <a href="/himkp/test/-/wikis/test-file.mp4" target="_blank" rel="noopener noreferrer" title="Download 'test-file'" data-canonical-src="test-file.mp4">test-file</a> - </span> -</p>`; - -const PROJECT_WIKI_ATTACHMENT_AUDIO_HTML = `<p data-sourcepos="3:1-3:74" dir="auto"> - <span class="media-container audio-container"> - <audio src="/himkp/test/-/wikis/test-file.mp3" controls="true" data-setup="{}" data-title="test-file" data-canonical-src="test-file.mp3"> - </audio> - <a href="/himkp/test/-/wikis/test-file.mp3" target="_blank" rel="noopener noreferrer" title="Download 'test-file'" data-canonical-src="test-file.mp3">test-file</a> - </span> -</p>`; - -const PROJECT_WIKI_ATTACHMENT_LINK_HTML = `<p data-sourcepos="1:1-1:26" dir="auto"> - <a href="/group1/project1/-/wikis/test-file.zip" data-canonical-src="test-file.zip">test-file</a> -</p>`; +import { + PROJECT_WIKI_ATTACHMENT_IMAGE_HTML, + PROJECT_WIKI_ATTACHMENT_AUDIO_HTML, + PROJECT_WIKI_ATTACHMENT_VIDEO_HTML, + PROJECT_WIKI_ATTACHMENT_LINK_HTML, +} from '../test_constants'; describe('content_editor/extensions/attachment', () => { let tiptapEditor; diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js index 02e5b1dc271..fc8460c7f84 100644 --- a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js +++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js @@ -1,4 +1,5 @@ import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; +import languageLoader from '~/content_editor/services/code_block_language_loader'; import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true"> @@ -9,20 +10,20 @@ const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language </code> </pre>`; +jest.mock('~/content_editor/services/code_block_language_loader'); + describe('content_editor/extensions/code_block_highlight', () => { let parsedCodeBlockHtmlFixture; let tiptapEditor; let doc; let codeBlock; - let languageLoader; const parseHTML = (html) => new DOMParser().parseFromString(html, 'text/html'); const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre'); beforeEach(() => { - languageLoader = { loadLanguages: jest.fn() }; tiptapEditor = createTestEditor({ - extensions: [CodeBlockHighlight.configure({ languageLoader })], + extensions: [CodeBlockHighlight], }); ({ @@ -70,6 +71,8 @@ describe('content_editor/extensions/code_block_highlight', () => { const language = 'javascript'; beforeEach(() => { + languageLoader.loadLanguageFromInputRule.mockReturnValueOnce({ language }); + triggerNodeInputRule({ tiptapEditor, inputRuleText: `${inputRule}${language} `, @@ -83,7 +86,9 @@ describe('content_editor/extensions/code_block_highlight', () => { }); it('loads language when language loader is available', () => { - expect(languageLoader.loadLanguages).toHaveBeenCalledWith([language]); + expect(languageLoader.loadLanguageFromInputRule).toHaveBeenCalledWith( + expect.arrayContaining([`${inputRule}${language} `, language]), + ); }); }); }); diff --git a/spec/frontend/content_editor/extensions/diagram_spec.js b/spec/frontend/content_editor/extensions/diagram_spec.js new file mode 100644 index 00000000000..b8d9e0b5aeb --- /dev/null +++ b/spec/frontend/content_editor/extensions/diagram_spec.js @@ -0,0 +1,16 @@ +import Diagram from '~/content_editor/extensions/diagram'; +import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; + +describe('content_editor/extensions/diagram', () => { + it('inherits from code block highlight extension', () => { + expect(Diagram.parent).toBe(CodeBlockHighlight); + }); + + it('sets isDiagram attribute to true by default', () => { + expect(Diagram.config.addAttributes()).toEqual( + expect.objectContaining({ + isDiagram: { default: true }, + }), + ); + }); +}); diff --git a/spec/frontend/content_editor/extensions/frontmatter_spec.js b/spec/frontend/content_editor/extensions/frontmatter_spec.js index 4f80c2cb81a..9bd29070858 100644 --- a/spec/frontend/content_editor/extensions/frontmatter_spec.js +++ b/spec/frontend/content_editor/extensions/frontmatter_spec.js @@ -22,6 +22,10 @@ describe('content_editor/extensions/frontmatter', () => { })); }); + it('inherits from code block highlight extension', () => { + expect(Frontmatter.parent).toBe(CodeBlockHighlight); + }); + it('does not insert a frontmatter block when executing code block input rule', () => { const expectedDoc = doc(codeBlock({ language: 'plaintext' }, '')); const inputRuleText = '``` '; @@ -31,6 +35,14 @@ describe('content_editor/extensions/frontmatter', () => { expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); }); + it('sets isFrontmatter attribute to true by default', () => { + expect(Frontmatter.config.addAttributes()).toEqual( + expect.objectContaining({ + isFrontmatter: { default: true }, + }), + ); + }); + it.each` command | result | resultDesc ${'toggleCodeBlock'} | ${() => doc(codeBlock(''))} | ${'code block element'} diff --git a/spec/frontend/content_editor/extensions/paste_markdown_spec.js b/spec/frontend/content_editor/extensions/paste_markdown_spec.js index 8f734c7dabc..5d46c2c0650 100644 --- a/spec/frontend/content_editor/extensions/paste_markdown_spec.js +++ b/spec/frontend/content_editor/extensions/paste_markdown_spec.js @@ -1,4 +1,7 @@ import PasteMarkdown from '~/content_editor/extensions/paste_markdown'; +import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; +import Diagram from '~/content_editor/extensions/diagram'; +import Frontmatter from '~/content_editor/extensions/frontmatter'; import Bold from '~/content_editor/extensions/bold'; import { VARIANT_DANGER } from '~/flash'; import eventHubFactory from '~/helpers/event_hub_factory'; @@ -11,6 +14,12 @@ import { import waitForPromises from 'helpers/wait_for_promises'; import { createTestEditor, createDocBuilder, waitUntilNextDocTransaction } from '../test_utils'; +const CODE_BLOCK_HTML = '<pre lang="javascript">var a = 2;</pre>'; +const DIAGRAM_HTML = + '<img data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,WzxmcmFtZT5EZWNvcmF0b3IgcGF0dGVybl0=">'; +const FRONTMATTER_HTML = '<pre lang="yaml" data-lang-params="frontmatter">key: value</pre>'; +const PARAGRAPH_HTML = '<p>Just a regular paragraph</p>'; + describe('content_editor/extensions/paste_markdown', () => { let tiptapEditor; let doc; @@ -27,7 +36,13 @@ describe('content_editor/extensions/paste_markdown', () => { jest.spyOn(eventHub, '$emit'); tiptapEditor = createTestEditor({ - extensions: [PasteMarkdown.configure({ renderMarkdown, eventHub }), Bold], + extensions: [ + Bold, + CodeBlockHighlight, + Diagram, + Frontmatter, + PasteMarkdown.configure({ renderMarkdown, eventHub }), + ], }); ({ @@ -35,7 +50,7 @@ describe('content_editor/extensions/paste_markdown', () => { } = createDocBuilder({ tiptapEditor, names: { - Bold: { markType: Bold.name }, + bold: { markType: Bold.name }, }, })); }); @@ -47,13 +62,11 @@ describe('content_editor/extensions/paste_markdown', () => { }; const triggerPasteEventHandler = (event) => { - let handled = false; - - tiptapEditor.view.someProp('handlePaste', (eventHandler) => { - handled = eventHandler(tiptapEditor.view, event); + return new Promise((resolve) => { + tiptapEditor.view.someProp('handlePaste', (eventHandler) => { + resolve(eventHandler(tiptapEditor.view, event)); + }); }); - - return handled; }; const triggerPasteEventHandlerAndWaitForTransaction = (event) => { @@ -73,8 +86,20 @@ describe('content_editor/extensions/paste_markdown', () => { ${['text/plain', 'text/html']} | ${{}} | ${false} | ${'doesn’t handle html format'} ${['text/plain', 'text/html', 'vscode-editor-data']} | ${{ 'vscode-editor-data': '{ "mode": "markdown" }' }} | ${true} | ${'handles vscode markdown'} ${['text/plain', 'text/html', 'vscode-editor-data']} | ${{ 'vscode-editor-data': '{ "mode": "ruby" }' }} | ${false} | ${'doesn’t vscode code snippet'} - `('$desc', ({ types, handled, data }) => { - expect(triggerPasteEventHandler(buildClipboardEvent({ types, data }))).toBe(handled); + `('$desc', async ({ types, handled, data }) => { + expect(await triggerPasteEventHandler(buildClipboardEvent({ types, data }))).toBe(handled); + }); + + it.each` + nodeType | html | handled | desc + ${'codeBlock'} | ${CODE_BLOCK_HTML} | ${false} | ${'does not handle'} + ${'diagram'} | ${DIAGRAM_HTML} | ${false} | ${'does not handle'} + ${'frontmatter'} | ${FRONTMATTER_HTML} | ${false} | ${'does not handle'} + ${'paragraph'} | ${PARAGRAPH_HTML} | ${true} | ${'handles'} + `('$desc paste if currently a `$nodeType` is in focus', async ({ html, handled }) => { + tiptapEditor.commands.insertContent(html); + + expect(await triggerPasteEventHandler(buildClipboardEvent())).toBe(handled); }); describe('when pasting raw markdown source', () => { @@ -105,16 +130,14 @@ describe('content_editor/extensions/paste_markdown', () => { }); it(`triggers ${LOADING_ERROR_EVENT} event`, async () => { - triggerPasteEventHandler(buildClipboardEvent()); - + await triggerPasteEventHandler(buildClipboardEvent()); await waitForPromises(); expect(eventHub.$emit).toHaveBeenCalledWith(LOADING_ERROR_EVENT); }); it(`triggers ${ALERT_EVENT} event`, async () => { - triggerPasteEventHandler(buildClipboardEvent()); - + await triggerPasteEventHandler(buildClipboardEvent()); await waitForPromises(); expect(eventHub.$emit).toHaveBeenCalledWith(ALERT_EVENT, { diff --git a/spec/frontend/content_editor/remark_markdown_processing_spec.js b/spec/frontend/content_editor/remark_markdown_processing_spec.js new file mode 100644 index 00000000000..6348b97d918 --- /dev/null +++ b/spec/frontend/content_editor/remark_markdown_processing_spec.js @@ -0,0 +1,248 @@ +import Bold from '~/content_editor/extensions/bold'; +import Blockquote from '~/content_editor/extensions/blockquote'; +import BulletList from '~/content_editor/extensions/bullet_list'; +import Code from '~/content_editor/extensions/code'; +import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; +import HardBreak from '~/content_editor/extensions/hard_break'; +import Heading from '~/content_editor/extensions/heading'; +import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; +import Image from '~/content_editor/extensions/image'; +import Italic from '~/content_editor/extensions/italic'; +import Link from '~/content_editor/extensions/link'; +import ListItem from '~/content_editor/extensions/list_item'; +import OrderedList from '~/content_editor/extensions/ordered_list'; +import Sourcemap from '~/content_editor/extensions/sourcemap'; +import remarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; +import markdownSerializer from '~/content_editor/services/markdown_serializer'; + +import { createTestEditor } from './test_utils'; + +const tiptapEditor = createTestEditor({ + extensions: [ + Blockquote, + Bold, + BulletList, + Code, + CodeBlockHighlight, + HardBreak, + Heading, + HorizontalRule, + Image, + Italic, + Link, + ListItem, + OrderedList, + Sourcemap, + ], +}); + +describe('Client side Markdown processing', () => { + const deserialize = async (content) => { + const { document } = await remarkMarkdownDeserializer().deserialize({ + schema: tiptapEditor.schema, + content, + }); + + return document; + }; + + const serialize = (document) => + markdownSerializer({}).serialize({ + doc: document, + pristineDoc: document, + }); + + it.each([ + { + markdown: '__bold text__', + }, + { + markdown: '**bold text**', + }, + { + markdown: '<strong>bold text</strong>', + }, + { + markdown: '<b>bold text</b>', + }, + { + markdown: '_italic text_', + }, + { + markdown: '*italic text*', + }, + { + markdown: '<em>italic text</em>', + }, + { + markdown: '<i>italic text</i>', + }, + { + markdown: '`inline code`', + }, + { + markdown: '**`inline code bold`**', + }, + { + markdown: '__`inline code italics`__', + }, + { + markdown: '[GitLab](https://gitlab.com "Go to GitLab")', + }, + { + markdown: '**[GitLab](https://gitlab.com "Go to GitLab")**', + }, + { + markdown: ` +This is a paragraph with a\\ +hard line break`, + }, + { + markdown: '![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")', + }, + { + markdown: '---', + }, + { + markdown: '***', + }, + { + markdown: '___', + }, + { + markdown: '<hr>', + }, + { + markdown: '# Heading 1', + }, + { + markdown: '## Heading 2', + }, + { + markdown: '### Heading 3', + }, + { + markdown: '#### Heading 4', + }, + { + markdown: '##### Heading 5', + }, + { + markdown: '###### Heading 6', + }, + + { + markdown: ` + Heading + one + ====== + `, + }, + { + markdown: ` + Heading + two + ------- + `, + }, + { + markdown: ` + - List item 1 + - List item 2 + `, + }, + { + markdown: ` + * List item 1 + * List item 2 + `, + }, + { + markdown: ` + + List item 1 + + List item 2 + `, + }, + { + markdown: ` + 1. List item 1 + 1. List item 2 + `, + }, + { + markdown: ` + 1. List item 1 + 2. List item 2 + `, + }, + { + markdown: ` + 1) List item 1 + 2) List item 2 + `, + }, + { + markdown: ` + - List item 1 + - Sub list item 1 + `, + }, + { + markdown: ` + - List item 1 paragraph 1 + + List item 1 paragraph 2 + - List item 2 + `, + }, + { + markdown: ` + > This is a blockquote + `, + }, + { + markdown: ` + > - List item 1 + > - List item 2 + `, + }, + { + markdown: ` + const fn = () => 'GitLab'; + `, + }, + { + markdown: ` + \`\`\`javascript + const fn = () => 'GitLab'; + \`\`\`\ + `, + }, + { + markdown: ` + ~~~javascript + const fn = () => 'GitLab'; + ~~~ + `, + }, + { + markdown: ` + \`\`\` + \`\`\`\ + `, + }, + { + markdown: ` + \`\`\`javascript + const fn = () => 'GitLab'; + + \`\`\`\ + `, + }, + ])('processes %s correctly', async ({ markdown }) => { + const trimmed = markdown.trim(); + const document = await deserialize(trimmed); + + expect(serialize(document)).toEqual(trimmed); + }); +}); diff --git a/spec/frontend/content_editor/services/asset_resolver_spec.js b/spec/frontend/content_editor/services/asset_resolver_spec.js new file mode 100644 index 00000000000..f4e7d9bf881 --- /dev/null +++ b/spec/frontend/content_editor/services/asset_resolver_spec.js @@ -0,0 +1,23 @@ +import createAssetResolver from '~/content_editor/services/asset_resolver'; + +describe('content_editor/services/asset_resolver', () => { + let renderMarkdown; + let assetResolver; + + beforeEach(() => { + renderMarkdown = jest.fn(); + assetResolver = createAssetResolver({ renderMarkdown }); + }); + + describe('resolveUrl', () => { + it('resolves a canonical url to an absolute url', async () => { + renderMarkdown.mockResolvedValue( + '<p><a href="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png">link</a></p>', + ); + + expect(await assetResolver.resolveUrl('test-file.png')).toBe( + '/group1/project1/-/wikis/test-file.png', + ); + }); + }); +}); diff --git a/spec/frontend/content_editor/services/code_block_language_loader_spec.js b/spec/frontend/content_editor/services/code_block_language_loader_spec.js index 905c1685b94..943de327762 100644 --- a/spec/frontend/content_editor/services/code_block_language_loader_spec.js +++ b/spec/frontend/content_editor/services/code_block_language_loader_spec.js @@ -53,47 +53,25 @@ describe('content_editor/services/code_block_language_loader', () => { }); }); - describe('loadLanguages', () => { + describe('loadLanguage', () => { it('loads highlight.js language packages identified by a list of languages', async () => { - const languages = ['javascript', 'ruby']; + const language = 'javascript'; - await languageLoader.loadLanguages(languages); + await languageLoader.loadLanguage(language); - languages.forEach((language) => { - expect(lowlight.registerLanguage).toHaveBeenCalledWith(language, expect.any(Function)); - }); + expect(lowlight.registerLanguage).toHaveBeenCalledWith(language, expect.any(Function)); }); describe('when language is already registered', () => { it('does not load the language again', async () => { - const languages = ['javascript']; - - await languageLoader.loadLanguages(languages); - await languageLoader.loadLanguages(languages); + await languageLoader.loadLanguage('javascript'); + await languageLoader.loadLanguage('javascript'); expect(lowlight.registerLanguage).toHaveBeenCalledTimes(1); }); }); }); - describe('loadLanguagesFromDOM', () => { - it('loads highlight.js language packages identified by pre tags in a DOM fragment', async () => { - const parser = new DOMParser(); - const { body } = parser.parseFromString( - ` - <pre lang="javascript"></pre> - <pre lang="ruby"></pre> - `, - 'text/html', - ); - - await languageLoader.loadLanguagesFromDOM(body); - - expect(lowlight.registerLanguage).toHaveBeenCalledWith('javascript', expect.any(Function)); - expect(lowlight.registerLanguage).toHaveBeenCalledWith('ruby', expect.any(Function)); - }); - }); - describe('loadLanguageFromInputRule', () => { it('loads highlight.js language packages identified from the input rule', async () => { const match = new RegExp(backtickInputRegex).exec('```js '); @@ -112,7 +90,7 @@ describe('content_editor/services/code_block_language_loader', () => { expect(languageLoader.isLanguageLoaded(language)).toBe(false); - await languageLoader.loadLanguages([language]); + await languageLoader.loadLanguage(language); expect(languageLoader.isLanguageLoaded(language)).toBe(true); }); diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js index 5b7a27b501d..a3553e612ca 100644 --- a/spec/frontend/content_editor/services/content_editor_spec.js +++ b/spec/frontend/content_editor/services/content_editor_spec.js @@ -11,7 +11,6 @@ describe('content_editor/services/content_editor', () => { let contentEditor; let serializer; let deserializer; - let languageLoader; let eventHub; let doc; let p; @@ -26,16 +25,14 @@ describe('content_editor/services/content_editor', () => { tiptapEditor, })); - serializer = { deserialize: jest.fn() }; + serializer = { serialize: jest.fn() }; deserializer = { deserialize: jest.fn() }; - languageLoader = { loadLanguagesFromDOM: jest.fn() }; eventHub = eventHubFactory(); contentEditor = new ContentEditor({ tiptapEditor, serializer, deserializer, eventHub, - languageLoader, }); }); @@ -51,12 +48,12 @@ describe('content_editor/services/content_editor', () => { describe('when setSerializedContent succeeds', () => { let document; - const dom = {}; + const languages = ['javascript']; const testMarkdown = '**bold text**'; beforeEach(() => { document = doc(p('document')); - deserializer.deserialize.mockResolvedValueOnce({ document, dom }); + deserializer.deserialize.mockResolvedValueOnce({ document, languages }); }); it('emits loadingContent and loadingSuccess event in the eventHub', () => { @@ -77,12 +74,6 @@ describe('content_editor/services/content_editor', () => { expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON()); }); - - it('passes deserialized DOM document to language loader', async () => { - await contentEditor.setSerializedContent(testMarkdown); - - expect(languageLoader.loadLanguagesFromDOM).toHaveBeenCalledWith(dom); - }); }); describe('when setSerializedContent fails', () => { diff --git a/spec/frontend/content_editor/services/create_content_editor_spec.js b/spec/frontend/content_editor/services/create_content_editor_spec.js index 6b2f28b3306..e1a30819ac8 100644 --- a/spec/frontend/content_editor/services/create_content_editor_spec.js +++ b/spec/frontend/content_editor/services/create_content_editor_spec.js @@ -1,8 +1,12 @@ import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '~/content_editor/constants'; import { createContentEditor } from '~/content_editor/services/create_content_editor'; +import createGlApiMarkdownDeserializer from '~/content_editor/services/gl_api_markdown_deserializer'; +import createRemarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; import { createTestContentEditorExtension } from '../test_utils'; jest.mock('~/emoji'); +jest.mock('~/content_editor/services/remark_markdown_deserializer'); +jest.mock('~/content_editor/services/gl_api_markdown_deserializer'); describe('content_editor/services/create_content_editor', () => { let renderMarkdown; @@ -11,9 +15,36 @@ describe('content_editor/services/create_content_editor', () => { beforeEach(() => { renderMarkdown = jest.fn(); + window.gon = { + features: { + preserveUnchangedMarkdown: false, + }, + }; editor = createContentEditor({ renderMarkdown, uploadsPath }); }); + describe('when preserveUnchangedMarkdown feature is on', () => { + beforeEach(() => { + window.gon.features.preserveUnchangedMarkdown = true; + }); + + it('provides a remark markdown deserializer to the content editor class', () => { + createContentEditor({ renderMarkdown, uploadsPath }); + expect(createRemarkMarkdownDeserializer).toHaveBeenCalled(); + }); + }); + + describe('when preserveUnchangedMarkdown feature is off', () => { + beforeEach(() => { + window.gon.features.preserveUnchangedMarkdown = false; + }); + + it('provides a gl api markdown deserializer to the content editor class', () => { + createContentEditor({ renderMarkdown, uploadsPath }); + expect(createGlApiMarkdownDeserializer).toHaveBeenCalledWith({ render: renderMarkdown }); + }); + }); + it('sets gl-outline-0! class selector to the tiptapEditor instance', () => { expect(editor.tiptapEditor.options.editorProps).toMatchObject({ attributes: { @@ -22,30 +53,19 @@ describe('content_editor/services/create_content_editor', () => { }); }); - it('provides the renderMarkdown function to the markdown serializer', async () => { - const serializedContent = '**bold text**'; - - renderMarkdown.mockReturnValueOnce('<p><b>bold text</b></p>'); - - await editor.setSerializedContent(serializedContent); - - expect(renderMarkdown).toHaveBeenCalledWith(serializedContent); - }); - it('allows providing external content editor extensions', async () => { const labelReference = 'this is a ~group::editor'; const { tiptapExtension, serializer } = createTestContentEditorExtension(); - renderMarkdown.mockReturnValueOnce( - '<p>this is a <span data-reference="label" data-label-name="group::editor">group::editor</span></p>', - ); editor = createContentEditor({ renderMarkdown, extensions: [tiptapExtension], serializerConfig: { nodes: { [tiptapExtension.name]: serializer } }, }); - await editor.setSerializedContent(labelReference); + editor.tiptapEditor.commands.setContent( + '<p>this is a <span data-reference="label" data-label-name="group::editor">group::editor</span></p>', + ); expect(editor.getSerializedContent()).toBe(labelReference); }); diff --git a/spec/frontend/content_editor/services/markdown_deserializer_spec.js b/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js index bea43a0effc..5458a42532f 100644 --- a/spec/frontend/content_editor/services/markdown_deserializer_spec.js +++ b/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js @@ -1,8 +1,8 @@ -import createMarkdownDeserializer from '~/content_editor/services/markdown_deserializer'; +import createMarkdownDeserializer from '~/content_editor/services/gl_api_markdown_deserializer'; import Bold from '~/content_editor/extensions/bold'; import { createTestEditor, createDocBuilder } from '../test_utils'; -describe('content_editor/services/markdown_deserializer', () => { +describe('content_editor/services/gl_api_markdown_deserializer', () => { let renderMarkdown; let doc; let p; @@ -32,7 +32,9 @@ describe('content_editor/services/markdown_deserializer', () => { beforeEach(async () => { const deserializer = createMarkdownDeserializer({ render: renderMarkdown }); - renderMarkdown.mockResolvedValueOnce(`<p><strong>${text}</strong></p>`); + renderMarkdown.mockResolvedValueOnce( + `<p><strong>${text}</strong></p><pre lang="javascript"></pre>`, + ); result = await deserializer.deserialize({ content: 'content', @@ -40,13 +42,9 @@ describe('content_editor/services/markdown_deserializer', () => { }); }); it('transforms HTML returned by render function to a ProseMirror document', async () => { - const expectedDoc = doc(p(bold(text))); + const document = doc(p(bold(text))); - expect(result.document.toJSON()).toEqual(expectedDoc.toJSON()); - }); - - it('returns parsed HTML as a DOM object', () => { - expect(result.dom.innerHTML).toEqual(`<p><strong>${text}</strong></p><!--content-->`); + expect(result.document.toJSON()).toEqual(document.toJSON()); }); }); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 2b76dc6c984..25b7483f234 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -24,6 +24,7 @@ import Link from '~/content_editor/extensions/link'; import ListItem from '~/content_editor/extensions/list_item'; import OrderedList from '~/content_editor/extensions/ordered_list'; import Paragraph from '~/content_editor/extensions/paragraph'; +import Sourcemap from '~/content_editor/extensions/sourcemap'; import Strike from '~/content_editor/extensions/strike'; import Table from '~/content_editor/extensions/table'; import TableCell from '~/content_editor/extensions/table_cell'; @@ -32,6 +33,7 @@ import TableRow from '~/content_editor/extensions/table_row'; import TaskItem from '~/content_editor/extensions/task_item'; import TaskList from '~/content_editor/extensions/task_list'; import markdownSerializer from '~/content_editor/services/markdown_serializer'; +import remarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; import { createTestEditor, createDocBuilder } from '../test_utils'; jest.mock('~/emoji'); @@ -63,6 +65,7 @@ const tiptapEditor = createTestEditor({ Link, ListItem, OrderedList, + Sourcemap, Strike, Table, TableCell, @@ -151,8 +154,7 @@ const { const serialize = (...content) => markdownSerializer({}).serialize({ - schema: tiptapEditor.schema, - content: doc(...content).toJSON(), + doc: doc(...content), }); describe('markdownSerializer', () => { @@ -1159,4 +1161,42 @@ Oranges are orange [^1] `.trim(), ); }); + + it.each` + mark | content | modifiedContent + ${'bold'} | ${'**bold**'} | ${'**bold modified**'} + ${'bold'} | ${'__bold__'} | ${'__bold modified__'} + ${'bold'} | ${'<strong>bold</strong>'} | ${'<strong>bold modified</strong>'} + ${'bold'} | ${'<b>bold</b>'} | ${'<b>bold modified</b>'} + ${'italic'} | ${'_italic_'} | ${'_italic modified_'} + ${'italic'} | ${'*italic*'} | ${'*italic modified*'} + ${'italic'} | ${'<em>italic</em>'} | ${'<em>italic modified</em>'} + ${'italic'} | ${'<i>italic</i>'} | ${'<i>italic modified</i>'} + ${'link'} | ${'[gitlab](https://gitlab.com)'} | ${'[gitlab modified](https://gitlab.com)'} + ${'link'} | ${'<a href="https://gitlab.com">link</a>'} | ${'<a href="https://gitlab.com">link modified</a>'} + ${'code'} | ${'`code`'} | ${'`code modified`'} + ${'code'} | ${'<code>code</code>'} | ${'<code>code modified</code>'} + `( + 'preserves original $mark syntax when sourceMarkdown is available', + async ({ content, modifiedContent }) => { + const { document } = await remarkMarkdownDeserializer().deserialize({ + schema: tiptapEditor.schema, + content, + }); + + tiptapEditor + .chain() + .setContent(document.toJSON()) + // changing the document ensures that block preservation doesn’t yield false positives + .insertContent(' modified') + .run(); + + const serialized = markdownSerializer({}).serialize({ + pristineDoc: document, + doc: tiptapEditor.state.doc, + }); + + expect(serialized).toEqual(modifiedContent); + }, + ); }); diff --git a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js index abd9588daff..8a304c73163 100644 --- a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js +++ b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js @@ -2,7 +2,7 @@ import { Extension } from '@tiptap/core'; import BulletList from '~/content_editor/extensions/bullet_list'; import ListItem from '~/content_editor/extensions/list_item'; import Paragraph from '~/content_editor/extensions/paragraph'; -import markdownDeserializer from '~/content_editor/services/markdown_deserializer'; +import markdownDeserializer from '~/content_editor/services/gl_api_markdown_deserializer'; import { getMarkdownSource, getFullSource } from '~/content_editor/services/markdown_sourcemap'; import { createTestEditor, createDocBuilder } from '../test_utils'; diff --git a/spec/frontend/content_editor/test_constants.js b/spec/frontend/content_editor/test_constants.js new file mode 100644 index 00000000000..45a0e4a8bd1 --- /dev/null +++ b/spec/frontend/content_editor/test_constants.js @@ -0,0 +1,25 @@ +export const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `<p data-sourcepos="1:1-1:27" dir="auto"> + <a class="no-attachment-icon" href="/group1/project1/-/wikis/test-file.png" target="_blank" rel="noopener noreferrer" data-canonical-src="test-file.png"> + <img alt="test-file" class="lazy" data-src="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png"> + </a> +</p>`; + +export const PROJECT_WIKI_ATTACHMENT_VIDEO_HTML = `<p data-sourcepos="1:1-1:132" dir="auto"> + <span class="media-container video-container"> + <video src="/group1/project1/-/wikis/test-file.mp4" controls="true" data-setup="{}" data-title="test-file" width="400" preload="metadata" data-canonical-src="test-file.mp4"> + </video> + <a href="/group1/project1/-/wikis/test-file.mp4" target="_blank" rel="noopener noreferrer" title="Download 'test-file'" data-canonical-src="test-file.mp4">test-file</a> + </span> +</p>`; + +export const PROJECT_WIKI_ATTACHMENT_AUDIO_HTML = `<p data-sourcepos="3:1-3:74" dir="auto"> + <span class="media-container audio-container"> + <audio src="/group1/project1/-/wikis/test-file.mp3" controls="true" data-setup="{}" data-title="test-file" data-canonical-src="test-file.mp3"> + </audio> + <a href="/group1/project1/-/wikis/test-file.mp3" target="_blank" rel="noopener noreferrer" title="Download 'test-file'" data-canonical-src="test-file.mp3">test-file</a> + </span> +</p>`; + +export const PROJECT_WIKI_ATTACHMENT_LINK_HTML = `<p data-sourcepos="1:1-1:26" dir="auto"> + <a href="/group1/project1/-/wikis/test-file.zip" data-canonical-src="test-file.zip">test-file</a> +</p>`; diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js index dde9d738235..4ed1ed97cbd 100644 --- a/spec/frontend/content_editor/test_utils.js +++ b/spec/frontend/content_editor/test_utils.js @@ -151,7 +151,7 @@ export const triggerMarkInputRule = ({ tiptapEditor, inputRuleText }) => { * @param {*} params.action A function that triggers a transaction in the tiptap Editor * @returns A promise that resolves when the transaction completes */ -export const waitUntilNextDocTransaction = ({ tiptapEditor, action }) => { +export const waitUntilNextDocTransaction = ({ tiptapEditor, action = () => {} }) => { return new Promise((resolve) => { const handleTransaction = () => { tiptapEditor.off('update', handleTransaction); diff --git a/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js b/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js deleted file mode 100644 index 2f835867f5f..00000000000 --- a/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js +++ /dev/null @@ -1,214 +0,0 @@ -import { GlIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import $ from 'jquery'; - -import { nextTick } from 'vue'; -import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue'; -import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; -import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue'; - -describe('ClusterFormDropdown', () => { - let wrapper; - const firstItem = { name: 'item 1', value: '1' }; - const secondItem = { name: 'item 2', value: '2' }; - const items = [firstItem, secondItem, { name: 'item 3', value: '3' }]; - - beforeEach(() => { - wrapper = shallowMount(ClusterFormDropdown); - }); - afterEach(() => wrapper.destroy()); - - describe('when initial value is provided', () => { - it('sets selectedItem to initial value', async () => { - wrapper.setProps({ items, value: secondItem.value }); - - await nextTick(); - expect(wrapper.find(DropdownButton).props('toggleText')).toEqual(secondItem.name); - }); - }); - - describe('when no item is selected', () => { - it('displays placeholder text', async () => { - const placeholder = 'placeholder'; - - wrapper.setProps({ placeholder }); - - await nextTick(); - expect(wrapper.find(DropdownButton).props('toggleText')).toEqual(placeholder); - }); - }); - - describe('when an item is selected', () => { - beforeEach(async () => { - wrapper.setProps({ items }); - await nextTick(); - wrapper.findAll('.js-dropdown-item').at(1).trigger('click'); - await nextTick(); - }); - - it('emits input event with selected item', () => { - expect(wrapper.emitted('input')[0]).toEqual([secondItem.value]); - }); - }); - - describe('when multiple items are selected', () => { - const value = ['1']; - - beforeEach(async () => { - wrapper.setProps({ items, multiple: true, value }); - - await nextTick(); - wrapper.findAll('.js-dropdown-item').at(0).trigger('click'); - - await nextTick(); - wrapper.findAll('.js-dropdown-item').at(1).trigger('click'); - - await nextTick(); - }); - - it('emits input event with an array of selected items', () => { - expect(wrapper.emitted('input')[1]).toEqual([[firstItem.value, secondItem.value]]); - }); - }); - - describe('when multiple items can be selected', () => { - beforeEach(async () => { - wrapper.setProps({ items, multiple: true, value: firstItem.value }); - await nextTick(); - }); - - it('displays a checked GlIcon next to the item', () => { - expect(wrapper.find(GlIcon).classes()).not.toContain('invisible'); - expect(wrapper.find(GlIcon).props('name')).toBe('mobile-issue-close'); - }); - }); - - describe('when multiple values can be selected and initial value is null', () => { - it('emits input event with an array of a single selected item', async () => { - wrapper.setProps({ items, multiple: true, value: null }); - - await nextTick(); - wrapper.findAll('.js-dropdown-item').at(0).trigger('click'); - - expect(wrapper.emitted('input')[0]).toEqual([[firstItem.value]]); - }); - }); - - describe('when an item is selected and has a custom label property', () => { - it('displays selected item custom label', async () => { - const labelProperty = 'customLabel'; - const label = 'Name'; - const currentValue = '1'; - const customLabelItems = [{ [labelProperty]: label, value: currentValue }]; - - wrapper.setProps({ labelProperty, items: customLabelItems, value: currentValue }); - - await nextTick(); - expect(wrapper.find(DropdownButton).props('toggleText')).toEqual(label); - }); - }); - - describe('when loading', () => { - it('dropdown button isLoading', async () => { - await wrapper.setProps({ loading: true }); - expect(wrapper.find(DropdownButton).props('isLoading')).toBe(true); - }); - }); - - describe('when loading and loadingText is provided', () => { - it('uses loading text as toggle button text', async () => { - const loadingText = 'loading text'; - - wrapper.setProps({ loading: true, loadingText }); - - await nextTick(); - expect(wrapper.find(DropdownButton).props('toggleText')).toEqual(loadingText); - }); - }); - - describe('when disabled', () => { - it('dropdown button isDisabled', async () => { - wrapper.setProps({ disabled: true }); - - await nextTick(); - expect(wrapper.find(DropdownButton).props('isDisabled')).toBe(true); - }); - }); - - describe('when disabled and disabledText is provided', () => { - it('uses disabled text as toggle button text', async () => { - const disabledText = 'disabled text'; - - wrapper.setProps({ disabled: true, disabledText }); - - await nextTick(); - expect(wrapper.find(DropdownButton).props('toggleText')).toBe(disabledText); - }); - }); - - describe('when has errors', () => { - it('sets border-danger class selector to dropdown toggle', async () => { - wrapper.setProps({ hasErrors: true }); - - await nextTick(); - expect(wrapper.find(DropdownButton).classes('border-danger')).toBe(true); - }); - }); - - describe('when has errors and an error message', () => { - it('displays error message', async () => { - const errorMessage = 'error message'; - - wrapper.setProps({ hasErrors: true, errorMessage }); - - await nextTick(); - expect(wrapper.find('.js-eks-dropdown-error-message').text()).toEqual(errorMessage); - }); - }); - - describe('when no results are available', () => { - it('displays empty text', async () => { - const emptyText = 'error message'; - - wrapper.setProps({ items: [], emptyText }); - - await nextTick(); - expect(wrapper.find('.js-empty-text').text()).toEqual(emptyText); - }); - }); - - it('displays search field placeholder', async () => { - const searchFieldPlaceholder = 'Placeholder'; - - wrapper.setProps({ searchFieldPlaceholder }); - - await nextTick(); - expect(wrapper.find(DropdownSearchInput).props('placeholderText')).toEqual( - searchFieldPlaceholder, - ); - }); - - it('it filters results by search query', async () => { - const searchQuery = secondItem.name; - - wrapper.setProps({ items }); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ searchQuery }); - - await nextTick(); - expect(wrapper.findAll('.js-dropdown-item').length).toEqual(1); - expect(wrapper.find('.js-dropdown-item').text()).toEqual(secondItem.name); - }); - - it('focuses dropdown search input when dropdown is displayed', async () => { - const dropdownEl = wrapper.find('.dropdown').element; - - expect(wrapper.find(DropdownSearchInput).props('focused')).toBe(false); - - $(dropdownEl).trigger('shown.bs.dropdown'); - - await nextTick(); - expect(wrapper.find(DropdownSearchInput).props('focused')).toBe(true); - }); -}); diff --git a/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js b/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js deleted file mode 100644 index c8020cf8308..00000000000 --- a/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js +++ /dev/null @@ -1,98 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; - -import CreateEksCluster from '~/create_cluster/eks_cluster/components/create_eks_cluster.vue'; -import EksClusterConfigurationForm from '~/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue'; -import ServiceCredentialsForm from '~/create_cluster/eks_cluster/components/service_credentials_form.vue'; - -Vue.use(Vuex); - -describe('CreateEksCluster', () => { - let vm; - let state; - const gitlabManagedClusterHelpPath = 'gitlab-managed-cluster-help-path'; - const namespacePerEnvironmentHelpPath = 'namespace-per-environment-help-path'; - const accountAndExternalIdsHelpPath = 'account-and-external-id-help-path'; - const createRoleArnHelpPath = 'role-arn-help-path'; - const kubernetesIntegrationHelpPath = 'kubernetes-integration'; - const externalLinkIcon = 'external-link'; - - beforeEach(() => { - state = { hasCredentials: false }; - const store = new Vuex.Store({ - state, - }); - - vm = shallowMount(CreateEksCluster, { - propsData: { - gitlabManagedClusterHelpPath, - namespacePerEnvironmentHelpPath, - accountAndExternalIdsHelpPath, - createRoleArnHelpPath, - externalLinkIcon, - kubernetesIntegrationHelpPath, - }, - store, - }); - }); - afterEach(() => vm.destroy()); - - describe('when credentials are provided', () => { - beforeEach(() => { - state.hasCredentials = true; - }); - - it('displays eks cluster configuration form when credentials are valid', () => { - expect(vm.find(EksClusterConfigurationForm).exists()).toBe(true); - }); - - describe('passes to the cluster configuration form', () => { - it('help url for kubernetes integration documentation', () => { - expect(vm.find(EksClusterConfigurationForm).props('gitlabManagedClusterHelpPath')).toBe( - gitlabManagedClusterHelpPath, - ); - }); - - it('help url for namespace per environment cluster documentation', () => { - expect(vm.find(EksClusterConfigurationForm).props('namespacePerEnvironmentHelpPath')).toBe( - namespacePerEnvironmentHelpPath, - ); - }); - - it('help url for gitlab managed cluster documentation', () => { - expect(vm.find(EksClusterConfigurationForm).props('kubernetesIntegrationHelpPath')).toBe( - kubernetesIntegrationHelpPath, - ); - }); - }); - }); - - describe('when credentials are invalid', () => { - beforeEach(() => { - state.hasCredentials = false; - }); - - it('displays service credentials form', () => { - expect(vm.find(ServiceCredentialsForm).exists()).toBe(true); - }); - - describe('passes to the service credentials form', () => { - it('help url for account and external ids', () => { - expect(vm.find(ServiceCredentialsForm).props('accountAndExternalIdsHelpPath')).toBe( - accountAndExternalIdsHelpPath, - ); - }); - - it('external link icon', () => { - expect(vm.find(ServiceCredentialsForm).props('externalLinkIcon')).toBe(externalLinkIcon); - }); - - it('help url to create a role ARN', () => { - expect(vm.find(ServiceCredentialsForm).props('createRoleArnHelpPath')).toBe( - createRoleArnHelpPath, - ); - }); - }); - }); -}); diff --git a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js deleted file mode 100644 index 1509d26c99d..00000000000 --- a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js +++ /dev/null @@ -1,562 +0,0 @@ -import { GlFormCheckbox } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import Vuex from 'vuex'; - -import EksClusterConfigurationForm from '~/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue'; -import eksClusterFormState from '~/create_cluster/eks_cluster/store/state'; -import clusterDropdownStoreState from '~/create_cluster/store/cluster_dropdown/state'; - -Vue.use(Vuex); - -describe('EksClusterConfigurationForm', () => { - let store; - let actions; - let getters; - let state; - let rolesState; - let vpcsState; - let subnetsState; - let keyPairsState; - let securityGroupsState; - let instanceTypesState; - let vpcsActions; - let rolesActions; - let subnetsActions; - let keyPairsActions; - let securityGroupsActions; - let vm; - - const createStore = (config = {}) => { - actions = { - createCluster: jest.fn(), - setClusterName: jest.fn(), - setEnvironmentScope: jest.fn(), - setKubernetesVersion: jest.fn(), - setRegion: jest.fn(), - setVpc: jest.fn(), - setSubnet: jest.fn(), - setRole: jest.fn(), - setKeyPair: jest.fn(), - setSecurityGroup: jest.fn(), - setInstanceType: jest.fn(), - setNodeCount: jest.fn(), - setGitlabManagedCluster: jest.fn(), - }; - keyPairsActions = { - fetchItems: jest.fn(), - }; - vpcsActions = { - fetchItems: jest.fn(), - }; - subnetsActions = { - fetchItems: jest.fn(), - }; - rolesActions = { - fetchItems: jest.fn(), - }; - securityGroupsActions = { - fetchItems: jest.fn(), - }; - state = { - ...eksClusterFormState(), - ...config.initialState, - }; - rolesState = { - ...clusterDropdownStoreState(), - ...config.rolesState, - }; - vpcsState = { - ...clusterDropdownStoreState(), - ...config.vpcsState, - }; - subnetsState = { - ...clusterDropdownStoreState(), - ...config.subnetsState, - }; - keyPairsState = { - ...clusterDropdownStoreState(), - ...config.keyPairsState, - }; - securityGroupsState = { - ...clusterDropdownStoreState(), - ...config.securityGroupsState, - }; - instanceTypesState = { - ...clusterDropdownStoreState(), - ...config.instanceTypesState, - }; - getters = { - subnetValid: config?.getters?.subnetValid || (() => false), - }; - store = new Vuex.Store({ - state, - getters, - actions, - modules: { - vpcs: { - namespaced: true, - state: vpcsState, - actions: vpcsActions, - }, - subnets: { - namespaced: true, - state: subnetsState, - actions: subnetsActions, - }, - roles: { - namespaced: true, - state: rolesState, - actions: rolesActions, - }, - keyPairs: { - namespaced: true, - state: keyPairsState, - actions: keyPairsActions, - }, - securityGroups: { - namespaced: true, - state: securityGroupsState, - actions: securityGroupsActions, - }, - instanceTypes: { - namespaced: true, - state: instanceTypesState, - }, - }, - }); - }; - - const createValidStateStore = (initialState) => { - createStore({ - initialState: { - clusterName: 'cluster name', - environmentScope: '*', - kubernetesVersion: '1.16', - selectedRegion: 'region', - selectedRole: 'role', - selectedKeyPair: 'key pair', - selectedVpc: 'vpc', - selectedSubnet: ['subnet 1', 'subnet 2'], - selectedSecurityGroup: 'group', - selectedInstanceType: 'small-1', - ...initialState, - }, - getters: { - subnetValid: () => true, - }, - }); - }; - - const buildWrapper = () => { - vm = shallowMount(EksClusterConfigurationForm, { - store, - propsData: { - gitlabManagedClusterHelpPath: '', - namespacePerEnvironmentHelpPath: '', - kubernetesIntegrationHelpPath: '', - externalLinkIcon: '', - }, - }); - }; - - beforeEach(() => { - createStore(); - buildWrapper(); - }); - - afterEach(() => { - vm.destroy(); - }); - - const findCreateClusterButton = () => vm.find('.js-create-cluster'); - const findClusterNameInput = () => vm.find('[id=eks-cluster-name]'); - const findEnvironmentScopeInput = () => vm.find('[id=eks-environment-scope]'); - const findKubernetesVersionDropdown = () => vm.find('[field-id="eks-kubernetes-version"]'); - const findKeyPairDropdown = () => vm.find('[field-id="eks-key-pair"]'); - const findVpcDropdown = () => vm.find('[field-id="eks-vpc"]'); - const findSubnetDropdown = () => vm.find('[field-id="eks-subnet"]'); - const findRoleDropdown = () => vm.find('[field-id="eks-role"]'); - const findSecurityGroupDropdown = () => vm.find('[field-id="eks-security-group"]'); - const findInstanceTypeDropdown = () => vm.find('[field-id="eks-instance-type"'); - const findNodeCountInput = () => vm.find('[id="eks-node-count"]'); - const findGitlabManagedClusterCheckbox = () => vm.find(GlFormCheckbox); - - describe('when mounted', () => { - it('fetches available roles', () => { - expect(rolesActions.fetchItems).toHaveBeenCalled(); - }); - - describe('when fetching vpcs and key pairs', () => { - const region = 'us-west-2'; - - beforeEach(() => { - createValidStateStore({ selectedRegion: region }); - buildWrapper(); - }); - - it('fetches available vpcs', () => { - expect(vpcsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { region }); - }); - - it('fetches available key pairs', () => { - expect(keyPairsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { region }); - }); - - it('cleans selected vpc', () => { - expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc: null }); - }); - - it('cleans selected key pair', () => { - expect(actions.setKeyPair).toHaveBeenCalledWith(expect.anything(), { keyPair: null }); - }); - - it('cleans selected subnet', () => { - expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet: [] }); - }); - - it('cleans selected security group', () => { - expect(actions.setSecurityGroup).toHaveBeenCalledWith(expect.anything(), { - securityGroup: null, - }); - }); - }); - }); - - it('sets isLoadingRoles to RoleDropdown loading property', async () => { - rolesState.isLoadingItems = true; - - await nextTick(); - expect(findRoleDropdown().props('loading')).toBe(rolesState.isLoadingItems); - }); - - it('sets roles to RoleDropdown items property', () => { - expect(findRoleDropdown().props('items')).toBe(rolesState.items); - }); - - it('sets RoleDropdown hasErrors to true when loading roles failed', async () => { - rolesState.loadingItemsError = new Error(); - - await nextTick(); - expect(findRoleDropdown().props('hasErrors')).toEqual(true); - }); - - it('disables KeyPairDropdown when no region is selected', () => { - expect(findKeyPairDropdown().props('disabled')).toBe(true); - }); - - it('enables KeyPairDropdown when no region is selected', async () => { - state.selectedRegion = { name: 'west-1 ' }; - - await nextTick(); - expect(findKeyPairDropdown().props('disabled')).toBe(false); - }); - - it('sets isLoadingKeyPairs to KeyPairDropdown loading property', async () => { - keyPairsState.isLoadingItems = true; - - await nextTick(); - expect(findKeyPairDropdown().props('loading')).toBe(keyPairsState.isLoadingItems); - }); - - it('sets keyPairs to KeyPairDropdown items property', () => { - expect(findKeyPairDropdown().props('items')).toBe(keyPairsState.items); - }); - - it('sets KeyPairDropdown hasErrors to true when loading key pairs fails', async () => { - keyPairsState.loadingItemsError = new Error(); - - await nextTick(); - expect(findKeyPairDropdown().props('hasErrors')).toEqual(true); - }); - - it('disables VpcDropdown when no region is selected', () => { - expect(findVpcDropdown().props('disabled')).toBe(true); - }); - - it('enables VpcDropdown when no region is selected', async () => { - state.selectedRegion = { name: 'west-1 ' }; - - await nextTick(); - expect(findVpcDropdown().props('disabled')).toBe(false); - }); - - it('sets isLoadingVpcs to VpcDropdown loading property', async () => { - vpcsState.isLoadingItems = true; - - await nextTick(); - expect(findVpcDropdown().props('loading')).toBe(vpcsState.isLoadingItems); - }); - - it('sets vpcs to VpcDropdown items property', () => { - expect(findVpcDropdown().props('items')).toBe(vpcsState.items); - }); - - it('sets VpcDropdown hasErrors to true when loading vpcs fails', async () => { - vpcsState.loadingItemsError = new Error(); - - await nextTick(); - expect(findVpcDropdown().props('hasErrors')).toEqual(true); - }); - - it('disables SubnetDropdown when no vpc is selected', () => { - expect(findSubnetDropdown().props('disabled')).toBe(true); - }); - - it('enables SubnetDropdown when a vpc is selected', async () => { - state.selectedVpc = { name: 'vpc-1 ' }; - - await nextTick(); - expect(findSubnetDropdown().props('disabled')).toBe(false); - }); - - it('sets isLoadingSubnets to SubnetDropdown loading property', async () => { - subnetsState.isLoadingItems = true; - - await nextTick(); - expect(findSubnetDropdown().props('loading')).toBe(subnetsState.isLoadingItems); - }); - - it('sets subnets to SubnetDropdown items property', () => { - expect(findSubnetDropdown().props('items')).toBe(subnetsState.items); - }); - - it('displays a validation error in the subnet dropdown when loading subnets fails', () => { - createStore({ - subnetsState: { - loadingItemsError: new Error(), - }, - }); - buildWrapper(); - - expect(findSubnetDropdown().props('hasErrors')).toEqual(true); - }); - - it('displays a validation error in the subnet dropdown when a single subnet is selected', () => { - createStore({ - initialState: { - selectedSubnet: ['subnet 1'], - }, - }); - buildWrapper(); - - expect(findSubnetDropdown().props('hasErrors')).toEqual(true); - expect(findSubnetDropdown().props('errorMessage')).toEqual( - 'You should select at least two subnets', - ); - }); - - it('disables SecurityGroupDropdown when no vpc is selected', () => { - expect(findSecurityGroupDropdown().props('disabled')).toBe(true); - }); - - it('enables SecurityGroupDropdown when a vpc is selected', async () => { - state.selectedVpc = { name: 'vpc-1 ' }; - - await nextTick(); - expect(findSecurityGroupDropdown().props('disabled')).toBe(false); - }); - - it('sets isLoadingSecurityGroups to SecurityGroupDropdown loading property', async () => { - securityGroupsState.isLoadingItems = true; - - await nextTick(); - expect(findSecurityGroupDropdown().props('loading')).toBe(securityGroupsState.isLoadingItems); - }); - - it('sets securityGroups to SecurityGroupDropdown items property', () => { - expect(findSecurityGroupDropdown().props('items')).toBe(securityGroupsState.items); - }); - - it('sets SecurityGroupDropdown hasErrors to true when loading security groups fails', async () => { - securityGroupsState.loadingItemsError = new Error(); - - await nextTick(); - expect(findSecurityGroupDropdown().props('hasErrors')).toEqual(true); - }); - - it('dispatches setClusterName when cluster name input changes', () => { - const clusterName = 'name'; - - findClusterNameInput().vm.$emit('input', clusterName); - - expect(actions.setClusterName).toHaveBeenCalledWith(expect.anything(), { clusterName }); - }); - - it('dispatches setEnvironmentScope when environment scope input changes', () => { - const environmentScope = 'production'; - - findEnvironmentScopeInput().vm.$emit('input', environmentScope); - - expect(actions.setEnvironmentScope).toHaveBeenCalledWith(expect.anything(), { - environmentScope, - }); - }); - - it('dispatches setKubernetesVersion when kubernetes version dropdown changes', () => { - const kubernetesVersion = { name: '1.11' }; - - findKubernetesVersionDropdown().vm.$emit('input', kubernetesVersion); - - expect(actions.setKubernetesVersion).toHaveBeenCalledWith(expect.anything(), { - kubernetesVersion, - }); - }); - - it('dispatches setGitlabManagedCluster when gitlab managed cluster input changes', () => { - const gitlabManagedCluster = false; - - findGitlabManagedClusterCheckbox().vm.$emit('input', gitlabManagedCluster); - - expect(actions.setGitlabManagedCluster).toHaveBeenCalledWith(expect.anything(), { - gitlabManagedCluster, - }); - }); - - describe('when vpc is selected', () => { - const vpc = { name: 'vpc-1' }; - const region = 'east-1'; - - beforeEach(() => { - state.selectedRegion = region; - findVpcDropdown().vm.$emit('input', vpc); - }); - - it('dispatches setVpc action', () => { - expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc }); - }); - - it('cleans selected subnet', () => { - expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet: [] }); - }); - - it('cleans selected security group', () => { - expect(actions.setSecurityGroup).toHaveBeenCalledWith(expect.anything(), { - securityGroup: null, - }); - }); - - it('dispatches fetchSubnets action', () => { - expect(subnetsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { vpc, region }); - }); - - it('dispatches fetchSecurityGroups action', () => { - expect(securityGroupsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { - vpc, - region, - }); - }); - }); - - describe('when a subnet is selected', () => { - const subnet = { name: 'subnet-1' }; - - beforeEach(() => { - findSubnetDropdown().vm.$emit('input', subnet); - }); - - it('dispatches setSubnet action', () => { - expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet }); - }); - }); - - describe('when role is selected', () => { - const role = { name: 'admin' }; - - beforeEach(() => { - findRoleDropdown().vm.$emit('input', role); - }); - - it('dispatches setRole action', () => { - expect(actions.setRole).toHaveBeenCalledWith(expect.anything(), { role }); - }); - }); - - describe('when key pair is selected', () => { - const keyPair = { name: 'key pair' }; - - beforeEach(() => { - findKeyPairDropdown().vm.$emit('input', keyPair); - }); - - it('dispatches setKeyPair action', () => { - expect(actions.setKeyPair).toHaveBeenCalledWith(expect.anything(), { keyPair }); - }); - }); - - describe('when security group is selected', () => { - const securityGroup = { name: 'default group' }; - - beforeEach(() => { - findSecurityGroupDropdown().vm.$emit('input', securityGroup); - }); - - it('dispatches setSecurityGroup action', () => { - expect(actions.setSecurityGroup).toHaveBeenCalledWith(expect.anything(), { securityGroup }); - }); - }); - - describe('when instance type is selected', () => { - const instanceType = 'small-1'; - - beforeEach(() => { - findInstanceTypeDropdown().vm.$emit('input', instanceType); - }); - - it('dispatches setInstanceType action', () => { - expect(actions.setInstanceType).toHaveBeenCalledWith(expect.anything(), { instanceType }); - }); - }); - - it('dispatches setNodeCount when node count input changes', () => { - const nodeCount = 5; - - findNodeCountInput().vm.$emit('input', nodeCount); - - expect(actions.setNodeCount).toHaveBeenCalledWith(expect.anything(), { nodeCount }); - }); - - describe('when all cluster configuration fields are set', () => { - it('enables create cluster button', () => { - createValidStateStore(); - buildWrapper(); - expect(findCreateClusterButton().props('disabled')).toBe(false); - }); - }); - - describe('when at least one cluster configuration field is not set', () => { - beforeEach(() => { - createValidStateStore({ - clusterName: null, - }); - buildWrapper(); - }); - - it('disables create cluster button', () => { - expect(findCreateClusterButton().props('disabled')).toBe(true); - }); - }); - - describe('when is creating cluster', () => { - beforeEach(() => { - createValidStateStore({ - isCreatingCluster: true, - }); - buildWrapper(); - }); - - it('sets create cluster button as loading', () => { - expect(findCreateClusterButton().props('loading')).toBe(true); - }); - }); - - describe('clicking create cluster button', () => { - beforeEach(() => { - findCreateClusterButton().vm.$emit('click'); - }); - - it('dispatches createCluster action', () => { - expect(actions.createCluster).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js deleted file mode 100644 index 0d823a18012..00000000000 --- a/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js +++ /dev/null @@ -1,124 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import Vuex from 'vuex'; -import ServiceCredentialsForm from '~/create_cluster/eks_cluster/components/service_credentials_form.vue'; -import eksClusterState from '~/create_cluster/eks_cluster/store/state'; - -Vue.use(Vuex); - -describe('ServiceCredentialsForm', () => { - let vm; - let state; - let createRoleAction; - const accountId = 'accountId'; - const externalId = 'externalId'; - - beforeEach(() => { - state = Object.assign(eksClusterState(), { - accountId, - externalId, - }); - createRoleAction = jest.fn(); - - const store = new Vuex.Store({ - state, - actions: { - createRole: createRoleAction, - }, - }); - vm = shallowMount(ServiceCredentialsForm, { - propsData: { - accountAndExternalIdsHelpPath: '', - createRoleArnHelpPath: '', - externalLinkIcon: '', - }, - store, - }); - }); - afterEach(() => vm.destroy()); - - const findAccountIdInput = () => vm.find('#gitlab-account-id'); - const findCopyAccountIdButton = () => vm.find('.js-copy-account-id-button'); - const findExternalIdInput = () => vm.find('#eks-external-id'); - const findCopyExternalIdButton = () => vm.find('.js-copy-external-id-button'); - const findInvalidCredentials = () => vm.find('.js-invalid-credentials'); - const findSubmitButton = () => vm.find(GlButton); - - it('displays provided account id', () => { - expect(findAccountIdInput().attributes('value')).toBe(accountId); - }); - - it('allows to copy account id', () => { - expect(findCopyAccountIdButton().props('text')).toBe(accountId); - }); - - it('displays provided external id', () => { - expect(findExternalIdInput().attributes('value')).toBe(externalId); - }); - - it('allows to copy external id', () => { - expect(findCopyExternalIdButton().props('text')).toBe(externalId); - }); - - it('disables submit button when role ARN is not provided', () => { - expect(findSubmitButton().attributes('disabled')).toBeTruthy(); - }); - - it('enables submit button when role ARN is not provided', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - vm.setData({ roleArn: '123' }); - - await nextTick(); - expect(findSubmitButton().attributes('disabled')).toBeFalsy(); - }); - - it('dispatches createRole action when submit button is clicked', () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - vm.setData({ roleArn: '123' }); // set role ARN to enable button - - findSubmitButton().vm.$emit('click', new Event('click')); - - expect(createRoleAction).toHaveBeenCalled(); - }); - - describe('when is creating role', () => { - beforeEach(async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - vm.setData({ roleArn: '123' }); // set role ARN to enable button - - state.isCreatingRole = true; - - await nextTick(); - }); - - it('disables submit button', () => { - expect(findSubmitButton().props('disabled')).toBe(true); - }); - - it('sets submit button as loading', () => { - expect(findSubmitButton().props('loading')).toBe(true); - }); - - it('displays Authenticating label on submit button', () => { - expect(findSubmitButton().text()).toBe('Authenticating'); - }); - }); - - describe('when role can’t be created', () => { - beforeEach(() => { - state.createRoleError = 'Invalid credentials'; - }); - - it('displays invalid role warning banner', () => { - expect(findInvalidCredentials().exists()).toBe(true); - }); - - it('displays invalid role error message', () => { - expect(findInvalidCredentials().text()).toContain(state.createRoleError); - }); - }); -}); diff --git a/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js b/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js deleted file mode 100644 index 7b93b6d0a09..00000000000 --- a/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js +++ /dev/null @@ -1,178 +0,0 @@ -import EC2 from 'aws-sdk/clients/ec2'; -import AWS from 'aws-sdk/global'; -import { - setAWSConfig, - fetchRoles, - fetchKeyPairs, - fetchVpcs, - fetchSubnets, - fetchSecurityGroups, -} from '~/create_cluster/eks_cluster/services/aws_services_facade'; - -const mockListRolesPromise = jest.fn(); -const mockDescribeRegionsPromise = jest.fn(); -const mockDescribeKeyPairsPromise = jest.fn(); -const mockDescribeVpcsPromise = jest.fn(); -const mockDescribeSubnetsPromise = jest.fn(); -const mockDescribeSecurityGroupsPromise = jest.fn(); - -jest.mock('aws-sdk/clients/iam', () => - jest.fn().mockImplementation(() => ({ - listRoles: jest.fn().mockReturnValue({ promise: mockListRolesPromise }), - })), -); - -jest.mock('aws-sdk/clients/ec2', () => - jest.fn().mockImplementation(() => ({ - describeRegions: jest.fn().mockReturnValue({ promise: mockDescribeRegionsPromise }), - describeKeyPairs: jest.fn().mockReturnValue({ promise: mockDescribeKeyPairsPromise }), - describeVpcs: jest.fn().mockReturnValue({ promise: mockDescribeVpcsPromise }), - describeSubnets: jest.fn().mockReturnValue({ promise: mockDescribeSubnetsPromise }), - describeSecurityGroups: jest - .fn() - .mockReturnValue({ promise: mockDescribeSecurityGroupsPromise }), - })), -); - -describe('awsServicesFacade', () => { - let region; - let vpc; - - beforeEach(() => { - region = 'west-1'; - vpc = 'vpc-2'; - }); - - it('setAWSConfig configures AWS SDK with provided credentials', () => { - const awsCredentials = { - accessKeyId: 'access-key', - secretAccessKey: 'secret-key', - sessionToken: 'session-token', - region, - }; - - setAWSConfig({ awsCredentials }); - - expect(AWS.config).toEqual(awsCredentials); - }); - - describe('when fetchRoles succeeds', () => { - let roles; - let rolesOutput; - - beforeEach(() => { - roles = [ - { RoleName: 'admin', Arn: 'aws::admin' }, - { RoleName: 'read-only', Arn: 'aws::read-only' }, - ]; - rolesOutput = roles.map(({ RoleName: name, Arn: value }) => ({ name, value })); - - mockListRolesPromise.mockResolvedValueOnce({ Roles: roles }); - }); - - it('return list of regions where each item has a name and value', () => { - return expect(fetchRoles()).resolves.toEqual(rolesOutput); - }); - }); - - describe('when fetchKeyPairs succeeds', () => { - let keyPairs; - let keyPairsOutput; - - beforeEach(() => { - keyPairs = [{ KeyName: 'key-pair' }, { KeyName: 'key-pair-2' }]; - keyPairsOutput = keyPairs.map(({ KeyName: name }) => ({ name, value: name })); - - mockDescribeKeyPairsPromise.mockResolvedValueOnce({ KeyPairs: keyPairs }); - }); - - it('instantatiates ec2 service with provided region', () => { - fetchKeyPairs({ region }); - expect(EC2).toHaveBeenCalledWith({ region }); - }); - - it('return list of key pairs where each item has a name and value', () => { - return expect(fetchKeyPairs({ region })).resolves.toEqual(keyPairsOutput); - }); - }); - - describe('when fetchVpcs succeeds', () => { - let vpcs; - let vpcsOutput; - - beforeEach(() => { - vpcs = [ - { VpcId: 'vpc-1', Tags: [] }, - { VpcId: 'vpc-2', Tags: [] }, - ]; - vpcsOutput = vpcs.map(({ VpcId: vpcId }) => ({ name: vpcId, value: vpcId })); - - mockDescribeVpcsPromise.mockResolvedValueOnce({ Vpcs: vpcs }); - }); - - it('instantatiates ec2 service with provided region', () => { - fetchVpcs({ region }); - expect(EC2).toHaveBeenCalledWith({ region }); - }); - - it('return list of vpcs where each item has a name and value', () => { - return expect(fetchVpcs({ region })).resolves.toEqual(vpcsOutput); - }); - }); - - describe('when vpcs has a Name tag', () => { - const vpcName = 'vpc name'; - const vpcId = 'vpc id'; - let vpcs; - let vpcsOutput; - - beforeEach(() => { - vpcs = [{ VpcId: vpcId, Tags: [{ Key: 'Name', Value: vpcName }] }]; - vpcsOutput = [{ name: vpcName, value: vpcId }]; - - mockDescribeVpcsPromise.mockResolvedValueOnce({ Vpcs: vpcs }); - }); - - it('uses name tag value as the vpc name', () => { - return expect(fetchVpcs({ region })).resolves.toEqual(vpcsOutput); - }); - }); - - describe('when fetchSubnets succeeds', () => { - let subnets; - let subnetsOutput; - - beforeEach(() => { - subnets = [{ SubnetId: 'subnet-1' }, { SubnetId: 'subnet-2' }]; - subnetsOutput = subnets.map(({ SubnetId }) => ({ name: SubnetId, value: SubnetId })); - - mockDescribeSubnetsPromise.mockResolvedValueOnce({ Subnets: subnets }); - }); - - it('return list of subnets where each item has a name and value', () => { - return expect(fetchSubnets({ region, vpc })).resolves.toEqual(subnetsOutput); - }); - }); - - describe('when fetchSecurityGroups succeeds', () => { - let securityGroups; - let securityGroupsOutput; - - beforeEach(() => { - securityGroups = [ - { GroupName: 'admin group', GroupId: 'group-1' }, - { GroupName: 'basic group', GroupId: 'group-2' }, - ]; - securityGroupsOutput = securityGroups.map(({ GroupId: value, GroupName: name }) => ({ - name, - value, - })); - - mockDescribeSecurityGroupsPromise.mockResolvedValueOnce({ SecurityGroups: securityGroups }); - }); - - it('return list of security groups where each item has a name and value', () => { - return expect(fetchSecurityGroups({ region, vpc })).resolves.toEqual(securityGroupsOutput); - }); - }); -}); diff --git a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js deleted file mode 100644 index 8d7b22fe4ff..00000000000 --- a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js +++ /dev/null @@ -1,366 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; -import testAction from 'helpers/vuex_action_helper'; -import { DEFAULT_REGION } from '~/create_cluster/eks_cluster/constants'; -import * as actions from '~/create_cluster/eks_cluster/store/actions'; -import { - SET_CLUSTER_NAME, - SET_ENVIRONMENT_SCOPE, - SET_KUBERNETES_VERSION, - SET_REGION, - SET_VPC, - SET_KEY_PAIR, - SET_SUBNET, - SET_ROLE, - SET_SECURITY_GROUP, - SET_GITLAB_MANAGED_CLUSTER, - SET_NAMESPACE_PER_ENVIRONMENT, - SET_INSTANCE_TYPE, - SET_NODE_COUNT, - REQUEST_CREATE_ROLE, - CREATE_ROLE_SUCCESS, - CREATE_ROLE_ERROR, - REQUEST_CREATE_CLUSTER, - CREATE_CLUSTER_ERROR, -} from '~/create_cluster/eks_cluster/store/mutation_types'; -import createState from '~/create_cluster/eks_cluster/store/state'; -import createFlash from '~/flash'; -import axios from '~/lib/utils/axios_utils'; - -jest.mock('~/flash'); - -describe('EKS Cluster Store Actions', () => { - let clusterName; - let environmentScope; - let kubernetesVersion; - let region; - let vpc; - let subnet; - let role; - let keyPair; - let securityGroup; - let instanceType; - let nodeCount; - let gitlabManagedCluster; - let namespacePerEnvironment; - let mock; - let state; - let newClusterUrl; - - beforeEach(() => { - clusterName = 'my cluster'; - environmentScope = 'production'; - kubernetesVersion = '1.16'; - region = 'regions-1'; - vpc = 'vpc-1'; - subnet = 'subnet-1'; - role = 'role-1'; - keyPair = 'key-pair-1'; - securityGroup = 'default group'; - instanceType = 'small-1'; - nodeCount = '5'; - gitlabManagedCluster = true; - namespacePerEnvironment = true; - - newClusterUrl = '/clusters/1'; - - state = { - ...createState(), - createRolePath: '/clusters/roles/', - createClusterPath: '/clusters/', - }; - }); - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - it.each` - action | mutation | payload | payloadDescription - ${'setClusterName'} | ${SET_CLUSTER_NAME} | ${{ clusterName }} | ${'cluster name'} - ${'setEnvironmentScope'} | ${SET_ENVIRONMENT_SCOPE} | ${{ environmentScope }} | ${'environment scope'} - ${'setKubernetesVersion'} | ${SET_KUBERNETES_VERSION} | ${{ kubernetesVersion }} | ${'kubernetes version'} - ${'setRole'} | ${SET_ROLE} | ${{ role }} | ${'role'} - ${'setRegion'} | ${SET_REGION} | ${{ region }} | ${'region'} - ${'setKeyPair'} | ${SET_KEY_PAIR} | ${{ keyPair }} | ${'key pair'} - ${'setVpc'} | ${SET_VPC} | ${{ vpc }} | ${'vpc'} - ${'setSubnet'} | ${SET_SUBNET} | ${{ subnet }} | ${'subnet'} - ${'setSecurityGroup'} | ${SET_SECURITY_GROUP} | ${{ securityGroup }} | ${'securityGroup'} - ${'setInstanceType'} | ${SET_INSTANCE_TYPE} | ${{ instanceType }} | ${'instance type'} - ${'setNodeCount'} | ${SET_NODE_COUNT} | ${{ nodeCount }} | ${'node count'} - ${'setGitlabManagedCluster'} | ${SET_GITLAB_MANAGED_CLUSTER} | ${gitlabManagedCluster} | ${'gitlab managed cluster'} - ${'setNamespacePerEnvironment'} | ${SET_NAMESPACE_PER_ENVIRONMENT} | ${namespacePerEnvironment} | ${'namespace per environment'} - `(`$action commits $mutation with $payloadDescription payload`, (data) => { - const { action, mutation, payload } = data; - - testAction(actions[action], payload, state, [{ type: mutation, payload }]); - }); - - describe('createRole', () => { - const payload = { - roleArn: 'role_arn', - externalId: 'externalId', - }; - const response = { - accessKeyId: 'access-key-id', - secretAccessKey: 'secret-key-id', - }; - - describe('when request succeeds with default region', () => { - beforeEach(() => { - mock - .onPost(state.createRolePath, { - role_arn: payload.roleArn, - role_external_id: payload.externalId, - region: DEFAULT_REGION, - }) - .reply(201, response); - }); - - it('dispatches createRoleSuccess action', () => - testAction( - actions.createRole, - payload, - state, - [], - [ - { type: 'requestCreateRole' }, - { - type: 'createRoleSuccess', - payload: { - region: DEFAULT_REGION, - ...response, - }, - }, - ], - )); - }); - - describe('when request succeeds with custom region', () => { - const customRegion = 'custom-region'; - - beforeEach(() => { - mock - .onPost(state.createRolePath, { - role_arn: payload.roleArn, - role_external_id: payload.externalId, - region: customRegion, - }) - .reply(201, response); - }); - - it('dispatches createRoleSuccess action', () => - testAction( - actions.createRole, - { - selectedRegion: customRegion, - ...payload, - }, - state, - [], - [ - { type: 'requestCreateRole' }, - { - type: 'createRoleSuccess', - payload: { - region: customRegion, - ...response, - }, - }, - ], - )); - }); - - describe('when request fails', () => { - let error; - - beforeEach(() => { - error = new Error('Request failed with status code 400'); - mock - .onPost(state.createRolePath, { - role_arn: payload.roleArn, - role_external_id: payload.externalId, - region: DEFAULT_REGION, - }) - .reply(400, null); - }); - - it('dispatches createRoleError action', () => - testAction( - actions.createRole, - payload, - state, - [], - [{ type: 'requestCreateRole' }, { type: 'createRoleError', payload: { error } }], - )); - }); - - describe('when request fails with a message', () => { - beforeEach(() => { - const errResp = { message: 'Something failed' }; - - mock - .onPost(state.createRolePath, { - role_arn: payload.roleArn, - role_external_id: payload.externalId, - region: DEFAULT_REGION, - }) - .reply(4, errResp); - }); - - it('dispatches createRoleError action', () => - testAction( - actions.createRole, - payload, - state, - [], - [ - { type: 'requestCreateRole' }, - { type: 'createRoleError', payload: { error: 'Something failed' } }, - ], - )); - }); - }); - - describe('requestCreateRole', () => { - it('commits requestCreaterole mutation', () => { - testAction(actions.requestCreateRole, null, state, [{ type: REQUEST_CREATE_ROLE }]); - }); - }); - - describe('createRoleSuccess', () => { - it('sets region and commits createRoleSuccess mutation', () => { - testAction( - actions.createRoleSuccess, - { region }, - state, - [{ type: CREATE_ROLE_SUCCESS }], - [{ type: 'setRegion', payload: { region } }], - ); - }); - }); - - describe('createRoleError', () => { - it('commits createRoleError mutation', () => { - const payload = { - error: new Error(), - }; - - testAction(actions.createRoleError, payload, state, [{ type: CREATE_ROLE_ERROR, payload }]); - }); - }); - - describe('createCluster', () => { - let requestPayload; - - beforeEach(() => { - requestPayload = { - name: clusterName, - environment_scope: environmentScope, - managed: gitlabManagedCluster, - namespace_per_environment: namespacePerEnvironment, - provider_aws_attributes: { - kubernetes_version: kubernetesVersion, - region, - vpc_id: vpc, - subnet_ids: subnet, - role_arn: role, - key_name: keyPair, - security_group_id: securityGroup, - instance_type: instanceType, - num_nodes: nodeCount, - }, - }; - state = Object.assign(createState(), { - clusterName, - environmentScope, - kubernetesVersion, - selectedRegion: region, - selectedVpc: vpc, - selectedSubnet: subnet, - selectedRole: role, - selectedKeyPair: keyPair, - selectedSecurityGroup: securityGroup, - selectedInstanceType: instanceType, - nodeCount, - gitlabManagedCluster, - namespacePerEnvironment, - }); - }); - - describe('when request succeeds', () => { - beforeEach(() => { - mock.onPost(state.createClusterPath, requestPayload).reply(201, null, { - location: '/clusters/1', - }); - }); - - it('dispatches createClusterSuccess action', () => - testAction( - actions.createCluster, - null, - state, - [], - [ - { type: 'requestCreateCluster' }, - { type: 'createClusterSuccess', payload: newClusterUrl }, - ], - )); - }); - - describe('when request fails', () => { - let response; - - beforeEach(() => { - response = 'Request failed with status code 400'; - mock.onPost(state.createClusterPath, requestPayload).reply(400, response); - }); - - it('dispatches createRoleError action', () => - testAction( - actions.createCluster, - null, - state, - [], - [{ type: 'requestCreateCluster' }, { type: 'createClusterError', payload: response }], - )); - }); - }); - - describe('requestCreateCluster', () => { - it('commits requestCreateCluster mutation', () => { - testAction(actions.requestCreateCluster, null, state, [{ type: REQUEST_CREATE_CLUSTER }]); - }); - }); - - describe('createClusterSuccess', () => { - useMockLocationHelper(); - - it('redirects to the new cluster URL', () => { - actions.createClusterSuccess(null, newClusterUrl); - - expect(window.location.assign).toHaveBeenCalledWith(newClusterUrl); - }); - }); - - describe('createClusterError', () => { - let payload; - - beforeEach(() => { - payload = { name: ['Create cluster failed'] }; - }); - - it('commits createClusterError mutation and displays flash message', () => - testAction(actions.createClusterError, payload, state, [ - { type: CREATE_CLUSTER_ERROR, payload }, - ]).then(() => { - expect(createFlash).toHaveBeenCalledWith({ - message: payload.name[0], - }); - })); - }); -}); diff --git a/spec/frontend/create_cluster/eks_cluster/store/getters_spec.js b/spec/frontend/create_cluster/eks_cluster/store/getters_spec.js deleted file mode 100644 index 46c37961dd3..00000000000 --- a/spec/frontend/create_cluster/eks_cluster/store/getters_spec.js +++ /dev/null @@ -1,13 +0,0 @@ -import { subnetValid } from '~/create_cluster/eks_cluster/store/getters'; - -describe('EKS Cluster Store Getters', () => { - describe('subnetValid', () => { - it('returns true if there are 2 or more selected subnets', () => { - expect(subnetValid({ selectedSubnet: [1, 2] })).toBe(true); - }); - - it.each([[[], [1]]])('returns false if there are 1 or less selected subnets', (subnets) => { - expect(subnetValid({ selectedSubnet: subnets })).toBe(false); - }); - }); -}); diff --git a/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js b/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js deleted file mode 100644 index 54d66e79be7..00000000000 --- a/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js +++ /dev/null @@ -1,161 +0,0 @@ -import { - SET_CLUSTER_NAME, - SET_ENVIRONMENT_SCOPE, - SET_KUBERNETES_VERSION, - SET_REGION, - SET_VPC, - SET_KEY_PAIR, - SET_SUBNET, - SET_ROLE, - SET_SECURITY_GROUP, - SET_INSTANCE_TYPE, - SET_NODE_COUNT, - SET_GITLAB_MANAGED_CLUSTER, - REQUEST_CREATE_ROLE, - CREATE_ROLE_SUCCESS, - CREATE_ROLE_ERROR, - REQUEST_CREATE_CLUSTER, - CREATE_CLUSTER_ERROR, -} from '~/create_cluster/eks_cluster/store/mutation_types'; -import mutations from '~/create_cluster/eks_cluster/store/mutations'; -import createState from '~/create_cluster/eks_cluster/store/state'; - -describe('Create EKS cluster store mutations', () => { - let clusterName; - let environmentScope; - let kubernetesVersion; - let state; - let region; - let vpc; - let subnet; - let role; - let keyPair; - let securityGroup; - let instanceType; - let nodeCount; - let gitlabManagedCluster; - - beforeEach(() => { - clusterName = 'my cluster'; - environmentScope = 'production'; - kubernetesVersion = '11.1'; - region = { name: 'regions-1' }; - vpc = { name: 'vpc-1' }; - subnet = { name: 'subnet-1' }; - role = { name: 'role-1' }; - keyPair = { name: 'key pair' }; - securityGroup = { name: 'default group' }; - instanceType = 'small-1'; - nodeCount = '5'; - gitlabManagedCluster = false; - - state = createState(); - }); - - it.each` - mutation | mutatedProperty | payload | expectedValue | expectedValueDescription - ${SET_CLUSTER_NAME} | ${'clusterName'} | ${{ clusterName }} | ${clusterName} | ${'cluster name'} - ${SET_ENVIRONMENT_SCOPE} | ${'environmentScope'} | ${{ environmentScope }} | ${environmentScope} | ${'environment scope'} - ${SET_KUBERNETES_VERSION} | ${'kubernetesVersion'} | ${{ kubernetesVersion }} | ${kubernetesVersion} | ${'kubernetes version'} - ${SET_ROLE} | ${'selectedRole'} | ${{ role }} | ${role} | ${'selected role payload'} - ${SET_REGION} | ${'selectedRegion'} | ${{ region }} | ${region} | ${'selected region payload'} - ${SET_KEY_PAIR} | ${'selectedKeyPair'} | ${{ keyPair }} | ${keyPair} | ${'selected key pair payload'} - ${SET_VPC} | ${'selectedVpc'} | ${{ vpc }} | ${vpc} | ${'selected vpc payload'} - ${SET_SUBNET} | ${'selectedSubnet'} | ${{ subnet }} | ${subnet} | ${'selected subnet payload'} - ${SET_SECURITY_GROUP} | ${'selectedSecurityGroup'} | ${{ securityGroup }} | ${securityGroup} | ${'selected security group payload'} - ${SET_INSTANCE_TYPE} | ${'selectedInstanceType'} | ${{ instanceType }} | ${instanceType} | ${'selected instance type payload'} - ${SET_NODE_COUNT} | ${'nodeCount'} | ${{ nodeCount }} | ${nodeCount} | ${'node count payload'} - ${SET_GITLAB_MANAGED_CLUSTER} | ${'gitlabManagedCluster'} | ${{ gitlabManagedCluster }} | ${gitlabManagedCluster} | ${'gitlab managed cluster'} - `(`$mutation sets $mutatedProperty to $expectedValueDescription`, (data) => { - const { mutation, mutatedProperty, payload, expectedValue } = data; - - mutations[mutation](state, payload); - expect(state[mutatedProperty]).toBe(expectedValue); - }); - - describe(`mutation ${REQUEST_CREATE_ROLE}`, () => { - beforeEach(() => { - mutations[REQUEST_CREATE_ROLE](state); - }); - - it('sets isCreatingRole to true', () => { - expect(state.isCreatingRole).toBe(true); - }); - - it('sets createRoleError to null', () => { - expect(state.createRoleError).toBe(null); - }); - - it('sets hasCredentials to false', () => { - expect(state.hasCredentials).toBe(false); - }); - }); - - describe(`mutation ${CREATE_ROLE_SUCCESS}`, () => { - beforeEach(() => { - mutations[CREATE_ROLE_SUCCESS](state); - }); - - it('sets isCreatingRole to false', () => { - expect(state.isCreatingRole).toBe(false); - }); - - it('sets createRoleError to null', () => { - expect(state.createRoleError).toBe(null); - }); - - it('sets hasCredentials to false', () => { - expect(state.hasCredentials).toBe(true); - }); - }); - - describe(`mutation ${CREATE_ROLE_ERROR}`, () => { - const error = new Error(); - - beforeEach(() => { - mutations[CREATE_ROLE_ERROR](state, { error }); - }); - - it('sets isCreatingRole to false', () => { - expect(state.isCreatingRole).toBe(false); - }); - - it('sets createRoleError to the error object', () => { - expect(state.createRoleError).toBe(error); - }); - - it('sets hasCredentials to false', () => { - expect(state.hasCredentials).toBe(false); - }); - }); - - describe(`mutation ${REQUEST_CREATE_CLUSTER}`, () => { - beforeEach(() => { - mutations[REQUEST_CREATE_CLUSTER](state); - }); - - it('sets isCreatingCluster to true', () => { - expect(state.isCreatingCluster).toBe(true); - }); - - it('sets createClusterError to null', () => { - expect(state.createClusterError).toBe(null); - }); - }); - - describe(`mutation ${CREATE_CLUSTER_ERROR}`, () => { - const error = new Error(); - - beforeEach(() => { - mutations[CREATE_CLUSTER_ERROR](state, { error }); - }); - - it('sets isCreatingRole to false', () => { - expect(state.isCreatingCluster).toBe(false); - }); - - it('sets createRoleError to the error object', () => { - expect(state.createClusterError).toBe(error); - }); - }); -}); diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js deleted file mode 100644 index f46b84da939..00000000000 --- a/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js +++ /dev/null @@ -1,129 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import Vuex from 'vuex'; -import GkeMachineTypeDropdown from '~/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue'; -import createState from '~/create_cluster/gke_cluster/store/state'; -import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; -import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; -import { selectedMachineTypeMock, gapiMachineTypesResponseMock } from '../mock_data'; - -const componentConfig = { - fieldId: 'cluster_provider_gcp_attributes_gcp_machine_type', - fieldName: 'cluster[provider_gcp_attributes][gcp_machine_type]', -}; -const setMachineType = jest.fn(); - -const LABELS = { - LOADING: 'Fetching machine types', - DISABLED_NO_PROJECT: 'Select project and zone to choose machine type', - DISABLED_NO_ZONE: 'Select zone to choose machine type', - DEFAULT: 'Select machine type', -}; - -Vue.use(Vuex); - -const createComponent = (store, propsData = componentConfig) => - shallowMount(GkeMachineTypeDropdown, { - propsData, - store, - }); - -const createStore = (initialState = {}, getters = {}) => - new Vuex.Store({ - state: { - ...createState(), - ...initialState, - }, - getters: { - hasZone: () => false, - ...getters, - }, - actions: { - setMachineType, - }, - }); - -describe('GkeMachineTypeDropdown', () => { - let wrapper; - let store; - - afterEach(() => { - wrapper.destroy(); - }); - - const dropdownButtonLabel = () => wrapper.find(DropdownButton).props('toggleText'); - const dropdownHiddenInputValue = () => wrapper.find(DropdownHiddenInput).props('value'); - - describe('shows various toggle text depending on state', () => { - it('returns disabled state toggle text when no project and zone are selected', () => { - store = createStore({ - projectHasBillingEnabled: false, - }); - wrapper = createComponent(store); - - expect(dropdownButtonLabel()).toBe(LABELS.DISABLED_NO_PROJECT); - }); - - it('returns disabled state toggle text when no zone is selected', () => { - store = createStore({ - projectHasBillingEnabled: true, - }); - wrapper = createComponent(store); - - expect(dropdownButtonLabel()).toBe(LABELS.DISABLED_NO_ZONE); - }); - - it('returns loading toggle text', async () => { - store = createStore(); - wrapper = createComponent(store); - - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ isLoading: true }); - - await nextTick(); - expect(dropdownButtonLabel()).toBe(LABELS.LOADING); - }); - - it('returns default toggle text', () => { - store = createStore( - { - projectHasBillingEnabled: true, - }, - { hasZone: () => true }, - ); - wrapper = createComponent(store); - - expect(dropdownButtonLabel()).toBe(LABELS.DEFAULT); - }); - - it('returns machine type name if machine type selected', () => { - store = createStore( - { - projectHasBillingEnabled: true, - selectedMachineType: selectedMachineTypeMock, - }, - { hasZone: () => true }, - ); - wrapper = createComponent(store); - - expect(dropdownButtonLabel()).toBe(selectedMachineTypeMock); - }); - }); - - describe('form input', () => { - it('reflects new value when dropdown item is clicked', async () => { - store = createStore({ - machineTypes: gapiMachineTypesResponseMock.items, - }); - wrapper = createComponent(store); - - expect(dropdownHiddenInputValue()).toBe(''); - - wrapper.find('.dropdown-content button').trigger('click'); - - await nextTick(); - expect(setMachineType).toHaveBeenCalledWith(expect.anything(), selectedMachineTypeMock); - }); - }); -}); diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_network_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_network_dropdown_spec.js deleted file mode 100644 index addb0ef72a0..00000000000 --- a/spec/frontend/create_cluster/gke_cluster/components/gke_network_dropdown_spec.js +++ /dev/null @@ -1,137 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; -import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue'; -import GkeNetworkDropdown from '~/create_cluster/gke_cluster/components/gke_network_dropdown.vue'; -import createClusterDropdownState from '~/create_cluster/store/cluster_dropdown/state'; - -Vue.use(Vuex); - -describe('GkeNetworkDropdown', () => { - let wrapper; - let store; - const defaultProps = { fieldName: 'field-name' }; - const selectedNetwork = { selfLink: '123456' }; - const projectId = '6789'; - const region = 'east-1'; - const setNetwork = jest.fn(); - const setSubnetwork = jest.fn(); - const fetchSubnetworks = jest.fn(); - - const buildStore = ({ clusterDropdownState } = {}) => - new Vuex.Store({ - state: { - selectedNetwork, - }, - actions: { - setNetwork, - setSubnetwork, - }, - getters: { - hasZone: () => false, - region: () => region, - projectId: () => projectId, - }, - modules: { - networks: { - namespaced: true, - state: { - ...createClusterDropdownState(), - ...(clusterDropdownState || {}), - }, - }, - subnetworks: { - namespaced: true, - actions: { - fetchItems: fetchSubnetworks, - }, - }, - }, - }); - - const buildWrapper = (propsData = defaultProps) => - shallowMount(GkeNetworkDropdown, { - propsData, - store, - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('sets correct field-name', () => { - const fieldName = 'field-name'; - - store = buildStore(); - wrapper = buildWrapper({ fieldName }); - - expect(wrapper.find(ClusterFormDropdown).props('fieldName')).toBe(fieldName); - }); - - it('sets selected network as the dropdown value', () => { - store = buildStore(); - wrapper = buildWrapper(); - - expect(wrapper.find(ClusterFormDropdown).props('value')).toBe(selectedNetwork); - }); - - it('maps networks store items to the dropdown items property', () => { - const items = [{ name: 'network' }]; - - store = buildStore({ clusterDropdownState: { items } }); - wrapper = buildWrapper(); - - expect(wrapper.find(ClusterFormDropdown).props('items')).toBe(items); - }); - - describe('when network dropdown store is loading items', () => { - it('sets network dropdown as loading', () => { - store = buildStore({ clusterDropdownState: { isLoadingItems: true } }); - wrapper = buildWrapper(); - - expect(wrapper.find(ClusterFormDropdown).props('loading')).toBe(true); - }); - }); - - describe('when there is no selected zone', () => { - it('disables the network dropdown', () => { - store = buildStore(); - wrapper = buildWrapper(); - - expect(wrapper.find(ClusterFormDropdown).props('disabled')).toBe(true); - }); - }); - - describe('when an error occurs while loading networks', () => { - it('sets the network dropdown as having errors', () => { - store = buildStore({ clusterDropdownState: { loadingItemsError: new Error() } }); - wrapper = buildWrapper(); - - expect(wrapper.find(ClusterFormDropdown).props('hasErrors')).toBe(true); - }); - }); - - describe('when dropdown emits input event', () => { - beforeEach(() => { - store = buildStore(); - wrapper = buildWrapper(); - wrapper.find(ClusterFormDropdown).vm.$emit('input', selectedNetwork); - }); - - it('cleans selected subnetwork', () => { - expect(setSubnetwork).toHaveBeenCalledWith(expect.anything(), ''); - }); - - it('dispatches the setNetwork action', () => { - expect(setNetwork).toHaveBeenCalledWith(expect.anything(), selectedNetwork); - }); - - it('fetches subnetworks for the selected project, region, and network', () => { - expect(fetchSubnetworks).toHaveBeenCalledWith(expect.anything(), { - project: projectId, - region, - network: selectedNetwork.selfLink, - }); - }); - }); -}); diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js deleted file mode 100644 index 36f8d4bd1e8..00000000000 --- a/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js +++ /dev/null @@ -1,137 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import Vuex from 'vuex'; -import GkeProjectIdDropdown from '~/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue'; -import createState from '~/create_cluster/gke_cluster/store/state'; -import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; -import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; -import { selectedProjectMock, gapiProjectsResponseMock } from '../mock_data'; - -const componentConfig = { - docsUrl: 'https://console.cloud.google.com/home/dashboard', - fieldId: 'cluster_provider_gcp_attributes_gcp_project_id', - fieldName: 'cluster[provider_gcp_attributes][gcp_project_id]', -}; - -const LABELS = { - LOADING: 'Fetching projects', - VALIDATING_PROJECT_BILLING: 'Validating project billing status', - DEFAULT: 'Select project', - EMPTY: 'No projects found', -}; - -Vue.use(Vuex); - -describe('GkeProjectIdDropdown', () => { - let wrapper; - let vuexStore; - let setProject; - - beforeEach(() => { - setProject = jest.fn(); - }); - - const createStore = (initialState = {}, getters = {}) => - new Vuex.Store({ - state: { - ...createState(), - ...initialState, - }, - actions: { - fetchProjects: jest.fn().mockResolvedValueOnce([]), - setProject, - }, - getters: { - hasProject: () => false, - ...getters, - }, - }); - - const createComponent = (store, propsData = componentConfig) => - shallowMount(GkeProjectIdDropdown, { - propsData, - store, - }); - - const bootstrap = (initialState, getters) => { - vuexStore = createStore(initialState, getters); - wrapper = createComponent(vuexStore); - }; - - const dropdownButtonLabel = () => wrapper.find(DropdownButton).props('toggleText'); - const dropdownHiddenInputValue = () => wrapper.find(DropdownHiddenInput).props('value'); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('toggleText', () => { - it('returns loading toggle text', () => { - bootstrap(); - - expect(dropdownButtonLabel()).toBe(LABELS.LOADING); - }); - - it('returns project billing validation text', () => { - bootstrap({ isValidatingProjectBilling: true }); - - expect(dropdownButtonLabel()).toBe(LABELS.VALIDATING_PROJECT_BILLING); - }); - - it('returns default toggle text', async () => { - bootstrap(); - - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ isLoading: false }); - - await nextTick(); - expect(dropdownButtonLabel()).toBe(LABELS.DEFAULT); - }); - - it('returns project name if project selected', async () => { - bootstrap( - { - selectedProject: selectedProjectMock, - }, - { - hasProject: () => true, - }, - ); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ isLoading: false }); - - await nextTick(); - expect(dropdownButtonLabel()).toBe(selectedProjectMock.name); - }); - - it('returns empty toggle text', async () => { - bootstrap({ - projects: null, - }); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ isLoading: false }); - - await nextTick(); - expect(dropdownButtonLabel()).toBe(LABELS.EMPTY); - }); - }); - - describe('selectItem', () => { - it('reflects new value when dropdown item is clicked', async () => { - bootstrap({ projects: gapiProjectsResponseMock.projects }); - - expect(dropdownHiddenInputValue()).toBe(''); - - wrapper.find('.dropdown-content button').trigger('click'); - - await nextTick(); - expect(setProject).toHaveBeenCalledWith( - expect.anything(), - gapiProjectsResponseMock.projects[0], - ); - }); - }); -}); diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_submit_button_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_submit_button_spec.js deleted file mode 100644 index 2bf9158628c..00000000000 --- a/spec/frontend/create_cluster/gke_cluster/components/gke_submit_button_spec.js +++ /dev/null @@ -1,51 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; -import GkeSubmitButton from '~/create_cluster/gke_cluster/components/gke_submit_button.vue'; - -Vue.use(Vuex); - -describe('GkeSubmitButton', () => { - let wrapper; - let store; - let hasValidData; - - const buildStore = () => - new Vuex.Store({ - getters: { - hasValidData, - }, - }); - - const buildWrapper = () => - shallowMount(GkeSubmitButton, { - store, - }); - - const bootstrap = () => { - store = buildStore(); - wrapper = buildWrapper(); - }; - - beforeEach(() => { - hasValidData = jest.fn(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('is disabled when hasValidData is false', () => { - hasValidData.mockReturnValueOnce(false); - bootstrap(); - - expect(wrapper.attributes('disabled')).toBe('disabled'); - }); - - it('is not disabled when hasValidData is true', () => { - hasValidData.mockReturnValueOnce(true); - bootstrap(); - - expect(wrapper.attributes('disabled')).toBeFalsy(); - }); -}); diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_subnetwork_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_subnetwork_dropdown_spec.js deleted file mode 100644 index 9df680d94b5..00000000000 --- a/spec/frontend/create_cluster/gke_cluster/components/gke_subnetwork_dropdown_spec.js +++ /dev/null @@ -1,111 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; -import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue'; -import GkeSubnetworkDropdown from '~/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue'; -import createClusterDropdownState from '~/create_cluster/store/cluster_dropdown/state'; - -Vue.use(Vuex); - -describe('GkeSubnetworkDropdown', () => { - let wrapper; - let store; - const defaultProps = { fieldName: 'field-name' }; - const selectedSubnetwork = '123456'; - const setSubnetwork = jest.fn(); - - const buildStore = ({ clusterDropdownState } = {}) => - new Vuex.Store({ - state: { - selectedSubnetwork, - }, - actions: { - setSubnetwork, - }, - getters: { - hasNetwork: () => false, - }, - modules: { - subnetworks: { - namespaced: true, - state: { - ...createClusterDropdownState(), - ...(clusterDropdownState || {}), - }, - }, - }, - }); - - const buildWrapper = (propsData = defaultProps) => - shallowMount(GkeSubnetworkDropdown, { - propsData, - store, - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('sets correct field-name', () => { - const fieldName = 'field-name'; - - store = buildStore(); - wrapper = buildWrapper({ fieldName }); - - expect(wrapper.find(ClusterFormDropdown).props('fieldName')).toBe(fieldName); - }); - - it('sets selected subnetwork as the dropdown value', () => { - store = buildStore(); - wrapper = buildWrapper(); - - expect(wrapper.find(ClusterFormDropdown).props('value')).toBe(selectedSubnetwork); - }); - - it('maps subnetworks store items to the dropdown items property', () => { - const items = [{ name: 'subnetwork' }]; - - store = buildStore({ clusterDropdownState: { items } }); - wrapper = buildWrapper(); - - expect(wrapper.find(ClusterFormDropdown).props('items')).toBe(items); - }); - - describe('when subnetwork dropdown store is loading items', () => { - it('sets subnetwork dropdown as loading', () => { - store = buildStore({ clusterDropdownState: { isLoadingItems: true } }); - wrapper = buildWrapper(); - - expect(wrapper.find(ClusterFormDropdown).props('loading')).toBe(true); - }); - }); - - describe('when there is no selected network', () => { - it('disables the subnetwork dropdown', () => { - store = buildStore(); - wrapper = buildWrapper(); - - expect(wrapper.find(ClusterFormDropdown).props('disabled')).toBe(true); - }); - }); - - describe('when an error occurs while loading subnetworks', () => { - it('sets the subnetwork dropdown as having errors', () => { - store = buildStore({ clusterDropdownState: { loadingItemsError: new Error() } }); - wrapper = buildWrapper(); - - expect(wrapper.find(ClusterFormDropdown).props('hasErrors')).toBe(true); - }); - }); - - describe('when dropdown emits input event', () => { - it('dispatches the setSubnetwork action', () => { - store = buildStore(); - wrapper = buildWrapper(); - - wrapper.find(ClusterFormDropdown).vm.$emit('input', selectedSubnetwork); - - expect(setSubnetwork).toHaveBeenCalledWith(expect.anything(), selectedSubnetwork); - }); - }); -}); diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_zone_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_zone_dropdown_spec.js deleted file mode 100644 index 7b4c228b879..00000000000 --- a/spec/frontend/create_cluster/gke_cluster/components/gke_zone_dropdown_spec.js +++ /dev/null @@ -1,103 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import GkeZoneDropdown from '~/create_cluster/gke_cluster/components/gke_zone_dropdown.vue'; -import { createStore } from '~/create_cluster/gke_cluster/store'; -import { - SET_PROJECT, - SET_ZONES, - SET_PROJECT_BILLING_STATUS, -} from '~/create_cluster/gke_cluster/store/mutation_types'; -import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; -import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; -import { selectedZoneMock, selectedProjectMock, gapiZonesResponseMock } from '../mock_data'; - -const propsData = { - fieldId: 'cluster_provider_gcp_attributes_gcp_zone', - fieldName: 'cluster[provider_gcp_attributes][gcp_zone]', -}; - -const LABELS = { - LOADING: 'Fetching zones', - DISABLED: 'Select project to choose zone', - DEFAULT: 'Select zone', -}; - -describe('GkeZoneDropdown', () => { - let store; - let wrapper; - - beforeEach(() => { - store = createStore(); - wrapper = shallowMount(GkeZoneDropdown, { propsData, store }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('toggleText', () => { - let dropdownButton; - - beforeEach(() => { - dropdownButton = wrapper.find(DropdownButton); - }); - - it('returns disabled state toggle text', () => { - expect(dropdownButton.props('toggleText')).toBe(LABELS.DISABLED); - }); - - describe('isLoading', () => { - beforeEach(async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ isLoading: true }); - await nextTick(); - }); - - it('returns loading toggle text', () => { - expect(dropdownButton.props('toggleText')).toBe(LABELS.LOADING); - }); - }); - - describe('project is set', () => { - beforeEach(async () => { - wrapper.vm.$store.commit(SET_PROJECT, selectedProjectMock); - wrapper.vm.$store.commit(SET_PROJECT_BILLING_STATUS, true); - await nextTick(); - }); - - it('returns default toggle text', () => { - expect(dropdownButton.props('toggleText')).toBe(LABELS.DEFAULT); - }); - }); - - describe('project is selected', () => { - beforeEach(async () => { - wrapper.vm.setItem(selectedZoneMock); - await nextTick(); - }); - - it('returns project name if project selected', () => { - expect(dropdownButton.props('toggleText')).toBe(selectedZoneMock); - }); - }); - }); - - describe('selectItem', () => { - beforeEach(async () => { - wrapper.vm.$store.commit(SET_ZONES, gapiZonesResponseMock.items); - await nextTick(); - }); - - it('reflects new value when dropdown item is clicked', async () => { - const dropdown = wrapper.find(DropdownHiddenInput); - - expect(dropdown.attributes('value')).toBe(''); - - wrapper.find('.dropdown-content button').trigger('click'); - - await nextTick(); - expect(dropdown.attributes('value')).toBe(selectedZoneMock); - }); - }); -}); diff --git a/spec/frontend/create_cluster/gke_cluster/gapi_loader_spec.js b/spec/frontend/create_cluster/gke_cluster/gapi_loader_spec.js deleted file mode 100644 index 9e4d6996340..00000000000 --- a/spec/frontend/create_cluster/gke_cluster/gapi_loader_spec.js +++ /dev/null @@ -1,47 +0,0 @@ -import gapiLoader from '~/create_cluster/gke_cluster/gapi_loader'; - -describe('gapiLoader', () => { - // A mock for document.head.appendChild to intercept the script tag injection. - let mockDOMHeadAppendChild; - - beforeEach(() => { - mockDOMHeadAppendChild = jest.spyOn(document.head, 'appendChild'); - }); - - afterEach(() => { - mockDOMHeadAppendChild.mockRestore(); - delete window.gapi; - delete window.gapiPromise; - delete window.onGapiLoad; - }); - - it('returns a promise', () => { - expect(gapiLoader()).toBeInstanceOf(Promise); - }); - - it('returns the same promise when already loading', () => { - const first = gapiLoader(); - const second = gapiLoader(); - expect(first).toBe(second); - }); - - it('resolves the promise when the script loads correctly', async () => { - mockDOMHeadAppendChild.mockImplementationOnce((script) => { - script.removeAttribute('src'); - script.appendChild( - document.createTextNode(`window.gapi = 'hello gapi'; window.onGapiLoad()`), - ); - document.head.appendChild(script); - }); - await expect(gapiLoader()).resolves.toBe('hello gapi'); - expect(mockDOMHeadAppendChild).toHaveBeenCalled(); - }); - - it('rejects the promise when the script fails loading', async () => { - mockDOMHeadAppendChild.mockImplementationOnce((script) => { - script.onerror(new Error('hello error')); - }); - await expect(gapiLoader()).rejects.toThrow('hello error'); - expect(mockDOMHeadAppendChild).toHaveBeenCalled(); - }); -}); diff --git a/spec/frontend/create_cluster/gke_cluster/helpers.js b/spec/frontend/create_cluster/gke_cluster/helpers.js deleted file mode 100644 index 026e99fa8f4..00000000000 --- a/spec/frontend/create_cluster/gke_cluster/helpers.js +++ /dev/null @@ -1,64 +0,0 @@ -import { - gapiProjectsResponseMock, - gapiZonesResponseMock, - gapiMachineTypesResponseMock, -} from './mock_data'; - -const cloudbilling = { - projects: { - getBillingInfo: jest.fn( - () => - new Promise((resolve) => { - resolve({ - result: { billingEnabled: true }, - }); - }), - ), - }, -}; - -const cloudresourcemanager = { - projects: { - list: jest.fn( - () => - new Promise((resolve) => { - resolve({ - result: { ...gapiProjectsResponseMock }, - }); - }), - ), - }, -}; - -const compute = { - zones: { - list: jest.fn( - () => - new Promise((resolve) => { - resolve({ - result: { ...gapiZonesResponseMock }, - }); - }), - ), - }, - machineTypes: { - list: jest.fn( - () => - new Promise((resolve) => { - resolve({ - result: { ...gapiMachineTypesResponseMock }, - }); - }), - ), - }, -}; - -const gapi = { - client: { - cloudbilling, - cloudresourcemanager, - compute, - }, -}; - -export { gapi as default }; diff --git a/spec/frontend/create_cluster/gke_cluster/mock_data.js b/spec/frontend/create_cluster/gke_cluster/mock_data.js deleted file mode 100644 index d9f5dbc636f..00000000000 --- a/spec/frontend/create_cluster/gke_cluster/mock_data.js +++ /dev/null @@ -1,75 +0,0 @@ -export const emptyProjectMock = { - projectId: '', - name: '', -}; - -export const selectedProjectMock = { - projectId: 'gcp-project-123', - name: 'gcp-project', -}; - -export const selectedZoneMock = 'us-central1-a'; - -export const selectedMachineTypeMock = 'n1-standard-2'; - -export const gapiProjectsResponseMock = { - projects: [ - { - projectNumber: '1234', - projectId: 'gcp-project-123', - lifecycleState: 'ACTIVE', - name: 'gcp-project', - createTime: '2017-12-16T01:48:29.129Z', - parent: { - type: 'organization', - id: '12345', - }, - }, - ], -}; - -export const gapiZonesResponseMock = { - kind: 'compute#zoneList', - id: 'projects/gitlab-internal-153318/zones', - items: [ - { - kind: 'compute#zone', - id: '2000', - creationTimestamp: '1969-12-31T16:00:00.000-08:00', - name: 'us-central1-a', - description: 'us-central1-a', - status: 'UP', - region: - 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/regions/us-central1', - selfLink: - 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/zones/us-central1-a', - availableCpuPlatforms: ['Intel Skylake', 'Intel Broadwell', 'Intel Sandy Bridge'], - }, - ], - selfLink: 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/zones', -}; - -export const gapiMachineTypesResponseMock = { - kind: 'compute#machineTypeList', - id: 'projects/gitlab-internal-153318/zones/us-central1-a/machineTypes', - items: [ - { - kind: 'compute#machineType', - id: '3002', - creationTimestamp: '1969-12-31T16:00:00.000-08:00', - name: 'n1-standard-2', - description: '2 vCPUs, 7.5 GB RAM', - guestCpus: 2, - memoryMb: 7680, - imageSpaceGb: 10, - maximumPersistentDisks: 64, - maximumPersistentDisksSizeGb: '65536', - zone: 'us-central1-a', - selfLink: - 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/zones/us-central1-a/machineTypes/n1-standard-2', - isSharedCpu: false, - }, - ], - selfLink: - 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/zones/us-central1-a/machineTypes', -}; diff --git a/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js b/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js deleted file mode 100644 index c365cb6a9f4..00000000000 --- a/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js +++ /dev/null @@ -1,141 +0,0 @@ -import testAction from 'helpers/vuex_action_helper'; -import * as actions from '~/create_cluster/gke_cluster/store/actions'; -import * as types from '~/create_cluster/gke_cluster/store/mutation_types'; -import createState from '~/create_cluster/gke_cluster/store/state'; -import gapi from '../helpers'; -import { - selectedProjectMock, - selectedZoneMock, - selectedMachineTypeMock, - gapiProjectsResponseMock, - gapiZonesResponseMock, - gapiMachineTypesResponseMock, -} from '../mock_data'; - -describe('GCP Cluster Dropdown Store Actions', () => { - describe('setProject', () => { - it('should set project', () => { - return testAction( - actions.setProject, - selectedProjectMock, - { selectedProject: {} }, - [{ type: 'SET_PROJECT', payload: selectedProjectMock }], - [], - ); - }); - }); - - describe('setZone', () => { - it('should set zone', () => { - return testAction( - actions.setZone, - selectedZoneMock, - { selectedZone: '' }, - [{ type: 'SET_ZONE', payload: selectedZoneMock }], - [], - ); - }); - }); - - describe('setMachineType', () => { - it('should set machine type', () => { - return testAction( - actions.setMachineType, - selectedMachineTypeMock, - { selectedMachineType: '' }, - [{ type: 'SET_MACHINE_TYPE', payload: selectedMachineTypeMock }], - [], - ); - }); - }); - - describe('setIsValidatingProjectBilling', () => { - it('should set machine type', () => { - return testAction( - actions.setIsValidatingProjectBilling, - true, - { isValidatingProjectBilling: null }, - [{ type: 'SET_IS_VALIDATING_PROJECT_BILLING', payload: true }], - [], - ); - }); - }); - - describe('async fetch methods', () => { - let originalGapi; - - beforeAll(() => { - originalGapi = window.gapi; - window.gapi = gapi; - window.gapiPromise = Promise.resolve(gapi); - }); - - afterAll(() => { - window.gapi = originalGapi; - delete window.gapiPromise; - }); - - describe('fetchProjects', () => { - it('fetches projects from Google API', () => { - const state = createState(); - - return testAction( - actions.fetchProjects, - null, - state, - [{ type: types.SET_PROJECTS, payload: gapiProjectsResponseMock.projects }], - [], - ); - }); - }); - - describe('validateProjectBilling', () => { - it('checks project billing status from Google API', () => { - return testAction( - actions.validateProjectBilling, - true, - { - selectedProject: selectedProjectMock, - selectedZone: '', - selectedMachineType: '', - projectHasBillingEnabled: null, - }, - [ - { type: 'SET_ZONE', payload: '' }, - { type: 'SET_MACHINE_TYPE', payload: '' }, - { type: 'SET_PROJECT_BILLING_STATUS', payload: true }, - ], - [{ type: 'setIsValidatingProjectBilling', payload: false }], - ); - }); - }); - - describe('fetchZones', () => { - it('fetches zones from Google API', () => { - const state = createState(); - - return testAction( - actions.fetchZones, - null, - state, - [{ type: types.SET_ZONES, payload: gapiZonesResponseMock.items }], - [], - ); - }); - }); - - describe('fetchMachineTypes', () => { - it('fetches machine types from Google API', () => { - const state = createState(); - - return testAction( - actions.fetchMachineTypes, - null, - state, - [{ type: types.SET_MACHINE_TYPES, payload: gapiMachineTypesResponseMock.items }], - [], - ); - }); - }); - }); -}); diff --git a/spec/frontend/create_cluster/gke_cluster/stores/getters_spec.js b/spec/frontend/create_cluster/gke_cluster/stores/getters_spec.js deleted file mode 100644 index 39106c3f6ca..00000000000 --- a/spec/frontend/create_cluster/gke_cluster/stores/getters_spec.js +++ /dev/null @@ -1,103 +0,0 @@ -import { - hasProject, - hasZone, - hasMachineType, - hasValidData, -} from '~/create_cluster/gke_cluster/store/getters'; -import { selectedProjectMock, selectedZoneMock, selectedMachineTypeMock } from '../mock_data'; - -describe('GCP Cluster Dropdown Store Getters', () => { - let state; - - describe('valid states', () => { - beforeEach(() => { - state = { - projectHasBillingEnabled: true, - selectedProject: selectedProjectMock, - selectedZone: selectedZoneMock, - selectedMachineType: selectedMachineTypeMock, - }; - }); - - describe('hasProject', () => { - it('should return true when project is selected', () => { - expect(hasProject(state)).toEqual(true); - }); - }); - - describe('hasZone', () => { - it('should return true when zone is selected', () => { - expect(hasZone(state)).toEqual(true); - }); - }); - - describe('hasMachineType', () => { - it('should return true when machine type is selected', () => { - expect(hasMachineType(state)).toEqual(true); - }); - }); - - describe('hasValidData', () => { - it('should return true when a project, zone and machine type are selected', () => { - expect(hasValidData(state, { hasZone: true, hasMachineType: true })).toEqual(true); - }); - }); - }); - - describe('invalid states', () => { - beforeEach(() => { - state = { - selectedProject: { - projectId: '', - name: '', - }, - selectedZone: '', - selectedMachineType: '', - }; - }); - - describe('hasProject', () => { - it('should return false when project is not selected', () => { - expect(hasProject(state)).toEqual(false); - }); - }); - - describe('hasZone', () => { - it('should return false when zone is not selected', () => { - expect(hasZone(state)).toEqual(false); - }); - }); - - describe('hasMachineType', () => { - it('should return false when machine type is not selected', () => { - expect(hasMachineType(state)).toEqual(false); - }); - }); - - describe('hasValidData', () => { - let getters; - - beforeEach(() => { - getters = { hasZone: true, hasMachineType: true }; - }); - - it('should return false when project is not billable', () => { - state.projectHasBillingEnabled = false; - - expect(hasValidData(state, getters)).toEqual(false); - }); - - it('should return false when zone is not selected', () => { - getters.hasZone = false; - - expect(hasValidData(state, getters)).toEqual(false); - }); - - it('should return false when machine type is not selected', () => { - getters.hasMachineType = false; - - expect(hasValidData(state, getters)).toEqual(false); - }); - }); - }); -}); diff --git a/spec/frontend/create_cluster/gke_cluster/stores/mutations_spec.js b/spec/frontend/create_cluster/gke_cluster/stores/mutations_spec.js deleted file mode 100644 index 4493d49af43..00000000000 --- a/spec/frontend/create_cluster/gke_cluster/stores/mutations_spec.js +++ /dev/null @@ -1,32 +0,0 @@ -import * as types from '~/create_cluster/gke_cluster/store/mutation_types'; -import mutations from '~/create_cluster/gke_cluster/store/mutations'; -import createState from '~/create_cluster/gke_cluster/store/state'; -import { - gapiProjectsResponseMock, - gapiZonesResponseMock, - gapiMachineTypesResponseMock, -} from '../mock_data'; - -describe('GCP Cluster Dropdown Store Mutations', () => { - describe.each` - mutation | stateProperty | mockData - ${types.SET_PROJECTS} | ${'projects'} | ${gapiProjectsResponseMock.projects} - ${types.SET_ZONES} | ${'zones'} | ${gapiZonesResponseMock.items} - ${types.SET_MACHINE_TYPES} | ${'machineTypes'} | ${gapiMachineTypesResponseMock.items} - ${types.SET_MACHINE_TYPE} | ${'selectedMachineType'} | ${gapiMachineTypesResponseMock.items[0].name} - ${types.SET_ZONE} | ${'selectedZone'} | ${gapiZonesResponseMock.items[0].name} - ${types.SET_PROJECT} | ${'selectedProject'} | ${gapiProjectsResponseMock.projects[0]} - ${types.SET_PROJECT_BILLING_STATUS} | ${'projectHasBillingEnabled'} | ${true} - ${types.SET_IS_VALIDATING_PROJECT_BILLING} | ${'isValidatingProjectBilling'} | ${true} - `('$mutation', ({ mutation, stateProperty, mockData }) => { - it(`should set the mutation payload to the ${stateProperty} state property`, () => { - const state = createState(); - - expect(state[stateProperty]).not.toBe(mockData); - - mutations[mutation](state, mockData); - - expect(state[stateProperty]).toBe(mockData); - }); - }); -}); diff --git a/spec/frontend/create_cluster/init_create_cluster_spec.js b/spec/frontend/create_cluster/init_create_cluster_spec.js deleted file mode 100644 index 42d1ceed864..00000000000 --- a/spec/frontend/create_cluster/init_create_cluster_spec.js +++ /dev/null @@ -1,77 +0,0 @@ -import initGkeDropdowns from '~/create_cluster/gke_cluster'; -import initGkeNamespace from '~/create_cluster/gke_cluster_namespace'; -import initCreateCluster from '~/create_cluster/init_create_cluster'; -import PersistentUserCallout from '~/persistent_user_callout'; - -// This import is loaded dynamically in `init_create_cluster`. -// Let's eager import it here so that the first spec doesn't timeout. -// https://gitlab.com/gitlab-org/gitlab/issues/118499 -import '~/create_cluster/eks_cluster'; - -jest.mock('~/create_cluster/gke_cluster', () => jest.fn()); -jest.mock('~/create_cluster/gke_cluster_namespace', () => jest.fn()); -jest.mock('~/persistent_user_callout', () => ({ - factory: jest.fn(), -})); - -describe('initCreateCluster', () => { - let document; - let gon; - - beforeEach(() => { - document = { - body: { dataset: {} }, - querySelector: jest.fn(), - }; - gon = { features: {} }; - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe.each` - pageSuffix | page - ${':clusters:new'} | ${'project:clusters:new'} - ${':clusters:create_gcp'} | ${'groups:clusters:create_gcp'} - ${':clusters:create_user'} | ${'admin:clusters:create_user'} - `('when cluster page ends in $pageSuffix', ({ page }) => { - beforeEach(() => { - document.body.dataset = { page }; - - initCreateCluster(document, gon); - }); - - it('initializes create GKE cluster app', () => { - expect(initGkeDropdowns).toHaveBeenCalled(); - }); - - it('initializes gcp signup offer banner', () => { - expect(PersistentUserCallout.factory).toHaveBeenCalled(); - }); - }); - - describe('when creating a project level cluster', () => { - it('initializes gke namespace app', () => { - document.body.dataset.page = 'project:clusters:new'; - - initCreateCluster(document, gon); - - expect(initGkeNamespace).toHaveBeenCalled(); - }); - }); - - describe.each` - clusterLevel | page - ${'group level'} | ${'groups:clusters:new'} - ${'instance level'} | ${'admin:clusters:create_gcp'} - `('when creating a $clusterLevel cluster', ({ page }) => { - it('does not initialize gke namespace app', () => { - document.body.dataset = { page }; - - initCreateCluster(document, gon); - - expect(initGkeNamespace).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/create_cluster/store/cluster_dropdown/actions_spec.js b/spec/frontend/create_cluster/store/cluster_dropdown/actions_spec.js deleted file mode 100644 index c0e8b11cf1e..00000000000 --- a/spec/frontend/create_cluster/store/cluster_dropdown/actions_spec.js +++ /dev/null @@ -1,95 +0,0 @@ -import testAction from 'helpers/vuex_action_helper'; - -import actionsFactory from '~/create_cluster/store/cluster_dropdown/actions'; -import * as types from '~/create_cluster/store/cluster_dropdown/mutation_types'; -import createState from '~/create_cluster/store/cluster_dropdown/state'; - -describe('Cluster dropdown Store Actions', () => { - const items = [{ name: 'item 1' }]; - let fetchFn; - let actions; - - beforeEach(() => { - fetchFn = jest.fn(); - actions = actionsFactory(fetchFn); - }); - - describe('fetchItems', () => { - describe('on success', () => { - beforeEach(() => { - fetchFn.mockResolvedValueOnce(items); - actions = actionsFactory(fetchFn); - }); - - it('dispatches success with received items', () => - testAction( - actions.fetchItems, - null, - createState(), - [], - [ - { type: 'requestItems' }, - { - type: 'receiveItemsSuccess', - payload: { items }, - }, - ], - )); - }); - - describe('on failure', () => { - const error = new Error('Could not fetch items'); - - beforeEach(() => { - fetchFn.mockRejectedValueOnce(error); - }); - - it('dispatches success with received items', () => - testAction( - actions.fetchItems, - null, - createState(), - [], - [ - { type: 'requestItems' }, - { - type: 'receiveItemsError', - payload: { error }, - }, - ], - )); - }); - }); - - describe('requestItems', () => { - it(`commits ${types.REQUEST_ITEMS} mutation`, () => - testAction(actions.requestItems, null, createState(), [{ type: types.REQUEST_ITEMS }])); - }); - - describe('receiveItemsSuccess', () => { - it(`commits ${types.RECEIVE_ITEMS_SUCCESS} mutation`, () => - testAction(actions.receiveItemsSuccess, { items }, createState(), [ - { - type: types.RECEIVE_ITEMS_SUCCESS, - payload: { - items, - }, - }, - ])); - }); - - describe('receiveItemsError', () => { - it(`commits ${types.RECEIVE_ITEMS_ERROR} mutation`, () => { - const error = new Error('Error fetching items'); - - testAction(actions.receiveItemsError, { error }, createState(), [ - { - type: types.RECEIVE_ITEMS_ERROR, - payload: { - error, - }, - }, - ]); - }); - }); -}); diff --git a/spec/frontend/create_cluster/store/cluster_dropdown/mutations_spec.js b/spec/frontend/create_cluster/store/cluster_dropdown/mutations_spec.js deleted file mode 100644 index 197fcfc2600..00000000000 --- a/spec/frontend/create_cluster/store/cluster_dropdown/mutations_spec.js +++ /dev/null @@ -1,36 +0,0 @@ -import { - REQUEST_ITEMS, - RECEIVE_ITEMS_SUCCESS, - RECEIVE_ITEMS_ERROR, -} from '~/create_cluster/store/cluster_dropdown/mutation_types'; -import mutations from '~/create_cluster/store/cluster_dropdown/mutations'; -import createState from '~/create_cluster/store/cluster_dropdown/state'; - -describe('Cluster dropdown store mutations', () => { - let state; - let emptyPayload; - let items; - let error; - - beforeEach(() => { - emptyPayload = {}; - items = [{ name: 'item 1' }]; - error = new Error('could not load error'); - state = createState(); - }); - - it.each` - mutation | mutatedProperty | payload | expectedValue | expectedValueDescription - ${REQUEST_ITEMS} | ${'isLoadingItems'} | ${emptyPayload} | ${true} | ${true} - ${REQUEST_ITEMS} | ${'loadingItemsError'} | ${emptyPayload} | ${null} | ${null} - ${RECEIVE_ITEMS_SUCCESS} | ${'isLoadingItems'} | ${{ items }} | ${false} | ${false} - ${RECEIVE_ITEMS_SUCCESS} | ${'items'} | ${{ items }} | ${items} | ${'items payload'} - ${RECEIVE_ITEMS_ERROR} | ${'isLoadingItems'} | ${{ error }} | ${false} | ${false} - ${RECEIVE_ITEMS_ERROR} | ${'error'} | ${{ error }} | ${error} | ${'received error object'} - `(`$mutation sets $mutatedProperty to $expectedValueDescription`, (data) => { - const { mutation, mutatedProperty, payload, expectedValue } = data; - - mutations[mutation](state, payload); - expect(state[mutatedProperty]).toBe(expectedValue); - }); -}); diff --git a/spec/frontend/create_item_dropdown_spec.js b/spec/frontend/create_item_dropdown_spec.js index 143ccb9b930..aea4bc6017d 100644 --- a/spec/frontend/create_item_dropdown_spec.js +++ b/spec/frontend/create_item_dropdown_spec.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import CreateItemDropdown from '~/create_item_dropdown'; const DROPDOWN_ITEM_DATA = [ @@ -41,12 +42,13 @@ describe('CreateItemDropdown', () => { } beforeEach(() => { - loadFixtures('static/create_item_dropdown.html'); + loadHTMLFixture('static/create_item_dropdown.html'); $wrapperEl = $('.js-create-item-dropdown-fixture-root'); }); afterEach(() => { $wrapperEl.remove(); + resetHTMLFixture(); }); describe('items', () => { diff --git a/spec/frontend/crm/contact_form_wrapper_spec.js b/spec/frontend/crm/contact_form_wrapper_spec.js index 6307889a7aa..5e1743701e4 100644 --- a/spec/frontend/crm/contact_form_wrapper_spec.js +++ b/spec/frontend/crm/contact_form_wrapper_spec.js @@ -1,22 +1,23 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; import ContactFormWrapper from '~/crm/contacts/components/contact_form_wrapper.vue'; import ContactForm from '~/crm/components/form.vue'; import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql'; import createContactMutation from '~/crm/contacts/components/graphql/create_contact.mutation.graphql'; import updateContactMutation from '~/crm/contacts/components/graphql/update_contact.mutation.graphql'; +import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql'; +import { getGroupContactsQueryResponse, getGroupOrganizationsQueryResponse } from './mock_data'; describe('Customer relations contact form wrapper', () => { + Vue.use(VueApollo); let wrapper; + let fakeApollo; const findContactForm = () => wrapper.findComponent(ContactForm); - const $apollo = { - queries: { - contacts: { - loading: false, - }, - }, - }; const $route = { params: { id: 7, @@ -33,56 +34,79 @@ describe('Customer relations contact form wrapper', () => { groupFullPath: 'flightjs', groupId: 26, }, - mocks: { - $apollo, - $route, - }, + apolloProvider: fakeApollo, + mocks: { $route }, }); }; + beforeEach(() => { + fakeApollo = createMockApollo([ + [getGroupContactsQuery, jest.fn().mockResolvedValue(getGroupContactsQueryResponse)], + [getGroupOrganizationsQuery, jest.fn().mockResolvedValue(getGroupOrganizationsQueryResponse)], + ]); + }); + afterEach(() => { wrapper.destroy(); + fakeApollo = null; }); - describe('in edit mode', () => { - it('should render contact form with correct props', () => { - mountComponent({ isEditMode: true }); + describe.each` + mode | title | successMessage | mutation | existingId + ${'edit'} | ${'Edit contact'} | ${'Contact has been updated.'} | ${updateContactMutation} | ${contacts[0].id} + ${'create'} | ${'New contact'} | ${'Contact has been added.'} | ${createContactMutation} | ${null} + `('in $mode mode', ({ mode, title, successMessage, mutation, existingId }) => { + beforeEach(() => { + const isEditMode = mode === 'edit'; + mountComponent({ isEditMode }); - const contactForm = findContactForm(); - expect(contactForm.props('fields')).toHaveLength(5); - expect(contactForm.props('title')).toBe('Edit contact'); - expect(contactForm.props('successMessage')).toBe('Contact has been updated.'); - expect(contactForm.props('mutation')).toBe(updateContactMutation); - expect(contactForm.props('getQuery')).toMatchObject({ - query: getGroupContactsQuery, - variables: { groupFullPath: 'flightjs' }, - }); - expect(contactForm.props('getQueryNodePath')).toBe('group.contacts'); - expect(contactForm.props('existingId')).toBe(contacts[0].id); - expect(contactForm.props('additionalCreateParams')).toMatchObject({ - groupId: 'gid://gitlab/Group/26', - }); + return waitForPromises(); + }); + + it('renders correct getQuery prop', () => { + expect(findContactForm().props('getQueryNodePath')).toBe('group.contacts'); }); - }); - describe('in create mode', () => { - it('should render contact form with correct props', () => { - mountComponent(); + it('renders correct mutation prop', () => { + expect(findContactForm().props('mutation')).toBe(mutation); + }); - const contactForm = findContactForm(); - expect(contactForm.props('fields')).toHaveLength(5); - expect(contactForm.props('title')).toBe('New contact'); - expect(contactForm.props('successMessage')).toBe('Contact has been added.'); - expect(contactForm.props('mutation')).toBe(createContactMutation); - expect(contactForm.props('getQuery')).toMatchObject({ - query: getGroupContactsQuery, - variables: { groupFullPath: 'flightjs' }, - }); - expect(contactForm.props('getQueryNodePath')).toBe('group.contacts'); - expect(contactForm.props('existingId')).toBeNull(); - expect(contactForm.props('additionalCreateParams')).toMatchObject({ + it('renders correct additionalCreateParams prop', () => { + expect(findContactForm().props('additionalCreateParams')).toMatchObject({ groupId: 'gid://gitlab/Group/26', }); }); + + it('renders correct existingId prop', () => { + expect(findContactForm().props('existingId')).toBe(existingId); + }); + + it('renders correct fields prop', () => { + expect(findContactForm().props('fields')).toEqual([ + { name: 'firstName', label: 'First name', required: true }, + { name: 'lastName', label: 'Last name', required: true }, + { name: 'email', label: 'Email', required: true }, + { name: 'phone', label: 'Phone' }, + { + name: 'organizationId', + label: 'Organization', + values: [ + { text: 'No organization', value: null }, + { text: 'ABC Company', value: 'gid://gitlab/CustomerRelations::Organization/2' }, + { text: 'GitLab', value: 'gid://gitlab/CustomerRelations::Organization/3' }, + { text: 'Test Inc', value: 'gid://gitlab/CustomerRelations::Organization/1' }, + ], + }, + { name: 'description', label: 'Description' }, + ]); + }); + + it('renders correct title prop', () => { + expect(findContactForm().props('title')).toBe(title); + }); + + it('renders correct successMessage prop', () => { + expect(findContactForm().props('successMessage')).toBe(successMessage); + }); }); }); diff --git a/spec/frontend/crm/contacts_root_spec.js b/spec/frontend/crm/contacts_root_spec.js index b02d94e9cb1..3a6989a00f1 100644 --- a/spec/frontend/crm/contacts_root_spec.js +++ b/spec/frontend/crm/contacts_root_spec.js @@ -105,7 +105,7 @@ describe('Customer relations contacts root app', () => { const issueLink = findIssuesLinks().at(0); expect(issueLink.exists()).toBe(true); - expect(issueLink.attributes('href')).toBe('/issues?scope=all&state=opened&crm_contact_id=16'); + expect(issueLink.attributes('href')).toBe('/issues?crm_contact_id=16'); }); }); }); diff --git a/spec/frontend/crm/form_spec.js b/spec/frontend/crm/form_spec.js index 5c349b24ea1..d39f0795f5f 100644 --- a/spec/frontend/crm/form_spec.js +++ b/spec/frontend/crm/form_spec.js @@ -1,4 +1,4 @@ -import { GlAlert } from '@gitlab/ui'; +import { GlAlert, GlFormInput, GlFormSelect, GlFormGroup } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import VueRouter from 'vue-router'; @@ -100,6 +100,14 @@ describe('Reusable form component', () => { { name: 'email', label: 'Email', required: true }, { name: 'phone', label: 'Phone' }, { name: 'description', label: 'Description' }, + { + name: 'organizationId', + label: 'Organization', + values: [ + { key: 'gid://gitlab/CustomerRelations::Organization/1', value: 'GitLab' }, + { key: 'gid://gitlab/CustomerRelations::Organization/2', value: 'ABC Corp' }, + ], + }, ], getQuery: { query: getGroupContactsQuery, @@ -270,4 +278,51 @@ describe('Reusable form component', () => { }); }, ); + + describe('edit form', () => { + beforeEach(() => { + mountContactUpdate(); + }); + + it.each` + index | id | componentName | value + ${0} | ${'firstName'} | ${'GlFormInput'} | ${'Marty'} + ${1} | ${'lastName'} | ${'GlFormInput'} | ${'McFly'} + ${2} | ${'email'} | ${'GlFormInput'} | ${'example@gitlab.com'} + ${4} | ${'description'} | ${'GlFormInput'} | ${undefined} + ${3} | ${'phone'} | ${'GlFormInput'} | ${undefined} + ${5} | ${'organizationId'} | ${'GlFormSelect'} | ${'gid://gitlab/CustomerRelations::Organization/2'} + `( + 'should render a $componentName for #$id with the value "$value"', + ({ index, id, componentName, value }) => { + const component = componentName === 'GlFormInput' ? GlFormInput : GlFormSelect; + const findFormGroup = (at) => wrapper.findAllComponents(GlFormGroup).at(at); + const findFormElement = () => findFormGroup(index).find(component); + + expect(findFormElement().attributes('id')).toBe(id); + expect(findFormElement().attributes('value')).toBe(value); + }, + ); + + it('should include updated values in update mutation', () => { + wrapper.find('#firstName').vm.$emit('input', 'Michael'); + wrapper + .find('#organizationId') + .vm.$emit('input', 'gid://gitlab/CustomerRelations::Organization/1'); + + findForm().trigger('submit'); + + expect(handler).toHaveBeenCalledWith('updateContact', { + input: { + description: null, + email: 'example@gitlab.com', + firstName: 'Michael', + id: 'gid://gitlab/CustomerRelations::Contact/12', + lastName: 'McFly', + organizationId: 'gid://gitlab/CustomerRelations::Organization/1', + phone: null, + }, + }); + }); + }); }); diff --git a/spec/frontend/crm/organizations_root_spec.js b/spec/frontend/crm/organizations_root_spec.js index 231208d938e..1780a5945a6 100644 --- a/spec/frontend/crm/organizations_root_spec.js +++ b/spec/frontend/crm/organizations_root_spec.js @@ -102,9 +102,7 @@ describe('Customer relations organizations root app', () => { const issueLink = findIssuesLinks().at(0); expect(issueLink.exists()).toBe(true); - expect(issueLink.attributes('href')).toBe( - '/issues?scope=all&state=opened&crm_organization_id=2', - ); + expect(issueLink.attributes('href')).toBe('/issues?crm_organization_id=2'); }); }); }); diff --git a/spec/frontend/cycle_analytics/base_spec.js b/spec/frontend/cycle_analytics/base_spec.js index bdf35f904ed..7b1ef71da63 100644 --- a/spec/frontend/cycle_analytics/base_spec.js +++ b/spec/frontend/cycle_analytics/base_spec.js @@ -143,12 +143,9 @@ describe('Value stream analytics component', () => { expect(findFilters().props()).toEqual({ groupId, groupPath, - canToggleAggregation: false, endDate: createdBefore, hasDateRangeFilter: true, hasProjectFilter: false, - isAggregationEnabled: false, - isUpdatingAggregationData: false, selectedProjects: [], startDate: createdAfter, }); diff --git a/spec/frontend/cycle_analytics/mock_data.js b/spec/frontend/cycle_analytics/mock_data.js index c482bd4e910..1fe1dbbb75c 100644 --- a/spec/frontend/cycle_analytics/mock_data.js +++ b/spec/frontend/cycle_analytics/mock_data.js @@ -40,7 +40,7 @@ export const summary = [ { value: '20', title: 'New Issues' }, { value: null, title: 'Commits' }, { value: null, title: 'Deploys' }, - { value: null, title: 'Deployment Frequency', unit: 'per day' }, + { value: null, title: 'Deployment Frequency', unit: '/day' }, ]; export const issueStage = { @@ -130,7 +130,7 @@ export const convertedData = { { value: '20', title: 'New Issues' }, { value: '-', title: 'Commits' }, { value: '-', title: 'Deploys' }, - { value: '-', title: 'Deployment Frequency', unit: 'per day' }, + { value: '-', title: 'Deployment Frequency', unit: '/day' }, ], }; diff --git a/spec/frontend/cycle_analytics/stage_table_spec.js b/spec/frontend/cycle_analytics/stage_table_spec.js index 107fe5fc865..0d15d67866d 100644 --- a/spec/frontend/cycle_analytics/stage_table_spec.js +++ b/spec/frontend/cycle_analytics/stage_table_spec.js @@ -329,7 +329,7 @@ describe('StageTable', () => { ]); }); - it('with sortDesc=false will toggle the direction field', async () => { + it('with sortDesc=false will toggle the direction field', () => { expect(wrapper.emitted('handleUpdatePagination')).toBeUndefined(); triggerTableSort(false); diff --git a/spec/frontend/cycle_analytics/value_stream_filters_spec.js b/spec/frontend/cycle_analytics/value_stream_filters_spec.js index 5a0b046393a..6e96a6d756a 100644 --- a/spec/frontend/cycle_analytics/value_stream_filters_spec.js +++ b/spec/frontend/cycle_analytics/value_stream_filters_spec.js @@ -1,5 +1,4 @@ import { shallowMount } from '@vue/test-utils'; -import { GlToggle } from '@gitlab/ui'; import Daterange from '~/analytics/shared/components/daterange.vue'; import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue'; import FilterBar from '~/cycle_analytics/components/filter_bar.vue'; @@ -30,7 +29,6 @@ describe('ValueStreamFilters', () => { const findProjectsDropdown = () => wrapper.findComponent(ProjectsDropdownFilter); const findDateRangePicker = () => wrapper.findComponent(Daterange); const findFilterBar = () => wrapper.findComponent(FilterBar); - const findAggregationToggle = () => wrapper.findComponent(GlToggle); beforeEach(() => { wrapper = createComponent(); @@ -59,10 +57,6 @@ describe('ValueStreamFilters', () => { expect(findDateRangePicker().exists()).toBe(true); }); - it('will not render the aggregation toggle', () => { - expect(findAggregationToggle().exists()).toBe(false); - }); - it('will emit `selectProject` when a project is selected', () => { findProjectsDropdown().vm.$emit('selected'); @@ -94,52 +88,4 @@ describe('ValueStreamFilters', () => { expect(findProjectsDropdown().exists()).toBe(false); }); }); - - describe('canToggleAggregation = true', () => { - beforeEach(() => { - wrapper = createComponent({ isAggregationEnabled: false, canToggleAggregation: true }); - }); - - it('will render the aggregation toggle', () => { - expect(findAggregationToggle().exists()).toBe(true); - }); - - it('will set the aggregation toggle to the `isAggregationEnabled` value', () => { - expect(findAggregationToggle().props('value')).toBe(false); - - wrapper = createComponent({ - isAggregationEnabled: true, - canToggleAggregation: true, - }); - - expect(findAggregationToggle().props('value')).toBe(true); - }); - - it('will emit `toggleAggregation` when the toggle is changed', async () => { - expect(wrapper.emitted('toggleAggregation')).toBeUndefined(); - - await findAggregationToggle().vm.$emit('change', true); - - expect(wrapper.emitted('toggleAggregation')).toHaveLength(1); - expect(wrapper.emitted('toggleAggregation')).toEqual([[true]]); - }); - }); - - describe('isUpdatingAggregationData = true', () => { - beforeEach(() => { - wrapper = createComponent({ canToggleAggregation: true, isUpdatingAggregationData: true }); - }); - - it('will disable the aggregation toggle', () => { - expect(findAggregationToggle().props('disabled')).toBe(true); - }); - - it('will not emit `toggleAggregation` when the toggle is changed', async () => { - expect(wrapper.emitted('toggleAggregation')).toBeUndefined(); - - await findAggregationToggle().vm.$emit('change', true); - - expect(wrapper.emitted('toggleAggregation')).toBeUndefined(); - }); - }); }); diff --git a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js index 6199e61df0c..4a3e8146b13 100644 --- a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js +++ b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js @@ -1,11 +1,11 @@ import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import metricsData from 'test_fixtures/projects/analytics/value_stream_analytics/summary.json'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue'; import { METRIC_TYPE_SUMMARY } from '~/api/analytics_api'; -import { METRICS_POPOVER_CONTENT } from '~/analytics/shared/constants'; +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'; @@ -27,7 +27,7 @@ describe('ValueStreamMetrics', () => { }); const createComponent = (props = {}) => { - return shallowMount(ValueStreamMetrics, { + return shallowMountExtended(ValueStreamMetrics, { propsData: { requestPath, requestParams: {}, @@ -38,6 +38,7 @@ describe('ValueStreamMetrics', () => { }; const findMetrics = () => wrapper.findAllComponents(MetricTile); + const findMetricsGroups = () => wrapper.findAllByTestId('vsa-metrics-group'); const expectToHaveRequest = (fields) => { expect(mockGetValueStreamSummaryMetrics).toHaveBeenCalledWith({ @@ -63,24 +64,6 @@ describe('ValueStreamMetrics', () => { expect(wrapper.findComponent(GlSkeletonLoading).exists()).toBe(true); }); - it('renders hidden MetricTile components for each metric', async () => { - await waitForPromises(); - - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ isLoading: true }); - - await nextTick(); - - const components = findMetrics(); - - expect(components).toHaveLength(metricsData.length); - - metricsData.forEach((metric, index) => { - expect(components.at(index).isVisible()).toBe(false); - }); - }); - describe('with data loaded', () => { beforeEach(async () => { await waitForPromises(); @@ -160,6 +143,27 @@ describe('ValueStreamMetrics', () => { }); }); }); + + describe('groupBy', () => { + beforeEach(async () => { + mockGetValueStreamSummaryMetrics = jest.fn().mockResolvedValue({ data: metricsData }); + wrapper = createComponent({ groupBy: VSA_METRICS_GROUPS }); + await waitForPromises(); + }); + + it('renders the metrics as separate groups', () => { + const groups = findMetricsGroups(); + expect(groups).toHaveLength(VSA_METRICS_GROUPS.length); + }); + + it('renders titles for each group', () => { + const groups = findMetricsGroups(); + groups.wrappers.forEach((g, index) => { + const { title } = VSA_METRICS_GROUPS[index]; + expect(g.html()).toContain(title); + }); + }); + }); }); }); diff --git a/spec/frontend/deprecated_jquery_dropdown_spec.js b/spec/frontend/deprecated_jquery_dropdown_spec.js index bec91fe5fc5..b18d53b317d 100644 --- a/spec/frontend/deprecated_jquery_dropdown_spec.js +++ b/spec/frontend/deprecated_jquery_dropdown_spec.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import mockProjects from 'test_fixtures_static/projects.json'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import '~/lib/utils/common_utils'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -64,7 +65,7 @@ describe('deprecatedJQueryDropdown', () => { } beforeEach(() => { - loadFixtures('static/deprecated_jquery_dropdown.html'); + loadHTMLFixture('static/deprecated_jquery_dropdown.html'); test.dropdownContainerElement = $('.dropdown.inline'); test.$dropdownMenuElement = $('.dropdown-menu', test.dropdownContainerElement); test.projectsData = JSON.parse(JSON.stringify(mockProjects)); @@ -73,6 +74,8 @@ describe('deprecatedJQueryDropdown', () => { afterEach(() => { $('body').off('keydown'); test.dropdownContainerElement.off('keyup'); + + resetHTMLFixture(); }); it('should open on click', () => { 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 0cef18c60de..d2d1fe6b2d8 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 @@ -31,10 +31,6 @@ describe('Design reply form component', () => { }); } - beforeEach(() => { - gon.features = { markdownContinueLists: true }; - }); - afterEach(() => { wrapper.destroy(); }); diff --git a/spec/frontend/design_management/components/design_sidebar_spec.js b/spec/frontend/design_management/components/design_sidebar_spec.js index a818a86bef6..e8426216c1c 100644 --- a/spec/frontend/design_management/components/design_sidebar_spec.js +++ b/spec/frontend/design_management/components/design_sidebar_spec.js @@ -1,7 +1,7 @@ import { GlCollapse, GlPopover } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import Cookies from 'js-cookie'; import { nextTick } from 'vue'; +import Cookies from '~/lib/utils/cookies'; import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue'; import DesignNoteSignedOut from '~/design_management/components/design_notes/design_note_signed_out.vue'; import DesignSidebar from '~/design_management/components/design_sidebar.vue'; diff --git a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap index abd455ae750..243cc9d891d 100644 --- a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap @@ -16,6 +16,7 @@ exports[`Design management index page designs renders error 1`] = ` primarybuttontext="" secondarybuttonlink="" secondarybuttontext="" + showicon="true" title="" variant="danger" > diff --git a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap index 31b3117cb6c..8f12dc8fb06 100644 --- a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap @@ -180,6 +180,7 @@ exports[`Design management design index page with error GlAlert is rendered in c primarybuttontext="" secondarybuttonlink="" secondarybuttontext="" + showicon="true" title="" variant="danger" > diff --git a/spec/frontend/diffs/components/diff_expansion_cell_spec.js b/spec/frontend/diffs/components/diff_expansion_cell_spec.js index cd472920bb9..bd538996349 100644 --- a/spec/frontend/diffs/components/diff_expansion_cell_spec.js +++ b/spec/frontend/diffs/components/diff_expansion_cell_spec.js @@ -20,7 +20,6 @@ function makeLoadMoreLinesPayload({ sinceLine, toLine, oldLineNumber, - diffViewType, fileHash, nextLineNumbers = {}, unfold = false, @@ -28,12 +27,11 @@ function makeLoadMoreLinesPayload({ isExpandDown = false, }) { return { - endpoint: 'contextLinesPath', + endpoint: diffFileMockData.context_lines_path, params: { since: sinceLine, to: toLine, offset: toLine + 1 - oldLineNumber, - view: diffViewType, unfold, bottom, }, @@ -70,10 +68,11 @@ describe('DiffExpansionCell', () => { const createComponent = (options = {}) => { const defaults = { fileHash: mockFile.file_hash, - contextLinesPath: 'contextLinesPath', line: mockLine, isTop: false, isBottom: false, + file: mockFile, + inline: true, }; const propsData = { ...defaults, ...options }; @@ -124,7 +123,7 @@ describe('DiffExpansionCell', () => { describe('any row', () => { [ - { diffViewType: INLINE_DIFF_VIEW_TYPE, lineIndex: 8, file: { parallel_diff_lines: [] } }, + { diffViewType: INLINE_DIFF_VIEW_TYPE, lineIndex: 8, file: cloneDeep(diffFileMockData) }, ].forEach(({ diffViewType, file, lineIndex }) => { describe(`with diffViewType (${diffViewType})`, () => { beforeEach(() => { @@ -140,12 +139,12 @@ describe('DiffExpansionCell', () => { it('on expand all clicked, dispatch loadMoreLines', () => { const oldLineNumber = mockLine.meta_data.old_pos; const newLineNumber = mockLine.meta_data.new_pos; - const previousIndex = getPreviousLineIndex(diffViewType, mockFile, { + const previousIndex = getPreviousLineIndex(mockFile, { oldLineNumber, newLineNumber, }); - const wrapper = createComponent(); + const wrapper = createComponent({ file }); findExpandAll(wrapper).click(); @@ -156,7 +155,6 @@ describe('DiffExpansionCell', () => { toLine: newLineNumber - 1, sinceLine: previousIndex, oldLineNumber, - diffViewType, }), ); }); @@ -168,7 +166,7 @@ describe('DiffExpansionCell', () => { const oldLineNumber = mockLine.meta_data.old_pos; const newLineNumber = mockLine.meta_data.new_pos; - const wrapper = createComponent(); + const wrapper = createComponent({ file }); findExpandUp(wrapper).trigger('click'); @@ -196,17 +194,16 @@ describe('DiffExpansionCell', () => { mockLine.meta_data.old_pos = 200; mockLine.meta_data.new_pos = 200; - const wrapper = createComponent(); + const wrapper = createComponent({ file }); findExpandDown(wrapper).trigger('click'); expect(store.dispatch).toHaveBeenCalledWith('diffs/loadMoreLines', { - endpoint: 'contextLinesPath', + endpoint: diffFileMockData.context_lines_path, params: { since: 1, to: 21, // the load amount, plus 1 line offset: 0, - view: diffViewType, unfold: true, bottom: true, }, diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index 3b567fbc704..cc595e58dda 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -1,5 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; -import Cookies from 'js-cookie'; +import Cookies from '~/lib/utils/cookies'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js index d8611b1ce1b..57e623b843d 100644 --- a/spec/frontend/diffs/store/mutations_spec.js +++ b/spec/frontend/diffs/store/mutations_spec.js @@ -131,7 +131,14 @@ describe('DiffsStoreMutations', () => { const options = { lineNumbers: { oldLineNumber: 1, newLineNumber: 2 }, contextLines: [ - { old_line: 1, new_line: 1, line_code: 'ff9200_1_1', discussions: [], hasForm: false }, + { + old_line: 1, + new_line: 1, + line_code: 'ff9200_1_1', + discussions: [], + hasForm: false, + type: 'expanded', + }, ], fileHash: 'ff9200', params: { diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index 03bcaab0d2b..8ae51a58819 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -51,21 +51,19 @@ describe('DiffsStoreUtils', () => { }); describe('getPreviousLineIndex', () => { - describe(`with diffViewType (inline) in split diffs`, () => { - let diffFile; + let diffFile; - beforeEach(() => { - diffFile = { ...clone(diffFileMockData) }; - }); + beforeEach(() => { + diffFile = { ...clone(diffFileMockData) }; + }); - it('should return the correct previous line number', () => { - expect( - utils.getPreviousLineIndex(INLINE_DIFF_VIEW_TYPE, diffFile, { - oldLineNumber: 3, - newLineNumber: 5, - }), - ).toBe(4); - }); + it('should return the correct previous line number', () => { + expect( + utils.getPreviousLineIndex(diffFile, { + oldLineNumber: 3, + newLineNumber: 5, + }), + ).toBe(4); }); }); diff --git a/spec/frontend/diffs/utils/diff_file_spec.js b/spec/frontend/diffs/utils/diff_file_spec.js index 3223b6c2dab..778897be3ba 100644 --- a/spec/frontend/diffs/utils/diff_file_spec.js +++ b/spec/frontend/diffs/utils/diff_file_spec.js @@ -3,6 +3,7 @@ import { getShortShaFromFile, stats, isNotDiffable, + match, } from '~/diffs/utils/diff_file'; import { diffViewerModes } from '~/ide/constants'; import mockDiffFile from '../mock_data/diff_file'; @@ -149,6 +150,38 @@ describe('diff_file utilities', () => { expect(preppedFile).not.toHaveProp('id'); }); + + it.each` + index + ${null} + ${undefined} + ${-1} + ${false} + ${true} + ${'idx'} + ${'42'} + `('does not set the order property if an invalid index ($index) is provided', ({ index }) => { + const preppedFile = prepareRawDiffFile({ + file: files[0], + allFiles: files, + index, + }); + + /* expect.anything() doesn't match null or undefined */ + expect(preppedFile).toEqual(expect.not.objectContaining({ order: null })); + expect(preppedFile).toEqual(expect.not.objectContaining({ order: undefined })); + expect(preppedFile).toEqual(expect.not.objectContaining({ order: expect.anything() })); + }); + + it('sets the provided valid index to the order property', () => { + const preppedFile = prepareRawDiffFile({ + file: files[0], + allFiles: files, + index: 42, + }); + + expect(preppedFile).toEqual(expect.objectContaining({ order: 42 })); + }); }); describe('getShortShaFromFile', () => { @@ -230,4 +263,42 @@ describe('diff_file utilities', () => { expect(isNotDiffable(file)).toBe(false); }); }); + + describe('match', () => { + const authorityFileId = '68296a4f-f1c7-445a-bd0e-6e3b02c4eec0'; + const fih = 'file_identifier_hash'; + const fihs = 'file identifier hashes'; + let authorityFile; + + beforeAll(() => { + const files = getDiffFiles(); + + authorityFile = prepareRawDiffFile({ + file: files[0], + allFiles: files, + }); + + Object.freeze(authorityFile); + }); + + describe.each` + mode | comparisonFiles | keyName + ${'universal'} | ${[{ [fih]: 'ABC1' }, { id: 'foo' }, { id: authorityFileId }]} | ${'ids'} + ${'mr'} | ${[{ id: authorityFileId }, { [fih]: 'ABC2' }, { [fih]: 'ABC1' }]} | ${fihs} + `('$mode mode', ({ mode, comparisonFiles, keyName }) => { + it(`fails to match if files or ${keyName} aren't present`, () => { + expect(match({ fileA: authorityFile, fileB: undefined, mode })).toBe(false); + expect(match({ fileA: authorityFile, fileB: null, mode })).toBe(false); + expect(match({ fileA: authorityFile, fileB: comparisonFiles[0], mode })).toBe(false); + }); + + it(`fails to match if the ${keyName} aren't the same`, () => { + expect(match({ fileA: authorityFile, fileB: comparisonFiles[1], mode })).toBe(false); + }); + + it(`matches if the ${keyName} are the same`, () => { + expect(match({ fileA: authorityFile, fileB: comparisonFiles[2], mode })).toBe(true); + }); + }); + }); }); diff --git a/spec/frontend/diffs/utils/queue_events_spec.js b/spec/frontend/diffs/utils/queue_events_spec.js index 007748d8b2c..ad2745f5188 100644 --- a/spec/frontend/diffs/utils/queue_events_spec.js +++ b/spec/frontend/diffs/utils/queue_events_spec.js @@ -1,11 +1,15 @@ import api from '~/api'; -import { DEFER_DURATION } from '~/diffs/constants'; +import { DEFER_DURATION, TRACKING_CAP_KEY, TRACKING_CAP_LENGTH } from '~/diffs/constants'; import { queueRedisHllEvents } from '~/diffs/utils/queue_events'; jest.mock('~/api', () => ({ trackRedisHllUserEvent: jest.fn(), })); +beforeAll(() => { + localStorage.clear(); +}); + describe('diffs events queue', () => { describe('queueRedisHllEvents', () => { it('does not dispatch the event immediately', () => { @@ -17,6 +21,7 @@ describe('diffs events queue', () => { queueRedisHllEvents(['know_event']); jest.advanceTimersByTime(DEFER_DURATION + 1); expect(api.trackRedisHllUserEvent).toHaveBeenCalled(); + expect(localStorage.getItem(TRACKING_CAP_KEY)).toBe(null); }); it('increase defer duration based on the provided events count', () => { @@ -32,5 +37,35 @@ describe('diffs events queue', () => { deferDuration *= index + 1; }); }); + + describe('with tracking cap verification', () => { + const currentTimestamp = Date.now(); + + beforeEach(() => { + localStorage.clear(); + }); + + it('dispatches the event if cap value is not found', () => { + queueRedisHllEvents(['know_event'], { verifyCap: true }); + jest.advanceTimersByTime(DEFER_DURATION + 1); + expect(api.trackRedisHllUserEvent).toHaveBeenCalled(); + expect(localStorage.getItem(TRACKING_CAP_KEY)).toBe(currentTimestamp.toString()); + }); + + it('dispatches the event if cap value is less than limit', () => { + localStorage.setItem(TRACKING_CAP_KEY, 1); + queueRedisHllEvents(['know_event'], { verifyCap: true }); + jest.advanceTimersByTime(DEFER_DURATION + 1); + expect(api.trackRedisHllUserEvent).toHaveBeenCalled(); + expect(localStorage.getItem(TRACKING_CAP_KEY)).toBe(currentTimestamp.toString()); + }); + + it('does not dispatch the event if cap value is greater than limit', () => { + localStorage.setItem(TRACKING_CAP_KEY, currentTimestamp - (TRACKING_CAP_LENGTH + 1)); + queueRedisHllEvents(['know_event'], { verifyCap: true }); + jest.advanceTimersByTime(DEFER_DURATION + 1); + expect(api.trackRedisHllUserEvent).toHaveBeenCalled(); + }); + }); }); }); diff --git a/spec/frontend/dropzone_input_spec.js b/spec/frontend/dropzone_input_spec.js index 11414e8890d..a633de9ef56 100644 --- a/spec/frontend/dropzone_input_spec.js +++ b/spec/frontend/dropzone_input_spec.js @@ -1,6 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import mock from 'xhr-mock'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'spec/test_constants'; import PasteMarkdownTable from '~/behaviors/markdown/paste_markdown_table'; @@ -45,7 +46,7 @@ describe('dropzone_input', () => { }; beforeEach(() => { - loadFixtures('issues/new-issue.html'); + loadHTMLFixture('issues/new-issue.html'); form = $('#new_issue'); form.data('uploads-path', TEST_UPLOAD_PATH); @@ -54,6 +55,8 @@ describe('dropzone_input', () => { afterEach(() => { form = null; + + resetHTMLFixture(); }); it('pastes Markdown tables', () => { diff --git a/spec/frontend/editor/components/helpers.js b/spec/frontend/editor/components/helpers.js index 3e6cd2a236d..12f90390c18 100644 --- a/spec/frontend/editor/components/helpers.js +++ b/spec/frontend/editor/components/helpers.js @@ -1,12 +1,28 @@ import { EDITOR_TOOLBAR_RIGHT_GROUP } from '~/editor/constants'; +import { apolloProvider } from '~/editor/components/source_editor_toolbar_graphql'; +import getToolbarItemsQuery from '~/editor/graphql/get_items.query.graphql'; export const buildButton = (id = 'foo-bar-btn', options = {}) => { return { __typename: 'Item', id, label: options.label || 'Foo Bar Button', - icon: options.icon || 'foo-bar', + icon: options.icon || 'check', selected: options.selected || false, group: options.group || EDITOR_TOOLBAR_RIGHT_GROUP, + onClick: options.onClick || (() => {}), + category: options.category || 'primary', + selectedLabel: options.selectedLabel || 'smth', }; }; + +export const warmUpCacheWithItems = (items = []) => { + apolloProvider.defaultClient.cache.writeQuery({ + query: getToolbarItemsQuery, + data: { + items: { + nodes: items, + }, + }, + }); +}; diff --git a/spec/frontend/editor/components/source_editor_toolbar_button_spec.js b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js index 5135091af4a..1475d451ab3 100644 --- a/spec/frontend/editor/components/source_editor_toolbar_button_spec.js +++ b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js @@ -1,43 +1,26 @@ -import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; +import { nextTick } from 'vue'; import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import createMockApollo from 'helpers/mock_apollo_helper'; import SourceEditorToolbarButton from '~/editor/components/source_editor_toolbar_button.vue'; -import getToolbarItemQuery from '~/editor/graphql/get_item.query.graphql'; -import updateToolbarItemMutation from '~/editor/graphql/update_item.mutation.graphql'; import { buildButton } from './helpers'; -Vue.use(VueApollo); - describe('Source Editor Toolbar button', () => { let wrapper; - let mockApollo; const defaultBtn = buildButton(); const findButton = () => wrapper.findComponent(GlButton); - const createComponentWithApollo = ({ propsData } = {}) => { - mockApollo = createMockApollo(); - mockApollo.clients.defaultClient.cache.writeQuery({ - query: getToolbarItemQuery, - variables: { id: defaultBtn.id }, - data: { - item: { - ...defaultBtn, - }, - }, - }); - + const createComponent = (props = { button: defaultBtn }) => { wrapper = shallowMount(SourceEditorToolbarButton, { - propsData, - apolloProvider: mockApollo, + propsData: { + ...props, + }, }); }; afterEach(() => { wrapper.destroy(); - mockApollo = null; + wrapper = null; }); describe('default', () => { @@ -49,98 +32,51 @@ describe('Source Editor Toolbar button', () => { category: 'secondary', variant: 'info', }; + + it('does not render the button if the props have not been passed', () => { + createComponent({}); + expect(findButton().vm).toBeUndefined(); + }); + it('renders a default button without props', async () => { - createComponentWithApollo(); + createComponent(); const btn = findButton(); expect(btn.exists()).toBe(true); expect(btn.props()).toMatchObject(defaultProps); }); it('renders a button based on the props passed', async () => { - createComponentWithApollo({ - propsData: { - button: customProps, - }, + createComponent({ + button: customProps, }); const btn = findButton(); expect(btn.props()).toMatchObject(customProps); }); }); - describe('button updates', () => { - it('it properly updates button on Apollo cache update', async () => { - const { id } = defaultBtn; - - createComponentWithApollo({ - propsData: { - button: { - id, - }, - }, - }); - - expect(findButton().props('selected')).toBe(false); - - mockApollo.clients.defaultClient.cache.writeQuery({ - query: getToolbarItemQuery, - variables: { id }, - data: { - item: { - ...defaultBtn, - selected: true, - }, - }, - }); - - jest.runOnlyPendingTimers(); - await nextTick(); - - expect(findButton().props('selected')).toBe(true); - }); - }); - describe('click handler', () => { - it('fires the click handler on the button when available', () => { + it('fires the click handler on the button when available', async () => { const spy = jest.fn(); - createComponentWithApollo({ - propsData: { - button: { - onClick: spy, - }, + createComponent({ + button: { + onClick: spy, }, }); expect(spy).not.toHaveBeenCalled(); findButton().vm.$emit('click'); + + await nextTick(); expect(spy).toHaveBeenCalled(); }); - it('emits the "click" event', () => { - createComponentWithApollo(); + it('emits the "click" event', async () => { + createComponent(); jest.spyOn(wrapper.vm, '$emit'); expect(wrapper.vm.$emit).not.toHaveBeenCalled(); + findButton().vm.$emit('click'); + await nextTick(); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('click'); }); - it('triggers the mutation exposing the changed "selected" prop', () => { - const { id } = defaultBtn; - createComponentWithApollo({ - propsData: { - button: { - id, - }, - }, - }); - jest.spyOn(wrapper.vm.$apollo, 'mutate'); - expect(wrapper.vm.$apollo.mutate).not.toHaveBeenCalled(); - findButton().vm.$emit('click'); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: updateToolbarItemMutation, - variables: { - id, - propsToUpdate: { - selected: true, - }, - }, - }); - }); }); }); diff --git a/spec/frontend/editor/components/source_editor_toolbar_graphql_spec.js b/spec/frontend/editor/components/source_editor_toolbar_graphql_spec.js new file mode 100644 index 00000000000..41c48aa0a58 --- /dev/null +++ b/spec/frontend/editor/components/source_editor_toolbar_graphql_spec.js @@ -0,0 +1,112 @@ +import { apolloProvider } from '~/editor/components/source_editor_toolbar_graphql'; +import getToolbarItemsQuery from '~/editor/graphql/get_items.query.graphql'; +import removeItemsMutation from '~/editor/graphql/remove_items.mutation.graphql'; +import updateToolbarItemMutation from '~/editor/graphql/update_item.mutation.graphql'; +import addToolbarItemsMutation from '~/editor/graphql/add_items.mutation.graphql'; +import { buildButton, warmUpCacheWithItems } from './helpers'; + +describe('Source Editor toolbar Apollo client', () => { + const item1 = buildButton('foo'); + const item2 = buildButton('bar'); + + const getItems = () => + apolloProvider.defaultClient.cache.readQuery({ query: getToolbarItemsQuery })?.items?.nodes || + []; + const getItem = (id) => { + return getItems().find((item) => item.id === id); + }; + + afterEach(() => { + apolloProvider.defaultClient.clearStore(); + }); + + describe('Mutations', () => { + describe('addToolbarItems', () => { + function addButtons(items) { + return apolloProvider.defaultClient.mutate({ + mutation: addToolbarItemsMutation, + variables: { + items, + }, + }); + } + it.each` + cache | idsToAdd | itemsToAdd | expectedResult | comment + ${[]} | ${'empty array'} | ${[]} | ${[]} | ${''} + ${[]} | ${'undefined'} | ${undefined} | ${[]} | ${''} + ${[]} | ${item2.id} | ${[item2]} | ${[item2]} | ${''} + ${[]} | ${item1.id} | ${[item1]} | ${[item1]} | ${''} + ${[]} | ${[item1.id, item2.id]} | ${[item1, item2]} | ${[item1, item2]} | ${''} + ${[]} | ${[item1.id]} | ${item1} | ${[item1]} | ${'does not fail if the item is an Object'} + ${[item2]} | ${[item1.id]} | ${item1} | ${[item2, item1]} | ${'does not fail if the item is an Object'} + ${[item1]} | ${[item2.id]} | ${[item2]} | ${[item1, item2]} | ${'correctly adds items to the pre-populated cache'} + `('adds $idsToAdd item(s) to $cache', async ({ cache, itemsToAdd, expectedResult }) => { + await warmUpCacheWithItems(cache); + await addButtons(itemsToAdd); + await expect(getItems()).toEqual(expectedResult); + }); + }); + + describe('removeToolbarItems', () => { + function removeButtons(ids) { + return apolloProvider.defaultClient.mutate({ + mutation: removeItemsMutation, + variables: { + ids, + }, + }); + } + + it.each` + cache | cacheIds | toRemove | expected + ${[item1, item2]} | ${[item1.id, item2.id]} | ${[item1.id]} | ${[item2]} + ${[item1, item2]} | ${[item1.id, item2.id]} | ${[item2.id]} | ${[item1]} + ${[item1, item2]} | ${[item1.id, item2.id]} | ${[item1.id, item2.id]} | ${[]} + ${[item1]} | ${[item1.id]} | ${[item1.id]} | ${[]} + ${[item2]} | ${[item2.id]} | ${[]} | ${[item2]} + ${[]} | ${['undefined']} | ${[item1.id]} | ${[]} + ${[item1]} | ${[item1.id]} | ${[item2.id]} | ${[item1]} + `('removes $toRemove from the $cacheIds toolbar', async ({ cache, toRemove, expected }) => { + await warmUpCacheWithItems(cache); + + expect(getItems()).toHaveLength(cache.length); + + await removeButtons(toRemove); + + expect(getItems()).toHaveLength(expected.length); + expect(getItems()).toEqual(expected); + }); + }); + + describe('updateToolbarItem', () => { + function mutateButton(item, propsToUpdate = {}) { + return apolloProvider.defaultClient.mutate({ + mutation: updateToolbarItemMutation, + variables: { + id: item.id, + propsToUpdate, + }, + }); + } + + beforeEach(() => { + warmUpCacheWithItems([item1, item2]); + }); + + it('updates the toolbar items', async () => { + expect(getItem(item1.id).selected).toBe(false); + expect(getItem(item2.id).selected).toBe(false); + + await mutateButton(item1, { selected: true }); + + expect(getItem(item1.id).selected).toBe(true); + expect(getItem(item2.id).selected).toBe(false); + + await mutateButton(item2, { selected: true }); + + expect(getItem(item1.id).selected).toBe(true); + expect(getItem(item2.id).selected).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/editor/extensions/source_editor_toolbar_ext_spec.js b/spec/frontend/editor/extensions/source_editor_toolbar_ext_spec.js new file mode 100644 index 00000000000..fa5a3b2987e --- /dev/null +++ b/spec/frontend/editor/extensions/source_editor_toolbar_ext_spec.js @@ -0,0 +1,156 @@ +import Vue from 'vue'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import SourceEditorToolbar from '~/editor/components/source_editor_toolbar.vue'; +import { ToolbarExtension } from '~/editor/extensions/source_editor_toolbar_ext'; +import EditorInstance from '~/editor/source_editor_instance'; +import { apolloProvider } from '~/editor/components/source_editor_toolbar_graphql'; +import { buildButton, warmUpCacheWithItems } from '../components/helpers'; + +describe('Source Editor Toolbar Extension', () => { + let instance; + + const createInstance = (baseInstance = {}) => { + return new EditorInstance(baseInstance); + }; + const getDefaultEl = () => document.getElementById('editor-toolbar'); + const getCustomEl = () => document.getElementById('custom-toolbar'); + const item1 = buildButton('foo'); + const item2 = buildButton('bar'); + + beforeEach(() => { + setHTMLFixture('<div id="editor-toolbar"></div><div id="custom-toolbar"></div>'); + }); + + afterEach(() => { + apolloProvider.defaultClient.clearStore(); + resetHTMLFixture(); + }); + + describe('onSetup', () => { + beforeEach(() => { + instance = createInstance(); + }); + + it.each` + id | type | prefix | expectedElFn + ${undefined} | ${'default'} | ${'Sets up'} | ${getDefaultEl} + ${'custom-toolbar'} | ${'custom'} | ${'Sets up'} | ${getCustomEl} + ${'non-existing'} | ${'default'} | ${'Does not set up'} | ${getDefaultEl} + `('Sets up the Vue application on $type node when node is $id', ({ id, expectedElFn }) => { + jest.spyOn(Vue, 'extend'); + jest.spyOn(ToolbarExtension, 'setupVue'); + + const el = document.getElementById(id); + const expectedEl = expectedElFn(); + + instance.use({ definition: ToolbarExtension, setupOptions: { el } }); + + if (expectedEl) { + expect(ToolbarExtension.setupVue).toHaveBeenCalledWith(expectedEl); + expect(Vue.extend).toHaveBeenCalledWith(SourceEditorToolbar); + } else { + expect(ToolbarExtension.setupVue).not.toHaveBeenCalled(); + } + }); + }); + + describe('public API', () => { + beforeEach(async () => { + await warmUpCacheWithItems(); + instance = createInstance(); + instance.use({ definition: ToolbarExtension }); + }); + + describe('getAllItems', () => { + it('returns the list of all toolbar items', async () => { + await expect(instance.toolbar.getAllItems()).toEqual([]); + await warmUpCacheWithItems([item1, item2]); + await expect(instance.toolbar.getAllItems()).toEqual([item1, item2]); + }); + }); + + describe('getItem', () => { + it('returns a toolbar item by id', async () => { + await expect(instance.toolbar.getItem(item1.id)).toEqual(undefined); + await warmUpCacheWithItems([item1]); + await expect(instance.toolbar.getItem(item1.id)).toEqual(item1); + }); + }); + + describe('addItems', () => { + it.each` + idsToAdd | itemsToAdd | expectedResult + ${'empty array'} | ${[]} | ${[]} + ${'undefined'} | ${undefined} | ${[]} + ${item2.id} | ${[item2]} | ${[item2]} + ${item1.id} | ${[item1]} | ${[item1]} + ${[item1.id, item2.id]} | ${[item1, item2]} | ${[item1, item2]} + `('adds $idsToAdd item(s) to cache', async ({ itemsToAdd, expectedResult }) => { + await instance.toolbar.addItems(itemsToAdd); + await expect(instance.toolbar.getAllItems()).toEqual(expectedResult); + }); + + it('correctly adds items to the pre-populated cache', async () => { + await warmUpCacheWithItems([item1]); + await instance.toolbar.addItems([item2]); + await expect(instance.toolbar.getAllItems()).toEqual([item1, item2]); + }); + + it('does not fail if the item is an Object', async () => { + await instance.toolbar.addItems(item1); + await expect(instance.toolbar.getAllItems()).toEqual([item1]); + }); + }); + + describe('removeItems', () => { + beforeEach(async () => { + await warmUpCacheWithItems([item1, item2]); + }); + + it.each` + idsToRemove | expectedResult + ${undefined} | ${[item1, item2]} + ${[]} | ${[item1, item2]} + ${[item1.id]} | ${[item2]} + ${[item2.id]} | ${[item1]} + ${[item1.id, item2.id]} | ${[]} + `( + 'successfully removes $idsToRemove from [foo, bar]', + async ({ idsToRemove, expectedResult }) => { + await instance.toolbar.removeItems(idsToRemove); + await expect(instance.toolbar.getAllItems()).toEqual(expectedResult); + }, + ); + }); + + describe('updateItem', () => { + const updatedProp = { + icon: 'book', + }; + + beforeEach(async () => { + await warmUpCacheWithItems([item1, item2]); + }); + + it.each` + itemsToUpdate | idToUpdate | propsToUpdate | expectedResult + ${undefined} | ${'undefined'} | ${undefined} | ${[item1, item2]} + ${item2.id} | ${item2.id} | ${undefined} | ${[item1, item2]} + ${item2.id} | ${item2.id} | ${{}} | ${[item1, item2]} + ${[item1]} | ${item1.id} | ${updatedProp} | ${[{ ...item1, ...updatedProp }, item2]} + ${[item2]} | ${item2.id} | ${updatedProp} | ${[item1, { ...item2, ...updatedProp }]} + `( + 'updates $idToUpdate item in cache with $propsToUpdate', + async ({ idToUpdate, propsToUpdate, expectedResult }) => { + await instance.toolbar.updateItem(idToUpdate, propsToUpdate); + await expect(instance.toolbar.getAllItems()).toEqual(expectedResult); + if (propsToUpdate) { + await expect(instance.toolbar.getItem(idToUpdate)).toEqual( + expect.objectContaining(propsToUpdate), + ); + } + }, + ); + }); + }); +}); diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json index 89420bbc35f..666a4852957 100644 --- a/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json @@ -97,7 +97,10 @@ "expire_in": "1 week", "reports": { "junit": "result.xml", - "cobertura": "cobertura-coverage.xml", + "coverage_report": { + "coverage_format": "cobertura", + "path": "cobertura-coverage.xml" + }, "codequality": "codequality.json", "sast": "sast.json", "dependency_scanning": "scan.json", @@ -147,7 +150,10 @@ "artifacts": { "reports": { "junit": ["result.xml"], - "cobertura": ["cobertura-coverage.xml"], + "coverage_report": { + "coverage_format": "cobertura", + "path": "cobertura-coverage.xml" + }, "codequality": ["codequality.json"], "sast": ["sast.json"], "dependency_scanning": ["scan.json"], 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 2f6d277ca75..9a14e1a55eb 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,5 @@ import { languages } from 'monaco-editor'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext'; import ciSchemaPath from '~/editor/schema/ci.json'; @@ -19,7 +20,7 @@ describe('~/editor/editor_ci_config_ext', () => { let originalGitlabUrl; const createMockEditor = ({ blobPath = defaultBlobPath } = {}) => { - setFixtures('<div id="editor"></div>'); + setHTMLFixture('<div id="editor"></div>'); editorEl = document.getElementById('editor'); editor = new SourceEditor(); instance = editor.createInstance({ @@ -45,7 +46,9 @@ describe('~/editor/editor_ci_config_ext', () => { afterEach(() => { instance.dispose(); + editorEl.remove(); + resetHTMLFixture(); }); describe('registerCiSchema', () => { diff --git a/spec/frontend/editor/source_editor_extension_base_spec.js b/spec/frontend/editor/source_editor_extension_base_spec.js index 6606557fd1f..eab39ccaba1 100644 --- a/spec/frontend/editor/source_editor_extension_base_spec.js +++ b/spec/frontend/editor/source_editor_extension_base_spec.js @@ -1,4 +1,5 @@ import { Range } from 'monaco-editor'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; import setWindowLocation from 'helpers/set_window_location_helper'; import { @@ -39,12 +40,13 @@ describe('The basis for an Source Editor extension', () => { }; beforeEach(() => { - setFixtures(generateLines()); + setHTMLFixture(generateLines()); event = generateEventMock(); }); afterEach(() => { jest.clearAllMocks(); + resetHTMLFixture(); }); describe('onUse callback', () => { @@ -253,7 +255,7 @@ describe('The basis for an Source Editor extension', () => { }); it('does not create a link if the event is triggered on a wrong node', () => { - setFixtures('<div class="wrong-class">3</div>'); + setHTMLFixture('<div class="wrong-class">3</div>'); SourceEditorExtension.createAnchor = jest.fn(); const wrongEvent = generateEventMock({ el: document.querySelector('.wrong-class') }); diff --git a/spec/frontend/editor/source_editor_markdown_ext_spec.js b/spec/frontend/editor/source_editor_markdown_ext_spec.js index eecd23bff6e..3e8c287df2f 100644 --- a/spec/frontend/editor/source_editor_markdown_ext_spec.js +++ b/spec/frontend/editor/source_editor_markdown_ext_spec.js @@ -1,5 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import { Range, Position } from 'monaco-editor'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; import SourceEditor from '~/editor/source_editor'; import axios from '~/lib/utils/axios_utils'; @@ -27,7 +28,7 @@ describe('Markdown Extension for Source Editor', () => { beforeEach(() => { mockAxios = new MockAdapter(axios); - setFixtures('<div id="editor" data-editor-loading></div>'); + setHTMLFixture('<div id="editor" data-editor-loading></div>'); editorEl = document.getElementById('editor'); editor = new SourceEditor(); instance = editor.createInstance({ @@ -42,6 +43,8 @@ describe('Markdown Extension for Source Editor', () => { instance.dispose(); editorEl.remove(); mockAxios.restore(); + + resetHTMLFixture(); }); describe('getSelectedText', () => { 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 c8d016e10ac..1926f3e268e 100644 --- a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js +++ b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js @@ -1,5 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; -import { editor as monacoEditor } from 'monaco-editor'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; import { EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS, @@ -30,7 +30,6 @@ describe('Markdown Live Preview Extension for Source Editor', () => { const secondLine = 'multiline'; const thirdLine = 'string with some **markup**'; const text = `${firstLine}\n${secondLine}\n${thirdLine}`; - const plaintextPath = 'foo.txt'; const markdownPath = 'foo.md'; const responseData = '<div>FooBar</div>'; @@ -41,7 +40,7 @@ describe('Markdown Live Preview Extension for Source Editor', () => { beforeEach(() => { mockAxios = new MockAdapter(axios); - setFixtures('<div id="editor" data-editor-loading></div>'); + setHTMLFixture('<div id="editor" data-editor-loading></div>'); editorEl = document.getElementById('editor'); editor = new SourceEditor(); instance = editor.createInstance({ @@ -49,6 +48,13 @@ describe('Markdown Live Preview Extension for Source Editor', () => { blobPath: markdownPath, blobContent: text, }); + + instance.toolbar = { + addItems: jest.fn(), + updateItem: jest.fn(), + removeItems: jest.fn(), + }; + extension = instance.use({ definition: EditorMarkdownPreviewExtension, setupOptions: { previewMarkdownPath }, @@ -60,64 +66,20 @@ describe('Markdown Live Preview Extension for Source Editor', () => { instance.dispose(); editorEl.remove(); mockAxios.restore(); + resetHTMLFixture(); }); it('sets up the preview on the instance', () => { expect(instance.markdownPreview).toEqual({ el: undefined, - action: expect.any(Object), + actions: expect.any(Object), shown: false, modelChangeListener: undefined, path: previewMarkdownPath, + actionShowPreviewCondition: expect.any(Object), }); }); - describe('model language changes listener', () => { - let cleanupSpy; - let actionSpy; - - beforeEach(async () => { - cleanupSpy = jest.fn(); - actionSpy = jest.fn(); - spyOnApi(extension, { - cleanup: cleanupSpy, - setupPreviewAction: actionSpy, - }); - await togglePreview(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('cleans up when switching away from markdown', () => { - expect(cleanupSpy).not.toHaveBeenCalled(); - expect(actionSpy).not.toHaveBeenCalled(); - - instance.updateModelLanguage(plaintextPath); - - expect(cleanupSpy).toHaveBeenCalled(); - expect(actionSpy).not.toHaveBeenCalled(); - }); - - it.each` - oldLanguage | newLanguage | setupCalledTimes - ${'plaintext'} | ${'markdown'} | ${1} - ${'markdown'} | ${'markdown'} | ${0} - ${'markdown'} | ${'plaintext'} | ${0} - ${'markdown'} | ${undefined} | ${0} - ${undefined} | ${'markdown'} | ${1} - `( - 'correctly handles re-enabling of the action when switching from $oldLanguage to $newLanguage', - ({ oldLanguage, newLanguage, setupCalledTimes } = {}) => { - expect(actionSpy).not.toHaveBeenCalled(); - instance.updateModelLanguage(oldLanguage); - instance.updateModelLanguage(newLanguage); - expect(actionSpy).toHaveBeenCalledTimes(setupCalledTimes); - }, - ); - }); - describe('model change listener', () => { let cleanupSpy; let actionSpy; @@ -142,33 +104,22 @@ describe('Markdown Live Preview Extension for Source Editor', () => { expect(cleanupSpy).not.toHaveBeenCalled(); expect(actionSpy).not.toHaveBeenCalled(); }); - - it('cleans up the preview when the model changes', () => { - instance.setModel(monacoEditor.createModel('foo')); - expect(cleanupSpy).toHaveBeenCalled(); - }); - - it.each` - language | setupCalledTimes - ${'markdown'} | ${1} - ${'plaintext'} | ${0} - ${undefined} | ${0} - `( - 'correctly handles actions when the new model is $language', - ({ language, setupCalledTimes } = {}) => { - instance.setModel(monacoEditor.createModel('foo', language)); - - expect(actionSpy).toHaveBeenCalledTimes(setupCalledTimes); - }, - ); }); - describe('cleanup', () => { + describe('onBeforeUnuse', () => { beforeEach(async () => { mockAxios.onPost().reply(200, { body: responseData }); await togglePreview(); }); + it('removes the registered buttons from the toolbar', () => { + expect(instance.toolbar.removeItems).not.toHaveBeenCalled(); + instance.unuse(extension); + expect(instance.toolbar.removeItems).toHaveBeenCalledWith([ + EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, + ]); + }); + it('disposes the modelChange listener and does not fetch preview on content changes', () => { expect(instance.markdownPreview.modelChangeListener).toBeDefined(); const fetchPreviewSpy = jest.fn(); @@ -176,7 +127,7 @@ describe('Markdown Live Preview Extension for Source Editor', () => { fetchPreview: fetchPreviewSpy, }); - instance.cleanup(); + instance.unuse(extension); instance.setValue('Foo Bar'); jest.advanceTimersByTime(EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY); @@ -186,17 +137,11 @@ describe('Markdown Live Preview Extension for Source Editor', () => { it('removes the contextual menu action', () => { expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined(); - instance.cleanup(); + instance.unuse(extension); expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBe(null); }); - it('toggles the `shown` flag', () => { - expect(instance.markdownPreview.shown).toBe(true); - instance.cleanup(); - expect(instance.markdownPreview.shown).toBe(false); - }); - it('toggles the panel only if the preview is visible', () => { const { el: previewEl } = instance.markdownPreview; const parentEl = previewEl.parentElement; @@ -204,13 +149,13 @@ describe('Markdown Live Preview Extension for Source Editor', () => { expect(previewEl).toBeVisible(); expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(true); - instance.cleanup(); + instance.unuse(extension); expect(previewEl).toBeHidden(); expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( false, ); - instance.cleanup(); + instance.unuse(extension); expect(previewEl).toBeHidden(); expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( false, @@ -222,12 +167,12 @@ describe('Markdown Live Preview Extension for Source Editor', () => { expect(instance.markdownPreview.shown).toBe(true); - instance.cleanup(); + instance.unuse(extension); const { width: newWidth } = instance.getLayoutInfo(); expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true); - instance.cleanup(); + instance.unuse(extension); expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true); }); }); @@ -305,6 +250,12 @@ describe('Markdown Live Preview Extension for Source Editor', () => { mockAxios.onPost().reply(200, { body: responseData }); }); + it('toggles the condition to toggle preview/hide actions in the context menu', () => { + expect(instance.markdownPreview.actionShowPreviewCondition.get()).toBe(true); + instance.togglePreview(); + expect(instance.markdownPreview.actionShowPreviewCondition.get()).toBe(false); + }); + it('toggles preview flag on instance', () => { expect(instance.markdownPreview.shown).toBe(false); diff --git a/spec/frontend/editor/source_editor_spec.js b/spec/frontend/editor/source_editor_spec.js index 049cab3a83b..b3d914e6755 100644 --- a/spec/frontend/editor/source_editor_spec.js +++ b/spec/frontend/editor/source_editor_spec.js @@ -1,4 +1,5 @@ import { editor as monacoEditor, languages as monacoLanguages } from 'monaco-editor'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { SOURCE_EDITOR_INSTANCE_ERROR_NO_EL, URI_PREFIX, @@ -33,7 +34,7 @@ describe('Base editor', () => { const blobGlobalId = 'snippet_777'; beforeEach(() => { - setFixtures('<div id="editor" data-editor-loading></div>'); + setHTMLFixture('<div id="editor" data-editor-loading></div>'); editorEl = document.getElementById('editor'); defaultArguments = { el: editorEl, blobPath, blobContent, blobGlobalId }; editor = new SourceEditor(); @@ -45,6 +46,8 @@ describe('Base editor', () => { monacoEditor.getModels().forEach((model) => { model.dispose(); }); + + resetHTMLFixture(); }); const uriFilePath = joinPaths('/', URI_PREFIX, blobGlobalId, blobPath); @@ -244,7 +247,7 @@ describe('Base editor', () => { const readOnlyIndex = '78'; // readOnly option has the internal index of 78 in the editor's options beforeEach(() => { - setFixtures('<div id="editor1"></div><div id="editor2"></div>'); + setHTMLFixture('<div id="editor1"></div><div id="editor2"></div>'); editorEl1 = document.getElementById('editor1'); editorEl2 = document.getElementById('editor2'); inst1Args = { @@ -262,6 +265,7 @@ describe('Base editor', () => { afterEach(() => { editor.dispose(); + resetHTMLFixture(); }); it('can initialize several instances of the same editor', () => { diff --git a/spec/frontend/editor/source_editor_yaml_ext_spec.js b/spec/frontend/editor/source_editor_yaml_ext_spec.js index b603b0e3a98..14ec7f8b93f 100644 --- a/spec/frontend/editor/source_editor_yaml_ext_spec.js +++ b/spec/frontend/editor/source_editor_yaml_ext_spec.js @@ -1,4 +1,5 @@ import { Document } from 'yaml'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import SourceEditor from '~/editor/source_editor'; import { YamlEditorExtension } from '~/editor/extensions/source_editor_yaml_ext'; import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; @@ -8,7 +9,7 @@ let baseExtension; let yamlExtension; const getEditorInstance = (editorInstanceOptions = {}) => { - setFixtures('<div id="editor"></div>'); + setHTMLFixture('<div id="editor"></div>'); return new SourceEditor().createInstance({ el: document.getElementById('editor'), blobPath: '.gitlab-ci.yml', @@ -18,7 +19,7 @@ const getEditorInstance = (editorInstanceOptions = {}) => { }; const getEditorInstanceWithExtension = (extensionOptions = {}, editorInstanceOptions = {}) => { - setFixtures('<div id="editor"></div>'); + setHTMLFixture('<div id="editor"></div>'); const instance = getEditorInstance(editorInstanceOptions); [baseExtension, yamlExtension] = instance.use([ { definition: SourceEditorExtension }, @@ -35,6 +36,10 @@ const getEditorInstanceWithExtension = (extensionOptions = {}, editorInstanceOpt }; describe('YamlCreatorExtension', () => { + afterEach(() => { + resetHTMLFixture(); + }); + describe('constructor', () => { it('saves setupOptions options on the extension, but does not expose those to instance', () => { const highlightPath = 'foo'; diff --git a/spec/frontend/editor/utils_spec.js b/spec/frontend/editor/utils_spec.js index 97d3e9e081d..e561cad1086 100644 --- a/spec/frontend/editor/utils_spec.js +++ b/spec/frontend/editor/utils_spec.js @@ -1,4 +1,5 @@ import { editor as monacoEditor } from 'monaco-editor'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import * as utils from '~/editor/utils'; import { DEFAULT_THEME } from '~/ide/lib/themes'; @@ -14,10 +15,14 @@ describe('Source Editor utils', () => { describe('clearDomElement', () => { beforeEach(() => { - setFixtures('<div id="foo"><div id="bar">Foo</div></div>'); + setHTMLFixture('<div id="foo"><div id="bar">Foo</div></div>'); el = document.getElementById('foo'); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('removes all child nodes from an element', () => { expect(el.children.length).toBe(1); utils.clearDomElement(el); @@ -68,10 +73,14 @@ describe('Source Editor utils', () => { beforeEach(() => { jest.spyOn(monacoEditor, 'colorizeElement').mockImplementation(); jest.spyOn(monacoEditor, 'setTheme').mockImplementation(); - setFixtures('<pre id="foo"></pre>'); + setHTMLFixture('<pre id="foo"></pre>'); el = document.getElementById('foo'); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('colorizes the element and applies the preference theme', () => { expect(monacoEditor.colorizeElement).not.toHaveBeenCalled(); expect(monacoEditor.setTheme).not.toHaveBeenCalled(); diff --git a/spec/frontend/emoji/components/utils_spec.js b/spec/frontend/emoji/components/utils_spec.js index 03eeb6b6bf7..56f514ee9a8 100644 --- a/spec/frontend/emoji/components/utils_spec.js +++ b/spec/frontend/emoji/components/utils_spec.js @@ -1,7 +1,7 @@ -import Cookies from 'js-cookie'; +import Cookies from '~/lib/utils/cookies'; import { getFrequentlyUsedEmojis, addToFrequentlyUsed } from '~/emoji/components/utils'; -jest.mock('js-cookie'); +jest.mock('~/lib/utils/cookies'); describe('getFrequentlyUsedEmojis', () => { it('it returns null when no saved emojis set', () => { diff --git a/spec/frontend/environments/environment_folder_spec.js b/spec/frontend/environments/environment_folder_spec.js index f2027252f05..37b897bf65d 100644 --- a/spec/frontend/environments/environment_folder_spec.js +++ b/spec/frontend/environments/environment_folder_spec.js @@ -15,12 +15,13 @@ Vue.use(VueApollo); describe('~/environments/components/environments_folder.vue', () => { let wrapper; let environmentFolderMock; + let intervalMock; let nestedEnvironment; const findLink = () => wrapper.findByRole('link', { name: s__('Environments|Show all') }); const createApolloProvider = () => { - const mockResolvers = { Query: { folder: environmentFolderMock } }; + const mockResolvers = { Query: { folder: environmentFolderMock, interval: intervalMock } }; return createMockApollo([], mockResolvers); }; @@ -40,6 +41,8 @@ describe('~/environments/components/environments_folder.vue', () => { environmentFolderMock = jest.fn(); [nestedEnvironment] = resolvedEnvironmentsApp.environments; environmentFolderMock.mockReturnValue(resolvedFolder); + intervalMock = jest.fn(); + intervalMock.mockReturnValue(2000); }); afterEach(() => { @@ -70,6 +73,8 @@ describe('~/environments/components/environments_folder.vue', () => { beforeEach(() => { collapse = wrapper.findComponent(GlCollapse); icons = wrapper.findAllComponents(GlIcon); + jest.spyOn(wrapper.vm.$apollo.queries.folder, 'startPolling'); + jest.spyOn(wrapper.vm.$apollo.queries.folder, 'stopPolling'); }); it('is collapsed by default', () => { @@ -93,6 +98,8 @@ describe('~/environments/components/environments_folder.vue', () => { expect(iconNames).toEqual(['angle-down', 'folder-open']); expect(folderName.classes('gl-font-weight-bold')).toBe(true); expect(link.attributes('href')).toBe(nestedEnvironment.latest.folderPath); + + expect(wrapper.vm.$apollo.queries.folder.startPolling).toHaveBeenCalledWith(2000); }); it('displays all environments when opened', async () => { @@ -106,6 +113,16 @@ describe('~/environments/components/environments_folder.vue', () => { .wrappers.map((w) => w.text()); expect(environments).toEqual(expect.arrayContaining(names)); }); + + it('stops polling on click', async () => { + await button.trigger('click'); + expect(wrapper.vm.$apollo.queries.folder.startPolling).toHaveBeenCalledWith(2000); + + const collapseButton = wrapper.findByRole('button', { name: __('Collapse') }); + await collapseButton.trigger('click'); + + expect(wrapper.vm.$apollo.queries.folder.stopPolling).toHaveBeenCalled(); + }); }); }); diff --git a/spec/frontend/filterable_list_spec.js b/spec/frontend/filterable_list_spec.js index 3fd5d198e3a..e7197ac6dbf 100644 --- a/spec/frontend/filterable_list_spec.js +++ b/spec/frontend/filterable_list_spec.js @@ -1,4 +1,4 @@ -import { setHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import FilterableList from '~/filterable_list'; describe('FilterableList', () => { @@ -20,6 +20,10 @@ describe('FilterableList', () => { List = new FilterableList(form, filter, holder); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('processes input parameters', () => { expect(List.filterForm).toEqual(form); expect(List.listFilterElement).toEqual(filter); diff --git a/spec/frontend/filtered_search/dropdown_user_spec.js b/spec/frontend/filtered_search/dropdown_user_spec.js index ee0eef6a1b6..26f12673f68 100644 --- a/spec/frontend/filtered_search/dropdown_user_spec.js +++ b/spec/frontend/filtered_search/dropdown_user_spec.js @@ -1,3 +1,4 @@ +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import DropdownUser from '~/filtered_search/dropdown_user'; import DropdownUtils from '~/filtered_search/dropdown_utils'; import FilteredSearchTokenizer from '~/filtered_search/filtered_search_tokenizer'; @@ -80,7 +81,7 @@ describe('Dropdown User', () => { let authorFilterDropdownElement; beforeEach(() => { - loadFixtures(fixtureTemplate); + loadHTMLFixture(fixtureTemplate); authorFilterDropdownElement = document.querySelector('#js-dropdown-author'); const dummyInput = document.createElement('div'); dropdown = new DropdownUser({ @@ -89,6 +90,10 @@ describe('Dropdown User', () => { }); }); + afterEach(() => { + resetHTMLFixture(); + }); + const findCurrentUserElement = () => authorFilterDropdownElement.querySelector('.js-current-user'); diff --git a/spec/frontend/filtered_search/dropdown_utils_spec.js b/spec/frontend/filtered_search/dropdown_utils_spec.js index 4c1e79eba42..2030b45b44c 100644 --- a/spec/frontend/filtered_search/dropdown_utils_spec.js +++ b/spec/frontend/filtered_search/dropdown_utils_spec.js @@ -1,3 +1,4 @@ +import { loadHTMLFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import FilteredSearchSpecHelper from 'helpers/filtered_search_spec_helper'; import DropdownUtils from '~/filtered_search/dropdown_utils'; import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager'; @@ -43,13 +44,17 @@ describe('Dropdown Utils', () => { }; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <input type="text" id="test" /> `); input = document.getElementById('test'); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('should filter without symbol', () => { input.value = 'roo'; @@ -142,7 +147,7 @@ describe('Dropdown Utils', () => { let allowedKeys; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <ul class="tokens-container"> <li class="input-token"> <input class="filtered-search" type="text" id="test" /> @@ -350,7 +355,7 @@ describe('Dropdown Utils', () => { let authorToken; beforeEach(() => { - loadFixtures(issuableListFixture); + loadHTMLFixture(issuableListFixture); authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user'); const searchTermToken = FilteredSearchSpecHelper.createSearchVisualToken('search term'); diff --git a/spec/frontend/filtered_search/filtered_search_dropdown_manager_spec.js b/spec/frontend/filtered_search/filtered_search_dropdown_manager_spec.js index e9ee69ca163..dff6d11a320 100644 --- a/spec/frontend/filtered_search/filtered_search_dropdown_manager_spec.js +++ b/spec/frontend/filtered_search/filtered_search_dropdown_manager_spec.js @@ -1,5 +1,6 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager'; describe('Filtered Search Dropdown Manager', () => { @@ -20,7 +21,7 @@ describe('Filtered Search Dropdown Manager', () => { } beforeEach(() => { - setFixtures(` + setHTMLFixture(` <ul class="tokens-container"> <li class="input-token"> <input class="filtered-search"> @@ -29,6 +30,10 @@ describe('Filtered Search Dropdown Manager', () => { `); }); + afterEach(() => { + resetHTMLFixture(); + }); + describe('input has no existing value', () => { it('should add just tokenName', () => { FilteredSearchDropdownManager.addWordToInput({ tokenName: 'milestone' }); diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js index 911a507af4c..5e68725c03e 100644 --- a/spec/frontend/filtered_search/filtered_search_manager_spec.js +++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js @@ -1,5 +1,5 @@ import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager'; - +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import FilteredSearchSpecHelper from 'helpers/filtered_search_spec_helper'; import DropdownUtils from '~/filtered_search/dropdown_utils'; import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager'; @@ -64,7 +64,7 @@ describe('Filtered Search Manager', () => { } beforeEach(() => { - setFixtures(` + setHTMLFixture(` <div class="filtered-search-box"> <form> <ul class="tokens-container list-unstyled"> @@ -80,6 +80,10 @@ describe('Filtered Search Manager', () => { jest.spyOn(FilteredSearchDropdownManager.prototype, 'setDropdown').mockImplementation(); }); + afterEach(() => { + resetHTMLFixture(); + }); + const initializeManager = ({ useDefaultState } = {}) => { jest.spyOn(FilteredSearchManager.prototype, 'loadSearchParamsFromURL').mockImplementation(); jest.spyOn(FilteredSearchManager.prototype, 'tokenChange').mockImplementation(); diff --git a/spec/frontend/filtered_search/filtered_search_visual_tokens_spec.js b/spec/frontend/filtered_search/filtered_search_visual_tokens_spec.js index c4e125e96da..0e5c94edd05 100644 --- a/spec/frontend/filtered_search/filtered_search_visual_tokens_spec.js +++ b/spec/frontend/filtered_search/filtered_search_visual_tokens_spec.js @@ -1,5 +1,6 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import FilteredSearchSpecHelper from 'helpers/filtered_search_spec_helper'; import waitForPromises from 'helpers/wait_for_promises'; import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens'; @@ -24,7 +25,7 @@ describe('Filtered Search Visual Tokens', () => { mock = new MockAdapter(axios); mock.onGet().reply(200); - setFixtures(` + setHTMLFixture(` <ul class="tokens-container"> ${FilteredSearchSpecHelper.createInputHTML()} </ul> @@ -35,6 +36,10 @@ describe('Filtered Search Visual Tokens', () => { bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '=', '~bug'); }); + afterEach(() => { + resetHTMLFixture(); + }); + describe('getLastVisualTokenBeforeInput', () => { it('returns when there are no visual tokens', () => { const { lastVisualToken, isLastVisualTokenValid } = subject.getLastVisualTokenBeforeInput(); @@ -241,7 +246,7 @@ describe('Filtered Search Visual Tokens', () => { let tokenElement; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <div class="test-area"> ${subject.createVisualTokenElementHTML('custom-token')} </div> diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js index bf526a8d371..e52ffa7bd9f 100644 --- a/spec/frontend/filtered_search/visual_token_value_spec.js +++ b/spec/frontend/filtered_search/visual_token_value_spec.js @@ -1,5 +1,6 @@ import { escape } from 'lodash'; import labelData from 'test_fixtures/labels/project_labels.json'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import FilteredSearchSpecHelper from 'helpers/filtered_search_spec_helper'; import { TEST_HOST } from 'helpers/test_constants'; import DropdownUtils from '~/filtered_search/dropdown_utils'; @@ -28,7 +29,7 @@ describe('Filtered Search Visual Tokens', () => { let bugLabelToken; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <ul class="tokens-container"> ${FilteredSearchSpecHelper.createInputHTML()} </ul> @@ -39,6 +40,10 @@ describe('Filtered Search Visual Tokens', () => { bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '=', '~bug'); }); + afterEach(() => { + resetHTMLFixture(); + }); + describe('updateUserTokenAppearance', () => { let usersCacheSpy; diff --git a/spec/frontend/fixtures/api_merge_requests.rb b/spec/frontend/fixtures/api_merge_requests.rb index 47321fbbeaa..75bc8c8df25 100644 --- a/spec/frontend/fixtures/api_merge_requests.rb +++ b/spec/frontend/fixtures/api_merge_requests.rb @@ -9,11 +9,13 @@ RSpec.describe API::MergeRequests, '(JavaScript fixtures)', type: :request do let_it_be(:admin) { create(:admin, name: 'root') } let_it_be(:namespace) { create(:namespace, name: 'gitlab-test' )} let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'lorem-ipsum') } + let_it_be(:early_mrs) do + 4.times { |i| create(:merge_request, source_project: project, source_branch: "branch-#{i}") } + end + let_it_be(:mr) { create(:merge_request, source_project: project) } it 'api/merge_requests/get.json' do - 4.times { |i| create(:merge_request, source_project: project, source_branch: "branch-#{i}") } - get api("/projects/#{project.id}/merge_requests", admin) expect(response).to be_successful diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb index 25049ee4722..e17e73a93c4 100644 --- a/spec/frontend/fixtures/runner.rb +++ b/spec/frontend/fixtures/runner.rb @@ -67,7 +67,7 @@ RSpec.describe 'Runner (JavaScript fixtures)' do end describe GraphQL::Query, type: :request do - runner_query = 'details/runner.query.graphql' + runner_query = 'show/runner.query.graphql' let_it_be(:query) do get_graphql_query_as_string("#{query_path}#{runner_query}") @@ -91,7 +91,7 @@ RSpec.describe 'Runner (JavaScript fixtures)' do end describe GraphQL::Query, type: :request do - runner_projects_query = 'details/runner_projects.query.graphql' + runner_projects_query = 'show/runner_projects.query.graphql' let_it_be(:query) do get_graphql_query_as_string("#{query_path}#{runner_projects_query}") @@ -107,7 +107,23 @@ RSpec.describe 'Runner (JavaScript fixtures)' do end describe GraphQL::Query, type: :request do - runner_jobs_query = 'details/runner_jobs.query.graphql' + runner_jobs_query = 'show/runner_jobs.query.graphql' + + let_it_be(:query) do + get_graphql_query_as_string("#{query_path}#{runner_jobs_query}") + end + + it "#{fixtures_path}#{runner_jobs_query}.json" do + post_graphql(query, current_user: admin, variables: { + id: instance_runner.to_global_id.to_s + }) + + expect_graphql_errors_to_be_empty + end + end + + describe GraphQL::Query, type: :request do + runner_jobs_query = 'edit/runner_form.query.graphql' let_it_be(:query) do get_graphql_query_as_string("#{query_path}#{runner_jobs_query}") diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js index 942e2c330fa..6cd32ff6b40 100644 --- a/spec/frontend/flash_spec.js +++ b/spec/frontend/flash_spec.js @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/browser'; -import { setHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import createFlash, { hideFlash, addDismissFlashClickListener, @@ -93,7 +93,7 @@ describe('Flash', () => { if (alert) { alert.$destroy(); } - document.querySelector('.flash-container')?.remove(); + resetHTMLFixture(); }); it('adds alert element into the document by default', () => { @@ -330,7 +330,7 @@ describe('Flash', () => { }); afterEach(() => { - document.querySelector('.js-content-wrapper').remove(); + resetHTMLFixture(); }); it('adds flash alert element into the document by default', () => { diff --git a/spec/frontend/frequent_items/components/app_spec.js b/spec/frontend/frequent_items/components/app_spec.js index ba989bf53ab..32c66c0d288 100644 --- a/spec/frontend/frequent_items/components/app_spec.js +++ b/spec/frontend/frequent_items/components/app_spec.js @@ -6,7 +6,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import App from '~/frequent_items/components/app.vue'; import FrequentItemsList from '~/frequent_items/components/frequent_items_list.vue'; -import { FREQUENT_ITEMS, HOUR_IN_MS } from '~/frequent_items/constants'; +import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/frequent_items/constants'; import eventHub from '~/frequent_items/event_hub'; import { createStore } from '~/frequent_items/store'; import { getTopFrequentItems } from '~/frequent_items/utils'; @@ -200,15 +200,15 @@ describe('Frequent Items App Component', () => { ]); }); - it('should increase frequency, when created an hour later', () => { - const hourLater = Date.now() + HOUR_IN_MS + 1; + it('should increase frequency, when created 15 minutes later', () => { + const fifteenMinutesLater = Date.now() + FIFTEEN_MINUTES_IN_MS + 1; - jest.spyOn(Date, 'now').mockReturnValue(hourLater); - createComponent({ currentItem: { ...TEST_PROJECT, lastAccessedOn: hourLater } }); + jest.spyOn(Date, 'now').mockReturnValue(fifteenMinutesLater); + createComponent({ currentItem: { ...TEST_PROJECT, lastAccessedOn: fifteenMinutesLater } }); expect(getStoredProjects()).toEqual([ expect.objectContaining({ - lastAccessedOn: hourLater, + lastAccessedOn: fifteenMinutesLater, frequency: 2, }), ]); diff --git a/spec/frontend/frequent_items/utils_spec.js b/spec/frontend/frequent_items/utils_spec.js index 8c3841558f4..33c655a6ffd 100644 --- a/spec/frontend/frequent_items/utils_spec.js +++ b/spec/frontend/frequent_items/utils_spec.js @@ -1,5 +1,5 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import { HOUR_IN_MS, FREQUENT_ITEMS } from '~/frequent_items/constants'; +import { FIFTEEN_MINUTES_IN_MS, FREQUENT_ITEMS } from '~/frequent_items/constants'; import { isMobile, getTopFrequentItems, @@ -67,8 +67,8 @@ describe('Frequent Items utils spec', () => { describe('updateExistingFrequentItem', () => { const LAST_ACCESSED = 1497979281815; - const WITHIN_AN_HOUR = LAST_ACCESSED + HOUR_IN_MS; - const OVER_AN_HOUR = WITHIN_AN_HOUR + 1; + const WITHIN_FIFTEEN_MINUTES = LAST_ACCESSED + FIFTEEN_MINUTES_IN_MS; + const OVER_FIFTEEN_MINUTES = WITHIN_FIFTEEN_MINUTES + 1; const EXISTING_ITEM = Object.freeze({ ...mockProject, frequency: 1, @@ -76,10 +76,10 @@ describe('Frequent Items utils spec', () => { }); it.each` - desc | existingProps | newProps | expected - ${'updates item if accessed over an hour ago'} | ${{}} | ${{ lastAccessedOn: OVER_AN_HOUR }} | ${{ lastAccessedOn: Date.now(), frequency: 2 }} - ${'does not update is accessed with an hour'} | ${{}} | ${{ lastAccessedOn: WITHIN_AN_HOUR }} | ${{ lastAccessedOn: EXISTING_ITEM.lastAccessedOn, frequency: 1 }} - ${'updates if lastAccessedOn not found'} | ${{ lastAccessedOn: undefined }} | ${{ lastAccessedOn: WITHIN_AN_HOUR }} | ${{ lastAccessedOn: Date.now(), frequency: 2 }} + desc | existingProps | newProps | expected + ${'updates item if accessed over 15 minutes ago'} | ${{}} | ${{ lastAccessedOn: OVER_FIFTEEN_MINUTES }} | ${{ lastAccessedOn: Date.now(), frequency: 2 }} + ${'does not update is accessed with 15 minutes'} | ${{}} | ${{ lastAccessedOn: WITHIN_FIFTEEN_MINUTES }} | ${{ lastAccessedOn: EXISTING_ITEM.lastAccessedOn, frequency: 1 }} + ${'updates if lastAccessedOn not found'} | ${{ lastAccessedOn: undefined }} | ${{ lastAccessedOn: WITHIN_FIFTEEN_MINUTES }} | ${{ lastAccessedOn: Date.now(), frequency: 2 }} `('$desc', ({ existingProps, newProps, expected }) => { const newItem = { ...EXISTING_ITEM, diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 1ab3286fe4c..aa98b2774ea 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import labelsFixture from 'test_fixtures/autocomplete_sources/labels.json'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import GfmAutoComplete, { membersBeforeSave, highlighter } from 'ee_else_ce/gfm_auto_complete'; import { initEmojiMock, clearEmojiMock } from 'helpers/emoji'; import '~/lib/utils/jquery_at_who'; @@ -722,7 +723,7 @@ describe('GfmAutoComplete', () => { let $textarea; beforeEach(() => { - setFixtures('<textarea></textarea>'); + setHTMLFixture('<textarea></textarea>'); autocomplete = new GfmAutoComplete(dataSources); $textarea = $('textarea'); autocomplete.setup($textarea, { labels: true }); @@ -730,6 +731,7 @@ describe('GfmAutoComplete', () => { afterEach(() => { autocomplete.destroy(); + resetHTMLFixture(); }); const triggerDropdown = (text) => { diff --git a/spec/frontend/gl_field_errors_spec.js b/spec/frontend/gl_field_errors_spec.js index ada3b34e6b1..92d04927ee5 100644 --- a/spec/frontend/gl_field_errors_spec.js +++ b/spec/frontend/gl_field_errors_spec.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import GlFieldErrors from '~/gl_field_errors'; describe('GL Style Field Errors', () => { @@ -9,13 +10,17 @@ describe('GL Style Field Errors', () => { }); beforeEach(() => { - loadFixtures('static/gl_field_errors.html'); + loadHTMLFixture('static/gl_field_errors.html'); const $form = $('form.gl-show-field-errors'); testContext.$form = $form; testContext.fieldErrors = new GlFieldErrors($form); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('should select the correct input elements', () => { expect(testContext.$form).toBeDefined(); expect(testContext.$form.length).toBe(1); diff --git a/spec/frontend/google_tag_manager/index_spec.js b/spec/frontend/google_tag_manager/index_spec.js index de4a57a7319..6412fe8bb33 100644 --- a/spec/frontend/google_tag_manager/index_spec.js +++ b/spec/frontend/google_tag_manager/index_spec.js @@ -14,7 +14,7 @@ import { trackTransaction, trackAddToCartUsageTab, } from '~/google_tag_manager'; -import { setHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { logError } from '~/lib/logger'; jest.mock('~/lib/logger'); @@ -216,6 +216,10 @@ describe('~/google_tag_manager/index', () => { subject(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it.each(expectedEvents)('when %p', ({ selector, trigger, expectation }) => { expect(spy).not.toHaveBeenCalled(); @@ -443,6 +447,8 @@ describe('~/google_tag_manager/index', () => { expect(spy).not.toHaveBeenCalled(); expect(logError).not.toHaveBeenCalled(); + + resetHTMLFixture(); }); }); @@ -468,6 +474,8 @@ describe('~/google_tag_manager/index', () => { 'Unexpected error while pushing to dataLayer', pushError, ); + + resetHTMLFixture(); }); }); }); diff --git a/spec/frontend/gpg_badges_spec.js b/spec/frontend/gpg_badges_spec.js index 0bb50fc3e6f..0a1596b492d 100644 --- a/spec/frontend/gpg_badges_spec.js +++ b/spec/frontend/gpg_badges_spec.js @@ -1,4 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'spec/test_constants'; import GpgBadges from '~/gpg_badges'; import axios from '~/lib/utils/axios_utils'; @@ -18,7 +19,7 @@ describe('GpgBadges', () => { const dummyUrl = `${TEST_HOST}/dummy/signatures`; const setForm = ({ utf8 = '✓', search = '' } = {}) => { - setFixtures(` + setHTMLFixture(` <form class="commits-search-form js-signature-container" data-signatures-path="${dummyUrl}" action="${dummyUrl}" method="get"> @@ -38,24 +39,27 @@ describe('GpgBadges', () => { afterEach(() => { mock.restore(); + resetHTMLFixture(); }); it('does not make a request if there is no container element', async () => { - setFixtures(''); + setHTMLFixture(''); jest.spyOn(axios, 'get').mockImplementation(() => {}); await GpgBadges.fetch(); expect(axios.get).not.toHaveBeenCalled(); + resetHTMLFixture(); }); it('throws an error if the endpoint is missing', async () => { - setFixtures('<div class="js-signature-container"></div>'); + setHTMLFixture('<div class="js-signature-container"></div>'); jest.spyOn(axios, 'get').mockImplementation(() => {}); await expect(GpgBadges.fetch()).rejects.toEqual( new Error('Missing commit signatures endpoint!'), ); expect(axios.get).not.toHaveBeenCalled(); + resetHTMLFixture(); }); it('fetches commit signatures', async () => { diff --git a/spec/frontend/group_settings/components/shared_runners_form_spec.js b/spec/frontend/group_settings/components/shared_runners_form_spec.js index 26e9cd39cfd..70a22c86e62 100644 --- a/spec/frontend/group_settings/components/shared_runners_form_spec.js +++ b/spec/frontend/group_settings/components/shared_runners_form_spec.js @@ -1,47 +1,46 @@ -import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlAlert } from '@gitlab/ui'; import MockAxiosAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import SharedRunnersForm from '~/group_settings/components/shared_runners_form.vue'; import axios from '~/lib/utils/axios_utils'; -const provide = { - updatePath: '/test/update', - sharedRunnersAvailability: 'enabled', - parentSharedRunnersAvailability: null, - runnerDisabled: 'disabled', - runnerEnabled: 'enabled', - runnerAllowOverride: 'allow_override', -}; - -jest.mock('~/flash'); +const UPDATE_PATH = '/test/update'; +const RUNNER_ENABLED_VALUE = 'enabled'; +const RUNNER_DISABLED_VALUE = 'disabled_and_unoverridable'; +const RUNNER_ALLOW_OVERRIDE_VALUE = 'disabled_with_override'; describe('group_settings/components/shared_runners_form', () => { let wrapper; let mock; - const createComponent = (provides = {}) => { - wrapper = shallowMount(SharedRunnersForm, { + const createComponent = (provide = {}) => { + wrapper = shallowMountExtended(SharedRunnersForm, { provide: { + updatePath: UPDATE_PATH, + sharedRunnersSetting: RUNNER_ENABLED_VALUE, + parentSharedRunnersSetting: null, + runnerEnabledValue: RUNNER_ENABLED_VALUE, + runnerDisabledValue: RUNNER_DISABLED_VALUE, + runnerAllowOverrideValue: RUNNER_ALLOW_OVERRIDE_VALUE, ...provide, - ...provides, }, }); }; - const findLoadingIcon = () => wrapper.find(GlLoadingIcon); - const findErrorAlert = () => wrapper.find(GlAlert); - const findEnabledToggle = () => wrapper.find('[data-testid="enable-runners-toggle"]'); - const findOverrideToggle = () => wrapper.find('[data-testid="override-runners-toggle"]'); - const changeToggle = (toggle) => toggle.vm.$emit('change', !toggle.props('value')); + const findAlert = (variant) => + wrapper + .findAllComponents(GlAlert) + .filter((w) => w.props('variant') === variant) + .at(0); + const findSharedRunnersToggle = () => wrapper.findByTestId('shared-runners-toggle'); + const findOverrideToggle = () => wrapper.findByTestId('override-runners-toggle'); const getSharedRunnersSetting = () => JSON.parse(mock.history.put[0].data).shared_runners_setting; - const isLoadingIconVisible = () => findLoadingIcon().exists(); beforeEach(() => { mock = new MockAxiosAdapter(axios); - - mock.onPut(provide.updatePath).reply(200); + mock.onPut(UPDATE_PATH).reply(200); }); afterEach(() => { @@ -51,102 +50,122 @@ describe('group_settings/components/shared_runners_form', () => { mock.restore(); }); - describe('with default', () => { + describe('default state', () => { beforeEach(() => { createComponent(); }); - it('loading icon does not exist', () => { - expect(isLoadingIconVisible()).toBe(false); + it('"Enable shared runners" toggle is enabled', () => { + expect(findSharedRunnersToggle().props()).toMatchObject({ + isLoading: false, + disabled: false, + }); }); - it('enabled toggle exists', () => { - expect(findEnabledToggle().exists()).toBe(true); + it('"Override the group setting" is disabled', () => { + expect(findOverrideToggle().props()).toMatchObject({ + isLoading: false, + disabled: true, + }); }); + }); - it('override toggle does not exist', () => { - expect(findOverrideToggle().exists()).toBe(false); + describe('When group disabled shared runners', () => { + it(`toggles are not disabled with setting ${RUNNER_DISABLED_VALUE}`, () => { + createComponent({ sharedRunnersSetting: RUNNER_DISABLED_VALUE }); + + expect(findSharedRunnersToggle().props('disabled')).toBe(false); + expect(findOverrideToggle().props('disabled')).toBe(false); }); }); - describe('loading icon', () => { - it('shows and hides the loading icon on request', async () => { - createComponent(); + describe('When parent group disabled shared runners', () => { + it('toggles are disabled', () => { + createComponent({ + sharedRunnersSetting: RUNNER_DISABLED_VALUE, + parentSharedRunnersSetting: RUNNER_DISABLED_VALUE, + }); + + expect(findSharedRunnersToggle().props('disabled')).toBe(true); + expect(findOverrideToggle().props('disabled')).toBe(true); + expect(findAlert('warning').exists()).toBe(true); + }); + }); - expect(isLoadingIconVisible()).toBe(false); + describe('loading state', () => { + beforeEach(() => { + createComponent(); + }); - findEnabledToggle().vm.$emit('change', true); + it('is not loading by default', () => { + expect(findSharedRunnersToggle().props('isLoading')).toBe(false); + expect(findOverrideToggle().props('isLoading')).toBe(false); + }); + it('is loading immediately after request', async () => { + findSharedRunnersToggle().vm.$emit('change', true); await nextTick(); - expect(isLoadingIconVisible()).toBe(true); + expect(findSharedRunnersToggle().props('isLoading')).toBe(true); + expect(findOverrideToggle().props('isLoading')).toBe(true); + }); + + it('does not update settings while loading', async () => { + findSharedRunnersToggle().vm.$emit('change', true); + findSharedRunnersToggle().vm.$emit('change', false); + await waitForPromises(); + expect(mock.history.put.length).toBe(1); + }); + + it('is not loading state after completed request', async () => { + findSharedRunnersToggle().vm.$emit('change', true); await waitForPromises(); - expect(isLoadingIconVisible()).toBe(false); + expect(findSharedRunnersToggle().props('isLoading')).toBe(false); + expect(findOverrideToggle().props('isLoading')).toBe(false); }); }); - describe('enable toggle', () => { + describe('"Enable shared runners" toggle', () => { beforeEach(() => { createComponent(); }); - it('enabling the toggle sends correct payload', async () => { - findEnabledToggle().vm.$emit('change', true); - + it('sends correct payload when turned on', async () => { + findSharedRunnersToggle().vm.$emit('change', true); await waitForPromises(); - expect(getSharedRunnersSetting()).toEqual(provide.runnerEnabled); - expect(findOverrideToggle().exists()).toBe(false); + expect(getSharedRunnersSetting()).toEqual(RUNNER_ENABLED_VALUE); + expect(findOverrideToggle().props('disabled')).toBe(true); }); - it('disabling the toggle sends correct payload', async () => { - findEnabledToggle().vm.$emit('change', false); - + it('sends correct payload when turned off', async () => { + findSharedRunnersToggle().vm.$emit('change', false); await waitForPromises(); - expect(getSharedRunnersSetting()).toEqual(provide.runnerDisabled); - expect(findOverrideToggle().exists()).toBe(true); + expect(getSharedRunnersSetting()).toEqual(RUNNER_DISABLED_VALUE); + expect(findOverrideToggle().props('disabled')).toBe(false); }); }); - describe('override toggle', () => { + describe('"Override the group setting" toggle', () => { beforeEach(() => { - createComponent({ sharedRunnersAvailability: provide.runnerAllowOverride }); + createComponent({ sharedRunnersSetting: RUNNER_ALLOW_OVERRIDE_VALUE }); }); it('enabling the override toggle sends correct payload', async () => { findOverrideToggle().vm.$emit('change', true); - await waitForPromises(); - expect(getSharedRunnersSetting()).toEqual(provide.runnerAllowOverride); + expect(getSharedRunnersSetting()).toEqual(RUNNER_ALLOW_OVERRIDE_VALUE); }); it('disabling the override toggle sends correct payload', async () => { findOverrideToggle().vm.$emit('change', false); - await waitForPromises(); - expect(getSharedRunnersSetting()).toEqual(provide.runnerDisabled); - }); - }); - - describe('toggle disabled state', () => { - it(`toggles are not disabled with setting ${provide.runnerDisabled}`, () => { - createComponent({ sharedRunnersAvailability: provide.runnerDisabled }); - expect(findEnabledToggle().props('disabled')).toBe(false); - expect(findOverrideToggle().props('disabled')).toBe(false); - }); - - it('toggles are disabled', () => { - createComponent({ - sharedRunnersAvailability: provide.runnerDisabled, - parentSharedRunnersAvailability: provide.runnerDisabled, - }); - expect(findEnabledToggle().props('disabled')).toBe(true); - expect(findOverrideToggle().props('disabled')).toBe(true); + expect(getSharedRunnersSetting()).toEqual(RUNNER_DISABLED_VALUE); }); }); @@ -156,16 +175,16 @@ describe('group_settings/components/shared_runners_form', () => { ${{ error: 'Undefined error' }} | ${'Undefined error Refresh the page and try again.'} `(`with error $errorObj`, ({ errorObj, message }) => { beforeEach(async () => { - mock.onPut(provide.updatePath).reply(500, errorObj); + mock.onPut(UPDATE_PATH).reply(500, errorObj); createComponent(); - changeToggle(findEnabledToggle()); + findSharedRunnersToggle().vm.$emit('change', false); await waitForPromises(); }); it('error should be shown', () => { - expect(findErrorAlert().text()).toBe(message); + expect(findAlert('danger').text()).toBe(message); }); }); }); diff --git a/spec/frontend/groups/landing_spec.js b/spec/frontend/groups/landing_spec.js index d60adea202b..2c2c19ee0c7 100644 --- a/spec/frontend/groups/landing_spec.js +++ b/spec/frontend/groups/landing_spec.js @@ -1,4 +1,4 @@ -import Cookies from 'js-cookie'; +import Cookies from '~/lib/utils/cookies'; import Landing from '~/groups/landing'; describe('Landing', () => { diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js index 937bc9aa478..19849fba63c 100644 --- a/spec/frontend/header_spec.js +++ b/spec/frontend/header_spec.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import initTodoToggle, { initNavUserDropdownTracking } from '~/header'; +import { loadHTMLFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; describe('Header', () => { describe('Todos notification', () => { @@ -17,7 +18,11 @@ describe('Header', () => { beforeEach(() => { initTodoToggle(); - loadFixtures(fixtureTemplate); + loadHTMLFixture(fixtureTemplate); + }); + + afterEach(() => { + resetHTMLFixture(); }); it('should update todos-count after receiving the todo:toggle event', () => { @@ -57,7 +62,7 @@ describe('Header', () => { let trackingSpy; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <li class="js-nav-user-dropdown"> <a class="js-buy-pipeline-minutes-link" data-track-action="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy Pipeline minutes</a> </li>`); @@ -70,6 +75,7 @@ describe('Header', () => { afterEach(() => { unmockTracking(); + resetHTMLFixture(); }); it('sends a tracking event when the dropdown is opened and contains Buy Pipeline minutes link', () => { diff --git a/spec/frontend/helpers/startup_css_helper_spec.js b/spec/frontend/helpers/startup_css_helper_spec.js index 703bdbd342f..2236b5aa261 100644 --- a/spec/frontend/helpers/startup_css_helper_spec.js +++ b/spec/frontend/helpers/startup_css_helper_spec.js @@ -1,3 +1,4 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { waitForCSSLoaded } from '~/helpers/startup_css_helper'; describe('waitForCSSLoaded', () => { @@ -41,17 +42,19 @@ describe('waitForCSSLoaded', () => { describe('with startup css enabled', () => { it('should dispatch CSSLoaded when the assets are cached or already loaded', async () => { - setFixtures(` + setHTMLFixture(` <link href="one.css" data-startupcss="loaded"> <link href="two.css" data-startupcss="loaded"> `); await waitForCSSLoaded(mockedCallback); expect(mockedCallback).toHaveBeenCalledTimes(1); + + resetHTMLFixture(); }); it('should wait to call CssLoaded until the assets are loaded', async () => { - setFixtures(` + setHTMLFixture(` <link href="one.css" data-startupcss="loading"> <link href="two.css" data-startupcss="loading"> `); @@ -63,6 +66,8 @@ describe('waitForCSSLoaded', () => { await events; expect(mockedCallback).toHaveBeenCalledTimes(1); + + resetHTMLFixture(); }); }); }); 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 e66de6bb0b0..ace266aec5e 100644 --- a/spec/frontend/ide/components/commit_sidebar/message_field_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/message_field_spec.js @@ -1,4 +1,5 @@ import Vue, { nextTick } from 'vue'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import createComponent from 'helpers/vue_mount_component_helper'; import CommitMessageField from '~/ide/components/commit_sidebar/message_field.vue'; @@ -7,7 +8,7 @@ describe('IDE commit message field', () => { let vm; beforeEach(() => { - setFixtures('<div id="app"></div>'); + setHTMLFixture('<div id="app"></div>'); vm = createComponent( Component, @@ -21,6 +22,8 @@ describe('IDE commit message field', () => { afterEach(() => { vm.$destroy(); + + resetHTMLFixture(); }); it('adds is-focused class on focus', async () => { diff --git a/spec/frontend/ide/components/new_dropdown/upload_spec.js b/spec/frontend/ide/components/new_dropdown/upload_spec.js index 5a7419d6dce..3eafe9e7ccb 100644 --- a/spec/frontend/ide/components/new_dropdown/upload_spec.js +++ b/spec/frontend/ide/components/new_dropdown/upload_spec.js @@ -70,7 +70,9 @@ 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)); + const waitForCreate = new Promise((resolve) => { + vm.$on('create', resolve); + }); vm.createFile(textTarget, textFile); diff --git a/spec/frontend/image_diff/image_diff_spec.js b/spec/frontend/image_diff/image_diff_spec.js index 710aa7108a8..f8faa8d78c2 100644 --- a/spec/frontend/image_diff/image_diff_spec.js +++ b/spec/frontend/image_diff/image_diff_spec.js @@ -1,3 +1,4 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import imageDiffHelper from '~/image_diff/helpers/index'; import ImageDiff from '~/image_diff/image_diff'; @@ -9,7 +10,7 @@ describe('ImageDiff', () => { let imageDiff; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <div id="element"> <div class="diff-file"> <div class="js-image-frame"> @@ -35,6 +36,10 @@ describe('ImageDiff', () => { element = document.getElementById('element'); }); + afterEach(() => { + resetHTMLFixture(); + }); + describe('constructor', () => { beforeEach(() => { imageDiff = new ImageDiff(element, { diff --git a/spec/frontend/image_diff/init_discussion_tab_spec.js b/spec/frontend/image_diff/init_discussion_tab_spec.js index f6f05037c95..3b427f0d54d 100644 --- a/spec/frontend/image_diff/init_discussion_tab_spec.js +++ b/spec/frontend/image_diff/init_discussion_tab_spec.js @@ -1,9 +1,10 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import initImageDiffHelper from '~/image_diff/helpers/init_image_diff'; import initDiscussionTab from '~/image_diff/init_discussion_tab'; describe('initDiscussionTab', () => { beforeEach(() => { - setFixtures(` + setHTMLFixture(` <div class="timeline-content"> <div class="diff-file js-image-file"></div> <div class="diff-file js-image-file"></div> @@ -11,6 +12,10 @@ describe('initDiscussionTab', () => { `); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('should pass canCreateNote as false to initImageDiff', () => { jest .spyOn(initImageDiffHelper, 'initImageDiff') diff --git a/spec/frontend/image_diff/replaced_image_diff_spec.js b/spec/frontend/image_diff/replaced_image_diff_spec.js index 2b401fc46bf..d789e964e4c 100644 --- a/spec/frontend/image_diff/replaced_image_diff_spec.js +++ b/spec/frontend/image_diff/replaced_image_diff_spec.js @@ -1,3 +1,4 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import imageDiffHelper from '~/image_diff/helpers/index'; import ImageDiff from '~/image_diff/image_diff'; @@ -9,7 +10,7 @@ describe('ReplacedImageDiff', () => { let replacedImageDiff; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <div id="element"> <div class="two-up"> <div class="js-image-frame"> @@ -36,6 +37,10 @@ describe('ReplacedImageDiff', () => { element = document.getElementById('element'); }); + afterEach(() => { + resetHTMLFixture(); + }); + function setupImageFrameEls() { replacedImageDiff.imageFrameEls = []; replacedImageDiff.imageFrameEls[viewTypes.TWO_UP] = element.querySelector( 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 b17ff2e0f52..1939e43e5dc 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 @@ -9,7 +9,7 @@ import createFlash from '~/flash'; import httpStatus from '~/lib/utils/http_status'; import axios from '~/lib/utils/axios_utils'; import { STATUSES } from '~/import_entities/constants'; -import { i18n } from '~/import_entities/import_groups/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 PaginationLinks from '~/vue_shared/components/pagination_links.vue'; @@ -45,6 +45,8 @@ describe('import table', () => { const findImportButtons = () => wrapper.findAll('button').wrappers.filter((w) => w.text() === 'Import'); const findPaginationDropdown = () => wrapper.find('[data-testid="page-size"]'); + const findTargetNamespaceDropdown = (rowWrapper) => + rowWrapper.find('[data-testid="target-namespace-selector"]'); const findPaginationDropdownText = () => findPaginationDropdown().find('button').text(); const findSelectionCount = () => wrapper.find('[data-test-id="selection-count"]'); @@ -70,6 +72,7 @@ describe('import table', () => { groupPathRegex: /.*/, jobsPath: '/fake_job_path', sourceUrl: SOURCE_URL, + historyPath: '/fake_history_path', }, apolloProvider, }); @@ -136,6 +139,32 @@ describe('import table', () => { expect(wrapper.findAll('tbody tr')).toHaveLength(FAKE_GROUPS.length); }); + it('correctly maintains root namespace as last import target', async () => { + createComponent({ + bulkImportSourceGroups: () => ({ + nodes: [ + { + ...generateFakeEntry({ id: 1, status: STATUSES.FINISHED }), + lastImportTarget: { + id: 1, + targetNamespace: ROOT_NAMESPACE.fullPath, + newName: 'does-not-matter', + }, + }, + ], + pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, + }), + }); + + await waitForPromises(); + const firstRow = wrapper.find('tbody tr'); + const targetNamespaceDropdownButton = findTargetNamespaceDropdown(firstRow).find( + '[aria-haspopup]', + ); + expect(targetNamespaceDropdownButton.text()).toBe('No parent'); + }); + it('does not render status string when result list is empty', async () => { createComponent({ bulkImportSourceGroups: jest.fn().mockResolvedValue({ diff --git a/spec/frontend/import_entities/import_groups/utils_spec.js b/spec/frontend/import_entities/import_groups/utils_spec.js new file mode 100644 index 00000000000..2892c5c217b --- /dev/null +++ b/spec/frontend/import_entities/import_groups/utils_spec.js @@ -0,0 +1,56 @@ +import { STATUSES } from '~/import_entities/constants'; +import { isFinished, isAvailableForImport } from '~/import_entities/import_groups/utils'; + +const FINISHED_STATUSES = [STATUSES.FINISHED, STATUSES.FAILED, STATUSES.TIMEOUT]; +const OTHER_STATUSES = Object.values(STATUSES).filter( + (status) => !FINISHED_STATUSES.includes(status), +); +describe('gitlab migration status utils', () => { + describe('isFinished', () => { + it.each(FINISHED_STATUSES.map((s) => [s]))( + 'reports group as finished when import status is %s', + (status) => { + expect(isFinished({ progress: { status } })).toBe(true); + }, + ); + + it.each(OTHER_STATUSES.map((s) => [s]))( + 'does not report group as finished when import status is %s', + (status) => { + expect(isFinished({ progress: { status } })).toBe(false); + }, + ); + + it('does not report group as finished when there is no progress', () => { + expect(isFinished({ progress: null })).toBe(false); + }); + + it('does not report group as finished when status is unknown', () => { + expect(isFinished({ progress: { status: 'weird' } })).toBe(false); + }); + }); + + describe('isAvailableForImport', () => { + it.each(FINISHED_STATUSES.map((s) => [s]))( + 'reports group as available for import when status is %s', + (status) => { + expect(isAvailableForImport({ progress: { status } })).toBe(true); + }, + ); + + it.each(OTHER_STATUSES.map((s) => [s]))( + 'does not report group as not available for import when status is %s', + (status) => { + expect(isAvailableForImport({ progress: { status } })).toBe(false); + }, + ); + + it('reports group as available for import when there is no progress', () => { + expect(isAvailableForImport({ progress: null })).toBe(true); + }); + + it('reports group as finished when status is unknown', () => { + expect(isFinished({ progress: { status: 'weird' } })).toBe(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 88fcedd31b2..140fec3863b 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 @@ -1,4 +1,4 @@ -import { GlLoadingIcon, GlButton, GlIntersectionObserver, GlFormInput } from '@gitlab/ui'; +import { GlLoadingIcon, GlButton, GlIntersectionObserver, GlSearchBoxByClick } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; @@ -15,7 +15,7 @@ describe('ImportProjectsTable', () => { const findFilterField = () => wrapper - .findAllComponents(GlFormInput) + .findAllComponents(GlSearchBoxByClick) .wrappers.find((w) => w.attributes('placeholder') === 'Filter by name'); const providerTitle = 'THE PROVIDER'; diff --git a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap index feee14c9c40..7e24aa439d4 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap +++ b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap @@ -57,6 +57,7 @@ exports[`Alert integration settings form should match the default snapshot 1`] = </gl-button-stub> <gl-modal-stub + arialabel="" dismisslabel="Close" modalclass="" modalid="resetWebhookModal" diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index ca481e009cf..a2bdece821f 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -1,4 +1,4 @@ -import { GlForm } from '@gitlab/ui'; +import { GlBadge, GlForm } from '@gitlab/ui'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import * as Sentry from '@sentry/browser'; @@ -18,11 +18,18 @@ import { integrationLevels, I18N_SUCCESSFUL_CONNECTION_MESSAGE, I18N_DEFAULT_ERROR_MESSAGE, + billingPlans, + billingPlanNames, } from '~/integrations/constants'; import { createStore } from '~/integrations/edit/store'; import httpStatus from '~/lib/utils/http_status'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; -import { mockIntegrationProps, mockField, mockSectionConnection } from '../mock_data'; +import { + mockIntegrationProps, + mockField, + mockSectionConnection, + mockSectionJiraIssues, +} from '../mock_data'; jest.mock('@sentry/browser'); jest.mock('~/lib/utils/url_utility'); @@ -72,6 +79,7 @@ describe('IntegrationForm', () => { const findInstanceOrGroupSaveButton = () => wrapper.findByTestId('save-button-instance-group'); const findTestButton = () => wrapper.findByTestId('test-button'); const findTriggerFields = () => wrapper.findComponent(TriggerFields); + const findGlBadge = () => wrapper.findComponent(GlBadge); const findGlForm = () => wrapper.findComponent(GlForm); const findRedirectToField = () => wrapper.findByTestId('redirect-to-field'); const findDynamicField = () => wrapper.findComponent(DynamicField); @@ -327,9 +335,21 @@ describe('IntegrationForm', () => { expect(connectionSection.find('h4').text()).toBe(mockSectionConnection.title); expect(connectionSection.find('p').text()).toBe(mockSectionConnection.description); + expect(findGlBadge().exists()).toBe(false); expect(findConnectionSectionComponent().exists()).toBe(true); }); + it('renders GlBadge when `plan` is present', () => { + createComponent({ + customStateProps: { + sections: [mockSectionConnection, mockSectionJiraIssues], + }, + }); + + expect(findGlBadge().exists()).toBe(true); + expect(findGlBadge().text()).toMatchInterpolatedText(billingPlanNames[billingPlans.PREMIUM]); + }); + it('passes only fields with section type', () => { const sectionFields = [ { name: 'username', type: 'text', section: mockSectionConnection.type }, diff --git a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js index 94e370a485f..b4c5d4f9957 100644 --- a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js @@ -10,7 +10,6 @@ describe('JiraIssuesFields', () => { let wrapper; const defaultProps = { - showJiraIssuesIntegration: true, showJiraVulnerabilitiesIntegration: true, upgradePlanPath: 'https://gitlab.com', }; @@ -42,8 +41,6 @@ describe('JiraIssuesFields', () => { findEnableCheckbox().find('[type=checkbox]').attributes('disabled'); const findProjectKey = () => wrapper.findComponent(GlFormInput); const findProjectKeyFormGroup = () => wrapper.findByTestId('project-key-form-group'); - const findPremiumUpgradeCTA = () => wrapper.findByTestId('premium-upgrade-cta'); - const findUltimateUpgradeCTA = () => wrapper.findByTestId('ultimate-upgrade-cta'); const findJiraForVulnerabilities = () => wrapper.findByTestId('jira-for-vulnerabilities'); const setEnableCheckbox = async (isEnabled = true) => findEnableCheckbox().vm.$emit('input', isEnabled); @@ -55,19 +52,16 @@ describe('JiraIssuesFields', () => { describe('template', () => { describe.each` - showJiraIssuesIntegration | showJiraVulnerabilitiesIntegration - ${false} | ${false} - ${false} | ${true} - ${true} | ${false} - ${true} | ${true} + showJiraIssuesIntegration + ${false} + ${true} `( - 'when `showJiraIssuesIntegration` is $jiraIssues and `showJiraVulnerabilitiesIntegration` is $jiraVulnerabilities', - ({ showJiraIssuesIntegration, showJiraVulnerabilitiesIntegration }) => { + 'when showJiraIssuesIntegration = $showJiraIssuesIntegration', + ({ showJiraIssuesIntegration }) => { beforeEach(() => { createComponent({ props: { showJiraIssuesIntegration, - showJiraVulnerabilitiesIntegration, }, }); }); @@ -77,39 +71,12 @@ describe('JiraIssuesFields', () => { expect(findEnableCheckbox().exists()).toBe(true); expect(findEnableCheckboxDisabled()).toBeUndefined(); }); - - it('does not render the Premium CTA', () => { - expect(findPremiumUpgradeCTA().exists()).toBe(false); - }); - - if (!showJiraVulnerabilitiesIntegration) { - it.each` - scenario | enableJiraIssues - ${'when "Enable Jira issues" is checked, renders Ultimate upgrade CTA'} | ${true} - ${'when "Enable Jira issues" is unchecked, does not render Ultimate upgrade CTA'} | ${false} - `('$scenario', async ({ enableJiraIssues }) => { - if (enableJiraIssues) { - await setEnableCheckbox(); - } - expect(findUltimateUpgradeCTA().exists()).toBe(enableJiraIssues); - }); - } } else { - it('does not render enable checkbox', () => { - expect(findEnableCheckbox().exists()).toBe(false); - }); - - it('renders the Premium CTA', () => { - const premiumUpgradeCTA = findPremiumUpgradeCTA(); - - expect(premiumUpgradeCTA.exists()).toBe(true); - expect(premiumUpgradeCTA.props('upgradePlanPath')).toBe(defaultProps.upgradePlanPath); + it('renders enable checkbox as disabled', () => { + expect(findEnableCheckbox().exists()).toBe(true); + expect(findEnableCheckboxDisabled()).toBe('disabled'); }); } - - it('does not render the Ultimate CTA', () => { - expect(findUltimateUpgradeCTA().exists()).toBe(false); - }); }, ); diff --git a/spec/frontend/integrations/edit/mock_data.js b/spec/frontend/integrations/edit/mock_data.js index 36850a0a33a..ac0c7d244e3 100644 --- a/spec/frontend/integrations/edit/mock_data.js +++ b/spec/frontend/integrations/edit/mock_data.js @@ -37,3 +37,11 @@ export const mockSectionConnection = { title: 'Connection details', description: 'Learn more on how to configure this integration.', }; + +export const mockSectionJiraIssues = { + type: 'jira_issues', + title: 'Issues', + description: + 'Work on Jira issues without leaving GitLab. Add a Jira menu to access a read-only list of your Jira issues. Learn more.', + plan: 'premium', +}; diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js index 84317da39e6..13985ce7d74 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -1,4 +1,4 @@ -import { GlLink, GlModal, GlSprintf } from '@gitlab/ui'; +import { GlLink, GlModal, GlSprintf, GlFormGroup } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import { stubComponent } from 'helpers/stub_component'; @@ -15,6 +15,7 @@ import { MEMBERS_MODAL_CELEBRATE_INTRO, MEMBERS_MODAL_CELEBRATE_TITLE, MEMBERS_PLACEHOLDER, + MEMBERS_PLACEHOLDER_DISABLED, MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT, LEARN_GITLAB, } from '~/invite_members/constants'; @@ -28,6 +29,8 @@ import { propsData, inviteSource, newProjectPath, + freeUsersLimit, + membersCount, user1, user2, user3, @@ -45,12 +48,13 @@ describe('InviteMembersModal', () => { let wrapper; let mock; - const createComponent = (props = {}) => { + const createComponent = (props = {}, stubs = {}) => { wrapper = shallowMountExtended(InviteMembersModal, { provide: { newProjectPath, }, propsData: { + usersLimitDataset: {}, ...propsData, ...props, }, @@ -62,16 +66,17 @@ describe('InviteMembersModal', () => { template: '<div><slot></slot><slot name="modal-footer"></slot></div>', }), GlEmoji, + ...stubs, }, }); }; - const createInviteMembersToProjectWrapper = () => { - createComponent({ isProject: true }); + const createInviteMembersToProjectWrapper = (usersLimitDataset = {}, stubs = {}) => { + createComponent({ usersLimitDataset, isProject: true }, stubs); }; - const createInviteMembersToGroupWrapper = () => { - createComponent({ isProject: false }); + const createInviteMembersToGroupWrapper = (usersLimitDataset = {}, stubs = {}) => { + createComponent({ usersLimitDataset, isProject: false }, stubs); }; beforeEach(() => { @@ -95,7 +100,7 @@ describe('InviteMembersModal', () => { const findMembersFormGroup = () => wrapper.findByTestId('members-form-group'); const membersFormGroupInvalidFeedback = () => findMembersFormGroup().attributes('invalid-feedback'); - const membersFormGroupDescription = () => findMembersFormGroup().attributes('description'); + const membersFormGroupText = () => findMembersFormGroup().text(); const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect); const findTasksToBeDone = () => wrapper.findByTestId('invite-members-modal-tasks-to-be-done'); const findTasks = () => wrapper.findByTestId('invite-members-modal-tasks'); @@ -259,16 +264,33 @@ describe('InviteMembersModal', () => { expect(wrapper.findComponent(ModalConfetti).exists()).toBe(false); }); - it('includes the correct invitee, type, and formatted name', () => { + it('includes the correct invitee', () => { expect(findIntroText()).toBe("You're inviting members to the test name project."); expect(findCelebrationEmoji().exists()).toBe(false); - expect(membersFormGroupDescription()).toBe(MEMBERS_PLACEHOLDER); + }); + + describe('members form group description', () => { + it('renders correct description', () => { + createInviteMembersToProjectWrapper({ freeUsersLimit, membersCount }, { GlFormGroup }); + expect(membersFormGroupText()).toContain(MEMBERS_PLACEHOLDER); + }); + + describe('when reached user limit', () => { + it('renders correct description', () => { + createInviteMembersToProjectWrapper( + { freeUsersLimit, membersCount: 5 }, + { GlFormGroup }, + ); + + expect(membersFormGroupText()).toContain(MEMBERS_PLACEHOLDER_DISABLED); + }); + }); }); }); describe('when inviting members with celebration', () => { beforeEach(async () => { - createComponent({ isProject: true }); + createInviteMembersToProjectWrapper(); await triggerOpenModal({ mode: 'celebrate' }); }); @@ -285,7 +307,28 @@ describe('InviteMembersModal', () => { `${MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT} ${MEMBERS_MODAL_CELEBRATE_INTRO}`, ); expect(findCelebrationEmoji().exists()).toBe(true); - expect(membersFormGroupDescription()).toBe(MEMBERS_PLACEHOLDER); + }); + + describe('members form group description', () => { + it('renders correct description', async () => { + createInviteMembersToProjectWrapper({ freeUsersLimit, membersCount }, { GlFormGroup }); + await triggerOpenModal({ mode: 'celebrate' }); + + expect(membersFormGroupText()).toContain(MEMBERS_PLACEHOLDER); + }); + + describe('when reached user limit', () => { + it('renders correct description', async () => { + createInviteMembersToProjectWrapper( + { freeUsersLimit, membersCount: 5 }, + { GlFormGroup }, + ); + + await triggerOpenModal({ mode: 'celebrate' }); + + expect(membersFormGroupText()).toContain(MEMBERS_PLACEHOLDER_DISABLED); + }); + }); }); }); }); @@ -295,7 +338,20 @@ describe('InviteMembersModal', () => { createInviteMembersToGroupWrapper(); expect(findIntroText()).toBe("You're inviting members to the test name group."); - expect(membersFormGroupDescription()).toBe(MEMBERS_PLACEHOLDER); + }); + + describe('members form group description', () => { + it('renders correct description', () => { + createInviteMembersToGroupWrapper({ freeUsersLimit, membersCount }, { GlFormGroup }); + expect(membersFormGroupText()).toContain(MEMBERS_PLACEHOLDER); + }); + + describe('when reached user limit', () => { + it('renders correct description', () => { + createInviteMembersToGroupWrapper({ freeUsersLimit, membersCount: 5 }, { GlFormGroup }); + expect(membersFormGroupText()).toContain(MEMBERS_PLACEHOLDER_DISABLED); + }); + }); }); }); }); diff --git a/spec/frontend/invite_members/components/invite_modal_base_spec.js b/spec/frontend/invite_members/components/invite_modal_base_spec.js index 8355ae67f20..010f7b999fc 100644 --- a/spec/frontend/invite_members/components/invite_modal_base_spec.js +++ b/spec/frontend/invite_members/components/invite_modal_base_spec.js @@ -6,18 +6,30 @@ import { GlSprintf, GlLink, GlModal, + GlIcon, } from '@gitlab/ui'; import { stubComponent } from 'helpers/stub_component'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import InviteModalBase from '~/invite_members/components/invite_modal_base.vue'; import ContentTransition from '~/vue_shared/components/content_transition.vue'; -import { CANCEL_BUTTON_TEXT, INVITE_BUTTON_TEXT } from '~/invite_members/constants'; -import { propsData } from '../mock_data/modal_base'; + +import { + CANCEL_BUTTON_TEXT, + INVITE_BUTTON_TEXT_DISABLED, + INVITE_BUTTON_TEXT, + CANCEL_BUTTON_TEXT_DISABLED, + ON_SHOW_TRACK_LABEL, + ON_CLOSE_TRACK_LABEL, + ON_SUBMIT_TRACK_LABEL, +} from '~/invite_members/constants'; + +import { propsData, membersPath, purchasePath } from '../mock_data/modal_base'; describe('InviteModalBase', () => { let wrapper; - const createComponent = (props = {}) => { + const createComponent = (props = {}, stubs = {}) => { wrapper = shallowMountExtended(InviteModalBase, { propsData: { ...propsData, @@ -33,8 +45,9 @@ describe('InviteModalBase', () => { GlDropdownItem: true, GlSprintf, GlFormGroup: stubComponent(GlFormGroup, { - props: ['state', 'invalidFeedback', 'description'], + props: ['state', 'invalidFeedback'], }), + ...stubs, }, }); }; @@ -48,8 +61,12 @@ describe('InviteModalBase', () => { const findDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem); const findDatepicker = () => wrapper.findComponent(GlDatepicker); const findLink = () => wrapper.findComponent(GlLink); + const findIcon = () => wrapper.findComponent(GlIcon); const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text(); const findMembersFormGroup = () => wrapper.findByTestId('members-form-group'); + const findDisabledInput = () => wrapper.findByTestId('disabled-input'); + const findCancelButton = () => wrapper.find('.js-modal-action-cancel'); + const findActionButton = () => wrapper.find('.js-modal-action-primary'); describe('rendering the modal', () => { beforeEach(() => { @@ -106,11 +123,103 @@ describe('InviteModalBase', () => { it('renders the members form group', () => { expect(findMembersFormGroup().props()).toEqual({ - description: propsData.formGroupDescription, invalidFeedback: '', state: null, }); }); + + it('renders description', () => { + createComponent({}, { GlFormGroup }); + + expect(findMembersFormGroup().text()).toContain(propsData.formGroupDescription); + }); + + describe('when users limit is reached', () => { + let trackingSpy; + + const expectTracking = (action, label) => + expect(trackingSpy).toHaveBeenCalledWith('default', action, { + label, + category: 'default', + }); + + beforeEach(() => { + createComponent( + { usersLimitDataset: { membersPath, purchasePath }, reachedLimit: true }, + { GlModal, GlFormGroup }, + ); + }); + + it('renders correct blocks', () => { + expect(findIcon().exists()).toBe(true); + expect(findDisabledInput().exists()).toBe(true); + expect(findDropdown().exists()).toBe(false); + expect(findDatepicker().exists()).toBe(false); + }); + + it('renders correct buttons', () => { + const cancelButton = findCancelButton(); + const actionButton = findActionButton(); + + expect(cancelButton.attributes('href')).toBe(purchasePath); + expect(cancelButton.text()).toBe(CANCEL_BUTTON_TEXT_DISABLED); + expect(actionButton.attributes('href')).toBe(membersPath); + expect(actionButton.text()).toBe(INVITE_BUTTON_TEXT_DISABLED); + }); + + it('tracks actions', () => { + createComponent({ reachedLimit: true }, { GlFormGroup, GlModal }); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + const modal = wrapper.findComponent(GlModal); + + modal.vm.$emit('shown'); + expectTracking('render', ON_SHOW_TRACK_LABEL); + + modal.vm.$emit('cancel', { preventDefault: jest.fn() }); + expectTracking('click_button', ON_CLOSE_TRACK_LABEL); + + modal.vm.$emit('primary', { preventDefault: jest.fn() }); + expectTracking('click_button', ON_SUBMIT_TRACK_LABEL); + + unmockTracking(); + }); + + describe('when free user namespace', () => { + it('hides cancel button', () => { + createComponent( + { + usersLimitDataset: { membersPath, purchasePath, userNamespace: true }, + reachedLimit: true, + }, + { GlModal, GlFormGroup }, + ); + + expect(findCancelButton().exists()).toBe(false); + }); + }); + }); + + describe('when users limit is not reached', () => { + const textRegex = /Select a role.+Read more about role permissions Access expiration date \(optional\)/; + + beforeEach(() => { + createComponent({ reachedLimit: false }, { GlModal, GlFormGroup }); + }); + + it('renders correct blocks', () => { + expect(findIcon().exists()).toBe(false); + expect(findDisabledInput().exists()).toBe(false); + expect(findDropdown().exists()).toBe(true); + expect(findDatepicker().exists()).toBe(true); + expect(wrapper.findComponent(GlModal).text()).toMatch(textRegex); + }); + + it('renders correct buttons', () => { + expect(findCancelButton().text()).toBe(CANCEL_BUTTON_TEXT); + expect(findActionButton().text()).toBe(INVITE_BUTTON_TEXT); + }); + }); }); it('with isLoading, shows loading for invite button', () => { @@ -127,7 +236,6 @@ describe('InviteModalBase', () => { }); expect(findMembersFormGroup().props()).toEqual({ - description: propsData.formGroupDescription, invalidFeedback: 'invalid message!', state: false, }); diff --git a/spec/frontend/invite_members/components/user_limit_notification_spec.js b/spec/frontend/invite_members/components/user_limit_notification_spec.js index c779cf2ee3f..4c9adbfcc44 100644 --- a/spec/frontend/invite_members/components/user_limit_notification_spec.js +++ b/spec/frontend/invite_members/components/user_limit_notification_spec.js @@ -2,21 +2,31 @@ import { GlAlert, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import UserLimitNotification from '~/invite_members/components/user_limit_notification.vue'; +import { + REACHED_LIMIT_MESSAGE, + REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE, +} from '~/invite_members/constants'; + +import { freeUsersLimit, membersCount } from '../mock_data/member_modal'; + describe('UserLimitNotification', () => { let wrapper; const findAlert = () => wrapper.findComponent(GlAlert); - const createComponent = (providers = {}) => { + const createComponent = (reachedLimit = false, usersLimitDataset = {}) => { wrapper = shallowMountExtended(UserLimitNotification, { - provide: { - name: 'my group', - newTrialRegistrationPath: 'newTrialRegistrationPath', - purchasePath: 'purchasePath', - freeUsersLimit: 5, - membersCount: 1, - ...providers, + propsData: { + reachedLimit, + usersLimitDataset: { + freeUsersLimit, + membersCount, + newTrialRegistrationPath: 'newTrialRegistrationPath', + purchasePath: 'purchasePath', + ...usersLimitDataset, + }, }, + provide: { name: 'my group' }, stubs: { GlSprintf }, }); }; @@ -26,21 +36,17 @@ describe('UserLimitNotification', () => { }); describe('when limit is not reached', () => { - beforeEach(() => { + it('renders empty block', () => { createComponent(); - }); - it('renders empty block', () => { expect(findAlert().exists()).toBe(false); }); }); describe('when close to limit', () => { - beforeEach(() => { - createComponent({ membersCount: 3 }); - }); - it("renders user's limit notification", () => { + createComponent(false, { membersCount: 3 }); + const alert = findAlert(); expect(alert.attributes('title')).toEqual( @@ -54,18 +60,27 @@ describe('UserLimitNotification', () => { }); describe('when limit is reached', () => { - beforeEach(() => { - createComponent({ membersCount: 5 }); - }); - it("renders user's limit notification", () => { + createComponent(true); + const alert = findAlert(); expect(alert.attributes('title')).toEqual("You've reached your 5 members limit for my group"); + expect(alert.text()).toEqual(REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE); + }); - expect(alert.text()).toEqual( - 'New members will be unable to participate. You can manage your members by removing ones you no longer need. To get more members an owner of this namespace can start a trial or upgrade to a paid tier.', - ); + describe('when free user namespace', () => { + it("renders user's limit notification", () => { + createComponent(true, { userNamespace: true }); + + const alert = findAlert(); + + expect(alert.attributes('title')).toEqual( + "You've reached your 5 members limit for my group", + ); + + expect(alert.text()).toEqual(REACHED_LIMIT_MESSAGE); + }); }); }); }); diff --git a/spec/frontend/invite_members/mock_data/member_modal.js b/spec/frontend/invite_members/mock_data/member_modal.js index 1b0cc57fb5b..474234cfacb 100644 --- a/spec/frontend/invite_members/mock_data/member_modal.js +++ b/spec/frontend/invite_members/mock_data/member_modal.js @@ -18,6 +18,8 @@ export const propsData = { export const inviteSource = 'unknown'; export const newProjectPath = 'projects/new'; +export const freeUsersLimit = 5; +export const membersCount = 1; export const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' }; export const user2 = { id: 2, name: 'Name Two', username: 'one_2', avatar_url: '' }; diff --git a/spec/frontend/invite_members/mock_data/modal_base.js b/spec/frontend/invite_members/mock_data/modal_base.js index ea5a8d2b00d..565e8d4df1e 100644 --- a/spec/frontend/invite_members/mock_data/modal_base.js +++ b/spec/frontend/invite_members/mock_data/modal_base.js @@ -9,3 +9,6 @@ export const propsData = { labelSearchField: '_label_search_field_', formGroupDescription: '_form_group_description_', }; + +export const membersPath = '/members_path'; +export const purchasePath = '/purchase_path'; diff --git a/spec/frontend/issuable/components/issuable_header_warnings_spec.js b/spec/frontend/issuable/components/issuable_header_warnings_spec.js index c8380e42787..e3a36dc8820 100644 --- a/spec/frontend/issuable/components/issuable_header_warnings_spec.js +++ b/spec/frontend/issuable/components/issuable_header_warnings_spec.js @@ -66,7 +66,15 @@ describe('IssuableHeaderWarnings', () => { }); it(`${renderTestMessage(confidentialStatus)} the confidential icon`, () => { - expect(findConfidentialIcon().exists()).toBe(confidentialStatus); + const confidentialEl = findConfidentialIcon(); + expect(confidentialEl.exists()).toBe(confidentialStatus); + + if (confidentialStatus && !hiddenStatus) { + expect(confidentialEl.props()).toMatchObject({ + workspaceType: 'project', + issuableType: 'issue', + }); + } }); it(`${renderTestMessage(confidentialStatus)} the hidden icon`, () => { diff --git a/spec/frontend/issuable/components/status_box_spec.js b/spec/frontend/issuable/components/status_box_spec.js index 9cbf023dbd6..728b8958b9b 100644 --- a/spec/frontend/issuable/components/status_box_spec.js +++ b/spec/frontend/issuable/components/status_box_spec.js @@ -1,71 +1,53 @@ -import { GlIcon, GlSprintf } from '@gitlab/ui'; +import { GlBadge, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import StatusBox from '~/issuable/components/status_box.vue'; let wrapper; function factory(propsData) { - wrapper = shallowMount(StatusBox, { propsData, stubs: { GlSprintf } }); + wrapper = shallowMount(StatusBox, { propsData, stubs: { GlBadge } }); } -const testCases = [ - { - name: 'Open', - state: 'opened', - class: 'status-box-open', - icon: 'issue-open-m', - }, - { - name: 'Open', - state: 'locked', - class: 'status-box-open', - icon: 'issue-open-m', - }, - { - name: 'Closed', - state: 'closed', - class: 'status-box-mr-closed', - icon: 'issue-close', - }, - { - name: 'Merged', - state: 'merged', - class: 'status-box-mr-merged', - icon: 'git-merge', - }, -]; - describe('Merge request status box component', () => { + const findBadge = () => wrapper.findComponent(GlBadge); + afterEach(() => { wrapper.destroy(); wrapper = null; }); - testCases.forEach((testCase) => { - describe(`when merge request is ${testCase.name}`, () => { - it('renders human readable test', () => { + describe.each` + issuableType | badgeText | initialState | badgeClass | badgeVariant | badgeIcon + ${'merge_request'} | ${'Open'} | ${'opened'} | ${'issuable-status-badge-open'} | ${'success'} | ${'merge-request-open'} + ${'merge_request'} | ${'Closed'} | ${'closed'} | ${'issuable-status-badge-closed'} | ${'danger'} | ${'merge-request-close'} + ${'merge_request'} | ${'Merged'} | ${'merged'} | ${'issuable-status-badge-merged'} | ${'info'} | ${'merge'} + ${'issue'} | ${'Open'} | ${'opened'} | ${'issuable-status-badge-open'} | ${'success'} | ${'issues'} + ${'issue'} | ${'Closed'} | ${'closed'} | ${'issuable-status-badge-closed'} | ${'info'} | ${'issue-closed'} + `( + 'with issuableType set to "$issuableType" and state set to "$initialState"', + ({ issuableType, badgeText, initialState, badgeClass, badgeVariant, badgeIcon }) => { + beforeEach(() => { factory({ - initialState: testCase.state, + initialState, + issuableType, }); - - expect(wrapper.text()).toContain(testCase.name); }); - it('sets css class', () => { - factory({ - initialState: testCase.state, - }); + it(`renders badge with text '${badgeText}'`, () => { + expect(findBadge().text()).toBe(badgeText); + }); - expect(wrapper.classes()).toContain(testCase.class); + it(`sets badge css class as '${badgeClass}'`, () => { + expect(findBadge().classes()).toContain(badgeClass); }); - it('renders icon', () => { - factory({ - initialState: testCase.state, - }); + it(`sets badge variant as '${badgeVariant}`, () => { + expect(findBadge().props('variant')).toBe(badgeVariant); + }); - expect(wrapper.findComponent(GlIcon).props('name')).toBe(testCase.icon); + it(`sets badge icon as '${badgeIcon}'`, () => { + expect(findBadge().findComponent(GlIcon).props('name')).toBe(badgeIcon); }); - }); - }); + }, + ); }); diff --git a/spec/frontend/issuable/issuable_form_spec.js b/spec/frontend/issuable/issuable_form_spec.js index 99ed18cf5bd..a1583076b41 100644 --- a/spec/frontend/issuable/issuable_form_spec.js +++ b/spec/frontend/issuable/issuable_form_spec.js @@ -1,5 +1,5 @@ import $ from 'jquery'; - +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import IssuableForm from '~/issuable/issuable_form'; import setWindowLocation from 'helpers/set_window_location_helper'; @@ -11,7 +11,7 @@ describe('IssuableForm', () => { }; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <form> <input name="[title]" /> </form> @@ -19,6 +19,10 @@ describe('IssuableForm', () => { createIssuable($('form')); }); + afterEach(() => { + resetHTMLFixture(); + }); + describe('initAutosave', () => { it('creates autosave with the searchTerm included', () => { setWindowLocation('https://gitlab.test/foo?bar=true'); @@ -28,7 +32,7 @@ describe('IssuableForm', () => { }); it("creates autosave fields without the searchTerm if it's an issue new form", () => { - setFixtures(` + setHTMLFixture(` <form data-new-issue-path="/issues/new"> <input name="[title]" /> </form> 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 b59717a1f60..1a03ea58b60 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 @@ -300,16 +300,27 @@ describe('RelatedIssuesRoot', () => { expect(wrapper.vm.state.pendingReferences[1]).toEqual('random'); }); - it('prepends # when user enters a numeric value [0-9]', async () => { - const input = '23'; + it.each` + pathIdSeparator + ${'#'} + ${'&'} + `( + 'prepends $pathIdSeparator when user enters a numeric value [0-9]', + async ({ pathIdSeparator }) => { + const input = '23'; + + await wrapper.setProps({ + pathIdSeparator, + }); - wrapper.vm.onInput({ - untouchedRawReferences: input.trim().split(/\s/), - touchedReference: input, - }); + wrapper.vm.onInput({ + untouchedRawReferences: input.trim().split(/\s/), + touchedReference: input, + }); - expect(wrapper.vm.inputValue).toBe(`#${input}`); - }); + expect(wrapper.vm.inputValue).toBe(`${pathIdSeparator}${input}`); + }, + ); it('prepends # when user enters a number', async () => { const input = 23; diff --git a/spec/frontend/issues/issue_spec.js b/spec/frontend/issues/issue_spec.js index 8a089b372ff..089ea8dbbad 100644 --- a/spec/frontend/issues/issue_spec.js +++ b/spec/frontend/issues/issue_spec.js @@ -1,5 +1,6 @@ import { getByText } from '@testing-library/dom'; import MockAdapter from 'axios-mock-adapter'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import Issue from '~/issues/issue'; import axios from '~/lib/utils/axios_utils'; @@ -24,11 +25,11 @@ describe('Issue', () => { const getIssueCounter = () => document.querySelector('.issue_counter'); const getOpenStatusBox = () => getByText(document, (_, el) => el.textContent.match(/Open/), { - selector: '.status-box-open', + selector: '.issuable-status-badge-open', }); const getClosedStatusBox = () => getByText(document, (_, el) => el.textContent.match(/Closed/), { - selector: '.status-box-issue-closed', + selector: '.issuable-status-badge-closed', }); describe.each` @@ -38,9 +39,9 @@ describe('Issue', () => { `('$desc', ({ isIssueInitiallyOpen, expectedCounterText }) => { beforeEach(() => { if (isIssueInitiallyOpen) { - loadFixtures('issues/open-issue.html'); + loadHTMLFixture('issues/open-issue.html'); } else { - loadFixtures('issues/closed-issue.html'); + loadHTMLFixture('issues/closed-issue.html'); } testContext.issueCounter = getIssueCounter(); @@ -50,6 +51,10 @@ describe('Issue', () => { testContext.issueCounter.textContent = '1,001'; }); + afterEach(() => { + resetHTMLFixture(); + }); + it(`has the proper visible status box when ${isIssueInitiallyOpen ? 'open' : 'closed'}`, () => { if (isIssueInitiallyOpen) { expect(testContext.statusBoxClosed).toHaveClass('hidden'); diff --git a/spec/frontend/issues/list/components/issues_list_app_spec.js b/spec/frontend/issues/list/components/issues_list_app_spec.js index 5a9bd1ff8e4..d92ba527b5c 100644 --- a/spec/frontend/issues/list/components/issues_list_app_spec.js +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -5,8 +5,11 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import { cloneDeep } from 'lodash'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql'; +import getIssuesWithoutCrmQuery from 'ee_else_ce/issues/list/queries/get_issues_without_crm.query.graphql'; +import getIssuesCountsWithoutCrmQuery from 'ee_else_ce/issues/list/queries/get_issues_counts_without_crm.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; @@ -58,6 +61,7 @@ describe('CE IssuesListApp component', () => { let wrapper; Vue.use(VueApollo); + Vue.use(VueRouter); const defaultProvide = { autocompleteAwardEmojisPath: 'autocomplete/award/emojis/path', @@ -78,6 +82,7 @@ describe('CE IssuesListApp component', () => { isAnonymousSearchDisabled: false, isIssueRepositioningDisabled: false, isProject: true, + isPublicVisibilityRestricted: false, isSignedIn: true, jiraIntegrationPath: 'jira/integration/path', newIssuePath: 'new/issue/path', @@ -107,6 +112,7 @@ describe('CE IssuesListApp component', () => { const mountComponent = ({ provide = {}, + data = {}, issuesQueryResponse = jest.fn().mockResolvedValue(defaultQueryResponse), issuesCountsQueryResponse = jest.fn().mockResolvedValue(getIssuesCountsQueryResponse), sortPreferenceMutationResponse = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse), @@ -115,16 +121,21 @@ describe('CE IssuesListApp component', () => { const requestHandlers = [ [getIssuesQuery, issuesQueryResponse], [getIssuesCountsQuery, issuesCountsQueryResponse], + [getIssuesWithoutCrmQuery, issuesQueryResponse], + [getIssuesCountsWithoutCrmQuery, issuesCountsQueryResponse], [setSortPreferenceMutation, sortPreferenceMutationResponse], ]; - const apolloProvider = createMockApollo(requestHandlers); return mountFn(IssuesListApp, { - apolloProvider, + apolloProvider: createMockApollo(requestHandlers), + router: new VueRouter({ mode: 'history' }), provide: { ...defaultProvide, ...provide, }, + data() { + return data; + }, }); }; @@ -139,10 +150,10 @@ describe('CE IssuesListApp component', () => { }); describe('IssuableList', () => { - beforeEach(async () => { + beforeEach(() => { wrapper = mountComponent(); jest.runOnlyPendingTimers(); - await waitForPromises(); + return waitForPromises(); }); it('renders', () => { @@ -167,10 +178,6 @@ describe('CE IssuesListApp component', () => { useKeysetPagination: true, hasPreviousPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasPreviousPage, hasNextPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasNextPage, - urlParams: { - sort: urlSortParams[CREATED_DESC], - state: IssuableStates.Opened, - }, }); }); }); @@ -200,7 +207,7 @@ describe('CE IssuesListApp component', () => { describe('csv import/export component', () => { describe('when user is signed in', () => { - beforeEach(async () => { + beforeEach(() => { setWindowLocation('?search=refactor&state=opened'); wrapper = mountComponent({ @@ -209,12 +216,12 @@ describe('CE IssuesListApp component', () => { }); jest.runOnlyPendingTimers(); - await waitForPromises(); + return waitForPromises(); }); it('renders', () => { expect(findCsvImportExportButtons().props()).toMatchObject({ - exportCsvPath: `${defaultProvide.exportCsvPath}?search=refactor&sort=created_date&state=opened`, + exportCsvPath: `${defaultProvide.exportCsvPath}?search=refactor&state=opened`, issuableCount: 1, }); }); @@ -252,11 +259,9 @@ describe('CE IssuesListApp component', () => { it('emits "issuables:enableBulkEdit" event to legacy bulk edit class', async () => { wrapper = mountComponent({ provide: { canBulkUpdate: true }, mountFn: mount }); - jest.spyOn(eventHub, '$emit'); findGlButtonAt(2).vm.$emit('click'); - await waitForPromises(); expect(eventHub.$emit).toHaveBeenCalledWith('issuables:enableBulkEdit'); @@ -297,32 +302,25 @@ describe('CE IssuesListApp component', () => { describe('page', () => { it('page_after is set from the url params', () => { setWindowLocation('?page_after=randomCursorString'); - wrapper = mountComponent(); - expect(findIssuableList().props('urlParams')).toMatchObject({ - page_after: 'randomCursorString', - }); + expect(wrapper.vm.$route.query).toMatchObject({ page_after: 'randomCursorString' }); }); it('page_before is set from the url params', () => { setWindowLocation('?page_before=anotherRandomCursorString'); - wrapper = mountComponent(); - expect(findIssuableList().props('urlParams')).toMatchObject({ - page_before: 'anotherRandomCursorString', - }); + expect(wrapper.vm.$route.query).toMatchObject({ page_before: 'anotherRandomCursorString' }); }); }); describe('search', () => { it('is set from the url params', () => { setWindowLocation(locationSearch); - wrapper = mountComponent(); - expect(findIssuableList().props('urlParams')).toMatchObject({ search: 'find issues' }); + expect(wrapper.vm.$route.query).toMatchObject({ search: 'find issues' }); }); }); @@ -333,10 +331,7 @@ describe('CE IssuesListApp component', () => { it.each(oldEnumSortValues)('initial sort is set with value %s', (sort) => { wrapper = mountComponent({ provide: { initialSort: sort } }); - expect(findIssuableList().props()).toMatchObject({ - initialSortBy: getSortKey(sort), - urlParams: { sort }, - }); + expect(findIssuableList().props('initialSortBy')).toBe(getSortKey(sort)); }); }); @@ -346,10 +341,7 @@ describe('CE IssuesListApp component', () => { it.each(graphQLEnumSortValues)('initial sort is set with value %s', (sort) => { wrapper = mountComponent({ provide: { initialSort: sort.toLowerCase() } }); - expect(findIssuableList().props()).toMatchObject({ - initialSortBy: sort, - urlParams: { sort: urlSortParams[sort] }, - }); + expect(findIssuableList().props('initialSortBy')).toBe(sort); }); }); @@ -359,10 +351,7 @@ describe('CE IssuesListApp component', () => { (sort) => { wrapper = mountComponent({ provide: { initialSort: sort } }); - expect(findIssuableList().props()).toMatchObject({ - initialSortBy: CREATED_DESC, - urlParams: { sort: urlSortParams[CREATED_DESC] }, - }); + expect(findIssuableList().props('initialSortBy')).toBe(CREATED_DESC); }, ); }); @@ -375,10 +364,7 @@ describe('CE IssuesListApp component', () => { }); it('changes the sort to the default of created descending', () => { - expect(findIssuableList().props()).toMatchObject({ - initialSortBy: CREATED_DESC, - urlParams: { sort: urlSortParams[CREATED_DESC] }, - }); + expect(findIssuableList().props('initialSortBy')).toBe(CREATED_DESC); }); it('shows an alert to tell the user that manual reordering is disabled', () => { @@ -393,9 +379,7 @@ describe('CE IssuesListApp component', () => { describe('state', () => { it('is set from the url params', () => { const initialState = IssuableStates.All; - setWindowLocation(`?state=${initialState}`); - wrapper = mountComponent(); expect(findIssuableList().props('currentTab')).toBe(initialState); @@ -405,7 +389,6 @@ describe('CE IssuesListApp component', () => { describe('filter tokens', () => { it('is set from the url params', () => { setWindowLocation(locationSearch); - wrapper = mountComponent(); expect(findIssuableList().props('initialFilterValue')).toEqual(filteredTokens); @@ -414,7 +397,6 @@ describe('CE IssuesListApp component', () => { describe('when anonymous searching is performed', () => { beforeEach(() => { setWindowLocation(locationSearch); - wrapper = mountComponent({ provide: { isAnonymousSearchDisabled: true, isSignedIn: false }, }); @@ -649,12 +631,12 @@ describe('CE IssuesListApp component', () => { ${'fetching issues'} | ${'issuesQueryResponse'} | ${IssuesListApp.i18n.errorFetchingIssues} ${'fetching issue counts'} | ${'issuesCountsQueryResponse'} | ${IssuesListApp.i18n.errorFetchingCounts} `('when there is an error $error', ({ mountOption, message }) => { - beforeEach(async () => { + beforeEach(() => { wrapper = mountComponent({ [mountOption]: jest.fn().mockRejectedValue(new Error('ERROR')), }); jest.runOnlyPendingTimers(); - await waitForPromises(); + return waitForPromises(); }); it('shows an error message', () => { @@ -676,29 +658,51 @@ describe('CE IssuesListApp component', () => { describe('when "click-tab" event is emitted by IssuableList', () => { beforeEach(() => { wrapper = mountComponent(); + jest.spyOn(wrapper.vm.$router, 'push'); findIssuableList().vm.$emit('click-tab', IssuableStates.Closed); }); - it('updates to the new tab', () => { + it('updates ui to the new tab', () => { expect(findIssuableList().props('currentTab')).toBe(IssuableStates.Closed); }); - }); - describe.each(['next-page', 'previous-page'])( - 'when "%s" event is emitted by IssuableList', - (event) => { - beforeEach(() => { - wrapper = mountComponent(); + it('updates url to the new tab', () => { + expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ + query: expect.objectContaining({ state: IssuableStates.Closed }), + }); + }); + }); - findIssuableList().vm.$emit(event); + describe.each` + event | paramName | paramValue + ${'next-page'} | ${'page_after'} | ${'endCursor'} + ${'previous-page'} | ${'page_before'} | ${'startCursor'} + `('when "$event" event is emitted by IssuableList', ({ event, paramName, paramValue }) => { + beforeEach(() => { + wrapper = mountComponent({ + data: { + pageInfo: { + endCursor: 'endCursor', + startCursor: 'startCursor', + }, + }, }); + jest.spyOn(wrapper.vm.$router, 'push'); + + findIssuableList().vm.$emit(event); + }); + + it('scrolls to the top', () => { + expect(scrollUp).toHaveBeenCalled(); + }); - it('scrolls to the top', () => { - expect(scrollUp).toHaveBeenCalled(); + it(`updates url with "${paramName}" param`, () => { + expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ + query: expect.objectContaining({ [paramName]: paramValue }), }); - }, - ); + }); + }); describe('when "reorder" event is emitted by IssuableList', () => { const issueOne = { @@ -752,18 +756,17 @@ describe('CE IssuesListApp component', () => { `( 'when moving issue $description', ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => { - beforeEach(async () => { + beforeEach(() => { wrapper = mountComponent({ provide: { isProject }, issuesQueryResponse: jest.fn().mockResolvedValue(response(isProject)), }); jest.runOnlyPendingTimers(); - await waitForPromises(); + return waitForPromises(); }); it('makes API call to reorder the issue', async () => { findIssuableList().vm.$emit('reorder', { oldIndex, newIndex }); - await waitForPromises(); expect(axiosMock.history.put[0]).toMatchObject({ @@ -780,19 +783,18 @@ describe('CE IssuesListApp component', () => { }); describe('when unsuccessful', () => { - beforeEach(async () => { + beforeEach(() => { wrapper = mountComponent({ issuesQueryResponse: jest.fn().mockResolvedValue(response()), }); jest.runOnlyPendingTimers(); - await waitForPromises(); + return waitForPromises(); }); it('displays an error message', async () => { axiosMock.onPut(joinPaths(issueOne.webPath, 'reorder')).reply(500); findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 }); - await waitForPromises(); expect(findIssuableList().props('error')).toBe(IssuesListApp.i18n.reorderError); @@ -808,14 +810,14 @@ describe('CE IssuesListApp component', () => { 'updates to the new sort when payload is `%s`', async (sortKey) => { wrapper = mountComponent(); + jest.spyOn(wrapper.vm.$router, 'push'); findIssuableList().vm.$emit('sort', sortKey); - jest.runOnlyPendingTimers(); await nextTick(); - expect(findIssuableList().props('urlParams')).toMatchObject({ - sort: urlSortParams[sortKey], + expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ + query: expect.objectContaining({ sort: urlSortParams[sortKey] }), }); }, ); @@ -827,14 +829,13 @@ describe('CE IssuesListApp component', () => { wrapper = mountComponent({ provide: { initialSort, isIssueRepositioningDisabled: true }, }); + jest.spyOn(wrapper.vm.$router, 'push'); findIssuableList().vm.$emit('sort', RELATIVE_POSITION_ASC); }); it('does not update the sort to manual', () => { - expect(findIssuableList().props('urlParams')).toMatchObject({ - sort: urlSortParams[initialSort], - }); + expect(wrapper.vm.$router.push).not.toHaveBeenCalled(); }); it('shows an alert to tell the user that manual reordering is disabled', () => { @@ -899,11 +900,14 @@ describe('CE IssuesListApp component', () => { describe('when "filter" event is emitted by IssuableList', () => { it('updates IssuableList with url params', async () => { wrapper = mountComponent(); + jest.spyOn(wrapper.vm.$router, 'push'); findIssuableList().vm.$emit('filter', filteredTokens); await nextTick(); - expect(findIssuableList().props('urlParams')).toMatchObject(urlParams); + expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ + query: expect.objectContaining(urlParams), + }); }); describe('when anonymous searching is performed', () => { @@ -911,19 +915,13 @@ describe('CE IssuesListApp component', () => { wrapper = mountComponent({ provide: { isAnonymousSearchDisabled: true, isSignedIn: false }, }); + jest.spyOn(wrapper.vm.$router, 'push'); findIssuableList().vm.$emit('filter', filteredTokens); }); - it('does not update IssuableList with url params ', async () => { - const defaultParams = { - page_after: null, - page_before: null, - sort: 'created_date', - state: 'opened', - }; - - expect(findIssuableList().props('urlParams')).toEqual(defaultParams); + it('does not update url params', () => { + expect(wrapper.vm.$router.push).not.toHaveBeenCalled(); }); it('shows an alert to tell the user they must be signed in to search', () => { @@ -935,4 +933,23 @@ describe('CE IssuesListApp component', () => { }); }); }); + + describe('public visibility', () => { + it.each` + description | isPublicVisibilityRestricted | isSignedIn | hideUsers + ${'shows users when public visibility is not restricted and is not signed in'} | ${false} | ${false} | ${false} + ${'shows users when public visibility is not restricted and is signed in'} | ${false} | ${true} | ${false} + ${'hides users when public visibility is restricted and is not signed in'} | ${true} | ${false} | ${true} + ${'shows users when public visibility is restricted and is signed in'} | ${true} | ${true} | ${false} + `('$description', ({ isPublicVisibilityRestricted, isSignedIn, hideUsers }) => { + const mockQuery = jest.fn().mockResolvedValue(defaultQueryResponse); + wrapper = mountComponent({ + provide: { isPublicVisibilityRestricted, isSignedIn }, + issuesQueryResponse: mockQuery, + }); + jest.runOnlyPendingTimers(); + + expect(mockQuery).toHaveBeenCalledWith(expect.objectContaining({ hideUsers })); + }); + }); }); diff --git a/spec/frontend/issues/list/mock_data.js b/spec/frontend/issues/list/mock_data.js index b1a135ceb18..42f2d08082e 100644 --- a/spec/frontend/issues/list/mock_data.js +++ b/spec/frontend/issues/list/mock_data.js @@ -117,6 +117,7 @@ export const locationSearch = [ 'not[author_username]=marge', 'assignee_username[]=bart', 'assignee_username[]=lisa', + 'assignee_username[]=5', 'not[assignee_username][]=patty', 'not[assignee_username][]=selma', 'milestone_title=season+3', @@ -146,6 +147,8 @@ export const locationSearch = [ 'not[epic_id]=34', 'weight=1', 'not[weight]=3', + 'crm_contact_id=123', + 'crm_organization_id=456', ].join('&'); export const locationSearchWithSpecialValues = [ @@ -165,6 +168,7 @@ export const filteredTokens = [ { type: 'author_username', value: { data: 'marge', operator: OPERATOR_IS_NOT } }, { type: 'assignee_username', value: { data: 'bart', operator: OPERATOR_IS } }, { type: 'assignee_username', value: { data: 'lisa', operator: OPERATOR_IS } }, + { type: 'assignee_username', value: { data: '5', operator: OPERATOR_IS } }, { type: 'assignee_username', value: { data: 'patty', operator: OPERATOR_IS_NOT } }, { type: 'assignee_username', value: { data: 'selma', operator: OPERATOR_IS_NOT } }, { type: 'milestone', value: { data: 'season 3', operator: OPERATOR_IS } }, @@ -194,6 +198,8 @@ export const filteredTokens = [ { type: 'epic_id', value: { data: '34', operator: OPERATOR_IS_NOT } }, { type: 'weight', value: { data: '1', operator: OPERATOR_IS } }, { type: 'weight', value: { data: '3', operator: OPERATOR_IS_NOT } }, + { type: 'crm_contact', value: { data: '123', operator: OPERATOR_IS } }, + { type: 'crm_organization', value: { data: '456', operator: OPERATOR_IS } }, { type: 'filtered-search-term', value: { data: 'find' } }, { type: 'filtered-search-term', value: { data: 'issues' } }, ]; @@ -212,7 +218,7 @@ export const filteredTokensWithSpecialValues = [ export const apiParams = { authorUsername: 'homer', - assigneeUsernames: ['bart', 'lisa'], + assigneeUsernames: ['bart', 'lisa', '5'], milestoneTitle: ['season 3', 'season 4'], labelName: ['cartoon', 'tv'], releaseTag: ['v3', 'v4'], @@ -222,6 +228,8 @@ export const apiParams = { iterationId: ['4', '12'], epicId: '12', weight: '1', + crmContactId: '123', + crmOrganizationId: '456', not: { authorUsername: 'marge', assigneeUsernames: ['patty', 'selma'], @@ -251,7 +259,7 @@ export const apiParamsWithSpecialValues = { export const urlParams = { author_username: 'homer', 'not[author_username]': 'marge', - 'assignee_username[]': ['bart', 'lisa'], + 'assignee_username[]': ['bart', 'lisa', '5'], 'not[assignee_username][]': ['patty', 'selma'], milestone_title: ['season 3', 'season 4'], 'not[milestone_title]': ['season 20', 'season 30'], @@ -270,6 +278,8 @@ export const urlParams = { 'not[epic_id]': '34', weight: '1', 'not[weight]': '3', + crm_contact_id: '123', + crm_organization_id: '456', }; export const urlParamsWithSpecialValues = { diff --git a/spec/frontend/issues/list/utils_spec.js b/spec/frontend/issues/list/utils_spec.js index a60350d91c5..ce0477883d7 100644 --- a/spec/frontend/issues/list/utils_spec.js +++ b/spec/frontend/issues/list/utils_spec.js @@ -1,3 +1,5 @@ +import setWindowLocation from 'helpers/set_window_location_helper'; +import { TEST_HOST } from 'helpers/test_constants'; import { apiParams, apiParamsWithSpecialValues, @@ -24,6 +26,7 @@ import { getSortOptions, isSortKey, } from '~/issues/list/utils'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; describe('getInitialPageParams', () => { it.each(Object.keys(urlSortParams))( @@ -124,24 +127,50 @@ describe('getFilterTokens', () => { filteredTokensWithSpecialValues, ); }); + + it.each` + description | argument + ${'an undefined value'} | ${undefined} + ${'an irrelevant value'} | ${'?unrecognised=parameter'} + `('returns an empty filtered search term given $description', ({ argument }) => { + expect(getFilterTokens(argument)).toEqual([ + { + id: expect.any(String), + type: FILTERED_SEARCH_TERM, + value: { data: '' }, + }, + ]); + }); }); describe('convertToApiParams', () => { + beforeEach(() => { + setWindowLocation(TEST_HOST); + }); + it('returns api params given filtered tokens', () => { expect(convertToApiParams(filteredTokens)).toEqual(apiParams); }); it('returns api params given filtered tokens with special values', () => { + setWindowLocation('?assignee_id=123'); + expect(convertToApiParams(filteredTokensWithSpecialValues)).toEqual(apiParamsWithSpecialValues); }); }); describe('convertToUrlParams', () => { + beforeEach(() => { + setWindowLocation(TEST_HOST); + }); + it('returns url params given filtered tokens', () => { expect(convertToUrlParams(filteredTokens)).toEqual(urlParams); }); it('returns url params given filtered tokens with special values', () => { + setWindowLocation('?assignee_id=123'); + expect(convertToUrlParams(filteredTokensWithSpecialValues)).toEqual(urlParamsWithSpecialValues); }); }); diff --git a/spec/frontend/issues/show/components/app_spec.js b/spec/frontend/issues/show/components/app_spec.js index 5ab64d8e9ca..27604b8ccf3 100644 --- a/spec/frontend/issues/show/components/app_spec.js +++ b/spec/frontend/issues/show/components/app_spec.js @@ -1,10 +1,12 @@ -import { GlIntersectionObserver } from '@gitlab/ui'; +import { GlIcon, GlIntersectionObserver } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import '~/behaviors/markdown/render_gfm'; -import { IssuableStatus, IssuableStatusText } from '~/issues/constants'; +import { IssuableStatus, IssuableStatusText, IssuableType } from '~/issues/constants'; import IssuableApp from '~/issues/show/components/app.vue'; import DescriptionComponent from '~/issues/show/components/description.vue'; import EditedComponent from '~/issues/show/components/edited.vue'; @@ -70,7 +72,7 @@ describe('Issuable output', () => { }; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <div> <title>Title</title> <div class="detail-page-description content-block"> @@ -105,6 +107,7 @@ describe('Issuable output', () => { realtimeRequestCount = 0; wrapper.vm.poll.stop(); wrapper.destroy(); + resetHTMLFixture(); }); it('should render a title/description/edited and update title/description/edited on update', () => { @@ -465,6 +468,31 @@ describe('Issuable output', () => { expect(findStickyHeader().text()).toContain('Sticky header title'); }); + it('shows with title for an epic', async () => { + wrapper.setProps({ issuableType: 'epic' }); + + await nextTick(); + + expect(findStickyHeader().text()).toContain('Sticky header title'); + }); + + it.each` + issuableType | issuableStatus | statusIcon + ${IssuableType.Issue} | ${IssuableStatus.Open} | ${'issues'} + ${IssuableType.Issue} | ${IssuableStatus.Closed} | ${'issue-closed'} + ${IssuableType.Epic} | ${IssuableStatus.Open} | ${'epic'} + ${IssuableType.Epic} | ${IssuableStatus.Closed} | ${'epic-closed'} + `( + 'shows with state icon "$statusIcon" for $issuableType when status is $issuableStatus', + async ({ issuableType, issuableStatus, statusIcon }) => { + wrapper.setProps({ issuableType, issuableStatus }); + + await nextTick(); + + expect(findStickyHeader().findComponent(GlIcon).props('name')).toBe(statusIcon); + }, + ); + it.each` title | state ${'shows with Open when status is opened'} | ${IssuableStatus.Open} @@ -487,7 +515,14 @@ describe('Issuable output', () => { await nextTick(); - expect(findConfidentialBadge().exists()).toBe(isConfidential); + const confidentialEl = findConfidentialBadge(); + expect(confidentialEl.exists()).toBe(isConfidential); + if (isConfidential) { + expect(confidentialEl.props()).toMatchObject({ + workspaceType: 'project', + issuableType: 'issue', + }); + } }); it.each` @@ -613,4 +648,14 @@ describe('Issuable output', () => { expect(wrapper.vm.updateStoreState).toHaveBeenCalled(); }); }); + + describe('listItemReorder event', () => { + it('makes request to update issue', async () => { + const description = 'I have been updated!'; + findDescription().vm.$emit('listItemReorder', description); + await waitForPromises(); + + expect(mock.history.put[0].data).toContain(description); + }); + }); }); diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js index 0b3daadae1d..1ae04531a6b 100644 --- a/spec/frontend/issues/show/components/description_spec.js +++ b/spec/frontend/issues/show/components/description_spec.js @@ -1,14 +1,20 @@ import $ from 'jquery'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import '~/behaviors/markdown/render_gfm'; import { GlTooltip, GlModal } from '@gitlab/ui'; + import setWindowLocation from 'helpers/set_window_location_helper'; import { stubComponent } from 'helpers/stub_component'; import { TEST_HOST } from 'helpers/test_constants'; import { mockTracking } from 'helpers/tracking_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + import Description from '~/issues/show/components/description.vue'; import { updateHistory } from '~/lib/utils/url_utility'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import TaskList from '~/task_list'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; @@ -27,17 +33,29 @@ jest.mock('~/task_list'); const showModal = jest.fn(); const hideModal = jest.fn(); +const showDetailsModal = jest.fn(); const $toast = { show: jest.fn(), }; +const workItemQueryResponse = { + data: { + workItem: null, + }, +}; + +const queryHandler = jest.fn().mockResolvedValue(workItemQueryResponse); + describe('Description component', () => { let wrapper; + Vue.use(VueApollo); + const findGfmContent = () => wrapper.find('[data-testid="gfm-content"]'); const findTextarea = () => wrapper.find('[data-testid="textarea"]'); const findTaskActionButtons = () => wrapper.findAll('.js-add-task'); const findConvertToTaskButton = () => wrapper.find('.js-add-task'); + const findTaskLink = () => wrapper.find('a.gfm-issue'); const findTooltips = () => wrapper.findAllComponents(GlTooltip); const findModal = () => wrapper.findComponent(GlModal); @@ -52,6 +70,7 @@ describe('Description component', () => { ...props, }, provide, + apolloProvider: createMockApollo([[workItemQuery, queryHandler]]), mocks: { $toast, }, @@ -62,6 +81,11 @@ describe('Description component', () => { hide: hideModal, }, }), + WorkItemDetailModal: stubComponent(WorkItemDetailModal, { + methods: { + show: showDetailsModal, + }, + }), }, }); } @@ -296,15 +320,15 @@ describe('Description component', () => { }); it('shows toast after delete success', async () => { - findWorkItemDetailModal().vm.$emit('workItemDeleted'); + const newDesc = 'description'; + findWorkItemDetailModal().vm.$emit('workItemDeleted', newDesc); + expect(wrapper.emitted('updateDescription')).toEqual([[newDesc]]); expect($toast.show).toHaveBeenCalledWith('Work item deleted'); }); }); describe('work items detail', () => { - const findTaskLink = () => wrapper.find('a.gfm-issue'); - describe('when opening and closing', () => { beforeEach(() => { createComponent({ @@ -319,11 +343,9 @@ describe('Description component', () => { }); it('opens when task button is clicked', async () => { - expect(findWorkItemDetailModal().props('visible')).toBe(false); - await findTaskLink().trigger('click'); - expect(findWorkItemDetailModal().props('visible')).toBe(true); + expect(showDetailsModal).toHaveBeenCalled(); expect(updateHistory).toHaveBeenCalledWith({ url: `${TEST_HOST}/?work_item_id=2`, replace: true, @@ -333,12 +355,9 @@ describe('Description component', () => { it('closes from an open state', async () => { await findTaskLink().trigger('click'); - expect(findWorkItemDetailModal().props('visible')).toBe(true); - findWorkItemDetailModal().vm.$emit('close'); await nextTick(); - expect(findWorkItemDetailModal().props('visible')).toBe(false); expect(updateHistory).toHaveBeenLastCalledWith({ url: `${TEST_HOST}/`, replace: true, @@ -364,16 +383,17 @@ describe('Description component', () => { describe('when url query `work_item_id` exists', () => { it.each` - behavior | workItemId | visible - ${'opens'} | ${'123'} | ${true} - ${'does not open'} | ${'123e'} | ${false} - ${'does not open'} | ${'12e3'} | ${false} - ${'does not open'} | ${'1e23'} | ${false} - ${'does not open'} | ${'x'} | ${false} - ${'does not open'} | ${'undefined'} | ${false} + behavior | workItemId | modalOpened + ${'opens'} | ${'2'} | ${1} + ${'does not open'} | ${'123'} | ${0} + ${'does not open'} | ${'123e'} | ${0} + ${'does not open'} | ${'12e3'} | ${0} + ${'does not open'} | ${'1e23'} | ${0} + ${'does not open'} | ${'x'} | ${0} + ${'does not open'} | ${'undefined'} | ${0} `( '$behavior when url contains `work_item_id=$workItemId`', - async ({ workItemId, visible }) => { + async ({ workItemId, modalOpened }) => { setWindowLocation(`?work_item_id=${workItemId}`); createComponent({ @@ -381,10 +401,43 @@ describe('Description component', () => { provide: { glFeatures: { workItems: true } }, }); - expect(findWorkItemDetailModal().props('visible')).toBe(visible); + expect(showDetailsModal).toHaveBeenCalledTimes(modalOpened); }, ); }); }); + + describe('when hovering task links', () => { + beforeEach(() => { + createComponent({ + props: { + descriptionHtml: descriptionHtmlWithTask, + }, + provide: { + glFeatures: { workItems: true }, + }, + }); + return nextTick(); + }); + + it('prefetches work item detail after work item link is hovered for 150ms', async () => { + await findTaskLink().trigger('mouseover'); + jest.advanceTimersByTime(150); + await waitForPromises(); + + expect(queryHandler).toHaveBeenCalledWith({ + id: 'gid://gitlab/WorkItem/2', + }); + }); + + it('does not work item detail after work item link is hovered for less than 150ms', async () => { + await findTaskLink().trigger('mouseover'); + await findTaskLink().trigger('mouseout'); + jest.advanceTimersByTime(150); + await waitForPromises(); + + expect(queryHandler).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/spec/frontend/issues/show/components/fields/description_spec.js b/spec/frontend/issues/show/components/fields/description_spec.js index 0dcd70ac19b..d0e33f0b980 100644 --- a/spec/frontend/issues/show/components/fields/description_spec.js +++ b/spec/frontend/issues/show/components/fields/description_spec.js @@ -24,7 +24,6 @@ describe('Description field component', () => { beforeEach(() => { jest.spyOn(eventHub, '$emit'); - gon.features = { markdownContinueLists: true }; }); afterEach(() => { diff --git a/spec/frontend/issues/show/components/title_spec.js b/spec/frontend/issues/show/components/title_spec.js index 29b5353ef1c..7560b733ae6 100644 --- a/spec/frontend/issues/show/components/title_spec.js +++ b/spec/frontend/issues/show/components/title_spec.js @@ -1,4 +1,5 @@ import Vue, { nextTick } from 'vue'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import titleComponent from '~/issues/show/components/title.vue'; import eventHub from '~/issues/show/event_hub'; import Store from '~/issues/show/stores'; @@ -6,7 +7,7 @@ import Store from '~/issues/show/stores'; describe('Title component', () => { let vm; beforeEach(() => { - setFixtures(`<title />`); + setHTMLFixture(`<title />`); const Component = Vue.extend(titleComponent); const store = new Store({ @@ -25,6 +26,10 @@ describe('Title component', () => { }).$mount(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('renders title HTML', () => { expect(vm.$el.querySelector('.title').innerHTML.trim()).toBe('Testing <img>'); }); diff --git a/spec/frontend/issues/show/mock_data/mock_data.js b/spec/frontend/issues/show/mock_data/mock_data.js index 7b0b8ca686a..909789b7a0f 100644 --- a/spec/frontend/issues/show/mock_data/mock_data.js +++ b/spec/frontend/issues/show/mock_data/mock_data.js @@ -77,7 +77,22 @@ export const descriptionHtmlWithTask = ` <ul data-sourcepos="1:1-3:7" class="task-list" dir="auto"> <li data-sourcepos="1:1-1:10" class="task-list-item"> <input type="checkbox" class="task-list-item-checkbox" disabled> - <a href="/gitlab-org/gitlab-test/-/issues/48" data-original="#48+" data-link="false" data-link-reference="false" data-project="1" data-issue="2" data-reference-format="+" data-reference-type="task" data-container="body" data-placement="top" title="1" class="gfm gfm-issue has-tooltip">1 (#48)</a> + <a href="/gitlab-org/gitlab-test/-/issues/48" data-original="#48+" data-link="false" data-link-reference="false" data-project="1" data-issue="2" data-reference-format="+" data-reference-type="task" data-container="body" data-placement="top" title="1" class="gfm gfm-issue has-tooltip" data-issue-type="task">1 (#48)</a> + </li> + <li data-sourcepos="2:1-2:7" class="task-list-item"> + <input type="checkbox" class="task-list-item-checkbox" disabled> 2 + </li> + <li data-sourcepos="3:1-3:7" class="task-list-item"> + <input type="checkbox" class="task-list-item-checkbox" disabled> 3 + </li> + </ul> +`; + +export const descriptionHtmlWithIssue = ` + <ul data-sourcepos="1:1-3:7" class="task-list" dir="auto"> + <li data-sourcepos="1:1-1:10" class="task-list-item"> + <input type="checkbox" class="task-list-item-checkbox" disabled> + <a href="/gitlab-org/gitlab-test/-/issues/48" data-original="#48+" data-link="false" data-link-reference="false" data-project="1" data-issue="2" data-reference-format="+" data-reference-type="task" data-container="body" data-placement="top" title="1" class="gfm gfm-issue has-tooltip" data-issue-type="issue">1 (#48)</a> </li> <li data-sourcepos="2:1-2:7" class="task-list-item"> <input type="checkbox" class="task-list-item-checkbox" disabled> 2 diff --git a/spec/frontend/issues/show/utils_spec.js b/spec/frontend/issues/show/utils_spec.js new file mode 100644 index 00000000000..e5f14cfc01a --- /dev/null +++ b/spec/frontend/issues/show/utils_spec.js @@ -0,0 +1,40 @@ +import { convertDescriptionWithNewSort } from '~/issues/show/utils'; + +describe('app/assets/javascripts/issues/show/utils.js', () => { + describe('convertDescriptionWithNewSort', () => { + it('converts markdown description with new list sort order', () => { + const description = `I am text + +- Item 1 +- Item 2 + - Item 3 + - Item 4 +- Item 5`; + + // Drag Item 2 + children to Item 1's position + const html = `<ul data-sourcepos="3:1-8:0"> + <li data-sourcepos="4:1-4:8"> + Item 2 + <ul data-sourcepos="5:1-6:10"> + <li data-sourcepos="5:1-5:10">Item 3</li> + <li data-sourcepos="6:1-6:10">Item 4</li> + </ul> + </li> + <li data-sourcepos="3:1-3:8">Item 1</li> + <li data-sourcepos="7:1-8:0">Item 5</li> + <ul>`; + const list = document.createElement('div'); + list.innerHTML = html; + + const expected = `I am text + +- Item 2 + - Item 3 + - Item 4 +- Item 1 +- Item 5`; + + expect(convertDescriptionWithNewSort(description, list.firstChild)).toBe(expected); + }); + }); +}); diff --git a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js index 3d7bf7acb41..5df54abfc05 100644 --- a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js @@ -7,21 +7,35 @@ import * as JiraConnectApi from '~/jira_connect/subscriptions/api'; import GroupItemName from '~/jira_connect/subscriptions/components/group_item_name.vue'; import GroupsListItem from '~/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue'; import { persistAlert, reloadPage } from '~/jira_connect/subscriptions/utils'; +import { + I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE, + I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE, + INTEGRATIONS_DOC_LINK, +} from '~/jira_connect/subscriptions/constants'; +import createStore from '~/jira_connect/subscriptions/store'; import { mockGroup1 } from '../../mock_data'; jest.mock('~/jira_connect/subscriptions/utils'); describe('GroupsListItem', () => { let wrapper; - const mockSubscriptionPath = 'subscriptionPath'; + let store; + + const mockAddSubscriptionsPath = '/addSubscriptionsPath'; + + const createComponent = ({ mountFn = shallowMount, provide } = {}) => { + store = createStore(); + + jest.spyOn(store, 'dispatch').mockImplementation(); - const createComponent = ({ mountFn = shallowMount } = {}) => { wrapper = mountFn(GroupsListItem, { + store, propsData: { group: mockGroup1, }, provide: { - subscriptionsPath: mockSubscriptionPath, + addSubscriptionsPath: mockAddSubscriptionsPath, + ...provide, }, }); }; @@ -51,62 +65,88 @@ describe('GroupsListItem', () => { }); describe('on Link button click', () => { - let addSubscriptionSpy; + describe('when jiraConnectOauth feature flag is disabled', () => { + let addSubscriptionSpy; - beforeEach(() => { - createComponent({ mountFn: mount }); + beforeEach(() => { + createComponent({ mountFn: mount }); - addSubscriptionSpy = jest.spyOn(JiraConnectApi, 'addSubscription').mockResolvedValue(); - }); + addSubscriptionSpy = jest.spyOn(JiraConnectApi, 'addSubscription').mockResolvedValue(); + }); - it('sets button to loading and sends request', async () => { - expect(findLinkButton().props('loading')).toBe(false); + it('sets button to loading and sends request', async () => { + expect(findLinkButton().props('loading')).toBe(false); + + clickLinkButton(); + await nextTick(); - clickLinkButton(); + expect(findLinkButton().props('loading')).toBe(true); + await waitForPromises(); - await nextTick(); + expect(addSubscriptionSpy).toHaveBeenCalledWith( + mockAddSubscriptionsPath, + mockGroup1.full_path, + ); + expect(persistAlert).toHaveBeenCalledWith({ + linkUrl: INTEGRATIONS_DOC_LINK, + message: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE, + title: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE, + variant: 'success', + }); + }); - expect(findLinkButton().props('loading')).toBe(true); + describe('when request is successful', () => { + it('reloads the page', async () => { + clickLinkButton(); - await waitForPromises(); + await waitForPromises(); - expect(addSubscriptionSpy).toHaveBeenCalledWith(mockSubscriptionPath, mockGroup1.full_path); - expect(persistAlert).toHaveBeenCalledWith({ - linkUrl: '/help/integration/jira_development_panel.html#use-the-integration', - message: - 'You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}', - title: 'Namespace successfully linked', - variant: 'success', + expect(reloadPage).toHaveBeenCalled(); + }); }); - }); - describe('when request is successful', () => { - it('reloads the page', async () => { - clickLinkButton(); + describe('when request has errors', () => { + const mockErrorMessage = 'error message'; + const mockError = { response: { data: { error: mockErrorMessage } } }; - await waitForPromises(); + beforeEach(() => { + addSubscriptionSpy = jest + .spyOn(JiraConnectApi, 'addSubscription') + .mockRejectedValue(mockError); + }); - expect(reloadPage).toHaveBeenCalled(); + it('emits `error` event', async () => { + clickLinkButton(); + + await waitForPromises(); + + expect(reloadPage).not.toHaveBeenCalled(); + expect(wrapper.emitted('error')[0][0]).toBe(mockErrorMessage); + }); }); }); - describe('when request has errors', () => { - const mockErrorMessage = 'error message'; - const mockError = { response: { data: { error: mockErrorMessage } } }; + describe('when jiraConnectOauth feature flag is enabled', () => { + const mockSubscriptionsPath = '/subscriptions'; beforeEach(() => { - addSubscriptionSpy = jest - .spyOn(JiraConnectApi, 'addSubscription') - .mockRejectedValue(mockError); + createComponent({ + mountFn: mount, + provide: { + subscriptionsPath: mockSubscriptionsPath, + glFeatures: { jiraConnectOauth: true }, + }, + }); }); - it('emits `error` event', async () => { + it('dispatches `addSubscription` action', async () => { clickLinkButton(); + await nextTick(); - await waitForPromises(); - - expect(reloadPage).not.toHaveBeenCalled(); - expect(wrapper.emitted('error')[0][0]).toBe(mockErrorMessage); + expect(store.dispatch).toHaveBeenCalledWith('addSubscription', { + namespacePath: mockGroup1.full_path, + subscriptionsPath: mockSubscriptionsPath, + }); }); }); }); diff --git a/spec/frontend/jira_connect/subscriptions/components/app_spec.js b/spec/frontend/jira_connect/subscriptions/components/app_spec.js index ce02144f22f..9894141be5a 100644 --- a/spec/frontend/jira_connect/subscriptions/components/app_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/app_spec.js @@ -3,8 +3,8 @@ import { nextTick } from 'vue'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import JiraConnectApp from '~/jira_connect/subscriptions/components/app.vue'; -import SignInPage from '~/jira_connect/subscriptions/pages/sign_in.vue'; -import SubscriptionsPage from '~/jira_connect/subscriptions/pages/subscriptions.vue'; +import SignInPage from '~/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue'; +import SubscriptionsPage from '~/jira_connect/subscriptions/pages/subscriptions_page.vue'; import UserLink from '~/jira_connect/subscriptions/components/user_link.vue'; import BrowserSupportAlert from '~/jira_connect/subscriptions/components/browser_support_alert.vue'; import createStore from '~/jira_connect/subscriptions/store'; @@ -12,6 +12,7 @@ import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types'; import { I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE } from '~/jira_connect/subscriptions/constants'; import { __ } from '~/locale'; import AccessorUtilities from '~/lib/utils/accessor'; +import * as api from '~/jira_connect/subscriptions/api'; import { mockSubscription } from '../mock_data'; jest.mock('~/jira_connect/subscriptions/utils', () => ({ @@ -31,7 +32,8 @@ describe('JiraConnectApp', () => { const findBrowserSupportAlert = () => wrapper.findComponent(BrowserSupportAlert); const createComponent = ({ provide, mountFn = shallowMountExtended } = {}) => { - store = createStore(); + store = createStore({ subscriptions: [mockSubscription] }); + jest.spyOn(store, 'dispatch').mockImplementation(); wrapper = mountFn(JiraConnectApp, { store, @@ -53,7 +55,6 @@ describe('JiraConnectApp', () => { createComponent({ provide: { usersPath, - subscriptions: [mockSubscription], }, }); }); @@ -79,14 +80,13 @@ describe('JiraConnectApp', () => { createComponent({ provide: { usersPath: '/user', - subscriptions: [], }, }); const userLink = findUserLink(); expect(userLink.exists()).toBe(true); expect(userLink.props()).toEqual({ - hasSubscriptions: false, + hasSubscriptions: true, user: null, userSignedIn: false, }); @@ -161,39 +161,11 @@ describe('JiraConnectApp', () => { }); describe('when user signed out', () => { - describe('when sign in page emits `sign-in-oauth` event', () => { - const mockUser = { name: 'test' }; - beforeEach(async () => { - createComponent({ - provide: { - usersPath: '/mock', - subscriptions: [], - }, - }); - findSignInPage().vm.$emit('sign-in-oauth', mockUser); - - await nextTick(); - }); - - it('hides sign in page and renders subscriptions page', () => { - expect(findSignInPage().exists()).toBe(false); - expect(findSubscriptionsPage().exists()).toBe(true); - }); - - it('sets correct UserLink props', () => { - expect(findUserLink().props()).toMatchObject({ - user: mockUser, - userSignedIn: true, - }); - }); - }); - describe('when sign in page emits `error` event', () => { beforeEach(async () => { createComponent({ provide: { usersPath: '/mock', - subscriptions: [], }, }); findSignInPage().vm.$emit('error'); @@ -235,4 +207,31 @@ describe('JiraConnectApp', () => { }); }, ); + + describe('when `jiraConnectOauth` feature flag is enabled', () => { + const mockSubscriptionsPath = '/mockSubscriptionsPath'; + + beforeEach(() => { + jest.spyOn(api, 'fetchSubscriptions').mockResolvedValue({ data: { subscriptions: [] } }); + + createComponent({ + provide: { + glFeatures: { jiraConnectOauth: true }, + subscriptionsPath: mockSubscriptionsPath, + }, + }); + }); + + describe('when component mounts', () => { + it('dispatches `fetchSubscriptions` action', async () => { + expect(store.dispatch).toHaveBeenCalledWith('fetchSubscriptions', mockSubscriptionsPath); + }); + }); + + describe('when oauth button emits `sign-in-oauth` event', () => { + it('dispatches `fetchSubscriptions` action', () => { + expect(store.dispatch).toHaveBeenCalledWith('fetchSubscriptions', mockSubscriptionsPath); + }); + }); + }); }); diff --git a/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js b/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js index 18274cd4362..8730e124ae7 100644 --- a/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js @@ -11,9 +11,14 @@ import axios from '~/lib/utils/axios_utils'; import waitForPromises from 'helpers/wait_for_promises'; import httpStatus from '~/lib/utils/http_status'; import AccessorUtilities from '~/lib/utils/accessor'; +import { getCurrentUser } from '~/rest_api'; +import createStore from '~/jira_connect/subscriptions/store'; +import { SET_ACCESS_TOKEN } from '~/jira_connect/subscriptions/store/mutation_types'; jest.mock('~/lib/utils/accessor'); jest.mock('~/jira_connect/subscriptions/utils'); +jest.mock('~/jira_connect/subscriptions/api'); +jest.mock('~/rest_api'); jest.mock('~/jira_connect/subscriptions/pkce', () => ({ createCodeVerifier: jest.fn().mockReturnValue('mock-verifier'), createCodeChallenge: jest.fn().mockResolvedValue('mock-challenge'), @@ -28,9 +33,15 @@ const mockOauthMetadata = { describe('SignInOauthButton', () => { let wrapper; let mockAxios; + let store; const createComponent = ({ slots } = {}) => { + store = createStore(); + jest.spyOn(store, 'dispatch').mockImplementation(); + jest.spyOn(store, 'commit').mockImplementation(); + wrapper = shallowMount(SignInOauthButton, { + store, slots, provide: { oauthMetadata: mockOauthMetadata, @@ -114,10 +125,6 @@ describe('SignInOauthButton', () => { await waitForPromises(); }); - it('emits `error` event', () => { - expect(wrapper.emitted('error')).toBeTruthy(); - }); - it('does not emit `sign-in` event', () => { expect(wrapper.emitted('sign-in')).toBeFalsy(); }); @@ -147,7 +154,7 @@ describe('SignInOauthButton', () => { mockAxios .onPost(mockOauthMetadata.oauth_token_url) .replyOnce(httpStatus.OK, { access_token: mockAccessToken }); - mockAxios.onGet('/api/v4/user').replyOnce(httpStatus.OK, mockUser); + getCurrentUser.mockResolvedValue({ data: mockUser }); window.dispatchEvent(new MessageEvent('message', mockEvent)); @@ -161,25 +168,25 @@ describe('SignInOauthButton', () => { }); }); - it('executes GET request to fetch user data', () => { - expect(axios.get).toHaveBeenCalledWith('/api/v4/user', { - headers: { Authorization: `Bearer ${mockAccessToken}` }, - }); + it('dispatches loadCurrentUser action', () => { + expect(store.dispatch).toHaveBeenCalledWith('loadCurrentUser', mockAccessToken); + }); + + it('commits SET_ACCESS_TOKEN mutation with correct access token', () => { + expect(store.commit).toHaveBeenCalledWith(SET_ACCESS_TOKEN, mockAccessToken); }); it('emits `sign-in` event with user data', () => { - expect(wrapper.emitted('sign-in')[0]).toEqual([mockUser]); + expect(wrapper.emitted('sign-in')[0]).toBeTruthy(); }); }); describe('when API requests fail', () => { beforeEach(async () => { jest.spyOn(axios, 'post'); - jest.spyOn(axios, 'get'); mockAxios .onPost(mockOauthMetadata.oauth_token_url) - .replyOnce(httpStatus.INTERNAL_SERVER_ERROR, { access_token: mockAccessToken }); - mockAxios.onGet('/api/v4/user').replyOnce(httpStatus.INTERNAL_SERVER_ERROR, mockUser); + .replyOnce(httpStatus.INTERNAL_SERVER_ERROR); window.dispatchEvent(new MessageEvent('message', mockEvent)); @@ -187,7 +194,7 @@ describe('SignInOauthButton', () => { }); it('emits `error` event', () => { - expect(wrapper.emitted('error')).toBeTruthy(); + expect(wrapper.emitted('error')[0]).toEqual([]); }); it('does not emit `sign-in` event', () => { diff --git a/spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js b/spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js index 2aad533f677..2d7c58fc278 100644 --- a/spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js @@ -20,12 +20,11 @@ describe('SubscriptionsList', () => { let store; const createComponent = () => { - store = createStore(); + store = createStore({ + subscriptions: [mockSubscription], + }); wrapper = mount(SubscriptionsList, { - provide: { - subscriptions: [mockSubscription], - }, store, }); }; diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com_spec.js index 97d1b077164..1649920b48b 100644 --- a/spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js +++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import SignInPage from '~/jira_connect/subscriptions/pages/sign_in.vue'; +import SignInGitlabCom from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue'; import SignInLegacyButton from '~/jira_connect/subscriptions/components/sign_in_legacy_button.vue'; import SignInOauthButton from '~/jira_connect/subscriptions/components/sign_in_oauth_button.vue'; import SubscriptionsList from '~/jira_connect/subscriptions/components/subscriptions_list.vue'; @@ -15,7 +15,7 @@ const defaultProvide = { usersPath: mockUsersPath, }; -describe('SignInPage', () => { +describe('SignInGitlabCom', () => { let wrapper; let store; @@ -26,7 +26,7 @@ describe('SignInPage', () => { const createComponent = ({ props, jiraConnectOauthEnabled } = {}) => { store = createStore(); - wrapper = shallowMount(SignInPage, { + wrapper = shallowMount(SignInGitlabCom, { store, provide: { ...defaultProvide, @@ -49,7 +49,7 @@ describe('SignInPage', () => { describe('template', () => { describe.each` scenario | hasSubscriptions | signInButtonText - ${'with subscriptions'} | ${true} | ${SignInPage.i18n.signInButtonTextWithSubscriptions} + ${'with subscriptions'} | ${true} | ${SignInGitlabCom.i18n.signInButtonTextWithSubscriptions} ${'without subscriptions'} | ${false} | ${I18N_DEFAULT_SIGN_IN_BUTTON_TEXT} `('$scenario', ({ hasSubscriptions, signInButtonText }) => { describe('when `jiraConnectOauthEnabled` feature flag is disabled', () => { diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index_spec.js new file mode 100644 index 00000000000..f4be8bf121d --- /dev/null +++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index_spec.js @@ -0,0 +1,83 @@ +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import SignInGitlabMultiversion from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue'; +import VersionSelectForm from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue'; +import SignInOauthButton from '~/jira_connect/subscriptions/components/sign_in_oauth_button.vue'; + +describe('SignInGitlabMultiversion', () => { + let wrapper; + + const findVersionSelectForm = () => wrapper.findComponent(VersionSelectForm); + const findSignInOauthButton = () => wrapper.findComponent(SignInOauthButton); + const findSubtitle = () => wrapper.findByTestId('subtitle'); + + const createComponent = () => { + wrapper = shallowMountExtended(SignInGitlabMultiversion); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when version is not selected', () => { + describe('VersionSelectForm', () => { + it('renders version select form', () => { + createComponent(); + + expect(findVersionSelectForm().exists()).toBe(true); + }); + + describe('when form emits "submit" event', () => { + it('hides the version select form and shows the sign in button', async () => { + createComponent(); + + findVersionSelectForm().vm.$emit('submit', 'gitlab.mycompany.com'); + await nextTick(); + + expect(findVersionSelectForm().exists()).toBe(false); + expect(findSignInOauthButton().exists()).toBe(true); + }); + }); + }); + }); + + describe('when version is selected', () => { + beforeEach(async () => { + createComponent(); + + findVersionSelectForm().vm.$emit('submit', 'gitlab.mycompany.com'); + await nextTick(); + }); + + describe('sign in button', () => { + it('renders sign in button', () => { + expect(findSignInOauthButton().exists()).toBe(true); + }); + + describe('when button emits `sign-in` event', () => { + it('emits `sign-in-oauth` event', () => { + const button = findSignInOauthButton(); + + const mockUser = { name: 'test' }; + button.vm.$emit('sign-in', mockUser); + + expect(wrapper.emitted('sign-in-oauth')[0]).toEqual([mockUser]); + }); + }); + + describe('when button emits `error` event', () => { + it('emits `error` event', () => { + const button = findSignInOauthButton(); + button.vm.$emit('error'); + + expect(wrapper.emitted('error')).toBeTruthy(); + }); + }); + }); + + it('renders correct subtitle', () => { + expect(findSubtitle().text()).toBe(SignInGitlabMultiversion.i18n.signInSubtitle); + }); + }); +}); diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form_spec.js new file mode 100644 index 00000000000..29e7fe7a5b2 --- /dev/null +++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form_spec.js @@ -0,0 +1,69 @@ +import { GlFormInput, GlFormRadioGroup, GlForm } from '@gitlab/ui'; +import { nextTick } from 'vue'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import VersionSelectForm from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue'; + +describe('VersionSelectForm', () => { + let wrapper; + + const findFormRadioGroup = () => wrapper.findComponent(GlFormRadioGroup); + const findForm = () => wrapper.findComponent(GlForm); + const findInput = () => wrapper.findComponent(GlFormInput); + + const submitForm = () => findForm().vm.$emit('submit', new Event('submit')); + + const createComponent = () => { + wrapper = shallowMountExtended(VersionSelectForm); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default state', () => { + beforeEach(() => { + createComponent(); + }); + + it('selects saas radio option by default', () => { + expect(findFormRadioGroup().vm.$attrs.checked).toBe(VersionSelectForm.radioOptions.saas); + }); + + it('does not render instance input', () => { + expect(findInput().exists()).toBe(false); + }); + + describe('when form is submitted', () => { + it('emits "submit" event with gitlab.com as the payload', () => { + submitForm(); + + expect(wrapper.emitted('submit')[0][0]).toBe('https://gitlab.com'); + }); + }); + }); + + describe('when "self-managed" radio option is selected', () => { + beforeEach(async () => { + createComponent(); + + findFormRadioGroup().vm.$emit('input', VersionSelectForm.radioOptions.selfManaged); + await nextTick(); + }); + + it('reveals the self-managed input field', () => { + expect(findInput().exists()).toBe(true); + }); + + describe('when form is submitted', () => { + it('emits "submit" event with the input field value as the payload', () => { + const mockInstanceUrl = 'https://gitlab.example.com'; + + findInput().vm.$emit('input', mockInstanceUrl); + submitForm(); + + expect(wrapper.emitted('submit')[0][0]).toBe(mockInstanceUrl); + }); + }); + }); +}); diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_page_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_page_spec.js new file mode 100644 index 00000000000..65b08fba592 --- /dev/null +++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_page_spec.js @@ -0,0 +1,82 @@ +import { shallowMount } from '@vue/test-utils'; + +import SignInPage from '~/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue'; +import SignInGitlabCom from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue'; +import SignInGitlabMultiversion from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue'; +import createStore from '~/jira_connect/subscriptions/store'; + +describe('SignInPage', () => { + let wrapper; + let store; + + const findSignInGitlabCom = () => wrapper.findComponent(SignInGitlabCom); + const findSignInGitabMultiversion = () => wrapper.findComponent(SignInGitlabMultiversion); + + const createComponent = ({ + props = {}, + jiraConnectOauthEnabled, + jiraConnectOauthSelfManagedEnabled, + } = {}) => { + store = createStore(); + + wrapper = shallowMount(SignInPage, { + store, + provide: { + glFeatures: { + jiraConnectOauth: jiraConnectOauthEnabled, + jiraConnectOauthSelfManaged: jiraConnectOauthSelfManagedEnabled, + }, + }, + propsData: { hasSubscriptions: false, ...props }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + jiraConnectOauthEnabled | jiraConnectOauthSelfManagedEnabled | shouldRenderDotCom | shouldRenderMultiversion + ${false} | ${false} | ${true} | ${false} + ${false} | ${true} | ${true} | ${false} + ${true} | ${false} | ${true} | ${false} + ${true} | ${true} | ${false} | ${true} + `( + 'renders correct component when jiraConnectOauth is $jiraConnectOauthEnabled and jiraConnectOauthSelfManaged is $jiraConnectOauthSelfManagedEnabled', + ({ + jiraConnectOauthEnabled, + jiraConnectOauthSelfManagedEnabled, + shouldRenderDotCom, + shouldRenderMultiversion, + }) => { + createComponent({ jiraConnectOauthEnabled, jiraConnectOauthSelfManagedEnabled }); + + expect(findSignInGitlabCom().exists()).toBe(shouldRenderDotCom); + expect(findSignInGitabMultiversion().exists()).toBe(shouldRenderMultiversion); + }, + ); + + describe('when jiraConnectOauthSelfManaged is false', () => { + beforeEach(() => { + createComponent({ jiraConnectOauthSelfManaged: false, props: { hasSubscriptions: true } }); + }); + + it('renders SignInGitlabCom with correct props', () => { + expect(findSignInGitlabCom().props()).toEqual({ hasSubscriptions: true }); + }); + + describe('when error event is emitted', () => { + it('emits another error event', () => { + findSignInGitlabCom().vm.$emit('error'); + expect(wrapper.emitted('error')[0]).toBeTruthy(); + }); + }); + + describe('when sign-in-oauth event is emitted', () => { + it('emits another sign-in-oauth event', () => { + findSignInGitlabCom().vm.$emit('sign-in-oauth'); + expect(wrapper.emitted('sign-in-oauth')[0]).toEqual([]); + }); + }); + }); +}); diff --git a/spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js b/spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js new file mode 100644 index 00000000000..4956af76ead --- /dev/null +++ b/spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js @@ -0,0 +1,71 @@ +import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import SubscriptionsPage from '~/jira_connect/subscriptions/pages/subscriptions_page.vue'; +import AddNamespaceButton from '~/jira_connect/subscriptions/components/add_namespace_button.vue'; +import SubscriptionsList from '~/jira_connect/subscriptions/components/subscriptions_list.vue'; +import createStore from '~/jira_connect/subscriptions/store'; + +describe('SubscriptionsPage', () => { + let wrapper; + let store; + + const findAddNamespaceButton = () => wrapper.findComponent(AddNamespaceButton); + const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findSubscriptionsList = () => wrapper.findComponent(SubscriptionsList); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + const createComponent = ({ props, initialState } = {}) => { + store = createStore(initialState); + + wrapper = shallowMount(SubscriptionsPage, { + store, + propsData: { hasSubscriptions: false, ...props }, + stubs: { + GlEmptyState, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + describe.each` + scenario | subscriptionsLoading | hasSubscriptions | expectSubscriptionsList | expectEmptyState + ${'with subscriptions loading'} | ${true} | ${false} | ${false} | ${false} + ${'with subscriptions'} | ${false} | ${true} | ${true} | ${false} + ${'without subscriptions'} | ${false} | ${false} | ${false} | ${true} + `( + '$scenario', + ({ subscriptionsLoading, hasSubscriptions, expectEmptyState, expectSubscriptionsList }) => { + beforeEach(() => { + createComponent({ + initialState: { subscriptionsLoading }, + props: { + hasSubscriptions, + }, + }); + }); + + it(`${ + subscriptionsLoading ? 'does not render' : 'renders' + } button to add namespace`, () => { + expect(findAddNamespaceButton().exists()).toBe(!subscriptionsLoading); + }); + + it(`${subscriptionsLoading ? 'renders' : 'does not render'} GlLoadingIcon`, () => { + expect(findGlLoadingIcon().exists()).toBe(subscriptionsLoading); + }); + + it(`${expectEmptyState ? 'renders' : 'does not render'} empty state`, () => { + expect(findEmptyState().exists()).toBe(expectEmptyState); + }); + + it(`${expectSubscriptionsList ? 'renders' : 'does not render'} subscriptions list`, () => { + expect(findSubscriptionsList().exists()).toBe(expectSubscriptionsList); + }); + }, + ); + }); +}); diff --git a/spec/frontend/jira_connect/subscriptions/pages/subscriptions_spec.js b/spec/frontend/jira_connect/subscriptions/pages/subscriptions_spec.js deleted file mode 100644 index 198278efc1f..00000000000 --- a/spec/frontend/jira_connect/subscriptions/pages/subscriptions_spec.js +++ /dev/null @@ -1,56 +0,0 @@ -import { GlEmptyState } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import SubscriptionsPage from '~/jira_connect/subscriptions/pages/subscriptions.vue'; -import AddNamespaceButton from '~/jira_connect/subscriptions/components/add_namespace_button.vue'; -import SubscriptionsList from '~/jira_connect/subscriptions/components/subscriptions_list.vue'; -import createStore from '~/jira_connect/subscriptions/store'; - -describe('SubscriptionsPage', () => { - let wrapper; - let store; - - const findAddNamespaceButton = () => wrapper.findComponent(AddNamespaceButton); - const findSubscriptionsList = () => wrapper.findComponent(SubscriptionsList); - const findEmptyState = () => wrapper.findComponent(GlEmptyState); - - const createComponent = ({ props } = {}) => { - store = createStore(); - - wrapper = shallowMount(SubscriptionsPage, { - store, - propsData: props, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - describe('template', () => { - describe.each` - scenario | expectSubscriptionsList | expectEmptyState - ${'with subscriptions'} | ${true} | ${false} - ${'without subscriptions'} | ${false} | ${true} - `('$scenario', ({ expectEmptyState, expectSubscriptionsList }) => { - beforeEach(() => { - createComponent({ - props: { - hasSubscriptions: expectSubscriptionsList, - }, - }); - }); - - it('renders button to add namespace', () => { - expect(findAddNamespaceButton().exists()).toBe(true); - }); - - it(`${expectEmptyState ? 'renders' : 'does not render'} empty state`, () => { - expect(findEmptyState().exists()).toBe(expectEmptyState); - }); - - it(`${expectSubscriptionsList ? 'renders' : 'does not render'} subscriptions list`, () => { - expect(findSubscriptionsList().exists()).toBe(expectSubscriptionsList); - }); - }); - }); -}); diff --git a/spec/frontend/jira_connect/subscriptions/store/actions_spec.js b/spec/frontend/jira_connect/subscriptions/store/actions_spec.js new file mode 100644 index 00000000000..53b5d8e70af --- /dev/null +++ b/spec/frontend/jira_connect/subscriptions/store/actions_spec.js @@ -0,0 +1,172 @@ +import testAction from 'helpers/vuex_action_helper'; + +import * as types from '~/jira_connect/subscriptions/store/mutation_types'; +import { + fetchSubscriptions, + loadCurrentUser, + addSubscription, +} from '~/jira_connect/subscriptions/store/actions'; +import state from '~/jira_connect/subscriptions/store/state'; +import * as api from '~/jira_connect/subscriptions/api'; +import * as userApi from '~/api/user_api'; +import * as integrationsApi from '~/api/integrations_api'; +import { + I18N_DEFAULT_SUBSCRIPTIONS_ERROR_MESSAGE, + I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE, + I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE, + INTEGRATIONS_DOC_LINK, +} from '~/jira_connect/subscriptions/constants'; +import * as utils from '~/jira_connect/subscriptions/utils'; + +describe('JiraConnect actions', () => { + let mockedState; + + beforeEach(() => { + mockedState = state(); + }); + + describe('fetchSubscriptions', () => { + const mockUrl = '/mock-url'; + + describe('when API request is successful', () => { + it('should commit SET_SUBSCRIPTIONS_LOADING and SET_SUBSCRIPTIONS mutations', async () => { + jest.spyOn(api, 'fetchSubscriptions').mockResolvedValue({ data: { subscriptions: [] } }); + + await testAction( + fetchSubscriptions, + mockUrl, + mockedState, + [ + { type: types.SET_SUBSCRIPTIONS_LOADING, payload: true }, + { type: types.SET_SUBSCRIPTIONS, payload: [] }, + { type: types.SET_SUBSCRIPTIONS_LOADING, payload: false }, + ], + [], + ); + + expect(api.fetchSubscriptions).toHaveBeenCalledWith(mockUrl); + }); + }); + + describe('when API request fails', () => { + it('should commit SET_SUBSCRIPTIONS_LOADING, SET_SUBSCRIPTIONS_ERROR and SET_ALERT mutations', async () => { + jest.spyOn(api, 'fetchSubscriptions').mockRejectedValue(); + + await testAction( + fetchSubscriptions, + mockUrl, + mockedState, + [ + { type: types.SET_SUBSCRIPTIONS_LOADING, payload: true }, + { type: types.SET_SUBSCRIPTIONS_ERROR, payload: true }, + { + type: types.SET_ALERT, + payload: { message: I18N_DEFAULT_SUBSCRIPTIONS_ERROR_MESSAGE, variant: 'danger' }, + }, + { type: types.SET_SUBSCRIPTIONS_LOADING, payload: false }, + ], + [], + ); + + expect(api.fetchSubscriptions).toHaveBeenCalledWith(mockUrl); + }); + }); + }); + + describe('loadCurrentUser', () => { + const mockAccessToken = 'abcd1234'; + + describe('when API request succeeds', () => { + it('commits the SET_ACCESS_TOKEN and SET_CURRENT_USER mutations', async () => { + const mockUser = { name: 'root' }; + jest.spyOn(userApi, 'getCurrentUser').mockResolvedValue({ data: mockUser }); + + await testAction( + loadCurrentUser, + mockAccessToken, + mockedState, + [{ type: types.SET_CURRENT_USER, payload: mockUser }], + [], + ); + + expect(userApi.getCurrentUser).toHaveBeenCalledWith({ + headers: { Authorization: `Bearer ${mockAccessToken}` }, + }); + }); + }); + + describe('when API request fails', () => { + it('commits the SET_CURRENT_USER_ERROR mutation', async () => { + jest.spyOn(userApi, 'getCurrentUser').mockRejectedValue(); + + await testAction( + loadCurrentUser, + mockAccessToken, + mockedState, + [{ type: types.SET_CURRENT_USER_ERROR }], + [], + ); + }); + }); + }); + + describe('addSubscription', () => { + const mockNamespace = 'gitlab-org/gitlab'; + const mockSubscriptionsPath = '/subscriptions'; + + beforeEach(() => { + jest.spyOn(utils, 'getJwt').mockReturnValue('1234'); + }); + + describe('when API request succeeds', () => { + it('commits the SET_ACCESS_TOKEN and SET_CURRENT_USER mutations', async () => { + jest + .spyOn(integrationsApi, 'addJiraConnectSubscription') + .mockResolvedValue({ success: true }); + + await testAction( + addSubscription, + { namespacePath: mockNamespace, subscriptionsPath: mockSubscriptionsPath }, + mockedState, + [ + { type: types.ADD_SUBSCRIPTION_LOADING, payload: true }, + { + type: types.SET_ALERT, + payload: { + title: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE, + message: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE, + linkUrl: INTEGRATIONS_DOC_LINK, + variant: 'success', + }, + }, + { type: types.ADD_SUBSCRIPTION_LOADING, payload: false }, + ], + [{ type: 'fetchSubscriptions', payload: mockSubscriptionsPath }], + ); + + expect(integrationsApi.addJiraConnectSubscription).toHaveBeenCalledWith(mockNamespace, { + accessToken: null, + jwt: '1234', + }); + }); + }); + + describe('when API request fails', () => { + it('commits the SET_CURRENT_USER_ERROR mutation', async () => { + jest.spyOn(integrationsApi, 'addJiraConnectSubscription').mockRejectedValue(); + + await testAction( + addSubscription, + mockNamespace, + mockedState, + [ + { type: types.ADD_SUBSCRIPTION_LOADING, payload: true }, + { type: types.ADD_SUBSCRIPTION_ERROR }, + { type: types.ADD_SUBSCRIPTION_LOADING, payload: false }, + ], + [], + ); + }); + }); + }); +}); diff --git a/spec/frontend/jira_connect/subscriptions/store/mutations_spec.js b/spec/frontend/jira_connect/subscriptions/store/mutations_spec.js index 84a33dbf0b5..aeb136a76b9 100644 --- a/spec/frontend/jira_connect/subscriptions/store/mutations_spec.js +++ b/spec/frontend/jira_connect/subscriptions/store/mutations_spec.js @@ -25,4 +25,71 @@ describe('JiraConnect store mutations', () => { }); }); }); + + describe('SET_SUBSCRIPTIONS', () => { + it('sets subscriptions loading flag', () => { + const mockSubscriptions = [{ name: 'test' }]; + mutations.SET_SUBSCRIPTIONS(localState, mockSubscriptions); + + expect(localState.subscriptions).toBe(mockSubscriptions); + }); + }); + + describe('SET_SUBSCRIPTIONS_LOADING', () => { + it('sets subscriptions loading flag', () => { + mutations.SET_SUBSCRIPTIONS_LOADING(localState, true); + + expect(localState.subscriptionsLoading).toBe(true); + }); + }); + + describe('SET_SUBSCRIPTIONS_ERROR', () => { + it('sets subscriptions error', () => { + mutations.SET_SUBSCRIPTIONS_ERROR(localState, true); + + expect(localState.subscriptionsError).toBe(true); + }); + }); + + describe('ADD_SUBSCRIPTION_LOADING', () => { + it('sets addSubscriptionLoading', () => { + mutations.ADD_SUBSCRIPTION_LOADING(localState, true); + + expect(localState.addSubscriptionLoading).toBe(true); + }); + }); + + describe('ADD_SUBSCRIPTION_ERROR', () => { + it('sets addSubscriptionError', () => { + mutations.ADD_SUBSCRIPTION_ERROR(localState, true); + + expect(localState.addSubscriptionError).toBe(true); + }); + }); + + describe('SET_CURRENT_USER', () => { + it('sets currentUser', () => { + const mockUser = { name: 'root' }; + mutations.SET_CURRENT_USER(localState, mockUser); + + expect(localState.currentUser).toBe(mockUser); + }); + }); + + describe('SET_CURRENT_USER_ERROR', () => { + it('sets currentUserError', () => { + mutations.SET_CURRENT_USER_ERROR(localState, true); + + expect(localState.currentUserError).toBe(true); + }); + }); + + describe('SET_ACCESS_TOKEN', () => { + it('sets accessToken', () => { + const mockAccessToken = 'asdf1234'; + mutations.SET_ACCESS_TOKEN(localState, mockAccessToken); + + expect(localState.accessToken).toBe(mockAccessToken); + }); + }); }); diff --git a/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js b/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js index ce8e482cc16..92ce3925a90 100644 --- a/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js +++ b/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js @@ -21,6 +21,7 @@ describe('Job Status Token', () => { value: { data: '', }, + cursorPosition: 'start', }; const createComponent = () => { diff --git a/spec/frontend/jobs/components/job_app_spec.js b/spec/frontend/jobs/components/job_app_spec.js index 9abe66b4696..fc308766ab9 100644 --- a/spec/frontend/jobs/components/job_app_spec.js +++ b/spec/frontend/jobs/components/job_app_spec.js @@ -129,7 +129,9 @@ describe('Job App', () => { const aYearAgo = new Date(); aYearAgo.setFullYear(aYearAgo.getFullYear() - 1); - return setupAndMount({ jobData: { started: aYearAgo.toISOString() } }); + return setupAndMount({ + jobData: { started: aYearAgo.toISOString(), started_at: aYearAgo.toISOString() }, + }); }); it('should render provided job information', () => { diff --git a/spec/frontend/jobs/components/stuck_block_spec.js b/spec/frontend/jobs/components/stuck_block_spec.js index 4db73eaaaec..1580ed45e46 100644 --- a/spec/frontend/jobs/components/stuck_block_spec.js +++ b/spec/frontend/jobs/components/stuck_block_spec.js @@ -32,7 +32,7 @@ describe('Stuck Block Job component', () => { describe('with no runners for project', () => { beforeEach(() => { createWrapper({ - hasNoRunnersForProject: true, + hasOfflineRunnersForProject: true, runnersPath: '/root/project/runners#js-runners-settings', }); }); @@ -53,7 +53,7 @@ describe('Stuck Block Job component', () => { describe('with tags', () => { beforeEach(() => { createWrapper({ - hasNoRunnersForProject: false, + hasOfflineRunnersForProject: false, tags, runnersPath: '/root/project/runners#js-runners-settings', }); @@ -81,7 +81,7 @@ describe('Stuck Block Job component', () => { describe('without active runners', () => { beforeEach(() => { createWrapper({ - hasNoRunnersForProject: false, + hasOfflineRunnersForProject: false, runnersPath: '/root/project/runners#js-runners-settings', }); }); diff --git a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js index 263698e94e1..976b128532d 100644 --- a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js +++ b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js @@ -1,8 +1,12 @@ import { GlModal } from '@gitlab/ui'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { redirectTo } from '~/lib/utils/url_utility'; import ActionsCell from '~/jobs/components/table/cells/actions_cell.vue'; +import eventHub from '~/jobs/components/table/event_hub'; import JobPlayMutation from '~/jobs/components/table/graphql/mutations/job_play.mutation.graphql'; import JobRetryMutation from '~/jobs/components/table/graphql/mutations/job_retry.mutation.graphql'; import JobUnscheduleMutation from '~/jobs/components/table/graphql/mutations/job_unschedule.mutation.graphql'; @@ -15,11 +19,18 @@ import { cannotRetryJob, cannotPlayJob, cannotPlayScheduledJob, + retryMutationResponse, + playMutationResponse, + cancelMutationResponse, + unscheduleMutationResponse, } from '../../../mock_data'; +jest.mock('~/lib/utils/url_utility'); + +Vue.use(VueApollo); + describe('Job actions cell', () => { let wrapper; - let mutate; const findRetryButton = () => wrapper.findByTestId('retry'); const findPlayButton = () => wrapper.findByTestId('play'); @@ -31,29 +42,27 @@ describe('Job actions cell', () => { const findModal = () => wrapper.findComponent(GlModal); - const MUTATION_SUCCESS = { data: { JobRetryMutation: { jobId: retryableJob.id } } }; - const MUTATION_SUCCESS_UNSCHEDULE = { - data: { JobUnscheduleMutation: { jobId: scheduledJob.id } }, - }; - const MUTATION_SUCCESS_PLAY = { data: { JobPlayMutation: { jobId: playableJob.id } } }; - const MUTATION_SUCCESS_CANCEL = { data: { JobCancelMutation: { jobId: cancelableJob.id } } }; + const playMutationHandler = jest.fn().mockResolvedValue(playMutationResponse); + const retryMutationHandler = jest.fn().mockResolvedValue(retryMutationResponse); + const unscheduleMutationHandler = jest.fn().mockResolvedValue(unscheduleMutationResponse); + const cancelMutationHandler = jest.fn().mockResolvedValue(cancelMutationResponse); const $toast = { show: jest.fn(), }; - const createComponent = (jobType, mutationType = MUTATION_SUCCESS, props = {}) => { - mutate = jest.fn().mockResolvedValue(mutationType); + const createMockApolloProvider = (requestHandlers) => { + return createMockApollo(requestHandlers); + }; + const createComponent = (jobType, requestHandlers, props = {}) => { wrapper = shallowMountExtended(ActionsCell, { propsData: { job: jobType, ...props, }, + apolloProvider: createMockApolloProvider(requestHandlers), mocks: { - $apollo: { - mutate, - }, $toast, }, }); @@ -101,24 +110,59 @@ describe('Job actions cell', () => { }); it.each` - button | mutationResult | action | jobType | mutationFile - ${findPlayButton} | ${MUTATION_SUCCESS_PLAY} | ${'play'} | ${playableJob} | ${JobPlayMutation} - ${findRetryButton} | ${MUTATION_SUCCESS} | ${'retry'} | ${retryableJob} | ${JobRetryMutation} - ${findCancelButton} | ${MUTATION_SUCCESS_CANCEL} | ${'cancel'} | ${cancelableJob} | ${JobCancelMutation} - `('performs the $action mutation', ({ button, mutationResult, jobType, mutationFile }) => { - createComponent(jobType, mutationResult); + button | action | jobType | mutationFile | handler | jobId + ${findPlayButton} | ${'play'} | ${playableJob} | ${JobPlayMutation} | ${playMutationHandler} | ${playableJob.id} + ${findRetryButton} | ${'retry'} | ${retryableJob} | ${JobRetryMutation} | ${retryMutationHandler} | ${retryableJob.id} + ${findCancelButton} | ${'cancel'} | ${cancelableJob} | ${JobCancelMutation} | ${cancelMutationHandler} | ${cancelableJob.id} + `('performs the $action mutation', async ({ button, jobType, mutationFile, handler, jobId }) => { + createComponent(jobType, [[mutationFile, handler]]); button().vm.$emit('click'); - expect(mutate).toHaveBeenCalledWith({ - mutation: mutationFile, - variables: { - id: jobType.id, - }, - }); + expect(handler).toHaveBeenCalledWith({ id: jobId }); }); it.each` + button | action | jobType | mutationFile | handler + ${findUnscheduleButton} | ${'unschedule'} | ${scheduledJob} | ${JobUnscheduleMutation} | ${unscheduleMutationHandler} + ${findCancelButton} | ${'cancel'} | ${cancelableJob} | ${JobCancelMutation} | ${cancelMutationHandler} + `( + 'the mutation action $action emits the jobActionPerformed event', + async ({ button, jobType, mutationFile, handler }) => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + createComponent(jobType, [[mutationFile, handler]]); + + button().vm.$emit('click'); + + await waitForPromises(); + + expect(eventHub.$emit).toHaveBeenCalledWith('jobActionPerformed'); + expect(redirectTo).not.toHaveBeenCalled(); + }, + ); + + it.each` + button | action | jobType | mutationFile | handler | redirectLink + ${findPlayButton} | ${'play'} | ${playableJob} | ${JobPlayMutation} | ${playMutationHandler} | ${'/root/project/-/jobs/1986'} + ${findRetryButton} | ${'retry'} | ${retryableJob} | ${JobRetryMutation} | ${retryMutationHandler} | ${'/root/project/-/jobs/1985'} + `( + 'the mutation action $action redirects to the job', + async ({ button, jobType, mutationFile, handler, redirectLink }) => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + createComponent(jobType, [[mutationFile, handler]]); + + button().vm.$emit('click'); + + await waitForPromises(); + + expect(redirectTo).toHaveBeenCalledWith(redirectLink); + expect(eventHub.$emit).not.toHaveBeenCalled(); + }, + ); + + it.each` button | action | jobType ${findPlayButton} | ${'play'} | ${playableJob} ${findRetryButton} | ${'retry'} | ${retryableJob} @@ -152,20 +196,17 @@ describe('Job actions cell', () => { }); it('unschedules a job', () => { - createComponent(scheduledJob, MUTATION_SUCCESS_UNSCHEDULE); + createComponent(scheduledJob, [[JobUnscheduleMutation, unscheduleMutationHandler]]); findUnscheduleButton().vm.$emit('click'); - expect(mutate).toHaveBeenCalledWith({ - mutation: JobUnscheduleMutation, - variables: { - id: scheduledJob.id, - }, + expect(unscheduleMutationHandler).toHaveBeenCalledWith({ + id: scheduledJob.id, }); }); it('shows the play job confirmation modal', async () => { - createComponent(scheduledJob, MUTATION_SUCCESS); + createComponent(scheduledJob); findPlayScheduledJobButton().vm.$emit('click'); diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js index 27b6c04eded..4676635cce0 100644 --- a/spec/frontend/jobs/mock_data.js +++ b/spec/frontend/jobs/mock_data.js @@ -1928,3 +1928,75 @@ export const CIJobConnectionExistingCache = { }; export const mockFailedSearchToken = { type: 'status', value: { data: 'FAILED', operator: '=' } }; + +export const retryMutationResponse = { + data: { + jobRetry: { + job: { + __typename: 'CiJob', + id: '"gid://gitlab/Ci::Build/1985"', + detailedStatus: { + detailsPath: '/root/project/-/jobs/1985', + id: 'pending-1985-1985', + __typename: 'DetailedStatus', + }, + }, + errors: [], + __typename: 'JobRetryPayload', + }, + }, +}; + +export const playMutationResponse = { + data: { + jobPlay: { + job: { + __typename: 'CiJob', + id: '"gid://gitlab/Ci::Build/1986"', + detailedStatus: { + detailsPath: '/root/project/-/jobs/1986', + id: 'pending-1986-1986', + __typename: 'DetailedStatus', + }, + }, + errors: [], + __typename: 'JobRetryPayload', + }, + }, +}; + +export const cancelMutationResponse = { + data: { + jobCancel: { + job: { + __typename: 'CiJob', + id: '"gid://gitlab/Ci::Build/1987"', + detailedStatus: { + detailsPath: '/root/project/-/jobs/1987', + id: 'pending-1987-1987', + __typename: 'DetailedStatus', + }, + }, + errors: [], + __typename: 'JobRetryPayload', + }, + }, +}; + +export const unscheduleMutationResponse = { + data: { + jobUnschedule: { + job: { + __typename: 'CiJob', + id: '"gid://gitlab/Ci::Build/1988"', + detailedStatus: { + detailsPath: '/root/project/-/jobs/1988', + id: 'pending-1988-1988', + __typename: 'DetailedStatus', + }, + }, + errors: [], + __typename: 'JobRetryPayload', + }, + }, +}; diff --git a/spec/frontend/jobs/store/getters_spec.js b/spec/frontend/jobs/store/getters_spec.js index f26c0cf00fd..c13b051c672 100644 --- a/spec/frontend/jobs/store/getters_spec.js +++ b/spec/frontend/jobs/store/getters_spec.js @@ -10,16 +10,18 @@ describe('Job Store Getters', () => { describe('headerTime', () => { describe('when the job has started key', () => { - it('returns started key', () => { + it('returns started_at value', () => { const started = '2018-08-31T16:20:49.023Z'; + const startedAt = '2018-08-31T16:20:49.023Z'; + localState.job.started_at = startedAt; localState.job.started = started; - expect(getters.headerTime(localState)).toEqual(started); + expect(getters.headerTime(localState)).toEqual(startedAt); }); }); describe('when the job does not have started key', () => { - it('returns created_at key', () => { + it('returns created_at value', () => { const created = '2018-08-31T16:20:49.023Z'; localState.job.created_at = created; @@ -58,7 +60,7 @@ describe('Job Store Getters', () => { describe('shouldRenderTriggeredLabel', () => { describe('when started equals null', () => { it('returns false', () => { - localState.job.started = null; + localState.job.started_at = null; expect(getters.shouldRenderTriggeredLabel(localState)).toEqual(false); }); @@ -66,7 +68,7 @@ describe('Job Store Getters', () => { describe('when started equals string', () => { it('returns true', () => { - localState.job.started = '2018-08-31T16:20:49.023Z'; + localState.job.started_at = '2018-08-31T16:20:49.023Z'; expect(getters.shouldRenderTriggeredLabel(localState)).toEqual(true); }); @@ -206,7 +208,7 @@ describe('Job Store Getters', () => { }); }); - describe('hasRunnersForProject', () => { + describe('hasOfflineRunnersForProject', () => { describe('with available and offline runners', () => { it('returns true', () => { localState.job.runners = { @@ -214,7 +216,7 @@ describe('Job Store Getters', () => { online: false, }; - expect(getters.hasRunnersForProject(localState)).toEqual(true); + expect(getters.hasOfflineRunnersForProject(localState)).toEqual(true); }); }); @@ -225,7 +227,7 @@ describe('Job Store Getters', () => { online: false, }; - expect(getters.hasRunnersForProject(localState)).toEqual(false); + expect(getters.hasOfflineRunnersForProject(localState)).toEqual(false); }); }); @@ -236,7 +238,7 @@ describe('Job Store Getters', () => { online: true, }; - expect(getters.hasRunnersForProject(localState)).toEqual(false); + expect(getters.hasOfflineRunnersForProject(localState)).toEqual(false); }); }); }); diff --git a/spec/frontend/lib/dompurify_spec.js b/spec/frontend/lib/dompurify_spec.js index 47a94a4dcde..34325dad6a1 100644 --- a/spec/frontend/lib/dompurify_spec.js +++ b/spec/frontend/lib/dompurify_spec.js @@ -73,6 +73,16 @@ describe('~/lib/dompurify', () => { expect(sanitize('<p><gl-emoji>💯</gl-emoji></p>')).toBe('<p><gl-emoji>💯</gl-emoji></p>'); }); + it("doesn't allow style tags", () => { + // removes style tags + expect(sanitize('<style>p {width:50%;}</style>')).toBe(''); + expect(sanitize('<style type="text/css">p {width:50%;}</style>')).toBe(''); + // removes mstyle tag (this can removed later by disallowing math tags) + expect(sanitize('<math><mstyle displaystyle="true"></mstyle></math>')).toBe('<math></math>'); + // removes link tag (this is DOMPurify's default behavior) + expect(sanitize('<link rel="stylesheet" href="styles.css">')).toBe(''); + }); + describe.each` type | gon ${'root'} | ${rootGon} diff --git a/spec/frontend/lib/gfm/index_spec.js b/spec/frontend/lib/gfm/index_spec.js index 5c72b5a51a7..c9a480e9943 100644 --- a/spec/frontend/lib/gfm/index_spec.js +++ b/spec/frontend/lib/gfm/index_spec.js @@ -33,14 +33,16 @@ describe('gfm', () => { }); it('returns the result of executing the renderer function', async () => { + const rendered = { value: 'rendered tree' }; + const result = await render({ markdown: '<strong>This is bold text</strong>', renderer: () => { - return 'rendered tree'; + return rendered; }, }); - expect(result).toBe('rendered tree'); + expect(result).toEqual(rendered); }); }); }); diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js index 763a9bd30fe..8e499844406 100644 --- a/spec/frontend/lib/utils/common_utils_spec.js +++ b/spec/frontend/lib/utils/common_utils_spec.js @@ -283,6 +283,75 @@ describe('common_utils', () => { }); }); + describe('insertText', () => { + let textArea; + + beforeAll(() => { + textArea = document.createElement('textarea'); + document.querySelector('body').appendChild(textArea); + textArea.value = 'two'; + textArea.setSelectionRange(0, 0); + textArea.focus(); + }); + + afterAll(() => { + textArea.parentNode.removeChild(textArea); + }); + + describe('using execCommand', () => { + beforeAll(() => { + document.execCommand = jest.fn(() => true); + }); + + it('inserts the text', () => { + commonUtils.insertText(textArea, 'one'); + + expect(document.execCommand).toHaveBeenCalledWith('insertText', false, 'one'); + }); + + it('removes selected text', () => { + textArea.setSelectionRange(0, textArea.value.length); + + commonUtils.insertText(textArea, ''); + + expect(document.execCommand).toHaveBeenCalledWith('delete'); + }); + }); + + describe('using fallback', () => { + beforeEach(() => { + document.execCommand = jest.fn(() => false); + jest.spyOn(textArea, 'dispatchEvent'); + textArea.value = 'two'; + textArea.setSelectionRange(0, 0); + }); + + it('inserts the text', () => { + commonUtils.insertText(textArea, 'one'); + + expect(textArea.value).toBe('onetwo'); + expect(textArea.dispatchEvent).toHaveBeenCalled(); + }); + + it('replaces the selection', () => { + textArea.setSelectionRange(0, textArea.value.length); + + commonUtils.insertText(textArea, 'one'); + + expect(textArea.value).toBe('one'); + expect(textArea.selectionStart).toBe(textArea.value.length); + }); + + it('removes selected text', () => { + textArea.setSelectionRange(0, textArea.value.length); + + commonUtils.insertText(textArea, ''); + + expect(textArea.value).toBe(''); + }); + }); + }); + describe('normalizedHeaders', () => { it('should upperCase all the header keys to keep them consistent', () => { const apiHeaders = { diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js index 7a64b654baa..8d989350173 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -308,7 +308,9 @@ describe('datefix', () => { }); describe('parsePikadayDate', () => { - // removed because of https://gitlab.com/gitlab-org/gitlab-foss/issues/39834 + it('should return a UTC date', () => { + expect(datetimeUtility.parsePikadayDate('2020-01-29')).toEqual(new Date(2020, 0, 29)); + }); }); describe('pikadayToString', () => { diff --git a/spec/frontend/lib/utils/dom_utils_spec.js b/spec/frontend/lib/utils/dom_utils_spec.js index 2f240f25d2a..88dac449527 100644 --- a/spec/frontend/lib/utils/dom_utils_spec.js +++ b/spec/frontend/lib/utils/dom_utils_spec.js @@ -1,3 +1,4 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { addClassIfElementExists, canScrollUp, @@ -6,6 +7,7 @@ import { isElementVisible, isElementHidden, getParents, + getParentByTagName, setAttributes, } from '~/lib/utils/dom_utils'; @@ -23,10 +25,14 @@ describe('DOM Utils', () => { let parentElement; beforeEach(() => { - setFixtures(fixture); + setHTMLFixture(fixture); parentElement = document.querySelector('.parent'); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('adds class if element exists', () => { const childElement = parentElement.querySelector('.child'); @@ -126,10 +132,14 @@ describe('DOM Utils', () => { let element; beforeEach(() => { - setFixtures('<div data-foo-bar data-baz data-qux="">'); + setHTMLFixture('<div data-foo-bar data-baz data-qux="">'); element = document.querySelector('[data-foo-bar]'); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('throws if not given an element', () => { expect(() => parseBooleanDataAttributes(null, ['baz'])).toThrow(); }); @@ -210,6 +220,21 @@ describe('DOM Utils', () => { }); }); + describe('getParentByTagName', () => { + const el = document.createElement('div'); + el.innerHTML = '<p><span><strong><mark>hello world'; + + it.each` + tagName | parent + ${'strong'} | ${el.querySelector('strong')} + ${'span'} | ${el.querySelector('span')} + ${'p'} | ${el.querySelector('p')} + ${'pre'} | ${undefined} + `('gets a parent by tag name', ({ tagName, parent }) => { + expect(getParentByTagName(el.querySelector('mark'), tagName)).toBe(parent); + }); + }); + describe('setAttributes', () => { it('sets multiple attribues on element', () => { const div = document.createElement('div'); diff --git a/spec/frontend/lib/utils/file_upload_spec.js b/spec/frontend/lib/utils/file_upload_spec.js index ff11107ea60..f63af2fe0a4 100644 --- a/spec/frontend/lib/utils/file_upload_spec.js +++ b/spec/frontend/lib/utils/file_upload_spec.js @@ -1,8 +1,9 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import fileUpload, { getFilename, validateImageName } from '~/lib/utils/file_upload'; describe('File upload', () => { beforeEach(() => { - setFixtures(` + setHTMLFixture(` <form> <button class="js-button" type="button">Click me!</button> <input type="text" class="js-input" /> @@ -11,6 +12,10 @@ describe('File upload', () => { `); }); + afterEach(() => { + resetHTMLFixture(); + }); + describe('when there is a matching button and input', () => { beforeEach(() => { fileUpload('.js-button', '.js-input'); diff --git a/spec/frontend/lib/utils/mock_data.js b/spec/frontend/lib/utils/mock_data.js index df1f79529e7..49a2af8b307 100644 --- a/spec/frontend/lib/utils/mock_data.js +++ b/spec/frontend/lib/utils/mock_data.js @@ -3,3 +3,45 @@ export const faviconDataUrl = export const overlayDataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAA85JREFUWAntVllIVGEUPv/9b46O41KplYN7PeRkti8TjQlhCUGh3MmeQugpIsGKAi2soIcIooiohxYKK2daqDAlIpIiWwxtQaJcaHE0d5tMrbn37z9XRqfR0TvVW56Hudf//uec72zfEWBCJjIwkYGJDPzvGSD/KgExN3Oi2Q+2DJgSDYQEMwItVGH1iZGmJw/Si1y+/PwVAMYYib22MYc/8hVQFgKDEfYoId0KYzagAQebsos/ewMZoeB9wdffcTYpQSaCTWHKoqSQaDk7zkIt0+aCUR8BelEHrf3dUNv9AcqbnsHtT5UKB/hTASh0SLYjnjb/CIDRJi0XiFAaJOpCD8zLpdb4NB66b1OfelthX815dtdRRfiti2aAXLvVLiMQ6olGyztGDkSo4JGGXk8/QFdGpYzpHG2GBQTDhtgVhPEaVbbVpvI6GJz22rv4TcAfrYI1x7Rj5MWWAppomKFVVb2302SFzUkZHAbkG+0b1+Gh77yNYjrmqnWTrLBLRxdvBWv8qlFujH/kYjJYyvLkj71t78zAUvzMAMnHhpN4zf9UREJhd8omyssxu1IgazQDwDnHUcNuH6vhPIE1fmuBzHt74Hn7W89jWGtcAjoaIDOFrdcMYJBkgOCoaRF0Lj0oglddDbCj6tRvKjphEpgjkzEQs2YAKsNxMzjn3nKurhzK+Ly7xe28ua8TwgMMcHJZnvvT0BPtEEKM4tDJ+C8GvIIk4ylINIXVZ0EUKJxYuh3mhCeokbudl6TtVc88dfBdLwbyaWB6zQCYQJpBYSrDGQxBQ/ZWRM2B+VNmQnVnHWx7elyNuL2/R336co7KyJR8CL9oLgEuFlREevWUkEl6uGwpVEG4FBm0OEf9N10NMgPlvWYAuNVwsWDKvcUNYsHUWTCZ13ysyFEXe6TO6aC8CUr9IiK+A05TQrc8yjwmxARHeeMAPlfQJw+AQRwu0YhL/GDXi9NwufG+S8dYkuYMqIb4SsWthotlNMOUCOM6r+G9cqXxPmd1dqrBav/o1zJy2l5/NUjJA/VORwYuFnOUaTQcPs9wMqwV++Xv8oADxKAcZ8nLPr8AoGW+xR6HSqYk3GodAz2QNj0V+Gr26dT9ASNH5239Pf0gktVNWZca8ZvfAFBprWS6hSu1pqt++Y0PD+WIwDAhIWQGtzvSHDbcodfFUFB9hg1Gjs5LXqIdFL+acFBl+FddqYwdxsWC3I70OvgfUaA65zhq2O2c8VxYcyIGFTVlXegYtvCXANCQZJMobjVcLMjtSK/IcEgyOOe8Ve5w7ryKDefp2P3+C/5ohv8HZmVLAAAAAElFTkSuQmCC'; + +const absoluteUrls = [ + 'http://example.org', + 'http://example.org:8080', + 'https://example.org', + 'https://example.org:8080', + 'https://192.168.1.1', +]; + +const rootRelativeUrls = ['/relative/link']; + +const relativeUrls = ['./relative/link', '../relative/link']; + +const urlsWithoutHost = ['http://', 'https://', 'https:https:https:']; + +/* eslint-disable no-script-url */ +const nonHttpUrls = [ + 'javascript:', + 'javascript:alert("XSS")', + 'jav\tascript:alert("XSS");', + '  javascript:alert("XSS");', + 'ftp://192.168.1.1', + 'file:///', + 'file:///etc/hosts', +]; +/* eslint-enable no-script-url */ + +// javascript:alert('XSS') +const encodedJavaScriptUrls = [ + 'javascript:alert('XSS')', + 'javascript:alert('XSS')', + 'javascript:alert('XSS')', + '\\u006A\\u0061\\u0076\\u0061\\u0073\\u0063\\u0072\\u0069\\u0070\\u0074\\u003A\\u0061\\u006C\\u0065\\u0072\\u0074\\u0028\\u0027\\u0058\\u0053\\u0053\\u0027\\u0029', +]; + +export const safeUrls = [...absoluteUrls, ...rootRelativeUrls]; +export const unsafeUrls = [ + ...relativeUrls, + ...urlsWithoutHost, + ...nonHttpUrls, + ...encodedJavaScriptUrls, +]; diff --git a/spec/frontend/lib/utils/navigation_utility_spec.js b/spec/frontend/lib/utils/navigation_utility_spec.js index 6a880a0f354..632a8904578 100644 --- a/spec/frontend/lib/utils/navigation_utility_spec.js +++ b/spec/frontend/lib/utils/navigation_utility_spec.js @@ -1,3 +1,4 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import findAndFollowLink from '~/lib/utils/navigation_utility'; import * as navigationUtils from '~/lib/utils/navigation_utility'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -8,11 +9,13 @@ describe('findAndFollowLink', () => { it('visits a link when the selector exists', () => { const href = '/some/path'; - setFixtures(`<a class="my-shortcut" href="${href}">link</a>`); + setHTMLFixture(`<a class="my-shortcut" href="${href}">link</a>`); findAndFollowLink('.my-shortcut'); expect(visitUrl).toHaveBeenCalledWith(href); + + resetHTMLFixture(); }); it('does not throw an exception when the selector does not exist', () => { diff --git a/spec/frontend/lib/utils/resize_observer_spec.js b/spec/frontend/lib/utils/resize_observer_spec.js index 6560562f204..c88ba73ebc6 100644 --- a/spec/frontend/lib/utils/resize_observer_spec.js +++ b/spec/frontend/lib/utils/resize_observer_spec.js @@ -1,3 +1,4 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { contentTop } from '~/lib/utils/common_utils'; import { scrollToTargetOnResize } from '~/lib/utils/resize_observer'; @@ -19,7 +20,7 @@ describe('ResizeObserver Utility', () => { jest.spyOn(document.documentElement, 'scrollTo'); - setFixtures(`<div id="content-body"><div id="note_1234">note to scroll to</div></div>`); + setHTMLFixture(`<div id="content-body"><div id="note_1234">note to scroll to</div></div>`); const target = document.querySelector('#note_1234'); @@ -28,6 +29,7 @@ describe('ResizeObserver Utility', () => { afterEach(() => { contentTop.mockReset(); + resetHTMLFixture(); }); describe('Observer behavior', () => { diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js index 103305f0797..d1bca3c73b6 100644 --- a/spec/frontend/lib/utils/text_markdown_spec.js +++ b/spec/frontend/lib/utils/text_markdown_spec.js @@ -1,5 +1,10 @@ import $ from 'jquery'; -import { insertMarkdownText, keypressNoteText } from '~/lib/utils/text_markdown'; +import { + insertMarkdownText, + keypressNoteText, + compositionStartNoteText, + compositionEndNoteText, +} from '~/lib/utils/text_markdown'; import '~/lib/utils/jquery_at_who'; describe('init markdown', () => { @@ -9,6 +14,9 @@ describe('init markdown', () => { textArea = document.createElement('textarea'); document.querySelector('body').appendChild(textArea); textArea.focus(); + + // needed for the underlying insertText to work + document.execCommand = jest.fn(() => false); }); afterAll(() => { @@ -172,7 +180,9 @@ describe('init markdown', () => { const enterEvent = new KeyboardEvent('keydown', { key: 'Enter' }); beforeEach(() => { - gon.features = { markdownContinueLists: true }; + textArea.addEventListener('keydown', keypressNoteText); + textArea.addEventListener('compositionstart', compositionStartNoteText); + textArea.addEventListener('compositionend', compositionEndNoteText); }); it.each` @@ -203,7 +213,6 @@ describe('init markdown', () => { textArea.value = text; textArea.setSelectionRange(text.length, text.length); - textArea.addEventListener('keydown', keypressNoteText); textArea.dispatchEvent(enterEvent); expect(textArea.value).toEqual(expected); @@ -231,7 +240,6 @@ describe('init markdown', () => { textArea.value = text; textArea.setSelectionRange(text.length, text.length); - textArea.addEventListener('keydown', keypressNoteText); textArea.dispatchEvent(enterEvent); expect(textArea.value.substr(0, textArea.selectionStart)).toEqual(expected); @@ -251,7 +259,6 @@ describe('init markdown', () => { textArea.value = text; textArea.setSelectionRange(text.length, text.length); - textArea.addEventListener('keydown', keypressNoteText); textArea.dispatchEvent(enterEvent); expect(textArea.value).toEqual(expected); @@ -267,23 +274,25 @@ describe('init markdown', () => { textArea.value = text; textArea.setSelectionRange(add_at, add_at); - textArea.addEventListener('keydown', keypressNoteText); textArea.dispatchEvent(enterEvent); expect(textArea.value).toEqual(expected); }, ); - it('does nothing if feature flag disabled', () => { - gon.features = { markdownContinueLists: false }; - - const text = '- item'; - const expected = '- item'; + it('does not duplicate a line item for IME characters', () => { + const text = '- 日本語'; + const expected = '- 日本語\n- '; + textArea.dispatchEvent(new CompositionEvent('compositionstart')); textArea.value = text; + + // Press enter to end composition + textArea.dispatchEvent(enterEvent); + textArea.dispatchEvent(new CompositionEvent('compositionend')); textArea.setSelectionRange(text.length, text.length); - textArea.addEventListener('keydown', keypressNoteText); + // Press enter to make new line textArea.dispatchEvent(enterEvent); expect(textArea.value).toEqual(expected); diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index 7608cff4c9e..81cf4bd293b 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -1,6 +1,7 @@ import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import * as urlUtils from '~/lib/utils/url_utility'; +import { safeUrls, unsafeUrls } from './mock_data'; const shas = { valid: [ @@ -575,48 +576,6 @@ describe('URL utility', () => { }); describe('isSafeUrl', () => { - const absoluteUrls = [ - 'http://example.org', - 'http://example.org:8080', - 'https://example.org', - 'https://example.org:8080', - 'https://192.168.1.1', - ]; - - const rootRelativeUrls = ['/relative/link']; - - const relativeUrls = ['./relative/link', '../relative/link']; - - const urlsWithoutHost = ['http://', 'https://', 'https:https:https:']; - - /* eslint-disable no-script-url */ - const nonHttpUrls = [ - 'javascript:', - 'javascript:alert("XSS")', - 'jav\tascript:alert("XSS");', - '  javascript:alert("XSS");', - 'ftp://192.168.1.1', - 'file:///', - 'file:///etc/hosts', - ]; - /* eslint-enable no-script-url */ - - // javascript:alert('XSS') - const encodedJavaScriptUrls = [ - 'javascript:alert('XSS')', - 'javascript:alert('XSS')', - 'javascript:alert('XSS')', - '\\u006A\\u0061\\u0076\\u0061\\u0073\\u0063\\u0072\\u0069\\u0070\\u0074\\u003A\\u0061\\u006C\\u0065\\u0072\\u0074\\u0028\\u0027\\u0058\\u0053\\u0053\\u0027\\u0029', - ]; - - const safeUrls = [...absoluteUrls, ...rootRelativeUrls]; - const unsafeUrls = [ - ...relativeUrls, - ...urlsWithoutHost, - ...nonHttpUrls, - ...encodedJavaScriptUrls, - ]; - describe('with URL constructor support', () => { it.each(safeUrls)('returns true for %s', (url) => { expect(urlUtils.isSafeURL(url)).toBe(true); @@ -628,6 +587,16 @@ describe('URL utility', () => { }); }); + describe('sanitizeUrl', () => { + it.each(safeUrls)('returns the url for %s', (url) => { + expect(urlUtils.sanitizeUrl(url)).toBe(url); + }); + + it.each(unsafeUrls)('returns `about:blank` for %s', (url) => { + expect(urlUtils.sanitizeUrl(url)).toBe('about:blank'); + }); + }); + describe('getNormalizedURL', () => { it.each` url | base | result diff --git a/spec/frontend/lib/utils/users_cache_spec.js b/spec/frontend/lib/utils/users_cache_spec.js index 30bdddd8e73..d35ba20f570 100644 --- a/spec/frontend/lib/utils/users_cache_spec.js +++ b/spec/frontend/lib/utils/users_cache_spec.js @@ -228,4 +228,29 @@ describe('UsersCache', () => { expect(userStatus).toBe(dummyUserStatus); }); }); + + describe('updateById', () => { + describe('when the user is not cached', () => { + it('does nothing and returns undefined', () => { + expect(UsersCache.updateById(dummyUserId, { name: 'root' })).toBe(undefined); + expect(UsersCache.internalStorage).toStrictEqual({}); + }); + }); + + describe('when the user is cached', () => { + const updatedName = 'has two farms'; + beforeEach(() => { + UsersCache.internalStorage[dummyUserId] = dummyUser; + }); + + it('updates the user only with the new data', async () => { + UsersCache.updateById(dummyUserId, { name: updatedName }); + + expect(await UsersCache.retrieveById(dummyUserId)).toStrictEqual({ + username: dummyUser.username, + name: updatedName, + }); + }); + }); + }); }); diff --git a/spec/frontend/listbox/index_spec.js b/spec/frontend/listbox/index_spec.js index 45659a0e523..07c6cca535a 100644 --- a/spec/frontend/listbox/index_spec.js +++ b/spec/frontend/listbox/index_spec.js @@ -3,7 +3,7 @@ import { getAllByRole, getByRole } from '@testing-library/dom'; import { GlDropdown } from '@gitlab/ui'; import { createWrapper } from '@vue/test-utils'; import { initListbox, parseAttributes } from '~/listbox'; -import { getFixture, setHTMLFixture } from 'helpers/fixtures'; +import { getFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; jest.mock('~/lib/utils/url_utility'); @@ -63,6 +63,10 @@ describe('initListbox', () => { await nextTick(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('returns an instance', () => { expect(instance).not.toBe(null); }); diff --git a/spec/frontend/logs/components/tokens/token_with_loading_state_spec.js b/spec/frontend/logs/components/tokens/token_with_loading_state_spec.js index d98d7d05c92..f667a590a36 100644 --- a/spec/frontend/logs/components/tokens/token_with_loading_state_spec.js +++ b/spec/frontend/logs/components/tokens/token_with_loading_state_spec.js @@ -11,7 +11,10 @@ describe('TokenWithLoadingState', () => { const initWrapper = (props = {}, options) => { wrapper = shallowMount(TokenWithLoadingState, { - propsData: props, + propsData: { + cursorPosition: 'start', + ...props, + }, ...options, }); }; diff --git a/spec/frontend/members/components/table/role_dropdown_spec.js b/spec/frontend/members/components/table/role_dropdown_spec.js index d4d950e99ba..2f1626a7044 100644 --- a/spec/frontend/members/components/table/role_dropdown_spec.js +++ b/spec/frontend/members/components/table/role_dropdown_spec.js @@ -4,8 +4,6 @@ import { within } from '@testing-library/dom'; import { mount, createWrapper } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; -import waitForPromises from 'helpers/wait_for_promises'; -import { BV_DROPDOWN_SHOW } from '~/lib/utils/constants'; import RoleDropdown from '~/members/components/table/role_dropdown.vue'; import { MEMBER_TYPES } from '~/members/constants'; import { member } from '../../mock_data'; @@ -70,13 +68,10 @@ describe('RoleDropdown', () => { }); describe('when dropdown is open', () => { - beforeEach((done) => { + beforeEach(() => { createComponent(); - findDropdownToggle().trigger('click'); - wrapper.vm.$root.$on(BV_DROPDOWN_SHOW, () => { - done(); - }); + return findDropdownToggle().trigger('click'); }); it('renders all valid roles', () => { @@ -95,14 +90,14 @@ describe('RoleDropdown', () => { }); describe('when dropdown item is selected', () => { - it('does nothing if the item selected was already selected', () => { - getDropdownItemByText('Owner').trigger('click'); + it('does nothing if the item selected was already selected', async () => { + await getDropdownItemByText('Owner').trigger('click'); expect(actions.updateMemberRole).not.toHaveBeenCalled(); }); - it('calls `updateMemberRole` Vuex action', () => { - getDropdownItemByText('Developer').trigger('click'); + it('calls `updateMemberRole` Vuex action', async () => { + await getDropdownItemByText('Developer').trigger('click'); expect(actions.updateMemberRole).toHaveBeenCalledWith(expect.any(Object), { memberId: member.id, @@ -111,21 +106,19 @@ describe('RoleDropdown', () => { }); it('displays toast when successful', async () => { - getDropdownItemByText('Developer').trigger('click'); + await getDropdownItemByText('Developer').trigger('click'); - await waitForPromises(); + await nextTick(); expect($toast.show).toHaveBeenCalledWith('Role updated successfully.'); }); it('disables dropdown while waiting for `updateMemberRole` to resolve', async () => { - getDropdownItemByText('Developer').trigger('click'); - - await nextTick(); + await getDropdownItemByText('Developer').trigger('click'); expect(findDropdown().props('disabled')).toBe(true); - await waitForPromises(); + await nextTick(); expect(findDropdown().props('disabled')).toBe(false); }); diff --git a/spec/frontend/merge_conflicts/store/actions_spec.js b/spec/frontend/merge_conflicts/store/actions_spec.js index 1b6a0f9e977..7cee6576b53 100644 --- a/spec/frontend/merge_conflicts/store/actions_spec.js +++ b/spec/frontend/merge_conflicts/store/actions_spec.js @@ -1,6 +1,6 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import Cookies from 'js-cookie'; +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'; @@ -11,7 +11,7 @@ import { restoreFileLinesState, markLine, decorateFiles } from '~/merge_conflict jest.mock('~/flash.js'); jest.mock('~/merge_conflicts/utils'); -jest.mock('js-cookie'); +jest.mock('~/lib/utils/cookies'); describe('merge conflicts actions', () => { let mock; diff --git a/spec/frontend/merge_request_spec.js b/spec/frontend/merge_request_spec.js index 9229b353685..bcf64204c7a 100644 --- a/spec/frontend/merge_request_spec.js +++ b/spec/frontend/merge_request_spec.js @@ -1,5 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'spec/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; @@ -11,7 +12,7 @@ describe('MergeRequest', () => { let mock; beforeEach(() => { - loadFixtures('merge_requests/merge_request_with_task_list.html'); + loadHTMLFixture('merge_requests/merge_request_with_task_list.html'); jest.spyOn(axios, 'patch'); mock = new MockAdapter(axios); @@ -26,6 +27,7 @@ describe('MergeRequest', () => { afterEach(() => { mock.restore(); + resetHTMLFixture(); }); it('modifies the Markdown field', async () => { @@ -103,7 +105,7 @@ describe('MergeRequest', () => { describe('hideCloseButton', () => { describe('merge request of current_user', () => { beforeEach(() => { - loadFixtures('merge_requests/merge_request_of_current_user.html'); + loadHTMLFixture('merge_requests/merge_request_of_current_user.html'); test.el = document.querySelector('.js-issuable-actions'); MergeRequest.hideCloseButton(); }); diff --git a/spec/frontend/merge_request_tabs_spec.js b/spec/frontend/merge_request_tabs_spec.js index 5c24a070342..ccbc61ea658 100644 --- a/spec/frontend/merge_request_tabs_spec.js +++ b/spec/frontend/merge_request_tabs_spec.js @@ -1,5 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import initMrPage from 'helpers/init_vue_mr_page_helper'; import axios from '~/lib/utils/axios_utils'; import MergeRequestTabs from '~/merge_request_tabs'; @@ -79,7 +80,7 @@ describe('MergeRequestTabs', () => { let tabUrl; beforeEach(() => { - loadFixtures('merge_requests/merge_request_with_task_list.html'); + loadHTMLFixture('merge_requests/merge_request_with_task_list.html'); tabUrl = $('.commits-tab a').attr('href'); @@ -97,6 +98,10 @@ describe('MergeRequestTabs', () => { }; }); + afterEach(() => { + resetHTMLFixture(); + }); + describe('meta click', () => { let metakeyEvent; diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap index 28039321428..a93035cc53a 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -17,6 +17,7 @@ exports[`Dashboard template matches the default snapshot 1`] = ` primarybuttontext="" secondarybuttonlink="" secondarybuttontext="" + showicon="true" title="Feature deprecation" variant="warning" > diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js index 7bd062b81f1..1f9eb03b5d4 100644 --- a/spec/frontend/monitoring/components/dashboard_panel_spec.js +++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js @@ -65,6 +65,7 @@ describe('Dashboard Panel', () => { }, store, mocks, + provide: { glFeatures: { monitorLogging: true } }, ...options, }); }; @@ -379,6 +380,21 @@ describe('Dashboard Panel', () => { expect(findViewLogsLink().attributes('href')).toMatch(mockLogsHref); }); + describe(':monitor_logging feature flag', () => { + it.each` + flagState | logsState | expected + ${true} | ${'shows'} | ${true} + ${false} | ${'hides'} | ${false} + `('$logsState logs when flag state is $flagState', async ({ flagState, expected }) => { + createWrapper({}, { provide: { glFeatures: { monitorLogging: flagState } } }); + state.logsPath = mockLogsPath; + state.timeRange = mockTimeRange; + await nextTick(); + + expect(findViewLogsLink().exists()).toBe(expected); + }); + }); + it('it is overridden when a datazoom event is received', async () => { state.logsPath = mockLogsPath; state.timeRange = mockTimeRange; @@ -488,15 +504,7 @@ describe('Dashboard Panel', () => { store.registerModule(mockNamespace, monitoringDashboard); store.state.embedGroup.modules.push(mockNamespace); - wrapper = shallowMount(DashboardPanel, { - propsData: { - graphData, - settingsPath: dashboardProps.settingsPath, - namespace: mockNamespace, - }, - store, - mocks, - }); + createWrapper({ namespace: mockNamespace }); }); it('handles namespaced time range and logs path state', async () => { diff --git a/spec/frontend/new_branch_spec.js b/spec/frontend/new_branch_spec.js index 66b28a8c0dc..e4f4b3fa5b5 100644 --- a/spec/frontend/new_branch_spec.js +++ b/spec/frontend/new_branch_spec.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import NewBranchForm from '~/new_branch_form'; describe('Branch', () => { @@ -18,11 +19,15 @@ describe('Branch', () => { } beforeEach(() => { - loadFixtures('branches/new_branch.html'); + loadHTMLFixture('branches/new_branch.html'); $('form').on('submit', (e) => e.preventDefault()); testContext.form = new NewBranchForm($('.js-create-branch-form'), []); }); + afterEach(() => { + resetHTMLFixture(); + }); + it("can't start with a dot", () => { fillNameWith('.foo'); expectToHaveError("can't start with '.'"); diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index a605edc4357..fb42e4d1d84 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -248,13 +248,21 @@ describe('issue_comment_form component', () => { describe('textarea', () => { describe('general', () => { - it('should render textarea with placeholder', () => { - mountComponent({ mountFunction: mount }); + it.each` + noteType | confidential | placeholder + ${'comment'} | ${false} | ${'Write a comment or drag your files here…'} + ${'internal note'} | ${true} | ${'Write an internal note or drag your files here…'} + `( + 'should render textarea with placeholder for $noteType', + ({ confidential, placeholder }) => { + mountComponent({ + mountFunction: mount, + initialData: { noteIsConfidential: confidential }, + }); - expect(findTextArea().attributes('placeholder')).toBe( - 'Write a comment or drag your files here…', - ); - }); + expect(findTextArea().attributes('placeholder')).toBe(placeholder); + }, + ); it('should make textarea disabled while requesting', async () => { mountComponent({ mountFunction: mount }); @@ -380,6 +388,20 @@ describe('issue_comment_form component', () => { expect(findCloseReopenButton().text()).toBe('Close issue'); }); + it.each` + confidential | buttonText + ${false} | ${'Comment'} + ${true} | ${'Add internal note'} + `('renders comment button with text "$buttonText"', ({ confidential, buttonText }) => { + mountComponent({ + mountFunction: mount, + noteableData: createNotableDataMock({ confidential }), + initialData: { noteIsConfidential: confidential }, + }); + + expect(findCommentButton().text()).toBe(buttonText); + }); + it('should render comment button as disabled', () => { mountComponent(); diff --git a/spec/frontend/notes/components/comment_type_dropdown_spec.js b/spec/frontend/notes/components/comment_type_dropdown_spec.js index 8ac6144e5c8..cabf551deba 100644 --- a/spec/frontend/notes/components/comment_type_dropdown_spec.js +++ b/spec/frontend/notes/components/comment_type_dropdown_spec.js @@ -28,18 +28,42 @@ describe('CommentTypeDropdown component', () => { wrapper.destroy(); }); - it('Should label action button "Comment" and correct dropdown item checked when selected', () => { + it.each` + isInternalNote | buttonText + ${false} | ${COMMENT_FORM.comment} + ${true} | ${COMMENT_FORM.internalComment} + `( + 'Should label action button as "$buttonText" for comment when `isInternalNote` is $isInternalNote', + ({ isInternalNote, buttonText }) => { + mountComponent({ props: { noteType: constants.COMMENT, isInternalNote } }); + + expect(findCommentGlDropdown().props()).toMatchObject({ text: buttonText }); + }, + ); + + it('Should set correct dropdown item checked when comment is selected', () => { mountComponent({ props: { noteType: constants.COMMENT } }); - expect(findCommentGlDropdown().props()).toMatchObject({ text: COMMENT_FORM.comment }); expect(findCommentDropdownOption().props()).toMatchObject({ isChecked: true }); expect(findDiscussionDropdownOption().props()).toMatchObject({ isChecked: false }); }); - it('Should label action button "Start Thread" and correct dropdown item option checked when selected', () => { + it.each` + isInternalNote | buttonText + ${false} | ${COMMENT_FORM.startThread} + ${true} | ${COMMENT_FORM.startInternalThread} + `( + 'Should label action button as "$buttonText" for discussion when `isInternalNote` is $isInternalNote', + ({ isInternalNote, buttonText }) => { + mountComponent({ props: { noteType: constants.DISCUSSION, isInternalNote } }); + + expect(findCommentGlDropdown().props()).toMatchObject({ text: buttonText }); + }, + ); + + it('Should set correct dropdown item option checked when discussion is selected', () => { mountComponent({ props: { noteType: constants.DISCUSSION } }); - expect(findCommentGlDropdown().props()).toMatchObject({ text: COMMENT_FORM.startThread }); expect(findCommentDropdownOption().props()).toMatchObject({ isChecked: false }); expect(findDiscussionDropdownOption().props()).toMatchObject({ isChecked: true }); }); diff --git a/spec/frontend/notes/components/discussion_counter_spec.js b/spec/frontend/notes/components/discussion_counter_spec.js index a856d002d2e..f016cef18e6 100644 --- a/spec/frontend/notes/components/discussion_counter_spec.js +++ b/spec/frontend/notes/components/discussion_counter_spec.js @@ -45,7 +45,7 @@ describe('DiscussionCounter component', () => { describe('has no discussions', () => { it('does not render', () => { - wrapper = shallowMount(DiscussionCounter, { store }); + wrapper = shallowMount(DiscussionCounter, { store, propsData: { blocksMerge: true } }); expect(wrapper.find({ ref: 'discussionCounter' }).exists()).toBe(false); }); @@ -55,7 +55,7 @@ describe('DiscussionCounter component', () => { it('does not render', () => { store.commit(types.ADD_OR_UPDATE_DISCUSSIONS, [{ ...discussionMock, resolvable: false }]); store.dispatch('updateResolvableDiscussionsCounts'); - wrapper = shallowMount(DiscussionCounter, { store }); + wrapper = shallowMount(DiscussionCounter, { store, propsData: { blocksMerge: true } }); expect(wrapper.find({ ref: 'discussionCounter' }).exists()).toBe(false); }); @@ -75,20 +75,34 @@ describe('DiscussionCounter component', () => { it('renders', () => { updateStore(); - wrapper = shallowMount(DiscussionCounter, { store }); + wrapper = shallowMount(DiscussionCounter, { store, propsData: { blocksMerge: true } }); expect(wrapper.find({ ref: 'discussionCounter' }).exists()).toBe(true); }); it.each` - title | resolved | isActive | groupLength - ${'not allResolved'} | ${false} | ${false} | ${3} - ${'allResolved'} | ${true} | ${true} | ${1} - `('renders correctly if $title', ({ resolved, isActive, groupLength }) => { + blocksMerge | color + ${true} | ${'gl-bg-orange-50'} + ${false} | ${'gl-bg-gray-50'} + `( + 'changes background color to $color if blocksMerge is $blocksMerge', + ({ blocksMerge, color }) => { + updateStore(); + store.state.unresolvedDiscussionsCount = 1; + wrapper = shallowMount(DiscussionCounter, { store, propsData: { blocksMerge } }); + + expect(wrapper.find('[data-testid="discussions-counter-text"]').classes()).toContain(color); + }, + ); + + it.each` + title | resolved | groupLength + ${'not allResolved'} | ${false} | ${4} + ${'allResolved'} | ${true} | ${1} + `('renders correctly if $title', ({ resolved, groupLength }) => { updateStore({ resolvable: true, resolved }); - wrapper = shallowMount(DiscussionCounter, { store }); + wrapper = shallowMount(DiscussionCounter, { store, propsData: { blocksMerge: true } }); - expect(wrapper.find(`.is-active`).exists()).toBe(isActive); expect(wrapper.findAll(GlButton)).toHaveLength(groupLength); }); }); @@ -99,7 +113,7 @@ describe('DiscussionCounter component', () => { const discussion = { ...discussionMock, expanded }; store.commit(types.ADD_OR_UPDATE_DISCUSSIONS, [discussion]); store.dispatch('updateResolvableDiscussionsCounts'); - wrapper = shallowMount(DiscussionCounter, { store }); + wrapper = shallowMount(DiscussionCounter, { store, propsData: { blocksMerge: true } }); toggleAllButton = wrapper.find('.toggle-all-discussions-btn'); }; @@ -117,26 +131,26 @@ describe('DiscussionCounter component', () => { updateStoreWithExpanded(true); expect(wrapper.vm.allExpanded).toBe(true); - expect(toggleAllButton.props('icon')).toBe('angle-up'); + expect(toggleAllButton.props('icon')).toBe('collapse'); toggleAllButton.vm.$emit('click'); await nextTick(); expect(wrapper.vm.allExpanded).toBe(false); - expect(toggleAllButton.props('icon')).toBe('angle-down'); + expect(toggleAllButton.props('icon')).toBe('expand'); }); it('expands all discussions if collapsed', async () => { updateStoreWithExpanded(false); expect(wrapper.vm.allExpanded).toBe(false); - expect(toggleAllButton.props('icon')).toBe('angle-down'); + expect(toggleAllButton.props('icon')).toBe('expand'); toggleAllButton.vm.$emit('click'); await nextTick(); expect(wrapper.vm.allExpanded).toBe(true); - expect(toggleAllButton.props('icon')).toBe('angle-up'); + expect(toggleAllButton.props('icon')).toBe('collapse'); }); }); }); diff --git a/spec/frontend/notes/components/note_body_spec.js b/spec/frontend/notes/components/note_body_spec.js index 63f3cd865d5..378dcb97fab 100644 --- a/spec/frontend/notes/components/note_body_spec.js +++ b/spec/frontend/notes/components/note_body_spec.js @@ -1,9 +1,10 @@ import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { suggestionCommitMessage } from '~/diffs/store/getters'; -import noteBody from '~/notes/components/note_body.vue'; +import NoteBody from '~/notes/components/note_body.vue'; +import NoteAwardsList from '~/notes/components/note_awards_list.vue'; +import NoteForm from '~/notes/components/note_form.vue'; import createStore from '~/notes/stores'; import notes from '~/notes/stores/modules/index'; @@ -11,68 +12,89 @@ import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; import { noteableDataMock, notesDataMock, note } from '../mock_data'; +const createComponent = ({ + props = {}, + noteableData = noteableDataMock, + notesData = notesDataMock, + store = null, +} = {}) => { + let mockStore; + + if (!store) { + mockStore = createStore(); + + mockStore.dispatch('setNoteableData', noteableData); + mockStore.dispatch('setNotesData', notesData); + } + + return shallowMount(NoteBody, { + store: mockStore || store, + propsData: { + note, + canEdit: true, + canAwardEmoji: true, + isEditing: false, + ...props, + }, + }); +}; + describe('issue_note_body component', () => { - let store; - let vm; + let wrapper; beforeEach(() => { - const Component = Vue.extend(noteBody); - - store = createStore(); - store.dispatch('setNoteableData', noteableDataMock); - store.dispatch('setNotesData', notesDataMock); - - vm = new Component({ - store, - propsData: { - note, - canEdit: true, - canAwardEmoji: true, - }, - }).$mount(); + wrapper = createComponent(); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('should render the note', () => { - expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html); + expect(wrapper.find('.note-text').html()).toContain(note.note_html); }); it('should render awards list', () => { - expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).not.toBeNull(); - expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).not.toBeNull(); + expect(wrapper.findComponent(NoteAwardsList).exists()).toBe(true); }); describe('isEditing', () => { - beforeEach(async () => { - vm.isEditing = true; - await nextTick(); + beforeEach(() => { + wrapper = createComponent({ props: { isEditing: true } }); }); it('renders edit form', () => { - expect(vm.$el.querySelector('textarea.js-task-list-field')).not.toBeNull(); + expect(wrapper.findComponent(NoteForm).exists()).toBe(true); + }); + + it.each` + confidential | buttonText + ${false} | ${'Save comment'} + ${true} | ${'Save internal note'} + `('renders save button with text "$buttonText"', ({ confidential, buttonText }) => { + wrapper = createComponent({ props: { note: { ...note, confidential }, isEditing: true } }); + + expect(wrapper.findComponent(NoteForm).props('saveButtonTitle')).toBe(buttonText); }); it('adds autosave', () => { const autosaveKey = `autosave/Note/${note.noteable_type}/${note.id}`; - expect(vm.autosave.key).toEqual(autosaveKey); + // While we discourage testing wrapper props + // here we aren't testing a component prop + // but instead an instance object property + // which is defined in `app/assets/javascripts/notes/mixins/autosave.js` + expect(wrapper.vm.autosave.key).toEqual(autosaveKey); }); }); describe('commitMessage', () => { - let wrapper; - - Vue.use(Vuex); - beforeEach(() => { const notesStore = notes(); notesStore.state.notes = {}; - store = new Vuex.Store({ + const store = new Vuex.Store({ modules: { notes: notesStore, diffs: { @@ -98,9 +120,9 @@ describe('issue_note_body component', () => { }, }); - wrapper = shallowMount(noteBody, { + wrapper = createComponent({ store, - propsData: { + props: { note: { ...note, suggestions: [12345] }, canEdit: true, file: { file_path: 'abc' }, diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index b709141f4ac..252c24d1117 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -6,7 +6,7 @@ import { getDraft, updateDraft } from '~/lib/utils/autosave'; import NoteForm from '~/notes/components/note_form.vue'; import createStore from '~/notes/stores'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import { noteableDataMock, notesDataMock, discussionMock } from '../mock_data'; +import { noteableDataMock, notesDataMock, discussionMock, note } from '../mock_data'; jest.mock('~/lib/utils/autosave'); @@ -45,8 +45,6 @@ describe('issue_note_form component', () => { noteBody: 'Magni suscipit eius consectetur enim et ex et commodi.', noteId: '545', }; - - gon.features = { markdownContinueLists: true }; }); afterEach(() => { @@ -116,6 +114,23 @@ describe('issue_note_form component', () => { expect(textarea.attributes('data-supports-quick-actions')).toBe('true'); }); + it.each` + confidential | placeholder + ${false} | ${'Write a comment or drag your files here…'} + ${true} | ${'Write an internal note or drag your files here…'} + `( + 'should set correct textarea placeholder text when discussion confidentiality is $confidential', + ({ confidential, placeholder }) => { + props.note = { + ...note, + confidential, + }; + wrapper = createComponentWrapper(); + + expect(wrapper.find('textarea').attributes('placeholder')).toBe(placeholder); + }, + ); + it('should link to markdown docs', () => { const { markdownDocsPath } = notesDataMock; const markdownField = wrapper.find(MarkdownField); diff --git a/spec/frontend/notes/components/note_header_spec.js b/spec/frontend/notes/components/note_header_spec.js index 3513b562e0a..310a470aa18 100644 --- a/spec/frontend/notes/components/note_header_spec.js +++ b/spec/frontend/notes/components/note_header_spec.js @@ -21,7 +21,7 @@ describe('NoteHeader component', () => { const findActionText = () => wrapper.find({ ref: 'actionText' }); const findTimestampLink = () => wrapper.find({ ref: 'noteTimestampLink' }); const findTimestamp = () => wrapper.find({ ref: 'noteTimestamp' }); - const findConfidentialIndicator = () => wrapper.findByTestId('confidentialIndicator'); + const findConfidentialIndicator = () => wrapper.findByTestId('internalNoteIndicator'); const findSpinner = () => wrapper.find({ ref: 'spinner' }); const findAuthorStatus = () => wrapper.find({ ref: 'authorStatus' }); @@ -297,7 +297,7 @@ describe('NoteHeader component', () => { createComponent({ isConfidential: true, noteableType: 'issue' }); expect(findConfidentialIndicator().attributes('title')).toBe( - 'This comment is confidential and only visible to project members', + 'This internal note will always remain confidential', ); }); }); diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js index e227af88d3f..413ee815906 100644 --- a/spec/frontend/notes/components/notes_app_spec.js +++ b/spec/frontend/notes/components/notes_app_spec.js @@ -2,6 +2,7 @@ import { mount, shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import { nextTick } from 'vue'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import setWindowLocation from 'helpers/set_window_location_helper'; import { setTestTimeout } from 'helpers/timeout'; import waitForPromises from 'helpers/wait_for_promises'; @@ -92,13 +93,17 @@ describe('note_app', () => { describe('set data', () => { beforeEach(() => { - setFixtures('<div class="js-discussions-count"></div>'); + setHTMLFixture('<div class="js-discussions-count"></div>'); axiosMock.onAny().reply(200, []); wrapper = mountComponent(); return waitForDiscussionsRequest(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('should set notes data', () => { expect(store.state.notesData).toEqual(mockData.notesDataMock); }); @@ -122,13 +127,17 @@ describe('note_app', () => { describe('render', () => { beforeEach(() => { - setFixtures('<div class="js-discussions-count"></div>'); + setHTMLFixture('<div class="js-discussions-count"></div>'); axiosMock.onAny().reply(mockData.getIndividualNoteResponse); wrapper = mountComponent(); return waitForDiscussionsRequest(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('should render list of notes', () => { const note = mockData.INDIVIDUAL_NOTE_RESPONSE_MAP.GET[ @@ -160,7 +169,7 @@ describe('note_app', () => { describe('render with comments disabled', () => { beforeEach(() => { - setFixtures('<div class="js-discussions-count"></div>'); + setHTMLFixture('<div class="js-discussions-count"></div>'); axiosMock.onAny().reply(mockData.getIndividualNoteResponse); store.state.commentsDisabled = true; @@ -168,6 +177,10 @@ describe('note_app', () => { return waitForDiscussionsRequest(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('should not render form when commenting is disabled', () => { expect(wrapper.find('.js-main-target-form').exists()).toBe(false); }); @@ -179,7 +192,7 @@ describe('note_app', () => { describe('timeline view', () => { beforeEach(() => { - setFixtures('<div class="js-discussions-count"></div>'); + setHTMLFixture('<div class="js-discussions-count"></div>'); axiosMock.onAny().reply(mockData.getIndividualNoteResponse); store.state.commentsDisabled = false; @@ -189,6 +202,10 @@ describe('note_app', () => { return waitForDiscussionsRequest(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('should not render comments form', () => { expect(wrapper.find('.js-main-target-form').exists()).toBe(false); }); @@ -196,12 +213,15 @@ describe('note_app', () => { describe('while fetching data', () => { beforeEach(() => { - setFixtures('<div class="js-discussions-count"></div>'); + setHTMLFixture('<div class="js-discussions-count"></div>'); axiosMock.onAny().reply(200, []); wrapper = mountComponent(); }); - afterEach(() => waitForDiscussionsRequest()); + afterEach(() => { + waitForDiscussionsRequest(); + resetHTMLFixture(); + }); it('renders skeleton notes', () => { expect(wrapper.find('.animation-container').exists()).toBe(true); diff --git a/spec/frontend/notes/deprecated_notes_spec.js b/spec/frontend/notes/deprecated_notes_spec.js index 7193475c96a..40b124b9029 100644 --- a/spec/frontend/notes/deprecated_notes_spec.js +++ b/spec/frontend/notes/deprecated_notes_spec.js @@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { createSpyObj } from 'helpers/jest_helpers'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; @@ -33,7 +34,7 @@ gl.utils.disableButtonIfEmptyField = () => {}; // eslint-disable-next-line jest/no-disabled-tests describe.skip('Old Notes (~/deprecated_notes.js)', () => { beforeEach(() => { - loadFixtures(fixture); + loadHTMLFixture(fixture); // Re-declare this here so that test_setup.js#beforeEach() doesn't // overwrite it. @@ -50,12 +51,14 @@ describe.skip('Old Notes (~/deprecated_notes.js)', () => { setTestTimeoutOnce(4000); }); - afterEach(() => { + afterEach(async () => { // The Notes component sets a polling interval. Clear it after every run. // Make sure to use jest.runOnlyPendingTimers() instead of runAllTimers(). jest.clearAllTimers(); - return axios.waitForAll().finally(() => mockAxios.restore()); + await axios.waitForAll().finally(() => mockAxios.restore()); + + resetHTMLFixture(); }); it('loads the Notes class into the DOM', () => { @@ -629,7 +632,7 @@ describe.skip('Old Notes (~/deprecated_notes.js)', () => { let $notesContainer; beforeEach(() => { - loadFixtures('commit/show.html'); + loadHTMLFixture('commit/show.html'); mockAxios.onPost(NOTES_POST_PATH).reply(200, note); new Notes('', []); diff --git a/spec/frontend/notes/mixins/discussion_navigation_spec.js b/spec/frontend/notes/mixins/discussion_navigation_spec.js index aba80789a01..35b3dec6298 100644 --- a/spec/frontend/notes/mixins/discussion_navigation_spec.js +++ b/spec/frontend/notes/mixins/discussion_navigation_spec.js @@ -59,6 +59,7 @@ describe('Discussion navigation mixin', () => { diffs: { namespaced: true, actions: { scrollToFile }, + state: { diffFiles: [] }, }, }, }); diff --git a/spec/frontend/notes/mock_data.js b/spec/frontend/notes/mock_data.js index a4aeeda48d8..c7a6ca5eae3 100644 --- a/spec/frontend/notes/mock_data.js +++ b/spec/frontend/notes/mock_data.js @@ -1171,7 +1171,7 @@ export const discussion1 = { resolved: false, active: true, diff_file: { - file_path: 'about.md', + file_identifier_hash: 'discfile1', }, position: { new_line: 50, @@ -1189,7 +1189,7 @@ export const resolvedDiscussion1 = { resolvable: true, resolved: true, diff_file: { - file_path: 'about.md', + file_identifier_hash: 'discfile1', }, position: { new_line: 50, @@ -1208,7 +1208,7 @@ export const discussion2 = { resolved: false, active: true, diff_file: { - file_path: 'README.md', + file_identifier_hash: 'discfile2', }, position: { new_line: null, @@ -1227,7 +1227,7 @@ export const discussion3 = { active: true, resolved: false, diff_file: { - file_path: 'README.md', + file_identifier_hash: 'discfile3', }, position: { new_line: 21, @@ -1240,6 +1240,12 @@ export const discussion3 = { ], }; +export const authoritativeDiscussionFile = { + id: 'abc', + file_identifier_hash: 'discfile1', + order: 0, +}; + export const unresolvableDiscussion = { resolvable: false, }; diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 75e7756cd6b..ecb213590ad 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -1,4 +1,5 @@ import AxiosMockAdapter from 'axios-mock-adapter'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import testAction from 'helpers/vuex_action_helper'; import { TEST_HOST } from 'spec/test_constants'; import Api from '~/api'; @@ -51,7 +52,7 @@ describe('Actions Notes Store', () => { axiosMock = new AxiosMockAdapter(axios); // This is necessary as we query Close issue button at the top of issue page when clicking bottom button - setFixtures( + setHTMLFixture( '<div class="detail-page-header-actions"><button class="btn-close btn-grouped"></button></div>', ); }); @@ -59,6 +60,7 @@ describe('Actions Notes Store', () => { afterEach(() => { resetStore(store); axiosMock.restore(); + resetHTMLFixture(); }); describe('setNotesData', () => { @@ -252,7 +254,9 @@ describe('Actions Notes Store', () => { jest.advanceTimersByTime(time); } - return new Promise((resolve) => requestAnimationFrame(resolve)); + return new Promise((resolve) => { + requestAnimationFrame(resolve); + }); }; const advanceXMoreIntervals = async (number) => { const timeoutLength = pollInterval * number; diff --git a/spec/frontend/notes/stores/getters_spec.js b/spec/frontend/notes/stores/getters_spec.js index 9a11fdba508..6d078dcefcf 100644 --- a/spec/frontend/notes/stores/getters_spec.js +++ b/spec/frontend/notes/stores/getters_spec.js @@ -12,6 +12,7 @@ import { discussion2, discussion3, resolvedDiscussion1, + authoritativeDiscussionFile, unresolvableDiscussion, draftComments, draftReply, @@ -26,6 +27,23 @@ const createDiscussionNeighborParams = (discussionId, diffOrder, step) => ({ }); const asDraftDiscussion = (x) => ({ ...x, individual_note: true }); +const createRootState = () => { + return { + diffs: { + diffFiles: [ + { ...authoritativeDiscussionFile }, + { + ...authoritativeDiscussionFile, + ...{ id: 'abc2', file_identifier_hash: 'discfile2', order: 1 }, + }, + { + ...authoritativeDiscussionFile, + ...{ id: 'abc3', file_identifier_hash: 'discfile3', order: 2 }, + }, + ], + }, + }; +}; describe('Getters Notes Store', () => { let state; @@ -226,20 +244,84 @@ describe('Getters Notes Store', () => { const localGetters = { allResolvableDiscussions: [discussion3, discussion1, discussion2], }; + const rootState = createRootState(); - expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters)).toEqual([ + expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters, rootState)).toEqual([ 'abc1', 'abc2', 'abc3', ]); }); + // This is the same test as above, but it exercises the sorting algorithm + // for a "strange" Diff File ordering. The intent is to ensure that even if lots + // of shuffling has to occur, everything still works + + it('should return all discussions IDs in unusual diff order', () => { + const localGetters = { + allResolvableDiscussions: [discussion3, discussion1, discussion2], + }; + const rootState = { + diffs: { + diffFiles: [ + // 2 is first, but should sort 2nd + { + ...authoritativeDiscussionFile, + ...{ id: 'abc2', file_identifier_hash: 'discfile2', order: 1 }, + }, + // 1 is second, but should sort 3rd + { ...authoritativeDiscussionFile, ...{ order: 2 } }, + // 3 is third, but should sort 1st + { + ...authoritativeDiscussionFile, + ...{ id: 'abc3', file_identifier_hash: 'discfile3', order: 0 }, + }, + ], + }, + }; + + expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters, rootState)).toEqual([ + 'abc3', + 'abc2', + 'abc1', + ]); + }); + + it("should use the discussions array order if the files don't have explicit order values", () => { + const localGetters = { + allResolvableDiscussions: [discussion3, discussion1, discussion2], // This order is used! + }; + const auth1 = { ...authoritativeDiscussionFile }; + const auth2 = { + ...authoritativeDiscussionFile, + ...{ id: 'abc2', file_identifier_hash: 'discfile2' }, + }; + const auth3 = { + ...authoritativeDiscussionFile, + ...{ id: 'abc3', file_identifier_hash: 'discfile3' }, + }; + const rootState = { + diffs: { diffFiles: [auth2, auth1, auth3] }, // This order is not used! + }; + + delete auth1.order; + delete auth2.order; + delete auth3.order; + + expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters, rootState)).toEqual([ + 'abc3', + 'abc1', + 'abc2', + ]); + }); + it('should return empty array if all discussions have been resolved', () => { const localGetters = { allResolvableDiscussions: [resolvedDiscussion1], }; + const rootState = createRootState(); - expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters)).toEqual([]); + expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters, rootState)).toEqual([]); }); }); diff --git a/spec/frontend/oauth_remember_me_spec.js b/spec/frontend/oauth_remember_me_spec.js index 3187cbf6547..1fa0e0aa8f6 100644 --- a/spec/frontend/oauth_remember_me_spec.js +++ b/spec/frontend/oauth_remember_me_spec.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import OAuthRememberMe from '~/pages/sessions/new/oauth_remember_me'; describe('OAuthRememberMe', () => { @@ -7,11 +8,15 @@ describe('OAuthRememberMe', () => { }; beforeEach(() => { - loadFixtures('static/oauth_remember_me.html'); + loadHTMLFixture('static/oauth_remember_me.html'); new OAuthRememberMe({ container: $('#oauth-container') }).bindEvents(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('adds the "remember_me" query parameter to all OAuth login buttons', () => { $('#oauth-container #remember_me').click(); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js index a8d0d15007c..ca666e38291 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js @@ -1,7 +1,7 @@ import { GlDropdownItem, GlIcon, GlDropdown } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { useFakeDate } from 'helpers/fake_date'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -28,7 +28,6 @@ import { imageTagsCountMock } from '../../mock_data'; describe('Details Header', () => { let wrapper; let apolloProvider; - let localVue; const defaultImage = { name: 'foo', @@ -64,28 +63,18 @@ describe('Details Header', () => { const mountComponent = ({ propsData = { image: defaultImage }, resolver = jest.fn().mockResolvedValue(imageTagsCountMock()), - $apollo = undefined, } = {}) => { - const mocks = {}; + Vue.use(VueApollo); - if ($apollo) { - mocks.$apollo = $apollo; - } else { - localVue = createLocalVue(); - localVue.use(VueApollo); - - const requestHandlers = [[getContainerRepositoryMetadata, resolver]]; - apolloProvider = createMockApollo(requestHandlers); - } + const requestHandlers = [[getContainerRepositoryMetadata, resolver]]; + apolloProvider = createMockApollo(requestHandlers); wrapper = shallowMount(component, { - localVue, apolloProvider, propsData, directives: { GlTooltip: createMockDirective(), }, - mocks, stubs: { TitleArea, GlDropdown, @@ -98,7 +87,6 @@ describe('Details Header', () => { // if we want to mix createMockApollo and manual mocks we need to reset everything wrapper.destroy(); apolloProvider = undefined; - localVue = undefined; wrapper = null; }); @@ -194,10 +182,7 @@ describe('Details Header', () => { describe('metadata items', () => { describe('tags count', () => { it('displays "-- tags" while loading', async () => { - // here we are forced to mock apollo because `waitForMetadataItems` waits - // for two ticks, de facto allowing the promise to resolve, so there is - // no way to catch the component as both rendered and in loading state - mountComponent({ $apollo: { queries: { containerRepository: { loading: true } } } }); + mountComponent(); await waitForMetadataItems(); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js index e8ddad2d8ca..af5723267f4 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js @@ -1,8 +1,8 @@ -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { GlLink, GlPopover, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { helpPagePath } from '~/helpers/help_page_helper'; import CleanupStatus from '~/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue'; import { - CLEANUP_TIMED_OUT_ERROR_MESSAGE, CLEANUP_STATUS_SCHEDULED, CLEANUP_STATUS_ONGOING, CLEANUP_STATUS_UNFINISHED, @@ -17,12 +17,20 @@ describe('cleanup_status', () => { const findMainIcon = () => wrapper.findByTestId('main-icon'); const findExtraInfoIcon = () => wrapper.findByTestId('extra-info'); + const findPopover = () => wrapper.findComponent(GlPopover); + + const cleanupPolicyHelpPage = helpPagePath( + 'user/packages/container_registry/reduce_container_registry_storage.html', + { anchor: 'how-the-cleanup-policy-works' }, + ); const mountComponent = (propsData = { status: SCHEDULED_STATUS }) => { wrapper = shallowMountExtended(CleanupStatus, { propsData, - directives: { - GlTooltip: createMockDirective(), + stubs: { + GlLink, + GlPopover, + GlSprintf, }, }); }; @@ -43,7 +51,7 @@ describe('cleanup_status', () => { mountComponent({ status }); expect(findMainIcon().exists()).toBe(visible); - expect(wrapper.text()).toBe(text); + expect(wrapper.text()).toContain(text); }, ); @@ -53,12 +61,6 @@ describe('cleanup_status', () => { expect(findMainIcon().exists()).toBe(true); }); - - it(`has the orange class when the status is ${UNFINISHED_STATUS}`, () => { - mountComponent({ status: UNFINISHED_STATUS }); - - expect(findMainIcon().classes('gl-text-orange-500')).toBe(true); - }); }); describe('extra info icon', () => { @@ -76,12 +78,18 @@ describe('cleanup_status', () => { }, ); - it(`has a tooltip`, () => { - mountComponent({ status: UNFINISHED_STATUS }); + it(`has a popover with a learn more link and a time frame for the next run`, () => { + jest.spyOn(Date, 'now').mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime()); - const tooltip = getBinding(findExtraInfoIcon().element, 'gl-tooltip'); + mountComponent({ + status: UNFINISHED_STATUS, + expirationPolicy: { next_run: '2063-04-08T01:44:03Z' }, + }); - expect(tooltip.value.title).toBe(CLEANUP_TIMED_OUT_ERROR_MESSAGE); + expect(findPopover().exists()).toBe(true); + expect(findPopover().text()).toContain('The cleanup will continue within 4 days. Learn more'); + expect(findPopover().findComponent(GlLink).exists()).toBe(true); + expect(findPopover().findComponent(GlLink).attributes('href')).toBe(cleanupPolicyHelpPage); }); }); }); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js index 7d09c09d03b..f811468550d 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js @@ -4,7 +4,6 @@ import { nextTick } from 'vue'; import Component from '~/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue'; import { CONTAINER_REGISTRY_TITLE, - LIST_INTRO_TEXT, EXPIRATION_POLICY_DISABLED_TEXT, SET_UP_CLEANUP, } from '~/packages_and_registries/container_registry/explorer/constants'; @@ -135,9 +134,7 @@ describe('registry_header', () => { it('is correctly bound to title_area props', () => { mountComponent({ helpPagePath: 'foo' }); - expect(findTitleArea().props('infoMessages')).toEqual([ - { text: LIST_INTRO_TEXT, link: 'foo' }, - ]); + expect(findTitleArea().props('infoMessages')).toEqual([]); }); }); }); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/utils_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/utils_spec.js new file mode 100644 index 00000000000..5063759a620 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/utils_spec.js @@ -0,0 +1,21 @@ +import { timeTilRun } from '~/packages_and_registries/container_registry/explorer/utils'; + +describe('Container registry utilities', () => { + describe('timeTilRun', () => { + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime()); + }); + + it('should return a human readable time', () => { + const result = timeTilRun('2063-04-08T01:44:03Z'); + + expect(result).toBe('4 days'); + }); + + it('should return an empty string with null times', () => { + const result = timeTilRun(null); + + expect(result).toBe(''); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js index dbe9793fb8c..fe4a2c06f1c 100644 --- a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js +++ b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js @@ -9,7 +9,7 @@ import { GlSprintf, GlEmptyState, } from '@gitlab/ui'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import MockAdapter from 'axios-mock-adapter'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -47,7 +47,6 @@ describe('DependencyProxyApp', () => { const provideDefaults = { groupPath: 'gitlab-org', groupId: dummyGrouptId, - dependencyProxyAvailable: true, noManifestsIllustration: 'noManifestsIllustration', }; @@ -74,7 +73,6 @@ describe('DependencyProxyApp', () => { }); } - const findProxyNotAvailableAlert = () => wrapper.findByTestId('proxy-not-available'); const findClipBoardButton = () => wrapper.findComponent(ClipboardButton); const findFormGroup = () => wrapper.findComponent(GlFormGroup); const findFormInputGroup = () => wrapper.findComponent(GlFormInputGroup); @@ -103,59 +101,22 @@ describe('DependencyProxyApp', () => { mock.restore(); }); - describe('when the dependency proxy is not available', () => { - const createComponentArguments = { - provide: { ...provideDefaults, dependencyProxyAvailable: false }, - }; - - it('renders an info alert', () => { - createComponent(createComponentArguments); - - expect(findProxyNotAvailableAlert().text()).toBe( - DependencyProxyApp.i18n.proxyNotAvailableText, - ); - }); - - it('does not render the main area', () => { - createComponent(createComponentArguments); - - expect(findMainArea().exists()).toBe(false); - }); - - it('does not call the graphql endpoint', async () => { - resolver = jest.fn().mockResolvedValue(proxyDetailsQuery()); - createComponent({ ...createComponentArguments }); - - await waitForPromises(); - - expect(resolver).not.toHaveBeenCalled(); - }); - - it('hides the clear cache dropdown list', () => { - createComponent(createComponentArguments); - - expect(findClearCacheDropdownList().exists()).toBe(false); - }); - }); - describe('when the dependency proxy is available', () => { describe('when is loading', () => { - it('renders the skeleton loader', () => { + beforeEach(() => { createComponent(); + }); + it('renders the skeleton loader', () => { expect(findSkeletonLoader().exists()).toBe(true); }); - it('does not show the main section', () => { - createComponent(); - - expect(findMainArea().exists()).toBe(false); + it('does not render a form group with label', () => { + expect(findFormGroup().exists()).toBe(false); }); - it('does not render the info alert', () => { - createComponent(); - - expect(findProxyNotAvailableAlert().exists()).toBe(false); + it('does not show the main section', () => { + expect(findMainArea().exists()).toBe(false); }); }); @@ -166,10 +127,6 @@ describe('DependencyProxyApp', () => { return waitForPromises(); }); - it('does not render the info alert', () => { - expect(findProxyNotAvailableAlert().exists()).toBe(false); - }); - it('renders the main area', () => { expect(findMainArea().exists()).toBe(true); }); @@ -193,7 +150,7 @@ describe('DependencyProxyApp', () => { }); }); - it('from group has a description with proxy count', () => { + it('form group has a description with proxy count', () => { expect(findProxyCountText().text()).toBe('Contains 2 blobs of images (1024 Bytes)'); }); @@ -257,6 +214,28 @@ describe('DependencyProxyApp', () => { }); }); + describe('triggering page event on list', () => { + beforeEach(async () => { + findManifestList().vm.$emit('next-page'); + + await nextTick(); + }); + + it('re-renders the skeleton loader', () => { + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('renders form group with label', () => { + expect(findFormGroup().attributes('label')).toEqual( + expect.stringMatching(DependencyProxyApp.i18n.proxyImagePrefix), + ); + }); + + it('does not show the main section', () => { + expect(findMainArea().exists()).toBe(false); + }); + }); + it('shows the clear cache dropdown list', () => { expect(findClearCacheDropdownList().exists()).toBe(true); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js index b7cbd875497..be3236d1f9c 100644 --- a/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js +++ b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js @@ -3,6 +3,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import Component from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue'; +import { MANIFEST_PENDING_DESTRUCTION_STATUS } from '~/packages_and_registries/dependency_proxy/constants'; import { proxyManifests } from 'jest/packages_and_registries/dependency_proxy/mock_data'; describe('Manifest Row', () => { @@ -26,34 +27,63 @@ describe('Manifest Row', () => { const findListItem = () => wrapper.findComponent(ListItem); const findCachedMessages = () => wrapper.findByTestId('cached-message'); const findTimeAgoTooltip = () => wrapper.findComponent(TimeagoTooltip); - - beforeEach(() => { - createComponent(); - }); + const findStatus = () => wrapper.findByTestId('status'); afterEach(() => { wrapper.destroy(); }); - it('has a list item', () => { - expect(findListItem().exists()).toBe(true); - }); + describe('With a manifest on the DEFAULT status', () => { + beforeEach(() => { + createComponent(); + }); - it('displays the name', () => { - expect(wrapper.text()).toContain('alpine'); - }); + it('has a list item', () => { + expect(findListItem().exists()).toBe(true); + }); - it('displays the version', () => { - expect(wrapper.text()).toContain('latest'); - }); + it('displays the name', () => { + expect(wrapper.text()).toContain('alpine'); + }); - it('displays the cached time', () => { - expect(findCachedMessages().text()).toContain('Cached'); + it('displays the version', () => { + expect(wrapper.text()).toContain('latest'); + }); + + it('displays the cached time', () => { + expect(findCachedMessages().text()).toContain('Cached'); + }); + + it('has a time ago tooltip component', () => { + expect(findTimeAgoTooltip().props()).toMatchObject({ + time: defaultProps.manifest.createdAt, + }); + }); + + it('does not have a status element displayed', () => { + expect(findStatus().exists()).toBe(false); + }); }); - it('has a time ago tooltip component', () => { - expect(findTimeAgoTooltip().props()).toMatchObject({ - time: defaultProps.manifest.createdAt, + describe('With a manifest on the PENDING_DESTRUCTION_STATUS', () => { + const pendingDestructionManifest = { + manifest: { + ...defaultProps.manifest, + status: MANIFEST_PENDING_DESTRUCTION_STATUS, + }, + }; + + beforeEach(() => { + createComponent(pendingDestructionManifest); + }); + + it('has a list item', () => { + expect(findListItem().exists()).toBe(true); + }); + + it('has a status element displayed', () => { + expect(findStatus().exists()).toBe(true); + expect(findStatus().text()).toBe('Scheduled for deletion'); }); }); }); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js b/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js index 2aa427bc6af..37c8eb669ba 100644 --- a/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js +++ b/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js @@ -8,8 +8,18 @@ export const proxyData = () => ({ export const proxySettings = (extend = {}) => ({ enabled: true, ...extend }); export const proxyManifests = () => [ - { id: 'proxy-1', createdAt: '2021-09-22T09:45:28Z', imageName: 'alpine:latest' }, - { id: 'proxy-2', createdAt: '2021-09-21T09:45:28Z', imageName: 'alpine:stable' }, + { + id: 'proxy-1', + createdAt: '2021-09-22T09:45:28Z', + imageName: 'alpine:latest', + status: 'DEFAULT', + }, + { + id: 'proxy-2', + createdAt: '2021-09-21T09:45:28Z', + imageName: 'alpine:stable', + status: 'DEFAULT', + }, ]; export const pagination = (extend) => ({ 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 519014bb9cf..fdddc131412 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 @@ -29,12 +29,6 @@ exports[`PackageTitle renders with tags 1`] = ` <div class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-3" > - <gl-icon-stub - class="gl-mr-3" - name="eye" - size="16" - /> - <span data-testid="sub-header" > @@ -127,12 +121,6 @@ exports[`PackageTitle renders without tags 1`] = ` <div class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-3" > - <gl-icon-stub - class="gl-mr-3" - name="eye" - size="16" - /> - <span data-testid="sub-header" > 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 5da9cfffaae..d306f7834f0 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 @@ -1,4 +1,4 @@ -import { GlIcon, GlSprintf } from '@gitlab/ui'; +import { GlSprintf } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import { nextTick } from 'vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; @@ -46,7 +46,6 @@ describe('PackageTitle', () => { const findPackageRef = () => wrapper.findByTestId('package-ref'); const findPackageTags = () => wrapper.findComponent(PackageTags); const findPackageBadges = () => wrapper.findAllByTestId('tag-badge'); - const findSubHeaderIcon = () => wrapper.findComponent(GlIcon); const findSubHeaderText = () => wrapper.findByTestId('sub-header'); const findSubHeaderTimeAgo = () => wrapper.findComponent(TimeAgoTooltip); @@ -120,12 +119,6 @@ describe('PackageTitle', () => { }); describe('sub-header', () => { - it('has the eye icon', async () => { - await createComponent(); - - expect(findSubHeaderIcon().props('name')).toBe('eye'); - }); - it('has a text showing version', async () => { await createComponent(); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap index 18a99f70756..031afa62890 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap @@ -38,8 +38,6 @@ exports[`packages_list_row renders 1`] = ` </router-link-stub> <!----> - - <!----> </div> <!----> @@ -98,16 +96,35 @@ exports[`packages_list_row renders 1`] = ` <div class="gl-w-9 gl-display-flex gl-justify-content-end gl-pr-1" > - <gl-button-stub - aria-label="Remove package" - buttontextclasses="" - category="secondary" - data-testid="action-delete" - icon="remove" + <gl-dropdown-stub + category="tertiary" + clearalltext="Clear all" + clearalltextclass="gl-px-5" + data-testid="delete-dropdown" + headertext="" + hideheaderborder="true" + highlighteditemstitle="Selected" + highlighteditemstitleclass="gl-px-5" + icon="ellipsis_v" + no-caret="" size="medium" - title="Remove package" - variant="danger" - /> + text="More actions" + textsronly="true" + variant="default" + > + <gl-dropdown-item-stub + avatarurl="" + data-testid="action-delete" + iconcolor="" + iconname="" + iconrightarialabel="" + iconrightname="" + secondarytext="" + variant="danger" + > + Delete package + </gl-dropdown-item-stub> + </gl-dropdown-stub> </div> </div> diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js index 12a3eaa3873..c16c09b5326 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js @@ -28,12 +28,12 @@ describe('packages_list_row', () => { const packageWithoutTags = { ...packageData(), project: packageProject() }; const packageWithTags = { ...packageWithoutTags, tags: { nodes: packageTags() } }; + const packageCannotDestroy = { ...packageData(), canDestroy: false }; const findPackageTags = () => wrapper.find(PackageTags); const findPackagePath = () => wrapper.find(PackagePath); - const findDeleteButton = () => wrapper.findByTestId('action-delete'); + const findDeleteDropdown = () => wrapper.findByTestId('action-delete'); const findPackageIconAndName = () => wrapper.find(PackageIconAndName); - const findListItem = () => wrapper.findComponent(ListItem); const findPackageLink = () => wrapper.findByTestId('details-link'); const findWarningIcon = () => wrapper.findByTestId('warning-icon'); const findLeftSecondaryInfos = () => wrapper.findByTestId('left-secondary-infos'); @@ -102,22 +102,25 @@ describe('packages_list_row', () => { }); describe('delete button', () => { + it('does not exist when package cannot be destroyed', () => { + mountComponent({ packageEntity: packageCannotDestroy }); + + expect(findDeleteDropdown().exists()).toBe(false); + }); + it('exists and has the correct props', () => { mountComponent({ packageEntity: packageWithoutTags }); - expect(findDeleteButton().exists()).toBe(true); - expect(findDeleteButton().attributes()).toMatchObject({ - icon: 'remove', - category: 'secondary', + expect(findDeleteDropdown().exists()).toBe(true); + expect(findDeleteDropdown().attributes()).toMatchObject({ variant: 'danger', - title: 'Remove package', }); }); it('emits the packageToDelete event when the delete button is clicked', async () => { mountComponent({ packageEntity: packageWithoutTags }); - findDeleteButton().vm.$emit('click'); + findDeleteDropdown().vm.$emit('click'); await nextTick(); expect(wrapper.emitted('packageToDelete')).toBeTruthy(); @@ -130,10 +133,6 @@ describe('packages_list_row', () => { mountComponent({ packageEntity: { ...packageWithoutTags, status: PACKAGE_ERROR_STATUS } }); }); - it('list item has a disabled prop', () => { - expect(findListItem().props('disabled')).toBe(true); - }); - it('details link is disabled', () => { expect(findPackageLink().props('event')).toBe(''); }); @@ -141,14 +140,14 @@ describe('packages_list_row', () => { it('has a warning icon', () => { const icon = findWarningIcon(); const tooltip = getBinding(icon.element, 'gl-tooltip'); - expect(icon.props('icon')).toBe('warning'); + expect(icon.props('name')).toBe('warning'); expect(tooltip.value).toMatchObject({ title: 'Invalid Package: failed metadata extraction', }); }); - it('delete button does not exist', () => { - expect(findDeleteButton().exists()).toBe(false); + it('has a delete dropdown', () => { + expect(findDeleteDropdown().exists()).toBe(true); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js index 97978dee909..660f00a2b31 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js @@ -1,4 +1,4 @@ -import { GlKeysetPagination, GlModal, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlKeysetPagination, GlModal, GlSprintf } from '@gitlab/ui'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue'; @@ -21,6 +21,12 @@ describe('packages_list', () => { id: 'gid://gitlab/Packages::Package/112', name: 'second-package', }; + const errorPackage = { + ...packageData(), + id: 'gid://gitlab/Packages::Package/121', + status: 'ERROR', + name: 'error package', + }; const defaultProps = { list: [firstPackage, secondPackage], @@ -40,6 +46,7 @@ describe('packages_list', () => { const findPackageListDeleteModal = () => wrapper.findComponent(GlModalStub); const findEmptySlot = () => wrapper.findComponent(EmptySlotStub); const findPackagesListRow = () => wrapper.findComponent(PackagesListRow); + const findErrorPackageAlert = () => wrapper.findComponent(GlAlert); const mountComponent = (props) => { wrapper = shallowMountExtended(PackagesList, { @@ -109,6 +116,12 @@ describe('packages_list', () => { expect(findPackageListDeleteModal().exists()).toBe(true); }); + + it('does not have an error alert displayed', () => { + mountComponent(); + + expect(findErrorPackageAlert().exists()).toBe(false); + }); }); describe('when the user can destroy the package', () => { @@ -140,6 +153,32 @@ describe('packages_list', () => { }); }); + describe('when an error package is present', () => { + beforeEach(() => { + mountComponent({ list: [firstPackage, errorPackage] }); + + return nextTick(); + }); + + it('should display an alert message', () => { + expect(findErrorPackageAlert().exists()).toBe(true); + expect(findErrorPackageAlert().props('title')).toBe( + 'There was an error publishing a error package package', + ); + expect(findErrorPackageAlert().text()).toBe( + 'There was a timeout and the package was not published. Delete this package and try again.', + ); + }); + + it('should display the deletion modal when clicked on the confirm button', async () => { + findErrorPackageAlert().vm.$emit('primaryAction'); + + await nextTick(); + + expect(findPackageListDeleteModal().text()).toContain(errorPackage.name); + }); + }); + describe('when the list is empty', () => { beforeEach(() => { mountComponent({ list: [] }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js index e992ba12faa..23e5c7330d5 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js @@ -37,7 +37,7 @@ describe('PackageTitle', () => { expect(findTitleArea().props()).toMatchObject({ title: PackageTitle.i18n.LIST_TITLE_TEXT, - infoMessages: [{ text: PackageTitle.i18n.LIST_INTRO_TEXT, link: 'foo' }], + infoMessages: [], }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js index 26b2f3b359f..d0c111bae2d 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js @@ -11,7 +11,10 @@ describe('packages_filter', () => { const mountComponent = ({ attrs, listeners } = {}) => { wrapper = shallowMount(component, { - attrs, + attrs: { + cursorPosition: 'start', + ...attrs, + }, listeners, }); }; diff --git a/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js index 94f56e5c979..22754d31f93 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js @@ -6,11 +6,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import component from '~/packages_and_registries/settings/group/components/dependency_proxy_settings.vue'; -import { - DEPENDENCY_PROXY_HEADER, - DEPENDENCY_PROXY_SETTINGS_DESCRIPTION, - DEPENDENCY_PROXY_DOCS_PATH, -} from '~/packages_and_registries/settings/group/constants'; +import { DEPENDENCY_PROXY_HEADER } from '~/packages_and_registries/settings/group/constants'; import updateDependencyProxySettings from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql'; import updateDependencyProxyImageTtlGroupPolicy from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_image_ttl_group_policy.mutation.graphql'; @@ -91,8 +87,6 @@ describe('DependencyProxySettings', () => { const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); const findSettingsTitles = () => wrapper.findComponent(SettingsTitles); - const findDescription = () => wrapper.findByTestId('description'); - const findDescriptionLink = () => wrapper.findByTestId('description-link'); const findEnableProxyToggle = () => wrapper.findByTestId('dependency-proxy-setting-toggle'); const findEnableTtlPoliciesToggle = () => wrapper.findByTestId('dependency-proxy-ttl-policies-toggle'); @@ -126,21 +120,6 @@ describe('DependencyProxySettings', () => { expect(wrapper.text()).toContain(DEPENDENCY_PROXY_HEADER); }); - it('has the correct description text', () => { - mountComponent(); - - expect(findDescription().text()).toMatchInterpolatedText(DEPENDENCY_PROXY_SETTINGS_DESCRIPTION); - }); - - it('has the correct link', () => { - mountComponent(); - - expect(findDescriptionLink().attributes()).toMatchObject({ - href: DEPENDENCY_PROXY_DOCS_PATH, - }); - expect(findDescriptionLink().text()).toBe('Learn more'); - }); - describe('enable toggle', () => { it('exists', () => { mountComponent(); diff --git a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js index 5c30074a6af..635195ff0a4 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js @@ -28,7 +28,6 @@ describe('Group Settings App', () => { const defaultProvide = { defaultExpanded: false, groupPath: 'foo_group_path', - dependencyProxyAvailable: true, }; const mountComponent = ({ @@ -140,15 +139,4 @@ describe('Group Settings App', () => { }); }); }); - - describe('when the dependency proxy is not available', () => { - beforeEach(() => { - mountComponent({ provide: { ...defaultProvide, dependencyProxyAvailable: false } }); - return waitForApolloQueryAndRender(); - }); - - it('the setting block is hidden', () => { - expect(findDependencyProxySettings().exists()).toBe(false); - }); - }); }); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js index 266f953c3e0..465e6dc73e2 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js @@ -1,6 +1,6 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { GlCard, GlLoadingIcon } from 'jest/packages_and_registries/shared/stubs'; @@ -14,8 +14,6 @@ import expirationPolicyQuery from '~/packages_and_registries/settings/project/gr import Tracking from '~/tracking'; import { expirationPolicyPayload, expirationPolicyMutationPayload } from '../mock_data'; -const localVue = createLocalVue(); - describe('Settings Form', () => { let wrapper; let fakeApollo; @@ -59,7 +57,6 @@ describe('Settings Form', () => { data, config, provide = defaultProvidedValues, - mocks, } = {}) => { wrapper = shallowMount(component, { stubs: { @@ -77,7 +74,6 @@ describe('Settings Form', () => { $toast: { show: jest.fn(), }, - ...mocks, }, ...config, }); @@ -88,7 +84,7 @@ describe('Settings Form', () => { mutationResolver, queryPayload = expirationPolicyPayload(), } = {}) => { - localVue.use(VueApollo); + Vue.use(VueApollo); const requestHandlers = [ [updateContainerExpirationPolicyMutation, mutationResolver], @@ -120,7 +116,6 @@ describe('Settings Form', () => { value, }, config: { - localVue, apolloProvider: fakeApollo, }, }); @@ -356,8 +351,8 @@ describe('Settings Form', () => { }); it('parses the error messages', async () => { - const mutate = jest.fn().mockRejectedValue({ - graphQLErrors: [ + const mutate = jest.fn().mockResolvedValue({ + errors: [ { extensions: { problems: [{ path: ['nameRegexKeep'], message: 'baz' }], @@ -365,7 +360,9 @@ describe('Settings Form', () => { }, ], }); - mountComponent({ mocks: { $apollo: { mutate } } }); + mountComponentWithApollo({ + mutationResolver: mutate, + }); await submitForm(); diff --git a/spec/frontend/pager_spec.js b/spec/frontend/pager_spec.js index 9df69124d66..dfb3e87a342 100644 --- a/spec/frontend/pager_spec.js +++ b/spec/frontend/pager_spec.js @@ -1,5 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; @@ -26,12 +27,14 @@ describe('pager', () => { const originalHref = window.location.href; beforeEach(() => { - setFixtures('<div class="content_list"></div><div class="loading"></div>'); + setHTMLFixture('<div class="content_list"></div><div class="loading"></div>'); jest.spyOn($.fn, 'endlessScroll').mockImplementation(); }); afterEach(() => { window.history.replaceState({}, null, originalHref); + + resetHTMLFixture(); }); it('should get initial offset from query parameter', () => { @@ -57,7 +60,7 @@ describe('pager', () => { } beforeEach(() => { - setFixtures( + setHTMLFixture( '<div class="content_list" data-href="/some_list"></div><div class="loading"></div>', ); jest.spyOn(axios, 'get'); @@ -65,6 +68,10 @@ describe('pager', () => { Pager.init(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('shows loader while loading next page', async () => { mockSuccess(); @@ -135,7 +142,11 @@ describe('pager', () => { const href = `${TEST_HOST}/some_list.json`; beforeEach(() => { - setFixtures(`<div class="content_list" data-href="${href}"></div>`); + setHTMLFixture(`<div class="content_list" data-href="${href}"></div>`); + }); + + afterEach(() => { + resetHTMLFixture(); }); it('should use data-href attribute', () => { @@ -154,7 +165,11 @@ describe('pager', () => { describe('no data-href attribute attribute provided from list element', () => { beforeEach(() => { - setFixtures(`<div class="content_list"></div>`); + setHTMLFixture(`<div class="content_list"></div>`); + }); + + afterEach(() => { + resetHTMLFixture(); }); it('should use current url', () => { @@ -190,7 +205,7 @@ describe('pager', () => { describe('when `container` is visible', () => { it('makes API request', () => { - setFixtures( + setHTMLFixture( `<div id="js-pager"><div class="content_list" data-href="${href}"></div></div>`, ); @@ -199,12 +214,14 @@ describe('pager', () => { endlessScrollCallback(); expect(axios.get).toHaveBeenCalledWith(href, expect.any(Object)); + + resetHTMLFixture(); }); }); describe('when `container` is not visible', () => { it('does not make API request', () => { - setFixtures( + setHTMLFixture( `<div id="js-pager" style="display: none;"><div class="content_list" data-href="${href}"></div></div>`, ); @@ -213,6 +230,8 @@ describe('pager', () => { endlessScrollCallback(); expect(axios.get).not.toHaveBeenCalled(); + + resetHTMLFixture(); }); }); }); diff --git a/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js b/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js index 71c9da238b4..6edfe9641b9 100644 --- a/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js +++ b/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import '~/lib/utils/text_utility'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import AbuseReports from '~/pages/admin/abuse_reports/abuse_reports'; describe('Abuse Reports', () => { @@ -15,11 +15,15 @@ describe('Abuse Reports', () => { $messages.filter((index, element) => element.innerText.indexOf(searchText) > -1).first(); beforeEach(() => { - loadFixtures(FIXTURE); + loadHTMLFixture(FIXTURE); new AbuseReports(); // eslint-disable-line no-new $messages = $('.abuse-reports .message'); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('should truncate long messages', () => { const $longMessage = findMessage('LONG MESSAGE'); diff --git a/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js index 3a4f93d4464..542eb2f3ab8 100644 --- a/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js +++ b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import initUserInternalRegexPlaceholder, { PLACEHOLDER_USER_EXTERNAL_DEFAULT_FALSE, PLACEHOLDER_USER_EXTERNAL_DEFAULT_TRUE, @@ -10,12 +11,16 @@ describe('AccountAndLimits', () => { let $userInternalRegex; beforeEach(() => { - loadFixtures(FIXTURE); + loadHTMLFixture(FIXTURE); initUserInternalRegexPlaceholder(); $userDefaultExternal = $('#application_setting_user_default_external'); $userInternalRegex = document.querySelector('#application_setting_user_default_internal_regex'); }); + afterEach(() => { + resetHTMLFixture(); + }); + describe('Changing of userInternalRegex when userDefaultExternal', () => { it('is unchecked', () => { expect($userDefaultExternal.prop('checked')).toBeFalsy(); diff --git a/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js b/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js index 4140b985682..3a52c243867 100644 --- a/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js +++ b/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js @@ -2,6 +2,7 @@ import initSetHelperText, { HELPER_TEXT_SERVICE_PING_DISABLED, HELPER_TEXT_SERVICE_PING_ENABLED, } from '~/pages/admin/application_settings/metrics_and_profiling/usage_statistics'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; describe('UsageStatistics', () => { const FIXTURE = 'application_settings/usage.html'; @@ -11,7 +12,7 @@ describe('UsageStatistics', () => { let servicePingFeaturesHelperText; beforeEach(() => { - loadFixtures(FIXTURE); + loadHTMLFixture(FIXTURE); initSetHelperText(); servicePingCheckBox = document.getElementById('application_setting_usage_ping_enabled'); servicePingFeaturesCheckBox = document.getElementById( @@ -21,6 +22,10 @@ describe('UsageStatistics', () => { servicePingFeaturesHelperText = document.getElementById('service_ping_features_helper_text'); }); + afterEach(() => { + resetHTMLFixture(); + }); + const expectEnabledservicePingFeaturesCheckBox = () => { expect(servicePingFeaturesCheckBox.classList.contains('gl-cursor-not-allowed')).toBe(false); expect(servicePingFeaturesHelperText.textContent).toEqual(HELPER_TEXT_SERVICE_PING_ENABLED); diff --git a/spec/frontend/pages/admin/projects/components/namespace_select_spec.js b/spec/frontend/pages/admin/projects/components/namespace_select_spec.js index f10b202f4d7..909349569a8 100644 --- a/spec/frontend/pages/admin/projects/components/namespace_select_spec.js +++ b/spec/frontend/pages/admin/projects/components/namespace_select_spec.js @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import Api from '~/api'; import NamespaceSelect from '~/pages/admin/projects/components/namespace_select.vue'; @@ -26,7 +27,7 @@ describe('Dropdown select component', () => { }; beforeEach(() => { - setFixtures('<div class="test-container"></div>'); + setHTMLFixture('<div class="test-container"></div>'); jest.spyOn(Api, 'namespaces').mockImplementation((_, callback) => callback([ @@ -36,6 +37,10 @@ describe('Dropdown select component', () => { ); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('creates a hidden input if fieldName is provided', () => { mountDropdown({ fieldName: 'namespace-input' }); diff --git a/spec/frontend/pages/dashboard/todos/index/todos_spec.js b/spec/frontend/pages/dashboard/todos/index/todos_spec.js index ae53afa7fba..3a9b59f291c 100644 --- a/spec/frontend/pages/dashboard/todos/index/todos_spec.js +++ b/spec/frontend/pages/dashboard/todos/index/todos_spec.js @@ -1,5 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; import '~/lib/utils/common_utils'; import axios from '~/lib/utils/axios_utils'; @@ -19,7 +20,7 @@ describe('Todos', () => { let mock; beforeEach(() => { - loadFixtures('todos/todos.html'); + loadHTMLFixture('todos/todos.html'); todoItem = document.querySelector('.todos-list .todo'); mock = new MockAdapter(axios); @@ -27,6 +28,10 @@ describe('Todos', () => { }); afterEach(() => { + resetHTMLFixture(); + }); + + afterEach(() => { mock.restore(); }); diff --git a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js index 43c48617800..a850b1655f7 100644 --- a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js +++ b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js @@ -3,6 +3,7 @@ import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import BulkImportsHistoryApp from '~/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -60,6 +61,8 @@ describe('BulkImportsHistoryApp', () => { wrapper = mountFn(BulkImportsHistoryApp); } + const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); + const originalApiVersion = gon.api_version; beforeAll(() => { gon.api_version = 'v4'; @@ -137,6 +140,20 @@ describe('BulkImportsHistoryApp', () => { ); }); + it('sets up the local storage sync correctly', async () => { + const NEW_PAGE_SIZE = 4; + + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent(); + await axios.waitForAll(); + mock.resetHistory(); + + wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE); + await axios.waitForAll(); + + expect(findLocalStorageSync().props('value')).toBe(NEW_PAGE_SIZE); + }); + it('renders correct url for destination group when relative_url is empty', async () => { mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); createComponent({ shallow: false }); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap index 269c7467c8b..005b8968383 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap +++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap @@ -178,7 +178,8 @@ exports[`Learn GitLab renders correctly 1`] = ` data-track-action="click_link" data-track-label="Start a free Ultimate trial" href="http://example.com/" - target="_self" + rel="noopener noreferrer" + target="_blank" > Start a free Ultimate trial @@ -209,7 +210,8 @@ exports[`Learn GitLab renders correctly 1`] = ` data-track-action="click_link" data-track-label="Add code owners" href="http://example.com/" - target="_self" + rel="noopener noreferrer" + target="_blank" > Add code owners @@ -240,7 +242,8 @@ exports[`Learn GitLab renders correctly 1`] = ` data-track-action="click_link" data-track-label="Add merge request approval" href="http://example.com/" - target="_self" + rel="noopener noreferrer" + target="_blank" > Add merge request approval diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js index b8ebf2a1430..d9aff37f703 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js +++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js @@ -1,8 +1,11 @@ +import { GlPopover, GlLink } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { stubExperiments } from 'helpers/experimentation_helper'; import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper'; import eventHub from '~/invite_members/event_hub'; import LearnGitlabSectionLink from '~/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue'; +import { ACTION_LABELS } from '~/pages/projects/learn_gitlab/constants'; const defaultAction = 'gitWrite'; const defaultProps = { @@ -10,6 +13,7 @@ const defaultProps = { description: 'Some description', url: 'https://example.com', completed: false, + enabled: true, }; const openInNewTabProps = { @@ -26,16 +30,21 @@ describe('Learn GitLab Section Link', () => { }); const createWrapper = (action = defaultAction, props = {}) => { - wrapper = mount(LearnGitlabSectionLink, { - propsData: { action, value: { ...defaultProps, ...props } }, - }); + wrapper = extendedWrapper( + mount(LearnGitlabSectionLink, { + propsData: { action, value: { ...defaultProps, ...props } }, + }), + ); }; const openInviteMembesrModalLink = () => wrapper.find('[data-testid="invite-for-help-continuous-onboarding-experiment-link"]'); const findUncompletedLink = () => wrapper.find('[data-testid="uncompleted-learn-gitlab-link"]'); - + const findDisabledLink = () => wrapper.findByTestId('disabled-learn-gitlab-link'); + const findPopoverTrigger = () => wrapper.findByTestId('contact-admin-popover-trigger'); + const findPopover = () => wrapper.findComponent(GlPopover); + const findPopoverLink = () => findPopover().findComponent(GlLink); const videoTutorialLink = () => wrapper.find('[data-testid="video-tutorial-link"]'); it('renders no icon when not completed', () => { @@ -62,6 +71,36 @@ describe('Learn GitLab Section Link', () => { expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(true); }); + describe('disabled links', () => { + beforeEach(() => { + createWrapper('trialStarted', { enabled: false }); + }); + + it('renders text without a link', () => { + expect(findDisabledLink().exists()).toBe(true); + expect(findDisabledLink().text()).toBe(ACTION_LABELS.trialStarted.title); + expect(findDisabledLink().attributes('href')).toBeUndefined(); + }); + + it('renders a popover trigger with question icon', () => { + expect(findPopoverTrigger().exists()).toBe(true); + expect(findPopoverTrigger().props('icon')).toBe('question-o'); + }); + + it('renders a popover', () => { + expect(findPopoverTrigger().attributes('id')).toBe(findPopover().props('target')); + expect(findPopover().props()).toMatchObject({ + placement: 'top', + triggers: 'hover focus', + }); + }); + + it('renders a link inside the popover', () => { + expect(findPopoverLink().exists()).toBe(true); + expect(findPopoverLink().attributes('href')).toBe(defaultProps.url); + }); + }); + describe('links marked with openInNewTab', () => { beforeEach(() => { createWrapper('securityScanEnabled', openInNewTabProps); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js index 5f1aff99578..0f63c243342 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js +++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js @@ -1,6 +1,6 @@ import { GlProgressBar, GlAlert } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import Cookies from 'js-cookie'; +import Cookies from '~/lib/utils/cookies'; import LearnGitlab from '~/pages/projects/learn_gitlab/components/learn_gitlab.vue'; import eventHub from '~/invite_members/event_hub'; import { INVITE_MODAL_OPEN_COOKIE } from '~/pages/projects/learn_gitlab/constants'; diff --git a/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js b/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js index 5dc64097d81..1c29c68d2a9 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js +++ b/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js @@ -3,47 +3,56 @@ export const testActions = { url: 'http://example.com/', completed: true, svg: 'http://example.com/images/illustration.svg', + enabled: true, }, userAdded: { url: 'http://example.com/', completed: true, svg: 'http://example.com/images/illustration.svg', + enabled: true, }, pipelineCreated: { url: 'http://example.com/', completed: false, svg: 'http://example.com/images/illustration.svg', + enabled: true, }, trialStarted: { url: 'http://example.com/', completed: false, svg: 'http://example.com/images/illustration.svg', + enabled: true, }, codeOwnersEnabled: { url: 'http://example.com/', completed: false, svg: 'http://example.com/images/illustration.svg', + enabled: true, }, requiredMrApprovalsEnabled: { url: 'http://example.com/', completed: false, svg: 'http://example.com/images/illustration.svg', + enabled: true, }, mergeRequestCreated: { url: 'http://example.com/', completed: false, svg: 'http://example.com/images/illustration.svg', + enabled: true, }, securityScanEnabled: { url: 'https://docs.gitlab.com/ee/foobar/', completed: false, svg: 'http://example.com/images/illustration.svg', + enabled: true, openInNewTab: true, }, issueCreated: { url: 'http://example.com/', completed: false, svg: 'http://example.com/images/illustration.svg', + enabled: true, }, }; diff --git a/spec/frontend/pages/projects/merge_requests/edit/check_form_state_spec.js b/spec/frontend/pages/projects/merge_requests/edit/check_form_state_spec.js index ea49111760b..5c186441817 100644 --- a/spec/frontend/pages/projects/merge_requests/edit/check_form_state_spec.js +++ b/spec/frontend/pages/projects/merge_requests/edit/check_form_state_spec.js @@ -1,3 +1,4 @@ +import { setHTMLFixture, resetHTMLFixture } from 'jest/__helpers__/fixtures'; import initCheckFormState from '~/pages/projects/merge_requests/edit/check_form_state'; describe('Check form state', () => { @@ -7,7 +8,7 @@ describe('Check form state', () => { let setDialogContent; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <form class="merge-request-form"> <input type="text" name="test" id="form-input"/> </form>`); @@ -22,6 +23,8 @@ describe('Check form state', () => { afterEach(() => { beforeUnloadEvent.preventDefault.mockRestore(); setDialogContent.mockRestore(); + + resetHTMLFixture(); }); it('shows confirmation dialog when there are unsaved changes', () => { diff --git a/spec/frontend/pages/projects/pages_domains/form_spec.js b/spec/frontend/pages/projects/pages_domains/form_spec.js index 55336596f30..e437121acd2 100644 --- a/spec/frontend/pages/projects/pages_domains/form_spec.js +++ b/spec/frontend/pages/projects/pages_domains/form_spec.js @@ -1,3 +1,4 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import initForm from '~/pages/projects/pages_domains/form'; const ENABLED_UNLESS_AUTO_SSL_CLASS = 'js-enabled-unless-auto-ssl'; @@ -17,7 +18,7 @@ describe('Page domains form', () => { const findUnlessAutoSsl = () => document.querySelector(`.${SHOW_UNLESS_AUTO_SSL_CLASS}`); const create = () => { - setFixtures(` + setHTMLFixture(` <form> <span class="${SSL_TOGGLE_CLASS}" @@ -31,6 +32,10 @@ describe('Page domains form', () => { `); }; + afterEach(() => { + resetHTMLFixture(); + }); + it('instantiates the toggle', () => { create(); initForm(); diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js index c28a03b35d7..ca7f70f4434 100644 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js @@ -1,7 +1,7 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import Cookies from 'js-cookie'; import { nextTick } from 'vue'; +import Cookies from '~/lib/utils/cookies'; import PipelineSchedulesCallout from '~/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue'; const cookieKey = 'pipeline_schedules_callout_dismissed'; 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 b700c255e8c..42eeff89bf4 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,4 +1,5 @@ import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import TimezoneDropdown, { formatUtcOffset, formatTimezone, @@ -25,13 +26,17 @@ describe('Timezone Dropdown', () => { describe('Initialize', () => { describe('with dropdown already loaded', () => { beforeEach(() => { - loadFixtures('pipeline_schedules/edit.html'); + loadHTMLFixture('pipeline_schedules/edit.html'); $wrapper = $('.dropdown'); $inputEl = $('#schedule_cron_timezone'); $inputEl.val(''); $dropdownEl = $('.js-timezone-dropdown'); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('can take an $inputEl in the constructor', () => { initTimezoneDropdown(); @@ -86,7 +91,7 @@ describe('Timezone Dropdown', () => { describe('without dropdown loaded', () => { beforeEach(() => { - loadFixtures('pipeline_schedules/edit.html'); + loadHTMLFixture('pipeline_schedules/edit.html'); $wrapper = $('.dropdown'); $inputEl = $('#schedule_cron_timezone'); $dropdownEl = $('.js-timezone-dropdown'); diff --git a/spec/frontend/pages/search/show/refresh_counts_spec.js b/spec/frontend/pages/search/show/refresh_counts_spec.js index 81c9bf74308..6f14f0c70bd 100644 --- a/spec/frontend/pages/search/show/refresh_counts_spec.js +++ b/spec/frontend/pages/search/show/refresh_counts_spec.js @@ -1,4 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import axios from '~/lib/utils/axios_utils'; import refreshCounts from '~/pages/search/show/refresh_counts'; @@ -18,7 +19,11 @@ describe('pages/search/show/refresh_counts', () => { beforeEach(() => { mock = new MockAdapter(axios); - setFixtures(fixture); + setHTMLFixture(fixture); + }); + + afterEach(() => { + resetHTMLFixture(); }); afterEach(() => { diff --git a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js index a29db961452..4c4a0fbea11 100644 --- a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js +++ b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import preserveUrlFragment from '~/pages/sessions/new/preserve_url_fragment'; describe('preserve_url_fragment', () => { @@ -7,7 +8,11 @@ describe('preserve_url_fragment', () => { }; beforeEach(() => { - loadFixtures('sessions/new.html'); + loadHTMLFixture('sessions/new.html'); + }); + + afterEach(() => { + resetHTMLFixture(); }); it('adds the url fragment to the login form actions', () => { diff --git a/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js b/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js index 601fcfedbe0..f736ce46f9b 100644 --- a/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js +++ b/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js @@ -1,3 +1,4 @@ +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import AccessorUtilities from '~/lib/utils/accessor'; import SigninTabsMemoizer from '~/pages/sessions/new/signin_tabs_memoizer'; @@ -19,11 +20,15 @@ describe('SigninTabsMemoizer', () => { } beforeEach(() => { - loadFixtures(fixtureTemplate); + loadHTMLFixture(fixtureTemplate); jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('does nothing if no tab was previously selected', () => { createMemoizer(); diff --git a/spec/frontend/pdf/index_spec.js b/spec/frontend/pdf/index_spec.js index 1ae77a62675..2b0932493bb 100644 --- a/spec/frontend/pdf/index_spec.js +++ b/spec/frontend/pdf/index_spec.js @@ -14,18 +14,8 @@ const Component = Vue.extend(PDFLab); describe('PDF component', () => { let vm; - const checkLoaded = (done) => { - if (vm.loading) { - setTimeout(() => { - checkLoaded(done); - }, 100); - } else { - done(); - } - }; - describe('without PDF data', () => { - beforeEach((done) => { + beforeEach(() => { vm = new Component({ propsData: { pdf: '', @@ -33,8 +23,6 @@ describe('PDF component', () => { }); vm.$mount(); - - checkLoaded(done); }); it('does not render', () => { @@ -43,7 +31,7 @@ describe('PDF component', () => { }); describe('with PDF data', () => { - beforeEach((done) => { + beforeEach(() => { vm = new Component({ propsData: { pdf, @@ -51,8 +39,6 @@ describe('PDF component', () => { }); vm.$mount(); - - checkLoaded(done); }); it('renders pdf component', () => { diff --git a/spec/frontend/performance_bar/index_spec.js b/spec/frontend/performance_bar/index_spec.js index 91cb46002be..6c1cbfa70a1 100644 --- a/spec/frontend/performance_bar/index_spec.js +++ b/spec/frontend/performance_bar/index_spec.js @@ -1,4 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import axios from '~/lib/utils/axios_utils'; import '~/performance_bar/components/performance_bar_app.vue'; import performanceBar from '~/performance_bar'; @@ -11,7 +12,7 @@ describe('performance bar wrapper', () => { let vm; beforeEach(() => { - setFixtures('<div id="js-peek"></div>'); + setHTMLFixture('<div id="js-peek"></div>'); const peekWrapper = document.getElementById('js-peek'); performance.getEntriesByType = jest.fn().mockReturnValue([]); @@ -49,6 +50,7 @@ describe('performance bar wrapper', () => { vm.$destroy(); document.getElementById('js-peek').remove(); mock.restore(); + resetHTMLFixture(); }); describe('addRequest', () => { diff --git a/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js b/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js index 59bd71b0e60..bec6c2a8d0c 100644 --- a/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js +++ b/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js @@ -71,7 +71,7 @@ describe('Pipeline Editor | Commit Form', () => { expect(wrapper.emitted('submit')[0]).toEqual([ { message: mockCommitMessage, - targetBranch: mockDefaultBranch, + sourceBranch: mockDefaultBranch, openMergeRequest: false, }, ]); @@ -127,7 +127,7 @@ describe('Pipeline Editor | Commit Form', () => { expect(wrapper.emitted('submit')[0]).toEqual([ { message: anotherMessage, - targetBranch: anotherBranch, + sourceBranch: anotherBranch, openMergeRequest: true, }, ]); diff --git a/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js b/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js index e24de832d6d..a61796dbed2 100644 --- a/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js +++ b/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js @@ -1,19 +1,58 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; import BranchSwitcher from '~/pipeline_editor/components/file_nav/branch_switcher.vue'; import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; +import FileTreePopover from '~/pipeline_editor/components/popovers/file_tree_popover.vue'; +import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.query.graphql'; +import { + EDITOR_APP_STATUS_EMPTY, + EDITOR_APP_STATUS_LOADING, + EDITOR_APP_STATUS_VALID, +} from '~/pipeline_editor/constants'; + +Vue.use(VueApollo); describe('Pipeline editor file nav', () => { let wrapper; - const createComponent = ({ provide = {} } = {}) => { - wrapper = shallowMount(PipelineEditorFileNav, { - provide: { - ...provide, + const mockApollo = createMockApollo(); + + const createComponent = ({ + appStatus = EDITOR_APP_STATUS_VALID, + isNewCiConfigFile = false, + pipelineEditorFileTree = false, + } = {}) => { + mockApollo.clients.defaultClient.cache.writeQuery({ + query: getAppStatus, + data: { + app: { + __typename: 'PipelineEditorApp', + status: appStatus, + }, }, }); + + wrapper = extendedWrapper( + shallowMount(PipelineEditorFileNav, { + apolloProvider: mockApollo, + provide: { + glFeatures: { + pipelineEditorFileTree, + }, + }, + propsData: { + isNewCiConfigFile, + }, + }), + ); }; const findBranchSwitcher = () => wrapper.findComponent(BranchSwitcher); + const findFileTreeBtn = () => wrapper.findByTestId('file-tree-toggle'); + const findPopoverContainer = () => wrapper.findComponent(FileTreePopover); afterEach(() => { wrapper.destroy(); @@ -27,5 +66,91 @@ describe('Pipeline editor file nav', () => { it('renders the branch switcher', () => { expect(findBranchSwitcher().exists()).toBe(true); }); + + it('does not render the file tree button', () => { + expect(findFileTreeBtn().exists()).toBe(false); + }); + + it('does not render the file tree popover', () => { + expect(findPopoverContainer().exists()).toBe(false); + }); + }); + + describe('with pipelineEditorFileTree feature flag ON', () => { + describe('when editor is in the empty state', () => { + beforeEach(() => { + createComponent({ + appStatus: EDITOR_APP_STATUS_EMPTY, + isNewCiConfigFile: false, + pipelineEditorFileTree: true, + }); + }); + + it('does not render the file tree button', () => { + expect(findFileTreeBtn().exists()).toBe(false); + }); + + it('does not render the file tree popover', () => { + expect(findPopoverContainer().exists()).toBe(false); + }); + }); + + describe('when user is about to create their config file for the first time', () => { + beforeEach(() => { + createComponent({ + appStatus: EDITOR_APP_STATUS_VALID, + isNewCiConfigFile: true, + pipelineEditorFileTree: true, + }); + }); + + it('does not render the file tree button', () => { + expect(findFileTreeBtn().exists()).toBe(false); + }); + + it('does not render the file tree popover', () => { + expect(findPopoverContainer().exists()).toBe(false); + }); + }); + + describe('when app is in a global loading state', () => { + it('renders the file tree button with a loading icon', () => { + createComponent({ + appStatus: EDITOR_APP_STATUS_LOADING, + isNewCiConfigFile: false, + pipelineEditorFileTree: true, + }); + + expect(findFileTreeBtn().exists()).toBe(true); + expect(findFileTreeBtn().attributes('loading')).toBe('true'); + }); + }); + + describe('when editor has a non-empty config file open', () => { + beforeEach(() => { + createComponent({ + appStatus: EDITOR_APP_STATUS_VALID, + isNewCiConfigFile: false, + pipelineEditorFileTree: true, + }); + }); + + it('renders the file tree button', () => { + expect(findFileTreeBtn().exists()).toBe(true); + expect(findFileTreeBtn().props('icon')).toBe('file-tree'); + }); + + it('renders the file tree popover', () => { + expect(findPopoverContainer().exists()).toBe(true); + }); + + it('file tree button emits toggle-file-tree event', () => { + expect(wrapper.emitted('toggle-file-tree')).toBe(undefined); + + findFileTreeBtn().vm.$emit('click'); + + expect(wrapper.emitted('toggle-file-tree')).toHaveLength(1); + }); + }); }); }); diff --git a/spec/frontend/pipeline_editor/components/file-tree/container_spec.js b/spec/frontend/pipeline_editor/components/file-tree/container_spec.js new file mode 100644 index 00000000000..04a93e8db25 --- /dev/null +++ b/spec/frontend/pipeline_editor/components/file-tree/container_spec.js @@ -0,0 +1,138 @@ +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { GlAlert } from '@gitlab/ui'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { createMockDirective } from 'helpers/vue_mock_directive'; +import PipelineEditorFileTreeContainer from '~/pipeline_editor/components/file_tree/container.vue'; +import PipelineEditorFileTreeItem from '~/pipeline_editor/components/file_tree/file_item.vue'; +import { FILE_TREE_TIP_DISMISSED_KEY } from '~/pipeline_editor/constants'; +import { mockCiConfigPath, mockIncludes, mockIncludesHelpPagePath } from '../../mock_data'; + +describe('Pipeline editor file nav', () => { + let wrapper; + + const createComponent = ({ includes = mockIncludes, stubs } = {}) => { + wrapper = extendedWrapper( + shallowMount(PipelineEditorFileTreeContainer, { + provide: { + ciConfigPath: mockCiConfigPath, + includesHelpPagePath: mockIncludesHelpPagePath, + }, + propsData: { + includes, + }, + directives: { + GlTooltip: createMockDirective(), + }, + stubs, + }), + ); + }; + + const findTip = () => wrapper.findComponent(GlAlert); + const findCurrentConfigFilename = () => wrapper.findByTestId('current-config-filename'); + const fileTreeItems = () => wrapper.findAll(PipelineEditorFileTreeItem); + + afterEach(() => { + localStorage.clear(); + wrapper.destroy(); + }); + + describe('template', () => { + beforeEach(() => { + createComponent({ stubs: { GlAlert } }); + }); + + it('renders config file as a file item', () => { + expect(findCurrentConfigFilename().text()).toBe(mockCiConfigPath); + }); + }); + + describe('when includes list is empty', () => { + describe('when dismiss state is not saved in local storage', () => { + beforeEach(() => { + createComponent({ + includes: [], + stubs: { GlAlert }, + }); + }); + + it('does not render filenames', () => { + expect(fileTreeItems().exists()).toBe(false); + }); + + it('renders alert tip', async () => { + expect(findTip().exists()).toBe(true); + }); + + it('renders learn more link', async () => { + expect(findTip().props('secondaryButtonLink')).toBe(mockIncludesHelpPagePath); + }); + + it('can dismiss the tip', async () => { + expect(findTip().exists()).toBe(true); + + findTip().vm.$emit('dismiss'); + await nextTick(); + + expect(findTip().exists()).toBe(false); + }); + }); + + describe('when dismiss state is saved in local storage', () => { + beforeEach(() => { + localStorage.setItem(FILE_TREE_TIP_DISMISSED_KEY, 'true'); + createComponent({ + includes: [], + stubs: { GlAlert }, + }); + }); + + it('does not render alert tip', async () => { + expect(findTip().exists()).toBe(false); + }); + }); + + describe('when component receives new props with includes files', () => { + beforeEach(() => { + createComponent({ includes: [] }); + }); + + it('hides tip and renders list of files', async () => { + expect(findTip().exists()).toBe(true); + expect(fileTreeItems()).toHaveLength(0); + + await wrapper.setProps({ includes: mockIncludes }); + + expect(findTip().exists()).toBe(false); + expect(fileTreeItems()).toHaveLength(mockIncludes.length); + }); + }); + }); + + describe('when there are includes files', () => { + beforeEach(() => { + createComponent({ stubs: { GlAlert } }); + }); + + it('does not render alert tip', () => { + expect(findTip().exists()).toBe(false); + }); + + it('renders the list of files', () => { + expect(fileTreeItems()).toHaveLength(mockIncludes.length); + }); + + describe('when component receives new props with empty includes', () => { + it('shows tip and does not render list of files', async () => { + expect(findTip().exists()).toBe(false); + expect(fileTreeItems()).toHaveLength(mockIncludes.length); + + await wrapper.setProps({ includes: [] }); + + expect(findTip().exists()).toBe(true); + expect(fileTreeItems()).toHaveLength(0); + }); + }); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/file-tree/file_item_spec.js b/spec/frontend/pipeline_editor/components/file-tree/file_item_spec.js new file mode 100644 index 00000000000..f12ac14c6be --- /dev/null +++ b/spec/frontend/pipeline_editor/components/file-tree/file_item_spec.js @@ -0,0 +1,52 @@ +import { GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import PipelineEditorFileTreeItem from '~/pipeline_editor/components/file_tree/file_item.vue'; +import { mockIncludesWithBlob, mockDefaultIncludes } from '../../mock_data'; + +describe('Pipeline editor file nav', () => { + let wrapper; + + const createComponent = ({ file = mockDefaultIncludes } = {}) => { + wrapper = shallowMount(PipelineEditorFileTreeItem, { + propsData: { + file, + }, + }); + }; + + const fileIcon = () => wrapper.findComponent(FileIcon); + const link = () => wrapper.findComponent(GlLink); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders file icon', () => { + expect(fileIcon().exists()).toBe(true); + }); + + it('renders file name', () => { + expect(wrapper.text()).toBe(mockDefaultIncludes.location); + }); + + it('links to raw path by default', () => { + expect(link().attributes('href')).toBe(mockDefaultIncludes.raw); + }); + }); + + describe('when file has blob link', () => { + beforeEach(() => { + createComponent({ file: mockIncludesWithBlob }); + }); + + it('links to blob path', () => { + expect(link().attributes('href')).toBe(mockIncludesWithBlob.blob); + }); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js index 6dffb7e5470..d159a20a8d6 100644 --- a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js +++ b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js @@ -3,7 +3,7 @@ import { shallowMount, mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import setWindowLocation from 'helpers/set_window_location_helper'; import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue'; -import WalkthroughPopover from '~/pipeline_editor/components/walkthrough_popover.vue'; +import WalkthroughPopover from '~/pipeline_editor/components/popovers/walkthrough_popover.vue'; import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue'; import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue'; diff --git a/spec/frontend/pipeline_editor/components/popovers/file_tree_popover_spec.js b/spec/frontend/pipeline_editor/components/popovers/file_tree_popover_spec.js new file mode 100644 index 00000000000..98ce3f6ea40 --- /dev/null +++ b/spec/frontend/pipeline_editor/components/popovers/file_tree_popover_spec.js @@ -0,0 +1,56 @@ +import { nextTick } from 'vue'; +import { GlLink, GlPopover, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import FileTreePopover from '~/pipeline_editor/components/popovers/file_tree_popover.vue'; +import { FILE_TREE_POPOVER_DISMISSED_KEY } from '~/pipeline_editor/constants'; +import { mockIncludesHelpPagePath } from '../../mock_data'; + +describe('FileTreePopover component', () => { + let wrapper; + + const findPopover = () => wrapper.findComponent(GlPopover); + const findLink = () => findPopover().findComponent(GlLink); + + const createComponent = ({ stubs } = {}) => { + wrapper = shallowMount(FileTreePopover, { + provide: { + includesHelpPagePath: mockIncludesHelpPagePath, + }, + stubs, + }); + }; + + afterEach(() => { + localStorage.clear(); + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(async () => { + createComponent({ stubs: { GlSprintf } }); + }); + + it('renders dismissable popover', async () => { + expect(findPopover().exists()).toBe(true); + + findPopover().vm.$emit('close-button-clicked'); + await nextTick(); + + expect(findPopover().exists()).toBe(false); + }); + + it('renders learn more link', () => { + expect(findLink().exists()).toBe(true); + expect(findLink().attributes('href')).toBe(mockIncludesHelpPagePath); + }); + }); + + describe('when popover has already been dismissed before', () => { + it('does not render popover', async () => { + localStorage.setItem(FILE_TREE_POPOVER_DISMISSED_KEY, 'true'); + createComponent(); + + expect(findPopover().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/walkthrough_popover_spec.js b/spec/frontend/pipeline_editor/components/popovers/walkthrough_popover_spec.js index a9ce89ff521..8d172a8462a 100644 --- a/spec/frontend/pipeline_editor/components/walkthrough_popover_spec.js +++ b/spec/frontend/pipeline_editor/components/popovers/walkthrough_popover_spec.js @@ -1,6 +1,6 @@ import { mount, shallowMount } from '@vue/test-utils'; import Vue from 'vue'; -import WalkthroughPopover from '~/pipeline_editor/components/walkthrough_popover.vue'; +import WalkthroughPopover from '~/pipeline_editor/components/popovers/walkthrough_popover.vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; Vue.config.ignoredElements = ['gl-emoji']; diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index f02f6870653..560b2820fae 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -9,6 +9,7 @@ export const mockNewBranch = 'new-branch'; export const mockNewMergeRequestPath = '/-/merge_requests/new'; export const mockCommitSha = 'aabbccdd'; export const mockCommitNextSha = 'eeffgghh'; +export const mockIncludesHelpPagePath = '/-/includes/help'; export const mockLintHelpPagePath = '/-/lint-help'; export const mockLintUnavailableHelpPagePath = '/-/pipeline-editor/troubleshoot'; export const mockYmlHelpPagePath = '/-/yml-help'; @@ -82,12 +83,46 @@ const mockJobFields = { __typename: 'CiConfigJob', }; +export const mockIncludesWithBlob = { + location: 'test-include.yml', + type: 'local', + blob: + 'http://gdk.test:3000/root/upstream/-/blob/dd54f00bb3645f8ddce7665d2ffb3864540399cb/test-include.yml', + raw: + 'http://gdk.test:3000/root/upstream/-/raw/dd54f00bb3645f8ddce7665d2ffb3864540399cb/test-include.yml', + __typename: 'CiConfigInclude', +}; + +export const mockDefaultIncludes = { + location: 'npm.gitlab-ci.yml', + type: 'template', + blob: null, + raw: + 'https://gitlab.com/gitlab-org/gitlab/-/raw/master/lib/gitlab/ci/templates/npm.gitlab-ci.yml', + __typename: 'CiConfigInclude', +}; + +export const mockIncludes = [ + mockDefaultIncludes, + mockIncludesWithBlob, + { + location: 'a_really_really_long_name_for_includes_file.yml', + type: 'local', + blob: + 'http://gdk.test:3000/root/upstream/-/blob/dd54f00bb3645f8ddce7665d2ffb3864540399cb/a_really_really_long_name_for_includes_file.yml', + raw: + 'http://gdk.test:3000/root/upstream/-/raw/dd54f00bb3645f8ddce7665d2ffb3864540399cb/a_really_really_long_name_for_includes_file.yml', + __typename: 'CiConfigInclude', + }, +]; + // Mock result of the graphql query at: // app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql export const mockCiConfigQueryResponse = { data: { ciConfig: { errors: [], + includes: mockIncludes, mergedYaml: mockCiYml, status: CI_CONFIG_STATUS_VALID, stages: { diff --git a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js index 98e2c17967c..bf0f7fd8c9f 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js @@ -6,10 +6,17 @@ import CiEditorHeader from '~/pipeline_editor/components/editor/ci_editor_header import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue'; import PipelineEditorDrawer from '~/pipeline_editor/components/drawer/pipeline_editor_drawer.vue'; import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; +import PipelineEditorFileTree from '~/pipeline_editor/components/file_tree/container.vue'; import BranchSwitcher from '~/pipeline_editor/components/file_nav/branch_switcher.vue'; import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue'; import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; -import { MERGED_TAB, VISUALIZE_TAB, CREATE_TAB, LINT_TAB } from '~/pipeline_editor/constants'; +import { + MERGED_TAB, + VISUALIZE_TAB, + CREATE_TAB, + LINT_TAB, + FILE_TREE_DISPLAY_KEY, +} from '~/pipeline_editor/constants'; import PipelineEditorHome from '~/pipeline_editor/pipeline_editor_home.vue'; import { mockLintResponse, mockCiYml } from './mock_data'; @@ -47,11 +54,14 @@ describe('Pipeline editor home wrapper', () => { const findFileNav = () => wrapper.findComponent(PipelineEditorFileNav); const findModal = () => wrapper.findComponent(GlModal); const findPipelineEditorDrawer = () => wrapper.findComponent(PipelineEditorDrawer); + const findPipelineEditorFileTree = () => wrapper.findComponent(PipelineEditorFileTree); const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorHeader); const findPipelineEditorTabs = () => wrapper.findComponent(PipelineEditorTabs); + const findFileTreeBtn = () => wrapper.findByTestId('file-tree-toggle'); const findHelpBtn = () => wrapper.findByTestId('drawer-toggle'); afterEach(() => { + localStorage.clear(); wrapper.destroy(); }); @@ -230,4 +240,89 @@ describe('Pipeline editor home wrapper', () => { expect(findPipelineEditorDrawer().props('isVisible')).toBe(false); }); }); + + describe('file tree', () => { + const toggleFileTree = async () => { + findFileTreeBtn().vm.$emit('click'); + await nextTick(); + }; + + describe('with pipelineEditorFileTree feature flag OFF', () => { + beforeEach(() => { + createComponent(); + }); + + it('hides the file tree', () => { + expect(findFileTreeBtn().exists()).toBe(false); + expect(findPipelineEditorFileTree().exists()).toBe(false); + }); + }); + + describe('with pipelineEditorFileTree feature flag ON', () => { + describe('button toggle', () => { + beforeEach(() => { + createComponent({ + glFeatures: { + pipelineEditorFileTree: true, + }, + stubs: { + GlButton, + PipelineEditorFileNav, + }, + }); + }); + + it('shows button toggle', () => { + expect(findFileTreeBtn().exists()).toBe(true); + }); + + it('toggles the drawer on button click', async () => { + await toggleFileTree(); + + expect(findPipelineEditorFileTree().exists()).toBe(true); + + await toggleFileTree(); + + expect(findPipelineEditorFileTree().exists()).toBe(false); + }); + + it('sets the display state in local storage', async () => { + await toggleFileTree(); + + expect(localStorage.getItem(FILE_TREE_DISPLAY_KEY)).toBe('true'); + + await toggleFileTree(); + + expect(localStorage.getItem(FILE_TREE_DISPLAY_KEY)).toBe('false'); + }); + }); + + describe('when file tree display state is saved in local storage', () => { + beforeEach(() => { + localStorage.setItem(FILE_TREE_DISPLAY_KEY, 'true'); + createComponent({ + glFeatures: { pipelineEditorFileTree: true }, + stubs: { PipelineEditorFileNav }, + }); + }); + + it('shows the file tree by default', () => { + expect(findPipelineEditorFileTree().exists()).toBe(true); + }); + }); + + describe('when file tree display state is not saved in local storage', () => { + beforeEach(() => { + createComponent({ + glFeatures: { pipelineEditorFileTree: true }, + stubs: { PipelineEditorFileNav }, + }); + }); + + it('hides the file tree by default', () => { + expect(findPipelineEditorFileTree().exists()).toBe(false); + }); + }); + }); + }); }); diff --git a/spec/frontend/pipeline_wizard/components/commit_spec.js b/spec/frontend/pipeline_wizard/components/commit_spec.js index 6496850b028..c987accbb0d 100644 --- a/spec/frontend/pipeline_wizard/components/commit_spec.js +++ b/spec/frontend/pipeline_wizard/components/commit_spec.js @@ -8,7 +8,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import createCommitMutation from '~/pipeline_wizard/queries/create_commit.graphql'; import getFileMetadataQuery from '~/pipeline_wizard/queries/get_file_meta.graphql'; import RefSelector from '~/ref/components/ref_selector.vue'; -import flushPromises from 'helpers/flush_promises'; +import waitForPromises from 'helpers/wait_for_promises'; import { createCommitMutationErrorResult, createCommitMutationResult, @@ -107,7 +107,7 @@ describe('Pipeline Wizard - Commit Page', () => { it('does not show a load error if call is successful', async () => { createComponent({ projectPath, filename }); - await flushPromises(); + await waitForPromises(); expect(wrapper.findByTestId('load-error').exists()).not.toBe(true); }); @@ -117,7 +117,7 @@ describe('Pipeline Wizard - Commit Page', () => { { defaultBranch: branch, projectPath, filename }, createMockApollo([[getFileMetadataQuery, () => fileQueryErrorResult]]), ); - await flushPromises(); + await waitForPromises(); expect(wrapper.findByTestId('load-error').exists()).toBe(true); expect(wrapper.findByTestId('load-error').text()).toBe(i18n.errors.loadError); }); @@ -131,9 +131,9 @@ describe('Pipeline Wizard - Commit Page', () => { describe('successful commit', () => { beforeEach(async () => { createComponent(); - await flushPromises(); + await waitForPromises(); await getButtonWithLabel(__('Commit')).trigger('click'); - await flushPromises(); + await waitForPromises(); }); it('will not show an error', async () => { @@ -159,9 +159,9 @@ describe('Pipeline Wizard - Commit Page', () => { describe('failed commit', () => { beforeEach(async () => { createComponent({}, getMockApollo({ commitHasError: true })); - await flushPromises(); + await waitForPromises(); await getButtonWithLabel(__('Commit')).trigger('click'); - await flushPromises(); + await waitForPromises(); }); it('will show an error', async () => { @@ -229,7 +229,7 @@ describe('Pipeline Wizard - Commit Page', () => { }), ); - await flushPromises(); + await waitForPromises(); consoleSpy = jest.spyOn(console, 'error'); @@ -243,7 +243,7 @@ describe('Pipeline Wizard - Commit Page', () => { } await Vue.nextTick(); - await flushPromises(); + await waitForPromises(); }); afterAll(() => { diff --git a/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap index 2d2e5db598a..724ec7366d3 100644 --- a/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap +++ b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap @@ -11,6 +11,7 @@ Array [ Object { "__typename": "CiJob", "id": "6", + "kind": "BUILD", "name": "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", "needs": Array [], "previousStageJobsOrNeeds": Array [], @@ -53,6 +54,7 @@ Array [ Object { "__typename": "CiJob", "id": "11", + "kind": "BUILD", "name": "build_b", "needs": Array [], "previousStageJobsOrNeeds": Array [], @@ -95,6 +97,7 @@ Array [ Object { "__typename": "CiJob", "id": "16", + "kind": "BUILD", "name": "build_c", "needs": Array [], "previousStageJobsOrNeeds": Array [], @@ -137,6 +140,7 @@ Array [ Object { "__typename": "CiJob", "id": "21", + "kind": "BUILD", "name": "build_d 1/3", "needs": Array [], "previousStageJobsOrNeeds": Array [], @@ -163,6 +167,7 @@ Array [ Object { "__typename": "CiJob", "id": "24", + "kind": "BUILD", "name": "build_d 2/3", "needs": Array [], "previousStageJobsOrNeeds": Array [], @@ -189,6 +194,7 @@ Array [ Object { "__typename": "CiJob", "id": "27", + "kind": "BUILD", "name": "build_d 3/3", "needs": Array [], "previousStageJobsOrNeeds": Array [], @@ -231,6 +237,7 @@ Array [ Object { "__typename": "CiJob", "id": "59", + "kind": "BUILD", "name": "test_c", "needs": Array [], "previousStageJobsOrNeeds": Array [], @@ -275,6 +282,7 @@ Array [ Object { "__typename": "CiJob", "id": "34", + "kind": "BUILD", "name": "test_a", "needs": Array [ "build_c", @@ -325,6 +333,7 @@ Array [ Object { "__typename": "CiJob", "id": "42", + "kind": "BUILD", "name": "test_b 1/2", "needs": Array [ "build_d 3/3", @@ -363,6 +372,7 @@ Array [ Object { "__typename": "CiJob", "id": "67", + "kind": "BUILD", "name": "test_b 2/2", "needs": Array [ "build_d 3/3", @@ -417,6 +427,7 @@ Array [ Object { "__typename": "CiJob", "id": "53", + "kind": "BUILD", "name": "test_d", "needs": Array [ "build_b", diff --git a/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js b/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js new file mode 100644 index 00000000000..3b5632a8a4e --- /dev/null +++ b/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js @@ -0,0 +1,87 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash 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'; +import { mockFailedJobsQueryResponse, mockFailedJobsSummaryData } from '../../mock_data'; + +Vue.use(VueApollo); + +jest.mock('~/flash'); + +describe('Failed Jobs App', () => { + let wrapper; + let resolverSpy; + + const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon); + const findJobsTable = () => wrapper.findComponent(FailedJobsTable); + + const createMockApolloProvider = (resolver) => { + const requestHandlers = [[GetFailedJobsQuery, resolver]]; + + return createMockApollo(requestHandlers); + }; + + const createComponent = (resolver, failedJobsSummaryData = mockFailedJobsSummaryData) => { + wrapper = shallowMount(FailedJobsApp, { + provide: { + fullPath: 'root/ci-project', + pipelineIid: 1, + }, + propsData: { + failedJobsSummary: failedJobsSummaryData, + }, + apolloProvider: createMockApolloProvider(resolver), + }); + }; + + beforeEach(() => { + resolverSpy = jest.fn().mockResolvedValue(mockFailedJobsQueryResponse); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('loading spinner', () => { + beforeEach(() => { + createComponent(resolverSpy); + }); + + it('displays loading spinner when fetching failed jobs', () => { + expect(findLoadingSpinner().exists()).toBe(true); + }); + + it('hides loading spinner after the failed jobs have been fetched', async () => { + await waitForPromises(); + + expect(findLoadingSpinner().exists()).toBe(false); + }); + }); + + it('displays the failed jobs table', async () => { + createComponent(resolverSpy); + + await waitForPromises(); + + expect(findJobsTable().exists()).toBe(true); + expect(createFlash).not.toHaveBeenCalled(); + }); + + it('handles query fetch error correctly', async () => { + resolverSpy = jest.fn().mockRejectedValue(new Error('GraphQL error')); + + createComponent(resolverSpy); + + await waitForPromises(); + + expect(createFlash).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 new file mode 100644 index 00000000000..b597a3bf4b0 --- /dev/null +++ b/spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js @@ -0,0 +1,117 @@ +import { GlButton, GlLink, GlTableLite } from '@gitlab/ui'; +import Vue from 'vue'; +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 { 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'; +import { + successRetryMutationResponse, + failedRetryMutationResponse, + mockPreparedFailedJobsData, + mockPreparedFailedJobsDataNoPermission, +} from '../../mock_data'; + +jest.mock('~/flash'); +jest.mock('~/lib/utils/url_utility'); + +Vue.use(VueApollo); + +describe('Failed Jobs Table', () => { + let wrapper; + + const successRetryMutationHandler = jest.fn().mockResolvedValue(successRetryMutationResponse); + const failedRetryMutationHandler = jest.fn().mockResolvedValue(failedRetryMutationResponse); + + const findJobsTable = () => wrapper.findComponent(GlTableLite); + const findRetryButton = () => wrapper.findComponent(GlButton); + const findJobLink = () => wrapper.findComponent(GlLink); + const findJobLog = () => wrapper.findByTestId('job-log'); + + const createMockApolloProvider = (resolver) => { + const requestHandlers = [[RetryFailedJobMutation, resolver]]; + return createMockApollo(requestHandlers); + }; + + const createComponent = (resolver, failedJobsData = mockPreparedFailedJobsData) => { + wrapper = mountExtended(FailedJobsTable, { + propsData: { + failedJobs: failedJobsData, + }, + apolloProvider: createMockApolloProvider(resolver), + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays the failed jobs table', () => { + createComponent(); + + expect(findJobsTable().exists()).toBe(true); + }); + + it('calls the retry failed job mutation correctly', () => { + createComponent(successRetryMutationHandler); + + findRetryButton().trigger('click'); + + expect(successRetryMutationHandler).toHaveBeenCalledWith({ + id: mockPreparedFailedJobsData[0].id, + }); + }); + + it('redirects to the new job after the mutation', async () => { + const { + data: { + jobRetry: { job }, + }, + } = successRetryMutationResponse; + + createComponent(successRetryMutationHandler); + + findRetryButton().trigger('click'); + + await waitForPromises(); + + expect(redirectTo).toHaveBeenCalledWith(job.detailedStatus.detailsPath); + }); + + it('shows error message if the retry failed job mutation fails', async () => { + createComponent(failedRetryMutationHandler); + + findRetryButton().trigger('click'); + + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem retrying the failed job.', + }); + }); + + it('hides the job log and retry button if a user does not have permission', () => { + createComponent([[]], mockPreparedFailedJobsDataNoPermission); + + expect(findJobLog().exists()).toBe(false); + expect(findRetryButton().exists()).toBe(false); + }); + + it('displays the job log and retry button if a user has permission', () => { + createComponent(); + + expect(findJobLog().exists()).toBe(true); + expect(findRetryButton().exists()).toBe(true); + }); + + it('job name links to the correct job', () => { + createComponent(); + + expect(findJobLink().attributes('href')).toBe( + mockPreparedFailedJobsData[0].detailedStatus.detailsPath, + ); + }); +}); diff --git a/spec/frontend/pipelines/components/jobs/utils_spec.js b/spec/frontend/pipelines/components/jobs/utils_spec.js new file mode 100644 index 00000000000..720446cfda3 --- /dev/null +++ b/spec/frontend/pipelines/components/jobs/utils_spec.js @@ -0,0 +1,14 @@ +import { prepareFailedJobs } from '~/pipelines/components/jobs/utils'; +import { + mockFailedJobsData, + mockFailedJobsSummaryData, + mockPreparedFailedJobsData, +} from '../../mock_data'; + +describe('Utils', () => { + it('prepares failed jobs data correctly', () => { + expect(prepareFailedJobs(mockFailedJobsData, mockFailedJobsSummaryData)).toEqual( + mockPreparedFailedJobsData, + ); + }); +}); diff --git a/spec/frontend/pipelines/components/pipeline_tabs_spec.js b/spec/frontend/pipelines/components/pipeline_tabs_spec.js index e18c3edbad9..89002ee47a8 100644 --- a/spec/frontend/pipelines/components/pipeline_tabs_spec.js +++ b/spec/frontend/pipelines/components/pipeline_tabs_spec.js @@ -21,14 +21,19 @@ describe('The Pipeline Tabs', () => { const findPipelineApp = () => wrapper.findComponent(PipelineGraphWrapper); const findTestsApp = () => wrapper.findComponent(TestReports); + const defaultProvide = { + defaultTabValue: '', + }; + const createComponent = (propsData = {}) => { wrapper = extendedWrapper( shallowMount(PipelineTabs, { propsData, + provide: { + ...defaultProvide, + }, stubs: { - Dag: { template: '<div id="dag"/>' }, JobsApp: { template: '<div class="jobs" />' }, - PipelineGraph: { template: '<div id="graph" />' }, TestReports: { template: '<div id="tests" />' }, }, }), diff --git a/spec/frontend/pipelines/empty_state/ci_templates_spec.js b/spec/frontend/pipelines/components/pipelines_list/empty_state/ci_templates_spec.js index 606fdc9cac1..6531a15ab8e 100644 --- a/spec/frontend/pipelines/empty_state/ci_templates_spec.js +++ b/spec/frontend/pipelines/components/pipelines_list/empty_state/ci_templates_spec.js @@ -13,34 +13,35 @@ describe('CI Templates', () => { let wrapper; let trackingSpy; - const createWrapper = () => { - return shallowMountExtended(CiTemplates, { + const createWrapper = (propsData = {}) => { + wrapper = shallowMountExtended(CiTemplates, { provide: { pipelineEditorPath, suggestedCiTemplates, }, + propsData, }); }; const findTemplateDescription = () => wrapper.findByTestId('template-description'); const findTemplateLink = () => wrapper.findByTestId('template-link'); + const findTemplateNames = () => wrapper.findAllByTestId('template-name'); const findTemplateName = () => wrapper.findByTestId('template-name'); const findTemplateLogo = () => wrapper.findByTestId('template-logo'); - beforeEach(() => { - wrapper = createWrapper(); - }); - afterEach(() => { wrapper.destroy(); wrapper = null; }); describe('renders template list', () => { - it('renders all suggested templates', () => { - const content = wrapper.text(); + beforeEach(() => { + createWrapper(); + }); - expect(content).toContain('Android', 'Bash', 'C++'); + it('renders all suggested templates', () => { + expect(findTemplateNames().length).toBe(3); + expect(wrapper.text()).toContain('Android', 'Bash', 'C++'); }); it('has the correct template name', () => { @@ -53,9 +54,13 @@ describe('CI Templates', () => { ); }); + it('has the link button enabled', () => { + expect(findTemplateLink().props('disabled')).toBe(false); + }); + it('has the description of the template', () => { expect(findTemplateDescription().text()).toBe( - 'CI/CD template to test and deploy your Android project.', + 'Continuous integration and deployment template to test and deploy your Android project.', ); }); @@ -64,8 +69,30 @@ describe('CI Templates', () => { }); }); + describe('filtering the templates', () => { + beforeEach(() => { + createWrapper({ filterTemplates: ['Bash'] }); + }); + + it('renders only the filtered templates', () => { + expect(findTemplateNames()).toHaveLength(1); + expect(findTemplateName().text()).toBe('Bash'); + }); + }); + + describe('disabling the templates', () => { + beforeEach(() => { + createWrapper({ disabled: true }); + }); + + it('has the link button disabled', () => { + expect(findTemplateLink().props('disabled')).toBe(true); + }); + }); + describe('tracking', () => { beforeEach(() => { + createWrapper(); trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); diff --git a/spec/frontend/pipelines/components/pipelines_list/empty_state/ios_templates_spec.js b/spec/frontend/pipelines/components/pipelines_list/empty_state/ios_templates_spec.js new file mode 100644 index 00000000000..0c2938921d6 --- /dev/null +++ b/spec/frontend/pipelines/components/pipelines_list/empty_state/ios_templates_spec.js @@ -0,0 +1,138 @@ +import '~/commons'; +import { nextTick } from 'vue'; +import { GlPopover, GlButton } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import IosTemplates from '~/pipelines/components/pipelines_list/empty_state/ios_templates.vue'; +import CiTemplates from '~/pipelines/components/pipelines_list/empty_state/ci_templates.vue'; + +const pipelineEditorPath = '/-/ci/editor'; +const registrationToken = 'SECRET_TOKEN'; +const iOSTemplateName = 'iOS-Fastlane'; + +describe('iOS Templates', () => { + let wrapper; + + const createWrapper = (providedPropsData = {}) => { + return shallowMountExtended(IosTemplates, { + provide: { + pipelineEditorPath, + iosRunnersAvailable: true, + ...providedPropsData, + }, + propsData: { + registrationToken, + }, + stubs: { + GlButton, + }, + }); + }; + + const findIosTemplate = () => wrapper.findComponent(CiTemplates); + const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal); + const findRunnerInstructionsPopover = () => wrapper.findComponent(GlPopover); + const findRunnerSetupTodoEmoji = () => wrapper.findByTestId('runner-setup-marked-todo'); + const findRunnerSetupCompletedEmoji = () => wrapper.findByTestId('runner-setup-marked-completed'); + const findSetupRunnerLink = () => wrapper.findByText('Set up a runner'); + const configurePipelineLink = () => wrapper.findByTestId('configure-pipeline-link'); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when ios runners are not available', () => { + beforeEach(() => { + wrapper = createWrapper({ iosRunnersAvailable: false }); + }); + + describe('the runner setup section', () => { + it('marks the section as todo', () => { + expect(findRunnerSetupTodoEmoji().isVisible()).toBe(true); + expect(findRunnerSetupCompletedEmoji().isVisible()).toBe(false); + }); + + it('renders the setup runner link', () => { + expect(findSetupRunnerLink().exists()).toBe(true); + }); + + it('renders the runner instructions modal with a popover once clicked', async () => { + findSetupRunnerLink().element.parentElement.click(); + + await nextTick(); + + expect(findRunnerInstructionsModal().exists()).toBe(true); + expect(findRunnerInstructionsModal().props('registrationToken')).toBe(registrationToken); + expect(findRunnerInstructionsModal().props('defaultPlatformName')).toBe('osx'); + + findRunnerInstructionsModal().vm.$emit('shown'); + + await nextTick(); + + expect(findRunnerInstructionsPopover().exists()).toBe(true); + }); + }); + + describe('the configure pipeline section', () => { + it('has a disabled link button', () => { + expect(configurePipelineLink().props('disabled')).toBe(true); + }); + }); + + describe('the ios-Fastlane template', () => { + it('renders the template', () => { + expect(findIosTemplate().props('filterTemplates')).toStrictEqual([iOSTemplateName]); + }); + + it('has a disabled link button', () => { + expect(findIosTemplate().props('disabled')).toBe(true); + }); + }); + }); + + describe('when ios runners are available', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + describe('the runner setup section', () => { + it('marks the section as completed', () => { + expect(findRunnerSetupTodoEmoji().isVisible()).toBe(false); + expect(findRunnerSetupCompletedEmoji().isVisible()).toBe(true); + }); + + it('does not render the setup runner link', () => { + expect(findSetupRunnerLink().exists()).toBe(false); + }); + }); + + describe('the configure pipeline section', () => { + it('has an enabled link button', () => { + expect(configurePipelineLink().props('disabled')).toBe(false); + }); + + it('links to the pipeline editor with the right template', () => { + expect(configurePipelineLink().attributes('href')).toBe( + `${pipelineEditorPath}?template=${iOSTemplateName}`, + ); + }); + }); + + describe('the ios-Fastlane template', () => { + it('renders the template', () => { + expect(findIosTemplate().props('filterTemplates')).toStrictEqual([iOSTemplateName]); + }); + + it('has an enabled link button', () => { + expect(findIosTemplate().props('disabled')).toBe(false); + }); + + it('links to the pipeline editor with the right template', () => { + expect(configurePipelineLink().attributes('href')).toBe( + `${pipelineEditorPath}?template=${iOSTemplateName}`, + ); + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/empty_state/pipelines_ci_templates_spec.js b/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js index 14860f20317..b537c81da3f 100644 --- a/spec/frontend/pipelines/empty_state/pipelines_ci_templates_spec.js +++ b/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js @@ -16,6 +16,7 @@ import { } from '~/pipeline_editor/constants'; const pipelineEditorPath = '/-/ci/editor'; +const ciRunnerSettingsPath = '/-/settings/ci_cd'; jest.mock('~/experimentation/experiment_tracking'); @@ -27,8 +28,10 @@ describe('Pipelines CI Templates', () => { return shallowMountExtended(PipelinesCiTemplates, { provide: { pipelineEditorPath, + ciRunnerSettingsPath, + anyRunnersAvailable: true, + ...propsData, }, - propsData, stubs, }); }; diff --git a/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js b/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js index 93bc8faa51b..6d0e99ff63e 100644 --- a/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js +++ b/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js @@ -1,6 +1,7 @@ import { GlDropdown } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import axios from '~/lib/utils/axios_utils'; import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue'; import eventHub from '~/pipelines/event_hub'; @@ -48,11 +49,12 @@ describe('Pipelines stage component', () => { mock.restore(); }); + const findCiActionBtn = () => wrapper.find('.js-ci-action'); + const findCiIcon = () => wrapper.findComponent(CiIcon); const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdownToggle = () => wrapper.find('button.dropdown-toggle'); const findDropdownMenu = () => wrapper.find('[data-testid="mini-pipeline-graph-dropdown-menu-list"]'); - const findCiActionBtn = () => wrapper.find('.js-ci-action'); const findMergeTrainWarning = () => wrapper.find('[data-testid="warning-message-merge-trains"]'); const openStageDropdown = () => { @@ -74,7 +76,7 @@ describe('Pipelines stage component', () => { it('should render a dropdown with the status icon', () => { expect(findDropdown().exists()).toBe(true); expect(findDropdownToggle().exists()).toBe(true); - expect(wrapper.find('[data-testid="status_success_borderless-icon"]').exists()).toBe(true); + expect(findCiIcon().exists()).toBe(true); }); }); diff --git a/spec/frontend/pipelines/empty_state_spec.js b/spec/frontend/pipelines/empty_state_spec.js index 46dad4a035c..0abf7f59717 100644 --- a/spec/frontend/pipelines/empty_state_spec.js +++ b/spec/frontend/pipelines/empty_state_spec.js @@ -1,7 +1,11 @@ import '~/commons'; -import { mount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import { GlEmptyState } from '@gitlab/ui'; +import { stubExperiments } from 'helpers/experimentation_helper'; import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue'; +import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue'; +import IosTemplates from '~/pipelines/components/pipelines_list/empty_state/ios_templates.vue'; describe('Pipelines Empty State', () => { let wrapper; @@ -9,44 +13,68 @@ describe('Pipelines Empty State', () => { const findIllustration = () => wrapper.find('img'); const findButton = () => wrapper.find('a'); const pipelinesCiTemplates = () => wrapper.findComponent(PipelinesCiTemplates); + const iosTemplates = () => wrapper.findComponent(IosTemplates); const createWrapper = (props = {}) => { - wrapper = mount(EmptyState, { + wrapper = shallowMount(EmptyState, { provide: { pipelineEditorPath: '', suggestedCiTemplates: [], + anyRunnersAvailable: true, + ciRunnerSettingsPath: '', }, propsData: { emptyStateSvgPath: 'foo.svg', canSetCi: true, ...props, }, + stubs: { + GlEmptyState, + GitlabExperiment, + }, }); }; + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + describe('when user can configure CI', () => { - beforeEach(() => { - createWrapper({}, mount); - }); + describe('when the ios_specific_templates experiment is active', () => { + beforeEach(() => { + stubExperiments({ ios_specific_templates: 'candidate' }); + createWrapper(); + }); - afterEach(() => { - wrapper.destroy(); - wrapper = null; + it('should render the iOS templates', () => { + expect(iosTemplates().exists()).toBe(true); + }); + + it('should not render the CI/CD templates', () => { + expect(pipelinesCiTemplates().exists()).toBe(false); + }); }); - it('should render the CI/CD templates', () => { - expect(pipelinesCiTemplates().exists()).toBe(true); + describe('when the ios_specific_templates experiment is inactive', () => { + beforeEach(() => { + stubExperiments({ ios_specific_templates: 'control' }); + createWrapper(); + }); + + it('should render the CI/CD templates', () => { + expect(pipelinesCiTemplates().exists()).toBe(true); + }); + + it('should not render the iOS templates', () => { + expect(iosTemplates().exists()).toBe(false); + }); }); }); describe('when user cannot configure CI', () => { beforeEach(() => { - createWrapper({ canSetCi: false }, mount); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; + createWrapper({ canSetCi: false }); }); it('should render empty state SVG', () => { diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js index cb7073fb5f5..49d64c6eac0 100644 --- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -16,7 +16,7 @@ import { } from '~/performance/constants'; import * as perfUtils from '~/performance/utils'; import { - IID_FAILURE, + ACTION_FAILURE, LAYER_VIEW, STAGE_VIEW, VIEW_TYPE_KEY, @@ -188,7 +188,9 @@ describe('Pipeline graph wrapper', () => { it('displays the no iid alert', () => { expect(getAlert().exists()).toBe(true); - expect(getAlert().text()).toBe(wrapper.vm.$options.errorTexts[IID_FAILURE]); + expect(getAlert().text()).toBe( + 'The data in this pipeline is too old to be rendered as a graph. Please check the Jobs tab to access historical data.', + ); }); it('does not display the graph', () => { @@ -196,6 +198,27 @@ describe('Pipeline graph wrapper', () => { }); }); + describe('when there is an error with an action in the graph', () => { + beforeEach(async () => { + createComponentWithApollo(); + await waitForPromises(); + await getGraph().vm.$emit('error', { type: ACTION_FAILURE }); + }); + + it('does not display the loading icon', () => { + expect(getLoadingIcon().exists()).toBe(false); + }); + + it('displays the action error alert', () => { + expect(getAlert().exists()).toBe(true); + expect(getAlert().text()).toBe('An error occurred while performing this action.'); + }); + + it('displays the graph', () => { + expect(getGraph().exists()).toBe(true); + }); + }); + describe('when refresh action is emitted', () => { beforeEach(async () => { createComponentWithApollo(); diff --git a/spec/frontend/pipelines/graph/job_item_spec.js b/spec/frontend/pipelines/graph/job_item_spec.js index 23e7ed7ebb4..4f0da09fec6 100644 --- a/spec/frontend/pipelines/graph/job_item_spec.js +++ b/spec/frontend/pipelines/graph/job_item_spec.js @@ -1,89 +1,34 @@ import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { GlBadge } from '@gitlab/ui'; import JobItem from '~/pipelines/components/graph/job_item.vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { + delayedJob, + mockJob, + mockJobWithoutDetails, + mockJobWithUnauthorizedAction, + triggerJob, +} from './mock_data'; describe('pipeline graph job item', () => { let wrapper; - const findJobWithoutLink = () => wrapper.find('[data-testid="job-without-link"]'); - const findJobWithLink = () => wrapper.find('[data-testid="job-with-link"]'); - const findActionComponent = () => wrapper.find('[data-testid="ci-action-component"]'); + const findJobWithoutLink = () => wrapper.findByTestId('job-without-link'); + const findJobWithLink = () => wrapper.findByTestId('job-with-link'); + const findActionComponent = () => wrapper.findByTestId('ci-action-component'); + const findBadge = () => wrapper.findComponent(GlBadge); const createWrapper = (propsData) => { - wrapper = mount(JobItem, { - propsData, - }); + wrapper = extendedWrapper( + mount(JobItem, { + propsData, + }), + ); }; const triggerActiveClass = 'gl-shadow-x0-y0-b3-s1-blue-500'; - const delayedJob = { - __typename: 'CiJob', - name: 'delayed job', - scheduledAt: '2015-07-03T10:01:00.000Z', - needs: [], - status: { - __typename: 'DetailedStatus', - icon: 'status_scheduled', - tooltip: 'delayed manual action (%{remainingTime})', - hasDetails: true, - detailsPath: '/root/kinder-pipe/-/jobs/5339', - group: 'scheduled', - action: { - __typename: 'StatusAction', - icon: 'time-out', - title: 'Unschedule', - path: '/frontend-fixtures/builds-project/-/jobs/142/unschedule', - buttonTitle: 'Unschedule job', - }, - }, - }; - - const mockJob = { - id: 4256, - name: 'test', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - tooltip: 'passed', - group: 'success', - detailsPath: '/root/ci-mock/builds/4256', - hasDetails: true, - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4256/retry', - method: 'post', - }, - }, - }; - const mockJobWithoutDetails = { - id: 4257, - name: 'job_without_details', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - detailsPath: '/root/ci-mock/builds/4257', - hasDetails: false, - }, - }; - const mockJobWithUnauthorizedAction = { - id: 4258, - name: 'stop-environment', - status: { - icon: 'status_manual', - label: 'manual stop action (not allowed)', - tooltip: 'manual action', - group: 'manual', - detailsPath: '/root/ci-mock/builds/4258', - hasDetails: true, - action: null, - }, - }; - afterEach(() => { wrapper.destroy(); }); @@ -148,13 +93,25 @@ describe('pipeline graph job item', () => { }); }); - it('should render provided class name', () => { - createWrapper({ - job: mockJob, - cssClassJobName: 'css-class-job-name', + describe('job style', () => { + beforeEach(() => { + createWrapper({ + job: mockJob, + cssClassJobName: 'css-class-job-name', + }); + }); + + it('should render provided class name', () => { + expect(wrapper.find('a').classes()).toContain('css-class-job-name'); + }); + + it('does not show a badge on the job item', () => { + expect(findBadge().exists()).toBe(false); }); - expect(wrapper.find('a').classes()).toContain('css-class-job-name'); + it('does not apply the trigger job class', () => { + expect(findJobWithLink().classes()).not.toContain('gl-rounded-lg'); + }); }); describe('status label', () => { @@ -201,34 +158,51 @@ describe('pipeline graph job item', () => { }); }); - describe('trigger job highlighting', () => { - it.each` - job | jobName | expanded | link - ${mockJob} | ${mockJob.name} | ${true} | ${true} - ${mockJobWithoutDetails} | ${mockJobWithoutDetails.name} | ${true} | ${false} - `( - `trigger job should stay highlighted when downstream is expanded`, - ({ job, jobName, expanded, link }) => { - createWrapper({ job, pipelineExpanded: { jobName, expanded } }); - const findJobEl = link ? findJobWithLink : findJobWithoutLink; - - expect(findJobEl().classes()).toContain(triggerActiveClass); - }, - ); + describe('trigger job', () => { + describe('card', () => { + beforeEach(() => { + createWrapper({ job: triggerJob }); + }); - it.each` - job | jobName | expanded | link - ${mockJob} | ${mockJob.name} | ${false} | ${true} - ${mockJobWithoutDetails} | ${mockJobWithoutDetails.name} | ${false} | ${false} - `( - `trigger job should not be highlighted when downstream is not expanded`, - ({ job, jobName, expanded, link }) => { - createWrapper({ job, pipelineExpanded: { jobName, expanded } }); - const findJobEl = link ? findJobWithLink : findJobWithoutLink; - - expect(findJobEl().classes()).not.toContain(triggerActiveClass); - }, - ); + it('shows a badge on the job item', () => { + expect(findBadge().exists()).toBe(true); + expect(findBadge().text()).toBe('Trigger job'); + }); + + it('applies a rounded corner style instead of the usual pill shape', () => { + expect(findJobWithoutLink().classes()).toContain('gl-rounded-lg'); + }); + }); + + describe('highlighting', () => { + it.each` + job | jobName | expanded | link + ${mockJob} | ${mockJob.name} | ${true} | ${true} + ${mockJobWithoutDetails} | ${mockJobWithoutDetails.name} | ${true} | ${false} + `( + `trigger job should stay highlighted when downstream is expanded`, + ({ job, jobName, expanded, link }) => { + createWrapper({ job, pipelineExpanded: { jobName, expanded } }); + const findJobEl = link ? findJobWithLink : findJobWithoutLink; + + expect(findJobEl().classes()).toContain(triggerActiveClass); + }, + ); + + it.each` + job | jobName | expanded | link + ${mockJob} | ${mockJob.name} | ${false} | ${true} + ${mockJobWithoutDetails} | ${mockJobWithoutDetails.name} | ${false} | ${false} + `( + `trigger job should not be highlighted when downstream is not expanded`, + ({ job, jobName, expanded, link }) => { + createWrapper({ job, pipelineExpanded: { jobName, expanded } }); + const findJobEl = link ? findJobWithLink : findJobWithoutLink; + + expect(findJobEl().classes()).not.toContain(triggerActiveClass); + }, + ); + }); }); describe('job classes', () => { diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js index d800a8c341e..06fd970778c 100644 --- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js @@ -1,11 +1,21 @@ -import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlButton, GlLoadingIcon, GlTooltip } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; -import { UPSTREAM, DOWNSTREAM } from '~/pipelines/components/graph/constants'; +import { ACTION_FAILURE, UPSTREAM, DOWNSTREAM } from '~/pipelines/components/graph/constants'; import LinkedPipelineComponent from '~/pipelines/components/graph/linked_pipeline.vue'; +import { PIPELINE_GRAPHQL_TYPE } from '~/pipelines/constants'; +import CancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql'; +import RetryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; import mockPipeline from './linked_pipelines_mock_data'; +Vue.use(VueApollo); + describe('Linked pipeline', () => { let wrapper; @@ -27,22 +37,30 @@ describe('Linked pipeline', () => { }; const findButton = () => wrapper.find(GlButton); - const findDownstreamPipelineTitle = () => wrapper.find('[data-testid="downstream-title"]'); - const findPipelineLabel = () => wrapper.find('[data-testid="downstream-pipeline-label"]'); + const findCancelButton = () => wrapper.findByLabelText('Cancel downstream pipeline'); + const findCardTooltip = () => wrapper.findComponent(GlTooltip); + const findDownstreamPipelineTitle = () => wrapper.findByTestId('downstream-title'); + const findExpandButton = () => wrapper.findByTestId('expand-pipeline-button'); const findLinkedPipeline = () => wrapper.find({ ref: 'linkedPipeline' }); const findLoadingIcon = () => wrapper.find(GlLoadingIcon); - const findPipelineLink = () => wrapper.find('[data-testid="pipelineLink"]'); - const findExpandButton = () => wrapper.find('[data-testid="expand-pipeline-button"]'); - - const createWrapper = (propsData, data = []) => { - wrapper = mount(LinkedPipelineComponent, { - propsData, - data() { - return { - ...data, - }; - }, - }); + const findPipelineLabel = () => wrapper.findByTestId('downstream-pipeline-label'); + const findPipelineLink = () => wrapper.findByTestId('pipelineLink'); + const findRetryButton = () => wrapper.findByLabelText('Retry downstream pipeline'); + + const createWrapper = ({ propsData, downstreamRetryAction = false }) => { + const mockApollo = createMockApollo(); + + wrapper = extendedWrapper( + mount(LinkedPipelineComponent, { + propsData, + provide: { + glFeatures: { + downstreamRetryAction, + }, + }, + apolloProvider: mockApollo, + }), + ); }; afterEach(() => { @@ -59,7 +77,7 @@ describe('Linked pipeline', () => { }; beforeEach(() => { - createWrapper(props); + createWrapper({ propsData: props }); }); it('should render the project name', () => { @@ -84,18 +102,13 @@ describe('Linked pipeline', () => { expect(wrapper.text()).toContain(`#${props.pipeline.id}`); }); - it('should correctly compute the tooltip text', () => { - expect(wrapper.vm.tooltipText).toContain(mockPipeline.project.name); - expect(wrapper.vm.tooltipText).toContain(mockPipeline.status.label); - expect(wrapper.vm.tooltipText).toContain(mockPipeline.sourceJob.name); - expect(wrapper.vm.tooltipText).toContain(mockPipeline.id); - }); + it('adds the card tooltip text to the DOM', () => { + expect(findCardTooltip().exists()).toBe(true); - it('should render the tooltip text as the title attribute', () => { - const titleAttr = findLinkedPipeline().attributes('title'); - - expect(titleAttr).toContain(mockPipeline.project.name); - expect(titleAttr).toContain(mockPipeline.status.label); + expect(findCardTooltip().text()).toContain(mockPipeline.project.name); + expect(findCardTooltip().text()).toContain(mockPipeline.status.label); + expect(findCardTooltip().text()).toContain(mockPipeline.sourceJob.name); + expect(findCardTooltip().text()).toContain(mockPipeline.id); }); it('should display multi-project label when pipeline project id is not the same as triggered pipeline project id', () => { @@ -105,7 +118,7 @@ describe('Linked pipeline', () => { describe('upstream pipelines', () => { beforeEach(() => { - createWrapper(upstreamProps); + createWrapper({ propsData: upstreamProps }); }); it('should display parent label when pipeline project id is the same as triggered_by pipeline project id', () => { @@ -123,45 +136,246 @@ describe('Linked pipeline', () => { }); describe('downstream pipelines', () => { - beforeEach(() => { - createWrapper(downstreamProps); - }); - - it('parent/child label container should exist', () => { - expect(findPipelineLabel().exists()).toBe(true); - }); - - it('should display child label when pipeline project id is the same as triggered pipeline project id', () => { - expect(findPipelineLabel().exists()).toBe(true); - }); - - it('should have the name of the trigger job on the card when it is a child pipeline', () => { - expect(findDownstreamPipelineTitle().text()).toBe(mockPipeline.sourceJob.name); - }); - - it('downstream pipeline should contain the correct link', () => { - expect(findPipelineLink().attributes('href')).toBe(downstreamProps.pipeline.path); + describe('styling', () => { + beforeEach(() => { + createWrapper({ propsData: downstreamProps }); + }); + + it('parent/child label container should exist', () => { + expect(findPipelineLabel().exists()).toBe(true); + }); + + it('should display child label when pipeline project id is the same as triggered pipeline project id', () => { + expect(findPipelineLabel().exists()).toBe(true); + }); + + it('should have the name of the trigger job on the card when it is a child pipeline', () => { + expect(findDownstreamPipelineTitle().text()).toBe(mockPipeline.sourceJob.name); + }); + + it('downstream pipeline should contain the correct link', () => { + expect(findPipelineLink().attributes('href')).toBe(downstreamProps.pipeline.path); + }); + + it('applies the flex-row css class to the card', () => { + expect(findLinkedPipeline().classes()).toContain('gl-flex-direction-row'); + expect(findLinkedPipeline().classes()).not.toContain('gl-flex-direction-row-reverse'); + }); }); - it('applies the flex-row css class to the card', () => { - expect(findLinkedPipeline().classes()).toContain('gl-flex-direction-row'); - expect(findLinkedPipeline().classes()).not.toContain('gl-flex-direction-row-reverse'); + describe('action button', () => { + describe('with the `downstream_retry_action` flag on', () => { + describe('with permissions', () => { + describe('on an upstream', () => { + describe('when retryable', () => { + beforeEach(() => { + const retryablePipeline = { + ...upstreamProps, + pipeline: { ...mockPipeline, retryable: true }, + }; + + createWrapper({ propsData: retryablePipeline, downstreamRetryAction: true }); + }); + + it('does not show the retry or cancel button', () => { + expect(findCancelButton().exists()).toBe(false); + expect(findRetryButton().exists()).toBe(false); + }); + }); + }); + + describe('on a downstream', () => { + describe('when retryable', () => { + beforeEach(() => { + const retryablePipeline = { + ...downstreamProps, + pipeline: { ...mockPipeline, retryable: true }, + }; + + createWrapper({ propsData: retryablePipeline, downstreamRetryAction: true }); + }); + + it('shows only the retry button', () => { + expect(findCancelButton().exists()).toBe(false); + expect(findRetryButton().exists()).toBe(true); + }); + + it('hides the card tooltip when the action button tooltip is hovered', async () => { + expect(findCardTooltip().exists()).toBe(true); + + await findRetryButton().trigger('mouseover'); + + expect(findCardTooltip().exists()).toBe(false); + }); + + describe('and the retry button is clicked', () => { + describe('on success', () => { + beforeEach(async () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); + jest.spyOn(wrapper.vm, '$emit'); + await findRetryButton().trigger('click'); + }); + + it('calls the retry mutation ', () => { + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: RetryPipelineMutation, + variables: { + id: convertToGraphQLId(PIPELINE_GRAPHQL_TYPE, mockPipeline.id), + }, + }); + }); + + it('emits the refreshPipelineGraph event', () => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('refreshPipelineGraph'); + }); + }); + + describe('on failure', () => { + beforeEach(async () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({ errors: [] }); + jest.spyOn(wrapper.vm, '$emit'); + await findRetryButton().trigger('click'); + }); + + it('emits an error event', () => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('error', { + type: ACTION_FAILURE, + }); + }); + }); + }); + }); + + describe('when cancelable', () => { + beforeEach(() => { + const cancelablePipeline = { + ...downstreamProps, + pipeline: { ...mockPipeline, cancelable: true }, + }; + + createWrapper({ propsData: cancelablePipeline, downstreamRetryAction: true }); + }); + + it('shows only the cancel button ', () => { + expect(findCancelButton().exists()).toBe(true); + expect(findRetryButton().exists()).toBe(false); + }); + + it('hides the card tooltip when the action button tooltip is hovered', async () => { + expect(findCardTooltip().exists()).toBe(true); + + await findCancelButton().trigger('mouseover'); + + expect(findCardTooltip().exists()).toBe(false); + }); + + describe('and the cancel button is clicked', () => { + describe('on success', () => { + beforeEach(async () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); + jest.spyOn(wrapper.vm, '$emit'); + await findCancelButton().trigger('click'); + }); + + it('calls the cancel mutation', () => { + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: CancelPipelineMutation, + variables: { + id: convertToGraphQLId(PIPELINE_GRAPHQL_TYPE, mockPipeline.id), + }, + }); + }); + it('emits the refreshPipelineGraph event', () => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('refreshPipelineGraph'); + }); + }); + describe('on failure', () => { + beforeEach(async () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({ errors: [] }); + jest.spyOn(wrapper.vm, '$emit'); + await findCancelButton().trigger('click'); + }); + it('emits an error event', () => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('error', { + type: ACTION_FAILURE, + }); + }); + }); + }); + }); + + describe('when both cancellable and retryable', () => { + beforeEach(() => { + const pipelineWithTwoActions = { + ...downstreamProps, + pipeline: { ...mockPipeline, cancelable: true, retryable: true }, + }; + + createWrapper({ propsData: pipelineWithTwoActions, downstreamRetryAction: true }); + }); + + it('only shows the cancel button', () => { + expect(findRetryButton().exists()).toBe(false); + expect(findCancelButton().exists()).toBe(true); + }); + }); + }); + }); + + describe('without permissions', () => { + beforeEach(() => { + const pipelineWithTwoActions = { + ...downstreamProps, + pipeline: { + ...mockPipeline, + cancelable: true, + retryable: true, + userPermissions: { updatePipeline: false }, + }, + }; + + createWrapper({ propsData: pipelineWithTwoActions }); + }); + + it('does not show any action button', () => { + expect(findRetryButton().exists()).toBe(false); + expect(findCancelButton().exists()).toBe(false); + }); + }); + }); + + describe('with the `downstream_retry_action` flag off', () => { + beforeEach(() => { + const pipelineWithTwoActions = { + ...downstreamProps, + pipeline: { ...mockPipeline, cancelable: true, retryable: true }, + }; + + createWrapper({ propsData: pipelineWithTwoActions }); + }); + it('does not show any action button', () => { + expect(findRetryButton().exists()).toBe(false); + expect(findCancelButton().exists()).toBe(false); + }); + }); }); }); describe('expand button', () => { it.each` - pipelineType | anglePosition | borderClass | expanded - ${downstreamProps} | ${'angle-right'} | ${'gl-border-l-1!'} | ${false} - ${downstreamProps} | ${'angle-left'} | ${'gl-border-l-1!'} | ${true} - ${upstreamProps} | ${'angle-left'} | ${'gl-border-r-1!'} | ${false} - ${upstreamProps} | ${'angle-right'} | ${'gl-border-r-1!'} | ${true} + pipelineType | anglePosition | buttonBorderClasses | expanded + ${downstreamProps} | ${'angle-right'} | ${'gl-border-l-0!'} | ${false} + ${downstreamProps} | ${'angle-left'} | ${'gl-border-l-0!'} | ${true} + ${upstreamProps} | ${'angle-left'} | ${'gl-border-r-0!'} | ${false} + ${upstreamProps} | ${'angle-right'} | ${'gl-border-r-0!'} | ${true} `( - '$pipelineType.columnTitle pipeline button icon should be $anglePosition with $borderClass if expanded state is $expanded', - ({ pipelineType, anglePosition, borderClass, expanded }) => { - createWrapper({ ...pipelineType, expanded }); + '$pipelineType.columnTitle pipeline button icon should be $anglePosition with $buttonBorderClasses if expanded state is $expanded', + ({ pipelineType, anglePosition, buttonBorderClasses, expanded }) => { + createWrapper({ propsData: { ...pipelineType, expanded } }); expect(findExpandButton().props('icon')).toBe(anglePosition); - expect(findExpandButton().classes()).toContain(borderClass); + expect(findExpandButton().classes()).toContain(buttonBorderClasses); }, ); }); @@ -176,7 +390,7 @@ describe('Linked pipeline', () => { }; beforeEach(() => { - createWrapper(props); + createWrapper({ propsData: props }); }); it('loading icon is visible', () => { @@ -194,7 +408,7 @@ describe('Linked pipeline', () => { }; beforeEach(() => { - createWrapper(props); + createWrapper({ propsData: props }); }); it('emits `pipelineClicked` event', () => { diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js index 1673065e09c..46000711110 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js @@ -67,7 +67,6 @@ describe('Linked Pipelines Column', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('it renders correctly', () => { diff --git a/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js b/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js index 955b70cbd3b..f7f5738e46d 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js @@ -2,6 +2,11 @@ export default { __typename: 'Pipeline', id: 195, iid: '5', + retryable: false, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, path: '/root/elemenohpee/-/pipelines/195', status: { __typename: 'DetailedStatus', diff --git a/spec/frontend/pipelines/graph/mock_data.js b/spec/frontend/pipelines/graph/mock_data.js index 0cf7dc507f4..6124d67af09 100644 --- a/spec/frontend/pipelines/graph/mock_data.js +++ b/spec/frontend/pipelines/graph/mock_data.js @@ -1,4 +1,5 @@ import { unwrapPipelineData } from '~/pipelines/components/graph/utils'; +import { BUILD_KIND, BRIDGE_KIND } from '~/pipelines/components/graph/constants'; export const mockPipelineResponse = { data: { @@ -50,6 +51,7 @@ export const mockPipelineResponse = { { __typename: 'CiJob', id: '6', + kind: BUILD_KIND, name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', scheduledAt: null, status: { @@ -101,6 +103,7 @@ export const mockPipelineResponse = { __typename: 'CiJob', id: '11', name: 'build_b', + kind: BUILD_KIND, scheduledAt: null, status: { __typename: 'DetailedStatus', @@ -151,6 +154,7 @@ export const mockPipelineResponse = { __typename: 'CiJob', id: '16', name: 'build_c', + kind: BUILD_KIND, scheduledAt: null, status: { __typename: 'DetailedStatus', @@ -200,6 +204,7 @@ export const mockPipelineResponse = { { __typename: 'CiJob', id: '21', + kind: BUILD_KIND, name: 'build_d 1/3', scheduledAt: null, status: { @@ -232,6 +237,7 @@ export const mockPipelineResponse = { { __typename: 'CiJob', id: '24', + kind: BUILD_KIND, name: 'build_d 2/3', scheduledAt: null, status: { @@ -264,6 +270,7 @@ export const mockPipelineResponse = { { __typename: 'CiJob', id: '27', + kind: BUILD_KIND, name: 'build_d 3/3', scheduledAt: null, status: { @@ -329,6 +336,7 @@ export const mockPipelineResponse = { { __typename: 'CiJob', id: '34', + kind: BUILD_KIND, name: 'test_a', scheduledAt: null, status: { @@ -413,6 +421,7 @@ export const mockPipelineResponse = { { __typename: 'CiJob', id: '42', + kind: BUILD_KIND, name: 'test_b 1/2', scheduledAt: null, status: { @@ -499,6 +508,7 @@ export const mockPipelineResponse = { { __typename: 'CiJob', id: '67', + kind: BUILD_KIND, name: 'test_b 2/2', scheduledAt: null, status: { @@ -603,6 +613,7 @@ export const mockPipelineResponse = { { __typename: 'CiJob', id: '59', + kind: BUILD_KIND, name: 'test_c', scheduledAt: null, status: { @@ -646,6 +657,7 @@ export const mockPipelineResponse = { { __typename: 'CiJob', id: '53', + kind: BUILD_KIND, name: 'test_d', scheduledAt: null, status: { @@ -699,6 +711,11 @@ export const downstream = { id: 175, iid: '31', path: '/root/elemenohpee/-/pipelines/175', + retryable: true, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, status: { id: '70', group: 'success', @@ -724,6 +741,11 @@ export const downstream = { id: 181, iid: '27', path: '/root/abcd-dag/-/pipelines/181', + retryable: true, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, status: { id: '72', group: 'success', @@ -752,6 +774,11 @@ export const upstream = { id: 161, iid: '24', path: '/root/abcd-dag/-/pipelines/161', + retryable: true, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, status: { id: '74', group: 'success', @@ -786,6 +813,11 @@ export const wrappedPipelineReturn = { updatePipeline: true, }, downstream: { + retryable: true, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, __typename: 'PipelineConnection', nodes: [], }, @@ -793,6 +825,11 @@ export const wrappedPipelineReturn = { id: 'gid://gitlab/Ci::Pipeline/174', iid: '37', path: '/root/elemenohpee/-/pipelines/174', + retryable: true, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, __typename: 'Pipeline', status: { __typename: 'DetailedStatus', @@ -846,6 +883,7 @@ export const wrappedPipelineReturn = { { __typename: 'CiJob', id: '83', + kind: BUILD_KIND, name: 'build_n', scheduledAt: null, needs: { @@ -916,3 +954,87 @@ export const mockCalloutsResponse = (mappedCallouts) => ({ }, }, }); + +export const delayedJob = { + __typename: 'CiJob', + kind: BUILD_KIND, + name: 'delayed job', + scheduledAt: '2015-07-03T10:01:00.000Z', + needs: [], + status: { + __typename: 'DetailedStatus', + icon: 'status_scheduled', + tooltip: 'delayed manual action (%{remainingTime})', + hasDetails: true, + detailsPath: '/root/kinder-pipe/-/jobs/5339', + group: 'scheduled', + action: { + __typename: 'StatusAction', + icon: 'time-out', + title: 'Unschedule', + path: '/frontend-fixtures/builds-project/-/jobs/142/unschedule', + buttonTitle: 'Unschedule job', + }, + }, +}; + +export const mockJob = { + id: 4256, + name: 'test', + kind: BUILD_KIND, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + tooltip: 'passed', + group: 'success', + detailsPath: '/root/ci-mock/builds/4256', + hasDetails: true, + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4256/retry', + method: 'post', + }, + }, +}; + +export const mockJobWithoutDetails = { + id: 4257, + name: 'job_without_details', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + detailsPath: '/root/ci-mock/builds/4257', + hasDetails: false, + }, +}; + +export const mockJobWithUnauthorizedAction = { + id: 4258, + name: 'stop-environment', + status: { + icon: 'status_manual', + label: 'manual stop action (not allowed)', + tooltip: 'manual action', + group: 'manual', + detailsPath: '/root/ci-mock/builds/4258', + hasDetails: true, + action: null, + }, +}; + +export const triggerJob = { + id: 4259, + name: 'trigger', + kind: BRIDGE_KIND, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + action: null, + }, +}; diff --git a/spec/frontend/pipelines/graph_shared/links_inner_spec.js b/spec/frontend/pipelines/graph_shared/links_inner_spec.js index be422fac92c..2c6d126e12c 100644 --- a/spec/frontend/pipelines/graph_shared/links_inner_spec.js +++ b/spec/frontend/pipelines/graph_shared/links_inner_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { setHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; import { parseData } from '~/pipelines/components/parsing_utils'; import { createJobsHash } from '~/pipelines/utils'; @@ -42,7 +42,7 @@ describe('Links Inner component', () => { // We create fixture so that each job has an empty div that represent // the JobPill in the DOM. Each `JobPill` would have different coordinates, // so we increment their coordinates on each iteration to simulate different positions. - const setFixtures = ({ stages }) => { + const setHTMLFixtureLocal = ({ stages }) => { const jobs = createJobsHash(stages); const arrayOfJobs = Object.keys(jobs); @@ -82,6 +82,7 @@ describe('Links Inner component', () => { afterEach(() => { jest.restoreAllMocks(); wrapper.destroy(); + resetHTMLFixture(); }); describe('basic SVG creation', () => { @@ -124,7 +125,7 @@ describe('Links Inner component', () => { describe('with one need', () => { beforeEach(() => { - setFixtures(pipelineData); + setHTMLFixtureLocal(pipelineData); createComponent({ pipelineData: pipelineData.stages }); }); @@ -143,7 +144,7 @@ describe('Links Inner component', () => { describe('with a parallel need', () => { beforeEach(() => { - setFixtures(parallelNeedData); + setHTMLFixtureLocal(parallelNeedData); createComponent({ pipelineData: parallelNeedData.stages }); }); @@ -162,7 +163,7 @@ describe('Links Inner component', () => { describe('with same stage needs', () => { beforeEach(() => { - setFixtures(sameStageNeeds); + setHTMLFixtureLocal(sameStageNeeds); createComponent({ pipelineData: sameStageNeeds.stages }); }); @@ -181,7 +182,7 @@ describe('Links Inner component', () => { describe('with a large number of needs', () => { beforeEach(() => { - setFixtures(largePipelineData); + setHTMLFixtureLocal(largePipelineData); createComponent({ pipelineData: largePipelineData.stages }); }); @@ -200,7 +201,7 @@ describe('Links Inner component', () => { describe('interactions', () => { beforeEach(() => { - setFixtures(largePipelineData); + setHTMLFixtureLocal(largePipelineData); createComponent({ pipelineData: largePipelineData.stages }); }); diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js index c4639bd8e16..5cc11adf696 100644 --- a/spec/frontend/pipelines/header_component_spec.js +++ b/spec/frontend/pipelines/header_component_spec.js @@ -1,4 +1,4 @@ -import { GlModal, GlLoadingIcon } from '@gitlab/ui'; +import { GlAlert, GlModal, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; @@ -21,6 +21,7 @@ describe('Pipeline details header', () => { let glModalDirective; let mutate = jest.fn(); + const findAlert = () => wrapper.find(GlAlert); const findDeleteModal = () => wrapper.find(GlModal); const findRetryButton = () => wrapper.find('[data-testid="retryPipeline"]'); const findCancelButton = () => wrapper.find('[data-testid="cancelPipeline"]'); @@ -121,6 +122,22 @@ describe('Pipeline details header', () => { it('should render retry action tooltip', () => { expect(findRetryButton().attributes('title')).toBe(BUTTON_TOOLTIP_RETRY); }); + + it('should display error message on failure', async () => { + const failureMessage = 'failure message'; + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ + data: { + pipelineRetry: { + errors: [failureMessage], + }, + }, + }); + + findRetryButton().vm.$emit('click'); + await waitForPromises(); + + expect(findAlert().text()).toBe(failureMessage); + }); }); describe('Retry action failed', () => { @@ -156,6 +173,22 @@ describe('Pipeline details header', () => { variables: { id: mockRunningPipelineHeader.id }, }); }); + + it('should display error message on failure', async () => { + const failureMessage = 'failure message'; + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ + data: { + pipelineCancel: { + errors: [failureMessage], + }, + }, + }); + + findCancelButton().vm.$emit('click'); + await waitForPromises(); + + expect(findAlert().text()).toBe(failureMessage); + }); }); describe('Delete action', () => { @@ -179,6 +212,22 @@ describe('Pipeline details header', () => { variables: { id: mockFailedPipelineHeader.id }, }); }); + + it('should display error message on failure', async () => { + const failureMessage = 'failure message'; + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ + data: { + pipelineDestroy: { + errors: [failureMessage], + }, + }, + }); + + findDeleteModal().vm.$emit('ok'); + await waitForPromises(); + + expect(findAlert().text()).toBe(failureMessage); + }); }); describe('Permissions', () => { diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js index 59d4e808b32..57d1511d859 100644 --- a/spec/frontend/pipelines/mock_data.js +++ b/spec/frontend/pipelines/mock_data.js @@ -1141,3 +1141,218 @@ export const mockPipelineBranch = () => { viewType: 'root', }; }; + +export const mockFailedJobsQueryResponse = { + data: { + project: { + __typename: 'Project', + id: 'gid://gitlab/Project/20', + pipeline: { + __typename: 'Pipeline', + id: 'gid://gitlab/Ci::Pipeline/300', + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + status: 'FAILED', + detailedStatus: { + __typename: 'DetailedStatus', + id: 'failed-1848-1848', + detailsPath: '/root/ci-project/-/jobs/1848', + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + tooltip: 'failed - (script failure)', + action: { + __typename: 'StatusAction', + id: 'Ci::Build-failed-1848', + buttonTitle: 'Retry this job', + icon: 'retry', + method: 'post', + path: '/root/ci-project/-/jobs/1848/retry', + title: 'Retry', + }, + }, + id: 'gid://gitlab/Ci::Build/1848', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/358', + name: 'build', + }, + name: 'wait_job', + retryable: true, + userPermissions: { + __typename: 'JobPermissions', + readBuild: true, + updateBuild: true, + }, + }, + { + __typename: 'CiJob', + status: 'FAILED', + detailedStatus: { + __typename: 'DetailedStatus', + id: 'failed-1710-1710', + detailsPath: '/root/ci-project/-/jobs/1710', + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + tooltip: 'failed - (script failure) (retried)', + action: null, + }, + id: 'gid://gitlab/Ci::Build/1710', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/358', + name: 'build', + }, + name: 'wait_job', + retryable: false, + userPermissions: { + __typename: 'JobPermissions', + readBuild: true, + updateBuild: true, + }, + }, + ], + }, + }, + }, + }, +}; + +export const mockFailedJobsSummaryData = [ + { + id: 1848, + failure: null, + failure_summary: + '<span>Pulling docker image node:latest ...<br/></span><span>Using docker image sha256:738d733448be00c72cb6618b7a06a1424806c6d239d8885e92f9b1e8727092b5 for node:latest with digest node@sha256:e5b7b349d517159246070bf14242027a9e220ffa8bd98a67ba1495d969c06c01 ...<br/></span><div class="section-start" data-timestamp="1651175313" data-section="prepare-script" role="button"></div><span class="term-fg-l-cyan term-bold section section-header js-s-prepare-script">Preparing environment</span><span class="section section-header js-s-prepare-script"><br/></span><span class="section line js-s-prepare-script">Running on runner-kvkqh24-project-20-concurrent-0 via 0706719b1b8d...<br/></span><div class="section-end" data-section="prepare-script"></div><div class="section-start" data-timestamp="1651175313" data-section="get-sources" role="button"></div><span class="term-fg-l-cyan term-bold section section-header js-s-get-sources">Getting source from Git repository</span><span class="section section-header js-s-get-sources"><br/></span><span class="term-fg-l-green term-bold section line js-s-get-sources">Fetching changes with git depth set to 50...</span><span class="section line js-s-get-sources"><br/>Reinitialized existing Git repository in /builds/root/ci-project/.git/<br/>fatal: couldn\'t find remote ref refs/heads/test<br/></span><div class="section-end" data-section="get-sources"></div><span class="term-fg-l-red term-bold">ERROR: Job failed: exit code 1<br/></span>', + }, +]; + +export const mockFailedJobsData = [ + { + normalizedId: 1848, + __typename: 'CiJob', + status: 'FAILED', + detailedStatus: { + __typename: 'DetailedStatus', + id: 'failed-1848-1848', + detailsPath: '/root/ci-project/-/jobs/1848', + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + tooltip: 'failed - (script failure)', + action: { + __typename: 'StatusAction', + id: 'Ci::Build-failed-1848', + buttonTitle: 'Retry this job', + icon: 'retry', + method: 'post', + path: '/root/ci-project/-/jobs/1848/retry', + title: 'Retry', + }, + }, + id: 'gid://gitlab/Ci::Build/1848', + stage: { __typename: 'CiStage', id: 'gid://gitlab/Ci::Stage/358', name: 'build' }, + name: 'wait_job', + retryable: true, + userPermissions: { __typename: 'JobPermissions', readBuild: true, updateBuild: true }, + }, + { + normalizedId: 1710, + __typename: 'CiJob', + status: 'FAILED', + detailedStatus: { + __typename: 'DetailedStatus', + id: 'failed-1710-1710', + detailsPath: '/root/ci-project/-/jobs/1710', + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + tooltip: 'failed - (script failure) (retried)', + action: null, + }, + id: 'gid://gitlab/Ci::Build/1710', + stage: { __typename: 'CiStage', id: 'gid://gitlab/Ci::Stage/358', name: 'build' }, + name: 'wait_job', + retryable: false, + userPermissions: { __typename: 'JobPermissions', readBuild: true, updateBuild: true }, + }, +]; + +export const mockPreparedFailedJobsData = [ + { + __typename: 'CiJob', + _showDetails: true, + detailedStatus: { + __typename: 'DetailedStatus', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + id: 'Ci::Build-failed-1848', + method: 'post', + path: '/root/ci-project/-/jobs/1848/retry', + title: 'Retry', + }, + detailsPath: '/root/ci-project/-/jobs/1848', + group: 'failed', + icon: 'status_failed', + id: 'failed-1848-1848', + label: 'failed', + text: 'failed', + tooltip: 'failed - (script failure)', + }, + failure: null, + failureSummary: + '<span>Pulling docker image node:latest ...<br/></span><span>Using docker image sha256:738d733448be00c72cb6618b7a06a1424806c6d239d8885e92f9b1e8727092b5 for node:latest with digest node@sha256:e5b7b349d517159246070bf14242027a9e220ffa8bd98a67ba1495d969c06c01 ...<br/></span><div class="section-start" data-timestamp="1651175313" data-section="prepare-script" role="button"></div><span class="term-fg-l-cyan term-bold section section-header js-s-prepare-script">Preparing environment</span><span class="section section-header js-s-prepare-script"><br/></span><span class="section line js-s-prepare-script">Running on runner-kvkqh24-project-20-concurrent-0 via 0706719b1b8d...<br/></span><div class="section-end" data-section="prepare-script"></div><div class="section-start" data-timestamp="1651175313" data-section="get-sources" role="button"></div><span class="term-fg-l-cyan term-bold section section-header js-s-get-sources">Getting source from Git repository</span><span class="section section-header js-s-get-sources"><br/></span><span class="term-fg-l-green term-bold section line js-s-get-sources">Fetching changes with git depth set to 50...</span><span class="section line js-s-get-sources"><br/>Reinitialized existing Git repository in /builds/root/ci-project/.git/<br/>fatal: couldn\'t find remote ref refs/heads/test<br/></span><div class="section-end" data-section="get-sources"></div><span class="term-fg-l-red term-bold">ERROR: Job failed: exit code 1<br/></span>', + id: 'gid://gitlab/Ci::Build/1848', + name: 'wait_job', + normalizedId: 1848, + retryable: true, + stage: { __typename: 'CiStage', id: 'gid://gitlab/Ci::Stage/358', name: 'build' }, + status: 'FAILED', + userPermissions: { __typename: 'JobPermissions', readBuild: true, updateBuild: true }, + }, +]; + +export const mockPreparedFailedJobsDataNoPermission = [ + { + ...mockPreparedFailedJobsData[0], + userPermissions: { __typename: 'JobPermissions', readBuild: false, updateBuild: false }, + }, +]; + +export const successRetryMutationResponse = { + data: { + jobRetry: { + job: { + __typename: 'CiJob', + id: '"gid://gitlab/Ci::Build/1985"', + detailedStatus: { + detailsPath: '/root/project/-/jobs/1985', + id: 'pending-1985-1985', + __typename: 'DetailedStatus', + }, + }, + errors: [], + __typename: 'JobRetryPayload', + }, + }, +}; + +export const failedRetryMutationResponse = { + data: { + jobRetry: { + job: {}, + errors: ['New Error'], + __typename: 'JobRetryPayload', + }, + }, +}; diff --git a/spec/frontend/pipelines/pipeline_graph/utils_spec.js b/spec/frontend/pipelines/pipeline_graph/utils_spec.js index 5816bc06fe3..d6b13da3c3a 100644 --- a/spec/frontend/pipelines/pipeline_graph/utils_spec.js +++ b/spec/frontend/pipelines/pipeline_graph/utils_spec.js @@ -1,4 +1,5 @@ -import { createJobsHash, generateJobNeedsDict } from '~/pipelines/utils'; +import { createJobsHash, generateJobNeedsDict, getPipelineDefaultTab } from '~/pipelines/utils'; +import { TAB_QUERY_PARAM, validPipelineTabNames } from '~/pipelines/constants'; describe('utils functions', () => { const jobName1 = 'build_1'; @@ -169,4 +170,21 @@ describe('utils functions', () => { }); }); }); + + describe('getPipelineDefaultTab', () => { + const baseUrl = 'http://gitlab.com/user/multi-projects-small/-/pipelines/332/'; + it('returns null if there was no `tab` params', () => { + expect(getPipelineDefaultTab(baseUrl)).toBe(null); + }); + + it('returns null if there was no valid tab param', () => { + expect(getPipelineDefaultTab(`${baseUrl}?${TAB_QUERY_PARAM}=invalid`)).toBe(null); + }); + + it('returns the correct tab name if present', () => { + validPipelineTabNames.forEach((tabName) => { + expect(getPipelineDefaultTab(`${baseUrl}?${TAB_QUERY_PARAM}=${tabName}`)).toBe(tabName); + }); + }); + }); }); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index d2b30c93746..de9f394db43 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -82,6 +82,8 @@ describe('Pipelines', () => { provide: { pipelineEditorPath: '', suggestedCiTemplates: [], + ciRunnerSettingsPath: paths.ciRunnerSettingsPath, + anyRunnersAvailable: true, }, propsData: { store: new Store(), diff --git a/spec/frontend/pipelines/test_reports/stores/actions_spec.js b/spec/frontend/pipelines/test_reports/stores/actions_spec.js index d5acb115bc1..74a9d8c354f 100644 --- a/spec/frontend/pipelines/test_reports/stores/actions_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/actions_spec.js @@ -82,17 +82,16 @@ describe('Actions TestReports Store', () => { ); }); - it('should create flash on API error', async () => { + it('should call SET_SUITE_ERROR on error', () => { const index = 0; - await testAction( + return testAction( actions.fetchTestSuite, index, { ...state, testReports, suiteEndpoint: null }, - [], + [{ type: types.SET_SUITE_ERROR, payload: expect.any(Error) }], [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], ); - expect(createFlash).toHaveBeenCalled(); }); describe('when we already have the suite data', () => { diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js index f2dbeec6a06..6ab479a257c 100644 --- a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js @@ -1,6 +1,9 @@ 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'; + +jest.mock('~/flash.js'); describe('Mutations TestReports Store', () => { let mockState; @@ -44,6 +47,24 @@ describe('Mutations TestReports Store', () => { }); }); + describe('set suite error', () => { + it('should set the error message in state if provided', () => { + const message = 'Test report artifacts have expired'; + + mutations[types.SET_SUITE_ERROR](mockState, { + response: { data: { errors: message } }, + }); + + expect(mockState.errorMessage).toBe(message); + }); + + it('should show a flash message otherwise', () => { + mutations[types.SET_SUITE_ERROR](mockState, {}); + + expect(createFlash).toHaveBeenCalled(); + }); + }); + describe('set selected suite index', () => { it('should set selectedSuiteIndex', () => { const selectedSuiteIndex = 0; diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js index 97241e14129..dc72fa31ace 100644 --- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js +++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js @@ -1,12 +1,13 @@ import { GlButton, GlFriendlyWrap, GlLink, GlPagination } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; import testReports from 'test_fixtures/pipelines/test_report.json'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SuiteTable from '~/pipelines/components/test_reports/test_suite_table.vue'; import { TestStatus } from '~/pipelines/constants'; import * as getters from '~/pipelines/stores/test_reports/getters'; import { formatFilePath } from '~/pipelines/stores/test_reports/utils'; +import { ARTIFACTS_EXPIRED_ERROR_MESSAGE } from '~/pipelines/stores/test_reports/constants'; import skippedTestCases from './mock_data'; Vue.use(Vuex); @@ -23,13 +24,14 @@ describe('Test reports suite table', () => { const testCases = testSuite.test_cases; const blobPath = '/test/blob/path'; - const noCasesMessage = () => wrapper.find('.js-no-test-cases'); - const allCaseRows = () => wrapper.findAll('.js-case-row'); - const findCaseRowAtIndex = (index) => wrapper.findAll('.js-case-row').at(index); + const noCasesMessage = () => wrapper.findByTestId('no-test-cases'); + const artifactsExpiredMessage = () => wrapper.findByTestId('artifacts-expired'); + const allCaseRows = () => wrapper.findAllByTestId('test-case-row'); + const findCaseRowAtIndex = (index) => wrapper.findAllByTestId('test-case-row').at(index); const findLinkForRow = (row) => row.find(GlLink); const findIconForRow = (row, status) => row.find(`.ci-status-icon-${status}`); - const createComponent = (suite = testSuite, perPage = 20) => { + const createComponent = ({ suite = testSuite, perPage = 20, errorMessage } = {}) => { store = new Vuex.Store({ state: { blobPath, @@ -41,11 +43,12 @@ describe('Test reports suite table', () => { page: 1, perPage, }, + errorMessage, }, getters, }); - wrapper = shallowMount(SuiteTable, { + wrapper = shallowMountExtended(SuiteTable, { store, stubs: { GlFriendlyWrap }, }); @@ -55,12 +58,18 @@ describe('Test reports suite table', () => { wrapper.destroy(); }); - describe('should not render', () => { - beforeEach(() => createComponent([])); + it('should render a message when there are no test cases', () => { + createComponent({ suite: [] }); - it('a table when there are no test cases', () => { - expect(noCasesMessage().exists()).toBe(true); - }); + expect(noCasesMessage().exists()).toBe(true); + expect(artifactsExpiredMessage().exists()).toBe(false); + }); + + it('should render a message when artifacts have expired', () => { + createComponent({ suite: [], errorMessage: ARTIFACTS_EXPIRED_ERROR_MESSAGE }); + + expect(noCasesMessage().exists()).toBe(true); + expect(artifactsExpiredMessage().exists()).toBe(true); }); describe('when a test suite is supplied', () => { @@ -102,7 +111,7 @@ describe('Test reports suite table', () => { const perPage = 2; beforeEach(() => { - createComponent(testSuite, perPage); + createComponent({ testSuite, perPage }); }); it('renders one page of test cases', () => { @@ -117,11 +126,13 @@ describe('Test reports suite table', () => { describe('when a test case classname property is null', () => { it('still renders all test cases', () => { createComponent({ - ...testSuite, - test_cases: testSuite.test_cases.map((testCase) => ({ - ...testCase, - classname: null, - })), + testSuite: { + ...testSuite, + test_cases: testSuite.test_cases.map((testCase) => ({ + ...testCase, + classname: null, + })), + }, }); expect(allCaseRows()).toHaveLength(testCases.length); @@ -131,11 +142,13 @@ describe('Test reports suite table', () => { describe('when a test case name property is null', () => { it('still renders all test cases', () => { createComponent({ - ...testSuite, - test_cases: testSuite.test_cases.map((testCase) => ({ - ...testCase, - name: null, - })), + testSuite: { + ...testSuite, + test_cases: testSuite.test_cases.map((testCase) => ({ + ...testCase, + name: null, + })), + }, }); expect(allCaseRows()).toHaveLength(testCases.length); diff --git a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js index 42ae154fb5e..ba478363d04 100644 --- a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js @@ -34,6 +34,7 @@ describe('Pipeline Branch Name Token', () => { value: { data: '', }, + cursorPosition: 'start', }; const optionsWithDefaultBranchName = (options) => { diff --git a/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js index 684d2d0664a..b8abf2c1727 100644 --- a/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js @@ -20,6 +20,7 @@ describe('Pipeline Source Token', () => { value: { data: '', }, + cursorPosition: 'start', }; const createComponent = () => { diff --git a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js index 1db736ba01e..2c5fa8b00e2 100644 --- a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js @@ -20,6 +20,7 @@ describe('Pipeline Status Token', () => { value: { data: '', }, + cursorPosition: 'start', }; const createComponent = () => { diff --git a/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js index b03dbb73b95..596a9218c39 100644 --- a/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js @@ -29,6 +29,7 @@ describe('Pipeline Branch Name Token', () => { value: { data: '', }, + cursorPosition: 'start', }; const createComponent = (options, data) => { diff --git a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js index 7ddbbb3b005..397dbdf95a9 100644 --- a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js @@ -24,6 +24,7 @@ describe('Pipeline Trigger Author Token', () => { value: { data: '', }, + cursorPosition: 'start', }; const createComponent = (data) => { diff --git a/spec/frontend/project_select_combo_button_spec.js b/spec/frontend/project_select_combo_button_spec.js index 40e7d27edc8..b8d5a1a61f3 100644 --- a/spec/frontend/project_select_combo_button_spec.js +++ b/spec/frontend/project_select_combo_button_spec.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import ProjectSelectComboButton from '~/project_select_combo_button'; const fixturePath = 'static/project_select_combo_button.html'; @@ -22,16 +23,25 @@ describe('Project Select Combo Button', () => { name: 'My Other Cool Project', url: 'http://myothercoolproject.com', }, + vulnerableProject: { + name: 'Self XSS', + // eslint-disable-next-line no-script-url + url: 'javascript:alert(1)', + }, localStorageKey: 'group-12345-new-issue-recent-project', relativePath: 'issues/new', }; - loadFixtures(fixturePath); + loadHTMLFixture(fixturePath); testContext.newItemBtn = document.querySelector('.js-new-project-item-link'); testContext.projectSelectInput = document.querySelector('.project-item-select'); }); + afterEach(() => { + resetHTMLFixture(); + }); + describe('on page load when localStorage is empty', () => { beforeEach(() => { testContext.comboButton = new ProjectSelectComboButton(testContext.projectSelectInput); @@ -99,6 +109,25 @@ describe('Project Select Combo Button', () => { }); }); + describe('after selecting a vulnerable project', () => { + beforeEach(() => { + testContext.comboButton = new ProjectSelectComboButton(testContext.projectSelectInput); + + // mock the effect of selecting an item from the projects dropdown (select2) + $('.project-item-select') + .val(JSON.stringify(testContext.defaults.vulnerableProject)) + .trigger('change'); + }); + + it('newItemBtn href is correctly sanitized', () => { + expect(testContext.newItemBtn.getAttribute('href')).toBe('about:blank'); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + }); + describe('deriveTextVariants', () => { beforeEach(() => { testContext.mockExecutionContext = { diff --git a/spec/frontend/projects/commits/components/author_select_spec.js b/spec/frontend/projects/commits/components/author_select_spec.js index 4e567ab030e..d11090cba8a 100644 --- a/spec/frontend/projects/commits/components/author_select_spec.js +++ b/spec/frontend/projects/commits/components/author_select_spec.js @@ -2,6 +2,7 @@ import { GlDropdown, GlDropdownSectionHeader, GlSearchBoxByType, GlDropdownItem import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import * as urlUtility from '~/lib/utils/url_utility'; import AuthorSelect from '~/projects/commits/components/author_select.vue'; import { createStore } from '~/projects/commits/store'; @@ -30,7 +31,7 @@ describe('Author Select', () => { let wrapper; const createComponent = () => { - setFixtures(` + setHTMLFixture(` <div class="js-project-commits-show"> <input id="commits-search" type="text" /> <div id="commits-list"></div> @@ -54,6 +55,7 @@ describe('Author Select', () => { afterEach(() => { wrapper.destroy(); + resetHTMLFixture(); }); const findDropdownContainer = () => wrapper.find({ ref: 'dropdownContainer' }); diff --git a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap index 26a3b27d958..736d149f06d 100644 --- a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap +++ b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap @@ -31,6 +31,7 @@ exports[`Project remove modal initialized matches the snapshot 1`] = ` <gl-modal-stub actioncancel="[object Object]" actionprimary="[object Object]" + arialabel="" dismisslabel="Close" footer-class="gl-bg-gray-10 gl-p-5" modalclass="" @@ -49,6 +50,7 @@ exports[`Project remove modal initialized matches the snapshot 1`] = ` primarybuttontext="" secondarybuttonlink="" secondarybuttontext="" + showicon="true" title="" variant="danger" > diff --git a/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap b/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap index 2d1039a8743..26495fbcf83 100644 --- a/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap +++ b/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap @@ -43,6 +43,7 @@ exports[`Project remove modal intialized matches the snapshot 1`] = ` primarybuttontext="" secondarybuttonlink="" secondarybuttontext="" + showicon="true" title="" variant="danger" > diff --git a/spec/frontend/projects/new/components/deployment_target_select_spec.js b/spec/frontend/projects/new/components/deployment_target_select_spec.js index 1c443879dc3..f3b22d4a1b9 100644 --- a/spec/frontend/projects/new/components/deployment_target_select_spec.js +++ b/spec/frontend/projects/new/components/deployment_target_select_spec.js @@ -1,6 +1,7 @@ import { GlFormGroup, GlFormSelect, GlFormText, GlLink, GlSprintf } from '@gitlab/ui'; import { nextTick } from 'vue'; import { shallowMount } from '@vue/test-utils'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { mockTracking } from 'helpers/tracking_helper'; import DeploymentTargetSelect from '~/projects/new/components/deployment_target_select.vue'; import { @@ -32,7 +33,7 @@ describe('Deployment target select', () => { }; const createForm = () => { - setFixtures(` + setHTMLFixture(` <form id="${NEW_PROJECT_FORM}"> </form> `); @@ -47,6 +48,7 @@ describe('Deployment target select', () => { afterEach(() => { wrapper.destroy(); + resetHTMLFixture(); }); it('renders the correct label', () => { diff --git a/spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js b/spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js index 31ddbc80ae4..42259a5c392 100644 --- a/spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js +++ b/spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js @@ -1,5 +1,6 @@ import { GlPopover, GlFormInputGroup } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import NewProjectPushTipPopover from '~/projects/new/components/new_project_push_tip_popover.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -31,12 +32,13 @@ describe('New project push tip popover', () => { }; beforeEach(() => { - setFixtures(`<a id="${targetId}"></a>`); + setHTMLFixture(`<a id="${targetId}"></a>`); buildWrapper(); }); afterEach(() => { wrapper.destroy(); + resetHTMLFixture(); }); it('renders popover that targets the specified target', () => { diff --git a/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js b/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js index cafb3f231bd..7bb289408b8 100644 --- a/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js +++ b/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js @@ -1,8 +1,8 @@ import { nextTick } from 'vue'; -import { GlSegmentedControl } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import CiCdAnalyticsAreaChart from '~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue'; import CiCdAnalyticsCharts from '~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue'; +import SegmentedControlButtonGroup from '~/vue_shared/components/segmented_control_button_group.vue'; import { transformedAreaChartData, chartOptions } from '../mock_data'; const DEFAULT_PROPS = { @@ -48,7 +48,7 @@ describe('~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue', ( }); const findMetricsSlot = () => wrapper.findByTestId('metrics-slot'); - const findSegmentedControl = () => wrapper.findComponent(GlSegmentedControl); + const findSegmentedControl = () => wrapper.findComponent(SegmentedControlButtonGroup); describe('segmented control', () => { beforeEach(() => { @@ -56,7 +56,7 @@ describe('~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue', ( }); it('should default to the first chart', () => { - expect(findSegmentedControl().props('checked')).toBe(0); + expect(findSegmentedControl().props('value')).toBe(0); }); it('should use the title and index as values', () => { diff --git a/spec/frontend/projects/project_import_gitlab_project_spec.js b/spec/frontend/projects/project_import_gitlab_project_spec.js index aaf8a81f626..76621ba9c06 100644 --- a/spec/frontend/projects/project_import_gitlab_project_spec.js +++ b/spec/frontend/projects/project_import_gitlab_project_spec.js @@ -1,3 +1,4 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import projectImportGitlab from '~/projects/project_import_gitlab_project'; describe('Import Gitlab project', () => { @@ -7,7 +8,7 @@ describe('Import Gitlab project', () => { const setTestFixtures = (url) => { window.history.pushState({}, null, url); - setFixtures(` + setHTMLFixture(` <input class="js-path-name" /> <input class="js-project-name" /> `); @@ -21,6 +22,7 @@ describe('Import Gitlab project', () => { afterEach(() => { window.history.pushState({}, null, ''); + resetHTMLFixture(); }); describe('project name', () => { diff --git a/spec/frontend/projects/project_new_spec.js b/spec/frontend/projects/project_new_spec.js index d2936cb9efe..fe325343da8 100644 --- a/spec/frontend/projects/project_new_spec.js +++ b/spec/frontend/projects/project_new_spec.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import projectNew from '~/projects/project_new'; @@ -8,7 +9,7 @@ describe('New Project', () => { let $projectName; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <div class='toggle-import-form'> <div class='import-url-data'> <div class="form-group"> @@ -33,6 +34,10 @@ describe('New Project', () => { $projectName = $('#project_name'); }); + afterEach(() => { + resetHTMLFixture(); + }); + describe('deriveProjectPathFromUrl', () => { const dummyImportUrl = `${TEST_HOST}/dummy/import/url.git`; diff --git a/spec/frontend/projects/projects_filterable_list_spec.js b/spec/frontend/projects/projects_filterable_list_spec.js index a41e8b7bc09..f217efa411e 100644 --- a/spec/frontend/projects/projects_filterable_list_spec.js +++ b/spec/frontend/projects/projects_filterable_list_spec.js @@ -1,4 +1,4 @@ -import { setHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import ProjectsFilterableList from '~/projects/projects_filterable_list'; describe('ProjectsFilterableList', () => { @@ -20,6 +20,10 @@ describe('ProjectsFilterableList', () => { List = new ProjectsFilterableList(form, filter, holder); }); + afterEach(() => { + resetHTMLFixture(); + }); + describe('getFilterEndpoint', () => { it('updates converts getPagePath for projects', () => { jest.spyOn(List, 'getPagePath').mockReturnValue('blah/projects?'); diff --git a/spec/frontend/projects/settings/access_dropdown_spec.js b/spec/frontend/projects/settings/access_dropdown_spec.js index 236968a3736..65b01172e7e 100644 --- a/spec/frontend/projects/settings/access_dropdown_spec.js +++ b/spec/frontend/projects/settings/access_dropdown_spec.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import AccessDropdown from '~/projects/settings/access_dropdown'; import { LEVEL_TYPES } from '~/projects/settings/constants'; @@ -7,7 +8,7 @@ describe('AccessDropdown', () => { let dropdown; beforeEach(() => { - setFixtures(` + setHTMLFixture(` <div id="dummy-dropdown"> <span class="dropdown-toggle-text"></span> </div> @@ -28,6 +29,10 @@ describe('AccessDropdown', () => { dropdown = new AccessDropdown(options); }); + afterEach(() => { + resetHTMLFixture(); + }); + describe('toggleLabel', () => { let $dropdownToggleText; const dummyItems = [ diff --git a/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js b/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js index dbea94cbd53..8b8e7d1454d 100644 --- a/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js +++ b/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js @@ -1,11 +1,11 @@ -import { GlTokenSelector, GlToken } from '@gitlab/ui'; +import { GlAvatarLabeled, GlTokenSelector, GlToken } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import TopicsTokenSelector from '~/projects/settings/topics/components/topics_token_selector.vue'; const mockTopics = [ - { id: 1, name: 'topic1', avatarUrl: 'avatar.com/topic1.png' }, - { id: 2, name: 'GitLab', avatarUrl: 'avatar.com/GitLab.png' }, + { id: 1, name: 'topic1', title: 'Topic 1', avatarUrl: 'avatar.com/topic1.png' }, + { id: 2, name: 'GitLab', title: 'GitLab', avatarUrl: 'avatar.com/GitLab.png' }, ]; describe('TopicsTokenSelector', () => { @@ -38,6 +38,8 @@ describe('TopicsTokenSelector', () => { const findTokenSelectorInput = () => findTokenSelector().find('input[type="text"]'); + const findAllAvatars = () => wrapper.findAllComponents(GlAvatarLabeled).wrappers; + const setTokenSelectorInputValue = (value) => { const tokenSelectorInput = findTokenSelectorInput(); @@ -81,6 +83,13 @@ describe('TopicsTokenSelector', () => { expect(tokenWrapper.text()).toBe(selected[index].name); }); }); + + it('passes topic title to the avatar', async () => { + createComponent(); + const avatars = findAllAvatars(); + + mockTopics.map((topic, index) => expect(avatars[index].text()).toBe(topic.title)); + }); }); describe('when enter key is pressed', () => { diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js index 57e515723e5..aac1a418142 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js @@ -165,8 +165,12 @@ describe('ServiceDeskSetting', () => { describe('save button', () => { it('renders a save button to save a template', () => { wrapper = createComponent(); + const saveButton = findButton(); - expect(findButton().text()).toContain('Save changes'); + expect(saveButton.text()).toContain('Save changes'); + expect(saveButton.props()).toMatchObject({ + variant: 'confirm', + }); }); it('emits a save event with the chosen template when the save button is clicked', async () => { diff --git a/spec/frontend/prometheus_alerts/components/reset_key_spec.js b/spec/frontend/prometheus_alerts/components/reset_key_spec.js deleted file mode 100644 index dc5fdb1dffc..00000000000 --- a/spec/frontend/prometheus_alerts/components/reset_key_spec.js +++ /dev/null @@ -1,99 +0,0 @@ -import { GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; -import waitForPromises from 'helpers/wait_for_promises'; -import axios from '~/lib/utils/axios_utils'; -import ResetKey from '~/prometheus_alerts/components/reset_key.vue'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; - -describe('ResetKey', () => { - let mock; - let vm; - - const propsData = { - initialAuthorizationKey: 'abcd1234', - changeKeyUrl: '/updateKeyUrl', - notifyUrl: '/root/autodevops-deploy/prometheus/alerts/notify.json', - learnMoreUrl: '/learnMore', - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - setFixtures('<div class="flash-container"></div><div id="reset-key"></div>'); - }); - - afterEach(() => { - mock.restore(); - vm.destroy(); - }); - - describe('authorization key exists', () => { - beforeEach(() => { - propsData.initialAuthorizationKey = 'abcd1234'; - vm = shallowMount(ResetKey, { - propsData, - }); - }); - - it('shows fields and buttons', () => { - expect(vm.find('#notify-url').attributes('value')).toEqual(propsData.notifyUrl); - expect(vm.find('#authorization-key').attributes('value')).toEqual( - propsData.initialAuthorizationKey, - ); - - expect(vm.findAll(ClipboardButton).length).toBe(2); - expect(vm.find('.js-reset-auth-key').text()).toEqual('Reset key'); - }); - - it('reset updates key', async () => { - mock.onPost(propsData.changeKeyUrl).replyOnce(200, { token: 'newToken' }); - - vm.find(GlModal).vm.$emit('ok'); - - await nextTick(); - await waitForPromises(); - expect(vm.vm.authorizationKey).toEqual('newToken'); - expect(vm.find('#authorization-key').attributes('value')).toEqual('newToken'); - }); - - it('reset key failure shows error', async () => { - mock.onPost(propsData.changeKeyUrl).replyOnce(500); - - vm.find(GlModal).vm.$emit('ok'); - - await nextTick(); - await waitForPromises(); - expect(vm.find('#authorization-key').attributes('value')).toEqual( - propsData.initialAuthorizationKey, - ); - - expect(document.querySelector('.flash-container').innerText.trim()).toEqual( - 'Failed to reset key. Please try again.', - ); - }); - }); - - describe('authorization key has not been set', () => { - beforeEach(() => { - propsData.initialAuthorizationKey = ''; - vm = shallowMount(ResetKey, { - propsData, - }); - }); - - it('shows Generate Key button', () => { - expect(vm.find('.js-reset-auth-key').text()).toEqual('Generate key'); - expect(vm.find('#authorization-key').attributes('value')).toEqual(''); - }); - - it('Generate key button triggers key change', async () => { - mock.onPost(propsData.changeKeyUrl).replyOnce(200, { token: 'newToken' }); - - vm.find('.js-reset-auth-key').vm.$emit('click'); - - await waitForPromises(); - expect(vm.find('#authorization-key').attributes('value')).toEqual('newToken'); - }); - }); -}); diff --git a/spec/frontend/prometheus_metrics/custom_metrics_spec.js b/spec/frontend/prometheus_metrics/custom_metrics_spec.js index 20593351ee5..473327bf5e1 100644 --- a/spec/frontend/prometheus_metrics/custom_metrics_spec.js +++ b/spec/frontend/prometheus_metrics/custom_metrics_spec.js @@ -1,4 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import axios from '~/lib/utils/axios_utils'; import PANEL_STATE from '~/prometheus_metrics/constants'; import CustomMetrics from '~/prometheus_metrics/custom_metrics'; @@ -15,11 +16,12 @@ describe('PrometheusMetrics', () => { mock.onGet(customMetricsEndpoint).reply(200, { metrics, }); - loadFixtures(FIXTURE); + loadHTMLFixture(FIXTURE); }); afterEach(() => { mock.restore(); + resetHTMLFixture(); }); describe('Custom Metrics', () => { diff --git a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js index ee74e28ba23..1151c0b3769 100644 --- a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js +++ b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js @@ -1,4 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import PANEL_STATE from '~/prometheus_metrics/constants'; @@ -9,7 +10,7 @@ describe('PrometheusMetrics', () => { const FIXTURE = 'services/prometheus/prometheus_service.html'; beforeEach(() => { - loadFixtures(FIXTURE); + loadHTMLFixture(FIXTURE); }); describe('constructor', () => { @@ -19,6 +20,10 @@ describe('PrometheusMetrics', () => { prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring'); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('should initialize wrapper element refs on class object', () => { expect(prometheusMetrics.$wrapper).toBeDefined(); expect(prometheusMetrics.$monitoredMetricsPanel).toBeDefined(); diff --git a/spec/frontend/protected_branches/protected_branch_create_spec.js b/spec/frontend/protected_branches/protected_branch_create_spec.js index b3de2d5e031..4b634c52b01 100644 --- a/spec/frontend/protected_branches/protected_branch_create_spec.js +++ b/spec/frontend/protected_branches/protected_branch_create_spec.js @@ -1,3 +1,4 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import ProtectedBranchCreate from '~/protected_branches/protected_branch_create'; const FORCE_PUSH_TOGGLE_TESTID = 'force-push-toggle'; @@ -21,7 +22,7 @@ describe('ProtectedBranchCreate', () => { codeOwnerToggleChecked = false, hasLicense = true, } = {}) => { - setFixtures(` + setHTMLFixture(` <form class="js-new-protected-branch"> <span class="js-force-push-toggle" @@ -40,6 +41,10 @@ describe('ProtectedBranchCreate', () => { return new ProtectedBranchCreate({ hasLicense }); }; + afterEach(() => { + resetHTMLFixture(); + }); + describe('when license supports code owner approvals', () => { it('instantiates the code owner toggle', () => { create(); diff --git a/spec/frontend/protected_branches/protected_branch_edit_spec.js b/spec/frontend/protected_branches/protected_branch_edit_spec.js index 959ca6ecde2..d842e00d850 100644 --- a/spec/frontend/protected_branches/protected_branch_edit_spec.js +++ b/spec/frontend/protected_branches/protected_branch_edit_spec.js @@ -1,5 +1,6 @@ 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 axios from '~/lib/utils/axios_utils'; @@ -33,7 +34,7 @@ describe('ProtectedBranchEdit', () => { codeOwnerToggleChecked = false, hasLicense = true, } = {}) => { - setFixtures(`<div id="wrap" data-url="${TEST_URL}"> + setHTMLFixture(`<div id="wrap" data-url="${TEST_URL}"> <span class="js-force-push-toggle" data-label="Toggle allowed to force push" @@ -51,6 +52,7 @@ describe('ProtectedBranchEdit', () => { afterEach(() => { mock.restore(); + resetHTMLFixture(); }); describe('when license supports code owner approvals', () => { @@ -76,7 +78,11 @@ describe('ProtectedBranchEdit', () => { describe('when toggles are not available in the DOM on page load', () => { beforeEach(() => { create({ hasLicense: true }); - setFixtures(''); + setHTMLFixture(''); + }); + + afterEach(() => { + resetHTMLFixture(); }); it('does not instantiate the force push toggle', () => { diff --git a/spec/frontend/read_more_spec.js b/spec/frontend/read_more_spec.js index 16f0d7fb075..80d7c941660 100644 --- a/spec/frontend/read_more_spec.js +++ b/spec/frontend/read_more_spec.js @@ -1,10 +1,15 @@ +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import initReadMore from '~/read_more'; describe('Read more click-to-expand functionality', () => { const fixtureName = 'projects/overview.html'; beforeEach(() => { - loadFixtures(fixtureName); + loadHTMLFixture(fixtureName); + }); + + afterEach(() => { + resetHTMLFixture(); }); describe('expands target element', () => { diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js index 0a0a683b56d..80be27c92ff 100644 --- a/spec/frontend/releases/components/app_edit_new_spec.js +++ b/spec/frontend/releases/components/app_edit_new_spec.js @@ -4,6 +4,7 @@ import MockAdapter from 'axios-mock-adapter'; import { merge } from 'lodash'; import Vuex from 'vuex'; import { nextTick } from 'vue'; +import { GlFormCheckbox } from '@gitlab/ui'; import originalRelease from 'test_fixtures/api/releases/release.json'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; @@ -11,6 +12,7 @@ import * as commonUtils from '~/lib/utils/common_utils'; import ReleaseEditNewApp from '~/releases/components/app_edit_new.vue'; import AssetLinksForm from '~/releases/components/asset_links_form.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; const originalMilestones = originalRelease.milestones; const releasesPagePath = 'path/to/releases/page'; @@ -47,6 +49,7 @@ describe('Release edit/new component', () => { links: [], }, }), + formattedReleaseNotes: () => 'these notes are formatted', }; const store = new Vuex.Store( @@ -129,6 +132,11 @@ describe('Release edit/new component', () => { expect(wrapper.find('#release-notes').element.value).toBe(release.description); }); + it('sets the preview text to be the formatted release notes', () => { + const notes = getters.formattedReleaseNotes(); + expect(wrapper.findComponent(MarkdownField).props('textareaValue')).toBe(notes); + }); + it('renders the "Save changes" button as type="submit"', () => { expect(findSubmitButton().attributes('type')).toBe('submit'); }); @@ -195,6 +203,10 @@ describe('Release edit/new component', () => { it('renders the submit button with the text "Create release"', () => { expect(findSubmitButton().text()).toBe('Create release'); }); + + it('renders a checkbox to include release notes', () => { + expect(wrapper.find(GlFormCheckbox).exists()).toBe(true); + }); }); describe('when editing an existing release', () => { diff --git a/spec/frontend/releases/components/tag_field_new_spec.js b/spec/frontend/releases/components/tag_field_new_spec.js index c13b513f87e..9f500c318ea 100644 --- a/spec/frontend/releases/components/tag_field_new_spec.js +++ b/spec/frontend/releases/components/tag_field_new_spec.js @@ -1,5 +1,7 @@ import { GlDropdownItem } 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 { __ } from '~/locale'; import TagFieldNew from '~/releases/components/tag_field_new.vue'; @@ -14,6 +16,7 @@ const NONEXISTENT_TAG_NAME = 'nonexistent-tag'; describe('releases/components/tag_field_new', () => { let store; let wrapper; + let mock; let RefSelectorStub; const createComponent = ( @@ -65,11 +68,14 @@ describe('releases/components/tag_field_new', () => { links: [], }, }; + + mock = new MockAdapter(axios); + gon.api_version = 'v4'; }); afterEach(() => { wrapper.destroy(); - wrapper = null; + mock.restore(); }); const findTagNameFormGroup = () => wrapper.find('[data-testid="tag-name-field"]'); @@ -114,9 +120,14 @@ describe('releases/components/tag_field_new', () => { expect(store.state.editNew.release.tagName).toBe(updatedTagName); }); - it('shows the "Create from" field', () => { + it('hides the "Create from" field', () => { expect(findCreateFromFormGroup().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 })); + }); }); }); @@ -177,6 +188,18 @@ describe('releases/components/tag_field_new', () => { await expectValidationMessageToBe('hidden'); }); + + it('displays a validation error if the tag has an associated release', async () => { + findTagNameDropdown().vm.$emit('input', 'vTest'); + findTagNameDropdown().vm.$emit('hide'); + + store.state.editNew.existingRelease = {}; + + await expectValidationMessageToBe('shown'); + expect(findTagNameFormGroup().text()).toContain( + __('Selected tag is already in use. Choose another option.'), + ); + }); }); describe('when the user has interacted with the component and the value is empty', () => { @@ -185,6 +208,7 @@ describe('releases/components/tag_field_new', () => { findTagNameDropdown().vm.$emit('hide'); await expectValidationMessageToBe('shown'); + expect(findTagNameFormGroup().text()).toContain(__('Tag name is required.')); }); }); }); diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js index d8329fb82b1..41653f62ebf 100644 --- a/spec/frontend/releases/stores/modules/detail/actions_spec.js +++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js @@ -1,8 +1,10 @@ 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 { redirectTo } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; import { ASSET_LINK_TYPE } from '~/releases/constants'; import createReleaseAssetLinkMutation from '~/releases/graphql/mutations/create_release_link.mutation.graphql'; import deleteReleaseAssetLinkMutation from '~/releases/graphql/mutations/delete_release_link.mutation.graphql'; @@ -12,6 +14,8 @@ import * as types from '~/releases/stores/modules/edit_new/mutation_types'; import createState from '~/releases/stores/modules/edit_new/state'; import { gqClient, convertOneReleaseGraphQLResponse } from '~/releases/util'; +jest.mock('~/api/tags_api'); + jest.mock('~/flash'); jest.mock('~/lib/utils/url_utility', () => ({ @@ -567,4 +571,46 @@ describe('Release edit/new actions', () => { }); }); }); + + describe('fetchTagNotes', () => { + const tagName = 'v8.0.0'; + + it('saves the tag notes on succes', async () => { + const tag = { message: 'this is a tag' }; + getTag.mockResolvedValue({ data: tag }); + + await testAction( + actions.fetchTagNotes, + tagName, + state, + [ + { type: types.REQUEST_TAG_NOTES }, + { type: types.RECEIVE_TAG_NOTES_SUCCESS, payload: tag }, + ], + [], + ); + + expect(getTag).toHaveBeenCalledWith(state.projectId, tagName); + }); + it('creates a flash on error', async () => { + error = new Error(); + getTag.mockRejectedValue(error); + + await testAction( + actions.fetchTagNotes, + tagName, + state, + [ + { type: types.REQUEST_TAG_NOTES }, + { type: types.RECEIVE_TAG_NOTES_ERROR, payload: error }, + ], + [], + ); + + expect(createFlash).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 c32969c131e..c42c6c00f56 100644 --- a/spec/frontend/releases/stores/modules/detail/getters_spec.js +++ b/spec/frontend/releases/stores/modules/detail/getters_spec.js @@ -1,3 +1,4 @@ +import { s__ } from '~/locale'; import * as getters from '~/releases/stores/modules/edit_new/getters'; describe('Release edit/new getters', () => { @@ -145,6 +146,8 @@ describe('Release edit/new getters', () => { ], }, }, + // tag has an existing release + existingRelease: {}, }; actualErrors = getters.validationErrors(state); @@ -158,6 +161,14 @@ describe('Release edit/new getters', () => { expect(actualErrors).toMatchObject(expectedErrors); }); + it('returns a validation error if the tag has an existing release', () => { + const expectedErrors = { + existingRelease: true, + }; + + expect(actualErrors).toMatchObject(expectedErrors); + }); + it('returns a validation error if links share a URL', () => { const expectedErrors = { assets: { @@ -369,4 +380,25 @@ describe('Release edit/new getters', () => { expect(actualVariables).toEqual(expectedVariables); }); }); + + describe('formattedReleaseNotes', () => { + it.each` + description | includeTagNotes | tagNotes | included + ${'release notes'} | ${true} | ${'tag notes'} | ${true} + ${'release notes'} | ${true} | ${''} | ${false} + ${'release notes'} | ${false} | ${'tag notes'} | ${false} + `( + 'should include tag notes=$included when includeTagNotes=$includeTagNotes and tagNotes=$tagNotes', + ({ description, includeTagNotes, tagNotes, included }) => { + const state = { release: { description }, includeTagNotes, tagNotes }; + + const text = `### ${s__('Releases|Tag message')}\n\n${tagNotes}\n`; + if (included) { + expect(getters.formattedReleaseNotes(state)).toContain(text); + } else { + expect(getters.formattedReleaseNotes(state)).not.toContain(text); + } + }, + ); + }); }); diff --git a/spec/frontend/releases/stores/modules/detail/mutations_spec.js b/spec/frontend/releases/stores/modules/detail/mutations_spec.js index 24dcedb3580..85844831e0b 100644 --- a/spec/frontend/releases/stores/modules/detail/mutations_spec.js +++ b/spec/frontend/releases/stores/modules/detail/mutations_spec.js @@ -237,4 +237,41 @@ describe('Release edit/new mutations', () => { expect(state.release.assets.links).not.toContainEqual(linkToRemove); }); }); + describe(`${types.REQUEST_TAG_NOTES}`, () => { + it('sets isFetchingTagNotes to true', () => { + state.isFetchingTagNotes = false; + mutations[types.REQUEST_TAG_NOTES](state); + expect(state.isFetchingTagNotes).toBe(true); + }); + }); + describe(`${types.RECEIVE_TAG_NOTES_SUCCESS}`, () => { + it('sets the tag notes in the state', () => { + state.isFetchingTagNotes = true; + const message = 'tag notes'; + + mutations[types.RECEIVE_TAG_NOTES_SUCCESS](state, { message, release }); + expect(state.tagNotes).toBe(message); + expect(state.isFetchingTagNotes).toBe(false); + expect(state.existingRelease).toBe(release); + }); + }); + describe(`${types.RECEIVE_TAG_NOTES_ERROR}`, () => { + it('sets tag notes to empty', () => { + const message = 'there was an error'; + state.isFetchingTagNotes = true; + state.tagNotes = 'tag notes'; + + mutations[types.RECEIVE_TAG_NOTES_ERROR](state, { message }); + expect(state.tagNotes).toBe(''); + expect(state.isFetchingTagNotes).toBe(false); + }); + }); + describe(`${types.UPDATE_INCLUDE_TAG_NOTES}`, () => { + it('sets whether or not to include the tag notes', () => { + state.includeTagNotes = false; + + mutations[types.UPDATE_INCLUDE_TAG_NOTES](state, true); + expect(state.includeTagNotes).toBe(true); + }); + }); }); diff --git a/spec/frontend/reports/codequality_report/store/getters_spec.js b/spec/frontend/reports/codequality_report/store/getters_spec.js index b5f6edf85eb..646903390ff 100644 --- a/spec/frontend/reports/codequality_report/store/getters_spec.js +++ b/spec/frontend/reports/codequality_report/store/getters_spec.js @@ -61,8 +61,8 @@ describe('Codequality reports store getters', () => { it.each` resolvedIssues | newIssues | expectedText ${0} | ${0} | ${'No changes to code quality'} - ${0} | ${1} | ${'Code quality degraded'} - ${2} | ${0} | ${'Code quality improved'} + ${0} | ${1} | ${'Code quality degraded due to 1 new issue'} + ${2} | ${0} | ${'Code quality improved due to 2 resolved issues'} ${1} | ${2} | ${'Code quality scanning detected 3 changes in merged results'} `( 'returns a summary containing $resolvedIssues resolved issues and $newIssues new issues', diff --git a/spec/frontend/reports/components/report_link_spec.js b/spec/frontend/reports/components/report_link_spec.js index fc21515ded6..2ed0617a598 100644 --- a/spec/frontend/reports/components/report_link_spec.js +++ b/spec/frontend/reports/components/report_link_spec.js @@ -1,69 +1,56 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import component from '~/reports/components/report_link.vue'; +import { shallowMount } from '@vue/test-utils'; +import ReportLink from '~/reports/components/report_link.vue'; -describe('report link', () => { - let vm; - - const Component = Vue.extend(component); +describe('app/assets/javascripts/reports/components/report_link.vue', () => { + let wrapper; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - describe('With url', () => { - it('renders link', () => { - vm = mountComponent(Component, { - issue: { - path: 'Gemfile.lock', - urlPath: '/Gemfile.lock', - }, - }); + const defaultProps = { + issue: {}, + }; + + const createComponent = (props = {}) => { + wrapper = shallowMount(ReportLink, { + propsData: { ...defaultProps, ...props }, + }); + }; + + describe('When an issue prop has a $urlPath property', () => { + it('render a link that will take the user to the $urlPath', () => { + createComponent({ issue: { path: 'Gemfile.lock', urlPath: '/Gemfile.lock' } }); - expect(vm.$el.textContent.trim()).toContain('in'); - expect(vm.$el.querySelector('a').getAttribute('href')).toEqual('/Gemfile.lock'); - expect(vm.$el.querySelector('a').textContent.trim()).toEqual('Gemfile.lock'); + expect(wrapper.text()).toContain('in'); + expect(wrapper.find('a').attributes('href')).toBe('/Gemfile.lock'); + expect(wrapper.find('a').text()).toContain('Gemfile.lock'); }); }); - describe('Without url', () => { + describe('When an issue prop has no $urlPath property', () => { it('does not render link', () => { - vm = mountComponent(Component, { - issue: { - path: 'Gemfile.lock', - }, - }); + createComponent({ issue: { path: 'Gemfile.lock' } }); - expect(vm.$el.querySelector('a')).toBeNull(); - expect(vm.$el.textContent.trim()).toContain('in'); - expect(vm.$el.textContent.trim()).toContain('Gemfile.lock'); + expect(wrapper.find('a').exists()).toBe(false); + expect(wrapper.text()).toContain('in'); + expect(wrapper.text()).toContain('Gemfile.lock'); }); }); - describe('with line', () => { - it('renders line number', () => { - vm = mountComponent(Component, { - issue: { - path: 'Gemfile.lock', - urlPath: 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00', - line: 22, - }, - }); + describe('When an issue prop has a $line property', () => { + it('render a line number', () => { + createComponent({ issue: { path: 'Gemfile.lock', urlPath: '/Gemfile.lock', line: 22 } }); - expect(vm.$el.querySelector('a').textContent.trim()).toContain('Gemfile.lock:22'); + expect(wrapper.find('a').text()).toContain('Gemfile.lock:22'); }); }); - describe('without line', () => { - it('does not render line number', () => { - vm = mountComponent(Component, { - issue: { - path: 'Gemfile.lock', - urlPath: 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00', - }, - }); + describe('When an issue prop does not have a $line property', () => { + it('does not render a line number', () => { + createComponent({ issue: { urlPath: '/Gemfile.lock' } }); - expect(vm.$el.querySelector('a').textContent.trim()).not.toContain(':22'); + expect(wrapper.find('a').text()).not.toContain(':22'); }); }); }); 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 fea937b905f..4732d68c8c6 100644 --- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap @@ -2,13 +2,13 @@ exports[`Repository last commit component renders commit widget 1`] = ` <div - class="well-segment commit gl-p-5 gl-w-full" + class="well-segment commit gl-p-5 gl-w-full gl-display-flex" > <user-avatar-link-stub - class="avatar-cell" + class="gl-my-2 gl-mr-4" imgalt="" - imgcssclasses="" - imgsize="40" + imgcssclasses="gl-mr-0!" + imgsize="32" imgsrc="https://test.com" linkhref="/test" tooltipplacement="top" @@ -55,7 +55,11 @@ exports[`Repository last commit component renders commit widget 1`] = ` </div> <div - class="commit-actions flex-row" + class="gl-flex-grow-1" + /> + + <div + class="commit-actions gl-display-flex gl-flex-align gl-align-items-center gl-flex-direction-row" > <!----> diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index 2f6de03b73d..2ab4afbffbe 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -26,6 +26,7 @@ import { isLoggedIn } from '~/lib/utils/common_utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import httpStatusCodes from '~/lib/utils/http_status'; import LineHighlighter from '~/blob/line_highlighter'; +import { LEGACY_FILE_TYPES } from '~/repository/constants'; import { simpleViewerMock, richViewerMock, @@ -195,6 +196,14 @@ describe('Blob content viewer component', () => { expect(mockAxios.history.get[0].url).toBe(legacyViewerUrl); }); + it.each(LEGACY_FILE_TYPES)( + 'loads the legacy viewer when a file type is identified as legacy', + async (type) => { + await createComponent({ blob: { ...simpleViewerMock, fileType: type, webPath: type } }); + expect(mockAxios.history.get[0].url).toBe(`${type}?format=json&viewer=simple`); + }, + ); + it('loads the LineHighlighter', async () => { mockAxios.onGet(legacyViewerUrl).replyOnce(httpStatusCodes.OK, 'test'); await createComponent({ blob: { ...simpleViewerMock, fileType, highlightJs } }); diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js index eef66045573..40b32904589 100644 --- a/spec/frontend/repository/components/breadcrumbs_spec.js +++ b/spec/frontend/repository/components/breadcrumbs_spec.js @@ -147,7 +147,7 @@ describe('Repository breadcrumbs component', () => { describe('renders the new directory modal', () => { beforeEach(() => { - factory('/', { canEditTree: true }); + factory('some_dir', { canEditTree: true, newDirPath: 'root/master' }); }); it('does not render the modal while loading', () => { expect(findNewDirectoryModal().exists()).toBe(false); @@ -161,6 +161,7 @@ describe('Repository breadcrumbs component', () => { await nextTick(); expect(findNewDirectoryModal().exists()).toBe(true); + expect(findNewDirectoryModal().props('path')).toBe('root/master/some_dir'); }); }); }); diff --git a/spec/frontend/right_sidebar_spec.js b/spec/frontend/right_sidebar_spec.js index d1f861669a0..5847842f5a6 100644 --- a/spec/frontend/right_sidebar_spec.js +++ b/spec/frontend/right_sidebar_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; -import '~/commons/bootstrap'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import axios from '~/lib/utils/axios_utils'; import Sidebar from '~/right_sidebar'; @@ -30,7 +30,7 @@ describe('RightSidebar', () => { let mock; beforeEach(() => { - loadFixtures(fixtureName); + loadHTMLFixture(fixtureName); mock = new MockAdapter(axios); new Sidebar(); // eslint-disable-line no-new $aside = $('.right-sidebar'); @@ -44,6 +44,8 @@ describe('RightSidebar', () => { afterEach(() => { mock.restore(); + + resetHTMLFixture(); }); it('should expand/collapse the sidebar when arrow is clicked', () => { diff --git a/spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js b/spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js index d121c6be218..8a34cb14d8b 100644 --- a/spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js +++ b/spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js @@ -7,17 +7,20 @@ import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerHeader from '~/runner/components/runner_header.vue'; -import runnerQuery from '~/runner/graphql/details/runner.query.graphql'; +import RunnerUpdateForm from '~/runner/components/runner_update_form.vue'; +import runnerFormQuery from '~/runner/graphql/edit/runner_form.query.graphql'; import AdminRunnerEditApp from '~//runner/admin_runner_edit/admin_runner_edit_app.vue'; import { captureException } from '~/runner/sentry_utils'; -import { runnerData } from '../mock_data'; +import { runnerFormData } from '../mock_data'; jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); -const mockRunnerGraphqlId = runnerData.data.runner.id; +const mockRunner = runnerFormData.data.runner; +const mockRunnerGraphqlId = mockRunner.id; const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; +const mockRunnerPath = `/admin/runners/${mockRunnerId}`; Vue.use(VueApollo); @@ -26,12 +29,14 @@ describe('AdminRunnerEditApp', () => { let mockRunnerQuery; const findRunnerHeader = () => wrapper.findComponent(RunnerHeader); + const findRunnerUpdateForm = () => wrapper.findComponent(RunnerUpdateForm); const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => { wrapper = mountFn(AdminRunnerEditApp, { - apolloProvider: createMockApollo([[runnerQuery, mockRunnerQuery]]), + apolloProvider: createMockApollo([[runnerFormQuery, mockRunnerQuery]]), propsData: { runnerId: mockRunnerId, + runnerPath: mockRunnerPath, ...props, }, }); @@ -40,7 +45,7 @@ describe('AdminRunnerEditApp', () => { }; beforeEach(() => { - mockRunnerQuery = jest.fn().mockResolvedValue(runnerData); + mockRunnerQuery = jest.fn().mockResolvedValue(runnerFormData); }); afterEach(() => { @@ -68,6 +73,26 @@ describe('AdminRunnerEditApp', () => { expect(findRunnerHeader().text()).toContain(`shared`); }); + it('displays a loading runner form', () => { + createComponentWithApollo(); + + expect(findRunnerUpdateForm().props()).toMatchObject({ + runner: null, + loading: true, + runnerPath: mockRunnerPath, + }); + }); + + it('displays the runner form', async () => { + await createComponentWithApollo(); + + expect(findRunnerUpdateForm().props()).toMatchObject({ + runner: mockRunner, + loading: false, + runnerPath: mockRunnerPath, + }); + }); + describe('When there is an error', () => { beforeEach(async () => { mockRunnerQuery = jest.fn().mockRejectedValueOnce(new Error('Error!')); 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 f994ff24c21..07259ec3538 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 @@ -3,24 +3,30 @@ import { mount, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { createAlert } from '~/flash'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; +import { redirectTo } from '~/lib/utils/url_utility'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerHeader from '~/runner/components/runner_header.vue'; import RunnerPauseButton from '~/runner/components/runner_pause_button.vue'; +import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue'; import RunnerEditButton from '~/runner/components/runner_edit_button.vue'; -import runnerQuery from '~/runner/graphql/details/runner.query.graphql'; +import runnerQuery from '~/runner/graphql/show/runner.query.graphql'; import AdminRunnerShowApp from '~/runner/admin_runner_show/admin_runner_show_app.vue'; import { captureException } from '~/runner/sentry_utils'; +import { saveAlertToLocalStorage } from '~/runner/local_storage_alert/save_alert_to_local_storage'; import { runnerData } from '../mock_data'; +jest.mock('~/runner/local_storage_alert/save_alert_to_local_storage'); jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); +jest.mock('~/lib/utils/url_utility'); const mockRunner = runnerData.data.runner; const mockRunnerGraphqlId = mockRunner.id; const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; +const mockRunnersPath = '/admin/runners'; Vue.use(VueApollo); @@ -29,6 +35,7 @@ describe('AdminRunnerShowApp', () => { let mockRunnerQuery; const findRunnerHeader = () => wrapper.findComponent(RunnerHeader); + const findRunnerDeleteButton = () => wrapper.findComponent(RunnerDeleteButton); const findRunnerEditButton = () => wrapper.findComponent(RunnerEditButton); const findRunnerPauseButton = () => wrapper.findComponent(RunnerPauseButton); @@ -45,6 +52,7 @@ describe('AdminRunnerShowApp', () => { apolloProvider: createMockApollo([[runnerQuery, mockRunnerQuery]]), propsData: { runnerId: mockRunnerId, + runnersPath: mockRunnersPath, ...props, }, }); @@ -75,6 +83,7 @@ describe('AdminRunnerShowApp', () => { it('displays the runner edit and pause buttons', async () => { expect(findRunnerEditButton().exists()).toBe(true); expect(findRunnerPauseButton().exists()).toBe(true); + expect(findRunnerDeleteButton().exists()).toBe(true); }); it('shows basic runner details', async () => { @@ -82,6 +91,9 @@ describe('AdminRunnerShowApp', () => { Last contact Never contacted Version 1.0.0 IP Address 127.0.0.1 + Executor None + Architecture None + Platform darwin Configuration Runs untagged jobs Maximum job timeout None Tags None`.replace(/\s+/g, ' '); @@ -108,6 +120,42 @@ describe('AdminRunnerShowApp', () => { }); }); + describe('when runner cannot be deleted', () => { + beforeEach(async () => { + mockRunnerQueryResult({ + userPermissions: { + deleteRunner: false, + }, + }); + + await createComponent({ + mountFn: mount, + }); + }); + + it('does not display the runner edit and pause buttons', () => { + expect(findRunnerDeleteButton().exists()).toBe(false); + }); + }); + + describe('when runner is deleted', () => { + beforeEach(async () => { + await createComponent({ + mountFn: mount, + }); + }); + + it('redirects to the runner list page', () => { + findRunnerDeleteButton().vm.$emit('deleted', { message: 'Runner deleted' }); + + expect(saveAlertToLocalStorage).toHaveBeenCalledWith({ + message: 'Runner deleted', + variant: VARIANT_SUCCESS, + }); + expect(redirectTo).toHaveBeenCalledWith(mockRunnersPath); + }); + }); + describe('when runner does not have an edit url ', () => { beforeEach(async () => { mockRunnerQueryResult({ 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 2ef856c90ab..405813be4e3 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -35,6 +35,8 @@ import { PARAM_KEY_STATUS, PARAM_KEY_TAG, STATUS_ONLINE, + STATUS_OFFLINE, + STATUS_STALE, RUNNER_PAGE_SIZE, } from '~/runner/constants'; import adminRunnersQuery from '~/runner/graphql/list/admin_runners.query.graphql'; @@ -52,6 +54,7 @@ import { const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; const mockRunners = runnersData.data.runners.nodes; +const mockRunnersCount = runnersCountData.data.runners.count; jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); @@ -124,18 +127,6 @@ describe('AdminRunnersApp', () => { wrapper.destroy(); }); - it('shows total runner counts', async () => { - createComponent({ mountFn: mountExtended }); - - await waitForPromises(); - - const stats = findRunnerStats().text(); - - expect(stats).toMatch('Online runners 4'); - expect(stats).toMatch('Offline runners 4'); - expect(stats).toMatch('Stale runners 4'); - }); - it('shows the runner tabs with a runner count for each type', async () => { mockRunnersCountQuery.mockImplementation(({ type }) => { let count; @@ -197,6 +188,24 @@ describe('AdminRunnersApp', () => { expect(findRegistrationDropdown().props('type')).toBe(INSTANCE_TYPE); }); + it('shows total runner counts', async () => { + expect(mockRunnersCountQuery).toHaveBeenCalledWith({ + status: STATUS_ONLINE, + }); + expect(mockRunnersCountQuery).toHaveBeenCalledWith({ + status: STATUS_OFFLINE, + }); + expect(mockRunnersCountQuery).toHaveBeenCalledWith({ + status: STATUS_STALE, + }); + + expect(findRunnerStats().props()).toMatchObject({ + onlineRunnersCount: mockRunnersCount, + offlineRunnersCount: mockRunnersCount, + staleRunnersCount: mockRunnersCount, + }); + }); + it('shows the runners list', () => { expect(findRunnerList().props('runners')).toEqual(mockRunners); }); @@ -329,13 +338,30 @@ describe('AdminRunnersApp', () => { first: RUNNER_PAGE_SIZE, }); }); + + it('fetches count results for requested status', () => { + expect(mockRunnersCountQuery).toHaveBeenCalledWith({ + type: INSTANCE_TYPE, + status: STATUS_ONLINE, + tagList: ['tag1'], + }); + + expect(findRunnerStats().props()).toMatchObject({ + onlineRunnersCount: mockRunnersCount, + }); + }); }); describe('when a filter is selected by the user', () => { beforeEach(() => { + mockRunnersCountQuery.mockClear(); + findRunnerFilteredSearchBar().vm.$emit('input', { runnerType: null, - filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], + filters: [ + { type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }, + { type: PARAM_KEY_TAG, value: { data: 'tag1', operator: '=' } }, + ], sort: CREATED_ASC, }); }); @@ -343,17 +369,45 @@ describe('AdminRunnersApp', () => { it('updates the browser url', () => { expect(updateHistory).toHaveBeenLastCalledWith({ title: expect.any(String), - url: 'http://test.host/admin/runners?status[]=ONLINE&sort=CREATED_ASC', + url: 'http://test.host/admin/runners?status[]=ONLINE&tag[]=tag1&sort=CREATED_ASC', }); }); it('requests the runners with filters', () => { expect(mockRunnersQuery).toHaveBeenLastCalledWith({ status: STATUS_ONLINE, + tagList: ['tag1'], sort: CREATED_ASC, first: RUNNER_PAGE_SIZE, }); }); + + it('fetches count results for requested status', () => { + expect(mockRunnersCountQuery).toHaveBeenCalledWith({ + tagList: ['tag1'], + status: STATUS_ONLINE, + }); + + expect(findRunnerStats().props()).toMatchObject({ + onlineRunnersCount: mockRunnersCount, + }); + }); + + it('skips fetching count results for status that were not in filter', () => { + expect(mockRunnersCountQuery).not.toHaveBeenCalledWith({ + tagList: ['tag1'], + status: STATUS_OFFLINE, + }); + expect(mockRunnersCountQuery).not.toHaveBeenCalledWith({ + tagList: ['tag1'], + status: STATUS_STALE, + }); + + expect(findRunnerStats().props()).toMatchObject({ + offlineRunnersCount: null, + staleRunnersCount: null, + }); + }); }); it('when runners have not loaded, shows a loading state', () => { diff --git a/spec/frontend/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/runner/components/registration/registration_dropdown_spec.js index 5cd93df9967..81c2788f084 100644 --- a/spec/frontend/runner/components/registration/registration_dropdown_spec.js +++ b/spec/frontend/runner/components/registration/registration_dropdown_spec.js @@ -35,6 +35,16 @@ describe('RegistrationDropdown', () => { const findRegistrationTokenInput = () => wrapper.findByTestId('token-value').find('input'); const findTokenResetDropdownItem = () => wrapper.findComponent(RegistrationTokenResetDropdownItem); + const findModalContent = () => + createWrapper(document.body) + .find('[data-testid="runner-instructions-modal"]') + .text() + .replace(/[\n\t\s]+/g, ' '); + + const openModal = async () => { + await findRegistrationInstructionsDropdownItem().trigger('click'); + await waitForPromises(); + }; const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMount) => { wrapper = extendedWrapper( @@ -49,6 +59,25 @@ describe('RegistrationDropdown', () => { ); }; + const createComponentWithModal = () => { + Vue.use(VueApollo); + + const requestHandlers = [ + [getRunnerPlatformsQuery, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)], + [getRunnerSetupInstructionsQuery, jest.fn().mockResolvedValue(mockGraphqlInstructions)], + ]; + + createComponent( + { + // Mock load modal contents from API + apolloProvider: createMockApollo(requestHandlers), + // Use `attachTo` to find the modal + attachTo: document.body, + }, + mount, + ); + }; + it.each` type | text ${INSTANCE_TYPE} | ${'Register an instance runner'} @@ -76,29 +105,10 @@ describe('RegistrationDropdown', () => { }); describe('When the dropdown item is clicked', () => { - Vue.use(VueApollo); - - const requestHandlers = [ - [getRunnerPlatformsQuery, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)], - [getRunnerSetupInstructionsQuery, jest.fn().mockResolvedValue(mockGraphqlInstructions)], - ]; - - const findModalInBody = () => - createWrapper(document.body).find('[data-testid="runner-instructions-modal"]'); - beforeEach(async () => { - createComponent( - { - // Mock load modal contents from API - apolloProvider: createMockApollo(requestHandlers), - // Use `attachTo` to find the modal - attachTo: document.body, - }, - mount, - ); - - await findRegistrationInstructionsDropdownItem().trigger('click'); - await waitForPromises(); + createComponentWithModal({}, mount); + + await openModal(); }); afterEach(() => { @@ -106,9 +116,7 @@ describe('RegistrationDropdown', () => { }); it('opens the modal with contents', () => { - const modalText = findModalInBody() - .text() - .replace(/[\n\t\s]+/g, ' '); + const modalText = findModalContent(); expect(modalText).toContain('Install a runner'); @@ -153,15 +161,34 @@ describe('RegistrationDropdown', () => { }); }); - it('Updates the token when it gets reset', async () => { + describe('When token is reset', () => { const newToken = 'mock1'; - createComponent({}, mount); - expect(findRegistrationTokenInput().props('value')).not.toBe(newToken); + const resetToken = async () => { + findTokenResetDropdownItem().vm.$emit('tokenReset', newToken); + await nextTick(); + }; + + it('Updates token in input', async () => { + createComponent({}, mount); + + expect(findRegistrationTokenInput().props('value')).not.toBe(newToken); + + await resetToken(); + + expect(findRegistrationToken().props('value')).toBe(newToken); + }); - findTokenResetDropdownItem().vm.$emit('tokenReset', newToken); - await nextTick(); + it('Updates token in modal', async () => { + createComponentWithModal({}, mount); - expect(findRegistrationToken().props('value')).toBe(newToken); + await openModal(); + + expect(findModalContent()).toContain(mockToken); + + await resetToken(); + + expect(findModalContent()).toContain(newToken); + }); }); }); diff --git a/spec/frontend/runner/components/runner_delete_button_spec.js b/spec/frontend/runner/components/runner_delete_button_spec.js index 3eb257607b4..b11c749d0a7 100644 --- a/spec/frontend/runner/components/runner_delete_button_spec.js +++ b/spec/frontend/runner/components/runner_delete_button_spec.js @@ -118,6 +118,12 @@ describe('RunnerDeleteButton', () => { expect(findBtn().attributes('aria-label')).toBe(undefined); }); + it('Passes other attributes to the button', () => { + createComponent({ props: { category: 'secondary' } }); + + expect(findBtn().props('category')).toBe('secondary'); + }); + describe(`Before the delete button is clicked`, () => { it('The mutation has not been called', () => { expect(runnerDeleteHandler).toHaveBeenCalledTimes(0); diff --git a/spec/frontend/runner/components/runner_details_spec.js b/spec/frontend/runner/components/runner_details_spec.js index 6bf4a52a799..162d21febfd 100644 --- a/spec/frontend/runner/components/runner_details_spec.js +++ b/spec/frontend/runner/components/runner_details_spec.js @@ -77,6 +77,9 @@ describe('RunnerDetails', () => { ${'Last contact'} | ${{ contactedAt: null }} | ${'Never contacted'} ${'Version'} | ${{ version: '12.3' }} | ${'12.3'} ${'Version'} | ${{ version: null }} | ${'None'} + ${'Executor'} | ${{ executorName: 'shell' }} | ${'shell'} + ${'Architecture'} | ${{ architectureName: 'amd64' }} | ${'amd64'} + ${'Platform'} | ${{ platformName: 'darwin' }} | ${'darwin'} ${'IP Address'} | ${{ ipAddress: '127.0.0.1' }} | ${'127.0.0.1'} ${'IP Address'} | ${{ ipAddress: null }} | ${'None'} ${'Configuration'} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED, runUntagged: true }} | ${'Protected, Runs untagged jobs'} diff --git a/spec/frontend/runner/components/runner_jobs_spec.js b/spec/frontend/runner/components/runner_jobs_spec.js index 9e40e911448..8ac5685a0dd 100644 --- a/spec/frontend/runner/components/runner_jobs_spec.js +++ b/spec/frontend/runner/components/runner_jobs_spec.js @@ -11,7 +11,7 @@ import RunnerPagination from '~/runner/components/runner_pagination.vue'; import { captureException } from '~/runner/sentry_utils'; import { I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '~/runner/constants'; -import runnerJobsQuery from '~/runner/graphql/details/runner_jobs.query.graphql'; +import runnerJobsQuery from '~/runner/graphql/show/runner_jobs.query.graphql'; import { runnerData, runnerJobsData } from '../mock_data'; diff --git a/spec/frontend/runner/components/runner_projects_spec.js b/spec/frontend/runner/components/runner_projects_spec.js index 62ebc6539e2..04627e2307b 100644 --- a/spec/frontend/runner/components/runner_projects_spec.js +++ b/spec/frontend/runner/components/runner_projects_spec.js @@ -16,7 +16,7 @@ import RunnerAssignedItem from '~/runner/components/runner_assigned_item.vue'; import RunnerPagination from '~/runner/components/runner_pagination.vue'; import { captureException } from '~/runner/sentry_utils'; -import runnerProjectsQuery from '~/runner/graphql/details/runner_projects.query.graphql'; +import runnerProjectsQuery from '~/runner/graphql/show/runner_projects.query.graphql'; import { runnerData, runnerProjectsData } from '../mock_data'; diff --git a/spec/frontend/runner/components/runner_update_form_spec.js b/spec/frontend/runner/components/runner_update_form_spec.js index b071791e39f..3037364d941 100644 --- a/spec/frontend/runner/components/runner_update_form_spec.js +++ b/spec/frontend/runner/components/runner_update_form_spec.js @@ -1,10 +1,11 @@ import Vue, { nextTick } from 'vue'; -import { GlForm } from '@gitlab/ui'; +import { GlForm, GlSkeletonLoader } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; +import { redirectTo } from '~/lib/utils/url_utility'; import RunnerUpdateForm from '~/runner/components/runner_update_form.vue'; import { INSTANCE_TYPE, @@ -13,14 +14,18 @@ import { ACCESS_LEVEL_REF_PROTECTED, ACCESS_LEVEL_NOT_PROTECTED, } from '~/runner/constants'; -import runnerUpdateMutation from '~/runner/graphql/details/runner_update.mutation.graphql'; +import runnerUpdateMutation from '~/runner/graphql/edit/runner_update.mutation.graphql'; import { captureException } from '~/runner/sentry_utils'; -import { runnerData } from '../mock_data'; +import { saveAlertToLocalStorage } from '~/runner/local_storage_alert/save_alert_to_local_storage'; +import { runnerFormData } from '../mock_data'; +jest.mock('~/runner/local_storage_alert/save_alert_to_local_storage'); jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); +jest.mock('~/lib/utils/url_utility'); -const mockRunner = runnerData.data.runner; +const mockRunner = runnerFormData.data.runner; +const mockRunnerPath = '/admin/runners/1'; Vue.use(VueApollo); @@ -33,8 +38,7 @@ describe('RunnerUpdateForm', () => { const findProtectedCheckbox = () => wrapper.findByTestId('runner-field-protected'); const findRunUntaggedCheckbox = () => wrapper.findByTestId('runner-field-run-untagged'); const findLockedCheckbox = () => wrapper.findByTestId('runner-field-locked'); - - const findIpInput = () => wrapper.findByTestId('runner-field-ip-address').find('input'); + const findFields = () => wrapper.findAll('[data-testid^="runner-field"'); const findDescriptionInput = () => wrapper.findByTestId('runner-field-description').find('input'); const findMaxJobTimeoutInput = () => @@ -53,7 +57,6 @@ describe('RunnerUpdateForm', () => { : ACCESS_LEVEL_NOT_PROTECTED, runUntagged: findRunUntaggedCheckbox().element.checked, locked: findLockedCheckbox().element?.checked || false, - ipAddress: findIpInput().element.value, maximumTimeout: findMaxJobTimeoutInput().element.value || null, tagList: findTagsInput().element.value.split(',').filter(Boolean), }); @@ -62,6 +65,7 @@ describe('RunnerUpdateForm', () => { wrapper = mountExtended(RunnerUpdateForm, { propsData: { runner: mockRunner, + runnerPath: mockRunnerPath, ...props, }, apolloProvider: createMockApollo([[runnerUpdateMutation, runnerUpdateHandler]]), @@ -74,12 +78,13 @@ describe('RunnerUpdateForm', () => { input: expect.objectContaining(submittedRunner), }); - expect(createAlert).toHaveBeenLastCalledWith({ - message: expect.stringContaining('saved'), - variant: VARIANT_SUCCESS, - }); - - expect(findSubmitDisabledAttr()).toBeUndefined(); + expect(saveAlertToLocalStorage).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.any(String), + variant: VARIANT_SUCCESS, + }), + ); + expect(redirectTo).toHaveBeenCalledWith(mockRunnerPath); }; beforeEach(() => { @@ -122,27 +127,19 @@ describe('RunnerUpdateForm', () => { await submitFormAndWait(); // Some read-only fields are not submitted - const { - __typename, - ipAddress, - runnerType, - createdAt, - status, - editAdminUrl, - contactedAt, - userPermissions, - version, - groups, - jobCount, - ...submitted - } = mockRunner; + const { __typename, shortSha, runnerType, createdAt, status, ...submitted } = mockRunner; expectToHaveSubmittedRunnerContaining(submitted); }); describe('When data is being loaded', () => { beforeEach(() => { - createComponent({ props: { runner: null } }); + createComponent({ props: { loading: true } }); + }); + + it('Form skeleton is shown', () => { + expect(wrapper.find(GlSkeletonLoader).exists()).toBe(true); + expect(findFields()).toHaveLength(0); }); it('Form cannot be submitted', () => { @@ -151,11 +148,12 @@ describe('RunnerUpdateForm', () => { it('Form is updated when data loads', async () => { wrapper.setProps({ - runner: mockRunner, + loading: false, }); await nextTick(); + expect(findFields()).not.toHaveLength(0); expect(mockRunner).toMatchObject(getFieldsModel()); }); }); @@ -273,8 +271,11 @@ describe('RunnerUpdateForm', () => { expect(createAlert).toHaveBeenLastCalledWith({ message: mockErrorMsg, }); - expect(captureException).not.toHaveBeenCalled(); expect(findSubmitDisabledAttr()).toBeUndefined(); + + expect(captureException).not.toHaveBeenCalled(); + expect(saveAlertToLocalStorage).not.toHaveBeenCalled(); + expect(redirectTo).not.toHaveBeenCalled(); }); }); }); 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 02348bf737a..52bd51a974b 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -30,7 +30,10 @@ import { PROJECT_TYPE, PARAM_KEY_PAUSED, PARAM_KEY_STATUS, + PARAM_KEY_TAG, STATUS_ONLINE, + STATUS_OFFLINE, + STATUS_STALE, RUNNER_PAGE_SIZE, I18N_EDIT, } from '~/runner/constants'; @@ -53,7 +56,7 @@ Vue.use(GlToast); const mockGroupFullPath = 'group1'; const mockRegistrationToken = 'AABBCC'; const mockGroupRunnersEdges = groupRunnersData.data.group.runners.edges; -const mockGroupRunnersLimitedCount = mockGroupRunnersEdges.length; +const mockGroupRunnersCount = mockGroupRunnersEdges.length; jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); @@ -94,7 +97,7 @@ describe('GroupRunnersApp', () => { propsData: { registrationToken: mockRegistrationToken, groupFullPath: mockGroupFullPath, - groupRunnersLimitedCount: mockGroupRunnersLimitedCount, + groupRunnersLimitedCount: mockGroupRunnersCount, ...props, }, provide: { @@ -115,15 +118,24 @@ describe('GroupRunnersApp', () => { }); it('shows total runner counts', async () => { - createComponent({ mountFn: mountExtended }); - - await waitForPromises(); - - const stats = findRunnerStats().text(); + expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({ + groupFullPath: mockGroupFullPath, + status: STATUS_ONLINE, + }); + expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({ + groupFullPath: mockGroupFullPath, + status: STATUS_OFFLINE, + }); + expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({ + groupFullPath: mockGroupFullPath, + status: STATUS_STALE, + }); - expect(stats).toMatch('Online runners 2'); - expect(stats).toMatch('Offline runners 2'); - expect(stats).toMatch('Stale runners 2'); + expect(findRunnerStats().props()).toMatchObject({ + onlineRunnersCount: mockGroupRunnersCount, + offlineRunnersCount: mockGroupRunnersCount, + staleRunnersCount: mockGroupRunnersCount, + }); }); it('shows the runner tabs with a runner count for each type', async () => { @@ -281,13 +293,28 @@ describe('GroupRunnersApp', () => { first: RUNNER_PAGE_SIZE, }); }); + + it('fetches count results for requested status', () => { + expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({ + groupFullPath: mockGroupFullPath, + type: INSTANCE_TYPE, + status: STATUS_ONLINE, + }); + + expect(findRunnerStats().props()).toMatchObject({ + onlineRunnersCount: mockGroupRunnersCount, + }); + }); }); describe('when a filter is selected by the user', () => { beforeEach(async () => { findRunnerFilteredSearchBar().vm.$emit('input', { runnerType: null, - filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], + filters: [ + { type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }, + { type: PARAM_KEY_TAG, value: { data: 'tag1', operator: '=' } }, + ], sort: CREATED_ASC, }); @@ -297,7 +324,7 @@ describe('GroupRunnersApp', () => { it('updates the browser url', () => { expect(updateHistory).toHaveBeenLastCalledWith({ title: expect.any(String), - url: 'http://test.host/groups/group1/-/runners?status[]=ONLINE&sort=CREATED_ASC', + url: 'http://test.host/groups/group1/-/runners?status[]=ONLINE&tag[]=tag1&sort=CREATED_ASC', }); }); @@ -305,10 +332,41 @@ describe('GroupRunnersApp', () => { expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ groupFullPath: mockGroupFullPath, status: STATUS_ONLINE, + tagList: ['tag1'], sort: CREATED_ASC, first: RUNNER_PAGE_SIZE, }); }); + + it('fetches count results for requested status', () => { + expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({ + groupFullPath: mockGroupFullPath, + tagList: ['tag1'], + status: STATUS_ONLINE, + }); + + expect(findRunnerStats().props()).toMatchObject({ + onlineRunnersCount: mockGroupRunnersCount, + }); + }); + + it('skips fetching count results for status that were not in filter', () => { + expect(mockGroupRunnersCountQuery).not.toHaveBeenCalledWith({ + groupFullPath: mockGroupFullPath, + tagList: ['tag1'], + status: STATUS_OFFLINE, + }); + expect(mockGroupRunnersCountQuery).not.toHaveBeenCalledWith({ + groupFullPath: mockGroupFullPath, + tagList: ['tag1'], + status: STATUS_STALE, + }); + + expect(findRunnerStats().props()).toMatchObject({ + offlineRunnersCount: null, + staleRunnersCount: null, + }); + }); }); it('when runners have not loaded, shows a loading state', () => { diff --git a/spec/frontend/runner/local_storage_alert/save_alert_to_local_storage_spec.js b/spec/frontend/runner/local_storage_alert/save_alert_to_local_storage_spec.js new file mode 100644 index 00000000000..69cda6d6022 --- /dev/null +++ b/spec/frontend/runner/local_storage_alert/save_alert_to_local_storage_spec.js @@ -0,0 +1,24 @@ +import AccessorUtilities from '~/lib/utils/accessor'; +import { saveAlertToLocalStorage } from '~/runner/local_storage_alert/save_alert_to_local_storage'; +import { LOCAL_STORAGE_ALERT_KEY } from '~/runner/local_storage_alert/constants'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; + +const mockAlert = { message: 'Message!' }; + +describe('saveAlertToLocalStorage', () => { + useLocalStorageSpy(); + + beforeEach(() => { + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true); + }); + + it('saves message to local storage', () => { + saveAlertToLocalStorage(mockAlert); + + expect(localStorage.setItem).toHaveBeenCalledTimes(1); + expect(localStorage.setItem).toHaveBeenCalledWith( + LOCAL_STORAGE_ALERT_KEY, + JSON.stringify(mockAlert), + ); + }); +}); diff --git a/spec/frontend/runner/local_storage_alert/show_alert_from_local_storage_spec.js b/spec/frontend/runner/local_storage_alert/show_alert_from_local_storage_spec.js new file mode 100644 index 00000000000..cabbe642dac --- /dev/null +++ b/spec/frontend/runner/local_storage_alert/show_alert_from_local_storage_spec.js @@ -0,0 +1,40 @@ +import AccessorUtilities from '~/lib/utils/accessor'; +import { showAlertFromLocalStorage } from '~/runner/local_storage_alert/show_alert_from_local_storage'; +import { LOCAL_STORAGE_ALERT_KEY } from '~/runner/local_storage_alert/constants'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { createAlert } from '~/flash'; + +jest.mock('~/flash'); + +describe('showAlertFromLocalStorage', () => { + useLocalStorageSpy(); + + beforeEach(() => { + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true); + }); + + it('retrieves message from local storage and displays it', async () => { + const mockAlert = { message: 'Message!' }; + + localStorage.getItem.mockReturnValueOnce(JSON.stringify(mockAlert)); + + await showAlertFromLocalStorage(); + + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith(mockAlert); + + expect(localStorage.removeItem).toHaveBeenCalledTimes(1); + expect(localStorage.removeItem).toHaveBeenCalledWith(LOCAL_STORAGE_ALERT_KEY); + }); + + it.each(['not a json string', null])('does not fail when stored message is %o', async (item) => { + localStorage.getItem.mockReturnValueOnce(item); + + await showAlertFromLocalStorage(); + + expect(createAlert).not.toHaveBeenCalled(); + + expect(localStorage.removeItem).toHaveBeenCalledTimes(1); + expect(localStorage.removeItem).toHaveBeenCalledWith(LOCAL_STORAGE_ALERT_KEY); + }); +}); diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js index fbe8926124c..1c2333b552c 100644 --- a/spec/frontend/runner/mock_data.js +++ b/spec/frontend/runner/mock_data.js @@ -1,5 +1,14 @@ // Fixtures generated by: spec/frontend/fixtures/runner.rb +// Show runner queries +import runnerData from 'test_fixtures/graphql/runner/show/runner.query.graphql.json'; +import runnerWithGroupData from 'test_fixtures/graphql/runner/show/runner.query.graphql.with_group.json'; +import runnerProjectsData from 'test_fixtures/graphql/runner/show/runner_projects.query.graphql.json'; +import runnerJobsData from 'test_fixtures/graphql/runner/show/runner_jobs.query.graphql.json'; + +// Edit runner queries +import runnerFormData from 'test_fixtures/graphql/runner/edit/runner_form.query.graphql.json'; + // List queries import runnersData from 'test_fixtures/graphql/runner/list/admin_runners.query.graphql.json'; import runnersDataPaginated from 'test_fixtures/graphql/runner/list/admin_runners.query.graphql.paginated.json'; @@ -8,25 +17,20 @@ 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'; -// Details queries -import runnerData from 'test_fixtures/graphql/runner/details/runner.query.graphql.json'; -import runnerWithGroupData from 'test_fixtures/graphql/runner/details/runner.query.graphql.with_group.json'; -import runnerProjectsData from 'test_fixtures/graphql/runner/details/runner_projects.query.graphql.json'; -import runnerJobsData from 'test_fixtures/graphql/runner/details/runner_jobs.query.graphql.json'; - // Other mock data export const onlineContactTimeoutSecs = 2 * 60 * 60; export const staleTimeoutSecs = 5259492; // Ruby's `2.months` export { runnersData, - runnersCountData, runnersDataPaginated, + runnersCountData, + groupRunnersData, + groupRunnersDataPaginated, + groupRunnersCountData, runnerData, runnerWithGroupData, runnerProjectsData, runnerJobsData, - groupRunnersData, - groupRunnersCountData, - groupRunnersDataPaginated, + runnerFormData, }; diff --git a/spec/frontend/runner/runner_search_utils_spec.js b/spec/frontend/runner/runner_search_utils_spec.js index 7834e76fe48..a3c1458ed26 100644 --- a/spec/frontend/runner/runner_search_utils_spec.js +++ b/spec/frontend/runner/runner_search_utils_spec.js @@ -220,13 +220,11 @@ describe('search_params.js', () => { }); it.each` - query | updatedQuery - ${'status[]=NOT_CONNECTED'} | ${'status[]=NEVER_CONTACTED'} - ${'status[]=NOT_CONNECTED&a=b'} | ${'status[]=NEVER_CONTACTED&a=b'} - ${'status[]=ACTIVE'} | ${'paused[]=false'} - ${'status[]=ACTIVE&a=b'} | ${'a=b&paused[]=false'} - ${'status[]=ACTIVE'} | ${'paused[]=false'} - ${'status[]=PAUSED'} | ${'paused[]=true'} + query | updatedQuery + ${'status[]=ACTIVE'} | ${'paused[]=false'} + ${'status[]=ACTIVE&a=b'} | ${'a=b&paused[]=false'} + ${'status[]=ACTIVE'} | ${'paused[]=false'} + ${'status[]=PAUSED'} | ${'paused[]=true'} `('updates "$query" to "$updatedQuery"', ({ query, updatedQuery }) => { const mockUrl = 'http://test.host/admin/runners?'; diff --git a/spec/frontend/search/highlight_blob_search_result_spec.js b/spec/frontend/search/highlight_blob_search_result_spec.js index 9fa3bfc1f9a..15cff436076 100644 --- a/spec/frontend/search/highlight_blob_search_result_spec.js +++ b/spec/frontend/search/highlight_blob_search_result_spec.js @@ -1,10 +1,15 @@ import setHighlightClass from '~/search/highlight_blob_search_result'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; const fixture = 'search/blob_search_result.html'; const searchKeyword = 'Send'; // spec/frontend/fixtures/search.rb#79 describe('search/highlight_blob_search_result', () => { - beforeEach(() => loadFixtures(fixture)); + beforeEach(() => loadHTMLFixture(fixture)); + + afterEach(() => { + resetHTMLFixture(); + }); it('highlights lines with search term occurrence', () => { setHighlightClass(searchKeyword); diff --git a/spec/frontend/search_autocomplete_spec.js b/spec/frontend/search_autocomplete_spec.js index 190f2803324..4639552b4d3 100644 --- a/spec/frontend/search_autocomplete_spec.js +++ b/spec/frontend/search_autocomplete_spec.js @@ -1,5 +1,6 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import axios from '~/lib/utils/axios_utils'; import initSearchAutocomplete from '~/search_autocomplete'; @@ -104,7 +105,7 @@ describe('Search autocomplete dropdown', () => { }; beforeEach(() => { - loadFixtures('static/search_autocomplete.html'); + loadHTMLFixture('static/search_autocomplete.html'); window.gon = {}; window.gon.current_user_id = userId; @@ -118,6 +119,8 @@ describe('Search autocomplete dropdown', () => { // Undo what we did to the shared <body> removeBodyAttributes(); window.gon = {}; + + resetHTMLFixture(); }); it('should show Dashboard specific dropdown menu', () => { diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index 9a18cb636b2..d7d46d0d415 100644 --- a/spec/frontend/security_configuration/components/app_spec.js +++ b/spec/frontend/security_configuration/components/app_spec.js @@ -1,6 +1,8 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import { GlTab, GlTabs, GlLink } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; + import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; import stubChildren from 'helpers/stub_children'; @@ -18,15 +20,22 @@ import { LICENSE_COMPLIANCE_DESCRIPTION, LICENSE_COMPLIANCE_HELP_PATH, AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY, + LICENSE_ULTIMATE, + LICENSE_PREMIUM, + LICENSE_FREE, } from '~/security_configuration/components/constants'; import FeatureCard from '~/security_configuration/components/feature_card.vue'; import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import currentLicenseQuery from '~/security_configuration/graphql/current_license.query.graphql'; +import waitForPromises from 'helpers/wait_for_promises'; import UpgradeBanner from '~/security_configuration/components/upgrade_banner.vue'; import { REPORT_TYPE_LICENSE_COMPLIANCE, REPORT_TYPE_SAST, } from '~/vue_shared/security_reports/constants'; +import { getCurrentLicensePlanResponse } from '../mock_data'; const upgradePath = '/upgrade'; const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath'; @@ -36,18 +45,33 @@ const projectFullPath = 'namespace/project'; const vulnerabilityTrainingDocsPath = 'user/application_security/vulnerabilities/index'; useLocalStorageSpy(); +Vue.use(VueApollo); describe('App component', () => { let wrapper; let userCalloutDismissSpy; + let mockApollo; const createComponent = ({ shouldShowCallout = true, - secureVulnerabilityTraining = true, + licenseQueryResponse = LICENSE_ULTIMATE, ...propsData }) => { userCalloutDismissSpy = jest.fn(); + mockApollo = createMockApollo([ + [ + currentLicenseQuery, + jest + .fn() + .mockResolvedValue( + licenseQueryResponse instanceof Error + ? licenseQueryResponse + : getCurrentLicensePlanResponse(licenseQueryResponse), + ), + ], + ]); + wrapper = extendedWrapper( mount(SecurityConfigurationApp, { propsData, @@ -57,10 +81,8 @@ describe('App component', () => { autoDevopsPath, projectFullPath, vulnerabilityTrainingDocsPath, - glFeatures: { - secureVulnerabilityTraining, - }, }, + apolloProvider: mockApollo, stubs: { ...stubChildren(SecurityConfigurationApp), GlLink: false, @@ -135,14 +157,16 @@ describe('App component', () => { afterEach(() => { wrapper.destroy(); + mockApollo = null; }); describe('basic structure', () => { - beforeEach(() => { + beforeEach(async () => { createComponent({ augmentedSecurityFeatures: securityFeaturesMock, augmentedComplianceFeatures: complianceFeaturesMock, }); + await waitForPromises(); }); it('renders main-heading with correct text', () => { @@ -445,11 +469,12 @@ describe('App component', () => { }); describe('Vulnerability management', () => { - beforeEach(() => { + beforeEach(async () => { createComponent({ augmentedSecurityFeatures: securityFeaturesMock, augmentedComplianceFeatures: complianceFeaturesMock, }); + await waitForPromises(); }); it('renders TrainingProviderList component', () => { @@ -466,23 +491,25 @@ describe('App component', () => { expect(trainingLink.text()).toBe('Learn more about vulnerability training'); expect(trainingLink.attributes('href')).toBe(vulnerabilityTrainingDocsPath); }); - }); - - describe('when secureVulnerabilityTraining feature flag is disabled', () => { - beforeEach(() => { - createComponent({ - augmentedSecurityFeatures: securityFeaturesMock, - augmentedComplianceFeatures: complianceFeaturesMock, - secureVulnerabilityTraining: false, - }); - }); - it('renders correct amount of tabs', () => { - expect(findTabs()).toHaveLength(2); - }); - - it('does not render the vulnerability-management tab', () => { - expect(wrapper.findByTestId('vulnerability-management-tab').exists()).toBe(false); - }); + it.each` + licenseQueryResponse | display + ${LICENSE_ULTIMATE} | ${true} + ${LICENSE_PREMIUM} | ${false} + ${LICENSE_FREE} | ${false} + ${null} | ${true} + ${new Error()} | ${true} + `( + 'displays $display for license $licenseQueryResponse', + async ({ licenseQueryResponse, display }) => { + createComponent({ + licenseQueryResponse, + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock, + }); + await waitForPromises(); + expect(findVulnerabilityManagementTab().exists()).toBe(display); + }, + ); }); }); diff --git a/spec/frontend/security_configuration/mock_data.js b/spec/frontend/security_configuration/mock_data.js index 18a480bf082..94a36472a1d 100644 --- a/spec/frontend/security_configuration/mock_data.js +++ b/spec/frontend/security_configuration/mock_data.js @@ -111,3 +111,12 @@ export const tempProviderLogos = { svg: `<svg>${[testProviderName[1]]}</svg>`, }, }; + +export const getCurrentLicensePlanResponse = (plan) => ({ + data: { + currentLicense: { + id: 'gid://gitlab/License/1', + plan, + }, + }, +}); diff --git a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap index c968c28c811..62a9ff98243 100644 --- a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap +++ b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap @@ -63,6 +63,7 @@ exports[`self monitor component When the self monitor project has not been creat </div> <gl-modal-stub + arialabel="" cancel-title="Cancel" category="primary" dismisslabel="Close" diff --git a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap deleted file mode 100644 index 0f4dfdf8a75..00000000000 --- a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap +++ /dev/null @@ -1,22 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EmptyStateComponent should render content 1`] = ` -"<section class=\\"gl-display-flex empty-state gl-text-center gl-flex-direction-column\\"> - <div class=\\"gl-max-w-full\\"> - <div class=\\"svg-250 svg-content\\"><img src=\\"/image.svg\\" alt=\\"\\" role=\\"img\\" class=\\"gl-max-w-full gl-dark-invert-keep-hue\\"></div> - </div> - <div class=\\"gl-max-w-full gl-m-auto\\"> - <div class=\\"gl-mx-auto gl-my-0 gl-p-5\\"> - <h1 class=\\"gl-font-size-h-display gl-line-height-36 h4\\"> - Getting started with serverless - </h1> - <p class=\\"gl-mt-3\\">Serverless was <gl-link-stub target=\\"_blank\\" href=\\"https://about.gitlab.com/releases/2021/09/22/gitlab-14-3-released/#gitlab-serverless\\">deprecated</gl-link-stub>. But if you opt to use it, you must install Knative in your Kubernetes cluster first. <gl-link-stub href=\\"/help\\">Learn more.</gl-link-stub> - </p> - <div class=\\"gl-display-flex gl-flex-wrap gl-justify-content-center\\"> - <!----> - <!----> - </div> - </div> - </div> -</section>" -`; diff --git a/spec/frontend/serverless/components/area_spec.js b/spec/frontend/serverless/components/area_spec.js deleted file mode 100644 index 05c9ee44307..00000000000 --- a/spec/frontend/serverless/components/area_spec.js +++ /dev/null @@ -1,121 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Area from '~/serverless/components/area.vue'; -import { mockNormalizedMetrics } from '../mock_data'; - -describe('Area component', () => { - const mockWidgets = 'mockWidgets'; - const mockGraphData = mockNormalizedMetrics; - let areaChart; - - beforeEach(() => { - areaChart = shallowMount(Area, { - propsData: { - graphData: mockGraphData, - containerWidth: 0, - }, - slots: { - default: mockWidgets, - }, - }); - }); - - afterEach(() => { - areaChart.destroy(); - }); - - it('renders chart title', () => { - expect(areaChart.find({ ref: 'graphTitle' }).text()).toBe(mockGraphData.title); - }); - - it('contains graph widgets from slot', () => { - expect(areaChart.find({ ref: 'graphWidgets' }).text()).toBe(mockWidgets); - }); - - describe('methods', () => { - describe('formatTooltipText', () => { - const mockDate = mockNormalizedMetrics.queries[0].result[0].values[0].time; - const generateSeriesData = (type) => ({ - seriesData: [ - { - componentSubType: type, - value: [mockDate, 4], - }, - ], - value: mockDate, - }); - - describe('series is of line type', () => { - beforeEach(() => { - areaChart.vm.formatTooltipText(generateSeriesData('line')); - }); - - it('formats tooltip title', () => { - expect(areaChart.vm.tooltipPopoverTitle).toBe('28 Feb 2019, 11:11AM'); - }); - - it('formats tooltip content', () => { - expect(areaChart.vm.tooltipPopoverContent).toBe('Invocations (requests): 4'); - }); - }); - - it('verify default interval value of 1', () => { - expect(areaChart.vm.getInterval).toBe(1); - }); - }); - - describe('onResize', () => { - const mockWidth = 233; - - beforeEach(() => { - jest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => ({ - width: mockWidth, - })); - areaChart.vm.onResize(); - }); - - it('sets area chart width', () => { - expect(areaChart.vm.width).toBe(mockWidth); - }); - }); - }); - - describe('computed', () => { - describe('chartData', () => { - it('utilizes all data points', () => { - expect(Object.keys(areaChart.vm.chartData)).toEqual(['requests']); - expect(areaChart.vm.chartData.requests.length).toBe(2); - }); - - it('creates valid data', () => { - const data = areaChart.vm.chartData.requests; - - expect( - data.filter( - (datum) => new Date(datum.time).getTime() > 0 && typeof datum.value === 'number', - ).length, - ).toBe(data.length); - }); - }); - - describe('generateSeries', () => { - it('utilizes correct time data', () => { - expect(areaChart.vm.generateSeries.data).toEqual([ - ['2019-02-28T11:11:38.756Z', 0], - ['2019-02-28T11:12:38.756Z', 0], - ]); - }); - }); - - describe('xAxisLabel', () => { - it('constructs a label for the chart x-axis', () => { - expect(areaChart.vm.xAxisLabel).toBe('invocations / minute'); - }); - }); - - describe('yAxisLabel', () => { - it('constructs a label for the chart y-axis', () => { - expect(areaChart.vm.yAxisLabel).toBe('Invocations (requests)'); - }); - }); - }); -}); diff --git a/spec/frontend/serverless/components/empty_state_spec.js b/spec/frontend/serverless/components/empty_state_spec.js deleted file mode 100644 index d63882c2a6d..00000000000 --- a/spec/frontend/serverless/components/empty_state_spec.js +++ /dev/null @@ -1,25 +0,0 @@ -import { GlEmptyState, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import EmptyStateComponent from '~/serverless/components/empty_state.vue'; -import { createStore } from '~/serverless/store'; - -describe('EmptyStateComponent', () => { - let wrapper; - - beforeEach(() => { - const store = createStore({ - clustersPath: '/clusters', - helpPath: '/help', - emptyImagePath: '/image.svg', - }); - wrapper = shallowMount(EmptyStateComponent, { store, stubs: { GlEmptyState, GlSprintf } }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('should render content', () => { - expect(wrapper.html()).toMatchSnapshot(); - }); -}); diff --git a/spec/frontend/serverless/components/environment_row_spec.js b/spec/frontend/serverless/components/environment_row_spec.js deleted file mode 100644 index 944283136d0..00000000000 --- a/spec/frontend/serverless/components/environment_row_spec.js +++ /dev/null @@ -1,68 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import environmentRowComponent from '~/serverless/components/environment_row.vue'; - -import { translate } from '~/serverless/utils'; -import { mockServerlessFunctions, mockServerlessFunctionsDiffEnv } from '../mock_data'; - -const createComponent = (env, envName) => - shallowMount(environmentRowComponent, { - propsData: { env, envName }, - }).vm; - -describe('environment row component', () => { - describe('default global cluster case', () => { - let vm; - - beforeEach(() => { - vm = createComponent(translate(mockServerlessFunctions.functions)['*'], '*'); - }); - - afterEach(() => vm.$destroy()); - - it('has the correct envId', () => { - expect(vm.envId).toEqual('env-global'); - }); - - it('is open by default', () => { - expect(vm.isOpenClass).toEqual({ 'is-open': true }); - }); - - it('generates correct output', () => { - expect(vm.$el.id).toEqual('env-global'); - expect(vm.$el.classList.contains('is-open')).toBe(true); - expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('*'); - }); - - it('opens and closes correctly', () => { - expect(vm.isOpen).toBe(true); - - vm.toggleOpen(); - - expect(vm.isOpen).toBe(false); - }); - }); - - describe('default named cluster case', () => { - let vm; - - beforeEach(() => { - vm = createComponent(translate(mockServerlessFunctionsDiffEnv.functions).test, 'test'); - }); - - afterEach(() => vm.$destroy()); - - it('has the correct envId', () => { - expect(vm.envId).toEqual('env-test'); - }); - - it('is open by default', () => { - expect(vm.isOpenClass).toEqual({ 'is-open': true }); - }); - - it('generates correct output', () => { - expect(vm.$el.id).toEqual('env-test'); - expect(vm.$el.classList.contains('is-open')).toBe(true); - expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('test'); - }); - }); -}); diff --git a/spec/frontend/serverless/components/function_details_spec.js b/spec/frontend/serverless/components/function_details_spec.js deleted file mode 100644 index 0c9b2498589..00000000000 --- a/spec/frontend/serverless/components/function_details_spec.js +++ /dev/null @@ -1,100 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; - -import functionDetailsComponent from '~/serverless/components/function_details.vue'; -import { createStore } from '~/serverless/store'; - -describe('functionDetailsComponent', () => { - let component; - let store; - - beforeEach(() => { - Vue.use(Vuex); - - store = createStore({ clustersPath: '/clusters', helpPath: '/help' }); - }); - - afterEach(() => { - component.vm.$destroy(); - }); - - describe('Verify base functionality', () => { - const serviceStub = { - name: 'test', - description: 'a description', - environment: '*', - url: 'http://service.com/test', - namespace: 'test-ns', - podcount: 0, - metricsUrl: '/metrics', - }; - - it('has a name, description, URL, and no pods loaded', () => { - component = shallowMount(functionDetailsComponent, { - store, - propsData: { - func: serviceStub, - hasPrometheus: false, - }, - }); - - expect( - component.vm.$el.querySelector('.serverless-function-name').innerHTML.trim(), - ).toContain('test'); - - expect( - component.vm.$el.querySelector('.serverless-function-description').innerHTML.trim(), - ).toContain('a description'); - - expect(component.vm.$el.querySelector('p').innerHTML.trim()).toContain( - 'No pods loaded at this time.', - ); - }); - - it('has a pods loaded', () => { - serviceStub.podcount = 1; - - component = shallowMount(functionDetailsComponent, { - store, - propsData: { - func: serviceStub, - hasPrometheus: false, - }, - }); - - expect(component.vm.$el.querySelector('p').innerHTML.trim()).toContain('1 pod in use'); - }); - - it('has multiple pods loaded', () => { - serviceStub.podcount = 3; - - component = shallowMount(functionDetailsComponent, { - store, - propsData: { - func: serviceStub, - hasPrometheus: false, - }, - }); - - expect(component.vm.$el.querySelector('p').innerHTML.trim()).toContain('3 pods in use'); - }); - - it('can support a missing description', () => { - serviceStub.description = null; - - component = shallowMount(functionDetailsComponent, { - store, - propsData: { - func: serviceStub, - hasPrometheus: false, - }, - }); - - expect( - component.vm.$el.querySelector('.serverless-function-description').querySelector('div') - .innerHTML.length, - ).toEqual(0); - }); - }); -}); diff --git a/spec/frontend/serverless/components/function_row_spec.js b/spec/frontend/serverless/components/function_row_spec.js deleted file mode 100644 index 081edd33b3b..00000000000 --- a/spec/frontend/serverless/components/function_row_spec.js +++ /dev/null @@ -1,34 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import functionRowComponent from '~/serverless/components/function_row.vue'; -import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; - -import { mockServerlessFunction } from '../mock_data'; - -describe('functionRowComponent', () => { - let wrapper; - - const createComponent = (func) => { - wrapper = shallowMount(functionRowComponent, { - propsData: { func }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it('Parses the function details correctly', () => { - createComponent(mockServerlessFunction); - - expect(wrapper.find('b').text()).toBe(mockServerlessFunction.name); - expect(wrapper.find('span').text()).toBe(mockServerlessFunction.image); - expect(wrapper.find(Timeago).attributes('time')).not.toBe(null); - }); - - it('handles clicks correctly', () => { - createComponent(mockServerlessFunction); - const { vm } = wrapper; - - expect(vm.checkClass(vm.$el.querySelector('p'))).toBe(true); // check somewhere inside the row - }); -}); diff --git a/spec/frontend/serverless/components/functions_spec.js b/spec/frontend/serverless/components/functions_spec.js deleted file mode 100644 index 846fd63e918..00000000000 --- a/spec/frontend/serverless/components/functions_spec.js +++ /dev/null @@ -1,86 +0,0 @@ -import { GlLoadingIcon, GlAlert, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import AxiosMockAdapter from 'axios-mock-adapter'; -import Vuex from 'vuex'; -import { TEST_HOST } from 'helpers/test_constants'; -import axios from '~/lib/utils/axios_utils'; -import EmptyState from '~/serverless/components/empty_state.vue'; -import EnvironmentRow from '~/serverless/components/environment_row.vue'; -import functionsComponent from '~/serverless/components/functions.vue'; -import { createStore } from '~/serverless/store'; -import { mockServerlessFunctions } from '../mock_data'; - -describe('functionsComponent', () => { - const statusPath = `${TEST_HOST}/statusPath`; - - let component; - let store; - let axiosMock; - - beforeEach(() => { - axiosMock = new AxiosMockAdapter(axios); - axiosMock.onGet(statusPath).reply(200); - - Vue.use(Vuex); - - store = createStore({}); - component = shallowMount(functionsComponent, { store, stubs: { GlSprintf } }); - }); - - afterEach(() => { - component.destroy(); - axiosMock.restore(); - }); - - it('should render deprecation notice', () => { - expect(component.findComponent(GlAlert).text()).toBe( - 'Serverless was deprecated in GitLab 14.3.', - ); - }); - - it('should render empty state when Knative is not installed', async () => { - await store.dispatch('receiveFunctionsSuccess', { knative_installed: false }); - - expect(component.findComponent(EmptyState).exists()).toBe(true); - }); - - it('should render a loading component', async () => { - await store.dispatch('requestFunctionsLoading'); - - expect(component.findComponent(GlLoadingIcon).exists()).toBe(true); - }); - - it('should render empty state when there is no function data', async () => { - await store.dispatch('receiveFunctionsNoDataSuccess', { knative_installed: true }); - - expect( - component.vm.$el - .querySelector('.empty-state, .js-empty-state') - .classList.contains('js-empty-state'), - ).toBe(true); - - expect(component.vm.$el.querySelector('.state-title, .text-center').innerHTML.trim()).toEqual( - 'No functions available', - ); - }); - - it('should render functions and a loader when functions are partially fetched', async () => { - await store.dispatch('receiveFunctionsPartial', { - ...mockServerlessFunctions, - knative_installed: 'checking', - }); - - expect(component.find('.js-functions-wrapper').exists()).toBe(true); - expect(component.find('.js-functions-loader').exists()).toBe(true); - }); - - it('should render the functions list', async () => { - store = createStore({ clustersPath: 'clustersPath', helpPath: 'helpPath', statusPath }); - - await component.vm.$store.dispatch('receiveFunctionsSuccess', mockServerlessFunctions); - - await nextTick(); - expect(component.findComponent(EnvironmentRow).exists()).toBe(true); - }); -}); diff --git a/spec/frontend/serverless/components/missing_prometheus_spec.js b/spec/frontend/serverless/components/missing_prometheus_spec.js deleted file mode 100644 index 1b93fd784e1..00000000000 --- a/spec/frontend/serverless/components/missing_prometheus_spec.js +++ /dev/null @@ -1,38 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import missingPrometheusComponent from '~/serverless/components/missing_prometheus.vue'; -import { createStore } from '~/serverless/store'; - -describe('missingPrometheusComponent', () => { - let wrapper; - - const createComponent = (missingData) => { - const store = createStore({ clustersPath: '/clusters', helpPath: '/help' }); - - wrapper = shallowMount(missingPrometheusComponent, { store, propsData: { missingData } }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it('should render missing prometheus message', () => { - createComponent(false); - const { vm } = wrapper; - - expect(vm.$el.querySelector('.state-description').innerHTML.trim()).toContain( - 'Function invocation metrics require the Prometheus cluster integration.', - ); - - expect(wrapper.find(GlButton).attributes('variant')).toBe('success'); - }); - - it('should render no prometheus data message', () => { - createComponent(true); - const { vm } = wrapper; - - expect(vm.$el.querySelector('.state-description').innerHTML.trim()).toContain( - 'Invocation metrics loading or not available at this time.', - ); - }); -}); diff --git a/spec/frontend/serverless/components/pod_box_spec.js b/spec/frontend/serverless/components/pod_box_spec.js deleted file mode 100644 index cf0c14a2cac..00000000000 --- a/spec/frontend/serverless/components/pod_box_spec.js +++ /dev/null @@ -1,22 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import podBoxComponent from '~/serverless/components/pod_box.vue'; - -const createComponent = (count) => - shallowMount(podBoxComponent, { - propsData: { - count, - }, - }).vm; - -describe('podBoxComponent', () => { - it('should render three boxes', () => { - const count = 3; - const vm = createComponent(count); - const rects = vm.$el.querySelectorAll('rect'); - - expect(rects.length).toEqual(3); - expect(parseInt(rects[2].getAttribute('x'), 10)).toEqual(40); - - vm.$destroy(); - }); -}); diff --git a/spec/frontend/serverless/components/url_spec.js b/spec/frontend/serverless/components/url_spec.js deleted file mode 100644 index 8c839577aa0..00000000000 --- a/spec/frontend/serverless/components/url_spec.js +++ /dev/null @@ -1,26 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import urlComponent from '~/serverless/components/url.vue'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; - -const createComponent = (uri) => - shallowMount(Vue.extend(urlComponent), { - propsData: { - uri, - }, - }); - -describe('urlComponent', () => { - it('should render correctly', () => { - const uri = 'http://testfunc.apps.example.com'; - const wrapper = createComponent(uri); - const { vm } = wrapper; - - expect(vm.$el.classList.contains('clipboard-group')).toBe(true); - expect(wrapper.find(ClipboardButton).attributes('text')).toEqual(uri); - - expect(vm.$el.querySelector('[data-testid="url-text-field"]').innerHTML).toContain(uri); - - vm.$destroy(); - }); -}); diff --git a/spec/frontend/serverless/mock_data.js b/spec/frontend/serverless/mock_data.js deleted file mode 100644 index 1816ad62a04..00000000000 --- a/spec/frontend/serverless/mock_data.js +++ /dev/null @@ -1,145 +0,0 @@ -export const mockServerlessFunctions = { - knative_installed: true, - functions: [ - { - name: 'testfunc1', - namespace: 'tm-example', - environment_scope: '*', - cluster_id: 46, - detail_url: '/testuser/testproj/serverless/functions/*/testfunc1', - podcount: null, - created_at: '2019-02-05T01:01:23Z', - url: 'http://testfunc1.tm-example.apps.example.com', - description: 'A test service', - image: 'knative-test-container-buildtemplate', - }, - { - name: 'testfunc2', - namespace: 'tm-example', - environment_scope: '*', - cluster_id: 46, - detail_url: '/testuser/testproj/serverless/functions/*/testfunc2', - podcount: null, - created_at: '2019-02-05T01:01:23Z', - url: 'http://testfunc2.tm-example.apps.example.com', - description: 'A second test service\nThis one with additional descriptions', - image: 'knative-test-echo-buildtemplate', - }, - ], -}; - -export const mockServerlessFunctionsDiffEnv = { - knative_installed: true, - functions: [ - { - name: 'testfunc1', - namespace: 'tm-example', - environment_scope: '*', - cluster_id: 46, - detail_url: '/testuser/testproj/serverless/functions/*/testfunc1', - podcount: null, - created_at: '2019-02-05T01:01:23Z', - url: 'http://testfunc1.tm-example.apps.example.com', - description: 'A test service', - image: 'knative-test-container-buildtemplate', - }, - { - name: 'testfunc2', - namespace: 'tm-example', - environment_scope: 'test', - cluster_id: 46, - detail_url: '/testuser/testproj/serverless/functions/*/testfunc2', - podcount: null, - created_at: '2019-02-05T01:01:23Z', - url: 'http://testfunc2.tm-example.apps.example.com', - description: 'A second test service\nThis one with additional descriptions', - image: 'knative-test-echo-buildtemplate', - }, - ], -}; - -export const mockServerlessFunction = { - name: 'testfunc1', - namespace: 'tm-example', - environment_scope: '*', - cluster_id: 46, - detail_url: '/testuser/testproj/serverless/functions/*/testfunc1', - podcount: '3', - created_at: '2019-02-05T01:01:23Z', - url: 'http://testfunc1.tm-example.apps.example.com', - description: 'A test service', - image: 'knative-test-container-buildtemplate', -}; - -export const mockMultilineServerlessFunction = { - name: 'testfunc1', - namespace: 'tm-example', - environment_scope: '*', - cluster_id: 46, - detail_url: '/testuser/testproj/serverless/functions/*/testfunc1', - podcount: '3', - created_at: '2019-02-05T01:01:23Z', - url: 'http://testfunc1.tm-example.apps.example.com', - description: 'testfunc1\nA test service line\\nWith additional services', - image: 'knative-test-container-buildtemplate', -}; - -export const mockMetrics = { - success: true, - last_update: '2019-02-28T19:11:38.926Z', - metrics: { - id: 22, - title: 'Knative function invocations', - required_metrics: ['container_memory_usage_bytes', 'container_cpu_usage_seconds_total'], - weight: 0, - y_label: 'Invocations', - queries: [ - { - query_range: - 'floor(sum(rate(istio_revision_request_count{destination_configuration="%{function_name}", destination_namespace="%{kube_namespace}"}[1m])*30))', - unit: 'requests', - label: 'invocations / minute', - result: [ - { - metric: {}, - values: [ - [1551352298.756, '0'], - [1551352358.756, '0'], - ], - }, - ], - }, - ], - }, -}; - -export const mockNormalizedMetrics = { - id: 22, - title: 'Knative function invocations', - required_metrics: ['container_memory_usage_bytes', 'container_cpu_usage_seconds_total'], - weight: 0, - y_label: 'Invocations', - queries: [ - { - query_range: - 'floor(sum(rate(istio_revision_request_count{destination_configuration="%{function_name}", destination_namespace="%{kube_namespace}"}[1m])*30))', - unit: 'requests', - label: 'invocations / minute', - result: [ - { - metric: {}, - values: [ - { - time: '2019-02-28T11:11:38.756Z', - value: 0, - }, - { - time: '2019-02-28T11:12:38.756Z', - value: 0, - }, - ], - }, - ], - }, - ], -}; diff --git a/spec/frontend/serverless/store/actions_spec.js b/spec/frontend/serverless/store/actions_spec.js deleted file mode 100644 index 5fbecf081a6..00000000000 --- a/spec/frontend/serverless/store/actions_spec.js +++ /dev/null @@ -1,80 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import testAction from 'helpers/vuex_action_helper'; -import axios from '~/lib/utils/axios_utils'; -import statusCodes from '~/lib/utils/http_status'; -import { fetchFunctions, fetchMetrics } from '~/serverless/store/actions'; -import { mockServerlessFunctions, mockMetrics } from '../mock_data'; -import { adjustMetricQuery } from '../utils'; - -describe('ServerlessActions', () => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - describe('fetchFunctions', () => { - it('should successfully fetch functions', () => { - const endpoint = '/functions'; - mock.onGet(endpoint).reply(statusCodes.OK, JSON.stringify(mockServerlessFunctions)); - - return testAction( - fetchFunctions, - { functionsPath: endpoint }, - {}, - [], - [ - { type: 'requestFunctionsLoading' }, - { type: 'receiveFunctionsSuccess', payload: mockServerlessFunctions }, - ], - ); - }); - - it('should successfully retry', () => { - const endpoint = '/functions'; - mock - .onGet(endpoint) - .reply(() => new Promise((resolve) => setTimeout(() => resolve(200), Infinity))); - - return testAction( - fetchFunctions, - { functionsPath: endpoint }, - {}, - [], - [{ type: 'requestFunctionsLoading' }], - ); - }); - }); - - describe('fetchMetrics', () => { - it('should return no prometheus', () => { - const endpoint = '/metrics'; - mock.onGet(endpoint).reply(statusCodes.NO_CONTENT); - - return testAction( - fetchMetrics, - { metricsPath: endpoint, hasPrometheus: false }, - {}, - [], - [{ type: 'receiveMetricsNoPrometheus' }], - ); - }); - - it('should successfully fetch metrics', () => { - const endpoint = '/metrics'; - mock.onGet(endpoint).reply(statusCodes.OK, JSON.stringify(mockMetrics)); - - return testAction( - fetchMetrics, - { metricsPath: endpoint, hasPrometheus: true }, - {}, - [], - [{ type: 'receiveMetricsSuccess', payload: adjustMetricQuery(mockMetrics) }], - ); - }); - }); -}); diff --git a/spec/frontend/serverless/store/getters_spec.js b/spec/frontend/serverless/store/getters_spec.js deleted file mode 100644 index e1942bd2759..00000000000 --- a/spec/frontend/serverless/store/getters_spec.js +++ /dev/null @@ -1,43 +0,0 @@ -import * as getters from '~/serverless/store/getters'; -import serverlessState from '~/serverless/store/state'; -import { mockServerlessFunctions } from '../mock_data'; - -describe('Serverless Store Getters', () => { - let state; - - beforeEach(() => { - state = serverlessState; - }); - - describe('hasPrometheusMissingData', () => { - it('should return false if Prometheus is not installed', () => { - state.hasPrometheus = false; - - expect(getters.hasPrometheusMissingData(state)).toEqual(false); - }); - - it('should return false if Prometheus is installed and there is data', () => { - state.hasPrometheusData = true; - - expect(getters.hasPrometheusMissingData(state)).toEqual(false); - }); - - it('should return true if Prometheus is installed and there is no data', () => { - state.hasPrometheus = true; - state.hasPrometheusData = false; - - expect(getters.hasPrometheusMissingData(state)).toEqual(true); - }); - }); - - describe('getFunctions', () => { - it('should translate the raw function array to group the functions per environment scope', () => { - state.functions = mockServerlessFunctions.functions; - - const funcs = getters.getFunctions(state); - - expect(Object.keys(funcs)).toContain('*'); - expect(funcs['*'].length).toEqual(2); - }); - }); -}); diff --git a/spec/frontend/serverless/store/mutations_spec.js b/spec/frontend/serverless/store/mutations_spec.js deleted file mode 100644 index a1a8f9a2ca7..00000000000 --- a/spec/frontend/serverless/store/mutations_spec.js +++ /dev/null @@ -1,86 +0,0 @@ -import * as types from '~/serverless/store/mutation_types'; -import mutations from '~/serverless/store/mutations'; -import { mockServerlessFunctions, mockMetrics } from '../mock_data'; - -describe('ServerlessMutations', () => { - describe('Functions List Mutations', () => { - it('should ensure loading is true', () => { - const state = {}; - - mutations[types.REQUEST_FUNCTIONS_LOADING](state); - - expect(state.isLoading).toEqual(true); - }); - - it('should set proper state once functions are loaded', () => { - const state = {}; - - mutations[types.RECEIVE_FUNCTIONS_SUCCESS](state, mockServerlessFunctions); - - expect(state.isLoading).toEqual(false); - expect(state.hasFunctionData).toEqual(true); - expect(state.functions).toEqual(mockServerlessFunctions.functions); - }); - - it('should ensure loading has stopped and hasFunctionData is false when there are no functions available', () => { - const state = {}; - - mutations[types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state, { knative_installed: true }); - - expect(state.isLoading).toEqual(false); - expect(state.hasFunctionData).toEqual(false); - expect(state.functions).toBe(undefined); - }); - - it('should ensure loading has stopped, and an error is raised', () => { - const state = {}; - - mutations[types.RECEIVE_FUNCTIONS_ERROR](state, 'sample error'); - - expect(state.isLoading).toEqual(false); - expect(state.hasFunctionData).toEqual(false); - expect(state.functions).toBe(undefined); - expect(state.error).not.toBe(undefined); - }); - }); - - describe('Function Details Metrics Mutations', () => { - it('should ensure isLoading and hasPrometheus data flags indicate data is loaded', () => { - const state = {}; - - mutations[types.RECEIVE_METRICS_SUCCESS](state, mockMetrics); - - expect(state.isLoading).toEqual(false); - expect(state.hasPrometheusData).toEqual(true); - expect(state.graphData).toEqual(mockMetrics); - }); - - it('should ensure isLoading and hasPrometheus data flags are cleared indicating no functions available', () => { - const state = {}; - - mutations[types.RECEIVE_METRICS_NODATA_SUCCESS](state); - - expect(state.isLoading).toEqual(false); - expect(state.hasPrometheusData).toEqual(false); - expect(state.graphData).toBe(undefined); - }); - - it('should properly indicate an error', () => { - const state = {}; - - mutations[types.RECEIVE_METRICS_ERROR](state, 'sample error'); - - expect(state.hasPrometheusData).toEqual(false); - expect(state.error).not.toBe(undefined); - }); - - it('should properly indicate when prometheus is installed', () => { - const state = {}; - - mutations[types.RECEIVE_METRICS_NO_PROMETHEUS](state); - - expect(state.hasPrometheus).toEqual(false); - expect(state.hasPrometheusData).toEqual(false); - }); - }); -}); diff --git a/spec/frontend/serverless/utils.js b/spec/frontend/serverless/utils.js deleted file mode 100644 index 7caf7da231e..00000000000 --- a/spec/frontend/serverless/utils.js +++ /dev/null @@ -1,17 +0,0 @@ -export const adjustMetricQuery = (data) => { - const updatedMetric = data.metrics; - - const queries = data.metrics.queries.map((query) => ({ - ...query, - result: query.result.map((result) => ({ - ...result, - values: result.values.map(([timestamp, value]) => ({ - time: new Date(timestamp * 1000).toISOString(), - value: Number(value), - })), - })), - })); - - updatedMetric.queries = queries; - return updatedMetric; -}; diff --git a/spec/frontend/settings_panels_spec.js b/spec/frontend/settings_panels_spec.js index 3a62cd703ab..d59e1a20b27 100644 --- a/spec/frontend/settings_panels_spec.js +++ b/spec/frontend/settings_panels_spec.js @@ -1,9 +1,14 @@ import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import initSettingsPanels, { isExpanded } from '~/settings_panels'; describe('Settings Panels', () => { beforeEach(() => { - loadFixtures('groups/edit.html'); + loadHTMLFixture('groups/edit.html'); + }); + + afterEach(() => { + resetHTMLFixture(); }); describe('initSettingsPane', () => { diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js index 8b9a11056f2..e859d435f48 100644 --- a/spec/frontend/shortcuts_spec.js +++ b/spec/frontend/shortcuts_spec.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import { flatten } from 'lodash'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import Shortcuts from '~/behaviors/shortcuts/shortcuts'; const mockMousetrap = { @@ -21,7 +22,7 @@ describe('Shortcuts', () => { }); beforeEach(() => { - loadFixtures(fixtureName); + loadHTMLFixture(fixtureName); jest.spyOn(document.querySelector('.js-new-note-form .js-md-preview-button'), 'focus'); jest.spyOn(document.querySelector('.edit-note .js-md-preview-button'), 'focus'); @@ -30,6 +31,10 @@ describe('Shortcuts', () => { new Shortcuts(); // eslint-disable-line no-new }); + afterEach(() => { + resetHTMLFixture(); + }); + describe('toggleMarkdownPreview', () => { it('focuses preview button in form', () => { Shortcuts.toggleMarkdownPreview( 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 90aae85e1ca..f7437386814 100644 --- a/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js +++ b/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js @@ -47,12 +47,10 @@ describe('UncollapsedAssigneeList component', () => { it('calls the AssigneeAvatarLink with the proper props', () => { expect(wrapper.find(AssigneeAvatarLink).exists()).toBe(true); - expect(wrapper.find(AssigneeAvatarLink).props().tooltipPlacement).toEqual('left'); }); it('Shows one user with avatar, username and author name', () => { expect(wrapper.text()).toContain(user.name); - expect(wrapper.text()).toContain(`@${user.username}`); }); }); diff --git a/spec/frontend/sidebar/components/attention_requested_toggle_spec.js b/spec/frontend/sidebar/components/attention_requested_toggle_spec.js index a9ae23c1624..959fa799eb7 100644 --- a/spec/frontend/sidebar/components/attention_requested_toggle_spec.js +++ b/spec/frontend/sidebar/components/attention_requested_toggle_spec.js @@ -68,6 +68,7 @@ describe('Attention require toggle', () => { { user: { attention_requested: true, can_update_merge_request: true }, callback: expect.anything(), + direction: 'remove', }, ]); }); @@ -96,9 +97,9 @@ describe('Attention require toggle', () => { it.each` type | attentionRequested | tooltip | canUpdateMergeRequest - ${'reviewer'} | ${true} | ${AttentionRequestedToggle.i18n.removeAttentionRequested} | ${true} - ${'reviewer'} | ${false} | ${AttentionRequestedToggle.i18n.attentionRequestedReviewer} | ${true} - ${'assignee'} | ${false} | ${AttentionRequestedToggle.i18n.attentionRequestedAssignee} | ${true} + ${'reviewer'} | ${true} | ${AttentionRequestedToggle.i18n.removeAttentionRequest} | ${true} + ${'reviewer'} | ${false} | ${AttentionRequestedToggle.i18n.addAttentionRequest} | ${true} + ${'assignee'} | ${false} | ${AttentionRequestedToggle.i18n.addAttentionRequest} | ${true} ${'reviewer'} | ${true} | ${AttentionRequestedToggle.i18n.attentionRequestedNoPermission} | ${false} ${'reviewer'} | ${false} | ${AttentionRequestedToggle.i18n.noAttentionRequestedNoPermission} | ${false} ${'assignee'} | ${true} | ${AttentionRequestedToggle.i18n.attentionRequestedNoPermission} | ${false} diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js index 8844e1626cd..ab45fdf03bc 100644 --- a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js +++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js @@ -1,4 +1,4 @@ -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlAlert } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import SidebarConfidentialityContent from '~/sidebar/components/confidential/sidebar_confidentiality_content.vue'; @@ -60,12 +60,24 @@ describe('Sidebar Confidentiality Content', () => { it('displays a correct confidential text for issue', () => { createComponent({ confidential: true }); - expect(findText().text()).toBe('This issue is confidential'); + + const alertEl = findText().findComponent(GlAlert); + + expect(alertEl.props()).toMatchObject({ + showIcon: false, + dismissible: false, + variant: 'warning', + }); + expect(alertEl.text()).toBe( + 'Only project members with at least Reporter role can view or be notified about this issue.', + ); }); it('displays a correct confidential text for epic', () => { createComponent({ confidential: true, issuableType: 'epic' }); - expect(findText().text()).toBe('This epic is confidential'); + expect(findText().findComponent(GlAlert).text()).toBe( + 'Only group members with at least Reporter role can view or be notified about this epic.', + ); }); }); }); diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js index 28a19fb9df6..85d6bc7b782 100644 --- a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js +++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js @@ -89,7 +89,7 @@ describe('Sidebar Confidentiality Form', () => { it('renders a message about making an issue confidential', () => { expect(findWarningMessage().text()).toBe( - 'You are going to turn on confidentiality. Only team members with at least Reporter access will be able to see and leave comments on the issue.', + 'You are going to turn on confidentiality. Only project members with at least Reporter role can view or be notified about this issue.', ); }); diff --git a/spec/frontend/sidebar/components/crm_contacts_spec.js b/spec/frontend/sidebar/components/crm_contacts_spec.js index 758cff30e2d..6456829258f 100644 --- a/spec/frontend/sidebar/components/crm_contacts_spec.js +++ b/spec/frontend/sidebar/components/crm_contacts_spec.js @@ -33,7 +33,7 @@ describe('Issue crm contacts component', () => { [issueCrmContactsSubscription, subscriptionHandler], ]); wrapper = shallowMountExtended(CrmContacts, { - propsData: { issueId: '123' }, + propsData: { issueId: '123', groupIssuesPath: '/groups/flightjs/-/issues' }, apolloProvider: fakeApollo, }); }; @@ -71,8 +71,14 @@ describe('Issue crm contacts component', () => { await waitForPromises(); expect(wrapper.find('#contact_0').text()).toContain('Someone Important'); + expect(wrapper.find('#contact_0').attributes('href')).toBe( + '/groups/flightjs/-/issues?crm_contact_id=1', + ); expect(wrapper.find('#contact_container_0').text()).toContain('si@gitlab.com'); expect(wrapper.find('#contact_1').text()).toContain('Marty McFly'); + expect(wrapper.find('#contact_1').attributes('href')).toBe( + '/groups/flightjs/-/issues?crm_contact_id=5', + ); }); it('renders correct results after subscription update', async () => { @@ -83,5 +89,8 @@ describe('Issue crm contacts component', () => { contact.forEach((property) => { expect(wrapper.find('#contact_container_0').text()).toContain(property); }); + expect(wrapper.find('#contact_0').attributes('href')).toBe( + '/groups/flightjs/-/issues?crm_contact_id=13', + ); }); }); diff --git a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js index d0792fa7b73..8999f120a0f 100644 --- a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js +++ b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js @@ -42,9 +42,8 @@ describe('UncollapsedReviewerList component', () => { expect(wrapper.findAll(ReviewerAvatarLink).length).toBe(1); }); - it('shows one user with avatar, username and author name', () => { + it('shows one user with avatar, and author name', () => { expect(wrapper.text()).toContain(user.name); - expect(wrapper.text()).toContain(`@root`); }); it('renders re-request loading icon', async () => { @@ -84,11 +83,9 @@ describe('UncollapsedReviewerList component', () => { expect(wrapper.findAll(ReviewerAvatarLink).length).toBe(2); }); - it('shows both users with avatar, username and author name', () => { + it('shows both users with avatar, and author name', () => { expect(wrapper.text()).toContain(user.name); - expect(wrapper.text()).toContain(`@root`); expect(wrapper.text()).toContain(user2.name); - expect(wrapper.text()).toContain(`@hello-world`); }); it('renders approval icon', () => { diff --git a/spec/frontend/sidebar/components/time_tracking/mock_data.js b/spec/frontend/sidebar/components/time_tracking/mock_data.js index 3f1b3fa8ec1..ba2781118d9 100644 --- a/spec/frontend/sidebar/components/time_tracking/mock_data.js +++ b/spec/frontend/sidebar/components/time_tracking/mock_data.js @@ -9,6 +9,7 @@ export const getIssueTimelogsQueryResponse = { nodes: [ { __typename: 'Timelog', + id: 'gid://gitlab/Timelog/18', timeSpent: 14400, user: { id: 'user-1', @@ -25,6 +26,7 @@ export const getIssueTimelogsQueryResponse = { }, { __typename: 'Timelog', + id: 'gid://gitlab/Timelog/20', timeSpent: 1800, user: { id: 'user-2', @@ -37,6 +39,7 @@ export const getIssueTimelogsQueryResponse = { }, { __typename: 'Timelog', + id: 'gid://gitlab/Timelog/25', timeSpent: 14400, user: { id: 'user-2', @@ -68,6 +71,7 @@ export const getMrTimelogsQueryResponse = { nodes: [ { __typename: 'Timelog', + id: 'gid://gitlab/Timelog/13', timeSpent: 1800, user: { id: 'user-1', @@ -84,6 +88,7 @@ export const getMrTimelogsQueryResponse = { }, { __typename: 'Timelog', + id: 'gid://gitlab/Timelog/22', timeSpent: 3600, user: { id: 'user-1', @@ -96,6 +101,7 @@ export const getMrTimelogsQueryResponse = { }, { __typename: 'Timelog', + id: 'gid://gitlab/Timelog/64', timeSpent: 300, user: { id: 'user-1', diff --git a/spec/frontend/sidebar/lock/issuable_lock_form_spec.js b/spec/frontend/sidebar/lock/issuable_lock_form_spec.js index 7bf7e563a01..8478d3d674d 100644 --- a/spec/frontend/sidebar/lock/issuable_lock_form_spec.js +++ b/spec/frontend/sidebar/lock/issuable_lock_form_spec.js @@ -37,6 +37,9 @@ describe('IssuableLockForm', () => { const createComponent = ({ props = {} }) => { wrapper = shallowMount(IssuableLockForm, { store, + provide: { + fullPath: '', + }, propsData: { isEditable: true, ...props, diff --git a/spec/frontend/sidebar/reviewers_spec.js b/spec/frontend/sidebar/reviewers_spec.js index fc24b51287f..351dfc9a6ed 100644 --- a/spec/frontend/sidebar/reviewers_spec.js +++ b/spec/frontend/sidebar/reviewers_spec.js @@ -146,7 +146,6 @@ describe('Reviewer component', () => { const userItems = wrapper.findAll('[data-testid="reviewer"]'); expect(userItems.length).toBe(3); - expect(userItems.at(0).find('a').attributes('title')).toBe(users[2].name); }); it('passes the sorted reviewers to the collapsed-reviewer-list', () => { diff --git a/spec/frontend/sidebar/sidebar_mediator_spec.js b/spec/frontend/sidebar/sidebar_mediator_spec.js index c472a98bf0b..82fb10ab1d2 100644 --- a/spec/frontend/sidebar/sidebar_mediator_spec.js +++ b/spec/frontend/sidebar/sidebar_mediator_spec.js @@ -1,4 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as urlUtility from '~/lib/utils/url_utility'; import SidebarService, { gqClient } from '~/sidebar/services/sidebar_service'; @@ -8,6 +9,7 @@ import toast from '~/vue_shared/plugins/global_toast'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import Mock from './mock_data'; +jest.mock('~/flash'); jest.mock('~/vue_shared/plugins/global_toast'); jest.mock('~/commons/nav/user_merge_requests'); @@ -122,25 +124,39 @@ describe('Sidebar mediator', () => { }); describe('toggleAttentionRequested', () => { - let attentionRequiredService; + let requestAttentionMock; + let removeAttentionRequestMock; beforeEach(() => { - attentionRequiredService = jest - .spyOn(mediator.service, 'toggleAttentionRequested') + requestAttentionMock = jest.spyOn(mediator.service, 'requestAttention').mockResolvedValue(); + removeAttentionRequestMock = jest + .spyOn(mediator.service, 'removeAttentionRequest') .mockResolvedValue(); }); - it('calls attentionRequired service method', async () => { - mediator.store.reviewers = [{ id: 1, attention_requested: false, username: 'root' }]; + it.each` + attentionIsCurrentlyRequested | serviceMethod + ${true} | ${'remove'} + ${false} | ${'add'} + `( + "calls the $serviceMethod service method when the user's attention request is set to $attentionIsCurrentlyRequested", + async ({ serviceMethod }) => { + const methods = { + add: requestAttentionMock, + remove: removeAttentionRequestMock, + }; + mediator.store.reviewers = [{ id: 1, attention_requested: false, username: 'root' }]; - await mediator.toggleAttentionRequested('reviewer', { - user: { id: 1, username: 'root' }, - callback: jest.fn(), - }); + await mediator.toggleAttentionRequested('reviewer', { + user: { id: 1, username: 'root' }, + callback: jest.fn(), + direction: serviceMethod, + }); - expect(attentionRequiredService).toHaveBeenCalledWith(1); - expect(refreshUserMergeRequestCounts).toHaveBeenCalled(); - }); + expect(methods[serviceMethod]).toHaveBeenCalledWith(1); + expect(refreshUserMergeRequestCounts).toHaveBeenCalled(); + }, + ); it.each` type | method @@ -172,5 +188,27 @@ describe('Sidebar mediator', () => { expect(toast).toHaveBeenCalledWith(toastMessage); }, ); + + describe('errors', () => { + beforeEach(() => { + jest + .spyOn(mediator.service, 'removeAttentionRequest') + .mockRejectedValueOnce(new Error('Something went wrong')); + }); + + it('shows an error message', async () => { + await mediator.toggleAttentionRequested('reviewer', { + user: { id: 1, username: 'root' }, + callback: jest.fn(), + direction: 'remove', + }); + + expect(createFlash).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Updating the attention request for root failed.', + }), + ); + }); + }); }); }); diff --git a/spec/frontend/single_file_diff_spec.js b/spec/frontend/single_file_diff_spec.js index 8718152655f..6f42ec47458 100644 --- a/spec/frontend/single_file_diff_spec.js +++ b/spec/frontend/single_file_diff_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; -import { setHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import axios from '~/lib/utils/axios_utils'; import SingleFileDiff from '~/single_file_diff'; @@ -15,6 +15,7 @@ describe('SingleFileDiff', () => { afterEach(() => { mock.restore(); + resetHTMLFixture(); }); it('loads diff via axios exactly once for collapsed diffs', async () => { diff --git a/spec/frontend/smart_interval_spec.js b/spec/frontend/smart_interval_spec.js index 1a2fd7ff8f1..5dda097ae6a 100644 --- a/spec/frontend/smart_interval_spec.js +++ b/spec/frontend/smart_interval_spec.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import { assignIn } from 'lodash'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; import SmartInterval from '~/smart_interval'; @@ -116,11 +117,15 @@ describe('SmartInterval', () => { describe('DOM Events', () => { beforeEach(() => { // This ensures DOM and DOM events are initialized for these specs. - setFixtures('<div></div>'); + setHTMLFixture('<div></div>'); interval = createDefaultSmartInterval(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('should pause when page is not visible', () => { jest.runOnlyPendingTimers(); diff --git a/spec/frontend/snippet/collapsible_input_spec.js b/spec/frontend/snippet/collapsible_input_spec.js index 3f14a9cd1a1..56e64d136c2 100644 --- a/spec/frontend/snippet/collapsible_input_spec.js +++ b/spec/frontend/snippet/collapsible_input_spec.js @@ -1,4 +1,4 @@ -import { setHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import setupCollapsibleInputs from '~/snippet/collapsible_input'; describe('~/snippet/collapsible_input', () => { @@ -38,6 +38,10 @@ describe('~/snippet/collapsible_input', () => { setupCollapsibleInputs(); }); + afterEach(() => { + resetHTMLFixture(); + }); + const findInput = (el) => el.querySelector('textarea,input'); const findCollapsed = (el) => el.querySelector('.js-collapsed'); const findExpanded = (el) => el.querySelector('.js-expanded'); diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap index 2b26c306c68..fec300ddd7e 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap @@ -28,9 +28,9 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = data-uploads-path="" > <markdown-header-stub - data-testid="markdownHeader" enablepreview="true" linecontent="" + restrictedtoolbaritems="" suggestionstartindex="0" /> @@ -81,6 +81,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = canattachfile="true" markdowndocspath="help/" quickactionsdocspath="" + showcommenttoolbar="true" /> </div> </div> diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index 9cfe136129a..8a767765149 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -1,5 +1,4 @@ -import { GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlFormGroup, GlLoadingIcon } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import { merge } from 'lodash'; @@ -7,6 +6,7 @@ import VueApollo, { ApolloMutation } from 'vue-apollo'; import { useFakeDate } from 'helpers/fake_date'; import createMockApollo from 'helpers/mock_apollo_helper'; 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 * as urlUtils from '~/lib/utils/url_utility'; @@ -22,7 +22,6 @@ import { import CreateSnippetMutation from '~/snippets/mutations/create_snippet.mutation.graphql'; import UpdateSnippetMutation from '~/snippets/mutations/update_snippet.mutation.graphql'; import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue'; -import TitleField from '~/vue_shared/components/form/title.vue'; import { testEntries, createGQLSnippetsQueryResponse, createGQLSnippet } from '../test_utils'; jest.mock('~/flash'); @@ -112,19 +111,19 @@ describe('Snippet Edit app', () => { gon.relative_url_root = originalRelativeUrlRoot; }); - const findBlobActions = () => wrapper.find(SnippetBlobActionsEdit); - const findSubmitButton = () => wrapper.find('[data-testid="snippet-submit-btn"]'); - const findCancelButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]'); - const hasDisabledSubmit = () => Boolean(findSubmitButton().attributes('disabled')); - const clickSubmitBtn = () => wrapper.find('[data-testid="snippet-edit-form"]').trigger('submit'); + const findBlobActions = () => wrapper.findComponent(SnippetBlobActionsEdit); + const findCancelButton = () => wrapper.findByTestId('snippet-cancel-btn'); + const clickSubmitBtn = () => wrapper.findByTestId('snippet-edit-form').trigger('submit'); + const triggerBlobActions = (actions) => findBlobActions().vm.$emit('actions', actions); const setUploadFilesHtml = (paths) => { wrapper.vm.$el.innerHTML = paths .map((path) => `<input name="files[]" value="${path}">`) .join(''); }; - const setTitle = (val) => wrapper.find(TitleField).vm.$emit('input', val); - const setDescription = (val) => wrapper.find(SnippetDescriptionEdit).vm.$emit('input', val); + const setTitle = (val) => wrapper.findByTestId('snippet-title-input').vm.$emit('input', val); + const setDescription = (val) => + wrapper.findComponent(SnippetDescriptionEdit).vm.$emit('input', val); const createComponent = ({ props = {}, selectedLevel = SNIPPET_VISIBILITY_PRIVATE } = {}) => { if (wrapper) { @@ -139,7 +138,7 @@ describe('Snippet Edit app', () => { ]; const apolloProvider = createMockApollo(requestHandlers); - wrapper = shallowMount(SnippetEditApp, { + wrapper = shallowMountExtended(SnippetEditApp, { apolloProvider, stubs: { ApolloMutation, @@ -177,7 +176,7 @@ describe('Snippet Edit app', () => { it('renders loader', () => { createComponent(); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); }); @@ -193,10 +192,10 @@ describe('Snippet Edit app', () => { }); it('should render components', () => { - expect(wrapper.find(TitleField).exists()).toBe(true); - expect(wrapper.find(SnippetDescriptionEdit).exists()).toBe(true); - expect(wrapper.find(SnippetVisibilityEdit).exists()).toBe(true); - expect(wrapper.find(FormFooterActions).exists()).toBe(true); + expect(wrapper.findComponent(GlFormGroup).attributes('label')).toEqual('Title'); + expect(wrapper.findComponent(SnippetDescriptionEdit).exists()).toBe(true); + expect(wrapper.findComponent(SnippetVisibilityEdit).exists()).toBe(true); + expect(wrapper.findComponent(FormFooterActions).exists()).toBe(true); expect(findBlobActions().exists()).toBe(true); }); @@ -207,25 +206,34 @@ describe('Snippet Edit app', () => { describe('default', () => { it.each` - title | actions | shouldDisable - ${''} | ${[]} | ${true} - ${''} | ${[TEST_ACTIONS.VALID]} | ${true} - ${'foo'} | ${[]} | ${false} - ${'foo'} | ${[TEST_ACTIONS.VALID]} | ${false} - ${'foo'} | ${[TEST_ACTIONS.VALID, TEST_ACTIONS.NO_CONTENT]} | ${true} - ${'foo'} | ${[TEST_ACTIONS.VALID, TEST_ACTIONS.NO_PATH]} | ${false} + title | actions | titleHasErrors | blobActionsHasErrors + ${''} | ${[]} | ${true} | ${false} + ${''} | ${[TEST_ACTIONS.VALID]} | ${true} | ${false} + ${'foo'} | ${[]} | ${false} | ${false} + ${'foo'} | ${[TEST_ACTIONS.VALID]} | ${false} | ${false} + ${'foo'} | ${[TEST_ACTIONS.VALID, TEST_ACTIONS.NO_CONTENT]} | ${false} | ${true} + ${'foo'} | ${[TEST_ACTIONS.VALID, TEST_ACTIONS.NO_PATH]} | ${false} | ${false} `( - 'should handle submit disable (title="$title", actions="$actions", shouldDisable="$shouldDisable")', - async ({ title, actions, shouldDisable }) => { + 'validates correctly (title="$title", actions="$actions", titleHasErrors="$titleHasErrors", blobActionsHasErrors="$blobActionsHasErrors")', + async ({ title, actions, titleHasErrors, blobActionsHasErrors }) => { getSpy.mockResolvedValue(createQueryResponse({ title })); await createComponentAndLoad(); triggerBlobActions(actions); + clickSubmitBtn(); + await nextTick(); - expect(hasDisabledSubmit()).toBe(shouldDisable); + expect(wrapper.findComponent(GlFormGroup).exists()).toBe(true); + expect(Boolean(wrapper.findComponent(GlFormGroup).attributes('state'))).toEqual( + !titleHasErrors, + ); + + expect(wrapper.find(SnippetBlobActionsEdit).props('isValid')).toEqual( + !blobActionsHasErrors, + ); }, ); @@ -262,35 +270,64 @@ describe('Snippet Edit app', () => { ); describe('form submission handling', () => { - it.each` - snippetGid | projectPath | uploadedFiles | input | mutationType - ${''} | ${'project/path'} | ${[]} | ${{ ...getApiData(), projectPath: 'project/path', uploadedFiles: [] }} | ${'createSnippet'} - ${''} | ${''} | ${[]} | ${{ ...getApiData(), projectPath: '', uploadedFiles: [] }} | ${'createSnippet'} - ${''} | ${''} | ${TEST_UPLOADED_FILES} | ${{ ...getApiData(), projectPath: '', uploadedFiles: TEST_UPLOADED_FILES }} | ${'createSnippet'} - ${TEST_SNIPPET_GID} | ${'project/path'} | ${[]} | ${getApiData(createSnippet())} | ${'updateSnippet'} - ${TEST_SNIPPET_GID} | ${''} | ${[]} | ${getApiData(createSnippet())} | ${'updateSnippet'} - `( - 'should submit mutation $mutationType (snippetGid=$snippetGid, projectPath=$projectPath, uploadedFiles=$uploadedFiles)', - async ({ snippetGid, projectPath, uploadedFiles, mutationType, input }) => { - await createComponentAndLoad({ - props: { - snippetGid, - projectPath, - }, - }); - - setUploadFilesHtml(uploadedFiles); - - await nextTick(); - - clickSubmitBtn(); + describe('when creating a new snippet', () => { + it.each` + projectPath | uploadedFiles | input + ${''} | ${TEST_UPLOADED_FILES} | ${{ ...getApiData({ title: 'Title' }), projectPath: '', uploadedFiles: TEST_UPLOADED_FILES }} + ${'project/path'} | ${TEST_UPLOADED_FILES} | ${{ ...getApiData({ title: 'Title' }), projectPath: 'project/path', uploadedFiles: TEST_UPLOADED_FILES }} + `( + 'should submit a createSnippet mutation (projectPath=$projectPath, uploadedFiles=$uploadedFiles)', + async ({ projectPath, uploadedFiles, input }) => { + await createComponentAndLoad({ + props: { + snippetGid: '', + projectPath, + }, + }); + + setTitle(input.title); + setUploadFilesHtml(uploadedFiles); + + await nextTick(); + + clickSubmitBtn(); + + expect(mutateSpy).toHaveBeenCalledTimes(1); + expect(mutateSpy).toHaveBeenCalledWith('createSnippet', { + input, + }); + }, + ); + }); - expect(mutateSpy).toHaveBeenCalledTimes(1); - expect(mutateSpy).toHaveBeenCalledWith(mutationType, { - input, - }); - }, - ); + describe('when updating a snippet', () => { + it.each` + projectPath | uploadedFiles | input + ${''} | ${[]} | ${getApiData(createSnippet())} + ${'project/path'} | ${[]} | ${getApiData(createSnippet())} + `( + 'should submit an updateSnippet mutation (projectPath=$projectPath, uploadedFiles=$uploadedFiles)', + async ({ projectPath, uploadedFiles, input }) => { + await createComponentAndLoad({ + props: { + snippetGid: TEST_SNIPPET_GID, + projectPath, + }, + }); + + setUploadFilesHtml(uploadedFiles); + + await nextTick(); + + clickSubmitBtn(); + + expect(mutateSpy).toHaveBeenCalledTimes(1); + expect(mutateSpy).toHaveBeenCalledWith('updateSnippet', { + input, + }); + }, + ); + }); it('should redirect to snippet view on successful mutation', async () => { await createComponentAndSubmit(); @@ -298,30 +335,55 @@ describe('Snippet Edit app', () => { expect(urlUtils.redirectTo).toHaveBeenCalledWith(TEST_WEB_URL); }); - it.each` - snippetGid | projectPath | mutationRes | expectMessage - ${''} | ${'project/path'} | ${createMutationResponseWithErrors('createSnippet')} | ${`Can't create snippet: ${TEST_MUTATION_ERROR}`} - ${''} | ${''} | ${createMutationResponseWithErrors('createSnippet')} | ${`Can't create snippet: ${TEST_MUTATION_ERROR}`} - ${TEST_SNIPPET_GID} | ${'project/path'} | ${createMutationResponseWithErrors('updateSnippet')} | ${`Can't update snippet: ${TEST_MUTATION_ERROR}`} - ${TEST_SNIPPET_GID} | ${''} | ${createMutationResponseWithErrors('updateSnippet')} | ${`Can't update snippet: ${TEST_MUTATION_ERROR}`} - `( - 'should flash error with (snippet=$snippetGid, projectPath=$projectPath)', - async ({ snippetGid, projectPath, mutationRes, expectMessage }) => { - mutateSpy.mockResolvedValue(mutationRes); - - await createComponentAndSubmit({ - props: { - projectPath, - snippetGid, - }, + describe('when there are errors after creating a new snippet', () => { + it.each` + projectPath + ${'project/path'} + ${''} + `('should flash error (projectPath=$projectPath)', async ({ projectPath }) => { + mutateSpy.mockResolvedValue(createMutationResponseWithErrors('createSnippet')); + + await createComponentAndLoad({ + props: { projectPath, snippetGid: '' }, }); + setTitle('Title'); + + clickSubmitBtn(); + + await waitForPromises(); + expect(urlUtils.redirectTo).not.toHaveBeenCalled(); expect(createFlash).toHaveBeenCalledWith({ - message: expectMessage, + message: `Can't create snippet: ${TEST_MUTATION_ERROR}`, }); - }, - ); + }); + }); + + describe('when there are errors after updating a snippet', () => { + it.each` + projectPath + ${'project/path'} + ${''} + `( + 'should flash error with (snippet=$snippetGid, projectPath=$projectPath)', + async ({ projectPath }) => { + mutateSpy.mockResolvedValue(createMutationResponseWithErrors('updateSnippet')); + + await createComponentAndSubmit({ + props: { + projectPath, + snippetGid: TEST_SNIPPET_GID, + }, + }); + + expect(urlUtils.redirectTo).not.toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalledWith({ + message: `Can't update snippet: ${TEST_MUTATION_ERROR}`, + }); + }, + ); + }); describe('with apollo network error', () => { beforeEach(async () => { @@ -382,6 +444,7 @@ describe('Snippet Edit app', () => { false, () => { triggerBlobActions([testEntries.updated.diff]); + setTitle('test'); clickSubmitBtn(); }, ], diff --git a/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js index 8174ba5c693..df98312b498 100644 --- a/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js @@ -1,6 +1,7 @@ -import { shallowMount } from '@vue/test-utils'; import { times } from 'lodash'; import { nextTick } from 'vue'; +import { GlFormGroup } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_edit.vue'; import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue'; import { @@ -8,6 +9,7 @@ import { SNIPPET_BLOB_ACTION_CREATE, SNIPPET_BLOB_ACTION_MOVE, } from '~/snippets/constants'; +import { s__ } from '~/locale'; import { testEntries, createBlobFromTestEntry } from '../test_utils'; const TEST_BLOBS = [ @@ -29,7 +31,7 @@ describe('snippets/components/snippet_blob_actions_edit', () => { }); }; - const findLabel = () => wrapper.find('label'); + const findLabel = () => wrapper.findComponent(GlFormGroup); const findBlobEdits = () => wrapper.findAll(SnippetBlobEdit); const findBlobsData = () => findBlobEdits().wrappers.map((x) => ({ @@ -65,7 +67,7 @@ describe('snippets/components/snippet_blob_actions_edit', () => { }); it('renders label', () => { - expect(findLabel().text()).toBe('Files'); + expect(findLabel().attributes('label')).toBe('Files'); }); it(`renders delete button (show=true)`, () => { @@ -280,4 +282,32 @@ describe('snippets/components/snippet_blob_actions_edit', () => { expect(findAddButton().props('disabled')).toBe(true); }); }); + + describe('isValid prop', () => { + const validationMessage = s__( + "Snippets|Snippets can't contain empty files. Ensure all files have content, or delete them.", + ); + + describe('when not present', () => { + it('sets the label validation state to true', () => { + createComponent(); + + const label = findLabel(); + + expect(Boolean(label.attributes('state'))).toEqual(true); + expect(label.attributes('invalid-feedback')).toEqual(validationMessage); + }); + }); + + describe('when present', () => { + it('sets the label validation state to the value', () => { + createComponent({ isValid: false }); + + const label = findLabel(); + + expect(Boolean(label.attributes('state'))).toEqual(false); + expect(label.attributes('invalid-feedback')).toEqual(validationMessage); + }); + }); + }); }); diff --git a/spec/frontend/syntax_highlight_spec.js b/spec/frontend/syntax_highlight_spec.js index 8ad4f8d5c70..1be6c213350 100644 --- a/spec/frontend/syntax_highlight_spec.js +++ b/spec/frontend/syntax_highlight_spec.js @@ -1,6 +1,6 @@ /* eslint-disable no-return-assign */ - import $ from 'jquery'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import syntaxHighlight from '~/syntax_highlight'; describe('Syntax Highlighter', () => { @@ -20,7 +20,11 @@ describe('Syntax Highlighter', () => { `('highlight using $desc syntax', ({ fn }) => { describe('on a js-syntax-highlight element', () => { beforeEach(() => { - setFixtures('<div class="js-syntax-highlight"></div>'); + setHTMLFixture('<div class="js-syntax-highlight"></div>'); + }); + + afterEach(() => { + resetHTMLFixture(); }); it('applies syntax highlighting', () => { @@ -33,11 +37,15 @@ describe('Syntax Highlighter', () => { describe('on a parent element', () => { beforeEach(() => { - setFixtures( + setHTMLFixture( '<div class="parent">\n <div class="js-syntax-highlight"></div>\n <div class="foo"></div>\n <div class="js-syntax-highlight"></div>\n</div>', ); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('applies highlighting to all applicable children', () => { stubUserColorScheme('monokai'); syntaxHighlight(fn('.parent')); @@ -49,7 +57,7 @@ describe('Syntax Highlighter', () => { }); it('prevents an infinite loop when no matches exist', () => { - setFixtures('<div></div>'); + setHTMLFixture('<div></div>'); const highlight = () => syntaxHighlight(fn('div')); expect(highlight).not.toThrow(); diff --git a/spec/frontend/tabs/index_spec.js b/spec/frontend/tabs/index_spec.js index 98617b404ff..67e3d707adb 100644 --- a/spec/frontend/tabs/index_spec.js +++ b/spec/frontend/tabs/index_spec.js @@ -1,6 +1,6 @@ import { GlTabsBehavior, TAB_SHOWN_EVENT } from '~/tabs'; import { ACTIVE_PANEL_CLASS, ACTIVE_TAB_CLASSES } from '~/tabs/constants'; -import { getFixture, setHTMLFixture } from 'helpers/fixtures'; +import { getFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; const tabsFixture = getFixture('tabs/tabs.html'); @@ -93,6 +93,8 @@ describe('GlTabsBehavior', () => { describe('when given an element', () => { afterEach(() => { glTabs.destroy(); + + resetHTMLFixture(); }); beforeEach(() => { @@ -250,6 +252,10 @@ describe('GlTabsBehavior', () => { glTabs = new GlTabsBehavior(tabsEl); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('connects the panels to their tabs correctly', () => { findTab('bar').click(); diff --git a/spec/frontend/task_list_spec.js b/spec/frontend/task_list_spec.js index fbdb73ae6de..e79c516a694 100644 --- a/spec/frontend/task_list_spec.js +++ b/spec/frontend/task_list_spec.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import axios from '~/lib/utils/axios_utils'; import TaskList from '~/task_list'; @@ -14,7 +15,7 @@ describe('TaskList', () => { const createTaskList = () => new TaskList(taskListOptions); beforeEach(() => { - setFixtures(` + setHTMLFixture(` <div class="task-list"> <div class="js-task-list-container"> <ul data-sourcepos="5:1-5:11" class="task-list" dir="auto"> @@ -37,6 +38,10 @@ describe('TaskList', () => { taskList = createTaskList(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('should call init when the class constructed', () => { jest.spyOn(TaskList.prototype, 'init'); jest.spyOn(TaskList.prototype, 'disable').mockImplementation(() => {}); diff --git a/spec/frontend/tracking/tracking_spec.js b/spec/frontend/tracking/tracking_spec.js index 665bf44fc77..08da3a9a465 100644 --- a/spec/frontend/tracking/tracking_spec.js +++ b/spec/frontend/tracking/tracking_spec.js @@ -76,6 +76,18 @@ describe('Tracking', () => { ); }); + it('returns `true` if the Snowplow library was called without issues', () => { + expect(Tracking.event(TEST_CATEGORY, TEST_ACTION)).toBe(true); + }); + + it('returns `false` if the Snowplow library throws an error', () => { + snowplowSpy.mockImplementation(() => { + throw new Error(); + }); + + expect(Tracking.event(TEST_CATEGORY, TEST_ACTION)).toBe(false); + }); + it('allows adding extra data to the default context', () => { const extra = { foo: 'bar' }; 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 08eb8ae0843..fb5093eb065 100644 --- a/spec/frontend/user_lists/components/user_lists_table_spec.js +++ b/spec/frontend/user_lists/components/user_lists_table_spec.js @@ -2,6 +2,7 @@ import { GlModal } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import * as timeago from 'timeago.js'; import { nextTick } from 'vue'; +import { timeagoLanguageCode } from '~/lib/utils/datetime/timeago_utility'; import UserListsTable from '~/user_lists/components/user_lists_table.vue'; import { userList } from 'jest/feature_flags/mock_data'; @@ -31,7 +32,7 @@ describe('User Lists Table', () => { userList.user_xids.replace(/,/g, ', '), ); expect(wrapper.find('[data-testid="ffUserListTimestamp"]').text()).toBe('created 2 weeks ago'); - expect(timeago.format).toHaveBeenCalledWith(userList.created_at); + expect(timeago.format).toHaveBeenCalledWith(userList.created_at, timeagoLanguageCode); }); it('should set the title for a tooltip on the created stamp', () => { diff --git a/spec/frontend/user_popovers_spec.js b/spec/frontend/user_popovers_spec.js index 745b66fd700..fa598716645 100644 --- a/spec/frontend/user_popovers_spec.js +++ b/spec/frontend/user_popovers_spec.js @@ -1,5 +1,13 @@ +import { within } from '@testing-library/dom'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import UsersCache from '~/lib/utils/users_cache'; import initUserPopovers from '~/user_popovers'; +import waitForPromises from 'helpers/wait_for_promises'; + +jest.mock('~/api/user_api', () => ({ + followUser: jest.fn().mockResolvedValue({}), + unfollowUser: jest.fn().mockResolvedValue({}), +})); describe('User Popovers', () => { const fixtureTemplate = 'merge_requests/merge_request_with_mentions.html'; @@ -19,7 +27,7 @@ describe('User Popovers', () => { return link; }; - const dummyUser = { name: 'root' }; + const dummyUser = { name: 'root', username: 'root', is_followed: false }; const dummyUserStatus = { message: 'active' }; let popovers; @@ -35,7 +43,7 @@ describe('User Popovers', () => { }; beforeEach(() => { - loadFixtures(fixtureTemplate); + loadHTMLFixture(fixtureTemplate); const usersCacheSpy = () => Promise.resolve(dummyUser); jest.spyOn(UsersCache, 'retrieveById').mockImplementation((userId) => usersCacheSpy(userId)); @@ -44,10 +52,15 @@ describe('User Popovers', () => { jest .spyOn(UsersCache, 'retrieveStatusById') .mockImplementation((userId) => userStatusCacheSpy(userId)); + jest.spyOn(UsersCache, 'updateById'); popovers = initUserPopovers(document.querySelectorAll(selector)); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('initializes a popover for each user link with a user id', () => { const linksWithUsers = findFixtureLinks(); @@ -115,4 +128,32 @@ describe('User Popovers', () => { expect(userLink.getAttribute('aria-describedby')).toBe(null); }); + + it('updates toggle follow button and `UsersCache` when toggle follow button is clicked', async () => { + const [firstPopover] = popovers; + const withinFirstPopover = within(firstPopover.$el); + const findFollowButton = () => withinFirstPopover.queryByRole('button', { name: 'Follow' }); + const findUnfollowButton = () => withinFirstPopover.queryByRole('button', { name: 'Unfollow' }); + + const userLink = document.querySelector(selector); + triggerEvent('mouseenter', userLink); + + await waitForPromises(); + + const { userId } = document.querySelector(selector).dataset; + + triggerEvent('click', findFollowButton()); + + await waitForPromises(); + + expect(findUnfollowButton()).not.toBe(null); + expect(UsersCache.updateById).toHaveBeenCalledWith(userId, { is_followed: true }); + + triggerEvent('click', findUnfollowButton()); + + await waitForPromises(); + + expect(findFollowButton()).not.toBe(null); + expect(UsersCache.updateById).toHaveBeenCalledWith(userId, { is_followed: false }); + }); }); diff --git a/spec/frontend/vue_alerts_spec.js b/spec/frontend/vue_alerts_spec.js index 1952eea4a01..de2faa09438 100644 --- a/spec/frontend/vue_alerts_spec.js +++ b/spec/frontend/vue_alerts_spec.js @@ -1,5 +1,5 @@ import { nextTick } from 'vue'; -import { setHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import initVueAlerts from '~/vue_alerts'; @@ -40,6 +40,10 @@ describe('VueAlerts', () => { ); }); + afterEach(() => { + resetHTMLFixture(); + }); + const findJsHooks = () => document.querySelectorAll('.js-vue-alert'); const findAlerts = () => document.querySelectorAll('.gl-alert'); const findAlertDismiss = (alert) => alert.querySelector('.gl-dismiss-btn'); diff --git a/spec/frontend/vue_mr_widget/components/added_commit_message_spec.js b/spec/frontend/vue_mr_widget/components/added_commit_message_spec.js new file mode 100644 index 00000000000..150680caa7e --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/added_commit_message_spec.js @@ -0,0 +1,31 @@ +import { shallowMount } from '@vue/test-utils'; +import AddedCommentMessage from '~/vue_merge_request_widget/components/added_commit_message.vue'; + +let wrapper; + +function factory(propsData) { + wrapper = shallowMount(AddedCommentMessage, { + propsData: { + isFastForwardEnabled: false, + targetBranch: 'main', + ...propsData, + }, + provide: { + glFeatures: { + restructuredMrWidget: true.valueOf, + }, + }, + }); +} + +describe('Widget added commit message', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('displays changes where not merged when state is closed', () => { + factory({ state: 'closed' }); + + expect(wrapper.element.outerHTML).toContain('The changes were not merged'); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js b/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js index 98cfc04eb25..5799799ad5e 100644 --- a/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js +++ b/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js @@ -2,16 +2,18 @@ import { generateText } from '~/vue_merge_request_widget/components/extensions/u describe('generateText', () => { it.each` - text | expectedText - ${'%{strong_start}Hello world%{strong_end}'} | ${'<span class="gl-font-weight-bold">Hello world</span>'} - ${'%{success_start}Hello world%{success_end}'} | ${'<span class="gl-font-weight-bold gl-text-green-500">Hello world</span>'} - ${'%{danger_start}Hello world%{danger_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-500">Hello world</span>'} - ${'%{critical_start}Hello world%{critical_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-800">Hello world</span>'} - ${'%{same_start}Hello world%{same_end}'} | ${'<span class="gl-font-weight-bold gl-text-gray-700">Hello world</span>'} - ${'%{small_start}Hello world%{small_end}'} | ${'<span class="gl-font-sm gl-text-gray-700">Hello world</span>'} - ${'%{strong_start}%{danger_start}Hello world%{danger_end}%{strong_end}'} | ${'<span class="gl-font-weight-bold"><span class="gl-font-weight-bold gl-text-red-500">Hello world</span></span>'} - ${'%{no_exist_start}Hello world%{no_exist_end}'} | ${'Hello world'} - ${['array']} | ${null} + text | expectedText + ${'%{strong_start}Hello world%{strong_end}'} | ${'<span class="gl-font-weight-bold">Hello world</span>'} + ${'%{success_start}Hello world%{success_end}'} | ${'<span class="gl-font-weight-bold gl-text-green-500">Hello world</span>'} + ${'%{danger_start}Hello world%{danger_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-500">Hello world</span>'} + ${'%{critical_start}Hello world%{critical_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-800">Hello world</span>'} + ${'%{same_start}Hello world%{same_end}'} | ${'<span class="gl-font-weight-bold gl-text-gray-700">Hello world</span>'} + ${'%{small_start}Hello world%{small_end}'} | ${'<span class="gl-font-sm gl-text-gray-700">Hello world</span>'} + ${'%{strong_start}%{danger_start}Hello world%{danger_end}%{strong_end}'} | ${'<span class="gl-font-weight-bold"><span class="gl-font-weight-bold gl-text-red-500">Hello world</span></span>'} + ${'%{no_exist_start}Hello world%{no_exist_end}'} | ${'Hello world'} + ${{ text: 'Hello world', href: 'http://www.example.com' }} | ${'<a class="gl-text-decoration-underline" href="http://www.example.com">Hello world</a>'} + ${{ prependText: 'Hello', text: 'world', href: 'http://www.example.com' }} | ${'Hello <a class="gl-text-decoration-underline" href="http://www.example.com">world</a>'} + ${['array']} | ${null} `('generates $expectedText from $text', ({ text, expectedText }) => { expect(generateText(text)).toBe(expectedText); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js index 5a1f17573d4..ed6dc598845 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js @@ -1,7 +1,5 @@ import { shallowMount, mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; import Header from '~/vue_merge_request_widget/components/mr_widget_header.vue'; -import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; describe('MRWidgetHeader', () => { let wrapper; @@ -17,16 +15,6 @@ describe('MRWidgetHeader', () => { gon.relative_url_root = ''; }); - const expectDownloadDropdownItems = () => { - const downloadEmailPatchesEl = wrapper.find('.js-download-email-patches'); - const downloadPlainDiffEl = wrapper.find('.js-download-plain-diff'); - - expect(downloadEmailPatchesEl.text().trim()).toBe('Email patches'); - expect(downloadEmailPatchesEl.attributes('href')).toBe('/mr/email-patches'); - expect(downloadPlainDiffEl.text().trim()).toBe('Plain diff'); - expect(downloadPlainDiffEl.attributes('href')).toBe('/mr/plainDiffPath'); - }; - const commonMrProps = { divergedCommitsCount: 1, sourceBranch: 'mr-widget-refactor', @@ -36,8 +24,6 @@ describe('MRWidgetHeader', () => { statusPath: 'abc', }; - const findWebIdeButton = () => wrapper.findComponent(WebIdeLink); - describe('computed', () => { describe('shouldShowCommitsBehindText', () => { it('return true when there are divergedCommitsCount', () => { @@ -133,136 +119,6 @@ describe('MRWidgetHeader', () => { }); }); - describe('with an open merge request', () => { - const mrDefaultOptions = { - iid: 1, - divergedCommitsCount: 12, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', - sourceBranchRemoved: false, - targetBranchPath: 'foo/bar/commits-path', - targetBranchTreePath: 'foo/bar/tree/path', - targetBranch: 'main', - isOpen: true, - canPushToSourceBranch: true, - emailPatchesPath: '/mr/email-patches', - plainDiffPath: '/mr/plainDiffPath', - statusPath: 'abc', - sourceProjectFullPath: 'root/gitlab-ce', - targetProjectFullPath: 'gitlab-org/gitlab-ce', - gitpodEnabled: true, - showGitpodButton: true, - gitpodUrl: 'http://gitpod.localhost', - userPreferencesGitpodPath: '/-/profile/preferences#user_gitpod_enabled', - userProfileEnableGitpodPath: '/-/profile?user%5Bgitpod_enabled%5D=true', - }; - - it('renders checkout branch button with modal trigger', () => { - createComponent({ - mr: { ...mrDefaultOptions }, - }); - - const button = wrapper.find('.js-check-out-branch'); - - expect(button.text().trim()).toBe('Check out branch'); - }); - - it.each([ - [ - 'renders web ide button', - { - mrProps: {}, - relativeUrl: '', - webIdeUrl: - '/-/ide/project/root/gitlab-ce/merge_requests/1?target_project=gitlab-org%2Fgitlab-ce', - }, - ], - [ - 'renders web ide button with blank target_project, when mr has same target project', - { - mrProps: { targetProjectFullPath: 'root/gitlab-ce' }, - relativeUrl: '', - webIdeUrl: '/-/ide/project/root/gitlab-ce/merge_requests/1?target_project=', - }, - ], - [ - 'renders web ide button with relative url', - { - mrProps: { iid: 2 }, - relativeUrl: '/gitlab', - webIdeUrl: - '/gitlab/-/ide/project/root/gitlab-ce/merge_requests/2?target_project=gitlab-org%2Fgitlab-ce', - }, - ], - ])('%s', async (_, { mrProps, relativeUrl, webIdeUrl }) => { - gon.relative_url_root = relativeUrl; - createComponent({ - mr: { ...mrDefaultOptions, ...mrProps }, - }); - - await nextTick(); - - expect(findWebIdeButton().props()).toMatchObject({ - showEditButton: false, - showWebIdeButton: true, - webIdeText: 'Open in Web IDE', - gitpodText: 'Open in Gitpod', - gitpodEnabled: true, - showGitpodButton: true, - gitpodUrl: 'http://gitpod.localhost', - userPreferencesGitpodPath: mrDefaultOptions.userPreferencesGitpodPath, - userProfileEnableGitpodPath: mrDefaultOptions.userProfileEnableGitpodPath, - webIdeUrl, - }); - }); - - it('does not render web ide button if source branch is removed', async () => { - createComponent({ mr: { ...mrDefaultOptions, sourceBranchRemoved: true } }); - - await nextTick(); - - expect(findWebIdeButton().exists()).toBe(false); - }); - - it('renders download dropdown with links', () => { - createComponent({ - mr: { ...mrDefaultOptions }, - }); - - expectDownloadDropdownItems(); - }); - }); - - describe('with a closed merge request', () => { - beforeEach(() => { - createComponent({ - mr: { - divergedCommitsCount: 12, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', - sourceBranchRemoved: false, - targetBranchPath: 'foo/bar/commits-path', - targetBranchTreePath: 'foo/bar/tree/path', - targetBranch: 'main', - isOpen: false, - emailPatchesPath: '/mr/email-patches', - plainDiffPath: '/mr/plainDiffPath', - statusPath: 'abc', - }, - }); - }); - - it('does not render checkout branch button with modal trigger', () => { - const button = wrapper.find('.js-check-out-branch'); - - expect(button.exists()).toBe(false); - }); - - it('renders download dropdown with links', () => { - expectDownloadDropdownItems(); - }); - }); - describe('without diverged commits', () => { beforeEach(() => { createComponent({ diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js index 0e364eb6800..da3a323e8ea 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -59,6 +59,7 @@ const createTestMr = (customConfig) => { mergeImmediatelyDocsPath: 'path/to/merge/immediately/docs', transitionStateMachine: (transition) => eventHub.$emit('StateMachineValueChanged', transition), translateStateToMachine: () => this.transitionStateMachine(), + state: 'open', }; Object.assign(mr, customConfig.mr); @@ -321,7 +322,6 @@ describe('ReadyToMerge', () => { await waitForPromises(); - expect(wrapper.vm.isMakingRequest).toBeTruthy(); expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); expect(eventHub.$emit).toHaveBeenCalledWith('StateMachineValueChanged', { transition: 'start-auto-merge', @@ -348,7 +348,6 @@ describe('ReadyToMerge', () => { await waitForPromises(); - expect(wrapper.vm.isMakingRequest).toBeTruthy(); expect(eventHub.$emit).toHaveBeenCalledWith('FailedToMerge', undefined); const params = wrapper.vm.service.merge.mock.calls[0][0]; @@ -371,7 +370,6 @@ describe('ReadyToMerge', () => { await waitForPromises(); - expect(wrapper.vm.isMakingRequest).toBeTruthy(); expect(wrapper.vm.mr.transitionStateMachine).toHaveBeenCalledWith({ transition: 'start-merge', }); diff --git a/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js b/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js index 88b8e32bd5d..2bc6860743a 100644 --- a/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js +++ b/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js @@ -16,6 +16,7 @@ import newErrorsTestReports from 'jest/reports/mock_data/new_errors_report.json' import newFailedTestReports from 'jest/reports/mock_data/new_failures_report.json'; import successTestReports from 'jest/reports/mock_data/no_failures_report.json'; import resolvedFailures from 'jest/reports/mock_data/resolved_failures.json'; +import recentFailures from 'jest/reports/mock_data/recent_failures_report.json'; const reportWithParsingErrors = failedReport; reportWithParsingErrors.suites[0].suite_errors = { @@ -101,6 +102,17 @@ describe('Test report extension', () => { expect(wrapper.text()).toContain(expectedResult); }); + it('displays report level recently failed count', async () => { + mockApi(httpStatusCodes.OK, recentFailures); + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toContain( + '2 out of 3 failed tests have failed more than once in the last 14 days', + ); + }); + it('displays a link to the full report', async () => { mockApi(httpStatusCodes.OK); createComponent(); @@ -125,10 +137,10 @@ describe('Test report extension', () => { it('displays summary for each suite', async () => { await createExpandedWidgetWithData(); - expect(trimText(findAllExtensionListItems().at(0).text())).toBe( + expect(trimText(findAllExtensionListItems().at(0).text())).toContain( 'rspec:pg: 1 failed and 2 fixed test results, 8 total tests', ); - expect(trimText(findAllExtensionListItems().at(1).text())).toBe( + expect(trimText(findAllExtensionListItems().at(1).text())).toContain( 'java ant: 1 failed, 3 total tests', ); }); @@ -145,5 +157,37 @@ describe('Test report extension', () => { 'Base report parsing error: JUnit data parsing failed: string not matched', ); }); + + it('displays suite level recently failed count', async () => { + await createExpandedWidgetWithData(recentFailures); + + expect(trimText(findAllExtensionListItems().at(0).text())).toContain( + '1 out of 2 failed tests has failed more than once in the last 14 days', + ); + expect(trimText(findAllExtensionListItems().at(1).text())).toContain( + '1 out of 1 failed test has failed more than once in the last 14 days', + ); + }); + + it('displays the list of failed and fixed tests', async () => { + await createExpandedWidgetWithData(); + + const firstSuite = trimText(findAllExtensionListItems().at(0).text()); + const secondSuite = trimText(findAllExtensionListItems().at(1).text()); + + expect(firstSuite).toContain('Test#subtract when a is 2 and b is 1 returns correct result'); + expect(firstSuite).toContain('Test#sum when a is 1 and b is 2 returns summary'); + expect(firstSuite).toContain('Test#sum when a is 100 and b is 200 returns summary'); + + expect(secondSuite).toContain('sumTest'); + }); + + it('displays the test level recently failed count', async () => { + await createExpandedWidgetWithData(recentFailures); + + expect(trimText(findAllExtensionListItems().at(0).text())).toContain( + 'Failed 8 times in main in the last 14 days', + ); + }); }); }); diff --git a/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js b/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js index ea422a57956..6d1b3bb34a5 100644 --- a/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js +++ b/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js @@ -107,7 +107,7 @@ describe('Accessibility extension', () => { it('displays report list item formatted', () => { const text = { newError: trimText(findAllExtensionListItems().at(0).text()), - resolvedError: findAllExtensionListItems().at(3).text(), + resolvedError: trimText(findAllExtensionListItems().at(3).text()), existingError: trimText(findAllExtensionListItems().at(6).text()), }; diff --git a/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js b/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js index 28b3bf5287a..8cbe0630426 100644 --- a/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js +++ b/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js @@ -3,6 +3,8 @@ import { mount, shallowMount } from '@vue/test-utils'; import ColorPicker from '~/vue_shared/components/color_picker/color_picker.vue'; +jest.mock('lodash/uniqueId', () => (prefix) => (prefix ? `${prefix}1` : 1)); + describe('ColorPicker', () => { let wrapper; @@ -14,10 +16,11 @@ describe('ColorPicker', () => { const setColor = '#000000'; const invalidText = 'Please enter a valid hex (#RRGGBB or #RGB) color value'; - const label = () => wrapper.find(GlFormGroup).attributes('label'); + const findGlFormGroup = () => wrapper.find(GlFormGroup); const colorPreview = () => wrapper.find('[data-testid="color-preview"]'); const colorPicker = () => wrapper.find(GlFormInput); - const colorInput = () => wrapper.find(GlFormInputGroup).find('input[type="text"]'); + const colorInput = () => wrapper.find('input[type="color"]'); + const colorTextInput = () => wrapper.find(GlFormInputGroup).find('input[type="text"]'); const invalidFeedback = () => wrapper.find('.invalid-feedback'); const description = () => wrapper.find(GlFormGroup).attributes('description'); const presetColors = () => wrapper.findAll(GlLink); @@ -39,13 +42,29 @@ describe('ColorPicker', () => { it('hides the label if the label is not passed', () => { createComponent(shallowMount); - expect(label()).toBe(''); + expect(findGlFormGroup().attributes('label')).toBe(''); }); it('shows the label if the label is passed', () => { createComponent(shallowMount, { label: 'test' }); - expect(label()).toBe('test'); + expect(findGlFormGroup().attributes('label')).toBe('test'); + }); + + describe.each` + desc | id + ${'with prop id'} | ${'test-id'} + ${'without prop id'} | ${undefined} + `('$desc', ({ id }) => { + beforeEach(() => { + createComponent(mount, { id, label: 'test' }); + }); + + it('renders the same `ID` for input and `for` for label', () => { + expect(findGlFormGroup().find('label').attributes('for')).toBe( + colorInput().attributes('id'), + ); + }); }); }); @@ -55,30 +74,30 @@ describe('ColorPicker', () => { expect(colorPreview().attributes('style')).toBe(undefined); expect(colorPicker().attributes('value')).toBe(undefined); - expect(colorInput().props('value')).toBe(''); + expect(colorTextInput().props('value')).toBe(''); expect(colorPreview().attributes('class')).toContain('gl-inset-border-1-gray-400'); }); it('has a color set on initialization', () => { createComponent(mount, { value: setColor }); - expect(colorInput().props('value')).toBe(setColor); + expect(colorTextInput().props('value')).toBe(setColor); }); it('emits input event from component when a color is selected', async () => { createComponent(); - await colorInput().setValue(setColor); + await colorTextInput().setValue(setColor); expect(wrapper.emitted().input[0]).toStrictEqual([setColor]); }); it('trims spaces from submitted colors', async () => { createComponent(); - await colorInput().setValue(` ${setColor} `); + await colorTextInput().setValue(` ${setColor} `); expect(wrapper.emitted().input[0]).toStrictEqual([setColor]); expect(colorPreview().attributes('class')).toContain('gl-inset-border-1-gray-400'); - expect(colorInput().attributes('class')).not.toContain('is-invalid'); + expect(colorTextInput().attributes('class')).not.toContain('is-invalid'); }); it('shows invalid feedback when the state is marked as invalid', async () => { @@ -86,14 +105,14 @@ describe('ColorPicker', () => { expect(invalidFeedback().text()).toBe(invalidText); expect(colorPreview().attributes('class')).toContain('gl-inset-border-1-red-500'); - expect(colorInput().attributes('class')).toContain('is-invalid'); + expect(colorTextInput().attributes('class')).toContain('is-invalid'); }); }); describe('inputs', () => { it('has color input value entered', async () => { createComponent(); - await colorInput().setValue(setColor); + await colorTextInput().setValue(setColor); expect(wrapper.emitted().input[0]).toStrictEqual([setColor]); }); diff --git a/spec/frontend/vue_shared/components/confidentiality_badge_spec.js b/spec/frontend/vue_shared/components/confidentiality_badge_spec.js new file mode 100644 index 00000000000..9d11fbbaf55 --- /dev/null +++ b/spec/frontend/vue_shared/components/confidentiality_badge_spec.js @@ -0,0 +1,52 @@ +import { GlBadge } from '@gitlab/ui'; + +import { shallowMount } from '@vue/test-utils'; +import { WorkspaceType, IssuableType } from '~/issues/constants'; + +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; + +const createComponent = ({ + workspaceType = WorkspaceType.project, + issuableType = IssuableType.Issue, +} = {}) => + shallowMount(ConfidentialityBadge, { + propsData: { + workspaceType, + issuableType, + }, + }); + +describe('ConfidentialityBadge', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + workspaceType | issuableType | expectedTooltip + ${WorkspaceType.project} | ${IssuableType.Issue} | ${'Only project members with at least Reporter role can view or be notified about this issue.'} + ${WorkspaceType.group} | ${IssuableType.Epic} | ${'Only group members with at least Reporter role can view or be notified about this epic.'} + `( + 'should render gl-badge with correct tooltip when workspaceType is $workspaceType and issuableType is $issuableType', + ({ workspaceType, issuableType, expectedTooltip }) => { + wrapper = createComponent({ + workspaceType, + issuableType, + }); + + const badgeEl = wrapper.findComponent(GlBadge); + + expect(badgeEl.props()).toMatchObject({ + icon: 'eye-slash', + variant: 'warning', + }); + expect(badgeEl.attributes('title')).toBe(expectedTooltip); + expect(badgeEl.text()).toBe('Confidential'); + }, + ); +}); diff --git a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js index f75694bd504..a660643d74f 100644 --- a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js +++ b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js @@ -3,6 +3,7 @@ import { CONFIRM_DANGER_WARNING, CONFIRM_DANGER_MODAL_BUTTON, CONFIRM_DANGER_MODAL_ID, + CONFIRM_DANGER_MODAL_CANCEL, } from '~/vue_shared/components/confirm_danger/constants'; import ConfirmDangerModal from '~/vue_shared/components/confirm_danger/confirm_danger_modal.vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -10,6 +11,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; describe('Confirm Danger Modal', () => { const confirmDangerMessage = 'This is a dangerous activity'; const confirmButtonText = 'Confirm button text'; + const cancelButtonText = 'Cancel button text'; const phrase = 'You must construct additional pylons'; const modalId = CONFIRM_DANGER_MODAL_ID; @@ -21,6 +23,7 @@ describe('Confirm Danger Modal', () => { const findDefaultWarning = () => wrapper.findByTestId('confirm-danger-warning'); const findAdditionalMessage = () => wrapper.findByTestId('confirm-danger-message'); const findPrimaryAction = () => findModal().props('actionPrimary'); + const findCancelAction = () => findModal().props('actionCancel'); const findPrimaryActionAttributes = (attr) => findPrimaryAction().attributes[0][attr]; const createComponent = ({ provide = {} } = {}) => @@ -34,7 +37,9 @@ describe('Confirm Danger Modal', () => { }); beforeEach(() => { - wrapper = createComponent({ provide: { confirmDangerMessage, confirmButtonText } }); + wrapper = createComponent({ + provide: { confirmDangerMessage, confirmButtonText, cancelButtonText }, + }); }); afterEach(() => { @@ -54,6 +59,10 @@ describe('Confirm Danger Modal', () => { expect(findPrimaryActionAttributes('variant')).toBe('danger'); }); + it('renders the cancel button', () => { + expect(findCancelAction().text).toBe(cancelButtonText); + }); + it('renders the correct confirmation phrase', () => { expect(findConfirmationPhrase().text()).toBe( `Please type ${phrase} to proceed or close this modal to cancel.`, @@ -72,6 +81,10 @@ describe('Confirm Danger Modal', () => { it('renders the default confirm button', () => { expect(findPrimaryAction().text).toBe(CONFIRM_DANGER_MODAL_BUTTON); }); + + it('renders the default cancel button', () => { + expect(findCancelAction().text).toBe(CONFIRM_DANGER_MODAL_CANCEL); + }); }); describe('with a valid confirmation phrase', () => { diff --git a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js index d4b6b987c69..aa41df438d2 100644 --- a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js +++ b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js @@ -15,7 +15,7 @@ describe('DateTimePicker', () => { const dropdownToggle = () => wrapper.find('.dropdown-toggle'); const dropdownMenu = () => wrapper.find('.dropdown-menu'); const cancelButton = () => wrapper.find('[data-testid="cancelButton"]'); - const applyButtonElement = () => wrapper.find('button.btn-success').element; + const applyButtonElement = () => wrapper.find('button.btn-confirm').element; const findQuickRangeItems = () => wrapper.findAll('.dropdown-item'); const createComponent = (props) => { diff --git a/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js b/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js index 59653a0ec13..e3d8bfd22ca 100644 --- a/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js +++ b/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js @@ -6,12 +6,16 @@ import { folder } from './mock_data'; describe('Deploy Board Instance', () => { let wrapper; - const createComponent = (props = {}) => + const createComponent = (props = {}, provide) => shallowMount(DeployBoardInstance, { propsData: { status: 'succeeded', ...props, }, + provide: { + glFeatures: { monitorLogging: true }, + ...provide, + }, }); describe('as a non-canary deployment', () => { @@ -95,4 +99,23 @@ describe('Deploy Board Instance', () => { expect(wrapper.attributes('title')).toEqual(''); }); }); + + describe(':monitor_logging feature flag', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + flagState | logsState | expected + ${true} | ${'shows'} | ${'/root/review-app/-/logs?environment_name=foo&pod_name=tanuki-1'} + ${false} | ${'hides'} | ${undefined} + `('$logsState log link when flag state is $flagState', async ({ flagState, expected }) => { + wrapper = createComponent( + { logsPath: folder.logs_path, podName: 'tanuki-1' }, + { glFeatures: { monitorLogging: flagState } }, + ); + + expect(wrapper.attributes('href')).toEqual(expected); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js deleted file mode 100644 index 30b8e869aab..00000000000 --- a/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js +++ /dev/null @@ -1,36 +0,0 @@ -import Vue from 'vue'; - -import mountComponent from 'helpers/vue_mount_component_helper'; -import dropdownHiddenInputComponent from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; - -import { mockLabels } from './mock_data'; - -const createComponent = (name = 'label_id[]', value = mockLabels[0].id) => { - const Component = Vue.extend(dropdownHiddenInputComponent); - - return mountComponent(Component, { - name, - value, - }); -}; - -describe('DropdownHiddenInputComponent', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('template', () => { - it('renders input element of type `hidden`', () => { - expect(vm.$el.nodeName).toBe('INPUT'); - expect(vm.$el.getAttribute('type')).toBe('hidden'); - expect(vm.$el.getAttribute('name')).toBe(vm.name); - expect(vm.$el.getAttribute('value')).toBe(`${vm.value}`); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js deleted file mode 100644 index b32dbeb8852..00000000000 --- a/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js +++ /dev/null @@ -1,51 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import DropdownSearchInputComponent from '~/vue_shared/components/dropdown/dropdown_search_input.vue'; - -describe('DropdownSearchInputComponent', () => { - let wrapper; - - const defaultProps = { - placeholderText: 'Search something', - }; - const buildVM = (propsData = defaultProps) => { - wrapper = mount(DropdownSearchInputComponent, { - propsData, - }); - }; - const findInputEl = () => wrapper.find('.dropdown-input-field'); - - beforeEach(() => { - buildVM(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('template', () => { - it('renders input element with type `search`', () => { - expect(findInputEl().exists()).toBe(true); - expect(findInputEl().attributes('type')).toBe('search'); - }); - - it('renders search icon element', () => { - expect(wrapper.find('.dropdown-input-search[data-testid="search-icon"]').exists()).toBe(true); - }); - - it('displays custom placeholder text', () => { - expect(findInputEl().attributes('placeholder')).toBe(defaultProps.placeholderText); - }); - - it('focuses input element when focused property equals true', async () => { - const inputEl = findInputEl().element; - - jest.spyOn(inputEl, 'focus'); - - wrapper.setProps({ focused: true }); - - await nextTick(); - expect(inputEl.focus).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/file_finder/index_spec.js b/spec/frontend/vue_shared/components/file_finder/index_spec.js index 921091c5b84..5cf891a2e52 100644 --- a/spec/frontend/vue_shared/components/file_finder/index_spec.js +++ b/spec/frontend/vue_shared/components/file_finder/index_spec.js @@ -1,5 +1,6 @@ import Mousetrap from 'mousetrap'; import Vue, { nextTick } from 'vue'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { file } from 'jest/ide/helpers'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; import FindFileComponent from '~/vue_shared/components/file_finder/index.vue'; @@ -22,7 +23,11 @@ describe('File finder item spec', () => { } beforeEach(() => { - setFixtures('<div id="app"></div>'); + setHTMLFixture('<div id="app"></div>'); + }); + + afterEach(() => { + resetHTMLFixture(); }); afterEach(() => { @@ -105,18 +110,6 @@ describe('File finder item spec', () => { }); }); - describe('listHeight', () => { - it('returns 55 when entries exist', () => { - expect(vm.listHeight).toBe(55); - }); - - it('returns 33 when entries dont exist', () => { - vm.searchText = 'testing 123'; - - expect(vm.listHeight).toBe(33); - }); - }); - describe('filteredBlobsLength', () => { it('returns length of filtered blobs', () => { vm.searchText = 'index'; @@ -253,11 +246,9 @@ describe('File finder item spec', () => { describe('without entries', () => { it('renders loading text when loading', () => { - createComponent({ - loading: true, - }); + createComponent({ loading: true }); - expect(vm.$el.textContent).toContain('Loading...'); + expect(vm.$el.querySelector('.gl-spinner')).not.toBe(null); }); it('renders no files text', () => { @@ -307,7 +298,7 @@ describe('File finder item spec', () => { }); it('stops callback in monaco editor', () => { - setFixtures('<div class="inputarea"></div>'); + setHTMLFixture('<div class="inputarea"></div>'); expect( Mousetrap.prototype.stopCallback(null, document.querySelector('.inputarea'), 't'), diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js index b6a181e6a0b..e44bc8771f5 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js @@ -11,7 +11,10 @@ import { shallowMount, mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; -import { SortDirection } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + FILTERED_SEARCH_TERM, + SortDirection, +} from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { uniqueTokens } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; @@ -68,6 +71,10 @@ const createComponent = ({ describe('FilteredSearchBarRoot', () => { let wrapper; + const findGlButton = () => wrapper.findComponent(GlButton); + const findGlDropdown = () => wrapper.findComponent(GlDropdown); + const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); + beforeEach(() => { wrapper = createComponent({ sortOptions: mockSortOptions }); }); @@ -79,7 +86,7 @@ describe('FilteredSearchBarRoot', () => { describe('data', () => { it('initializes `filterValue`, `selectedSortOption` and `selectedSortDirection` data props and displays the sort dropdown', () => { expect(wrapper.vm.filterValue).toEqual([]); - expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[0].sortDirection.descending); + expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[0]); expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.descending); expect(wrapper.find(GlButtonGroup).exists()).toBe(true); expect(wrapper.find(GlButton).exists()).toBe(true); @@ -225,9 +232,7 @@ describe('FilteredSearchBarRoot', () => { }); it('initializes `recentSearchesPromise` prop with a promise by using `recentSearchesService.fetch()`', () => { - jest - .spyOn(wrapper.vm.recentSearchesService, 'fetch') - .mockReturnValue(new Promise(() => [])); + jest.spyOn(wrapper.vm.recentSearchesService, 'fetch').mockResolvedValue([]); wrapper.vm.setupRecentSearch(); @@ -489,4 +494,40 @@ describe('FilteredSearchBarRoot', () => { expect(sortButtonEl.props('icon')).toBe('sort-highest'); }); }); + + describe('watchers', () => { + const tokenValue = { + id: 'id-1', + type: FILTERED_SEARCH_TERM, + value: { data: '' }, + }; + + it('syncs filter value', async () => { + await wrapper.setProps({ initialFilterValue: [tokenValue], syncFilterAndSort: true }); + + expect(findGlFilteredSearch().props('value')).toEqual([tokenValue]); + }); + + it('does not sync filter value when syncFilterAndSort=false', async () => { + await wrapper.setProps({ initialFilterValue: [tokenValue], syncFilterAndSort: false }); + + expect(findGlFilteredSearch().props('value')).toEqual([]); + }); + + it('syncs sort values', async () => { + await wrapper.setProps({ initialSortBy: 'updated_asc', syncFilterAndSort: true }); + + expect(findGlDropdown().props('text')).toBe('Last updated'); + expect(findGlButton().props('icon')).toBe('sort-lowest'); + expect(findGlButton().attributes('aria-label')).toBe('Sort direction: Ascending'); + }); + + it('does not sync sort values when syncFilterAndSort=false', async () => { + await wrapper.setProps({ initialSortBy: 'updated_asc', syncFilterAndSort: false }); + + expect(findGlDropdown().props('text')).toBe('Created date'); + expect(findGlButton().props('icon')).toBe('sort-highest'); + expect(findGlButton().attributes('aria-label')).toBe('Sort direction: Descending'); + }); + }); }); 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 87066b70023..3f24d5df858 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 @@ -51,6 +51,7 @@ function createComponent(options = {}) { config, value, active, + cursorPosition: 'start', }, provide: { portalName: 'fake target', diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js index af8a2a496ea..ca8cd419d87 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js @@ -78,6 +78,7 @@ const mockProps = { suggestionsLoading: false, defaultSuggestions: DEFAULT_NONE_ANY, getActiveTokenValue: (labels, data) => labels.find((label) => label.title === data), + cursorPosition: 'start', }; function createComponent({ 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 7a7db434052..7b495ec9bee 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 @@ -39,6 +39,7 @@ function createComponent(options = {}) { config, value, active, + cursorPosition: 'start', }, provide: { portalName: 'fake target', 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 b163563cea4..dcb0d095b1b 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 @@ -45,6 +45,7 @@ function createComponent(options = {}) { config, value, active, + cursorPosition: 'start', }, provide: { portalName: 'fake target', 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 52df27c2d00..f03a2e7934f 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 @@ -45,6 +45,7 @@ function createComponent(options = {}) { config, value, active, + cursorPosition: 'start', }, provide: { portalName: 'fake target', 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 de9ec863dd5..7c545f76c0b 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 @@ -42,6 +42,7 @@ function createComponent(options = {}) { config, value, active, + cursorPosition: 'start', }, provide: { portalName: 'fake target', 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 8be21b35414..4bbbaab9b7a 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 @@ -18,6 +18,7 @@ describe('ReleaseToken', () => { active: false, config, value, + cursorPosition: 'start', }, provide: { portalName: 'fake target', 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 b673e5407d4..b180e8c12dd 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,7 @@ import { GlBadge } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import flushPromises from 'helpers/flush_promises'; +import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import GitlabVersionCheck from '~/vue_shared/components/gitlab_version_check.vue'; @@ -43,7 +43,7 @@ describe('GitlabVersionCheck', () => { describe(`is ${description}`, () => { beforeEach(async () => { createComponent(mockResponse); - await flushPromises(); // Ensure we wrap up the axios call + await waitForPromises(); // Ensure we wrap up the axios call }); it(`does${renders ? '' : ' not'} render GlBadge`, () => { @@ -61,7 +61,7 @@ describe('GitlabVersionCheck', () => { describe(`when response is ${mockResponse.res.severity}`, () => { beforeEach(async () => { createComponent(mockResponse); - await flushPromises(); // Ensure we wrap up the axios call + await waitForPromises(); // Ensure we wrap up the axios call }); it(`title is ${expectedUI.title}`, () => { diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index d1c4d777d44..b3376f26a25 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -5,12 +5,14 @@ import { TEST_HOST, FIXTURES_PATH } from 'spec/test_constants'; import axios from '~/lib/utils/axios_utils'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import MarkdownFieldHeader from '~/vue_shared/components/markdown/header.vue'; +import MarkdownToolbar from '~/vue_shared/components/markdown/toolbar.vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; const markdownPreviewPath = `${TEST_HOST}/preview`; const markdownDocsPath = `${TEST_HOST}/docs`; const textareaValue = 'testing\n123'; const uploadsPath = 'test/uploads'; +const restrictedToolBarItems = ['quote']; function assertMarkdownTabs(isWrite, writeLink, previewLink, wrapper) { expect(writeLink.element.children[0].classList.contains('active')).toBe(isWrite); @@ -63,6 +65,7 @@ describe('Markdown field component', () => { textareaValue, lines, enablePreview, + restrictedToolBarItems, }, provide: { glFeatures: { @@ -81,6 +84,8 @@ describe('Markdown field component', () => { const getAttachButton = () => subject.find('.button-attach-file'); const clickAttachButton = () => getAttachButton().trigger('click'); const findDropzone = () => subject.find('.div-dropzone'); + const findMarkdownHeader = () => subject.findComponent(MarkdownFieldHeader); + const findMarkdownToolbar = () => subject.findComponent(MarkdownToolbar); describe('mounted', () => { const previewHTML = ` @@ -184,9 +189,23 @@ describe('Markdown field component', () => { assertMarkdownTabs(false, writeLink, previewLink, subject); }); + + it('passes correct props to MarkdownToolbar', () => { + expect(findMarkdownToolbar().props()).toEqual({ + canAttachFile: true, + markdownDocsPath, + quickActionsDocsPath: '', + showCommentToolBar: true, + }); + }); }); describe('markdown buttons', () => { + beforeEach(() => { + // needed for the underlying insertText to work + document.execCommand = jest.fn(() => false); + }); + it('converts single words', async () => { const textarea = subject.find('textarea').element; textarea.setSelectionRange(0, 7); @@ -309,9 +328,7 @@ describe('Markdown field component', () => { it('escapes new line characters', () => { createSubject({ lines: [{ rich_text: 'hello world\\n' }] }); - expect(subject.find('[data-testid="markdownHeader"]').props('lineContent')).toBe( - 'hello world%br', - ); + expect(findMarkdownHeader().props('lineContent')).toBe('hello world%br'); }); }); @@ -325,4 +342,12 @@ describe('Markdown field component', () => { expect(subject.findComponent(MarkdownFieldHeader).props('enablePreview')).toBe(true); }); + + it('passess restricted tool bar items', () => { + createSubject(); + + expect(subject.findComponent(MarkdownFieldHeader).props('restrictedToolBarItems')).toBe( + restrictedToolBarItems, + ); + }); }); diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js index fa4ca63f910..67222cab247 100644 --- a/spec/frontend/vue_shared/components/markdown/header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/header_spec.js @@ -166,4 +166,26 @@ describe('Markdown field header component', () => { expect(wrapper.findByTestId('preview-tab').exists()).toBe(false); }); + + describe('restricted tool bar items', () => { + let defaultCount; + + beforeEach(() => { + defaultCount = findToolbarButtons().length; + }); + + it('restricts items as per input', () => { + createWrapper({ + restrictedToolBarItems: ['quote'], + }); + + expect(findToolbarButtons().length).toBe(defaultCount - 1); + }); + + it('shows all items by default', () => { + createWrapper(); + + expect(findToolbarButtons().length).toBe(defaultCount); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js index 8bff85b0bda..f698794b951 100644 --- a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js +++ b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js @@ -33,4 +33,18 @@ describe('toolbar', () => { expect(wrapper.vm.$el.querySelector('.uploading-container')).toBeNull(); }); }); + + describe('comment tool bar settings', () => { + it('does not show comment tool bar div', () => { + createMountedWrapper({ showCommentToolBar: false }); + + expect(wrapper.find('.comment-toolbar').exists()).toBe(false); + }); + + it('shows comment tool bar by default', () => { + createMountedWrapper(); + + expect(wrapper.find('.comment-toolbar').exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap b/spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap index 5dd12d9edf5..015049795a1 100644 --- a/spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap +++ b/spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap @@ -10,6 +10,7 @@ exports[`Metrics upload item render the metrics image component 1`] = ` <gl-modal-stub actioncancel="[object Object]" actionprimary="[object Object]" + arialabel="" body-class="gl-pb-0! gl-min-h-6!" dismisslabel="Close" modalclass="" @@ -26,6 +27,7 @@ exports[`Metrics upload item render the metrics image component 1`] = ` <gl-modal-stub actioncancel="[object Object]" actionprimary="[object Object]" + arialabel="" data-testid="metric-image-edit-modal" dismisslabel="Close" modalclass="" diff --git a/spec/frontend/vue_shared/components/registry/list_item_spec.js b/spec/frontend/vue_shared/components/registry/list_item_spec.js index 1b93292e37b..6e9abb2bfb3 100644 --- a/spec/frontend/vue_shared/components/registry/list_item_spec.js +++ b/spec/frontend/vue_shared/components/registry/list_item_spec.js @@ -101,20 +101,6 @@ describe('list item', () => { }); }); - describe('disabled prop', () => { - it('when true applies gl-opacity-5 class', () => { - mountComponent({ disabled: true }); - - expect(wrapper.classes('gl-opacity-5')).toBe(true); - }); - - it('when false does not apply gl-opacity-5 class', () => { - mountComponent({ disabled: false }); - - expect(wrapper.classes('gl-opacity-5')).toBe(false); - }); - }); - describe('borders and selection', () => { it.each` first | selected | shouldHave | shouldNotHave diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap index ac313e556fc..8ff49271eb5 100644 --- a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap +++ b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap @@ -4,6 +4,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` <gl-modal-stub actionprimary="[object Object]" actionsecondary="[object Object]" + arialabel="" dismisslabel="Close" modalclass="" modalid="runner-aws-deployments-modal" diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js index 0da9939e97f..001b6ee4a6f 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js +++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js @@ -45,8 +45,10 @@ describe('RunnerInstructionsModal component', () => { const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAlert = () => wrapper.findComponent(GlAlert); + const findModal = () => wrapper.findComponent(GlModal); const findPlatformButtonGroup = () => wrapper.findByTestId('platform-buttons'); const findPlatformButtons = () => findPlatformButtonGroup().findAllComponents(GlButton); + const findOsxPlatformButton = () => wrapper.find({ ref: 'osx' }); const findArchitectureDropdownItems = () => wrapper.findAllByTestId('architecture-dropdown-item'); const findBinaryInstructions = () => wrapper.findByTestId('binary-instructions'); const findRegisterCommand = () => wrapper.findByTestId('register-command'); @@ -140,6 +142,38 @@ describe('RunnerInstructionsModal component', () => { expect(instructions).toBe(registerInstructions); }); }); + + describe('when the modal is shown', () => { + it('sets the focus on the selected platform', () => { + findPlatformButtons().at(0).element.focus = jest.fn(); + + findModal().vm.$emit('shown'); + + expect(findPlatformButtons().at(0).element.focus).toHaveBeenCalled(); + }); + }); + + describe('when providing a defaultPlatformName', () => { + beforeEach(async () => { + createComponent({ props: { defaultPlatformName: 'osx' } }); + await waitForPromises(); + }); + + it('runner instructions for the default selected platform are requested', () => { + expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({ + platform: 'osx', + architecture: 'amd64', + }); + }); + + it('sets the focus on the default selected platform', () => { + findOsxPlatformButton().element.focus = jest.fn(); + + findModal().vm.$emit('shown'); + + expect(findOsxPlatformButton().element.focus).toHaveBeenCalled(); + }); + }); }); describe('after a platform and architecture are selected', () => { diff --git a/spec/frontend/vue_shared/components/segmented_control_button_group_spec.js b/spec/frontend/vue_shared/components/segmented_control_button_group_spec.js new file mode 100644 index 00000000000..88445b6684c --- /dev/null +++ b/spec/frontend/vue_shared/components/segmented_control_button_group_spec.js @@ -0,0 +1,104 @@ +import { GlButtonGroup, GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import SegmentedControlButtonGroup from '~/vue_shared/components/segmented_control_button_group.vue'; + +const DEFAULT_OPTIONS = [ + { text: 'Lorem', value: 'abc' }, + { text: 'Ipsum', value: 'def' }, + { text: 'Foo', value: 'x', disabled: true }, + { text: 'Dolar', value: 'ghi' }, +]; + +describe('~/vue_shared/components/segmented_control_button_group.vue', () => { + let wrapper; + + const createComponent = (props = {}, scopedSlots = {}) => { + wrapper = shallowMount(SegmentedControlButtonGroup, { + propsData: { + value: DEFAULT_OPTIONS[0].value, + options: DEFAULT_OPTIONS, + ...props, + }, + scopedSlots, + }); + }; + + const findButtonGroup = () => wrapper.findComponent(GlButtonGroup); + const findButtons = () => findButtonGroup().findAllComponents(GlButton); + const findButtonsData = () => + findButtons().wrappers.map((x) => ({ + selected: x.props('selected'), + text: x.text(), + disabled: x.props('disabled'), + })); + const findButtonWithText = (text) => findButtons().wrappers.find((x) => x.text() === text); + + const optionsAsButtonData = (options) => + options.map(({ text, disabled = false }) => ({ + selected: false, + text, + disabled, + })); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders button group', () => { + expect(findButtonGroup().exists()).toBe(true); + }); + + it('renders buttons', () => { + const expectation = optionsAsButtonData(DEFAULT_OPTIONS); + expectation[0].selected = true; + + expect(findButtonsData()).toEqual(expectation); + }); + + describe.each(DEFAULT_OPTIONS.filter((x) => !x.disabled))( + 'when button clicked %p', + ({ text, value }) => { + it('emits input with value', () => { + expect(wrapper.emitted('input')).toBeUndefined(); + + findButtonWithText(text).vm.$emit('click'); + + expect(wrapper.emitted('input')).toEqual([[value]]); + }); + }, + ); + }); + + const VALUE_TEST_CASES = [0, 1, 3].map((index) => [DEFAULT_OPTIONS[index].value, index]); + + describe.each(VALUE_TEST_CASES)('with value=%s', (value, index) => { + it(`renders selected button at ${index}`, () => { + createComponent({ value }); + + const expectation = optionsAsButtonData(DEFAULT_OPTIONS); + expectation[index].selected = true; + + expect(findButtonsData()).toEqual(expectation); + }); + }); + + describe('with button-content slot', () => { + it('renders button content based on slot', () => { + createComponent( + {}, + { + 'button-content': `<template #button-content="{ text }">In a slot - {{ text }}</template>`, + }, + ); + + expect(findButtonsData().map((x) => x.text)).toEqual( + DEFAULT_OPTIONS.map((x) => `In a slot - ${x.text}`), + ); + }); + }); +}); 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 3ceed670d77..9c29f304c71 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 @@ -153,7 +153,11 @@ describe('DropdownContentsCreateView', () => { }); it('enables a Create button', () => { - expect(findCreateButton().props('disabled')).toBe(false); + expect(findCreateButton().props()).toMatchObject({ + disabled: false, + category: 'primary', + variant: 'confirm', + }); }); it('renders a loader spinner after Create button click', async () => { diff --git a/spec/frontend/vue_shared/components/usage_quotas/usage_banner_spec.js b/spec/frontend/vue_shared/components/usage_quotas/usage_banner_spec.js new file mode 100644 index 00000000000..662c09d02bf --- /dev/null +++ b/spec/frontend/vue_shared/components/usage_quotas/usage_banner_spec.js @@ -0,0 +1,62 @@ +import { GlSkeletonLoader } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import component from '~/vue_shared/components/usage_quotas/usage_banner.vue'; + +describe('usage banner', () => { + let wrapper; + + const findLeftPrimaryTextSlot = () => wrapper.findByTestId('left-primary-text'); + const findLeftSecondaryTextSlot = () => wrapper.findByTestId('left-secondary-text'); + const findRightPrimaryTextSlot = () => wrapper.findByTestId('right-primary-text'); + const findRightSecondaryTextSlot = () => wrapper.findByTestId('right-secondary-text'); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + + const mountComponent = (propsData, slots) => { + wrapper = shallowMountExtended(component, { + propsData, + slots: { + 'left-primary-text': '<div data-testid="left-primary-text" />', + 'left-secondary-text': '<div data-testid="left-secondary-text" />', + 'right-primary-text': '<div data-testid="right-primary-text" />', + 'right-secondary-text': '<div data-testid="right-secondary-text" />', + ...slots, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each` + slotName | finderFunction + ${'left-primary-text'} | ${findLeftPrimaryTextSlot} + ${'left-secondary-text'} | ${findLeftSecondaryTextSlot} + ${'right-primary-text'} | ${findRightPrimaryTextSlot} + ${'right-secondary-text'} | ${findRightSecondaryTextSlot} + `('$slotName slot', ({ finderFunction, slotName }) => { + it('exist when the slot is filled', () => { + mountComponent(); + + expect(finderFunction().exists()).toBe(true); + }); + + it('does not exist when the slot is empty', () => { + mountComponent({}, { [slotName]: '' }); + + expect(finderFunction().exists()).toBe(false); + }); + }); + + it('should show a skeleton loader component', () => { + mountComponent({ loading: true }); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('should not show a skeleton loader component', () => { + mountComponent(); + + expect(findSkeletonLoader().exists()).toBe(false); + }); +}); 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 3329199a46b..a54f3450633 100644 --- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js @@ -1,11 +1,22 @@ import { GlSkeletonLoader, GlIcon } from '@gitlab/ui'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { AVAILABILITY_STATUS } from '~/set_status_modal/utils'; import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; import UserPopover from '~/vue_shared/components/user_popover/user_popover.vue'; +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; +import { followUser, unfollowUser } from '~/api/user_api'; + +jest.mock('~/flash'); +jest.mock('~/api/user_api', () => ({ + followUser: jest.fn(), + unfollowUser: jest.fn(), +})); const DEFAULT_PROPS = { user: { + id: 1, username: 'root', name: 'Administrator', location: 'Vienna', @@ -15,6 +26,7 @@ const DEFAULT_PROPS = { workInformation: null, status: null, pronouns: 'they/them', + isFollowed: false, loaded: true, }, }; @@ -25,11 +37,13 @@ describe('User Popover Component', () => { let wrapper; beforeEach(() => { - loadFixtures(fixtureTemplate); + loadHTMLFixture(fixtureTemplate); + gon.features = {}; }); afterEach(() => { wrapper.destroy(); + resetHTMLFixture(); }); const findUserStatus = () => wrapper.findByTestId('user-popover-status'); @@ -37,15 +51,15 @@ describe('User Popover Component', () => { const findUserName = () => wrapper.find(UserNameWithStatus); const findSecurityBotDocsLink = () => wrapper.findByTestId('user-popover-bot-docs-link'); const findUserLocalTime = () => wrapper.findByTestId('user-popover-local-time'); + const findToggleFollowButton = () => wrapper.findByTestId('toggle-follow-button'); - const createWrapper = (props = {}, options = {}) => { + const createWrapper = (props = {}) => { wrapper = mountExtended(UserPopover, { propsData: { ...DEFAULT_PROPS, target: findTarget(), ...props, }, - ...options, }); }; @@ -289,4 +303,124 @@ describe('User Popover Component', () => { expect(findUserLocalTime().exists()).toBe(false); }); }); + + describe("when current user doesn't follow the user", () => { + beforeEach(() => createWrapper()); + + it('renders the Follow button with the correct variant', () => { + expect(findToggleFollowButton().text()).toBe('Follow'); + expect(findToggleFollowButton().props('variant')).toBe('confirm'); + }); + + describe('when clicking', () => { + it('follows the user', async () => { + followUser.mockResolvedValue({}); + + await findToggleFollowButton().trigger('click'); + + expect(findToggleFollowButton().props('loading')).toBe(true); + + await axios.waitForAll(); + + expect(wrapper.emitted().follow.length).toBe(1); + expect(wrapper.emitted().unfollow).toBeFalsy(); + }); + + describe('when an error occurs', () => { + beforeEach(() => { + followUser.mockRejectedValue({}); + + findToggleFollowButton().trigger('click'); + }); + + it('shows an error message', async () => { + await axios.waitForAll(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'An error occurred while trying to follow this user, please try again.', + error: {}, + captureError: true, + }); + }); + + it('emits no events', async () => { + await axios.waitForAll(); + + expect(wrapper.emitted().follow).toBe(undefined); + expect(wrapper.emitted().unfollow).toBe(undefined); + }); + }); + }); + }); + + describe('when current user follows the user', () => { + beforeEach(() => createWrapper({ user: { ...DEFAULT_PROPS.user, isFollowed: true } })); + + it('renders the Unfollow button with the correct variant', () => { + expect(findToggleFollowButton().text()).toBe('Unfollow'); + expect(findToggleFollowButton().props('variant')).toBe('default'); + }); + + describe('when clicking', () => { + it('unfollows the user', async () => { + unfollowUser.mockResolvedValue({}); + + findToggleFollowButton().trigger('click'); + + await axios.waitForAll(); + + expect(wrapper.emitted().follow).toBe(undefined); + expect(wrapper.emitted().unfollow.length).toBe(1); + }); + + describe('when an error occurs', () => { + beforeEach(async () => { + unfollowUser.mockRejectedValue({}); + + findToggleFollowButton().trigger('click'); + + await axios.waitForAll(); + }); + + it('shows an error message', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: 'An error occurred while trying to unfollow this user, please try again.', + error: {}, + captureError: true, + }); + }); + + it('emits no events', () => { + expect(wrapper.emitted().follow).toBe(undefined); + expect(wrapper.emitted().unfollow).toBe(undefined); + }); + }); + }); + }); + + describe('when the current user is the user', () => { + beforeEach(() => { + gon.current_username = DEFAULT_PROPS.user.username; + createWrapper(); + }); + + it("doesn't render the toggle follow button", () => { + expect(findToggleFollowButton().exists()).toBe(false); + }); + }); + + describe('when API does not support `isFollowed`', () => { + beforeEach(() => { + const user = { + ...DEFAULT_PROPS.user, + isFollowed: undefined, + }; + + createWrapper({ user }); + }); + + it('does not render the toggle follow button', () => { + expect(findToggleFollowButton().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/vue_shared/directives/autofocusonshow_spec.js b/spec/frontend/vue_shared/directives/autofocusonshow_spec.js index 59ce9f086c3..d052c99ec0e 100644 --- a/spec/frontend/vue_shared/directives/autofocusonshow_spec.js +++ b/spec/frontend/vue_shared/directives/autofocusonshow_spec.js @@ -1,3 +1,4 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; /** @@ -10,10 +11,14 @@ describe('AutofocusOnShow directive', () => { let el; beforeEach(() => { - setFixtures('<div id="container" style="display: none;"><input id="inputel"/></div>'); + setHTMLFixture('<div id="container" style="display: none;"><input id="inputel"/></div>'); el = document.querySelector('#inputel'); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('should bind IntersectionObserver on input element', () => { jest.spyOn(el, 'focus').mockImplementation(() => {}); diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar_spec.js index 7dfeced571a..a25f92c9cf2 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar_spec.js @@ -1,6 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import IssuableBulkEditSidebar from '~/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue'; const createComponent = ({ expanded = true } = {}) => @@ -22,12 +23,13 @@ describe('IssuableBulkEditSidebar', () => { let wrapper; beforeEach(() => { - setFixtures('<div class="layout-page right-sidebar-collapsed"></div>'); + setHTMLFixture('<div class="layout-page right-sidebar-collapsed"></div>'); wrapper = createComponent(); }); afterEach(() => { wrapper.destroy(); + resetHTMLFixture(); }); describe('watch', () => { diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js index b79dc0bf976..d3e484cf913 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js @@ -36,7 +36,6 @@ describe('IssuableEditForm', () => { beforeEach(() => { wrapper = createComponent(); - gon.features = { markdownContinueLists: true }; }); afterEach(() => { diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js index 1cdd709159f..544db891a13 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js @@ -1,8 +1,6 @@ import { GlIcon, GlAvatarLabeled } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; - +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import IssuableHeader from '~/vue_shared/issuable/show/components/issuable_header.vue'; import { mockIssuableShowProps, mockIssuable } from '../mock_data'; @@ -12,10 +10,17 @@ const issuableHeaderProps = { ...mockIssuableShowProps, }; -const createComponent = (propsData = issuableHeaderProps, { stubs } = {}) => - extendedWrapper( - shallowMount(IssuableHeader, { - propsData, +describe('IssuableHeader', () => { + let wrapper; + + const findTaskStatusEl = () => wrapper.findByTestId('task-status'); + + const createComponent = (props = {}, { stubs } = {}) => { + wrapper = shallowMountExtended(IssuableHeader, { + propsData: { + ...issuableHeaderProps, + ...props, + }, slots: { 'status-badge': 'Open', 'header-actions': ` @@ -24,23 +29,18 @@ const createComponent = (propsData = issuableHeaderProps, { stubs } = {}) => `, }, stubs, - }), - ); - -describe('IssuableHeader', () => { - let wrapper; - - beforeEach(() => { - wrapper = createComponent(); - }); + }); + }; afterEach(() => { wrapper.destroy(); + resetHTMLFixture(); }); describe('computed', () => { describe('authorId', () => { it('returns numeric ID from GraphQL ID of `author` prop', () => { + createComponent(); expect(wrapper.vm.authorId).toBe(1); }); }); @@ -48,10 +48,11 @@ describe('IssuableHeader', () => { describe('handleRightSidebarToggleClick', () => { beforeEach(() => { - setFixtures('<button class="js-toggle-right-sidebar-button">Collapse sidebar</button>'); + setHTMLFixture('<button class="js-toggle-right-sidebar-button">Collapse sidebar</button>'); }); it('dispatches `click` event on sidebar toggle button', () => { + createComponent(); wrapper.vm.toggleSidebarButtonEl = document.querySelector('.js-toggle-right-sidebar-button'); jest.spyOn(wrapper.vm.toggleSidebarButtonEl, 'dispatchEvent').mockImplementation(jest.fn); @@ -67,20 +68,21 @@ describe('IssuableHeader', () => { describe('template', () => { it('renders issuable status icon and text', () => { + createComponent(); const statusBoxEl = wrapper.findByTestId('status'); + const statusIconEl = statusBoxEl.findComponent(GlIcon); expect(statusBoxEl.exists()).toBe(true); - expect(statusBoxEl.find(GlIcon).props('name')).toBe(mockIssuableShowProps.statusIcon); + expect(statusIconEl.props('name')).toBe(mockIssuableShowProps.statusIcon); + expect(statusIconEl.attributes('class')).toBe(mockIssuableShowProps.statusIconClass); expect(statusBoxEl.text()).toContain('Open'); }); it('renders blocked icon when issuable is blocked', async () => { - wrapper.setProps({ + createComponent({ blocked: true, }); - await nextTick(); - const blockedEl = wrapper.findByTestId('blocked'); expect(blockedEl.exists()).toBe(true); @@ -88,12 +90,10 @@ describe('IssuableHeader', () => { }); it('renders confidential icon when issuable is confidential', async () => { - wrapper.setProps({ + createComponent({ confidential: true, }); - await nextTick(); - const confidentialEl = wrapper.findByTestId('confidential'); expect(confidentialEl.exists()).toBe(true); @@ -101,6 +101,7 @@ describe('IssuableHeader', () => { }); it('renders issuable author avatar', () => { + createComponent(); const { username, name, webUrl, avatarUrl } = mockIssuable.author; const avatarElAttrs = { 'data-user-id': '1', @@ -120,28 +121,26 @@ describe('IssuableHeader', () => { expect(avatarEl.find(GlAvatarLabeled).find(GlIcon).exists()).toBe(false); }); - it('renders tast status text when `taskCompletionStatus` prop is defined', () => { - let taskStatusEl = wrapper.findByTestId('task-status'); + it('renders task status text when `taskCompletionStatus` prop is defined', () => { + createComponent(); - expect(taskStatusEl.exists()).toBe(true); - expect(taskStatusEl.text()).toContain('0 of 5 tasks completed'); + expect(findTaskStatusEl().exists()).toBe(true); + expect(findTaskStatusEl().text()).toContain('0 of 5 tasks completed'); + }); - const wrapperSingleTask = createComponent({ - ...issuableHeaderProps, + it('does not render task status text when tasks count is 0', () => { + createComponent({ taskCompletionStatus: { + count: 0, completedCount: 0, - count: 1, }, }); - taskStatusEl = wrapperSingleTask.findByTestId('task-status'); - - expect(taskStatusEl.text()).toContain('0 of 1 task completed'); - - wrapperSingleTask.destroy(); + expect(findTaskStatusEl().exists()).toBe(false); }); it('renders sidebar toggle button', () => { + createComponent(); const toggleButtonEl = wrapper.findByTestId('sidebar-toggle'); expect(toggleButtonEl.exists()).toBe(true); @@ -149,6 +148,7 @@ describe('IssuableHeader', () => { }); it('renders header actions', () => { + createComponent(); const actionsEl = wrapper.findByTestId('header-actions'); expect(actionsEl.find('button.js-close').exists()).toBe(true); @@ -157,9 +157,8 @@ describe('IssuableHeader', () => { describe('when author exists outside of GitLab', () => { it("renders 'external-link' icon in avatar label", () => { - wrapper = createComponent( + createComponent( { - ...issuableHeaderProps, author: { ...issuableHeaderProps.author, webUrl: 'https://jira.com/test-user/author.jpg', diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js index d1eb1366225..8b027f990a2 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js @@ -49,6 +49,7 @@ describe('IssuableShowRoot', () => { const { statusBadgeClass, statusIcon, + statusIconClass, enableEdit, enableAutocomplete, editFormVisible, @@ -56,7 +57,7 @@ describe('IssuableShowRoot', () => { descriptionHelpPath, taskCompletionStatus, } = mockIssuableShowProps; - const { blocked, confidential, createdAt, author } = mockIssuable; + const { state, blocked, confidential, createdAt, author } = mockIssuable; it('renders component container element with class `issuable-show-container`', () => { expect(wrapper.classes()).toContain('issuable-show-container'); @@ -67,15 +68,17 @@ describe('IssuableShowRoot', () => { expect(issuableHeader.exists()).toBe(true); expect(issuableHeader.props()).toMatchObject({ + issuableState: state, statusBadgeClass, statusIcon, + statusIconClass, blocked, confidential, createdAt, author, taskCompletionStatus, }); - expect(issuableHeader.find('.issuable-status-box').text()).toContain('Open'); + expect(issuableHeader.find('.issuable-status-badge').text()).toContain('Open'); expect(issuableHeader.find('.detail-page-header-actions button.js-close').exists()).toBe( true, ); diff --git a/spec/frontend/vue_shared/issuable/show/mock_data.js b/spec/frontend/vue_shared/issuable/show/mock_data.js index f5f3ed58655..32bb9edfe08 100644 --- a/spec/frontend/vue_shared/issuable/show/mock_data.js +++ b/spec/frontend/vue_shared/issuable/show/mock_data.js @@ -36,8 +36,9 @@ export const mockIssuableShowProps = { enableTaskList: true, enableEdit: true, showFieldTitle: false, - statusBadgeClass: 'status-box-open', - statusIcon: 'issue-open-m', + statusBadgeClass: 'issuable-status-badge-open', + statusIcon: 'issues', + statusIconClass: 'gl-sm-display-none', taskCompletionStatus: { completedCount: 0, count: 5, diff --git a/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js b/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js index 47bf3c8ed83..6c9e5f85fa0 100644 --- a/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js +++ b/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js @@ -1,6 +1,7 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import Cookies from 'js-cookie'; import { nextTick } from 'vue'; +import Cookies from '~/lib/utils/cookies'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import IssuableSidebarRoot from '~/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue'; @@ -9,7 +10,7 @@ import { USER_COLLAPSED_GUTTER_COOKIE } from '~/vue_shared/issuable/sidebar/cons const MOCK_LAYOUT_PAGE_CLASS = 'layout-page'; const createComponent = () => { - setFixtures(`<div class="${MOCK_LAYOUT_PAGE_CLASS}"></div>`); + setHTMLFixture(`<div class="${MOCK_LAYOUT_PAGE_CLASS}"></div>`); return shallowMountExtended(IssuableSidebarRoot, { slots: { @@ -38,6 +39,7 @@ describe('IssuableSidebarRoot', () => { afterEach(() => { wrapper.destroy(); + resetHTMLFixture(); }); describe('when sidebar is expanded', () => { diff --git a/spec/frontend/security_configuration/components/section_layout_spec.js b/spec/frontend/vue_shared/security_configuration/components/section_layout_spec.js index 75da380bbb8..136fe74b0d6 100644 --- a/spec/frontend/security_configuration/components/section_layout_spec.js +++ b/spec/frontend/vue_shared/security_configuration/components/section_layout_spec.js @@ -1,6 +1,7 @@ import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import SectionLayout from '~/security_configuration/components/section_layout.vue'; +import SectionLayout from '~/vue_shared/security_configuration/components/section_layout.vue'; +import SectionLoader from '~/vue_shared/security_configuration/components/section_loader.vue'; describe('Section Layout component', () => { let wrapper; @@ -18,6 +19,7 @@ describe('Section Layout component', () => { }; const findHeading = () => wrapper.find('h2'); + const findLoader = () => wrapper.findComponent(SectionLoader); afterEach(() => { wrapper.destroy(); @@ -46,4 +48,11 @@ describe('Section Layout component', () => { }); }); }); + + describe('loading state', () => { + it('should show loaders when loading', () => { + createComponent({ heading: 'testheading', isLoading: true }); + expect(findLoader().exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/vue_shared/security_reports/mock_data.js b/spec/frontend/vue_shared/security_reports/mock_data.js index dac9accbbf5..a9ad675e538 100644 --- a/spec/frontend/vue_shared/security_reports/mock_data.js +++ b/spec/frontend/vue_shared/security_reports/mock_data.js @@ -62,7 +62,7 @@ export const mockFindings = [ report_type: 'dependency_scanning', name: '3rd party CORS request may execute in jquery', severity: 'high', - scanner: { external_id: 'retire.js', name: 'Retire.js' }, + scanner: { external_id: 'gemnasium', name: 'gemnasium' }, identifiers: [ { external_type: 'cve', @@ -145,7 +145,7 @@ export const mockFindings = [ name: 'jQuery before 3.4.0, as used in Drupal, Backdrop CMS, and other products, mishandles jQuery.extend(true, {}, ...) because of Object.prototype pollution in jquery', severity: 'low', - scanner: { external_id: 'retire.js', name: 'Retire.js' }, + scanner: { external_id: 'gemnasium', name: 'gemnasium' }, identifiers: [ { external_type: 'cve', @@ -227,7 +227,7 @@ export const mockFindings = [ name: 'jQuery before 3.4.0, as used in Drupal, Backdrop CMS, and other products, mishandles jQuery.extend(true, {}, ...) because of Object.prototype pollution in jquery', severity: 'low', - scanner: { external_id: 'retire.js', name: 'Retire.js' }, + scanner: { external_id: 'gemnasium', name: 'gemnasium' }, identifiers: [ { external_type: 'cve', diff --git a/spec/frontend/whats_new/components/feature_spec.js b/spec/frontend/whats_new/components/feature_spec.js index 8f4b4b08f50..b6627c257ff 100644 --- a/spec/frontend/whats_new/components/feature_spec.js +++ b/spec/frontend/whats_new/components/feature_spec.js @@ -21,6 +21,7 @@ describe("What's new single feature", () => { const findReleaseDate = () => wrapper.find('[data-testid="release-date"]'); const findBodyAnchor = () => wrapper.find('[data-testid="body-content"] a'); + const findImageLink = () => wrapper.find('[data-testid="whats-new-image-link"]'); const createWrapper = ({ feature } = {}) => { wrapper = shallowMount(Feature, { @@ -35,18 +36,38 @@ describe("What's new single feature", () => { it('renders the date', () => { createWrapper({ feature: exampleFeature }); + expect(findReleaseDate().text()).toBe('April 22, 2021'); }); - describe('when the published_at is null', () => { - it("doesn't render the date", () => { + it('renders image link', () => { + createWrapper({ feature: exampleFeature }); + + expect(findImageLink().exists()).toBe(true); + expect(findImageLink().find('div').attributes('style')).toBe( + `background-image: url(${exampleFeature.image_url});`, + ); + }); + + describe('when published_at is null', () => { + it('does not render the date', () => { createWrapper({ feature: { ...exampleFeature, published_at: null } }); + expect(findReleaseDate().exists()).toBe(false); }); }); + describe('when image_url is null', () => { + it('does not render image link', () => { + createWrapper({ feature: { ...exampleFeature, image_url: null } }); + + expect(findImageLink().exists()).toBe(false); + }); + }); + it('safe-html config allows target attribute on elements', () => { createWrapper({ feature: exampleFeature }); + expect(findBodyAnchor().attributes()).toEqual({ href: expect.any(String), rel: 'noopener noreferrer', diff --git a/spec/frontend/whats_new/utils/notification_spec.js b/spec/frontend/whats_new/utils/notification_spec.js index ef61462a3c5..dac02ee07bd 100644 --- a/spec/frontend/whats_new/utils/notification_spec.js +++ b/spec/frontend/whats_new/utils/notification_spec.js @@ -1,3 +1,4 @@ +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { setNotification, getVersionDigest } from '~/whats_new/utils/notification'; @@ -11,12 +12,13 @@ describe('~/whats_new/utils/notification', () => { const getAppEl = () => wrapper.querySelector('.app'); beforeEach(() => { - loadFixtures('static/whats_new_notification.html'); + loadHTMLFixture('static/whats_new_notification.html'); wrapper = document.querySelector('.whats-new-notification-fixture-root'); }); afterEach(() => { wrapper.remove(); + resetHTMLFixture(); }); describe('setNotification', () => { diff --git a/spec/frontend/wikis_spec.js b/spec/frontend/wikis_spec.js index c4e914bcf34..d8748c65da7 100644 --- a/spec/frontend/wikis_spec.js +++ b/spec/frontend/wikis_spec.js @@ -1,5 +1,5 @@ import { escape } from 'lodash'; -import { setHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import Wikis from '~/pages/shared/wikis/wikis'; import Tracking from '~/tracking'; @@ -21,6 +21,10 @@ describe('Wikis', () => { Wikis.trackPageView(); }); + afterEach(() => { + resetHTMLFixture(); + }); + it('sends the tracking event and context', () => { expect(Tracking.event).toHaveBeenCalledWith(trackingPage, 'view_wiki_page', { label: 'view_wiki_page', diff --git a/spec/frontend/work_items/components/item_state_spec.js b/spec/frontend/work_items/components/item_state_spec.js new file mode 100644 index 00000000000..79b76f3c061 --- /dev/null +++ b/spec/frontend/work_items/components/item_state_spec.js @@ -0,0 +1,54 @@ +import { mount } from '@vue/test-utils'; +import { STATE_OPEN, STATE_CLOSED } from '~/work_items/constants'; +import ItemState from '~/work_items/components/item_state.vue'; + +describe('ItemState', () => { + let wrapper; + + const findLabel = () => wrapper.find('label').text(); + const selectedValue = () => wrapper.find('option:checked').element.value; + + const clickOpen = () => wrapper.findAll('option').at(0).setSelected(); + + const createComponent = ({ state = STATE_OPEN, disabled = false } = {}) => { + wrapper = mount(ItemState, { + propsData: { + state, + disabled, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders label and dropdown', () => { + createComponent(); + + expect(findLabel()).toBe('Status'); + expect(selectedValue()).toBe(STATE_OPEN); + }); + + it('renders dropdown for closed', () => { + createComponent({ state: STATE_CLOSED }); + + expect(selectedValue()).toBe(STATE_CLOSED); + }); + + it('emits changed event', async () => { + createComponent({ state: STATE_CLOSED }); + + await clickOpen(); + + expect(wrapper.emitted('changed')).toEqual([[STATE_OPEN]]); + }); + + it('does not emits changed event if clicking selected value', async () => { + createComponent({ state: STATE_OPEN }); + + await clickOpen(); + + expect(wrapper.emitted('changed')).toBeUndefined(); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_actions_spec.js b/spec/frontend/work_items/components/work_item_actions_spec.js index d0e9cfee353..137a0a7326d 100644 --- a/spec/frontend/work_items/components/work_item_actions_spec.js +++ b/spec/frontend/work_items/components/work_item_actions_spec.js @@ -1,30 +1,18 @@ import { GlDropdownItem, GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import waitForPromises from 'helpers/wait_for_promises'; -import createMockApollo from 'helpers/mock_apollo_helper'; import WorkItemActions from '~/work_items/components/work_item_actions.vue'; -import deleteWorkItem from '~/work_items/graphql/delete_work_item.mutation.graphql'; -import { deleteWorkItemResponse, deleteWorkItemFailureResponse } from '../mock_data'; describe('WorkItemActions component', () => { let wrapper; let glModalDirective; - Vue.use(VueApollo); - const findModal = () => wrapper.findComponent(GlModal); const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); - const createComponent = ({ - canUpdate = true, - deleteWorkItemHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse), - } = {}) => { + const createComponent = ({ canDelete = true } = {}) => { glModalDirective = jest.fn(); wrapper = shallowMount(WorkItemActions, { - apolloProvider: createMockApollo([[deleteWorkItem, deleteWorkItemHandler]]), - propsData: { workItemId: '123', canUpdate }, + propsData: { workItemId: '123', canDelete }, directives: { glModal: { bind(_, { value }) { @@ -54,48 +42,17 @@ describe('WorkItemActions component', () => { expect(glModalDirective).toHaveBeenCalled(); }); - it('calls delete mutation when clicking OK button', () => { - const deleteWorkItemHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse); - - createComponent({ - deleteWorkItemHandler, - }); - - findModal().vm.$emit('ok'); - - expect(deleteWorkItemHandler).toHaveBeenCalled(); - expect(wrapper.emitted('error')).toBeUndefined(); - }); - - it('emits event after delete success', async () => { + it('emits event when clicking OK button', () => { createComponent(); findModal().vm.$emit('ok'); - await waitForPromises(); - - expect(wrapper.emitted('workItemDeleted')).not.toBeUndefined(); - expect(wrapper.emitted('error')).toBeUndefined(); - }); - - it('emits error event after delete failure', async () => { - createComponent({ - deleteWorkItemHandler: jest.fn().mockResolvedValue(deleteWorkItemFailureResponse), - }); - - findModal().vm.$emit('ok'); - - await waitForPromises(); - - expect(wrapper.emitted('error')[0]).toEqual([ - "The resource that you are attempting to access does not exist or you don't have permission to perform this action", - ]); - expect(wrapper.emitted('workItemDeleted')).toBeUndefined(); + expect(wrapper.emitted('deleteWorkItem')).toEqual([[]]); }); - it('does not render when canUpdate is false', () => { + it('does not render when canDelete is false', () => { createComponent({ - canUpdate: false, + canDelete: false, }); expect(wrapper.html()).toBe(''); diff --git a/spec/frontend/work_items/components/work_item_detail_modal_spec.js b/spec/frontend/work_items/components/work_item_detail_modal_spec.js index 9f35ccb853b..aaabdbc82d9 100644 --- a/spec/frontend/work_items/components/work_item_detail_modal_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_modal_spec.js @@ -1,23 +1,57 @@ -import { GlModal } from '@gitlab/ui'; +import { GlAlert } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; -import WorkItemActions from '~/work_items/components/work_item_actions.vue'; +import deleteWorkItemFromTaskMutation from '~/work_items/graphql/delete_task_from_work_item.mutation.graphql'; describe('WorkItemDetailModal component', () => { let wrapper; Vue.use(VueApollo); + const hideModal = jest.fn(); + const GlModal = { + template: ` + <div> + <slot></slot> + </div> + `, + methods: { + hide: hideModal, + }, + }; + const findModal = () => wrapper.findComponent(GlModal); - const findWorkItemActions = () => wrapper.findComponent(WorkItemActions); + const findAlert = () => wrapper.findComponent(GlAlert); const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail); - const createComponent = ({ visible = true, workItemId = '1', canUpdate = false } = {}) => { + const createComponent = ({ workItemId = '1', error = false } = {}) => { + const apolloProvider = createMockApollo([ + [ + deleteWorkItemFromTaskMutation, + jest.fn().mockResolvedValue({ + data: { + workItemDeleteTask: { + workItem: { id: 123, descriptionHtml: 'updated work item desc' }, + errors: [], + }, + }, + }), + ], + ]); + wrapper = shallowMount(WorkItemDetailModal, { - propsData: { visible, workItemId, canUpdate }, + apolloProvider, + propsData: { workItemId }, + data() { + return { + error, + }; + }, stubs: { GlModal, }, @@ -28,31 +62,59 @@ describe('WorkItemDetailModal component', () => { wrapper.destroy(); }); - describe.each([true, false])('when visible=%s', (visible) => { - it(`${visible ? 'renders' : 'does not render'} modal`, () => { - createComponent({ visible }); + it('renders WorkItemDetail', () => { + createComponent(); - expect(findModal().props('visible')).toBe(visible); + expect(findWorkItemDetail().props()).toEqual({ + workItemId: '1', }); }); - it('renders heading', () => { + it('renders alert if there is an error', () => { + createComponent({ error: true }); + + expect(findAlert().exists()).toBe(true); + }); + + it('does not render alert if there is no error', () => { createComponent(); - expect(wrapper.find('h2').text()).toBe('Work Item'); + expect(findAlert().exists()).toBe(false); }); - it('renders WorkItemDetail', () => { + it('dismisses the alert on `dismiss` emitted event', async () => { + createComponent({ error: true }); + findAlert().vm.$emit('dismiss'); + await nextTick(); + + expect(findAlert().exists()).toBe(false); + }); + + it('emits `close` event on hiding the modal', () => { createComponent(); + findModal().vm.$emit('hide'); - expect(findWorkItemDetail().props()).toEqual({ workItemId: '1' }); + expect(wrapper.emitted('close')).toBeTruthy(); }); - it('shows work item actions', () => { - createComponent({ - canUpdate: true, - }); + it('emits `workItemUpdated` event on updating work item', () => { + createComponent(); + findWorkItemDetail().vm.$emit('workItemUpdated'); + + expect(wrapper.emitted('workItemUpdated')).toBeTruthy(); + }); + + describe('delete work item', () => { + it('emits workItemDeleted and closes modal', async () => { + createComponent(); + const newDesc = 'updated work item desc'; + + findWorkItemDetail().vm.$emit('deleteWorkItem'); - expect(findWorkItemActions().exists()).toBe(true); + await waitForPromises(); + + expect(wrapper.emitted('workItemDeleted')).toEqual([[newDesc]]); + expect(hideModal).toHaveBeenCalled(); + }); }); }); diff --git a/spec/frontend/work_items/components/work_item_state_spec.js b/spec/frontend/work_items/components/work_item_state_spec.js new file mode 100644 index 00000000000..9e48f56d9e9 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_state_spec.js @@ -0,0 +1,117 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mockTracking } from 'helpers/tracking_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import ItemState from '~/work_items/components/item_state.vue'; +import WorkItemState from '~/work_items/components/work_item_state.vue'; +import { + i18n, + STATE_OPEN, + STATE_CLOSED, + STATE_EVENT_CLOSE, + STATE_EVENT_REOPEN, +} from '~/work_items/constants'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import { updateWorkItemMutationResponse, workItemQueryResponse } from '../mock_data'; + +describe('WorkItemState component', () => { + let wrapper; + + Vue.use(VueApollo); + + const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); + + const findItemState = () => wrapper.findComponent(ItemState); + + const createComponent = ({ + state = STATE_OPEN, + mutationHandler = mutationSuccessHandler, + } = {}) => { + const { id, workItemType } = workItemQueryResponse.data.workItem; + wrapper = shallowMount(WorkItemState, { + apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), + propsData: { + workItem: { + id, + state, + workItemType, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders state', () => { + createComponent(); + + expect(findItemState().props('state')).toBe(workItemQueryResponse.data.workItem.state); + }); + + describe('when updating the state', () => { + it('calls a mutation', () => { + createComponent(); + + findItemState().vm.$emit('changed', STATE_CLOSED); + + expect(mutationSuccessHandler).toHaveBeenCalledWith({ + input: { + id: workItemQueryResponse.data.workItem.id, + stateEvent: STATE_EVENT_CLOSE, + }, + }); + }); + + it('calls a mutation with REOPEN', () => { + createComponent({ + state: STATE_CLOSED, + }); + + findItemState().vm.$emit('changed', STATE_OPEN); + + expect(mutationSuccessHandler).toHaveBeenCalledWith({ + input: { + id: workItemQueryResponse.data.workItem.id, + stateEvent: STATE_EVENT_REOPEN, + }, + }); + }); + + it('emits updated event', async () => { + createComponent(); + + findItemState().vm.$emit('changed', STATE_CLOSED); + await waitForPromises(); + + expect(wrapper.emitted('updated')).toEqual([[]]); + }); + + it('emits an error message when the mutation was unsuccessful', async () => { + createComponent({ mutationHandler: jest.fn().mockRejectedValue('Error!') }); + + findItemState().vm.$emit('changed', STATE_CLOSED); + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[i18n.updateError]]); + }); + + it('tracks editing the state', async () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + createComponent(); + + findItemState().vm.$emit('changed', STATE_CLOSED); + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith('workItems:show', 'updated_state', { + category: 'workItems:show', + label: 'item_state', + property: 'type_Task', + }); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_title_spec.js b/spec/frontend/work_items/components/work_item_title_spec.js index 9b1ef2d14e4..19b56362ac0 100644 --- a/spec/frontend/work_items/components/work_item_title_spec.js +++ b/spec/frontend/work_items/components/work_item_title_spec.js @@ -1,4 +1,3 @@ -import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; @@ -18,15 +17,13 @@ describe('WorkItemTitle component', () => { const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findItemTitle = () => wrapper.findComponent(ItemTitle); - const createComponent = ({ loading = false, mutationHandler = mutationSuccessHandler } = {}) => { + const createComponent = ({ mutationHandler = mutationSuccessHandler } = {}) => { const { id, title, workItemType } = workItemQueryResponse.data.workItem; wrapper = shallowMount(WorkItemTitle, { apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), propsData: { - loading, workItemId: id, workItemTitle: title, workItemType: workItemType.name, @@ -38,32 +35,10 @@ describe('WorkItemTitle component', () => { wrapper.destroy(); }); - describe('when loading', () => { - beforeEach(() => { - createComponent({ loading: true }); - }); - - it('renders loading spinner', () => { - expect(findLoadingIcon().exists()).toBe(true); - }); - - it('does not render title', () => { - expect(findItemTitle().exists()).toBe(false); - }); - }); - - describe('when loaded', () => { - beforeEach(() => { - createComponent({ loading: false }); - }); - - it('does not render loading spinner', () => { - expect(findLoadingIcon().exists()).toBe(false); - }); + it('renders title', () => { + createComponent(); - it('renders title', () => { - expect(findItemTitle().props('title')).toBe(workItemQueryResponse.data.workItem.title); - }); + expect(findItemTitle().props('title')).toBe(workItemQueryResponse.data.workItem.title); }); describe('when updating the title', () => { @@ -82,6 +57,15 @@ describe('WorkItemTitle component', () => { }); }); + it('emits updated event', async () => { + createComponent(); + + findItemTitle().vm.$emit('title-changed', 'new title'); + await waitForPromises(); + + expect(wrapper.emitted('updated')).toEqual([[]]); + }); + it('does not call a mutation when the title has not changed', () => { createComponent(); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 722e1708c15..f3483550013 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -4,11 +4,17 @@ export const workItemQueryResponse = { __typename: 'WorkItem', id: 'gid://gitlab/WorkItem/1', title: 'Test', + state: 'OPEN', + description: 'description', workItemType: { __typename: 'WorkItemType', id: 'gid://gitlab/WorkItems::Type/5', name: 'Task', }, + userPermissions: { + deleteWorkItem: false, + updateWorkItem: false, + }, }, }, }; @@ -21,11 +27,17 @@ export const updateWorkItemMutationResponse = { __typename: 'WorkItem', id: 'gid://gitlab/WorkItem/1', title: 'Updated title', + state: 'OPEN', + description: 'description', workItemType: { __typename: 'WorkItemType', id: 'gid://gitlab/WorkItems::Type/5', name: 'Task', }, + userPermissions: { + deleteWorkItem: false, + updateWorkItem: false, + }, }, }, }, @@ -39,6 +51,7 @@ export const projectWorkItemTypesQueryResponse = { nodes: [ { id: 'gid://gitlab/WorkItems::Type/1', name: 'Issue' }, { id: 'gid://gitlab/WorkItems::Type/2', name: 'Incident' }, + { id: 'gid://gitlab/WorkItems::Type/3', name: 'Task' }, ], }, }, @@ -53,11 +66,17 @@ export const createWorkItemMutationResponse = { __typename: 'WorkItem', id: 'gid://gitlab/WorkItem/1', title: 'Updated title', + state: 'OPEN', + description: 'description', workItemType: { __typename: 'WorkItemType', id: 'gid://gitlab/WorkItems::Type/5', name: 'Task', }, + userPermissions: { + deleteWorkItem: false, + updateWorkItem: false, + }, }, }, }, @@ -72,6 +91,10 @@ export const createWorkItemFromTaskMutationResponse = { descriptionHtml: '<p>New description</p>', id: 'gid://gitlab/WorkItem/13', __typename: 'WorkItem', + userPermissions: { + deleteWorkItem: false, + updateWorkItem: false, + }, }, }, }, diff --git a/spec/frontend/work_items/pages/create_work_item_spec.js b/spec/frontend/work_items/pages/create_work_item_spec.js index fb1f1d56356..e89477ed599 100644 --- a/spec/frontend/work_items/pages/create_work_item_spec.js +++ b/spec/frontend/work_items/pages/create_work_item_spec.js @@ -158,6 +158,11 @@ describe('Create work item component', () => { it('adds padding for content', () => { expect(findContent().classes('gl-px-5')).toBe(true); }); + + it('defaults type to `Task`', async () => { + await waitForPromises(); + expect(findSelect().attributes('value')).toBe('gid://gitlab/WorkItems::Type/3'); + }); }); it('displays a loading icon inside dropdown when work items query is loading', () => { @@ -181,7 +186,7 @@ describe('Create work item component', () => { }); it('displays a list of work item types', () => { - expect(findSelect().attributes('options').split(',')).toHaveLength(3); + expect(findSelect().attributes('options').split(',')).toHaveLength(4); }); it('selects a work item type on click', async () => { diff --git a/spec/frontend/work_items/pages/work_item_detail_spec.js b/spec/frontend/work_items/pages/work_item_detail_spec.js index 1eb6c0145e7..9f87655175c 100644 --- a/spec/frontend/work_items/pages/work_item_detail_spec.js +++ b/spec/frontend/work_items/pages/work_item_detail_spec.js @@ -1,10 +1,11 @@ -import { GlAlert } from '@gitlab/ui'; +import { GlAlert, GlSkeletonLoader } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; +import WorkItemState from '~/work_items/components/work_item_state.vue'; import WorkItemTitle from '~/work_items/components/work_item_title.vue'; import { i18n } from '~/work_items/constants'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; @@ -20,7 +21,9 @@ describe('WorkItemDetail component', () => { const initialSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse); const findAlert = () => wrapper.findComponent(GlAlert); + const findSkeleton = () => wrapper.findComponent(GlSkeletonLoader); const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle); + const findWorkItemState = () => wrapper.findComponent(WorkItemState); const createComponent = ({ workItemId = workItemQueryResponse.data.workItem.id, @@ -55,8 +58,10 @@ describe('WorkItemDetail component', () => { createComponent(); }); - it('renders WorkItemTitle in loading state', () => { - expect(findWorkItemTitle().props('loading')).toBe(true); + it('renders skeleton loader', () => { + expect(findSkeleton().exists()).toBe(true); + expect(findWorkItemState().exists()).toBe(false); + expect(findWorkItemTitle().exists()).toBe(false); }); }); @@ -66,8 +71,10 @@ describe('WorkItemDetail component', () => { return waitForPromises(); }); - it('does not render WorkItemTitle in loading state', () => { - expect(findWorkItemTitle().props('loading')).toBe(false); + it('does not render skeleton', () => { + expect(findSkeleton().exists()).toBe(false); + expect(findWorkItemState().exists()).toBe(true); + expect(findWorkItemTitle().exists()).toBe(true); }); }); @@ -82,6 +89,7 @@ describe('WorkItemDetail component', () => { it('shows an error message when WorkItemTitle emits an `error` event', async () => { createComponent(); + await waitForPromises(); findWorkItemTitle().vm.$emit('error', i18n.updateError); await waitForPromises(); @@ -96,4 +104,18 @@ describe('WorkItemDetail component', () => { issuableId: workItemQueryResponse.data.workItem.id, }); }); + + it('emits workItemUpdated event when fields updated', async () => { + createComponent(); + + await waitForPromises(); + + findWorkItemState().vm.$emit('updated'); + + expect(wrapper.emitted('workItemUpdated')).toEqual([[]]); + + findWorkItemTitle().vm.$emit('updated'); + + expect(wrapper.emitted('workItemUpdated')).toEqual([[], []]); + }); }); diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js index 2803724b9af..85096392e84 100644 --- a/spec/frontend/work_items/pages/work_item_root_spec.js +++ b/spec/frontend/work_items/pages/work_item_root_spec.js @@ -1,21 +1,45 @@ +import { GlAlert } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { visitUrl } from '~/lib/utils/url_utility'; import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; import WorkItemsRoot from '~/work_items/pages/work_item_root.vue'; +import deleteWorkItem from '~/work_items/graphql/delete_work_item.mutation.graphql'; +import { deleteWorkItemResponse, deleteWorkItemFailureResponse } from '../mock_data'; + +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn(), +})); Vue.use(VueApollo); describe('Work items root component', () => { let wrapper; + const issuesListPath = '/-/issues'; + const mockToastShow = jest.fn(); const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail); + const findAlert = () => wrapper.findComponent(GlAlert); - const createComponent = () => { + const createComponent = ({ + deleteWorkItemHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse), + } = {}) => { wrapper = shallowMount(WorkItemsRoot, { + apolloProvider: createMockApollo([[deleteWorkItem, deleteWorkItemHandler]]), + provide: { + issuesListPath, + }, propsData: { id: '1', }, + mocks: { + $toast: { + show: mockToastShow, + }, + }, }); }; @@ -26,6 +50,38 @@ describe('Work items root component', () => { it('renders WorkItemDetail', () => { createComponent(); - expect(findWorkItemDetail().props()).toEqual({ workItemId: 'gid://gitlab/WorkItem/1' }); + expect(findWorkItemDetail().props()).toEqual({ + workItemId: 'gid://gitlab/WorkItem/1', + }); + }); + + it('deletes work item when deleteWorkItem event emitted', async () => { + const deleteWorkItemHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse); + + createComponent({ + deleteWorkItemHandler, + }); + + findWorkItemDetail().vm.$emit('deleteWorkItem'); + + await waitForPromises(); + + expect(deleteWorkItemHandler).toHaveBeenCalled(); + expect(mockToastShow).toHaveBeenCalled(); + expect(visitUrl).toHaveBeenCalledWith(issuesListPath); + }); + + it('shows alert if delete fails', async () => { + const deleteWorkItemHandler = jest.fn().mockRejectedValue(deleteWorkItemFailureResponse); + + createComponent({ + deleteWorkItemHandler, + }); + + findWorkItemDetail().vm.$emit('deleteWorkItem'); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); }); }); diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js index 7e68c5e4f0e..99dcd886f7b 100644 --- a/spec/frontend/work_items/router_spec.js +++ b/spec/frontend/work_items/router_spec.js @@ -17,6 +17,7 @@ describe('Work items router', () => { router, provide: { fullPath: 'full-path', + issuesListPath: 'full-path/-/issues', }, mocks: { $apollo: { diff --git a/spec/frontend/zen_mode_spec.js b/spec/frontend/zen_mode_spec.js index 44684619fae..a88910b2613 100644 --- a/spec/frontend/zen_mode_spec.js +++ b/spec/frontend/zen_mode_spec.js @@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import Dropzone from 'dropzone'; import $ from 'jquery'; import Mousetrap from 'mousetrap'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import GLForm from '~/gl_form'; import * as utils from '~/lib/utils/common_utils'; import ZenMode from '~/zen_mode'; @@ -33,7 +34,7 @@ describe('ZenMode', () => { mock = new MockAdapter(axios); mock.onGet().reply(200); - loadFixtures(fixtureName); + loadHTMLFixture(fixtureName); const form = $('.js-new-note-form'); new GLForm(form); // eslint-disable-line no-new @@ -45,8 +46,10 @@ describe('ZenMode', () => { // Set this manually because we can't actually scroll the window zen.scroll_position = 456; + }); - gon.features = { markdownContinueLists: true }; + afterEach(() => { + resetHTMLFixture(); }); describe('enabling dropzone', () => { |