diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-19 09:08:42 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-19 09:08:42 +0000 |
commit | b76ae638462ab0f673e5915986070518dd3f9ad3 (patch) | |
tree | bdab0533383b52873be0ec0eb4d3c66598ff8b91 /spec/frontend | |
parent | 434373eabe7b4be9593d18a585fb763f1e5f1a6f (diff) | |
download | gitlab-ce-b76ae638462ab0f673e5915986070518dd3f9ad3.tar.gz |
Add latest changes from gitlab-org/gitlab@14-2-stable-eev14.2.0-rc42
Diffstat (limited to 'spec/frontend')
318 files changed, 11352 insertions, 4947 deletions
diff --git a/spec/frontend/__helpers__/mock_dom_observer.js b/spec/frontend/__helpers__/mock_dom_observer.js index 1b93b81535d..dd26b594ad9 100644 --- a/spec/frontend/__helpers__/mock_dom_observer.js +++ b/spec/frontend/__helpers__/mock_dom_observer.js @@ -52,7 +52,7 @@ class MockIntersectionObserver extends MockObserver { * const { trigger: triggerMutate } = useMockMutationObserver(); * * it('test', () => { - * trigger(el, { options: { childList: true }, entry: { } }); + * triggerMutate(el, { options: { childList: true }, entry: { } }); * }); * }) * ``` @@ -60,33 +60,31 @@ class MockIntersectionObserver extends MockObserver { * @param {String} key */ const useMockObserver = (key, createMock) => { - let mockObserver; + let mockObservers = []; let origObserver; beforeEach(() => { origObserver = global[key]; global[key] = jest.fn().mockImplementation((...args) => { - mockObserver = createMock(...args); + const mockObserver = createMock(...args); + mockObservers.push(mockObserver); return mockObserver; }); }); afterEach(() => { - mockObserver = null; + mockObservers.forEach((x) => x.disconnect()); + mockObservers = []; global[key] = origObserver; }); const trigger = (...args) => { - if (!mockObserver) { - return; - } - - mockObserver.$_triggerObserve(...args); + mockObservers.forEach((observer) => { + observer.$_triggerObserve(...args); + }); }; - const observersCount = () => mockObserver.$_observers.length; - - return { trigger, observersCount }; + return { trigger }; }; export const useMockIntersectionObserver = () => diff --git a/spec/frontend/__helpers__/mock_window_location_helper.js b/spec/frontend/__helpers__/mock_window_location_helper.js index 08a28fbbbd6..3755778e5c1 100644 --- a/spec/frontend/__helpers__/mock_window_location_helper.js +++ b/spec/frontend/__helpers__/mock_window_location_helper.js @@ -10,7 +10,7 @@ */ const useMockLocation = (fn) => { const origWindowLocation = window.location; - let currentWindowLocation; + let currentWindowLocation = origWindowLocation; Object.defineProperty(window, 'location', { get: () => currentWindowLocation, diff --git a/spec/frontend/__helpers__/set_window_location_helper.js b/spec/frontend/__helpers__/set_window_location_helper.js index a94e73762c9..573a089f111 100644 --- a/spec/frontend/__helpers__/set_window_location_helper.js +++ b/spec/frontend/__helpers__/set_window_location_helper.js @@ -1,40 +1,53 @@ /** - * setWindowLocation allows for setting `window.location` - * (doing so directly is causing an error in jsdom) + * setWindowLocation allows for setting `window.location` within Jest. * - * Example usage: - * assert(window.location.hash === undefined); - * setWindowLocation('http://example.com#foo') - * assert(window.location.hash === '#foo'); + * The jsdom environment at the time of writing does not support changing the + * current location (see + * https://github.com/jsdom/jsdom/blob/16.4.0/lib/jsdom/living/window/navigation.js#L76), + * hence this helper. * - * More information: - * https://github.com/facebook/jest/issues/890 + * This helper mutates the current `window.location` very similarly to how + * a direct assignment to `window.location.href` would in a browser (but + * without the navigation/reload behaviour). For instance: * - * @param url + * - Set the full href by passing an absolute URL, e.g.: + * + * setWindowLocation('https://gdk.test'); + * // window.location.href is now 'https://gdk.test' + * + * - Set the path, search and/or hash components by passing a relative URL: + * + * setWindowLocation('/foo/bar'); + * // window.location.href is now 'http://test.host/foo/bar' + * + * setWindowLocation('?foo=bar'); + * // window.location.href is now 'http://test.host/?foo=bar' + * + * setWindowLocation('#foo'); + * // window.location.href is now 'http://test.host/#foo' + * + * setWindowLocation('/a/b/foo.html?bar=1#qux'); + * // window.location.href is now 'http://test.host/a/b/foo.html?bar=1#qux + * + * Both approaches also automatically update the rest of the properties on + * `window.locaton`. For instance: + * + * setWindowLocation('http://test.host/a/b/foo.html?bar=1#qux'); + * // window.location.origin is now 'http://test.host' + * // window.location.pathname is now '/a/b/foo.html' + * // window.location.search is now '?bar=1' + * // window.location.searchParams is now { bar: 1 } + * // window.location.hash is now '#qux' + * + * @param {string} url A string representing an absolute or relative URL. + * @returns {undefined} */ export default function setWindowLocation(url) { - const parsedUrl = new URL(url); + if (typeof url !== 'string') { + throw new TypeError(`Expected string; got ${url} (${typeof url})`); + } - const newLocationValue = [ - 'hash', - 'host', - 'hostname', - 'href', - 'origin', - 'pathname', - 'port', - 'protocol', - 'search', - ].reduce( - (location, prop) => ({ - ...location, - [prop]: parsedUrl[prop], - }), - {}, - ); + const newUrl = new URL(url, window.location.href); - Object.defineProperty(window, 'location', { - value: newLocationValue, - writable: true, - }); + global.jsdom.reconfigure({ url: newUrl.href }); } diff --git a/spec/frontend/__helpers__/set_window_location_helper_spec.js b/spec/frontend/__helpers__/set_window_location_helper_spec.js index 98f26854822..c0f3debddbc 100644 --- a/spec/frontend/__helpers__/set_window_location_helper_spec.js +++ b/spec/frontend/__helpers__/set_window_location_helper_spec.js @@ -1,40 +1,133 @@ import setWindowLocation from './set_window_location_helper'; +import { TEST_HOST } from './test_constants'; -describe('setWindowLocation', () => { - const originalLocation = window.location; +describe('helpers/set_window_location_helper', () => { + const originalLocation = window.location.href; - afterEach(() => { - window.location = originalLocation; + beforeEach(() => { + setWindowLocation(originalLocation); }); - it.each` - url | property | value - ${'https://gitlab.com#foo'} | ${'hash'} | ${'#foo'} - ${'http://gitlab.com'} | ${'host'} | ${'gitlab.com'} - ${'http://gitlab.org'} | ${'hostname'} | ${'gitlab.org'} - ${'http://gitlab.org/foo#bar'} | ${'href'} | ${'http://gitlab.org/foo#bar'} - ${'http://gitlab.com'} | ${'origin'} | ${'http://gitlab.com'} - ${'http://gitlab.com/foo/bar/baz'} | ${'pathname'} | ${'/foo/bar/baz'} - ${'https://gitlab.com'} | ${'protocol'} | ${'https:'} - ${'http://gitlab.com#foo'} | ${'protocol'} | ${'http:'} - ${'http://gitlab.com:8080'} | ${'port'} | ${'8080'} - ${'http://gitlab.com?foo=bar&bar=foo'} | ${'search'} | ${'?foo=bar&bar=foo'} - `( - 'sets "window.location.$property" to be "$value" when called with: "$url"', - ({ url, property, value }) => { - expect(window.location).toBe(originalLocation); - - setWindowLocation(url); - - expect(window.location[property]).toBe(value); - }, - ); - - it.each([null, 1, undefined, false, '', 'gitlab.com'])( - 'throws an error when called with an invalid url: "%s"', - (invalidUrl) => { - expect(() => setWindowLocation(invalidUrl)).toThrow(/Invalid URL/); - expect(window.location).toBe(originalLocation); - }, - ); + describe('setWindowLocation', () => { + describe('given a complete URL', () => { + it.each` + url | property | value + ${'https://gitlab.com#foo'} | ${'hash'} | ${'#foo'} + ${'http://gitlab.com'} | ${'host'} | ${'gitlab.com'} + ${'http://gitlab.org'} | ${'hostname'} | ${'gitlab.org'} + ${'http://gitlab.org/foo#bar'} | ${'href'} | ${'http://gitlab.org/foo#bar'} + ${'http://gitlab.com'} | ${'origin'} | ${'http://gitlab.com'} + ${'http://gitlab.com/foo/bar/baz'} | ${'pathname'} | ${'/foo/bar/baz'} + ${'https://gitlab.com'} | ${'protocol'} | ${'https:'} + ${'ftp://gitlab.com#foo'} | ${'protocol'} | ${'ftp:'} + ${'http://gitlab.com:8080'} | ${'port'} | ${'8080'} + ${'http://gitlab.com?foo=bar&bar=foo'} | ${'search'} | ${'?foo=bar&bar=foo'} + `( + 'sets "window.location.$property" to be "$value" when called with: "$url"', + ({ url, property, value }) => { + expect(window.location.href).toBe(originalLocation); + + setWindowLocation(url); + + expect(window.location[property]).toBe(value); + }, + ); + }); + + describe('given a partial URL', () => { + it.each` + partialURL | href + ${'//foo.test:3000/'} | ${'http://foo.test:3000/'} + ${'/foo/bar'} | ${`${originalLocation}foo/bar`} + ${'foo/bar'} | ${`${originalLocation}foo/bar`} + ${'?foo=bar'} | ${`${originalLocation}?foo=bar`} + ${'#a-thing'} | ${`${originalLocation}#a-thing`} + `('$partialURL sets location.href to $href', ({ partialURL, href }) => { + expect(window.location.href).toBe(originalLocation); + + setWindowLocation(partialURL); + + expect(window.location.href).toBe(href); + }); + }); + + describe('relative path', () => { + describe.each` + initialHref | path | newHref + ${'https://gdk.test/foo/bar'} | ${'/qux'} | ${'https://gdk.test/qux'} + ${'https://gdk.test/foo/bar/'} | ${'/qux'} | ${'https://gdk.test/qux'} + ${'https://gdk.test/foo/bar'} | ${'qux'} | ${'https://gdk.test/foo/qux'} + ${'https://gdk.test/foo/bar/'} | ${'qux'} | ${'https://gdk.test/foo/bar/qux'} + ${'https://gdk.test/foo/bar'} | ${'../qux'} | ${'https://gdk.test/qux'} + ${'https://gdk.test/foo/bar/'} | ${'../qux'} | ${'https://gdk.test/foo/qux'} + `('when location is $initialHref', ({ initialHref, path, newHref }) => { + beforeEach(() => { + setWindowLocation(initialHref); + }); + + it(`${path} sets window.location.href to ${newHref}`, () => { + expect(window.location.href).toBe(initialHref); + + setWindowLocation(path); + + expect(window.location.href).toBe(newHref); + }); + }); + }); + + it.each([null, 1, undefined, false, 'https://', 'https:', { foo: 1 }, []])( + 'throws an error when called with an invalid url: "%s"', + (invalidUrl) => { + expect(() => setWindowLocation(invalidUrl)).toThrow(); + expect(window.location.href).toBe(originalLocation); + }, + ); + + describe('affects links', () => { + it.each` + url | hrefAttr | expectedHref + ${'http://gitlab.com/'} | ${'foo'} | ${'http://gitlab.com/foo'} + ${'http://gitlab.com/bar/'} | ${'foo'} | ${'http://gitlab.com/bar/foo'} + ${'http://gitlab.com/bar/'} | ${'/foo'} | ${'http://gitlab.com/foo'} + ${'http://gdk.test:3000/?foo=bar'} | ${'?qux=1'} | ${'http://gdk.test:3000/?qux=1'} + ${'https://gdk.test:3000/?foo=bar'} | ${'//other.test'} | ${'https://other.test/'} + `( + 'given $url, <a href="$hrefAttr"> points to $expectedHref', + ({ url, hrefAttr, expectedHref }) => { + setWindowLocation(url); + + const link = document.createElement('a'); + link.setAttribute('href', hrefAttr); + + expect(link.href).toBe(expectedHref); + }, + ); + }); + }); + + // This set of tests relies on Jest executing tests in source order, which is + // at the time of writing the only order they will execute, by design. + // See https://github.com/facebook/jest/issues/4386 for more details. + describe('window.location resetting by global beforeEach', () => { + const overridden = 'https://gdk.test:1234/'; + const initial = `${TEST_HOST}/`; + + it('works before an override', () => { + expect(window.location.href).toBe(initial); + }); + + describe('overriding', () => { + beforeEach(() => { + setWindowLocation(overridden); + }); + + it('works', () => { + expect(window.location.href).toBe(overridden); + }); + }); + + it('works after an override', () => { + expect(window.location.href).toBe(initial); + }); + }); }); diff --git a/spec/frontend/admin/analytics/devops_score/components/devops_score_callout_spec.js b/spec/frontend/admin/analytics/devops_score/components/devops_score_callout_spec.js new file mode 100644 index 00000000000..ee14e002f1b --- /dev/null +++ b/spec/frontend/admin/analytics/devops_score/components/devops_score_callout_spec.js @@ -0,0 +1,67 @@ +import { GlBanner } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import DevopsScoreCallout from '~/analytics/devops_report/components/devops_score_callout.vue'; +import { INTRO_COOKIE_KEY } from '~/analytics/devops_report/constants'; +import * as utils from '~/lib/utils/common_utils'; +import { devopsReportDocsPath, devopsScoreIntroImagePath } from '../mock_data'; + +describe('DevopsScoreCallout', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(DevopsScoreCallout, { + provide: { + devopsReportDocsPath, + devopsScoreIntroImagePath, + }, + }); + }; + + const findBanner = () => wrapper.findComponent(GlBanner); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('with no cookie set', () => { + beforeEach(() => { + utils.setCookie = jest.fn(); + + createComponent(); + }); + + it('displays the banner', () => { + expect(findBanner().exists()).toBe(true); + }); + + it('does not call setCookie', () => { + expect(utils.setCookie).not.toHaveBeenCalled(); + }); + + describe('when the close button is clicked', () => { + beforeEach(() => { + findBanner().vm.$emit('close'); + }); + + it('sets the dismissed cookie', () => { + expect(utils.setCookie).toHaveBeenCalledWith(INTRO_COOKIE_KEY, 'true'); + }); + + it('hides the banner', () => { + expect(findBanner().exists()).toBe(false); + }); + }); + }); + + describe('with the dismissed cookie set', () => { + beforeEach(() => { + jest.spyOn(utils, 'getCookie').mockReturnValue('true'); + + createComponent(); + }); + + it('hides the banner', () => { + expect(findBanner().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js b/spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js index 7c20bbe21c8..8f8dac977de 100644 --- a/spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js +++ b/spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js @@ -1,14 +1,10 @@ -import { GlTable, GlBadge, GlEmptyState, GlLink } from '@gitlab/ui'; +import { GlTable, GlBadge, GlEmptyState } from '@gitlab/ui'; import { GlSingleStat } from '@gitlab/ui/dist/charts'; import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import DevopsScore from '~/analytics/devops_report/components/devops_score.vue'; -import { - devopsScoreMetricsData, - devopsReportDocsPath, - noDataImagePath, - devopsScoreTableHeaders, -} from '../mock_data'; +import DevopsScoreCallout from '~/analytics/devops_report/components/devops_score_callout.vue'; +import { devopsScoreMetricsData, noDataImagePath, devopsScoreTableHeaders } from '../mock_data'; describe('DevopsScore', () => { let wrapper; @@ -18,7 +14,6 @@ describe('DevopsScore', () => { mount(DevopsScore, { provide: { devopsScoreMetrics, - devopsReportDocsPath, noDataImagePath, }, }), @@ -30,12 +25,19 @@ describe('DevopsScore', () => { const findCol = (testId) => findTable().find(`[data-testid="${testId}"]`); const findUsageCol = () => findCol('usageCol'); const findDevopsScoreApp = () => wrapper.findByTestId('devops-score-app'); + const bannerExists = () => wrapper.findComponent(DevopsScoreCallout).exists(); + const findDocsLink = () => + wrapper.findByRole('link', { name: 'See example DevOps Score page in our documentation.' }); describe('with no data', () => { beforeEach(() => { createComponent({ devopsScoreMetrics: {} }); }); + it('includes the DevopsScoreCallout component ', () => { + expect(bannerExists()).toBe(true); + }); + describe('empty state', () => { it('displays the empty state', () => { expect(findEmptyState().exists()).toBe(true); @@ -48,7 +50,10 @@ describe('DevopsScore', () => { }); it('contains a link to the feature documentation', () => { - expect(wrapper.findComponent(GlLink).exists()).toBe(true); + expect(findDocsLink().exists()).toBe(true); + expect(findDocsLink().attributes('href')).toBe( + '/help/user/admin_area/analytics/dev_ops_report', + ); }); }); @@ -62,6 +67,10 @@ describe('DevopsScore', () => { createComponent(); }); + it('includes the DevopsScoreCallout component ', () => { + expect(bannerExists()).toBe(true); + }); + it('does not display the empty state', () => { expect(findEmptyState().exists()).toBe(false); }); diff --git a/spec/frontend/admin/analytics/devops_score/mock_data.js b/spec/frontend/admin/analytics/devops_score/mock_data.js index ae0c01a2661..e8f8b778ffa 100644 --- a/spec/frontend/admin/analytics/devops_score/mock_data.js +++ b/spec/frontend/admin/analytics/devops_score/mock_data.js @@ -44,3 +44,5 @@ export const devopsScoreMetricsData = { export const devopsReportDocsPath = 'docs-path'; export const noDataImagePath = 'image-path'; + +export const devopsScoreIntroImagePath = 'image-path'; diff --git a/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js b/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js index 18339164d5a..4bb22feb913 100644 --- a/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js +++ b/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js @@ -192,22 +192,27 @@ describe('Signup Form', () => { describe('form submit button confirmation modal for side-effect of adding possibly unwanted new users', () => { it.each` - requireAdminApprovalAction | userCapAction | buttonEffect - ${'unchanged from true'} | ${'unchanged'} | ${'submits form'} - ${'unchanged from false'} | ${'unchanged'} | ${'submits form'} - ${'toggled off'} | ${'unchanged'} | ${'shows confirmation modal'} - ${'toggled on'} | ${'unchanged'} | ${'submits form'} - ${'unchanged from false'} | ${'increased'} | ${'shows confirmation modal'} - ${'unchanged from true'} | ${'increased'} | ${'shows confirmation modal'} - ${'toggled off'} | ${'increased'} | ${'shows confirmation modal'} - ${'toggled on'} | ${'increased'} | ${'shows confirmation modal'} - ${'toggled on'} | ${'decreased'} | ${'submits form'} - ${'unchanged from false'} | ${'changed from limited to unlimited'} | ${'shows confirmation modal'} - ${'unchanged from false'} | ${'changed from unlimited to limited'} | ${'submits form'} - ${'unchanged from false'} | ${'unchanged from unlimited'} | ${'submits form'} + requireAdminApprovalAction | userCapAction | pendingUserCount | buttonEffect + ${'unchanged from true'} | ${'unchanged'} | ${0} | ${'submits form'} + ${'unchanged from false'} | ${'unchanged'} | ${0} | ${'submits form'} + ${'toggled off'} | ${'unchanged'} | ${1} | ${'shows confirmation modal'} + ${'toggled off'} | ${'unchanged'} | ${0} | ${'submits form'} + ${'toggled on'} | ${'unchanged'} | ${0} | ${'submits form'} + ${'unchanged from false'} | ${'increased'} | ${1} | ${'shows confirmation modal'} + ${'unchanged from true'} | ${'increased'} | ${0} | ${'submits form'} + ${'toggled off'} | ${'increased'} | ${1} | ${'shows confirmation modal'} + ${'toggled off'} | ${'increased'} | ${0} | ${'submits form'} + ${'toggled on'} | ${'increased'} | ${1} | ${'shows confirmation modal'} + ${'toggled on'} | ${'increased'} | ${0} | ${'submits form'} + ${'toggled on'} | ${'decreased'} | ${0} | ${'submits form'} + ${'toggled on'} | ${'decreased'} | ${1} | ${'submits form'} + ${'unchanged from false'} | ${'changed from limited to unlimited'} | ${1} | ${'shows confirmation modal'} + ${'unchanged from false'} | ${'changed from limited to unlimited'} | ${0} | ${'submits form'} + ${'unchanged from false'} | ${'changed from unlimited to limited'} | ${0} | ${'submits form'} + ${'unchanged from false'} | ${'unchanged from unlimited'} | ${0} | ${'submits form'} `( - '$buttonEffect if require admin approval for new sign-ups is $requireAdminApprovalAction and the user cap is $userCapAction', - async ({ requireAdminApprovalAction, userCapAction, buttonEffect }) => { + '$buttonEffect if require admin approval for new sign-ups is $requireAdminApprovalAction and the user cap is $userCapAction and pending user count is $pendingUserCount', + async ({ requireAdminApprovalAction, userCapAction, pendingUserCount, buttonEffect }) => { let isModalDisplayed; switch (buttonEffect) { @@ -224,7 +229,9 @@ describe('Signup Form', () => { const isFormSubmittedWhenClickingFormSubmitButton = !isModalDisplayed; - const injectedProps = {}; + const injectedProps = { + pendingUserCount, + }; const USER_CAP_DEFAULT = 5; @@ -310,6 +317,7 @@ describe('Signup Form', () => { await mountComponent({ injectedProps: { newUserSignupsCap: INITIAL_USER_CAP, + pendingUserCount: 5, }, stubs: { GlButton, GlModal: stubComponent(GlModal) }, }); diff --git a/spec/frontend/admin/signup_restrictions/mock_data.js b/spec/frontend/admin/signup_restrictions/mock_data.js index 624a5614c9c..135fc8caae0 100644 --- a/spec/frontend/admin/signup_restrictions/mock_data.js +++ b/spec/frontend/admin/signup_restrictions/mock_data.js @@ -17,6 +17,7 @@ export const rawMockData = { supportedSyntaxLinkUrl: '/supported/syntax/link', emailRestrictions: 'user1@domain.com, user2@domain.com', afterSignUpText: 'Congratulations on your successful sign-up!', + pendingUserCount: '0', }; export const mockData = { @@ -38,4 +39,5 @@ export const mockData = { supportedSyntaxLinkUrl: '/supported/syntax/link', emailRestrictions: 'user1@domain.com, user2@domain.com', afterSignUpText: 'Congratulations on your successful sign-up!', + pendingUserCount: '0', }; diff --git a/spec/frontend/admin/users/components/actions/actions_spec.js b/spec/frontend/admin/users/components/actions/actions_spec.js index 67d9bac8580..fd05b08a3fb 100644 --- a/spec/frontend/admin/users/components/actions/actions_spec.js +++ b/spec/frontend/admin/users/components/actions/actions_spec.js @@ -5,8 +5,8 @@ import { nextTick } from 'vue'; import Actions from '~/admin/users/components/actions'; import SharedDeleteAction from '~/admin/users/components/actions/shared/shared_delete_action.vue'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; - import { CONFIRMATION_ACTIONS, DELETE_ACTIONS } from '../../constants'; +import { paths } from '../../mock_data'; describe('Action components', () => { let wrapper; @@ -47,32 +47,33 @@ describe('Action components', () => { describe('DELETE_ACTION_COMPONENTS', () => { const oncallSchedules = [{ name: 'schedule1' }, { name: 'schedule2' }]; - it.each(DELETE_ACTIONS)('renders a dropdown item for "%s"', async (action) => { - initComponent({ - component: Actions[capitalizeFirstCharacter(action)], - props: { - username: 'John Doe', - paths: { - delete: '/delete', - block: '/block', + + it.each(DELETE_ACTIONS.map((action) => [action, paths[action]]))( + 'renders a dropdown item for "%s"', + async (action, expectedPath) => { + initComponent({ + component: Actions[capitalizeFirstCharacter(action)], + props: { + username: 'John Doe', + paths, + oncallSchedules, }, - oncallSchedules, - }, - stubs: { SharedDeleteAction }, - }); + stubs: { SharedDeleteAction }, + }); - await nextTick(); + await nextTick(); - const sharedAction = wrapper.find(SharedDeleteAction); + const sharedAction = wrapper.find(SharedDeleteAction); - expect(sharedAction.attributes('data-block-user-url')).toBe('/block'); - expect(sharedAction.attributes('data-delete-user-url')).toBe('/delete'); - expect(sharedAction.attributes('data-gl-modal-action')).toBe(kebabCase(action)); - expect(sharedAction.attributes('data-username')).toBe('John Doe'); - expect(sharedAction.attributes('data-oncall-schedules')).toBe( - JSON.stringify(oncallSchedules), - ); - expect(findDropdownItem().exists()).toBe(true); - }); + expect(sharedAction.attributes('data-block-user-url')).toBe(paths.block); + expect(sharedAction.attributes('data-delete-user-url')).toBe(expectedPath); + expect(sharedAction.attributes('data-gl-modal-action')).toBe(kebabCase(action)); + expect(sharedAction.attributes('data-username')).toBe('John Doe'); + expect(sharedAction.attributes('data-oncall-schedules')).toBe( + JSON.stringify(oncallSchedules), + ); + expect(findDropdownItem().exists()).toBe(true); + }, + ); }); }); diff --git a/spec/frontend/admin/users/components/user_date_spec.js b/spec/frontend/admin/users/components/user_date_spec.js index 1a2f2938db5..af262c6d3f0 100644 --- a/spec/frontend/admin/users/components/user_date_spec.js +++ b/spec/frontend/admin/users/components/user_date_spec.js @@ -1,6 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import UserDate from '~/vue_shared/components/user_date.vue'; +import { ISO_SHORT_FORMAT } from '~/vue_shared/constants'; import { users } from '../mock_data'; const mockDate = users[0].createdAt; @@ -22,12 +23,15 @@ describe('FormatDate component', () => { }); it.each` - date | output - ${mockDate} | ${'13 Nov, 2020'} - ${null} | ${'Never'} - ${undefined} | ${'Never'} - `('renders $date as $output', ({ date, output }) => { - initComponent({ date }); + date | dateFormat | output + ${mockDate} | ${undefined} | ${'13 Nov, 2020'} + ${null} | ${undefined} | ${'Never'} + ${undefined} | ${undefined} | ${'Never'} + ${mockDate} | ${ISO_SHORT_FORMAT} | ${'2020-11-13'} + ${null} | ${ISO_SHORT_FORMAT} | ${'Never'} + ${undefined} | ${ISO_SHORT_FORMAT} | ${'Never'} + `('renders $date as $output', ({ date, dateFormat, output }) => { + initComponent({ date, dateFormat }); expect(wrapper.text()).toBe(output); }); diff --git a/spec/frontend/admin/users/mock_data.js b/spec/frontend/admin/users/mock_data.js index ded3e6f7edf..73fa73c0b47 100644 --- a/spec/frontend/admin/users/mock_data.js +++ b/spec/frontend/admin/users/mock_data.js @@ -30,7 +30,7 @@ export const paths = { activate: '/admin/users/id/activate', unlock: '/admin/users/id/unlock', delete: '/admin/users/id', - deleteWithContributions: '/admin/users/id', + deleteWithContributions: '/admin/users/id?hard_delete=true', adminUser: '/admin/users/id', ban: '/admin/users/id/ban', unban: '/admin/users/id/unban', diff --git a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js index 1c4dde39585..e6a6e01c41c 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js @@ -6,7 +6,6 @@ import VueApollo from 'vue-apollo'; import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql'; import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; -import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import IntegrationsList from '~/alerts_settings/components/alerts_integrations_list.vue'; @@ -57,7 +56,6 @@ describe('AlertsSettingsWrapper', () => { let wrapper; let fakeApollo; let destroyIntegrationHandler; - useMockIntersectionObserver(); const httpMappingData = { payloadExample: '{"test: : "field"}', diff --git a/spec/frontend/analytics/devops_report/components/service_ping_disabled_spec.js b/spec/frontend/analytics/devops_report/components/service_ping_disabled_spec.js index 75ef9d9db94..c5c40e9a360 100644 --- a/spec/frontend/analytics/devops_report/components/service_ping_disabled_spec.js +++ b/spec/frontend/analytics/devops_report/components/service_ping_disabled_spec.js @@ -1,6 +1,6 @@ import { GlEmptyState, GlSprintf } from '@gitlab/ui'; import { TEST_HOST } from 'helpers/test_constants'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import ServicePingDisabled from '~/analytics/devops_report/components/service_ping_disabled.vue'; describe('~/analytics/devops_report/components/service_ping_disabled.vue', () => { @@ -11,21 +11,19 @@ describe('~/analytics/devops_report/components/service_ping_disabled.vue', () => }); const createWrapper = ({ isAdmin = false } = {}) => { - wrapper = shallowMountExtended(ServicePingDisabled, { + wrapper = mountExtended(ServicePingDisabled, { provide: { isAdmin, svgPath: TEST_HOST, - docsLink: TEST_HOST, primaryButtonPath: TEST_HOST, }, - stubs: { GlEmptyState, GlSprintf }, }); }; const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findMessageForRegularUsers = () => wrapper.findComponent(GlSprintf); - const findDocsLink = () => wrapper.findByTestId('docs-link'); - const findPowerOnButton = () => wrapper.findByTestId('power-on-button'); + const findDocsLink = () => wrapper.findByRole('link', { name: 'service ping' }); + const findPowerOnButton = () => wrapper.findByRole('link', { name: 'Turn on service ping' }); it('renders empty state with provided SVG path', () => { createWrapper(); @@ -45,7 +43,7 @@ describe('~/analytics/devops_report/components/service_ping_disabled.vue', () => it('renders docs link', () => { expect(findDocsLink().exists()).toBe(true); - expect(findDocsLink().attributes('href')).toBe(TEST_HOST); + expect(findDocsLink().attributes('href')).toBe('/help/development/service_ping/index.md'); }); }); diff --git a/spec/frontend/authentication/two_factor_auth/index_spec.js b/spec/frontend/authentication/two_factor_auth/index_spec.js index f5345139021..0ff9d60f409 100644 --- a/spec/frontend/authentication/two_factor_auth/index_spec.js +++ b/spec/frontend/authentication/two_factor_auth/index_spec.js @@ -1,5 +1,6 @@ import { getByTestId, fireEvent } from '@testing-library/dom'; import { createWrapper } from '@vue/test-utils'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { initRecoveryCodes, initClose2faSuccessMessage } from '~/authentication/two_factor_auth'; import RecoveryCodes from '~/authentication/two_factor_auth/components/recovery_codes.vue'; import * as urlUtils from '~/lib/utils/url_utility'; @@ -53,8 +54,7 @@ describe('initClose2faSuccessMessage', () => { describe('when alert is closed', () => { beforeEach(() => { - delete window.location; - window.location = new URL( + setWindowLocation( 'https://localhost/-/profile/account?two_factor_auth_enabled_successfully=true', ); diff --git a/spec/frontend/authentication/webauthn/error_spec.js b/spec/frontend/authentication/webauthn/error_spec.js index 26f1ca5e27d..9b71f77dde2 100644 --- a/spec/frontend/authentication/webauthn/error_spec.js +++ b/spec/frontend/authentication/webauthn/error_spec.js @@ -1,3 +1,4 @@ +import setWindowLocation from 'helpers/set_window_location_helper'; import WebAuthnError from '~/authentication/webauthn/error'; describe('WebAuthnError', () => { @@ -17,19 +18,8 @@ describe('WebAuthnError', () => { }); describe('SecurityError', () => { - const { location } = window; - - beforeEach(() => { - delete window.location; - window.location = {}; - }); - - afterEach(() => { - window.location = location; - }); - it('returns a descriptive error if https is disabled', () => { - window.location.protocol = 'http:'; + setWindowLocation('http://localhost'); const expectedMessage = 'WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details.'; @@ -39,7 +29,7 @@ describe('WebAuthnError', () => { }); it('returns a generic error if https is enabled', () => { - window.location.protocol = 'https:'; + setWindowLocation('https://localhost'); const expectedMessage = 'There was a problem communicating with your device.'; expect( diff --git a/spec/frontend/authentication/webauthn/register_spec.js b/spec/frontend/authentication/webauthn/register_spec.js index 43cd3d7ca34..0f8ea2b635f 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 setWindowLocation from 'helpers/set_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; import WebAuthnRegister from '~/authentication/webauthn/register'; import MockWebAuthnDevice from './mock_webauthn_device'; @@ -50,17 +51,14 @@ describe('WebAuthnRegister', () => { }); describe('when unsupported', () => { - const { location, PublicKeyCredential } = window; + const { PublicKeyCredential } = window; beforeEach(() => { - delete window.location; delete window.credentials; - window.location = {}; window.PublicKeyCredential = undefined; }); afterEach(() => { - window.location = location; window.PublicKeyCredential = PublicKeyCredential; }); @@ -69,7 +67,7 @@ describe('WebAuthnRegister', () => { ${false} | ${'WebAuthn only works with HTTPS-enabled websites'} ${true} | ${'Please use a supported browser, e.g. Chrome (67+) or Firefox'} `('when https is $httpsEnabled', ({ httpsEnabled, expectedText }) => { - window.location.protocol = httpsEnabled ? 'https:' : 'http:'; + setWindowLocation(`${httpsEnabled ? 'https:' : 'http:'}//localhost`); component.start(); expect(findMessage().text()).toContain(expectedText); diff --git a/spec/frontend/blob/components/blob_header_default_actions_spec.js b/spec/frontend/blob/components/blob_header_default_actions_spec.js index bce65899c43..e321bb41774 100644 --- a/spec/frontend/blob/components/blob_header_default_actions_spec.js +++ b/spec/frontend/blob/components/blob_header_default_actions_spec.js @@ -39,6 +39,9 @@ describe('Blob Header Default Actions', () => { }); describe('renders', () => { + const findCopyButton = () => wrapper.find('[data-testid="copyContentsButton"]'); + const findViewRawButton = () => wrapper.find('[data-testid="viewRawButton"]'); + it('gl-button-group component', () => { expect(btnGroup.exists()).toBe(true); }); @@ -76,7 +79,14 @@ describe('Blob Header Default Actions', () => { hasRenderError: true, }); - expect(wrapper.find('[data-testid="copyContentsButton"]').exists()).toBe(false); + expect(findCopyButton().exists()).toBe(false); + }); + + it('does not render the copy and view raw button if isBinary is set to true', () => { + createComponent({ isBinary: true }); + + expect(findCopyButton().exists()).toBe(false); + expect(findViewRawButton().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/blob/components/blob_header_spec.js b/spec/frontend/blob/components/blob_header_spec.js index 865e8ab1124..f841785be42 100644 --- a/spec/frontend/blob/components/blob_header_spec.js +++ b/spec/frontend/blob/components/blob_header_spec.js @@ -29,6 +29,8 @@ describe('Blob Header Default Actions', () => { }); describe('rendering', () => { + const findDefaultActions = () => wrapper.find(DefaultActions); + const slots = { prepend: 'Foo Prepend', actions: 'Actions Bar', @@ -42,7 +44,7 @@ describe('Blob Header Default Actions', () => { it('renders all components', () => { createComponent(); expect(wrapper.find(ViewerSwitcher).exists()).toBe(true); - expect(wrapper.find(DefaultActions).exists()).toBe(true); + expect(findDefaultActions().exists()).toBe(true); expect(wrapper.find(BlobFilepath).exists()).toBe(true); }); @@ -100,7 +102,13 @@ describe('Blob Header Default Actions', () => { hasRenderError: true, }, ); - expect(wrapper.find(DefaultActions).props('hasRenderError')).toBe(true); + expect(findDefaultActions().props('hasRenderError')).toBe(true); + }); + + it('passes the correct isBinary value to default actions when viewing a binary file', () => { + createComponent({}, {}, { isBinary: true }); + + expect(findDefaultActions().props('isBinary')).toBe(true); }); }); diff --git a/spec/frontend/blob/csv/csv_viewer_spec.js b/spec/frontend/blob/csv/csv_viewer_spec.js index abb914b8f57..17973c709c1 100644 --- a/spec/frontend/blob/csv/csv_viewer_spec.js +++ b/spec/frontend/blob/csv/csv_viewer_spec.js @@ -1,8 +1,9 @@ -import { GlAlert, GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { GlLoadingIcon, GlTable } from '@gitlab/ui'; import { getAllByRole } from '@testing-library/dom'; import { shallowMount, mount } from '@vue/test-utils'; import { nextTick } from 'vue'; -import CSVViewer from '~/blob/csv/csv_viewer.vue'; +import CsvViewer from '~/blob/csv/csv_viewer.vue'; +import PapaParseAlert from '~/vue_shared/components/papa_parse_alert.vue'; const validCsv = 'one,two,three'; const brokenCsv = '{\n "json": 1,\n "key": [1, 2, 3]\n}'; @@ -11,7 +12,7 @@ describe('app/assets/javascripts/blob/csv/csv_viewer.vue', () => { let wrapper; const createComponent = ({ csv = validCsv, mountFunction = shallowMount } = {}) => { - wrapper = mountFunction(CSVViewer, { + wrapper = mountFunction(CsvViewer, { propsData: { csv, }, @@ -20,7 +21,7 @@ describe('app/assets/javascripts/blob/csv/csv_viewer.vue', () => { const findCsvTable = () => wrapper.findComponent(GlTable); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findAlert = () => wrapper.findComponent(GlAlert); + const findAlert = () => wrapper.findComponent(PapaParseAlert); afterEach(() => { wrapper.destroy(); @@ -35,12 +36,12 @@ describe('app/assets/javascripts/blob/csv/csv_viewer.vue', () => { }); describe('when the CSV contains errors', () => { - it('should render alert', async () => { + it('should render alert with correct props', async () => { createComponent({ csv: brokenCsv }); await nextTick; expect(findAlert().props()).toMatchObject({ - variant: 'danger', + papaParseErrors: [{ code: 'UndetectableDelimiter' }], }); }); }); diff --git a/spec/frontend/blob/viewer/index_spec.js b/spec/frontend/blob/viewer/index_spec.js index 6a24b76abc8..705c4630a68 100644 --- a/spec/frontend/blob/viewer/index_spec.js +++ b/spec/frontend/blob/viewer/index_spec.js @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import { setTestTimeout } from 'helpers/timeout'; -import BlobViewer from '~/blob/viewer/index'; +import { BlobViewer } from '~/blob/viewer/index'; import axios from '~/lib/utils/axios_utils'; const execImmediately = (callback) => { diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js index 87f9a68f5dd..7d3ecc773a6 100644 --- a/spec/frontend/boards/board_card_inner_spec.js +++ b/spec/frontend/boards/board_card_inner_spec.js @@ -1,6 +1,7 @@ import { GlLabel, GlLoadingIcon, GlTooltip } from '@gitlab/ui'; import { range } from 'lodash'; import Vuex from 'vuex'; +import setWindowLocation from 'helpers/set_window_location_helper'; 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'; @@ -8,7 +9,7 @@ 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 } from './mock_data'; +import { mockLabelList, mockIssue, mockIssueFullPath } from './mock_data'; jest.mock('~/lib/utils/url_utility'); jest.mock('~/boards/eventhub'); @@ -44,7 +45,7 @@ describe('Board card component', () => { const findEpicCountablesTotalWeight = () => wrapper.findByTestId('epic-countables-total-weight'); const findEpicProgressTooltip = () => wrapper.findByTestId('epic-progress-tooltip-content'); - const createStore = ({ isEpicBoard = false } = {}) => { + const createStore = ({ isEpicBoard = false, isProjectBoard = false } = {}) => { store = new Vuex.Store({ ...defaultStore, state: { @@ -54,7 +55,7 @@ describe('Board card component', () => { getters: { isGroupBoard: () => true, isEpicBoard: () => isEpicBoard, - isProjectBoard: () => false, + isProjectBoard: () => isProjectBoard, }, }); }; @@ -133,6 +134,17 @@ describe('Board card component', () => { expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); }); + it('does not render item reference path', () => { + createStore({ isProjectBoard: true }); + createWrapper(); + + expect(wrapper.find('.board-card-number').text()).not.toContain(mockIssueFullPath); + }); + + it('renders item reference path', () => { + expect(wrapper.find('.board-card-number').text()).toContain(mockIssueFullPath); + }); + describe('blocked', () => { it('renders blocked icon if issue is blocked', async () => { createWrapper({ @@ -363,8 +375,6 @@ describe('Board card component', () => { describe('filterByLabel method', () => { beforeEach(() => { - delete window.location; - wrapper.setProps({ updateFilters: true, }); @@ -373,7 +383,7 @@ describe('Board card component', () => { describe('when selected label is not in the filter', () => { beforeEach(() => { jest.spyOn(wrapper.vm, 'performSearch').mockImplementation(() => {}); - window.location = { search: '' }; + setWindowLocation('?'); wrapper.vm.filterByLabel(label1); }); @@ -394,7 +404,7 @@ describe('Board card component', () => { describe('when selected label is already in the filter', () => { beforeEach(() => { jest.spyOn(wrapper.vm, 'performSearch').mockImplementation(() => {}); - window.location = { search: '?label_name[]=testing%20123' }; + setWindowLocation('?label_name[]=testing%20123'); wrapper.vm.filterByLabel(label1); }); diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js index c440c110094..811f0043a01 100644 --- a/spec/frontend/boards/board_list_helper.js +++ b/spec/frontend/boards/board_list_helper.js @@ -4,8 +4,9 @@ import Vuex from 'vuex'; import BoardCard from '~/boards/components/board_card.vue'; import BoardList from '~/boards/components/board_list.vue'; import BoardNewIssue from '~/boards/components/board_new_issue.vue'; +import BoardNewItem from '~/boards/components/board_new_item.vue'; import defaultState from '~/boards/stores/state'; -import { mockList, mockIssuesByListId, issues } from './mock_data'; +import { mockList, mockIssuesByListId, issues, mockGroupProjects } from './mock_data'; export default function createComponent({ listIssueProps = {}, @@ -17,6 +18,7 @@ export default function createComponent({ state = defaultState, stubs = { BoardNewIssue, + BoardNewItem, BoardCard, }, } = {}) { @@ -25,6 +27,7 @@ export default function createComponent({ const store = new Vuex.Store({ state: { + selectedProject: mockGroupProjects[0], boardItemsByListId: mockIssuesByListId, boardItems: issues, pageInfoByListId: { @@ -77,6 +80,7 @@ export default function createComponent({ provide: { groupId: null, rootPath: '/', + boardId: '1', weightFeatureAvailable: false, boardWeight: null, canAdminList: true, diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index a3b1810ab80..6f623eab1af 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -1,3 +1,5 @@ +import Draggable from 'vuedraggable'; +import { DraggableItemTypes } from 'ee_else_ce/boards/constants'; import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; import createComponent from 'jest/boards/board_list_helper'; import BoardCard from '~/boards/components/board_card.vue'; @@ -10,6 +12,23 @@ describe('Board list component', () => { const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); const findIssueCountLoadingIcon = () => wrapper.find('[data-testid="count-loading-icon"]'); + const findDraggable = () => wrapper.findComponent(Draggable); + + const startDrag = ( + params = { + item: { + dataset: { + draggableItemType: DraggableItemTypes.card, + }, + }, + }, + ) => { + findByTestId('tree-root-wrapper').vm.$emit('start', params); + }; + + const endDrag = (params) => { + findByTestId('tree-root-wrapper').vm.$emit('end', params); + }; useFakeRequestAnimationFrame(); @@ -155,40 +174,89 @@ describe('Board list component', () => { }); describe('drag & drop issue', () => { - beforeEach(() => { - wrapper = createComponent(); - }); + describe('when dragging is allowed', () => { + beforeEach(() => { + wrapper = createComponent({ + componentProps: { + disabled: false, + }, + }); + }); - describe('handleDragOnStart', () => { - it('adds a class `is-dragging` to document body', () => { - expect(document.body.classList.contains('is-dragging')).toBe(false); + it('Draggable is used', () => { + expect(findDraggable().exists()).toBe(true); + }); + + describe('handleDragOnStart', () => { + it('adds a class `is-dragging` to document body', () => { + expect(document.body.classList.contains('is-dragging')).toBe(false); - findByTestId('tree-root-wrapper').vm.$emit('start'); + startDrag(); - expect(document.body.classList.contains('is-dragging')).toBe(true); + expect(document.body.classList.contains('is-dragging')).toBe(true); + }); }); - }); - describe('handleDragOnEnd', () => { - it('removes class `is-dragging` from document body', () => { - jest.spyOn(wrapper.vm, 'moveItem').mockImplementation(() => {}); - document.body.classList.add('is-dragging'); + describe('handleDragOnEnd', () => { + beforeEach(() => { + jest.spyOn(wrapper.vm, 'moveItem').mockImplementation(() => {}); + + startDrag(); + }); + + it('removes class `is-dragging` from document body', () => { + document.body.classList.add('is-dragging'); + + endDrag({ + oldIndex: 1, + newIndex: 0, + item: { + dataset: { + draggableItemType: DraggableItemTypes.card, + itemId: mockIssues[0].id, + itemIid: mockIssues[0].iid, + itemPath: mockIssues[0].referencePath, + }, + }, + to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } }, + from: { dataset: { listId: 'gid://gitlab/List/2' } }, + }); - findByTestId('tree-root-wrapper').vm.$emit('end', { - oldIndex: 1, - newIndex: 0, - item: { - dataset: { - itemId: mockIssues[0].id, - itemIid: mockIssues[0].iid, - itemPath: mockIssues[0].referencePath, + expect(document.body.classList.contains('is-dragging')).toBe(false); + }); + + it(`should not handle the event if the dragged item is not a "${DraggableItemTypes.card}"`, () => { + endDrag({ + oldIndex: 1, + newIndex: 0, + item: { + dataset: { + draggableItemType: DraggableItemTypes.list, + itemId: mockIssues[0].id, + itemIid: mockIssues[0].iid, + itemPath: mockIssues[0].referencePath, + }, }, + to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } }, + from: { dataset: { listId: 'gid://gitlab/List/2' } }, + }); + + expect(document.body.classList.contains('is-dragging')).toBe(true); + }); + }); + }); + + describe('when dragging is not allowed', () => { + beforeEach(() => { + wrapper = createComponent({ + componentProps: { + disabled: true, }, - to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } }, - from: { dataset: { listId: 'gid://gitlab/List/2' } }, }); + }); - expect(document.body.classList.contains('is-dragging')).toBe(false); + it('Draggable is not used', () => { + expect(findDraggable().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js index 9a9ce7b8dc1..25ec568e48d 100644 --- a/spec/frontend/boards/components/board_card_spec.js +++ b/spec/frontend/boards/components/board_card_spec.js @@ -31,6 +31,7 @@ describe('Board card', () => { actions: mockActions, getters: { isEpicBoard: () => false, + isProjectBoard: () => false, }, }); }; diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js index 6ac5d16e5a3..50f86e92adb 100644 --- a/spec/frontend/boards/components/board_filtered_search_spec.js +++ b/spec/frontend/boards/components/board_filtered_search_spec.js @@ -115,6 +115,9 @@ describe('BoardFilteredSearch', () => { { type: 'author_username', value: { data: 'root', operator: '=' } }, { type: 'label_name', value: { data: 'label', operator: '=' } }, { type: 'label_name', value: { data: 'label2', operator: '=' } }, + { type: 'milestone_title', value: { data: 'New Milestone', operator: '=' } }, + { type: 'types', value: { data: 'INCIDENT', operator: '=' } }, + { type: 'weight', value: { data: '2', operator: '=' } }, ]; jest.spyOn(urlUtility, 'updateHistory'); findFilteredSearch().vm.$emit('onFilter', mockFilters); @@ -122,7 +125,8 @@ describe('BoardFilteredSearch', () => { expect(urlUtility.updateHistory).toHaveBeenCalledWith({ title: '', replace: true, - url: 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label2', + url: + 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label2&milestone_title=New+Milestone&types=INCIDENT&weight=2', }); }); }); diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index 3966c3e6b87..52f1907654a 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -1,5 +1,6 @@ import { GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; @@ -75,10 +76,6 @@ describe('BoardForm', () => { }); }; - beforeEach(() => { - delete window.location; - }); - afterEach(() => { wrapper.destroy(); wrapper = null; @@ -244,7 +241,7 @@ describe('BoardForm', () => { updateBoard: { board: { id: 'gid://gitlab/Board/321', webPath: 'test-path' } }, }, }); - window.location = new URL('https://test/boards/1'); + setWindowLocation('https://test/boards/1'); createComponent({ canAdminBoard: true, currentPage: formType.edit }); findInput().trigger('keyup.enter', { metaKey: true }); @@ -270,7 +267,7 @@ describe('BoardForm', () => { updateBoard: { board: { id: 'gid://gitlab/Board/321', webPath: 'test-path' } }, }, }); - window.location = new URL('https://test/boards/1?group_by=epic'); + setWindowLocation('https://test/boards/1?group_by=epic'); createComponent({ canAdminBoard: true, currentPage: formType.edit }); findInput().trigger('keyup.enter', { metaKey: true }); diff --git a/spec/frontend/boards/components/board_new_issue_spec.js b/spec/frontend/boards/components/board_new_issue_spec.js index e6405bbcff3..57ccebf3676 100644 --- a/spec/frontend/boards/components/board_new_issue_spec.js +++ b/spec/frontend/boards/components/board_new_issue_spec.js @@ -1,6 +1,9 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import BoardNewIssue from '~/boards/components/board_new_issue.vue'; +import BoardNewItem from '~/boards/components/board_new_item.vue'; +import ProjectSelect from '~/boards/components/project_select.vue'; +import eventHub from '~/boards/eventhub'; import { mockList, mockGroupProjects } from '../mock_data'; @@ -8,107 +11,104 @@ const localVue = createLocalVue(); localVue.use(Vuex); +const addListNewIssuesSpy = jest.fn().mockResolvedValue(); +const mockActions = { addListNewIssue: addListNewIssuesSpy }; + +const createComponent = ({ + state = { selectedProject: mockGroupProjects[0], fullPath: mockGroupProjects[0].fullPath }, + actions = mockActions, + getters = { isGroupBoard: () => true, isProjectBoard: () => false }, +} = {}) => + shallowMount(BoardNewIssue, { + localVue, + store: new Vuex.Store({ + state, + actions, + getters, + }), + propsData: { + list: mockList, + }, + provide: { + groupId: 1, + weightFeatureAvailable: false, + boardWeight: null, + }, + stubs: { + BoardNewItem, + }, + }); + describe('Issue boards new issue form', () => { let wrapper; - let vm; - - const addListNewIssuesSpy = jest.fn(); - - const findSubmitButton = () => wrapper.find({ ref: 'submitButton' }); - const findCancelButton = () => wrapper.find({ ref: 'cancelButton' }); - const findSubmitForm = () => wrapper.find({ ref: 'submitForm' }); - - const submitIssue = () => { - const dummySubmitEvent = { - preventDefault() {}, - }; - return findSubmitForm().trigger('submit', dummySubmitEvent); - }; - - beforeEach(() => { - const store = new Vuex.Store({ - state: { selectedProject: mockGroupProjects[0] }, - actions: { addListNewIssue: addListNewIssuesSpy }, - getters: { isGroupBoard: () => false, isProjectBoard: () => true }, - }); - - wrapper = shallowMount(BoardNewIssue, { - propsData: { - disabled: false, - list: mockList, - }, - store, - localVue, - provide: { - groupId: null, - weightFeatureAvailable: false, - boardWeight: null, - }, - }); + const findBoardNewItem = () => wrapper.findComponent(BoardNewItem); - vm = wrapper.vm; + beforeEach(async () => { + wrapper = createComponent(); - return vm.$nextTick(); + await wrapper.vm.$nextTick(); }); afterEach(() => { wrapper.destroy(); }); - it('calls submit if submit button is clicked', async () => { - jest.spyOn(wrapper.vm, 'submit').mockImplementation(); - wrapper.setData({ title: 'Testing Title' }); - - await vm.$nextTick(); - await submitIssue(); - expect(wrapper.vm.submit).toHaveBeenCalled(); - }); - - it('disables submit button if title is empty', () => { - expect(findSubmitButton().props().disabled).toBe(true); + it('renders board-new-item component', () => { + const boardNewItem = findBoardNewItem(); + expect(boardNewItem.exists()).toBe(true); + expect(boardNewItem.props()).toEqual({ + list: mockList, + formEventPrefix: 'toggle-issue-form-', + submitButtonTitle: 'Create issue', + disableSubmit: false, + }); }); - it('enables submit button if title is not empty', async () => { - wrapper.setData({ title: 'Testing Title' }); - - await vm.$nextTick(); - expect(wrapper.find({ ref: 'input' }).element.value).toBe('Testing Title'); - expect(findSubmitButton().props().disabled).toBe(false); + it('calls addListNewIssue action when `board-new-item` emits form-submit event', async () => { + findBoardNewItem().vm.$emit('form-submit', { title: 'Foo' }); + + await wrapper.vm.$nextTick(); + expect(addListNewIssuesSpy).toHaveBeenCalledWith(expect.any(Object), { + list: mockList, + issueInput: { + title: 'Foo', + labelIds: [], + assigneeIds: [], + milestoneId: undefined, + projectPath: mockGroupProjects[0].fullPath, + }, + }); }); - it('clears title after clicking cancel', async () => { - findCancelButton().trigger('click'); + it('emits event `toggle-issue-form` with current list Id suffix on eventHub when `board-new-item` emits form-cancel event', async () => { + jest.spyOn(eventHub, '$emit').mockImplementation(); + findBoardNewItem().vm.$emit('form-cancel'); - await vm.$nextTick(); - expect(vm.title).toBe(''); + await wrapper.vm.$nextTick(); + expect(eventHub.$emit).toHaveBeenCalledWith(`toggle-issue-form-${mockList.id}`); }); - describe('submit success', () => { - it('creates new issue', async () => { - wrapper.setData({ title: 'create issue' }); + describe('when in group issue board', () => { + it('renders project-select component within board-new-item component', () => { + const projectSelect = findBoardNewItem().findComponent(ProjectSelect); - await vm.$nextTick(); - await submitIssue(); - expect(addListNewIssuesSpy).toHaveBeenCalled(); + expect(projectSelect.exists()).toBe(true); + expect(projectSelect.props('list')).toEqual(mockList); }); + }); - it('enables button after submit', async () => { - jest.spyOn(wrapper.vm, 'submit').mockImplementation(); - wrapper.setData({ title: 'create issue' }); - - await vm.$nextTick(); - await submitIssue(); - expect(findSubmitButton().props().disabled).toBe(false); + describe('when in project issue board', () => { + beforeEach(() => { + wrapper = createComponent({ + getters: { isGroupBoard: () => false, isProjectBoard: () => true }, + }); }); - it('clears title after submit', async () => { - wrapper.setData({ title: 'create issue' }); + it('does not render project-select component within board-new-item component', () => { + const projectSelect = findBoardNewItem().findComponent(ProjectSelect); - await vm.$nextTick(); - await submitIssue(); - await vm.$nextTick(); - expect(vm.title).toBe(''); + expect(projectSelect.exists()).toBe(false); }); }); }); diff --git a/spec/frontend/boards/components/board_new_item_spec.js b/spec/frontend/boards/components/board_new_item_spec.js new file mode 100644 index 00000000000..0151d9c1c14 --- /dev/null +++ b/spec/frontend/boards/components/board_new_item_spec.js @@ -0,0 +1,103 @@ +import { GlForm, GlFormInput, GlButton } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; + +import BoardNewItem from '~/boards/components/board_new_item.vue'; +import eventHub from '~/boards/eventhub'; + +import { mockList } from '../mock_data'; + +const createComponent = ({ + list = mockList, + formEventPrefix = 'toggle-issue-form-', + disabledSubmit = false, + submitButtonTitle = 'Create item', +} = {}) => + mountExtended(BoardNewItem, { + propsData: { + list, + formEventPrefix, + disabledSubmit, + submitButtonTitle, + }, + slots: { + default: '<div id="default-slot"></div>', + }, + stubs: { + GlForm, + }, + }); + +describe('BoardNewItem', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + it('renders gl-form component', () => { + expect(wrapper.findComponent(GlForm).exists()).toBe(true); + }); + + it('renders field label', () => { + expect(wrapper.find('label').exists()).toBe(true); + expect(wrapper.find('label').text()).toBe('Title'); + }); + + it('renders gl-form-input field', () => { + expect(wrapper.findComponent(GlFormInput).exists()).toBe(true); + }); + + it('renders default slot contents', () => { + expect(wrapper.find('#default-slot').exists()).toBe(true); + }); + + it('renders submit and cancel buttons', () => { + const buttons = wrapper.findAllComponents(GlButton); + expect(buttons).toHaveLength(2); + expect(buttons.at(0).text()).toBe('Create item'); + expect(buttons.at(1).text()).toBe('Cancel'); + }); + + describe('events', () => { + const glForm = () => wrapper.findComponent(GlForm); + const titleInput = () => wrapper.find('input[name="issue_title"]'); + + it('emits `form-submit` event with title value when `submit` is triggered on gl-form', async () => { + titleInput().setValue('Foo'); + await glForm().trigger('submit'); + + expect(wrapper.emitted('form-submit')).toBeTruthy(); + expect(wrapper.emitted('form-submit')[0]).toEqual([ + { + title: 'Foo', + list: mockList, + }, + ]); + }); + + it('emits `scroll-board-list-` event with list.id on eventHub when `submit` is triggered on gl-form', async () => { + jest.spyOn(eventHub, '$emit').mockImplementation(); + await glForm().trigger('submit'); + + expect(eventHub.$emit).toHaveBeenCalledWith(`scroll-board-list-${mockList.id}`); + }); + + it('emits `form-cancel` event and clears title value when `reset` is triggered on gl-form', async () => { + titleInput().setValue('Foo'); + + await wrapper.vm.$nextTick(); + expect(titleInput().element.value).toBe('Foo'); + + await glForm().trigger('reset'); + + expect(titleInput().element.value).toBe(''); + expect(wrapper.emitted('form-cancel')).toBeTruthy(); + }); + }); + }); +}); diff --git a/spec/frontend/boards/components/issue_board_filtered_search_spec.js b/spec/frontend/boards/components/issue_board_filtered_search_spec.js index 0e3cf59901e..b6de46f8db8 100644 --- a/spec/frontend/boards/components/issue_board_filtered_search_spec.js +++ b/spec/frontend/boards/components/issue_board_filtered_search_spec.js @@ -1,16 +1,16 @@ import { shallowMount } from '@vue/test-utils'; import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue'; import IssueBoardFilteredSpec from '~/boards/components/issue_board_filtered_search.vue'; -import { BoardType } from '~/boards/constants'; import issueBoardFilters from '~/boards/issue_board_filters'; import { mockTokens } from '../mock_data'; +jest.mock('~/boards/issue_board_filters'); + describe('IssueBoardFilter', () => { let wrapper; - const createComponent = ({ initialFilterParams = {} } = {}) => { + const createComponent = () => { wrapper = shallowMount(IssueBoardFilteredSpec, { - provide: { initialFilterParams }, props: { fullPath: '', boardType: '' }, }); }; @@ -20,7 +20,17 @@ describe('IssueBoardFilter', () => { }); describe('default', () => { + let fetchAuthorsSpy; + let fetchLabelsSpy; beforeEach(() => { + fetchAuthorsSpy = jest.fn(); + fetchLabelsSpy = jest.fn(); + + issueBoardFilters.mockReturnValue({ + fetchAuthors: fetchAuthorsSpy, + fetchLabels: fetchLabelsSpy, + }); + createComponent(); }); @@ -28,17 +38,10 @@ describe('IssueBoardFilter', () => { expect(wrapper.find(BoardFilteredSearch).exists()).toBe(true); }); - it.each([[BoardType.group], [BoardType.project]])( - 'when boardType is %s we pass the correct tokens to BoardFilteredSearch', - (boardType) => { - const { fetchAuthors, fetchLabels } = issueBoardFilters({}, '', boardType); + it('passes the correct tokens to BoardFilteredSearch', () => { + const tokens = mockTokens(fetchLabelsSpy, fetchAuthorsSpy, wrapper.vm.fetchMilestones); - const tokens = mockTokens(fetchLabels, fetchAuthors); - - expect(wrapper.find(BoardFilteredSearch).props('tokens').toString()).toBe( - tokens.toString(), - ); - }, - ); + expect(wrapper.find(BoardFilteredSearch).props('tokens')).toEqual(tokens); + }); }); }); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js index 8992a5780f3..60474767f2d 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js @@ -97,6 +97,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { addLabelIds: TEST_LABELS.map((label) => label.id), projectPath: TEST_ISSUE_FULLPATH, removeLabelIds: [], + iid: null, }); }); }); @@ -121,6 +122,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { addLabelIds: [5, 7], removeLabelIds: [6], projectPath: TEST_ISSUE_FULLPATH, + iid: null, }); }); }); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 6ac4db8cdaa..106f7b04c4b 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -1,5 +1,6 @@ /* global List */ +import { GlFilteredSearchToken } from '@gitlab/ui'; import { keyBy } from 'lodash'; import Vue from 'vue'; import '~/boards/models/list'; @@ -8,6 +9,8 @@ import boardsStore from '~/boards/stores/boards_store'; import { __ } from '~/locale'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; +import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; +import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue'; export const boardObj = { id: 1, @@ -101,6 +104,17 @@ export const mockMilestone = { due_date: '2019-12-31', }; +export const mockMilestones = [ + { + id: 'gid://gitlab/Milestone/1', + title: 'Milestone 1', + }, + { + id: 'gid://gitlab/Milestone/2', + title: 'Milestone 2', + }, +]; + export const assignees = [ { id: 'gid://gitlab/User/2', @@ -531,7 +545,7 @@ export const mockMoveData = { ...mockMoveIssueParams, }; -export const mockTokens = (fetchLabels, fetchAuthors) => [ +export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones) => [ { icon: 'labels', title: __('Label'), @@ -557,6 +571,7 @@ export const mockTokens = (fetchLabels, fetchAuthors) => [ token: AuthorToken, unique: true, fetchAuthors, + preloadedAuthors: [], }, { icon: 'user', @@ -569,5 +584,35 @@ export const mockTokens = (fetchLabels, fetchAuthors) => [ token: AuthorToken, unique: true, fetchAuthors, + preloadedAuthors: [], + }, + { + icon: 'issues', + title: __('Type'), + type: 'types', + operators: [{ value: '=', description: 'is' }], + token: GlFilteredSearchToken, + unique: true, + options: [ + { icon: 'issue-type-issue', value: 'ISSUE', title: 'Issue' }, + { icon: 'issue-type-incident', value: 'INCIDENT', title: 'Incident' }, + ], + }, + { + icon: 'clock', + title: __('Milestone'), + symbol: '%', + type: 'milestone_title', + token: MilestoneToken, + unique: true, + defaultMilestones: [], + fetchMilestones, + }, + { + icon: 'weight', + title: __('Weight'), + type: 'weight', + token: WeightToken, + unique: true, }, ]; diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 5e16e389ddc..1272a573d2f 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -1,4 +1,7 @@ import * as Sentry from '@sentry/browser'; +import { cloneDeep } from 'lodash'; +import Vue from 'vue'; +import Vuex from 'vuex'; import { inactiveId, ISSUABLE, @@ -6,6 +9,7 @@ import { issuableTypes, BoardType, listsQuery, + DraggableItemTypes, } from 'ee_else_ce/boards/constants'; import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; import testAction from 'helpers/vuex_action_helper'; @@ -21,6 +25,7 @@ import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutati import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql'; import actions, { gqlClient } from '~/boards/stores/actions'; import * as types from '~/boards/stores/mutation_types'; +import mutations from '~/boards/stores/mutations'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { @@ -37,6 +42,7 @@ import { mockMoveState, mockMoveData, mockList, + mockMilestones, } from '../mock_data'; jest.mock('~/flash'); @@ -45,6 +51,8 @@ jest.mock('~/flash'); // subgroups when the movIssue action is called. const getProjectPath = (path) => path.split('#')[0]; +Vue.use(Vuex); + beforeEach(() => { window.gon = { features: {} }; }); @@ -260,6 +268,87 @@ describe('fetchLists', () => { ); }); +describe('fetchMilestones', () => { + const queryResponse = { + data: { + project: { + milestones: { + nodes: mockMilestones, + }, + }, + }, + }; + + const queryErrors = { + data: { + project: { + errors: ['You cannot view these milestones'], + milestones: {}, + }, + }, + }; + + function createStore({ + state = { + boardType: 'project', + fullPath: 'gitlab-org/gitlab', + milestones: [], + milestonesLoading: false, + }, + } = {}) { + return new Vuex.Store({ + state, + mutations, + }); + } + + it('throws error if state.boardType is not group or project', () => { + const store = createStore({ + state: { + boardType: 'invalid', + }, + }); + + expect(() => actions.fetchMilestones(store)).toThrow(new Error('Unknown board type')); + }); + + it('sets milestonesLoading to true', async () => { + jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); + + const store = createStore(); + + actions.fetchMilestones(store); + + expect(store.state.milestonesLoading).toBe(true); + }); + + describe('success', () => { + it('sets state.milestones from query result', async () => { + jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); + + const store = createStore(); + + await actions.fetchMilestones(store); + + expect(store.state.milestonesLoading).toBe(false); + expect(store.state.milestones).toBe(mockMilestones); + }); + }); + + describe('failure', () => { + it('sets state.milestones from query result', async () => { + jest.spyOn(gqlClient, 'query').mockResolvedValue(queryErrors); + + const store = createStore(); + + await expect(actions.fetchMilestones(store)).rejects.toThrow(); + + expect(store.state.milestonesLoading).toBe(false); + expect(store.state.error).toBe('Failed to load milestones.'); + }); + }); +}); + describe('createList', () => { it('should dispatch createIssueList action', () => { testAction({ @@ -419,75 +508,114 @@ describe('fetchLabels', () => { }); describe('moveList', () => { - it('should commit MOVE_LIST mutation and dispatch updateList action', (done) => { - const initialBoardListsState = { - 'gid://gitlab/List/1': mockLists[0], - 'gid://gitlab/List/2': mockLists[1], - }; + const backlogListId = 'gid://1'; + const closedListId = 'gid://5'; - const state = { - fullPath: 'gitlab-org', - fullBoardId: 'gid://gitlab/Board/1', - boardType: 'group', - disabled: false, - boardLists: initialBoardListsState, - }; + const boardLists1 = { + 'gid://3': { listType: '', position: 0 }, + 'gid://4': { listType: '', position: 1 }, + 'gid://5': { listType: '', position: 2 }, + }; - testAction( - actions.moveList, - { - listId: 'gid://gitlab/List/1', - replacedListId: 'gid://gitlab/List/2', - newIndex: 1, - adjustmentValue: 1, - }, - state, - [ - { - type: types.MOVE_LIST, - payload: { movedList: mockLists[0], listAtNewIndex: mockLists[1] }, - }, - ], - [ - { - type: 'updateList', - payload: { - listId: 'gid://gitlab/List/1', - position: 0, - backupList: initialBoardListsState, - }, + const boardLists2 = { + [backlogListId]: { listType: ListType.backlog, position: -Infinity }, + [closedListId]: { listType: ListType.closed, position: Infinity }, + ...cloneDeep(boardLists1), + }; + + const movableListsOrder = ['gid://3', 'gid://4', 'gid://5']; + const allListsOrder = [backlogListId, ...movableListsOrder, closedListId]; + + it(`should not handle the event if the dragged item is not a "${DraggableItemTypes.list}"`, () => { + return testAction({ + action: actions.moveList, + payload: { + item: { dataset: { listId: '', draggableItemType: DraggableItemTypes.card } }, + to: { + children: [], }, - ], - done, - ); + }, + state: {}, + expectedMutations: [], + expectedActions: [], + }); }); - it('should not commit MOVE_LIST or dispatch updateList if listId and replacedListId are the same', () => { - const initialBoardListsState = { - 'gid://gitlab/List/1': mockLists[0], - 'gid://gitlab/List/2': mockLists[1], - }; + describe.each` + draggableFrom | draggableTo | boardLists | boardListsOrder | expectedMovableListsOrder + ${0} | ${2} | ${boardLists1} | ${movableListsOrder} | ${['gid://4', 'gid://5', 'gid://3']} + ${2} | ${0} | ${boardLists1} | ${movableListsOrder} | ${['gid://5', 'gid://3', 'gid://4']} + ${0} | ${1} | ${boardLists1} | ${movableListsOrder} | ${['gid://4', 'gid://3', 'gid://5']} + ${1} | ${2} | ${boardLists1} | ${movableListsOrder} | ${['gid://3', 'gid://5', 'gid://4']} + ${2} | ${1} | ${boardLists1} | ${movableListsOrder} | ${['gid://3', 'gid://5', 'gid://4']} + ${1} | ${3} | ${boardLists2} | ${allListsOrder} | ${['gid://4', 'gid://5', 'gid://3']} + ${3} | ${1} | ${boardLists2} | ${allListsOrder} | ${['gid://5', 'gid://3', 'gid://4']} + ${1} | ${2} | ${boardLists2} | ${allListsOrder} | ${['gid://4', 'gid://3', 'gid://5']} + ${2} | ${3} | ${boardLists2} | ${allListsOrder} | ${['gid://3', 'gid://5', 'gid://4']} + ${3} | ${2} | ${boardLists2} | ${allListsOrder} | ${['gid://3', 'gid://5', 'gid://4']} + `( + 'when moving a list from position $draggableFrom to $draggableTo with lists $boardListsOrder', + ({ draggableFrom, draggableTo, boardLists, boardListsOrder, expectedMovableListsOrder }) => { + const movedListId = boardListsOrder[draggableFrom]; + const displacedListId = boardListsOrder[draggableTo]; + const buildDraggablePayload = () => { + return { + item: { + dataset: { + listId: boardListsOrder[draggableFrom], + draggableItemType: DraggableItemTypes.list, + }, + }, + newIndex: draggableTo, + to: { + children: boardListsOrder.map((listId) => ({ dataset: { listId } })), + }, + }; + }; - const state = { - fullPath: 'gitlab-org', - fullBoardId: 'gid://gitlab/Board/1', - boardType: 'group', - disabled: false, - boardLists: initialBoardListsState, - }; + it('should commit MOVE_LIST mutations and dispatch updateList action with correct payloads', () => { + return testAction({ + action: actions.moveList, + payload: buildDraggablePayload(), + state: { boardLists }, + expectedMutations: [ + { + type: types.MOVE_LISTS, + payload: expectedMovableListsOrder.map((listId, i) => ({ listId, position: i })), + }, + ], + expectedActions: [ + { + type: 'updateList', + payload: { + listId: movedListId, + position: movableListsOrder.findIndex((i) => i === displacedListId), + }, + }, + ], + }); + }); + }, + ); - testAction( - actions.moveList, - { - listId: 'gid://gitlab/List/1', - replacedListId: 'gid://gitlab/List/1', - newIndex: 1, - adjustmentValue: 1, - }, - state, - [], - [], - ); + describe('when moving from and to the same position', () => { + it('should not commit MOVE_LIST and should not dispatch updateList', () => { + const listId = 'gid://1000'; + + return testAction({ + action: actions.moveList, + payload: { + item: { dataset: { listId, draggbaleItemType: DraggableItemTypes.list } }, + newIndex: 0, + to: { + children: [{ dataset: { listId } }], + }, + }, + state: { boardLists: { [listId]: { position: 0 } } }, + expectedMutations: [], + expectedActions: [], + }); + }); }); }); @@ -549,7 +677,7 @@ describe('updateList', () => { }); }); - it('should commit UPDATE_LIST_FAILURE mutation when API returns an error', (done) => { + it('should dispatch handleUpdateListFailure when API returns an error', () => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { updateBoardList: { @@ -559,17 +687,31 @@ describe('updateList', () => { }, }); - testAction( + return testAction( actions.updateList, { listId: 'gid://gitlab/List/1', position: 1 }, createState(), - [{ type: types.UPDATE_LIST_FAILURE }], [], - done, + [{ type: 'handleUpdateListFailure' }], ); }); }); +describe('handleUpdateListFailure', () => { + it('should dispatch fetchLists action and commit SET_ERROR mutation', async () => { + await testAction({ + action: actions.handleUpdateListFailure, + expectedMutations: [ + { + type: types.SET_ERROR, + payload: 'An error occurred while updating the board list. Please try again.', + }, + ], + expectedActions: [{ type: 'fetchLists' }], + }); + }); +}); + describe('toggleListCollapsed', () => { it('should commit TOGGLE_LIST_COLLAPSED mutation', async () => { const payload = { listId: 'gid://gitlab/List/1', collapsed: true }; diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index 37f0969a39a..a2ba1e9eb5e 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -165,40 +165,26 @@ describe('Board Store Mutations', () => { }); }); - describe('MOVE_LIST', () => { - it('updates boardLists state with reordered lists', () => { + describe('MOVE_LISTS', () => { + it('updates the positions of board lists', () => { state = { ...state, boardLists: initialBoardListsState, }; - mutations.MOVE_LIST(state, { - movedList: mockLists[0], - listAtNewIndex: mockLists[1], - }); - - expect(state.boardLists).toEqual({ - 'gid://gitlab/List/2': mockLists[1], - 'gid://gitlab/List/1': mockLists[0], - }); - }); - }); - - describe('UPDATE_LIST_FAILURE', () => { - it('updates boardLists state with previous order and sets error message', () => { - state = { - ...state, - boardLists: { - 'gid://gitlab/List/2': mockLists[1], - 'gid://gitlab/List/1': mockLists[0], + mutations.MOVE_LISTS(state, [ + { + listId: mockLists[0].id, + position: 1, }, - error: undefined, - }; - - mutations.UPDATE_LIST_FAILURE(state, initialBoardListsState); + { + listId: mockLists[1].id, + position: 0, + }, + ]); - expect(state.boardLists).toEqual(initialBoardListsState); - expect(state.error).toEqual('An error occurred while updating the list. Please try again.'); + expect(state.boardLists[mockLists[0].id].position).toBe(1); + expect(state.boardLists[mockLists[1].id].position).toBe(0); }); }); 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 eb18147fcef..5c7404c1175 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 @@ -123,6 +123,29 @@ describe('Ci variable modal', () => { }); }); + describe.each` + value | secret | rendered + ${'value'} | ${'secret_value'} | ${false} + ${'dollar$ign'} | ${'dollar$ign'} | ${true} + `('Adding a new variable', ({ value, secret, rendered }) => { + beforeEach(() => { + const [variable] = mockData.mockVariables; + const invalidKeyVariable = { + ...variable, + key: 'key', + value, + secret_value: secret, + }; + createComponent(mount); + store.state.variable = invalidKeyVariable; + }); + + it(`${rendered ? 'renders' : 'does not render'} the variable reference warning`, () => { + const warning = wrapper.find(`[data-testid='contains-variable-reference']`); + expect(warning.exists()).toBe(rendered); + }); + }); + describe('Editing a variable', () => { beforeEach(() => { const [variable] = mockData.mockVariables; diff --git a/spec/frontend/clusters/clusters_bundle_spec.js b/spec/frontend/clusters/clusters_bundle_spec.js index 42990334f0a..2a0610b1b0a 100644 --- a/spec/frontend/clusters/clusters_bundle_spec.js +++ b/spec/frontend/clusters/clusters_bundle_spec.js @@ -1,5 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import { loadHTMLFixture } from 'helpers/fixtures'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import { setTestTimeout } from 'helpers/timeout'; import Clusters from '~/clusters/clusters_bundle'; import axios from '~/lib/utils/axios_utils'; @@ -8,6 +9,8 @@ import initProjectSelectDropdown from '~/project_select'; jest.mock('~/lib/utils/poll'); jest.mock('~/project_select'); +useMockLocationHelper(); + describe('Clusters', () => { setTestTimeout(1000); @@ -55,20 +58,6 @@ describe('Clusters', () => { }); describe('updateContainer', () => { - const { location } = window; - - beforeEach(() => { - delete window.location; - window.location = { - reload: jest.fn(), - hash: location.hash, - }; - }); - - afterEach(() => { - window.location = location; - }); - describe('when creating cluster', () => { it('should show the creating container', () => { cluster.updateContainer(null, 'creating'); diff --git a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js new file mode 100644 index 00000000000..1a2e188e7ae --- /dev/null +++ b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js @@ -0,0 +1,50 @@ +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import CommitBoxPipelineMiniGraph from '~/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue'; +import { mockStages } from './mock_data'; + +describe('Commit box pipeline mini graph', () => { + let wrapper; + + const findMiniGraph = () => wrapper.findByTestId('commit-box-mini-graph'); + const findUpstream = () => wrapper.findByTestId('commit-box-mini-graph-upstream'); + const findDownstream = () => wrapper.findByTestId('commit-box-mini-graph-downstream'); + + const createComponent = () => { + wrapper = extendedWrapper( + shallowMount(CommitBoxPipelineMiniGraph, { + propsData: { + stages: mockStages, + }, + mocks: { + $apollo: { + queries: { + pipeline: { + loading: false, + }, + }, + }, + }, + }), + ); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('linked pipelines', () => { + it('should display the mini pipeine graph', () => { + expect(findMiniGraph().exists()).toBe(true); + }); + + it('should not display linked pipelines', () => { + expect(findUpstream().exists()).toBe(false); + expect(findDownstream().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/commit/mock_data.js b/spec/frontend/commit/mock_data.js new file mode 100644 index 00000000000..ef018a4fbd7 --- /dev/null +++ b/spec/frontend/commit/mock_data.js @@ -0,0 +1,117 @@ +export const mockStages = [ + { + name: 'build', + title: 'build: passed', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/root/ci-project/-/pipelines/611#build', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/ci-project/-/pipelines/611#build', + dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=build', + }, + { + name: 'test', + title: 'test: passed', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/root/ci-project/-/pipelines/611#test', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/ci-project/-/pipelines/611#test', + dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=test', + }, + { + name: 'test_two', + title: 'test_two: passed', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/root/ci-project/-/pipelines/611#test_two', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/ci-project/-/pipelines/611#test_two', + dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=test_two', + }, + { + name: 'manual', + title: 'manual: skipped', + status: { + icon: 'status_skipped', + text: 'skipped', + label: 'skipped', + group: 'skipped', + tooltip: 'skipped', + has_details: true, + details_path: '/root/ci-project/-/pipelines/611#manual', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', + action: { + icon: 'play', + title: 'Play all manual', + path: '/root/ci-project/-/pipelines/611/stages/manual/play_manual', + method: 'post', + button_title: 'Play all manual', + }, + }, + path: '/root/ci-project/-/pipelines/611#manual', + dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=manual', + }, + { + name: 'deploy', + title: 'deploy: passed', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/root/ci-project/-/pipelines/611#deploy', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/ci-project/-/pipelines/611#deploy', + dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=deploy', + }, + { + name: 'qa', + title: 'qa: passed', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/root/ci-project/-/pipelines/611#qa', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/ci-project/-/pipelines/611#qa', + dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=qa', + }, +]; 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 35c02911e27..e508cddd6f9 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-mx-2 gl-button btn-default-tertiary btn-icon\\"> +"<b-button-stub size=\\"sm\\" 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/__snapshots__/toolbar_link_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap index e56c37b0dc9..3c88c05a4b4 100644 --- a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap +++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap @@ -26,8 +26,21 @@ exports[`content_editor/components/toolbar_link_button renders dropdown componen </div> </form> </li> - <!----> - <!----> + <li role=\\"presentation\\" class=\\"gl-new-dropdown-divider\\"> + <hr role=\\"separator\\" aria-orientation=\\"horizontal\\" class=\\"dropdown-divider\\"> + </li> + <li role=\\"presentation\\" class=\\"gl-new-dropdown-item\\"><button role=\\"menuitem\\" type=\\"button\\" class=\\"dropdown-item\\"> + <!----> + <!----> + <!----> + <div class=\\"gl-new-dropdown-item-text-wrapper\\"> + <p class=\\"gl-new-dropdown-item-text-primary\\"> + Upload file + </p> + <!----> + </div> + <!----> + </button></li> <input type=\\"file\\" name=\\"content_editor_attachment\\" class=\\"gl-display-none\\"> </div> <!----> </div> diff --git a/spec/frontend/content_editor/components/content_editor_error_spec.js b/spec/frontend/content_editor/components/content_editor_error_spec.js new file mode 100644 index 00000000000..8723fb5a338 --- /dev/null +++ b/spec/frontend/content_editor/components/content_editor_error_spec.js @@ -0,0 +1,54 @@ +import { GlAlert } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ContentEditorError from '~/content_editor/components/content_editor_error.vue'; +import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; +import { createTestEditor, emitEditorEvent } from '../test_utils'; + +describe('content_editor/components/content_editor_error', () => { + let wrapper; + let tiptapEditor; + + const findErrorAlert = () => wrapper.findComponent(GlAlert); + + const createWrapper = async () => { + tiptapEditor = createTestEditor(); + + wrapper = shallowMountExtended(ContentEditorError, { + provide: { + tiptapEditor, + }, + stubs: { + EditorStateObserver, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders error when content editor emits an error event', async () => { + const error = 'error message'; + + createWrapper(); + + await emitEditorEvent({ tiptapEditor, event: 'error', params: { error } }); + + expect(findErrorAlert().text()).toBe(error); + }); + + it('allows dismissing the error', async () => { + const error = 'error message'; + + createWrapper(); + + await emitEditorEvent({ tiptapEditor, event: 'error', params: { error } }); + + findErrorAlert().vm.$emit('dismiss'); + + await nextTick(); + + expect(findErrorAlert().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index 563e80e04c1..d516baf6f0f 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -1,91 +1,175 @@ -import { GlAlert } from '@gitlab/ui'; +import { GlLoadingIcon } from '@gitlab/ui'; import { EditorContent } from '@tiptap/vue-2'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ContentEditor from '~/content_editor/components/content_editor.vue'; +import ContentEditorError from '~/content_editor/components/content_editor_error.vue'; +import ContentEditorProvider from '~/content_editor/components/content_editor_provider.vue'; +import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; import TopToolbar from '~/content_editor/components/top_toolbar.vue'; -import { createContentEditor } from '~/content_editor/services/create_content_editor'; +import { + LOADING_CONTENT_EVENT, + LOADING_SUCCESS_EVENT, + LOADING_ERROR_EVENT, +} from '~/content_editor/constants'; +import { emitEditorEvent } from '../test_utils'; + +jest.mock('~/emoji'); describe('ContentEditor', () => { let wrapper; - let editor; + let contentEditor; + let renderMarkdown; + const uploadsPath = '/uploads'; const findEditorElement = () => wrapper.findByTestId('content-editor'); - const findErrorAlert = () => wrapper.findComponent(GlAlert); + const findEditorContent = () => wrapper.findComponent(EditorContent); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + const createWrapper = (propsData = {}) => { + renderMarkdown = jest.fn(); - const createWrapper = async (contentEditor) => { wrapper = shallowMountExtended(ContentEditor, { propsData: { - contentEditor, + renderMarkdown, + uploadsPath, + ...propsData, + }, + stubs: { + EditorStateObserver, + ContentEditorProvider, + }, + listeners: { + initialized(editor) { + contentEditor = editor; + }, }, }); }; - beforeEach(() => { - editor = createContentEditor({ renderMarkdown: () => true }); - }); - afterEach(() => { wrapper.destroy(); }); - it('renders editor content component and attaches editor instance', () => { - createWrapper(editor); + it('triggers initialized event and provides contentEditor instance as event data', () => { + createWrapper(); - const editorContent = wrapper.findComponent(EditorContent); + expect(contentEditor).not.toBeFalsy(); + }); + + it('renders EditorContent component and provides tiptapEditor instance', () => { + createWrapper(); + + const editorContent = findEditorContent(); - expect(editorContent.props().editor).toBe(editor.tiptapEditor); + expect(editorContent.props().editor).toBe(contentEditor.tiptapEditor); expect(editorContent.classes()).toContain('md'); }); - it('renders top toolbar component and attaches editor instance', () => { - createWrapper(editor); + it('renders ContentEditorProvider component', () => { + createWrapper(); - expect(wrapper.findComponent(TopToolbar).props().contentEditor).toBe(editor); + expect(wrapper.findComponent(ContentEditorProvider).exists()).toBe(true); }); - it.each` - isFocused | classes - ${true} | ${['md-area', 'is-focused']} - ${false} | ${['md-area']} - `( - 'has $classes class selectors when tiptapEditor.isFocused = $isFocused', - ({ isFocused, classes }) => { - editor.tiptapEditor.isFocused = isFocused; - createWrapper(editor); + it('renders top toolbar component', () => { + createWrapper(); + + expect(wrapper.findComponent(TopToolbar).exists()).toBe(true); + }); - expect(findEditorElement().classes()).toStrictEqual(classes); - }, - ); + it('adds is-focused class when focus event is emitted', async () => { + createWrapper(); - it('adds isFocused class when tiptapEditor is focused', () => { - editor.tiptapEditor.isFocused = true; - createWrapper(editor); + await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'focus' }); expect(findEditorElement().classes()).toContain('is-focused'); }); - describe('displaying error', () => { - const error = 'Content Editor error'; + it('removes is-focused class when blur event is emitted', async () => { + createWrapper(); + + await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'focus' }); + await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'blur' }); + + expect(findEditorElement().classes()).not.toContain('is-focused'); + }); + + it('emits change event when document is updated', async () => { + createWrapper(); + + await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'update' }); + + expect(wrapper.emitted('change')).toEqual([ + [ + { + empty: contentEditor.empty, + }, + ], + ]); + }); + + it('renders content_editor_error component', () => { + createWrapper(); + + expect(wrapper.findComponent(ContentEditorError).exists()).toBe(true); + }); + describe('when loading content', () => { beforeEach(async () => { - createWrapper(editor); + createWrapper(); - editor.tiptapEditor.emit('error', error); + contentEditor.emit(LOADING_CONTENT_EVENT); await nextTick(); }); - it('displays error notifications from the tiptap editor', () => { - expect(findErrorAlert().text()).toBe(error); + it('displays loading indicator', () => { + expect(findLoadingIcon().exists()).toBe(true); }); - it('allows dismissing an error alert', async () => { - findErrorAlert().vm.$emit('dismiss'); + it('hides EditorContent component', () => { + expect(findEditorContent().exists()).toBe(false); + }); + }); + + describe('when loading content succeeds', () => { + beforeEach(async () => { + createWrapper(); + + contentEditor.emit(LOADING_CONTENT_EVENT); + await nextTick(); + contentEditor.emit(LOADING_SUCCESS_EVENT); + await nextTick(); + }); + + it('hides loading indicator', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + it('displays EditorContent component', () => { + expect(findEditorContent().exists()).toBe(true); + }); + }); + + describe('when loading content fails', () => { + const error = 'error'; + + beforeEach(async () => { + createWrapper(); + + contentEditor.emit(LOADING_CONTENT_EVENT); + await nextTick(); + contentEditor.emit(LOADING_ERROR_EVENT, error); await nextTick(); + }); + + it('hides loading indicator', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); - expect(findErrorAlert().exists()).toBe(false); + it('displays EditorContent component', () => { + expect(findEditorContent().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/content_editor/components/editor_state_observer_spec.js b/spec/frontend/content_editor/components/editor_state_observer_spec.js new file mode 100644 index 00000000000..5e4bb348e1f --- /dev/null +++ b/spec/frontend/content_editor/components/editor_state_observer_spec.js @@ -0,0 +1,75 @@ +import { shallowMount } from '@vue/test-utils'; +import { each } from 'lodash'; +import EditorStateObserver, { + tiptapToComponentMap, +} from '~/content_editor/components/editor_state_observer.vue'; +import { createTestEditor } from '../test_utils'; + +describe('content_editor/components/editor_state_observer', () => { + let tiptapEditor; + let wrapper; + let onDocUpdateListener; + let onSelectionUpdateListener; + let onTransactionListener; + + const buildEditor = () => { + tiptapEditor = createTestEditor(); + jest.spyOn(tiptapEditor, 'on'); + }; + + const buildWrapper = () => { + wrapper = shallowMount(EditorStateObserver, { + provide: { tiptapEditor }, + listeners: { + docUpdate: onDocUpdateListener, + selectionUpdate: onSelectionUpdateListener, + transaction: onTransactionListener, + }, + }); + }; + + beforeEach(() => { + onDocUpdateListener = jest.fn(); + onSelectionUpdateListener = jest.fn(); + onTransactionListener = jest.fn(); + buildEditor(); + buildWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when editor content changes', () => { + it('emits update, selectionUpdate, and transaction events', () => { + const content = '<p>My paragraph</p>'; + + tiptapEditor.commands.insertContent(content); + + expect(onDocUpdateListener).toHaveBeenCalledWith( + expect.objectContaining({ editor: tiptapEditor }), + ); + expect(onSelectionUpdateListener).toHaveBeenCalledWith( + expect.objectContaining({ editor: tiptapEditor }), + ); + expect(onSelectionUpdateListener).toHaveBeenCalledWith( + expect.objectContaining({ editor: tiptapEditor }), + ); + }); + }); + + describe('when component is destroyed', () => { + it('removes onTiptapDocUpdate and onTiptapSelectionUpdate hooks', () => { + jest.spyOn(tiptapEditor, 'off'); + + wrapper.destroy(); + + each(tiptapToComponentMap, (_, tiptapEvent) => { + expect(tiptapEditor.off).toHaveBeenCalledWith( + tiptapEvent, + tiptapEditor.on.mock.calls.find(([eventName]) => eventName === tiptapEvent)[1], + ); + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js b/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js new file mode 100644 index 00000000000..e44a7fa4ddb --- /dev/null +++ b/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js @@ -0,0 +1,80 @@ +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 { + BUBBLE_MENU_TRACKING_ACTION, + CONTENT_EDITOR_TRACKING_LABEL, +} from '~/content_editor/constants'; +import { createTestEditor } from '../test_utils'; + +describe('content_editor/components/top_toolbar', () => { + let wrapper; + let trackingSpy; + let tiptapEditor; + + const buildEditor = () => { + tiptapEditor = createTestEditor(); + + jest.spyOn(tiptapEditor, 'isActive'); + }; + + const buildWrapper = () => { + wrapper = shallowMountExtended(FormattingBubbleMenu, { + provide: { + tiptapEditor, + }, + }); + }; + + beforeEach(() => { + trackingSpy = mockTracking(undefined, null, jest.spyOn); + buildEditor(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders bubble menu component', () => { + buildWrapper(); + const bubbleMenu = wrapper.findComponent(BubbleMenu); + + expect(bubbleMenu.props().editor).toBe(tiptapEditor); + expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base']); + }); + + 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' }} + `('given a $testId toolbar control', ({ testId, controlProps }) => { + beforeEach(() => { + buildWrapper(); + }); + + it('renders the toolbar control with the provided properties', () => { + expect(wrapper.findByTestId(testId).exists()).toBe(true); + + Object.keys(controlProps).forEach((propName) => { + expect(wrapper.findByTestId(testId).props(propName)).toBe(controlProps[propName]); + }); + }); + + it('tracks the execution of toolbar controls', () => { + const eventData = { contentType: 'italic', value: 1 }; + const { contentType, value } = eventData; + + wrapper.findByTestId(testId).vm.$emit('execute', eventData); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, BUBBLE_MENU_TRACKING_ACTION, { + label: CONTENT_EDITOR_TRACKING_LABEL, + property: contentType, + value, + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/toolbar_button_spec.js b/spec/frontend/content_editor/components/toolbar_button_spec.js index d848adcbff8..60263c46bdd 100644 --- a/spec/frontend/content_editor/components/toolbar_button_spec.js +++ b/spec/frontend/content_editor/components/toolbar_button_spec.js @@ -1,7 +1,8 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; import ToolbarButton from '~/content_editor/components/toolbar_button.vue'; -import { createTestEditor, mockChainedCommands } from '../test_utils'; +import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils'; describe('content_editor/components/toolbar_button', () => { let wrapper; @@ -20,9 +21,12 @@ describe('content_editor/components/toolbar_button', () => { wrapper = shallowMount(ToolbarButton, { stubs: { GlButton, + EditorStateObserver, }, - propsData: { + provide: { tiptapEditor, + }, + propsData: { contentType: CONTENT_TYPE, iconName: ICON_NAME, label: LABEL, @@ -46,19 +50,43 @@ describe('content_editor/components/toolbar_button', () => { expect(findButton().html()).toMatchSnapshot(); }); + it('allows customizing the variant, category, size of the button', () => { + const variant = 'danger'; + const category = 'secondary'; + const size = 'medium'; + + buildWrapper({ + variant, + category, + size, + }); + + expect(findButton().props()).toMatchObject({ + variant, + category, + size, + }); + }); + it.each` editorState | outcomeDescription | outcome ${{ isActive: true, isFocused: true }} | ${'button is active'} | ${true} ${{ isActive: false, isFocused: true }} | ${'button is not active'} | ${false} ${{ isActive: true, isFocused: false }} | ${'button is not active '} | ${false} - `('$outcomeDescription when when editor state is $editorState', ({ editorState, outcome }) => { - tiptapEditor.isActive.mockReturnValueOnce(editorState.isActive); - tiptapEditor.isFocused = editorState.isFocused; - buildWrapper(); + `( + '$outcomeDescription when when editor state is $editorState', + async ({ editorState, outcome }) => { + tiptapEditor.isActive.mockReturnValueOnce(editorState.isActive); + tiptapEditor.isFocused = editorState.isFocused; - expect(findButton().classes().includes('active')).toBe(outcome); - expect(tiptapEditor.isActive).toHaveBeenCalledWith(CONTENT_TYPE); - }); + buildWrapper(); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expect(findButton().classes().includes('active')).toBe(outcome); + expect(tiptapEditor.isActive).toHaveBeenCalledWith(CONTENT_TYPE); + }, + ); describe('when button is clicked', () => { it('executes the content type command when executeCommand = true', async () => { diff --git a/spec/frontend/content_editor/components/toolbar_image_button_spec.js b/spec/frontend/content_editor/components/toolbar_image_button_spec.js index 701dcf83476..dab7e67d7c5 100644 --- a/spec/frontend/content_editor/components/toolbar_image_button_spec.js +++ b/spec/frontend/content_editor/components/toolbar_image_button_spec.js @@ -1,7 +1,8 @@ import { GlButton, GlFormInputGroup } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import ToolbarImageButton from '~/content_editor/components/toolbar_image_button.vue'; -import { configure as configureImageExtension } from '~/content_editor/extensions/image'; +import Attachment from '~/content_editor/extensions/attachment'; +import Image from '~/content_editor/extensions/image'; import { createTestEditor, mockChainedCommands } from '../test_utils'; describe('content_editor/components/toolbar_image_button', () => { @@ -10,7 +11,7 @@ describe('content_editor/components/toolbar_image_button', () => { const buildWrapper = () => { wrapper = mountExtended(ToolbarImageButton, { - propsData: { + provide: { tiptapEditor: editor, }, }); @@ -29,13 +30,14 @@ describe('content_editor/components/toolbar_image_button', () => { }; beforeEach(() => { - const { tiptapExtension: Image } = configureImageExtension({ - renderMarkdown: jest.fn(), - uploadsPath: '/uploads/', - }); - editor = createTestEditor({ - extensions: [Image], + extensions: [ + Image, + Attachment.configure({ + renderMarkdown: jest.fn(), + uploadsPath: '/uploads/', + }), + ], }); buildWrapper(); @@ -64,13 +66,13 @@ describe('content_editor/components/toolbar_image_button', () => { }); it('uploads the selected image when file input changes', async () => { - const commands = mockChainedCommands(editor, ['focus', 'uploadImage', 'run']); + const commands = mockChainedCommands(editor, ['focus', 'uploadAttachment', 'run']); const file = new File(['foo'], 'foo.png', { type: 'image/png' }); await selectFile(file); expect(commands.focus).toHaveBeenCalled(); - expect(commands.uploadImage).toHaveBeenCalledWith({ file }); + expect(commands.uploadAttachment).toHaveBeenCalledWith({ file }); expect(commands.run).toHaveBeenCalled(); expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'image', value: 'upload' }]); diff --git a/spec/frontend/content_editor/components/toolbar_link_button_spec.js b/spec/frontend/content_editor/components/toolbar_link_button_spec.js index 576a2912f72..0cf488260bd 100644 --- a/spec/frontend/content_editor/components/toolbar_link_button_spec.js +++ b/spec/frontend/content_editor/components/toolbar_link_button_spec.js @@ -1,9 +1,9 @@ -import { GlDropdown, GlDropdownDivider, GlButton, GlFormInputGroup } from '@gitlab/ui'; +import { GlDropdown, GlButton, GlFormInputGroup } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue'; -import { tiptapExtension as Link } from '~/content_editor/extensions/link'; +import Link from '~/content_editor/extensions/link'; import { hasSelection } from '~/content_editor/services/utils'; -import { createTestEditor, mockChainedCommands } from '../test_utils'; +import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils'; jest.mock('~/content_editor/services/utils'); @@ -13,21 +13,26 @@ describe('content_editor/components/toolbar_link_button', () => { const buildWrapper = () => { wrapper = mountExtended(ToolbarLinkButton, { - propsData: { + provide: { tiptapEditor: editor, }, }); }; const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownDivider = () => wrapper.findComponent(GlDropdownDivider); const findLinkURLInput = () => wrapper.findComponent(GlFormInputGroup).find('input[type="text"]'); const findApplyLinkButton = () => wrapper.findComponent(GlButton); const findRemoveLinkButton = () => wrapper.findByText('Remove link'); + 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'); + }; + beforeEach(() => { - editor = createTestEditor({ - extensions: [Link], - }); + editor = createTestEditor(); }); afterEach(() => { @@ -45,14 +50,19 @@ describe('content_editor/components/toolbar_link_button', () => { beforeEach(async () => { jest.spyOn(editor, 'isActive').mockReturnValueOnce(true); buildWrapper(); + + await emitEditorEvent({ event: 'transaction', tiptapEditor: editor }); }); it('sets dropdown as active when link extension is active', () => { expect(findDropdown().props('toggleClass')).toEqual({ active: true }); }); + it('does not display the upload file option', () => { + expect(wrapper.findByText('Upload file').exists()).toBe(false); + }); + it('displays a remove link dropdown option', () => { - expect(findDropdownDivider().exists()).toBe(true); expect(wrapper.findByText('Remove link').exists()).toBe(true); }); @@ -90,7 +100,7 @@ describe('content_editor/components/toolbar_link_button', () => { href: '/username/my-project/uploads/abcdefgh133535/my-file.zip', }); - await editor.emit('selectionUpdate', { editor }); + await emitEditorEvent({ event: 'transaction', tiptapEditor: editor }); expect(findLinkURLInput().element.value).toEqual('uploads/my-file.zip'); }); @@ -100,14 +110,14 @@ describe('content_editor/components/toolbar_link_button', () => { href: 'https://gitlab.com', }); - await editor.emit('selectionUpdate', { editor }); + await emitEditorEvent({ event: 'transaction', tiptapEditor: editor }); expect(findLinkURLInput().element.value).toEqual('https://gitlab.com'); }); }); }); - describe('when there is not an active link', () => { + describe('when there is no active link', () => { beforeEach(() => { jest.spyOn(editor, 'isActive'); editor.isActive.mockReturnValueOnce(false); @@ -118,8 +128,11 @@ describe('content_editor/components/toolbar_link_button', () => { expect(findDropdown().props('toggleClass')).toEqual({ active: false }); }); + it('displays the upload file option', () => { + expect(wrapper.findByText('Upload file').exists()).toBe(true); + }); + it('does not display a remove link dropdown option', () => { - expect(findDropdownDivider().exists()).toBe(false); expect(wrapper.findByText('Remove link').exists()).toBe(false); }); @@ -138,6 +151,19 @@ describe('content_editor/components/toolbar_link_button', () => { expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]); }); + + it('uploads the selected image when file input changes', async () => { + const commands = mockChainedCommands(editor, ['focus', 'uploadAttachment', 'run']); + const file = new File(['foo'], 'foo.png', { type: 'image/png' }); + + await selectFile(file); + + expect(commands.focus).toHaveBeenCalled(); + expect(commands.uploadAttachment).toHaveBeenCalledWith({ file }); + expect(commands.run).toHaveBeenCalled(); + + expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]); + }); }); describe('when the user displays the dropdown', () => { diff --git a/spec/frontend/content_editor/components/toolbar_table_button_spec.js b/spec/frontend/content_editor/components/toolbar_table_button_spec.js index 237b2848246..056e5e04e1f 100644 --- a/spec/frontend/content_editor/components/toolbar_table_button_spec.js +++ b/spec/frontend/content_editor/components/toolbar_table_button_spec.js @@ -1,10 +1,6 @@ import { GlDropdown, GlButton } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import ToolbarTableButton from '~/content_editor/components/toolbar_table_button.vue'; -import { tiptapExtension as Table } from '~/content_editor/extensions/table'; -import { tiptapExtension as TableCell } from '~/content_editor/extensions/table_cell'; -import { tiptapExtension as TableHeader } from '~/content_editor/extensions/table_header'; -import { tiptapExtension as TableRow } from '~/content_editor/extensions/table_row'; import { createTestEditor, mockChainedCommands } from '../test_utils'; describe('content_editor/components/toolbar_table_button', () => { @@ -13,7 +9,7 @@ describe('content_editor/components/toolbar_table_button', () => { const buildWrapper = () => { wrapper = mountExtended(ToolbarTableButton, { - propsData: { + provide: { tiptapEditor: editor, }, }); @@ -23,9 +19,7 @@ describe('content_editor/components/toolbar_table_button', () => { const getNumButtons = () => findDropdown().findAllComponents(GlButton).length; beforeEach(() => { - editor = createTestEditor({ - extensions: [Table, TableCell, TableRow, TableHeader], - }); + editor = createTestEditor(); buildWrapper(); }); @@ -35,17 +29,17 @@ describe('content_editor/components/toolbar_table_button', () => { wrapper.destroy(); }); - it('renders a grid of 3x3 buttons to create a table', () => { - expect(getNumButtons()).toBe(9); // 3 x 3 + it('renders a grid of 5x5 buttons to create a table', () => { + expect(getNumButtons()).toBe(25); // 5x5 }); describe.each` row | col | numButtons | tableSize - ${1} | ${2} | ${9} | ${'1x2'} - ${2} | ${2} | ${9} | ${'2x2'} - ${2} | ${3} | ${12} | ${'2x3'} - ${3} | ${2} | ${12} | ${'3x2'} - ${3} | ${3} | ${16} | ${'3x3'} + ${3} | ${4} | ${25} | ${'3x4'} + ${4} | ${4} | ${25} | ${'4x4'} + ${4} | ${5} | ${30} | ${'4x5'} + ${5} | ${4} | ${30} | ${'5x4'} + ${5} | ${5} | ${36} | ${'5x5'} `('button($row, $col) in the table creator grid', ({ row, col, numButtons, tableSize }) => { describe('on mouse over', () => { beforeEach(async () => { @@ -56,9 +50,7 @@ describe('content_editor/components/toolbar_table_button', () => { it('marks all rows and cols before it as active', () => { const prevRow = Math.max(1, row - 1); const prevCol = Math.max(1, col - 1); - expect(wrapper.findByTestId(`table-${prevRow}-${prevCol}`).element).toHaveClass( - 'gl-bg-blue-50!', - ); + expect(wrapper.findByTestId(`table-${prevRow}-${prevCol}`).element).toHaveClass('active'); }); it('shows a help text indicating the size of the table being inserted', () => { @@ -95,8 +87,8 @@ describe('content_editor/components/toolbar_table_button', () => { }); }); - it('does not create more buttons than a 8x8 grid', async () => { - for (let i = 3; i < 8; i += 1) { + it('does not create more buttons than a 10x10 grid', async () => { + for (let i = 5; i < 10; i += 1) { expect(getNumButtons()).toBe(i * i); // eslint-disable-next-line no-await-in-loop @@ -104,6 +96,6 @@ describe('content_editor/components/toolbar_table_button', () => { expect(findDropdown().element).toHaveText(`Insert a ${i}x${i} table.`); } - expect(getNumButtons()).toBe(64); // 8x8 (and not 9x9) + expect(getNumButtons()).toBe(100); // 10x10 (and not 11x11) }); }); diff --git a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js index 9a46e27404f..65c1c8c8310 100644 --- a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js +++ b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js @@ -1,11 +1,12 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; import ToolbarTextStyleDropdown from '~/content_editor/components/toolbar_text_style_dropdown.vue'; import { TEXT_STYLE_DROPDOWN_ITEMS } from '~/content_editor/constants'; -import { tiptapExtension as Heading } from '~/content_editor/extensions/heading'; -import { createTestEditor, mockChainedCommands } from '../test_utils'; +import Heading from '~/content_editor/extensions/heading'; +import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils'; -describe('content_editor/components/toolbar_headings_dropdown', () => { +describe('content_editor/components/toolbar_text_style_dropdown', () => { let wrapper; let tiptapEditor; @@ -22,9 +23,12 @@ describe('content_editor/components/toolbar_headings_dropdown', () => { stubs: { GlDropdown, GlDropdownItem, + EditorStateObserver, }, - propsData: { + provide: { tiptapEditor, + }, + propsData: { ...propsData, }, }); @@ -50,7 +54,7 @@ describe('content_editor/components/toolbar_headings_dropdown', () => { describe('when there is an active item ', () => { let activeTextStyle; - beforeEach(() => { + beforeEach(async () => { [, activeTextStyle] = TEXT_STYLE_DROPDOWN_ITEMS; tiptapEditor.isActive.mockImplementation( @@ -59,6 +63,7 @@ describe('content_editor/components/toolbar_headings_dropdown', () => { ); buildWrapper(); + await emitEditorEvent({ event: 'transaction', tiptapEditor }); }); it('displays the active text style label as the dropdown toggle text ', () => { @@ -79,9 +84,10 @@ describe('content_editor/components/toolbar_headings_dropdown', () => { }); describe('when there isn’t an active item', () => { - beforeEach(() => { + beforeEach(async () => { tiptapEditor.isActive.mockReturnValue(false); buildWrapper(); + await emitEditorEvent({ event: 'transaction', tiptapEditor }); }); it('sets dropdown as disabled', () => { diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js index 5411793cd5e..a5df3d73289 100644 --- a/spec/frontend/content_editor/components/top_toolbar_spec.js +++ b/spec/frontend/content_editor/components/top_toolbar_spec.js @@ -1,39 +1,23 @@ -import { shallowMount } from '@vue/test-utils'; import { mockTracking } from 'helpers/tracking_helper'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import TopToolbar from '~/content_editor/components/top_toolbar.vue'; import { TOOLBAR_CONTROL_TRACKING_ACTION, CONTENT_EDITOR_TRACKING_LABEL, } from '~/content_editor/constants'; -import { createContentEditor } from '~/content_editor/services/create_content_editor'; describe('content_editor/components/top_toolbar', () => { let wrapper; - let contentEditor; let trackingSpy; - const buildEditor = () => { - contentEditor = createContentEditor({ renderMarkdown: () => true }); - }; const buildWrapper = () => { - wrapper = extendedWrapper( - shallowMount(TopToolbar, { - propsData: { - contentEditor, - }, - }), - ); + wrapper = shallowMountExtended(TopToolbar); }; beforeEach(() => { trackingSpy = mockTracking(undefined, null, jest.spyOn); }); - beforeEach(() => { - buildEditor(); - }); - afterEach(() => { wrapper.destroy(); }); @@ -58,18 +42,17 @@ describe('content_editor/components/top_toolbar', () => { }); it('renders the toolbar control with the provided properties', () => { - expect(wrapper.findByTestId(testId).props()).toEqual({ - ...controlProps, - tiptapEditor: contentEditor.tiptapEditor, + expect(wrapper.findByTestId(testId).exists()).toBe(true); + + Object.keys(controlProps).forEach((propName) => { + expect(wrapper.findByTestId(testId).props(propName)).toBe(controlProps[propName]); }); }); - it.each` - eventData - ${{ contentType: 'bold' }} - ${{ contentType: 'blockquote', value: 1 }} - `('tracks the execution of toolbar controls', ({ eventData }) => { + it('tracks the execution of toolbar controls', () => { + const eventData = { contentType: 'blockquote', value: 1 }; const { contentType, value } = eventData; + wrapper.findByTestId(testId).vm.$emit('execute', eventData); expect(trackingSpy).toHaveBeenCalledWith(undefined, TOOLBAR_CONTROL_TRACKING_ACTION, { diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js new file mode 100644 index 00000000000..1334b1ddaad --- /dev/null +++ b/spec/frontend/content_editor/extensions/attachment_spec.js @@ -0,0 +1,235 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { once } from 'lodash'; +import waitForPromises from 'helpers/wait_for_promises'; +import Attachment from '~/content_editor/extensions/attachment'; +import Image from '~/content_editor/extensions/image'; +import Link from '~/content_editor/extensions/link'; +import Loading from '~/content_editor/extensions/loading'; +import httpStatus from '~/lib/utils/http_status'; +import { loadMarkdownApiResult } from '../markdown_processing_examples'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/attachment', () => { + let tiptapEditor; + let eq; + let doc; + let p; + let image; + let loading; + let link; + let renderMarkdown; + let mock; + + const uploadsPath = '/uploads/'; + const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' }); + const attachmentFile = new File(['foo'], 'test-file.zip', { type: 'application/zip' }); + + beforeEach(() => { + renderMarkdown = jest.fn(); + + tiptapEditor = createTestEditor({ + extensions: [Loading, Link, Image, Attachment.configure({ renderMarkdown, uploadsPath })], + }); + + ({ + builders: { doc, p, image, loading, link }, + eq, + } = createDocBuilder({ + tiptapEditor, + names: { + loading: { markType: Loading.name }, + image: { nodeType: Image.name }, + link: { nodeType: Link.name }, + }, + })); + + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.reset(); + }); + + it.each` + eventType | propName | eventData | output + ${'paste'} | ${'handlePaste'} | ${{ clipboardData: { files: [attachmentFile] } }} | ${true} + ${'paste'} | ${'handlePaste'} | ${{ clipboardData: { files: [] } }} | ${undefined} + ${'drop'} | ${'handleDrop'} | ${{ dataTransfer: { files: [attachmentFile] } }} | ${true} + `('handles $eventType properly', ({ eventType, propName, eventData, output }) => { + const event = Object.assign(new Event(eventType), eventData); + const handled = tiptapEditor.view.someProp(propName, (eventHandler) => { + return eventHandler(tiptapEditor.view, event); + }); + + expect(handled).toBe(output); + }); + + describe('uploadAttachment command', () => { + let initialDoc; + beforeEach(() => { + initialDoc = doc(p('')); + tiptapEditor.commands.setContent(initialDoc.toJSON()); + }); + + describe('when the file has image mime type', () => { + const base64EncodedFile = 'data:image/png;base64,Zm9v'; + + beforeEach(() => { + renderMarkdown.mockResolvedValue( + loadMarkdownApiResult('project_wiki_attachment_image').body, + ); + }); + + describe('when uploading succeeds', () => { + const successResponse = { + link: { + markdown: '![test-file](test-file.png)', + }, + }; + + beforeEach(() => { + mock.onPost().reply(httpStatus.OK, successResponse); + }); + + it('inserts an image with src set to the encoded image file and uploading true', (done) => { + const expectedDoc = doc(p(image({ uploading: true, src: base64EncodedFile }))); + + tiptapEditor.on( + 'update', + once(() => { + expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + done(); + }), + ); + + tiptapEditor.commands.uploadAttachment({ file: imageFile }); + }); + + it('updates the inserted image with canonicalSrc when upload is successful', async () => { + const expectedDoc = doc( + p( + image({ + canonicalSrc: 'test-file.png', + src: base64EncodedFile, + alt: 'test-file', + uploading: false, + }), + ), + ); + + tiptapEditor.commands.uploadAttachment({ file: imageFile }); + + await waitForPromises(); + + expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + }); + }); + + describe('when uploading request fails', () => { + beforeEach(() => { + mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR); + }); + + it('resets the doc to orginal state', async () => { + const expectedDoc = doc(p('')); + + tiptapEditor.commands.uploadAttachment({ file: imageFile }); + + await waitForPromises(); + + expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + }); + + it('emits an error event that includes an error message', (done) => { + tiptapEditor.commands.uploadAttachment({ file: imageFile }); + + tiptapEditor.on('error', ({ error }) => { + expect(error).toBe('An error occurred while uploading the image. Please try again.'); + done(); + }); + }); + }); + }); + + describe('when the file has a zip (or any other attachment) mime type', () => { + const markdownApiResult = loadMarkdownApiResult('project_wiki_attachment_link').body; + + beforeEach(() => { + renderMarkdown.mockResolvedValue(markdownApiResult); + }); + + describe('when uploading succeeds', () => { + const successResponse = { + link: { + markdown: '[test-file](test-file.zip)', + }, + }; + + beforeEach(() => { + mock.onPost().reply(httpStatus.OK, successResponse); + }); + + it('inserts a loading mark', (done) => { + const expectedDoc = doc(p(loading({ label: 'test-file' }))); + + tiptapEditor.on( + 'update', + once(() => { + expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + done(); + }), + ); + + tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); + }); + + it('updates the loading mark with a link with canonicalSrc and href attrs', async () => { + const [, group, project] = markdownApiResult.match(/\/(group[0-9]+)\/(project[0-9]+)\//); + const expectedDoc = doc( + p( + link( + { + canonicalSrc: 'test-file.zip', + href: `/${group}/${project}/-/wikis/test-file.zip`, + }, + 'test-file', + ), + ), + ); + + tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); + + await waitForPromises(); + + expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + }); + }); + + describe('when uploading request fails', () => { + beforeEach(() => { + mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR); + }); + + it('resets the doc to orginal state', async () => { + const expectedDoc = doc(p('')); + + tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); + + await waitForPromises(); + + expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + }); + + it('emits an error event that includes an error message', (done) => { + tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); + + tiptapEditor.on('error', ({ error }) => { + expect(error).toBe('An error occurred while uploading the file. Please try again.'); + done(); + }); + }); + }); + }); + }); +}); 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 cc695ffe241..188e6580dc6 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,4 @@ -import { tiptapExtension as CodeBlockHighlight } from '~/content_editor/extensions/code_block_highlight'; +import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; import { loadMarkdownApiResult } from '../markdown_processing_examples'; import { createTestEditor } from '../test_utils'; @@ -25,7 +25,6 @@ describe('content_editor/extensions/code_block_highlight', () => { expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({ language, - params: language, }); }); diff --git a/spec/frontend/content_editor/extensions/emoji_spec.js b/spec/frontend/content_editor/extensions/emoji_spec.js new file mode 100644 index 00000000000..c1b8dc9bdbb --- /dev/null +++ b/spec/frontend/content_editor/extensions/emoji_spec.js @@ -0,0 +1,57 @@ +import { initEmojiMock } from 'helpers/emoji'; +import Emoji from '~/content_editor/extensions/emoji'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/emoji', () => { + let tiptapEditor; + let doc; + let p; + let emoji; + let eq; + + beforeEach(async () => { + await initEmojiMock(); + }); + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [Emoji] }); + ({ + builders: { doc, p, emoji }, + eq, + } = createDocBuilder({ + tiptapEditor, + names: { + loading: { nodeType: Emoji.name }, + }, + })); + }); + + describe('when typing a valid emoji input rule', () => { + it('inserts an emoji node', () => { + const { view } = tiptapEditor; + const { selection } = view.state; + const expectedDoc = doc( + p( + ' ', + emoji({ moji: '❤', name: 'heart', title: 'heavy black heart', unicodeVersion: '1.1' }), + ), + ); + // Triggers the event handler that input rules listen to + view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, ':heart:')); + + expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + }); + }); + + describe('when typing a invalid emoji input rule', () => { + it('does not insert an emoji node', () => { + const { view } = tiptapEditor; + const { selection } = view.state; + const invalidEmoji = ':invalid:'; + const expectedDoc = doc(p()); + // Triggers the event handler that input rules listen to + view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, invalidEmoji)); + expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + }); + }); +}); diff --git a/spec/frontend/content_editor/extensions/hard_break_spec.js b/spec/frontend/content_editor/extensions/hard_break_spec.js index ebd58e60b0c..9e2e28b6e72 100644 --- a/spec/frontend/content_editor/extensions/hard_break_spec.js +++ b/spec/frontend/content_editor/extensions/hard_break_spec.js @@ -1,4 +1,4 @@ -import { tiptapExtension as HardBreak } from '~/content_editor/extensions/hard_break'; +import HardBreak from '~/content_editor/extensions/hard_break'; import { createTestEditor, createDocBuilder } from '../test_utils'; describe('content_editor/extensions/hard_break', () => { diff --git a/spec/frontend/content_editor/extensions/image_spec.js b/spec/frontend/content_editor/extensions/image_spec.js deleted file mode 100644 index 922966b813a..00000000000 --- a/spec/frontend/content_editor/extensions/image_spec.js +++ /dev/null @@ -1,193 +0,0 @@ -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; -import { once } from 'lodash'; -import waitForPromises from 'helpers/wait_for_promises'; -import * as Image from '~/content_editor/extensions/image'; -import httpStatus from '~/lib/utils/http_status'; -import { loadMarkdownApiResult } from '../markdown_processing_examples'; -import { createTestEditor, createDocBuilder } from '../test_utils'; - -describe('content_editor/extensions/image', () => { - let tiptapEditor; - let eq; - let doc; - let p; - let image; - let renderMarkdown; - let mock; - const uploadsPath = '/uploads/'; - const validFile = new File(['foo'], 'foo.png', { type: 'image/png' }); - const invalidFile = new File(['foo'], 'bar.html', { type: 'text/html' }); - - beforeEach(() => { - renderMarkdown = jest - .fn() - .mockResolvedValue(loadMarkdownApiResult('project_wiki_attachment_image').body); - - const { tiptapExtension } = Image.configure({ renderMarkdown, uploadsPath }); - - tiptapEditor = createTestEditor({ extensions: [tiptapExtension] }); - - ({ - builders: { doc, p, image }, - eq, - } = createDocBuilder({ - tiptapEditor, - names: { image: { nodeType: tiptapExtension.name } }, - })); - - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.reset(); - }); - - it.each` - file | valid | description - ${validFile} | ${true} | ${'handles paste event when mime type is valid'} - ${invalidFile} | ${false} | ${'does not handle paste event when mime type is invalid'} - `('$description', ({ file, valid }) => { - const pasteEvent = Object.assign(new Event('paste'), { - clipboardData: { - files: [file], - }, - }); - let handled; - - tiptapEditor.view.someProp('handlePaste', (eventHandler) => { - handled = eventHandler(tiptapEditor.view, pasteEvent); - }); - - expect(handled).toBe(valid); - }); - - it.each` - file | valid | description - ${validFile} | ${true} | ${'handles drop event when mime type is valid'} - ${invalidFile} | ${false} | ${'does not handle drop event when mime type is invalid'} - `('$description', ({ file, valid }) => { - const dropEvent = Object.assign(new Event('drop'), { - dataTransfer: { - files: [file], - }, - }); - let handled; - - tiptapEditor.view.someProp('handleDrop', (eventHandler) => { - handled = eventHandler(tiptapEditor.view, dropEvent); - }); - - expect(handled).toBe(valid); - }); - - it('handles paste event when mime type is correct', () => { - const pasteEvent = Object.assign(new Event('paste'), { - clipboardData: { - files: [new File(['foo'], 'foo.png', { type: 'image/png' })], - }, - }); - const handled = tiptapEditor.view.someProp('handlePaste', (eventHandler) => { - return eventHandler(tiptapEditor.view, pasteEvent); - }); - - expect(handled).toBe(true); - }); - - describe('uploadImage command', () => { - describe('when file has correct mime type', () => { - let initialDoc; - const base64EncodedFile = 'data:image/png;base64,Zm9v'; - - beforeEach(() => { - initialDoc = doc(p('')); - tiptapEditor.commands.setContent(initialDoc.toJSON()); - }); - - describe('when uploading image succeeds', () => { - const successResponse = { - link: { - markdown: '[image](/uploads/25265/image.png)', - }, - }; - - beforeEach(() => { - mock.onPost().reply(httpStatus.OK, successResponse); - }); - - it('inserts an image with src set to the encoded image file and uploading true', (done) => { - const expectedDoc = doc(p(image({ uploading: true, src: base64EncodedFile }))); - - tiptapEditor.on( - 'update', - once(() => { - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); - done(); - }), - ); - - tiptapEditor.commands.uploadImage({ file: validFile }); - }); - - it('updates the inserted image with canonicalSrc when upload is successful', async () => { - const expectedDoc = doc( - p( - image({ - canonicalSrc: 'test-file.png', - src: base64EncodedFile, - alt: 'test file', - uploading: false, - }), - ), - ); - - tiptapEditor.commands.uploadImage({ file: validFile }); - - await waitForPromises(); - - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); - }); - }); - - describe('when uploading image request fails', () => { - beforeEach(() => { - mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR); - }); - - it('resets the doc to orginal state', async () => { - const expectedDoc = doc(p('')); - - tiptapEditor.commands.uploadImage({ file: validFile }); - - await waitForPromises(); - - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); - }); - - it('emits an error event that includes an error message', (done) => { - tiptapEditor.commands.uploadImage({ file: validFile }); - - tiptapEditor.on('error', (message) => { - expect(message).toBe('An error occurred while uploading the image. Please try again.'); - done(); - }); - }); - }); - }); - - describe('when file does not have correct mime type', () => { - let initialDoc; - - beforeEach(() => { - initialDoc = doc(p('')); - tiptapEditor.commands.setContent(initialDoc.toJSON()); - }); - - it('does not start the upload image process', () => { - tiptapEditor.commands.uploadImage({ file: invalidFile }); - - expect(eq(tiptapEditor.state.doc, initialDoc)).toBe(true); - }); - }); - }); -}); diff --git a/spec/frontend/content_editor/extensions/inline_diff_spec.js b/spec/frontend/content_editor/extensions/inline_diff_spec.js new file mode 100644 index 00000000000..63cdf665e7f --- /dev/null +++ b/spec/frontend/content_editor/extensions/inline_diff_spec.js @@ -0,0 +1,27 @@ +import { inputRegexAddition, inputRegexDeletion } from '~/content_editor/extensions/inline_diff'; + +describe('content_editor/extensions/inline_diff', () => { + describe.each` + inputRegex | description | input | matches + ${inputRegexAddition} | ${'inputRegexAddition'} | ${'hello{+world+}'} | ${true} + ${inputRegexAddition} | ${'inputRegexAddition'} | ${'hello{+ world +}'} | ${true} + ${inputRegexAddition} | ${'inputRegexAddition'} | ${'hello {+ world+}'} | ${true} + ${inputRegexAddition} | ${'inputRegexAddition'} | ${'{+hello world +}'} | ${true} + ${inputRegexAddition} | ${'inputRegexAddition'} | ${'{+hello with \nnewline+}'} | ${false} + ${inputRegexAddition} | ${'inputRegexAddition'} | ${'{+open only'} | ${false} + ${inputRegexAddition} | ${'inputRegexAddition'} | ${'close only+}'} | ${false} + ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'hello{-world-}'} | ${true} + ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'hello{- world -}'} | ${true} + ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'hello {- world-}'} | ${true} + ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'{-hello world -}'} | ${true} + ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'{+hello with \nnewline+}'} | ${false} + ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'{-open only'} | ${false} + ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'close only-}'} | ${false} + `('$description', ({ inputRegex, input, matches }) => { + it(`${matches ? 'matches' : 'does not match'}: "${input}"`, () => { + const match = new RegExp(inputRegex).test(input); + + expect(match).toBe(matches); + }); + }); +}); diff --git a/spec/frontend/content_editor/markdown_processing_spec.js b/spec/frontend/content_editor/markdown_processing_spec.js index 028cd6a8271..da3f6e64db8 100644 --- a/spec/frontend/content_editor/markdown_processing_spec.js +++ b/spec/frontend/content_editor/markdown_processing_spec.js @@ -1,6 +1,8 @@ import { createContentEditor } from '~/content_editor'; import { loadMarkdownApiExamples, loadMarkdownApiResult } from './markdown_processing_examples'; +jest.mock('~/emoji'); + describe('markdown processing', () => { // Ensure we generate same markdown that was provided to Markdown API. it.each(loadMarkdownApiExamples())( diff --git a/spec/frontend/content_editor/services/build_serializer_config_spec.js b/spec/frontend/content_editor/services/build_serializer_config_spec.js deleted file mode 100644 index 532e0493830..00000000000 --- a/spec/frontend/content_editor/services/build_serializer_config_spec.js +++ /dev/null @@ -1,38 +0,0 @@ -import * as Blockquote from '~/content_editor/extensions/blockquote'; -import * as Bold from '~/content_editor/extensions/bold'; -import * as Dropcursor from '~/content_editor/extensions/dropcursor'; -import * as Paragraph from '~/content_editor/extensions/paragraph'; - -import buildSerializerConfig from '~/content_editor/services/build_serializer_config'; - -describe('content_editor/services/build_serializer_config', () => { - describe('given one or more content editor extensions', () => { - it('creates a serializer config that collects all extension serializers by type', () => { - const extensions = [Bold, Blockquote, Paragraph]; - const serializerConfig = buildSerializerConfig(extensions); - - extensions.forEach(({ tiptapExtension, serializer }) => { - const { name, type } = tiptapExtension; - expect(serializerConfig[`${type}s`][name]).toBe(serializer); - }); - }); - }); - - describe('given an extension without serializer', () => { - it('does not include the extension in the serializer config', () => { - const serializerConfig = buildSerializerConfig([Dropcursor]); - - expect(serializerConfig.marks[Dropcursor.tiptapExtension.name]).toBe(undefined); - expect(serializerConfig.nodes[Dropcursor.tiptapExtension.name]).toBe(undefined); - }); - }); - - describe('given no extensions', () => { - it('creates an empty serializer config', () => { - expect(buildSerializerConfig()).toStrictEqual({ - marks: {}, - nodes: {}, - }); - }); - }); -}); diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js new file mode 100644 index 00000000000..e48687f1548 --- /dev/null +++ b/spec/frontend/content_editor/services/content_editor_spec.js @@ -0,0 +1,68 @@ +import { + LOADING_CONTENT_EVENT, + LOADING_SUCCESS_EVENT, + LOADING_ERROR_EVENT, +} from '~/content_editor/constants'; +import { ContentEditor } from '~/content_editor/services/content_editor'; + +import { createTestEditor } from '../test_utils'; + +describe('content_editor/services/content_editor', () => { + let contentEditor; + let serializer; + + beforeEach(() => { + const tiptapEditor = createTestEditor(); + jest.spyOn(tiptapEditor, 'destroy'); + + serializer = { deserialize: jest.fn() }; + contentEditor = new ContentEditor({ tiptapEditor, serializer }); + }); + + describe('.dispose', () => { + it('destroys the tiptapEditor', () => { + expect(contentEditor.tiptapEditor.destroy).not.toHaveBeenCalled(); + + contentEditor.dispose(); + + expect(contentEditor.tiptapEditor.destroy).toHaveBeenCalled(); + }); + }); + + describe('when setSerializedContent succeeds', () => { + beforeEach(() => { + serializer.deserialize.mockResolvedValueOnce(''); + }); + + it('emits loadingContent and loadingSuccess event', () => { + let loadingContentEmitted = false; + + contentEditor.on(LOADING_CONTENT_EVENT, () => { + loadingContentEmitted = true; + }); + contentEditor.on(LOADING_SUCCESS_EVENT, () => { + expect(loadingContentEmitted).toBe(true); + }); + + contentEditor.setSerializedContent('**bold text**'); + }); + }); + + describe('when setSerializedContent fails', () => { + const error = 'error'; + + beforeEach(() => { + serializer.deserialize.mockRejectedValueOnce(error); + }); + + it('emits loadingError event', async () => { + contentEditor.on(LOADING_ERROR_EVENT, (e) => { + expect(e).toBe('error'); + }); + + await expect(() => contentEditor.setSerializedContent('**bold text**')).rejects.toEqual( + error, + ); + }); + }); +}); 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 b614efd954a..6b2f28b3306 100644 --- a/spec/frontend/content_editor/services/create_content_editor_spec.js +++ b/spec/frontend/content_editor/services/create_content_editor_spec.js @@ -2,7 +2,9 @@ import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '~/content_editor/constants import { createContentEditor } from '~/content_editor/services/create_content_editor'; import { createTestContentEditorExtension } from '../test_utils'; -describe('content_editor/services/create_editor', () => { +jest.mock('~/emoji'); + +describe('content_editor/services/create_content_editor', () => { let renderMarkdown; let editor; const uploadsPath = '/uploads'; @@ -32,13 +34,15 @@ describe('content_editor/services/create_editor', () => { 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: [createTestContentEditorExtension()], + extensions: [tiptapExtension], + serializerConfig: { nodes: { [tiptapExtension.name]: serializer } }, }); await editor.setSerializedContent(labelReference); @@ -50,9 +54,9 @@ describe('content_editor/services/create_editor', () => { expect(() => createContentEditor()).toThrow(PROVIDE_SERIALIZER_OR_RENDERER_ERROR); }); - it('provides uploadsPath and renderMarkdown function to Image extension', () => { + it('provides uploadsPath and renderMarkdown function to Attachment extension', () => { expect( - editor.tiptapEditor.extensionManager.extensions.find((e) => e.name === 'image').options, + editor.tiptapEditor.extensionManager.extensions.find((e) => e.name === 'attachment').options, ).toMatchObject({ uploadsPath, renderMarkdown, diff --git a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js index 64f3d8df6e0..afe09a75f16 100644 --- a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js +++ b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js @@ -4,10 +4,10 @@ import { INPUT_RULE_TRACKING_ACTION, CONTENT_EDITOR_TRACKING_LABEL, } from '~/content_editor/constants'; -import { tiptapExtension as BulletList } from '~/content_editor/extensions/bullet_list'; -import { tiptapExtension as CodeBlockLowlight } from '~/content_editor/extensions/code_block_highlight'; -import { tiptapExtension as Heading } from '~/content_editor/extensions/heading'; -import { tiptapExtension as ListItem } from '~/content_editor/extensions/list_item'; +import BulletList from '~/content_editor/extensions/bullet_list'; +import CodeBlockLowlight from '~/content_editor/extensions/code_block_highlight'; +import Heading from '~/content_editor/extensions/heading'; +import ListItem from '~/content_editor/extensions/list_item'; import trackInputRulesAndShortcuts from '~/content_editor/services/track_input_rules_and_shortcuts'; import { ENTER_KEY, BACKSPACE_KEY } from '~/lib/utils/keys'; import { createTestEditor } from '../test_utils'; diff --git a/spec/frontend/content_editor/services/upload_file_spec.js b/spec/frontend/content_editor/services/upload_helpers_spec.js index 87c5298079e..ee9333232db 100644 --- a/spec/frontend/content_editor/services/upload_file_spec.js +++ b/spec/frontend/content_editor/services/upload_helpers_spec.js @@ -1,9 +1,9 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import { uploadFile } from '~/content_editor/services/upload_file'; +import { uploadFile } from '~/content_editor/services/upload_helpers'; import httpStatus from '~/lib/utils/http_status'; -describe('content_editor/services/upload_file', () => { +describe('content_editor/services/upload_helpers', () => { const uploadsPath = '/uploads'; const file = new File(['content'], 'file.txt'); // TODO: Replace with automated fixture diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js index 090e1d92218..b5a2abc2389 100644 --- a/spec/frontend/content_editor/test_utils.js +++ b/spec/frontend/content_editor/test_utils.js @@ -4,6 +4,7 @@ import { Paragraph } from '@tiptap/extension-paragraph'; import { Text } from '@tiptap/extension-text'; import { Editor } from '@tiptap/vue-2'; import { builders, eq } from 'prosemirror-test-builder'; +import { nextTick } from 'vue'; export const createDocBuilder = ({ tiptapEditor, names = {} }) => { const docBuilders = builders(tiptapEditor.schema, { @@ -14,6 +15,12 @@ export const createDocBuilder = ({ tiptapEditor, names = {} }) => { return { eq, builders: docBuilders }; }; +export const emitEditorEvent = ({ tiptapEditor, event, params = {} }) => { + tiptapEditor.emit(event, { editor: tiptapEditor, ...params }); + + return nextTick(); +}; + /** * Creates an instance of the Tiptap Editor class * with a minimal configuration for testing purposes. diff --git a/spec/frontend/cycle_analytics/__snapshots__/base_spec.js.snap b/spec/frontend/cycle_analytics/__snapshots__/base_spec.js.snap deleted file mode 100644 index 1af612ed029..00000000000 --- a/spec/frontend/cycle_analytics/__snapshots__/base_spec.js.snap +++ /dev/null @@ -1,9 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Value stream analytics component isEmptyStage = true renders the empty stage with \`Not enough data\` message 1`] = `"<gl-empty-state-stub title=\\"We don't have enough data to show this stage.\\" svgpath=\\"path/to/no/data\\" description=\\"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.\\" class=\\"js-empty-state\\"></gl-empty-state-stub>"`; - -exports[`Value stream analytics component isEmptyStage = true with a selectedStageError renders the empty stage with \`There is too much data to calculate\` message 1`] = `"<gl-empty-state-stub title=\\"There is too much data to calculate\\" svgpath=\\"path/to/no/data\\" description=\\"\\" class=\\"js-empty-state\\"></gl-empty-state-stub>"`; - -exports[`Value stream analytics component isLoading = true renders the path navigation component with prop \`loading\` set to true 1`] = `"<path-navigation-stub loading=\\"true\\" stages=\\"\\" selectedstage=\\"[object Object]\\" class=\\"js-path-navigation gl-w-full gl-pb-2\\"></path-navigation-stub>"`; - -exports[`Value stream analytics component without enough permissions renders the empty stage with \`You need permission\` message 1`] = `"<gl-empty-state-stub title=\\"You need permission.\\" svgpath=\\"path/to/no/access\\" description=\\"Want to see the data? Please ask an administrator for access.\\" class=\\"js-empty-state\\"></gl-empty-state-stub>"`; diff --git a/spec/frontend/cycle_analytics/__snapshots__/total_time_component_spec.js.snap b/spec/frontend/cycle_analytics/__snapshots__/total_time_component_spec.js.snap new file mode 100644 index 00000000000..e688df8f281 --- /dev/null +++ b/spec/frontend/cycle_analytics/__snapshots__/total_time_component_spec.js.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TotalTimeComponent with a blank object should render -- 1`] = `"<span class=\\"total-time\\"> -- </span>"`; + +exports[`TotalTimeComponent with a valid time object with {"days": 3, "mins": 47, "seconds": 3} 1`] = ` +"<span class=\\"total-time\\"> + 3 <span>days</span></span>" +`; + +exports[`TotalTimeComponent with a valid time object with {"hours": 7, "mins": 20, "seconds": 10} 1`] = ` +"<span class=\\"total-time\\"> + 7 <span>hrs</span></span>" +`; + +exports[`TotalTimeComponent with a valid time object with {"hours": 23, "mins": 10} 1`] = ` +"<span class=\\"total-time\\"> + 23 <span>hrs</span></span>" +`; + +exports[`TotalTimeComponent with a valid time object with {"mins": 47, "seconds": 3} 1`] = ` +"<span class=\\"total-time\\"> + 47 <span>mins</span></span>" +`; + +exports[`TotalTimeComponent with a valid time object with {"seconds": 35} 1`] = ` +"<span class=\\"total-time\\"> + 35 <span>s</span></span>" +`; diff --git a/spec/frontend/cycle_analytics/base_spec.js b/spec/frontend/cycle_analytics/base_spec.js index 2f85cc04051..71830eed3ef 100644 --- a/spec/frontend/cycle_analytics/base_spec.js +++ b/spec/frontend/cycle_analytics/base_spec.js @@ -5,62 +5,89 @@ import Vuex from 'vuex'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import BaseComponent from '~/cycle_analytics/components/base.vue'; import PathNavigation from '~/cycle_analytics/components/path_navigation.vue'; +import StageTable from '~/cycle_analytics/components/stage_table.vue'; +import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue'; +import { NOT_ENOUGH_DATA_ERROR } from '~/cycle_analytics/constants'; import initState from '~/cycle_analytics/store/state'; -import { selectedStage, convertedEvents as selectedStageEvents } from './mock_data'; - +import { + permissions, + transformedProjectStagePathData, + selectedStage, + issueEvents, + createdBefore, + createdAfter, + currentGroup, + stageCounts, +} from './mock_data'; + +const selectedStageEvents = issueEvents.events; const noDataSvgPath = 'path/to/no/data'; const noAccessSvgPath = 'path/to/no/access'; +const selectedStageCount = stageCounts[selectedStage.id]; +const fullPath = 'full/path/to/foo'; Vue.use(Vuex); let wrapper; -function createStore({ initialState = {} }) { +const defaultState = { + permissions, + currentGroup, + createdBefore, + createdAfter, + stageCounts, + endpoints: { fullPath }, +}; + +function createStore({ initialState = {}, initialGetters = {} }) { return new Vuex.Store({ state: { ...initState(), - permissions: { - [selectedStage.id]: true, - }, + ...defaultState, ...initialState, }, getters: { - pathNavigationData: () => [], + pathNavigationData: () => transformedProjectStagePathData, + filterParams: () => ({ + created_after: createdAfter, + created_before: createdBefore, + }), + ...initialGetters, }, }); } -function createComponent({ initialState } = {}) { +function createComponent({ initialState, initialGetters } = {}) { return extendedWrapper( shallowMount(BaseComponent, { - store: createStore({ initialState }), + store: createStore({ initialState, initialGetters }), propsData: { noDataSvgPath, noAccessSvgPath, }, + stubs: { + StageTable, + }, }), ); } const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findPathNavigation = () => wrapper.findComponent(PathNavigation); -const findOverviewMetrics = () => wrapper.findByTestId('vsa-stage-overview-metrics'); -const findStageTable = () => wrapper.findByTestId('vsa-stage-table'); -const findEmptyStage = () => wrapper.findComponent(GlEmptyState); -const findStageEvents = () => wrapper.findByTestId('stage-table-events'); +const findOverviewMetrics = () => wrapper.findComponent(ValueStreamMetrics); +const findStageTable = () => wrapper.findComponent(StageTable); +const findStageEvents = () => findStageTable().props('stageEvents'); +const findEmptyStageTitle = () => wrapper.findComponent(GlEmptyState).props('title'); + +const hasMetricsRequests = (reqs) => { + const foundReqs = findOverviewMetrics().props('requests'); + expect(foundReqs.length).toEqual(reqs.length); + expect(foundReqs.map(({ name }) => name)).toEqual(reqs); +}; describe('Value stream analytics component', () => { beforeEach(() => { - wrapper = createComponent({ - initialState: { - isLoading: false, - isLoadingStage: false, - isEmptyStage: false, - selectedStageEvents, - selectedStage, - selectedStageError: '', - }, - }); + wrapper = createComponent({ initialState: { selectedStage, selectedStageEvents } }); }); afterEach(() => { @@ -72,23 +99,44 @@ describe('Value stream analytics component', () => { expect(findPathNavigation().exists()).toBe(true); }); + it('receives the stages formatted for the path navigation', () => { + expect(findPathNavigation().props('stages')).toBe(transformedProjectStagePathData); + }); + it('renders the overview metrics', () => { expect(findOverviewMetrics().exists()).toBe(true); }); + it('passes requests prop to the metrics component', () => { + hasMetricsRequests(['recent activity']); + }); + it('renders the stage table', () => { expect(findStageTable().exists()).toBe(true); }); + it('passes the selected stage count to the stage table', () => { + expect(findStageTable().props('stageCount')).toBe(selectedStageCount); + }); + it('renders the stage table events', () => { - expect(findEmptyStage().exists()).toBe(false); - expect(findStageEvents().exists()).toBe(true); + expect(findStageEvents()).toEqual(selectedStageEvents); }); it('does not render the loading icon', () => { expect(findLoadingIcon().exists()).toBe(false); }); + describe('with `cycleAnalyticsForGroups=true` license', () => { + beforeEach(() => { + wrapper = createComponent({ initialState: { features: { cycleAnalyticsForGroups: true } } }); + }); + + it('passes requests prop to the metrics component', () => { + hasMetricsRequests(['time summary', 'recent activity']); + }); + }); + describe('isLoading = true', () => { beforeEach(() => { wrapper = createComponent({ @@ -97,17 +145,17 @@ describe('Value stream analytics component', () => { }); it('renders the path navigation component with prop `loading` set to true', () => { - expect(findPathNavigation().html()).toMatchSnapshot(); - }); - - it('does not render the overview metrics', () => { - expect(findOverviewMetrics().exists()).toBe(false); + expect(findPathNavigation().props('loading')).toBe(true); }); it('does not render the stage table', () => { expect(findStageTable().exists()).toBe(false); }); + it('renders the overview metrics', () => { + expect(findOverviewMetrics().exists()).toBe(true); + }); + it('renders the loading icon', () => { expect(findLoadingIcon().exists()).toBe(true); }); @@ -125,32 +173,37 @@ describe('Value stream analytics component', () => { expect(tableWrapper.exists()).toBe(true); expect(tableWrapper.find(GlLoadingIcon).exists()).toBe(true); }); + + it('renders the path navigation loading state', () => { + expect(findPathNavigation().props('loading')).toBe(true); + }); }); describe('isEmptyStage = true', () => { + const emptyStageParams = { + isEmptyStage: true, + selectedStage: { ...selectedStage, emptyStageText: 'This stage is empty' }, + }; beforeEach(() => { - wrapper = createComponent({ - initialState: { selectedStage, isEmptyStage: true }, - }); + wrapper = createComponent({ initialState: emptyStageParams }); }); it('renders the empty stage with `Not enough data` message', () => { - expect(findEmptyStage().html()).toMatchSnapshot(); + expect(findEmptyStageTitle()).toBe(NOT_ENOUGH_DATA_ERROR); }); describe('with a selectedStageError', () => { beforeEach(() => { wrapper = createComponent({ initialState: { - selectedStage, - isEmptyStage: true, + ...emptyStageParams, selectedStageError: 'There is too much data to calculate', }, }); }); it('renders the empty stage with `There is too much data to calculate` message', () => { - expect(findEmptyStage().html()).toMatchSnapshot(); + expect(findEmptyStageTitle()).toBe('There is too much data to calculate'); }); }); }); @@ -159,21 +212,24 @@ describe('Value stream analytics component', () => { beforeEach(() => { wrapper = createComponent({ initialState: { + selectedStage, permissions: { + ...permissions, [selectedStage.id]: false, }, }, }); }); - it('renders the empty stage with `You need permission` message', () => { - expect(findEmptyStage().html()).toMatchSnapshot(); + it('renders the empty stage with `You need permission.` message', () => { + expect(findEmptyStageTitle()).toBe('You need permission.'); }); }); describe('without a selected stage', () => { beforeEach(() => { wrapper = createComponent({ + initialGetters: { pathNavigationData: () => [] }, initialState: { selectedStage: null, isEmptyStage: true }, }); }); @@ -182,12 +238,12 @@ describe('Value stream analytics component', () => { expect(findStageTable().exists()).toBe(true); }); - it('does not render the path navigation component', () => { + it('does not render the path navigation', () => { expect(findPathNavigation().exists()).toBe(false); }); it('does not render the stage table events', () => { - expect(findStageEvents().exists()).toBe(false); + expect(findStageEvents()).toHaveLength(0); }); it('does not render the loading icon', () => { diff --git a/spec/frontend/cycle_analytics/mock_data.js b/spec/frontend/cycle_analytics/mock_data.js index 4e6471d5f7b..d9659d5d4c3 100644 --- a/spec/frontend/cycle_analytics/mock_data.js +++ b/spec/frontend/cycle_analytics/mock_data.js @@ -1,3 +1,4 @@ +import { getJSONFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import { DEFAULT_VALUE_STREAM, DEFAULT_DAYS_IN_PAST } from '~/cycle_analytics/constants'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; @@ -6,11 +7,33 @@ import { getDateInPast } from '~/lib/utils/datetime_utility'; export const createdBefore = new Date(2019, 0, 14); export const createdAfter = getDateInPast(createdBefore, DEFAULT_DAYS_IN_PAST); +export const deepCamelCase = (obj) => convertObjectPropsToCamelCase(obj, { deep: true }); + export const getStageByTitle = (stages, title) => stages.find((stage) => stage.title && stage.title.toLowerCase().trim() === title) || {}; +const fixtureEndpoints = { + customizableCycleAnalyticsStagesAndEvents: 'projects/analytics/value_stream_analytics/stages', + stageEvents: (stage) => `projects/analytics/value_stream_analytics/events/${stage}`, + metricsData: 'projects/analytics/value_stream_analytics/summary', +}; + +export const metricsData = getJSONFixture(fixtureEndpoints.metricsData); + +export const customizableStagesAndEvents = getJSONFixture( + fixtureEndpoints.customizableCycleAnalyticsStagesAndEvents, +); + export const defaultStages = ['issue', 'plan', 'review', 'code', 'test', 'staging']; +const stageFixtures = defaultStages.reduce((acc, stage) => { + const events = getJSONFixture(fixtureEndpoints.stageEvents(stage)); + return { + ...acc, + [stage]: events, + }; +}, {}); + export const summary = [ { value: '20', title: 'New Issues' }, { value: null, title: 'Commits' }, @@ -18,7 +41,7 @@ export const summary = [ { value: null, title: 'Deployment Frequency', unit: 'per day' }, ]; -const issueStage = { +export const issueStage = { id: 'issue', title: 'Issue', name: 'issue', @@ -27,7 +50,7 @@ const issueStage = { value: null, }; -const planStage = { +export const planStage = { id: 'plan', title: 'Plan', name: 'plan', @@ -36,7 +59,7 @@ const planStage = { value: 75600, }; -const codeStage = { +export const codeStage = { id: 'code', title: 'Code', name: 'code', @@ -45,7 +68,7 @@ const codeStage = { value: 172800, }; -const testStage = { +export const testStage = { id: 'test', title: 'Test', name: 'test', @@ -54,7 +77,7 @@ const testStage = { value: 17550, }; -const reviewStage = { +export const reviewStage = { id: 'review', title: 'Review', name: 'review', @@ -63,7 +86,7 @@ const reviewStage = { value: null, }; -const stagingStage = { +export const stagingStage = { id: 'staging', title: 'Staging', name: 'staging', @@ -79,7 +102,7 @@ export const selectedStage = { isUserAllowed: true, emptyStageText: 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.', - component: 'stage-issue-component', + slug: 'issue', }; @@ -109,53 +132,30 @@ export const convertedData = { ], }; -export const rawEvents = [ - { - title: 'Brockfunc-1617160796', - author: { - id: 275, - name: 'VSM User4', - username: 'vsm-user-4-1617160796', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/6a6f5480ae582ba68982a34169420747?s=80&d=identicon', - web_url: 'http://gdk.test:3001/vsm-user-4-1617160796', - show_status: false, - path: '/vsm-user-4-1617160796', - }, - iid: '16', - total_time: { days: 1, hours: 9 }, - created_at: 'about 1 month ago', - url: 'http://gdk.test:3001/vsa-life/ror-project-vsa/-/issues/16', - short_sha: 'some_sha', - commit_url: 'some_commit_url', - }, - { - title: 'Subpod-1617160796', - author: { - id: 274, - name: 'VSM User3', - username: 'vsm-user-3-1617160796', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/fde853fc3ab7dc552e649dcb4fcf5f7f?s=80&d=identicon', - web_url: 'http://gdk.test:3001/vsm-user-3-1617160796', - show_status: false, - path: '/vsm-user-3-1617160796', - }, - iid: '20', - total_time: { days: 2, hours: 18 }, - created_at: 'about 1 month ago', - url: 'http://gdk.test:3001/vsa-life/ror-project-vsa/-/issues/20', - }, -]; - -export const convertedEvents = rawEvents.map((ev) => - convertObjectPropsToCamelCase(ev, { deep: true }), -); +export const rawIssueEvents = stageFixtures.issue; +export const issueEvents = deepCamelCase(rawIssueEvents); +export const reviewEvents = deepCamelCase(stageFixtures.review); export const pathNavIssueMetric = 172800; +export const rawStageCounts = [ + { id: 'issue', count: 6 }, + { id: 'plan', count: 6 }, + { id: 'code', count: 1 }, + { id: 'test', count: 5 }, + { id: 'review', count: 12 }, + { id: 'staging', count: 3 }, +]; + +export const stageCounts = { + code: 1, + issue: 6, + plan: 6, + review: 12, + staging: 3, + test: 5, +}; + export const rawStageMedians = [ { id: 'issue', value: 172800 }, { id: 'plan', value: 86400 }, @@ -189,7 +189,7 @@ export const transformedProjectStagePathData = [ { metric: 172800, selected: true, - stageCount: undefined, + stageCount: 6, icon: null, id: 'issue', title: 'Issue', @@ -201,7 +201,7 @@ export const transformedProjectStagePathData = [ { metric: 86400, selected: false, - stageCount: undefined, + stageCount: 6, icon: null, id: 'plan', title: 'Plan', @@ -213,7 +213,7 @@ export const transformedProjectStagePathData = [ { metric: 129600, selected: false, - stageCount: undefined, + stageCount: 1, icon: null, id: 'code', title: 'Code', @@ -251,46 +251,8 @@ export const selectedProjects = [ }, ]; -export const rawValueStreamStages = [ - { - title: 'Issue', - hidden: false, - legend: '', - description: 'Time before an issue gets scheduled', - id: 'issue', - custom: false, - start_event_html_description: - '\u003cp data-sourcepos="1:1-1:13" dir="auto"\u003eIssue created\u003c/p\u003e', - end_event_html_description: - '\u003cp data-sourcepos="1:1-1:71" dir="auto"\u003eIssue first associated with a milestone or issue first added to a board\u003c/p\u003e', - }, - { - title: 'Plan', - hidden: false, - legend: '', - description: 'Time before an issue starts implementation', - id: 'plan', - custom: false, - start_event_html_description: - '\u003cp data-sourcepos="1:1-1:71" dir="auto"\u003eIssue first associated with a milestone or issue first added to a board\u003c/p\u003e', - end_event_html_description: - '\u003cp data-sourcepos="1:1-1:33" dir="auto"\u003eIssue first mentioned in a commit\u003c/p\u003e', - }, - { - title: 'Code', - hidden: false, - legend: '', - description: 'Time until first merge request', - id: 'code', - custom: false, - start_event_html_description: - '\u003cp data-sourcepos="1:1-1:33" dir="auto"\u003eIssue first mentioned in a commit\u003c/p\u003e', - end_event_html_description: - '\u003cp data-sourcepos="1:1-1:21" dir="auto"\u003eMerge request created\u003c/p\u003e', - }, -]; +export const rawValueStreamStages = customizableStagesAndEvents.stages; -export const valueStreamStages = rawValueStreamStages.map((s) => ({ - ...convertObjectPropsToCamelCase(s, { deep: true }), - component: `stage-${s.id}-component`, -})); +export const valueStreamStages = rawValueStreamStages.map((s) => + convertObjectPropsToCamelCase(s, { deep: true }), +); diff --git a/spec/frontend/cycle_analytics/stage_nav_item_spec.js b/spec/frontend/cycle_analytics/stage_nav_item_spec.js deleted file mode 100644 index d577d0b602a..00000000000 --- a/spec/frontend/cycle_analytics/stage_nav_item_spec.js +++ /dev/null @@ -1,152 +0,0 @@ -import { mount, shallowMount } from '@vue/test-utils'; -import StageNavItem from '~/cycle_analytics/components/stage_nav_item.vue'; - -describe('StageNavItem', () => { - let wrapper = null; - const title = 'Cool stage'; - const value = '1 day'; - - function createComponent(props, shallow = true) { - const func = shallow ? shallowMount : mount; - return func(StageNavItem, { - propsData: { - isActive: false, - isUserAllowed: false, - isDefaultStage: true, - title, - value, - ...props, - }, - }); - } - - function hasStageName() { - const stageName = wrapper.find('.stage-name'); - expect(stageName.exists()).toBe(true); - expect(stageName.text()).toEqual(title); - } - - it('renders stage name', () => { - wrapper = createComponent({ isUserAllowed: true }); - hasStageName(); - wrapper.destroy(); - }); - - describe('User has access', () => { - describe('with a value', () => { - beforeEach(() => { - wrapper = createComponent({ isUserAllowed: true }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - it('renders the value for median value', () => { - expect(wrapper.find('.stage-empty').exists()).toBe(false); - expect(wrapper.find('.not-available').exists()).toBe(false); - expect(wrapper.find('.stage-median').text()).toEqual(value); - }); - }); - - describe('without a value', () => { - beforeEach(() => { - wrapper = createComponent({ isUserAllowed: true, value: null }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('has the stage-empty class', () => { - expect(wrapper.find('.stage-empty').exists()).toBe(true); - }); - - it('renders Not enough data for the median value', () => { - expect(wrapper.find('.stage-median').text()).toEqual('Not enough data'); - }); - }); - }); - - describe('is active', () => { - beforeEach(() => { - wrapper = createComponent({ isActive: true }, false); - }); - - afterEach(() => { - wrapper.destroy(); - }); - it('has the active class', () => { - expect(wrapper.find('.stage-nav-item').classes('active')).toBe(true); - }); - }); - - describe('is not active', () => { - beforeEach(() => { - wrapper = createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - it('emits the `select` event when clicked', () => { - expect(wrapper.emitted().select).toBeUndefined(); - wrapper.trigger('click'); - return wrapper.vm.$nextTick(() => { - expect(wrapper.emitted().select.length).toBe(1); - }); - }); - }); - - describe('User does not have access', () => { - beforeEach(() => { - wrapper = createComponent({ isUserAllowed: false }, false); - }); - - afterEach(() => { - wrapper.destroy(); - }); - it('renders stage name', () => { - hasStageName(); - }); - - it('has class not-available', () => { - expect(wrapper.find('.stage-empty').exists()).toBe(false); - expect(wrapper.find('.not-available').exists()).toBe(true); - }); - - it('renders Not available for the median value', () => { - expect(wrapper.find('.stage-median').text()).toBe('Not available'); - }); - it('does not render options menu', () => { - expect(wrapper.find('[data-testid="more-actions-toggle"]').exists()).toBe(false); - }); - }); - - describe('User can edit stages', () => { - beforeEach(() => { - wrapper = createComponent({ isUserAllowed: true }, false); - }); - - afterEach(() => { - wrapper.destroy(); - }); - it('renders stage name', () => { - hasStageName(); - }); - - it('does not render options menu', () => { - expect(wrapper.find('[data-testid="more-actions-toggle"]').exists()).toBe(false); - }); - - it('can not edit the stage', () => { - expect(wrapper.text()).not.toContain('Edit stage'); - }); - it('can not remove the stage', () => { - expect(wrapper.text()).not.toContain('Remove stage'); - }); - - it('can not hide the stage', () => { - expect(wrapper.text()).not.toContain('Hide stage'); - }); - }); -}); diff --git a/spec/frontend/cycle_analytics/stage_table_spec.js b/spec/frontend/cycle_analytics/stage_table_spec.js new file mode 100644 index 00000000000..47a2ce4444b --- /dev/null +++ b/spec/frontend/cycle_analytics/stage_table_spec.js @@ -0,0 +1,279 @@ +import { GlEmptyState, GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import StageTable from '~/cycle_analytics/components/stage_table.vue'; +import { PAGINATION_SORT_FIELD_DURATION } from '~/cycle_analytics/constants'; +import { issueEvents, issueStage, reviewStage, reviewEvents } from './mock_data'; + +let wrapper = null; +let trackingSpy = null; + +const noDataSvgPath = 'path/to/no/data'; +const emptyStateTitle = 'Too much data'; +const notEnoughDataError = "We don't have enough data to show this stage."; +const issueEventItems = issueEvents.events; +const reviewEventItems = reviewEvents.events; +const [firstIssueEvent] = issueEventItems; +const [firstReviewEvent] = reviewEventItems; +const pagination = { page: 1, hasNextPage: true }; + +const findStageEvents = () => wrapper.findAllByTestId('vsa-stage-event'); +const findPagination = () => wrapper.findByTestId('vsa-stage-pagination'); +const findTable = () => wrapper.findComponent(GlTable); +const findTableHead = () => wrapper.find('thead'); +const findStageEventTitle = (ev) => extendedWrapper(ev).findByTestId('vsa-stage-event-title'); +const findStageTime = () => wrapper.findByTestId('vsa-stage-event-time'); +const findIcon = (name) => wrapper.findByTestId(`${name}-icon`); + +function createComponent(props = {}, shallow = false) { + const func = shallow ? shallowMount : mount; + return extendedWrapper( + func(StageTable, { + propsData: { + isLoading: false, + stageEvents: issueEventItems, + noDataSvgPath, + selectedStage: issueStage, + pagination, + ...props, + }, + stubs: { + GlLoadingIcon, + GlEmptyState, + }, + }), + ); +} + +describe('StageTable', () => { + afterEach(() => { + wrapper.destroy(); + }); + + describe('is loaded with data', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('will render the correct events', () => { + const evs = findStageEvents(); + expect(evs).toHaveLength(issueEventItems.length); + + const titles = evs.wrappers.map((ev) => findStageEventTitle(ev).text()); + issueEventItems.forEach((ev, index) => { + expect(titles[index]).toBe(ev.title); + }); + }); + + it('will not display the default data message', () => { + expect(wrapper.html()).not.toContain(notEnoughDataError); + }); + }); + + describe('with minimal stage data', () => { + beforeEach(() => { + wrapper = createComponent({ currentStage: { title: 'New stage title' } }); + }); + + it('will render the correct events', () => { + const evs = findStageEvents(); + expect(evs).toHaveLength(issueEventItems.length); + + const titles = evs.wrappers.map((ev) => findStageEventTitle(ev).text()); + issueEventItems.forEach((ev, index) => { + expect(titles[index]).toBe(ev.title); + }); + }); + }); + + describe('default event', () => { + beforeEach(() => { + wrapper = createComponent({ + stageEvents: [{ ...firstIssueEvent }], + selectedStage: { ...issueStage, custom: false }, + }); + }); + + it('will render the event title', () => { + expect(wrapper.findByTestId('vsa-stage-event-title').text()).toBe(firstIssueEvent.title); + }); + + it('will set the workflow title to "Issues"', () => { + expect(findTableHead().text()).toContain('Issues'); + }); + + it('does not render the fork icon', () => { + expect(findIcon('fork').exists()).toBe(false); + }); + + it('does not render the branch icon', () => { + expect(findIcon('commit').exists()).toBe(false); + }); + + it('will render the total time', () => { + const createdAt = firstIssueEvent.createdAt.replace(' ago', ''); + expect(findStageTime().text()).toBe(createdAt); + }); + + it('will render the author', () => { + expect(wrapper.findByTestId('vsa-stage-event-author').text()).toContain( + firstIssueEvent.author.name, + ); + }); + + it('will render the created at date', () => { + expect(wrapper.findByTestId('vsa-stage-event-date').text()).toContain( + firstIssueEvent.createdAt, + ); + }); + }); + + describe('merge request event', () => { + beforeEach(() => { + wrapper = createComponent({ + stageEvents: [{ ...firstReviewEvent }], + selectedStage: { ...reviewStage, custom: false }, + }); + }); + + it('will set the workflow title to "Merge requests"', () => { + expect(findTableHead().text()).toContain('Merge requests'); + expect(findTableHead().text()).not.toContain('Issues'); + }); + }); + + describe('isLoading = true', () => { + beforeEach(() => { + wrapper = createComponent({ isLoading: true }, true); + }); + + it('will display the loading icon', () => { + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + + it('will not display pagination', () => { + expect(findPagination().exists()).toBe(false); + }); + }); + + describe('with no stageEvents', () => { + beforeEach(() => { + wrapper = createComponent({ stageEvents: [] }); + }); + + it('will render the empty state', () => { + expect(wrapper.findComponent(GlEmptyState).exists()).toBe(true); + }); + + it('will display the default no data message', () => { + expect(wrapper.html()).toContain(notEnoughDataError); + }); + + it('will not display the pagination component', () => { + expect(findPagination().exists()).toBe(false); + }); + }); + + describe('emptyStateTitle set', () => { + beforeEach(() => { + wrapper = createComponent({ stageEvents: [], emptyStateTitle }); + }); + + it('will display the custom message', () => { + expect(wrapper.html()).not.toContain(notEnoughDataError); + expect(wrapper.html()).toContain(emptyStateTitle); + }); + }); + + describe('Pagination', () => { + beforeEach(() => { + wrapper = createComponent(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + wrapper.destroy(); + }); + + it('will display the pagination component', () => { + expect(findPagination().exists()).toBe(true); + }); + + it('clicking prev or next will emit an event', async () => { + expect(wrapper.emitted('handleUpdatePagination')).toBeUndefined(); + + findPagination().vm.$emit('input', 2); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('handleUpdatePagination')[0]).toEqual([{ page: 2 }]); + }); + + it('clicking prev or next will send tracking information', () => { + findPagination().vm.$emit('input', 2); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', { label: 'pagination' }); + }); + + describe('with `hasNextPage=false', () => { + beforeEach(() => { + wrapper = createComponent({ pagination: { page: 1, hasNextPage: false } }); + }); + + it('will not display the pagination component', () => { + expect(findPagination().exists()).toBe(false); + }); + }); + }); + + describe('Sorting', () => { + const triggerTableSort = (sortDesc = true) => + findTable().vm.$emit('sort-changed', { + sortBy: PAGINATION_SORT_FIELD_DURATION, + sortDesc, + }); + + beforeEach(() => { + wrapper = createComponent(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + wrapper.destroy(); + }); + + it('clicking a table column will send tracking information', () => { + triggerTableSort(); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', { + label: 'sort_duration_desc', + }); + }); + + it('clicking a table column will update the sort field', () => { + expect(wrapper.emitted('handleUpdatePagination')).toBeUndefined(); + triggerTableSort(); + + expect(wrapper.emitted('handleUpdatePagination')[0]).toEqual([ + { + direction: 'desc', + sort: 'duration', + }, + ]); + }); + + it('with sortDesc=false will toggle the direction field', async () => { + expect(wrapper.emitted('handleUpdatePagination')).toBeUndefined(); + triggerTableSort(false); + + expect(wrapper.emitted('handleUpdatePagination')[0]).toEqual([ + { + direction: 'asc', + sort: 'duration', + }, + ]); + }); + }); +}); diff --git a/spec/frontend/cycle_analytics/store/actions_spec.js b/spec/frontend/cycle_analytics/store/actions_spec.js index 8a8dd374f8e..915a828ff19 100644 --- a/spec/frontend/cycle_analytics/store/actions_spec.js +++ b/spec/frontend/cycle_analytics/store/actions_spec.js @@ -2,39 +2,23 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import * as actions from '~/cycle_analytics/store/actions'; +import * as getters from '~/cycle_analytics/store/getters'; import httpStatusCodes from '~/lib/utils/http_status'; import { allowedStages, selectedStage, selectedValueStream } from '../mock_data'; const mockRequestPath = 'some/cool/path'; const mockFullPath = '/namespace/-/analytics/value_stream_analytics/value_streams'; const mockStartDate = 30; -const mockRequestedDataActions = ['fetchValueStreams', 'fetchCycleAnalyticsData']; -const mockInitializeActionCommit = { - payload: { requestPath: mockRequestPath }, - type: 'INITIALIZE_VSA', -}; +const mockEndpoints = { fullPath: mockFullPath, requestPath: mockRequestPath }; const mockSetDateActionCommit = { payload: { startDate: mockStartDate }, type: 'SET_DATE_RANGE' }; -const mockRequestedDataMutations = [ - { - payload: true, - type: 'SET_LOADING', - }, - { - payload: false, - type: 'SET_LOADING', - }, -]; - -const features = { - cycleAnalyticsForGroups: true, -}; + +const defaultState = { ...getters, selectedValueStream }; describe('Project Value Stream Analytics actions', () => { let state; let mock; beforeEach(() => { - state = {}; mock = new MockAdapter(axios); }); @@ -45,28 +29,62 @@ describe('Project Value Stream Analytics actions', () => { const mutationTypes = (arr) => arr.map(({ type }) => type); + const mockFetchStageDataActions = [ + { type: 'setLoading', payload: true }, + { type: 'fetchCycleAnalyticsData' }, + { type: 'fetchStageData' }, + { type: 'fetchStageMedians' }, + { type: 'setLoading', payload: false }, + ]; + describe.each` - action | payload | expectedActions | expectedMutations - ${'initializeVsa'} | ${{ requestPath: mockRequestPath }} | ${mockRequestedDataActions} | ${[mockInitializeActionCommit, ...mockRequestedDataMutations]} - ${'setDateRange'} | ${{ startDate: mockStartDate }} | ${mockRequestedDataActions} | ${[mockSetDateActionCommit, ...mockRequestedDataMutations]} - ${'setSelectedStage'} | ${{ selectedStage }} | ${['fetchStageData']} | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]} - ${'setSelectedValueStream'} | ${{ selectedValueStream }} | ${['fetchValueStreamStages']} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]} + action | payload | expectedActions | expectedMutations + ${'setLoading'} | ${true} | ${[]} | ${[{ type: 'SET_LOADING', payload: true }]} + ${'setDateRange'} | ${{ startDate: mockStartDate }} | ${mockFetchStageDataActions} | ${[mockSetDateActionCommit]} + ${'setFilters'} | ${[]} | ${mockFetchStageDataActions} | ${[]} + ${'setSelectedStage'} | ${{ selectedStage }} | ${[{ type: 'fetchStageData' }]} | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]} + ${'setSelectedValueStream'} | ${{ selectedValueStream }} | ${[{ type: 'fetchValueStreamStages' }, { type: 'fetchCycleAnalyticsData' }]} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]} `('$action', ({ action, payload, expectedActions, expectedMutations }) => { const types = mutationTypes(expectedMutations); - it(`will dispatch ${expectedActions} and commit ${types}`, () => testAction({ action: actions[action], state, payload, expectedMutations, - expectedActions: expectedActions.map((a) => ({ type: a })), + expectedActions, })); }); + describe('initializeVsa', () => { + let mockDispatch; + let mockCommit; + const payload = { endpoints: mockEndpoints }; + + beforeEach(() => { + mockDispatch = jest.fn(() => Promise.resolve()); + mockCommit = jest.fn(); + }); + + it('will dispatch the setLoading and fetchValueStreams actions and commit INITIALIZE_VSA', async () => { + await actions.initializeVsa( + { + ...state, + dispatch: mockDispatch, + commit: mockCommit, + }, + payload, + ); + expect(mockCommit).toHaveBeenCalledWith('INITIALIZE_VSA', { endpoints: mockEndpoints }); + expect(mockDispatch).toHaveBeenCalledWith('setLoading', true); + expect(mockDispatch).toHaveBeenCalledWith('fetchValueStreams'); + expect(mockDispatch).toHaveBeenCalledWith('setLoading', false); + }); + }); + describe('fetchCycleAnalyticsData', () => { beforeEach(() => { - state = { requestPath: mockRequestPath }; + state = { endpoints: mockEndpoints }; mock = new MockAdapter(axios); mock.onGet(mockRequestPath).reply(httpStatusCodes.OK); }); @@ -85,7 +103,7 @@ describe('Project Value Stream Analytics actions', () => { describe('with a failing request', () => { beforeEach(() => { - state = { requestPath: mockRequestPath }; + state = { endpoints: mockEndpoints }; mock = new MockAdapter(axios); mock.onGet(mockRequestPath).reply(httpStatusCodes.BAD_REQUEST); }); @@ -105,11 +123,12 @@ describe('Project Value Stream Analytics actions', () => { }); describe('fetchStageData', () => { - const mockStagePath = `${mockRequestPath}/events/${selectedStage.name}`; + const mockStagePath = /value_streams\/\w+\/stages\/\w+\/records/; beforeEach(() => { state = { - requestPath: mockRequestPath, + ...defaultState, + endpoints: mockEndpoints, startDate: mockStartDate, selectedStage, }; @@ -131,7 +150,8 @@ describe('Project Value Stream Analytics actions', () => { beforeEach(() => { state = { - requestPath: mockRequestPath, + ...defaultState, + endpoints: mockEndpoints, startDate: mockStartDate, selectedStage, }; @@ -155,7 +175,8 @@ describe('Project Value Stream Analytics actions', () => { describe('with a failing request', () => { beforeEach(() => { state = { - requestPath: mockRequestPath, + ...defaultState, + endpoints: mockEndpoints, startDate: mockStartDate, selectedStage, }; @@ -179,8 +200,7 @@ describe('Project Value Stream Analytics actions', () => { beforeEach(() => { state = { - features, - fullPath: mockFullPath, + endpoints: mockEndpoints, }; mock = new MockAdapter(axios); mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK); @@ -196,29 +216,10 @@ describe('Project Value Stream Analytics actions', () => { { type: 'receiveValueStreamsSuccess' }, { type: 'setSelectedStage' }, { type: 'fetchStageMedians' }, + { type: 'fetchStageCountValues' }, ], })); - describe('with cycleAnalyticsForGroups=false', () => { - beforeEach(() => { - state = { - features: { cycleAnalyticsForGroups: false }, - fullPath: mockFullPath, - }; - mock = new MockAdapter(axios); - mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK); - }); - - it("does not dispatch the 'fetchStageMedians' request", () => - testAction({ - action: actions.fetchValueStreams, - state, - payload: {}, - expectedMutations: [{ type: 'REQUEST_VALUE_STREAMS' }], - expectedActions: [{ type: 'receiveValueStreamsSuccess' }, { type: 'setSelectedStage' }], - })); - }); - describe('with a failing request', () => { beforeEach(() => { mock = new MockAdapter(axios); @@ -271,7 +272,7 @@ describe('Project Value Stream Analytics actions', () => { beforeEach(() => { state = { - fullPath: mockFullPath, + endpoints: mockEndpoints, selectedValueStream, }; mock = new MockAdapter(axios); @@ -364,4 +365,64 @@ describe('Project Value Stream Analytics actions', () => { })); }); }); + + describe('fetchStageCountValues', () => { + const mockValueStreamPath = /count/; + const stageCountsPayload = [ + { id: 'issue', count: 1 }, + { id: 'plan', count: 2 }, + { id: 'code', count: 3 }, + ]; + + const stageCountError = new Error( + `Request failed with status code ${httpStatusCodes.BAD_REQUEST}`, + ); + + beforeEach(() => { + state = { + fullPath: mockFullPath, + selectedValueStream, + stages: allowedStages, + }; + mock = new MockAdapter(axios); + mock + .onGet(mockValueStreamPath) + .replyOnce(httpStatusCodes.OK, { count: 1 }) + .onGet(mockValueStreamPath) + .replyOnce(httpStatusCodes.OK, { count: 2 }) + .onGet(mockValueStreamPath) + .replyOnce(httpStatusCodes.OK, { count: 3 }); + }); + + it(`commits the 'REQUEST_STAGE_COUNTS' and 'RECEIVE_STAGE_COUNTS_SUCCESS' mutations`, () => + testAction({ + action: actions.fetchStageCountValues, + state, + payload: {}, + expectedMutations: [ + { type: 'REQUEST_STAGE_COUNTS' }, + { type: 'RECEIVE_STAGE_COUNTS_SUCCESS', payload: stageCountsPayload }, + ], + expectedActions: [], + })); + + describe('with a failing request', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet(mockValueStreamPath).reply(httpStatusCodes.BAD_REQUEST); + }); + + it(`commits the 'RECEIVE_STAGE_COUNTS_ERROR' mutation`, () => + testAction({ + action: actions.fetchStageCountValues, + state, + payload: {}, + expectedMutations: [ + { type: 'REQUEST_STAGE_COUNTS' }, + { type: 'RECEIVE_STAGE_COUNTS_ERROR', payload: stageCountError }, + ], + expectedActions: [], + })); + }); + }); }); diff --git a/spec/frontend/cycle_analytics/store/getters_spec.js b/spec/frontend/cycle_analytics/store/getters_spec.js index 5745e9d7902..c47a30a5f79 100644 --- a/spec/frontend/cycle_analytics/store/getters_spec.js +++ b/spec/frontend/cycle_analytics/store/getters_spec.js @@ -4,12 +4,13 @@ import { stageMedians, transformedProjectStagePathData, selectedStage, + stageCounts, } from '../mock_data'; describe('Value stream analytics getters', () => { describe('pathNavigationData', () => { it('returns the transformed data', () => { - const state = { stages: allowedStages, medians: stageMedians, selectedStage }; + const state = { stages: allowedStages, medians: stageMedians, selectedStage, stageCounts }; expect(getters.pathNavigationData(state)).toEqual(transformedProjectStagePathData); }); }); diff --git a/spec/frontend/cycle_analytics/store/mutations_spec.js b/spec/frontend/cycle_analytics/store/mutations_spec.js index 77b19280517..7fcfef98547 100644 --- a/spec/frontend/cycle_analytics/store/mutations_spec.js +++ b/spec/frontend/cycle_analytics/store/mutations_spec.js @@ -4,30 +4,29 @@ import * as types from '~/cycle_analytics/store/mutation_types'; import mutations from '~/cycle_analytics/store/mutations'; import { selectedStage, - rawEvents, - convertedEvents, - rawData, - convertedData, + rawIssueEvents, + issueEvents, selectedValueStream, rawValueStreamStages, valueStreamStages, rawStageMedians, formattedStageMedians, + rawStageCounts, + stageCounts, } from '../mock_data'; let state; +const rawEvents = rawIssueEvents.events; +const convertedEvents = issueEvents.events; const mockRequestPath = 'fake/request/path'; const mockCreatedAfter = '2020-06-18'; const mockCreatedBefore = '2020-07-18'; -const features = { - cycleAnalyticsForGroups: true, -}; describe('Project Value Stream Analytics mutations', () => { useFakeDate(2020, 6, 18); beforeEach(() => { - state = { features }; + state = {}; }); afterEach(() => { @@ -58,26 +57,48 @@ describe('Project Value Stream Analytics mutations', () => { ${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true} ${types.REQUEST_STAGE_MEDIANS} | ${'medians'} | ${{}} ${types.RECEIVE_STAGE_MEDIANS_ERROR} | ${'medians'} | ${{}} + ${types.REQUEST_STAGE_COUNTS} | ${'stageCounts'} | ${{}} + ${types.RECEIVE_STAGE_COUNTS_ERROR} | ${'stageCounts'} | ${{}} `('$mutation will set $stateKey to $value', ({ mutation, stateKey, value }) => { - mutations[mutation](state, {}); + mutations[mutation](state); expect(state).toMatchObject({ [stateKey]: value }); }); + const mockInitialPayload = { + endpoints: { requestPath: mockRequestPath }, + currentGroup: { title: 'cool-group' }, + id: 1337, + }; + const mockInitializedObj = { + endpoints: { requestPath: mockRequestPath }, + createdAfter: mockCreatedAfter, + createdBefore: mockCreatedBefore, + }; + it.each` - mutation | payload | stateKey | value - ${types.INITIALIZE_VSA} | ${{ requestPath: mockRequestPath }} | ${'requestPath'} | ${mockRequestPath} - ${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'startDate'} | ${DEFAULT_DAYS_TO_DISPLAY} - ${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'createdAfter'} | ${mockCreatedAfter} - ${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'createdBefore'} | ${mockCreatedBefore} - ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true} - ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false} - ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream} - ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'summary'} | ${convertedData.summary} - ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]} - ${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages} - ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]} - ${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians} + mutation | stateKey | value + ${types.INITIALIZE_VSA} | ${'endpoints'} | ${{ requestPath: mockRequestPath }} + ${types.INITIALIZE_VSA} | ${'createdAfter'} | ${mockCreatedAfter} + ${types.INITIALIZE_VSA} | ${'createdBefore'} | ${mockCreatedBefore} + `('$mutation will set $stateKey', ({ mutation, stateKey, value }) => { + mutations[mutation](state, { ...mockInitialPayload }); + + expect(state).toMatchObject({ ...mockInitializedObj, [stateKey]: value }); + }); + + it.each` + mutation | payload | stateKey | value + ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'daysInPast'} | ${DEFAULT_DAYS_TO_DISPLAY} + ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdAfter'} | ${mockCreatedAfter} + ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdBefore'} | ${mockCreatedBefore} + ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true} + ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false} + ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream} + ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]} + ${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages} + ${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians} + ${types.RECEIVE_STAGE_COUNTS_SUCCESS} | ${rawStageCounts} | ${'stageCounts'} | ${stageCounts} `( '$mutation with $payload will set $stateKey to $value', ({ mutation, payload, stateKey, value }) => { @@ -95,41 +116,10 @@ describe('Project Value Stream Analytics mutations', () => { }); it.each` - mutation | payload | stateKey | value - ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${{ events: [] }} | ${'isEmptyStage'} | ${true} - ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${{ events: rawEvents }} | ${'selectedStageEvents'} | ${convertedEvents} - ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${{ events: rawEvents }} | ${'isEmptyStage'} | ${false} - `( - '$mutation with $payload will set $stateKey to $value', - ({ mutation, payload, stateKey, value }) => { - mutations[mutation](state, payload); - - expect(state).toMatchObject({ [stateKey]: value }); - }, - ); - }); - - describe('with cycleAnalyticsForGroups=false', () => { - useFakeDate(2020, 6, 18); - - beforeEach(() => { - state = { features: { cycleAnalyticsForGroups: false } }; - }); - - const formattedMedians = { - code: '2d', - issue: '-', - plan: '21h', - review: '-', - staging: '2d', - test: '4h', - }; - - it.each` - mutation | payload | stateKey | value - ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'medians'} | ${formattedMedians} - ${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${{}} | ${'medians'} | ${{}} - ${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR} | ${{}} | ${'medians'} | ${{}} + mutation | payload | stateKey | value + ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${[]} | ${'isEmptyStage'} | ${true} + ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${rawEvents} | ${'selectedStageEvents'} | ${convertedEvents} + ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${rawEvents} | ${'isEmptyStage'} | ${false} `( '$mutation with $payload will set $stateKey to $value', ({ mutation, payload, stateKey, value }) => { diff --git a/spec/frontend/cycle_analytics/total_time_component_spec.js b/spec/frontend/cycle_analytics/total_time_component_spec.js index e831bc311ed..9003c0330c0 100644 --- a/spec/frontend/cycle_analytics/total_time_component_spec.js +++ b/spec/frontend/cycle_analytics/total_time_component_spec.js @@ -1,11 +1,11 @@ -import { shallowMount } from '@vue/test-utils'; -import TotalTime from '~/cycle_analytics/components/total_time_component.vue'; +import { mount } from '@vue/test-utils'; +import TotalTimeComponent from '~/cycle_analytics/components/total_time_component.vue'; -describe('Total time component', () => { - let wrapper; +describe('TotalTimeComponent', () => { + let wrapper = null; const createComponent = (propsData) => { - wrapper = shallowMount(TotalTime, { + return mount(TotalTimeComponent, { propsData, }); }; @@ -14,45 +14,32 @@ describe('Total time component', () => { wrapper.destroy(); }); - describe('With data', () => { - it('should render information for days and hours', () => { - createComponent({ - time: { - days: 3, - hours: 4, - }, + describe('with a valid time object', () => { + it.each` + time + ${{ seconds: 35 }} + ${{ mins: 47, seconds: 3 }} + ${{ days: 3, mins: 47, seconds: 3 }} + ${{ hours: 23, mins: 10 }} + ${{ hours: 7, mins: 20, seconds: 10 }} + `('with $time', ({ time }) => { + wrapper = createComponent({ + time, }); - expect(wrapper.text()).toMatchInterpolatedText('3 days 4 hrs'); - }); - - it('should render information for hours and minutes', () => { - createComponent({ - time: { - hours: 4, - mins: 35, - }, - }); - - expect(wrapper.text()).toMatchInterpolatedText('4 hrs 35 mins'); + expect(wrapper.html()).toMatchSnapshot(); }); + }); - it('should render information for seconds', () => { - createComponent({ - time: { - seconds: 45, - }, + describe('with a blank object', () => { + beforeEach(() => { + wrapper = createComponent({ + time: {}, }); - - expect(wrapper.text()).toMatchInterpolatedText('45 s'); }); - }); - - describe('Without data', () => { - it('should render no information', () => { - createComponent(); - expect(wrapper.text()).toBe('--'); + it('should render --', () => { + expect(wrapper.html()).toMatchSnapshot(); }); }); }); diff --git a/spec/frontend/cycle_analytics/utils_spec.js b/spec/frontend/cycle_analytics/utils_spec.js index 1fecdfc0539..69fed879fd8 100644 --- a/spec/frontend/cycle_analytics/utils_spec.js +++ b/spec/frontend/cycle_analytics/utils_spec.js @@ -1,70 +1,24 @@ import { useFakeDate } from 'helpers/fake_date'; import { - decorateEvents, - decorateData, transformStagesForPathNavigation, timeSummaryForPathNavigation, medianTimeToParsedSeconds, formatMedianValues, filterStagesByHiddenStatus, calculateFormattedDayInPast, + prepareTimeMetricsData, } from '~/cycle_analytics/utils'; +import { slugify } from '~/lib/utils/text_utility'; import { selectedStage, - rawData, - convertedData, - rawEvents, allowedStages, stageMedians, pathNavIssueMetric, rawStageMedians, + metricsData, } from './mock_data'; describe('Value stream analytics utils', () => { - describe('decorateEvents', () => { - const [result] = decorateEvents(rawEvents, selectedStage); - const eventKeys = Object.keys(result); - const authorKeys = Object.keys(result.author); - it('will return the same number of events', () => { - expect(decorateEvents(rawEvents, selectedStage).length).toBe(rawEvents.length); - }); - - it('will set all the required event fields', () => { - ['totalTime', 'author', 'createdAt', 'shortSha', 'commitUrl'].forEach((key) => { - expect(eventKeys).toContain(key); - }); - ['webUrl', 'avatarUrl'].forEach((key) => { - expect(authorKeys).toContain(key); - }); - }); - - it('will remove unused fields', () => { - ['total_time', 'created_at', 'short_sha', 'commit_url'].forEach((key) => { - expect(eventKeys).not.toContain(key); - }); - - ['web_url', 'avatar_url'].forEach((key) => { - expect(authorKeys).not.toContain(key); - }); - }); - }); - - describe('decorateData', () => { - const result = decorateData(rawData); - it('returns the summary data', () => { - expect(result.summary).toEqual(convertedData.summary); - }); - - it('returns `-` for summary data that has no value', () => { - const singleSummaryResult = decorateData({ - stats: [], - permissions: { issue: true }, - summary: [{ value: null, title: 'Commits' }], - }); - expect(singleSummaryResult.summary).toEqual([{ value: '-', title: 'Commits' }]); - }); - }); - describe('transformStagesForPathNavigation', () => { const stages = allowedStages; const response = transformStagesForPathNavigation({ @@ -159,4 +113,32 @@ describe('Value stream analytics utils', () => { expect(calculateFormattedDayInPast(5)).toEqual({ now: '1815-12-10', past: '1815-12-05' }); }); }); + + describe('prepareTimeMetricsData', () => { + let prepared; + const [first, second] = metricsData; + const firstKey = slugify(first.title); + const secondKey = slugify(second.title); + + beforeEach(() => { + prepared = prepareTimeMetricsData([first, second], { + [firstKey]: { description: 'Is a value that is good' }, + }); + }); + + it('will add a `key` based on the title', () => { + expect(prepared).toMatchObject([{ key: firstKey }, { key: secondKey }]); + }); + + it('will add a `label` key', () => { + expect(prepared).toMatchObject([{ label: 'New Issues' }, { label: 'Commits' }]); + }); + + it('will add a popover description using the key if it is provided', () => { + expect(prepared).toMatchObject([ + { description: 'Is a value that is good' }, + { description: '' }, + ]); + }); + }); }); diff --git a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js new file mode 100644 index 00000000000..ffdb49a828c --- /dev/null +++ b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js @@ -0,0 +1,128 @@ +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import { METRIC_TYPE_SUMMARY } from '~/api/analytics_api'; +import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue'; +import createFlash from '~/flash'; +import { group, metricsData } from './mock_data'; + +jest.mock('~/flash'); + +describe('ValueStreamMetrics', () => { + let wrapper; + let mockGetValueStreamSummaryMetrics; + + const { full_path: requestPath } = group; + const fakeReqName = 'Mock metrics'; + const metricsRequestFactory = () => ({ + request: mockGetValueStreamSummaryMetrics, + endpoint: METRIC_TYPE_SUMMARY, + name: fakeReqName, + }); + + const createComponent = ({ requestParams = {} } = {}) => { + return shallowMount(ValueStreamMetrics, { + propsData: { + requestPath, + requestParams, + requests: [metricsRequestFactory()], + }, + }); + }; + + const findMetrics = () => wrapper.findAllComponents(GlSingleStat); + + const expectToHaveRequest = (fields) => { + expect(mockGetValueStreamSummaryMetrics).toHaveBeenCalledWith({ + endpoint: METRIC_TYPE_SUMMARY, + requestPath, + ...fields, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('with successful requests', () => { + beforeEach(() => { + mockGetValueStreamSummaryMetrics = jest.fn().mockResolvedValue({ data: metricsData }); + wrapper = createComponent(); + }); + + it('will display a loader with pending requests', async () => { + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true); + }); + + describe('with data loaded', () => { + beforeEach(async () => { + await waitForPromises(); + }); + + it('fetches data from the value stream analytics endpoint', () => { + expectToHaveRequest({ params: {} }); + }); + + it.each` + index | value | title | unit + ${0} | ${metricsData[0].value} | ${metricsData[0].title} | ${metricsData[0].unit} + ${1} | ${metricsData[1].value} | ${metricsData[1].title} | ${metricsData[1].unit} + ${2} | ${metricsData[2].value} | ${metricsData[2].title} | ${metricsData[2].unit} + ${3} | ${metricsData[3].value} | ${metricsData[3].title} | ${metricsData[3].unit} + `( + 'renders a single stat component for the $title with value and unit', + ({ index, value, title, unit }) => { + const metric = findMetrics().at(index); + expect(metric.props()).toMatchObject({ value, title, unit: unit ?? '' }); + }, + ); + + it('will not display a loading icon', () => { + expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false); + }); + + describe('with additional params', () => { + beforeEach(async () => { + wrapper = createComponent({ + requestParams: { + 'project_ids[]': [1], + created_after: '2020-01-01', + created_before: '2020-02-01', + }, + }); + + await waitForPromises(); + }); + + it('fetches data for the `getValueStreamSummaryMetrics` request', () => { + expectToHaveRequest({ + params: { + 'project_ids[]': [1], + created_after: '2020-01-01', + created_before: '2020-02-01', + }, + }); + }); + }); + }); + }); + + describe('with a request failing', () => { + beforeEach(async () => { + mockGetValueStreamSummaryMetrics = jest.fn().mockRejectedValue(); + wrapper = createComponent(); + + await waitForPromises(); + }); + + it('it should render an error message', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: `There was an error while fetching value stream analytics ${fakeReqName} data.`, + }); + }); + }); +}); diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js index efadb9b717d..9335d800a16 100644 --- a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js @@ -17,6 +17,8 @@ const defaultMockDiscussion = { notes, }; +const DEFAULT_TODO_COUNT = 2; + describe('Design discussions component', () => { let wrapper; @@ -41,8 +43,14 @@ describe('Design discussions component', () => { }, }; const mutate = jest.fn().mockResolvedValue({ data: { createNote: { errors: [] } } }); + const readQuery = jest.fn().mockReturnValue({ + project: { + issue: { designCollection: { designs: { nodes: [{ currentUserTodos: { nodes: [] } }] } } }, + }, + }); const $apollo = { mutate, + provider: { clients: { defaultClient: { readQuery } } }, }; function createComponent(props = {}, data = {}) { @@ -69,6 +77,12 @@ describe('Design discussions component', () => { $apollo, $route: { hash: '#note_1', + params: { + id: 1, + }, + query: { + version: null, + }, }, }, }); @@ -138,7 +152,13 @@ describe('Design discussions component', () => { }); describe('when discussion is resolved', () => { + let dispatchEventSpy; + beforeEach(() => { + dispatchEventSpy = jest.spyOn(document, 'dispatchEvent'); + jest.spyOn(document, 'querySelector').mockReturnValue({ + innerText: DEFAULT_TODO_COUNT, + }); createComponent({ discussion: { ...defaultMockDiscussion, @@ -174,6 +194,24 @@ describe('Design discussions component', () => { expect(findResolveIcon().props('name')).toBe('check-circle-filled'); }); + it('emit todo:toggle when discussion is resolved', async () => { + createComponent( + { discussionWithOpenForm: defaultMockDiscussion.id }, + { discussionComment: 'test', isFormRendered: true }, + ); + findResolveButton().trigger('click'); + findReplyForm().vm.$emit('submitForm'); + + await mutate(); + await wrapper.vm.$nextTick(); + + const dispatchedEvent = dispatchEventSpy.mock.calls[0][0]; + + expect(dispatchEventSpy).toHaveBeenCalledTimes(1); + expect(dispatchedEvent.detail).toEqual({ count: DEFAULT_TODO_COUNT }); + expect(dispatchedEvent.type).toBe('todo:toggle'); + }); + describe('when replies are expanded', () => { beforeEach(() => { findRepliesWidget().vm.$emit('toggle'); diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap index 63afc3f000d..637f22457c4 100644 --- a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap +++ b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap @@ -17,13 +17,31 @@ exports[`Design management design version dropdown component renders design vers iconname="" iconrightarialabel="" iconrightname="" + ischeckcentered="true" ischecked="true" ischeckitem="true" secondarytext="" > - Version - 2 - (latest) + <strong> + Version + 2 + (latest) + </strong> + + <div + class="gl-text-gray-600 gl-mt-1" + > + <div> + Adminstrator + </div> + + <time-ago-stub + class="text-1" + cssclass="" + time="2021-08-09T06:05:00Z" + tooltipplacement="bottom" + /> + </div> </gl-dropdown-item-stub> <gl-dropdown-item-stub avatarurl="" @@ -31,12 +49,30 @@ exports[`Design management design version dropdown component renders design vers iconname="" iconrightarialabel="" iconrightname="" + ischeckcentered="true" ischeckitem="true" secondarytext="" > - Version - 1 - + <strong> + Version + 1 + + </strong> + + <div + class="gl-text-gray-600 gl-mt-1" + > + <div> + Adminstrator + </div> + + <time-ago-stub + class="text-1" + cssclass="" + time="2021-08-09T06:05:00Z" + tooltipplacement="bottom" + /> + </div> </gl-dropdown-item-stub> </gl-dropdown-stub> `; @@ -58,13 +94,31 @@ exports[`Design management design version dropdown component renders design vers iconname="" iconrightarialabel="" iconrightname="" + ischeckcentered="true" ischecked="true" ischeckitem="true" secondarytext="" > - Version - 2 - (latest) + <strong> + Version + 2 + (latest) + </strong> + + <div + class="gl-text-gray-600 gl-mt-1" + > + <div> + Adminstrator + </div> + + <time-ago-stub + class="text-1" + cssclass="" + time="2021-08-09T06:05:00Z" + tooltipplacement="bottom" + /> + </div> </gl-dropdown-item-stub> <gl-dropdown-item-stub avatarurl="" @@ -72,12 +126,30 @@ exports[`Design management design version dropdown component renders design vers iconname="" iconrightarialabel="" iconrightname="" + ischeckcentered="true" ischeckitem="true" secondarytext="" > - Version - 1 - + <strong> + Version + 1 + + </strong> + + <div + class="gl-text-gray-600 gl-mt-1" + > + <div> + Adminstrator + </div> + + <time-ago-stub + class="text-1" + cssclass="" + time="2021-08-09T06:05:00Z" + tooltipplacement="bottom" + /> + </div> </gl-dropdown-item-stub> </gl-dropdown-stub> `; diff --git a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js index 1b01a363688..ebfe27eaa71 100644 --- a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js +++ b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js @@ -1,9 +1,10 @@ import { GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import DesignVersionDropdown from '~/design_management/components/upload/design_version_dropdown.vue'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import mockAllVersions from './mock_data/all_versions'; -const LATEST_VERSION_ID = 3; +const LATEST_VERSION_ID = 1; const PREVIOUS_VERSION_ID = 2; const designRouteFactory = (versionId) => ({ @@ -110,5 +111,13 @@ describe('Design management design version dropdown component', () => { expect(wrapper.findAll(GlDropdownItem)).toHaveLength(wrapper.vm.allVersions.length); }); }); + + it('should render TimeAgo', async () => { + createComponent(); + + await wrapper.vm.$nextTick(); + + expect(wrapper.findAllComponents(TimeAgo)).toHaveLength(wrapper.vm.allVersions.length); + }); }); }); diff --git a/spec/frontend/design_management/components/upload/mock_data/all_versions.js b/spec/frontend/design_management/components/upload/mock_data/all_versions.js index 237e1654f9b..24c59ce1a75 100644 --- a/spec/frontend/design_management/components/upload/mock_data/all_versions.js +++ b/spec/frontend/design_management/components/upload/mock_data/all_versions.js @@ -1,10 +1,20 @@ export default [ { - id: 'gid://gitlab/DesignManagement::Version/3', - sha: '0945756378e0b1588b9dd40d5a6b99e8b7198f55', + id: 'gid://gitlab/DesignManagement::Version/1', + sha: 'b389071a06c153509e11da1f582005b316667001', + createdAt: '2021-08-09T06:05:00Z', + author: { + id: 'gid://gitlab/User/1', + name: 'Adminstrator', + }, }, { id: 'gid://gitlab/DesignManagement::Version/2', - sha: '5b063fef0cd7213b312db65b30e24f057df21b20', + sha: 'b389071a06c153509e11da1f582005b316667021', + createdAt: '2021-08-09T06:05:00Z', + author: { + id: 'gid://gitlab/User/1', + name: 'Adminstrator', + }, }, ]; diff --git a/spec/frontend/design_management/mock_data/all_versions.js b/spec/frontend/design_management/mock_data/all_versions.js index 2b216574e27..f4026da7dfd 100644 --- a/spec/frontend/design_management/mock_data/all_versions.js +++ b/spec/frontend/design_management/mock_data/all_versions.js @@ -2,5 +2,19 @@ export default [ { id: 'gid://gitlab/DesignManagement::Version/1', sha: 'b389071a06c153509e11da1f582005b316667001', + createdAt: '2021-08-09T06:05:00Z', + author: { + id: 'gid://gitlab/User/1', + name: 'Adminstrator', + }, + }, + { + id: 'gid://gitlab/DesignManagement::Version/1', + sha: 'b389071a06c153509e11da1f582005b316667021', + createdAt: '2021-08-09T06:05:00Z', + author: { + id: 'gid://gitlab/User/1', + name: 'Adminstrator', + }, }, ]; diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js index e53ad2e6afe..cdd07a16e90 100644 --- a/spec/frontend/design_management/mock_data/apollo_mock.js +++ b/spec/frontend/design_management/mock_data/apollo_mock.js @@ -172,3 +172,40 @@ export const moveDesignMutationResponseWithErrors = { }, }, }; + +export const resolveCommentMutationResponse = { + discussionToggleResolve: { + discussion: { + noteable: { + id: 'gid://gitlab/DesignManagement::Design/1', + currentUserTodos: { + nodes: [], + __typename: 'TodoConnection', + }, + __typename: 'Design', + }, + __typename: 'Discussion', + }, + errors: [], + __typename: 'DiscussionToggleResolvePayload', + }, +}; + +export const getDesignQueryResponse = { + project: { + issue: { + designCollection: { + designs: { + nodes: [ + { + id: 'gid://gitlab/DesignManagement::Design/1', + currentUserTodos: { + nodes: [{ id: 'gid://gitlab/Todo::1' }], + }, + }, + ], + }, + }, + }, + }, +}; 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 03ae77d4977..57023c55878 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 @@ -61,6 +61,7 @@ exports[`Design management design index page renders design index 1`] = ` <participants-stub class="gl-mb-4" + lazy="true" numberoflessparticipants="7" participants="[object Object]" /> @@ -221,6 +222,7 @@ exports[`Design management design index page with error GlAlert is rendered in c <participants-stub class="gl-mb-4" + lazy="true" numberoflessparticipants="7" participants="[object Object]" /> diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index b5eb3e1713c..1464dd84666 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -4,6 +4,7 @@ import MockAdapter from 'axios-mock-adapter'; import Mousetrap from 'mousetrap'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'spec/test_constants'; import App from '~/diffs/components/app.vue'; import CommitWidget from '~/diffs/components/commit_widget.vue'; @@ -16,7 +17,6 @@ import TreeList from '~/diffs/components/tree_list.vue'; /* You know what: sometimes alphabetical isn't the best order */ import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue'; import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue'; -import MergeConflictWarning from '~/diffs/components/merge_conflict_warning.vue'; /* eslint-enable import/order */ import axios from '~/lib/utils/axios_utils'; @@ -258,6 +258,8 @@ describe('diffs/components/app', () => { }); it('marks current diff file based on currently highlighted row', async () => { + window.location.hash = 'ABC_123'; + createComponent({ shouldShow: true, }); @@ -428,12 +430,9 @@ describe('diffs/components/app', () => { jest.spyOn(wrapper.vm, 'refetchDiffData').mockImplementation(() => {}); jest.spyOn(wrapper.vm, 'adjustView').mockImplementation(() => {}); }; - let location; - beforeAll(() => { - location = window.location; - delete window.location; - window.location = COMMIT_URL; + beforeEach(() => { + setWindowLocation(COMMIT_URL); document.title = 'My Title'; }); @@ -441,10 +440,6 @@ describe('diffs/components/app', () => { jest.spyOn(urlUtils, 'updateHistory'); }); - afterAll(() => { - window.location = location; - }); - it('when the commit changes and the app is not loading it should update the history, refetch the diff data, and update the view', async () => { createComponent({}, ({ state }) => { state.diffs.commit = { ...state.diffs.commit, id: 'OLD' }; @@ -546,43 +541,6 @@ describe('diffs/components/app', () => { expect(getCollapsedFilesWarning(wrapper).exists()).toBe(false); }); }); - - describe('merge conflicts', () => { - it('should render the merge conflicts banner if viewing the whole changeset and there are conflicts', () => { - createComponent({}, ({ state }) => { - Object.assign(state.diffs, { - latestDiff: true, - startVersion: null, - hasConflicts: true, - canMerge: false, - conflictResolutionPath: 'path', - }); - }); - - expect(wrapper.find(MergeConflictWarning).exists()).toBe(true); - }); - - it.each` - prop | value - ${'latestDiff'} | ${false} - ${'startVersion'} | ${'notnull'} - ${'hasConflicts'} | ${false} - `( - "should not render if any of the MR properties aren't correct - like $prop: $value", - ({ prop, value }) => { - createComponent({}, ({ state }) => { - Object.assign(state.diffs, { - latestDiff: true, - startVersion: null, - hasConflicts: true, - [prop]: value, - }); - }); - - expect(wrapper.find(MergeConflictWarning).exists()).toBe(false); - }, - ); - }); }); it('should display commit widget if store has a commit', () => { diff --git a/spec/frontend/diffs/components/compare_versions_spec.js b/spec/frontend/diffs/components/compare_versions_spec.js index 80a51ee137a..1c0cb1193fa 100644 --- a/spec/frontend/diffs/components/compare_versions_spec.js +++ b/spec/frontend/diffs/components/compare_versions_spec.js @@ -1,5 +1,6 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import { trimText } from 'helpers/text_helper'; import CompareVersionsComponent from '~/diffs/components/compare_versions.vue'; @@ -13,6 +14,10 @@ localVue.use(Vuex); const NEXT_COMMIT_URL = `${TEST_HOST}/?commit_id=next`; const PREV_COMMIT_URL = `${TEST_HOST}/?commit_id=prev`; +beforeEach(() => { + setWindowLocation(TEST_HOST); +}); + describe('CompareVersions', () => { let wrapper; let store; @@ -215,15 +220,7 @@ describe('CompareVersions', () => { describe('prev commit', () => { beforeAll(() => { - global.jsdom.reconfigure({ - url: `${TEST_HOST}?commit_id=${mrCommit.id}`, - }); - }); - - afterAll(() => { - global.jsdom.reconfigure({ - url: TEST_HOST, - }); + setWindowLocation(`?commit_id=${mrCommit.id}`); }); beforeEach(() => { @@ -258,15 +255,7 @@ describe('CompareVersions', () => { describe('next commit', () => { beforeAll(() => { - global.jsdom.reconfigure({ - url: `${TEST_HOST}?commit_id=${mrCommit.id}`, - }); - }); - - afterAll(() => { - global.jsdom.reconfigure({ - url: TEST_HOST, - }); + setWindowLocation(`?commit_id=${mrCommit.id}`); }); beforeEach(() => { diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js index 99dda8d5deb..3dec56f2fe3 100644 --- a/spec/frontend/diffs/components/diff_file_spec.js +++ b/spec/frontend/diffs/components/diff_file_spec.js @@ -521,4 +521,54 @@ describe('DiffFile', () => { expect(button.attributes('href')).toBe('/file/view/path'); }); }); + + it('loads collapsed file on mounted when single file mode is enabled', async () => { + wrapper.destroy(); + + const file = { + ...getReadableFile(), + load_collapsed_diff_url: '/diff_for_path', + highlighted_diff_lines: [], + parallel_diff_lines: [], + viewer: { name: 'collapsed', automaticallyCollapsed: true }, + }; + + axiosMock.onGet(file.load_collapsed_diff_url).reply(httpStatus.OK, getReadableFile()); + + ({ wrapper, store } = createComponent({ file, props: { viewDiffsFileByFile: true } })); + + await wrapper.vm.$nextTick(); + + expect(findLoader(wrapper).exists()).toBe(true); + }); + + describe('merge conflicts', () => { + beforeEach(() => { + wrapper.destroy(); + }); + + it('does not render conflict alert', () => { + const file = { + ...getReadableFile(), + conflict_type: null, + renderIt: true, + }; + + ({ wrapper, store } = createComponent({ file })); + + expect(wrapper.find('[data-testid="conflictsAlert"]').exists()).toBe(false); + }); + + it('renders conflict alert when conflict_type is present', () => { + const file = { + ...getReadableFile(), + conflict_type: 'both_modified', + renderIt: true, + }; + + ({ wrapper, store } = createComponent({ file })); + + expect(wrapper.find('[data-testid="conflictsAlert"]').exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/diffs/components/settings_dropdown_spec.js b/spec/frontend/diffs/components/settings_dropdown_spec.js index 43b9c5871a6..2dd35519464 100644 --- a/spec/frontend/diffs/components/settings_dropdown_spec.js +++ b/spec/frontend/diffs/components/settings_dropdown_spec.js @@ -48,13 +48,17 @@ describe('Diff settings dropdown component', () => { it('list view button dispatches setRenderTreeList with false', () => { wrapper.find('.js-list-view').trigger('click'); - expect(store.dispatch).toHaveBeenCalledWith('diffs/setRenderTreeList', false); + expect(store.dispatch).toHaveBeenCalledWith('diffs/setRenderTreeList', { + renderTreeList: false, + }); }); it('tree view button dispatches setRenderTreeList with true', () => { wrapper.find('.js-tree-view').trigger('click'); - expect(store.dispatch).toHaveBeenCalledWith('diffs/setRenderTreeList', true); + expect(store.dispatch).toHaveBeenCalledWith('diffs/setRenderTreeList', { + renderTreeList: true, + }); }); it('sets list button as selected when renderTreeList is false', () => { diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index c2e5d07bcfd..6d005b868a9 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -874,6 +874,7 @@ describe('DiffsStoreActions', () => { describe('scrollToFile', () => { let commit; + const getters = { isVirtualScrollingEnabled: false }; beforeEach(() => { commit = jest.fn(); @@ -888,7 +889,7 @@ describe('DiffsStoreActions', () => { }, }; - scrollToFile({ state, commit }, 'path'); + scrollToFile({ state, commit, getters }, 'path'); expect(document.location.hash).toBe('#test'); }); @@ -902,7 +903,7 @@ describe('DiffsStoreActions', () => { }, }; - scrollToFile({ state, commit }, 'path'); + scrollToFile({ state, commit, getters }, 'path'); expect(commit).toHaveBeenCalledWith(types.VIEW_DIFF_FILE, 'test'); }); @@ -1000,7 +1001,7 @@ describe('DiffsStoreActions', () => { it('commits SET_RENDER_TREE_LIST', (done) => { testAction( setRenderTreeList, - true, + { renderTreeList: true }, {}, [{ type: types.SET_RENDER_TREE_LIST, payload: true }], [], @@ -1009,7 +1010,7 @@ describe('DiffsStoreActions', () => { }); it('sets localStorage', () => { - setRenderTreeList({ commit() {} }, true); + setRenderTreeList({ commit() {} }, { renderTreeList: true }); expect(localStorage.setItem).toHaveBeenCalledWith('mr_diff_tree_list', true); }); diff --git a/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js b/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js index 99f13a1c84c..6ea8f691c3c 100644 --- a/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js +++ b/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js @@ -1,3 +1,4 @@ +import setWindowLocation from 'helpers/set_window_location_helper'; import { DIFF_COMPARE_BASE_VERSION_INDEX, DIFF_COMPARE_HEAD_VERSION_INDEX, @@ -47,15 +48,12 @@ describe('Compare diff version dropdowns', () => { let expectedFirstVersion; let expectedBaseVersion; let expectedHeadVersion; - const originalLocation = window.location; + const originalLocation = window.location.href; const setupTest = (includeDiffHeadParam) => { const diffHeadParam = includeDiffHeadParam ? '?diff_head=true' : ''; - Object.defineProperty(window, 'location', { - writable: true, - value: { search: diffHeadParam }, - }); + setWindowLocation(diffHeadParam); expectedFirstVersion = { ...diffsMockData[1], @@ -91,7 +89,7 @@ describe('Compare diff version dropdowns', () => { }; afterEach(() => { - window.location = originalLocation; + setWindowLocation(originalLocation); }); it('base version selected', () => { diff --git a/spec/frontend/diffs/utils/queue_events_spec.js b/spec/frontend/diffs/utils/queue_events_spec.js new file mode 100644 index 00000000000..007748d8b2c --- /dev/null +++ b/spec/frontend/diffs/utils/queue_events_spec.js @@ -0,0 +1,36 @@ +import api from '~/api'; +import { DEFER_DURATION } from '~/diffs/constants'; +import { queueRedisHllEvents } from '~/diffs/utils/queue_events'; + +jest.mock('~/api', () => ({ + trackRedisHllUserEvent: jest.fn(), +})); + +describe('diffs events queue', () => { + describe('queueRedisHllEvents', () => { + it('does not dispatch the event immediately', () => { + queueRedisHllEvents(['know_event']); + expect(api.trackRedisHllUserEvent).not.toHaveBeenCalled(); + }); + + it('does dispatch the event after the defer duration', () => { + queueRedisHllEvents(['know_event']); + jest.advanceTimersByTime(DEFER_DURATION + 1); + expect(api.trackRedisHllUserEvent).toHaveBeenCalled(); + }); + + it('increase defer duration based on the provided events count', () => { + let deferDuration = DEFER_DURATION + 1; + const events = ['know_event_a', 'know_event_b', 'know_event_c']; + queueRedisHllEvents(events); + + expect(api.trackRedisHllUserEvent).not.toHaveBeenCalled(); + + events.forEach((event, index) => { + jest.advanceTimersByTime(deferDuration); + expect(api.trackRedisHllUserEvent).toHaveBeenLastCalledWith(event); + deferDuration *= index + 1; + }); + }); + }); +}); diff --git a/spec/frontend/editor/source_editor_extension_base_spec.js b/spec/frontend/editor/source_editor_extension_base_spec.js index 352db9d0d51..2c06ae03892 100644 --- a/spec/frontend/editor/source_editor_extension_base_spec.js +++ b/spec/frontend/editor/source_editor_extension_base_spec.js @@ -1,5 +1,6 @@ import { Range } from 'monaco-editor'; import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION, EDITOR_TYPE_CODE, @@ -152,12 +153,7 @@ describe('The basis for an Source Editor extension', () => { useFakeRequestAnimationFrame(); beforeEach(() => { - delete window.location; - window.location = new URL(`https://localhost`); - }); - - afterEach(() => { - window.location.hash = ''; + setWindowLocation('https://localhost'); }); it.each` diff --git a/spec/frontend/editor/source_editor_markdown_ext_spec.js b/spec/frontend/editor/source_editor_markdown_ext_spec.js index 943e21250b4..48ccc10e486 100644 --- a/spec/frontend/editor/source_editor_markdown_ext_spec.js +++ b/spec/frontend/editor/source_editor_markdown_ext_spec.js @@ -1,16 +1,36 @@ -import { Range, Position } from 'monaco-editor'; +import MockAdapter from 'axios-mock-adapter'; +import { Range, Position, editor as monacoEditor } from 'monaco-editor'; +import waitForPromises from 'helpers/wait_for_promises'; +import { + EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS, + EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, + EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, + EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS, + EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY, +} from '~/editor/constants'; import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; import SourceEditor from '~/editor/source_editor'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import syntaxHighlight from '~/syntax_highlight'; + +jest.mock('~/syntax_highlight'); +jest.mock('~/flash'); describe('Markdown Extension for Source Editor', () => { let editor; let instance; let editorEl; + let panelSpy; + let mockAxios; + const projectPath = 'fooGroup/barProj'; const firstLine = 'This is a'; const secondLine = 'multiline'; const thirdLine = 'string with some **markup**'; const text = `${firstLine}\n${secondLine}\n${thirdLine}`; - const filePath = 'foo.md'; + const plaintextPath = 'foo.txt'; + const markdownPath = 'foo.md'; + const responseData = '<div>FooBar</div>'; const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => { const selection = new Range(startLineNumber, startColumn, endLineNumber, endColumn); @@ -22,21 +42,378 @@ describe('Markdown Extension for Source Editor', () => { const selectionToString = () => instance.getSelection().toString(); const positionToString = () => instance.getPosition().toString(); + const togglePreview = async () => { + instance.togglePreview(); + await waitForPromises(); + }; + beforeEach(() => { + mockAxios = new MockAdapter(axios); setFixtures('<div id="editor" data-editor-loading></div>'); editorEl = document.getElementById('editor'); editor = new SourceEditor(); instance = editor.createInstance({ el: editorEl, - blobPath: filePath, + blobPath: markdownPath, blobContent: text, }); - editor.use(new EditorMarkdownExtension()); + editor.use(new EditorMarkdownExtension({ instance, projectPath })); + panelSpy = jest.spyOn(EditorMarkdownExtension, 'togglePreviewPanel'); }); afterEach(() => { instance.dispose(); editorEl.remove(); + mockAxios.restore(); + }); + + it('sets up the instance', () => { + expect(instance.preview).toEqual({ + el: undefined, + action: expect.any(Object), + shown: false, + modelChangeListener: undefined, + }); + expect(instance.projectPath).toBe(projectPath); + }); + + describe('model language changes listener', () => { + let cleanupSpy; + let actionSpy; + + beforeEach(async () => { + cleanupSpy = jest.spyOn(instance, 'cleanup'); + actionSpy = jest.spyOn(instance, 'setupPreviewAction'); + await togglePreview(); + }); + + it('cleans up when switching away from markdown', () => { + expect(instance.cleanup).not.toHaveBeenCalled(); + expect(instance.setupPreviewAction).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; + + beforeEach(() => { + cleanupSpy = jest.spyOn(instance, 'cleanup'); + actionSpy = jest.spyOn(instance, 'setupPreviewAction'); + instance.togglePreview(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('does not do anything if there is no model', () => { + instance.setModel(null); + + 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', () => { + beforeEach(async () => { + mockAxios.onPost().reply(200, { body: responseData }); + await togglePreview(); + }); + + it('disposes the modelChange listener and does not fetch preview on content changes', () => { + expect(instance.preview.modelChangeListener).toBeDefined(); + jest.spyOn(instance, 'fetchPreview'); + + instance.cleanup(); + instance.setValue('Foo Bar'); + jest.advanceTimersByTime(EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY); + + expect(instance.fetchPreview).not.toHaveBeenCalled(); + }); + + it('removes the contextual menu action', () => { + expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined(); + + instance.cleanup(); + + expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBe(null); + }); + + it('toggles the `shown` flag', () => { + expect(instance.preview.shown).toBe(true); + instance.cleanup(); + expect(instance.preview.shown).toBe(false); + }); + + it('toggles the panel only if the preview is visible', () => { + const { el: previewEl } = instance.preview; + const parentEl = previewEl.parentElement; + + expect(previewEl).toBeVisible(); + expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(true); + + instance.cleanup(); + expect(previewEl).toBeHidden(); + expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( + false, + ); + + instance.cleanup(); + expect(previewEl).toBeHidden(); + expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( + false, + ); + }); + + it('toggles the layout only if the preview is visible', () => { + const { width } = instance.getLayoutInfo(); + + expect(instance.preview.shown).toBe(true); + + instance.cleanup(); + + const { width: newWidth } = instance.getLayoutInfo(); + expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true); + + instance.cleanup(); + expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true); + }); + }); + + describe('fetchPreview', () => { + const group = 'foo'; + const project = 'bar'; + const setData = (path, g, p) => { + instance.projectPath = path; + document.body.setAttribute('data-group', g); + document.body.setAttribute('data-project', p); + }; + const fetchPreview = async () => { + instance.fetchPreview(); + await waitForPromises(); + }; + + beforeEach(() => { + mockAxios.onPost().reply(200, { body: responseData }); + }); + + it('correctly fetches preview based on projectPath', async () => { + setData(projectPath, group, project); + await fetchPreview(); + expect(mockAxios.history.post[0].url).toBe(`/${projectPath}/preview_markdown`); + expect(mockAxios.history.post[0].data).toEqual(JSON.stringify({ text })); + }); + + it('correctly fetches preview based on group and project data attributes', async () => { + setData(undefined, group, project); + await fetchPreview(); + expect(mockAxios.history.post[0].url).toBe(`/${group}/${project}/preview_markdown`); + expect(mockAxios.history.post[0].data).toEqual(JSON.stringify({ text })); + }); + + it('puts the fetched content into the preview DOM element', async () => { + instance.preview.el = editorEl.parentElement; + await fetchPreview(); + expect(instance.preview.el.innerHTML).toEqual(responseData); + }); + + it('applies syntax highlighting to the preview content', async () => { + instance.preview.el = editorEl.parentElement; + await fetchPreview(); + expect(syntaxHighlight).toHaveBeenCalled(); + }); + + it('catches the errors when fetching the preview', async () => { + mockAxios.onPost().reply(500); + + await fetchPreview(); + expect(createFlash).toHaveBeenCalled(); + }); + }); + + describe('setupPreviewAction', () => { + it('adds the contextual menu action', () => { + expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined(); + }); + + it('does not set up action if one already exists', () => { + jest.spyOn(instance, 'addAction').mockImplementation(); + + instance.setupPreviewAction(); + expect(instance.addAction).not.toHaveBeenCalled(); + }); + + it('toggles preview when the action is triggered', () => { + jest.spyOn(instance, 'togglePreview').mockImplementation(); + + expect(instance.togglePreview).not.toHaveBeenCalled(); + + const action = instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID); + action.run(); + + expect(instance.togglePreview).toHaveBeenCalled(); + }); + }); + + describe('togglePreview', () => { + beforeEach(() => { + mockAxios.onPost().reply(200, { body: responseData }); + }); + + it('toggles preview flag on instance', () => { + expect(instance.preview.shown).toBe(false); + + instance.togglePreview(); + expect(instance.preview.shown).toBe(true); + + instance.togglePreview(); + expect(instance.preview.shown).toBe(false); + }); + + describe('panel DOM element set up', () => { + it('sets up an element to contain the preview and stores it on instance', () => { + expect(instance.preview.el).toBeUndefined(); + + instance.togglePreview(); + + expect(instance.preview.el).toBeDefined(); + expect(instance.preview.el.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS)).toBe( + true, + ); + }); + + it('re-uses existing preview DOM element on repeated calls', () => { + instance.togglePreview(); + const origPreviewEl = instance.preview.el; + instance.togglePreview(); + + expect(instance.preview.el).toBe(origPreviewEl); + }); + + it('hides the preview DOM element by default', () => { + panelSpy.mockImplementation(); + instance.togglePreview(); + expect(instance.preview.el.style.display).toBe('none'); + }); + }); + + describe('preview layout setup', () => { + it('sets correct preview layout', () => { + jest.spyOn(instance, 'layout'); + const { width, height } = instance.getLayoutInfo(); + + instance.togglePreview(); + + expect(instance.layout).toHaveBeenCalledWith({ + width: width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, + height, + }); + }); + }); + + describe('preview panel', () => { + it('toggles preview CSS class on the editor', () => { + expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( + false, + ); + instance.togglePreview(); + expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( + true, + ); + instance.togglePreview(); + expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( + false, + ); + }); + + it('toggles visibility of the preview DOM element', async () => { + await togglePreview(); + expect(instance.preview.el.style.display).toBe('block'); + await togglePreview(); + expect(instance.preview.el.style.display).toBe('none'); + }); + + describe('hidden preview DOM element', () => { + it('listens to model changes and re-fetches preview', async () => { + expect(mockAxios.history.post).toHaveLength(0); + await togglePreview(); + expect(mockAxios.history.post).toHaveLength(1); + + instance.setValue('New Value'); + await waitForPromises(); + expect(mockAxios.history.post).toHaveLength(2); + }); + + it('stores disposable listener for model changes', async () => { + expect(instance.preview.modelChangeListener).toBeUndefined(); + await togglePreview(); + expect(instance.preview.modelChangeListener).toBeDefined(); + }); + }); + + describe('already visible preview', () => { + beforeEach(async () => { + await togglePreview(); + mockAxios.resetHistory(); + }); + + it('does not re-fetch the preview', () => { + instance.togglePreview(); + expect(mockAxios.history.post).toHaveLength(0); + }); + + it('disposes the model change event listener', () => { + const disposeSpy = jest.fn(); + instance.preview.modelChangeListener = { + dispose: disposeSpy, + }; + instance.togglePreview(); + expect(disposeSpy).toHaveBeenCalled(); + }); + }); + }); }); describe('getSelectedText', () => { diff --git a/spec/frontend/editor/utils_spec.js b/spec/frontend/editor/utils_spec.js new file mode 100644 index 00000000000..97d3e9e081d --- /dev/null +++ b/spec/frontend/editor/utils_spec.js @@ -0,0 +1,85 @@ +import { editor as monacoEditor } from 'monaco-editor'; +import * as utils from '~/editor/utils'; +import { DEFAULT_THEME } from '~/ide/lib/themes'; + +describe('Source Editor utils', () => { + let el; + + const stubUserColorScheme = (value) => { + if (window.gon == null) { + window.gon = {}; + } + window.gon.user_color_scheme = value; + }; + + describe('clearDomElement', () => { + beforeEach(() => { + setFixtures('<div id="foo"><div id="bar">Foo</div></div>'); + el = document.getElementById('foo'); + }); + + it('removes all child nodes from an element', () => { + expect(el.children.length).toBe(1); + utils.clearDomElement(el); + expect(el.children.length).toBe(0); + }); + }); + + describe('setupEditorTheme', () => { + beforeEach(() => { + jest.spyOn(monacoEditor, 'defineTheme').mockImplementation(); + jest.spyOn(monacoEditor, 'setTheme').mockImplementation(); + }); + + it.each` + themeName | expectedThemeName + ${'solarized-light'} | ${'solarized-light'} + ${DEFAULT_THEME} | ${DEFAULT_THEME} + ${'non-existent'} | ${DEFAULT_THEME} + `( + 'sets the $expectedThemeName theme when $themeName is set in the user preference', + ({ themeName, expectedThemeName }) => { + stubUserColorScheme(themeName); + utils.setupEditorTheme(); + + expect(monacoEditor.setTheme).toHaveBeenCalledWith(expectedThemeName); + }, + ); + }); + + describe('getBlobLanguage', () => { + it.each` + path | expectedLanguage + ${'foo.js'} | ${'javascript'} + ${'foo.js.rb'} | ${'ruby'} + ${'foo.bar'} | ${'plaintext'} + ${undefined} | ${'plaintext'} + `( + 'sets the $expectedThemeName theme when $themeName is set in the user preference', + ({ path, expectedLanguage }) => { + const language = utils.getBlobLanguage(path); + + expect(language).toEqual(expectedLanguage); + }, + ); + }); + + describe('setupCodeSnipet', () => { + beforeEach(() => { + jest.spyOn(monacoEditor, 'colorizeElement').mockImplementation(); + jest.spyOn(monacoEditor, 'setTheme').mockImplementation(); + setFixtures('<pre id="foo"></pre>'); + el = document.getElementById('foo'); + }); + + it('colorizes the element and applies the preference theme', () => { + expect(monacoEditor.colorizeElement).not.toHaveBeenCalled(); + expect(monacoEditor.setTheme).not.toHaveBeenCalled(); + + utils.setupCodeSnippet(el); + + expect(monacoEditor.colorizeElement).toHaveBeenCalledWith(el); + expect(monacoEditor.setTheme).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js index 29aa416149c..cf47a1cd7bb 100644 --- a/spec/frontend/environment.js +++ b/spec/frontend/environment.js @@ -88,13 +88,32 @@ class CustomEnvironment extends JSDOMEnvironment { }), }); - this.global.PerformanceObserver = class { + /** + * JSDom doesn't have an own observer implementation, so this a Noop Observer. + * If you are testing functionality, related to observers, have a look at __helpers__/mock_dom_observer.js + * + * JSDom actually implements a _proper_ MutationObserver, so no need to mock it! + */ + class NoopObserver { /* eslint-disable no-useless-constructor, no-unused-vars, no-empty-function, class-methods-use-this */ constructor(callback) {} disconnect() {} observe(element, initObject) {} + unobserve(element) {} + takeRecords() { + return []; + } /* eslint-enable no-useless-constructor, no-unused-vars, no-empty-function, class-methods-use-this */ - }; + } + + ['IntersectionObserver', 'PerformanceObserver', 'ResizeObserver'].forEach((observer) => { + if (this.global[observer]) { + throw new Error( + `We overwrite an existing Observer in jsdom (${observer}), are you sure you want to do that?`, + ); + } + this.global[observer] = NoopObserver; + }); } async teardown() { diff --git a/spec/frontend/environments/confirm_rollback_modal_spec.js b/spec/frontend/environments/confirm_rollback_modal_spec.js index 8fb53579f96..d62aaec4f69 100644 --- a/spec/frontend/environments/confirm_rollback_modal_spec.js +++ b/spec/frontend/environments/confirm_rollback_modal_spec.js @@ -1,70 +1,104 @@ -import { GlModal } from '@gitlab/ui'; +import { GlModal, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue'; import eventHub from '~/environments/event_hub'; describe('Confirm Rollback Modal Component', () => { let environment; + let component; - beforeEach(() => { - environment = { - name: 'test', - last_deployment: { - commit: { - short_id: 'abc0123', - }, + const envWithLastDeployment = { + name: 'test', + last_deployment: { + commit: { + short_id: 'abc0123', }, - modalId: 'test', - }; - }); + }, + modalId: 'test', + }; - it('should show "Rollback" when isLastDeployment is false', () => { - const component = shallowMount(ConfirmRollbackModal, { - propsData: { - environment: { - ...environment, - isLastDeployment: false, - }, - }, - }); - const modal = component.find(GlModal); + const envWithoutLastDeployment = { + name: 'test', + modalId: 'test', + commitShortSha: 'abc0123', + commitUrl: 'test/-/commit/abc0123', + }; - expect(modal.attributes('title')).toContain('Rollback'); - expect(modal.attributes('title')).toContain('test'); - expect(modal.attributes('ok-title')).toBe('Rollback'); - expect(modal.text()).toContain('commit abc0123'); - expect(modal.text()).toContain('Are you sure you want to continue?'); - }); + const retryPath = 'test/-/jobs/123/retry'; - it('should show "Re-deploy" when isLastDeployment is true', () => { - const component = shallowMount(ConfirmRollbackModal, { + const createComponent = (props = {}) => { + component = shallowMount(ConfirmRollbackModal, { propsData: { - environment: { - ...environment, - isLastDeployment: true, - }, + ...props, + }, + stubs: { + GlSprintf, }, }); - const modal = component.find(GlModal); + }; - expect(modal.attributes('title')).toContain('Re-deploy'); - expect(modal.attributes('title')).toContain('test'); - expect(modal.attributes('ok-title')).toBe('Re-deploy'); - expect(modal.text()).toContain('commit abc0123'); - expect(modal.text()).toContain('Are you sure you want to continue?'); - }); + describe.each` + hasMultipleCommits | environmentData | retryUrl | primaryPropsAttrs + ${true} | ${envWithLastDeployment} | ${null} | ${[{ variant: 'danger' }]} + ${false} | ${envWithoutLastDeployment} | ${retryPath} | ${[{ variant: 'danger' }, { 'data-method': 'post' }, { href: retryPath }]} + `( + 'when hasMultipleCommits=$hasMultipleCommits', + ({ hasMultipleCommits, environmentData, retryUrl, primaryPropsAttrs }) => { + beforeEach(() => { + environment = environmentData; + }); - it('should emit the "rollback" event when "ok" is clicked', () => { - environment = { ...environment, isLastDeployment: true }; - const component = shallowMount(ConfirmRollbackModal, { - propsData: { - environment, - }, - }); - const eventHubSpy = jest.spyOn(eventHub, '$emit'); - const modal = component.find(GlModal); - modal.vm.$emit('ok'); + it('should show "Rollback" when isLastDeployment is false', () => { + createComponent({ + environment: { + ...environment, + isLastDeployment: false, + }, + hasMultipleCommits, + retryUrl, + }); + const modal = component.find(GlModal); + + expect(modal.attributes('title')).toContain('Rollback'); + expect(modal.attributes('title')).toContain('test'); + expect(modal.props('actionPrimary').text).toBe('Rollback'); + expect(modal.props('actionPrimary').attributes).toEqual(primaryPropsAttrs); + expect(modal.text()).toContain('commit abc0123'); + expect(modal.text()).toContain('Are you sure you want to continue?'); + }); + + it('should show "Re-deploy" when isLastDeployment is true', () => { + createComponent({ + environment: { + ...environment, + isLastDeployment: true, + }, + hasMultipleCommits, + }); + + const modal = component.find(GlModal); + + expect(modal.attributes('title')).toContain('Re-deploy'); + expect(modal.attributes('title')).toContain('test'); + expect(modal.props('actionPrimary').text).toBe('Re-deploy'); + expect(modal.text()).toContain('commit abc0123'); + expect(modal.text()).toContain('Are you sure you want to continue?'); + }); + + it('should emit the "rollback" event when "ok" is clicked', () => { + const env = { ...environmentData, isLastDeployment: true }; + + createComponent({ + environment: env, + hasMultipleCommits, + }); + + const eventHubSpy = jest.spyOn(eventHub, '$emit'); + const modal = component.find(GlModal); + modal.vm.$emit('ok'); - expect(eventHubSpy).toHaveBeenCalledWith('rollbackEnvironment', environment); - }); + expect(eventHubSpy).toHaveBeenCalledWith('rollbackEnvironment', env); + }); + }, + ); }); diff --git a/spec/frontend/environments/edit_environment_spec.js b/spec/frontend/environments/edit_environment_spec.js new file mode 100644 index 00000000000..3e7f5dd5ff4 --- /dev/null +++ b/spec/frontend/environments/edit_environment_spec.js @@ -0,0 +1,104 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import EditEnvironment from '~/environments/components/edit_environment.vue'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { visitUrl } from '~/lib/utils/url_utility'; + +jest.mock('~/lib/utils/url_utility'); +jest.mock('~/flash'); + +const DEFAULT_OPTS = { + provide: { + projectEnvironmentsPath: '/projects/environments', + updateEnvironmentPath: '/proejcts/environments/1', + }, + propsData: { environment: { name: 'foo', externalUrl: 'https://foo.example.com' } }, +}; + +describe('~/environments/components/edit.vue', () => { + let wrapper; + let mock; + let name; + let url; + let form; + + const createWrapper = (opts = {}) => + mountExtended(EditEnvironment, { + ...DEFAULT_OPTS, + ...opts, + }); + + beforeEach(() => { + mock = new MockAdapter(axios); + wrapper = createWrapper(); + name = wrapper.findByLabelText('Name'); + url = wrapper.findByLabelText('External URL'); + form = wrapper.findByRole('form', { name: 'Edit environment' }); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + const showsLoading = () => wrapper.find(GlLoadingIcon).exists(); + + const submitForm = async (expected, response) => { + mock + .onPut(DEFAULT_OPTS.provide.updateEnvironmentPath, { + name: expected.name, + external_url: expected.url, + }) + .reply(...response); + await name.setValue(expected.name); + await url.setValue(expected.url); + + await form.trigger('submit'); + await waitForPromises(); + }; + + it('sets the title to Edit environment', () => { + const header = wrapper.findByRole('heading', { name: 'Edit environment' }); + expect(header.exists()).toBe(true); + }); + + it.each` + input | value + ${() => name} | ${'test'} + ${() => url} | ${'https://example.org'} + `('it changes the value of the input to $value', async ({ input, value }) => { + await input().setValue(value); + + expect(input().element.value).toBe(value); + }); + + it('shows loader after form is submitted', async () => { + const expected = { name: 'test', url: 'https://google.ca' }; + + expect(showsLoading()).toBe(false); + + await submitForm(expected, [200, { path: '/test' }]); + + expect(showsLoading()).toBe(true); + }); + + it('submits the updated environment on submit', async () => { + const expected = { name: 'test', url: 'https://google.ca' }; + + await submitForm(expected, [200, { path: '/test' }]); + + expect(visitUrl).toHaveBeenCalledWith('/test'); + }); + + it('shows errors on error', async () => { + const expected = { name: 'test', url: 'https://google.ca' }; + + await submitForm(expected, [400, { message: ['name taken'] }]); + + expect(createFlash).toHaveBeenCalledWith({ message: 'name taken' }); + expect(showsLoading()).toBe(false); + }); +}); diff --git a/spec/frontend/environments/environment_form_spec.js b/spec/frontend/environments/environment_form_spec.js new file mode 100644 index 00000000000..ed8fda71dab --- /dev/null +++ b/spec/frontend/environments/environment_form_spec.js @@ -0,0 +1,105 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import EnvironmentForm from '~/environments/components/environment_form.vue'; + +jest.mock('~/lib/utils/csrf'); + +const DEFAULT_PROPS = { + environment: { name: '', externalUrl: '' }, + title: 'environment', + cancelPath: '/cancel', +}; + +describe('~/environments/components/form.vue', () => { + let wrapper; + + const createWrapper = (propsData = {}) => + mountExtended(EnvironmentForm, { + propsData: { + ...DEFAULT_PROPS, + ...propsData, + }, + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('links to documentation regarding environments', () => { + const link = wrapper.findByRole('link', { name: 'More information' }); + expect(link.attributes('href')).toBe('/help/ci/environments/index.md'); + }); + + it('links the cancel button to the cancel path', () => { + const cancel = wrapper.findByRole('link', { name: 'Cancel' }); + + expect(cancel.attributes('href')).toBe(DEFAULT_PROPS.cancelPath); + }); + + describe('name input', () => { + let name; + + beforeEach(() => { + name = wrapper.findByLabelText('Name'); + }); + + it('should emit changes to the name', async () => { + await name.setValue('test'); + await name.trigger('blur'); + + expect(wrapper.emitted('change')).toEqual([[{ name: 'test', externalUrl: '' }]]); + }); + + it('should validate that the name is required', async () => { + await name.setValue(''); + await name.trigger('blur'); + + expect(wrapper.findByText('This field is required').exists()).toBe(true); + expect(name.attributes('aria-invalid')).toBe('true'); + }); + }); + + describe('url input', () => { + let url; + + beforeEach(() => { + url = wrapper.findByLabelText('External URL'); + }); + + it('should emit changes to the url', async () => { + await url.setValue('https://example.com'); + await url.trigger('blur'); + + expect(wrapper.emitted('change')).toEqual([ + [{ name: '', externalUrl: 'https://example.com' }], + ]); + }); + + it('should validate that the url is required', async () => { + await url.setValue('example.com'); + await url.trigger('blur'); + + expect(wrapper.findByText('The URL should start with http:// or https://').exists()).toBe( + true, + ); + expect(url.attributes('aria-invalid')).toBe('true'); + }); + }); + + it('submits when the form does', async () => { + await wrapper.findByRole('form', { title: 'environment' }).trigger('submit'); + + expect(wrapper.emitted('submit')).toEqual([[]]); + }); + }); + + it('shows a loading icon while loading', () => { + wrapper = createWrapper({ loading: true }); + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js index 62806c9e44c..a568a7d5396 100644 --- a/spec/frontend/environments/environment_item_spec.js +++ b/spec/frontend/environments/environment_item_spec.js @@ -1,14 +1,21 @@ import { mount } from '@vue/test-utils'; import { cloneDeep } from 'lodash'; import { format } from 'timeago.js'; +import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper'; +import ActionsComponent from '~/environments/components/environment_actions.vue'; import DeleteComponent from '~/environments/components/environment_delete.vue'; +import ExternalUrlComponent from '~/environments/components/environment_external_url.vue'; import EnvironmentItem from '~/environments/components/environment_item.vue'; import PinComponent from '~/environments/components/environment_pin.vue'; +import RollbackComponent from '~/environments/components/environment_rollback.vue'; +import StopComponent from '~/environments/components/environment_stop.vue'; +import TerminalButtonComponent from '~/environments/components/environment_terminal_button.vue'; import { differenceInMilliseconds } from '~/lib/utils/datetime_utility'; import { environment, folder, tableData } from './mock_data'; describe('Environment item', () => { let wrapper; + let tracking; const factory = (options = {}) => { // This destroys any wrappers created before a nested call to factory reassigns it @@ -28,6 +35,12 @@ describe('Environment item', () => { tableData, }, }); + + tracking = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); }); const findAutoStop = () => wrapper.find('.js-auto-stop'); @@ -62,7 +75,7 @@ describe('Environment item', () => { }); it('should not render the delete button', () => { - expect(wrapper.find(DeleteComponent).exists()).toBe(false); + expect(wrapper.findComponent(DeleteComponent).exists()).toBe(false); }); describe('With user information', () => { @@ -176,12 +189,14 @@ describe('Environment item', () => { }); it('should not render the auto-stop button', () => { - expect(wrapper.find(PinComponent).exists()).toBe(false); + expect(wrapper.findComponent(PinComponent).exists()).toBe(false); }); }); describe('With auto-stop date', () => { describe('in the future', () => { + let pin; + const futureDate = new Date(Date.now() + 100000); beforeEach(() => { factory({ @@ -195,6 +210,9 @@ describe('Environment item', () => { shouldShowAutoStopDate: true, }, }); + tracking = mockTracking(undefined, wrapper.element, jest.spyOn); + + pin = wrapper.findComponent(PinComponent); }); it('renders the date', () => { @@ -202,7 +220,15 @@ describe('Environment item', () => { }); it('should render the auto-stop button', () => { - expect(wrapper.find(PinComponent).exists()).toBe(true); + expect(pin.exists()).toBe(true); + }); + + it('should tracks clicks', () => { + pin.trigger('click'); + + expect(tracking).toHaveBeenCalledWith('_category_', 'click_button', { + label: 'environment_pin', + }); }); }); @@ -227,33 +253,104 @@ describe('Environment item', () => { }); it('should not render the suto-stop button', () => { - expect(wrapper.find(PinComponent).exists()).toBe(false); + expect(wrapper.findComponent(PinComponent).exists()).toBe(false); }); }); }); }); describe('With manual actions', () => { + let actions; + + beforeEach(() => { + actions = wrapper.findComponent(ActionsComponent); + }); + it('should render actions component', () => { - expect(wrapper.find('.js-manual-actions-container')).toBeDefined(); + expect(actions.exists()).toBe(true); + }); + + it('should track clicks', () => { + actions.trigger('click'); + expect(tracking).toHaveBeenCalledWith('_category_', 'click_dropdown', { + label: 'environment_actions', + }); }); }); describe('With external URL', () => { + let externalUrl; + + beforeEach(() => { + externalUrl = wrapper.findComponent(ExternalUrlComponent); + }); + it('should render external url component', () => { - expect(wrapper.find('.js-external-url-container')).toBeDefined(); + expect(externalUrl.exists()).toBe(true); + }); + + it('should track clicks', () => { + externalUrl.trigger('click'); + expect(tracking).toHaveBeenCalledWith('_category_', 'click_button', { + label: 'environment_url', + }); }); }); describe('With stop action', () => { + let stop; + + beforeEach(() => { + stop = wrapper.findComponent(StopComponent); + }); + it('should render stop action component', () => { - expect(wrapper.find('.js-stop-component-container')).toBeDefined(); + expect(stop.exists()).toBe(true); + }); + + it('should track clicks', () => { + stop.trigger('click'); + expect(tracking).toHaveBeenCalledWith('_category_', 'click_button', { + label: 'environment_stop', + }); }); }); describe('With retry action', () => { + let rollback; + + beforeEach(() => { + rollback = wrapper.findComponent(RollbackComponent); + }); + it('should render rollback component', () => { - expect(wrapper.find('.js-rollback-component-container')).toBeDefined(); + expect(rollback.exists()).toBe(true); + }); + + it('should track clicks', () => { + rollback.trigger('click'); + expect(tracking).toHaveBeenCalledWith('_category_', 'click_button', { + label: 'environment_rollback', + }); + }); + }); + + describe('With terminal path', () => { + let terminal; + + beforeEach(() => { + terminal = wrapper.findComponent(TerminalButtonComponent); + }); + + it('should render terminal action component', () => { + expect(terminal.exists()).toBe(true); + }); + + it('should track clicks', () => { + triggerEvent(terminal.element); + expect(tracking).toHaveBeenCalledWith('_category_', 'click_button', { + label: 'environment_terminal', + }); }); }); }); @@ -312,7 +409,17 @@ describe('Environment item', () => { }); it('should render the delete button', () => { - expect(wrapper.find(DeleteComponent).exists()).toBe(true); + expect(wrapper.findComponent(DeleteComponent).exists()).toBe(true); + }); + + it('should trigger a tracking event', async () => { + tracking = mockTracking(undefined, wrapper.element, jest.spyOn); + + await wrapper.findComponent(DeleteComponent).trigger('click'); + + expect(tracking).toHaveBeenCalledWith('_category_', 'click_button', { + label: 'environment_delete', + }); }); }); }); diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js index 1abdeff614c..dc176001943 100644 --- a/spec/frontend/environments/environments_app_spec.js +++ b/spec/frontend/environments/environments_app_spec.js @@ -1,4 +1,4 @@ -import { GlTabs } from '@gitlab/ui'; +import { GlTabs, GlAlert } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -7,7 +7,9 @@ import DeployBoard from '~/environments/components/deploy_board.vue'; import EmptyState from '~/environments/components/empty_state.vue'; import EnableReviewAppModal from '~/environments/components/enable_review_app_modal.vue'; import EnvironmentsApp from '~/environments/components/environments_app.vue'; +import { ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME } from '~/environments/constants'; import axios from '~/lib/utils/axios_utils'; +import { setCookie, getCookie, removeCookie } from '~/lib/utils/common_utils'; import * as urlUtils from '~/lib/utils/url_utility'; import { environment, folder } from './mock_data'; @@ -48,6 +50,7 @@ describe('Environment', () => { const findNewEnvironmentButton = () => wrapper.findByTestId('new-environment'); const findEnvironmentsTabAvailable = () => wrapper.find('.js-environments-tab-available > a'); const findEnvironmentsTabStopped = () => wrapper.find('.js-environments-tab-stopped > a'); + const findSurveyAlert = () => wrapper.find(GlAlert); beforeEach(() => { mock = new MockAdapter(axios); @@ -280,4 +283,49 @@ describe('Environment', () => { expect(wrapper.findComponent(GlTabs).attributes('value')).toBe('1'); }); }); + + describe('survey alert', () => { + beforeEach(async () => { + mockRequest(200, { environments: [] }); + await createWrapper(true); + }); + + afterEach(() => { + removeCookie(ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME); + }); + + describe('when the user has not dismissed the alert', () => { + it('shows the alert', () => { + expect(findSurveyAlert().exists()).toBe(true); + }); + + describe('when the user dismisses the alert', () => { + beforeEach(() => { + findSurveyAlert().vm.$emit('dismiss'); + }); + + it('hides the alert', () => { + expect(findSurveyAlert().exists()).toBe(false); + }); + + it('persists the dismisal using a cookie', () => { + const cookieValue = getCookie(ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME); + + expect(cookieValue).toBe('true'); + }); + }); + }); + + describe('when the user has previously dismissed the alert', () => { + beforeEach(async () => { + setCookie(ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME, 'true'); + + await createWrapper(true); + }); + + it('does not show the alert', () => { + expect(findSurveyAlert().exists()).toBe(false); + }); + }); + }); }); diff --git a/spec/frontend/environments/environments_detail_header_spec.js b/spec/frontend/environments/environments_detail_header_spec.js new file mode 100644 index 00000000000..6334060c736 --- /dev/null +++ b/spec/frontend/environments/environments_detail_header_spec.js @@ -0,0 +1,238 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import DeleteEnvironmentModal from '~/environments/components/delete_environment_modal.vue'; +import EnvironmentsDetailHeader from '~/environments/components/environments_detail_header.vue'; +import StopEnvironmentModal from '~/environments/components/stop_environment_modal.vue'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { createEnvironment } from './mock_data'; + +describe('Environments detail header component', () => { + const cancelAutoStopPath = '/my-environment/cancel/path'; + const terminalPath = '/my-environment/terminal/path'; + const metricsPath = '/my-environment/metrics/path'; + const updatePath = '/my-environment/edit/path'; + + let wrapper; + + const findHeader = () => wrapper.findByRole('heading'); + const findAutoStopsAt = () => wrapper.findByTestId('auto-stops-at'); + const findCancelAutoStopAtButton = () => wrapper.findByTestId('cancel-auto-stop-button'); + const findCancelAutoStopAtForm = () => wrapper.findByTestId('cancel-auto-stop-form'); + const findTerminalButton = () => wrapper.findByTestId('terminal-button'); + const findExternalUrlButton = () => wrapper.findByTestId('external-url-button'); + const findMetricsButton = () => wrapper.findByTestId('metrics-button'); + const findEditButton = () => wrapper.findByTestId('edit-button'); + const findStopButton = () => wrapper.findByTestId('stop-button'); + const findDestroyButton = () => wrapper.findByTestId('destroy-button'); + const findStopEnvironmentModal = () => wrapper.findComponent(StopEnvironmentModal); + const findDeleteEnvironmentModal = () => wrapper.findComponent(DeleteEnvironmentModal); + + const buttons = [ + ['Cancel Auto Stop At', findCancelAutoStopAtButton], + ['Terminal', findTerminalButton], + ['External Url', findExternalUrlButton], + ['Metrics', findMetricsButton], + ['Edit', findEditButton], + ['Stop', findStopButton], + ['Destroy', findDestroyButton], + ]; + + const createWrapper = ({ props }) => { + wrapper = shallowMountExtended(EnvironmentsDetailHeader, { + stubs: { + GlSprintf, + TimeAgo, + }, + propsData: { + canReadEnvironment: false, + canAdminEnvironment: false, + canUpdateEnvironment: false, + canStopEnvironment: false, + canDestroyEnvironment: false, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default state with minimal access', () => { + beforeEach(() => { + createWrapper({ props: { environment: createEnvironment() } }); + }); + + it('displays the environment name', () => { + expect(findHeader().text()).toBe('My environment'); + }); + + it('does not display an auto stops at text', () => { + expect(findAutoStopsAt().exists()).toBe(false); + }); + + it.each(buttons)('does not display button: %s', (_, findSelector) => { + expect(findSelector().exists()).toBe(false); + }); + + it('does not display stop environment modal', () => { + expect(findStopEnvironmentModal().exists()).toBe(false); + }); + + it('does not display delete environment modal', () => { + expect(findDeleteEnvironmentModal().exists()).toBe(false); + }); + }); + + describe('when auto stops at is enabled and environment is available', () => { + beforeEach(() => { + const now = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(now.getDate() + 1); + createWrapper({ + props: { + environment: createEnvironment({ autoStopAt: tomorrow.toISOString() }), + cancelAutoStopPath, + }, + }); + }); + + it('displays a text that describes when the environment is going to be stopped', () => { + expect(findAutoStopsAt().text()).toBe('Auto stops in 1 day'); + }); + + it('displays a cancel auto stops at button with a form to make a post request', () => { + const button = findCancelAutoStopAtButton(); + const form = findCancelAutoStopAtForm(); + expect(form.attributes('action')).toBe(cancelAutoStopPath); + expect(form.attributes('method')).toBe('POST'); + expect(button.props('icon')).toBe('thumbtack'); + expect(button.attributes('type')).toBe('submit'); + }); + + it('includes a csrf token', () => { + const input = findCancelAutoStopAtForm().find('input'); + expect(input.attributes('name')).toBe('authenticity_token'); + }); + }); + + describe('when auto stops at is enabled and environment is unavailable (already stopped)', () => { + beforeEach(() => { + const now = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(now.getDate() + 1); + createWrapper({ + props: { + environment: createEnvironment({ + autoStopAt: tomorrow.toISOString(), + isAvailable: false, + }), + cancelAutoStopPath, + }, + }); + }); + + it('does not display a text that describes when the environment is going to be stopped', () => { + expect(findAutoStopsAt().exists()).toBe(false); + }); + + it('displays a cancel auto stops at button with correct path', () => { + expect(findCancelAutoStopAtButton().exists()).toBe(false); + }); + }); + + describe('when has a terminal', () => { + beforeEach(() => { + createWrapper({ + props: { + environment: createEnvironment({ hasTerminals: true }), + canAdminEnvironment: true, + terminalPath, + }, + }); + }); + + it('displays the terminal button with correct path', () => { + expect(findTerminalButton().attributes('href')).toBe(terminalPath); + }); + }); + + describe('when has an external url enabled', () => { + const externalUrl = 'https://example.com/my-environment/external/url'; + + beforeEach(() => { + createWrapper({ + props: { + environment: createEnvironment({ hasTerminals: true, externalUrl }), + canReadEnvironment: true, + }, + }); + }); + + it('displays the external url button with correct path', () => { + expect(findExternalUrlButton().attributes('href')).toBe(externalUrl); + }); + }); + + describe('when metrics are enabled', () => { + beforeEach(() => { + createWrapper({ + props: { + environment: createEnvironment(), + canReadEnvironment: true, + metricsPath, + }, + }); + }); + + it('displays the metrics button with correct path', () => { + expect(findMetricsButton().attributes('href')).toBe(metricsPath); + }); + }); + + describe('when has all admin rights', () => { + beforeEach(() => { + createWrapper({ + props: { + environment: createEnvironment(), + canReadEnvironment: true, + canAdminEnvironment: true, + canStopEnvironment: true, + canUpdateEnvironment: true, + updatePath, + }, + }); + }); + + it('displays the edit button with correct path', () => { + expect(findEditButton().attributes('href')).toBe(updatePath); + }); + + it('displays the stop button with correct icon', () => { + expect(findStopButton().attributes('icon')).toBe('stop'); + }); + + it('displays stop environment modal', () => { + expect(findStopEnvironmentModal().exists()).toBe(true); + }); + }); + + describe('when the environment is unavailable and user has destroy permissions', () => { + beforeEach(() => { + createWrapper({ + props: { + environment: createEnvironment({ isAvailable: false }), + canDestroyEnvironment: true, + }, + }); + }); + + it('displays a delete button', () => { + expect(findDestroyButton().exists()).toBe(true); + }); + + it('displays delete environment modal', () => { + expect(findDeleteEnvironmentModal().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/environments/mock_data.js b/spec/frontend/environments/mock_data.js index 9ba71b78c2f..a6d67c26304 100644 --- a/spec/frontend/environments/mock_data.js +++ b/spec/frontend/environments/mock_data.js @@ -71,6 +71,8 @@ const environment = { state: 'stopped', external_url: 'http://external.com', environment_type: null, + can_stop: true, + terminal_path: '/terminal', last_deployment: { id: 66, iid: 6, @@ -301,4 +303,22 @@ const tableData = { }, }; -export { environment, environmentsList, folder, serverData, tableData, deployBoardMockData }; +const createEnvironment = (data = {}) => ({ + id: 1, + name: 'My environment', + externalUrl: 'my external url', + isAvailable: true, + hasTerminals: false, + autoStopAt: null, + ...data, +}); + +export { + environment, + environmentsList, + folder, + serverData, + tableData, + deployBoardMockData, + createEnvironment, +}; diff --git a/spec/frontend/environments/new_environment_spec.js b/spec/frontend/environments/new_environment_spec.js new file mode 100644 index 00000000000..f6d970e02d8 --- /dev/null +++ b/spec/frontend/environments/new_environment_spec.js @@ -0,0 +1,100 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import NewEnvironment from '~/environments/components/new_environment.vue'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { visitUrl } from '~/lib/utils/url_utility'; + +jest.mock('~/lib/utils/url_utility'); +jest.mock('~/flash'); + +const DEFAULT_OPTS = { + provide: { projectEnvironmentsPath: '/projects/environments' }, +}; + +describe('~/environments/components/new.vue', () => { + let wrapper; + let mock; + let name; + let url; + let form; + + const createWrapper = (opts = {}) => + mountExtended(NewEnvironment, { + ...DEFAULT_OPTS, + ...opts, + }); + + beforeEach(() => { + mock = new MockAdapter(axios); + wrapper = createWrapper(); + name = wrapper.findByLabelText('Name'); + url = wrapper.findByLabelText('External URL'); + form = wrapper.findByRole('form', { name: 'New environment' }); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + const showsLoading = () => wrapper.find(GlLoadingIcon).exists(); + + const submitForm = async (expected, response) => { + mock + .onPost(DEFAULT_OPTS.provide.projectEnvironmentsPath, { + name: expected.name, + external_url: expected.url, + }) + .reply(...response); + await name.setValue(expected.name); + await url.setValue(expected.url); + + await form.trigger('submit'); + await waitForPromises(); + }; + + it('sets the title to New environment', () => { + const header = wrapper.findByRole('heading', { name: 'New environment' }); + expect(header.exists()).toBe(true); + }); + + it.each` + input | value + ${() => name} | ${'test'} + ${() => url} | ${'https://example.org'} + `('it changes the value of the input to $value', async ({ input, value }) => { + await input().setValue(value); + + expect(input().element.value).toBe(value); + }); + + it('shows loader after form is submitted', async () => { + const expected = { name: 'test', url: 'https://google.ca' }; + + expect(showsLoading()).toBe(false); + + await submitForm(expected, [200, { path: '/test' }]); + + expect(showsLoading()).toBe(true); + }); + + it('submits the new environment on submit', async () => { + const expected = { name: 'test', url: 'https://google.ca' }; + + await submitForm(expected, [200, { path: '/test' }]); + + expect(visitUrl).toHaveBeenCalledWith('/test'); + }); + + it('shows errors on error', async () => { + const expected = { name: 'test', url: 'https://google.ca' }; + + await submitForm(expected, [400, { message: ['name taken'] }]); + + expect(createFlash).toHaveBeenCalledWith({ message: 'name taken' }); + expect(showsLoading()).toBe(false); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js b/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js index 02216370b79..07aa456e69e 100644 --- a/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js +++ b/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js @@ -66,15 +66,14 @@ describe('feature_flags/components/strategies/flexible_rollout.vue', () => { }); it('emits a change when the stickiness value changes', async () => { - stickinessSelect.setValue('USERID'); - await wrapper.vm.$nextTick(); + await stickinessSelect.setValue('userId'); expect(wrapper.emitted('change')).toEqual([ [ { parameters: { rollout: flexibleRolloutStrategy.parameters.rollout, groupId: PERCENT_ROLLOUT_GROUP_ID, - stickiness: 'USERID', + stickiness: 'userId', }, }, ], diff --git a/spec/frontend/feature_flags/mock_data.js b/spec/frontend/feature_flags/mock_data.js index b5f09ac1957..4c40c2acf01 100644 --- a/spec/frontend/feature_flags/mock_data.js +++ b/spec/frontend/feature_flags/mock_data.js @@ -76,7 +76,7 @@ export const percentRolloutStrategy = { export const flexibleRolloutStrategy = { name: ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, - parameters: { rollout: '50', groupId: 'default', stickiness: 'DEFAULT' }, + parameters: { rollout: '50', groupId: 'default', stickiness: 'default' }, scopes: [], }; diff --git a/spec/frontend/fixtures/analytics.rb b/spec/frontend/fixtures/analytics.rb new file mode 100644 index 00000000000..6d106dce166 --- /dev/null +++ b/spec/frontend/fixtures/analytics.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do + include_context 'Analytics fixtures shared context' + + let_it_be(:value_stream_id) { 'default' } + + before(:all) do + clean_frontend_fixtures('projects/analytics/value_stream_analytics/') + end + + before do + update_metrics + create_deployment + end + + describe Projects::Analytics::CycleAnalytics::StagesController, type: :controller do + render_views + + let(:params) { { namespace_id: group, project_id: project, value_stream_id: value_stream_id } } + + before do + project.add_developer(user) + + sign_in(user) + end + + it 'projects/analytics/value_stream_analytics/stages' do + get(:index, params: params, format: :json) + + expect(response).to be_successful + end + end + + describe Projects::CycleAnalytics::EventsController, type: :controller do + render_views + let(:params) { { namespace_id: group, project_id: project, value_stream_id: value_stream_id } } + + before do + project.add_developer(user) + + sign_in(user) + end + + Gitlab::Analytics::CycleAnalytics::DefaultStages.all.each do |stage| + it "projects/analytics/value_stream_analytics/events/#{stage[:name]}" do + get(stage[:name], params: params, format: :json) + + expect(response).to be_successful + end + end + end + + describe Projects::Analytics::CycleAnalytics::SummaryController, type: :controller do + render_views + let(:params) { { namespace_id: group, project_id: project, value_stream_id: value_stream_id } } + + before do + project.add_developer(user) + + sign_in(user) + end + + it "projects/analytics/value_stream_analytics/summary" do + get(:show, params: params, format: :json) + + expect(response).to be_successful + end + end +end diff --git a/spec/frontend/fixtures/api_markdown.rb b/spec/frontend/fixtures/api_markdown.rb index 94db262e4fd..cb9a116f293 100644 --- a/spec/frontend/fixtures/api_markdown.rb +++ b/spec/frontend/fixtures/api_markdown.rb @@ -7,12 +7,17 @@ RSpec.describe API::MergeRequests, '(JavaScript fixtures)', type: :request do include WikiHelpers include JavaScriptFixturesHelpers - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, username: 'gitlab') } let_it_be(:group) { create(:group, :public) } let_it_be(:project) { create(:project, :public, :repository, group: group) } - let_it_be(:project_wiki) { create(:project_wiki, user: user) } + let_it_be(:label) { create(:label, project: project, title: 'bug') } + let_it_be(:milestone) { create(:milestone, project: project, title: '1.1') } + let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:merge_request) { create(:merge_request, source_project: project) } + + let_it_be(:project_wiki) { create(:project_wiki, project: project, user: user) } let(:project_wiki_page) { create(:wiki_page, wiki: project_wiki) } diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml index 8d8c9a1d902..b581aac6aee 100644 --- a/spec/frontend/fixtures/api_markdown.yml +++ b/spec/frontend/fixtures/api_markdown.yml @@ -8,6 +8,14 @@ markdown: '_emphasized text_' - name: inline_code markdown: '`code`' +- name: inline_diff + markdown: |- + * {-deleted-} + * {+added+} +- name: subscript + markdown: H<sub>2</sub>O +- name: superscript + markdown: 2<sup>8</sup> = 256 - name: strike markdown: '~~del~~' - name: horizontal_rule @@ -68,6 +76,22 @@ 1. list item 1 2. list item 2 3. list item 3 +- name: task_list + markdown: |- + * [x] hello + * [x] world + * [ ] example + * [ ] of nested + * [x] task list + * [ ] items +- name: ordered_task_list + markdown: |- + 1. [x] hello + 2. [x] world + 3. [ ] example + 1. [ ] of nested + 1. [x] task list + 2. [ ] items - name: image markdown: '![alt text](https://gitlab.com/logo.png)' - name: hard_break @@ -86,4 +110,9 @@ |--------|------------|----------| | cell | cell | cell | | cell | cell | cell | - +- name: emoji + markdown: ':sparkles: :heart: :100:' +- name: reference + context: project_wiki + markdown: |- + Hi @gitlab - thank you for reporting this ~bug (#1) we hope to fix it in %1.1 as part of !1 diff --git a/spec/frontend/fixtures/startup_css.rb b/spec/frontend/fixtures/startup_css.rb index 003f7b768dd..be2ead756cf 100644 --- a/spec/frontend/fixtures/startup_css.rb +++ b/spec/frontend/fixtures/startup_css.rb @@ -10,8 +10,6 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do render_views before(:all) do - stub_feature_flags(combined_menu: true) - stub_feature_flags(sidebar_refactor: true) clean_frontend_fixtures('startup_css/') end @@ -23,17 +21,6 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do sign_in(user) end - it "startup_css/project-#{type}-legacy-menu.html" do - stub_feature_flags(combined_menu: false) - - get :show, params: { - namespace_id: project.namespace.to_param, - id: project - } - - expect(response).to be_successful - end - it "startup_css/project-#{type}.html" do get :show, params: { namespace_id: project.namespace.to_param, @@ -43,17 +30,6 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do expect(response).to be_successful end - it "startup_css/project-#{type}-legacy-sidebar.html" do - stub_feature_flags(sidebar_refactor: false) - - get :show, params: { - namespace_id: project.namespace.to_param, - id: project - } - - expect(response).to be_successful - end - it "startup_css/project-#{type}-signed-out.html" do sign_out(user) diff --git a/spec/frontend/graphql_shared/utils_spec.js b/spec/frontend/graphql_shared/utils_spec.js index 56bfb02ea4a..1732f24eeff 100644 --- a/spec/frontend/graphql_shared/utils_spec.js +++ b/spec/frontend/graphql_shared/utils_spec.js @@ -1,4 +1,5 @@ import { + isGid, getIdFromGraphQLId, convertToGraphQLId, convertToGraphQLIds, @@ -10,6 +11,16 @@ const mockType = 'Group'; const mockId = 12; const mockGid = `gid://gitlab/Group/12`; +describe('isGid', () => { + it('returns true if passed id is gid', () => { + expect(isGid(mockGid)).toBe(true); + }); + + it('returns false if passed id is not gid', () => { + expect(isGid(mockId)).toBe(false); + }); +}); + describe('getIdFromGraphQLId', () => { [ { @@ -67,6 +78,10 @@ describe('convertToGraphQLId', () => { `('throws TypeError with "$message" if a param is missing', ({ type, id, message }) => { expect(() => convertToGraphQLId(type, id)).toThrow(new TypeError(message)); }); + + it('returns id as is if it follows the gid format', () => { + expect(convertToGraphQLId(mockType, mockGid)).toStrictEqual(mockGid); + }); }); describe('convertToGraphQLIds', () => { diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js index 2369685f506..60d47895a95 100644 --- a/spec/frontend/groups/components/group_item_spec.js +++ b/spec/frontend/groups/components/group_item_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import Vue from 'vue'; +import waitForPromises from 'helpers/wait_for_promises'; import GroupFolder from '~/groups/components/group_folder.vue'; import GroupItem from '~/groups/components/group_item.vue'; import ItemActions from '~/groups/components/item_actions.vue'; @@ -22,8 +22,7 @@ describe('GroupItemComponent', () => { beforeEach(() => { wrapper = createComponent(); - - return Vue.nextTick(); + return waitForPromises(); }); afterEach(() => { diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index 4bf3334ae6b..3f722c24dbb 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -166,6 +166,11 @@ describe('RepoEditor', () => { expect(tabs).toHaveLength(1); expect(tabs.at(0).text()).toBe('Edit'); }); + + it('does not get markdown extension by default', async () => { + await createComponent(); + expect(vm.editor.projectPath).toBeUndefined(); + }); }); describe('when file is markdown', () => { @@ -213,6 +218,11 @@ describe('RepoEditor', () => { }); expect(findTabs()).toHaveLength(0); }); + + it('uses the markdown extension and sets it up correctly', async () => { + await createComponent({ activeFile }); + expect(vm.editor.projectPath).toBe(vm.currentProjectId); + }); }); describe('when file is binary and not raw', () => { 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 99ef6d9a7fb..bbd8463e685 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 @@ -3,21 +3,22 @@ import { GlEmptyState, GlLoadingIcon, GlSearchBoxByClick, - GlSprintf, GlDropdown, GlDropdownItem, + GlTable, } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { mount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import stubChildren from 'helpers/stub_children'; import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import { STATUSES } from '~/import_entities/constants'; import ImportTable from '~/import_entities/import_groups/components/import_table.vue'; -import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue'; +import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue'; import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql'; -import setNewNameMutation from '~/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql'; -import setTargetNamespaceMutation from '~/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql'; +import setImportTargetMutation from '~/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import { availableNamespacesFixture, generateFakeEntry } from '../graphql/fixtures'; @@ -41,10 +42,15 @@ describe('import table', () => { ]; const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 }; - const findImportAllButton = () => wrapper.find('h1').find(GlButton); + const findImportSelectedButton = () => + wrapper.findAllComponents(GlButton).wrappers.find((w) => w.text() === 'Import selected'); const findPaginationDropdown = () => wrapper.findComponent(GlDropdown); const findPaginationDropdownText = () => findPaginationDropdown().find({ ref: 'text' }).text(); + // TODO: remove this ugly approach when + // issue: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1531 + const findTable = () => wrapper.vm.getTableRef(); + const createComponent = ({ bulkImportSourceGroups }) => { apolloProvider = createMockApollo([], { Query: { @@ -58,14 +64,17 @@ describe('import table', () => { }, }); - wrapper = shallowMount(ImportTable, { + wrapper = mount(ImportTable, { propsData: { groupPathRegex: /.*/, sourceUrl: SOURCE_URL, + groupUrlErrorMessage: 'Please choose a group URL with no special characters or spaces.', }, stubs: { - GlSprintf, + ...stubChildren(ImportTable), + GlSprintf: false, GlDropdown: GlDropdownStub, + GlTable: false, }, localVue, apolloProvider, @@ -115,7 +124,7 @@ describe('import table', () => { }); await waitForPromises(); - expect(wrapper.findAll(ImportTableRow)).toHaveLength(FAKE_GROUPS.length); + expect(wrapper.findAll('tbody tr')).toHaveLength(FAKE_GROUPS.length); }); it('does not render status string when result list is empty', async () => { @@ -139,19 +148,32 @@ describe('import table', () => { }); it.each` - event | payload | mutation | variables - ${'update-target-namespace'} | ${'new-namespace'} | ${setTargetNamespaceMutation} | ${{ sourceGroupId: FAKE_GROUP.id, targetNamespace: 'new-namespace' }} - ${'update-new-name'} | ${'new-name'} | ${setNewNameMutation} | ${{ sourceGroupId: FAKE_GROUP.id, newName: 'new-name' }} - ${'import-group'} | ${undefined} | ${importGroupsMutation} | ${{ sourceGroupIds: [FAKE_GROUP.id] }} + event | payload | mutation | variables + ${'update-target-namespace'} | ${'new-namespace'} | ${setImportTargetMutation} | ${{ sourceGroupId: FAKE_GROUP.id, targetNamespace: 'new-namespace', newName: 'group1' }} + ${'update-new-name'} | ${'new-name'} | ${setImportTargetMutation} | ${{ sourceGroupId: FAKE_GROUP.id, targetNamespace: 'root', newName: 'new-name' }} `('correctly maps $event to mutation', async ({ event, payload, mutation, variables }) => { jest.spyOn(apolloProvider.defaultClient, 'mutate'); - wrapper.find(ImportTableRow).vm.$emit(event, payload); + wrapper.find(ImportTargetCell).vm.$emit(event, payload); await waitForPromises(); expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({ mutation, variables, }); }); + + it('invokes importGroups mutation when row button is clicked', async () => { + jest.spyOn(apolloProvider.defaultClient, 'mutate'); + const triggerImportButton = wrapper + .findAllComponents(GlButton) + .wrappers.find((w) => w.text() === 'Import'); + + triggerImportButton.vm.$emit('click'); + await waitForPromises(); + expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({ + mutation: importGroupsMutation, + variables: { sourceGroupIds: [FAKE_GROUP.id] }, + }); + }); }); describe('pagination', () => { @@ -279,16 +301,20 @@ describe('import table', () => { }); }); - describe('import all button', () => { - it('does not exists when no groups available', () => { + describe('bulk operations', () => { + it('import selected button is disabled when no groups selected', async () => { createComponent({ - bulkImportSourceGroups: () => new Promise(() => {}), + bulkImportSourceGroups: () => ({ + nodes: FAKE_GROUPS, + pageInfo: FAKE_PAGE_INFO, + }), }); + await waitForPromises(); - expect(findImportAllButton().exists()).toBe(false); + expect(findImportSelectedButton().props().disabled).toBe(true); }); - it('exists when groups are available for import', async () => { + it('import selected button is enabled when groups were selected for import', async () => { createComponent({ bulkImportSourceGroups: () => ({ nodes: FAKE_GROUPS, @@ -296,16 +322,14 @@ describe('import table', () => { }), }); await waitForPromises(); + wrapper.find(GlTable).vm.$emit('row-selected', [FAKE_GROUPS[0]]); + await nextTick(); - expect(findImportAllButton().exists()).toBe(true); + expect(findImportSelectedButton().props().disabled).toBe(false); }); - it('counts only not-imported groups', async () => { - const NEW_GROUPS = [ - generateFakeEntry({ id: 1, status: STATUSES.NONE }), - generateFakeEntry({ id: 2, status: STATUSES.NONE }), - generateFakeEntry({ id: 3, status: STATUSES.FINISHED }), - ]; + it('does not allow selecting already started groups', async () => { + const NEW_GROUPS = [generateFakeEntry({ id: 1, status: STATUSES.FINISHED })]; createComponent({ bulkImportSourceGroups: () => ({ @@ -315,17 +339,41 @@ describe('import table', () => { }); await waitForPromises(); - expect(findImportAllButton().text()).toMatchInterpolatedText('Import 2 groups'); + findTable().selectRow(0); + await nextTick(); + + expect(findImportSelectedButton().props().disabled).toBe(true); }); - it('disables button when any group has validation errors', async () => { + it('does not allow selecting groups with validation errors', async () => { const NEW_GROUPS = [ - generateFakeEntry({ id: 1, status: STATUSES.NONE }), generateFakeEntry({ id: 2, status: STATUSES.NONE, - validation_errors: [{ field: 'new_name', message: 'test validation error' }], + validation_errors: [{ field: 'new_name', message: 'FAKE_VALIDATION_ERROR' }], }), + ]; + + createComponent({ + bulkImportSourceGroups: () => ({ + nodes: NEW_GROUPS, + pageInfo: FAKE_PAGE_INFO, + }), + }); + await waitForPromises(); + + // TODO: remove this ugly approach when + // issue: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1531 + findTable().selectRow(0); + await nextTick(); + + expect(findImportSelectedButton().props().disabled).toBe(true); + }); + + it('invokes importGroups mutation when import selected button is clicked', async () => { + const NEW_GROUPS = [ + generateFakeEntry({ id: 1, status: STATUSES.NONE }), + generateFakeEntry({ id: 2, status: STATUSES.NONE }), generateFakeEntry({ id: 3, status: STATUSES.FINISHED }), ]; @@ -335,9 +383,19 @@ describe('import table', () => { pageInfo: FAKE_PAGE_INFO, }), }); + jest.spyOn(apolloProvider.defaultClient, 'mutate'); await waitForPromises(); - expect(findImportAllButton().props().disabled).toBe(true); + findTable().selectRow(0); + findTable().selectRow(1); + await nextTick(); + + findImportSelectedButton().vm.$emit('click'); + + expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({ + mutation: importGroupsMutation, + variables: { sourceGroupIds: [NEW_GROUPS[0].id, NEW_GROUPS[1].id] }, + }); }); }); }); diff --git a/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js b/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js index 654a8fd00d3..8231297e594 100644 --- a/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js @@ -2,19 +2,13 @@ import { GlButton, GlDropdownItem, GlLink, GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; import ImportGroupDropdown from '~/import_entities/components/group_dropdown.vue'; import { STATUSES } from '~/import_entities/constants'; -import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue'; -import addValidationErrorMutation from '~/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql'; -import removeValidationErrorMutation from '~/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql'; -import groupAndProjectQuery from '~/import_entities/import_groups/graphql/queries/groupAndProject.query.graphql'; +import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue'; import { availableNamespacesFixture } from '../graphql/fixtures'; Vue.use(VueApollo); -const { i18n: I18N } = ImportTableRow; - const getFakeGroup = (status) => ({ web_url: 'https://fake.host/', full_path: 'fake_group_1', @@ -28,48 +22,23 @@ const getFakeGroup = (status) => ({ progress: { status }, }); -const EXISTING_GROUP_TARGET_NAMESPACE = 'existing-group'; -const EXISTING_GROUP_PATH = 'existing-path'; -const EXISTING_PROJECT_PATH = 'existing-project-path'; - -describe('import table row', () => { +describe('import target cell', () => { let wrapper; - let apolloProvider; let group; const findByText = (cmp, text) => { return wrapper.findAll(cmp).wrappers.find((node) => node.text().indexOf(text) === 0); }; - const findImportButton = () => findByText(GlButton, 'Import'); const findNameInput = () => wrapper.find(GlFormInput); const findNamespaceDropdown = () => wrapper.find(ImportGroupDropdown); const createComponent = (props) => { - apolloProvider = createMockApollo([ - [ - groupAndProjectQuery, - ({ fullPath }) => { - const existingGroup = - fullPath === `${EXISTING_GROUP_TARGET_NAMESPACE}/${EXISTING_GROUP_PATH}` - ? { id: 1 } - : null; - - const existingProject = - fullPath === `${EXISTING_GROUP_TARGET_NAMESPACE}/${EXISTING_PROJECT_PATH}` - ? { id: 1 } - : null; - - return Promise.resolve({ data: { existingGroup, existingProject } }); - }, - ], - ]); - - wrapper = shallowMount(ImportTableRow, { - apolloProvider, + wrapper = shallowMount(ImportTargetCell, { stubs: { ImportGroupDropdown }, propsData: { availableNamespaces: availableNamespacesFixture, groupPathRegex: /.*/, + groupUrlErrorMessage: 'Please choose a group URL with no special characters or spaces.', ...props, }, }); @@ -86,14 +55,10 @@ describe('import table row', () => { createComponent({ group }); }); - it.each` - selector | sourceEvent | payload | event - ${findNameInput} | ${'input'} | ${'demo'} | ${'update-new-name'} - ${findImportButton} | ${'click'} | ${undefined} | ${'import-group'} - `('invokes $event', ({ selector, sourceEvent, payload, event }) => { - selector().vm.$emit(sourceEvent, payload); - expect(wrapper.emitted(event)).toBeDefined(); - expect(wrapper.emitted(event)[0][0]).toBe(payload); + it('invokes $event', () => { + findNameInput().vm.$emit('input', 'demo'); + expect(wrapper.emitted('update-new-name')).toBeDefined(); + expect(wrapper.emitted('update-new-name')[0][0]).toBe('demo'); }); it('emits update-target-namespace when dropdown option is clicked', () => { @@ -113,10 +78,6 @@ describe('import table row', () => { createComponent({ group }); }); - it('renders Import button', () => { - expect(findByText(GlButton, 'Import').exists()).toBe(true); - }); - it('renders namespace dropdown as not disabled', () => { expect(findNamespaceDropdown().attributes('disabled')).toBe(undefined); }); @@ -198,7 +159,9 @@ describe('import table row', () => { groupPathRegex: /^[a-zA-Z]+$/, }); - expect(wrapper.text()).toContain('Please choose a group URL with no special characters.'); + expect(wrapper.text()).toContain( + 'Please choose a group URL with no special characters or spaces.', + ); }); it('reports invalid group name if relevant validation error exists', async () => { @@ -221,101 +184,5 @@ describe('import table row', () => { expect(wrapper.text()).toContain(FAKE_ERROR_MESSAGE); }); - - it('sets validation error when targetting existing group', async () => { - const testGroup = getFakeGroup(STATUSES.NONE); - - createComponent({ - group: { - ...testGroup, - import_target: { - target_namespace: EXISTING_GROUP_TARGET_NAMESPACE, - new_name: EXISTING_GROUP_PATH, - }, - }, - }); - - jest.spyOn(wrapper.vm.$apollo, 'mutate'); - - jest.runOnlyPendingTimers(); - await nextTick(); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: addValidationErrorMutation, - variables: { - field: 'new_name', - message: I18N.NAME_ALREADY_EXISTS, - sourceGroupId: testGroup.id, - }, - }); - }); - - it('sets validation error when targetting existing project', async () => { - const testGroup = getFakeGroup(STATUSES.NONE); - - createComponent({ - group: { - ...testGroup, - import_target: { - target_namespace: EXISTING_GROUP_TARGET_NAMESPACE, - new_name: EXISTING_PROJECT_PATH, - }, - }, - }); - - jest.spyOn(wrapper.vm.$apollo, 'mutate'); - - jest.runOnlyPendingTimers(); - await nextTick(); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: addValidationErrorMutation, - variables: { - field: 'new_name', - message: I18N.NAME_ALREADY_EXISTS, - sourceGroupId: testGroup.id, - }, - }); - }); - - it('clears validation error when target is updated', async () => { - const testGroup = getFakeGroup(STATUSES.NONE); - - createComponent({ - group: { - ...testGroup, - import_target: { - target_namespace: EXISTING_GROUP_TARGET_NAMESPACE, - new_name: EXISTING_PROJECT_PATH, - }, - }, - }); - - jest.runOnlyPendingTimers(); - await nextTick(); - - jest.spyOn(wrapper.vm.$apollo, 'mutate'); - - await wrapper.setProps({ - group: { - ...testGroup, - import_target: { - target_namespace: 'valid_namespace', - new_name: 'valid_path', - }, - }, - }); - - jest.runOnlyPendingTimers(); - await nextTick(); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: removeValidationErrorMutation, - variables: { - field: 'new_name', - sourceGroupId: testGroup.id, - }, - }); - }); }); }); diff --git a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js index ef83c9ebbc4..ec50dfd037f 100644 --- a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js +++ b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js @@ -12,12 +12,12 @@ import addValidationErrorMutation from '~/import_entities/import_groups/graphql/ import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql'; import removeValidationErrorMutation from '~/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql'; import setImportProgressMutation from '~/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql'; -import setNewNameMutation from '~/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql'; -import setTargetNamespaceMutation from '~/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql'; +import setImportTargetMutation from '~/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql'; import updateImportStatusMutation from '~/import_entities/import_groups/graphql/mutations/update_import_status.mutation.graphql'; import availableNamespacesQuery from '~/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql'; import bulkImportSourceGroupQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql'; import bulkImportSourceGroupsQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql'; +import groupAndProjectQuery from '~/import_entities/import_groups/graphql/queries/group_and_project.query.graphql'; import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller'; import axios from '~/lib/utils/axios_utils'; @@ -38,18 +38,29 @@ const FAKE_ENDPOINTS = { jobs: '/fake_jobs', }; +const FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER = jest.fn().mockResolvedValue({ + data: { + existingGroup: null, + existingProject: null, + }, +}); + describe('Bulk import resolvers', () => { let axiosMockAdapter; let client; const createClient = (extraResolverArgs) => { - return createMockClient({ + const mockedClient = createMockClient({ cache: new InMemoryCache({ fragmentMatcher: { match: () => true }, addTypename: false, }), resolvers: createResolvers({ endpoints: FAKE_ENDPOINTS, ...extraResolverArgs }), }); + + mockedClient.setRequestHandler(groupAndProjectQuery, FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER); + + return mockedClient; }; beforeEach(() => { @@ -196,6 +207,12 @@ describe('Bulk import resolvers', () => { const [statusPoller] = StatusPoller.mock.instances; expect(statusPoller.startPolling).toHaveBeenCalled(); }); + + it('requests validation status when request completes', async () => { + expect(FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER).not.toHaveBeenCalled(); + jest.runOnlyPendingTimers(); + expect(FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER).toHaveBeenCalled(); + }); }); it.each` @@ -256,40 +273,49 @@ describe('Bulk import resolvers', () => { }); }); - it('setTargetNamespaces updates group target namespace', async () => { - const NEW_TARGET_NAMESPACE = 'target'; - const { - data: { - setTargetNamespace: { - id: idInResponse, - import_target: { target_namespace: namespaceInResponse }, + describe('setImportTarget', () => { + it('updates group target namespace and name', async () => { + const NEW_TARGET_NAMESPACE = 'target'; + const NEW_NAME = 'new'; + + const { + data: { + setImportTarget: { + id: idInResponse, + import_target: { target_namespace: namespaceInResponse, new_name: newNameInResponse }, + }, }, - }, - } = await client.mutate({ - mutation: setTargetNamespaceMutation, - variables: { sourceGroupId: GROUP_ID, targetNamespace: NEW_TARGET_NAMESPACE }, + } = await client.mutate({ + mutation: setImportTargetMutation, + variables: { + sourceGroupId: GROUP_ID, + targetNamespace: NEW_TARGET_NAMESPACE, + newName: NEW_NAME, + }, + }); + + expect(idInResponse).toBe(GROUP_ID); + expect(namespaceInResponse).toBe(NEW_TARGET_NAMESPACE); + expect(newNameInResponse).toBe(NEW_NAME); }); - expect(idInResponse).toBe(GROUP_ID); - expect(namespaceInResponse).toBe(NEW_TARGET_NAMESPACE); - }); + it('invokes validation', async () => { + const NEW_TARGET_NAMESPACE = 'target'; + const NEW_NAME = 'new'; - it('setNewName updates group target name', async () => { - const NEW_NAME = 'new'; - const { - data: { - setNewName: { - id: idInResponse, - import_target: { new_name: nameInResponse }, + await client.mutate({ + mutation: setImportTargetMutation, + variables: { + sourceGroupId: GROUP_ID, + targetNamespace: NEW_TARGET_NAMESPACE, + newName: NEW_NAME, }, - }, - } = await client.mutate({ - mutation: setNewNameMutation, - variables: { sourceGroupId: GROUP_ID, newName: NEW_NAME }, - }); + }); - expect(idInResponse).toBe(GROUP_ID); - expect(nameInResponse).toBe(NEW_NAME); + expect(FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER).toHaveBeenCalledWith({ + fullPath: `${NEW_TARGET_NAMESPACE}/${NEW_NAME}`, + }); + }); }); describe('importGroup', () => { diff --git a/spec/frontend/integrations/edit/components/dynamic_field_spec.js b/spec/frontend/integrations/edit/components/dynamic_field_spec.js index 8784b3c2b00..da8a2f41c1b 100644 --- a/spec/frontend/integrations/edit/components/dynamic_field_spec.js +++ b/spec/frontend/integrations/edit/components/dynamic_field_spec.js @@ -182,6 +182,19 @@ describe('DynamicField', () => { expect(findGlFormGroup().find('small').html()).toContain(helpHTML); }); + + it('strips unsafe HTML from the help text', () => { + const helpHTML = + '[<code>1</code> <iframe>2</iframe> <a href="javascript:alert(document.cookie)">3</a> <a href="foo" target="_blank">4</a>]'; + + createComponent({ + help: helpHTML, + }); + + expect(findGlFormGroup().find('small').html()).toContain( + '[<code>1</code> <a>3</a> <a target="_blank" href="foo">4</a>]', + ); + }); }); describe('label text', () => { diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index cbce26762b1..ff602327592 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -278,6 +278,7 @@ describe('IntegrationForm', () => { <svg class="gl-icon"> <use></use> </svg> + <a data-confirm="Are you sure?" data-method="delete" href="/settings/slack"></a> </div> `); @@ -291,9 +292,14 @@ describe('IntegrationForm', () => { }); const helpHtml = wrapper.findByTestId(mockTestId); + const helpLink = helpHtml.find('a'); expect(helpHtml.isVisible()).toBe(true); expect(helpHtml.find('svg').isVisible()).toBe(true); + expect(helpLink.attributes()).toMatchObject({ + 'data-confirm': 'Are you sure?', + 'data-method': 'delete', + }); }); }); }); diff --git a/spec/frontend/integrations/overrides/components/integration_overrides_spec.js b/spec/frontend/integrations/overrides/components/integration_overrides_spec.js new file mode 100644 index 00000000000..dbed236d7df --- /dev/null +++ b/spec/frontend/integrations/overrides/components/integration_overrides_spec.js @@ -0,0 +1,146 @@ +import { GlTable, GlLink, GlPagination } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import { DEFAULT_PER_PAGE } from '~/api'; +import createFlash from '~/flash'; +import IntegrationOverrides from '~/integrations/overrides/components/integration_overrides.vue'; +import axios from '~/lib/utils/axios_utils'; +import httpStatus from '~/lib/utils/http_status'; +import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; + +jest.mock('~/flash'); + +const mockOverrides = Array(DEFAULT_PER_PAGE * 3) + .fill(1) + .map((_, index) => ({ + name: `test-proj-${index}`, + avatar_url: `avatar-${index}`, + full_path: `test-proj-${index}`, + full_name: `test-proj-${index}`, + })); + +describe('IntegrationOverrides', () => { + let wrapper; + let mockAxios; + + const defaultProps = { + overridesPath: 'mock/overrides', + }; + + const createComponent = ({ mountFn = shallowMount } = {}) => { + wrapper = mountFn(IntegrationOverrides, { + propsData: defaultProps, + }); + }; + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.OK, mockOverrides, { + 'X-TOTAL': mockOverrides.length, + 'X-PAGE': 1, + }); + }); + + afterEach(() => { + mockAxios.restore(); + wrapper.destroy(); + }); + + const findGlTable = () => wrapper.findComponent(GlTable); + const findPagination = () => wrapper.findComponent(GlPagination); + const findRowsAsModel = () => + findGlTable() + .findAllComponents(GlLink) + .wrappers.map((link) => { + const avatar = link.findComponent(ProjectAvatar); + + return { + href: link.attributes('href'), + avatarUrl: avatar.props('projectAvatarUrl'), + avatarName: avatar.props('projectName'), + text: link.text(), + }; + }); + + describe('while loading', () => { + it('sets GlTable `busy` attribute to `true`', () => { + createComponent(); + + const table = findGlTable(); + expect(table.exists()).toBe(true); + expect(table.attributes('busy')).toBe('true'); + }); + }); + + describe('when initial request is successful', () => { + it('sets GlTable `busy` attribute to `false`', async () => { + createComponent(); + await waitForPromises(); + + const table = findGlTable(); + expect(table.exists()).toBe(true); + expect(table.attributes('busy')).toBeFalsy(); + }); + + describe('table template', () => { + beforeEach(async () => { + createComponent({ mountFn: mount }); + await waitForPromises(); + }); + + it('renders overrides as rows in table', () => { + expect(findRowsAsModel()).toEqual( + mockOverrides.map((x) => ({ + href: x.full_path, + avatarUrl: x.avatar_url, + avatarName: x.name, + text: expect.stringContaining(x.full_name), + })), + ); + }); + }); + }); + + describe('when request fails', () => { + beforeEach(async () => { + mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.INTERNAL_SERVER_ERROR); + createComponent(); + await waitForPromises(); + }); + + it('calls createFlash', () => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith({ + message: IntegrationOverrides.i18n.defaultErrorMessage, + captureError: true, + error: expect.any(Error), + }); + }); + }); + + describe('pagination', () => { + it('triggers fetch when `input` event is emitted', async () => { + createComponent(); + jest.spyOn(axios, 'get'); + await waitForPromises(); + + await findPagination().vm.$emit('input', 2); + expect(axios.get).toHaveBeenCalledWith(defaultProps.overridesPath, { + params: { page: 2, per_page: DEFAULT_PER_PAGE }, + }); + }); + + it('does not render with <=1 page', async () => { + mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.OK, [mockOverrides[0]], { + 'X-TOTAL': 1, + 'X-PAGE': 1, + }); + + createComponent(); + await waitForPromises(); + + expect(findPagination().exists()).toBe(false); + }); + }); +}); 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 b828b5d8a04..95b1c55b82d 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -6,6 +6,7 @@ import { GlSprintf, GlLink, GlModal, + GlFormCheckboxGroup, } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import { stubComponent } from 'helpers/stub_component'; @@ -15,7 +16,8 @@ import Api from '~/api'; import ExperimentTracking from '~/experimentation/experiment_tracking'; import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue'; import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; -import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants'; +import { INVITE_MEMBERS_IN_COMMENT, MEMBER_AREAS_OF_FOCUS } from '~/invite_members/constants'; +import eventHub from '~/invite_members/event_hub'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; import { apiPaths, membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses'; @@ -32,7 +34,12 @@ const inviteeType = 'members'; const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }; const defaultAccessLevel = 10; const inviteSource = 'unknown'; +const noSelectionAreasOfFocus = ['no_selection']; const helpLink = 'https://example.com'; +const areasOfFocusOptions = [ + { text: 'area1', value: 'area1' }, + { text: 'area2', value: 'area2' }, +]; const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' }; const user2 = { id: 2, name: 'Name Two', username: 'one_2', avatar_url: '' }; @@ -58,7 +65,9 @@ const createComponent = (data = {}, props = {}) => { isProject, inviteeType, accessLevels, + areasOfFocusOptions, defaultAccessLevel, + noSelectionAreasOfFocus, helpLink, ...props, }, @@ -74,7 +83,7 @@ const createComponent = (data = {}, props = {}) => { GlDropdownItem: true, GlSprintf, GlFormGroup: stubComponent(GlFormGroup, { - props: ['state', 'invalidFeedback'], + props: ['state', 'invalidFeedback', 'description'], }), }, }); @@ -116,9 +125,12 @@ describe('InviteMembersModal', () => { const findCancelButton = () => wrapper.findByTestId('cancel-button'); const findInviteButton = () => wrapper.findByTestId('invite-button'); const clickInviteButton = () => findInviteButton().vm.$emit('click'); + const clickCancelButton = () => findCancelButton().vm.$emit('click'); const findMembersFormGroup = () => wrapper.findByTestId('members-form-group'); const membersFormGroupInvalidFeedback = () => findMembersFormGroup().props('invalidFeedback'); + const membersFormGroupDescription = () => findMembersFormGroup().props('description'); const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect); + const findAreaofFocusCheckBoxGroup = () => wrapper.findComponent(GlFormCheckboxGroup); describe('rendering the modal', () => { beforeEach(() => { @@ -137,6 +149,10 @@ describe('InviteMembersModal', () => { expect(findInviteButton().text()).toBe('Invite'); }); + it('renders the Invite button modal without isLoading', () => { + expect(findInviteButton().props('loading')).toBe(false); + }); + describe('rendering the access levels dropdown', () => { it('sets the default dropdown text to the default access level name', () => { expect(findDropdown().attributes('text')).toBe('Guest'); @@ -160,13 +176,29 @@ describe('InviteMembersModal', () => { }); }); - describe('displaying the correct introText', () => { + describe('rendering the areas_of_focus', () => { + it('renders the areas_of_focus checkboxes', () => { + createComponent(); + + expect(findAreaofFocusCheckBoxGroup().props('options')).toBe(areasOfFocusOptions); + expect(findAreaofFocusCheckBoxGroup().exists()).toBe(true); + }); + + it('does not render the areas_of_focus checkboxes', () => { + createComponent({}, { areasOfFocusOptions: [] }); + + expect(findAreaofFocusCheckBoxGroup().exists()).toBe(false); + }); + }); + + describe('displaying the correct introText and form group description', () => { describe('when inviting to a project', () => { describe('when inviting members', () => { it('includes the correct invitee, type, and formatted name', () => { createInviteMembersToProjectWrapper(); expect(findIntroText()).toBe("You're inviting members to the test name project."); + expect(membersFormGroupDescription()).toBe('Select members or type email addresses'); }); }); @@ -175,6 +207,7 @@ describe('InviteMembersModal', () => { createInviteGroupToProjectWrapper(); expect(findIntroText()).toBe("You're inviting a group to the test name project."); + expect(membersFormGroupDescription()).toBe(''); }); }); }); @@ -185,6 +218,7 @@ describe('InviteMembersModal', () => { createInviteMembersToGroupWrapper(); expect(findIntroText()).toBe("You're inviting members to the test name group."); + expect(membersFormGroupDescription()).toBe('Select members or type email addresses'); }); }); @@ -193,6 +227,7 @@ describe('InviteMembersModal', () => { createInviteGroupToGroupWrapper(); expect(findIntroText()).toBe("You're inviting a group to the test name group."); + expect(membersFormGroupDescription()).toBe(''); }); }); }); @@ -210,6 +245,20 @@ describe('InviteMembersModal', () => { "email 'email@example.com' does not match the allowed domains: example1.org"; const expectedSyntaxError = 'email contains an invalid email address'; + it('calls the API with the expected focus data when an areas_of_focus checkbox is clicked', () => { + const spy = jest.spyOn(Api, 'addGroupMembersByUserId'); + const expectedFocus = [areasOfFocusOptions[0].value]; + createComponent({ newUsersToInvite: [user1] }); + + findAreaofFocusCheckBoxGroup().vm.$emit('input', expectedFocus); + clickInviteButton(); + + expect(spy).toHaveBeenCalledWith( + user1.id.toString(), + expect.objectContaining({ areas_of_focus: expectedFocus }), + ); + }); + describe('when inviting an existing user to group by user ID', () => { const postData = { user_id: '1,2', @@ -217,6 +266,7 @@ describe('InviteMembersModal', () => { expires_at: undefined, invite_source: inviteSource, format: 'json', + areas_of_focus: noSelectionAreasOfFocus, }; describe('when member is added successfully', () => { @@ -226,20 +276,34 @@ describe('InviteMembersModal', () => { wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); + }); + + it('includes the non-default selected areas of focus', () => { + const focus = ['abc']; + const updatedPostData = { ...postData, areas_of_focus: focus }; + wrapper.setData({ selectedAreasOfFocus: focus }); clickInviteButton(); + + expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, updatedPostData); }); - it('calls Api addGroupMembersByUserId with the correct params', async () => { - await waitForPromises; + describe('when triggered from regular mounting', () => { + beforeEach(() => { + clickInviteButton(); + }); - expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, postData); - }); + it('sets isLoading on the Invite button when it is clicked', () => { + expect(findInviteButton().props('loading')).toBe(true); + }); - it('displays the successful toastMessage', async () => { - await waitForPromises; + it('calls Api addGroupMembersByUserId with the correct params', () => { + expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, postData); + }); - expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); + it('displays the successful toastMessage', () => { + expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); + }); }); }); @@ -260,6 +324,51 @@ describe('InviteMembersModal', () => { expect(membersFormGroupInvalidFeedback()).toBe('Member already exists'); expect(findMembersFormGroup().props('state')).toBe(false); expect(findMembersSelect().props('validationState')).toBe(false); + expect(findInviteButton().props('loading')).toBe(false); + }); + + describe('clearing the invalid state and message', () => { + beforeEach(async () => { + mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS); + + clickInviteButton(); + + await waitForPromises(); + }); + + it('clears the error when the list of members to invite is cleared', async () => { + expect(membersFormGroupInvalidFeedback()).toBe('Member already exists'); + expect(findMembersFormGroup().props('state')).toBe(false); + expect(findMembersSelect().props('validationState')).toBe(false); + + findMembersSelect().vm.$emit('clear'); + + await wrapper.vm.$nextTick(); + + expect(membersFormGroupInvalidFeedback()).toBe(''); + expect(findMembersFormGroup().props('state')).not.toBe(false); + expect(findMembersSelect().props('validationState')).not.toBe(false); + }); + + it('clears the error when the cancel button is clicked', async () => { + clickCancelButton(); + + await wrapper.vm.$nextTick(); + + expect(membersFormGroupInvalidFeedback()).toBe(''); + expect(findMembersFormGroup().props('state')).not.toBe(false); + expect(findMembersSelect().props('validationState')).not.toBe(false); + }); + + it('clears the error when the modal is hidden', async () => { + wrapper.findComponent(GlModal).vm.$emit('hide'); + + await wrapper.vm.$nextTick(); + + expect(membersFormGroupInvalidFeedback()).toBe(''); + expect(findMembersFormGroup().props('state')).not.toBe(false); + expect(findMembersSelect().props('validationState')).not.toBe(false); + }); }); it('clears the invalid state and message once the list of members to invite is cleared', async () => { @@ -272,6 +381,7 @@ describe('InviteMembersModal', () => { expect(membersFormGroupInvalidFeedback()).toBe('Member already exists'); expect(findMembersFormGroup().props('state')).toBe(false); expect(findMembersSelect().props('validationState')).toBe(false); + expect(findInviteButton().props('loading')).toBe(false); findMembersSelect().vm.$emit('clear'); @@ -280,6 +390,7 @@ describe('InviteMembersModal', () => { expect(membersFormGroupInvalidFeedback()).toBe(''); expect(findMembersFormGroup().props('state')).not.toBe(false); expect(findMembersSelect().props('validationState')).not.toBe(false); + expect(findInviteButton().props('loading')).toBe(false); }); it('displays the generic error for http server error', async () => { @@ -336,6 +447,7 @@ describe('InviteMembersModal', () => { expires_at: undefined, email: 'email@example.com', invite_source: inviteSource, + areas_of_focus: noSelectionAreasOfFocus, format: 'json', }; @@ -346,16 +458,30 @@ describe('InviteMembersModal', () => { wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); + }); + + it('includes the non-default selected areas of focus', () => { + const focus = ['abc']; + const updatedPostData = { ...postData, areas_of_focus: focus }; + wrapper.setData({ selectedAreasOfFocus: focus }); clickInviteButton(); - }); - it('calls Api inviteGroupMembersByEmail with the correct params', () => { - expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, postData); + expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, updatedPostData); }); - it('displays the successful toastMessage', () => { - expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); + describe('when triggered from regular mounting', () => { + beforeEach(() => { + clickInviteButton(); + }); + + it('calls Api inviteGroupMembersByEmail with the correct params', () => { + expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, postData); + }); + + it('displays the successful toastMessage', () => { + expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); + }); }); }); @@ -375,6 +501,7 @@ describe('InviteMembersModal', () => { expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError); expect(findMembersSelect().props('validationState')).toBe(false); + expect(findInviteButton().props('loading')).toBe(false); }); it('displays the restricted email error when restricted email is invited', async () => { @@ -386,6 +513,7 @@ describe('InviteMembersModal', () => { expect(membersFormGroupInvalidFeedback()).toContain(expectedEmailRestrictedError); expect(findMembersSelect().props('validationState')).toBe(false); + expect(findInviteButton().props('loading')).toBe(false); }); it('displays the successful toast message when email has already been invited', async () => { @@ -446,6 +574,7 @@ describe('InviteMembersModal', () => { access_level: defaultAccessLevel, expires_at: undefined, invite_source: inviteSource, + areas_of_focus: noSelectionAreasOfFocus, format: 'json', }; @@ -482,7 +611,7 @@ describe('InviteMembersModal', () => { }); it('calls Apis with the invite source passed through to openModal', () => { - wrapper.vm.openModal({ inviteeType: 'members', source: '_invite_source_' }); + eventHub.$emit('openModal', { inviteeType: 'members', source: '_invite_source_' }); clickInviteButton(); @@ -548,9 +677,9 @@ describe('InviteMembersModal', () => { describe('when sharing the group fails', () => { beforeEach(() => { - createComponent({ groupToBeSharedWith: sharedGroup }); + createInviteGroupToGroupWrapper(); - wrapper.setData({ inviteeType: 'group' }); + wrapper.setData({ groupToBeSharedWith: sharedGroup }); wrapper.vm.$toast = { show: jest.fn() }; jest @@ -560,10 +689,9 @@ describe('InviteMembersModal', () => { clickInviteButton(); }); - it('displays the generic error message', async () => { - await waitForPromises(); - + it('displays the generic error message', () => { expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong'); + expect(membersFormGroupDescription()).toBe(''); }); }); }); @@ -577,7 +705,7 @@ describe('InviteMembersModal', () => { }); it('tracks the invite', () => { - wrapper.vm.openModal({ inviteeType: 'members', source: INVITE_MEMBERS_IN_COMMENT }); + eventHub.$emit('openModal', { inviteeType: 'members', source: INVITE_MEMBERS_IN_COMMENT }); clickInviteButton(); @@ -586,19 +714,37 @@ describe('InviteMembersModal', () => { }); it('does not track invite for unknown source', () => { - wrapper.vm.openModal({ inviteeType: 'members', source: 'unknown' }); + eventHub.$emit('openModal', { inviteeType: 'members', source: 'unknown' }); clickInviteButton(); - expect(ExperimentTracking).not.toHaveBeenCalled(); + expect(ExperimentTracking).not.toHaveBeenCalledWith(INVITE_MEMBERS_IN_COMMENT); }); it('does not track invite undefined source', () => { - wrapper.vm.openModal({ inviteeType: 'members' }); + eventHub.$emit('openModal', { inviteeType: 'members' }); + + clickInviteButton(); + + expect(ExperimentTracking).not.toHaveBeenCalledWith(INVITE_MEMBERS_IN_COMMENT); + }); + + it('tracks the view for areas_of_focus', () => { + eventHub.$emit('openModal', { inviteeType: 'members' }); + + expect(ExperimentTracking).toHaveBeenCalledWith(MEMBER_AREAS_OF_FOCUS.name); + expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(MEMBER_AREAS_OF_FOCUS.view); + }); + + it('tracks the invite for areas_of_focus', () => { + eventHub.$emit('openModal', { inviteeType: 'members' }); clickInviteButton(); - expect(ExperimentTracking).not.toHaveBeenCalled(); + expect(ExperimentTracking).toHaveBeenCalledWith(MEMBER_AREAS_OF_FOCUS.name); + expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith( + MEMBER_AREAS_OF_FOCUS.submit, + ); }); }); }); diff --git a/spec/frontend/invite_members/components/members_token_select_spec.js b/spec/frontend/invite_members/components/members_token_select_spec.js index 12db7e42464..196a716d08c 100644 --- a/spec/frontend/invite_members/components/members_token_select_spec.js +++ b/spec/frontend/invite_members/components/members_token_select_spec.js @@ -12,11 +12,12 @@ const user1 = { id: 1, name: 'John Smith', username: 'one_1', avatar_url: '' }; const user2 = { id: 2, name: 'Jane Doe', username: 'two_2', avatar_url: '' }; const allUsers = [user1, user2]; -const createComponent = () => { +const createComponent = (props) => { return shallowMount(MembersTokenSelect, { propsData: { ariaLabelledby: label, placeholder, + ...props, }, stubs: { GlTokenSelector: stubComponent(GlTokenSelector), @@ -27,11 +28,6 @@ const createComponent = () => { describe('MembersTokenSelect', () => { let wrapper; - beforeEach(() => { - jest.spyOn(UserApi, 'getUsers').mockResolvedValue({ data: allUsers }); - wrapper = createComponent(); - }); - afterEach(() => { wrapper.destroy(); wrapper = null; @@ -41,6 +37,8 @@ describe('MembersTokenSelect', () => { describe('rendering the token-selector component', () => { it('renders with the correct props', () => { + wrapper = createComponent(); + const expectedProps = { ariaLabelledby: label, placeholder, @@ -51,6 +49,11 @@ describe('MembersTokenSelect', () => { }); describe('users', () => { + beforeEach(() => { + jest.spyOn(UserApi, 'getUsers').mockResolvedValue({ data: allUsers }); + wrapper = createComponent(); + }); + describe('when input is focused for the first time (modal auto-focus)', () => { it('does not call the API', async () => { findTokenSelector().vm.$emit('focus'); @@ -90,10 +93,10 @@ describe('MembersTokenSelect', () => { await waitForPromises(); - expect(UserApi.getUsers).toHaveBeenCalledWith( - searchParam, - wrapper.vm.$options.queryOptions, - ); + expect(UserApi.getUsers).toHaveBeenCalledWith(searchParam, { + active: true, + exclude_internal: true, + }); expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false); }); @@ -134,6 +137,8 @@ describe('MembersTokenSelect', () => { describe('when text input is blurred', () => { it('clears text input', async () => { + wrapper = createComponent(); + const tokenSelector = findTokenSelector(); tokenSelector.vm.$emit('blur'); @@ -143,4 +148,33 @@ describe('MembersTokenSelect', () => { expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false); }); }); + + describe('when component is mounted for a group using a saml provider', () => { + const searchParam = 'name'; + const samlProviderId = 123; + let resolveApiRequest; + + beforeEach(() => { + jest.spyOn(UserApi, 'getUsers').mockImplementation( + () => + new Promise((resolve) => { + resolveApiRequest = resolve; + }), + ); + + wrapper = createComponent({ filterId: samlProviderId, usersFilter: 'saml_provider_id' }); + + findTokenSelector().vm.$emit('text-input', searchParam); + }); + + it('calls the API with the saml provider ID param', () => { + resolveApiRequest({ data: allUsers }); + + expect(UserApi.getUsers).toHaveBeenCalledWith(searchParam, { + active: true, + exclude_internal: true, + saml_provider_id: samlProviderId, + }); + }); + }); }); diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js index 4c06f2dca1b..babe3a66578 100644 --- a/spec/frontend/issue_show/components/app_spec.js +++ b/spec/frontend/issue_show/components/app_spec.js @@ -2,7 +2,6 @@ import { GlIntersectionObserver } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; -import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; import '~/behaviors/markdown/render_gfm'; import IssuableApp from '~/issue_show/components/app.vue'; import DescriptionComponent from '~/issue_show/components/description.vue'; @@ -30,8 +29,6 @@ jest.mock('~/issue_show/event_hub'); const REALTIME_REQUEST_STACK = [initialRequest, secondRequest]; describe('Issuable output', () => { - useMockIntersectionObserver(); - let mock; let realtimeRequestCount = 0; let wrapper; diff --git a/spec/frontend/issue_show/components/fields/type_spec.js b/spec/frontend/issue_show/components/fields/type_spec.js index 0c8af60d50d..fac745716d7 100644 --- a/spec/frontend/issue_show/components/fields/type_spec.js +++ b/spec/frontend/issue_show/components/fields/type_spec.js @@ -1,4 +1,4 @@ -import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlFormGroup, GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -35,6 +35,9 @@ describe('Issue type field component', () => { const findTypeFromGroup = () => wrapper.findComponent(GlFormGroup); const findTypeFromDropDown = () => wrapper.findComponent(GlDropdown); const findTypeFromDropDownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findTypeFromDropDownItemAt = (at) => findTypeFromDropDownItems().at(at); + const findTypeFromDropDownItemIconAt = (at) => + findTypeFromDropDownItems().at(at).findComponent(GlIcon); const createComponent = ({ data } = {}) => { fakeApollo = createMockApollo([], mockResolvers); @@ -60,6 +63,15 @@ describe('Issue type field component', () => { wrapper.destroy(); }); + it.each` + at | text | icon + ${0} | ${IssuableTypes[0].text} | ${IssuableTypes[0].icon} + ${1} | ${IssuableTypes[1].text} | ${IssuableTypes[1].icon} + `(`renders the issue type $text with an icon in the dropdown`, ({ at, text, icon }) => { + expect(findTypeFromDropDownItemIconAt(at).attributes('name')).toBe(icon); + expect(findTypeFromDropDownItemAt(at).text()).toBe(text); + }); + it('renders a form group with the correct label', () => { expect(findTypeFromGroup().attributes('label')).toBe(i18n.label); }); diff --git a/spec/frontend/issue_show/issue_spec.js b/spec/frontend/issue_show/issue_spec.js index d043693b863..76989413edb 100644 --- a/spec/frontend/issue_show/issue_spec.js +++ b/spec/frontend/issue_show/issue_spec.js @@ -1,5 +1,4 @@ import MockAdapter from 'axios-mock-adapter'; -import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; import waitForPromises from 'helpers/wait_for_promises'; import { initIssuableApp } from '~/issue_show/issue'; import * as parseData from '~/issue_show/utils/parse_data'; @@ -10,8 +9,6 @@ import { appProps } from './mock_data/mock_data'; const mock = new MockAdapter(axios); mock.onGet().reply(200); -useMockIntersectionObserver(); - jest.mock('~/lib/utils/poll'); const setupHTML = (initialData) => { diff --git a/spec/frontend/issues_list/components/issuables_list_app_spec.js b/spec/frontend/issues_list/components/issuables_list_app_spec.js index 86112dad444..5ef2a2e0525 100644 --- a/spec/frontend/issues_list/components/issuables_list_app_spec.js +++ b/spec/frontend/issues_list/components/issuables_list_app_spec.js @@ -6,6 +6,7 @@ import { import { shallowMount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; @@ -27,11 +28,6 @@ const TEST_ENDPOINT = '/issues'; const TEST_CREATE_ISSUES_PATH = '/createIssue'; const TEST_SVG_PATH = '/emptySvg'; -const setUrl = (query) => { - window.location.href = `${TEST_LOCATION}${query}`; - window.location.search = query; -}; - const MOCK_ISSUES = Array(PAGE_SIZE_MANUAL) .fill(0) .map((_, i) => ({ @@ -40,7 +36,6 @@ const MOCK_ISSUES = Array(PAGE_SIZE_MANUAL) })); describe('Issuables list component', () => { - let oldLocation; let mockAxios; let wrapper; let apiSpy; @@ -75,19 +70,13 @@ describe('Issuables list component', () => { beforeEach(() => { mockAxios = new MockAdapter(axios); - oldLocation = window.location; - Object.defineProperty(window, 'location', { - writable: true, - value: { href: '', search: '' }, - }); - window.location.href = TEST_LOCATION; + setWindowLocation(TEST_LOCATION); }); afterEach(() => { wrapper.destroy(); wrapper = null; mockAxios.restore(); - window.location = oldLocation; }); describe('with failed issues response', () => { @@ -314,7 +303,7 @@ describe('Issuables list component', () => { '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&weight=0¬[label_name][]=Afterpod¬[milestone_title][]=13'; beforeEach(() => { - setUrl(query); + setWindowLocation(query); setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); factory({ sortKey: 'milestone_due_desc' }); @@ -358,7 +347,7 @@ describe('Issuables list component', () => { '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&weight=0&page=3'; beforeEach(() => { - setUrl(query); + setWindowLocation(query); setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); factory({ sortKey: 'milestone_due_desc' }); @@ -387,7 +376,7 @@ describe('Issuables list component', () => { describe('with hash in window.location', () => { beforeEach(() => { - window.location.href = `${TEST_LOCATION}#stuff`; + setWindowLocation(`${TEST_LOCATION}#stuff`); setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); factory(); return waitForPromises(); @@ -422,7 +411,7 @@ describe('Issuables list component', () => { describe('with query in window location', () => { beforeEach(() => { - window.location.search = '?weight=Any'; + setWindowLocation('?weight=Any'); factory(); @@ -436,7 +425,7 @@ describe('Issuables list component', () => { describe('with closed state', () => { beforeEach(() => { - window.location.search = '?state=closed'; + setWindowLocation('?state=closed'); factory(); @@ -450,7 +439,7 @@ describe('Issuables list component', () => { describe('with all state', () => { beforeEach(() => { - window.location.search = '?state=all'; + setWindowLocation('?state=all'); factory(); @@ -565,7 +554,7 @@ describe('Issuables list component', () => { }); it('sets value according to query', () => { - setUrl(query); + setWindowLocation(query); factory({ type: 'jira' }); @@ -583,7 +572,7 @@ describe('Issuables list component', () => { it('sets value according to query', () => { const query = '?search=free+text'; - setUrl(query); + setWindowLocation(query); factory({ type: 'jira' }); diff --git a/spec/frontend/issues_list/components/issue_card_time_info_spec.js b/spec/frontend/issues_list/components/issue_card_time_info_spec.js index 634687e77ab..d195c159cbb 100644 --- a/spec/frontend/issues_list/components/issue_card_time_info_spec.js +++ b/spec/frontend/issues_list/components/issue_card_time_info_spec.js @@ -24,6 +24,7 @@ describe('IssuesListApp component', () => { const findDueDate = () => wrapper.find('[data-testid="issuable-due-date"]'); const mountComponent = ({ + closedAt = null, dueDate = issue.dueDate, milestoneDueDate = issue.milestone.dueDate, milestoneStartDate = issue.milestone.startDate, @@ -37,6 +38,7 @@ describe('IssuesListApp component', () => { dueDate: milestoneDueDate, startDate: milestoneStartDate, }, + closedAt, dueDate, }, }, @@ -87,10 +89,23 @@ describe('IssuesListApp component', () => { }); describe('when in the past', () => { - it('renders in red', () => { - wrapper = mountComponent({ dueDate: new Date('2020-10-10') }); + describe('when issue is open', () => { + it('renders in red', () => { + wrapper = mountComponent({ dueDate: new Date('2020-10-10') }); - expect(findDueDate().classes()).toContain('gl-text-red-500'); + expect(findDueDate().classes()).toContain('gl-text-red-500'); + }); + }); + + describe('when issue is closed', () => { + it('does not render in red', () => { + wrapper = mountComponent({ + dueDate: new Date('2020-10-10'), + closedAt: '2020-09-05T13:06:25Z', + }); + + expect(findDueDate().classes()).not.toContain('gl-text-red-500'); + }); }); }); }); 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 846236e1fb5..0cb1092135f 100644 --- a/spec/frontend/issues_list/components/issues_list_app_spec.js +++ b/spec/frontend/issues_list/components/issues_list_app_spec.js @@ -7,6 +7,7 @@ import VueApollo from 'vue-apollo'; import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql'; import getIssuesCountQuery from 'ee_else_ce/issues_list/queries/get_issues_count.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import { @@ -17,7 +18,7 @@ import { getIssuesCountQueryResponse, } from 'jest/issues_list/mock_data'; import createFlash from '~/flash'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; import IssuableList from '~/issuable_list/components/issuable_list_root.vue'; @@ -35,6 +36,7 @@ import { TOKEN_TYPE_LABEL, TOKEN_TYPE_MILESTONE, TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_TYPE, TOKEN_TYPE_WEIGHT, urlSortParams, } from '~/issues_list/constants'; @@ -42,7 +44,7 @@ import eventHub from '~/issues_list/eventhub'; import { getSortOptions } from '~/issues_list/utils'; import axios from '~/lib/utils/axios_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; -import { setUrlParams } from '~/lib/utils/url_utility'; +import { joinPaths } from '~/lib/utils/url_utility'; jest.mock('~/flash'); jest.mock('~/lib/utils/scroll_utils', () => ({ @@ -115,11 +117,11 @@ describe('IssuesListApp component', () => { }; beforeEach(() => { + setWindowLocation(TEST_HOST); axiosMock = new AxiosMockAdapter(axios); }); afterEach(() => { - global.jsdom.reconfigure({ url: TEST_HOST }); axiosMock.reset(); wrapper.destroy(); }); @@ -186,7 +188,7 @@ describe('IssuesListApp component', () => { const search = '?search=refactor&sort=created_date&state=opened'; beforeEach(() => { - global.jsdom.reconfigure({ url: `${TEST_HOST}${search}` }); + setWindowLocation(search); wrapper = mountComponent({ provide: { ...defaultProvide, isSignedIn: true }, @@ -258,7 +260,7 @@ describe('IssuesListApp component', () => { describe('initial url params', () => { describe('due_date', () => { it('is set from the url params', () => { - global.jsdom.reconfigure({ url: `${TEST_HOST}?${PARAM_DUE_DATE}=${DUE_DATE_OVERDUE}` }); + setWindowLocation(`?${PARAM_DUE_DATE}=${DUE_DATE_OVERDUE}`); wrapper = mountComponent(); @@ -268,7 +270,7 @@ describe('IssuesListApp component', () => { describe('search', () => { it('is set from the url params', () => { - global.jsdom.reconfigure({ url: `${TEST_HOST}${locationSearch}` }); + setWindowLocation(locationSearch); wrapper = mountComponent(); @@ -278,9 +280,7 @@ describe('IssuesListApp component', () => { describe('sort', () => { it.each(Object.keys(urlSortParams))('is set as %s from the url params', (sortKey) => { - global.jsdom.reconfigure({ - url: setUrlParams({ sort: urlSortParams[sortKey] }, TEST_HOST), - }); + setWindowLocation(`?sort=${urlSortParams[sortKey]}`); wrapper = mountComponent(); @@ -297,7 +297,7 @@ describe('IssuesListApp component', () => { it('is set from the url params', () => { const initialState = IssuableStates.All; - global.jsdom.reconfigure({ url: setUrlParams({ state: initialState }, TEST_HOST) }); + setWindowLocation(`?state=${initialState}`); wrapper = mountComponent(); @@ -307,7 +307,7 @@ describe('IssuesListApp component', () => { describe('filter tokens', () => { it('is set from the url params', () => { - global.jsdom.reconfigure({ url: `${TEST_HOST}${locationSearch}` }); + setWindowLocation(locationSearch); wrapper = mountComponent(); @@ -347,7 +347,7 @@ describe('IssuesListApp component', () => { describe('when there are issues', () => { describe('when search returns no results', () => { beforeEach(() => { - global.jsdom.reconfigure({ url: `${TEST_HOST}?search=no+results` }); + setWindowLocation(`?search=no+results`); wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount }); }); @@ -377,9 +377,7 @@ describe('IssuesListApp component', () => { describe('when "Closed" tab has no issues', () => { beforeEach(() => { - global.jsdom.reconfigure({ - url: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST), - }); + setWindowLocation(`?state=${IssuableStates.Closed}`); wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount }); }); @@ -560,6 +558,7 @@ describe('IssuesListApp component', () => { { type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors }, { type: TOKEN_TYPE_MILESTONE }, { type: TOKEN_TYPE_LABEL }, + { type: TOKEN_TYPE_TYPE }, { type: TOKEN_TYPE_MY_REACTION }, { type: TOKEN_TYPE_CONFIDENTIAL }, { type: TOKEN_TYPE_ITERATION }, @@ -625,25 +624,25 @@ describe('IssuesListApp component', () => { const issueOne = { ...defaultQueryResponse.data.project.issues.nodes[0], id: 'gid://gitlab/Issue/1', - iid: 101, + iid: '101', title: 'Issue one', }; const issueTwo = { ...defaultQueryResponse.data.project.issues.nodes[0], id: 'gid://gitlab/Issue/2', - iid: 102, + iid: '102', title: 'Issue two', }; const issueThree = { ...defaultQueryResponse.data.project.issues.nodes[0], id: 'gid://gitlab/Issue/3', - iid: 103, + iid: '103', title: 'Issue three', }; const issueFour = { ...defaultQueryResponse.data.project.issues.nodes[0], id: 'gid://gitlab/Issue/4', - iid: 104, + iid: '104', title: 'Issue four', }; const response = { @@ -662,9 +661,36 @@ describe('IssuesListApp component', () => { jest.runOnlyPendingTimers(); }); + describe('when successful', () => { + describe.each` + description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId + ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id} + ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id} + ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id} + ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null} + `( + 'when moving issue $description', + ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => { + it('makes API call to reorder the issue', async () => { + findIssuableList().vm.$emit('reorder', { oldIndex, newIndex }); + + await waitForPromises(); + + expect(axiosMock.history.put[0]).toMatchObject({ + url: joinPaths(defaultProvide.issuesPath, issueToMove.iid, 'reorder'), + data: JSON.stringify({ + move_before_id: getIdFromGraphQLId(moveBeforeId), + move_after_id: getIdFromGraphQLId(moveAfterId), + }), + }); + }); + }, + ); + }); + describe('when unsuccessful', () => { it('displays an error message', async () => { - axiosMock.onPut(`${defaultProvide.issuesPath}/${issueOne.iid}/reorder`).reply(500); + axiosMock.onPut(joinPaths(defaultProvide.issuesPath, issueOne.iid, 'reorder')).reply(500); findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 }); diff --git a/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js b/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js index 0c96b95a61f..633799816d8 100644 --- a/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js +++ b/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js @@ -43,10 +43,12 @@ describe('JiraIssuesImportStatus', () => { wrapper = null; }); - describe('when Jira import is not in progress', () => { - it('does not show an alert', () => { + describe('when Jira import is neither in progress nor finished', () => { + beforeEach(() => { wrapper = mountComponent(); + }); + it('does not show an alert', () => { expect(wrapper.find(GlAlert).exists()).toBe(false); }); }); diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js index fd59241fd1d..d3f3f2f9f23 100644 --- a/spec/frontend/issues_list/mock_data.js +++ b/spec/frontend/issues_list/mock_data.js @@ -23,6 +23,7 @@ export const getIssuesQueryResponse = { downvotes: 2, dueDate: '2021-05-29', humanTimeEstimate: null, + mergeRequestsCount: false, moved: false, title: 'Issue title', updatedAt: '2021-05-22T04:08:01Z', @@ -106,8 +107,11 @@ export const locationSearch = [ export const locationSearchWithSpecialValues = [ 'assignee_id=123', 'assignee_username=bart', + 'type[]=issue', + 'type[]=incident', 'my_reaction_emoji=None', 'iteration_id=Current', + 'milestone_title=Upcoming', 'epic_id=None', 'weight=None', ].join('&'); @@ -140,8 +144,11 @@ export const filteredTokens = [ export const filteredTokensWithSpecialValues = [ { type: 'assignee_username', value: { data: '123', operator: OPERATOR_IS } }, { type: 'assignee_username', value: { data: 'bart', operator: OPERATOR_IS } }, + { type: 'type', value: { data: 'issue', operator: OPERATOR_IS } }, + { type: 'type', value: { data: 'incident', operator: OPERATOR_IS } }, { type: 'my_reaction_emoji', value: { data: 'None', operator: OPERATOR_IS } }, { type: 'iteration', value: { data: 'Current', operator: OPERATOR_IS } }, + { type: 'milestone', value: { data: 'Upcoming', operator: OPERATOR_IS } }, { type: 'epic_id', value: { data: 'None', operator: OPERATOR_IS } }, { type: 'weight', value: { data: 'None', operator: OPERATOR_IS } }, ]; @@ -170,8 +177,10 @@ export const apiParams = { export const apiParamsWithSpecialValues = { assigneeId: '123', assigneeUsernames: 'bart', + types: ['ISSUE', 'INCIDENT'], myReactionEmoji: 'None', iterationWildcardId: 'CURRENT', + milestoneWildcardId: 'UPCOMING', epicId: 'None', weight: 'None', }; @@ -198,8 +207,10 @@ export const urlParams = { export const urlParamsWithSpecialValues = { assignee_id: '123', 'assignee_username[]': 'bart', + 'type[]': ['issue', 'incident'], my_reaction_emoji: 'None', iteration_id: 'Current', + milestone_title: 'Upcoming', epic_id: 'None', weight: 'None', }; diff --git a/spec/frontend/issues_list/utils_spec.js b/spec/frontend/issues_list/utils_spec.js index b7863068570..458776d9ec5 100644 --- a/spec/frontend/issues_list/utils_spec.js +++ b/spec/frontend/issues_list/utils_spec.js @@ -8,17 +8,36 @@ import { urlParams, urlParamsWithSpecialValues, } from 'jest/issues_list/mock_data'; -import { DUE_DATE_VALUES, urlSortParams } from '~/issues_list/constants'; +import { + defaultPageSizeParams, + DUE_DATE_VALUES, + largePageSizeParams, + RELATIVE_POSITION_ASC, + urlSortParams, +} from '~/issues_list/constants'; import { convertToApiParams, convertToSearchQuery, convertToUrlParams, getDueDateValue, getFilterTokens, + getInitialPageParams, getSortKey, getSortOptions, } from '~/issues_list/utils'; +describe('getInitialPageParams', () => { + it.each(Object.keys(urlSortParams))( + 'returns the correct page params for sort key %s', + (sortKey) => { + const expectedPageParams = + sortKey === RELATIVE_POSITION_ASC ? largePageSizeParams : defaultPageSizeParams; + + expect(getInitialPageParams(sortKey)).toBe(expectedPageParams); + }, + ); +}); + describe('getSortKey', () => { it.each(Object.keys(urlSortParams))('returns %s given the correct inputs', (sortKey) => { const sort = urlSortParams[sortKey]; diff --git a/spec/frontend/jira_connect/branches/components/new_branch_form_spec.js b/spec/frontend/jira_connect/branches/components/new_branch_form_spec.js new file mode 100644 index 00000000000..7326b84ad54 --- /dev/null +++ b/spec/frontend/jira_connect/branches/components/new_branch_form_spec.js @@ -0,0 +1,236 @@ +import { GlAlert, GlForm, GlFormInput, GlButton } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import NewBranchForm from '~/jira_connect/branches/components/new_branch_form.vue'; +import ProjectDropdown from '~/jira_connect/branches/components/project_dropdown.vue'; +import SourceBranchDropdown from '~/jira_connect/branches/components/source_branch_dropdown.vue'; +import { + CREATE_BRANCH_ERROR_GENERIC, + CREATE_BRANCH_ERROR_WITH_CONTEXT, +} from '~/jira_connect/branches/constants'; +import createBranchMutation from '~/jira_connect/branches/graphql/mutations/create_branch.mutation.graphql'; + +const mockProject = { + id: 'test', + fullPath: 'test-path', + repository: { + branchNames: ['main', 'f-test', 'release'], + rootRef: 'main', + }, +}; +const mockCreateBranchMutationResponse = { + data: { + createBranch: { + clientMutationId: 1, + errors: [], + }, + }, +}; +const mockCreateBranchMutationResponseWithErrors = { + data: { + createBranch: { + clientMutationId: 1, + errors: ['everything is broken, sorry.'], + }, + }, +}; +const mockCreateBranchMutationSuccess = jest + .fn() + .mockResolvedValue(mockCreateBranchMutationResponse); +const mockCreateBranchMutationWithErrors = jest + .fn() + .mockResolvedValue(mockCreateBranchMutationResponseWithErrors); +const mockCreateBranchMutationFailed = jest.fn().mockRejectedValue(new Error('GraphQL error')); +const mockMutationLoading = jest.fn().mockReturnValue(new Promise(() => {})); + +const localVue = createLocalVue(); + +describe('NewBranchForm', () => { + let wrapper; + + const findSourceBranchDropdown = () => wrapper.findComponent(SourceBranchDropdown); + const findProjectDropdown = () => wrapper.findComponent(ProjectDropdown); + const findAlert = () => wrapper.findComponent(GlAlert); + const findForm = () => wrapper.findComponent(GlForm); + const findInput = () => wrapper.findComponent(GlFormInput); + const findButton = () => wrapper.findComponent(GlButton); + + const completeForm = async () => { + await findInput().vm.$emit('input', 'cool-branch-name'); + await findProjectDropdown().vm.$emit('change', mockProject); + await findSourceBranchDropdown().vm.$emit('change', 'source-branch'); + }; + + function createMockApolloProvider({ + mockCreateBranchMutation = mockCreateBranchMutationSuccess, + } = {}) { + localVue.use(VueApollo); + + const mockApollo = createMockApollo([[createBranchMutation, mockCreateBranchMutation]]); + + return mockApollo; + } + + function createComponent({ mockApollo, provide } = {}) { + wrapper = shallowMount(NewBranchForm, { + localVue, + apolloProvider: mockApollo || createMockApolloProvider(), + provide: { + initialBranchName: '', + ...provide, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when selecting items from dropdowns', () => { + describe('when a project is selected', () => { + it('sets the `selectedProject` prop for ProjectDropdown and SourceBranchDropdown', async () => { + createComponent(); + + const projectDropdown = findProjectDropdown(); + await projectDropdown.vm.$emit('change', mockProject); + + expect(projectDropdown.props('selectedProject')).toEqual(mockProject); + expect(findSourceBranchDropdown().props('selectedProject')).toEqual(mockProject); + }); + }); + + describe('when a source branch is selected', () => { + it('sets the `selectedBranchName` prop for SourceBranchDropdown', async () => { + createComponent(); + + const mockBranchName = 'main'; + const sourceBranchDropdown = findSourceBranchDropdown(); + await sourceBranchDropdown.vm.$emit('change', mockBranchName); + + expect(sourceBranchDropdown.props('selectedBranchName')).toBe(mockBranchName); + }); + }); + }); + + describe('when submitting form', () => { + describe('when form submission is loading', () => { + it('sets submit button `loading` prop to `true`', async () => { + createComponent({ + mockApollo: createMockApolloProvider({ + mockCreateBranchMutation: mockMutationLoading, + }), + }); + + await completeForm(); + + await findForm().vm.$emit('submit', new Event('submit')); + await waitForPromises(); + + expect(findButton().props('loading')).toBe(true); + }); + }); + + describe('when form submission is successful', () => { + beforeEach(async () => { + createComponent(); + + await completeForm(); + + await findForm().vm.$emit('submit', new Event('submit')); + await waitForPromises(); + }); + + it('emits `success` event', () => { + expect(wrapper.emitted('success')).toBeTruthy(); + }); + + it('called `createBranch` mutation correctly', () => { + expect(mockCreateBranchMutationSuccess).toHaveBeenCalledWith({ + name: 'cool-branch-name', + projectPath: mockProject.fullPath, + ref: 'source-branch', + }); + }); + + it('sets submit button `loading` prop to `false`', () => { + expect(findButton().props('loading')).toBe(false); + }); + }); + + describe('when form submission fails', () => { + describe.each` + scenario | mutation | alertTitle | alertText + ${'with errors-as-data'} | ${mockCreateBranchMutationWithErrors} | ${CREATE_BRANCH_ERROR_WITH_CONTEXT} | ${mockCreateBranchMutationResponseWithErrors.data.createBranch.errors[0]} + ${'top-level error'} | ${mockCreateBranchMutationFailed} | ${''} | ${CREATE_BRANCH_ERROR_GENERIC} + `('', ({ mutation, alertTitle, alertText }) => { + beforeEach(async () => { + createComponent({ + mockApollo: createMockApolloProvider({ + mockCreateBranchMutation: mutation, + }), + }); + + await completeForm(); + + await findForm().vm.$emit('submit', new Event('submit')); + await waitForPromises(); + }); + + it('displays an alert', () => { + const alert = findAlert(); + expect(alert.exists()).toBe(true); + expect(alert.text()).toBe(alertText); + expect(alert.props()).toMatchObject({ title: alertTitle, variant: 'danger' }); + }); + + it('sets submit button `loading` prop to `false`', () => { + expect(findButton().props('loading')).toBe(false); + }); + }); + }); + }); + + describe('when `initialBranchName` is specified', () => { + it('sets value of branch name input to `initialBranchName` by default', () => { + const mockInitialBranchName = 'ap1-test-branch-name'; + + createComponent({ provide: { initialBranchName: mockInitialBranchName } }); + expect(findInput().attributes('value')).toBe(mockInitialBranchName); + }); + }); + + describe('error handling', () => { + describe.each` + component | componentName + ${SourceBranchDropdown} | ${'SourceBranchDropdown'} + ${ProjectDropdown} | ${'ProjectDropdown'} + `('when $componentName emits error', ({ component }) => { + const mockErrorMessage = 'oh noes!'; + + beforeEach(async () => { + createComponent(); + await wrapper.findComponent(component).vm.$emit('error', { message: mockErrorMessage }); + }); + + it('displays an alert', () => { + const alert = findAlert(); + expect(alert.exists()).toBe(true); + expect(alert.text()).toBe(mockErrorMessage); + expect(alert.props('variant')).toBe('danger'); + }); + + describe('when alert is dismissed', () => { + it('hides alert', async () => { + const alert = findAlert(); + expect(alert.exists()).toBe(true); + + await alert.vm.$emit('dismiss'); + + expect(alert.exists()).toBe(false); + }); + }); + }); + }); +}); diff --git a/spec/frontend/jira_connect/branches/pages/index_spec.js b/spec/frontend/jira_connect/branches/pages/index_spec.js new file mode 100644 index 00000000000..92976dd28da --- /dev/null +++ b/spec/frontend/jira_connect/branches/pages/index_spec.js @@ -0,0 +1,65 @@ +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import NewBranchForm from '~/jira_connect/branches/components/new_branch_form.vue'; +import { + I18N_PAGE_TITLE_WITH_BRANCH_NAME, + I18N_PAGE_TITLE_DEFAULT, +} from '~/jira_connect/branches/constants'; +import JiraConnectNewBranchPage from '~/jira_connect/branches/pages/index.vue'; +import { sprintf } from '~/locale'; + +describe('NewBranchForm', () => { + let wrapper; + + const findPageTitle = () => wrapper.find('h1'); + const findNewBranchForm = () => wrapper.findComponent(NewBranchForm); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + function createComponent({ provide } = {}) { + wrapper = shallowMount(JiraConnectNewBranchPage, { + provide: { + initialBranchName: '', + successStateSvgPath: '', + ...provide, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('page title', () => { + it.each` + initialBranchName | pageTitle + ${undefined} | ${I18N_PAGE_TITLE_DEFAULT} + ${'ap1-test-button'} | ${sprintf(I18N_PAGE_TITLE_WITH_BRANCH_NAME, { jiraIssue: 'ap1-test-button' })} + `( + 'sets page title to "$pageTitle" when initial branch name is "$initialBranchName"', + ({ initialBranchName, pageTitle }) => { + createComponent({ provide: { initialBranchName } }); + + expect(findPageTitle().text()).toBe(pageTitle); + }, + ); + }); + + it('renders NewBranchForm by default', () => { + createComponent(); + + expect(findNewBranchForm().exists()).toBe(true); + expect(findEmptyState().exists()).toBe(false); + }); + + describe('when `sucesss` event emitted from NewBranchForm', () => { + it('renders the success state', async () => { + createComponent(); + + const newBranchForm = findNewBranchForm(); + await newBranchForm.vm.$emit('success'); + + expect(findNewBranchForm().exists()).toBe(false); + expect(findEmptyState().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/jira_connect/api_spec.js b/spec/frontend/jira_connect/subscriptions/api_spec.js index 88922999715..57b11bdbc27 100644 --- a/spec/frontend/jira_connect/api_spec.js +++ b/spec/frontend/jira_connect/subscriptions/api_spec.js @@ -1,10 +1,10 @@ import MockAdapter from 'axios-mock-adapter'; -import { addSubscription, removeSubscription, fetchGroups } from '~/jira_connect/api'; -import { getJwt } from '~/jira_connect/utils'; +import { addSubscription, removeSubscription, fetchGroups } from '~/jira_connect/subscriptions/api'; +import { getJwt } from '~/jira_connect/subscriptions/utils'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; -jest.mock('~/jira_connect/utils', () => ({ +jest.mock('~/jira_connect/subscriptions/utils', () => ({ getJwt: jest.fn().mockResolvedValue('jwt'), })); diff --git a/spec/frontend/jira_connect/components/__snapshots__/group_item_name_spec.js.snap b/spec/frontend/jira_connect/subscriptions/components/__snapshots__/group_item_name_spec.js.snap index 21c903f064d..21c903f064d 100644 --- a/spec/frontend/jira_connect/components/__snapshots__/group_item_name_spec.js.snap +++ b/spec/frontend/jira_connect/subscriptions/components/__snapshots__/group_item_name_spec.js.snap diff --git a/spec/frontend/jira_connect/components/app_spec.js b/spec/frontend/jira_connect/subscriptions/components/app_spec.js index e0d61d8209b..8915a7697a5 100644 --- a/spec/frontend/jira_connect/components/app_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/app_spec.js @@ -1,12 +1,12 @@ import { GlAlert, GlButton, GlModal, GlLink } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; -import JiraConnectApp from '~/jira_connect/components/app.vue'; -import createStore from '~/jira_connect/store'; -import { SET_ALERT } from '~/jira_connect/store/mutation_types'; +import JiraConnectApp from '~/jira_connect/subscriptions/components/app.vue'; +import createStore from '~/jira_connect/subscriptions/store'; +import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types'; import { __ } from '~/locale'; -jest.mock('~/jira_connect/utils', () => ({ +jest.mock('~/jira_connect/subscriptions/utils', () => ({ retrieveAlert: jest.fn().mockReturnValue({ message: 'error message' }), getLocation: jest.fn(), })); diff --git a/spec/frontend/jira_connect/components/group_item_name_spec.js b/spec/frontend/jira_connect/subscriptions/components/group_item_name_spec.js index ea0067f8ed1..b5fe08486b1 100644 --- a/spec/frontend/jira_connect/components/group_item_name_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/group_item_name_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import GroupItemName from '~/jira_connect/components/group_item_name.vue'; +import GroupItemName from '~/jira_connect/subscriptions/components/group_item_name.vue'; import { mockGroup1 } from '../mock_data'; describe('GroupItemName', () => { diff --git a/spec/frontend/jira_connect/components/groups_list_item_spec.js b/spec/frontend/jira_connect/subscriptions/components/groups_list_item_spec.js index bcc27cc2898..b69435df83a 100644 --- a/spec/frontend/jira_connect/components/groups_list_item_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/groups_list_item_spec.js @@ -2,13 +2,13 @@ import { GlButton } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; -import * as JiraConnectApi from '~/jira_connect/api'; -import GroupItemName from '~/jira_connect/components/group_item_name.vue'; -import GroupsListItem from '~/jira_connect/components/groups_list_item.vue'; -import { persistAlert, reloadPage } from '~/jira_connect/utils'; +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/groups_list_item.vue'; +import { persistAlert, reloadPage } from '~/jira_connect/subscriptions/utils'; import { mockGroup1 } from '../mock_data'; -jest.mock('~/jira_connect/utils'); +jest.mock('~/jira_connect/subscriptions/utils'); describe('GroupsListItem', () => { let wrapper; diff --git a/spec/frontend/jira_connect/components/groups_list_spec.js b/spec/frontend/jira_connect/subscriptions/components/groups_list_spec.js index d583fb68771..d3a9a3bfd41 100644 --- a/spec/frontend/jira_connect/components/groups_list_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/groups_list_spec.js @@ -2,10 +2,10 @@ import { GlAlert, GlLoadingIcon, GlSearchBoxByType, GlPagination } from '@gitlab import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { fetchGroups } from '~/jira_connect/api'; -import GroupsList from '~/jira_connect/components/groups_list.vue'; -import GroupsListItem from '~/jira_connect/components/groups_list_item.vue'; -import { DEFAULT_GROUPS_PER_PAGE } from '~/jira_connect/constants'; +import { fetchGroups } from '~/jira_connect/subscriptions/api'; +import GroupsList from '~/jira_connect/subscriptions/components/groups_list.vue'; +import GroupsListItem from '~/jira_connect/subscriptions/components/groups_list_item.vue'; +import { DEFAULT_GROUPS_PER_PAGE } from '~/jira_connect/subscriptions/constants'; import { mockGroup1, mockGroup2 } from '../mock_data'; const createMockGroup = (groupId) => { @@ -19,7 +19,7 @@ const createMockGroups = (count) => { return [...new Array(count)].map((_, idx) => createMockGroup(idx)); }; -jest.mock('~/jira_connect/api', () => { +jest.mock('~/jira_connect/subscriptions/api', () => { return { fetchGroups: jest.fn(), }; diff --git a/spec/frontend/jira_connect/components/subscriptions_list_spec.js b/spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js index ff86969367d..32b43765843 100644 --- a/spec/frontend/jira_connect/components/subscriptions_list_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js @@ -2,14 +2,14 @@ import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; -import * as JiraConnectApi from '~/jira_connect/api'; -import SubscriptionsList from '~/jira_connect/components/subscriptions_list.vue'; -import createStore from '~/jira_connect/store'; -import { SET_ALERT } from '~/jira_connect/store/mutation_types'; -import { reloadPage } from '~/jira_connect/utils'; +import * as JiraConnectApi from '~/jira_connect/subscriptions/api'; +import SubscriptionsList from '~/jira_connect/subscriptions/components/subscriptions_list.vue'; +import createStore from '~/jira_connect/subscriptions/store'; +import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types'; +import { reloadPage } from '~/jira_connect/subscriptions/utils'; import { mockSubscription } from '../mock_data'; -jest.mock('~/jira_connect/utils'); +jest.mock('~/jira_connect/subscriptions/utils'); describe('SubscriptionsList', () => { let wrapper; diff --git a/spec/frontend/jira_connect/index_spec.js b/spec/frontend/jira_connect/subscriptions/index_spec.js index 0161cfa0273..786f3b4a7d3 100644 --- a/spec/frontend/jira_connect/index_spec.js +++ b/spec/frontend/jira_connect/subscriptions/index_spec.js @@ -1,6 +1,6 @@ -import { initJiraConnect } from '~/jira_connect'; +import { initJiraConnect } from '~/jira_connect/subscriptions'; -jest.mock('~/jira_connect/utils', () => ({ +jest.mock('~/jira_connect/subscriptions/utils', () => ({ getLocation: jest.fn().mockResolvedValue('test/location'), })); diff --git a/spec/frontend/jira_connect/mock_data.js b/spec/frontend/jira_connect/subscriptions/mock_data.js index 5247a3dc522..5247a3dc522 100644 --- a/spec/frontend/jira_connect/mock_data.js +++ b/spec/frontend/jira_connect/subscriptions/mock_data.js diff --git a/spec/frontend/jira_connect/store/mutations_spec.js b/spec/frontend/jira_connect/subscriptions/store/mutations_spec.js index 584b17b36f7..84a33dbf0b5 100644 --- a/spec/frontend/jira_connect/store/mutations_spec.js +++ b/spec/frontend/jira_connect/subscriptions/store/mutations_spec.js @@ -1,5 +1,5 @@ -import mutations from '~/jira_connect/store/mutations'; -import state from '~/jira_connect/store/state'; +import mutations from '~/jira_connect/subscriptions/store/mutations'; +import state from '~/jira_connect/subscriptions/store/state'; describe('JiraConnect store mutations', () => { let localState; diff --git a/spec/frontend/jira_connect/utils_spec.js b/spec/frontend/jira_connect/subscriptions/utils_spec.js index 7eae870478d..2dd95de1b8c 100644 --- a/spec/frontend/jira_connect/utils_spec.js +++ b/spec/frontend/jira_connect/subscriptions/utils_spec.js @@ -1,6 +1,6 @@ import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; -import { ALERT_LOCALSTORAGE_KEY } from '~/jira_connect/constants'; +import { ALERT_LOCALSTORAGE_KEY } from '~/jira_connect/subscriptions/constants'; import { persistAlert, retrieveAlert, @@ -8,7 +8,7 @@ import { getLocation, reloadPage, sizeToParent, -} from '~/jira_connect/utils'; +} from '~/jira_connect/subscriptions/utils'; describe('JiraConnect utils', () => { describe('alert utils', () => { diff --git a/spec/frontend/jobs/components/log/mock_data.js b/spec/frontend/jobs/components/log/mock_data.js index 76c35703106..3ff0bd73581 100644 --- a/spec/frontend/jobs/components/log/mock_data.js +++ b/spec/frontend/jobs/components/log/mock_data.js @@ -123,6 +123,15 @@ export const multipleCollapsibleSectionsMockData = [ }, ]; +export const backwardsCompatibilityTrace = [ + { + offset: 2365, + content: [], + section: 'download-artifacts', + section_duration: '00:01', + }, +]; + export const originalTrace = [ { offset: 1, diff --git a/spec/frontend/jobs/components/stages_dropdown_spec.js b/spec/frontend/jobs/components/stages_dropdown_spec.js index b75d1707a8d..b0e95a2d5b6 100644 --- a/spec/frontend/jobs/components/stages_dropdown_spec.js +++ b/spec/frontend/jobs/components/stages_dropdown_spec.js @@ -20,6 +20,7 @@ describe('Stages Dropdown', () => { const findPipelineInfoText = () => wrapper.findByTestId('pipeline-info').text(); const findPipelinePath = () => wrapper.findByTestId('pipeline-path').attributes('href'); const findMRLinkPath = () => wrapper.findByTestId('mr-link').attributes('href'); + const findCopySourceBranchBtn = () => wrapper.findByTestId('copy-source-ref-link'); const findSourceBranchLinkPath = () => wrapper.findByTestId('source-branch-link').attributes('href'); const findTargetBranchLinkPath = () => @@ -70,6 +71,10 @@ describe('Stages Dropdown', () => { expect(actual).toBe(expected); }); + + it(`renders the source ref copy button`, () => { + expect(findCopySourceBranchBtn().exists()).toBe(true); + }); }); describe('with an "attached" merge request pipeline', () => { @@ -103,6 +108,10 @@ describe('Stages Dropdown', () => { mockPipelineWithAttachedMR.merge_request.target_branch_path, ); }); + + it(`renders the source ref copy button`, () => { + expect(findCopySourceBranchBtn().exists()).toBe(true); + }); }); describe('with a detached merge request pipeline', () => { @@ -130,5 +139,9 @@ describe('Stages Dropdown', () => { mockPipelineDetached.merge_request.source_branch_path, ); }); + + it(`renders the source ref copy button`, () => { + expect(findCopySourceBranchBtn().exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/jobs/store/utils_spec.js b/spec/frontend/jobs/store/utils_spec.js index 35ac2945ab5..0c5fa150002 100644 --- a/spec/frontend/jobs/store/utils_spec.js +++ b/spec/frontend/jobs/store/utils_spec.js @@ -19,6 +19,7 @@ import { collapsibleTrace, collapsibleTraceIncremental, multipleCollapsibleSectionsMockData, + backwardsCompatibilityTrace, } from '../components/log/mock_data'; describe('Jobs Store Utils', () => { @@ -297,6 +298,21 @@ describe('Jobs Store Utils', () => { expect(result.parsedLines[1].lines).toEqual(expect.arrayContaining(innerSection)); }); }); + + describe('backwards compatibility', () => { + beforeEach(() => { + result = logLinesParser(backwardsCompatibilityTrace); + }); + + it('should return an object with a parsedLines prop', () => { + expect(result).toEqual( + expect.objectContaining({ + parsedLines: expect.any(Array), + }), + ); + expect(result.parsedLines).toHaveLength(1); + }); + }); }); describe('findOffsetAndRemove', () => { diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index 66d0faa95e7..c8ac7ffc9d9 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -1,3 +1,4 @@ +import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import * as urlUtils from '~/lib/utils/url_utility'; @@ -16,24 +17,11 @@ const shas = { ], }; -const setWindowLocation = (value) => { - Object.defineProperty(window, 'location', { - writable: true, - value, - }); -}; +beforeEach(() => { + setWindowLocation(TEST_HOST); +}); describe('URL utility', () => { - let originalLocation; - - beforeAll(() => { - originalLocation = window.location; - }); - - afterAll(() => { - window.location = originalLocation; - }); - describe('webIDEUrl', () => { afterEach(() => { gon.relative_url_root = ''; @@ -68,14 +56,7 @@ describe('URL utility', () => { describe('getParameterValues', () => { beforeEach(() => { - setWindowLocation({ - href: 'https://gitlab.com?test=passing&multiple=1&multiple=2', - // make our fake location act like real window.location.toString - // URL() (used in getParameterValues) does this if passed an object - toString() { - return this.href; - }, - }); + setWindowLocation('https://gitlab.com?test=passing&multiple=1&multiple=2'); }); it('returns empty array for no params', () => { @@ -330,9 +311,7 @@ describe('URL utility', () => { describe('doesHashExistInUrl', () => { beforeEach(() => { - setWindowLocation({ - hash: 'https://gitlab.com/gitlab-org/gitlab-test/issues/1#note_1', - }); + setWindowLocation('#note_1'); }); it('should return true when the given string exists in the URL hash', () => { @@ -442,10 +421,7 @@ describe('URL utility', () => { describe('getBaseURL', () => { beforeEach(() => { - setWindowLocation({ - protocol: 'https:', - host: 'gitlab.com', - }); + setWindowLocation('https://gitlab.com'); }); it('returns correct base URL', () => { @@ -637,10 +613,7 @@ describe('URL utility', () => { ${'http:'} | ${'ws:'} ${'https:'} | ${'wss:'} `('returns "$expectation" with "$protocol" protocol', ({ protocol, expectation }) => { - setWindowLocation({ - protocol, - host: 'example.com', - }); + setWindowLocation(`${protocol}//example.com`); expect(urlUtils.getWebSocketProtocol()).toEqual(expectation); }); @@ -648,10 +621,7 @@ describe('URL utility', () => { describe('getWebSocketUrl', () => { it('joins location host to path', () => { - setWindowLocation({ - protocol: 'http:', - host: 'example.com', - }); + setWindowLocation('http://example.com'); const path = '/lorem/ipsum?a=bc'; @@ -700,21 +670,23 @@ describe('URL utility', () => { describe('queryToObject', () => { it.each` - case | query | options | result - ${'converts query'} | ${'?one=1&two=2'} | ${undefined} | ${{ one: '1', two: '2' }} - ${'converts query without ?'} | ${'one=1&two=2'} | ${undefined} | ${{ one: '1', two: '2' }} - ${'removes undefined values'} | ${'?one=1&two=2&three'} | ${undefined} | ${{ one: '1', two: '2' }} - ${'overwrites values with same key and does not change key'} | ${'?one[]=1&one[]=2&two=2&two=3'} | ${undefined} | ${{ 'one[]': '2', two: '3' }} - ${'gathers values with the same array-key, strips `[]` from key'} | ${'?one[]=1&one[]=2&two=2&two=3'} | ${{ gatherArrays: true }} | ${{ one: ['1', '2'], two: '3' }} - ${'overwrites values with the same array-key name'} | ${'?one=1&one[]=2&two=2&two=3'} | ${{ gatherArrays: true }} | ${{ one: ['2'], two: '3' }} - ${'overwrites values with the same key name'} | ${'?one[]=1&one=2&two=2&two=3'} | ${{ gatherArrays: true }} | ${{ one: '2', two: '3' }} - ${'ignores plus symbols'} | ${'?search=a+b'} | ${{ legacySpacesDecode: true }} | ${{ search: 'a+b' }} - ${'ignores plus symbols in keys'} | ${'?search+term=a'} | ${{ legacySpacesDecode: true }} | ${{ 'search+term': 'a' }} - ${'ignores plus symbols when gathering arrays'} | ${'?search[]=a+b'} | ${{ gatherArrays: true, legacySpacesDecode: true }} | ${{ search: ['a+b'] }} - ${'replaces plus symbols with spaces'} | ${'?search=a+b'} | ${undefined} | ${{ search: 'a b' }} - ${'replaces plus symbols in keys with spaces'} | ${'?search+term=a'} | ${undefined} | ${{ 'search term': 'a' }} - ${'replaces plus symbols when gathering arrays'} | ${'?search[]=a+b'} | ${{ gatherArrays: true }} | ${{ search: ['a b'] }} - ${'replaces plus symbols when gathering arrays for values with same key'} | ${'?search[]=a+b&search[]=c+d'} | ${{ gatherArrays: true }} | ${{ search: ['a b', 'c d'] }} + case | query | options | result + ${'converts query'} | ${'?one=1&two=2'} | ${undefined} | ${{ one: '1', two: '2' }} + ${'converts query without ?'} | ${'one=1&two=2'} | ${undefined} | ${{ one: '1', two: '2' }} + ${'removes undefined values'} | ${'?one=1&two=2&three'} | ${undefined} | ${{ one: '1', two: '2' }} + ${'overwrites values with same key and does not change key'} | ${'?one[]=1&one[]=2&two=2&two=3'} | ${undefined} | ${{ 'one[]': '2', two: '3' }} + ${'gathers values with the same array-key, strips `[]` from key'} | ${'?one[]=1&one[]=2&two=2&two=3'} | ${{ gatherArrays: true }} | ${{ one: ['1', '2'], two: '3' }} + ${'overwrites values with the same array-key name'} | ${'?one=1&one[]=2&two=2&two=3'} | ${{ gatherArrays: true }} | ${{ one: ['2'], two: '3' }} + ${'overwrites values with the same key name'} | ${'?one[]=1&one=2&two=2&two=3'} | ${{ gatherArrays: true }} | ${{ one: '2', two: '3' }} + ${'ignores plus symbols'} | ${'?search=a+b'} | ${{ legacySpacesDecode: true }} | ${{ search: 'a+b' }} + ${'ignores plus symbols in keys'} | ${'?search+term=a'} | ${{ legacySpacesDecode: true }} | ${{ 'search+term': 'a' }} + ${'ignores plus symbols when gathering arrays'} | ${'?search[]=a+b'} | ${{ gatherArrays: true, legacySpacesDecode: true }} | ${{ search: ['a+b'] }} + ${'replaces plus symbols with spaces'} | ${'?search=a+b'} | ${undefined} | ${{ search: 'a b' }} + ${'replaces plus symbols in keys with spaces'} | ${'?search+term=a'} | ${undefined} | ${{ 'search term': 'a' }} + ${'preserves square brackets in array params'} | ${'?search[]=a&search[]=b'} | ${{ gatherArrays: true }} | ${{ search: ['a', 'b'] }} + ${'decodes encoded square brackets in array params'} | ${'?search%5B%5D=a&search%5B%5D=b'} | ${{ gatherArrays: true }} | ${{ search: ['a', 'b'] }} + ${'replaces plus symbols when gathering arrays'} | ${'?search[]=a+b'} | ${{ gatherArrays: true }} | ${{ search: ['a b'] }} + ${'replaces plus symbols when gathering arrays for values with same key'} | ${'?search[]=a+b&search[]=c+d'} | ${{ gatherArrays: true }} | ${{ search: ['a b', 'c d'] }} `('$case', ({ query, options, result }) => { expect(urlUtils.queryToObject(query, options)).toEqual(result); }); @@ -724,32 +696,32 @@ describe('URL utility', () => { const { getParameterByName } = urlUtils; it('should return valid parameter', () => { - setWindowLocation({ search: '?scope=all&p=2' }); + setWindowLocation('?scope=all&p=2'); expect(getParameterByName('p')).toEqual('2'); expect(getParameterByName('scope')).toBe('all'); }); it('should return invalid parameter', () => { - setWindowLocation({ search: '?scope=all&p=2' }); + setWindowLocation('?scope=all&p=2'); expect(getParameterByName('fakeParameter')).toBe(null); }); it('should return a parameter with spaces', () => { - setWindowLocation({ search: '?search=my terms' }); + setWindowLocation('?search=my terms'); expect(getParameterByName('search')).toBe('my terms'); }); it('should return a parameter with encoded spaces', () => { - setWindowLocation({ search: '?search=my%20terms' }); + setWindowLocation('?search=my%20terms'); expect(getParameterByName('search')).toBe('my terms'); }); it('should return a parameter with plus signs as spaces', () => { - setWindowLocation({ search: '?search=my+terms' }); + setWindowLocation('?search=my+terms'); expect(getParameterByName('search')).toBe('my terms'); }); @@ -842,18 +814,20 @@ describe('URL utility', () => { }); describe('urlIsDifferent', () => { + const current = 'http://current.test/'; + beforeEach(() => { - setWindowLocation('current'); + setWindowLocation(current); }); it('should compare against the window location if no compare value is provided', () => { expect(urlUtils.urlIsDifferent('different')).toBeTruthy(); - expect(urlUtils.urlIsDifferent('current')).toBeFalsy(); + expect(urlUtils.urlIsDifferent(current)).toBeFalsy(); }); it('should use the provided compare value', () => { - expect(urlUtils.urlIsDifferent('different', 'current')).toBeTruthy(); - expect(urlUtils.urlIsDifferent('current', 'current')).toBeFalsy(); + expect(urlUtils.urlIsDifferent('different', current)).toBeTruthy(); + expect(urlUtils.urlIsDifferent(current, current)).toBeFalsy(); }); }); @@ -944,9 +918,8 @@ describe('URL utility', () => { it.each([[httpProtocol], [httpsProtocol]])( 'when no url passed, returns correct protocol for %i from window location', (protocol) => { - setWindowLocation({ - protocol, - }); + setWindowLocation(`${protocol}//test.host`); + expect(urlUtils.getHTTPProtocol()).toBe(protocol.slice(0, -1)); }, ); @@ -979,10 +952,8 @@ describe('URL utility', () => { describe('getURLOrigin', () => { it('when no url passed, returns correct origin from window location', () => { - const origin = 'https://foo.bar'; - - setWindowLocation({ origin }); - expect(urlUtils.getURLOrigin()).toBe(origin); + setWindowLocation('https://user:pass@origin.test:1234/foo/bar?foo=1#bar'); + expect(urlUtils.getURLOrigin()).toBe('https://origin.test:1234'); }); it.each` @@ -1032,10 +1003,6 @@ describe('URL utility', () => { // eslint-disable-next-line no-script-url const javascriptUrl = 'javascript:alert(1)'; - beforeEach(() => { - setWindowLocation({ origin: TEST_HOST }); - }); - it.each` url | expected ${TEST_HOST} | ${true} diff --git a/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js index e7a99a96da6..79252456f67 100644 --- a/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js +++ b/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js @@ -37,7 +37,7 @@ describe('InviteActionButtons', () => { }); it('sets props correctly', () => { - expect(findRemoveMemberButton().props()).toEqual({ + expect(findRemoveMemberButton().props()).toMatchObject({ memberId: member.id, memberType: null, message: `Are you sure you want to revoke the invitation for ${member.invite.email} to join "${member.source.fullName}"`, diff --git a/spec/frontend/members/components/action_buttons/remove_member_button_spec.js b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js index 4ff12f7fa97..d8453d453e7 100644 --- a/spec/frontend/members/components/action_buttons/remove_member_button_spec.js +++ b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js @@ -1,6 +1,8 @@ +import { GlButton } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { modalData } from 'jest/members/mock_data'; import RemoveMemberButton from '~/members/components/action_buttons/remove_member_button.vue'; import { MEMBER_TYPES } from '~/members/constants'; @@ -10,6 +12,10 @@ localVue.use(Vuex); describe('RemoveMemberButton', () => { let wrapper; + const actions = { + showRemoveMemberModal: jest.fn(), + }; + const createStore = (state = {}) => { return new Vuex.Store({ modules: { @@ -19,6 +25,7 @@ describe('RemoveMemberButton', () => { memberPath: '/groups/foo-bar/-/group_members/:id', ...state, }, + actions, }, }, }); @@ -47,20 +54,16 @@ describe('RemoveMemberButton', () => { }); }; + beforeEach(() => { + createComponent(); + }); + afterEach(() => { wrapper.destroy(); }); it('sets attributes on button', () => { - createComponent(); - expect(wrapper.attributes()).toMatchObject({ - 'data-member-path': '/groups/foo-bar/-/group_members/1', - 'data-member-type': 'GroupMember', - 'data-message': 'Are you sure you want to remove John Smith?', - 'data-is-access-request': 'true', - 'data-is-invite': 'true', - 'data-oncall-schedules': '{"name":"user","schedules":[]}', 'aria-label': 'Remove member', title: 'Remove member', icon: 'remove', @@ -68,14 +71,12 @@ describe('RemoveMemberButton', () => { }); it('displays `title` prop as a tooltip', () => { - createComponent(); - expect(getBinding(wrapper.element, 'gl-tooltip')).not.toBeUndefined(); }); - it('has CSS class used by `remove_member_modal.vue`', () => { - createComponent(); + it('calls Vuex action to show `remove member` modal when clicked', () => { + wrapper.findComponent(GlButton).vm.$emit('click'); - expect(wrapper.classes()).toContain('js-remove-member-button'); + expect(actions.showRemoveMemberModal).toHaveBeenCalledWith(expect.any(Object), modalData); }); }); diff --git a/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js index a3b91cb20bb..3f47fa024bc 100644 --- a/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js +++ b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js @@ -1,11 +1,23 @@ import { GlFilteredSearchToken } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { redirectTo } from '~/lib/utils/url_utility'; import MembersFilteredSearchBar from '~/members/components/filter_sort/members_filtered_search_bar.vue'; import { MEMBER_TYPES } from '~/members/constants'; import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +jest.mock('~/lib/utils/url_utility', () => { + const urlUtility = jest.requireActual('~/lib/utils/url_utility'); + + return { + __esModule: true, + ...urlUtility, + redirectTo: jest.fn(), + }; +}); + const localVue = createLocalVue(); localVue.use(Vuex); @@ -113,12 +125,11 @@ describe('MembersFilteredSearchBar', () => { describe('when filters are set via query params', () => { beforeEach(() => { - delete window.location; - window.location = new URL('https://localhost'); + setWindowLocation('https://localhost'); }); it('parses and passes tokens to `FilteredSearchBar` component as `initialFilterValue` prop', () => { - window.location.search = '?two_factor=enabled&token_not_available=foobar'; + setWindowLocation('?two_factor=enabled&token_not_available=foobar'); createComponent(); @@ -134,7 +145,7 @@ describe('MembersFilteredSearchBar', () => { }); it('parses and passes search param to `FilteredSearchBar` component as `initialFilterValue` prop', () => { - window.location.search = '?search=foobar'; + setWindowLocation('?search=foobar'); createComponent(); @@ -149,7 +160,7 @@ describe('MembersFilteredSearchBar', () => { }); it('parses and passes search param with multiple words to `FilteredSearchBar` component as `initialFilterValue` prop', () => { - window.location.search = '?search=foo+bar+baz'; + setWindowLocation('?search=foo+bar+baz'); createComponent(); @@ -166,8 +177,7 @@ describe('MembersFilteredSearchBar', () => { describe('when filter bar is submitted', () => { beforeEach(() => { - delete window.location; - window.location = new URL('https://localhost'); + setWindowLocation('https://localhost'); }); it('adds correct filter query params', () => { @@ -177,7 +187,7 @@ describe('MembersFilteredSearchBar', () => { { type: 'two_factor', value: { data: 'enabled', operator: '=' } }, ]); - expect(window.location.href).toBe('https://localhost/?two_factor=enabled'); + expect(redirectTo).toHaveBeenCalledWith('https://localhost/?two_factor=enabled'); }); it('adds search query param', () => { @@ -188,7 +198,9 @@ describe('MembersFilteredSearchBar', () => { { type: 'filtered-search-term', value: { data: 'foobar' } }, ]); - expect(window.location.href).toBe('https://localhost/?two_factor=enabled&search=foobar'); + expect(redirectTo).toHaveBeenCalledWith( + 'https://localhost/?two_factor=enabled&search=foobar', + ); }); it('adds search query param with multiple words', () => { @@ -199,11 +211,13 @@ describe('MembersFilteredSearchBar', () => { { type: 'filtered-search-term', value: { data: 'foo bar baz' } }, ]); - expect(window.location.href).toBe('https://localhost/?two_factor=enabled&search=foo+bar+baz'); + expect(redirectTo).toHaveBeenCalledWith( + 'https://localhost/?two_factor=enabled&search=foo+bar+baz', + ); }); it('adds sort query param', () => { - window.location.search = '?sort=name_asc'; + setWindowLocation('?sort=name_asc'); createComponent(); @@ -212,13 +226,13 @@ describe('MembersFilteredSearchBar', () => { { type: 'filtered-search-term', value: { data: 'foobar' } }, ]); - expect(window.location.href).toBe( + expect(redirectTo).toHaveBeenCalledWith( 'https://localhost/?two_factor=enabled&search=foobar&sort=name_asc', ); }); it('adds active tab query param', () => { - window.location.search = '?tab=invited'; + setWindowLocation('?tab=invited'); createComponent(); @@ -226,7 +240,7 @@ describe('MembersFilteredSearchBar', () => { { type: 'filtered-search-term', value: { data: 'foobar' } }, ]); - expect(window.location.href).toBe('https://localhost/?search=foobar&tab=invited'); + expect(redirectTo).toHaveBeenCalledWith('https://localhost/?search=foobar&tab=invited'); }); }); }); diff --git a/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js b/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js index 4b335755980..d0684acd487 100644 --- a/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js +++ b/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js @@ -1,6 +1,7 @@ import { GlSorting, GlSortingItem } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import setWindowLocation from 'helpers/set_window_location_helper'; import * as urlUtilities from '~/lib/utils/url_utility'; import SortDropdown from '~/members/components/filter_sort/sort_dropdown.vue'; import { MEMBER_TYPES } from '~/members/constants'; @@ -52,17 +53,16 @@ describe('SortDropdown', () => { .findAll(GlSortingItem) .wrappers.find((dropdownItemWrapper) => dropdownItemWrapper.text() === text); - describe('dropdown options', () => { - beforeEach(() => { - delete window.location; - window.location = new URL(URL_HOST); - }); + beforeEach(() => { + setWindowLocation(URL_HOST); + }); + describe('dropdown options', () => { it('adds dropdown items for all the sortable fields', () => { const URL_FILTER_PARAMS = '?two_factor=enabled&search=foobar'; const EXPECTED_BASE_URL = `${URL_HOST}${URL_FILTER_PARAMS}&sort=`; - window.location.search = URL_FILTER_PARAMS; + setWindowLocation(URL_FILTER_PARAMS); const expectedDropdownItems = [ { @@ -94,7 +94,7 @@ describe('SortDropdown', () => { }); it('checks selected sort option', () => { - window.location.search = '?sort=access_level_asc'; + setWindowLocation('?sort=access_level_asc'); createComponent(); @@ -103,11 +103,6 @@ describe('SortDropdown', () => { }); describe('dropdown toggle', () => { - beforeEach(() => { - delete window.location; - window.location = new URL(URL_HOST); - }); - it('defaults to sorting by "Account" in ascending order', () => { createComponent(); @@ -116,7 +111,7 @@ describe('SortDropdown', () => { }); it('sets text as selected sort option', () => { - window.location.search = '?sort=access_level_asc'; + setWindowLocation('?sort=access_level_asc'); createComponent(); @@ -126,15 +121,12 @@ describe('SortDropdown', () => { describe('sort direction toggle', () => { beforeEach(() => { - delete window.location; - window.location = new URL(URL_HOST); - - jest.spyOn(urlUtilities, 'visitUrl'); + jest.spyOn(urlUtilities, 'visitUrl').mockImplementation(); }); describe('when current sort direction is ascending', () => { beforeEach(() => { - window.location.search = '?sort=access_level_asc'; + setWindowLocation('?sort=access_level_asc'); createComponent(); }); @@ -152,7 +144,7 @@ describe('SortDropdown', () => { describe('when current sort direction is descending', () => { beforeEach(() => { - window.location.search = '?sort=access_level_desc'; + setWindowLocation('?sort=access_level_desc'); createComponent(); }); diff --git a/spec/frontend/members/components/members_tabs_spec.js b/spec/frontend/members/components/members_tabs_spec.js index 33d8eebf7eb..1d882e5ef09 100644 --- a/spec/frontend/members/components/members_tabs_spec.js +++ b/spec/frontend/members/components/members_tabs_spec.js @@ -1,6 +1,7 @@ -import { GlTabs } from '@gitlab/ui'; +import { GlTabs, GlButton } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import MembersApp from '~/members/components/app.vue'; import MembersTabs from '~/members/components/members_tabs.vue'; @@ -16,7 +17,7 @@ describe('MembersTabs', () => { let wrapper; - const createComponent = ({ totalItems = 10, options = {} } = {}) => { + const createComponent = ({ totalItems = 10, provide = {} } = {}) => { const store = new Vuex.Store({ modules: { [MEMBER_TYPES.user]: { @@ -78,8 +79,10 @@ describe('MembersTabs', () => { stubs: ['members-app'], provide: { canManageMembers: true, + canExportMembers: true, + exportCsvPath: '', + ...provide, }, - ...options, }); return nextTick(); @@ -88,10 +91,10 @@ describe('MembersTabs', () => { const findTabs = () => wrapper.findAllByRole('tab').wrappers; const findTabByText = (text) => findTabs().find((tab) => tab.text().includes(text)); const findActiveTab = () => wrapper.findByRole('tab', { selected: true }); + const findExportButton = () => wrapper.findComponent(GlButton); beforeEach(() => { - delete window.location; - window.location = new URL('https://localhost'); + setWindowLocation('https://localhost'); }); afterEach(() => { @@ -151,7 +154,7 @@ describe('MembersTabs', () => { describe('when url param matches `filteredSearchBar.searchParam`', () => { beforeEach(() => { - window.location.search = '?search_groups=foo+bar'; + setWindowLocation('?search_groups=foo+bar'); }); it('shows tab that corresponds to search param', async () => { @@ -164,7 +167,7 @@ describe('MembersTabs', () => { describe('when `canManageMembers` is `false`', () => { it('shows all tabs except `Invited` and `Access requests`', async () => { - await createComponent({ options: { provide: { canManageMembers: false } } }); + await createComponent({ provide: { canManageMembers: false } }); expect(findTabByText('Members')).not.toBeUndefined(); expect(findTabByText('Groups')).not.toBeUndefined(); @@ -172,4 +175,20 @@ describe('MembersTabs', () => { expect(findTabByText('Access requests')).toBeUndefined(); }); }); + + describe('when `canExportMembers` is true', () => { + it('shows the CSV export button with export path', async () => { + await createComponent({ provide: { canExportMembers: true, exportCsvPath: 'foo' } }); + + expect(findExportButton().attributes('href')).toBe('foo'); + }); + }); + + describe('when `canExportMembers` is false', () => { + it('does not show the CSV export button', async () => { + await createComponent({ provide: { canExportMembers: false } }); + + expect(findExportButton().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/remove_member_modal_spec.js b/spec/frontend/members/components/modals/remove_member_modal_spec.js index ce9de28d53c..1dc41582c12 100644 --- a/spec/frontend/vue_shared/components/remove_member_modal_spec.js +++ b/spec/frontend/members/components/modals/remove_member_modal_spec.js @@ -1,37 +1,61 @@ import { GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import RemoveMemberModal from '~/members/components/modals/remove_member_modal.vue'; +import { MEMBER_TYPES } from '~/members/constants'; import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; -import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; -const mockSchedules = JSON.stringify({ - schedules: [ - { - id: 1, - name: 'Schedule 1', - }, - ], - name: 'User1', -}); +Vue.use(Vuex); describe('RemoveMemberModal', () => { const memberPath = '/gitlab-org/gitlab-test/-/project_members/90'; + const mockSchedules = { + name: 'User1', + schedules: [{ id: 1, name: 'Schedule 1' }], + }; let wrapper; + const actions = { + hideRemoveMemberModal: jest.fn(), + }; + + const createStore = (removeMemberModalData) => + new Vuex.Store({ + modules: { + [MEMBER_TYPES.user]: { + namespaced: true, + state: { + removeMemberModalData, + }, + actions, + }, + }, + }); + + const createComponent = (state) => { + wrapper = shallowMount(RemoveMemberModal, { + store: createStore(state), + provide: { + namespace: MEMBER_TYPES.user, + }, + }); + }; + const findForm = () => wrapper.find({ ref: 'form' }); const findGlModal = () => wrapper.findComponent(GlModal); const findOnCallSchedulesList = () => wrapper.findComponent(OncallSchedulesList); afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe.each` - state | memberType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | onCallSchedules - ${'removing a group member'} | ${'GroupMember'} | ${false} | ${'false'} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${`{}`} - ${'removing a project member'} | ${'ProjectMember'} | ${false} | ${'false'} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${mockSchedules} - ${'denying an access request'} | ${'ProjectMember'} | ${true} | ${'false'} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} | ${`{}`} - ${'revoking invite'} | ${'ProjectMember'} | ${false} | ${'true'} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'} | ${mockSchedules} + state | memberType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | onCallSchedules + ${'removing a group member'} | ${'GroupMember'} | ${false} | ${false} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${{}} + ${'removing a project member'} | ${'ProjectMember'} | ${false} | ${false} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${mockSchedules} + ${'denying an access request'} | ${'ProjectMember'} | ${true} | ${false} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} | ${{}} + ${'revoking invite'} | ${'ProjectMember'} | ${false} | ${true} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'} | ${mockSchedules} `( 'when $state', ({ @@ -45,24 +69,17 @@ describe('RemoveMemberModal', () => { onCallSchedules, }) => { beforeEach(() => { - wrapper = shallowMount(RemoveMemberModal, { - data() { - return { - modalData: { - isAccessRequest, - isInvite, - message, - memberPath, - memberType, - onCallSchedules, - }, - }; - }, + createComponent({ + isAccessRequest, + isInvite, + message, + memberPath, + memberType, + onCallSchedules, }); }); - const parsedSchedules = JSON.parse(onCallSchedules); - const isPartOfOncallSchedules = Boolean(isAccessRequest && parsedSchedules.schedules?.length); + const isPartOfOncallSchedules = Boolean(isAccessRequest && onCallSchedules.schedules?.length); it(`has the title ${actionText}`, () => { expect(findGlModal().attributes('title')).toBe(actionText); @@ -73,7 +90,7 @@ describe('RemoveMemberModal', () => { }); it('displays a message to the user', () => { - expect(wrapper.find('[data-testid=modal-message]').text()).toBe(message); + expect(wrapper.find('p').text()).toBe(message); }); it(`shows ${ @@ -105,6 +122,12 @@ describe('RemoveMemberModal', () => { spy.mockRestore(); }); + + it('calls Vuex action to hide the modal when `GlModal` emits `hide` event', () => { + findGlModal().vm.$emit('hide'); + + expect(actions.hideRemoveMemberModal).toHaveBeenCalled(); + }); }, ); }); diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js index 3a17d78bd17..6885da53b26 100644 --- a/spec/frontend/members/components/table/members_table_spec.js +++ b/spec/frontend/members/components/table/members_table_spec.js @@ -6,6 +6,7 @@ import { } from '@testing-library/dom'; import { mount, createLocalVue, createWrapper } from '@vue/test-utils'; import Vuex from 'vuex'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import CreatedAt from '~/members/components/table/created_at.vue'; import ExpirationDatepicker from '~/members/components/table/expiration_datepicker.vue'; @@ -72,6 +73,7 @@ describe('MembersTable', () => { 'member-action-buttons', 'role-dropdown', 'remove-group-link-modal', + 'remove-member-modal', 'expiration-datepicker', ], }); @@ -242,12 +244,8 @@ describe('MembersTable', () => { }); describe('when required pagination data is provided', () => { - beforeEach(() => { - delete window.location; - }); - it('renders `gl-pagination` component with correct props', () => { - window.location = new URL(url); + setWindowLocation(url); createComponent(); @@ -267,7 +265,7 @@ describe('MembersTable', () => { }); it('uses `pagination.paramName` to generate the pagination links', () => { - window.location = new URL(url); + setWindowLocation(url); createComponent({ pagination: { @@ -282,7 +280,7 @@ describe('MembersTable', () => { }); it('removes any url params defined as `null` in the `params` attribute', () => { - window.location = new URL(`${url}&search_groups=foo`); + setWindowLocation(`${url}&search_groups=foo`); createComponent({ pagination: { diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js index 4275db5fa9f..eb9f905fea2 100644 --- a/spec/frontend/members/mock_data.js +++ b/spec/frontend/members/mock_data.js @@ -57,6 +57,15 @@ export const group = { validRoles: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }, }; +export const modalData = { + isAccessRequest: true, + isInvite: true, + memberPath: '/groups/foo-bar/-/group_members/1', + memberType: 'GroupMember', + message: 'Are you sure you want to remove John Smith?', + oncallSchedules: { name: 'user', schedules: [] }, +}; + const { user, ...memberNoUser } = member; export const invite = { ...memberNoUser, diff --git a/spec/frontend/members/store/actions_spec.js b/spec/frontend/members/store/actions_spec.js index d913c5c56df..d37e6871387 100644 --- a/spec/frontend/members/store/actions_spec.js +++ b/spec/frontend/members/store/actions_spec.js @@ -3,12 +3,14 @@ import MockAdapter from 'axios-mock-adapter'; import { noop } from 'lodash'; import { useFakeDate } from 'helpers/fake_date'; import testAction from 'helpers/vuex_action_helper'; -import { members, group } from 'jest/members/mock_data'; +import { members, group, modalData } from 'jest/members/mock_data'; import httpStatusCodes from '~/lib/utils/http_status'; import { updateMemberRole, showRemoveGroupLinkModal, hideRemoveGroupLinkModal, + showRemoveMemberModal, + hideRemoveMemberModal, updateMemberExpiration, } from '~/members/store/actions'; import * as types from '~/members/store/mutation_types'; @@ -153,4 +155,32 @@ describe('Vuex members actions', () => { }); }); }); + + describe('Remove member modal', () => { + const state = { + removeMemberModalVisible: false, + removeMemberModalData: {}, + }; + + describe('showRemoveMemberModal', () => { + it(`commits ${types.SHOW_REMOVE_MEMBER_MODAL} mutation`, () => { + testAction(showRemoveMemberModal, modalData, state, [ + { + type: types.SHOW_REMOVE_MEMBER_MODAL, + payload: modalData, + }, + ]); + }); + }); + + describe('hideRemoveMemberModal', () => { + it(`commits ${types.HIDE_REMOVE_MEMBER_MODAL} mutation`, () => { + testAction(hideRemoveMemberModal, undefined, state, [ + { + type: types.HIDE_REMOVE_MEMBER_MODAL, + }, + ]); + }); + }); + }); }); diff --git a/spec/frontend/members/store/mutations_spec.js b/spec/frontend/members/store/mutations_spec.js index 78bbad394a0..8160cc373d8 100644 --- a/spec/frontend/members/store/mutations_spec.js +++ b/spec/frontend/members/store/mutations_spec.js @@ -1,4 +1,4 @@ -import { members, group } from 'jest/members/mock_data'; +import { members, group, modalData } from 'jest/members/mock_data'; import * as types from '~/members/store/mutation_types'; import mutations from '~/members/store/mutations'; @@ -152,4 +152,32 @@ describe('Vuex members mutations', () => { expect(state.removeGroupLinkModalVisible).toBe(false); }); }); + + describe(types.SHOW_REMOVE_MEMBER_MODAL, () => { + it('sets `removeMemberModalVisible` and `removeMemberModalData`', () => { + const state = { + removeMemberModalVisible: false, + removeMemberModalData: {}, + }; + + mutations[types.SHOW_REMOVE_MEMBER_MODAL](state, modalData); + + expect(state).toEqual({ + removeMemberModalVisible: true, + removeMemberModalData: modalData, + }); + }); + }); + + describe(types.HIDE_REMOVE_MEMBER_MODAL, () => { + it('sets `removeMemberModalVisible` to `false`', () => { + const state = { + removeMemberModalVisible: true, + }; + + mutations[types.HIDE_REMOVE_MEMBER_MODAL](state); + + expect(state.removeMemberModalVisible).toBe(false); + }); + }); }); diff --git a/spec/frontend/members/utils_spec.js b/spec/frontend/members/utils_spec.js index 9740e1c2edb..a157cfa1c1d 100644 --- a/spec/frontend/members/utils_spec.js +++ b/spec/frontend/members/utils_spec.js @@ -1,3 +1,4 @@ +import setWindowLocation from 'helpers/set_window_location_helper'; import { DEFAULT_SORT, MEMBER_TYPES } from '~/members/constants'; import { generateBadges, @@ -150,21 +151,18 @@ describe('Members Utils', () => { describe('parseSortParam', () => { beforeEach(() => { - delete window.location; - window.location = new URL(URL_HOST); + setWindowLocation(URL_HOST); }); describe('when `sort` param is not present', () => { it('returns default sort options', () => { - window.location.search = ''; - expect(parseSortParam(['account'])).toEqual(DEFAULT_SORT); }); }); describe('when field passed in `sortableFields` argument does not have `sort` key defined', () => { it('returns default sort options', () => { - window.location.search = '?sort=source_asc'; + setWindowLocation('?sort=source_asc'); expect(parseSortParam(['source'])).toEqual(DEFAULT_SORT); }); @@ -182,7 +180,7 @@ describe('Members Utils', () => { ${'oldest_sign_in'} | ${{ sortByKey: 'lastSignIn', sortDesc: true }} `('when `sort` query string param is `$sortParam`', ({ sortParam, expected }) => { it(`returns ${JSON.stringify(expected)}`, async () => { - window.location.search = `?sort=${sortParam}`; + setWindowLocation(`?sort=${sortParam}`); expect(parseSortParam(['account', 'granted', 'expires', 'maxRole', 'lastSignIn'])).toEqual( expected, @@ -193,8 +191,7 @@ describe('Members Utils', () => { describe('buildSortHref', () => { beforeEach(() => { - delete window.location; - window.location = new URL(URL_HOST); + setWindowLocation(URL_HOST); }); describe('when field passed in `sortBy` argument does not have `sort` key defined', () => { @@ -225,7 +222,7 @@ describe('Members Utils', () => { describe('when filter params are set', () => { it('merges the `sort` param with the filter params', () => { - window.location.search = '?two_factor=enabled&with_inherited_permissions=exclude'; + setWindowLocation('?two_factor=enabled&with_inherited_permissions=exclude'); expect( buildSortHref({ @@ -240,7 +237,7 @@ describe('Members Utils', () => { describe('when search param is set', () => { it('merges the `sort` param with the search param', () => { - window.location.search = '?search=foobar'; + setWindowLocation('?search=foobar'); expect( buildSortHref({ diff --git a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js index dbb9fd5f603..f2116c1f478 100644 --- a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js +++ b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js @@ -301,9 +301,6 @@ describe('Actions menu', () => { }); it('redirects to the newly created dashboard', () => { - delete window.location; - window.location = new URL('https://localhost'); - const newDashboard = dashboardGitResponse[1]; const newDashboardUrl = 'root/sandbox/-/metrics/dashboard.yml'; diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index 7ca1b97d849..f899580b3df 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -1,5 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import VueDraggable from 'vuedraggable'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createFlash from '~/flash'; @@ -226,32 +227,25 @@ describe('Dashboard', () => { }); describe('when the URL contains a reference to a panel', () => { - let location; + const location = window.location.href; - const setSearch = (search) => { - window.location = { ...location, search }; + const setSearch = (searchParams) => { + setWindowLocation(`?${objectToQuery(searchParams)}`); }; - beforeEach(() => { - location = window.location; - delete window.location; - }); - afterEach(() => { - window.location = location; + setWindowLocation(location); }); it('when the URL points to a panel it expands', () => { const panelGroup = metricsDashboardViewModel.panelGroups[0]; const panel = panelGroup.panels[0]; - setSearch( - objectToQuery({ - group: panelGroup.group, - title: panel.title, - y_label: panel.y_label, - }), - ); + setSearch({ + group: panelGroup.group, + title: panel.title, + y_label: panel.y_label, + }); createMountedWrapper({ hasMetrics: true }); setupStoreWithData(store); @@ -268,7 +262,7 @@ describe('Dashboard', () => { }); it('when the URL does not link to any panel, no panel is expanded', () => { - setSearch(''); + setSearch(); createMountedWrapper({ hasMetrics: true }); setupStoreWithData(store); @@ -285,13 +279,11 @@ describe('Dashboard', () => { const panelGroup = metricsDashboardViewModel.panelGroups[0]; const panel = panelGroup.panels[0]; - setSearch( - objectToQuery({ - group: panelGroup.group, - title: 'incorrect', - y_label: panel.y_label, - }), - ); + setSearch({ + group: panelGroup.group, + title: 'incorrect', + y_label: panel.y_label, + }); createMountedWrapper({ hasMetrics: true }); setupStoreWithData(store); diff --git a/spec/frontend/monitoring/utils_spec.js b/spec/frontend/monitoring/utils_spec.js index 25ae4dcd702..31975052077 100644 --- a/spec/frontend/monitoring/utils_spec.js +++ b/spec/frontend/monitoring/utils_spec.js @@ -448,7 +448,7 @@ describe('monitoring/utils', () => { input | urlParams ${[]} | ${''} ${[{ name: 'env', value: 'prod' }]} | ${'?var-env=prod'} - ${[{ name: 'env1', value: 'prod' }, { name: 'env2', value: null }]} | ${'?var-env=prod&var-env1=prod'} + ${[{ name: 'env1', value: 'prod' }, { name: 'env2', value: null }]} | ${'?var-env1=prod'} `( 'setCustomVariablesFromUrl updates history with query "$urlParams" with input $input', ({ input, urlParams }) => { diff --git a/spec/frontend/nav/components/responsive_app_spec.js b/spec/frontend/nav/components/responsive_app_spec.js index e1b443745e3..4af8c6020bc 100644 --- a/spec/frontend/nav/components/responsive_app_spec.js +++ b/spec/frontend/nav/components/responsive_app_spec.js @@ -3,16 +3,10 @@ import ResponsiveApp from '~/nav/components/responsive_app.vue'; import ResponsiveHeader from '~/nav/components/responsive_header.vue'; import ResponsiveHome from '~/nav/components/responsive_home.vue'; import TopNavContainerView from '~/nav/components/top_nav_container_view.vue'; -import eventHub, { EVENT_RESPONSIVE_TOGGLE } from '~/nav/event_hub'; import { resetMenuItemsActive } from '~/nav/utils/reset_menu_items_active'; import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue'; import { TEST_NAV_DATA } from '../mock_data'; -const HTML_HEADER_CONTENT = '<div class="header-content"></div>'; -const HTML_MENU_EXPANDED = '<div class="menu-expanded"></div>'; -const HTML_HEADER_WITH_MENU_EXPANDED = - '<div></div><div class="header-content menu-expanded"></div>'; - describe('~/nav/components/responsive_app.vue', () => { let wrapper; @@ -26,13 +20,10 @@ describe('~/nav/components/responsive_app.vue', () => { }, }); }; - const triggerResponsiveToggle = () => eventHub.$emit(EVENT_RESPONSIVE_TOGGLE); - const findHome = () => wrapper.findComponent(ResponsiveHome); const findMobileOverlay = () => wrapper.find('[data-testid="mobile-overlay"]'); const findSubviewHeader = () => wrapper.findComponent(ResponsiveHeader); const findSubviewContainer = () => wrapper.findComponent(TopNavContainerView); - const hasBodyResponsiveOpen = () => document.body.classList.contains('top-nav-responsive-open'); const hasMobileOverlayVisible = () => findMobileOverlay().classes('mobile-nav-open'); beforeEach(() => { @@ -58,23 +49,6 @@ describe('~/nav/components/responsive_app.vue', () => { }); it.each` - bodyHtml | expectation - ${''} | ${false} - ${HTML_HEADER_CONTENT} | ${false} - ${HTML_MENU_EXPANDED} | ${false} - ${HTML_HEADER_WITH_MENU_EXPANDED} | ${true} - `( - 'with responsive toggle event and html set to $bodyHtml, responsive open = $expectation', - ({ bodyHtml, expectation }) => { - document.body.innerHTML = bodyHtml; - - triggerResponsiveToggle(); - - expect(hasBodyResponsiveOpen()).toBe(expectation); - }, - ); - - it.each` events | expectation ${[]} | ${false} ${['bv::dropdown::show']} | ${true} @@ -96,17 +70,6 @@ describe('~/nav/components/responsive_app.vue', () => { ); }); - describe('with menu expanded in body', () => { - beforeEach(() => { - document.body.innerHTML = HTML_HEADER_WITH_MENU_EXPANDED; - createComponent(); - }); - - it('sets the body responsive open', () => { - expect(hasBodyResponsiveOpen()).toBe(true); - }); - }); - const projectsContainerProps = { containerClass: 'gl-px-3', frequentItemsDropdownType: ResponsiveApp.FREQUENT_ITEMS_PROJECTS.namespace, @@ -159,17 +122,4 @@ describe('~/nav/components/responsive_app.vue', () => { }); }); }); - - describe('when destroyed', () => { - beforeEach(() => { - createComponent(); - wrapper.destroy(); - }); - - it('responsive toggle event does nothing', () => { - triggerResponsiveToggle(); - - expect(hasBodyResponsiveOpen()).toBe(false); - }); - }); }); diff --git a/spec/frontend/notes/components/comment_field_layout_spec.js b/spec/frontend/notes/components/comment_field_layout_spec.js index 4d9b4ea8c6f..90c989540b9 100644 --- a/spec/frontend/notes/components/comment_field_layout_spec.js +++ b/spec/frontend/notes/components/comment_field_layout_spec.js @@ -134,4 +134,18 @@ describe('Comment Field Layout Component', () => { ]); }); }); + + describe('issue has email participants, but note is confidential', () => { + it('does not show EmailParticipantsWarning', () => { + createWrapper({ + noteableData: { + ...noteableDataMock, + issue_email_participants: [{ email: 'someone@gitlab.com' }], + }, + noteIsConfidential: true, + }); + + expect(findEmailParticipantsWarning().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js index f217dfd2e48..467a8bec21b 100644 --- a/spec/frontend/notes/components/noteable_note_spec.js +++ b/spec/frontend/notes/components/noteable_note_spec.js @@ -258,7 +258,11 @@ describe('issue_note', () => { }, }); - noteBodyComponent.vm.$emit('handleFormUpdate', noteBody, null, () => {}); + noteBodyComponent.vm.$emit('handleFormUpdate', { + noteText: noteBody, + parentElement: null, + callback: () => {}, + }); await waitForPromises(); expect(alertSpy).not.toHaveBeenCalled(); @@ -287,14 +291,18 @@ describe('issue_note', () => { const noteBody = wrapper.findComponent(NoteBody); noteBody.vm.resetAutoSave = () => {}; - noteBody.vm.$emit('handleFormUpdate', updatedText, null, () => {}); + noteBody.vm.$emit('handleFormUpdate', { + noteText: updatedText, + parentElement: null, + callback: () => {}, + }); await wrapper.vm.$nextTick(); let noteBodyProps = noteBody.props(); expect(noteBodyProps.note.note_html).toBe(`<p>${updatedText}</p>\n`); - noteBody.vm.$emit('cancelForm'); + noteBody.vm.$emit('cancelForm', {}); await wrapper.vm.$nextTick(); noteBodyProps = noteBody.props(); @@ -305,7 +313,12 @@ describe('issue_note', () => { describe('formUpdateHandler', () => { const updateNote = jest.fn(); - const params = ['', null, jest.fn(), '']; + const params = { + noteText: '', + parentElement: null, + callback: jest.fn(), + resolveDiscussion: false, + }; const updateActions = () => { store.hotUpdate({ @@ -325,14 +338,14 @@ describe('issue_note', () => { it('responds to handleFormUpdate', () => { createWrapper(); updateActions(); - wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', ...params); + wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', params); expect(wrapper.emitted('handleUpdateNote')).toBeTruthy(); }); it('does not stringify empty position', () => { createWrapper(); updateActions(); - wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', ...params); + wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', params); expect(updateNote.mock.calls[0][1].note.note.position).toBeUndefined(); }); @@ -341,7 +354,7 @@ describe('issue_note', () => { const expectation = JSON.stringify(position); createWrapper({ note: { ...note, position } }); updateActions(); - wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', ...params); + wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', params); expect(updateNote.mock.calls[0][1].note.note.position).toBe(expectation); }); }); diff --git a/spec/frontend/packages/details/components/app_spec.js b/spec/frontend/packages/details/components/app_spec.js index 3132ec61942..377e7e05f09 100644 --- a/spec/frontend/packages/details/components/app_spec.js +++ b/spec/frontend/packages/details/components/app_spec.js @@ -2,6 +2,7 @@ import { GlEmptyState } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import { nextTick } from 'vue'; import Vuex from 'vuex'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import stubChildren from 'helpers/stub_children'; import AdditionalMetadata from '~/packages/details/components/additional_metadata.vue'; @@ -30,6 +31,8 @@ import { const localVue = createLocalVue(); localVue.use(Vuex); +useMockLocationHelper(); + describe('PackagesApp', () => { let wrapper; let store; @@ -37,7 +40,6 @@ describe('PackagesApp', () => { const deletePackage = jest.fn(); const deletePackageFile = jest.fn(); const defaultProjectName = 'bar'; - const { location } = window; function createComponent({ packageEntity = mavenPackage, @@ -100,14 +102,8 @@ describe('PackagesApp', () => { const findInstallationCommands = () => wrapper.find(InstallationCommands); const findPackageFiles = () => wrapper.find(PackageFiles); - beforeEach(() => { - delete window.location; - window.location = { replace: jest.fn() }; - }); - afterEach(() => { wrapper.destroy(); - window.location = location; }); it('renders the app and displays the package title', async () => { diff --git a/spec/frontend/packages/list/components/packages_list_app_spec.js b/spec/frontend/packages/list/components/packages_list_app_spec.js index 4de2dd0789e..b94192c531c 100644 --- a/spec/frontend/packages/list/components/packages_list_app_spec.js +++ b/spec/frontend/packages/list/components/packages_list_app_spec.js @@ -1,6 +1,7 @@ import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import setWindowLocation from 'helpers/set_window_location_helper'; import createFlash from '~/flash'; import * as commonUtils from '~/lib/utils/common_utils'; import PackageListApp from '~/packages/list/components/packages_list_app.vue'; @@ -233,21 +234,17 @@ describe('packages_list_app', () => { }); describe('delete alert handling', () => { - const { location } = window.location; + const originalLocation = window.location.href; const search = `?${SHOW_DELETE_SUCCESS_ALERT}=true`; beforeEach(() => { createStore(); jest.spyOn(commonUtils, 'historyReplaceState').mockImplementation(() => {}); - delete window.location; - window.location = { - href: `foo_bar_baz${search}`, - search, - }; + setWindowLocation(search); }); afterEach(() => { - window.location = location; + setWindowLocation(originalLocation); }); it(`creates a flash if the query string contains ${SHOW_DELETE_SUCCESS_ALERT}`, () => { @@ -262,11 +259,11 @@ describe('packages_list_app', () => { it('calls historyReplaceState with a clean url', () => { mountComponent(); - expect(commonUtils.historyReplaceState).toHaveBeenCalledWith('foo_bar_baz'); + expect(commonUtils.historyReplaceState).toHaveBeenCalledWith(originalLocation); }); it(`does nothing if the query string does not contain ${SHOW_DELETE_SUCCESS_ALERT}`, () => { - window.location.search = ''; + setWindowLocation('?'); mountComponent(); expect(createFlash).not.toHaveBeenCalled(); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/conan_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/conan_installation_spec.js.snap new file mode 100644 index 00000000000..e9f80d5f512 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/conan_installation_spec.js.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConanInstallation renders all the messages 1`] = ` +<div> + <installation-title-stub + options="[object Object]" + packagetype="conan" + /> + + <code-instruction-stub + copytext="Copy Conan Command" + instruction="conan install @gitlab-org/package-15 --remote=gitlab" + label="Conan Command" + trackingaction="copy_conan_command" + trackinglabel="code_instruction" + /> + + <h3 + class="gl-font-lg" + > + Registry setup + </h3> + + <code-instruction-stub + copytext="Copy Conan Setup Command" + instruction="conan remote add gitlab conanPath" + label="Add Conan Remote" + trackingaction="copy_conan_setup_command" + trackinglabel="code_instruction" + /> + + <gl-sprintf-stub + message="For more information on the Conan registry, %{linkStart}see the documentation%{linkEnd}." + /> +</div> +`; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/dependency_row_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/dependency_row_spec.js.snap new file mode 100644 index 00000000000..f83df7b11f4 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/dependency_row_spec.js.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DependencyRow renders full dependency 1`] = ` +<div + class="gl-responsive-table-row" +> + <div + class="table-section section-50" + > + <strong + class="gl-text-body" + > + Ninject.Extensions.Factory + </strong> + + <span + data-testid="target-framework" + > + + (.NETCoreApp3.1) + + </span> + </div> + + <div + class="table-section section-50 gl-display-flex gl-md-justify-content-end" + data-testid="version-pattern" + > + <span + class="gl-text-body" + > + 3.3.2 + </span> + </div> +</div> +`; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap new file mode 100644 index 00000000000..881d441e116 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FileSha renders 1`] = ` +<div + class="gl-display-flex gl-align-items-center gl-font-monospace gl-font-sm gl-word-break-all gl-py-2 gl-border-b-solid gl-border-gray-100 gl-border-b-1" +> + <!----> + + <span> + <div + class="gl-px-4" + > + + bar: + foo + + <gl-button-stub + aria-label="Copy this value" + buttontextclasses="" + category="tertiary" + data-clipboard-text="foo" + icon="copy-to-clipboard" + size="small" + title="Copy SHA" + variant="default" + /> + </div> + </span> +</div> +`; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/maven_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/maven_installation_spec.js.snap new file mode 100644 index 00000000000..4865b8205ab --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/maven_installation_spec.js.snap @@ -0,0 +1,135 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MavenInstallation groovy renders all the messages 1`] = ` +<div> + <installation-title-stub + options="[object Object],[object Object],[object Object]" + packagetype="maven" + /> + + <code-instruction-stub + class="gl-mb-5" + copytext="Copy Gradle Groovy DSL install command" + instruction="implementation 'appGroup:appName:appVersion'" + label="Gradle Groovy DSL install command" + trackingaction="copy_gradle_install_command" + trackinglabel="code_instruction" + /> + + <code-instruction-stub + copytext="Copy add Gradle Groovy DSL repository command" + instruction="maven { + url 'mavenPath' +}" + label="Add Gradle Groovy DSL repository command" + multiline="true" + trackingaction="copy_gradle_add_to_source_command" + trackinglabel="code_instruction" + /> +</div> +`; + +exports[`MavenInstallation kotlin renders all the messages 1`] = ` +<div> + <installation-title-stub + options="[object Object],[object Object],[object Object]" + packagetype="maven" + /> + + <code-instruction-stub + class="gl-mb-5" + copytext="Copy Gradle Kotlin DSL install command" + instruction="implementation(\\"appGroup:appName:appVersion\\")" + label="Gradle Kotlin DSL install command" + trackingaction="copy_kotlin_install_command" + trackinglabel="code_instruction" + /> + + <code-instruction-stub + copytext="Copy add Gradle Kotlin DSL repository command" + instruction="maven(\\"mavenPath\\")" + label="Add Gradle Kotlin DSL repository command" + multiline="true" + trackingaction="copy_kotlin_add_to_source_command" + trackinglabel="code_instruction" + /> +</div> +`; + +exports[`MavenInstallation maven renders all the messages 1`] = ` +<div> + <installation-title-stub + options="[object Object],[object Object],[object Object]" + packagetype="maven" + /> + + <p> + <gl-sprintf-stub + message="Copy and paste this inside your %{codeStart}pom.xml%{codeEnd} %{codeStart}dependencies%{codeEnd} block." + /> + </p> + + <code-instruction-stub + copytext="Copy Maven XML" + instruction="<dependency> + <groupId>appGroup</groupId> + <artifactId>appName</artifactId> + <version>appVersion</version> +</dependency>" + label="" + multiline="true" + trackingaction="copy_maven_xml" + trackinglabel="code_instruction" + /> + + <code-instruction-stub + copytext="Copy Maven command" + instruction="mvn dependency:get -Dartifact=appGroup:appName:appVersion" + label="Maven Command" + trackingaction="copy_maven_command" + trackinglabel="code_instruction" + /> + + <h3 + class="gl-font-lg" + > + Registry setup + </h3> + + <p> + <gl-sprintf-stub + message="If you haven't already done so, you will need to add the below to your %{codeStart}pom.xml%{codeEnd} file." + /> + </p> + + <code-instruction-stub + copytext="Copy Maven registry XML" + instruction="<repositories> + <repository> + <id>gitlab-maven</id> + <url>mavenPath</url> + </repository> +</repositories> + +<distributionManagement> + <repository> + <id>gitlab-maven</id> + <url>mavenPath</url> + </repository> + + <snapshotRepository> + <id>gitlab-maven</id> + <url>mavenPath</url> + </snapshotRepository> +</distributionManagement>" + label="" + multiline="true" + trackingaction="copy_maven_setup_xml" + trackinglabel="code_instruction" + /> + + <gl-sprintf-stub + message="For more information on the Maven registry, %{linkStart}see the documentation%{linkEnd}." + /> +</div> +`; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap new file mode 100644 index 00000000000..6a7f14dc33f --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NpmInstallation renders all the messages 1`] = ` +<div> + <installation-title-stub + options="[object Object],[object Object]" + packagetype="npm" + /> + + <code-instruction-stub + copytext="Copy npm command" + instruction="npm i @gitlab-org/package-15" + label="" + trackingaction="copy_npm_install_command" + trackinglabel="code_instruction" + /> + + <h3 + class="gl-font-lg" + > + Registry setup + </h3> + + <code-instruction-stub + copytext="Copy npm setup command" + instruction="echo @gitlab-org:registry=npmPath/ >> .npmrc" + label="" + trackingaction="copy_npm_setup_command" + trackinglabel="code_instruction" + /> + + <gl-sprintf-stub + message="You may also need to setup authentication using an auth token. %{linkStart}See the documentation%{linkEnd} to find out more." + /> +</div> +`; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/nuget_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/nuget_installation_spec.js.snap new file mode 100644 index 00000000000..29ddd7b77ed --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/nuget_installation_spec.js.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NugetInstallation renders all the messages 1`] = ` +<div> + <installation-title-stub + options="[object Object]" + packagetype="nuget" + /> + + <code-instruction-stub + copytext="Copy NuGet Command" + instruction="nuget install @gitlab-org/package-15 -Source \\"GitLab\\"" + label="NuGet Command" + trackingaction="copy_nuget_install_command" + trackinglabel="code_instruction" + /> + + <h3 + class="gl-font-lg" + > + Registry setup + </h3> + + <code-instruction-stub + copytext="Copy NuGet Setup Command" + instruction="nuget source Add -Name \\"GitLab\\" -Source \\"nugetPath\\" -UserName <your_username> -Password <your_token>" + label="Add NuGet Source" + trackingaction="copy_nuget_setup_command" + trackinglabel="code_instruction" + /> + + <gl-sprintf-stub + message="For more information on the NuGet registry, %{linkStart}see the documentation%{linkEnd}." + /> +</div> +`; 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 new file mode 100644 index 00000000000..45d261625b4 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap @@ -0,0 +1,197 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PackageTitle renders with tags 1`] = ` +<div + class="gl-display-flex gl-flex-direction-column" + data-qa-selector="package_title" +> + <div + class="gl-display-flex gl-justify-content-space-between gl-py-3" + > + <div + class="gl-flex-direction-column gl-flex-grow-1" + > + <div + class="gl-display-flex" + > + <!----> + + <div + class="gl-display-flex gl-flex-direction-column" + > + <h1 + class="gl-font-size-h1 gl-mt-3 gl-mb-2" + data-testid="title" + > + @gitlab-org/package-15 + </h1> + + <div + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1" + > + <gl-icon-stub + class="gl-mr-3" + name="eye" + size="16" + /> + + <span + data-testid="sub-header" + > + v + 1.0.0 + published + <time-ago-tooltip-stub + class="gl-ml-2" + cssclass="" + time="2020-08-17T14:23:32Z" + tooltipplacement="top" + /> + </span> + </div> + </div> + </div> + + <div + class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3" + > + <div + class="gl-display-flex gl-align-items-center gl-mr-5" + > + <metadata-item-stub + data-testid="package-type" + icon="package" + link="" + size="s" + text="npm" + texttooltip="" + /> + </div> + <div + class="gl-display-flex gl-align-items-center gl-mr-5" + > + <metadata-item-stub + data-testid="package-size" + icon="disk" + link="" + size="s" + text="800.00 KiB" + texttooltip="" + /> + </div> + <div + class="gl-display-flex gl-align-items-center gl-mr-5" + > + <package-tags-stub + hidelabel="true" + tagdisplaylimit="2" + tags="[object Object],[object Object],[object Object]" + /> + </div> + </div> + </div> + + <!----> + </div> + + <p /> +</div> +`; + +exports[`PackageTitle renders without tags 1`] = ` +<div + class="gl-display-flex gl-flex-direction-column" + data-qa-selector="package_title" +> + <div + class="gl-display-flex gl-justify-content-space-between gl-py-3" + > + <div + class="gl-flex-direction-column gl-flex-grow-1" + > + <div + class="gl-display-flex" + > + <!----> + + <div + class="gl-display-flex gl-flex-direction-column" + > + <h1 + class="gl-font-size-h1 gl-mt-3 gl-mb-2" + data-testid="title" + > + @gitlab-org/package-15 + </h1> + + <div + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1" + > + <gl-icon-stub + class="gl-mr-3" + name="eye" + size="16" + /> + + <span + data-testid="sub-header" + > + v + 1.0.0 + published + <time-ago-tooltip-stub + class="gl-ml-2" + cssclass="" + time="2020-08-17T14:23:32Z" + tooltipplacement="top" + /> + </span> + </div> + </div> + </div> + + <div + class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3" + > + <div + class="gl-display-flex gl-align-items-center gl-mr-5" + > + <metadata-item-stub + data-testid="package-type" + icon="package" + link="" + size="s" + text="npm" + texttooltip="" + /> + </div> + <div + class="gl-display-flex gl-align-items-center gl-mr-5" + > + <metadata-item-stub + data-testid="package-size" + icon="disk" + link="" + size="s" + text="800.00 KiB" + texttooltip="" + /> + </div> + <div + class="gl-display-flex gl-align-items-center gl-mr-5" + > + <package-tags-stub + hidelabel="true" + tagdisplaylimit="2" + tags="[object Object],[object Object],[object Object]" + /> + </div> + </div> + </div> + + <!----> + </div> + + <p /> +</div> +`; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap new file mode 100644 index 00000000000..158bbbc3463 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap @@ -0,0 +1,48 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PypiInstallation renders all the messages 1`] = ` +<div> + <installation-title-stub + options="[object Object]" + packagetype="pypi" + /> + + <code-instruction-stub + copytext="Copy Pip command" + data-testid="pip-command" + instruction="pip install @gitlab-org/package-15 --extra-index-url pypiPath" + label="Pip Command" + trackingaction="copy_pip_install_command" + trackinglabel="code_instruction" + /> + + <h3 + class="gl-font-lg" + > + Registry setup + </h3> + + <p> + <gl-sprintf-stub + message="If you haven't already done so, you will need to add the below to your %{codeStart}.pypirc%{codeEnd} file." + /> + </p> + + <code-instruction-stub + copytext="Copy .pypirc content" + data-testid="pypi-setup-content" + instruction="[gitlab] +repository = pypiSetupPath +username = __token__ +password = <your personal access token>" + label="" + multiline="true" + trackingaction="copy_pypi_setup_command" + trackinglabel="code_instruction" + /> + + <gl-sprintf-stub + message="For more information on the PyPi registry, %{linkStart}see the documentation%{linkEnd}." + /> +</div> +`; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/version_row_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/version_row_spec.js.snap new file mode 100644 index 00000000000..8f69f943112 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/version_row_spec.js.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`VersionRow renders 1`] = ` +<div + class="gl-display-flex gl-flex-direction-column gl-border-b-solid gl-border-t-solid gl-border-t-1 gl-border-b-1 gl-border-t-transparent gl-border-b-gray-100" +> + <div + class="gl-display-flex gl-align-items-center gl-py-3 gl-px-5" + > + <!----> + + <div + class="gl-display-flex gl-xs-flex-direction-column gl-justify-content-space-between gl-align-items-stretch gl-flex-grow-1" + > + <div + class="gl-display-flex gl-flex-direction-column gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1" + > + <div + class="gl-display-flex gl-align-items-center gl-text-body gl-font-weight-bold gl-min-h-6 gl-min-w-0" + > + <div + class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0" + > + <gl-link-stub + class="gl-text-body gl-min-w-0" + href="243" + > + <span + class="gl-truncate" + title="@gitlab-org/package-15" + > + <span + class="gl-truncate-end" + > + @gitlab-org/package-15 + </span> + </span> + </gl-link-stub> + + <package-tags-stub + class="gl-ml-3" + hidelabel="true" + tagdisplaylimit="1" + tags="[object Object],[object Object],[object Object]" + /> + </div> + + <!----> + </div> + + <div + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-min-h-6 gl-min-w-0 gl-flex-grow-1" + > + + 1.0.1 + + </div> + </div> + + <div + class="gl-display-flex gl-flex-direction-column gl-sm-align-items-flex-end gl-justify-content-space-between gl-text-gray-500 gl-flex-shrink-0" + > + <div + class="gl-display-flex gl-align-items-center gl-sm-text-body gl-sm-font-weight-bold gl-min-h-6" + > + <publish-method-stub + packageentity="[object Object]" + /> + </div> + + <div + class="gl-display-flex gl-align-items-center gl-min-h-6" + > + Created + <time-ago-tooltip-stub + cssclass="" + time="2021-08-10T09:33:54Z" + tooltipplacement="top" + /> + </div> + </div> + </div> + + <!----> + </div> + + <div + class="gl-display-flex" + > + <div + class="gl-w-7" + /> + + <!----> + + <div + class="gl-w-9" + /> + </div> +</div> +`; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js new file mode 100644 index 00000000000..0504a42dfcf --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js @@ -0,0 +1,130 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + conanMetadata, + mavenMetadata, + nugetMetadata, + packageData, +} from 'jest/packages_and_registries/package_registry/mock_data'; +import component from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue'; +import { + PACKAGE_TYPE_NUGET, + PACKAGE_TYPE_CONAN, + PACKAGE_TYPE_MAVEN, + PACKAGE_TYPE_NPM, +} from '~/packages_and_registries/package_registry/constants'; +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; + +const mavenPackage = { packageType: PACKAGE_TYPE_MAVEN, metadata: mavenMetadata() }; +const conanPackage = { packageType: PACKAGE_TYPE_CONAN, metadata: conanMetadata() }; +const nugetPackage = { packageType: PACKAGE_TYPE_NUGET, metadata: nugetMetadata() }; +const npmPackage = { packageType: PACKAGE_TYPE_NPM, metadata: {} }; + +describe('Package Additional Metadata', () => { + let wrapper; + const defaultProps = { + packageEntity: { + ...packageData(mavenPackage), + }, + }; + + const mountComponent = (props) => { + wrapper = shallowMountExtended(component, { + propsData: { ...defaultProps, ...props }, + stubs: { + DetailsRow, + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findTitle = () => wrapper.findByTestId('title'); + const findMainArea = () => wrapper.findByTestId('main'); + const findNugetSource = () => wrapper.findByTestId('nuget-source'); + const findNugetLicense = () => wrapper.findByTestId('nuget-license'); + const findConanRecipe = () => wrapper.findByTestId('conan-recipe'); + const findMavenApp = () => wrapper.findByTestId('maven-app'); + const findMavenGroup = () => wrapper.findByTestId('maven-group'); + const findElementLink = (container) => container.findComponent(GlLink); + + it('has the correct title', () => { + mountComponent(); + + const title = findTitle(); + + expect(title.exists()).toBe(true); + expect(title.text()).toBe('Additional Metadata'); + }); + + it.each` + packageEntity | visible | packageType + ${mavenPackage} | ${true} | ${PACKAGE_TYPE_MAVEN} + ${conanPackage} | ${true} | ${PACKAGE_TYPE_CONAN} + ${nugetPackage} | ${true} | ${PACKAGE_TYPE_NUGET} + ${npmPackage} | ${false} | ${PACKAGE_TYPE_NPM} + `( + `It is $visible that the component is visible when the package is $packageType`, + ({ packageEntity, visible }) => { + mountComponent({ packageEntity }); + + expect(findTitle().exists()).toBe(visible); + expect(findMainArea().exists()).toBe(visible); + }, + ); + + describe('nuget metadata', () => { + beforeEach(() => { + mountComponent({ packageEntity: nugetPackage }); + }); + + it.each` + name | finderFunction | text | link | icon + ${'source'} | ${findNugetSource} | ${'Source project located at projectUrl'} | ${'projectUrl'} | ${'project'} + ${'license'} | ${findNugetLicense} | ${'License information located at licenseUrl'} | ${'licenseUrl'} | ${'license'} + `('$name element', ({ finderFunction, text, link, icon }) => { + const element = finderFunction(); + expect(element.exists()).toBe(true); + expect(element.text()).toBe(text); + expect(element.props('icon')).toBe(icon); + expect(findElementLink(element).attributes('href')).toBe(nugetPackage.metadata[link]); + }); + }); + + describe('conan metadata', () => { + beforeEach(() => { + mountComponent({ packageEntity: conanPackage }); + }); + + it.each` + name | finderFunction | text | icon + ${'recipe'} | ${findConanRecipe} | ${'Recipe: package-8/1.0.0@gitlab-org+gitlab-test/stable'} | ${'information-o'} + `('$name element', ({ finderFunction, text, icon }) => { + const element = finderFunction(); + expect(element.exists()).toBe(true); + expect(element.text()).toBe(text); + expect(element.props('icon')).toBe(icon); + }); + }); + + describe('maven metadata', () => { + beforeEach(() => { + mountComponent(); + }); + + it.each` + name | finderFunction | text | icon + ${'app'} | ${findMavenApp} | ${'App name: appName'} | ${'information-o'} + ${'group'} | ${findMavenGroup} | ${'App group: appGroup'} | ${'information-o'} + `('$name element', ({ finderFunction, text, icon }) => { + const element = finderFunction(); + expect(element.exists()).toBe(true); + expect(element.text()).toBe(text); + expect(element.props('icon')).toBe(icon); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js index 97444ec108f..5119512564f 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js @@ -1,35 +1,451 @@ -import { GlEmptyState } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlEmptyState, GlBadge, GlTabs, GlTab } from '@gitlab/ui'; +import { createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import AdditionalMetadata from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue'; import PackagesApp from '~/packages_and_registries/package_registry/components/details/app.vue'; +import DependencyRow from '~/packages_and_registries/package_registry/components/details/dependency_row.vue'; +import InstallationCommands from '~/packages_and_registries/package_registry/components/details/installation_commands.vue'; +import PackageFiles from '~/packages_and_registries/package_registry/components/details/package_files.vue'; +import PackageHistory from '~/packages_and_registries/package_registry/components/details/package_history.vue'; +import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue'; +import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue'; +import { + FETCH_PACKAGE_DETAILS_ERROR_MESSAGE, + DELETE_PACKAGE_ERROR_MESSAGE, + PACKAGE_TYPE_COMPOSER, + DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, + DELETE_PACKAGE_FILE_ERROR_MESSAGE, + PACKAGE_TYPE_NUGET, +} from '~/packages_and_registries/package_registry/constants'; + +import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql'; +import destroyPackageFileMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql'; +import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql'; +import { + packageDetailsQuery, + packageData, + packageVersions, + dependencyLinks, + emptyPackageDetailsQuery, + packageDestroyMutation, + packageDestroyMutationError, + packageFiles, + packageDestroyFileMutation, + packageDestroyFileMutationError, +} from '../../mock_data'; + +jest.mock('~/flash'); +useMockLocationHelper(); + +const localVue = createLocalVue(); describe('PackagesApp', () => { let wrapper; + let apolloProvider; + + const provide = { + packageId: '111', + titleComponent: 'PackageTitle', + projectName: 'projectName', + canDelete: 'canDelete', + svgPath: 'svgPath', + npmPath: 'npmPath', + npmHelpPath: 'npmHelpPath', + projectListUrl: 'projectListUrl', + groupListUrl: 'groupListUrl', + }; - function createComponent() { - wrapper = shallowMount(PackagesApp, { - provide: { - titleComponent: 'titleComponent', - projectName: 'projectName', - canDelete: 'canDelete', - svgPath: 'svgPath', - npmPath: 'npmPath', - npmHelpPath: 'npmHelpPath', - projectListUrl: 'projectListUrl', - groupListUrl: 'groupListUrl', + function createComponent({ + resolver = jest.fn().mockResolvedValue(packageDetailsQuery()), + mutationResolver = jest.fn().mockResolvedValue(packageDestroyMutation()), + fileDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFileMutation()), + } = {}) { + localVue.use(VueApollo); + + const requestHandlers = [ + [getPackageDetails, resolver], + [destroyPackageMutation, mutationResolver], + [destroyPackageFileMutation, fileDeleteMutationResolver], + ]; + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMountExtended(PackagesApp, { + localVue, + apolloProvider, + provide, + stubs: { + PackageTitle, + GlModal: { + template: '<div></div>', + methods: { + show: jest.fn(), + }, + }, + GlTabs, + GlTab, }, }); } - const emptyState = () => wrapper.findComponent(GlEmptyState); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findPackageTitle = () => wrapper.findComponent(PackageTitle); + const findPackageHistory = () => wrapper.findComponent(PackageHistory); + const findAdditionalMetadata = () => wrapper.findComponent(AdditionalMetadata); + const findInstallationCommands = () => wrapper.findComponent(InstallationCommands); + const findDeleteModal = () => wrapper.findByTestId('delete-modal'); + const findDeleteButton = () => wrapper.findByTestId('delete-package'); + const findPackageFiles = () => wrapper.findComponent(PackageFiles); + const findDeleteFileModal = () => wrapper.findByTestId('delete-file-modal'); + const findVersionRows = () => wrapper.findAllComponents(VersionRow); + const noVersionsMessage = () => wrapper.findByTestId('no-versions-message'); + const findDependenciesCountBadge = () => wrapper.findComponent(GlBadge); + const findNoDependenciesMessage = () => wrapper.findByTestId('no-dependencies-message'); + const findDependencyRows = () => wrapper.findAllComponents(DependencyRow); afterEach(() => { wrapper.destroy(); }); - it('renders an empty state component', () => { + it('renders an empty state component', async () => { + createComponent({ resolver: jest.fn().mockResolvedValue(emptyPackageDetailsQuery) }); + + await waitForPromises(); + + expect(findEmptyState().exists()).toBe(true); + }); + + it('renders the app and displays the package title', async () => { + createComponent(); + + await waitForPromises(); + + expect(findPackageTitle().exists()).toBe(true); + expect(findPackageTitle().props()).toMatchObject({ + packageEntity: expect.objectContaining(packageData()), + }); + }); + + it('emits an error message if the load fails', async () => { + createComponent({ resolver: jest.fn().mockRejectedValue() }); + + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith( + expect.objectContaining({ + message: FETCH_PACKAGE_DETAILS_ERROR_MESSAGE, + }), + ); + }); + + it('renders history and has the right props', async () => { + createComponent(); + + await waitForPromises(); + + expect(findPackageHistory().exists()).toBe(true); + expect(findPackageHistory().props()).toMatchObject({ + packageEntity: expect.objectContaining(packageData()), + projectName: provide.projectName, + }); + }); + + it('renders additional metadata and has the right props', async () => { + createComponent(); + + await waitForPromises(); + + expect(findAdditionalMetadata().exists()).toBe(true); + expect(findAdditionalMetadata().props()).toMatchObject({ + packageEntity: expect.objectContaining(packageData()), + }); + }); + + it('renders installation commands and has the right props', async () => { createComponent(); - expect(emptyState().exists()).toBe(true); + await waitForPromises(); + + expect(findInstallationCommands().exists()).toBe(true); + expect(findInstallationCommands().props()).toMatchObject({ + packageEntity: expect.objectContaining(packageData()), + }); + }); + + describe('delete package', () => { + const originalReferrer = document.referrer; + const setReferrer = (value = provide.projectName) => { + Object.defineProperty(document, 'referrer', { + value, + configurable: true, + }); + }; + + const performDeletePackage = async () => { + await findDeleteButton().trigger('click'); + + findDeleteModal().vm.$emit('primary'); + + await waitForPromises(); + }; + + afterEach(() => { + Object.defineProperty(document, 'referrer', { + value: originalReferrer, + configurable: true, + }); + }); + + it('shows the delete confirmation modal when delete is clicked', async () => { + createComponent(); + + await waitForPromises(); + + await findDeleteButton().trigger('click'); + + expect(findDeleteModal().exists()).toBe(true); + }); + + describe('successful request', () => { + it('when referrer contains project name calls window.replace with project url', async () => { + setReferrer(); + + createComponent(); + + await waitForPromises(); + + await performDeletePackage(); + + expect(window.location.replace).toHaveBeenCalledWith( + 'projectListUrl?showSuccessDeleteAlert=true', + ); + }); + + it('when referrer does not contain project name calls window.replace with group url', async () => { + setReferrer('baz'); + + createComponent(); + + await waitForPromises(); + + await performDeletePackage(); + + expect(window.location.replace).toHaveBeenCalledWith( + 'groupListUrl?showSuccessDeleteAlert=true', + ); + }); + }); + + describe('request failure', () => { + it('on global failure it displays an alert', async () => { + createComponent({ mutationResolver: jest.fn().mockRejectedValue() }); + + await waitForPromises(); + + await performDeletePackage(); + + expect(createFlash).toHaveBeenCalledWith( + expect.objectContaining({ + message: DELETE_PACKAGE_ERROR_MESSAGE, + }), + ); + }); + + it('on payload with error it displays an alert', async () => { + createComponent({ + mutationResolver: jest.fn().mockResolvedValue(packageDestroyMutationError()), + }); + + await waitForPromises(); + + await performDeletePackage(); + + expect(createFlash).toHaveBeenCalledWith( + expect.objectContaining({ + message: DELETE_PACKAGE_ERROR_MESSAGE, + }), + ); + }); + }); + }); + + describe('package files', () => { + it('renders the package files component and has the right props', async () => { + const expectedFile = { ...packageFiles()[0] }; + // eslint-disable-next-line no-underscore-dangle + delete expectedFile.__typename; + createComponent(); + + await waitForPromises(); + + expect(findPackageFiles().exists()).toBe(true); + + expect(findPackageFiles().props('packageFiles')[0]).toMatchObject(expectedFile); + }); + + it('does not render the package files table when the package is composer', async () => { + createComponent({ + resolver: jest + .fn() + .mockResolvedValue(packageDetailsQuery({ packageType: PACKAGE_TYPE_COMPOSER })), + }); + + await waitForPromises(); + + expect(findPackageFiles().exists()).toBe(false); + }); + + describe('deleting a file', () => { + const [fileToDelete] = packageFiles(); + + const doDeleteFile = () => { + findPackageFiles().vm.$emit('delete-file', fileToDelete); + + findDeleteFileModal().vm.$emit('primary'); + + return waitForPromises(); + }; + + it('opens a confirmation modal', async () => { + createComponent(); + + await waitForPromises(); + + findPackageFiles().vm.$emit('delete-file', fileToDelete); + + await nextTick(); + + expect(findDeleteFileModal().exists()).toBe(true); + }); + + it('confirming on the modal deletes the file and shows a success message', async () => { + const resolver = jest.fn().mockResolvedValue(packageDetailsQuery()); + createComponent({ resolver }); + + await waitForPromises(); + + await doDeleteFile(); + + expect(createFlash).toHaveBeenCalledWith( + expect.objectContaining({ + message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, + }), + ); + // we are re-fetching the package details, so we expect the resolver to have been called twice + expect(resolver).toHaveBeenCalledTimes(2); + }); + + describe('errors', () => { + it('shows an error when the mutation request fails', async () => { + createComponent({ fileDeleteMutationResolver: jest.fn().mockRejectedValue() }); + await waitForPromises(); + + await doDeleteFile(); + + expect(createFlash).toHaveBeenCalledWith( + expect.objectContaining({ + message: DELETE_PACKAGE_FILE_ERROR_MESSAGE, + }), + ); + }); + + it('shows an error when the mutation request returns an error payload', async () => { + createComponent({ + fileDeleteMutationResolver: jest + .fn() + .mockResolvedValue(packageDestroyFileMutationError()), + }); + await waitForPromises(); + + await doDeleteFile(); + + expect(createFlash).toHaveBeenCalledWith( + expect.objectContaining({ + message: DELETE_PACKAGE_FILE_ERROR_MESSAGE, + }), + ); + }); + }); + }); + }); + + describe('versions', () => { + it('displays the correct version count when the package has versions', async () => { + createComponent(); + + await waitForPromises(); + + expect(findVersionRows()).toHaveLength(packageVersions().length); + }); + + it('binds the correct props', async () => { + const [versionPackage] = packageVersions(); + // eslint-disable-next-line no-underscore-dangle + delete versionPackage.__typename; + delete versionPackage.tags; + + createComponent(); + + await waitForPromises(); + + expect(findVersionRows().at(0).props()).toMatchObject({ + packageEntity: expect.objectContaining(versionPackage), + }); + }); + + it('displays the no versions message when there are none', async () => { + createComponent({ + resolver: jest.fn().mockResolvedValue(packageDetailsQuery({ versions: { nodes: [] } })), + }); + + await waitForPromises(); + + expect(noVersionsMessage().exists()).toBe(true); + }); + }); + describe('dependency links', () => { + it('does not show the dependency links for a non nuget package', async () => { + createComponent(); + + expect(findDependenciesCountBadge().exists()).toBe(false); + }); + + it('shows the dependencies tab with 0 count when a nuget package with no dependencies', async () => { + createComponent({ + resolver: jest.fn().mockResolvedValue( + packageDetailsQuery({ + packageType: PACKAGE_TYPE_NUGET, + dependencyLinks: { nodes: [] }, + }), + ), + }); + + await waitForPromises(); + + expect(findDependenciesCountBadge().exists()).toBe(true); + expect(findDependenciesCountBadge().text()).toBe('0'); + expect(findNoDependenciesMessage().exists()).toBe(true); + }); + + it('renders the correct number of dependency rows for a nuget package', async () => { + createComponent({ + resolver: jest.fn().mockResolvedValue( + packageDetailsQuery({ + packageType: PACKAGE_TYPE_NUGET, + }), + ), + }); + await waitForPromises(); + + expect(findDependenciesCountBadge().exists()).toBe(true); + expect(findDependenciesCountBadge().text()).toBe(dependencyLinks().length.toString()); + expect(findDependencyRows()).toHaveLength(dependencyLinks().length); + }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/composer_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/composer_installation_spec.js new file mode 100644 index 00000000000..aedf20e873a --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/composer_installation_spec.js @@ -0,0 +1,118 @@ +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { packageData } from 'jest/packages_and_registries/package_registry/mock_data'; +import ComposerInstallation from '~/packages_and_registries/package_registry/components/details/composer_installation.vue'; +import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; +import { + TRACKING_ACTION_COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND, + TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND, + PACKAGE_TYPE_COMPOSER, +} from '~/packages_and_registries/package_registry/constants'; + +const packageEntity = { ...packageData(), packageType: PACKAGE_TYPE_COMPOSER }; + +describe('ComposerInstallation', () => { + let wrapper; + + const findRootNode = () => wrapper.findByTestId('root-node'); + const findRegistryInclude = () => wrapper.findByTestId('registry-include'); + const findPackageInclude = () => wrapper.findByTestId('package-include'); + const findHelpText = () => wrapper.findByTestId('help-text'); + const findHelpLink = () => wrapper.findComponent(GlLink); + const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); + + function createComponent(groupListUrl = 'groupListUrl') { + wrapper = shallowMountExtended(ComposerInstallation, { + provide: { + composerHelpPath: 'composerHelpPath', + composerConfigRepositoryName: 'composerConfigRepositoryName', + composerPath: 'composerPath', + groupListUrl, + }, + propsData: { packageEntity }, + stubs: { + GlSprintf, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('install command switch', () => { + it('has the installation title component', () => { + createComponent(); + + expect(findInstallationTitle().exists()).toBe(true); + expect(findInstallationTitle().props()).toMatchObject({ + packageType: 'composer', + options: [{ value: 'composer', label: 'Show Composer commands' }], + }); + }); + }); + + describe('registry include command', () => { + beforeEach(() => { + createComponent(); + }); + + it('uses code_instructions', () => { + const registryIncludeCommand = findRegistryInclude(); + expect(registryIncludeCommand.exists()).toBe(true); + expect(registryIncludeCommand.props()).toMatchObject({ + instruction: `composer config repositories.composerConfigRepositoryName '{"type": "composer", "url": "composerPath"}'`, + copyText: 'Copy registry include', + trackingAction: TRACKING_ACTION_COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND, + }); + }); + + it('has the correct title', () => { + expect(findRegistryInclude().props('label')).toBe('Add composer registry'); + }); + }); + + describe('package include command', () => { + beforeEach(() => { + createComponent(); + }); + + it('uses code_instructions', () => { + const registryIncludeCommand = findPackageInclude(); + expect(registryIncludeCommand.exists()).toBe(true); + expect(registryIncludeCommand.props()).toMatchObject({ + instruction: 'composer req @gitlab-org/package-15:1.0.0', + copyText: 'Copy require package include', + trackingAction: TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND, + }); + }); + + it('has the correct title', () => { + expect(findPackageInclude().props('label')).toBe('Install package version'); + }); + + it('has the correct help text', () => { + expect(findHelpText().text()).toBe( + 'For more information on Composer packages in GitLab, see the documentation.', + ); + expect(findHelpLink().attributes()).toMatchObject({ + href: 'composerHelpPath', + target: '_blank', + }); + }); + }); + + describe('root node', () => { + it('is normally rendered', () => { + createComponent(); + + expect(findRootNode().exists()).toBe(true); + }); + + it('is not rendered when the group does not exist', () => { + createComponent(''); + + expect(findRootNode().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/conan_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/conan_installation_spec.js new file mode 100644 index 00000000000..6b642cc21b7 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/conan_installation_spec.js @@ -0,0 +1,65 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { packageData } from 'jest/packages_and_registries/package_registry/mock_data'; +import ConanInstallation from '~/packages_and_registries/package_registry/components/details/conan_installation.vue'; +import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; +import { PACKAGE_TYPE_CONAN } from '~/packages_and_registries/package_registry/constants'; +import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; + +const packageEntity = { ...packageData(), packageType: PACKAGE_TYPE_CONAN }; + +describe('ConanInstallation', () => { + let wrapper; + + const findCodeInstructions = () => wrapper.findAllComponents(CodeInstructions); + const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); + + function createComponent() { + wrapper = shallowMountExtended(ConanInstallation, { + provide: { + conanHelpPath: 'conanHelpPath', + conanPath: 'conanPath', + }, + propsData: { + packageEntity, + }, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders all the messages', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('install command switch', () => { + it('has the installation title component', () => { + expect(findInstallationTitle().exists()).toBe(true); + expect(findInstallationTitle().props()).toMatchObject({ + packageType: 'conan', + options: [{ value: 'conan', label: 'Show Conan commands' }], + }); + }); + }); + + describe('installation commands', () => { + it('renders the correct command', () => { + expect(findCodeInstructions().at(0).props('instruction')).toBe( + 'conan install @gitlab-org/package-15 --remote=gitlab', + ); + }); + }); + + describe('setup commands', () => { + it('renders the correct command', () => { + expect(findCodeInstructions().at(1).props('instruction')).toBe( + 'conan remote add gitlab conanPath', + ); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/dependency_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/dependency_row_spec.js new file mode 100644 index 00000000000..9aed5b90c73 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/dependency_row_spec.js @@ -0,0 +1,69 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import DependencyRow from '~/packages_and_registries/package_registry/components/details/dependency_row.vue'; +import { dependencyLinks } from '../../mock_data'; + +describe('DependencyRow', () => { + let wrapper; + + const [fullDependencyLink] = dependencyLinks(); + const { dependency, metadata } = fullDependencyLink; + + function createComponent(dependencyLink = fullDependencyLink) { + wrapper = shallowMountExtended(DependencyRow, { + propsData: { + dependencyLink, + }, + }); + } + + const dependencyVersion = () => wrapper.findByTestId('version-pattern'); + const dependencyFramework = () => wrapper.findByTestId('target-framework'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('renders', () => { + it('full dependency', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('version', () => { + it('does not render any version information when not supplied', () => { + createComponent({ + ...fullDependencyLink, + dependency: { ...dependency, versionPattern: undefined }, + }); + + expect(dependencyVersion().exists()).toBe(false); + }); + + it('does render version info when it exists', () => { + createComponent(); + + expect(dependencyVersion().exists()).toBe(true); + expect(dependencyVersion().text()).toBe(dependency.versionPattern); + }); + }); + + describe('target framework', () => { + it('does not render any framework information when not supplied', () => { + createComponent({ + ...fullDependencyLink, + metadata: { ...metadata, targetFramework: undefined }, + }); + + expect(dependencyFramework().exists()).toBe(false); + }); + + it('does render framework info when it exists', () => { + createComponent(); + + expect(dependencyFramework().exists()).toBe(true); + expect(dependencyFramework().text()).toBe(`(${metadata.targetFramework})`); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/file_sha_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/file_sha_spec.js new file mode 100644 index 00000000000..ebfbbe5b864 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/file_sha_spec.js @@ -0,0 +1,33 @@ +import { shallowMount } from '@vue/test-utils'; + +import FileSha from '~/packages_and_registries/package_registry/components/details/file_sha.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; + +describe('FileSha', () => { + let wrapper; + + const defaultProps = { sha: 'foo', title: 'bar' }; + + function createComponent() { + wrapper = shallowMount(FileSha, { + propsData: { + ...defaultProps, + }, + stubs: { + ClipboardButton, + DetailsRow, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/installation_title_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/installation_title_spec.js new file mode 100644 index 00000000000..5fe795f768e --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/installation_title_spec.js @@ -0,0 +1,58 @@ +import { shallowMount } from '@vue/test-utils'; + +import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; +import PersistedDropdownSelection from '~/vue_shared/components/registry/persisted_dropdown_selection.vue'; + +describe('InstallationTitle', () => { + let wrapper; + + const defaultProps = { packageType: 'foo', options: [{ value: 'foo', label: 'bar' }] }; + + const findPersistedDropdownSelection = () => wrapper.findComponent(PersistedDropdownSelection); + const findTitle = () => wrapper.find('h3'); + + function createComponent({ props = {} } = {}) { + wrapper = shallowMount(InstallationTitle, { + propsData: { + ...defaultProps, + ...props, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('has a title', () => { + createComponent(); + + expect(findTitle().exists()).toBe(true); + expect(findTitle().text()).toBe('Installation'); + }); + + describe('persisted dropdown selection', () => { + it('exists', () => { + createComponent(); + + expect(findPersistedDropdownSelection().exists()).toBe(true); + }); + + it('has the correct props', () => { + createComponent(); + + expect(findPersistedDropdownSelection().props()).toMatchObject({ + storageKey: 'package_foo_installation_instructions', + options: defaultProps.options, + }); + }); + + it('on change event emits a change event', () => { + createComponent(); + + findPersistedDropdownSelection().vm.$emit('change', 'baz'); + + expect(wrapper.emitted('change')).toEqual([['baz']]); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/installations_commands_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/installations_commands_spec.js new file mode 100644 index 00000000000..b24946c8638 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/installations_commands_spec.js @@ -0,0 +1,64 @@ +import { shallowMount } from '@vue/test-utils'; +import { packageData } from 'jest/packages_and_registries/package_registry/mock_data'; +import ComposerInstallation from '~/packages_and_registries/package_registry/components/details/composer_installation.vue'; +import ConanInstallation from '~/packages_and_registries/package_registry/components/details/conan_installation.vue'; +import InstallationCommands from '~/packages_and_registries/package_registry/components/details/installation_commands.vue'; + +import MavenInstallation from '~/packages_and_registries/package_registry/components/details/maven_installation.vue'; +import NpmInstallation from '~/packages_and_registries/package_registry/components/details/npm_installation.vue'; +import NugetInstallation from '~/packages_and_registries/package_registry/components/details/nuget_installation.vue'; +import PypiInstallation from '~/packages_and_registries/package_registry/components/details/pypi_installation.vue'; +import { + PACKAGE_TYPE_CONAN, + PACKAGE_TYPE_MAVEN, + PACKAGE_TYPE_NPM, + PACKAGE_TYPE_NUGET, + PACKAGE_TYPE_PYPI, + PACKAGE_TYPE_COMPOSER, +} from '~/packages_and_registries/package_registry/constants'; + +const conanPackage = { ...packageData(), packageType: PACKAGE_TYPE_CONAN }; +const mavenPackage = { ...packageData(), packageType: PACKAGE_TYPE_MAVEN }; +const npmPackage = { ...packageData(), packageType: PACKAGE_TYPE_NPM }; +const nugetPackage = { ...packageData(), packageType: PACKAGE_TYPE_NUGET }; +const pypiPackage = { ...packageData(), packageType: PACKAGE_TYPE_PYPI }; +const composerPackage = { ...packageData(), packageType: PACKAGE_TYPE_COMPOSER }; + +describe('InstallationCommands', () => { + let wrapper; + + function createComponent(propsData) { + wrapper = shallowMount(InstallationCommands, { + propsData, + }); + } + + const npmInstallation = () => wrapper.find(NpmInstallation); + const mavenInstallation = () => wrapper.find(MavenInstallation); + const conanInstallation = () => wrapper.find(ConanInstallation); + const nugetInstallation = () => wrapper.find(NugetInstallation); + const pypiInstallation = () => wrapper.find(PypiInstallation); + const composerInstallation = () => wrapper.find(ComposerInstallation); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('installation instructions', () => { + describe.each` + packageEntity | selector + ${conanPackage} | ${conanInstallation} + ${mavenPackage} | ${mavenInstallation} + ${npmPackage} | ${npmInstallation} + ${nugetPackage} | ${nugetInstallation} + ${pypiPackage} | ${pypiInstallation} + ${composerPackage} | ${composerInstallation} + `('renders', ({ packageEntity, selector }) => { + it(`${packageEntity.packageType} instructions exist`, () => { + createComponent({ packageEntity }); + + expect(selector()).toExist(); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/maven_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/maven_installation_spec.js new file mode 100644 index 00000000000..eed7e903833 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/maven_installation_spec.js @@ -0,0 +1,213 @@ +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import { + packageData, + mavenMetadata, +} from 'jest/packages_and_registries/package_registry/mock_data'; +import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; +import MavenInstallation from '~/packages_and_registries/package_registry/components/details/maven_installation.vue'; +import { + TRACKING_ACTION_COPY_MAVEN_XML, + TRACKING_ACTION_COPY_MAVEN_COMMAND, + TRACKING_ACTION_COPY_MAVEN_SETUP, + TRACKING_ACTION_COPY_GRADLE_INSTALL_COMMAND, + TRACKING_ACTION_COPY_GRADLE_ADD_TO_SOURCE_COMMAND, + TRACKING_ACTION_COPY_KOTLIN_INSTALL_COMMAND, + TRACKING_ACTION_COPY_KOTLIN_ADD_TO_SOURCE_COMMAND, + PACKAGE_TYPE_MAVEN, +} from '~/packages_and_registries/package_registry/constants'; +import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; + +describe('MavenInstallation', () => { + let wrapper; + + const packageEntity = { + ...packageData(), + packageType: PACKAGE_TYPE_MAVEN, + metadata: mavenMetadata(), + }; + + const mavenHelpPath = 'mavenHelpPath'; + const mavenPath = 'mavenPath'; + + const xmlCodeBlock = `<dependency> + <groupId>appGroup</groupId> + <artifactId>appName</artifactId> + <version>appVersion</version> +</dependency>`; + const mavenCommandStr = 'mvn dependency:get -Dartifact=appGroup:appName:appVersion'; + const mavenSetupXml = `<repositories> + <repository> + <id>gitlab-maven</id> + <url>${mavenPath}</url> + </repository> +</repositories> + +<distributionManagement> + <repository> + <id>gitlab-maven</id> + <url>${mavenPath}</url> + </repository> + + <snapshotRepository> + <id>gitlab-maven</id> + <url>${mavenPath}</url> + </snapshotRepository> +</distributionManagement>`; + const gradleGroovyInstallCommandText = `implementation 'appGroup:appName:appVersion'`; + const gradleGroovyAddSourceCommandText = `maven { + url '${mavenPath}' +}`; + const gradleKotlinInstallCommandText = `implementation("appGroup:appName:appVersion")`; + const gradleKotlinAddSourceCommandText = `maven("${mavenPath}")`; + + const findCodeInstructions = () => wrapper.findAllComponents(CodeInstructions); + const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); + + function createComponent({ data = {} } = {}) { + wrapper = shallowMountExtended(MavenInstallation, { + provide: { + mavenHelpPath, + mavenPath, + }, + propsData: { + packageEntity, + }, + data() { + return data; + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('install command switch', () => { + it('has the installation title component', () => { + createComponent(); + + expect(findInstallationTitle().exists()).toBe(true); + expect(findInstallationTitle().props()).toMatchObject({ + packageType: 'maven', + options: [ + { value: 'maven', label: 'Maven XML' }, + { value: 'groovy', label: 'Gradle Groovy DSL' }, + { value: 'kotlin', label: 'Gradle Kotlin DSL' }, + ], + }); + }); + + it('on change event updates the instructions to show', async () => { + createComponent(); + + expect(findCodeInstructions().at(0).props('instruction')).toBe(xmlCodeBlock); + findInstallationTitle().vm.$emit('change', 'groovy'); + + await nextTick(); + + expect(findCodeInstructions().at(0).props('instruction')).toBe( + gradleGroovyInstallCommandText, + ); + }); + }); + + describe('maven', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders all the messages', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('installation commands', () => { + it('renders the correct xml block', () => { + expect(findCodeInstructions().at(0).props()).toMatchObject({ + instruction: xmlCodeBlock, + multiline: true, + trackingAction: TRACKING_ACTION_COPY_MAVEN_XML, + }); + }); + + it('renders the correct maven command', () => { + expect(findCodeInstructions().at(1).props()).toMatchObject({ + instruction: mavenCommandStr, + multiline: false, + trackingAction: TRACKING_ACTION_COPY_MAVEN_COMMAND, + }); + }); + }); + + describe('setup commands', () => { + it('renders the correct xml block', () => { + expect(findCodeInstructions().at(2).props()).toMatchObject({ + instruction: mavenSetupXml, + multiline: true, + trackingAction: TRACKING_ACTION_COPY_MAVEN_SETUP, + }); + }); + }); + }); + + describe('groovy', () => { + beforeEach(() => { + createComponent({ data: { instructionType: 'groovy' } }); + }); + + it('renders all the messages', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('installation commands', () => { + it('renders the gradle install command', () => { + expect(findCodeInstructions().at(0).props()).toMatchObject({ + instruction: gradleGroovyInstallCommandText, + multiline: false, + trackingAction: TRACKING_ACTION_COPY_GRADLE_INSTALL_COMMAND, + }); + }); + }); + + describe('setup commands', () => { + it('renders the correct gradle command', () => { + expect(findCodeInstructions().at(1).props()).toMatchObject({ + instruction: gradleGroovyAddSourceCommandText, + multiline: true, + trackingAction: TRACKING_ACTION_COPY_GRADLE_ADD_TO_SOURCE_COMMAND, + }); + }); + }); + }); + + describe('kotlin', () => { + beforeEach(() => { + createComponent({ data: { instructionType: 'kotlin' } }); + }); + + it('renders all the messages', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('installation commands', () => { + it('renders the gradle install command', () => { + expect(findCodeInstructions().at(0).props()).toMatchObject({ + instruction: gradleKotlinInstallCommandText, + multiline: false, + trackingAction: TRACKING_ACTION_COPY_KOTLIN_INSTALL_COMMAND, + }); + }); + }); + + describe('setup commands', () => { + it('renders the correct gradle command', () => { + expect(findCodeInstructions().at(1).props()).toMatchObject({ + instruction: gradleKotlinAddSourceCommandText, + multiline: true, + trackingAction: TRACKING_ACTION_COPY_KOTLIN_ADD_TO_SOURCE_COMMAND, + }); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js new file mode 100644 index 00000000000..083c6858ad0 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js @@ -0,0 +1,122 @@ +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import { packageData } from 'jest/packages_and_registries/package_registry/mock_data'; +import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; +import NpmInstallation from '~/packages_and_registries/package_registry/components/details/npm_installation.vue'; +import { + TRACKING_ACTION_COPY_NPM_INSTALL_COMMAND, + TRACKING_ACTION_COPY_NPM_SETUP_COMMAND, + TRACKING_ACTION_COPY_YARN_INSTALL_COMMAND, + TRACKING_ACTION_COPY_YARN_SETUP_COMMAND, + PACKAGE_TYPE_NPM, + NPM_PACKAGE_MANAGER, + YARN_PACKAGE_MANAGER, +} from '~/packages_and_registries/package_registry/constants'; +import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; + +const packageEntity = { ...packageData(), packageType: PACKAGE_TYPE_NPM }; + +describe('NpmInstallation', () => { + let wrapper; + + const npmInstallationCommandLabel = 'npm i @gitlab-org/package-15'; + const yarnInstallationCommandLabel = 'yarn add @gitlab-org/package-15'; + + const findCodeInstructions = () => wrapper.findAllComponents(CodeInstructions); + const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); + + function createComponent({ data = {} } = {}) { + wrapper = shallowMountExtended(NpmInstallation, { + provide: { + npmHelpPath: 'npmHelpPath', + npmPath: 'npmPath', + }, + propsData: { + packageEntity, + }, + data() { + return data; + }, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders all the messages', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('install command switch', () => { + it('has the installation title component', () => { + expect(findInstallationTitle().exists()).toBe(true); + expect(findInstallationTitle().props()).toMatchObject({ + packageType: NPM_PACKAGE_MANAGER, + options: [ + { value: NPM_PACKAGE_MANAGER, label: 'Show NPM commands' }, + { value: YARN_PACKAGE_MANAGER, label: 'Show Yarn commands' }, + ], + }); + }); + + it('on change event updates the instructions to show', async () => { + createComponent(); + + expect(findCodeInstructions().at(0).props('instruction')).toBe(npmInstallationCommandLabel); + findInstallationTitle().vm.$emit('change', YARN_PACKAGE_MANAGER); + + await nextTick(); + + expect(findCodeInstructions().at(0).props('instruction')).toBe(yarnInstallationCommandLabel); + }); + }); + + describe('npm', () => { + beforeEach(() => { + createComponent(); + }); + it('renders the correct installation command', () => { + expect(findCodeInstructions().at(0).props()).toMatchObject({ + instruction: npmInstallationCommandLabel, + multiline: false, + trackingAction: TRACKING_ACTION_COPY_NPM_INSTALL_COMMAND, + }); + }); + + it('renders the correct setup command', () => { + expect(findCodeInstructions().at(1).props()).toMatchObject({ + instruction: 'echo @gitlab-org:registry=npmPath/ >> .npmrc', + multiline: false, + trackingAction: TRACKING_ACTION_COPY_NPM_SETUP_COMMAND, + }); + }); + }); + + describe('yarn', () => { + beforeEach(() => { + createComponent({ data: { instructionType: YARN_PACKAGE_MANAGER } }); + }); + + it('renders the correct setup command', () => { + expect(findCodeInstructions().at(0).props()).toMatchObject({ + instruction: yarnInstallationCommandLabel, + multiline: false, + trackingAction: TRACKING_ACTION_COPY_YARN_INSTALL_COMMAND, + }); + }); + + it('renders the correct registry command', () => { + expect(findCodeInstructions().at(1).props()).toMatchObject({ + instruction: 'echo \\"@gitlab-org:registry\\" \\"npmPath/\\" >> .yarnrc', + multiline: false, + trackingAction: TRACKING_ACTION_COPY_YARN_SETUP_COMMAND, + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/nuget_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/nuget_installation_spec.js new file mode 100644 index 00000000000..c48a3f07299 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/nuget_installation_spec.js @@ -0,0 +1,75 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { packageData } from 'jest/packages_and_registries/package_registry/mock_data'; +import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; +import NugetInstallation from '~/packages_and_registries/package_registry/components/details/nuget_installation.vue'; +import { + TRACKING_ACTION_COPY_NUGET_INSTALL_COMMAND, + TRACKING_ACTION_COPY_NUGET_SETUP_COMMAND, + PACKAGE_TYPE_NUGET, +} from '~/packages_and_registries/package_registry/constants'; +import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; + +const packageEntity = { ...packageData(), packageType: PACKAGE_TYPE_NUGET }; + +describe('NugetInstallation', () => { + let wrapper; + + const nugetInstallationCommandStr = 'nuget install @gitlab-org/package-15 -Source "GitLab"'; + const nugetSetupCommandStr = + 'nuget source Add -Name "GitLab" -Source "nugetPath" -UserName <your_username> -Password <your_token>'; + + const findCodeInstructions = () => wrapper.findAllComponents(CodeInstructions); + const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); + + function createComponent() { + wrapper = shallowMountExtended(NugetInstallation, { + provide: { + nugetHelpPath: 'nugetHelpPath', + nugetPath: 'nugetPath', + }, + propsData: { + packageEntity, + }, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders all the messages', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('install command switch', () => { + it('has the installation title component', () => { + expect(findInstallationTitle().exists()).toBe(true); + expect(findInstallationTitle().props()).toMatchObject({ + packageType: 'nuget', + options: [{ value: 'nuget', label: 'Show Nuget commands' }], + }); + }); + }); + + describe('installation commands', () => { + it('renders the correct command', () => { + expect(findCodeInstructions().at(0).props()).toMatchObject({ + instruction: nugetInstallationCommandStr, + trackingAction: TRACKING_ACTION_COPY_NUGET_INSTALL_COMMAND, + }); + }); + }); + + describe('setup commands', () => { + it('renders the correct command', () => { + expect(findCodeInstructions().at(1).props()).toMatchObject({ + instruction: nugetSetupCommandStr, + trackingAction: TRACKING_ACTION_COPY_NUGET_SETUP_COMMAND, + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js new file mode 100644 index 00000000000..042b2026199 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js @@ -0,0 +1,272 @@ +import { GlDropdown, GlButton } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import stubChildren from 'helpers/stub_children'; +import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { packageFiles as packageFilesMock } from 'jest/packages_and_registries/package_registry/mock_data'; +import PackageFiles from '~/packages_and_registries/package_registry/components/details/package_files.vue'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +describe('Package Files', () => { + let wrapper; + + const findAllRows = () => wrapper.findAllByTestId('file-row'); + const findFirstRow = () => extendedWrapper(findAllRows().at(0)); + const findSecondRow = () => extendedWrapper(findAllRows().at(1)); + const findFirstRowDownloadLink = () => findFirstRow().findByTestId('download-link'); + const findFirstRowCommitLink = () => findFirstRow().findByTestId('commit-link'); + const findSecondRowCommitLink = () => findSecondRow().findByTestId('commit-link'); + const findFirstRowFileIcon = () => findFirstRow().findComponent(FileIcon); + const findFirstRowCreatedAt = () => findFirstRow().findComponent(TimeAgoTooltip); + const findFirstActionMenu = () => extendedWrapper(findFirstRow().findComponent(GlDropdown)); + const findActionMenuDelete = () => findFirstActionMenu().findByTestId('delete-file'); + const findFirstToggleDetailsButton = () => findFirstRow().findComponent(GlButton); + const findFirstRowShaComponent = (id) => wrapper.findByTestId(id); + + const files = packageFilesMock(); + const [file] = files; + + const createComponent = ({ packageFiles = [file], canDelete = true } = {}) => { + wrapper = mountExtended(PackageFiles, { + provide: { canDelete }, + propsData: { + packageFiles, + }, + stubs: { + ...stubChildren(PackageFiles), + GlTable: false, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('rows', () => { + it('renders a single file for an npm package', () => { + createComponent(); + + expect(findAllRows()).toHaveLength(1); + }); + + it('renders multiple files for a package that contains more than one file', () => { + createComponent({ packageFiles: files }); + + expect(findAllRows()).toHaveLength(2); + }); + }); + + describe('link', () => { + it('exists', () => { + createComponent(); + + expect(findFirstRowDownloadLink().exists()).toBe(true); + }); + + it('has the correct attrs bound', () => { + createComponent(); + + expect(findFirstRowDownloadLink().attributes('href')).toBe(file.downloadPath); + }); + + it('emits "download-file" event on click', () => { + createComponent(); + + findFirstRowDownloadLink().vm.$emit('click'); + + expect(wrapper.emitted('download-file')).toEqual([[]]); + }); + }); + + describe('file-icon', () => { + it('exists', () => { + createComponent(); + + expect(findFirstRowFileIcon().exists()).toBe(true); + }); + + it('has the correct props bound', () => { + createComponent(); + + expect(findFirstRowFileIcon().props('fileName')).toBe(file.fileName); + }); + }); + + describe('time-ago tooltip', () => { + it('exists', () => { + createComponent(); + + expect(findFirstRowCreatedAt().exists()).toBe(true); + }); + + it('has the correct props bound', () => { + createComponent(); + + expect(findFirstRowCreatedAt().props('time')).toBe(file.createdAt); + }); + }); + + describe('commit', () => { + const withPipeline = { + ...file, + pipelines: [ + { + sha: 'sha', + id: 1, + commitPath: 'commitPath', + }, + ], + }; + + describe('when package file has a pipeline associated', () => { + it('exists', () => { + createComponent({ packageFiles: [withPipeline] }); + + expect(findFirstRowCommitLink().exists()).toBe(true); + }); + + it('the link points to the commit path', () => { + createComponent({ packageFiles: [withPipeline] }); + + expect(findFirstRowCommitLink().attributes('href')).toBe( + withPipeline.pipelines[0].commitPath, + ); + }); + + it('the text is the pipeline sha', () => { + createComponent({ packageFiles: [withPipeline] }); + + expect(findFirstRowCommitLink().text()).toBe(withPipeline.pipelines[0].sha); + }); + }); + + describe('when package file has no pipeline associated', () => { + it('does not exist', () => { + createComponent(); + + expect(findFirstRowCommitLink().exists()).toBe(false); + }); + }); + + describe('when only one file lacks an associated pipeline', () => { + it('renders the commit when it exists and not otherwise', () => { + createComponent({ packageFiles: [withPipeline, file] }); + + expect(findFirstRowCommitLink().exists()).toBe(true); + expect(findSecondRowCommitLink().exists()).toBe(false); + }); + }); + + describe('action menu', () => { + describe('when the user can delete', () => { + it('exists', () => { + createComponent(); + + expect(findFirstActionMenu().exists()).toBe(true); + }); + + describe('menu items', () => { + describe('delete file', () => { + it('exists', () => { + createComponent(); + + expect(findActionMenuDelete().exists()).toBe(true); + }); + + it('emits a delete event when clicked', () => { + createComponent(); + + findActionMenuDelete().vm.$emit('click'); + + const [[{ id }]] = wrapper.emitted('delete-file'); + expect(id).toBe(file.id); + }); + }); + }); + }); + + describe('when the user can not delete', () => { + const canDelete = false; + + it('does not exist', () => { + createComponent({ canDelete }); + + expect(findFirstActionMenu().exists()).toBe(false); + }); + }); + }); + }); + + describe('additional details', () => { + describe('details toggle button', () => { + it('exists', () => { + createComponent(); + + expect(findFirstToggleDetailsButton().exists()).toBe(true); + }); + + it('is hidden when no details is present', () => { + const { ...noShaFile } = file; + noShaFile.fileSha256 = null; + noShaFile.fileMd5 = null; + noShaFile.fileSha1 = null; + createComponent({ packageFiles: [noShaFile] }); + + expect(findFirstToggleDetailsButton().exists()).toBe(false); + }); + + it('toggles the details row', async () => { + createComponent(); + + expect(findFirstToggleDetailsButton().props('icon')).toBe('angle-down'); + + findFirstToggleDetailsButton().vm.$emit('click'); + await nextTick(); + + expect(findFirstRowShaComponent('sha-256').exists()).toBe(true); + expect(findFirstToggleDetailsButton().props('icon')).toBe('angle-up'); + + findFirstToggleDetailsButton().vm.$emit('click'); + await nextTick(); + + expect(findFirstRowShaComponent('sha-256').exists()).toBe(false); + expect(findFirstToggleDetailsButton().props('icon')).toBe('angle-down'); + }); + }); + + describe('file shas', () => { + const showShaFiles = () => { + findFirstToggleDetailsButton().vm.$emit('click'); + return nextTick(); + }; + + it.each` + selector | title | sha + ${'sha-256'} | ${'SHA-256'} | ${'fileSha256'} + ${'md5'} | ${'MD5'} | ${'fileMd5'} + ${'sha-1'} | ${'SHA-1'} | ${'be93151dc23ac34a82752444556fe79b32c7a1ad'} + `('has a $title row', async ({ selector, title, sha }) => { + createComponent(); + + await showShaFiles(); + + expect(findFirstRowShaComponent(selector).props()).toMatchObject({ + title, + sha, + }); + }); + + it('does not display a row when the data is missing', async () => { + const { ...missingMd5 } = file; + missingMd5.fileMd5 = null; + + createComponent({ packageFiles: [missingMd5] }); + + await showShaFiles(); + + expect(findFirstRowShaComponent('md5').exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js new file mode 100644 index 00000000000..b69008f04f0 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js @@ -0,0 +1,122 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { stubComponent } from 'helpers/stub_component'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + packageData, + packagePipelines, +} from 'jest/packages_and_registries/package_registry/mock_data'; +import { HISTORY_PIPELINES_LIMIT } from '~/packages/details/constants'; +import component from '~/packages_and_registries/package_registry/components/details/package_history.vue'; +import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +describe('Package History', () => { + let wrapper; + const defaultProps = { + projectName: 'baz project', + packageEntity: { ...packageData() }, + }; + + const [onePipeline] = packagePipelines(); + + const createPipelines = (amount) => + [...Array(amount)].map((x, index) => packagePipelines({ id: index + 1 })[0]); + + const mountComponent = (props) => { + wrapper = shallowMountExtended(component, { + propsData: { ...defaultProps, ...props }, + stubs: { + HistoryItem: stubComponent(HistoryItem, { + template: '<div data-testid="history-element"><slot></slot></div>', + }), + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findHistoryElement = (testId) => wrapper.findByTestId(testId); + const findElementLink = (container) => container.findComponent(GlLink); + const findElementTimeAgo = (container) => container.findComponent(TimeAgoTooltip); + const findTitle = () => wrapper.findByTestId('title'); + const findTimeline = () => wrapper.findByTestId('timeline'); + + it('has the correct title', () => { + mountComponent(); + + const title = findTitle(); + + expect(title.exists()).toBe(true); + expect(title.text()).toBe('History'); + }); + + it('has a timeline container', () => { + mountComponent(); + + const title = findTimeline(); + + expect(title.exists()).toBe(true); + expect(title.classes()).toEqual( + expect.arrayContaining(['timeline', 'main-notes-list', 'notes']), + ); + }); + + describe.each` + name | amount | icon | text | timeAgoTooltip | link + ${'created-on'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'clock'} | ${'@gitlab-org/package-15 version 1.0.0 was first created'} | ${packageData().createdAt} | ${null} + ${'first-pipeline-commit'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'commit'} | ${'Created by commit #b83d6e39 on branch master'} | ${null} | ${onePipeline.commitPath} + ${'first-pipeline-pipeline'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'pipeline'} | ${'Built by pipeline #1 triggered by Administrator'} | ${onePipeline.createdAt} | ${onePipeline.path} + ${'published'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'package'} | ${'Published to the baz project Package Registry'} | ${packageData().createdAt} | ${null} + ${'archived'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'history'} | ${'Package has 1 archived update'} | ${null} | ${null} + ${'archived'} | ${HISTORY_PIPELINES_LIMIT + 3} | ${'history'} | ${'Package has 2 archived updates'} | ${null} | ${null} + ${'pipeline-entry'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'pencil'} | ${'Package updated by commit #b83d6e39 on branch master, built by pipeline #3, and published to the registry'} | ${packageData().createdAt} | ${onePipeline.commitPath} + `( + 'with $amount pipelines history element $name', + ({ name, icon, text, timeAgoTooltip, link, amount }) => { + let element; + + beforeEach(() => { + const packageEntity = { ...packageData(), pipelines: { nodes: createPipelines(amount) } }; + mountComponent({ + packageEntity, + }); + element = findHistoryElement(name); + }); + + it('exists', () => { + expect(element.exists()).toBe(true); + }); + + it('has the correct icon', () => { + expect(element.props('icon')).toBe(icon); + }); + + it('has the correct text', () => { + expect(element.text()).toBe(text); + }); + + it('time-ago tooltip', () => { + const timeAgo = findElementTimeAgo(element); + const exist = Boolean(timeAgoTooltip); + + expect(timeAgo.exists()).toBe(exist); + if (exist) { + expect(timeAgo.props('time')).toBe(timeAgoTooltip); + } + }); + + it('link', () => { + const linkElement = findElementLink(element); + const exist = Boolean(link); + + expect(linkElement.exists()).toBe(exist); + if (exist) { + expect(linkElement.attributes('href')).toBe(link); + } + }); + }, + ); +}); 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 new file mode 100644 index 00000000000..327f6d81905 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js @@ -0,0 +1,202 @@ +import { GlIcon, GlSprintf } from '@gitlab/ui'; +import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PackageTags from '~/packages/shared/components/package_tags.vue'; +import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue'; +import { + PACKAGE_TYPE_CONAN, + PACKAGE_TYPE_MAVEN, + PACKAGE_TYPE_NPM, + PACKAGE_TYPE_NUGET, +} from '~/packages_and_registries/package_registry/constants'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +import { packageData, packageFiles, packageTags, packagePipelines } from '../../mock_data'; + +const packageWithTags = { + ...packageData(), + tags: { nodes: packageTags() }, + packageFiles: { nodes: packageFiles() }, +}; + +describe('PackageTitle', () => { + let wrapper; + + function createComponent(packageEntity = packageWithTags) { + wrapper = shallowMountExtended(PackageTitle, { + propsData: { packageEntity }, + stubs: { + TitleArea, + GlSprintf, + }, + }); + return wrapper.vm.$nextTick(); + } + + const findTitleArea = () => wrapper.findComponent(TitleArea); + const findPackageType = () => wrapper.findByTestId('package-type'); + const findPackageSize = () => wrapper.findByTestId('package-size'); + const findPipelineProject = () => wrapper.findByTestId('pipeline-project'); + 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); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('renders', () => { + it('without tags', async () => { + await createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('with tags', async () => { + await createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('with tags on mobile', async () => { + jest.spyOn(GlBreakpointInstance, 'isDesktop').mockReturnValue(false); + await createComponent(); + + await wrapper.vm.$nextTick(); + + expect(findPackageBadges()).toHaveLength(packageTags().length); + }); + }); + + describe('package title', () => { + it('is correctly bound', async () => { + await createComponent(); + + expect(findTitleArea().props('title')).toBe(packageData().name); + }); + }); + + describe('package icon', () => { + const iconUrl = 'a-fake-src'; + + it('shows an icon when present and package type is NUGET', async () => { + await createComponent({ + ...packageData(), + packageType: PACKAGE_TYPE_NUGET, + metadata: { iconUrl }, + }); + + expect(findTitleArea().props('avatar')).toBe(iconUrl); + }); + + it('hides the icon when not present', async () => { + await createComponent(); + + expect(findTitleArea().props('avatar')).toBe(null); + }); + }); + + 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(); + + expect(findSubHeaderText().text()).toMatchInterpolatedText('v 1.0.0 published'); + }); + + it('has a time ago tooltip component', async () => { + await createComponent(); + expect(findSubHeaderTimeAgo().props('time')).toBe(packageWithTags.createdAt); + }); + }); + + describe.each` + packageType | text + ${PACKAGE_TYPE_CONAN} | ${'Conan'} + ${PACKAGE_TYPE_MAVEN} | ${'Maven'} + ${PACKAGE_TYPE_NPM} | ${'npm'} + ${PACKAGE_TYPE_NUGET} | ${'NuGet'} + `(`package type`, ({ packageType, text }) => { + beforeEach(() => createComponent({ ...packageData, packageType })); + + it(`${packageType} should render ${text}`, () => { + expect(findPackageType().props()).toEqual(expect.objectContaining({ text, icon: 'package' })); + }); + }); + + describe('calculates the package size', () => { + it('correctly calculates when there is only 1 file', async () => { + await createComponent({ ...packageData(), packageFiles: { nodes: [packageFiles()[0]] } }); + + expect(findPackageSize().props()).toMatchObject({ text: '400.00 KiB', icon: 'disk' }); + }); + + it('correctly calculates when there are multiple files', async () => { + await createComponent(); + + expect(findPackageSize().props('text')).toBe('800.00 KiB'); + }); + }); + + describe('package tags', () => { + it('displays the package-tags component when the package has tags', async () => { + await createComponent(); + + expect(findPackageTags().exists()).toBe(true); + }); + + it('does not display the package-tags component when there are no tags', async () => { + await createComponent({ ...packageData(), tags: { nodes: [] } }); + + expect(findPackageTags().exists()).toBe(false); + }); + }); + + describe('package ref', () => { + it('does not display the ref if missing', async () => { + await createComponent(); + + expect(findPackageRef().exists()).toBe(false); + }); + + it('correctly shows the package ref if there is one', async () => { + await createComponent({ + ...packageData(), + pipelines: { nodes: packagePipelines({ ref: 'test' }) }, + }); + expect(findPackageRef().props()).toMatchObject({ + text: 'test', + icon: 'branch', + }); + }); + }); + + describe('pipeline project', () => { + it('does not display the project if missing', async () => { + await createComponent(); + + expect(findPipelineProject().exists()).toBe(false); + }); + + it('correctly shows the pipeline project if there is one', async () => { + await createComponent({ + ...packageData(), + pipelines: { nodes: packagePipelines() }, + }); + expect(findPipelineProject().props()).toMatchObject({ + text: packagePipelines()[0].project.name, + icon: 'review-list', + link: packagePipelines()[0].project.webUrl, + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js new file mode 100644 index 00000000000..410c1b65348 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js @@ -0,0 +1,80 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { packageData } from 'jest/packages_and_registries/package_registry/mock_data'; +import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; +import PypiInstallation from '~/packages_and_registries/package_registry/components/details/pypi_installation.vue'; +import { + PACKAGE_TYPE_PYPI, + TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND, + TRACKING_ACTION_COPY_PYPI_SETUP_COMMAND, +} from '~/packages_and_registries/package_registry/constants'; + +const packageEntity = { ...packageData(), packageType: PACKAGE_TYPE_PYPI }; + +describe('PypiInstallation', () => { + let wrapper; + + const pipCommandStr = 'pip install @gitlab-org/package-15 --extra-index-url pypiPath'; + const pypiSetupStr = `[gitlab] +repository = pypiSetupPath +username = __token__ +password = <your personal access token>`; + + const pipCommand = () => wrapper.findByTestId('pip-command'); + const setupInstruction = () => wrapper.findByTestId('pypi-setup-content'); + + const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); + + function createComponent() { + wrapper = shallowMountExtended(PypiInstallation, { + provide: { + pypiHelpPath: 'pypiHelpPath', + pypiPath: 'pypiPath', + pypiSetupPath: 'pypiSetupPath', + }, + propsData: { + packageEntity, + }, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('install command switch', () => { + it('has the installation title component', () => { + expect(findInstallationTitle().exists()).toBe(true); + expect(findInstallationTitle().props()).toMatchObject({ + packageType: 'pypi', + options: [{ value: 'pypi', label: 'Show PyPi commands' }], + }); + }); + }); + + it('renders all the messages', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('installation commands', () => { + it('renders the correct pip command', () => { + expect(pipCommand().props()).toMatchObject({ + instruction: pipCommandStr, + trackingAction: TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND, + }); + }); + }); + + describe('setup commands', () => { + it('renders the correct setup block', () => { + expect(setupInstruction().props()).toMatchObject({ + instruction: pypiSetupStr, + multiline: true, + trackingAction: TRACKING_ACTION_COPY_PYPI_SETUP_COMMAND, + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js new file mode 100644 index 00000000000..f7613949fe4 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js @@ -0,0 +1,89 @@ +import { GlLink, GlSprintf, GlTruncate } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import PackageTags from '~/packages/shared/components/package_tags.vue'; +import PublishMethod from '~/packages/shared/components/publish_method.vue'; +import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +import { packageVersions } from '../../mock_data'; + +const packageVersion = packageVersions()[0]; + +describe('VersionRow', () => { + let wrapper; + + const findListItem = () => wrapper.findComponent(ListItem); + const findLink = () => wrapper.findComponent(GlLink); + const findPackageTags = () => wrapper.findComponent(PackageTags); + const findPublishMethod = () => wrapper.findComponent(PublishMethod); + const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip); + + function createComponent(packageEntity = packageVersion) { + wrapper = shallowMountExtended(VersionRow, { + propsData: { + packageEntity, + }, + stubs: { + ListItem, + GlSprintf, + GlTruncate, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('has a link to the version detail', () => { + createComponent(); + + expect(findLink().attributes('href')).toBe(`${getIdFromGraphQLId(packageVersion.id)}`); + expect(findLink().text()).toBe(packageVersion.name); + }); + + it('has the version of the package', () => { + createComponent(); + + expect(wrapper.text()).toContain(packageVersion.version); + }); + + it('has a package tags component', () => { + createComponent(); + + expect(findPackageTags().props('tags')).toBe(packageVersion.tags.nodes); + }); + + it('has a publish method component', () => { + createComponent(); + + expect(findPublishMethod().props('packageEntity')).toBe(packageVersion); + }); + it('has a time-ago tooltip', () => { + createComponent(); + + expect(findTimeAgoTooltip().props('time')).toBe(packageVersion.createdAt); + }); + + describe('disabled status', () => { + it('disables the list item', () => { + createComponent({ ...packageVersion, status: 'something' }); + + expect(findListItem().props('disabled')).toBe(true); + }); + + it('disables the link', () => { + createComponent({ ...packageVersion, status: 'something' }); + + expect(findLink().attributes('disabled')).toBe('true'); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js new file mode 100644 index 00000000000..98ff29ef728 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -0,0 +1,251 @@ +export const packageTags = () => [ + { id: 'gid://gitlab/Packages::Tag/87', name: 'bananas_9', __typename: 'PackageTag' }, + { id: 'gid://gitlab/Packages::Tag/86', name: 'bananas_8', __typename: 'PackageTag' }, + { id: 'gid://gitlab/Packages::Tag/85', name: 'bananas_7', __typename: 'PackageTag' }, +]; + +export const packagePipelines = (extend) => [ + { + commitPath: '/namespace14/project14/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0', + createdAt: '2020-08-17T14:23:32Z', + id: 'gid://gitlab/Ci::Pipeline/36', + path: '/namespace14/project14/-/pipelines/36', + name: 'project14', + ref: 'master', + sha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0', + project: { + name: 'project14', + webUrl: 'http://gdk.test:3000/namespace14/project14', + __typename: 'Project', + }, + user: { + name: 'Administrator', + }, + ...extend, + __typename: 'Pipeline', + }, +]; + +export const packageFiles = () => [ + { + id: 'gid://gitlab/Packages::PackageFile/118', + fileMd5: 'fileMd5', + fileName: 'foo-1.0.1.tgz', + fileSha1: 'be93151dc23ac34a82752444556fe79b32c7a1ad', + fileSha256: 'fileSha256', + size: '409600', + createdAt: '2020-08-17T14:23:32Z', + downloadPath: 'downloadPath', + __typename: 'PackageFile', + }, + { + id: 'gid://gitlab/Packages::PackageFile/119', + fileMd5: null, + fileName: 'foo-1.0.2.tgz', + fileSha1: 'be93151dc23ac34a82752444556fe79b32c7a1ss', + fileSha256: null, + size: '409600', + createdAt: '2020-08-17T14:23:32Z', + downloadPath: 'downloadPath', + __typename: 'PackageFile', + }, +]; + +export const dependencyLinks = () => [ + { + dependencyType: 'DEPENDENCIES', + id: 'gid://gitlab/Packages::DependencyLink/77', + __typename: 'PackageDependencyLink', + dependency: { + id: 'gid://gitlab/Packages::Dependency/3', + name: 'Ninject.Extensions.Factory', + versionPattern: '3.3.2', + __typename: 'PackageDependency', + }, + metadata: { + id: 'gid://gitlab/Packages::Nuget::DependencyLinkMetadatum/77', + targetFramework: '.NETCoreApp3.1', + __typename: 'NugetDependencyLinkMetadata', + }, + }, + { + dependencyType: 'DEPENDENCIES', + id: 'gid://gitlab/Packages::DependencyLink/78', + __typename: 'PackageDependencyLink', + dependency: { + id: 'gid://gitlab/Packages::Dependency/4', + name: 'Ninject.Extensions.Factory', + versionPattern: '3.3.2', + __typename: 'PackageDependency', + }, + metadata: { + id: 'gid://gitlab/Packages::Nuget::DependencyLinkMetadatum/78', + targetFramework: '.NETCoreApp3.1', + __typename: 'NugetDependencyLinkMetadata', + }, + }, +]; + +export const packageVersions = () => [ + { + createdAt: '2021-08-10T09:33:54Z', + id: 'gid://gitlab/Packages::Package/243', + name: '@gitlab-org/package-15', + status: 'DEFAULT', + tags: { nodes: packageTags() }, + version: '1.0.1', + __typename: 'Package', + }, + { + createdAt: '2021-08-10T09:33:54Z', + id: 'gid://gitlab/Packages::Package/244', + name: '@gitlab-org/package-15', + status: 'DEFAULT', + tags: { nodes: packageTags() }, + version: '1.0.2', + __typename: 'Package', + }, +]; + +export const packageData = (extend) => ({ + id: 'gid://gitlab/Packages::Package/111', + name: '@gitlab-org/package-15', + packageType: 'NPM', + version: '1.0.0', + createdAt: '2020-08-17T14:23:32Z', + updatedAt: '2020-08-17T14:23:32Z', + status: 'DEFAULT', + ...extend, +}); + +export const conanMetadata = () => ({ + packageChannel: 'stable', + packageUsername: 'gitlab-org+gitlab-test', + recipe: 'package-8/1.0.0@gitlab-org+gitlab-test/stable', + recipePath: 'package-8/1.0.0/gitlab-org+gitlab-test/stable', +}); + +export const composerMetadata = () => ({ + targetSha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0', + composerJson: { + license: 'MIT', + version: '1.0.0', + }, +}); + +export const pypyMetadata = () => ({ + requiredPython: '1.0.0', +}); + +export const mavenMetadata = () => ({ + appName: 'appName', + appGroup: 'appGroup', + appVersion: 'appVersion', + path: 'path', +}); + +export const nugetMetadata = () => ({ + iconUrl: 'iconUrl', + licenseUrl: 'licenseUrl', + projectUrl: 'projectUrl', +}); + +export const packageDetailsQuery = (extendPackage) => ({ + data: { + package: { + ...packageData(), + metadata: { + ...conanMetadata(), + ...composerMetadata(), + ...pypyMetadata(), + ...mavenMetadata(), + ...nugetMetadata(), + }, + project: { + path: 'projectPath', + }, + tags: { + nodes: packageTags(), + __typename: 'PackageTagConnection', + }, + pipelines: { + nodes: packagePipelines(), + __typename: 'PipelineConnection', + }, + packageFiles: { + nodes: packageFiles(), + __typename: 'PackageFileConnection', + }, + versions: { + nodes: packageVersions(), + __typename: 'PackageConnection', + }, + dependencyLinks: { + nodes: dependencyLinks(), + }, + __typename: 'PackageDetailsType', + ...extendPackage, + }, + }, +}); + +export const emptyPackageDetailsQuery = () => ({ + data: { + package: { + __typename: 'PackageDetailsType', + }, + }, +}); + +export const packageDestroyMutation = () => ({ + data: { + destroyPackage: { + errors: [], + }, + }, +}); + +export const packageDestroyMutationError = () => ({ + data: { + destroyPackage: null, + }, + errors: [ + { + message: + "The resource that you are attempting to access does not exist or you don't have permission to perform this action", + locations: [ + { + line: 2, + column: 3, + }, + ], + path: ['destroyPackage'], + }, + ], +}); + +export const packageDestroyFileMutation = () => ({ + data: { + destroyPackageFile: { + errors: [], + }, + }, +}); +export const packageDestroyFileMutationError = () => ({ + data: { + destroyPackageFile: null, + }, + errors: [ + { + message: + "The resource that you are attempting to access does not exist or you don't have permission to perform this action", + locations: [ + { + line: 2, + column: 3, + }, + ], + path: ['destroyPackageFile'], + }, + ], +}); diff --git a/spec/frontend/packages_and_registries/package_registry/utils_spec.js b/spec/frontend/packages_and_registries/package_registry/utils_spec.js new file mode 100644 index 00000000000..019f94aaec2 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/utils_spec.js @@ -0,0 +1,23 @@ +import { getPackageTypeLabel } from '~/packages_and_registries/package_registry/utils'; + +describe('Packages shared utils', () => { + describe('getPackageTypeLabel', () => { + describe.each` + packageType | expectedResult + ${'CONAN'} | ${'Conan'} + ${'MAVEN'} | ${'Maven'} + ${'NPM'} | ${'npm'} + ${'NUGET'} | ${'NuGet'} + ${'PYPI'} | ${'PyPI'} + ${'RUBYGEMS'} | ${'RubyGems'} + ${'COMPOSER'} | ${'Composer'} + ${'DEBIAN'} | ${'Debian'} + ${'HELM'} | ${'Helm'} + ${'FOO'} | ${null} + `(`package type`, ({ packageType, expectedResult }) => { + it(`${packageType} should show as ${expectedResult}`, () => { + expect(getPackageTypeLabel(packageType)).toBe(expectedResult); + }); + }); + }); +}); 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 858c7b76ac8..4140b985682 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 @@ -5,53 +5,53 @@ import initSetHelperText, { describe('UsageStatistics', () => { const FIXTURE = 'application_settings/usage.html'; - let usagePingCheckBox; - let usagePingFeaturesCheckBox; - let usagePingFeaturesLabel; - let usagePingFeaturesHelperText; + let servicePingCheckBox; + let servicePingFeaturesCheckBox; + let servicePingFeaturesLabel; + let servicePingFeaturesHelperText; beforeEach(() => { loadFixtures(FIXTURE); initSetHelperText(); - usagePingCheckBox = document.getElementById('application_setting_usage_ping_enabled'); - usagePingFeaturesCheckBox = document.getElementById( + servicePingCheckBox = document.getElementById('application_setting_usage_ping_enabled'); + servicePingFeaturesCheckBox = document.getElementById( 'application_setting_usage_ping_features_enabled', ); - usagePingFeaturesLabel = document.getElementById('service_ping_features_label'); - usagePingFeaturesHelperText = document.getElementById('service_ping_features_helper_text'); + servicePingFeaturesLabel = document.getElementById('service_ping_features_label'); + servicePingFeaturesHelperText = document.getElementById('service_ping_features_helper_text'); }); - const expectEnabledUsagePingFeaturesCheckBox = () => { - expect(usagePingFeaturesCheckBox.classList.contains('gl-cursor-not-allowed')).toBe(false); - expect(usagePingFeaturesHelperText.textContent).toEqual(HELPER_TEXT_SERVICE_PING_ENABLED); + const expectEnabledservicePingFeaturesCheckBox = () => { + expect(servicePingFeaturesCheckBox.classList.contains('gl-cursor-not-allowed')).toBe(false); + expect(servicePingFeaturesHelperText.textContent).toEqual(HELPER_TEXT_SERVICE_PING_ENABLED); }; - const expectDisabledUsagePingFeaturesCheckBox = () => { - expect(usagePingFeaturesLabel.classList.contains('gl-cursor-not-allowed')).toBe(true); - expect(usagePingFeaturesHelperText.textContent).toEqual(HELPER_TEXT_SERVICE_PING_DISABLED); + const expectDisabledservicePingFeaturesCheckBox = () => { + expect(servicePingFeaturesLabel.classList.contains('gl-cursor-not-allowed')).toBe(true); + expect(servicePingFeaturesHelperText.textContent).toEqual(HELPER_TEXT_SERVICE_PING_DISABLED); }; describe('Registration Features checkbox', () => { - it('is disabled when Usage Ping checkbox is unchecked', () => { - expect(usagePingCheckBox.checked).toBe(false); - expectDisabledUsagePingFeaturesCheckBox(); + it('is disabled when Service Ping checkbox is unchecked', () => { + expect(servicePingCheckBox.checked).toBe(false); + expectDisabledservicePingFeaturesCheckBox(); }); - it('is enabled when Usage Ping checkbox is checked', () => { - usagePingCheckBox.click(); - expect(usagePingCheckBox.checked).toBe(true); - expectEnabledUsagePingFeaturesCheckBox(); + it('is enabled when Servie Ping checkbox is checked', () => { + servicePingCheckBox.click(); + expect(servicePingCheckBox.checked).toBe(true); + expectEnabledservicePingFeaturesCheckBox(); }); - it('is switched to disabled when Usage Ping checkbox is unchecked ', () => { - usagePingCheckBox.click(); - usagePingFeaturesCheckBox.click(); - expectEnabledUsagePingFeaturesCheckBox(); + it('is switched to disabled when Service Ping checkbox is unchecked ', () => { + servicePingCheckBox.click(); + servicePingFeaturesCheckBox.click(); + expectEnabledservicePingFeaturesCheckBox(); - usagePingCheckBox.click(); - expect(usagePingCheckBox.checked).toBe(false); - expect(usagePingFeaturesCheckBox.checked).toBe(false); - expectDisabledUsagePingFeaturesCheckBox(); + servicePingCheckBox.click(); + expect(servicePingCheckBox.checked).toBe(false); + expect(servicePingFeaturesCheckBox.checked).toBe(false); + expectDisabledservicePingFeaturesCheckBox(); }); }); }); diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js index 4c253f0610b..1e562419f32 100644 --- a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js +++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js @@ -1,4 +1,4 @@ -import { GlToggle } from '@gitlab/ui'; +import { GlSprintf, GlToggle } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; import projectFeatureSetting from '~/pages/projects/shared/permissions/components/project_feature_setting.vue'; import settingsPanel from '~/pages/projects/shared/permissions/components/settings_panel.vue'; @@ -22,12 +22,11 @@ const defaultProps = { operationsAccessLevel: 20, pagesAccessLevel: 10, analyticsAccessLevel: 20, - containerRegistryEnabled: true, + containerRegistryAccessLevel: 20, lfsEnabled: true, emailsDisabled: false, packagesEnabled: true, showDefaultAwardEmojis: true, - allowEditingCommitMessages: false, }, isGitlabCom: true, canDisableEmails: true, @@ -53,7 +52,7 @@ describe('Settings Panel', () => { let wrapper; const mountComponent = ( - { currentSettings = {}, glFeatures = {}, ...customProps } = {}, + { currentSettings = {}, ...customProps } = {}, mountFn = shallowMount, ) => { const propsData = { @@ -64,9 +63,6 @@ describe('Settings Panel', () => { return mountFn(settingsPanel, { propsData, - provide: { - glFeatures, - }, }); }; @@ -89,8 +85,10 @@ describe('Settings Panel', () => { const findBuildsAccessLevelInput = () => wrapper.find('[name="project[project_feature_attributes][builds_access_level]"]'); const findContainerRegistrySettings = () => wrapper.find({ ref: 'container-registry-settings' }); - const findContainerRegistryEnabledInput = () => - wrapper.find('[name="project[container_registry_enabled]"]'); + const findContainerRegistryPublicNoteGlSprintfComponent = () => + findContainerRegistrySettings().findComponent(GlSprintf); + const findContainerRegistryAccessLevelInput = () => + wrapper.find('[name="project[project_feature_attributes][container_registry_access_level]"]'); const findPackageSettings = () => wrapper.find({ ref: 'package-settings' }); const findPackagesEnabledInput = () => wrapper.find('[name="project[packages_enabled]"]'); const findPagesSettings = () => wrapper.find({ ref: 'pages-settings' }); @@ -100,8 +98,6 @@ describe('Settings Panel', () => { const findShowDefaultAwardEmojis = () => wrapper.find('input[name="project[project_setting_attributes][show_default_award_emojis]"]'); const findMetricsVisibilitySettings = () => wrapper.find({ ref: 'metrics-visibility-settings' }); - const findAllowEditingCommitMessages = () => - wrapper.find({ ref: 'allow-editing-commit-messages' }).exists(); const findOperationsSettings = () => wrapper.find({ ref: 'operations-settings' }); afterEach(() => { @@ -281,42 +277,38 @@ describe('Settings Panel', () => { it('should show the container registry public note if the visibility level is public and the registry is available', () => { wrapper = mountComponent({ - currentSettings: { visibilityLevel: visibilityOptions.PUBLIC }, - registryAvailable: true, - }); - - expect(findContainerRegistrySettings().text()).toContain( - 'Note: the container registry is always visible when a project is public', - ); - }); - - it('should hide the container registry public note if the visibility level is private and the registry is available', () => { - wrapper = mountComponent({ - currentSettings: { visibilityLevel: visibilityOptions.PRIVATE }, + currentSettings: { + visibilityLevel: visibilityOptions.PUBLIC, + containerRegistryAccessLevel: featureAccessLevel.EVERYONE, + }, registryAvailable: true, }); - expect(findContainerRegistrySettings().text()).not.toContain( - 'Note: the container registry is always visible when a project is public', + expect(findContainerRegistryPublicNoteGlSprintfComponent().exists()).toBe(true); + expect(findContainerRegistryPublicNoteGlSprintfComponent().attributes('message')).toContain( + `Note: The container registry is always visible when a project is public and the container registry is set to '%{access_level_description}'`, ); }); - it('should enable the container registry input when the repository is enabled', () => { + it('should hide the container registry public note if the visibility level is public but the registry is private', () => { wrapper = mountComponent({ - currentSettings: { repositoryAccessLevel: featureAccessLevel.EVERYONE }, + currentSettings: { + visibilityLevel: visibilityOptions.PUBLIC, + containerRegistryAccessLevel: featureAccessLevel.PROJECT_MEMBERS, + }, registryAvailable: true, }); - expect(findContainerRegistryEnabledInput().props('disabled')).toBe(false); + expect(findContainerRegistryPublicNoteGlSprintfComponent().exists()).toBe(false); }); - it('should disable the container registry input when the repository is disabled', () => { + it('should hide the container registry public note if the visibility level is private and the registry is available', () => { wrapper = mountComponent({ - currentSettings: { repositoryAccessLevel: featureAccessLevel.NOT_ENABLED }, + currentSettings: { visibilityLevel: visibilityOptions.PRIVATE }, registryAvailable: true, }); - expect(findContainerRegistryEnabledInput().props('disabled')).toBe(true); + expect(findContainerRegistryPublicNoteGlSprintfComponent().exists()).toBe(false); }); it('has label for the toggle', () => { @@ -325,7 +317,7 @@ describe('Settings Panel', () => { registryAvailable: true, }); - expect(findContainerRegistrySettings().findComponent(GlToggle).props('label')).toBe( + expect(findContainerRegistryAccessLevelInput().props('label')).toBe( settingsPanel.i18n.containerRegistryLabel, ); }); @@ -582,18 +574,6 @@ describe('Settings Panel', () => { ); }); - describe('Settings panel with feature flags', () => { - describe('Allow edit of commit message', () => { - it('should show the allow editing of commit messages checkbox', () => { - wrapper = mountComponent({ - glFeatures: { allowEditingCommitMessages: true }, - }); - - expect(findAllowEditingCommitMessages()).toBe(true); - }); - }); - }); - describe('Analytics', () => { it('should show the analytics toggle', () => { wrapper = mountComponent(); diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js index f36d6262b5f..082a8977710 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -15,6 +15,8 @@ import { import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +jest.mock('~/emoji'); + describe('WikiForm', () => { let wrapper; let mock; @@ -350,11 +352,6 @@ describe('WikiForm', () => { await waitForPromises(); }); - it('editor is shown in a perpetual loading state', () => { - expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); - expect(wrapper.findComponent(ContentEditor).exists()).toBe(false); - }); - it('disables the submit button', () => { expect(findSubmitButton().props('disabled')).toBe(true); }); diff --git a/spec/frontend/persistent_user_callout_spec.js b/spec/frontend/persistent_user_callout_spec.js index 1e51ddf909a..1db255106ed 100644 --- a/spec/frontend/persistent_user_callout_spec.js +++ b/spec/frontend/persistent_user_callout_spec.js @@ -1,4 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; @@ -166,6 +167,8 @@ describe('PersistentUserCallout', () => { let mockAxios; let persistentUserCallout; + useMockLocationHelper(); + beforeEach(() => { const fixture = createFollowLinkFixture(); const container = fixture.querySelector('.container'); @@ -174,9 +177,6 @@ describe('PersistentUserCallout', () => { persistentUserCallout = new PersistentUserCallout(container); jest.spyOn(persistentUserCallout.container, 'remove').mockImplementation(() => {}); - - delete window.location; - window.location = { assign: jest.fn() }; }); afterEach(() => { diff --git a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js index b6d49d0d0f8..a95921359cc 100644 --- a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js +++ b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js @@ -44,6 +44,7 @@ describe('Pipeline Status', () => { const findPipelineCommit = () => wrapper.find('[data-testid="pipeline-commit"]'); const findPipelineErrorMsg = () => wrapper.find('[data-testid="pipeline-error-msg"]'); const findPipelineLoadingMsg = () => wrapper.find('[data-testid="pipeline-loading-msg"]'); + const findPipelineViewBtn = () => wrapper.find('[data-testid="pipeline-view-btn"]'); beforeEach(() => { mockPipelineQuery = jest.fn(); @@ -96,11 +97,15 @@ describe('Pipeline Status', () => { }); it('renders pipeline data', () => { - const { id } = mockProjectPipeline.pipeline; + const { + id, + detailedStatus: { detailsPath }, + } = mockProjectPipeline.pipeline; expect(findCiIcon().exists()).toBe(true); expect(findPipelineId().text()).toBe(`#${id.match(/\d+/g)[0]}`); expect(findPipelineCommit().text()).toBe(mockCommitSha); + expect(findPipelineViewBtn().attributes('href')).toBe(detailsPath); }); }); @@ -121,6 +126,7 @@ describe('Pipeline Status', () => { expect(findCiIcon().exists()).toBe(false); expect(findPipelineId().exists()).toBe(false); expect(findPipelineCommit().exists()).toBe(false); + expect(findPipelineViewBtn().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js index 93ebbc648fe..9f910ed4f9c 100644 --- a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js +++ b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js @@ -1,5 +1,6 @@ import { GlAlert } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import CodeSnippetAlert from '~/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue'; import { CODE_SNIPPET_SOURCES } from '~/pipeline_editor/components/code_snippet_alert/constants'; @@ -12,6 +13,10 @@ import { LOAD_FAILURE_UNKNOWN, } from '~/pipeline_editor/constants'; +beforeEach(() => { + setWindowLocation(TEST_HOST); +}); + describe('Pipeline Editor messages', () => { let wrapper; @@ -95,9 +100,7 @@ describe('Pipeline Editor messages', () => { describe('code snippet alert', () => { const setCodeSnippetUrlParam = (value) => { - global.jsdom.reconfigure({ - url: `${TEST_HOST}/?code_snippet_copied_from=${value}`, - }); + setWindowLocation(`${TEST_HOST}/?code_snippet_copied_from=${value}`); }; it('does not show by default', () => { diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index b0d1a69ee56..0c5c08d7190 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -2,6 +2,7 @@ import { GlAlert, GlButton, GlLoadingIcon, GlTabs } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import setWindowLocation from 'helpers/set_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue'; import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue'; @@ -348,15 +349,14 @@ describe('Pipeline editor app component', () => { }); describe('when a template parameter is present in the URL', () => { - const { location } = window; + const originalLocation = window.location.href; beforeEach(() => { - delete window.location; - window.location = new URL('https://localhost?template=Android'); + setWindowLocation('?template=Android'); }); afterEach(() => { - window.location = location; + setWindowLocation(originalLocation); }); it('renders the given template', async () => { diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js index b0dbba37b94..e0ba6b2e8da 100644 --- a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js +++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js @@ -20,6 +20,7 @@ describe('Pipelines filtered search', () => { const findTagToken = () => getSearchToken('tag'); const findUserToken = () => getSearchToken('username'); const findStatusToken = () => getSearchToken('status'); + const findSourceToken = () => getSearchToken('source'); const createComponent = (params = {}) => { wrapper = mount(PipelinesFilteredSearch, { @@ -32,6 +33,8 @@ describe('Pipelines filtered search', () => { }; beforeEach(() => { + window.gon = { features: { pipelineSourceFilter: true } }; + mock = new MockAdapter(axios); jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); @@ -70,6 +73,14 @@ describe('Pipelines filtered search', () => { operators: OPERATOR_IS_ONLY, }); + expect(findSourceToken()).toMatchObject({ + type: 'source', + icon: 'trigger-source', + title: 'Source', + unique: true, + operators: OPERATOR_IS_ONLY, + }); + expect(findStatusToken()).toMatchObject({ type: 'status', icon: 'status', diff --git a/spec/frontend/pipelines/graph/graph_component_legacy_spec.js b/spec/frontend/pipelines/graph/graph_component_legacy_spec.js deleted file mode 100644 index a955572a481..00000000000 --- a/spec/frontend/pipelines/graph/graph_component_legacy_spec.js +++ /dev/null @@ -1,300 +0,0 @@ -import { GlLoadingIcon } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { setHTMLFixture } from 'helpers/fixtures'; -import GraphComponentLegacy from '~/pipelines/components/graph/graph_component_legacy.vue'; -import LinkedPipelinesColumnLegacy from '~/pipelines/components/graph/linked_pipelines_column_legacy.vue'; -import StageColumnComponentLegacy from '~/pipelines/components/graph/stage_column_component_legacy.vue'; -import PipelinesMediator from '~/pipelines/pipeline_details_mediator'; -import PipelineStore from '~/pipelines/stores/pipeline_store'; -import linkedPipelineJSON from './linked_pipelines_mock_data'; -import graphJSON from './mock_data_legacy'; - -describe('graph component', () => { - let store; - let mediator; - let wrapper; - - const findExpandPipelineBtn = () => wrapper.find('[data-testid="expand-pipeline-button"]'); - const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expand-pipeline-button"]'); - const findStageColumns = () => wrapper.findAll(StageColumnComponentLegacy); - const findStageColumnAt = (i) => findStageColumns().at(i); - - beforeEach(() => { - mediator = new PipelinesMediator({ endpoint: '' }); - store = new PipelineStore(); - store.storePipeline(linkedPipelineJSON); - - setHTMLFixture('<div class="layout-page"></div>'); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('while is loading', () => { - it('should render a loading icon', () => { - wrapper = mount(GraphComponentLegacy, { - propsData: { - isLoading: true, - pipeline: {}, - mediator, - }, - }); - - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); - }); - }); - - describe('with data', () => { - beforeEach(() => { - wrapper = mount(GraphComponentLegacy, { - propsData: { - isLoading: false, - pipeline: graphJSON, - mediator, - }, - }); - }); - - it('renders the graph', () => { - expect(wrapper.find('.js-pipeline-graph').exists()).toBe(true); - expect(wrapper.find('.loading-icon').exists()).toBe(false); - expect(wrapper.find('.stage-column-list').exists()).toBe(true); - }); - - it('renders columns in the graph', () => { - expect(findStageColumns()).toHaveLength(graphJSON.details.stages.length); - }); - }); - - describe('when linked pipelines are present', () => { - beforeEach(() => { - wrapper = mount(GraphComponentLegacy, { - propsData: { - isLoading: false, - pipeline: store.state.pipeline, - mediator, - }, - }); - }); - - describe('rendered output', () => { - it('should include the pipelines graph', () => { - expect(wrapper.find('.js-pipeline-graph').exists()).toBe(true); - }); - - it('should not include the loading icon', () => { - expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); - }); - - it('should include the stage column', () => { - expect(findStageColumnAt(0).exists()).toBe(true); - }); - - it('stage column should have no-margin, gl-mr-26, has-only-one-job classes if there is only one job', () => { - expect(findStageColumnAt(0).classes()).toEqual( - expect.arrayContaining(['no-margin', 'gl-mr-26', 'has-only-one-job']), - ); - }); - - it('should include the left-margin class on the second child', () => { - expect(findStageColumnAt(1).classes('left-margin')).toBe(true); - }); - - it('should include the left-connector class in the build of the second child', () => { - expect(findStageColumnAt(1).find('.build:nth-child(1)').classes('left-connector')).toBe( - true, - ); - }); - - it('should include the js-has-linked-pipelines flag', () => { - expect(wrapper.find('.js-has-linked-pipelines').exists()).toBe(true); - }); - }); - - describe('computeds and methods', () => { - describe('capitalizeStageName', () => { - it('it capitalizes the stage name', () => { - expect(wrapper.findAll('.stage-column .stage-name').at(1).text()).toBe('Prebuild'); - }); - }); - - describe('stageConnectorClass', () => { - it('it returns left-margin when there is a triggerer', () => { - expect(findStageColumnAt(1).classes('left-margin')).toBe(true); - }); - }); - }); - - describe('linked pipelines components', () => { - beforeEach(() => { - wrapper = mount(GraphComponentLegacy, { - propsData: { - isLoading: false, - pipeline: store.state.pipeline, - mediator, - }, - }); - }); - - it('should render an upstream pipelines column at first position', () => { - expect(wrapper.find(LinkedPipelinesColumnLegacy).exists()).toBe(true); - expect(wrapper.find('.stage-column .stage-name').text()).toBe('Upstream'); - }); - - it('should render a downstream pipelines column at last position', () => { - const stageColumnNames = wrapper.findAll('.stage-column .stage-name'); - - expect(wrapper.find(LinkedPipelinesColumnLegacy).exists()).toBe(true); - expect(stageColumnNames.at(stageColumnNames.length - 1).text()).toBe('Downstream'); - }); - - describe('triggered by', () => { - describe('on click', () => { - it('should emit `onClickUpstreamPipeline` when triggered by linked pipeline is clicked', async () => { - const btnWrapper = findExpandPipelineBtn(); - - btnWrapper.trigger('click'); - - await nextTick(); - expect(wrapper.emitted().onClickUpstreamPipeline).toEqual([ - store.state.pipeline.triggered_by, - ]); - }); - }); - - describe('with expanded pipeline', () => { - it('should render expanded pipeline', async () => { - // expand the pipeline - store.state.pipeline.triggered_by[0].isExpanded = true; - - wrapper = mount(GraphComponentLegacy, { - propsData: { - isLoading: false, - pipeline: store.state.pipeline, - mediator, - }, - }); - - await nextTick(); - expect(wrapper.find('.js-upstream-pipeline-12').exists()).toBe(true); - }); - }); - }); - - describe('triggered', () => { - describe('on click', () => { - // We have to mock this property of HTMLElement since component relies on it - let offsetParentDescriptor; - beforeAll(() => { - offsetParentDescriptor = Object.getOwnPropertyDescriptor( - HTMLElement.prototype, - 'offsetParent', - ); - Object.defineProperty(HTMLElement.prototype, 'offsetParent', { - get() { - return this.parentNode; - }, - }); - }); - afterAll(() => { - Object.defineProperty(HTMLElement.prototype, offsetParentDescriptor); - }); - - it('should emit `onClickDownstreamPipeline`', async () => { - const btnWrappers = findAllExpandPipelineBtns(); - const downstreamBtnWrapper = btnWrappers.at(btnWrappers.length - 1); - - downstreamBtnWrapper.trigger('click'); - - await nextTick(); - expect(wrapper.emitted().onClickDownstreamPipeline).toEqual([ - [store.state.pipeline.triggered[1]], - ]); - }); - }); - - describe('with expanded pipeline', () => { - it('should render expanded pipeline', async () => { - // expand the pipeline - store.state.pipeline.triggered[0].isExpanded = true; - - wrapper = mount(GraphComponentLegacy, { - propsData: { - isLoading: false, - pipeline: store.state.pipeline, - mediator, - }, - }); - - await nextTick(); - expect(wrapper.find('.js-downstream-pipeline-34993051')).not.toBeNull(); - }); - }); - - describe('when column requests a refresh', () => { - beforeEach(() => { - findStageColumnAt(0).vm.$emit('refreshPipelineGraph'); - }); - - it('refreshPipelineGraph is emitted', () => { - expect(wrapper.emitted().refreshPipelineGraph).toHaveLength(1); - }); - }); - }); - }); - }); - - describe('when linked pipelines are not present', () => { - beforeEach(() => { - const pipeline = Object.assign(linkedPipelineJSON, { triggered: null, triggered_by: null }); - wrapper = mount(GraphComponentLegacy, { - propsData: { - isLoading: false, - pipeline, - mediator, - }, - }); - }); - - describe('rendered output', () => { - it('should include the first column with a no margin', () => { - const firstColumn = wrapper.find('.stage-column'); - - expect(firstColumn.classes('no-margin')).toBe(true); - }); - - it('should not render a linked pipelines column', () => { - expect(wrapper.find('.linked-pipelines-column').exists()).toBe(false); - }); - }); - - describe('stageConnectorClass', () => { - it('it returns no-margin when no triggerer and there is one job', () => { - expect(findStageColumnAt(0).classes('no-margin')).toBe(true); - }); - - it('it returns left-margin when no triggerer and not the first stage', () => { - expect(findStageColumnAt(1).classes('left-margin')).toBe(true); - }); - }); - }); - - describe('capitalizeStageName', () => { - it('capitalizes and escapes stage name', () => { - wrapper = mount(GraphComponentLegacy, { - propsData: { - isLoading: false, - pipeline: graphJSON, - mediator, - }, - }); - - expect(findStageColumnAt(1).props('title')).toEqual( - 'Deploy <img src=x onerror=alert(document.domain)>', - ); - }); - }); -}); diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js index 30914ba99a5..1fba3823161 100644 --- a/spec/frontend/pipelines/graph/graph_component_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_spec.js @@ -4,8 +4,8 @@ import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; import JobItem from '~/pipelines/components/graph/job_item.vue'; import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; +import { calculatePipelineLayersInfo } from '~/pipelines/components/graph/utils'; import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; -import { listByLayers } from '~/pipelines/components/parsing_utils'; import { generateResponse, mockPipelineResponse, @@ -150,7 +150,7 @@ describe('graph component', () => { }, props: { viewType: LAYER_VIEW, - pipelineLayers: listByLayers(defaultProps.pipeline), + computedPipelineInfo: calculatePipelineLayersInfo(defaultProps.pipeline, 'layer', ''), }, }); }); diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js index bb7e27b5ec2..2e8979f2b9d 100644 --- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -1,11 +1,19 @@ import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; import getUserCallouts from '~/graphql_shared/queries/get_user_callouts.query.graphql'; +import axios from '~/lib/utils/axios_utils'; +import { + PIPELINES_DETAIL_LINK_DURATION, + PIPELINES_DETAIL_LINKS_TOTAL, + PIPELINES_DETAIL_LINKS_JOB_RATIO, +} from '~/performance/constants'; +import * as perfUtils from '~/performance/utils'; import { IID_FAILURE, LAYER_VIEW, @@ -16,8 +24,12 @@ import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; import PipelineGraphWrapper from '~/pipelines/components/graph/graph_component_wrapper.vue'; import GraphViewSelector from '~/pipelines/components/graph/graph_view_selector.vue'; import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; +import * as Api from '~/pipelines/components/graph_shared/api'; import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; import * as parsingUtils from '~/pipelines/components/parsing_utils'; +import getPipelineHeaderData from '~/pipelines/graphql/queries/get_pipeline_header_data.query.graphql'; +import * as sentryUtils from '~/pipelines/utils'; +import { mockRunningPipelineHeaderData } from '../mock_data'; import { mapCallouts, mockCalloutsResponse, mockPipelineResponse } from './mock_data'; const defaultProvide = { @@ -72,8 +84,10 @@ describe('Pipeline graph wrapper', () => { } = {}) => { const callouts = mapCallouts(calloutsList); const getUserCalloutsHandler = jest.fn().mockResolvedValue(mockCalloutsResponse(callouts)); + const getPipelineHeaderDataHandler = jest.fn().mockResolvedValue(mockRunningPipelineHeaderData); const requestHandlers = [ + [getPipelineHeaderData, getPipelineHeaderDataHandler], [getPipelineDetails, getPipelineDetailsHandler], [getUserCallouts, getUserCalloutsHandler], ]; @@ -111,6 +125,11 @@ describe('Pipeline graph wrapper', () => { createComponentWithApollo(); expect(getGraph().exists()).toBe(false); }); + + it('skips querying headerPipeline', () => { + createComponentWithApollo(); + expect(wrapper.vm.$apollo.queries.headerPipeline.skip).toBe(true); + }); }); describe('when data has loaded', () => { @@ -190,12 +209,15 @@ describe('Pipeline graph wrapper', () => { describe('when refresh action is emitted', () => { beforeEach(async () => { createComponentWithApollo(); + jest.spyOn(wrapper.vm.$apollo.queries.headerPipeline, 'refetch'); jest.spyOn(wrapper.vm.$apollo.queries.pipeline, 'refetch'); await wrapper.vm.$nextTick(); getGraph().vm.$emit('refreshPipelineGraph'); }); it('calls refetch', () => { + expect(wrapper.vm.$apollo.queries.headerPipeline.skip).toBe(false); + expect(wrapper.vm.$apollo.queries.headerPipeline.refetch).toHaveBeenCalled(); expect(wrapper.vm.$apollo.queries.pipeline.refetch).toHaveBeenCalled(); }); }); @@ -245,28 +267,11 @@ describe('Pipeline graph wrapper', () => { }); describe('view dropdown', () => { - describe('when pipelineGraphLayersView feature flag is off', () => { - beforeEach(async () => { - createComponentWithApollo(); - jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); - }); - - it('does not appear', () => { - expect(getViewSelector().exists()).toBe(false); - }); - }); - - describe('when pipelineGraphLayersView feature flag is on', () => { + describe('default', () => { let layersFn; beforeEach(async () => { layersFn = jest.spyOn(parsingUtils, 'listByLayers'); createComponentWithApollo({ - provide: { - glFeatures: { - pipelineGraphLayersView: true, - }, - }, mountFn: mount, }); @@ -304,14 +309,9 @@ describe('Pipeline graph wrapper', () => { }); }); - describe('when pipelineGraphLayersView feature flag is on and layers view is selected', () => { + describe('when layers view is selected', () => { beforeEach(async () => { createComponentWithApollo({ - provide: { - glFeatures: { - pipelineGraphLayersView: true, - }, - }, data: { currentViewType: LAYER_VIEW, }, @@ -334,14 +334,9 @@ describe('Pipeline graph wrapper', () => { }); }); - describe('when pipelineGraphLayersView feature flag is on, layers view is selected, and links are active', () => { + describe('when layers view is selected, and links are active', () => { beforeEach(async () => { createComponentWithApollo({ - provide: { - glFeatures: { - pipelineGraphLayersView: true, - }, - }, data: { currentViewType: LAYER_VIEW, showLinks: true, @@ -362,11 +357,6 @@ describe('Pipeline graph wrapper', () => { describe('when hover tip would otherwise show, but it has been previously dismissed', () => { beforeEach(async () => { createComponentWithApollo({ - provide: { - glFeatures: { - pipelineGraphLayersView: true, - }, - }, data: { currentViewType: LAYER_VIEW, showLinks: true, @@ -390,11 +380,6 @@ describe('Pipeline graph wrapper', () => { localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW); createComponentWithApollo({ - provide: { - glFeatures: { - pipelineGraphLayersView: true, - }, - }, mountFn: mount, }); @@ -422,11 +407,6 @@ describe('Pipeline graph wrapper', () => { localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW); createComponentWithApollo({ - provide: { - glFeatures: { - pipelineGraphLayersView: true, - }, - }, mountFn: mount, getPipelineDetailsHandler: jest.fn().mockResolvedValue(nonNeedsResponse), }); @@ -450,11 +430,6 @@ describe('Pipeline graph wrapper', () => { nonNeedsResponse.data.project.pipeline.usesNeeds = false; createComponentWithApollo({ - provide: { - glFeatures: { - pipelineGraphLayersView: true, - }, - }, mountFn: mount, getPipelineDetailsHandler: jest.fn().mockResolvedValue(nonNeedsResponse), }); @@ -468,4 +443,112 @@ describe('Pipeline graph wrapper', () => { }); }); }); + + describe('performance metrics', () => { + const metricsPath = '/root/project/-/ci/prometheus_metrics/histograms.json'; + let markAndMeasure; + let reportToSentry; + let reportPerformance; + let mock; + + beforeEach(() => { + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb()); + markAndMeasure = jest.spyOn(perfUtils, 'performanceMarkAndMeasure'); + reportToSentry = jest.spyOn(sentryUtils, 'reportToSentry'); + reportPerformance = jest.spyOn(Api, 'reportPerformance'); + }); + + describe('with no metrics path', () => { + beforeEach(async () => { + createComponentWithApollo(); + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + }); + + it('is not called', () => { + expect(markAndMeasure).not.toHaveBeenCalled(); + expect(reportToSentry).not.toHaveBeenCalled(); + expect(reportPerformance).not.toHaveBeenCalled(); + }); + }); + + describe('with metrics path', () => { + const duration = 875; + const numLinks = 7; + const totalGroups = 8; + const metricsData = { + histograms: [ + { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 }, + { name: PIPELINES_DETAIL_LINKS_TOTAL, value: numLinks }, + { + name: PIPELINES_DETAIL_LINKS_JOB_RATIO, + value: numLinks / totalGroups, + }, + ], + }; + + describe('when no duration is obtained', () => { + beforeEach(async () => { + jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => { + return []; + }); + + createComponentWithApollo({ + provide: { + metricsPath, + glFeatures: { + pipelineGraphLayersView: true, + }, + }, + data: { + currentViewType: LAYER_VIEW, + }, + }); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + }); + + it('attempts to collect metrics', () => { + expect(markAndMeasure).toHaveBeenCalled(); + expect(reportPerformance).not.toHaveBeenCalled(); + expect(reportToSentry).not.toHaveBeenCalled(); + }); + }); + + describe('with duration and no error', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onPost(metricsPath).reply(200, {}); + + jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => { + return [{ duration }]; + }); + + createComponentWithApollo({ + provide: { + metricsPath, + glFeatures: { + pipelineGraphLayersView: true, + }, + }, + data: { + currentViewType: LAYER_VIEW, + }, + }); + }); + + afterEach(() => { + mock.restore(); + }); + + it('it calls reportPerformance with expected arguments', () => { + expect(markAndMeasure).toHaveBeenCalled(); + expect(reportPerformance).toHaveBeenCalled(); + expect(reportPerformance).toHaveBeenCalledWith(metricsPath, metricsData); + expect(reportToSentry).not.toHaveBeenCalled(); + }); + }); + }); + }); }); diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_legacy_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_legacy_spec.js deleted file mode 100644 index 200e3f48401..00000000000 --- a/spec/frontend/pipelines/graph/linked_pipelines_column_legacy_spec.js +++ /dev/null @@ -1,40 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { UPSTREAM } from '~/pipelines/components/graph/constants'; -import LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue'; -import LinkedPipelinesColumnLegacy from '~/pipelines/components/graph/linked_pipelines_column_legacy.vue'; -import mockData from './linked_pipelines_mock_data'; - -describe('Linked Pipelines Column', () => { - const propsData = { - columnTitle: 'Upstream', - linkedPipelines: mockData.triggered, - graphPosition: 'right', - projectId: 19, - type: UPSTREAM, - }; - let wrapper; - - beforeEach(() => { - wrapper = shallowMount(LinkedPipelinesColumnLegacy, { propsData }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders the pipeline orientation', () => { - const titleElement = wrapper.find('.linked-pipelines-column-title'); - - expect(titleElement.text()).toBe(propsData.columnTitle); - }); - - it('renders the correct number of linked pipelines', () => { - const linkedPipelineElements = wrapper.findAll(LinkedPipeline); - - expect(linkedPipelineElements.length).toBe(propsData.linkedPipelines.length); - }); - - it('renders cross project triangle when column is upstream', () => { - expect(wrapper.find('.cross-project-triangle').exists()).toBe(true); - }); -}); diff --git a/spec/frontend/pipelines/graph/mock_data_legacy.js b/spec/frontend/pipelines/graph/mock_data_legacy.js deleted file mode 100644 index e1c8b027121..00000000000 --- a/spec/frontend/pipelines/graph/mock_data_legacy.js +++ /dev/null @@ -1,261 +0,0 @@ -export default { - id: 123, - user: { - name: 'Root', - username: 'root', - id: 1, - state: 'active', - avatar_url: null, - web_url: 'http://localhost:3000/root', - }, - active: false, - coverage: null, - path: '/root/ci-mock/pipelines/123', - details: { - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/pipelines/123', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - }, - duration: 9, - finished_at: '2017-04-19T14:30:27.542Z', - stages: [ - { - name: 'test', - title: 'test: passed', - groups: [ - { - name: 'test', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4153', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4153/retry', - method: 'post', - }, - }, - jobs: [ - { - id: 4153, - name: 'test', - build_path: '/root/ci-mock/builds/4153', - retry_path: '/root/ci-mock/builds/4153/retry', - playable: false, - created_at: '2017-04-13T09:25:18.959Z', - updated_at: '2017-04-13T09:25:23.118Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4153', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4153/retry', - method: 'post', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/pipelines/123#test', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - }, - path: '/root/ci-mock/pipelines/123#test', - dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=test', - }, - { - name: 'deploy <img src=x onerror=alert(document.domain)>', - title: 'deploy: passed', - groups: [ - { - name: 'deploy to production', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4166', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4166/retry', - method: 'post', - }, - }, - jobs: [ - { - id: 4166, - name: 'deploy to production', - build_path: '/root/ci-mock/builds/4166', - retry_path: '/root/ci-mock/builds/4166/retry', - playable: false, - created_at: '2017-04-19T14:29:46.463Z', - updated_at: '2017-04-19T14:30:27.498Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4166', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4166/retry', - method: 'post', - }, - }, - }, - ], - }, - { - name: 'deploy to staging', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4159', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4159/retry', - method: 'post', - }, - }, - jobs: [ - { - id: 4159, - name: 'deploy to staging', - build_path: '/root/ci-mock/builds/4159', - retry_path: '/root/ci-mock/builds/4159/retry', - playable: false, - created_at: '2017-04-18T16:32:08.420Z', - updated_at: '2017-04-18T16:32:12.631Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4159', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4159/retry', - method: 'post', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/pipelines/123#deploy', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - }, - path: '/root/ci-mock/pipelines/123#deploy', - dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=deploy', - }, - ], - artifacts: [], - manual_actions: [ - { - name: 'deploy to production', - path: '/root/ci-mock/builds/4166/play', - playable: false, - }, - ], - }, - flags: { - latest: true, - triggered: false, - stuck: false, - yaml_errors: false, - retryable: false, - cancelable: false, - }, - ref: { - name: 'main', - path: '/root/ci-mock/tree/main', - tag: false, - branch: true, - }, - commit: { - id: '798e5f902592192afaba73f4668ae30e56eae492', - short_id: '798e5f90', - title: "Merge branch 'new-branch' into 'main'\r", - created_at: '2017-04-13T10:25:17.000+01:00', - parent_ids: [ - '54d483b1ed156fbbf618886ddf7ab023e24f8738', - 'c8e2d38a6c538822e81c57022a6e3a0cfedebbcc', - ], - message: - "Merge branch 'new-branch' into 'main'\r\n\r\nAdd new file\r\n\r\nSee merge request !1", - author_name: 'Root', - author_email: 'admin@example.com', - authored_date: '2017-04-13T10:25:17.000+01:00', - committer_name: 'Root', - committer_email: 'admin@example.com', - committed_date: '2017-04-13T10:25:17.000+01:00', - author: { - name: 'Root', - username: 'root', - id: 1, - state: 'active', - avatar_url: null, - web_url: 'http://localhost:3000/root', - }, - author_gravatar_url: null, - commit_url: - 'http://localhost:3000/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492', - commit_path: '/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492', - }, - created_at: '2017-04-13T09:25:18.881Z', - updated_at: '2017-04-19T14:30:27.561Z', -}; diff --git a/spec/frontend/pipelines/graph/stage_column_component_legacy_spec.js b/spec/frontend/pipelines/graph/stage_column_component_legacy_spec.js deleted file mode 100644 index 2965325ea7c..00000000000 --- a/spec/frontend/pipelines/graph/stage_column_component_legacy_spec.js +++ /dev/null @@ -1,130 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import StageColumnComponentLegacy from '~/pipelines/components/graph/stage_column_component_legacy.vue'; - -describe('stage column component', () => { - const mockJob = { - id: 4250, - name: 'test', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - details_path: '/root/ci-mock/builds/4250', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4250/retry', - method: 'post', - }, - }, - }; - - let wrapper; - - beforeEach(() => { - const mockGroups = []; - for (let i = 0; i < 3; i += 1) { - const mockedJob = { ...mockJob }; - mockedJob.id += i; - mockGroups.push(mockedJob); - } - - wrapper = shallowMount(StageColumnComponentLegacy, { - propsData: { - title: 'foo', - groups: mockGroups, - hasTriggeredBy: false, - }, - }); - }); - - it('should render provided title', () => { - expect(wrapper.find('.stage-name').text().trim()).toBe('foo'); - }); - - it('should render the provided groups', () => { - expect(wrapper.findAll('.builds-container > ul > li').length).toBe( - wrapper.props('groups').length, - ); - }); - - describe('jobId', () => { - it('escapes job name', () => { - wrapper = shallowMount(StageColumnComponentLegacy, { - propsData: { - groups: [ - { - id: 4259, - name: '<img src=x onerror=alert(document.domain)>', - status: { - icon: 'status_success', - label: 'success', - tooltip: '<img src=x onerror=alert(document.domain)>', - }, - }, - ], - title: 'test', - hasTriggeredBy: false, - }, - }); - - expect(wrapper.find('.builds-container li').attributes('id')).toBe( - 'ci-badge-<img src=x onerror=alert(document.domain)>', - ); - }); - }); - - describe('with action', () => { - it('renders action button', () => { - wrapper = shallowMount(StageColumnComponentLegacy, { - propsData: { - groups: [ - { - id: 4259, - name: '<img src=x onerror=alert(document.domain)>', - status: { - icon: 'status_success', - label: 'success', - tooltip: '<img src=x onerror=alert(document.domain)>', - }, - }, - ], - title: 'test', - hasTriggeredBy: false, - action: { - icon: 'play', - title: 'Play all', - path: 'action', - }, - }, - }); - - expect(wrapper.find('.js-stage-action').exists()).toBe(true); - }); - }); - - describe('without action', () => { - it('does not render action button', () => { - wrapper = shallowMount(StageColumnComponentLegacy, { - propsData: { - groups: [ - { - id: 4259, - name: '<img src=x onerror=alert(document.domain)>', - status: { - icon: 'status_success', - label: 'success', - tooltip: '<img src=x onerror=alert(document.domain)>', - }, - }, - ], - title: 'test', - hasTriggeredBy: false, - }, - }); - - expect(wrapper.find('.js-stage-action').exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/pipelines/graph_shared/links_inner_spec.js b/spec/frontend/pipelines/graph_shared/links_inner_spec.js index 8f39c8c2405..be422fac92c 100644 --- a/spec/frontend/pipelines/graph_shared/links_inner_spec.js +++ b/spec/frontend/pipelines/graph_shared/links_inner_spec.js @@ -31,7 +31,7 @@ describe('Links Inner component', () => { propsData: { ...defaultProps, ...props, - parsedData: parseData(currentPipelineData.flatMap(({ groups }) => groups)), + linksData: parseData(currentPipelineData.flatMap(({ groups }) => groups)).links, }, }); }; diff --git a/spec/frontend/pipelines/graph_shared/links_layer_spec.js b/spec/frontend/pipelines/graph_shared/links_layer_spec.js index 932a19f2f00..44ab60cbee7 100644 --- a/spec/frontend/pipelines/graph_shared/links_layer_spec.js +++ b/spec/frontend/pipelines/graph_shared/links_layer_spec.js @@ -1,16 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; -import { - PIPELINES_DETAIL_LINK_DURATION, - PIPELINES_DETAIL_LINKS_TOTAL, - PIPELINES_DETAIL_LINKS_JOB_RATIO, -} from '~/performance/constants'; -import * as perfUtils from '~/performance/utils'; -import * as Api from '~/pipelines/components/graph_shared/api'; import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; -import * as sentryUtils from '~/pipelines/utils'; import { generateResponse, mockPipelineResponse } from '../graph/mock_data'; describe('links layer component', () => { @@ -94,139 +84,4 @@ describe('links layer component', () => { expect(findLinksInner().exists()).toBe(false); }); }); - - describe('performance metrics', () => { - const metricsPath = '/root/project/-/ci/prometheus_metrics/histograms.json'; - let markAndMeasure; - let reportToSentry; - let reportPerformance; - let mock; - - beforeEach(() => { - jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb()); - markAndMeasure = jest.spyOn(perfUtils, 'performanceMarkAndMeasure'); - reportToSentry = jest.spyOn(sentryUtils, 'reportToSentry'); - reportPerformance = jest.spyOn(Api, 'reportPerformance'); - }); - - describe('with no metrics config object', () => { - beforeEach(() => { - createComponent(); - }); - - it('is not called', () => { - expect(markAndMeasure).not.toHaveBeenCalled(); - expect(reportToSentry).not.toHaveBeenCalled(); - expect(reportPerformance).not.toHaveBeenCalled(); - }); - }); - - describe('with metrics config set to false', () => { - beforeEach(() => { - createComponent({ - props: { - metricsConfig: { - collectMetrics: false, - metricsPath: '/path/to/metrics', - }, - }, - }); - }); - - it('is not called', () => { - expect(markAndMeasure).not.toHaveBeenCalled(); - expect(reportToSentry).not.toHaveBeenCalled(); - expect(reportPerformance).not.toHaveBeenCalled(); - }); - }); - - describe('with no metrics path', () => { - beforeEach(() => { - createComponent({ - props: { - metricsConfig: { - collectMetrics: true, - metricsPath: '', - }, - }, - }); - }); - - it('is not called', () => { - expect(markAndMeasure).not.toHaveBeenCalled(); - expect(reportToSentry).not.toHaveBeenCalled(); - expect(reportPerformance).not.toHaveBeenCalled(); - }); - }); - - describe('with metrics path and collect set to true', () => { - const duration = 875; - const numLinks = 7; - const totalGroups = 8; - const metricsData = { - histograms: [ - { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 }, - { name: PIPELINES_DETAIL_LINKS_TOTAL, value: numLinks }, - { - name: PIPELINES_DETAIL_LINKS_JOB_RATIO, - value: numLinks / totalGroups, - }, - ], - }; - - describe('when no duration is obtained', () => { - beforeEach(() => { - jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => { - return []; - }); - - createComponent({ - props: { - metricsConfig: { - collectMetrics: true, - path: metricsPath, - }, - }, - }); - }); - - it('attempts to collect metrics', () => { - expect(markAndMeasure).toHaveBeenCalled(); - expect(reportPerformance).not.toHaveBeenCalled(); - expect(reportToSentry).not.toHaveBeenCalled(); - }); - }); - - describe('with duration and no error', () => { - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onPost(metricsPath).reply(200, {}); - - jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => { - return [{ duration }]; - }); - - createComponent({ - props: { - metricsConfig: { - collectMetrics: true, - path: metricsPath, - }, - }, - }); - }); - - afterEach(() => { - mock.restore(); - }); - - it('it calls reportPerformance with expected arguments', () => { - expect(markAndMeasure).toHaveBeenCalled(); - expect(reportPerformance).toHaveBeenCalled(); - expect(reportPerformance).toHaveBeenCalledWith(metricsPath, metricsData); - expect(reportToSentry).not.toHaveBeenCalled(); - }); - }); - }); - }); }); diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js index 31f0e72c279..e531e26a858 100644 --- a/spec/frontend/pipelines/header_component_spec.js +++ b/spec/frontend/pipelines/header_component_spec.js @@ -99,24 +99,6 @@ describe('Pipeline details header', () => { ); }); - describe('polling', () => { - it('is stopped when pipeline is finished', async () => { - wrapper = createComponent({ ...mockRunningPipelineHeader }); - - await wrapper.setData({ - pipeline: { ...mockCancelledPipelineHeader }, - }); - - expect(wrapper.vm.$apollo.queries.pipeline.stopPolling).toHaveBeenCalled(); - }); - - it('is not stopped when pipeline is not finished', () => { - wrapper = createComponent(); - - expect(wrapper.vm.$apollo.queries.pipeline.stopPolling).not.toHaveBeenCalled(); - }); - }); - describe('actions', () => { describe('Retry action', () => { beforeEach(() => { diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js index 7e3c3727c9d..fdc78d48901 100644 --- a/spec/frontend/pipelines/mock_data.js +++ b/spec/frontend/pipelines/mock_data.js @@ -127,6 +127,28 @@ export const mockSuccessfulPipelineHeader = { }, }; +export const mockRunningPipelineHeaderData = { + data: { + project: { + pipeline: { + ...mockRunningPipelineHeader, + iid: '28', + user: { + name: 'Foo', + username: 'foobar', + webPath: '/foo', + email: 'foo@bar.com', + avatarUrl: 'link', + status: null, + __typename: 'UserCore', + }, + __typename: 'Pipeline', + }, + __typename: 'Project', + }, + }, +}; + export const stageReply = { name: 'deploy', title: 'deploy: running', diff --git a/spec/frontend/pipelines/parsing_utils_spec.js b/spec/frontend/pipelines/parsing_utils_spec.js index 074009ae056..3a270c1c1b5 100644 --- a/spec/frontend/pipelines/parsing_utils_spec.js +++ b/spec/frontend/pipelines/parsing_utils_spec.js @@ -120,8 +120,8 @@ describe('DAG visualization parsing utilities', () => { describe('generateColumnsFromLayersList', () => { const pipeline = generateResponse(mockPipelineResponse, 'root/fungi-xoxo'); - const layers = listByLayers(pipeline); - const columns = generateColumnsFromLayersListBare(pipeline, layers); + const { pipelineLayers } = listByLayers(pipeline); + const columns = generateColumnsFromLayersListBare(pipeline, pipelineLayers); it('returns stage-like objects with default name, id, and status', () => { columns.forEach((col, idx) => { @@ -136,7 +136,7 @@ describe('DAG visualization parsing utilities', () => { it('creates groups that match the list created in listByLayers', () => { columns.forEach((col, idx) => { const groupNames = col.groups.map(({ name }) => name); - expect(groupNames).toEqual(layers[idx]); + expect(groupNames).toEqual(pipelineLayers[idx]); }); }); diff --git a/spec/frontend/pipelines/pipeline_details_mediator_spec.js b/spec/frontend/pipelines/pipeline_details_mediator_spec.js deleted file mode 100644 index d6699a43b54..00000000000 --- a/spec/frontend/pipelines/pipeline_details_mediator_spec.js +++ /dev/null @@ -1,36 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import waitForPromises from 'helpers/wait_for_promises'; -import axios from '~/lib/utils/axios_utils'; -import PipelineMediator from '~/pipelines/pipeline_details_mediator'; - -describe('PipelineMdediator', () => { - let mediator; - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - mediator = new PipelineMediator({ endpoint: 'foo.json' }); - }); - - afterEach(() => { - mock.restore(); - }); - - it('should set defaults', () => { - expect(mediator.options).toEqual({ endpoint: 'foo.json' }); - expect(mediator.state.isLoading).toEqual(false); - expect(mediator.store).toBeDefined(); - expect(mediator.service).toBeDefined(); - }); - - describe('request and store data', () => { - it('should store received data', () => { - mock.onGet('foo.json').reply(200, { id: '121123' }); - mediator.fetchPipeline(); - - return waitForPromises().then(() => { - expect(mediator.store.state.pipeline).toEqual({ id: '121123' }); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/pipeline_multi_actions_spec.js b/spec/frontend/pipelines/pipeline_multi_actions_spec.js index 88b3ef2032a..ce33b6011bf 100644 --- a/spec/frontend/pipelines/pipeline_multi_actions_spec.js +++ b/spec/frontend/pipelines/pipeline_multi_actions_spec.js @@ -53,6 +53,7 @@ describe('Pipeline Multi Actions Dropdown', () => { const findDropdown = () => wrapper.findComponent(GlDropdown); const findAllArtifactItems = () => wrapper.findAllByTestId(artifactItemTestId); const findFirstArtifactItem = () => wrapper.findByTestId(artifactItemTestId); + const findEmptyMessage = () => wrapper.findByTestId('artifacts-empty-message'); beforeEach(() => { mockAxios = new MockAdapter(axios); @@ -86,6 +87,7 @@ describe('Pipeline Multi Actions Dropdown', () => { createComponent({ mockData: { artifacts } }); expect(findAllArtifactItems()).toHaveLength(artifacts.length); + expect(findEmptyMessage().exists()).toBe(false); }); it('should render the correct artifact name and path', () => { @@ -95,6 +97,12 @@ describe('Pipeline Multi Actions Dropdown', () => { expect(findFirstArtifactItem().text()).toBe(`Download ${artifacts[0].name} artifact`); }); + it('should render empty message when no artifacts are found', () => { + createComponent({ mockData: { artifacts: [] } }); + + expect(findEmptyMessage().exists()).toBe(true); + }); + describe('with a failing request', () => { it('should render an error message', async () => { const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId); diff --git a/spec/frontend/pipelines/pipeline_store_spec.js b/spec/frontend/pipelines/pipeline_store_spec.js deleted file mode 100644 index 1d5754d1f05..00000000000 --- a/spec/frontend/pipelines/pipeline_store_spec.js +++ /dev/null @@ -1,27 +0,0 @@ -import PipelineStore from '~/pipelines/stores/pipeline_store'; - -describe('Pipeline Store', () => { - let store; - - beforeEach(() => { - store = new PipelineStore(); - }); - - it('should set defaults', () => { - expect(store.state.pipeline).toEqual({}); - }); - - describe('storePipeline', () => { - it('should store empty object if none is provided', () => { - store.storePipeline(); - - expect(store.state.pipeline).toEqual({}); - }); - - it('should store received object', () => { - store.storePipeline({ foo: 'bar' }); - - expect(store.state.pipeline).toEqual({ foo: 'bar' }); - }); - }); -}); diff --git a/spec/frontend/pipelines/pipeline_url_spec.js b/spec/frontend/pipelines/pipeline_url_spec.js index 367c7f2b2f6..912b5afe0e1 100644 --- a/spec/frontend/pipelines/pipeline_url_spec.js +++ b/spec/frontend/pipelines/pipeline_url_spec.js @@ -28,6 +28,7 @@ describe('Pipeline Url Component', () => { flags: {}, }, pipelineScheduleUrl: 'foo', + pipelineKey: 'id', }; const createComponent = (props) => { diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index 2166961cedd..76feaaad1ec 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -4,6 +4,8 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { chunk } from 'lodash'; import { nextTick } from 'vue'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { TEST_HOST } from 'helpers/test_constants'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import Api from '~/api'; @@ -40,7 +42,6 @@ const mockPipelineWithStages = mockPipelinesResponse.pipelines.find( describe('Pipelines', () => { let wrapper; let mock; - let origWindowLocation; const paths = { emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg', @@ -73,6 +74,7 @@ describe('Pipelines', () => { const findTablePagination = () => wrapper.findComponent(TablePagination); const findTab = (tab) => wrapper.findByTestId(`pipelines-tab-${tab}`); + const findPipelineKeyDropdown = () => wrapper.findByTestId('pipeline-key-dropdown'); const findRunPipelineButton = () => wrapper.findByTestId('run-pipeline-button'); const findCiLintButton = () => wrapper.findByTestId('ci-lint-button'); const findCleanCacheButton = () => wrapper.findByTestId('clear-cache-button'); @@ -98,20 +100,13 @@ describe('Pipelines', () => { ); }; - beforeAll(() => { - origWindowLocation = window.location; - delete window.location; - window.location = { - search: '', - protocol: 'https:', - }; - }); - - afterAll(() => { - window.location = origWindowLocation; + beforeEach(() => { + setWindowLocation(TEST_HOST); }); beforeEach(() => { + window.gon = { features: { pipelineSourceFilter: true } }; + mock = new MockAdapter(axios); jest.spyOn(window.history, 'pushState'); @@ -536,6 +531,10 @@ describe('Pipelines', () => { expect(findFilteredSearch().exists()).toBe(true); }); + it('renders the pipeline key dropdown', () => { + expect(findPipelineKeyDropdown().exists()).toBe(true); + }); + it('renders tab empty state finished scope', async () => { mock.onGet(mockPipelinesEndpoint, { params: { scope: 'finished', page: '1' } }).reply(200, { pipelines: [], @@ -631,6 +630,10 @@ describe('Pipelines', () => { expect(findFilteredSearch().exists()).toBe(false); }); + it('does not render the pipeline key dropdown', () => { + expect(findPipelineKeyDropdown().exists()).toBe(false); + }); + it('does not render tabs nor buttons', () => { expect(findNavigationTabs().exists()).toBe(false); expect(findTab('all').exists()).toBe(false); diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js index 68b0dfc018e..4472a5ae70d 100644 --- a/spec/frontend/pipelines/pipelines_table_spec.js +++ b/spec/frontend/pipelines/pipelines_table_spec.js @@ -8,6 +8,7 @@ import PipelineTriggerer from '~/pipelines/components/pipelines_list/pipeline_tr import PipelineUrl from '~/pipelines/components/pipelines_list/pipeline_url.vue'; import PipelinesTable from '~/pipelines/components/pipelines_list/pipelines_table.vue'; import PipelinesTimeago from '~/pipelines/components/pipelines_list/time_ago.vue'; +import { PipelineKeyOptions } from '~/pipelines/constants'; import eventHub from '~/pipelines/event_hub'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; @@ -24,6 +25,7 @@ describe('Pipelines Table', () => { const defaultProps = { pipelines: [], viewType: 'root', + pipelineKeyOption: PipelineKeyOptions[0], }; const createMockPipeline = () => { @@ -80,7 +82,7 @@ describe('Pipelines Table', () => { it('should render table head with correct columns', () => { expect(findStatusTh().text()).toBe('Status'); - expect(findPipelineTh().text()).toBe('Pipeline'); + expect(findPipelineTh().text()).toBe('Pipeline ID'); expect(findTriggererTh().text()).toBe('Triggerer'); expect(findCommitTh().text()).toBe('Commit'); expect(findStagesTh().text()).toBe('Stages'); diff --git a/spec/frontend/pipelines/stores/pipeline_store_spec.js b/spec/frontend/pipelines/stores/pipeline_store_spec.js deleted file mode 100644 index 2daf7e4b324..00000000000 --- a/spec/frontend/pipelines/stores/pipeline_store_spec.js +++ /dev/null @@ -1,135 +0,0 @@ -import PipelineStore from '~/pipelines/stores/pipeline_store'; -import LinkedPipelines from '../linked_pipelines_mock.json'; - -describe('EE Pipeline store', () => { - let store; - let data; - - beforeEach(() => { - store = new PipelineStore(); - data = { ...LinkedPipelines }; - - store.storePipeline(data); - }); - - describe('storePipeline', () => { - describe('triggered_by', () => { - it('sets triggered_by as an array', () => { - expect(store.state.pipeline.triggered_by.length).toEqual(1); - }); - - it('adds isExpanding & isLoading keys set to false', () => { - expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false); - expect(store.state.pipeline.triggered_by[0].isLoading).toEqual(false); - }); - - it('parses nested triggered_by', () => { - expect(store.state.pipeline.triggered_by[0].triggered_by.length).toEqual(1); - expect(store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded).toEqual(false); - expect(store.state.pipeline.triggered_by[0].triggered_by[0].isLoading).toEqual(false); - }); - }); - - describe('triggered', () => { - it('adds isExpanding & isLoading keys set to false for each triggered pipeline', () => { - store.state.pipeline.triggered.forEach((pipeline) => { - expect(pipeline.isExpanded).toEqual(false); - expect(pipeline.isLoading).toEqual(false); - }); - }); - - it('parses nested triggered pipelines', () => { - store.state.pipeline.triggered[1].triggered.forEach((pipeline) => { - expect(pipeline.isExpanded).toEqual(false); - expect(pipeline.isLoading).toEqual(false); - }); - }); - }); - }); - - describe('resetTriggeredByPipeline', () => { - it('closes the pipeline & nested ones', () => { - store.state.pipeline.triggered_by[0].isExpanded = true; - store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded = true; - - store.resetTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]); - - expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false); - expect(store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded).toEqual(false); - }); - }); - - describe('openTriggeredByPipeline', () => { - it('opens the given pipeline', () => { - store.openTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]); - - expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(true); - }); - }); - - describe('closeTriggeredByPipeline', () => { - it('closes the given pipeline', () => { - // open it first - store.openTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]); - - store.closeTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]); - - expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false); - }); - }); - - describe('resetTriggeredPipelines', () => { - it('closes the pipeline & nested ones', () => { - store.state.pipeline.triggered[0].isExpanded = true; - store.state.pipeline.triggered[0].triggered[0].isExpanded = true; - - store.resetTriggeredPipelines(store.state.pipeline, store.state.pipeline.triggered[0]); - - expect(store.state.pipeline.triggered[0].isExpanded).toEqual(false); - expect(store.state.pipeline.triggered[0].triggered[0].isExpanded).toEqual(false); - }); - }); - - describe('openTriggeredPipeline', () => { - it('opens the given pipeline', () => { - store.openTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]); - - expect(store.state.pipeline.triggered[0].isExpanded).toEqual(true); - }); - }); - - describe('closeTriggeredPipeline', () => { - it('closes the given pipeline', () => { - // open it first - store.openTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]); - - store.closeTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]); - - expect(store.state.pipeline.triggered[0].isExpanded).toEqual(false); - }); - }); - - describe('toggleLoading', () => { - it('toggles the isLoading property for the given pipeline', () => { - store.toggleLoading(store.state.pipeline.triggered[0]); - - expect(store.state.pipeline.triggered[0].isLoading).toEqual(true); - }); - }); - - describe('addExpandedPipelineToRequestData', () => { - it('pushes the given id to expandedPipelines array', () => { - store.addExpandedPipelineToRequestData('213231'); - - expect(store.state.expandedPipelines).toEqual(['213231']); - }); - }); - - describe('removeExpandedPipelineToRequestData', () => { - it('pushes the given id to expandedPipelines array', () => { - store.removeExpandedPipelineToRequestData('213231'); - - expect(store.state.expandedPipelines).toEqual([]); - }); - }); -}); diff --git a/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js new file mode 100644 index 00000000000..5d15f0a3c55 --- /dev/null +++ b/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js @@ -0,0 +1,50 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import PipelineSourceToken from '~/pipelines/components/pipelines_list/tokens/pipeline_source_token.vue'; + +describe('Pipeline Source Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion); + + const defaultProps = { + config: { + type: 'source', + icon: 'trigger-source', + title: 'Source', + unique: true, + }, + value: { + data: '', + }, + }; + + const createComponent = () => { + wrapper = shallowMount(PipelineSourceToken, { + propsData: { + ...defaultProps, + }, + stubs: { + GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { + template: `<div><slot name="suggestions"></slot></div>`, + }), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + describe('shows sources correctly', () => { + it('renders all pipeline sources available', () => { + expect(findAllFilteredSearchSuggestions()).toHaveLength(wrapper.vm.sources.length); + }); + }); +}); diff --git a/spec/frontend/popovers/components/popovers_spec.js b/spec/frontend/popovers/components/popovers_spec.js index 0c164d97564..25c509346d1 100644 --- a/spec/frontend/popovers/components/popovers_spec.js +++ b/spec/frontend/popovers/components/popovers_spec.js @@ -4,7 +4,7 @@ import { useMockMutationObserver } from 'helpers/mock_dom_observer'; import Popovers from '~/popovers/components/popovers.vue'; describe('popovers/components/popovers.vue', () => { - const { trigger: triggerMutate, observersCount } = useMockMutationObserver(); + const { trigger: triggerMutate } = useMockMutationObserver(); let wrapper; const buildWrapper = (...targets) => { @@ -120,10 +120,13 @@ describe('popovers/components/popovers.vue', () => { it('disconnects mutation observer on beforeDestroy', async () => { await buildWrapper(createPopoverTarget()); + const { observer } = wrapper.vm; + jest.spyOn(observer, 'disconnect'); - expect(observersCount()).toBe(1); + expect(observer.disconnect).toHaveBeenCalledTimes(0); wrapper.destroy(); - expect(observersCount()).toBe(0); + + expect(observer.disconnect).toHaveBeenCalledTimes(1); }); }); diff --git a/spec/frontend/profile/preferences/components/profile_preferences_spec.js b/spec/frontend/profile/preferences/components/profile_preferences_spec.js index f1172a73d36..4d2dcf83d3b 100644 --- a/spec/frontend/profile/preferences/components/profile_preferences_spec.js +++ b/spec/frontend/profile/preferences/components/profile_preferences_spec.js @@ -1,6 +1,7 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import createFlash from '~/flash'; import IntegrationView from '~/profile/preferences/components/integration_view.vue'; @@ -19,6 +20,8 @@ import { jest.mock('~/flash'); const expectedUrl = '/foo'; +useMockLocationHelper(); + describe('ProfilePreferences component', () => { let wrapper; const defaultProvide = { @@ -174,8 +177,6 @@ describe('ProfilePreferences component', () => { }); describe('theme changes', () => { - const { location } = window; - let themeInput; let form; @@ -197,18 +198,6 @@ describe('ProfilePreferences component', () => { form.dispatchEvent(successEvent); } - beforeAll(() => { - delete window.location; - window.location = { - ...location, - reload: jest.fn(), - }; - }); - - afterAll(() => { - window.location = location; - }); - beforeEach(() => { setupBody(); themeInput = createThemeInput(); diff --git a/spec/frontend/projects/compare/components/app_legacy_spec.js b/spec/frontend/projects/compare/components/app_legacy_spec.js deleted file mode 100644 index 6fdf4014575..00000000000 --- a/spec/frontend/projects/compare/components/app_legacy_spec.js +++ /dev/null @@ -1,159 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import CompareApp from '~/projects/compare/components/app_legacy.vue'; -import RevisionDropdown from '~/projects/compare/components/revision_dropdown_legacy.vue'; - -jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); - -const projectCompareIndexPath = 'some/path'; -const refsProjectPath = 'some/refs/path'; -const paramsFrom = 'main'; -const paramsTo = 'some-other-branch'; - -describe('CompareApp component', () => { - let wrapper; - - const createComponent = (props = {}) => { - wrapper = shallowMount(CompareApp, { - propsData: { - projectCompareIndexPath, - refsProjectPath, - paramsFrom, - paramsTo, - projectMergeRequestPath: '', - createMrPath: '', - ...props, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - beforeEach(() => { - createComponent(); - }); - - const findSourceDropdown = () => wrapper.find('[data-testid="sourceRevisionDropdown"]'); - const findTargetDropdown = () => wrapper.find('[data-testid="targetRevisionDropdown"]'); - - it('renders component with prop', () => { - expect(wrapper.props()).toEqual( - expect.objectContaining({ - projectCompareIndexPath, - refsProjectPath, - paramsFrom, - paramsTo, - }), - ); - }); - - it('contains the correct form attributes', () => { - expect(wrapper.attributes('action')).toBe(projectCompareIndexPath); - expect(wrapper.attributes('method')).toBe('POST'); - }); - - it('has input with csrf token', () => { - expect(wrapper.find('input[name="authenticity_token"]').attributes('value')).toBe( - 'mock-csrf-token', - ); - }); - - it('has ellipsis', () => { - expect(wrapper.find('[data-testid="ellipsis"]').exists()).toBe(true); - }); - - describe('Source and Target BranchDropdown components', () => { - const findAllBranchDropdowns = () => wrapper.findAll(RevisionDropdown); - - it('renders the components with the correct props', () => { - expect(findAllBranchDropdowns().length).toBe(2); - expect(findSourceDropdown().props('revisionText')).toBe('Source'); - expect(findTargetDropdown().props('revisionText')).toBe('Target'); - }); - - it('sets the revision when the "selectRevision" event is emitted', async () => { - findSourceDropdown().vm.$emit('selectRevision', { - direction: 'to', - revision: 'some-source-revision', - }); - - findTargetDropdown().vm.$emit('selectRevision', { - direction: 'from', - revision: 'some-target-revision', - }); - - await wrapper.vm.$nextTick(); - - expect(findTargetDropdown().props('paramsBranch')).toBe('some-target-revision'); - expect(findSourceDropdown().props('paramsBranch')).toBe('some-source-revision'); - }); - }); - - describe('compare button', () => { - const findCompareButton = () => wrapper.find(GlButton); - - it('renders button', () => { - expect(findCompareButton().exists()).toBe(true); - }); - - it('submits form', () => { - findCompareButton().vm.$emit('click'); - expect(wrapper.find('form').element.submit).toHaveBeenCalled(); - }); - - it('has compare text', () => { - expect(findCompareButton().text()).toBe('Compare'); - }); - }); - - describe('swap revisions button', () => { - const findSwapRevisionsButton = () => wrapper.find('[data-testid="swapRevisionsButton"]'); - - it('renders the swap revisions button', () => { - expect(findSwapRevisionsButton().exists()).toBe(true); - }); - - it('has the correct text', () => { - expect(findSwapRevisionsButton().text()).toBe('Swap revisions'); - }); - - it('swaps revisions when clicked', async () => { - findSwapRevisionsButton().vm.$emit('click'); - - await wrapper.vm.$nextTick(); - - expect(findTargetDropdown().props('paramsBranch')).toBe(paramsTo); - expect(findSourceDropdown().props('paramsBranch')).toBe(paramsFrom); - }); - }); - - describe('merge request buttons', () => { - const findProjectMrButton = () => wrapper.find('[data-testid="projectMrButton"]'); - const findCreateMrButton = () => wrapper.find('[data-testid="createMrButton"]'); - - it('does not have merge request buttons', () => { - createComponent(); - expect(findProjectMrButton().exists()).toBe(false); - expect(findCreateMrButton().exists()).toBe(false); - }); - - it('has "View open merge request" button', () => { - createComponent({ - projectMergeRequestPath: 'some/project/merge/request/path', - }); - expect(findProjectMrButton().exists()).toBe(true); - expect(findCreateMrButton().exists()).toBe(false); - }); - - it('has "Create merge request" button', () => { - createComponent({ - createMrPath: 'some/create/create/mr/path', - }); - expect(findProjectMrButton().exists()).toBe(false); - expect(findCreateMrButton().exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/projects/terraform_notification/terraform_notification_spec.js b/spec/frontend/projects/terraform_notification/terraform_notification_spec.js index be34b207c4b..71c22998b08 100644 --- a/spec/frontend/projects/terraform_notification/terraform_notification_spec.js +++ b/spec/frontend/projects/terraform_notification/terraform_notification_spec.js @@ -5,19 +5,21 @@ import TerraformNotification from '~/projects/terraform_notification/components/ jest.mock('~/lib/utils/common_utils'); -const bannerDissmisedKey = 'terraform_notification_dismissed_for_project_1'; +const terraformImagePath = '/path/to/image'; +const bannerDismissedKey = 'terraform_notification_dismissed'; describe('TerraformNotificationBanner', () => { let wrapper; - const propsData = { - projectId: 1, + const provideData = { + terraformImagePath, + bannerDismissedKey, }; const findBanner = () => wrapper.findComponent(GlBanner); beforeEach(() => { wrapper = shallowMount(TerraformNotification, { - propsData, + provide: provideData, stubs: { GlBanner }, }); }); @@ -27,19 +29,6 @@ describe('TerraformNotificationBanner', () => { parseBoolean.mockReturnValue(false); }); - describe('when the dismiss cookie is set', () => { - beforeEach(() => { - parseBoolean.mockReturnValue(true); - wrapper = shallowMount(TerraformNotification, { - propsData, - }); - }); - - it('should not render the banner', () => { - expect(findBanner().exists()).toBe(false); - }); - }); - describe('when the dismiss cookie is not set', () => { it('should render the banner', () => { expect(findBanner().exists()).toBe(true); @@ -51,8 +40,8 @@ describe('TerraformNotificationBanner', () => { await findBanner().vm.$emit('close'); }); - it('should set the cookie with the bannerDissmisedKey', () => { - expect(setCookie).toHaveBeenCalledWith(bannerDissmisedKey, true); + it('should set the cookie with the bannerDismissedKey', () => { + expect(setCookie).toHaveBeenCalledWith(bannerDismissedKey, true); }); it('should remove the banner', () => { diff --git a/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js b/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js index 8fe659694ba..d2fe5af3a94 100644 --- a/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js @@ -1,5 +1,6 @@ -import { GlSprintf } from '@gitlab/ui'; +import { GlSprintf, GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import component from '~/registry/explorer/components/details_page/delete_modal.vue'; import { REMOVE_TAG_CONFIRMATION_TEXT, @@ -12,8 +13,9 @@ import { GlModal } from '../../stubs'; describe('Delete Modal', () => { let wrapper; - const findModal = () => wrapper.find(GlModal); + const findModal = () => wrapper.findComponent(GlModal); const findDescription = () => wrapper.find('[data-testid="description"]'); + const findInputComponent = () => wrapper.findComponent(GlFormInput); const mountComponent = (propsData) => { wrapper = shallowMount(component, { @@ -25,6 +27,13 @@ describe('Delete Modal', () => { }); }; + const expectPrimaryActionStatus = (disabled = true) => + expect(findModal().props('actionPrimary')).toMatchObject( + expect.objectContaining({ + attributes: [{ variant: 'danger' }, { disabled }], + }), + ); + afterEach(() => { wrapper.destroy(); wrapper = null; @@ -65,11 +74,49 @@ describe('Delete Modal', () => { it('has the correct description', () => { mountComponent({ deleteImage: true }); - expect(wrapper.text()).toContain(DELETE_IMAGE_CONFIRMATION_TEXT); + expect(wrapper.text()).toContain( + DELETE_IMAGE_CONFIRMATION_TEXT.replace('%{code}', '').trim(), + ); + }); + + describe('delete button', () => { + const itemsToBeDeleted = [{ project: { path: 'foo' } }]; + + it('is disabled by default', () => { + mountComponent({ deleteImage: true }); + + expectPrimaryActionStatus(); + }); + + it('if the user types something different from the project path is disabled', async () => { + mountComponent({ deleteImage: true, itemsToBeDeleted }); + + findInputComponent().vm.$emit('input', 'bar'); + + await nextTick(); + + expectPrimaryActionStatus(); + }); + + it('if the user types the project path it is enabled', async () => { + mountComponent({ deleteImage: true, itemsToBeDeleted }); + + findInputComponent().vm.$emit('input', 'foo'); + + await nextTick(); + + expectPrimaryActionStatus(false); + }); }); }); describe('when we are deleting tags', () => { + it('delete button is enabled', () => { + mountComponent(); + + expectPrimaryActionStatus(false); + }); + describe('itemsToBeDeleted contains one element', () => { beforeEach(() => { mountComponent({ itemsToBeDeleted: [{ path: 'foo' }] }); diff --git a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js index 632f506f4ae..acff5c21940 100644 --- a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js @@ -1,10 +1,11 @@ -import { GlButton, GlIcon } from '@gitlab/ui'; +import { GlDropdownItem, GlIcon } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import { useFakeDate } from 'helpers/fake_date'; import createMockApollo from 'helpers/mock_apollo_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import waitForPromises from 'helpers/wait_for_promises'; +import { GlDropdown } from 'jest/registry/explorer/stubs'; import component from '~/registry/explorer/components/details_page/details_header.vue'; import { UNSCHEDULED_STATUS, @@ -48,8 +49,8 @@ describe('Details Header', () => { const findTitle = () => findByTestId('title'); const findTagsCount = () => findByTestId('tags-count'); const findCleanup = () => findByTestId('cleanup'); - const findDeleteButton = () => wrapper.find(GlButton); - const findInfoIcon = () => wrapper.find(GlIcon); + const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); + const findInfoIcon = () => wrapper.findComponent(GlIcon); const waitForMetadataItems = async () => { // Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available @@ -84,6 +85,8 @@ describe('Details Header', () => { mocks, stubs: { TitleArea, + GlDropdown, + GlDropdownItem, }, }); }; @@ -152,10 +155,11 @@ describe('Details Header', () => { it('has the correct props', () => { mountComponent(); - expect(findDeleteButton().props()).toMatchObject({ - variant: 'danger', - disabled: false, - }); + expect(findDeleteButton().attributes()).toMatchObject( + expect.objectContaining({ + variant: 'danger', + }), + ); }); it('emits the correct event', () => { @@ -168,16 +172,16 @@ describe('Details Header', () => { it.each` canDelete | disabled | isDisabled - ${true} | ${false} | ${false} - ${true} | ${true} | ${true} - ${false} | ${false} | ${true} - ${false} | ${true} | ${true} + ${true} | ${false} | ${undefined} + ${true} | ${true} | ${'true'} + ${false} | ${false} | ${'true'} + ${false} | ${true} | ${'true'} `( 'when canDelete is $canDelete and disabled is $disabled is $isDisabled that the button is disabled', ({ canDelete, disabled, isDisabled }) => { mountComponent({ propsData: { image: { ...defaultImage, canDelete }, disabled } }); - expect(findDeleteButton().props('disabled')).toBe(isDisabled); + expect(findDeleteButton().attributes('disabled')).toBe(isDisabled); }, ); }); diff --git a/spec/frontend/registry/explorer/components/list_page/cleanup_status_spec.js b/spec/frontend/registry/explorer/components/list_page/cleanup_status_spec.js index c89bb874a7f..8f2c049a357 100644 --- a/spec/frontend/registry/explorer/components/list_page/cleanup_status_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/cleanup_status_spec.js @@ -2,7 +2,7 @@ import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import CleanupStatus from '~/registry/explorer/components/list_page/cleanup_status.vue'; import { - ASYNC_DELETE_IMAGE_ERROR_MESSAGE, + CLEANUP_TIMED_OUT_ERROR_MESSAGE, CLEANUP_STATUS_SCHEDULED, CLEANUP_STATUS_ONGOING, CLEANUP_STATUS_UNFINISHED, @@ -81,7 +81,7 @@ describe('cleanup_status', () => { const tooltip = getBinding(findExtraInfoIcon().element, 'gl-tooltip'); - expect(tooltip.value.title).toBe(ASYNC_DELETE_IMAGE_ERROR_MESSAGE); + expect(tooltip.value.title).toBe(CLEANUP_TIMED_OUT_ERROR_MESSAGE); }); }); }); diff --git a/spec/frontend/registry/explorer/mock_data.js b/spec/frontend/registry/explorer/mock_data.js index 27246cf2364..6a835a28807 100644 --- a/spec/frontend/registry/explorer/mock_data.js +++ b/spec/frontend/registry/explorer/mock_data.js @@ -119,6 +119,7 @@ export const containerRepositoryMock = { expirationPolicyCleanupStatus: 'UNSCHEDULED', project: { visibility: 'public', + path: 'gitlab-test', containerExpirationPolicy: { enabled: false, nextRunAt: '2020-11-27T08:59:27Z', diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js index 022f6e71fe6..21af9dcc60f 100644 --- a/spec/frontend/registry/explorer/pages/details_spec.js +++ b/spec/frontend/registry/explorer/pages/details_spec.js @@ -335,7 +335,7 @@ describe('Details Page', () => { describe('Partial Cleanup Alert', () => { const config = { runCleanupPoliciesHelpPagePath: 'foo', - cleanupPoliciesHelpPagePath: 'bar', + expirationPolicyHelpPagePath: 'bar', userCalloutsPath: 'call_out_path', userCalloutId: 'call_out_id', showUnfinishedTagCleanupCallout: true, @@ -367,7 +367,7 @@ describe('Details Page', () => { expect(findPartialCleanupAlert().props()).toEqual({ runCleanupPoliciesHelpPagePath: config.runCleanupPoliciesHelpPagePath, - cleanupPoliciesHelpPagePath: config.cleanupPoliciesHelpPagePath, + cleanupPoliciesHelpPagePath: config.expirationPolicyHelpPagePath, }); }); diff --git a/spec/frontend/registry/explorer/stubs.js b/spec/frontend/registry/explorer/stubs.js index d6fba863ee0..4f65e73d3fa 100644 --- a/spec/frontend/registry/explorer/stubs.js +++ b/spec/frontend/registry/explorer/stubs.js @@ -2,6 +2,7 @@ import { GlModal as RealGlModal, GlEmptyState as RealGlEmptyState, GlSkeletonLoader as RealGlSkeletonLoader, + GlDropdown as RealGlDropdown, } from '@gitlab/ui'; import { RouterLinkStub } from '@vue/test-utils'; import { stubComponent } from 'helpers/stub_component'; @@ -38,3 +39,7 @@ export const ListItem = { }; }, }; + +export const GlDropdown = stubComponent(RealGlDropdown, { + template: '<div><slot></slot></div>', +}); diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js index 748b48dacaa..1db6fa21d6b 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 { getJSONFixture } from 'helpers/fixtures'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import * as commonUtils from '~/lib/utils/common_utils'; import ReleaseEditNewApp from '~/releases/components/app_edit_new.vue'; @@ -77,7 +78,7 @@ describe('Release edit/new component', () => { }; beforeEach(() => { - global.jsdom.reconfigure({ url: TEST_HOST }); + setWindowLocation(TEST_HOST); mock = new MockAdapter(axios); gon.api_version = 'v4'; @@ -164,9 +165,7 @@ describe('Release edit/new component', () => { `when the URL contains a "${BACK_URL_PARAM}=$backUrl" parameter`, ({ backUrl, expectedHref }) => { beforeEach(async () => { - global.jsdom.reconfigure({ - url: `${TEST_HOST}?${BACK_URL_PARAM}=${encodeURIComponent(backUrl)}`, - }); + setWindowLocation(`${TEST_HOST}?${BACK_URL_PARAM}=${encodeURIComponent(backUrl)}`); await factory(); }); diff --git a/spec/frontend/releases/components/release_block_header_spec.js b/spec/frontend/releases/components/release_block_header_spec.js index 0f6657090e6..47fd6377fcf 100644 --- a/spec/frontend/releases/components/release_block_header_spec.js +++ b/spec/frontend/releases/components/release_block_header_spec.js @@ -2,6 +2,7 @@ import { GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { merge } from 'lodash'; import { getJSONFixture } from 'helpers/fixtures'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import ReleaseBlockHeader from '~/releases/components/release_block_header.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; @@ -60,12 +61,7 @@ describe('Release block header', () => { const currentUrl = 'https://example.gitlab.com/path'; beforeEach(() => { - Object.defineProperty(window, 'location', { - writable: true, - value: { - href: currentUrl, - }, - }); + setWindowLocation(currentUrl); factory(); }); diff --git a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js index b8299d44f13..84863eac3d3 100644 --- a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js +++ b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js @@ -3,6 +3,7 @@ import Vuex from 'vuex'; import CodequalityIssueBody from '~/reports/codequality_report/components/codequality_issue_body.vue'; import GroupedCodequalityReportsApp from '~/reports/codequality_report/grouped_codequality_reports_app.vue'; import { getStoreConfig } from '~/reports/codequality_report/store'; +import { STATUS_NOT_FOUND } from '~/reports/constants'; import { parsedReportIssues } from './mock_data'; const localVue = createLocalVue(); @@ -14,8 +15,6 @@ describe('Grouped code quality reports app', () => { const PATHS = { codequalityHelpPath: 'codequality_help.html', - basePath: 'base.json', - headPath: 'head.json', baseBlobPath: 'base/blob/path/', headBlobPath: 'head/blob/path/', }; @@ -127,21 +126,6 @@ describe('Grouped code quality reports app', () => { }); }); - describe('when there is a head report but no base report', () => { - beforeEach(() => { - mockStore.state.basePath = null; - mockStore.state.hasError = true; - }); - - it('renders error text', () => { - expect(findWidget().text()).toContain('Failed to load codeclimate report'); - }); - - it('renders a help icon with more information', () => { - expect(findWidget().find('[data-testid="question-icon"]').exists()).toBe(true); - }); - }); - describe('on error', () => { beforeEach(() => { mockStore.state.hasError = true; @@ -154,5 +138,15 @@ describe('Grouped code quality reports app', () => { it('does not render a help icon', () => { expect(findWidget().find('[data-testid="question-icon"]').exists()).toBe(false); }); + + describe('when base report was not found', () => { + beforeEach(() => { + mockStore.state.status = STATUS_NOT_FOUND; + }); + + it('renders a help icon with more information', () => { + expect(findWidget().find('[data-testid="question-icon"]').exists()).toBe(true); + }); + }); }); }); diff --git a/spec/frontend/reports/codequality_report/store/actions_spec.js b/spec/frontend/reports/codequality_report/store/actions_spec.js index 9dda024bffd..1821390786b 100644 --- a/spec/frontend/reports/codequality_report/store/actions_spec.js +++ b/spec/frontend/reports/codequality_report/store/actions_spec.js @@ -5,8 +5,14 @@ import axios from '~/lib/utils/axios_utils'; import createStore from '~/reports/codequality_report/store'; import * as actions from '~/reports/codequality_report/store/actions'; import * as types from '~/reports/codequality_report/store/mutation_types'; +import { STATUS_NOT_FOUND } from '~/reports/constants'; import { reportIssues, parsedReportIssues } from '../mock_data'; +const pollInterval = 123; +const pollIntervalHeader = { + 'Poll-Interval': pollInterval, +}; + describe('Codequality Reports actions', () => { let localState; let localStore; @@ -19,8 +25,6 @@ describe('Codequality Reports actions', () => { describe('setPaths', () => { it('should commit SET_PATHS mutation', (done) => { const paths = { - basePath: 'basePath', - headPath: 'headPath', baseBlobPath: 'baseBlobPath', headBlobPath: 'headBlobPath', reportsPath: 'reportsPath', @@ -39,11 +43,11 @@ describe('Codequality Reports actions', () => { }); describe('fetchReports', () => { + const endpoint = `${TEST_HOST}/codequality_reports.json`; let mock; beforeEach(() => { - localState.reportsPath = `${TEST_HOST}/codequality_reports.json`; - localState.basePath = '/base/path'; + localState.reportsPath = endpoint; mock = new MockAdapter(axios); }); @@ -53,7 +57,7 @@ describe('Codequality Reports actions', () => { describe('on success', () => { it('commits REQUEST_REPORTS and dispatches receiveReportsSuccess', (done) => { - mock.onGet(`${TEST_HOST}/codequality_reports.json`).reply(200, reportIssues); + mock.onGet(endpoint).reply(200, reportIssues); testAction( actions.fetchReports, @@ -73,7 +77,7 @@ describe('Codequality Reports actions', () => { describe('on error', () => { it('commits REQUEST_REPORTS and dispatches receiveReportsError', (done) => { - mock.onGet(`${TEST_HOST}/codequality_reports.json`).reply(500); + mock.onGet(endpoint).reply(500); testAction( actions.fetchReports, @@ -86,20 +90,78 @@ describe('Codequality Reports actions', () => { }); }); - describe('with no base path', () => { + describe('when base report is not found', () => { it('commits REQUEST_REPORTS and dispatches receiveReportsError', (done) => { - localState.basePath = null; + const data = { status: STATUS_NOT_FOUND }; + mock.onGet(`${TEST_HOST}/codequality_reports.json`).reply(200, data); testAction( actions.fetchReports, null, localState, [{ type: types.REQUEST_REPORTS }], - [{ type: 'receiveReportsError' }], + [{ type: 'receiveReportsError', payload: data }], done, ); }); }); + + describe('while waiting for report results', () => { + it('continues polling until it receives data', (done) => { + mock + .onGet(endpoint) + .replyOnce(204, undefined, pollIntervalHeader) + .onGet(endpoint) + .reply(200, reportIssues); + + Promise.all([ + testAction( + actions.fetchReports, + null, + localState, + [{ type: types.REQUEST_REPORTS }], + [ + { + payload: parsedReportIssues, + type: 'receiveReportsSuccess', + }, + ], + done, + ), + axios + // wait for initial NO_CONTENT response to be fulfilled + .waitForAll() + .then(() => { + jest.advanceTimersByTime(pollInterval); + }), + ]).catch(done.fail); + }); + + it('continues polling until it receives an error', (done) => { + mock + .onGet(endpoint) + .replyOnce(204, undefined, pollIntervalHeader) + .onGet(endpoint) + .reply(500); + + Promise.all([ + testAction( + actions.fetchReports, + null, + localState, + [{ type: types.REQUEST_REPORTS }], + [{ type: 'receiveReportsError', payload: expect.any(Error) }], + done, + ), + axios + // wait for initial NO_CONTENT response to be fulfilled + .waitForAll() + .then(() => { + jest.advanceTimersByTime(pollInterval); + }), + ]).catch(done.fail); + }); + }); }); describe('receiveReportsSuccess', () => { diff --git a/spec/frontend/reports/codequality_report/store/getters_spec.js b/spec/frontend/reports/codequality_report/store/getters_spec.js index de025f814ef..0378171084d 100644 --- a/spec/frontend/reports/codequality_report/store/getters_spec.js +++ b/spec/frontend/reports/codequality_report/store/getters_spec.js @@ -1,6 +1,6 @@ import createStore from '~/reports/codequality_report/store'; import * as getters from '~/reports/codequality_report/store/getters'; -import { LOADING, ERROR, SUCCESS } from '~/reports/constants'; +import { LOADING, ERROR, SUCCESS, STATUS_NOT_FOUND } from '~/reports/constants'; describe('Codequality reports store getters', () => { let localState; @@ -76,10 +76,9 @@ describe('Codequality reports store getters', () => { }); describe('codequalityPopover', () => { - describe('when head report is available but base report is not', () => { + describe('when base report is not available', () => { it('returns a popover with a documentation link', () => { - localState.headPath = 'head.json'; - localState.basePath = undefined; + localState.status = STATUS_NOT_FOUND; localState.helpPath = 'codequality_help.html'; expect(getters.codequalityPopover(localState).title).toEqual( diff --git a/spec/frontend/reports/codequality_report/store/mutations_spec.js b/spec/frontend/reports/codequality_report/store/mutations_spec.js index 8bc6bb26c2a..6e14cd7438b 100644 --- a/spec/frontend/reports/codequality_report/store/mutations_spec.js +++ b/spec/frontend/reports/codequality_report/store/mutations_spec.js @@ -1,5 +1,6 @@ import createStore from '~/reports/codequality_report/store'; import mutations from '~/reports/codequality_report/store/mutations'; +import { STATUS_NOT_FOUND } from '~/reports/constants'; describe('Codequality Reports mutations', () => { let localState; @@ -12,24 +13,18 @@ describe('Codequality Reports mutations', () => { describe('SET_PATHS', () => { it('sets paths to given values', () => { - const basePath = 'base.json'; - const headPath = 'head.json'; const baseBlobPath = 'base/blob/path/'; const headBlobPath = 'head/blob/path/'; const reportsPath = 'reports.json'; const helpPath = 'help.html'; mutations.SET_PATHS(localState, { - basePath, - headPath, baseBlobPath, headBlobPath, reportsPath, helpPath, }); - expect(localState.basePath).toEqual(basePath); - expect(localState.headPath).toEqual(headPath); expect(localState.baseBlobPath).toEqual(baseBlobPath); expect(localState.headBlobPath).toEqual(headBlobPath); expect(localState.reportsPath).toEqual(reportsPath); @@ -58,9 +53,10 @@ describe('Codequality Reports mutations', () => { expect(localState.hasError).toEqual(false); }); - it('clears statusReason', () => { + it('clears status and statusReason', () => { mutations.RECEIVE_REPORTS_SUCCESS(localState, {}); + expect(localState.status).toEqual(''); expect(localState.statusReason).toEqual(''); }); @@ -86,6 +82,13 @@ describe('Codequality Reports mutations', () => { expect(localState.hasError).toEqual(true); }); + it('sets status based on error object', () => { + const error = { status: STATUS_NOT_FOUND }; + mutations.RECEIVE_REPORTS_ERROR(localState, error); + + expect(localState.status).toEqual(error.status); + }); + it('sets statusReason to string from error response data', () => { const data = { status_reason: 'This merge request does not have codequality reports' }; const error = { response: { data } }; diff --git a/spec/frontend/repository/components/blob_button_group_spec.js b/spec/frontend/repository/components/blob_button_group_spec.js index a449fd6f06c..f2a3354f204 100644 --- a/spec/frontend/repository/components/blob_button_group_spec.js +++ b/spec/frontend/repository/components/blob_button_group_spec.js @@ -12,6 +12,9 @@ const DEFAULT_PROPS = { replacePath: 'some/replace/path', deletePath: 'some/delete/path', emptyRepo: false, + projectPath: 'some/project/path', + isLocked: false, + canLock: true, }; const DEFAULT_INJECT = { @@ -43,7 +46,7 @@ describe('BlobButtonGroup component', () => { const findDeleteBlobModal = () => wrapper.findComponent(DeleteBlobModal); const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal); - const findReplaceButton = () => wrapper.findAll(GlButton).at(0); + const findReplaceButton = () => wrapper.find('[data-testid="replace"]'); it('renders component', () => { createComponent(); diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index a83d0a607f2..d462995328b 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -20,6 +20,8 @@ import blobInfoQuery from '~/repository/queries/blob_info.query.graphql'; jest.mock('~/repository/components/blob_viewers'); let wrapper; +let mockResolver; + const simpleMockData = { name: 'some_file.js', size: 123, @@ -37,9 +39,6 @@ const simpleMockData = { externalStorageUrl: 'some_file.js', replacePath: 'some_file.js/replace', deletePath: 'some_file.js/delete', - canLock: true, - isLocked: false, - lockLink: 'some_file.js/lock', forkPath: 'some_file.js/fork', simpleViewer: { fileType: 'text', @@ -62,6 +61,7 @@ const richMockData = { const projectMockData = { userPermissions: { pushCode: true, + downloadCode: true, }, repository: { empty: false, @@ -71,17 +71,28 @@ const projectMockData = { const localVue = createLocalVue(); const mockAxios = new MockAdapter(axios); -const createComponentWithApollo = (mockData = {}) => { +const createComponentWithApollo = (mockData = {}, inject = {}) => { localVue.use(VueApollo); const defaultPushCode = projectMockData.userPermissions.pushCode; + const defaultDownloadCode = projectMockData.userPermissions.downloadCode; const defaultEmptyRepo = projectMockData.repository.empty; - const { blobs, emptyRepo = defaultEmptyRepo, canPushCode = defaultPushCode } = mockData; - - const mockResolver = jest.fn().mockResolvedValue({ + const { + blobs, + emptyRepo = defaultEmptyRepo, + canPushCode = defaultPushCode, + canDownloadCode = defaultDownloadCode, + pathLocks = [], + } = mockData; + + mockResolver = jest.fn().mockResolvedValue({ data: { project: { - userPermissions: { pushCode: canPushCode }, + id: '1234', + userPermissions: { pushCode: canPushCode, downloadCode: canDownloadCode }, + pathLocks: { + nodes: pathLocks, + }, repository: { empty: emptyRepo, blobs: { @@ -101,6 +112,14 @@ const createComponentWithApollo = (mockData = {}) => { path: 'some_file.js', projectPath: 'some/path', }, + mixins: [ + { + data: () => ({ ref: 'default-ref' }), + }, + ], + provide: { + ...inject, + }, }); }; @@ -119,6 +138,7 @@ const createFactory = (mountFn) => ( queries: { project: { loading, + refetch: jest.fn(), }, }, }, @@ -298,6 +318,7 @@ describe('Blob content viewer component', () => { expect(findBlobEdit().props()).toMatchObject({ editPath: editBlobPath, webIdePath: ideEditPath, + showEditButton: true, }); }); @@ -315,10 +336,11 @@ describe('Blob content viewer component', () => { expect(findBlobEdit().props()).toMatchObject({ editPath: editBlobPath, webIdePath: ideEditPath, + showEditButton: true, }); }); - it('does not render BlobHeaderEdit button when viewing a binary file', async () => { + it('renders BlobHeaderEdit button for binary files', async () => { fullFactory({ mockData: { blobInfo: richMockData, isBinary: true }, stubs: { @@ -329,13 +351,36 @@ describe('Blob content viewer component', () => { await nextTick(); - expect(findBlobEdit().exists()).toBe(false); + expect(findBlobEdit().props()).toMatchObject({ + editPath: editBlobPath, + webIdePath: ideEditPath, + showEditButton: false, + }); + }); + + describe('blob header binary file', () => { + it.each([richMockData, { simpleViewer: { fileType: 'download' } }])( + 'passes the correct isBinary value when viewing a binary file', + async (blobInfo) => { + fullFactory({ + mockData: { + blobInfo, + isBinary: true, + }, + stubs: { BlobContent: true, BlobReplace: true }, + }); + + await nextTick(); + + expect(findBlobHeader().props('isBinary')).toBe(true); + }, + ); }); describe('BlobButtonGroup', () => { const { name, path, replacePath, webPath } = simpleMockData; const { - userPermissions: { pushCode }, + userPermissions: { pushCode, downloadCode }, repository: { empty }, } = projectMockData; @@ -345,7 +390,7 @@ describe('Blob content viewer component', () => { fullFactory({ mockData: { blobInfo: simpleMockData, - project: { userPermissions: { pushCode }, repository: { empty } }, + project: { userPermissions: { pushCode, downloadCode }, repository: { empty } }, }, stubs: { BlobContent: true, @@ -361,10 +406,37 @@ describe('Blob content viewer component', () => { replacePath, deletePath: webPath, canPushCode: pushCode, + canLock: true, + isLocked: false, emptyRepo: empty, }); }); + it.each` + canPushCode | canDownloadCode | canLock + ${true} | ${true} | ${true} + ${false} | ${true} | ${false} + ${true} | ${false} | ${false} + `('passes the correct lock states', async ({ canPushCode, canDownloadCode, canLock }) => { + fullFactory({ + mockData: { + blobInfo: simpleMockData, + project: { + userPermissions: { pushCode: canPushCode, downloadCode: canDownloadCode }, + repository: { empty }, + }, + }, + stubs: { + BlobContent: true, + BlobButtonGroup: true, + }, + }); + + await nextTick(); + + expect(findBlobButtonGroup().props('canLock')).toBe(canLock); + }); + it('does not render if not logged in', async () => { window.gon.current_user_id = null; @@ -382,4 +454,32 @@ describe('Blob content viewer component', () => { }); }); }); + + describe('blob info query', () => { + it('is called with originalBranch value if the prop has a value', async () => { + const inject = { originalBranch: 'some-branch' }; + createComponentWithApollo({ blobs: simpleMockData }, inject); + + await waitForPromises(); + + expect(mockResolver).toHaveBeenCalledWith( + expect.objectContaining({ + ref: 'some-branch', + }), + ); + }); + + it('is called with ref value if the originalBranch prop has no value', async () => { + const inject = { originalBranch: null }; + createComponentWithApollo({ blobs: simpleMockData }, inject); + + await waitForPromises(); + + expect(mockResolver).toHaveBeenCalledWith( + expect.objectContaining({ + ref: 'default-ref', + }), + ); + }); + }); }); diff --git a/spec/frontend/repository/components/blob_edit_spec.js b/spec/frontend/repository/components/blob_edit_spec.js index e6e69cd8549..11739674bc9 100644 --- a/spec/frontend/repository/components/blob_edit_spec.js +++ b/spec/frontend/repository/components/blob_edit_spec.js @@ -6,6 +6,7 @@ import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; const DEFAULT_PROPS = { editPath: 'some_file.js/edit', webIdePath: 'some_file.js/ide/edit', + showEditButton: true, }; describe('BlobEdit component', () => { @@ -31,8 +32,8 @@ describe('BlobEdit component', () => { }); const findButtons = () => wrapper.findAll(GlButton); - const findEditButton = () => findButtons().at(0); - const findWebIdeButton = () => findButtons().at(1); + const findEditButton = () => wrapper.find('[data-testid="edit"]'); + const findWebIdeButton = () => wrapper.find('[data-testid="web-ide"]'); const findWebIdeLink = () => wrapper.find(WebIdeLink); it('renders component', () => { @@ -77,6 +78,23 @@ describe('BlobEdit component', () => { editUrl, webIdeUrl, isBlob: true, + showEditButton: true, + }); + }); + + describe('Without Edit button', () => { + const showEditButton = false; + + it('renders WebIdeLink component without an edit button', () => { + createComponent(true, { showEditButton }); + + expect(findWebIdeLink().props()).toMatchObject({ showEditButton }); + }); + + it('does not render an Edit button', () => { + createComponent(false, { showEditButton }); + + expect(findEditButton().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js index 93bfd3d9d32..0733cffe4f4 100644 --- a/spec/frontend/repository/components/breadcrumbs_spec.js +++ b/spec/frontend/repository/components/breadcrumbs_spec.js @@ -3,10 +3,14 @@ import { shallowMount, RouterLinkStub } from '@vue/test-utils'; import Breadcrumbs from '~/repository/components/breadcrumbs.vue'; import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; +const defaultMockRoute = { + name: 'blobPath', +}; + describe('Repository breadcrumbs component', () => { let wrapper; - const factory = (currentPath, extraProps = {}) => { + const factory = (currentPath, extraProps = {}, mockRoute = {}) => { const $apollo = { queries: { userPermissions: { @@ -23,7 +27,13 @@ describe('Repository breadcrumbs component', () => { stubs: { RouterLink: RouterLinkStub, }, - mocks: { $apollo }, + mocks: { + $route: { + defaultMockRoute, + ...mockRoute, + }, + $apollo, + }, }); }; @@ -69,6 +79,21 @@ describe('Repository breadcrumbs component', () => { expect(wrapper.find(GlDropdown).exists()).toBe(false); }); + it.each` + routeName | isRendered + ${'blobPath'} | ${false} + ${'blobPathDecoded'} | ${false} + ${'treePath'} | ${true} + ${'treePathDecoded'} | ${true} + ${'projectRoot'} | ${true} + `( + 'does render add to tree dropdown $isRendered when route is $routeName', + ({ routeName, isRendered }) => { + factory('app/assets/javascripts.js', { canCollaborate: true }, { name: routeName }); + expect(wrapper.find(GlDropdown).exists()).toBe(isRendered); + }, + ); + it('renders add to tree dropdown when permissions are true', async () => { factory('/', { canCollaborate: true }); diff --git a/spec/frontend/repository/components/delete_blob_modal_spec.js b/spec/frontend/repository/components/delete_blob_modal_spec.js index a74e3e6d325..2c62868f391 100644 --- a/spec/frontend/repository/components/delete_blob_modal_spec.js +++ b/spec/frontend/repository/components/delete_blob_modal_spec.js @@ -1,5 +1,5 @@ -import { GlFormTextarea, GlModal, GlFormInput, GlToggle } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlFormTextarea, GlModal, GlFormInput, GlToggle, GlForm } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import DeleteBlobModal from '~/repository/components/delete_blob_modal.vue'; @@ -19,17 +19,34 @@ const initialProps = { describe('DeleteBlobModal', () => { let wrapper; - const createComponent = (props = {}) => { - wrapper = shallowMount(DeleteBlobModal, { + const createComponentFactory = (mountFn) => (props = {}) => { + wrapper = mountFn(DeleteBlobModal, { propsData: { ...initialProps, ...props, }, + attrs: { + static: true, + visible: true, + }, }); }; + const createComponent = createComponentFactory(shallowMount); + const createFullComponent = createComponentFactory(mount); + const findModal = () => wrapper.findComponent(GlModal); - const findForm = () => wrapper.findComponent({ ref: 'form' }); + const findForm = () => findModal().findComponent(GlForm); + const findCommitTextarea = () => findForm().findComponent(GlFormTextarea); + const findTargetInput = () => findForm().findComponent(GlFormInput); + const findCommitHint = () => wrapper.find('[data-testid="hint"]'); + + const fillForm = async (inputValue = {}) => { + const { targetText, commitText } = inputValue; + + await findTargetInput().vm.$emit('input', targetText); + await findCommitTextarea().vm.$emit('input', commitText); + }; afterEach(() => { wrapper.destroy(); @@ -58,17 +75,6 @@ describe('DeleteBlobModal', () => { expect(findForm().attributes('action')).toBe(initialProps.deletePath); }); - it('submits the form', async () => { - createComponent(); - - const submitSpy = jest.spyOn(findForm().element, 'submit'); - findModal().vm.$emit('primary', { preventDefault: () => {} }); - await nextTick(); - - expect(submitSpy).toHaveBeenCalled(); - submitSpy.mockRestore(); - }); - it.each` component | defaultValue | canPushCode | targetBranch | originalBranch | exist ${GlFormTextarea} | ${initialProps.commitMessage} | ${true} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true} @@ -127,4 +133,85 @@ describe('DeleteBlobModal', () => { }, ); }); + + describe('hint', () => { + const targetText = 'some target branch'; + const hintText = 'Try to keep the first line under 52 characters and the others under 72.'; + const charsGenerator = (length) => 'lorem'.repeat(length); + + beforeEach(async () => { + createFullComponent(); + await nextTick(); + }); + + it.each` + commitText | exist | desc + ${charsGenerator(53)} | ${true} | ${'first line length > 52'} + ${`lorem\n${charsGenerator(73)}`} | ${true} | ${'other line length > 72'} + ${charsGenerator(52)} | ${true} | ${'other line length = 52'} + ${`lorem\n${charsGenerator(72)}`} | ${true} | ${'other line length = 72'} + ${`lorem`} | ${false} | ${'first line length < 53'} + ${`lorem\nlorem`} | ${false} | ${'other line length < 53'} + `('displays hint $exist for $desc', async ({ commitText, exist }) => { + await fillForm({ targetText, commitText }); + + if (!exist) { + expect(findCommitHint().exists()).toBe(false); + return; + } + + expect(findCommitHint().text()).toBe(hintText); + }); + }); + + describe('form submission', () => { + let submitSpy; + + beforeEach(async () => { + createFullComponent(); + await nextTick(); + submitSpy = jest.spyOn(findForm().element, 'submit'); + }); + + afterEach(() => { + submitSpy.mockRestore(); + }); + + describe('invalid form', () => { + beforeEach(async () => { + await fillForm({ targetText: '', commitText: '' }); + }); + + it('disables submit button', async () => { + expect(findModal().props('actionPrimary').attributes[0]).toEqual( + expect.objectContaining({ disabled: true }), + ); + }); + + it('does not submit form', async () => { + findModal().vm.$emit('primary', { preventDefault: () => {} }); + expect(submitSpy).not.toHaveBeenCalled(); + }); + }); + + describe('valid form', () => { + beforeEach(async () => { + await fillForm({ + targetText: 'some valid target branch', + commitText: 'some valid commit message', + }); + }); + + it('enables submit button', async () => { + expect(findModal().props('actionPrimary').attributes[0]).toEqual( + expect.objectContaining({ disabled: false }), + ); + }); + + it('submits form', async () => { + findModal().vm.$emit('primary', { preventDefault: () => {} }); + expect(submitSpy).toHaveBeenCalled(); + }); + }); + }); }); diff --git a/spec/frontend/runner/runner_list/runner_list_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js index 54b7d1f1bdb..c1596711be7 100644 --- a/spec/frontend/runner/runner_list/runner_list_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -1,11 +1,12 @@ import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; -import { TEST_HOST } from 'helpers/test_constants'; +import setWindowLocation from 'helpers/set_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import { updateHistory } from '~/lib/utils/url_utility'; +import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerList from '~/runner/components/runner_list.vue'; import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; @@ -22,7 +23,6 @@ import { RUNNER_PAGE_SIZE, } from '~/runner/constants'; import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; -import RunnerListApp from '~/runner/runner_list/runner_list_app.vue'; import { captureException } from '~/runner/sentry_utils'; import { runnersData, runnersDataPaginated } from '../mock_data'; @@ -40,10 +40,9 @@ jest.mock('~/lib/utils/url_utility', () => ({ const localVue = createLocalVue(); localVue.use(VueApollo); -describe('RunnerListApp', () => { +describe('AdminRunnersApp', () => { let wrapper; let mockRunnersQuery; - let originalLocation; const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp); const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp); @@ -54,7 +53,7 @@ describe('RunnerListApp', () => { const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => { const handlers = [[getRunnersQuery, mockRunnersQuery]]; - wrapper = mountFn(RunnerListApp, { + wrapper = mountFn(AdminRunnersApp, { localVue, apolloProvider: createMockApollo(handlers), propsData: { @@ -65,22 +64,8 @@ describe('RunnerListApp', () => { }); }; - const setQuery = (query) => { - window.location.href = `${TEST_HOST}/admin/runners?${query}`; - window.location.search = query; - }; - - beforeAll(() => { - originalLocation = window.location; - Object.defineProperty(window, 'location', { writable: true, value: { href: '', search: '' } }); - }); - - afterAll(() => { - window.location = originalLocation; - }); - beforeEach(async () => { - setQuery(''); + setWindowLocation('/admin/runners'); mockRunnersQuery = jest.fn().mockResolvedValue(runnersData); createComponentWithApollo(); @@ -116,7 +101,7 @@ describe('RunnerListApp', () => { describe('when a filter is preselected', () => { beforeEach(async () => { - setQuery(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`); + setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`); createComponentWithApollo(); await waitForPromises(); @@ -197,7 +182,7 @@ describe('RunnerListApp', () => { it('error is reported to sentry', async () => { expect(captureException).toHaveBeenCalledWith({ error: new Error('Network error: Error!'), - component: 'RunnerListApp', + component: 'AdminRunnersApp', }); }); diff --git a/spec/frontend/runner/components/runner_registration_token_reset_spec.js b/spec/frontend/runner/components/runner_registration_token_reset_spec.js index 6dc207e369c..8b360b88417 100644 --- a/spec/frontend/runner/components/runner_registration_token_reset_spec.js +++ b/spec/frontend/runner/components/runner_registration_token_reset_spec.js @@ -1,11 +1,12 @@ import { GlButton } from '@gitlab/ui'; import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash, { FLASH_TYPES } from '~/flash'; import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue'; -import { INSTANCE_TYPE } from '~/runner/constants'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql'; import { captureException } from '~/runner/sentry_utils'; @@ -23,11 +24,13 @@ describe('RunnerRegistrationTokenReset', () => { const findButton = () => wrapper.findComponent(GlButton); - const createComponent = () => { + const createComponent = ({ props, provide = {} } = {}) => { wrapper = shallowMount(RunnerRegistrationTokenReset, { localVue, + provide, propsData: { type: INSTANCE_TYPE, + ...props, }, apolloProvider: createMockApollo([ [runnersRegistrationTokenResetMutation, runnersRegistrationTokenResetMutationHandler], @@ -59,31 +62,47 @@ describe('RunnerRegistrationTokenReset', () => { }); describe('On click and confirmation', () => { - beforeEach(async () => { - window.confirm.mockReturnValueOnce(true); - await findButton().vm.$emit('click'); - }); + const mockGroupId = '11'; + const mockProjectId = '22'; + + describe.each` + type | provide | expectedInput + ${INSTANCE_TYPE} | ${{}} | ${{ type: INSTANCE_TYPE }} + ${GROUP_TYPE} | ${{ groupId: mockGroupId }} | ${{ type: GROUP_TYPE, id: `gid://gitlab/Group/${mockGroupId}` }} + ${PROJECT_TYPE} | ${{ projectId: mockProjectId }} | ${{ type: PROJECT_TYPE, id: `gid://gitlab/Project/${mockProjectId}` }} + `('Resets token of type $type', ({ type, provide, expectedInput }) => { + beforeEach(async () => { + createComponent({ + provide, + props: { type }, + }); + + window.confirm.mockReturnValueOnce(true); + findButton().vm.$emit('click'); + await waitForPromises(); + }); - it('resets token', () => { - expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledTimes(1); - expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledWith({ - input: { type: INSTANCE_TYPE }, + it('resets token', () => { + expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledTimes(1); + expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledWith({ + input: expectedInput, + }); }); - }); - it('emits result', () => { - expect(wrapper.emitted('tokenReset')).toHaveLength(1); - expect(wrapper.emitted('tokenReset')[0]).toEqual([mockNewToken]); - }); + it('emits result', () => { + expect(wrapper.emitted('tokenReset')).toHaveLength(1); + expect(wrapper.emitted('tokenReset')[0]).toEqual([mockNewToken]); + }); - it('does not show a loading state', () => { - expect(findButton().props('loading')).toBe(false); - }); + it('does not show a loading state', () => { + expect(findButton().props('loading')).toBe(false); + }); - it('shows confirmation', () => { - expect(createFlash).toHaveBeenLastCalledWith({ - message: expect.stringContaining('registration token generated'), - type: FLASH_TYPES.SUCCESS, + it('shows confirmation', () => { + expect(createFlash).toHaveBeenLastCalledWith({ + message: expect.stringContaining('registration token generated'), + type: FLASH_TYPES.SUCCESS, + }); }); }); }); @@ -91,7 +110,8 @@ describe('RunnerRegistrationTokenReset', () => { describe('On click without confirmation', () => { beforeEach(async () => { window.confirm.mockReturnValueOnce(false); - await findButton().vm.$emit('click'); + findButton().vm.$emit('click'); + await waitForPromises(); }); it('does not reset token', () => { @@ -118,7 +138,7 @@ describe('RunnerRegistrationTokenReset', () => { runnersRegistrationTokenResetMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); window.confirm.mockReturnValueOnce(true); - await findButton().vm.$emit('click'); + findButton().vm.$emit('click'); await waitForPromises(); expect(createFlash).toHaveBeenLastCalledWith({ @@ -144,7 +164,7 @@ describe('RunnerRegistrationTokenReset', () => { }); window.confirm.mockReturnValueOnce(true); - await findButton().vm.$emit('click'); + findButton().vm.$emit('click'); await waitForPromises(); expect(createFlash).toHaveBeenLastCalledWith({ @@ -160,7 +180,8 @@ describe('RunnerRegistrationTokenReset', () => { describe('Immediately after click', () => { it('shows loading state', async () => { window.confirm.mockReturnValue(true); - await findButton().vm.$emit('click'); + findButton().vm.$emit('click'); + await nextTick(); expect(findButton().props('loading')).toBe(true); }); diff --git a/spec/frontend/runner/components/runner_type_alert_spec.js b/spec/frontend/runner/components/runner_type_alert_spec.js index 5b136a77eeb..e54e499743b 100644 --- a/spec/frontend/runner/components/runner_type_alert_spec.js +++ b/spec/frontend/runner/components/runner_type_alert_spec.js @@ -23,10 +23,10 @@ describe('RunnerTypeAlert', () => { }); describe.each` - type | exampleText | anchor | variant - ${INSTANCE_TYPE} | ${'Shared runners are available to every project'} | ${'#shared-runners'} | ${'success'} - ${GROUP_TYPE} | ${'Use Group runners when you want all projects in a group'} | ${'#group-runners'} | ${'success'} - ${PROJECT_TYPE} | ${'You can set up a specific runner to be used by multiple projects'} | ${'#specific-runners'} | ${'info'} + type | exampleText | anchor | variant + ${INSTANCE_TYPE} | ${'This runner is available to all groups and projects'} | ${'#shared-runners'} | ${'success'} + ${GROUP_TYPE} | ${'This runner is available to all projects and subgroups in a group'} | ${'#group-runners'} | ${'success'} + ${PROJECT_TYPE} | ${'This runner is associated with one or more projects'} | ${'#specific-runners'} | ${'info'} `('When it is an $type level runner', ({ type, exampleText, anchor, variant }) => { beforeEach(() => { createComponent({ props: { type } }); diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js new file mode 100644 index 00000000000..6a0863e92b4 --- /dev/null +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -0,0 +1,34 @@ +import { shallowMount } from '@vue/test-utils'; +import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; +import RunnerTypeHelp from '~/runner/components/runner_type_help.vue'; +import GroupRunnersApp from '~/runner/group_runners/group_runners_app.vue'; + +const mockRegistrationToken = 'AABBCC'; + +describe('GroupRunnersApp', () => { + let wrapper; + + const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp); + const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp); + + const createComponent = ({ mountFn = shallowMount } = {}) => { + wrapper = mountFn(GroupRunnersApp, { + propsData: { + registrationToken: mockRegistrationToken, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('shows the runner type help', () => { + expect(findRunnerTypeHelp().exists()).toBe(true); + }); + + it('shows the runner setup instructions', () => { + expect(findRunnerManualSetupHelp().exists()).toBe(true); + expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); + }); +}); diff --git a/spec/frontend/runner/runner_list/runner_search_utils_spec.js b/spec/frontend/runner/runner_search_utils_spec.js index e7969676549..3a0c3abe7bd 100644 --- a/spec/frontend/runner/runner_list/runner_search_utils_spec.js +++ b/spec/frontend/runner/runner_search_utils_spec.js @@ -3,7 +3,7 @@ import { fromUrlQueryToSearch, fromSearchToUrl, fromSearchToVariables, -} from '~/runner/runner_list/runner_search_utils'; +} from '~/runner/runner_search_utils'; describe('search_params.js', () => { const examples = [ diff --git a/spec/frontend/search/index_spec.js b/spec/frontend/search/index_spec.js index 1992a7f4437..c07cd74b456 100644 --- a/spec/frontend/search/index_spec.js +++ b/spec/frontend/search/index_spec.js @@ -1,4 +1,5 @@ import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { initSearchApp } from '~/search'; import createStore from '~/search/store'; @@ -8,25 +9,6 @@ jest.mock('~/search/sidebar'); jest.mock('ee_else_ce/search/highlight_blob_search_result'); describe('initSearchApp', () => { - let defaultLocation; - - const setUrl = (query) => { - window.location.href = `https://localhost:3000/search${query}`; - window.location.search = query; - }; - - beforeEach(() => { - defaultLocation = window.location; - Object.defineProperty(window, 'location', { - writable: true, - value: { href: '', search: '' }, - }); - }); - - afterEach(() => { - window.location = defaultLocation; - }); - describe.each` search | decodedSearch ${'test'} | ${'test'} @@ -38,7 +20,7 @@ describe('initSearchApp', () => { ${'test+%2520+this+%2520+stuff'} | ${'test %20 this %20 stuff'} `('parameter decoding', ({ search, decodedSearch }) => { beforeEach(() => { - setUrl(`?search=${search}`); + setWindowLocation(`/search?search=${search}`); initSearchApp(); }); diff --git a/spec/frontend/search/mock_data.js b/spec/frontend/search/mock_data.js index 24ce45e8a09..0542e96c77c 100644 --- a/spec/frontend/search/mock_data.js +++ b/spec/frontend/search/mock_data.js @@ -86,18 +86,21 @@ export const STALE_STORED_DATA = [ export const MOCK_FRESH_DATA_RES = { name: 'fresh' }; -export const PROMISE_ALL_EXPECTED_MUTATIONS = { - initGroups: { +export const PRELOAD_EXPECTED_MUTATIONS = [ + { type: types.LOAD_FREQUENT_ITEMS, payload: { key: GROUPS_LOCAL_STORAGE_KEY, data: FRESH_STORED_DATA }, }, - resGroups: { + { type: types.LOAD_FREQUENT_ITEMS, - payload: { key: GROUPS_LOCAL_STORAGE_KEY, data: [MOCK_FRESH_DATA_RES, MOCK_FRESH_DATA_RES] }, + payload: { key: PROJECTS_LOCAL_STORAGE_KEY, data: FRESH_STORED_DATA }, }, - initProjects: { +]; + +export const PROMISE_ALL_EXPECTED_MUTATIONS = { + resGroups: { type: types.LOAD_FREQUENT_ITEMS, - payload: { key: PROJECTS_LOCAL_STORAGE_KEY, data: FRESH_STORED_DATA }, + payload: { key: GROUPS_LOCAL_STORAGE_KEY, data: [MOCK_FRESH_DATA_RES, MOCK_FRESH_DATA_RES] }, }, resProjects: { type: types.LOAD_FREQUENT_ITEMS, diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js index 3755f8ffae7..9f8c83f2873 100644 --- a/spec/frontend/search/store/actions_spec.js +++ b/spec/frontend/search/store/actions_spec.js @@ -17,6 +17,7 @@ import { MOCK_GROUP, FRESH_STORED_DATA, MOCK_FRESH_DATA_RES, + PRELOAD_EXPECTED_MUTATIONS, PROMISE_ALL_EXPECTED_MUTATIONS, } from '../mock_data'; @@ -68,31 +69,31 @@ describe('Global Search Store Actions', () => { }); describe.each` - action | axiosMock | type | expectedMutations | flashCallCount | lsKey - ${actions.loadFrequentGroups} | ${{ method: 'onGet', code: 200 }} | ${'success'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.initGroups, PROMISE_ALL_EXPECTED_MUTATIONS.resGroups]} | ${0} | ${GROUPS_LOCAL_STORAGE_KEY} - ${actions.loadFrequentGroups} | ${{ method: 'onGet', code: 500 }} | ${'error'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.initGroups]} | ${1} | ${GROUPS_LOCAL_STORAGE_KEY} - ${actions.loadFrequentProjects} | ${{ method: 'onGet', code: 200 }} | ${'success'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.initProjects, PROMISE_ALL_EXPECTED_MUTATIONS.resProjects]} | ${0} | ${PROJECTS_LOCAL_STORAGE_KEY} - ${actions.loadFrequentProjects} | ${{ method: 'onGet', code: 500 }} | ${'error'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.initProjects]} | ${1} | ${PROJECTS_LOCAL_STORAGE_KEY} - `( - 'Promise.all calls', - ({ action, axiosMock, type, expectedMutations, flashCallCount, lsKey }) => { - describe(action.name, () => { - describe(`on ${type}`, () => { - beforeEach(() => { - storeUtils.loadDataFromLS = jest.fn().mockReturnValue(FRESH_STORED_DATA); - mock[axiosMock.method]().reply(axiosMock.code, MOCK_FRESH_DATA_RES); - }); + action | axiosMock | type | expectedMutations | flashCallCount + ${actions.loadFrequentGroups} | ${{ method: 'onGet', code: 200 }} | ${'success'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.resGroups]} | ${0} + ${actions.loadFrequentGroups} | ${{ method: 'onGet', code: 500 }} | ${'error'} | ${[]} | ${1} + ${actions.loadFrequentProjects} | ${{ method: 'onGet', code: 200 }} | ${'success'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.resProjects]} | ${0} + ${actions.loadFrequentProjects} | ${{ method: 'onGet', code: 500 }} | ${'error'} | ${[]} | ${1} + `('Promise.all calls', ({ action, axiosMock, type, expectedMutations, flashCallCount }) => { + describe(action.name, () => { + describe(`on ${type}`, () => { + beforeEach(() => { + state.frequentItems = { + [GROUPS_LOCAL_STORAGE_KEY]: FRESH_STORED_DATA, + [PROJECTS_LOCAL_STORAGE_KEY]: FRESH_STORED_DATA, + }; + + mock[axiosMock.method]().reply(axiosMock.code, MOCK_FRESH_DATA_RES); + }); - it(`should dispatch the correct mutations`, () => { - return testAction({ action, state, expectedMutations }).then(() => { - expect(storeUtils.loadDataFromLS).toHaveBeenCalledWith(lsKey); - flashCallback(flashCallCount); - }); + it(`should dispatch the correct mutations`, () => { + return testAction({ action, state, expectedMutations }).then(() => { + flashCallback(flashCallCount); }); }); }); - }, - ); + }); + }); describe('getGroupsData', () => { const mockCommit = () => {}; @@ -182,14 +183,38 @@ describe('Global Search Store Actions', () => { }); }); + describe('preloadStoredFrequentItems', () => { + beforeEach(() => { + storeUtils.loadDataFromLS = jest.fn().mockReturnValue(FRESH_STORED_DATA); + }); + + it('calls preloadStoredFrequentItems for both groups and projects and commits LOAD_FREQUENT_ITEMS', async () => { + await testAction({ + action: actions.preloadStoredFrequentItems, + state, + expectedMutations: PRELOAD_EXPECTED_MUTATIONS, + }); + + expect(storeUtils.loadDataFromLS).toHaveBeenCalledTimes(2); + expect(storeUtils.loadDataFromLS).toHaveBeenCalledWith(GROUPS_LOCAL_STORAGE_KEY); + expect(storeUtils.loadDataFromLS).toHaveBeenCalledWith(PROJECTS_LOCAL_STORAGE_KEY); + }); + }); + describe('setFrequentGroup', () => { beforeEach(() => { - storeUtils.setFrequentItemToLS = jest.fn(); + storeUtils.setFrequentItemToLS = jest.fn().mockReturnValue(FRESH_STORED_DATA); }); - it(`calls setFrequentItemToLS with ${GROUPS_LOCAL_STORAGE_KEY} and item data`, async () => { + it(`calls setFrequentItemToLS with ${GROUPS_LOCAL_STORAGE_KEY} and item data then commits LOAD_FREQUENT_ITEMS`, async () => { await testAction({ action: actions.setFrequentGroup, + expectedMutations: [ + { + type: types.LOAD_FREQUENT_ITEMS, + payload: { key: GROUPS_LOCAL_STORAGE_KEY, data: FRESH_STORED_DATA }, + }, + ], payload: MOCK_GROUP, state, }); @@ -204,12 +229,18 @@ describe('Global Search Store Actions', () => { describe('setFrequentProject', () => { beforeEach(() => { - storeUtils.setFrequentItemToLS = jest.fn(); + storeUtils.setFrequentItemToLS = jest.fn().mockReturnValue(FRESH_STORED_DATA); }); it(`calls setFrequentItemToLS with ${PROJECTS_LOCAL_STORAGE_KEY} and item data`, async () => { await testAction({ action: actions.setFrequentProject, + expectedMutations: [ + { + type: types.LOAD_FREQUENT_ITEMS, + payload: { key: PROJECTS_LOCAL_STORAGE_KEY, data: FRESH_STORED_DATA }, + }, + ], payload: MOCK_PROJECT, state, }); diff --git a/spec/frontend/search/store/utils_spec.js b/spec/frontend/search/store/utils_spec.js index 5055fa2cc3d..cd7f7dc3b5f 100644 --- a/spec/frontend/search/store/utils_spec.js +++ b/spec/frontend/search/store/utils_spec.js @@ -51,19 +51,25 @@ describe('Global Search Store Utils', () => { describe('setFrequentItemToLS', () => { const frequentItems = {}; + let res; describe('with existing data', () => { describe(`when frequency is less than ${MAX_FREQUENCY}`, () => { beforeEach(() => { frequentItems[MOCK_LS_KEY] = [{ ...MOCK_GROUPS[0], frequency: 1, lastUsed: PREV_TIME }]; - setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]); + res = setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]); }); - it('adds 1 to the frequency, tracks lastUsed, and calls localStorage.setItem', () => { + it('adds 1 to the frequency, tracks lastUsed, calls localStorage.setItem and returns the array', () => { + const updatedFrequentItems = [ + { ...MOCK_GROUPS[0], frequency: 2, lastUsed: CURRENT_TIME }, + ]; + expect(localStorage.setItem).toHaveBeenCalledWith( MOCK_LS_KEY, - JSON.stringify([{ ...MOCK_GROUPS[0], frequency: 2, lastUsed: CURRENT_TIME }]), + JSON.stringify(updatedFrequentItems), ); + expect(res).toEqual(updatedFrequentItems); }); }); @@ -72,16 +78,19 @@ describe('Global Search Store Utils', () => { frequentItems[MOCK_LS_KEY] = [ { ...MOCK_GROUPS[0], frequency: MAX_FREQUENCY, lastUsed: PREV_TIME }, ]; - setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]); + res = setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]); }); - it(`does not further increase frequency past ${MAX_FREQUENCY}, tracks lastUsed, and calls localStorage.setItem`, () => { + it(`does not further increase frequency past ${MAX_FREQUENCY}, tracks lastUsed, calls localStorage.setItem, and returns the array`, () => { + const updatedFrequentItems = [ + { ...MOCK_GROUPS[0], frequency: MAX_FREQUENCY, lastUsed: CURRENT_TIME }, + ]; + expect(localStorage.setItem).toHaveBeenCalledWith( MOCK_LS_KEY, - JSON.stringify([ - { ...MOCK_GROUPS[0], frequency: MAX_FREQUENCY, lastUsed: CURRENT_TIME }, - ]), + JSON.stringify(updatedFrequentItems), ); + expect(res).toEqual(updatedFrequentItems); }); }); }); @@ -89,14 +98,17 @@ describe('Global Search Store Utils', () => { describe('with no existing data', () => { beforeEach(() => { frequentItems[MOCK_LS_KEY] = []; - setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]); + res = setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]); }); - it('adds a new entry with frequency 1, tracks lastUsed, and calls localStorage.setItem', () => { + it('adds a new entry with frequency 1, tracks lastUsed, calls localStorage.setItem, and returns the array', () => { + const updatedFrequentItems = [{ ...MOCK_GROUPS[0], frequency: 1, lastUsed: CURRENT_TIME }]; + expect(localStorage.setItem).toHaveBeenCalledWith( MOCK_LS_KEY, - JSON.stringify([{ ...MOCK_GROUPS[0], frequency: 1, lastUsed: CURRENT_TIME }]), + JSON.stringify(updatedFrequentItems), ); + expect(res).toEqual(updatedFrequentItems); }); }); @@ -107,18 +119,21 @@ describe('Global Search Store Utils', () => { { id: 2, frequency: 1, lastUsed: PREV_TIME }, { id: 3, frequency: 1, lastUsed: PREV_TIME }, ]; - setFrequentItemToLS(MOCK_LS_KEY, frequentItems, { id: 3 }); + res = setFrequentItemToLS(MOCK_LS_KEY, frequentItems, { id: 3 }); }); - it('sorts the array by most frequent and lastUsed', () => { + it('sorts the array by most frequent and lastUsed and returns the array', () => { + const updatedFrequentItems = [ + { id: 3, frequency: 2, lastUsed: CURRENT_TIME }, + { id: 1, frequency: 2, lastUsed: PREV_TIME }, + { id: 2, frequency: 1, lastUsed: PREV_TIME }, + ]; + expect(localStorage.setItem).toHaveBeenCalledWith( MOCK_LS_KEY, - JSON.stringify([ - { id: 3, frequency: 2, lastUsed: CURRENT_TIME }, - { id: 1, frequency: 2, lastUsed: PREV_TIME }, - { id: 2, frequency: 1, lastUsed: PREV_TIME }, - ]), + JSON.stringify(updatedFrequentItems), ); + expect(res).toEqual(updatedFrequentItems); }); }); @@ -131,31 +146,35 @@ describe('Global Search Store Utils', () => { { id: 4, frequency: 2, lastUsed: PREV_TIME }, { id: 5, frequency: 1, lastUsed: PREV_TIME }, ]; - setFrequentItemToLS(MOCK_LS_KEY, frequentItems, { id: 6 }); + res = setFrequentItemToLS(MOCK_LS_KEY, frequentItems, { id: 6 }); }); - it('removes the last item in the array', () => { + it('removes the last item in the array and returns the array', () => { + const updatedFrequentItems = [ + { id: 1, frequency: 5, lastUsed: PREV_TIME }, + { id: 2, frequency: 4, lastUsed: PREV_TIME }, + { id: 3, frequency: 3, lastUsed: PREV_TIME }, + { id: 4, frequency: 2, lastUsed: PREV_TIME }, + { id: 6, frequency: 1, lastUsed: CURRENT_TIME }, + ]; + expect(localStorage.setItem).toHaveBeenCalledWith( MOCK_LS_KEY, - JSON.stringify([ - { id: 1, frequency: 5, lastUsed: PREV_TIME }, - { id: 2, frequency: 4, lastUsed: PREV_TIME }, - { id: 3, frequency: 3, lastUsed: PREV_TIME }, - { id: 4, frequency: 2, lastUsed: PREV_TIME }, - { id: 6, frequency: 1, lastUsed: CURRENT_TIME }, - ]), + JSON.stringify(updatedFrequentItems), ); + expect(res).toEqual(updatedFrequentItems); }); }); describe('with null data loaded in', () => { beforeEach(() => { frequentItems[MOCK_LS_KEY] = null; - setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]); + res = setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]); }); - it('wipes local storage', () => { + it('wipes local storage and returns empty array', () => { expect(localStorage.removeItem).toHaveBeenCalledWith(MOCK_LS_KEY); + expect(res).toEqual([]); }); }); @@ -163,14 +182,17 @@ describe('Global Search Store Utils', () => { beforeEach(() => { const MOCK_ADDITIONAL_DATA_GROUP = { ...MOCK_GROUPS[0], extraData: 'test' }; frequentItems[MOCK_LS_KEY] = []; - setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_ADDITIONAL_DATA_GROUP); + res = setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_ADDITIONAL_DATA_GROUP); }); - it('parses out extra data for LS', () => { + it('parses out extra data for LS and returns the array', () => { + const updatedFrequentItems = [{ ...MOCK_GROUPS[0], frequency: 1, lastUsed: CURRENT_TIME }]; + expect(localStorage.setItem).toHaveBeenCalledWith( MOCK_LS_KEY, - JSON.stringify([{ ...MOCK_GROUPS[0], frequency: 1, lastUsed: CURRENT_TIME }]), + JSON.stringify(updatedFrequentItems), ); + expect(res).toEqual(updatedFrequentItems); }); }); }); diff --git a/spec/frontend/search/topbar/components/app_spec.js b/spec/frontend/search/topbar/components/app_spec.js index fb953f2ed1b..7ce5efb3c52 100644 --- a/spec/frontend/search/topbar/components/app_spec.js +++ b/spec/frontend/search/topbar/components/app_spec.js @@ -1,13 +1,13 @@ import { GlForm, GlSearchBoxByType, GlButton } from '@gitlab/ui'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import { MOCK_QUERY } from 'jest/search/mock_data'; import GlobalSearchTopbar from '~/search/topbar/components/app.vue'; import GroupFilter from '~/search/topbar/components/group_filter.vue'; import ProjectFilter from '~/search/topbar/components/project_filter.vue'; -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); describe('GlobalSearchTopbar', () => { let wrapper; @@ -15,6 +15,7 @@ describe('GlobalSearchTopbar', () => { const actionSpies = { applyQuery: jest.fn(), setQuery: jest.fn(), + preloadStoredFrequentItems: jest.fn(), }; const createComponent = (initialState) => { @@ -27,14 +28,12 @@ describe('GlobalSearchTopbar', () => { }); wrapper = shallowMount(GlobalSearchTopbar, { - localVue, store, }); }; afterEach(() => { wrapper.destroy(); - wrapper = null; }); const findTopbarForm = () => wrapper.find(GlForm); @@ -110,4 +109,14 @@ describe('GlobalSearchTopbar', () => { expect(actionSpies.applyQuery).toHaveBeenCalled(); }); }); + + describe('onCreate', () => { + beforeEach(() => { + createComponent(); + }); + + it('calls preloadStoredFrequentItems', () => { + expect(actionSpies.preloadStoredFrequentItems).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/search/topbar/components/group_filter_spec.js b/spec/frontend/search/topbar/components/group_filter_spec.js index fbd7ad6bb57..bd173791fee 100644 --- a/spec/frontend/search/topbar/components/group_filter_spec.js +++ b/spec/frontend/search/topbar/components/group_filter_spec.js @@ -51,7 +51,6 @@ describe('GroupFilter', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); const findSearchableDropdown = () => wrapper.find(SearchableDropdown); @@ -89,10 +88,11 @@ describe('GroupFilter', () => { findSearchableDropdown().vm.$emit('change', ANY_OPTION); }); - it('calls setUrlParams with group null, project id null, and then calls visitUrl', () => { + it('calls setUrlParams with group null, project id null, nav_source null, and then calls visitUrl', () => { expect(setUrlParams).toHaveBeenCalledWith({ [GROUP_DATA.queryParam]: null, [PROJECT_DATA.queryParam]: null, + nav_source: null, }); expect(visitUrl).toHaveBeenCalled(); @@ -108,10 +108,11 @@ describe('GroupFilter', () => { findSearchableDropdown().vm.$emit('change', MOCK_GROUP); }); - it('calls setUrlParams with group id, project id null, and then calls visitUrl', () => { + it('calls setUrlParams with group id, project id null, nav_source null, and then calls visitUrl', () => { expect(setUrlParams).toHaveBeenCalledWith({ [GROUP_DATA.queryParam]: MOCK_GROUP.id, [PROJECT_DATA.queryParam]: null, + nav_source: null, }); expect(visitUrl).toHaveBeenCalled(); @@ -156,4 +157,31 @@ describe('GroupFilter', () => { }); }); }); + + describe.each` + navSource | initialData | callMethod + ${null} | ${null} | ${false} + ${null} | ${MOCK_GROUP} | ${false} + ${'navbar'} | ${null} | ${false} + ${'navbar'} | ${MOCK_GROUP} | ${true} + `('onCreate', ({ navSource, initialData, callMethod }) => { + describe(`when nav_source is ${navSource} and ${ + initialData ? 'has' : 'does not have' + } an initial group`, () => { + beforeEach(() => { + createComponent({ query: { ...MOCK_QUERY, nav_source: navSource } }, { initialData }); + }); + + it(`${callMethod ? 'does' : 'does not'} call setFrequentGroup`, () => { + if (callMethod) { + expect(actionSpies.setFrequentGroup).toHaveBeenCalledWith( + expect.any(Object), + initialData, + ); + } else { + expect(actionSpies.setFrequentGroup).not.toHaveBeenCalled(); + } + }); + }); + }); }); diff --git a/spec/frontend/search/topbar/components/project_filter_spec.js b/spec/frontend/search/topbar/components/project_filter_spec.js index 63b0f882ca4..5afcd281d0c 100644 --- a/spec/frontend/search/topbar/components/project_filter_spec.js +++ b/spec/frontend/search/topbar/components/project_filter_spec.js @@ -51,7 +51,6 @@ describe('ProjectFilter', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); const findSearchableDropdown = () => wrapper.find(SearchableDropdown); @@ -89,9 +88,10 @@ describe('ProjectFilter', () => { findSearchableDropdown().vm.$emit('change', ANY_OPTION); }); - it('calls setUrlParams with null, no group id, then calls visitUrl', () => { + it('calls setUrlParams with null, no group id, nav_source null, then calls visitUrl', () => { expect(setUrlParams).toHaveBeenCalledWith({ [PROJECT_DATA.queryParam]: null, + nav_source: null, }); expect(visitUrl).toHaveBeenCalled(); }); @@ -106,10 +106,11 @@ describe('ProjectFilter', () => { findSearchableDropdown().vm.$emit('change', MOCK_PROJECT); }); - it('calls setUrlParams with project id, group id, then calls visitUrl', () => { + it('calls setUrlParams with project id, group id, nav_source null, then calls visitUrl', () => { expect(setUrlParams).toHaveBeenCalledWith({ [GROUP_DATA.queryParam]: MOCK_PROJECT.namespace.id, [PROJECT_DATA.queryParam]: MOCK_PROJECT.id, + nav_source: null, }); expect(visitUrl).toHaveBeenCalled(); }); @@ -157,4 +158,31 @@ describe('ProjectFilter', () => { }); }); }); + + describe.each` + navSource | initialData | callMethod + ${null} | ${null} | ${false} + ${null} | ${MOCK_PROJECT} | ${false} + ${'navbar'} | ${null} | ${false} + ${'navbar'} | ${MOCK_PROJECT} | ${true} + `('onCreate', ({ navSource, initialData, callMethod }) => { + describe(`when nav_source is ${navSource} and ${ + initialData ? 'has' : 'does not have' + } an initial project`, () => { + beforeEach(() => { + createComponent({ query: { ...MOCK_QUERY, nav_source: navSource } }, { initialData }); + }); + + it(`${callMethod ? 'does' : 'does not'} call setFrequentProject`, () => { + if (callMethod) { + expect(actionSpies.setFrequentProject).toHaveBeenCalledWith( + expect.any(Object), + initialData, + ); + } else { + expect(actionSpies.setFrequentProject).not.toHaveBeenCalled(); + } + }); + }); + }); }); diff --git a/spec/frontend/security_configuration/app_spec.js b/spec/frontend/security_configuration/app_spec.js deleted file mode 100644 index 11d481fb210..00000000000 --- a/spec/frontend/security_configuration/app_spec.js +++ /dev/null @@ -1,27 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import App from '~/security_configuration/components/app.vue'; -import ConfigurationTable from '~/security_configuration/components/configuration_table.vue'; - -describe('App Component', () => { - let wrapper; - - const createComponent = () => { - wrapper = shallowMount(App, {}); - }; - const findConfigurationTable = () => wrapper.findComponent(ConfigurationTable); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders correct primary & Secondary Heading', () => { - createComponent(); - expect(wrapper.text()).toContain('Security Configuration'); - expect(wrapper.text()).toContain('Testing & Compliance'); - }); - - it('renders ConfigurationTable Component', () => { - createComponent(); - expect(findConfigurationTable().exists()).toBe(true); - }); -}); diff --git a/spec/frontend/security_configuration/components/redesigned_app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index 119a25a77c1..f27f45f2b26 100644 --- a/spec/frontend/security_configuration/components/redesigned_app_spec.js +++ b/spec/frontend/security_configuration/components/app_spec.js @@ -1,8 +1,12 @@ import { GlTab } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; +import stubChildren from 'helpers/stub_children'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import SecurityConfigurationApp, { i18n } from '~/security_configuration/components/app.vue'; import AutoDevopsAlert from '~/security_configuration/components/auto_dev_ops_alert.vue'; +import AutoDevopsEnabledAlert from '~/security_configuration/components/auto_dev_ops_enabled_alert.vue'; import { SAST_NAME, SAST_SHORT_NAME, @@ -12,12 +16,10 @@ import { LICENSE_COMPLIANCE_NAME, LICENSE_COMPLIANCE_DESCRIPTION, LICENSE_COMPLIANCE_HELP_PATH, + AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY, } from '~/security_configuration/components/constants'; import FeatureCard from '~/security_configuration/components/feature_card.vue'; -import RedesignedSecurityConfigurationApp, { - i18n, -} from '~/security_configuration/components/redesigned_app.vue'; import UpgradeBanner from '~/security_configuration/components/upgrade_banner.vue'; import { REPORT_TYPE_LICENSE_COMPLIANCE, @@ -28,8 +30,11 @@ const upgradePath = '/upgrade'; const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath'; const autoDevopsPath = '/autoDevopsPath'; const gitlabCiHistoryPath = 'test/historyPath'; +const projectPath = 'namespace/project'; -describe('redesigned App component', () => { +useLocalStorageSpy(); + +describe('App component', () => { let wrapper; let userCalloutDismissSpy; @@ -37,14 +42,20 @@ describe('redesigned App component', () => { userCalloutDismissSpy = jest.fn(); wrapper = extendedWrapper( - mount(RedesignedSecurityConfigurationApp, { + mount(SecurityConfigurationApp, { propsData, provide: { upgradePath, autoDevopsHelpPagePath, autoDevopsPath, + projectPath, }, stubs: { + ...stubChildren(SecurityConfigurationApp), + GlLink: false, + GlSprintf: false, + LocalStorageSync: false, + SectionLayout: false, UserCalloutDismisser: makeMockUserCalloutDismisser({ dismiss: userCalloutDismissSpy, shouldShowCallout, @@ -83,6 +94,7 @@ describe('redesigned App component', () => { }); const findUpgradeBanner = () => wrapper.findComponent(UpgradeBanner); const findAutoDevopsAlert = () => wrapper.findComponent(AutoDevopsAlert); + const findAutoDevopsEnabledAlert = () => wrapper.findComponent(AutoDevopsEnabledAlert); const securityFeaturesMock = [ { @@ -161,7 +173,7 @@ describe('redesigned App component', () => { }); }); - describe('autoDevOpsAlert', () => { + describe('Auto DevOps hint alert', () => { describe('given the right props', () => { beforeEach(() => { createComponent({ @@ -199,6 +211,76 @@ describe('redesigned App component', () => { }); }); + describe('Auto DevOps enabled alert', () => { + describe.each` + context | autoDevopsEnabled | localStorageValue | shouldRender + ${'enabled'} | ${true} | ${null} | ${true} + ${'enabled, alert dismissed on other project'} | ${true} | ${['foo/bar']} | ${true} + ${'enabled, alert dismissed on this project'} | ${true} | ${[projectPath]} | ${false} + ${'not enabled'} | ${false} | ${null} | ${false} + `('given Auto DevOps is $context', ({ autoDevopsEnabled, localStorageValue, shouldRender }) => { + beforeEach(() => { + if (localStorageValue !== null) { + window.localStorage.setItem( + AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY, + JSON.stringify(localStorageValue), + ); + } + + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock, + autoDevopsEnabled, + }); + }); + + it(shouldRender ? 'renders' : 'does not render', () => { + expect(findAutoDevopsEnabledAlert().exists()).toBe(shouldRender); + }); + }); + + describe('dismissing', () => { + describe.each` + dismissedProjects | expectedWrittenValue + ${null} | ${[projectPath]} + ${[]} | ${[projectPath]} + ${['foo/bar']} | ${['foo/bar', projectPath]} + ${[projectPath]} | ${[projectPath]} + `( + 'given dismissed projects $dismissedProjects', + ({ dismissedProjects, expectedWrittenValue }) => { + beforeEach(() => { + if (dismissedProjects !== null) { + window.localStorage.setItem( + AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY, + JSON.stringify(dismissedProjects), + ); + } + + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock, + autoDevopsEnabled: true, + }); + + findAutoDevopsEnabledAlert().vm.$emit('dismiss'); + }); + + it('adds current project to localStorage value', () => { + expect(window.localStorage.setItem).toHaveBeenLastCalledWith( + AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY, + JSON.stringify(expectedWrittenValue), + ); + }); + + it('hides the alert', () => { + expect(findAutoDevopsEnabledAlert().exists()).toBe(false); + }); + }, + ); + }); + }); + describe('upgrade banner', () => { const makeAvailable = (available) => (feature) => ({ ...feature, available }); diff --git a/spec/frontend/security_configuration/components/auto_dev_ops_enabled_alert_spec.js b/spec/frontend/security_configuration/components/auto_dev_ops_enabled_alert_spec.js new file mode 100644 index 00000000000..778fea2896a --- /dev/null +++ b/spec/frontend/security_configuration/components/auto_dev_ops_enabled_alert_spec.js @@ -0,0 +1,46 @@ +import { GlAlert } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import AutoDevopsEnabledAlert from '~/security_configuration/components/auto_dev_ops_enabled_alert.vue'; + +const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath'; + +describe('AutoDevopsEnabledAlert component', () => { + let wrapper; + + const createComponent = () => { + wrapper = mount(AutoDevopsEnabledAlert, { + provide: { + autoDevopsHelpPagePath, + }, + }); + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains correct body text', () => { + expect(wrapper.text()).toMatchInterpolatedText(AutoDevopsEnabledAlert.i18n.body); + }); + + it('renders the link correctly', () => { + const link = wrapper.find('a[href]'); + + expect(link.attributes('href')).toBe(autoDevopsHelpPagePath); + expect(link.text()).toBe('Auto DevOps'); + }); + + it('bubbles up dismiss events from the GlAlert', () => { + expect(wrapper.emitted('dismiss')).toBe(undefined); + + findAlert().vm.$emit('dismiss'); + + expect(wrapper.emitted('dismiss')).toEqual([[]]); + }); +}); diff --git a/spec/frontend/security_configuration/components/feature_card_spec.js b/spec/frontend/security_configuration/components/feature_card_spec.js index 3658dbb5ef2..fdb1d2f86e3 100644 --- a/spec/frontend/security_configuration/components/feature_card_spec.js +++ b/spec/frontend/security_configuration/components/feature_card_spec.js @@ -127,25 +127,35 @@ describe('FeatureCard component', () => { describe('actions', () => { describe.each` - context | type | available | configured | configurationPath | canEnableByMergeRequest | action - ${'unavailable'} | ${REPORT_TYPE_SAST} | ${false} | ${false} | ${null} | ${false} | ${null} - ${'available'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${null} | ${false} | ${'guide'} - ${'configured'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${null} | ${false} | ${'guide'} - ${'available, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${null} | ${true} | ${'create-mr'} - ${'available, can enable by MR, unknown type'} | ${'foo'} | ${true} | ${false} | ${null} | ${true} | ${'guide'} - ${'configured, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${null} | ${true} | ${'guide'} - ${'available with config path'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${'foo'} | ${false} | ${'enable'} - ${'available with config path, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${'foo'} | ${true} | ${'enable'} - ${'configured with config path'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${'foo'} | ${false} | ${'configure'} - ${'configured with config path, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${'foo'} | ${true} | ${'configure'} + context | type | available | configured | configurationHelpPath | configurationPath | canEnableByMergeRequest | action + ${'unavailable'} | ${REPORT_TYPE_SAST} | ${false} | ${false} | ${'/help'} | ${null} | ${false} | ${null} + ${'available, no configurationHelpPath'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${null} | ${null} | ${false} | ${null} + ${'available'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${'/help'} | ${null} | ${false} | ${'guide'} + ${'configured'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${'/help'} | ${null} | ${false} | ${'guide'} + ${'available, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${'/help'} | ${null} | ${true} | ${'create-mr'} + ${'available, can enable by MR, unknown type'} | ${'foo'} | ${true} | ${false} | ${'/help'} | ${null} | ${true} | ${'guide'} + ${'configured, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${'/help'} | ${null} | ${true} | ${'guide'} + ${'available with config path'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${'/help'} | ${'foo'} | ${false} | ${'enable'} + ${'available with config path, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${'/help'} | ${'foo'} | ${true} | ${'enable'} + ${'configured with config path'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${'/help'} | ${'foo'} | ${false} | ${'configure'} + ${'configured with config path, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${'/help'} | ${'foo'} | ${true} | ${'configure'} `( 'given $context feature', - ({ type, available, configured, configurationPath, canEnableByMergeRequest, action }) => { + ({ + type, + available, + configured, + configurationHelpPath, + configurationPath, + canEnableByMergeRequest, + action, + }) => { beforeEach(() => { feature = makeFeature({ type, available, configured, + configurationHelpPath, configurationPath, canEnableByMergeRequest, }); diff --git a/spec/frontend/security_configuration/components/upgrade_banner_spec.js b/spec/frontend/security_configuration/components/upgrade_banner_spec.js index cf7945343af..a35fded72fb 100644 --- a/spec/frontend/security_configuration/components/upgrade_banner_spec.js +++ b/spec/frontend/security_configuration/components/upgrade_banner_spec.js @@ -43,11 +43,11 @@ describe('UpgradeBanner component', () => { it('renders the list of benefits', () => { const wrapperText = wrapper.text(); - expect(wrapperText).toContain('GitLab Ultimate checks your application'); + expect(wrapperText).toContain('Immediately begin risk analysis and remediation'); expect(wrapperText).toContain('statistics in the merge request'); expect(wrapperText).toContain('statistics across projects'); expect(wrapperText).toContain('Runtime security metrics'); - expect(wrapperText).toContain('risk analysis and remediation'); + expect(wrapperText).toContain('More scan types, including Container Scanning,'); }); it(`re-emits GlBanner's close event`, () => { diff --git a/spec/frontend/security_configuration/configuration_table_spec.js b/spec/frontend/security_configuration/configuration_table_spec.js deleted file mode 100644 index fbd72265c4b..00000000000 --- a/spec/frontend/security_configuration/configuration_table_spec.js +++ /dev/null @@ -1,52 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import ConfigurationTable from '~/security_configuration/components/configuration_table.vue'; -import { scanners, UPGRADE_CTA } from '~/security_configuration/components/constants'; - -import { - REPORT_TYPE_SAST, - REPORT_TYPE_SECRET_DETECTION, -} from '~/vue_shared/security_reports/constants'; - -describe('Configuration Table Component', () => { - let wrapper; - - const createComponent = () => { - wrapper = extendedWrapper( - mount(ConfigurationTable, { - provide: { - projectPath: 'testProjectPath', - }, - }), - ); - }; - - const findHelpLinks = () => wrapper.findAll('[data-testid="help-link"]'); - - afterEach(() => { - wrapper.destroy(); - }); - - beforeEach(() => { - createComponent(); - }); - - describe.each(scanners.map((scanner, i) => [scanner, i]))('given scanner %s', (scanner, i) => { - it('should match strings', () => { - expect(wrapper.text()).toContain(scanner.name); - expect(wrapper.text()).toContain(scanner.description); - if (scanner.type === REPORT_TYPE_SAST) { - expect(wrapper.findByTestId(scanner.type).text()).toBe('Configure via Merge Request'); - } else if (scanner.type === REPORT_TYPE_SECRET_DETECTION) { - expect(wrapper.findByTestId(scanner.type).exists()).toBe(false); - } else { - expect(wrapper.findByTestId(scanner.type).text()).toMatchInterpolatedText(UPGRADE_CTA); - } - }); - - it('should show expected help link', () => { - const helpLink = findHelpLinks().at(i); - expect(helpLink.attributes('href')).toBe(scanner.helpPath); - }); - }); -}); diff --git a/spec/frontend/security_configuration/upgrade_spec.js b/spec/frontend/security_configuration/upgrade_spec.js deleted file mode 100644 index 20bb38aa469..00000000000 --- a/spec/frontend/security_configuration/upgrade_spec.js +++ /dev/null @@ -1,30 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { UPGRADE_CTA } from '~/security_configuration/components/constants'; -import Upgrade from '~/security_configuration/components/upgrade.vue'; - -const TEST_URL = 'http://www.example.test'; -let wrapper; -const createComponent = (componentData = {}) => { - wrapper = mount(Upgrade, componentData); -}; - -afterEach(() => { - wrapper.destroy(); -}); - -describe('Upgrade component', () => { - beforeEach(() => { - createComponent({ provide: { upgradePath: TEST_URL } }); - }); - - it('renders correct text in link', () => { - expect(wrapper.text()).toMatchInterpolatedText(UPGRADE_CTA); - }); - - it('renders link with correct default attributes', () => { - expect(wrapper.find('a').attributes()).toMatchObject({ - href: TEST_URL, - target: '_blank', - }); - }); -}); 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 523f4e88985..1a874c3dcd6 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 @@ -30,8 +30,13 @@ exports[`self monitor component When the self monitor project has not been creat class="js-section-sub-header" > - Enable or disable instance self monitoring - + Activate or deactivate instance self monitoring. + + <gl-link-stub + href="/help/administration/monitoring/gitlab_self_monitoring_project/index" + > + Learn more. + </gl-link-stub> </p> </div> @@ -42,14 +47,14 @@ exports[`self monitor component When the self monitor project has not been creat name="self-monitoring-form" > <p> - Enabling this feature creates a project that can be used to monitor the health of your instance. + Activate self monitoring to create a project to use to monitor the health of your instance. </p> <gl-form-group-stub labeldescription="" > <gl-toggle-stub - label="Create Project" + label="Self monitoring" labelposition="top" /> </gl-form-group-stub> @@ -62,15 +67,15 @@ exports[`self monitor component When the self monitor project has not been creat dismisslabel="Close" modalclass="" modalid="delete-self-monitor-modal" - ok-title="Delete project" + ok-title="Delete self monitoring project" ok-variant="danger" size="md" - title="Disable self monitoring?" + title="Deactivate self monitoring?" titletag="h4" > <div> - Disabling this feature will delete the self monitoring project. Are you sure you want to delete the project? + Deactivating self monitoring deletes the self monitoring project. Are you sure you want to deactivate self monitoring and delete the project? </div> </gl-modal-stub> diff --git a/spec/frontend/self_monitor/components/self_monitor_form_spec.js b/spec/frontend/self_monitor/components/self_monitor_form_spec.js index e6962e4c453..89ad5a00a14 100644 --- a/spec/frontend/self_monitor/components/self_monitor_form_spec.js +++ b/spec/frontend/self_monitor/components/self_monitor_form_spec.js @@ -53,7 +53,7 @@ describe('self monitor component', () => { wrapper = shallowMount(SelfMonitor, { store }); expect(wrapper.find('.js-section-sub-header').text()).toContain( - 'Enable or disable instance self monitoring', + 'Activate or deactivate instance self monitoring.', ); }); }); @@ -63,7 +63,7 @@ describe('self monitor component', () => { wrapper = shallowMount(SelfMonitor, { store }); expect(wrapper.vm.selfMonitoringFormText).toContain( - 'Enabling this feature creates a project that can be used to monitor the health of your instance.', + 'Activate self monitoring to create a project to use to monitor the health of your instance.', ); }); diff --git a/spec/frontend/self_monitor/store/actions_spec.js b/spec/frontend/self_monitor/store/actions_spec.js index 29181e15680..6bcb2a713ea 100644 --- a/spec/frontend/self_monitor/store/actions_spec.js +++ b/spec/frontend/self_monitor/store/actions_spec.js @@ -134,7 +134,7 @@ describe('self monitor actions', () => { payload: { actionName: 'viewSelfMonitorProject', actionText: 'View project', - message: 'Self monitoring project has been successfully created.', + message: 'Self monitoring project successfully created.', }, }, { type: types.SET_SHOW_ALERT, payload: true }, @@ -245,7 +245,7 @@ describe('self monitor actions', () => { payload: { actionName: 'createProject', actionText: 'Undo', - message: 'Self monitoring project has been successfully deleted.', + message: 'Self monitoring project successfully deleted.', }, }, { type: types.SET_SHOW_ALERT, payload: true }, diff --git a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js index 5a3a152d201..69f6a6e6e04 100644 --- a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js +++ b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js @@ -81,30 +81,33 @@ describe('AssigneeAvatarLink component', () => { ); describe.each` - tooltipHasName | availability | canMerge | expected - ${true} | ${'Busy'} | ${false} | ${'Root (Busy) (cannot merge)'} - ${true} | ${'Busy'} | ${true} | ${'Root (Busy)'} - ${true} | ${''} | ${false} | ${'Root (cannot merge)'} - ${true} | ${''} | ${true} | ${'Root'} - ${false} | ${'Busy'} | ${false} | ${'Cannot merge'} - ${false} | ${'Busy'} | ${true} | ${''} - ${false} | ${''} | ${false} | ${'Cannot merge'} - ${false} | ${''} | ${true} | ${''} + tooltipHasName | name | availability | canMerge | expected + ${true} | ${"Rabbit O'Hare"} | ${''} | ${true} | ${"Rabbit O'Hare"} + ${true} | ${"Rabbit O'Hare"} | ${'Busy'} | ${false} | ${"Rabbit O'Hare (Busy) (cannot merge)"} + ${true} | ${'Root'} | ${'Busy'} | ${false} | ${'Root (Busy) (cannot merge)'} + ${true} | ${'Root'} | ${'Busy'} | ${true} | ${'Root (Busy)'} + ${true} | ${'Root'} | ${''} | ${false} | ${'Root (cannot merge)'} + ${true} | ${'Root'} | ${''} | ${true} | ${'Root'} + ${false} | ${'Root'} | ${'Busy'} | ${false} | ${'Cannot merge'} + ${false} | ${'Root'} | ${'Busy'} | ${true} | ${''} + ${false} | ${'Root'} | ${''} | ${false} | ${'Cannot merge'} + ${false} | ${'Root'} | ${''} | ${true} | ${''} `( - "with tooltipHasName=$tooltipHasName and availability='$availability' and canMerge=$canMerge", - ({ tooltipHasName, availability, canMerge, expected }) => { + "with name=$name tooltipHasName=$tooltipHasName and availability='$availability' and canMerge=$canMerge", + ({ name, tooltipHasName, availability, canMerge, expected }) => { beforeEach(() => { createComponent({ tooltipHasName, user: { ...userDataMock(), + name, can_merge: canMerge, availability, }, }); }); - it('sets tooltip to $expected', () => { + it(`sets tooltip to "${expected}"`, () => { expect(findTooltipText()).toBe(expected); }); }, diff --git a/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js b/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js index 747d370e1cf..6116bc68927 100644 --- a/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js +++ b/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js @@ -1,5 +1,6 @@ import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import { INCIDENT_SEVERITY, ISSUABLE_TYPES } from '~/sidebar/components/severity/constants'; @@ -15,6 +16,7 @@ describe('SidebarSeverity', () => { const projectPath = 'gitlab-org/gitlab-test'; const iid = '1'; const severity = 'CRITICAL'; + let canUpdate = true; function createComponent(props = {}) { const propsData = { @@ -25,8 +27,11 @@ describe('SidebarSeverity', () => { ...props, }; mutate = jest.fn(); - wrapper = shallowMount(SidebarSeverity, { + wrapper = shallowMountExtended(SidebarSeverity, { propsData, + provide: { + canUpdate, + }, mocks: { $apollo: { mutate, @@ -45,22 +50,34 @@ describe('SidebarSeverity', () => { afterEach(() => { if (wrapper) { wrapper.destroy(); - wrapper = null; } }); - const findSeverityToken = () => wrapper.findAll(SeverityToken); - const findEditBtn = () => wrapper.find('[data-testid="editButton"]'); - const findDropdown = () => wrapper.find(GlDropdown); - const findCriticalSeverityDropdownItem = () => wrapper.find(GlDropdownItem); - const findLoadingIcon = () => wrapper.find(GlLoadingIcon); - const findTooltip = () => wrapper.find(GlTooltip); + const findSeverityToken = () => wrapper.findAllComponents(SeverityToken); + const findEditBtn = () => wrapper.findByTestId('editButton'); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findCriticalSeverityDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findTooltip = () => wrapper.findComponent(GlTooltip); const findCollapsedSeverity = () => wrapper.find({ ref: 'severity' }); - it('renders severity widget', () => { - expect(findEditBtn().exists()).toBe(true); - expect(findSeverityToken().exists()).toBe(true); - expect(findDropdown().exists()).toBe(true); + describe('Severity widget', () => { + it('renders severity dropdown and token', () => { + expect(findSeverityToken().exists()).toBe(true); + expect(findDropdown().exists()).toBe(true); + }); + + describe('edit button', () => { + it('is rendered when `canUpdate` provided as `true`', () => { + expect(findEditBtn().exists()).toBe(true); + }); + + it('is NOT rendered when `canUpdate` provided as `false`', () => { + canUpdate = false; + createComponent(); + expect(findEditBtn().exists()).toBe(false); + }); + }); }); describe('Update severity', () => { @@ -100,7 +117,7 @@ describe('SidebarSeverity', () => { ); findCriticalSeverityDropdownItem().vm.$emit('click'); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findLoadingIcon().exists()).toBe(true); resolvePromise(); @@ -128,27 +145,29 @@ describe('SidebarSeverity', () => { it('should expand the dropdown on collapsed icon click', async () => { wrapper.vm.isDropdownShowing = false; - await wrapper.vm.$nextTick(); + await nextTick(); expect(findDropdown().classes(HIDDDEN_CLASS)).toBe(true); findCollapsedSeverity().trigger('click'); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findDropdown().classes(SHOWN_CLASS)).toBe(true); }); }); describe('expanded', () => { it('toggles dropdown with edit button', async () => { + canUpdate = true; + createComponent(); wrapper.vm.isDropdownShowing = false; - await wrapper.vm.$nextTick(); + await nextTick(); expect(findDropdown().classes(HIDDDEN_CLASS)).toBe(true); findEditBtn().vm.$emit('click'); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findDropdown().classes(SHOWN_CLASS)).toBe(true); findEditBtn().vm.$emit('click'); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findDropdown().classes(HIDDDEN_CLASS)).toBe(true); }); }); diff --git a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js index f5e5ab4a984..ca6e5ac5e7f 100644 --- a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js +++ b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js @@ -12,11 +12,13 @@ import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { IssuableType } from '~/issue_show/constants'; +import { timeFor } from '~/lib/utils/datetime_utility'; import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget.vue'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import { IssuableAttributeType } from '~/sidebar/constants'; @@ -54,6 +56,7 @@ describe('SidebarDropdownWidget', () => { const mutationSuccessWithErrors = () => jest.fn().mockResolvedValue({ data: promiseWithErrors }); const findGlLink = () => wrapper.findComponent(GlLink); + const findDateTooltip = () => getBinding(findGlLink().element, 'gl-tooltip'); const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdownText = () => wrapper.findComponent(GlDropdownText); const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); @@ -155,6 +158,9 @@ describe('SidebarDropdownWidget', () => { }, }, }, + directives: { + GlTooltip: createMockDirective(), + }, stubs: { SidebarEditableItem, GlSearchBoxByType, @@ -177,7 +183,7 @@ describe('SidebarDropdownWidget', () => { beforeEach(() => { createComponent({ data: { - currentAttribute: { id: 'id', title: 'title', webUrl: 'webUrl' }, + currentAttribute: { id: 'id', title: 'title', webUrl: 'webUrl', dueDate: '2021-09-09' }, }, stubs: { GlDropdown, @@ -223,6 +229,10 @@ describe('SidebarDropdownWidget', () => { expect(findSelectedAttribute().text()).toBe('Some milestone title'); }); + it('displays time for milestone due date in tooltip', () => { + expect(findDateTooltip().value).toBe(timeFor('2021-09-09')); + }); + describe('when current attribute does not exist', () => { it('renders "None" as the selected attribute title', () => { createComponent(); @@ -451,7 +461,6 @@ describe('SidebarDropdownWidget', () => { expect(projectMilestonesSpy).toHaveBeenNthCalledWith(1, { fullPath: mockIssue.projectPath, - sort: null, state: 'active', title: '', }); @@ -478,7 +487,6 @@ describe('SidebarDropdownWidget', () => { expect(projectMilestonesSpy).toHaveBeenNthCalledWith(2, { fullPath: mockIssue.projectPath, - sort: null, state: 'active', title: mockSearchTerm, }); diff --git a/spec/frontend/sidebar/components/time_tracking/mock_data.js b/spec/frontend/sidebar/components/time_tracking/mock_data.js index 862bcbe861e..938750bd58b 100644 --- a/spec/frontend/sidebar/components/time_tracking/mock_data.js +++ b/spec/frontend/sidebar/components/time_tracking/mock_data.js @@ -16,9 +16,10 @@ export const getIssueTimelogsQueryResponse = { }, spentAt: '2020-05-01T00:00:00Z', note: { - body: 'I paired with @root on this last week.', + body: 'A note', __typename: 'Note', }, + summary: 'A summary', }, { __typename: 'Timelog', @@ -29,6 +30,7 @@ export const getIssueTimelogsQueryResponse = { }, spentAt: '2021-05-07T13:19:01Z', note: null, + summary: 'A summary', }, { __typename: 'Timelog', @@ -39,9 +41,10 @@ export const getIssueTimelogsQueryResponse = { }, spentAt: '2021-05-01T00:00:00Z', note: { - body: 'I did some work on this last week.', + body: 'A note', __typename: 'Note', }, + summary: null, }, ], __typename: 'TimelogConnection', @@ -70,6 +73,7 @@ export const getMrTimelogsQueryResponse = { body: 'Thirty minutes!', __typename: 'Note', }, + summary: null, }, { __typename: 'Timelog', @@ -80,6 +84,7 @@ export const getMrTimelogsQueryResponse = { }, spentAt: '2021-05-07T14:44:39Z', note: null, + summary: null, }, { __typename: 'Timelog', @@ -93,6 +98,7 @@ export const getMrTimelogsQueryResponse = { body: 'A note with some time', __typename: 'Note', }, + summary: null, }, ], __typename: 'TimelogConnection', diff --git a/spec/frontend/sidebar/components/time_tracking/report_spec.js b/spec/frontend/sidebar/components/time_tracking/report_spec.js index 710fae8ddf7..66218626e6b 100644 --- a/spec/frontend/sidebar/components/time_tracking/report_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/report_spec.js @@ -74,6 +74,8 @@ describe('Issuable Time Tracking Report', () => { expect(getAllByRole(wrapper.element, 'row', { name: /John Doe18/i })).toHaveLength(1); expect(getAllByRole(wrapper.element, 'row', { name: /Administrator/i })).toHaveLength(2); + expect(getAllByRole(wrapper.element, 'row', { name: /A note/i })).toHaveLength(1); + expect(getAllByRole(wrapper.element, 'row', { name: /A summary/i })).toHaveLength(2); }); }); diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index 9fab24d7518..1ebd3c622ca 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -415,7 +415,7 @@ const mockUser1 = { status: null, }; -const mockUser2 = { +export const mockUser2 = { id: 'gid://gitlab/User/4', avatarUrl: '/avatar2', name: 'rookie', @@ -452,9 +452,40 @@ export const projectMembersResponse = { null, null, // Remove duplicated entry https://gitlab.com/gitlab-org/gitlab/-/issues/327822 - mockUser1, - mockUser1, - mockUser2, + { user: mockUser1 }, + { user: mockUser1 }, + { user: mockUser2 }, + { + user: { + id: 'gid://gitlab/User/2', + avatarUrl: + 'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon', + name: 'Jacki Kub', + username: 'francina.skiles', + webUrl: '/franc', + status: { + availability: 'BUSY', + }, + }, + }, + ], + }, + }, + }, +}; + +export const groupMembersResponse = { + data: { + workspace: { + __typename: 'roup', + users: { + nodes: [ + // Remove nulls https://gitlab.com/gitlab-org/gitlab/-/issues/329750 + null, + null, + // Remove duplicated entry https://gitlab.com/gitlab-org/gitlab/-/issues/327822 + { user: mockUser1 }, + { user: mockUser1 }, { user: { id: 'gid://gitlab/User/2', @@ -531,6 +562,7 @@ export const mockMilestone1 = { webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/milestones/1', state: 'active', expired: false, + dueDate: '2030-09-09', }; export const mockMilestone2 = { @@ -540,6 +572,7 @@ export const mockMilestone2 = { webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/milestones/2', state: 'active', expired: false, + dueDate: '2030-09-09', }; export const mockProjectMilestonesResponse = { @@ -554,6 +587,19 @@ export const mockProjectMilestonesResponse = { }, }; +export const mockGroupMilestonesResponse = { + data: { + workspace: { + id: 'gid://gitlab/Group/1', + attributes: { + nodes: [mockMilestone1, mockMilestone2], + }, + __typename: 'MilestoneConnection', + }, + __typename: 'Group', + }, +}; + export const noCurrentMilestoneResponse = { data: { workspace: { @@ -574,6 +620,7 @@ export const mockMilestoneMutationResponse = { title: 'Awesome Milestone', state: 'active', expired: false, + dueDate: '2030-09-09', __typename: 'Milestone', }, __typename: 'Issue', diff --git a/spec/frontend/snippets/components/show_spec.js b/spec/frontend/snippets/components/show_spec.js index e6162c6aad2..b7b638b5137 100644 --- a/spec/frontend/snippets/components/show_spec.js +++ b/spec/frontend/snippets/components/show_spec.js @@ -71,7 +71,9 @@ describe('Snippet view app', () => { it('renders correct snippet-blob components', () => { createComponent({ data: { - blobs: [Blob, BinaryBlob], + snippet: { + blobs: [Blob, BinaryBlob], + }, }, }); const blobs = wrapper.findAll(SnippetBlob); diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js index 585614a6b79..fb95be3a77c 100644 --- a/spec/frontend/snippets/components/snippet_header_spec.js +++ b/spec/frontend/snippets/components/snippet_header_spec.js @@ -1,6 +1,7 @@ -import { GlButton, GlModal } from '@gitlab/ui'; +import { GlButton, GlModal, GlDropdown } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { ApolloMutation } from 'vue-apollo'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { Blob, BinaryBlob } from 'jest/blob/components/mock_data'; import { differenceInMilliseconds } from '~/lib/utils/datetime_utility'; @@ -16,6 +17,7 @@ describe('Snippet header component', () => { let errorMsg; let err; const originalRelativeUrlRoot = gon.relative_url_root; + const reportAbusePath = '/-/snippets/42/mark_as_spam'; const GlEmoji = { template: '<img/>' }; @@ -24,6 +26,7 @@ describe('Snippet header component', () => { permissions = {}, mutationRes = mutationTypes.RESOLVE, snippetProps = {}, + provide = {}, } = {}) { const defaultProps = Object.assign(snippet, snippetProps); if (permissions) { @@ -42,6 +45,10 @@ describe('Snippet header component', () => { wrapper = mount(SnippetHeader, { mocks: { $apollo }, + provide: { + reportAbusePath, + ...provide, + }, propsData: { snippet: { ...defaultProps, @@ -54,9 +61,27 @@ describe('Snippet header component', () => { }); } - const findAuthorEmoji = () => wrapper.find(GlEmoji); + const findAuthorEmoji = () => wrapper.findComponent(GlEmoji); const findAuthoredMessage = () => wrapper.find('[data-testid="authored-message"]').text(); - const buttonCount = () => wrapper.findAll(GlButton).length; + const findButtons = () => wrapper.findAllComponents(GlButton); + const findButtonsAsModel = () => + findButtons().wrappers.map((x) => ({ + text: x.text(), + href: x.attributes('href'), + category: x.props('category'), + variant: x.props('variant'), + disabled: x.props('disabled'), + })); + const findResponsiveDropdown = () => wrapper.findComponent(GlDropdown); + // We can't search by component here since we are full mounting and the attributes are applied to a child of the GlDropdownItem + const findResponsiveDropdownItems = () => findResponsiveDropdown().findAll('[role="menuitem"]'); + const findResponsiveDropdownItemsAsModel = () => + findResponsiveDropdownItems().wrappers.map((x) => ({ + disabled: x.attributes('disabled'), + href: x.attributes('href'), + title: x.attributes('title'), + text: x.text(), + })); beforeEach(() => { gon.relative_url_root = '/foo/'; @@ -141,42 +166,108 @@ describe('Snippet header component', () => { expect(text).toBe('Authored 1 month ago'); }); - it('renders action buttons based on permissions', () => { - createComponent({ - permissions: { - adminSnippet: false, - updateSnippet: false, + it('renders a action buttons', () => { + createComponent(); + + expect(findButtonsAsModel()).toEqual([ + { + category: 'primary', + disabled: false, + href: `${snippet.webUrl}/edit`, + text: 'Edit', + variant: 'default', }, - }); - expect(buttonCount()).toEqual(0); + { + category: 'secondary', + disabled: false, + text: 'Delete', + variant: 'danger', + }, + { + category: 'primary', + disabled: false, + href: reportAbusePath, + text: 'Submit as spam', + variant: 'default', + }, + ]); + }); - createComponent({ - permissions: { - adminSnippet: true, - updateSnippet: false, + it('renders responsive dropdown for action buttons', () => { + createComponent(); + + expect(findResponsiveDropdownItemsAsModel()).toEqual([ + { + href: `${snippet.webUrl}/edit`, + text: 'Edit', }, - }); - expect(buttonCount()).toEqual(1); + { + text: 'Delete', + }, + { + href: reportAbusePath, + text: 'Submit as spam', + title: 'Submit as spam', + }, + ]); + }); + it.each` + permissions | buttons + ${{ adminSnippet: false, updateSnippet: false }} | ${['Submit as spam']} + ${{ adminSnippet: true, updateSnippet: false }} | ${['Delete', 'Submit as spam']} + ${{ adminSnippet: false, updateSnippet: true }} | ${['Edit', 'Submit as spam']} + `('with permissions ($permissions), renders buttons ($buttons)', ({ permissions, buttons }) => { createComponent({ permissions: { - adminSnippet: true, - updateSnippet: true, + ...permissions, }, }); - expect(buttonCount()).toEqual(2); - createComponent({ - permissions: { - adminSnippet: true, - updateSnippet: true, - }, + expect(findButtonsAsModel().map((x) => x.text)).toEqual(buttons); + }); + + it('with canCreateSnippet permission, renders create button', async () => { + createComponent(); + + // TODO: we should avoid `wrapper.setData` since they + // are component internals. Let's use the apollo mock helpers + // in a follow-up. + wrapper.setData({ canCreateSnippet: true }); + await wrapper.vm.$nextTick(); + + expect(findButtonsAsModel()).toEqual( + expect.arrayContaining([ + { + category: 'secondary', + disabled: false, + href: `/foo/-/snippets/new`, + text: 'New snippet', + variant: 'success', + }, + ]), + ); + }); + + describe('with guest user', () => { + beforeEach(() => { + createComponent({ + permissions: { + adminSnippet: false, + updateSnippet: false, + }, + provide: { + reportAbusePath: null, + }, + }); }); - wrapper.setData({ - canCreateSnippet: true, + + it('does not show any action buttons', () => { + expect(findButtons()).toHaveLength(0); }); - return wrapper.vm.$nextTick().then(() => { - expect(buttonCount()).toEqual(3); + + it('does not show responsive action dropdown', () => { + expect(findResponsiveDropdown().exists()).toBe(false); }); }); @@ -200,19 +291,6 @@ describe('Snippet header component', () => { }); describe('Delete mutation', () => { - const { location } = window; - - beforeEach(() => { - delete window.location; - window.location = { - pathname: '', - }; - }); - - afterEach(() => { - window.location = location; - }); - it('dispatches a mutation to delete the snippet with correct variables', () => { createComponent(); wrapper.vm.deleteSnippet(); @@ -231,6 +309,8 @@ describe('Snippet header component', () => { }); describe('in case of successful mutation, closes modal and redirects to correct listing', () => { + useMockLocationHelper(); + const createDeleteSnippet = (snippetProps = {}) => { createComponent({ snippetProps, diff --git a/spec/frontend/syntax_highlight_spec.js b/spec/frontend/syntax_highlight_spec.js index 418679e7d18..8ad4f8d5c70 100644 --- a/spec/frontend/syntax_highlight_spec.js +++ b/spec/frontend/syntax_highlight_spec.js @@ -10,39 +10,50 @@ describe('Syntax Highlighter', () => { } return (window.gon.user_color_scheme = value); }; - describe('on a js-syntax-highlight element', () => { - beforeEach(() => { - setFixtures('<div class="js-syntax-highlight"></div>'); - }); - - it('applies syntax highlighting', () => { - stubUserColorScheme('monokai'); - syntaxHighlight($('.js-syntax-highlight')); - expect($('.js-syntax-highlight')).toHaveClass('monokai'); + // We have to bind `document.querySelectorAll` to `document` to not mess up the fn's context + describe.each` + desc | fn + ${'jquery'} | ${$} + ${'vanilla all'} | ${document.querySelectorAll.bind(document)} + ${'vanilla single'} | ${document.querySelector.bind(document)} + `('highlight using $desc syntax', ({ fn }) => { + describe('on a js-syntax-highlight element', () => { + beforeEach(() => { + setFixtures('<div class="js-syntax-highlight"></div>'); + }); + + it('applies syntax highlighting', () => { + stubUserColorScheme('monokai'); + syntaxHighlight(fn('.js-syntax-highlight')); + + expect(fn('.js-syntax-highlight')).toHaveClass('monokai'); + }); }); - }); - describe('on a parent element', () => { - beforeEach(() => { - setFixtures( - '<div class="parent">\n <div class="js-syntax-highlight"></div>\n <div class="foo"></div>\n <div class="js-syntax-highlight"></div>\n</div>', - ); - }); + describe('on a parent element', () => { + beforeEach(() => { + setFixtures( + '<div class="parent">\n <div class="js-syntax-highlight"></div>\n <div class="foo"></div>\n <div class="js-syntax-highlight"></div>\n</div>', + ); + }); - it('applies highlighting to all applicable children', () => { - stubUserColorScheme('monokai'); - syntaxHighlight($('.parent')); + it('applies highlighting to all applicable children', () => { + stubUserColorScheme('monokai'); + syntaxHighlight(fn('.parent')); - expect($('.parent, .foo')).not.toHaveClass('monokai'); - expect($('.monokai').length).toBe(2); - }); + expect(fn('.parent')).not.toHaveClass('monokai'); + expect(fn('.foo')).not.toHaveClass('monokai'); + + expect(document.querySelectorAll('.monokai').length).toBe(2); + }); - it('prevents an infinite loop when no matches exist', () => { - setFixtures('<div></div>'); - const highlight = () => syntaxHighlight($('div')); + it('prevents an infinite loop when no matches exist', () => { + setFixtures('<div></div>'); + const highlight = () => syntaxHighlight(fn('div')); - expect(highlight).not.toThrow(); + expect(highlight).not.toThrow(); + }); }); }); }); diff --git a/spec/frontend/terraform/components/empty_state_spec.js b/spec/frontend/terraform/components/empty_state_spec.js index c86160e18f3..1637ac2039c 100644 --- a/spec/frontend/terraform/components/empty_state_spec.js +++ b/spec/frontend/terraform/components/empty_state_spec.js @@ -1,4 +1,4 @@ -import { GlEmptyState, GlSprintf } from '@gitlab/ui'; +import { GlEmptyState, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import EmptyState from '~/terraform/components/empty_state.vue'; @@ -8,19 +8,20 @@ describe('EmptyStateComponent', () => { const propsData = { image: '/image/path', }; + const docsUrl = '/help/user/infrastructure/terraform_state'; + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findLink = () => wrapper.findComponent(GlLink); beforeEach(() => { - wrapper = shallowMount(EmptyState, { propsData, stubs: { GlEmptyState, GlSprintf } }); - return wrapper.vm.$nextTick(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; + wrapper = shallowMount(EmptyState, { propsData, stubs: { GlEmptyState, GlLink } }); }); it('should render content', () => { - expect(wrapper.find(GlEmptyState).exists()).toBe(true); + expect(findEmptyState().exists()).toBe(true); expect(wrapper.text()).toContain('Get started with Terraform'); }); + + it('should have a link to the GitLab managed Terraform States docs', () => { + expect(findLink().attributes('href')).toBe(docsUrl); + }); }); diff --git a/spec/frontend/terraform/components/init_command_modal_spec.js b/spec/frontend/terraform/components/init_command_modal_spec.js new file mode 100644 index 00000000000..dbdff899bac --- /dev/null +++ b/spec/frontend/terraform/components/init_command_modal_spec.js @@ -0,0 +1,79 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import InitCommandModal from '~/terraform/components/init_command_modal.vue'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; + +const accessTokensPath = '/path/to/access-tokens-page'; +const terraformApiUrl = 'https://gitlab.com/api/v4/projects/1'; +const username = 'username'; +const modalId = 'fake-modal-id'; +const stateName = 'production'; +const modalInfoCopyStr = `export GITLAB_ACCESS_TOKEN=<YOUR-ACCESS-TOKEN> +terraform init \\ + -backend-config="address=${terraformApiUrl}/${stateName}" \\ + -backend-config="lock_address=${terraformApiUrl}/${stateName}/lock" \\ + -backend-config="unlock_address=${terraformApiUrl}/${stateName}/lock" \\ + -backend-config="username=${username}" \\ + -backend-config="password=$GITLAB_ACCESS_TOKEN" \\ + -backend-config="lock_method=POST" \\ + -backend-config="unlock_method=DELETE" \\ + -backend-config="retry_wait_min=5" + `; + +describe('InitCommandModal', () => { + let wrapper; + + const propsData = { + modalId, + stateName, + }; + const provideData = { + accessTokensPath, + terraformApiUrl, + username, + }; + + const findExplanatoryText = () => wrapper.findByTestId('init-command-explanatory-text'); + const findLink = () => wrapper.findComponent(GlLink); + const findInitCommand = () => wrapper.findByTestId('terraform-init-command'); + const findCopyButton = () => wrapper.findComponent(ModalCopyButton); + + beforeEach(() => { + wrapper = shallowMountExtended(InitCommandModal, { + propsData, + provide: provideData, + stubs: { + GlSprintf, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('on rendering', () => { + it('renders the explanatory text', () => { + expect(findExplanatoryText().text()).toContain('personal access token'); + }); + + it('renders the personal access token link', () => { + expect(findLink().attributes('href')).toBe(accessTokensPath); + }); + + it('renders the init command with the username and state name prepopulated', () => { + expect(findInitCommand().text()).toContain(username); + expect(findInitCommand().text()).toContain(stateName); + }); + + it('renders the copyToClipboard button', () => { + expect(findCopyButton().exists()).toBe(true); + }); + }); + + describe('when copy button is clicked', () => { + it('copies init command to clipboard', () => { + expect(findCopyButton().props('text')).toBe(modalInfoCopyStr); + }); + }); +}); diff --git a/spec/frontend/terraform/components/states_table_actions_spec.js b/spec/frontend/terraform/components/states_table_actions_spec.js index 61f6e9f0f7b..9d28e8ce294 100644 --- a/spec/frontend/terraform/components/states_table_actions_spec.js +++ b/spec/frontend/terraform/components/states_table_actions_spec.js @@ -3,6 +3,7 @@ import { createLocalVue, 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 InitCommandModal from '~/terraform/components/init_command_modal.vue'; import StateActions from '~/terraform/components/states_table_actions.vue'; import lockStateMutation from '~/terraform/graphql/mutations/lock_state.mutation.graphql'; import removeStateMutation from '~/terraform/graphql/mutations/remove_state.mutation.graphql'; @@ -73,12 +74,14 @@ describe('StatesTableActions', () => { return wrapper.vm.$nextTick(); }; - const findActionsDropdown = () => wrapper.find(GlDropdown); + const findActionsDropdown = () => wrapper.findComponent(GlDropdown); + const findCopyBtn = () => wrapper.find('[data-testid="terraform-state-copy-init-command"]'); + const findCopyModal = () => wrapper.findComponent(InitCommandModal); const findLockBtn = () => wrapper.find('[data-testid="terraform-state-lock"]'); const findUnlockBtn = () => wrapper.find('[data-testid="terraform-state-unlock"]'); const findDownloadBtn = () => wrapper.find('[data-testid="terraform-state-download"]'); const findRemoveBtn = () => wrapper.find('[data-testid="terraform-state-remove"]'); - const findRemoveModal = () => wrapper.find(GlModal); + const findRemoveModal = () => wrapper.findComponent(GlModal); beforeEach(() => { return createComponent(); @@ -125,6 +128,25 @@ describe('StatesTableActions', () => { }); }); + describe('copy command button', () => { + it('displays a copy init command button', () => { + expect(findCopyBtn().text()).toBe('Copy Terraform init command'); + }); + + describe('when clicking the copy init command button', () => { + beforeEach(() => { + findCopyBtn().vm.$emit('click'); + + return waitForPromises(); + }); + + it('opens the modal', async () => { + expect(findCopyModal().exists()).toBe(true); + expect(findCopyModal().isVisible()).toBe(true); + }); + }); + }); + describe('download button', () => { it('displays a download button', () => { expect(findDownloadBtn().text()).toBe('Download JSON'); @@ -253,7 +275,7 @@ describe('StatesTableActions', () => { it('displays a remove modal', () => { expect(findRemoveModal().text()).toContain( - `You are about to remove the State file ${defaultProps.state.name}`, + `You are about to remove the state file ${defaultProps.state.name}`, ); }); diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js index 9b95ed6b816..4d1b0f54e42 100644 --- a/spec/frontend/test_setup.js +++ b/spec/frontend/test_setup.js @@ -3,6 +3,8 @@ import * as jqueryMatchers from 'custom-jquery-matchers'; import Vue from 'vue'; import 'jquery'; import { setGlobalDateToFakeDate } from 'helpers/fake_date'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { TEST_HOST } from 'helpers/test_constants'; import Translate from '~/vue_shared/translate'; import { getJSONFixture, loadHTMLFixture, setHTMLFixture } from './__helpers__/fixtures'; import { initializeTestTimeout } from './__helpers__/timeout'; @@ -88,8 +90,13 @@ Object.assign(global, { }, }); -// make sure that each test actually tests something -// see https://jestjs.io/docs/en/expect#expecthasassertions beforeEach(() => { + // make sure that each test actually tests something + // see https://jestjs.io/docs/en/expect#expecthasassertions expect.hasAssertions(); + + // Reset the mocked window.location. This ensures tests don't interfere with + // each other, and removes the need to tidy up if it was changed for a given + // test. + setWindowLocation(TEST_HOST); }); diff --git a/spec/frontend/token_access/token_access_spec.js b/spec/frontend/token_access/token_access_spec.js index c7323eb19fe..c4e29a52f1c 100644 --- a/spec/frontend/token_access/token_access_spec.js +++ b/spec/frontend/token_access/token_access_spec.js @@ -1,7 +1,8 @@ import { GlToggle, GlLoadingIcon } from '@gitlab/ui'; -import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; +import { createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import TokenAccess from '~/token_access/components/token_access.vue'; @@ -41,15 +42,15 @@ describe('TokenAccess component', () => { const findToggle = () => wrapper.findComponent(GlToggle); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findAddProjectBtn = () => wrapper.find('[data-testid="add-project-button"]'); - const findRemoveProjectBtn = () => wrapper.find('[data-testid="remove-project-button"]'); + const findAddProjectBtn = () => wrapper.findByRole('button', { name: 'Add project' }); + const findRemoveProjectBtn = () => wrapper.findByRole('button', { name: 'Remove access' }); const findTokenSection = () => wrapper.find('[data-testid="token-section"]'); const createMockApolloProvider = (requestHandlers) => { return createMockApollo(requestHandlers); }; - const createComponent = (requestHandlers, mountFn = shallowMount) => { + const createComponent = (requestHandlers, mountFn = shallowMountExtended) => { wrapper = mountFn(TokenAccess, { localVue, provide: { @@ -138,7 +139,7 @@ describe('TokenAccess component', () => { [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], [addProjectCIJobTokenScopeMutation, addProjectSuccessHandler], ], - mount, + mountExtended, ); await waitForPromises(); @@ -160,7 +161,7 @@ describe('TokenAccess component', () => { [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], [addProjectCIJobTokenScopeMutation, addProjectFailureHandler], ], - mount, + mountExtended, ); await waitForPromises(); @@ -181,7 +182,7 @@ describe('TokenAccess component', () => { [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], [removeProjectCIJobTokenScopeMutation, removeProjectSuccessHandler], ], - mount, + mountExtended, ); await waitForPromises(); @@ -203,7 +204,7 @@ describe('TokenAccess component', () => { [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], [removeProjectCIJobTokenScopeMutation, removeProjectFailureHandler], ], - mount, + mountExtended, ); await waitForPromises(); diff --git a/spec/frontend/tooltips/components/tooltips_spec.js b/spec/frontend/tooltips/components/tooltips_spec.js index c44918ceaf3..9b703b74a1a 100644 --- a/spec/frontend/tooltips/components/tooltips_spec.js +++ b/spec/frontend/tooltips/components/tooltips_spec.js @@ -4,7 +4,7 @@ import { useMockMutationObserver } from 'helpers/mock_dom_observer'; import Tooltips from '~/tooltips/components/tooltips.vue'; describe('tooltips/components/tooltips.vue', () => { - const { trigger: triggerMutate, observersCount } = useMockMutationObserver(); + const { trigger: triggerMutate } = useMockMutationObserver(); let wrapper; const buildWrapper = () => { @@ -211,11 +211,14 @@ describe('tooltips/components/tooltips.vue', () => { it('disconnects mutation observer on beforeDestroy', () => { buildWrapper(); wrapper.vm.addTooltips([createTooltipTarget()]); + const { observer } = wrapper.vm; + jest.spyOn(observer, 'disconnect'); - expect(observersCount()).toBe(1); + expect(observer.disconnect).toHaveBeenCalledTimes(0); wrapper.destroy(); - expect(observersCount()).toBe(0); + + expect(observer.disconnect).toHaveBeenCalledTimes(1); }); it('exposes hidden event', async () => { diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js index 13498cfb823..a17efdd61a9 100644 --- a/spec/frontend/tracking_spec.js +++ b/spec/frontend/tracking_spec.js @@ -387,11 +387,13 @@ describe('Tracking', () => { beforeEach(() => { eventSpy = jest.spyOn(Tracking, 'event'); setHTMLFixture(` - <input data-track-${term}="render" data-track-label="label1" value=1 data-track-property="_property_"/> - <span data-track-${term}="render" data-track-label="label2" data-track-value=1> - Something - </span> - <input data-track-${term}="_render_bogus_" data-track-label="label3" value="_value_" data-track-property="_property_"/> + <div data-track-${term}="click_link" data-track-label="all_nested_links"> + <input data-track-${term}="render" data-track-label="label1" value=1 data-track-property="_property_"/> + <span data-track-${term}="render" data-track-label="label2" data-track-value=1> + <a href="#" id="link">Something</a> + </span> + <input data-track-${term}="_render_bogus_" data-track-label="label3" value="_value_" data-track-property="_property_"/> + </div> `); Tracking.trackLoadEvents('_category_'); // only happens once }); @@ -417,6 +419,35 @@ describe('Tracking', () => { ], ]); }); + + describe.each` + event | actionSuffix + ${'click'} | ${''} + ${'show.bs.dropdown'} | ${'_show'} + ${'hide.bs.dropdown'} | ${'_hide'} + `(`auto-tracking $event events on nested elements`, ({ event, actionSuffix }) => { + let link; + + beforeEach(() => { + link = document.querySelector('#link'); + eventSpy.mockClear(); + }); + + it(`avoids using ancestor [data-track-${term}="render"] tracking configurations`, () => { + link.dispatchEvent(new Event(event, { bubbles: true })); + + expect(eventSpy).not.toHaveBeenCalledWith( + '_category_', + `render${actionSuffix}`, + expect.any(Object), + ); + expect(eventSpy).toHaveBeenCalledWith( + '_category_', + `click_link${actionSuffix}`, + expect.objectContaining({ label: 'all_nested_links' }), + ); + }); + }); }); describe('tracking mixin', () => { 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 f44f0b98207..a09269e869c 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,6 +1,7 @@ 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; @@ -35,6 +36,8 @@ describe('MRWidgetHeader', () => { statusPath: 'abc', }; + const findWebIdeButton = () => wrapper.findComponent(WebIdeLink); + describe('computed', () => { describe('shouldShowCommitsBehindText', () => { it('return true when there are divergedCommitsCount', () => { @@ -147,73 +150,81 @@ describe('MRWidgetHeader', () => { statusPath: 'abc', sourceProjectFullPath: 'root/gitlab-ce', targetProjectFullPath: 'gitlab-org/gitlab-ce', + gitpodEnabled: true, + showGitpodButton: true, + gitpodUrl: 'http://gitpod.localhost', }; - beforeEach(() => { + it('renders checkout branch button with modal trigger', () => { createComponent({ mr: { ...mrDefaultOptions }, }); - }); - it('renders checkout branch button with modal trigger', () => { const button = wrapper.find('.js-check-out-branch'); expect(button.text().trim()).toBe('Check out branch'); }); - it('renders web ide button', async () => { - const button = wrapper.find('.js-web-ide'); - - await nextTick(); - - expect(button.text().trim()).toBe('Open in Web IDE'); - expect(button.classes('disabled')).toBe(false); - expect(button.attributes('href')).toBe( - '/-/ide/project/root/gitlab-ce/merge_requests/1?target_project=gitlab-org%2Fgitlab-ce', - ); - }); - - it('renders web ide button in disabled state with no href', async () => { - const mr = { ...mrDefaultOptions, canPushToSourceBranch: false }; - createComponent({ mr }); - - await nextTick(); - - const link = wrapper.find('.js-web-ide'); - - expect(link.attributes('disabled')).toBe('true'); - expect(link.attributes('href')).toBeUndefined(); - }); - - it('renders web ide button with blank query string if target & source project branch', async () => { - createComponent({ mr: { ...mrDefaultOptions, targetProjectFullPath: 'root/gitlab-ce' } }); + 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(); - const button = wrapper.find('.js-web-ide'); - - expect(button.text().trim()).toBe('Open in Web IDE'); - expect(button.attributes('href')).toBe( - '/-/ide/project/root/gitlab-ce/merge_requests/1?target_project=', - ); + 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', + webIdeUrl, + }); }); - it('renders web ide button with relative URL', async () => { - gon.relative_url_root = '/gitlab'; - - createComponent({ mr: { ...mrDefaultOptions, iid: 2 } }); + it('does not render web ide button if source branch is removed', async () => { + createComponent({ mr: { ...mrDefaultOptions, sourceBranchRemoved: true } }); await nextTick(); - const button = wrapper.find('.js-web-ide'); - - expect(button.text().trim()).toBe('Open in Web IDE'); - expect(button.attributes('href')).toBe( - '/gitlab/-/ide/project/root/gitlab-ce/merge_requests/2?target_project=gitlab-org%2Fgitlab-ce', - ); + expect(findWebIdeButton().exists()).toBe(false); }); it('renders download dropdown with links', () => { + createComponent({ + mr: { ...mrDefaultOptions }, + }); + expectDownloadDropdownItems(); }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js index a879b06e858..6ea8ca10c02 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js @@ -17,7 +17,7 @@ describe('MRWidgetRelatedLinks', () => { it('returns Closes text for open merge request', () => { createComponent({ state: 'open', relatedLinks: {} }); - expect(wrapper.vm.closesText).toBe('Closes'); + expect(wrapper.vm.closesText).toBe('Closes issues'); }); it('returns correct text for closed merge request', () => { @@ -38,6 +38,7 @@ describe('MRWidgetRelatedLinks', () => { createComponent({ relatedLinks: { closing: '<a href="#">#23</a> and <a>#42</a>', + closingCount: 2, }, }); const content = wrapper @@ -45,7 +46,7 @@ describe('MRWidgetRelatedLinks', () => { .replace(/\n(\s)+/g, ' ') .trim(); - expect(content).toContain('Closes #23 and #42'); + expect(content).toContain('Closes issues #23 and #42'); expect(content).not.toContain('Mentions'); }); @@ -53,11 +54,17 @@ describe('MRWidgetRelatedLinks', () => { createComponent({ relatedLinks: { mentioned: '<a href="#">#7</a>', + mentionedCount: 1, }, }); - expect(wrapper.text().trim()).toContain('Mentions #7'); - expect(wrapper.text().trim()).not.toContain('Closes'); + const content = wrapper + .text() + .replace(/\n(\s)+/g, ' ') + .trim(); + + expect(content).toContain('Mentions issue #7'); + expect(content).not.toContain('Closes issues'); }); it('should have closing and mentioned issues at the same time', () => { @@ -65,6 +72,8 @@ describe('MRWidgetRelatedLinks', () => { relatedLinks: { closing: '<a href="#">#7</a>', mentioned: '<a href="#">#23</a> and <a>#42</a>', + closingCount: 1, + mentionedCount: 2, }, }); const content = wrapper @@ -72,8 +81,8 @@ describe('MRWidgetRelatedLinks', () => { .replace(/\n(\s)+/g, ' ') .trim(); - expect(content).toContain('Closes #7'); - expect(content).toContain('Mentions #23 and #42'); + expect(content).toContain('Closes issue #7'); + expect(content).toContain('Mentions issues #23 and #42'); }); it('should have assing issues link', () => { diff --git a/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap b/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap index ac20487c55f..5981d2d7849 100644 --- a/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap +++ b/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap @@ -4,8 +4,10 @@ exports[`MRWidgetAutoMergeEnabled when graphql is disabled template should have <div class="mr-widget-body media" > - <status-icon-stub - status="success" + <gl-icon-stub + class="gl-text-blue-500 gl-mr-3 gl-mt-1" + name="status_scheduled" + size="24" /> <div @@ -17,55 +19,31 @@ exports[`MRWidgetAutoMergeEnabled when graphql is disabled template should have <span class="gl-mr-3" > - <span - class="js-status-text-before-author" - data-testid="beforeStatusText" - > - Set by - </span> - - <mr-widget-author-stub - author="[object Object]" - showauthorname="true" + <gl-sprintf-stub + data-testid="statusText" + message="Set by %{merge_author} to be merged automatically when the pipeline succeeds" /> - - <span - class="js-status-text-after-author" - data-testid="afterStatusText" - > - to be merged automatically when the pipeline succeeds - </span> </span> - <a - class="btn btn-sm btn-default js-cancel-auto-merge" + <gl-button-stub + buttontextclasses="" + category="primary" + class="js-cancel-auto-merge" data-qa-selector="cancel_auto_merge_button" data-testid="cancelAutomaticMergeButton" - href="#" - role="button" + icon="" + size="small" + variant="default" > - <!----> - Cancel + Cancel auto-merge - </a> + </gl-button-stub> </h4> <section class="mr-info-list" > - <p> - - The changes will be merged into - - <a - class="label-branch" - href="/foo/bar" - > - foo - </a> - </p> - <p class="gl-display-flex" > @@ -75,17 +53,19 @@ exports[`MRWidgetAutoMergeEnabled when graphql is disabled template should have The source branch will not be deleted </span> - <a - class="btn btn-sm btn-default js-remove-source-branch" + <gl-button-stub + buttontextclasses="" + category="primary" + class="js-remove-source-branch" data-testid="removeSourceBranchButton" - href="#" - role="button" + icon="" + size="small" + variant="default" > - <!----> Delete source branch - </a> + </gl-button-stub> </p> </section> </div> @@ -96,8 +76,10 @@ exports[`MRWidgetAutoMergeEnabled when graphql is enabled template should have c <div class="mr-widget-body media" > - <status-icon-stub - status="success" + <gl-icon-stub + class="gl-text-blue-500 gl-mr-3 gl-mt-1" + name="status_scheduled" + size="24" /> <div @@ -109,55 +91,31 @@ exports[`MRWidgetAutoMergeEnabled when graphql is enabled template should have c <span class="gl-mr-3" > - <span - class="js-status-text-before-author" - data-testid="beforeStatusText" - > - Set by - </span> - - <mr-widget-author-stub - author="[object Object]" - showauthorname="true" + <gl-sprintf-stub + data-testid="statusText" + message="Set by %{merge_author} to be merged automatically when the pipeline succeeds" /> - - <span - class="js-status-text-after-author" - data-testid="afterStatusText" - > - to be merged automatically when the pipeline succeeds - </span> </span> - <a - class="btn btn-sm btn-default js-cancel-auto-merge" + <gl-button-stub + buttontextclasses="" + category="primary" + class="js-cancel-auto-merge" data-qa-selector="cancel_auto_merge_button" data-testid="cancelAutomaticMergeButton" - href="#" - role="button" + icon="" + size="small" + variant="default" > - <!----> - Cancel + Cancel auto-merge - </a> + </gl-button-stub> </h4> <section class="mr-info-list" > - <p> - - The changes will be merged into - - <a - class="label-branch" - href="/foo/bar" - > - foo - </a> - </p> - <p class="gl-display-flex" > @@ -167,17 +125,19 @@ exports[`MRWidgetAutoMergeEnabled when graphql is enabled template should have c The source branch will not be deleted </span> - <a - class="btn btn-sm btn-default js-remove-source-branch" + <gl-button-stub + buttontextclasses="" + category="primary" + class="js-remove-source-branch" data-testid="removeSourceBranchButton" - href="#" - role="button" + icon="" + size="small" + variant="default" > - <!----> Delete source branch - </a> + </gl-button-stub> </p> </section> </div> diff --git a/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_ready_to_merge_spec.js.snap b/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_ready_to_merge_spec.js.snap deleted file mode 100644 index cef1dff3335..00000000000 --- a/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_ready_to_merge_spec.js.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ReadyToMerge with a mismatched SHA warns the user to refresh to review 1`] = `"<gl-sprintf-stub message=\\"New changes were added. %{linkStart}Reload the page to review them%{linkEnd}\\"></gl-sprintf-stub>"`; diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js index 0110a76e722..4c1534574f5 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js @@ -72,6 +72,8 @@ const defaultMrProps = () => ({ autoMergeStrategy: MWPS_MERGE_STRATEGY, }); +const getStatusText = () => wrapper.findByTestId('statusText').attributes('message'); + describe('MRWidgetAutoMergeEnabled', () => { let oldWindowGl; @@ -167,30 +169,6 @@ describe('MRWidgetAutoMergeEnabled', () => { }); }); - describe('statusTextBeforeAuthor', () => { - it('should return "Set by" if the MWPS is selected', () => { - factory({ - ...defaultMrProps(), - autoMergeStrategy: MWPS_MERGE_STRATEGY, - }); - - expect(wrapper.findByTestId('beforeStatusText').text()).toBe('Set by'); - }); - }); - - describe('statusTextAfterAuthor', () => { - it('should return "to be merged automatically..." if MWPS is selected', () => { - factory({ - ...defaultMrProps(), - autoMergeStrategy: MWPS_MERGE_STRATEGY, - }); - - expect(wrapper.findByTestId('afterStatusText').text()).toBe( - 'to be merged automatically when the pipeline succeeds', - ); - }); - }); - describe('cancelButtonText', () => { it('should return "Cancel" if MWPS is selected', () => { factory({ @@ -198,7 +176,9 @@ describe('MRWidgetAutoMergeEnabled', () => { autoMergeStrategy: MWPS_MERGE_STRATEGY, }); - expect(wrapper.findByTestId('cancelAutomaticMergeButton').text()).toBe('Cancel'); + expect(wrapper.findByTestId('cancelAutomaticMergeButton').text()).toBe( + 'Cancel auto-merge', + ); }); }); }); @@ -279,7 +259,7 @@ describe('MRWidgetAutoMergeEnabled', () => { await nextTick(); - expect(wrapper.find('.js-cancel-auto-merge').attributes('disabled')).toBe('disabled'); + expect(wrapper.find('.js-cancel-auto-merge').props('loading')).toBe(true); }); it('should show source branch will be deleted text when it source branch set to remove', () => { @@ -313,7 +293,7 @@ describe('MRWidgetAutoMergeEnabled', () => { await nextTick(); - expect(wrapper.find('.js-remove-source-branch').attributes('disabled')).toBe('disabled'); + expect(wrapper.find('.js-remove-source-branch').props('loading')).toBe(true); }); it('should render the status text as "...to merged automatically" if MWPS is selected', () => { @@ -322,9 +302,9 @@ describe('MRWidgetAutoMergeEnabled', () => { autoMergeStrategy: MWPS_MERGE_STRATEGY, }); - const statusText = trimText(wrapper.find('.js-status-text-after-author').text()); - - expect(statusText).toBe('to be merged automatically when the pipeline succeeds'); + expect(getStatusText()).toBe( + 'Set by %{merge_author} to be merged automatically when the pipeline succeeds', + ); }); it('should render the cancel button as "Cancel" if MWPS is selected', () => { @@ -335,7 +315,7 @@ describe('MRWidgetAutoMergeEnabled', () => { const cancelButtonText = trimText(wrapper.find('.js-cancel-auto-merge').text()); - expect(cancelButtonText).toBe('Cancel'); + expect(cancelButtonText).toBe('Cancel auto-merge'); }); }); }); 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 cd77d442cbf..e41fb815c8d 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 @@ -1,4 +1,3 @@ -import { GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import simplePoll from '~/lib/utils/simple_poll'; @@ -782,26 +781,4 @@ describe('ReadyToMerge', () => { }); }); }); - - describe('with a mismatched SHA', () => { - const findMismatchShaBlock = () => wrapper.find('.js-sha-mismatch'); - const findMismatchShaTextBlock = () => findMismatchShaBlock().find(GlSprintf); - - beforeEach(() => { - createComponent({ - mr: { - isSHAMismatch: true, - mergeRequestDiffsPath: '/merge_requests/1/diffs', - }, - }); - }); - - it('displays a warning message', () => { - expect(findMismatchShaBlock().exists()).toBe(true); - }); - - it('warns the user to refresh to review', () => { - expect(findMismatchShaTextBlock().element.outerHTML).toMatchSnapshot(); - }); - }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js index ef6a9b1e8fc..2a343997cf5 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js @@ -1,25 +1,42 @@ -import Vue from 'vue'; -import { removeBreakLine } from 'helpers/text_helper'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { mount } from '@vue/test-utils'; import ShaMismatch from '~/vue_merge_request_widget/components/states/sha_mismatch.vue'; +import { I18N_SHA_MISMATCH } from '~/vue_merge_request_widget/i18n'; + +function createComponent({ path = '' } = {}) { + return mount(ShaMismatch, { + propsData: { + mr: { + mergeRequestDiffsPath: path, + }, + }, + }); +} describe('ShaMismatch', () => { - let vm; + let wrapper; + const findActionButton = () => wrapper.find('[data-testid="action-button"]'); beforeEach(() => { - const Component = Vue.extend(ShaMismatch); - vm = mountComponent(Component); + wrapper = createComponent(); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + }); + + it('should render warning message', () => { + expect(wrapper.element.innerText).toContain(I18N_SHA_MISMATCH.warningMessage); }); - it('should render information message', () => { - expect(vm.$el.querySelector('button').disabled).toEqual(true); + it('action button should have correct label', () => { + expect(findActionButton().text()).toBe(I18N_SHA_MISMATCH.actionButtonLabel); + }); + + it('action button should link to the diff path', () => { + const DIFF_PATH = '/gitlab-org/gitlab-test/-/merge_requests/6/diffs'; + + wrapper = createComponent({ path: DIFF_PATH }); - expect(removeBreakLine(vm.$el.textContent).trim()).toContain( - 'The source branch HEAD has recently changed. Please reload the page and review the changes before merging', - ); + expect(findActionButton().attributes('href')).toBe(DIFF_PATH); }); }); diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js index 49783560bf2..31ade17e50a 100644 --- a/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js +++ b/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js @@ -45,7 +45,6 @@ describe('DeploymentAction component', () => { propsData: { computedDeploymentStatus: CREATED, deployment: deploymentMockData, - showVisualReviewApp: false, }, }); }); @@ -64,7 +63,6 @@ describe('DeploymentAction component', () => { ...deploymentMockData, stop_url: null, }, - showVisualReviewApp: false, }, }); }); @@ -115,7 +113,6 @@ describe('DeploymentAction component', () => { ...deploymentMockData, details: displayConditionChanges, }, - showVisualReviewApp: false, }, }); }); diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_list_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_list_spec.js index dd0c483b28a..948d7ebab5e 100644 --- a/spec/frontend/vue_mr_widget/deployment/deployment_list_spec.js +++ b/spec/frontend/vue_mr_widget/deployment/deployment_list_spec.js @@ -7,7 +7,6 @@ import MrCollapsibleExtension from '~/vue_merge_request_widget/components/mr_col import { mockStore } from '../mock_data'; const DEFAULT_PROPS = { - showVisualReviewAppLink: false, hasDeploymentMetrics: false, deploymentClass: 'js-pre-deployment', }; @@ -46,7 +45,6 @@ describe('~/vue_merge_request_widget/components/deployment/deployment_list.vue', ([deploymentWrapper, deployment]) => { expect(deploymentWrapper.props('deployment')).toEqual(deployment); expect(deploymentWrapper.props()).toMatchObject({ - showVisualReviewApp: DEFAULT_PROPS.showVisualReviewAppLink, showMetrics: DEFAULT_PROPS.hasDeploymentMetrics, }); expect(deploymentWrapper.classes(DEFAULT_PROPS.deploymentClass)).toBe(true); @@ -87,10 +85,6 @@ describe('~/vue_merge_request_widget/components/deployment/deployment_list.vue', zip(deploymentWrappers.wrappers, propsData.deployments).forEach( ([deploymentWrapper, deployment]) => { expect(deploymentWrapper.props('deployment')).toEqual(deployment); - expect(deploymentWrapper.props()).toMatchObject({ - showVisualReviewApp: DEFAULT_PROPS.showVisualReviewAppLink, - showMetrics: DEFAULT_PROPS.hasDeploymentMetrics, - }); expect(deploymentWrapper.classes(DEFAULT_PROPS.deploymentClass)).toBe(true); expect(deploymentWrapper.text()).toEqual(expect.any(String)); expect(deploymentWrapper.text()).not.toBe(''); diff --git a/spec/frontend/vue_mr_widget/mock_data.js b/spec/frontend/vue_mr_widget/mock_data.js index e6f1e15d718..f356f6fb5bf 100644 --- a/spec/frontend/vue_mr_widget/mock_data.js +++ b/spec/frontend/vue_mr_widget/mock_data.js @@ -234,14 +234,11 @@ export default { can_revert_on_current_merge_request: true, can_cherry_pick_on_current_merge_request: true, }, - codeclimate: { - head_path: 'head.json', - base_path: 'base.json', - }, blob_path: { base_path: 'blob_path', head_path: 'blob_path', }, + codequality_reports_path: 'codequality_reports.json', codequality_help_path: 'code_quality.html', target_branch_path: '/root/acets-app/branches/main', source_branch_path: '/root/acets-app/branches/daaaa', @@ -284,6 +281,9 @@ export default { security_reports_docs_path: 'security-reports-docs-path', sast_comparison_path: '/sast_comparison_path', secret_scanning_comparison_path: '/secret_scanning_comparison_path', + gitpod_enabled: true, + show_gitpod_button: true, + gitpod_url: 'http://gitpod.localhost', }; export const mockStore = { diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js index 9da370747fc..c50cf7cb076 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -12,7 +12,7 @@ import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/consta import eventHub from '~/vue_merge_request_widget/event_hub'; import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue'; import { stateKey } from '~/vue_merge_request_widget/stores/state_maps'; -import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql'; +import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql'; import { faviconDataUrl, overlayDataUrl } from '../lib/utils/mock_data'; import mockData from './mock_data'; @@ -80,14 +80,15 @@ describe('MrWidgetOptions', () => { describe('computed', () => { describe('componentName', () => { - it('should return merged component', () => { - expect(wrapper.vm.componentName).toEqual('mr-widget-merged'); - }); - - it('should return conflicts component', () => { - wrapper.vm.mr.state = 'conflicts'; - - expect(wrapper.vm.componentName).toEqual('mr-widget-conflicts'); + it.each` + state | componentName + ${'merged'} | ${'mr-widget-merged'} + ${'conflicts'} | ${'mr-widget-conflicts'} + ${'shaMismatch'} | ${'sha-mismatch'} + `('should translate $state into $componentName', ({ state, componentName }) => { + wrapper.vm.mr.state = state; + + expect(wrapper.vm.componentName).toEqual(componentName); }); }); diff --git a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js index cfc846075ea..bf0179aa425 100644 --- a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js +++ b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js @@ -10,6 +10,14 @@ describe('MergeRequestStore', () => { store = new MergeRequestStore(mockData); }); + it('should initialize gitpod attributes', () => { + expect(store).toMatchObject({ + gitpodEnabled: mockData.gitpod_enabled, + showGitpodButton: mockData.show_gitpod_button, + gitpodUrl: mockData.gitpod_url, + }); + }); + describe('setData', () => { it('should set isSHAMismatch when the diff SHA changes', () => { store.setData({ ...mockData, diff_head_sha: 'a-different-string' }); diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js new file mode 100644 index 00000000000..016fe1f131e --- /dev/null +++ b/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js @@ -0,0 +1,97 @@ +import { GlDropdown, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; + +import DropdownWidget from '~/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue'; + +describe('DropdownWidget component', () => { + let wrapper; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItems = () => wrapper.findAll(GlDropdownItem); + const findSearch = () => wrapper.findComponent(GlSearchBoxByType); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(DropdownWidget, { + propsData: { + options: [ + { + id: '1', + title: 'Option 1', + }, + { + id: '2', + title: 'Option 2', + }, + ], + ...props, + }, + stubs: { + GlDropdown, + }, + }); + + // We need to mock out `showDropdown` which + // invokes `show` method of BDropdown used inside GlDropdown. + // Context: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/54895#note_524281679 + jest.spyOn(wrapper.vm, 'showDropdown').mockImplementation(); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('passes default selectText prop to dropdown', () => { + expect(findDropdown().props('text')).toBe('Select'); + }); + + describe('when dropdown is open', () => { + beforeEach(async () => { + findDropdown().vm.$emit('show'); + await wrapper.vm.$nextTick(); + }); + + it('emits search event when typing in search box', () => { + const searchTerm = 'searchTerm'; + findSearch().vm.$emit('input', searchTerm); + + expect(wrapper.emitted('set-search')).toEqual([[searchTerm]]); + }); + + it('renders one selectable item per passed option', async () => { + expect(findDropdownItems()).toHaveLength(2); + }); + + it('emits set-option event when clicking on an option', async () => { + wrapper + .findAll('[data-testid="unselected-option"]') + .at(1) + .vm.$emit('click', new Event('click')); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('set-option')).toEqual([[wrapper.props().options[1]]]); + }); + }); + + describe('when options are users', () => { + const mockUser = { + id: 1, + name: 'User name', + username: 'username', + avatarUrl: 'foo/bar', + }; + + beforeEach(() => { + createComponent({ props: { options: [mockUser] } }); + }); + + it('passes user related props to dropdown item', () => { + expect(findDropdownItems().at(0).props('avatarUrl')).toBe(mockUser.avatarUrl); + expect(findDropdownItems().at(0).props('secondaryText')).toBe(mockUser.username); + }); + }); +}); 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 9fa9d35e3e2..8e931aebfe0 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 @@ -32,6 +32,9 @@ jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', ( stripQuotes: jest.requireActual( '~/vue_shared/components/filtered_search_bar/filtered_search_utils', ).stripQuotes, + filterEmptySearchTerm: jest.requireActual( + '~/vue_shared/components/filtered_search_bar/filtered_search_utils', + ).filterEmptySearchTerm, })); const createComponent = ({ 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 74f579e77ed..d3e1bfef561 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 @@ -86,7 +86,7 @@ describe('AuthorToken', () => { }); describe('methods', () => { - describe('fetchAuthorBySearchTerm', () => { + describe('fetchAuthors', () => { beforeEach(() => { wrapper = createComponent(); }); @@ -155,7 +155,7 @@ describe('AuthorToken', () => { expect(baseTokenEl.exists()).toBe(true); expect(baseTokenEl.props()).toMatchObject({ suggestions: mockAuthors, - fnActiveTokenValue: wrapper.vm.getActiveAuthor, + getActiveTokenValue: wrapper.vm.getActiveAuthor, }); }); 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 cd6ffd679d0..eb1dbed52cc 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 @@ -5,7 +5,7 @@ import { mockLabels, } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; -import { DEFAULT_LABELS } from '~/vue_shared/components/filtered_search_bar/constants'; +import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed, @@ -51,9 +51,8 @@ const mockProps = { active: false, suggestions: [], suggestionsLoading: false, - defaultSuggestions: DEFAULT_LABELS, + defaultSuggestions: DEFAULT_NONE_ANY, recentSuggestionsStorageKey: mockStorageKey, - fnCurrentTokenValue: jest.fn(), }; function createComponent({ @@ -99,31 +98,20 @@ describe('BaseToken', () => { }); describe('computed', () => { - describe('currentTokenValue', () => { - it('calls `fnCurrentTokenValue` when it is provided', () => { - // We're disabling lint to trigger computed prop execution for this test. - // eslint-disable-next-line no-unused-vars - const { currentTokenValue } = wrapper.vm; - - expect(wrapper.vm.fnCurrentTokenValue).toHaveBeenCalledWith(`"${mockRegularLabel.title}"`); - }); - }); - describe('activeTokenValue', () => { - it('calls `fnActiveTokenValue` when it is provided', async () => { - const mockFnActiveTokenValue = jest.fn(); + it('calls `getActiveTokenValue` when it is provided', async () => { + const mockGetActiveTokenValue = jest.fn(); wrapper.setProps({ - fnActiveTokenValue: mockFnActiveTokenValue, - fnCurrentTokenValue: undefined, + getActiveTokenValue: mockGetActiveTokenValue, }); await wrapper.vm.$nextTick(); - expect(mockFnActiveTokenValue).toHaveBeenCalledTimes(1); - expect(mockFnActiveTokenValue).toHaveBeenCalledWith( + expect(mockGetActiveTokenValue).toHaveBeenCalledTimes(1); + expect(mockGetActiveTokenValue).toHaveBeenCalledWith( mockLabels, - `"${mockRegularLabel.title.toLowerCase()}"`, + `"${mockRegularLabel.title}"`, ); }); }); 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 331c9c2c14d..09eac636cae 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 @@ -61,40 +61,16 @@ describe('BranchToken', () => { wrapper.destroy(); }); - describe('computed', () => { - beforeEach(async () => { - wrapper = createComponent({ value: { data: mockBranches[0].name } }); - - wrapper.setData({ - branches: mockBranches, - }); - - await wrapper.vm.$nextTick(); - }); - - describe('currentValue', () => { - it('returns lowercase string for `value.data`', () => { - expect(wrapper.vm.currentValue).toBe('main'); - }); - }); - - describe('activeBranch', () => { - it('returns object for currently present `value.data`', () => { - expect(wrapper.vm.activeBranch).toEqual(mockBranches[0]); - }); - }); - }); - describe('methods', () => { beforeEach(() => { wrapper = createComponent(); }); - describe('fetchBranchBySearchTerm', () => { + describe('fetchBranches', () => { it('calls `config.fetchBranches` with provided searchTerm param', () => { jest.spyOn(wrapper.vm.config, 'fetchBranches'); - wrapper.vm.fetchBranchBySearchTerm('foo'); + wrapper.vm.fetchBranches('foo'); expect(wrapper.vm.config.fetchBranches).toHaveBeenCalledWith('foo'); }); @@ -102,7 +78,7 @@ describe('BranchToken', () => { it('sets response to `branches` when request is succesful', () => { jest.spyOn(wrapper.vm.config, 'fetchBranches').mockResolvedValue({ data: mockBranches }); - wrapper.vm.fetchBranchBySearchTerm('foo'); + wrapper.vm.fetchBranches('foo'); return waitForPromises().then(() => { expect(wrapper.vm.branches).toEqual(mockBranches); @@ -112,7 +88,7 @@ describe('BranchToken', () => { it('calls `createFlash` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchBranches').mockRejectedValue({}); - wrapper.vm.fetchBranchBySearchTerm('foo'); + wrapper.vm.fetchBranches('foo'); return waitForPromises().then(() => { expect(createFlash).toHaveBeenCalledWith({ @@ -124,7 +100,7 @@ describe('BranchToken', () => { it('sets `loading` to false when request completes', () => { jest.spyOn(wrapper.vm.config, 'fetchBranches').mockRejectedValue({}); - wrapper.vm.fetchBranchBySearchTerm('foo'); + wrapper.vm.fetchBranches('foo'); return waitForPromises().then(() => { expect(wrapper.vm.loading).toBe(false); 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 778a214f97e..c2d61fd9f05 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 @@ -67,40 +67,16 @@ describe('EmojiToken', () => { wrapper.destroy(); }); - describe('computed', () => { - beforeEach(async () => { - wrapper = createComponent({ value: { data: mockEmojis[0].name } }); - - wrapper.setData({ - emojis: mockEmojis, - }); - - await wrapper.vm.$nextTick(); - }); - - describe('currentValue', () => { - it('returns lowercase string for `value.data`', () => { - expect(wrapper.vm.currentValue).toBe(mockEmojis[0].name); - }); - }); - - describe('activeEmoji', () => { - it('returns object for currently present `value.data`', () => { - expect(wrapper.vm.activeEmoji).toEqual(mockEmojis[0]); - }); - }); - }); - describe('methods', () => { beforeEach(() => { wrapper = createComponent(); }); - describe('fetchEmojiBySearchTerm', () => { + describe('fetchEmojis', () => { it('calls `config.fetchEmojis` with provided searchTerm param', () => { jest.spyOn(wrapper.vm.config, 'fetchEmojis'); - wrapper.vm.fetchEmojiBySearchTerm('foo'); + wrapper.vm.fetchEmojis('foo'); expect(wrapper.vm.config.fetchEmojis).toHaveBeenCalledWith('foo'); }); @@ -108,7 +84,7 @@ describe('EmojiToken', () => { it('sets response to `emojis` when request is successful', () => { jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockResolvedValue(mockEmojis); - wrapper.vm.fetchEmojiBySearchTerm('foo'); + wrapper.vm.fetchEmojis('foo'); return waitForPromises().then(() => { expect(wrapper.vm.emojis).toEqual(mockEmojis); @@ -118,7 +94,7 @@ describe('EmojiToken', () => { it('calls `createFlash` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({}); - wrapper.vm.fetchEmojiBySearchTerm('foo'); + wrapper.vm.fetchEmojis('foo'); return waitForPromises().then(() => { expect(createFlash).toHaveBeenCalledWith({ @@ -130,7 +106,7 @@ describe('EmojiToken', () => { it('sets `loading` to false when request completes', () => { jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({}); - wrapper.vm.fetchEmojiBySearchTerm('foo'); + wrapper.vm.fetchEmojis('foo'); return waitForPromises().then(() => { expect(wrapper.vm.loading).toBe(false); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js index bd654c5a9cb..a609aaa1c4e 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js @@ -1,5 +1,6 @@ import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import IterationToken from '~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue'; import { mockIterationToken } from '../mock_data'; @@ -13,6 +14,7 @@ describe('IterationToken', () => { const createComponent = ({ config = mockIterationToken, value = { data: '' } } = {}) => mount(IterationToken, { propsData: { + active: false, config, value, }, @@ -69,7 +71,7 @@ describe('IterationToken', () => { config: { ...mockIterationToken, fetchIterations: fetchIterationsSpy }, }); - await wrapper.vm.$nextTick(); + await waitForPromises(); expect(createFlash).toHaveBeenCalledWith({ message: 'There was a problem fetching iterations.', 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 ec9458f64d2..a348344b9dd 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 @@ -13,10 +13,7 @@ import { import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import { - DEFAULT_LABELS, - DEFAULT_NONE_ANY, -} from '~/vue_shared/components/filtered_search_bar/constants'; +import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; @@ -98,11 +95,11 @@ describe('LabelToken', () => { }); }); - describe('fetchLabelBySearchTerm', () => { + describe('fetchLabels', () => { it('calls `config.fetchLabels` with provided searchTerm param', () => { jest.spyOn(wrapper.vm.config, 'fetchLabels'); - wrapper.vm.fetchLabelBySearchTerm('foo'); + wrapper.vm.fetchLabels('foo'); expect(wrapper.vm.config.fetchLabels).toHaveBeenCalledWith('foo'); }); @@ -110,7 +107,7 @@ describe('LabelToken', () => { it('sets response to `labels` when request is succesful', () => { jest.spyOn(wrapper.vm.config, 'fetchLabels').mockResolvedValue(mockLabels); - wrapper.vm.fetchLabelBySearchTerm('foo'); + wrapper.vm.fetchLabels('foo'); return waitForPromises().then(() => { expect(wrapper.vm.labels).toEqual(mockLabels); @@ -120,7 +117,7 @@ describe('LabelToken', () => { it('calls `createFlash` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchLabels').mockRejectedValue({}); - wrapper.vm.fetchLabelBySearchTerm('foo'); + wrapper.vm.fetchLabels('foo'); return waitForPromises().then(() => { expect(createFlash).toHaveBeenCalledWith({ @@ -132,7 +129,7 @@ describe('LabelToken', () => { it('sets `loading` to false when request completes', () => { jest.spyOn(wrapper.vm.config, 'fetchLabels').mockRejectedValue({}); - wrapper.vm.fetchLabelBySearchTerm('foo'); + wrapper.vm.fetchLabels('foo'); return waitForPromises().then(() => { expect(wrapper.vm.loading).toBe(false); @@ -160,7 +157,7 @@ describe('LabelToken', () => { expect(baseTokenEl.exists()).toBe(true); expect(baseTokenEl.props()).toMatchObject({ suggestions: mockLabels, - fnActiveTokenValue: wrapper.vm.getActiveLabel, + getActiveTokenValue: wrapper.vm.getActiveLabel, }); }); @@ -208,7 +205,7 @@ describe('LabelToken', () => { expect(wrapper.find(GlDropdownDivider).exists()).toBe(false); }); - it('renders `DEFAULT_LABELS` as default suggestions', () => { + it('renders `DEFAULT_NONE_ANY` as default suggestions', () => { wrapper = createComponent({ active: true, config: { ...mockLabelToken }, @@ -220,8 +217,8 @@ describe('LabelToken', () => { const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); - expect(suggestions).toHaveLength(DEFAULT_LABELS.length); - DEFAULT_LABELS.forEach((label, index) => { + expect(suggestions).toHaveLength(DEFAULT_NONE_ANY.length); + DEFAULT_NONE_ANY.forEach((label, index) => { expect(suggestions.at(index).text()).toBe(label.text); }); }); 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 74ceb03bb96..529844817d3 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 @@ -14,12 +14,7 @@ import { sortMilestonesByDueDate } from '~/milestones/milestone_utils'; import { DEFAULT_MILESTONES } from '~/vue_shared/components/filtered_search_bar/constants'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; -import { - mockMilestoneToken, - mockMilestones, - mockRegularMilestone, - mockEscapedMilestone, -} from '../mock_data'; +import { mockMilestoneToken, mockMilestones, mockRegularMilestone } from '../mock_data'; jest.mock('~/flash'); jest.mock('~/milestones/milestone_utils'); @@ -70,37 +65,12 @@ describe('MilestoneToken', () => { wrapper.destroy(); }); - describe('computed', () => { - beforeEach(async () => { - // Milestone title with spaces is always enclosed in quotations by component. - wrapper = createComponent({ value: { data: `"${mockEscapedMilestone.title}"` } }); - - wrapper.setData({ - milestones: mockMilestones, - }); - - await wrapper.vm.$nextTick(); - }); - - describe('currentValue', () => { - it('returns lowercase string for `value.data`', () => { - expect(wrapper.vm.currentValue).toBe('"5.0 rc1"'); - }); - }); - - describe('activeMilestone', () => { - it('returns object for currently present `value.data`', () => { - expect(wrapper.vm.activeMilestone).toEqual(mockEscapedMilestone); - }); - }); - }); - describe('methods', () => { - describe('fetchMilestoneBySearchTerm', () => { + describe('fetchMilestones', () => { it('calls `config.fetchMilestones` with provided searchTerm param', () => { jest.spyOn(wrapper.vm.config, 'fetchMilestones'); - wrapper.vm.fetchMilestoneBySearchTerm('foo'); + wrapper.vm.fetchMilestones('foo'); expect(wrapper.vm.config.fetchMilestones).toHaveBeenCalledWith('foo'); }); @@ -110,7 +80,7 @@ describe('MilestoneToken', () => { data: mockMilestones, }); - wrapper.vm.fetchMilestoneBySearchTerm(); + wrapper.vm.fetchMilestones(); return waitForPromises().then(() => { expect(wrapper.vm.milestones).toEqual(mockMilestones); @@ -121,7 +91,7 @@ describe('MilestoneToken', () => { it('calls `createFlash` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockRejectedValue({}); - wrapper.vm.fetchMilestoneBySearchTerm('foo'); + wrapper.vm.fetchMilestones('foo'); return waitForPromises().then(() => { expect(createFlash).toHaveBeenCalledWith({ @@ -133,7 +103,7 @@ describe('MilestoneToken', () => { it('sets `loading` to false when request completes', () => { jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockRejectedValue({}); - wrapper.vm.fetchMilestoneBySearchTerm('foo'); + wrapper.vm.fetchMilestones('foo'); return waitForPromises().then(() => { expect(wrapper.vm.loading).toBe(false); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js index 9a72be636cd..e788c742736 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js @@ -12,6 +12,7 @@ describe('WeightToken', () => { const createComponent = ({ config = mockWeightToken, value = { data: '' } } = {}) => mount(WeightToken, { propsData: { + active: false, config, value, }, diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js index 8738924f717..6ab828efebe 100644 --- a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js +++ b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js @@ -144,23 +144,6 @@ describe('RelatedIssuableItem', () => { expect(wrapper.find(IssueDueDate).props('closed')).toBe(true); }); - - it('should not contain the `.text-danger` css class for overdue issue that is closed', async () => { - mountComponent({ - props: { - ...props, - closedAt: '2018-12-01T00:00:00.00Z', - }, - }); - await wrapper.vm.$nextTick(); - - expect(wrapper.find(IssueDueDate).find('.board-card-info-icon').classes('text-danger')).toBe( - false, - ); - expect(wrapper.find(IssueDueDate).find('.board-card-info-text').classes('text-danger')).toBe( - false, - ); - }); }); describe('token assignees', () => { diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js index 786dfabb990..19e4f2d8c92 100644 --- a/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js +++ b/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js @@ -1,3 +1,4 @@ +import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import ToolbarButton from '~/vue_shared/components/markdown/toolbar_button.vue'; @@ -25,7 +26,7 @@ describe('toolbar_button', () => { }); const getButtonShortcutsAttr = () => { - return wrapper.find('button').attributes('data-md-shortcuts'); + return wrapper.find(GlButton).attributes('data-md-shortcuts'); }; describe('keyboard shortcuts', () => { diff --git a/spec/frontend/vue_shared/components/papa_parse_alert_spec.js b/spec/frontend/vue_shared/components/papa_parse_alert_spec.js new file mode 100644 index 00000000000..9be2de17d01 --- /dev/null +++ b/spec/frontend/vue_shared/components/papa_parse_alert_spec.js @@ -0,0 +1,44 @@ +import { GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import PapaParseAlert from '~/vue_shared/components/papa_parse_alert.vue'; + +describe('app/assets/javascripts/vue_shared/components/papa_parse_alert.vue', () => { + let wrapper; + + const createComponent = ({ errorMessages } = {}) => { + wrapper = shallowMount(PapaParseAlert, { + propsData: { + papaParseErrors: errorMessages, + }, + }); + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should render alert with correct props', async () => { + createComponent({ errorMessages: [{ code: 'MissingQuotes' }] }); + await nextTick; + + expect(findAlert().props()).toMatchObject({ + variant: 'danger', + }); + expect(findAlert().text()).toContain( + 'Failed to render the CSV file for the following reasons:', + ); + expect(findAlert().text()).toContain('Quoted field unterminated'); + }); + + it('should render original message if no translation available', async () => { + createComponent({ + errorMessages: [{ code: 'NotDefined', message: 'Error code is undefined' }], + }); + await nextTick; + + expect(findAlert().text()).toContain('Error code is undefined'); + }); +}); diff --git a/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js b/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js index 395c74dcba6..71ebe561def 100644 --- a/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js +++ b/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js @@ -13,7 +13,7 @@ import { REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION, } from '~/vue_shared/security_reports/constants'; -import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql'; +import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql'; jest.mock('~/flash'); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js index 06ea88c09a0..a1942e59571 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -116,6 +116,8 @@ describe('DropdownContentsLabelsView', () => { }); describe('methods', () => { + const fakePreventDefault = jest.fn(); + describe('isLabelSelected', () => { it('returns true when provided `label` param is one of the selected labels', () => { expect(wrapper.vm.isLabelSelected(mockRegularLabel)).toBe(true); @@ -191,9 +193,11 @@ describe('DropdownContentsLabelsView', () => { wrapper.vm.handleKeyDown({ keyCode: ENTER_KEY_CODE, + preventDefault: fakePreventDefault, }); expect(wrapper.vm.searchKey).toBe(''); + expect(fakePreventDefault).toHaveBeenCalled(); }); it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => { @@ -204,6 +208,7 @@ describe('DropdownContentsLabelsView', () => { wrapper.vm.handleKeyDown({ keyCode: ENTER_KEY_CODE, + preventDefault: fakePreventDefault, }); expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([ diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js index be849789667..bc1ec8b812b 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js @@ -238,4 +238,14 @@ describe('LabelsSelectRoot', () => { expect(store.dispatch).not.toHaveBeenCalled(); }); + + it('calls updateLabelsSetState after selected labels were updated', async () => { + createComponent(); + + jest.spyOn(store, 'dispatch').mockResolvedValue(); + await wrapper.setProps({ selectedLabels: [] }); + jest.advanceTimersByTime(100); + + expect(store.dispatch).toHaveBeenCalledWith('updateLabelsSetState'); + }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js index 46ade5d5857..2e4c056df61 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js @@ -214,7 +214,7 @@ describe('LabelsSelect Actions', () => { }); describe('on success', () => { - it('dispatches `requestCreateLabel`, `receiveCreateLabelSuccess` & `toggleDropdownContentsCreateView` actions', (done) => { + it('dispatches `requestCreateLabel`, `fetchLabels` & `receiveCreateLabelSuccess` & `toggleDropdownContentsCreateView` actions', (done) => { const label = { id: 1 }; mock.onPost(/labels.json/).replyOnce(200, label); @@ -225,6 +225,7 @@ describe('LabelsSelect Actions', () => { [], [ { type: 'requestCreateLabel' }, + { payload: { refetch: true }, type: 'fetchLabels' }, { type: 'receiveCreateLabelSuccess' }, { type: 'toggleDropdownContentsCreateView' }, ], @@ -263,4 +264,16 @@ describe('LabelsSelect Actions', () => { ); }); }); + + describe('updateLabelsSetState', () => { + it('updates labels `set` state to match `selectedLabels`', () => { + testAction( + actions.updateLabelsSetState, + {}, + state, + [{ type: types.UPDATE_LABELS_SET_STATE }], + [], + ); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js index 1d2a9c34599..14e0c8a2278 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js @@ -197,4 +197,26 @@ describe('LabelsSelect Mutations', () => { }); }); }); + + describe(`${types.UPDATE_LABELS_SET_STATE}`, () => { + it('updates labels `set` state to match selected labels', () => { + const state = { + labels: [ + { id: 1, title: 'scoped::test', set: false }, + { id: 2, set: true, title: 'scoped::one', touched: true }, + { id: 3, title: '' }, + { id: 4, title: '' }, + ], + selectedLabels: [{ id: 1 }, { id: 3 }], + }; + mutations[types.UPDATE_LABELS_SET_STATE](state); + + expect(state.labels).toEqual([ + { id: 1, title: 'scoped::test', set: true }, + { id: 2, set: false, title: 'scoped::one', touched: true }, + { id: 3, title: '', set: true }, + { id: 4, title: '', set: false }, + ]); + }); + }); }); 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 46a11bc28d8..90bc1980ac3 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 @@ -1,6 +1,6 @@ import { GlLoadingIcon, GlLink } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; +import { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -14,7 +14,7 @@ jest.mock('~/flash'); const colors = Object.keys(mockSuggestedColors); const localVue = createLocalVue(); -Vue.use(VueApollo); +localVue.use(VueApollo); const userRecoverableError = { ...createLabelSuccessfulResponse, diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js index 51301387c99..8bd944a3d54 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js @@ -1,357 +1,213 @@ -import { GlIntersectionObserver, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui'; +import { GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue'; +import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue'; +import { mockConfig, labelsQueryResponse } from './mock_data'; -import * as actions from '~/vue_shared/components/sidebar/labels_select_widget/store/actions'; -import * as getters from '~/vue_shared/components/sidebar/labels_select_widget/store/getters'; -import mutations from '~/vue_shared/components/sidebar/labels_select_widget/store/mutations'; -import defaultState from '~/vue_shared/components/sidebar/labels_select_widget/store/state'; - -import { mockConfig, mockLabels, mockRegularLabel } from './mock_data'; +jest.mock('~/flash'); const localVue = createLocalVue(); -localVue.use(Vuex); +localVue.use(VueApollo); + +const selectedLabels = [ + { + id: 28, + title: 'Bug', + description: 'Label for bugs', + color: '#FF0000', + textColor: '#FFFFFF', + }, +]; describe('DropdownContentsLabelsView', () => { let wrapper; - const createComponent = (initialState = mockConfig) => { - const store = new Vuex.Store({ - getters, - mutations, - state: { - ...defaultState(), - footerCreateLabelTitle: 'Create label', - footerManageLabelTitle: 'Manage labels', - }, - actions: { - ...actions, - fetchLabels: jest.fn(), - }, - }); + const successfulQueryHandler = jest.fn().mockResolvedValue(labelsQueryResponse); - store.dispatch('setInitialState', initialState); - store.dispatch('receiveLabelsSuccess', mockLabels); + const createComponent = ({ + initialState = mockConfig, + queryHandler = successfulQueryHandler, + injected = {}, + } = {}) => { + const mockApollo = createMockApollo([[projectLabelsQuery, queryHandler]]); wrapper = shallowMount(DropdownContentsLabelsView, { localVue, - store, + apolloProvider: mockApollo, + provide: { + projectPath: 'test', + iid: 1, + allowLabelCreate: true, + labelsManagePath: '/gitlab-org/my-project/-/labels', + variant: DropdownVariant.Sidebar, + ...injected, + }, + propsData: { + ...initialState, + selectedLabels, + }, + stubs: { + GlSearchBoxByType, + }, }); }; - beforeEach(() => { - createComponent(); - }); - afterEach(() => { wrapper.destroy(); - wrapper = null; }); - const findDropdownContent = () => wrapper.find('[data-testid="dropdown-content"]'); + const findSearchInput = () => wrapper.findComponent(GlSearchBoxByType); + const findLabels = () => wrapper.findAllComponents(LabelItem); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + const findLabelsList = () => wrapper.find('[data-testid="labels-list"]'); + const findDropdownWrapper = () => wrapper.find('[data-testid="dropdown-wrapper"]'); const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]'); - const findLoadingIcon = () => wrapper.find(GlLoadingIcon); - - describe('computed', () => { - describe('visibleLabels', () => { - it('returns matching labels filtered with `searchKey`', () => { - wrapper.setData({ - searchKey: 'bug', - }); - - expect(wrapper.vm.visibleLabels.length).toBe(1); - expect(wrapper.vm.visibleLabels[0].title).toBe('Bug'); - }); - - it('returns matching labels with fuzzy filtering', () => { - wrapper.setData({ - searchKey: 'bg', - }); - - expect(wrapper.vm.visibleLabels.length).toBe(2); - expect(wrapper.vm.visibleLabels[0].title).toBe('Bug'); - expect(wrapper.vm.visibleLabels[1].title).toBe('Boog'); - }); - - it('returns all labels when `searchKey` is empty', () => { - wrapper.setData({ - searchKey: '', - }); - - expect(wrapper.vm.visibleLabels.length).toBe(mockLabels.length); - }); - }); + const findNoResultsMessage = () => wrapper.find('[data-testid="no-results"]'); + const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]'); - describe('showNoMatchingResultsMessage', () => { - it.each` - searchKey | labels | labelsDescription | returnValue - ${''} | ${[]} | ${'empty'} | ${false} - ${'bug'} | ${[]} | ${'empty'} | ${true} - ${''} | ${mockLabels} | ${'not empty'} | ${false} - ${'bug'} | ${mockLabels} | ${'not empty'} | ${false} - `( - 'returns $returnValue when searchKey is "$searchKey" and visibleLabels is $labelsDescription', - async ({ searchKey, labels, returnValue }) => { - wrapper.setData({ - searchKey, - }); - - wrapper.vm.$store.dispatch('receiveLabelsSuccess', labels); - - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.showNoMatchingResultsMessage).toBe(returnValue); - }, - ); + describe('when loading labels', () => { + it('renders disabled search input field', async () => { + createComponent(); + expect(findSearchInput().props('disabled')).toBe(true); }); - }); - - describe('methods', () => { - describe('isLabelSelected', () => { - it('returns true when provided `label` param is one of the selected labels', () => { - expect(wrapper.vm.isLabelSelected(mockRegularLabel)).toBe(true); - }); - it('returns false when provided `label` param is not one of the selected labels', () => { - expect(wrapper.vm.isLabelSelected(mockLabels[2])).toBe(false); - }); + it('renders loading icon', async () => { + createComponent(); + expect(findLoadingIcon().exists()).toBe(true); }); - describe('handleComponentAppear', () => { - it('calls `focusInput` on searchInput field', async () => { - wrapper.vm.$refs.searchInput.focusInput = jest.fn(); - - await wrapper.vm.handleComponentAppear(); - - expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled(); - }); + it('does not render labels list', async () => { + createComponent(); + expect(findLabelsList().exists()).toBe(false); }); + }); - describe('handleComponentDisappear', () => { - it('calls action `receiveLabelsSuccess` with empty array', () => { - jest.spyOn(wrapper.vm, 'receiveLabelsSuccess'); - - wrapper.vm.handleComponentDisappear(); - - expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]); - }); + describe('when labels are loaded', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); }); - describe('handleCreateLabelClick', () => { - it('calls actions `receiveLabelsSuccess` with empty array and `toggleDropdownContentsCreateView`', () => { - jest.spyOn(wrapper.vm, 'receiveLabelsSuccess'); - jest.spyOn(wrapper.vm, 'toggleDropdownContentsCreateView'); - - wrapper.vm.handleCreateLabelClick(); - - expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]); - expect(wrapper.vm.toggleDropdownContentsCreateView).toHaveBeenCalled(); - }); + it('renders enabled search input field', async () => { + expect(findSearchInput().props('disabled')).toBe(false); }); - describe('handleKeyDown', () => { - it('decreases `currentHighlightItem` value by 1 when Up arrow key is pressed', () => { - wrapper.setData({ - currentHighlightItem: 1, - }); - - wrapper.vm.handleKeyDown({ - keyCode: UP_KEY_CODE, - }); - - expect(wrapper.vm.currentHighlightItem).toBe(0); - }); - - it('increases `currentHighlightItem` value by 1 when Down arrow key is pressed', () => { - wrapper.setData({ - currentHighlightItem: 1, - }); - - wrapper.vm.handleKeyDown({ - keyCode: DOWN_KEY_CODE, - }); - - expect(wrapper.vm.currentHighlightItem).toBe(2); - }); - - it('resets the search text when the Enter key is pressed', () => { - wrapper.setData({ - currentHighlightItem: 1, - searchKey: 'bug', - }); - - wrapper.vm.handleKeyDown({ - keyCode: ENTER_KEY_CODE, - }); - - expect(wrapper.vm.searchKey).toBe(''); - }); - - it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => { - jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation(); - wrapper.setData({ - currentHighlightItem: 1, - }); - - wrapper.vm.handleKeyDown({ - keyCode: ENTER_KEY_CODE, - }); - - expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([ - { - ...mockLabels[1], - set: true, - }, - ]); - }); - - it('calls action `toggleDropdownContents` when Esc key is pressed', () => { - jest.spyOn(wrapper.vm, 'toggleDropdownContents').mockImplementation(); - wrapper.setData({ - currentHighlightItem: 1, - }); - - wrapper.vm.handleKeyDown({ - keyCode: ESC_KEY_CODE, - }); - - expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled(); - }); - - it('calls action `scrollIntoViewIfNeeded` in next tick when any key is pressed', () => { - jest.spyOn(wrapper.vm, 'scrollIntoViewIfNeeded').mockImplementation(); - wrapper.setData({ - currentHighlightItem: 1, - }); - - wrapper.vm.handleKeyDown({ - keyCode: DOWN_KEY_CODE, - }); - - return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.scrollIntoViewIfNeeded).toHaveBeenCalled(); - }); - }); + it('does not render loading icon', async () => { + expect(findLoadingIcon().exists()).toBe(false); }); - describe('handleLabelClick', () => { - beforeEach(() => { - jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation(); - }); - - it('calls action `updateSelectedLabels` with provided `label` param', () => { - wrapper.vm.handleLabelClick(mockRegularLabel); - - expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([mockRegularLabel]); - }); + it('renders labels list', async () => { + expect(findLabelsList().exists()).toBe(true); + expect(findLabels()).toHaveLength(2); + }); - it('calls action `toggleDropdownContents` when `state.allowMultiselect` is false', () => { - jest.spyOn(wrapper.vm, 'toggleDropdownContents'); - wrapper.vm.$store.state.allowMultiselect = false; + it('changes highlighted label correctly on pressing down button', async () => { + expect(findLabels().at(0).attributes('highlight')).toBeUndefined(); - wrapper.vm.handleLabelClick(mockRegularLabel); + await findDropdownWrapper().trigger('keydown.down'); + expect(findLabels().at(0).attributes('highlight')).toBe('true'); - expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled(); - }); + await findDropdownWrapper().trigger('keydown.down'); + expect(findLabels().at(1).attributes('highlight')).toBe('true'); + expect(findLabels().at(0).attributes('highlight')).toBeUndefined(); }); - }); - describe('template', () => { - it('renders gl-intersection-observer as component root', () => { - expect(wrapper.find(GlIntersectionObserver).exists()).toBe(true); - }); + it('changes highlighted label correctly on pressing up button', async () => { + await findDropdownWrapper().trigger('keydown.down'); + await findDropdownWrapper().trigger('keydown.down'); + expect(findLabels().at(1).attributes('highlight')).toBe('true'); - it('renders gl-loading-icon component when `labelsFetchInProgress` prop is true', () => { - wrapper.vm.$store.dispatch('requestLabels'); + await findDropdownWrapper().trigger('keydown.up'); + expect(findLabels().at(0).attributes('highlight')).toBe('true'); + }); - return wrapper.vm.$nextTick(() => { - const loadingIconEl = findLoadingIcon(); + it('changes label selected state when Enter is pressed', async () => { + expect(findLabels().at(0).attributes('islabelset')).toBeUndefined(); + await findDropdownWrapper().trigger('keydown.down'); + await findDropdownWrapper().trigger('keydown.enter'); - expect(loadingIconEl.exists()).toBe(true); - expect(loadingIconEl.attributes('class')).toContain('labels-fetch-loading'); - }); + expect(findLabels().at(0).attributes('islabelset')).toBe('true'); }); - it('renders label search input element', () => { - const searchInputEl = wrapper.find(GlSearchBoxByType); + it('emits `closeDropdown event` when Esc button is pressed', () => { + findDropdownWrapper().trigger('keydown.esc'); - expect(searchInputEl.exists()).toBe(true); + expect(wrapper.emitted('closeDropdown')).toEqual([[selectedLabels]]); }); + }); - it('renders label elements for all labels', () => { - expect(wrapper.findAll(LabelItem)).toHaveLength(mockLabels.length); + it('when search returns 0 results', async () => { + createComponent({ + queryHandler: jest.fn().mockResolvedValue({ + data: { + workspace: { + labels: { + nodes: [], + }, + }, + }, + }), }); + findSearchInput().vm.$emit('input', '123'); + await waitForPromises(); + await nextTick(); - it('renders label element with `highlight` set to true when value of `currentHighlightItem` is more than -1', () => { - wrapper.setData({ - currentHighlightItem: 0, - }); + expect(findNoResultsMessage().isVisible()).toBe(true); + }); - return wrapper.vm.$nextTick(() => { - const labelItemEl = findDropdownContent().find(LabelItem); + it('calls `createFlash` when fetching labels failed', async () => { + createComponent({ queryHandler: jest.fn().mockRejectedValue('Houston, we have a problem!') }); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await waitForPromises(); + expect(createFlash).toHaveBeenCalled(); + }); - expect(labelItemEl.attributes('highlight')).toBe('true'); - }); - }); + it('does not render footer on standalone dropdown', () => { + createComponent({ injected: { variant: DropdownVariant.Standalone } }); - it('renders element containing "No matching results" when `searchKey` does not match with any label', () => { - wrapper.setData({ - searchKey: 'abc', - }); + expect(findDropdownFooter().exists()).toBe(false); + }); - return wrapper.vm.$nextTick(() => { - const noMatchEl = findDropdownContent().find('li'); + it('renders footer on sidebar dropdown', () => { + createComponent(); - expect(noMatchEl.isVisible()).toBe(true); - expect(noMatchEl.text()).toContain('No matching results'); - }); - }); + expect(findDropdownFooter().exists()).toBe(true); + }); - it('renders empty content while loading', () => { - wrapper.vm.$store.state.labelsFetchInProgress = true; + it('renders footer on embedded dropdown', () => { + createComponent({ injected: { variant: DropdownVariant.Embedded } }); - return wrapper.vm.$nextTick(() => { - const dropdownContent = findDropdownContent(); - const loadingIcon = findLoadingIcon(); + expect(findDropdownFooter().exists()).toBe(true); + }); - expect(dropdownContent.exists()).toBe(true); - expect(dropdownContent.isVisible()).toBe(true); - expect(loadingIcon.exists()).toBe(true); - expect(loadingIcon.isVisible()).toBe(true); - }); - }); + it('does not render create label button if `allowLabelCreate` is false', () => { + createComponent({ injected: { allowLabelCreate: false } }); - it('renders footer list items', () => { - const footerLinks = findDropdownFooter().findAll(GlLink); - const createLabelLink = footerLinks.at(0); - const manageLabelsLink = footerLinks.at(1); + expect(findCreateLabelButton().exists()).toBe(false); + }); - expect(createLabelLink.exists()).toBe(true); - expect(createLabelLink.text()).toBe('Create label'); - expect(manageLabelsLink.exists()).toBe(true); - expect(manageLabelsLink.text()).toBe('Manage labels'); + describe('when `allowLabelCreate` is true', () => { + beforeEach(() => { + createComponent(); }); - it('does not render "Create label" footer link when `state.allowLabelCreate` is `false`', () => { - wrapper.vm.$store.state.allowLabelCreate = false; - - return wrapper.vm.$nextTick(() => { - const createLabelLink = findDropdownFooter().findAll(GlLink).at(0); - - expect(createLabelLink.text()).not.toBe('Create label'); - }); + it('renders create label button', () => { + expect(findCreateLabelButton().exists()).toBe(true); }); - it('does not render footer list items when `state.variant` is "standalone"', () => { - createComponent({ ...mockConfig, variant: 'standalone' }); - expect(findDropdownFooter().exists()).toBe(false); - }); + it('emits `toggleDropdownContentsCreateView` event on create label button click', () => { + findCreateLabelButton().vm.$emit('click'); - it('renders footer list items when `state.variant` is "embedded"', () => { - expect(findDropdownFooter().exists()).toBe(true); + expect(wrapper.emitted('toggleDropdownContentsCreateView')).toEqual([[]]); }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js index 8273bbdf7a7..3c2fd0c5acc 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js @@ -5,7 +5,7 @@ import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_w import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue'; import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store'; -import { mockConfig } from './mock_data'; +import { mockConfig, mockLabels } from './mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -19,6 +19,11 @@ const createComponent = (initialState = mockConfig, defaultProps = {}) => { propsData: { ...defaultProps, labelsCreateTitle: 'test', + selectedLabels: mockLabels, + allowMultiselect: true, + labelsListTitle: 'Assign labels', + footerCreateLabelTitle: 'create', + footerManageLabelTitle: 'manage', }, localVue, store, diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js index 66971446f47..e17dfd93efc 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js @@ -50,58 +50,6 @@ describe('LabelsSelectRoot', () => { }); describe('methods', () => { - describe('handleVuexActionDispatch', () => { - it('calls `handleDropdownClose` when params `action.type` is `toggleDropdownContents` and state has `showDropdownButton` & `showDropdownContents` props `false`', () => { - createComponent(); - jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation(); - - wrapper.vm.handleVuexActionDispatch( - { type: 'toggleDropdownContents' }, - { - showDropdownButton: false, - showDropdownContents: false, - labels: [{ id: 1 }, { id: 2, touched: true }], - }, - ); - - expect(wrapper.vm.handleDropdownClose).toHaveBeenCalledWith( - expect.arrayContaining([ - { - id: 2, - touched: true, - }, - ]), - ); - }); - - it('calls `handleDropdownClose` with state.labels filterd using `set` prop when dropdown variant is `embedded`', () => { - createComponent({ - ...mockConfig, - variant: 'embedded', - }); - - jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation(); - - wrapper.vm.handleVuexActionDispatch( - { type: 'toggleDropdownContents' }, - { - showDropdownButton: false, - showDropdownContents: false, - labels: [{ id: 1 }, { id: 2, set: true }], - }, - ); - - expect(wrapper.vm.handleDropdownClose).toHaveBeenCalledWith( - expect.arrayContaining([ - { - id: 2, - set: true, - }, - ]), - ); - }); - }); - describe('handleDropdownClose', () => { beforeEach(() => { createComponent(); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js index 9e29030fb56..5dd8fc1b8b2 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js @@ -48,6 +48,8 @@ export const mockConfig = { labelsManagePath: '/gitlab-org/my-project/-/labels', labelsFilterBasePath: '/gitlab-org/my-project/issues', labelsFilterParam: 'label_name', + footerCreateLabelTitle: 'create', + footerManageLabelTitle: 'manage', }; export const mockSuggestedColors = { @@ -91,3 +93,26 @@ export const createLabelSuccessfulResponse = { }, }, }; + +export const labelsQueryResponse = { + data: { + workspace: { + labels: { + nodes: [ + { + color: '#330066', + description: null, + id: 'gid://gitlab/ProjectLabel/1', + title: 'Label1', + }, + { + color: '#2f7b2e', + description: null, + id: 'gid://gitlab/ProjectLabel/2', + title: 'Label2', + }, + ], + }, + }, + }, +}; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js index 27de7de2411..ee905410ffa 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js @@ -1,8 +1,4 @@ -import MockAdapter from 'axios-mock-adapter'; - import testAction from 'helpers/vuex_action_helper'; -import createFlash from '~/flash'; -import axios from '~/lib/utils/axios_utils'; import * as actions from '~/vue_shared/components/sidebar/labels_select_widget/store/actions'; import * as types from '~/vue_shared/components/sidebar/labels_select_widget/store/mutation_types'; import defaultState from '~/vue_shared/components/sidebar/labels_select_widget/store/state'; @@ -72,90 +68,6 @@ describe('LabelsSelect Actions', () => { }); }); - describe('requestLabels', () => { - it('sets value of `state.labelsFetchInProgress` to `true`', (done) => { - testAction(actions.requestLabels, {}, state, [{ type: types.REQUEST_LABELS }], [], done); - }); - }); - - describe('receiveLabelsSuccess', () => { - it('sets provided labels to `state.labels`', (done) => { - const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - - testAction( - actions.receiveLabelsSuccess, - labels, - state, - [{ type: types.RECEIVE_SET_LABELS_SUCCESS, payload: labels }], - [], - done, - ); - }); - }); - - describe('receiveLabelsFailure', () => { - it('sets value `state.labelsFetchInProgress` to `false`', (done) => { - testAction( - actions.receiveLabelsFailure, - {}, - state, - [{ type: types.RECEIVE_SET_LABELS_FAILURE }], - [], - done, - ); - }); - - it('shows flash error', () => { - actions.receiveLabelsFailure({ commit: () => {} }); - - expect(createFlash).toHaveBeenCalledWith({ message: 'Error fetching labels.' }); - }); - }); - - describe('fetchLabels', () => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - state.labelsFetchPath = 'labels.json'; - }); - - afterEach(() => { - mock.restore(); - }); - - describe('on success', () => { - it('dispatches `requestLabels` & `receiveLabelsSuccess` actions', (done) => { - const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - mock.onGet(/labels.json/).replyOnce(200, labels); - - testAction( - actions.fetchLabels, - {}, - state, - [], - [{ type: 'requestLabels' }, { type: 'receiveLabelsSuccess', payload: labels }], - done, - ); - }); - }); - - describe('on failure', () => { - it('dispatches `requestLabels` & `receiveLabelsFailure` actions', (done) => { - mock.onGet(/labels.json/).replyOnce(500, {}); - - testAction( - actions.fetchLabels, - {}, - state, - [], - [{ type: 'requestLabels' }, { type: 'receiveLabelsFailure' }], - done, - ); - }); - }); - }); - describe('updateSelectedLabels', () => { it('updates `state.labels` based on provided `labels` param', (done) => { const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js index 9e965cb33e8..1f0e0eee420 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js @@ -67,58 +67,6 @@ describe('LabelsSelect Mutations', () => { }); }); - describe(`${types.REQUEST_LABELS}`, () => { - it('sets value of `state.labelsFetchInProgress` to true', () => { - const state = { - labelsFetchInProgress: false, - }; - mutations[types.REQUEST_LABELS](state); - - expect(state.labelsFetchInProgress).toBe(true); - }); - }); - - describe(`${types.RECEIVE_SET_LABELS_SUCCESS}`, () => { - const selectedLabels = [{ id: 2 }, { id: 4 }]; - const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - - it('sets value of `state.labelsFetchInProgress` to false', () => { - const state = { - selectedLabels, - labelsFetchInProgress: true, - }; - mutations[types.RECEIVE_SET_LABELS_SUCCESS](state, labels); - - expect(state.labelsFetchInProgress).toBe(false); - }); - - it('sets provided `labels` to `state.labels` along with `set` prop based on `state.selectedLabels`', () => { - const selectedLabelIds = selectedLabels.map((label) => label.id); - const state = { - selectedLabels, - labelsFetchInProgress: true, - }; - mutations[types.RECEIVE_SET_LABELS_SUCCESS](state, labels); - - state.labels.forEach((label) => { - if (selectedLabelIds.includes(label.id)) { - expect(label.set).toBe(true); - } - }); - }); - }); - - describe(`${types.RECEIVE_SET_LABELS_FAILURE}`, () => { - it('sets value of `state.labelsFetchInProgress` to false', () => { - const state = { - labelsFetchInProgress: true, - }; - mutations[types.RECEIVE_SET_LABELS_FAILURE](state); - - expect(state.labelsFetchInProgress).toBe(false); - }); - }); - describe(`${types.UPDATE_SELECTED_LABELS}`, () => { let labels; diff --git a/spec/frontend/vue_shared/components/url_sync_spec.js b/spec/frontend/vue_shared/components/url_sync_spec.js index 86bbc146c5f..aefe6a5c3e8 100644 --- a/spec/frontend/vue_shared/components/url_sync_spec.js +++ b/spec/frontend/vue_shared/components/url_sync_spec.js @@ -1,5 +1,4 @@ import { shallowMount } from '@vue/test-utils'; -import setWindowLocation from 'helpers/set_window_location_helper'; import { historyPushState } from '~/lib/utils/common_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import UrlSyncComponent from '~/vue_shared/components/url_sync.vue'; @@ -15,9 +14,6 @@ jest.mock('~/lib/utils/common_utils', () => ({ describe('url sync component', () => { let wrapper; const mockQuery = { group_id: '5014437163714', project_ids: ['5014437608314'] }; - const TEST_HOST = 'http://testhost/'; - - setWindowLocation(TEST_HOST); const findButton = () => wrapper.find('button'); @@ -35,7 +31,9 @@ describe('url sync component', () => { const expectUrlSync = (query, times, mergeUrlParamsReturnValue) => { expect(mergeUrlParams).toHaveBeenCalledTimes(times); - expect(mergeUrlParams).toHaveBeenCalledWith(query, TEST_HOST, { spreadArrays: true }); + expect(mergeUrlParams).toHaveBeenCalledWith(query, window.location.href, { + spreadArrays: true, + }); expect(historyPushState).toHaveBeenCalledTimes(times); expect(historyPushState).toHaveBeenCalledWith(mergeUrlParamsReturnValue); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js index d62c4a98b10..d3fec680b54 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js @@ -104,4 +104,15 @@ describe('User Avatar Link Component', () => { ); }); }); + + describe('lazy', () => { + it('passes lazy prop to avatar image', () => { + createWrapper({ + username: '', + lazy: true, + }); + + expect(wrapper.find(UserAvatarImage).props('lazy')).toBe(true); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js index 0fd4d0dab87..5fe4eeb6061 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -85,6 +85,10 @@ describe('Web IDE link component', () => { expectedActions: [ACTION_WEB_IDE, ACTION_EDIT], }, { + props: { webIdeText: 'Test Web IDE' }, + expectedActions: [{ ...ACTION_WEB_IDE_EDIT_FORK, text: 'Test Web IDE' }, ACTION_EDIT], + }, + { props: { isFork: true }, expectedActions: [ACTION_WEB_IDE_EDIT_FORK, ACTION_EDIT], }, @@ -105,6 +109,10 @@ describe('Web IDE link component', () => { expectedActions: [ACTION_WEB_IDE, ACTION_EDIT, ACTION_GITPOD_ENABLE], }, { + props: { showEditButton: false, showGitpodButton: true, gitpodText: 'Test Gitpod' }, + expectedActions: [ACTION_WEB_IDE, { ...ACTION_GITPOD_ENABLE, text: 'Test Gitpod' }], + }, + { props: { showEditButton: false }, expectedActions: [ACTION_WEB_IDE], }, diff --git a/spec/frontend/vue_shared/directives/autofocusonshow_spec.js b/spec/frontend/vue_shared/directives/autofocusonshow_spec.js index 1c9e89f99e9..59ce9f086c3 100644 --- a/spec/frontend/vue_shared/directives/autofocusonshow_spec.js +++ b/spec/frontend/vue_shared/directives/autofocusonshow_spec.js @@ -1,4 +1,3 @@ -import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; /** @@ -7,8 +6,6 @@ import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; * on underlying DOM methods. */ describe('AutofocusOnShow directive', () => { - useMockIntersectionObserver(); - describe('with input invisible on component render', () => { let el; diff --git a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js index bef538e1ff1..4d579fa61df 100644 --- a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js +++ b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js @@ -22,7 +22,7 @@ import { REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION, } from '~/vue_shared/security_reports/constants'; -import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql'; +import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql'; import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_app.vue'; jest.mock('~/flash'); |