diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-12-20 16:37:47 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-12-20 16:37:47 +0300 |
commit | aee0a117a889461ce8ced6fcf73207fe017f1d99 (patch) | |
tree | 891d9ef189227a8445d83f35c1b0fc99573f4380 /spec/frontend | |
parent | 8d46af3258650d305f53b819eabf7ab18d22f59e (diff) |
Add latest changes from gitlab-org/gitlab@14-6-stable-eev14.6.0-rc42
Diffstat (limited to 'spec/frontend')
451 files changed, 12321 insertions, 7217 deletions
diff --git a/spec/frontend/__helpers__/emoji.js b/spec/frontend/__helpers__/emoji.js index a64135601ae..014a7854024 100644 --- a/spec/frontend/__helpers__/emoji.js +++ b/spec/frontend/__helpers__/emoji.js @@ -1,8 +1,7 @@ -import MockAdapter from 'axios-mock-adapter'; import { initEmojiMap, EMOJI_VERSION } from '~/emoji'; -import axios from '~/lib/utils/axios_utils'; +import { CACHE_VERSION_KEY, CACHE_KEY } from '~/emoji/constants'; -export const emojiFixtureMap = { +export const validEmoji = { atom: { moji: '⚛', description: 'atom symbol', @@ -49,11 +48,39 @@ export const emojiFixtureMap = { unicodeVersion: '5.1', description: 'white medium star', }, + gay_pride_flag: { + moji: '🏳️🌈', + unicodeVersion: '7.0', + description: 'because it contains a zero width joiner', + }, + family_mmb: { + moji: '👨👨👦', + unicodeVersion: '6.0', + description: 'because it contains multiple zero width joiners', + }, +}; + +export const invalidEmoji = { xss: { moji: '<img src=x onerror=prompt(1)>', unicodeVersion: '5.1', description: 'xss', }, + non_moji: { + moji: 'I am not an emoji...', + unicodeVersion: '9.0', + description: '...and should be filtered out', + }, + multiple_moji: { + moji: '🍂🏭', + unicodeVersion: '9.0', + description: 'Multiple separate emoji that are not joined by a zero width joiner', + }, +}; + +export const emojiFixtureMap = { + ...validEmoji, + ...invalidEmoji, }; export const mockEmojiData = Object.keys(emojiFixtureMap).reduce((acc, k) => { @@ -63,11 +90,14 @@ export const mockEmojiData = Object.keys(emojiFixtureMap).reduce((acc, k) => { return acc; }, {}); -export async function initEmojiMock(mockData = mockEmojiData) { - const mock = new MockAdapter(axios); - mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, JSON.stringify(mockData)); +export function clearEmojiMock() { + localStorage.clear(); + initEmojiMap.promise = null; +} +export async function initEmojiMock(mockData = mockEmojiData) { + clearEmojiMock(); + localStorage.setItem(CACHE_VERSION_KEY, EMOJI_VERSION); + localStorage.setItem(CACHE_KEY, JSON.stringify(mockData)); await initEmojiMap(); - - return mock; } diff --git a/spec/frontend/__helpers__/experimentation_helper.js b/spec/frontend/__helpers__/experimentation_helper.js index e0156226acc..d5044be88d7 100644 --- a/spec/frontend/__helpers__/experimentation_helper.js +++ b/spec/frontend/__helpers__/experimentation_helper.js @@ -25,7 +25,7 @@ export function stubExperiments(experiments = {}) { window.gon.experiment = window.gon.experiment || {}; // Preferred window.gl = window.gl || {}; - window.gl.experiments = window.gl.experiemnts || {}; + window.gl.experiments = window.gl.experiments || {}; Object.entries(experiments).forEach(([name, variant]) => { const experimentData = { experiment: name, variant }; diff --git a/spec/frontend/matchers.js b/spec/frontend/__helpers__/matchers.js index 945abdafe9a..945abdafe9a 100644 --- a/spec/frontend/matchers.js +++ b/spec/frontend/__helpers__/matchers.js diff --git a/spec/frontend/matchers_spec.js b/spec/frontend/__helpers__/matchers_spec.js index dfd6f754c72..dfd6f754c72 100644 --- a/spec/frontend/matchers_spec.js +++ b/spec/frontend/__helpers__/matchers_spec.js diff --git a/spec/frontend/__helpers__/mock_apollo_helper.js b/spec/frontend/__helpers__/mock_apollo_helper.js index 520d6c72541..ee4bbd42b1e 100644 --- a/spec/frontend/__helpers__/mock_apollo_helper.js +++ b/spec/frontend/__helpers__/mock_apollo_helper.js @@ -26,7 +26,5 @@ export function createMockClient(handlers = [], resolvers = {}, cacheOptions = { export default function createMockApollo(handlers, resolvers, cacheOptions) { const mockClient = createMockClient(handlers, resolvers, cacheOptions); - const apolloProvider = new VueApollo({ defaultClient: mockClient }); - - return apolloProvider; + return new VueApollo({ defaultClient: mockClient }); } diff --git a/spec/frontend/mocks/ce/lib/utils/axios_utils.js b/spec/frontend/__helpers__/mocks/axios_utils.js index 674563b9f28..674563b9f28 100644 --- a/spec/frontend/mocks/ce/lib/utils/axios_utils.js +++ b/spec/frontend/__helpers__/mocks/axios_utils.js diff --git a/spec/frontend/__helpers__/shared_test_setup.js b/spec/frontend/__helpers__/shared_test_setup.js new file mode 100644 index 00000000000..03389e16b65 --- /dev/null +++ b/spec/frontend/__helpers__/shared_test_setup.js @@ -0,0 +1,90 @@ +/* Common setup for both unit and integration test environments */ +import { config as testUtilsConfig } from '@vue/test-utils'; +import * as jqueryMatchers from 'custom-jquery-matchers'; +import Vue from 'vue'; +import 'jquery'; +import Translate from '~/vue_shared/translate'; +import setWindowLocation from './set_window_location_helper'; +import { setGlobalDateToFakeDate } from './fake_date'; +import { loadHTMLFixture, setHTMLFixture } from './fixtures'; +import { TEST_HOST } from './test_constants'; +import customMatchers from './matchers'; + +import './dom_shims'; +import './jquery'; +import '~/commons/bootstrap'; + +// This module has some fairly decent visual test coverage in it's own repository. +jest.mock('@gitlab/favicon-overlay'); + +process.on('unhandledRejection', global.promiseRejectionHandler); + +// Fake the `Date` for the rest of the jest spec runtime environment. +// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39496#note_503084332 +setGlobalDateToFakeDate(); + +Vue.config.devtools = false; +Vue.config.productionTip = false; + +Vue.use(Translate); + +// convenience wrapper for migration from Karma +Object.assign(global, { + loadFixtures: loadHTMLFixture, + setFixtures: setHTMLFixture, +}); + +const JQUERY_MATCHERS_TO_EXCLUDE = ['toHaveLength', 'toExist']; + +// custom-jquery-matchers was written for an old Jest version, we need to make it compatible +Object.entries(jqueryMatchers).forEach(([matcherName, matcherFactory]) => { + // Exclude these jQuery matchers + if (JQUERY_MATCHERS_TO_EXCLUDE.includes(matcherName)) { + return; + } + + expect.extend({ + [matcherName]: matcherFactory().compare, + }); +}); + +expect.extend(customMatchers); + +testUtilsConfig.deprecationWarningHandler = (method, message) => { + const ALLOWED_DEPRECATED_METHODS = [ + // https://gitlab.com/gitlab-org/gitlab/-/issues/295679 + 'finding components with `find` or `get`', + + // https://gitlab.com/gitlab-org/gitlab/-/issues/295680 + 'finding components with `findAll`', + ]; + if (!ALLOWED_DEPRECATED_METHODS.includes(method)) { + global.console.error(message); + } +}; + +Object.assign(global, { + requestIdleCallback(cb) { + const start = Date.now(); + return setTimeout(() => { + cb({ + didTimeout: false, + timeRemaining: () => Math.max(0, 50 - (Date.now() - start)), + }); + }); + }, + cancelIdleCallback(id) { + clearTimeout(id); + }, +}); + +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/access_tokens/components/token_spec.js b/spec/frontend/access_tokens/components/token_spec.js new file mode 100644 index 00000000000..1af21aaa8cd --- /dev/null +++ b/spec/frontend/access_tokens/components/token_spec.js @@ -0,0 +1,65 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; + +import Token from '~/access_tokens/components/token.vue'; +import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; + +describe('Token', () => { + let wrapper; + + const defaultPropsData = { + token: 'az4a2l5f8ssa0zvdfbhidbzlx', + inputId: 'feed_token', + inputLabel: 'Feed token', + copyButtonTitle: 'Copy feed token', + }; + + const defaultSlots = { + title: 'Feed token title', + description: 'Feed token description', + 'input-description': 'Feed token input description', + }; + + const createComponent = () => { + wrapper = mountExtended(Token, { propsData: defaultPropsData, slots: defaultSlots }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders title slot', () => { + createComponent(); + + expect(wrapper.findByText(defaultSlots.title, { selector: 'h4' }).exists()).toBe(true); + }); + + it('renders description slot', () => { + createComponent(); + + expect(wrapper.findByText(defaultSlots.description).exists()).toBe(true); + }); + + it('renders input description slot', () => { + createComponent(); + + expect(wrapper.findByText(defaultSlots['input-description']).exists()).toBe(true); + }); + + it('correctly passes props to `InputCopyToggleVisibility` component', () => { + createComponent(); + + const inputCopyToggleVisibilityComponent = wrapper.findComponent(InputCopyToggleVisibility); + + expect(inputCopyToggleVisibilityComponent.props()).toMatchObject({ + formInputGroupProps: { + id: defaultPropsData.inputId, + }, + value: defaultPropsData.token, + copyButtonTitle: defaultPropsData.copyButtonTitle, + }); + expect(inputCopyToggleVisibilityComponent.attributes()).toMatchObject({ + label: defaultPropsData.inputLabel, + 'label-for': defaultPropsData.inputId, + }); + }); +}); diff --git a/spec/frontend/access_tokens/components/tokens_app_spec.js b/spec/frontend/access_tokens/components/tokens_app_spec.js new file mode 100644 index 00000000000..d7acfbb47eb --- /dev/null +++ b/spec/frontend/access_tokens/components/tokens_app_spec.js @@ -0,0 +1,148 @@ +import { merge } from 'lodash'; + +import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; + +import TokensApp from '~/access_tokens/components/tokens_app.vue'; +import { FEED_TOKEN, INCOMING_EMAIL_TOKEN, STATIC_OBJECT_TOKEN } from '~/access_tokens/constants'; + +describe('TokensApp', () => { + let wrapper; + + const defaultProvide = { + tokenTypes: { + [FEED_TOKEN]: { + enabled: true, + token: 'DUKu345VD73Py7zz3z89', + resetPath: '/-/profile/reset_feed_token', + }, + [INCOMING_EMAIL_TOKEN]: { + enabled: true, + token: 'az4a2l5f8ssa0zvdfbhidbzlx', + resetPath: '/-/profile/reset_incoming_email_token', + }, + [STATIC_OBJECT_TOKEN]: { + enabled: true, + token: 'QHXwGHYioHTgxQnAcyZ-', + resetPath: '/-/profile/reset_static_object_token', + }, + }, + }; + + const createComponent = (options = {}) => { + wrapper = mountExtended(TokensApp, merge({}, { provide: defaultProvide }, options)); + }; + + const expectTokenRendered = ({ + testId, + expectedLabel, + expectedDescription, + expectedInputDescription, + expectedResetPath, + expectedResetConfirmMessage, + expectedProps, + }) => { + const container = extendedWrapper(wrapper.findByTestId(testId)); + + expect(container.findByText(expectedLabel, { selector: 'h4' }).exists()).toBe(true); + expect(container.findByText(expectedDescription).exists()).toBe(true); + expect(container.findByText(expectedInputDescription, { exact: false }).exists()).toBe(true); + expect(container.findByText('reset this token').attributes()).toMatchObject({ + 'data-confirm': expectedResetConfirmMessage, + 'data-method': 'put', + href: expectedResetPath, + }); + expect(container.props()).toMatchObject(expectedProps); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders all enabled tokens', () => { + createComponent(); + + expectTokenRendered({ + testId: TokensApp.htmlAttributes[FEED_TOKEN].containerTestId, + expectedLabel: TokensApp.i18n[FEED_TOKEN].label, + expectedDescription: TokensApp.i18n[FEED_TOKEN].description, + expectedInputDescription: + 'Keep this token secret. Anyone who has it can read activity and issue RSS feeds or your calendar feed as if they were you.', + expectedResetPath: defaultProvide.tokenTypes[FEED_TOKEN].resetPath, + expectedResetConfirmMessage: TokensApp.i18n[FEED_TOKEN].resetConfirmMessage, + expectedProps: { + token: defaultProvide.tokenTypes[FEED_TOKEN].token, + inputId: TokensApp.htmlAttributes[FEED_TOKEN].inputId, + inputLabel: TokensApp.i18n[FEED_TOKEN].label, + copyButtonTitle: TokensApp.i18n[FEED_TOKEN].copyButtonTitle, + }, + }); + + expectTokenRendered({ + testId: TokensApp.htmlAttributes[INCOMING_EMAIL_TOKEN].containerTestId, + expectedLabel: TokensApp.i18n[INCOMING_EMAIL_TOKEN].label, + expectedDescription: TokensApp.i18n[INCOMING_EMAIL_TOKEN].description, + expectedInputDescription: + 'Keep this token secret. Anyone who has it can create issues as if they were you.', + expectedResetPath: defaultProvide.tokenTypes[INCOMING_EMAIL_TOKEN].resetPath, + expectedResetConfirmMessage: TokensApp.i18n[INCOMING_EMAIL_TOKEN].resetConfirmMessage, + expectedProps: { + token: defaultProvide.tokenTypes[INCOMING_EMAIL_TOKEN].token, + inputId: TokensApp.htmlAttributes[INCOMING_EMAIL_TOKEN].inputId, + inputLabel: TokensApp.i18n[INCOMING_EMAIL_TOKEN].label, + copyButtonTitle: TokensApp.i18n[INCOMING_EMAIL_TOKEN].copyButtonTitle, + }, + }); + + expectTokenRendered({ + testId: TokensApp.htmlAttributes[STATIC_OBJECT_TOKEN].containerTestId, + expectedLabel: TokensApp.i18n[STATIC_OBJECT_TOKEN].label, + expectedDescription: TokensApp.i18n[STATIC_OBJECT_TOKEN].description, + expectedInputDescription: + 'Keep this token secret. Anyone who has it can access repository static objects as if they were you.', + expectedResetPath: defaultProvide.tokenTypes[STATIC_OBJECT_TOKEN].resetPath, + expectedResetConfirmMessage: TokensApp.i18n[STATIC_OBJECT_TOKEN].resetConfirmMessage, + expectedProps: { + token: defaultProvide.tokenTypes[STATIC_OBJECT_TOKEN].token, + inputId: TokensApp.htmlAttributes[STATIC_OBJECT_TOKEN].inputId, + inputLabel: TokensApp.i18n[STATIC_OBJECT_TOKEN].label, + copyButtonTitle: TokensApp.i18n[STATIC_OBJECT_TOKEN].copyButtonTitle, + }, + }); + }); + + it("doesn't render disabled tokens", () => { + createComponent({ + provide: { + tokenTypes: { + [FEED_TOKEN]: { + enabled: false, + }, + }, + }, + }); + + expect( + wrapper.findByTestId(TokensApp.htmlAttributes[FEED_TOKEN].containerTestId).exists(), + ).toBe(false); + }); + + describe('when there are tokens missing an `i18n` definition', () => { + it('renders without errors', () => { + createComponent({ + provide: { + tokenTypes: { + fooBar: { + enabled: true, + token: 'rewjoa58dfm54jfkdlsdf', + resetPath: '/-/profile/foo_bar', + }, + }, + }, + }); + + expect( + wrapper.findByTestId(TokensApp.htmlAttributes[FEED_TOKEN].containerTestId).exists(), + ).toBe(true); + }); + }); +}); 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 824eb033671..14f94e671a4 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,4 +1,4 @@ -import { GlTable, GlBadge, GlEmptyState } from '@gitlab/ui'; +import { GlTableLite, 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'; @@ -20,7 +20,7 @@ describe('DevopsScore', () => { ); }; - const findTable = () => wrapper.findComponent(GlTable); + const findTable = () => wrapper.findComponent(GlTableLite); const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findCol = (testId) => findTable().find(`[data-testid="${testId}"]`); const findUsageCol = () => findCol('usageCol'); @@ -44,7 +44,7 @@ describe('DevopsScore', () => { }); it('displays the correct message', () => { - expect(findEmptyState().text()).toBe( + expect(findEmptyState().text().replace(/\s+/g, ' ')).toBe( 'Data is still calculating... It may be several days before you see feature usage data. See example DevOps Score page in our documentation.', ); }); @@ -124,11 +124,11 @@ describe('DevopsScore', () => { describe('table columns', () => { describe('Your usage', () => { - it('displays the corrrect value', () => { + it('displays the correct value', () => { expect(findUsageCol().text()).toContain('3.2'); }); - it('displays the corrrect badge', () => { + it('displays the correct badge', () => { const badge = findUsageCol().find(GlBadge); expect(badge.exists()).toBe(true); diff --git a/spec/frontend/admin/deploy_keys/components/table_spec.js b/spec/frontend/admin/deploy_keys/components/table_spec.js index 3b3be488043..49bda7100fb 100644 --- a/spec/frontend/admin/deploy_keys/components/table_spec.js +++ b/spec/frontend/admin/deploy_keys/components/table_spec.js @@ -1,8 +1,19 @@ import { merge } from 'lodash'; -import { GlTable, GlButton } from '@gitlab/ui'; +import { GlLoadingIcon, GlEmptyState, GlPagination, GlModal } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import responseBody from 'test_fixtures/api/deploy_keys/index.json'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { stubComponent } from 'helpers/stub_component'; import DeployKeysTable from '~/admin/deploy_keys/components/table.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import Api, { DEFAULT_PER_PAGE } from '~/api'; +import createFlash from '~/flash'; + +jest.mock('~/api'); +jest.mock('~/flash'); +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); describe('DeployKeysTable', () => { let wrapper; @@ -14,9 +25,60 @@ describe('DeployKeysTable', () => { emptyStateSvgPath: '/assets/illustrations/empty-state/empty-deploy-keys.svg', }; + const deployKey = responseBody[0]; + const deployKey2 = responseBody[1]; + const createComponent = (provide = {}) => { wrapper = mountExtended(DeployKeysTable, { provide: merge({}, defaultProvide, provide), + stubs: { + GlModal: stubComponent(GlModal, { + template: ` + <div> + <slot name="modal-title"></slot> + <slot></slot> + <slot name="modal-footer"></slot> + </div>`, + }), + }, + }); + }; + + const findEditButton = (index) => + wrapper.findAllByLabelText(DeployKeysTable.i18n.edit, { selector: 'a' }).at(index); + const findRemoveButton = (index) => + wrapper.findAllByLabelText(DeployKeysTable.i18n.delete, { selector: 'button' }).at(index); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findTimeAgoTooltip = (index) => wrapper.findAllComponents(TimeAgoTooltip).at(index); + const findPagination = () => wrapper.findComponent(GlPagination); + + const expectDeployKeyIsRendered = (expectedDeployKey, expectedRowIndex) => { + const editButton = findEditButton(expectedRowIndex); + const timeAgoTooltip = findTimeAgoTooltip(expectedRowIndex); + + expect(wrapper.findByText(expectedDeployKey.title).exists()).toBe(true); + expect(wrapper.findByText(expectedDeployKey.fingerprint, { selector: 'code' }).exists()).toBe( + true, + ); + expect(timeAgoTooltip.exists()).toBe(true); + expect(timeAgoTooltip.props('time')).toBe(expectedDeployKey.created_at); + expect(editButton.exists()).toBe(true); + expect(editButton.attributes('href')).toBe(`/admin/deploy_keys/${expectedDeployKey.id}/edit`); + expect(findRemoveButton(expectedRowIndex).exists()).toBe(true); + }; + + const itRendersTheEmptyState = () => { + it('renders empty state', () => { + const emptyState = wrapper.findComponent(GlEmptyState); + + expect(emptyState.exists()).toBe(true); + expect(emptyState.props()).toMatchObject({ + svgPath: defaultProvide.emptyStateSvgPath, + title: DeployKeysTable.i18n.emptyStateTitle, + description: DeployKeysTable.i18n.emptyStateDescription, + primaryButtonText: DeployKeysTable.i18n.newDeployKeyButtonText, + primaryButtonLink: defaultProvide.createPath, + }); }); }; @@ -30,18 +92,149 @@ describe('DeployKeysTable', () => { expect(wrapper.findByText(DeployKeysTable.i18n.pageTitle).exists()).toBe(true); }); - it('renders table', () => { + it('renders `New deploy key` button', () => { createComponent(); - expect(wrapper.findComponent(GlTable).exists()).toBe(true); + const newDeployKeyButton = wrapper.findByTestId('new-deploy-key-button'); + + expect(newDeployKeyButton.exists()).toBe(true); + expect(newDeployKeyButton.attributes('href')).toBe(defaultProvide.createPath); + }); + + describe('when `/deploy_keys` API request is pending', () => { + beforeEach(() => { + Api.deployKeys.mockImplementation(() => new Promise(() => {})); + }); + + it('shows loading icon', async () => { + createComponent(); + + await nextTick(); + + expect(findLoadingIcon().exists()).toBe(true); + }); }); - it('renders `New deploy key` button', () => { - createComponent(); + describe('when `/deploy_keys` API request is successful', () => { + describe('when there are deploy keys', () => { + beforeEach(() => { + Api.deployKeys.mockResolvedValue({ + data: responseBody, + headers: { 'x-total': `${responseBody.length}` }, + }); - const newDeployKeyButton = wrapper.findComponent(GlButton); + createComponent(); + }); - expect(newDeployKeyButton.text()).toBe(DeployKeysTable.i18n.newDeployKeyButtonText); - expect(newDeployKeyButton.attributes('href')).toBe(defaultProvide.createPath); + it('renders deploy keys in table', () => { + expectDeployKeyIsRendered(deployKey, 0); + expectDeployKeyIsRendered(deployKey2, 1); + }); + + describe('when delete button is clicked', () => { + it('asks user to confirm', async () => { + await findRemoveButton(0).trigger('click'); + + const modal = wrapper.findComponent(GlModal); + const form = modal.find('form'); + const submitSpy = jest.spyOn(form.element, 'submit'); + + expect(modal.props('visible')).toBe(true); + expect(form.attributes('action')).toBe(`/admin/deploy_keys/${deployKey.id}`); + expect(form.find('input[name="_method"]').attributes('value')).toBe('delete'); + expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe( + 'mock-csrf-token', + ); + + modal.vm.$emit('primary'); + + expect(submitSpy).toHaveBeenCalled(); + }); + }); + }); + + describe('pagination', () => { + beforeEach(() => { + Api.deployKeys.mockResolvedValueOnce({ + data: [deployKey], + headers: { 'x-total': '2' }, + }); + + createComponent(); + }); + + it('renders pagination', () => { + const pagination = findPagination(); + expect(pagination.exists()).toBe(true); + expect(pagination.props()).toMatchObject({ + value: 1, + perPage: DEFAULT_PER_PAGE, + totalItems: responseBody.length, + nextText: DeployKeysTable.i18n.pagination.next, + prevText: DeployKeysTable.i18n.pagination.prev, + align: 'center', + }); + }); + + describe('when pagination is changed', () => { + it('calls API with `page` parameter', async () => { + const pagination = findPagination(); + expectDeployKeyIsRendered(deployKey, 0); + + Api.deployKeys.mockResolvedValue({ + data: [deployKey2], + headers: { 'x-total': '2' }, + }); + + pagination.vm.$emit('input', 2); + + await nextTick(); + + expect(findLoadingIcon().exists()).toBe(true); + expect(pagination.exists()).toBe(false); + + await waitForPromises(); + + expect(Api.deployKeys).toHaveBeenCalledWith({ + page: 2, + public: true, + }); + expectDeployKeyIsRendered(deployKey2, 0); + }); + }); + }); + + describe('when there are no deploy keys', () => { + beforeEach(() => { + Api.deployKeys.mockResolvedValue({ + data: [], + headers: { 'x-total': '0' }, + }); + + createComponent(); + }); + + itRendersTheEmptyState(); + }); + }); + + describe('when `deploy_keys` API request is unsuccessful', () => { + const error = new Error('Network Error'); + + beforeEach(() => { + Api.deployKeys.mockRejectedValue(error); + + createComponent(); + }); + + itRendersTheEmptyState(); + + it('displays flash', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: DeployKeysTable.i18n.apiErrorMessage, + captureError: true, + error, + }); + }); }); }); diff --git a/spec/frontend/admin/statistics_panel/components/app_spec.js b/spec/frontend/admin/statistics_panel/components/app_spec.js index 9c424491d04..3cfb6feeb86 100644 --- a/spec/frontend/admin/statistics_panel/components/app_spec.js +++ b/spec/frontend/admin/statistics_panel/components/app_spec.js @@ -1,6 +1,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; +import Vue from 'vue'; import Vuex from 'vuex'; import StatisticsPanelApp from '~/admin/statistics_panel/components/app.vue'; import statisticsLabels from '~/admin/statistics_panel/constants'; @@ -9,8 +10,7 @@ import axios from '~/lib/utils/axios_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import mockStatistics from '../mock_data'; -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); describe('Admin statistics app', () => { let wrapper; @@ -19,7 +19,6 @@ describe('Admin statistics app', () => { const createComponent = () => { wrapper = shallowMount(StatisticsPanelApp, { - localVue, store, }); }; diff --git a/spec/frontend/admin/users/components/actions/actions_spec.js b/spec/frontend/admin/users/components/actions/actions_spec.js index 67dcf5c6149..fa485e73999 100644 --- a/spec/frontend/admin/users/components/actions/actions_spec.js +++ b/spec/frontend/admin/users/components/actions/actions_spec.js @@ -1,7 +1,7 @@ import { GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { kebabCase } from 'lodash'; import { nextTick } from 'vue'; +import { kebabCase } from 'lodash'; 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'; @@ -39,9 +39,6 @@ describe('Action components', () => { }); await nextTick(); - - expect(wrapper.attributes('data-path')).toBe('/test'); - expect(wrapper.attributes('data-modal-attributes')).toContain('John Doe'); expect(findDropdownItem().exists()).toBe(true); }); }); @@ -66,7 +63,6 @@ describe('Action components', () => { }); await nextTick(); - const sharedAction = wrapper.find(SharedDeleteAction); expect(sharedAction.attributes('data-block-user-url')).toBe(paths.block); @@ -76,6 +72,7 @@ describe('Action components', () => { expect(sharedAction.attributes('data-user-deletion-obstacles')).toBe( JSON.stringify(userDeletionObstacles), ); + expect(findDropdownItem().exists()).toBe(true); }, ); diff --git a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap index 472158a9b10..7a17ef2cc6c 100644 --- a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap +++ b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap @@ -78,3 +78,83 @@ exports[`User Operation confirmation modal renders modal with form included 1`] </gl-button-stub> </div> `; + +exports[`User Operation confirmation modal when user's name has leading and trailing whitespace displays user's name without whitespace 1`] = ` +<div> + <p> + content + </p> + + <user-deletion-obstacles-list-stub + obstacles="schedule1,policy1" + username="John Smith" + /> + + <p> + To confirm, type + <code + class="gl-white-space-pre-wrap" + > + John Smith + </code> + </p> + + <form + action="delete-url" + method="post" + > + <input + name="_method" + type="hidden" + value="delete" + /> + + <input + name="authenticity_token" + type="hidden" + value="csrf" + /> + + <gl-form-input-stub + autocomplete="off" + autofocus="" + name="username" + type="text" + value="" + /> + </form> + <gl-button-stub + buttontextclasses="" + category="primary" + icon="" + size="medium" + variant="default" + > + Cancel + </gl-button-stub> + + <gl-button-stub + buttontextclasses="" + category="secondary" + disabled="true" + icon="" + size="medium" + variant="danger" + > + + secondaryAction + + </gl-button-stub> + + <gl-button-stub + buttontextclasses="" + category="primary" + disabled="true" + icon="" + size="medium" + variant="danger" + > + action + </gl-button-stub> +</div> +`; diff --git a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js index 82307c9e3b3..025ae825e0d 100644 --- a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js +++ b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js @@ -1,4 +1,4 @@ -import { GlButton, GlFormInput } from '@gitlab/ui'; +import { GlButton, GlFormInput, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import DeleteUserModal from '~/admin/users/components/modals/delete_user_modal.vue'; import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; @@ -35,7 +35,7 @@ describe('User Operation confirmation modal', () => { const badUsername = 'bad_username'; const userDeletionObstacles = '["schedule1", "policy1"]'; - const createComponent = (props = {}) => { + const createComponent = (props = {}, stubs = {}) => { wrapper = shallowMount(DeleteUserModal, { propsData: { username, @@ -51,6 +51,7 @@ describe('User Operation confirmation modal', () => { }, stubs: { GlModal: ModalStub, + ...stubs, }, }); }; @@ -150,6 +151,30 @@ describe('User Operation confirmation modal', () => { }); }); + describe("when user's name has leading and trailing whitespace", () => { + beforeEach(() => { + createComponent( + { + username: ' John Smith ', + }, + { GlSprintf }, + ); + }); + + it("displays user's name without whitespace", () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it("shows enabled buttons when user's name is entered without whitespace", async () => { + setUsername('John Smith'); + + await wrapper.vm.$nextTick(); + + expect(findPrimaryButton().attributes('disabled')).toBeUndefined(); + expect(findSecondaryButton().attributes('disabled')).toBeUndefined(); + }); + }); + describe('Related user-deletion-obstacles list', () => { it('does NOT render the list when user has no related obstacles', () => { createComponent({ userDeletionObstacles: '[]' }); diff --git a/spec/frontend/admin/users/components/users_table_spec.js b/spec/frontend/admin/users/components/users_table_spec.js index 708c9e1979e..9ff5961c7ec 100644 --- a/spec/frontend/admin/users/components/users_table_spec.js +++ b/spec/frontend/admin/users/components/users_table_spec.js @@ -1,5 +1,5 @@ import { GlTable, GlSkeletonLoader } from '@gitlab/ui'; -import { createLocalVue } from '@vue/test-utils'; +import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -16,8 +16,7 @@ import { users, paths, createGroupCountResponse } from '../mock_data'; jest.mock('~/flash'); -const localVue = createLocalVue(); -localVue.use(VueApollo); +Vue.use(VueApollo); describe('AdminUsersTable component', () => { let wrapper; @@ -48,7 +47,6 @@ describe('AdminUsersTable component', () => { const initComponent = (props = {}, resolverMock = fetchGroupCountsResponse) => { wrapper = mountExtended(AdminUsersTable, { - localVue, apolloProvider: createMockApolloProvider(resolverMock), propsData: { users, diff --git a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap index f4d3fd97fd8..ec5b6a5597b 100644 --- a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap +++ b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap @@ -12,6 +12,7 @@ exports[`Alert integration settings form default state should match the default <gl-form-group-stub class="gl-pl-0" labeldescription="" + optionaltext="(optional)" > <gl-form-checkbox-stub checked="true" @@ -28,6 +29,7 @@ exports[`Alert integration settings form default state should match the default label-for="alert-integration-settings-issue-template" label-size="sm" labeldescription="" + optionaltext="(optional)" > <label class="gl-display-inline-flex" @@ -83,6 +85,7 @@ exports[`Alert integration settings form default state should match the default <gl-form-group-stub class="gl-pl-0 gl-mb-5" labeldescription="" + optionaltext="(optional)" > <gl-form-checkbox-stub> <span> @@ -94,6 +97,7 @@ exports[`Alert integration settings form default state should match the default <gl-form-group-stub class="gl-pl-0 gl-mb-5" labeldescription="" + optionaltext="(optional)" > <gl-form-checkbox-stub checked="true" diff --git a/spec/frontend/alerts_settings/components/mocks/apollo_mock.js b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js index 828580a436b..e7ad2cd1d2a 100644 --- a/spec/frontend/alerts_settings/components/mocks/apollo_mock.js +++ b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js @@ -34,6 +34,7 @@ export const updatePrometheusVariables = { export const getIntegrationsQueryResponse = { data: { project: { + id: '1', alertManagementIntegrations: { nodes: [ { diff --git a/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js b/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js index 7c2df3fe8c4..1a331100bb8 100644 --- a/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js +++ b/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js @@ -1,6 +1,7 @@ import { GlAlert } from '@gitlab/ui'; import { GlLineChart } from '@gitlab/ui/dist/charts'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import UsageTrendsCountChart from '~/analytics/usage_trends/components/usage_trends_count_chart.vue'; @@ -9,8 +10,7 @@ import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleto import { mockQueryResponse, mockApolloResponse } from '../apollo_mock_data'; import { mockCountsData1 } from '../mock_data'; -const localVue = createLocalVue(); -localVue.use(VueApollo); +Vue.use(VueApollo); const loadChartErrorMessage = 'My load error message'; const noDataMessage = 'My no data message'; @@ -39,7 +39,6 @@ describe('UsageTrendsCountChart', () => { const createComponent = ({ responseHandler }) => { return shallowMount(UsageTrendsCountChart, { - localVue, apolloProvider: createMockApollo([[statsQuery, responseHandler]]), propsData: { ...mockChartConfig }, }); diff --git a/spec/frontend/analytics/usage_trends/components/users_chart_spec.js b/spec/frontend/analytics/usage_trends/components/users_chart_spec.js index 6adfcca11ac..04ea25a02d5 100644 --- a/spec/frontend/analytics/usage_trends/components/users_chart_spec.js +++ b/spec/frontend/analytics/usage_trends/components/users_chart_spec.js @@ -1,6 +1,7 @@ import { GlAlert } from '@gitlab/ui'; import { GlAreaChart } from '@gitlab/ui/dist/charts'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import UsersChart from '~/analytics/usage_trends/components/users_chart.vue'; @@ -13,8 +14,7 @@ import { roundedSortedCountsMonthlyChartData2, } from '../mock_data'; -const localVue = createLocalVue(); -localVue.use(VueApollo); +Vue.use(VueApollo); describe('UsersChart', () => { let wrapper; @@ -34,7 +34,6 @@ describe('UsersChart', () => { endDate: new Date(2020, 10, 1), totalDataPoints: mockCountsData2.length, }, - localVue, apolloProvider: createMockApollo([[usersQuery, queryHandler]]), data() { return { loadingError }; diff --git a/spec/frontend/api/packages_api_spec.js b/spec/frontend/api/packages_api_spec.js new file mode 100644 index 00000000000..3286dccb1b2 --- /dev/null +++ b/spec/frontend/api/packages_api_spec.js @@ -0,0 +1,53 @@ +import MockAdapter from 'axios-mock-adapter'; +import { publishPackage } from '~/api/packages_api'; +import axios from '~/lib/utils/axios_utils'; +import httpStatus from '~/lib/utils/http_status'; + +describe('Api', () => { + const dummyApiVersion = 'v3000'; + const dummyUrlRoot = '/gitlab'; + const dummyGon = { + api_version: dummyApiVersion, + relative_url_root: dummyUrlRoot, + }; + let originalGon; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + originalGon = window.gon; + window.gon = { ...dummyGon }; + }); + + afterEach(() => { + mock.restore(); + window.gon = originalGon; + }); + + describe('packages', () => { + const projectPath = 'project_a'; + const name = 'foo'; + const packageVersion = '0'; + const apiResponse = [{ id: 1, name: 'foo' }]; + + describe('publishPackage', () => { + it('publishes the package', () => { + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/packages/generic/${name}/${packageVersion}/${name}`; + + jest.spyOn(axios, 'put'); + mock.onPut(expectedUrl).replyOnce(httpStatus.OK, apiResponse); + + return publishPackage( + { projectPath, name, version: 0, fileName: name, files: [{}] }, + { status: 'hidden', select: 'package_file' }, + ).then(({ data }) => { + expect(data).toEqual(apiResponse); + expect(axios.put).toHaveBeenCalledWith(expectedUrl, expect.any(FormData), { + headers: { 'Content-Type': 'multipart/form-data' }, + params: { select: 'package_file', status: 'hidden' }, + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index c3e5a2973d7..75faf6d66fa 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -1,5 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; -import Api from '~/api'; +import Api, { DEFAULT_PER_PAGE } from '~/api'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; @@ -1574,6 +1574,51 @@ describe('Api', () => { }); }); + describe('deployKeys', () => { + it('fetches deploy keys', async () => { + const deployKeys = [ + { + id: 7, + title: 'My title 1', + created_at: '2021-10-29T16:59:55.229Z', + expires_at: null, + key: + 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDLvQzRX960N7dxPdge9o5a96+M4GEGQ7rxT2D3wAQDtQFjQV5ZcKb5wfeLtYLe3kRVI4lCO10PXeQppb1XBaYmVO31IaRkcgmMEPVyfp76Dp4CJZz6aMEbbcqfaHkDre0Fa8kzTXnBJVh2NeDbBfGMjFM5NRQLhKykodNsepO6dQ== dummy@gitlab.com', + fingerprint: '81:93:63:b9:1e:24:a2:aa:e0:87:d3:3f:42:81:f2:c2', + projects_with_write_access: [ + { + id: 11, + description: null, + name: 'project1', + name_with_namespace: 'John Doe3 / project1', + path: 'project1', + path_with_namespace: 'namespace1/project1', + created_at: '2021-10-29T16:59:54.668Z', + }, + { + id: 12, + description: null, + name: 'project2', + name_with_namespace: 'John Doe4 / project2', + path: 'project2', + path_with_namespace: 'namespace2/project2', + created_at: '2021-10-29T16:59:55.116Z', + }, + ], + }, + ]; + + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/deploy_keys`; + mock.onGet(expectedUrl).reply(httpStatus.OK, deployKeys); + + const params = { page: 2, public: true }; + const { data } = await Api.deployKeys(params); + + expect(data).toEqual(deployKeys); + expect(mock.history.get[0].params).toEqual({ ...params, per_page: DEFAULT_PER_PAGE }); + }); + }); + describe('Feature Flag User List', () => { let expectedUrl; let projectId; diff --git a/spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js b/spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js index b0d1b70c198..bfa8274f0eb 100644 --- a/spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js +++ b/spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js @@ -13,6 +13,7 @@ localVue.use(VueApollo); const keepLatestArtifactProjectMock = { data: { project: { + id: '1', ciCdSettings: { keepLatestArtifact: true }, }, }, diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js index 09270174674..c4002ec11f3 100644 --- a/spec/frontend/awards_handler_spec.js +++ b/spec/frontend/awards_handler_spec.js @@ -1,15 +1,12 @@ -import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import Cookies from 'js-cookie'; +import { initEmojiMock, clearEmojiMock } from 'helpers/emoji'; import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; import loadAwardsHandler from '~/awards_handler'; -import { EMOJI_VERSION } from '~/emoji'; -import axios from '~/lib/utils/axios_utils'; window.gl = window.gl || {}; window.gon = window.gon || {}; -let mock; let awardsHandler = null; const urlRoot = gon.relative_url_root; @@ -76,8 +73,7 @@ describe('AwardsHandler', () => { }; beforeEach(async () => { - mock = new MockAdapter(axios); - mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, emojiData); + await initEmojiMock(emojiData); loadFixtures('snippets/show.html'); @@ -89,7 +85,7 @@ describe('AwardsHandler', () => { // restore original url root value gon.relative_url_root = urlRoot; - mock.restore(); + clearEmojiMock(); // Undo what we did to the shared <body> $('body').removeAttr('data-page'); diff --git a/spec/frontend/behaviors/gl_emoji_spec.js b/spec/frontend/behaviors/gl_emoji_spec.js index d23a0a84997..0f4e2e08dbd 100644 --- a/spec/frontend/behaviors/gl_emoji_spec.js +++ b/spec/frontend/behaviors/gl_emoji_spec.js @@ -1,15 +1,13 @@ -import MockAdapter from 'axios-mock-adapter'; +import { initEmojiMock, clearEmojiMock } from 'helpers/emoji'; import waitForPromises from 'helpers/wait_for_promises'; import installGlEmojiElement from '~/behaviors/gl_emoji'; -import { initEmojiMap, EMOJI_VERSION } from '~/emoji'; +import { EMOJI_VERSION } from '~/emoji'; import * as EmojiUnicodeSupport from '~/emoji/support'; -import axios from '~/lib/utils/axios_utils'; jest.mock('~/emoji/support'); describe('gl_emoji', () => { - let mock; const emojiData = { grey_question: { c: 'symbols', @@ -38,15 +36,12 @@ describe('gl_emoji', () => { return div.firstElementChild; } - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, emojiData); - - return initEmojiMap().catch(() => {}); + beforeEach(async () => { + await initEmojiMock(emojiData); }); afterEach(() => { - mock.restore(); + clearEmojiMock(); document.body.innerHTML = ''; }); diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap index dfa6b99080b..46a5631b028 100644 --- a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap +++ b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap @@ -34,6 +34,7 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = ` text="foo/bar/dummy.md" title="Copy file path" tooltipplacement="top" + variant="default" /> </div> `; diff --git a/spec/frontend/blob/viewer/index_spec.js b/spec/frontend/blob/viewer/index_spec.js index 705c4630a68..061ac7ad167 100644 --- a/spec/frontend/blob/viewer/index_spec.js +++ b/spec/frontend/blob/viewer/index_spec.js @@ -28,7 +28,7 @@ describe('Blob viewer', () => { loadFixtures('blob/show_readme.html'); $('#modal-upload-blob').remove(); - mock.onGet(/blob\/master\/README\.md/).reply(200, { + mock.onGet(/blob\/.+\/README\.md/).reply(200, { html: '<div>testing</div>', }); diff --git a/spec/frontend/blob_edit/edit_blob_spec.js b/spec/frontend/blob_edit/edit_blob_spec.js index ebef0656750..9c974e79e6e 100644 --- a/spec/frontend/blob_edit/edit_blob_spec.js +++ b/spec/frontend/blob_edit/edit_blob_spec.js @@ -1,14 +1,29 @@ import waitForPromises from 'helpers/wait_for_promises'; import EditBlob from '~/blob_edit/edit_blob'; +import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext'; import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; +import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext'; import SourceEditor from '~/editor/source_editor'; jest.mock('~/editor/source_editor'); -jest.mock('~/editor/extensions/source_editor_markdown_ext'); +jest.mock('~/editor/extensions/source_editor_extension_base'); jest.mock('~/editor/extensions/source_editor_file_template_ext'); +jest.mock('~/editor/extensions/source_editor_markdown_ext'); +jest.mock('~/editor/extensions/source_editor_markdown_livepreview_ext'); const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown'; +const defaultExtensions = [ + { definition: SourceEditorExtension }, + { definition: FileTemplateExtension }, +]; +const markdownExtensions = [ + { definition: EditorMarkdownExtension }, + { + definition: EditorMarkdownPreviewExtension, + setupOptions: { previewMarkdownPath: PREVIEW_MARKDOWN_PATH }, + }, +]; describe('Blob Editing', () => { const useMock = jest.fn(); @@ -29,7 +44,9 @@ describe('Blob Editing', () => { jest.spyOn(SourceEditor.prototype, 'createInstance').mockReturnValue(mockInstance); }); afterEach(() => { + SourceEditorExtension.mockClear(); EditorMarkdownExtension.mockClear(); + EditorMarkdownPreviewExtension.mockClear(); FileTemplateExtension.mockClear(); }); @@ -45,26 +62,22 @@ describe('Blob Editing', () => { await waitForPromises(); }; - it('loads FileTemplateExtension by default', async () => { + it('loads SourceEditorExtension and FileTemplateExtension by default', async () => { await initEditor(); - expect(useMock).toHaveBeenCalledWith(expect.any(FileTemplateExtension)); - expect(FileTemplateExtension).toHaveBeenCalledTimes(1); + expect(useMock).toHaveBeenCalledWith(defaultExtensions); }); describe('Markdown', () => { - it('does not load MarkdownExtension by default', async () => { + it('does not load MarkdownExtensions by default', async () => { await initEditor(); expect(EditorMarkdownExtension).not.toHaveBeenCalled(); + expect(EditorMarkdownPreviewExtension).not.toHaveBeenCalled(); }); it('loads MarkdownExtension only for the markdown files', async () => { await initEditor(true); - expect(useMock).toHaveBeenCalledWith(expect.any(EditorMarkdownExtension)); - expect(EditorMarkdownExtension).toHaveBeenCalledTimes(1); - expect(EditorMarkdownExtension).toHaveBeenCalledWith({ - instance: mockInstance, - previewMarkdownPath: PREVIEW_MARKDOWN_PATH, - }); + expect(useMock).toHaveBeenCalledTimes(2); + expect(useMock.mock.calls[1]).toEqual([markdownExtensions]); }); }); diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js index 811f0043a01..d0f14bd37c1 100644 --- a/spec/frontend/boards/board_list_helper.js +++ b/spec/frontend/boards/board_list_helper.js @@ -1,4 +1,5 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; import Vuex from 'vuex'; import BoardCard from '~/boards/components/board_card.vue'; @@ -6,7 +7,15 @@ 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, mockGroupProjects } from './mock_data'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; +import { + mockList, + mockIssuesByListId, + issues, + mockGroupProjects, + boardListQueryResponse, +} from './mock_data'; export default function createComponent({ listIssueProps = {}, @@ -15,16 +24,23 @@ export default function createComponent({ actions = {}, getters = {}, provide = {}, + data = {}, state = defaultState, stubs = { BoardNewIssue, BoardNewItem, BoardCard, }, + issuesCount, } = {}) { const localVue = createLocalVue(); + localVue.use(VueApollo); localVue.use(Vuex); + const fakeApollo = createMockApollo([ + [listQuery, jest.fn().mockResolvedValue(boardListQueryResponse(issuesCount))], + ]); + const store = new Vuex.Store({ state: { selectedProject: mockGroupProjects[0], @@ -68,6 +84,7 @@ export default function createComponent({ } const component = shallowMount(BoardList, { + apolloProvider: fakeApollo, localVue, store, propsData: { @@ -87,6 +104,11 @@ export default function createComponent({ ...provide, }, stubs, + data() { + return { + ...data, + }; + }, }); return component; diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index 6f623eab1af..1981ed5ab7f 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -38,7 +38,7 @@ describe('Board list component', () => { describe('When Expanded', () => { beforeEach(() => { - wrapper = createComponent(); + wrapper = createComponent({ issuesCount: 1 }); }); it('renders component', () => { @@ -97,14 +97,6 @@ describe('Board list component', () => { await wrapper.vm.$nextTick(); expect(wrapper.find('.board-list-count').attributes('data-issue-id')).toBe('-1'); }); - - it('shows how many more issues to load', async () => { - wrapper.vm.showCount = true; - wrapper.setProps({ list: { issuesCount: 20 } }); - - await wrapper.vm.$nextTick(); - expect(wrapper.find('.board-list-count').text()).toBe('Showing 1 of 20 issues'); - }); }); describe('load more issues', () => { @@ -113,9 +105,7 @@ describe('Board list component', () => { }; beforeEach(() => { - wrapper = createComponent({ - listProps: { issuesCount: 25 }, - }); + wrapper = createComponent(); }); it('does not load issues if already loading', () => { @@ -131,13 +121,27 @@ describe('Board list component', () => { it('shows loading more spinner', async () => { wrapper = createComponent({ state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } }, + data: { + showCount: true, + }, }); - wrapper.vm.showCount = true; await wrapper.vm.$nextTick(); expect(findIssueCountLoadingIcon().exists()).toBe(true); }); + + it('shows how many more issues to load', async () => { + // wrapper.vm.showCount = true; + wrapper = createComponent({ + data: { + showCount: true, + }, + }); + + await wrapper.vm.$nextTick(); + expect(wrapper.find('.board-list-count').text()).toBe('Showing 1 of 20 issues'); + }); }); describe('max issue count warning', () => { diff --git a/spec/frontend/boards/components/board_content_sidebar_spec.js b/spec/frontend/boards/components/board_content_sidebar_spec.js index 8a8250205d0..7b176cea2a3 100644 --- a/spec/frontend/boards/components/board_content_sidebar_spec.js +++ b/spec/frontend/boards/components/board_content_sidebar_spec.js @@ -1,18 +1,20 @@ import { GlDrawer } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { MountingPortal } from 'portal-vue'; +import Vue from 'vue'; import Vuex from 'vuex'; import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue'; import { stubComponent } from 'helpers/stub_component'; import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue'; -import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { ISSUABLE } from '~/boards/constants'; import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; +import SidebarLabelsWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; import { mockActiveIssue, mockIssue, mockIssueGroupPath, mockIssueProjectPath } from '../mock_data'; +Vue.use(Vuex); describe('BoardContentSidebar', () => { let wrapper; let store; @@ -32,6 +34,7 @@ describe('BoardContentSidebar', () => { groupPathForActiveIssue: () => mockIssueGroupPath, projectPathForActiveIssue: () => mockIssueProjectPath, isSidebarOpen: () => true, + isGroupBoard: () => false, ...mockGetters, }, actions: mockActions, @@ -115,8 +118,8 @@ describe('BoardContentSidebar', () => { expect(wrapper.findComponent(SidebarTodoWidget).exists()).toBe(true); }); - it('renders BoardSidebarLabelsSelect', () => { - expect(wrapper.findComponent(BoardSidebarLabelsSelect).exists()).toBe(true); + it('renders SidebarLabelsWidget', () => { + expect(wrapper.findComponent(SidebarLabelsWidget).exists()).toBe(true); }); it('renders BoardSidebarTitle', () => { diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js index b858d6e95a0..ea551e94f2f 100644 --- a/spec/frontend/boards/components/board_filtered_search_spec.js +++ b/spec/frontend/boards/components/board_filtered_search_spec.js @@ -18,7 +18,7 @@ describe('BoardFilteredSearch', () => { { icon: 'labels', title: __('Label'), - type: 'label_name', + type: 'label', operators: [ { value: '=', description: 'is' }, { value: '!=', description: 'is not' }, @@ -31,7 +31,7 @@ describe('BoardFilteredSearch', () => { { icon: 'pencil', title: __('Author'), - type: 'author_username', + type: 'author', operators: [ { value: '=', description: 'is' }, { value: '!=', description: 'is not' }, @@ -97,7 +97,7 @@ describe('BoardFilteredSearch', () => { createComponent({ props: { eeFilters: { labelName: ['label'] } } }); expect(findFilteredSearch().props('initialFilterValue')).toEqual([ - { type: 'label_name', value: { data: 'label', operator: '=' } }, + { type: 'label', value: { data: 'label', operator: '=' } }, ]); }); }); @@ -117,12 +117,14 @@ describe('BoardFilteredSearch', () => { it('sets the url params to the correct results', async () => { const mockFilters = [ - { 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: 'author', value: { data: 'root', operator: '=' } }, + { type: 'label', value: { data: 'label', operator: '=' } }, + { type: 'label', value: { data: 'label2', operator: '=' } }, + { type: 'milestone', value: { data: 'New Milestone', operator: '=' } }, + { type: 'type', value: { data: 'INCIDENT', operator: '=' } }, { type: 'weight', value: { data: '2', operator: '=' } }, + { type: 'iteration', value: { data: '3341', operator: '=' } }, + { type: 'release', value: { data: 'v1.0.0', operator: '=' } }, ]; jest.spyOn(urlUtility, 'updateHistory'); findFilteredSearch().vm.$emit('onFilter', mockFilters); @@ -131,7 +133,7 @@ describe('BoardFilteredSearch', () => { title: '', replace: true, url: - 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label2&milestone_title=New+Milestone&types=INCIDENT&weight=2', + 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label2&milestone_title=New+Milestone&iteration_id=3341&types=INCIDENT&weight=2&release_tag=v1.0.0', }); }); }); @@ -145,8 +147,8 @@ describe('BoardFilteredSearch', () => { it('passes the correct props to FilterSearchBar', () => { expect(findFilteredSearch().props('initialFilterValue')).toEqual([ - { type: 'author_username', value: { data: 'root', operator: '=' } }, - { type: 'label_name', value: { data: 'label', operator: '=' } }, + { type: 'author', value: { data: 'root', operator: '=' } }, + { type: 'label', value: { data: 'label', operator: '=' } }, ]); }); }); diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js index 0abb00e0fa5..148d0c5684d 100644 --- a/spec/frontend/boards/components/board_list_header_spec.js +++ b/spec/frontend/boards/components/board_list_header_spec.js @@ -1,18 +1,22 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import Vuex from 'vuex'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import { mockLabelList } from 'jest/boards/mock_data'; +import { boardListQueryResponse, mockLabelList } from 'jest/boards/mock_data'; import BoardListHeader from '~/boards/components/board_list_header.vue'; import { ListType } from '~/boards/constants'; +import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; -const localVue = createLocalVue(); - -localVue.use(Vuex); +Vue.use(VueApollo); +Vue.use(Vuex); describe('Board List Header Component', () => { let wrapper; let store; + let fakeApollo; const updateListSpy = jest.fn(); const toggleListCollapsedSpy = jest.fn(); @@ -20,6 +24,7 @@ describe('Board List Header Component', () => { afterEach(() => { wrapper.destroy(); wrapper = null; + fakeApollo = null; localStorage.clear(); }); @@ -29,6 +34,7 @@ describe('Board List Header Component', () => { collapsed = false, withLocalStorage = true, currentUserId = 1, + listQueryHandler = jest.fn().mockResolvedValue(boardListQueryResponse()), } = {}) => { const boardId = '1'; @@ -56,10 +62,12 @@ describe('Board List Header Component', () => { getters: { isEpicBoard: () => false }, }); + fakeApollo = createMockApollo([[listQuery, listQueryHandler]]); + wrapper = extendedWrapper( shallowMount(BoardListHeader, { + apolloProvider: fakeApollo, store, - localVue, propsData: { disabled: false, list: listMock, 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 45c5c87d800..76e8b84d8ef 100644 --- a/spec/frontend/boards/components/issue_board_filtered_search_spec.js +++ b/spec/frontend/boards/components/issue_board_filtered_search_spec.js @@ -1,3 +1,4 @@ +import { orderBy } from 'lodash'; import { shallowMount } from '@vue/test-utils'; import BoardFilteredSearch from 'ee_else_ce/boards/components/board_filtered_search.vue'; import IssueBoardFilteredSpec from '~/boards/components/issue_board_filtered_search.vue'; @@ -16,6 +17,7 @@ describe('IssueBoardFilter', () => { propsData: { fullPath: 'gitlab-org', boardType: 'group' }, provide: { isSignedIn, + releasesFetchPath: '/releases', }, }); }; @@ -61,7 +63,7 @@ describe('IssueBoardFilter', () => { isSignedIn, ); - expect(findBoardsFilteredSearch().props('tokens')).toEqual(tokens); + expect(findBoardsFilteredSearch().props('tokens')).toEqual(orderBy(tokens, ['title'])); }, ); }); 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 deleted file mode 100644 index fb9d823107e..00000000000 --- a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js +++ /dev/null @@ -1,168 +0,0 @@ -import { GlLabel } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { TEST_HOST } from 'helpers/test_constants'; -import { - labels as TEST_LABELS, - mockIssue as TEST_ISSUE, - mockIssueFullPath as TEST_ISSUE_FULLPATH, -} from 'jest/boards/mock_data'; -import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; -import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; -import { createStore } from '~/boards/stores'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; - -const TEST_LABELS_PAYLOAD = TEST_LABELS.map((label) => ({ ...label, set: true })); -const TEST_LABELS_TITLES = TEST_LABELS.map((label) => label.title); - -describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { - let wrapper; - let store; - - afterEach(() => { - wrapper.destroy(); - store = null; - wrapper = null; - }); - - const createWrapper = ({ labels = [], providedValues = {} } = {}) => { - store = createStore(); - store.state.boardItems = { [TEST_ISSUE.id]: { ...TEST_ISSUE, labels } }; - store.state.activeId = TEST_ISSUE.id; - - wrapper = shallowMount(BoardSidebarLabelsSelect, { - store, - provide: { - canUpdate: true, - labelsManagePath: TEST_HOST, - labelsFilterBasePath: TEST_HOST, - ...providedValues, - }, - stubs: { - BoardEditableItem, - LabelsSelect: true, - }, - }); - }; - - const findLabelsSelect = () => wrapper.find({ ref: 'labelsSelect' }); - const findLabelsTitles = () => - wrapper.findAll(GlLabel).wrappers.map((item) => item.props('title')); - const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]'); - - describe('when labelsFetchPath is provided', () => { - it('uses injected labels fetch path', () => { - createWrapper({ providedValues: { labelsFetchPath: 'foobar' } }); - - expect(findLabelsSelect().props('labelsFetchPath')).toEqual('foobar'); - }); - }); - - it('uses the default project label endpoint', () => { - createWrapper(); - - expect(findLabelsSelect().props('labelsFetchPath')).toEqual( - `/${TEST_ISSUE_FULLPATH}/-/labels?include_ancestor_groups=true`, - ); - }); - - it('renders "None" when no labels are selected', () => { - createWrapper(); - - expect(findCollapsed().text()).toBe('None'); - }); - - it('renders labels when set', () => { - createWrapper({ labels: TEST_LABELS }); - - expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES); - }); - - describe('when labels are submitted', () => { - beforeEach(async () => { - createWrapper(); - - jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => TEST_LABELS); - findLabelsSelect().vm.$emit('updateSelectedLabels', TEST_LABELS_PAYLOAD); - store.state.boardItems[TEST_ISSUE.id].labels = TEST_LABELS; - await wrapper.vm.$nextTick(); - }); - - it('collapses sidebar and renders labels', () => { - expect(findCollapsed().isVisible()).toBe(true); - expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES); - }); - - it('commits change to the server', () => { - expect(wrapper.vm.setActiveBoardItemLabels).toHaveBeenCalledWith({ - addLabelIds: TEST_LABELS.map((label) => label.id), - projectPath: TEST_ISSUE_FULLPATH, - removeLabelIds: [], - iid: null, - }); - }); - }); - - describe('when labels are updated over existing labels', () => { - const testLabelsPayload = [ - { id: 5, set: true }, - { id: 6, set: false }, - { id: 7, set: true }, - ]; - const expectedLabels = [{ id: 5 }, { id: 7 }]; - - beforeEach(async () => { - createWrapper({ labels: TEST_LABELS }); - - jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => expectedLabels); - findLabelsSelect().vm.$emit('updateSelectedLabels', testLabelsPayload); - await wrapper.vm.$nextTick(); - }); - - it('commits change to the server', () => { - expect(wrapper.vm.setActiveBoardItemLabels).toHaveBeenCalledWith({ - addLabelIds: [5, 7], - removeLabelIds: [6], - projectPath: TEST_ISSUE_FULLPATH, - iid: null, - }); - }); - }); - - describe('when removing individual labels', () => { - const testLabel = TEST_LABELS[0]; - - beforeEach(async () => { - createWrapper({ labels: [testLabel] }); - - jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => {}); - }); - - it('commits change to the server', () => { - wrapper.find(GlLabel).vm.$emit('close', testLabel); - - expect(wrapper.vm.setActiveBoardItemLabels).toHaveBeenCalledWith({ - removeLabelIds: [getIdFromGraphQLId(testLabel.id)], - projectPath: TEST_ISSUE_FULLPATH, - }); - }); - }); - - describe('when the mutation fails', () => { - beforeEach(async () => { - createWrapper({ labels: TEST_LABELS }); - - jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => { - throw new Error(['failed mutation']); - }); - jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); - findLabelsSelect().vm.$emit('updateSelectedLabels', [{ id: '?' }]); - await wrapper.vm.$nextTick(); - }); - - it('collapses sidebar and renders former issue weight', () => { - expect(findCollapsed().isVisible()).toBe(true); - expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES); - expect(wrapper.vm.setError).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js deleted file mode 100644 index 6e1b528babc..00000000000 --- a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js +++ /dev/null @@ -1,163 +0,0 @@ -import { GlToggle, GlLoadingIcon } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; -import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue'; -import { createStore } from '~/boards/stores'; -import * as types from '~/boards/stores/mutation_types'; -import { mockActiveIssue } from '../../mock_data'; - -Vue.use(Vuex); - -describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () => { - let wrapper; - let store; - - const findNotificationHeader = () => wrapper.find("[data-testid='notification-header-text']"); - const findToggle = () => wrapper.findComponent(GlToggle); - const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - - const createComponent = (activeBoardItem = { ...mockActiveIssue }) => { - store = createStore(); - store.state.boardItems = { [activeBoardItem.id]: activeBoardItem }; - store.state.activeId = activeBoardItem.id; - - wrapper = mount(BoardSidebarSubscription, { - store, - provide: { - emailsDisabled: false, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - store = null; - jest.clearAllMocks(); - }); - - describe('Board sidebar subscription component template', () => { - it('displays "notifications" heading', () => { - createComponent(); - - expect(findNotificationHeader().text()).toBe('Notifications'); - }); - - it('renders toggle with label', () => { - createComponent(); - - expect(findToggle().props('label')).toBe(BoardSidebarSubscription.i18n.header.title); - }); - - it('renders toggle as "off" when currently not subscribed', () => { - createComponent(); - - expect(findToggle().exists()).toBe(true); - expect(findToggle().props('value')).toBe(false); - }); - - it('renders toggle as "on" when currently subscribed', () => { - createComponent({ - ...mockActiveIssue, - subscribed: true, - }); - - expect(findToggle().exists()).toBe(true); - expect(findToggle().props('value')).toBe(true); - }); - - describe('when notification emails have been disabled', () => { - beforeEach(() => { - createComponent({ - ...mockActiveIssue, - emailsDisabled: true, - }); - }); - - it('displays a message that notification have been disabled', () => { - expect(findNotificationHeader().text()).toBe( - 'Notifications have been disabled by the project or group owner', - ); - }); - - it('does not render the toggle button', () => { - expect(findToggle().exists()).toBe(false); - }); - }); - }); - - describe('Board sidebar subscription component `behavior`', () => { - const mockSetActiveIssueSubscribed = (subscribedState) => { - jest.spyOn(wrapper.vm, 'setActiveItemSubscribed').mockImplementation(async () => { - store.commit(types.UPDATE_BOARD_ITEM_BY_ID, { - itemId: mockActiveIssue.id, - prop: 'subscribed', - value: subscribedState, - }); - }); - }; - - it('subscribing to notification', async () => { - createComponent(); - mockSetActiveIssueSubscribed(true); - - expect(findGlLoadingIcon().exists()).toBe(false); - - findToggle().vm.$emit('change'); - - await wrapper.vm.$nextTick(); - - expect(findGlLoadingIcon().exists()).toBe(true); - expect(wrapper.vm.setActiveItemSubscribed).toHaveBeenCalledWith({ - subscribed: true, - projectPath: 'gitlab-org/test-subgroup/gitlab-test', - }); - - await wrapper.vm.$nextTick(); - - expect(findGlLoadingIcon().exists()).toBe(false); - expect(findToggle().props('value')).toBe(true); - }); - - it('unsubscribing from notification', async () => { - createComponent({ - ...mockActiveIssue, - subscribed: true, - }); - mockSetActiveIssueSubscribed(false); - - expect(findGlLoadingIcon().exists()).toBe(false); - - findToggle().vm.$emit('change'); - - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.setActiveItemSubscribed).toHaveBeenCalledWith({ - subscribed: false, - projectPath: 'gitlab-org/test-subgroup/gitlab-test', - }); - expect(findGlLoadingIcon().exists()).toBe(true); - - await wrapper.vm.$nextTick(); - - expect(findGlLoadingIcon().exists()).toBe(false); - expect(findToggle().props('value')).toBe(false); - }); - - it('flashes an error message when setting the subscribed state fails', async () => { - createComponent(); - jest.spyOn(wrapper.vm, 'setActiveItemSubscribed').mockImplementation(async () => { - throw new Error(); - }); - jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); - - findToggle().vm.$emit('change'); - - await wrapper.vm.$nextTick(); - expect(wrapper.vm.setError).toHaveBeenCalled(); - expect(wrapper.vm.setError.mock.calls[0][0].message).toBe( - wrapper.vm.$options.i18n.updateSubscribedErrorMessage, - ); - }); - }); -}); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 8fcad99f8a7..a081a60166b 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -2,12 +2,11 @@ import { GlFilteredSearchToken } from '@gitlab/ui'; import { keyBy } from 'lodash'; import { ListType } from '~/boards/constants'; import { __ } from '~/locale'; -import { DEFAULT_MILESTONES_GRAPHQL } from '~/vue_shared/components/filtered_search_bar/constants'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_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'; +import ReleaseToken from '~/vue_shared/components/filtered_search_bar/tokens/release_token.vue'; export const boardObj = { id: 1, @@ -21,7 +20,6 @@ export const listObj = { position: 0, title: 'Test', list_type: 'label', - weight: 3, label: { id: 5000, title: 'Test', @@ -154,7 +152,6 @@ export const rawIssue = { iid: '27', dueDate: null, timeEstimate: 0, - weight: null, confidential: false, referencePath: 'gitlab-org/test-subgroup/gitlab-test#27', path: '/gitlab-org/test-subgroup/gitlab-test/-/issues/27', @@ -184,7 +181,6 @@ export const mockIssue = { title: 'Issue 1', dueDate: null, timeEstimate: 0, - weight: null, confidential: false, referencePath: `${mockIssueFullPath}#27`, path: `/${mockIssueFullPath}/-/issues/27`, @@ -216,7 +212,6 @@ export const mockIssue2 = { title: 'Issue 2', dueDate: null, timeEstimate: 0, - weight: null, confidential: false, referencePath: 'gitlab-org/test-subgroup/gitlab-test#28', path: '/gitlab-org/test-subgroup/gitlab-test/-/issues/28', @@ -234,7 +229,6 @@ export const mockIssue3 = { referencePath: '#29', dueDate: null, timeEstimate: 0, - weight: null, confidential: false, path: '/gitlab-org/gitlab-test/-/issues/28', assignees, @@ -249,7 +243,6 @@ export const mockIssue4 = { referencePath: '#30', dueDate: null, timeEstimate: 0, - weight: null, confidential: false, path: '/gitlab-org/gitlab-test/-/issues/28', assignees, @@ -551,7 +544,7 @@ export const mockMoveData = { }; export const mockEmojiToken = { - type: 'my_reaction_emoji', + type: 'my-reaction', icon: 'thumb-up', title: 'My-Reaction', unique: true, @@ -559,11 +552,24 @@ export const mockEmojiToken = { fetchEmojis: expect.any(Function), }; -export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones, hasEmoji) => [ +export const mockConfidentialToken = { + type: 'confidential', + icon: 'eye-slash', + title: 'Confidential', + unique: true, + token: GlFilteredSearchToken, + operators: [{ value: '=', description: 'is' }], + options: [ + { icon: 'eye-slash', value: 'yes', title: 'Yes' }, + { icon: 'eye', value: 'no', title: 'No' }, + ], +}; + +export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones, isSignedIn) => [ { icon: 'user', title: __('Assignee'), - type: 'assignee_username', + type: 'assignee', operators: [ { value: '=', description: 'is' }, { value: '!=', description: 'is not' }, @@ -576,7 +582,7 @@ export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones, hasEmoji) { icon: 'pencil', title: __('Author'), - type: 'author_username', + type: 'author', operators: [ { value: '=', description: 'is' }, { value: '!=', description: 'is not' }, @@ -590,7 +596,7 @@ export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones, hasEmoji) { icon: 'labels', title: __('Label'), - type: 'label_name', + type: 'label', operators: [ { value: '=', description: 'is' }, { value: '!=', description: 'is not' }, @@ -600,21 +606,20 @@ export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones, hasEmoji) symbol: '~', fetchLabels, }, - ...(hasEmoji ? [mockEmojiToken] : []), + ...(isSignedIn ? [mockEmojiToken, mockConfidentialToken] : []), { icon: 'clock', title: __('Milestone'), symbol: '%', - type: 'milestone_title', + type: 'milestone', token: MilestoneToken, unique: true, - defaultMilestones: DEFAULT_MILESTONES_GRAPHQL, fetchMilestones, }, { icon: 'issues', title: __('Type'), - type: 'types', + type: 'type', token: GlFilteredSearchToken, unique: true, options: [ @@ -623,11 +628,11 @@ export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones, hasEmoji) ], }, { - icon: 'weight', - title: __('Weight'), - type: 'weight', - token: WeightToken, - unique: true, + type: 'release', + title: __('Release'), + icon: 'rocket', + token: ReleaseToken, + fetchReleases: expect.any(Function), }, ]; @@ -670,3 +675,14 @@ export const mockGroupLabelsResponse = { }, }, }; + +export const boardListQueryResponse = (issuesCount = 20) => ({ + data: { + boardList: { + __typename: 'BoardList', + id: 'gid://gitlab/BoardList/5', + totalWeight: 5, + issuesCount, + }, + }, +}); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index e245325b956..51340a3ea4f 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -20,7 +20,7 @@ import { formatIssue, getMoveData, updateListPosition, -} from '~/boards/boards_util'; +} from 'ee_else_ce/boards/boards_util'; import { gqlClient } from '~/boards/graphql'; import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql'; import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql'; @@ -1241,6 +1241,7 @@ describe('updateIssueOrder', () => { moveBeforeId: undefined, moveAfterId: undefined, }, + update: expect.anything(), }; jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { @@ -1447,6 +1448,7 @@ describe('addListNewIssue', () => { variables: { input: formatIssueInput(mockIssue, stateWithBoardConfig.boardConfig), }, + update: expect.anything(), }); }); @@ -1478,6 +1480,7 @@ describe('addListNewIssue', () => { variables: { input: formatIssueInput(issue, stateWithBoardConfig.boardConfig), }, + update: expect.anything(), }); expect(payload.labelIds).toEqual(['gid://gitlab/GroupLabel/4', 'gid://gitlab/GroupLabel/5']); expect(payload.assigneeIds).toEqual(['gid://gitlab/User/1', 'gid://gitlab/User/2']); @@ -1570,7 +1573,7 @@ describe('addListNewIssue', () => { describe('setActiveIssueLabels', () => { const state = { boardItems: { [mockIssue.id]: mockIssue } }; - const getters = { activeBoardItem: mockIssue }; + const getters = { activeBoardItem: { ...mockIssue, labels } }; const testLabelIds = labels.map((label) => label.id); const input = { labelIds: testLabelIds, @@ -1579,11 +1582,7 @@ describe('setActiveIssueLabels', () => { labels, }; - it('should assign labels on success', (done) => { - jest - .spyOn(gqlClient, 'mutate') - .mockResolvedValue({ data: { updateIssue: { issue: { labels: { nodes: labels } } } } }); - + it('should assign labels', () => { const payload = { itemId: getters.activeBoardItem.id, prop: 'labels', @@ -1601,74 +1600,28 @@ describe('setActiveIssueLabels', () => { }, ], [], - done, ); }); - it('throws error if fails', async () => { - jest - .spyOn(gqlClient, 'mutate') - .mockResolvedValue({ data: { updateIssue: { errors: ['failed mutation'] } } }); - - await expect(actions.setActiveIssueLabels({ getters }, input)).rejects.toThrow(Error); - }); - - describe('labels_widget FF on', () => { - beforeEach(() => { - window.gon = { - features: { labelsWidget: true }, - }; - - getters.activeBoardItem = { ...mockIssue, labels }; - }); - - afterEach(() => { - window.gon = { - features: {}, - }; - }); - - it('should assign labels', () => { - const payload = { - itemId: getters.activeBoardItem.id, - prop: 'labels', - value: labels, - }; - - testAction( - actions.setActiveIssueLabels, - input, - { ...state, ...getters }, - [ - { - type: types.UPDATE_BOARD_ITEM_BY_ID, - payload, - }, - ], - [], - ); - }); - - it('should remove label', () => { - const payload = { - itemId: getters.activeBoardItem.id, - prop: 'labels', - value: [labels[1]], - }; + it('should remove label', () => { + const payload = { + itemId: getters.activeBoardItem.id, + prop: 'labels', + value: [labels[1]], + }; - testAction( - actions.setActiveIssueLabels, - { ...input, removeLabelIds: [getIdFromGraphQLId(labels[0].id)] }, - { ...state, ...getters }, - [ - { - type: types.UPDATE_BOARD_ITEM_BY_ID, - payload, - }, - ], - [], - ); - }); + testAction( + actions.setActiveIssueLabels, + { ...input, removeLabelIds: [getIdFromGraphQLId(labels[0].id)] }, + { ...state, ...getters }, + [ + { + type: types.UPDATE_BOARD_ITEM_BY_ID, + payload, + }, + ], + [], + ); }); }); diff --git a/spec/frontend/ci_lint/components/ci_lint_spec.js b/spec/frontend/ci_lint/components/ci_lint_spec.js index 36d860b1ccd..70d116c12d3 100644 --- a/spec/frontend/ci_lint/components/ci_lint_spec.js +++ b/spec/frontend/ci_lint/components/ci_lint_spec.js @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; import CiLint from '~/ci_lint/components/ci_lint.vue'; import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue'; -import lintCIMutation from '~/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql'; +import lintCIMutation from '~/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql'; import SourceEditor from '~/vue_shared/components/source_editor.vue'; import { mockLintDataValid } from '../mock_data'; 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 5c7404c1175..7c4ff67feb3 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 @@ -1,9 +1,10 @@ import { GlButton, GlFormInput } from '@gitlab/ui'; import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; import Vuex from 'vuex'; +import { mockTracking } from 'helpers/tracking_helper'; import CiEnvironmentsDropdown from '~/ci_variable_list/components/ci_environments_dropdown.vue'; import CiVariableModal from '~/ci_variable_list/components/ci_variable_modal.vue'; -import { AWS_ACCESS_KEY_ID } from '~/ci_variable_list/constants'; +import { AWS_ACCESS_KEY_ID, EVENT_LABEL, EVENT_ACTION } from '~/ci_variable_list/constants'; import createStore from '~/ci_variable_list/store'; import mockData from '../services/mock_data'; import ModalStub from '../stubs'; @@ -14,9 +15,12 @@ localVue.use(Vuex); describe('Ci variable modal', () => { let wrapper; let store; + let trackingSpy; + + const maskableRegex = '^[a-zA-Z0-9_+=/@:.~-]{8,}$'; const createComponent = (method, options = {}) => { - store = createStore({ isGroup: options.isGroup }); + store = createStore({ maskableRegex, isGroup: options.isGroup }); wrapper = method(CiVariableModal, { attachTo: document.body, stubs: { @@ -138,6 +142,7 @@ describe('Ci variable modal', () => { }; createComponent(mount); store.state.variable = invalidKeyVariable; + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); it(`${rendered ? 'renders' : 'does not render'} the variable reference warning`, () => { @@ -226,6 +231,7 @@ describe('Ci variable modal', () => { }; createComponent(mount); store.state.variable = invalidMaskVariable; + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); it('disables the submit button', () => { @@ -235,6 +241,50 @@ describe('Ci variable modal', () => { it('shows the correct error text', () => { expect(findModal().text()).toContain(maskError); }); + + it('sends the correct tracking event', () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTION, { + label: EVENT_LABEL, + property: ';', + }); + }); + }); + + describe.each` + value | secret | masked | eventSent | trackingErrorProperty + ${'value'} | ${'secretValue'} | ${false} | ${0} | ${null} + ${'shortMasked'} | ${'short'} | ${true} | ${0} | ${null} + ${'withDollar$Sign'} | ${'dollar$ign'} | ${false} | ${1} | ${'$'} + ${'withDollar$Sign'} | ${'dollar$ign'} | ${true} | ${1} | ${'$'} + ${'unsupported'} | ${'unsupported|char'} | ${true} | ${1} | ${'|'} + ${'unsupportedMasked'} | ${'unsupported|char'} | ${false} | ${0} | ${null} + `('Adding a new variable', ({ value, secret, masked, eventSent, trackingErrorProperty }) => { + beforeEach(() => { + const [variable] = mockData.mockVariables; + const invalidKeyVariable = { + ...variable, + key: 'key', + value, + secret_value: secret, + masked, + }; + createComponent(mount); + store.state.variable = invalidKeyVariable; + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + it(`${ + eventSent > 0 ? 'sends the correct' : 'does not send the' + } variable validation tracking event`, () => { + expect(trackingSpy).toHaveBeenCalledTimes(eventSent); + + if (eventSent > 0) { + expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTION, { + label: EVENT_LABEL, + property: trackingErrorProperty, + }); + } + }); }); describe('when both states are valid', () => { @@ -249,7 +299,6 @@ describe('Ci variable modal', () => { }; createComponent(mount); store.state.variable = validMaskandKeyVariable; - store.state.maskableRegex = /^[a-zA-Z0-9_+=/@:.~-]{8,}$/; }); it('does not disable the submit button', () => { diff --git a/spec/frontend/clusters/agents/components/activity_events_list_spec.js b/spec/frontend/clusters/agents/components/activity_events_list_spec.js new file mode 100644 index 00000000000..4abbd77dfb7 --- /dev/null +++ b/spec/frontend/clusters/agents/components/activity_events_list_spec.js @@ -0,0 +1,102 @@ +import { GlLoadingIcon, GlAlert, GlEmptyState } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { useFakeDate } from 'helpers/fake_date'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import ActivityEvents from '~/clusters/agents/components/activity_events_list.vue'; +import ActivityHistoryItem from '~/clusters/agents/components/activity_history_item.vue'; +import getAgentActivityEventsQuery from '~/clusters/agents/graphql/queries/get_agent_activity_events.query.graphql'; +import { mockResponse, mockEmptyResponse } from '../../mock_data'; + +const activityEmptyStateImage = '/path/to/image'; +const projectPath = 'path/to/project'; +const agentName = 'cluster-agent'; + +Vue.use(VueApollo); + +describe('ActivityEvents', () => { + let wrapper; + useFakeDate([2021, 12, 3]); + + const provideData = { + agentName, + projectPath, + activityEmptyStateImage, + }; + + const createWrapper = ({ queryResponse = null } = {}) => { + const agentEventsQueryResponse = queryResponse || jest.fn().mockResolvedValue(mockResponse); + const apolloProvider = createMockApollo([ + [getAgentActivityEventsQuery, agentEventsQueryResponse], + ]); + + wrapper = shallowMountExtended(ActivityEvents, { + apolloProvider, + provide: provideData, + }); + }; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findAlert = () => wrapper.findComponent(GlAlert); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findAllActivityHistoryItems = () => wrapper.findAllComponents(ActivityHistoryItem); + const findSectionTitle = (at) => wrapper.findAllByTestId('activity-section-title').at(at); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('while the agentEvents query is loading', () => { + it('displays a loading icon', async () => { + createWrapper(); + + expect(findLoadingIcon().exists()).toBe(true); + await waitForPromises(); + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('when the agentEvents query has errored', () => { + beforeEach(() => { + createWrapper({ queryResponse: jest.fn().mockRejectedValue() }); + return waitForPromises(); + }); + + it('displays an alert message', () => { + expect(findAlert().exists()).toBe(true); + }); + }); + + describe('when there are no agentEvents', () => { + beforeEach(() => { + createWrapper({ queryResponse: jest.fn().mockResolvedValue(mockEmptyResponse) }); + }); + + it('displays an empty state with the correct illustration', () => { + expect(findEmptyState().exists()).toBe(true); + expect(findEmptyState().props('svgPath')).toBe(activityEmptyStateImage); + }); + }); + + describe('when the agentEvents are present', () => { + const length = mockResponse.data?.project?.clusterAgent?.activityEvents?.nodes?.length; + + beforeEach(() => { + createWrapper(); + }); + it('renders an activity-history-item components for every event', () => { + expect(findAllActivityHistoryItems()).toHaveLength(length); + }); + + it.each` + recordedAt | date | lineNumber + ${'2021-12-03T01:06:56Z'} | ${'Today'} | ${0} + ${'2021-12-02T19:26:56Z'} | ${'Yesterday'} | ${1} + ${'2021-11-22T19:26:56Z'} | ${'2021-11-22'} | ${2} + `('renders correct titles for different days', ({ date, lineNumber }) => { + expect(findSectionTitle(lineNumber).text()).toBe(date); + }); + }); +}); diff --git a/spec/frontend/clusters/agents/components/activity_history_item_spec.js b/spec/frontend/clusters/agents/components/activity_history_item_spec.js new file mode 100644 index 00000000000..100a280d0cc --- /dev/null +++ b/spec/frontend/clusters/agents/components/activity_history_item_spec.js @@ -0,0 +1,56 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { sprintf } from '~/locale'; +import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import ActivityHistoryItem from '~/clusters/agents/components/activity_history_item.vue'; +import { EVENT_DETAILS, DEFAULT_ICON } from '~/clusters/agents/constants'; +import { mockAgentHistoryActivityItems } from '../../mock_data'; + +const agentName = 'cluster-agent'; + +describe('ActivityHistoryItem', () => { + let wrapper; + + const createWrapper = ({ event = {} }) => { + wrapper = shallowMount(ActivityHistoryItem, { + propsData: { event }, + stubs: { + HistoryItem, + GlSprintf, + }, + }); + }; + + const findHistoryItem = () => wrapper.findComponent(HistoryItem); + const findTimeAgo = () => wrapper.find(TimeAgoTooltip); + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each` + kind | icon | title | lineNumber + ${'token_created'} | ${EVENT_DETAILS.token_created.eventTypeIcon} | ${sprintf(EVENT_DETAILS.token_created.title, { tokenName: agentName })} | ${0} + ${'token_revoked'} | ${EVENT_DETAILS.token_revoked.eventTypeIcon} | ${sprintf(EVENT_DETAILS.token_revoked.title, { tokenName: agentName })} | ${1} + ${'agent_connected'} | ${EVENT_DETAILS.agent_connected.eventTypeIcon} | ${sprintf(EVENT_DETAILS.agent_connected.title, { titleIcon: '' })} | ${2} + ${'agent_disconnected'} | ${EVENT_DETAILS.agent_disconnected.eventTypeIcon} | ${sprintf(EVENT_DETAILS.agent_disconnected.title, { titleIcon: '' })} | ${3} + ${'agent_connected'} | ${EVENT_DETAILS.agent_connected.eventTypeIcon} | ${sprintf(EVENT_DETAILS.agent_connected.title, { titleIcon: '' })} | ${4} + ${'unknown_agent'} | ${DEFAULT_ICON} | ${'unknown_agent Event occurred'} | ${5} + `('when the event type is $kind event', ({ icon, title, lineNumber }) => { + beforeEach(() => { + const event = mockAgentHistoryActivityItems[lineNumber]; + createWrapper({ event }); + }); + it('renders the correct icon', () => { + expect(findHistoryItem().props('icon')).toBe(icon); + }); + it('renders the correct title', () => { + expect(findHistoryItem().text()).toContain(title); + }); + it('renders the correct time-ago tooltip', () => { + const activityEvents = mockAgentHistoryActivityItems; + expect(findTimeAgo().props('time')).toBe(activityEvents[lineNumber].recordedAt); + }); + }); +}); diff --git a/spec/frontend/clusters/agents/components/show_spec.js b/spec/frontend/clusters/agents/components/show_spec.js index c502e7d813e..d5a8117f48c 100644 --- a/spec/frontend/clusters/agents/components/show_spec.js +++ b/spec/frontend/clusters/agents/components/show_spec.js @@ -5,6 +5,7 @@ import VueApollo from 'vue-apollo'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import ClusterAgentShow from '~/clusters/agents/components/show.vue'; import TokenTable from '~/clusters/agents/components/token_table.vue'; +import ActivityEvents from '~/clusters/agents/components/activity_events_list.vue'; import getAgentQuery from '~/clusters/agents/graphql/queries/get_cluster_agent.query.graphql'; import { useFakeDate } from 'helpers/fake_date'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -27,6 +28,7 @@ describe('ClusterAgentShow', () => { id: '1', createdAt: '2021-02-13T00:00:00Z', createdByUser: { + id: 'user-1', name: 'user-1', }, name: 'token-1', @@ -39,7 +41,8 @@ describe('ClusterAgentShow', () => { const createWrapper = ({ clusterAgent, queryResponse = null }) => { const agentQueryResponse = - queryResponse || jest.fn().mockResolvedValue({ data: { project: { clusterAgent } } }); + queryResponse || + jest.fn().mockResolvedValue({ data: { project: { id: 'project-1', clusterAgent } } }); const apolloProvider = createMockApollo([[getAgentQuery, agentQueryResponse]]); wrapper = extendedWrapper( @@ -70,6 +73,7 @@ describe('ClusterAgentShow', () => { const findPaginationButtons = () => wrapper.findComponent(GlKeysetPagination); const findTokenCount = () => wrapper.findByTestId('cluster-agent-token-count').text(); const findEESecurityTabSlot = () => wrapper.findByTestId('ee-security-tab'); + const findActivity = () => wrapper.findComponent(ActivityEvents); afterEach(() => { wrapper.destroy(); @@ -101,6 +105,10 @@ describe('ClusterAgentShow', () => { it('should not render pagination buttons when there are no additional pages', () => { expect(findPaginationButtons().exists()).toBe(false); }); + + it('renders activity events list', () => { + expect(findActivity().exists()).toBe(true); + }); }); describe('when create user is unknown', () => { diff --git a/spec/frontend/clusters/mock_data.js b/spec/frontend/clusters/mock_data.js new file mode 100644 index 00000000000..75306ca0295 --- /dev/null +++ b/spec/frontend/clusters/mock_data.js @@ -0,0 +1,165 @@ +const user = { + id: 1, + name: 'Administrator', + username: 'root', + webUrl: 'http://172.31.0.1:3000/root', +}; + +const agentToken = { + id: 1, + name: 'cluster-agent', +}; + +export const defaultActivityEvent = { + kind: 'unknown_agent', + level: 'info', + recordedAt: '2021-11-22T19:26:56Z', + agentToken, + user, +}; + +export const mockAgentActivityEvents = [ + { + kind: 'token_created', + level: 'info', + recordedAt: '2021-12-03T01:06:56Z', + agentToken, + user, + }, + + { + kind: 'token_revoked', + level: 'info', + recordedAt: '2021-12-03T00:26:56Z', + agentToken, + user, + }, + + { + kind: 'agent_connected', + level: 'info', + recordedAt: '2021-12-02T19:26:56Z', + agentToken, + user, + }, + + { + kind: 'agent_disconnected', + level: 'info', + recordedAt: '2021-12-02T19:26:56Z', + agentToken, + user, + }, + + { + kind: 'agent_connected', + level: 'info', + recordedAt: '2021-11-22T19:26:56Z', + agentToken, + user, + }, + + { + kind: 'unknown_agent', + level: 'info', + recordedAt: '2021-11-22T19:26:56Z', + agentToken, + user, + }, +]; + +export const mockResponse = { + data: { + project: { + id: 'project-1', + clusterAgent: { + id: 'cluster-agent', + activityEvents: { + nodes: mockAgentActivityEvents, + }, + }, + }, + }, +}; + +export const mockEmptyResponse = { + data: { + project: { + id: 'project-1', + clusterAgent: { + id: 'cluster-agent', + activityEvents: { + nodes: [], + }, + }, + }, + }, +}; + +export const mockAgentHistoryActivityItems = [ + { + kind: 'token_created', + level: 'info', + recordedAt: '2021-12-03T01:06:56Z', + agentToken, + user, + eventTypeIcon: 'token', + title: 'cluster-agent created', + body: 'Token created by Administrator', + }, + + { + kind: 'token_revoked', + level: 'info', + recordedAt: '2021-12-03T00:26:56Z', + agentToken, + user, + eventTypeIcon: 'token', + title: 'cluster-agent revoked', + body: 'Token revoked by Administrator', + }, + + { + kind: 'agent_connected', + level: 'info', + recordedAt: '2021-12-02T19:26:56Z', + agentToken, + user, + eventTypeIcon: 'connected', + title: 'Connected', + body: 'Agent Connected', + }, + + { + kind: 'agent_disconnected', + level: 'info', + recordedAt: '2021-12-02T19:26:56Z', + agentToken, + user, + eventTypeIcon: 'connected', + title: 'Not connected', + body: 'Agent Not connected', + }, + + { + kind: 'agent_connected', + level: 'info', + recordedAt: '2021-11-22T19:26:56Z', + agentToken, + user, + eventTypeIcon: 'connected', + title: 'Connected', + body: 'Agent Connected', + }, + + { + kind: 'unknown_agent', + level: 'info', + recordedAt: '2021-11-22T19:26:56Z', + agentToken, + user, + eventTypeIcon: 'token', + title: 'unknown_agent', + body: 'Event occurred', + }, +]; diff --git a/spec/frontend/clusters_list/components/agent_empty_state_spec.js b/spec/frontend/clusters_list/components/agent_empty_state_spec.js index 38f0e0ba2c4..ed2a0d0b97b 100644 --- a/spec/frontend/clusters_list/components/agent_empty_state_spec.js +++ b/spec/frontend/clusters_list/components/agent_empty_state_spec.js @@ -1,34 +1,29 @@ -import { GlAlert, GlEmptyState, GlSprintf } from '@gitlab/ui'; +import { GlEmptyState, GlSprintf, GlLink, GlButton } from '@gitlab/ui'; import AgentEmptyState from '~/clusters_list/components/agent_empty_state.vue'; +import { INSTALL_AGENT_MODAL_ID } from '~/clusters_list/constants'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { helpPagePath } from '~/helpers/help_page_helper'; const emptyStateImage = '/path/to/image'; -const projectPath = 'path/to/project'; -const multipleClustersDocsUrl = helpPagePath('user/project/clusters/multiple_kubernetes_clusters'); -const installDocsUrl = helpPagePath('administration/clusters/kas'); +const installDocsUrl = helpPagePath('user/clusters/agent/index'); describe('AgentEmptyStateComponent', () => { let wrapper; - - const propsData = { - hasConfigurations: false, - }; const provideData = { emptyStateImage, - projectPath, }; - const findConfigurationsAlert = () => wrapper.findComponent(GlAlert); - const findMultipleClustersDocsLink = () => wrapper.findByTestId('multiple-clusters-docs-link'); - const findInstallDocsLink = () => wrapper.findByTestId('install-docs-link'); - const findIntegrationButton = () => wrapper.findByTestId('integration-primary-button'); + const findInstallDocsLink = () => wrapper.findComponent(GlLink); + const findIntegrationButton = () => wrapper.findComponent(GlButton); const findEmptyState = () => wrapper.findComponent(GlEmptyState); beforeEach(() => { wrapper = shallowMountExtended(AgentEmptyState, { - propsData, provide: provideData, + directives: { + GlModalDirective: createMockDirective(), + }, stubs: { GlEmptyState, GlSprintf }, }); }); @@ -39,33 +34,21 @@ describe('AgentEmptyStateComponent', () => { } }); - it('renders correct href attributes for the links', () => { - expect(findMultipleClustersDocsLink().attributes('href')).toBe(multipleClustersDocsUrl); - expect(findInstallDocsLink().attributes('href')).toBe(installDocsUrl); + it('renders the empty state', () => { + expect(findEmptyState().exists()).toBe(true); }); - describe('when there are no agent configurations in repository', () => { - it('should render notification message box', () => { - expect(findConfigurationsAlert().exists()).toBe(true); - }); + it('renders button for the agent registration', () => { + expect(findIntegrationButton().exists()).toBe(true); + }); - it('should disable integration button', () => { - expect(findIntegrationButton().attributes('disabled')).toBe('true'); - }); + it('renders correct href attributes for the docs link', () => { + expect(findInstallDocsLink().attributes('href')).toBe(installDocsUrl); }); - describe('when there is a list of agent configurations', () => { - beforeEach(() => { - propsData.hasConfigurations = true; - wrapper = shallowMountExtended(AgentEmptyState, { - propsData, - provide: provideData, - }); - }); - it('should render content without notification message box', () => { - expect(findEmptyState().exists()).toBe(true); - expect(findConfigurationsAlert().exists()).toBe(false); - expect(findIntegrationButton().attributes('disabled')).toBeUndefined(); - }); + it('renders correct modal id for the agent registration modal', () => { + const binding = getBinding(findIntegrationButton().element, 'gl-modal-directive'); + + expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID); }); }); diff --git a/spec/frontend/clusters_list/components/agents_spec.js b/spec/frontend/clusters_list/components/agents_spec.js index 2dec7cdc973..c9ca10f6bf7 100644 --- a/spec/frontend/clusters_list/components/agents_spec.js +++ b/spec/frontend/clusters_list/components/agents_spec.js @@ -19,7 +19,6 @@ describe('Agents', () => { }; const provideData = { projectPath: 'path/to/project', - kasAddress: 'kas.example.com', }; const createWrapper = ({ props = {}, agents = [], pageInfo = null, trees = [], count = 0 }) => { @@ -27,6 +26,7 @@ describe('Agents', () => { const apolloQueryResponse = { data: { project: { + id: '1', clusterAgents: { nodes: agents, pageInfo, tokens: { nodes: [] }, count }, repository: { tree: { trees: { nodes: trees, pageInfo } } }, }, @@ -76,6 +76,7 @@ describe('Agents', () => { tokens: { nodes: [ { + id: 'token-1', lastUsedAt: testDate, }, ], @@ -87,6 +88,7 @@ describe('Agents', () => { const trees = [ { + id: 'tree-1', name: 'agent-2', path: '.gitlab/agents/agent-2', webPath: '/project/path/.gitlab/agents/agent-2', @@ -216,24 +218,6 @@ describe('Agents', () => { }); }); - describe('when the agent configurations are present', () => { - const trees = [ - { - name: 'agent-1', - path: '.gitlab/agents/agent-1', - webPath: '/project/path/.gitlab/agents/agent-1', - }, - ]; - - beforeEach(() => { - return createWrapper({ agents: [], trees }); - }); - - it('should pass the correct hasConfigurations boolean value to empty state component', () => { - expect(findEmptyState().props('hasConfigurations')).toEqual(true); - }); - }); - describe('when agents query has errored', () => { beforeEach(() => { return createWrapper({ agents: null }); diff --git a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js index 40c2c59e187..bcc1d4e8b9e 100644 --- a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js +++ b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js @@ -1,14 +1,7 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { createLocalVue, mount } from '@vue/test-utils'; -import VueApollo from 'vue-apollo'; +import { shallowMount } from '@vue/test-utils'; import AvailableAgentsDropdown from '~/clusters_list/components/available_agents_dropdown.vue'; import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '~/clusters_list/constants'; -import agentConfigurationsQuery from '~/clusters_list/graphql/queries/agent_configurations.query.graphql'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { agentConfigurationsResponse } from './mock_data'; - -const localVue = createLocalVue(); -localVue.use(VueApollo); describe('AvailableAgentsDropdown', () => { let wrapper; @@ -18,46 +11,19 @@ describe('AvailableAgentsDropdown', () => { const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); const findConfiguredAgentItem = () => findDropdownItems().at(0); - const createWrapper = ({ propsData = {}, isLoading = false }) => { - const provide = { - projectPath: 'path/to/project', - }; - - wrapper = (() => { - if (isLoading) { - const mocks = { - $apollo: { - queries: { - agents: { - loading: true, - }, - }, - }, - }; - - return mount(AvailableAgentsDropdown, { mocks, provide, propsData }); - } - - const apolloProvider = createMockApollo([ - [agentConfigurationsQuery, jest.fn().mockResolvedValue(agentConfigurationsResponse)], - ]); - - return mount(AvailableAgentsDropdown, { - localVue, - apolloProvider, - provide, - propsData, - }); - })(); + const createWrapper = ({ propsData }) => { + wrapper = shallowMount(AvailableAgentsDropdown, { + propsData, + }); }; afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('there are agents available', () => { const propsData = { + availableAgents: ['configured-agent'], isRegistering: false, }; @@ -69,12 +35,6 @@ describe('AvailableAgentsDropdown', () => { expect(findDropdown().props('text')).toBe(i18n.selectAgent); }); - it('shows only agents that are not yet installed', () => { - expect(findDropdownItems()).toHaveLength(1); - expect(findConfiguredAgentItem().text()).toBe('configured-agent'); - expect(findConfiguredAgentItem().props('isChecked')).toBe(false); - }); - describe('click events', () => { beforeEach(() => { findConfiguredAgentItem().vm.$emit('click'); @@ -93,6 +53,7 @@ describe('AvailableAgentsDropdown', () => { describe('registration in progress', () => { const propsData = { + availableAgents: ['configured-agent'], isRegistering: true, }; @@ -108,22 +69,4 @@ describe('AvailableAgentsDropdown', () => { expect(findDropdown().props('loading')).toBe(true); }); }); - - describe('agents query is loading', () => { - const propsData = { - isRegistering: false, - }; - - beforeEach(() => { - createWrapper({ propsData, isLoading: true }); - }); - - it('updates the text in the dropdown', () => { - expect(findDropdown().text()).toBe(i18n.selectAgent); - }); - - it('displays a loading icon', () => { - expect(findDropdown().props('loading')).toBe(true); - }); - }); }); diff --git a/spec/frontend/clusters_list/components/clusters_empty_state_spec.js b/spec/frontend/clusters_list/components/clusters_empty_state_spec.js index f7e1791d0f7..cf0f6881960 100644 --- a/spec/frontend/clusters_list/components/clusters_empty_state_spec.js +++ b/spec/frontend/clusters_list/components/clusters_empty_state_spec.js @@ -6,35 +6,33 @@ import ClusterStore from '~/clusters_list/store'; const clustersEmptyStateImage = 'path/to/svg'; const newClusterPath = '/path/to/connect/cluster'; const emptyStateHelpText = 'empty state text'; -const canAddCluster = true; describe('ClustersEmptyStateComponent', () => { let wrapper; - const propsData = { - isChildComponent: false, - }; - - const provideData = { + const defaultProvideData = { clustersEmptyStateImage, - emptyStateHelpText: null, newClusterPath, }; - const entryData = { - canAddCluster, - }; - const findButton = () => wrapper.findComponent(GlButton); const findEmptyStateText = () => wrapper.findByTestId('clusters-empty-state-text'); - beforeEach(() => { + const createWrapper = ({ + provideData = { emptyStateHelpText: null }, + isChildComponent = false, + canAddCluster = true, + } = {}) => { wrapper = shallowMountExtended(ClustersEmptyState, { - store: ClusterStore(entryData), - propsData, - provide: provideData, + store: ClusterStore({ canAddCluster }), + propsData: { isChildComponent }, + provide: { ...defaultProvideData, ...provideData }, stubs: { GlEmptyState }, }); + }; + + beforeEach(() => { + createWrapper(); }); afterEach(() => { @@ -55,16 +53,7 @@ describe('ClustersEmptyStateComponent', () => { describe('when the component is loaded as a child component', () => { beforeEach(() => { - propsData.isChildComponent = true; - wrapper = shallowMountExtended(ClustersEmptyState, { - store: ClusterStore(entryData), - propsData, - provide: provideData, - }); - }); - - afterEach(() => { - propsData.isChildComponent = false; + createWrapper({ isChildComponent: true }); }); it('should not render the action button', () => { @@ -74,12 +63,7 @@ describe('ClustersEmptyStateComponent', () => { describe('when the help text is provided', () => { beforeEach(() => { - provideData.emptyStateHelpText = emptyStateHelpText; - wrapper = shallowMountExtended(ClustersEmptyState, { - store: ClusterStore(entryData), - propsData, - provide: provideData, - }); + createWrapper({ provideData: { emptyStateHelpText } }); }); it('should show the empty state text', () => { @@ -88,14 +72,8 @@ describe('ClustersEmptyStateComponent', () => { }); describe('when the user cannot add clusters', () => { - entryData.canAddCluster = false; beforeEach(() => { - wrapper = shallowMountExtended(ClustersEmptyState, { - store: ClusterStore(entryData), - propsData, - provide: provideData, - stubs: { GlEmptyState }, - }); + createWrapper({ canAddCluster: false }); }); it('should disable the button', () => { expect(findButton().props('disabled')).toBe(true); diff --git a/spec/frontend/clusters_list/components/clusters_main_view_spec.js b/spec/frontend/clusters_list/components/clusters_main_view_spec.js index c2233e5d39c..37665bf7abd 100644 --- a/spec/frontend/clusters_list/components/clusters_main_view_spec.js +++ b/spec/frontend/clusters_list/components/clusters_main_view_spec.js @@ -1,5 +1,6 @@ import { GlTabs, GlTab } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking } from 'helpers/tracking_helper'; import ClustersMainView from '~/clusters_list/components/clusters_main_view.vue'; import InstallAgentModal from '~/clusters_list/components/install_agent_modal.vue'; import { @@ -8,12 +9,15 @@ import { CLUSTERS_TABS, MAX_CLUSTERS_LIST, MAX_LIST_COUNT, + EVENT_LABEL_TABS, + EVENT_ACTIONS_CHANGE, } from '~/clusters_list/constants'; const defaultBranchName = 'default-branch'; describe('ClustersMainViewComponent', () => { let wrapper; + let trackingSpy; const propsData = { defaultBranchName, @@ -23,6 +27,7 @@ describe('ClustersMainViewComponent', () => { wrapper = shallowMountExtended(ClustersMainView, { propsData, }); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); afterEach(() => { @@ -54,10 +59,10 @@ describe('ClustersMainViewComponent', () => { describe('tabs', () => { it.each` - tabTitle | queryParamValue | lineNumber - ${'All'} | ${'all'} | ${0} - ${'Agent'} | ${AGENT} | ${1} - ${'Certificate based'} | ${CERTIFICATE_BASED} | ${2} + tabTitle | queryParamValue | lineNumber + ${'All'} | ${'all'} | ${0} + ${'Agent'} | ${AGENT} | ${1} + ${'Certificate'} | ${CERTIFICATE_BASED} | ${2} `( 'renders correct tab title and query param value', ({ tabTitle, queryParamValue, lineNumber }) => { @@ -71,6 +76,7 @@ describe('ClustersMainViewComponent', () => { beforeEach(() => { findComponent().vm.$emit('changeTab', AGENT); }); + it('changes the tab', () => { expect(findTabs().attributes('value')).toBe('1'); }); @@ -78,5 +84,13 @@ describe('ClustersMainViewComponent', () => { it('passes correct max-agents param to the modal', () => { expect(findModal().props('maxAgents')).toBe(MAX_LIST_COUNT); }); + + it('sends the correct tracking event', () => { + findTabs().vm.$emit('input', 1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTIONS_CHANGE, { + label: EVENT_LABEL_TABS, + property: AGENT, + }); + }); }); }); diff --git a/spec/frontend/clusters_list/components/install_agent_modal_spec.js b/spec/frontend/clusters_list/components/install_agent_modal_spec.js index 6c2ea45b99b..4d1429c9e50 100644 --- a/spec/frontend/clusters_list/components/install_agent_modal_spec.js +++ b/spec/frontend/clusters_list/components/install_agent_modal_spec.js @@ -1,10 +1,21 @@ import { GlAlert, GlButton, GlFormInputGroup } from '@gitlab/ui'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking } from 'helpers/tracking_helper'; import AvailableAgentsDropdown from '~/clusters_list/components/available_agents_dropdown.vue'; import InstallAgentModal from '~/clusters_list/components/install_agent_modal.vue'; -import { I18N_INSTALL_AGENT_MODAL, MAX_LIST_COUNT } from '~/clusters_list/constants'; +import { + I18N_AGENT_MODAL, + MAX_LIST_COUNT, + EVENT_LABEL_MODAL, + EVENT_ACTIONS_OPEN, + EVENT_ACTIONS_SELECT, + MODAL_TYPE_EMPTY, + MODAL_TYPE_REGISTER, +} from '~/clusters_list/constants'; import getAgentsQuery from '~/clusters_list/graphql/queries/get_agents.query.graphql'; +import getAgentConfigurations from '~/clusters_list/graphql/queries/agent_configurations.query.graphql'; import createAgentMutation from '~/clusters_list/graphql/mutations/create_agent.mutation.graphql'; import createAgentTokenMutation from '~/clusters_list/graphql/mutations/create_agent_token.mutation.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -23,14 +34,28 @@ const localVue = createLocalVue(); localVue.use(VueApollo); const projectPath = 'path/to/project'; +const kasAddress = 'kas.example.com'; +const kasEnabled = true; +const emptyStateImage = 'path/to/image'; const defaultBranchName = 'default'; const maxAgents = MAX_LIST_COUNT; describe('InstallAgentModal', () => { let wrapper; let apolloProvider; + let trackingSpy; + + const configurations = [{ agentName: 'agent-name' }]; + const apolloQueryResponse = { + data: { + project: { + id: '1', + clusterAgents: { nodes: [] }, + agentConfigurations: { nodes: configurations }, + }, + }, + }; - const i18n = I18N_INSTALL_AGENT_MODAL; const findModal = () => wrapper.findComponent(ModalStub); const findAgentDropdown = () => findModal().findComponent(AvailableAgentsDropdown); const findAlert = () => findModal().findComponent(GlAlert); @@ -40,6 +65,8 @@ describe('InstallAgentModal', () => { .wrappers.find((button) => button.props('variant') === variant); const findActionButton = () => findButtonByVariant('confirm'); const findCancelButton = () => findButtonByVariant('default'); + const findSecondaryButton = () => wrapper.findByTestId('agent-secondary-button'); + const findImage = () => wrapper.findByRole('img', { alt: I18N_AGENT_MODAL.empty_state.altText }); const expectDisabledAttribute = (element, disabled) => { if (disabled) { @@ -52,7 +79,9 @@ describe('InstallAgentModal', () => { const createWrapper = () => { const provide = { projectPath, - kasAddress: 'kas.example.com', + kasAddress, + kasEnabled, + emptyStateImage, }; const propsData = { @@ -60,7 +89,7 @@ describe('InstallAgentModal', () => { maxAgents, }; - wrapper = shallowMount(InstallAgentModal, { + wrapper = shallowMountExtended(InstallAgentModal, { attachTo: document.body, stubs: { GlModal: ModalStub, @@ -85,10 +114,12 @@ describe('InstallAgentModal', () => { }); }; - const mockSelectedAgentResponse = () => { + const mockSelectedAgentResponse = async () => { createWrapper(); writeQuery(); + await wrapper.vm.$nextTick(); + wrapper.vm.setAgentName('agent-name'); findActionButton().vm.$emit('click'); @@ -96,120 +127,182 @@ describe('InstallAgentModal', () => { }; beforeEach(() => { + apolloProvider = createMockApollo([ + [getAgentConfigurations, jest.fn().mockResolvedValue(apolloQueryResponse)], + ]); createWrapper(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); afterEach(() => { wrapper.destroy(); - wrapper = null; apolloProvider = null; }); - describe('initial state', () => { - it('renders the dropdown for available agents', () => { - expect(findAgentDropdown().isVisible()).toBe(true); - expect(findModal().text()).not.toContain(i18n.basicInstallTitle); - expect(findModal().findComponent(GlFormInputGroup).exists()).toBe(false); - expect(findModal().findComponent(GlAlert).exists()).toBe(false); - expect(findModal().findComponent(CodeBlock).exists()).toBe(false); - }); + describe('when agent configurations are present', () => { + const i18n = I18N_AGENT_MODAL.agent_registration; - it('renders a cancel button', () => { - expect(findCancelButton().isVisible()).toBe(true); - expectDisabledAttribute(findCancelButton(), false); - }); + describe('initial state', () => { + it('renders the dropdown for available agents', () => { + expect(findAgentDropdown().isVisible()).toBe(true); + expect(findModal().text()).not.toContain(i18n.basicInstallTitle); + expect(findModal().findComponent(GlFormInputGroup).exists()).toBe(false); + expect(findModal().findComponent(GlAlert).exists()).toBe(false); + expect(findModal().findComponent(CodeBlock).exists()).toBe(false); + }); - it('renders a disabled next button', () => { - expect(findActionButton().isVisible()).toBe(true); - expect(findActionButton().text()).toBe(i18n.registerAgentButton); - expectDisabledAttribute(findActionButton(), true); - }); - }); + it('renders a cancel button', () => { + expect(findCancelButton().isVisible()).toBe(true); + expectDisabledAttribute(findCancelButton(), false); + }); - describe('an agent is selected', () => { - beforeEach(() => { - findAgentDropdown().vm.$emit('agentSelected'); - }); + it('renders a disabled next button', () => { + expect(findActionButton().isVisible()).toBe(true); + expect(findActionButton().text()).toBe(i18n.registerAgentButton); + expectDisabledAttribute(findActionButton(), true); + }); - it('enables the next button', () => { - expect(findActionButton().isVisible()).toBe(true); - expectDisabledAttribute(findActionButton(), false); + it('sends the event with the modalType', () => { + findModal().vm.$emit('show'); + expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTIONS_OPEN, { + label: EVENT_LABEL_MODAL, + property: MODAL_TYPE_REGISTER, + }); + }); }); - }); - describe('registering an agent', () => { - const createAgentHandler = jest.fn().mockResolvedValue(createAgentResponse); - const createAgentTokenHandler = jest.fn().mockResolvedValue(createAgentTokenResponse); + describe('an agent is selected', () => { + beforeEach(() => { + findAgentDropdown().vm.$emit('agentSelected'); + }); - beforeEach(() => { - apolloProvider = createMockApollo([ - [createAgentMutation, createAgentHandler], - [createAgentTokenMutation, createAgentTokenHandler], - ]); + it('enables the next button', () => { + expect(findActionButton().isVisible()).toBe(true); + expectDisabledAttribute(findActionButton(), false); + }); - return mockSelectedAgentResponse(apolloProvider); + it('sends the correct tracking event', () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTIONS_SELECT, { + label: EVENT_LABEL_MODAL, + }); + }); }); - it('creates an agent and token', () => { - expect(createAgentHandler).toHaveBeenCalledWith({ - input: { name: 'agent-name', projectPath }, - }); + describe('registering an agent', () => { + const createAgentHandler = jest.fn().mockResolvedValue(createAgentResponse); + const createAgentTokenHandler = jest.fn().mockResolvedValue(createAgentTokenResponse); - expect(createAgentTokenHandler).toHaveBeenCalledWith({ - input: { clusterAgentId: 'agent-id', name: 'agent-name' }, + beforeEach(() => { + apolloProvider = createMockApollo([ + [getAgentConfigurations, jest.fn().mockResolvedValue(apolloQueryResponse)], + [createAgentMutation, createAgentHandler], + [createAgentTokenMutation, createAgentTokenHandler], + ]); + + return mockSelectedAgentResponse(); }); - }); - it('renders a close button', () => { - expect(findActionButton().isVisible()).toBe(true); - expect(findActionButton().text()).toBe(i18n.close); - expectDisabledAttribute(findActionButton(), false); - }); + it('creates an agent and token', () => { + expect(createAgentHandler).toHaveBeenCalledWith({ + input: { name: 'agent-name', projectPath }, + }); - it('shows agent instructions', () => { - const modalText = findModal().text(); - expect(modalText).toContain(i18n.basicInstallTitle); - expect(modalText).toContain(i18n.basicInstallBody); + expect(createAgentTokenHandler).toHaveBeenCalledWith({ + input: { clusterAgentId: 'agent-id', name: 'agent-name' }, + }); + }); - const token = findModal().findComponent(GlFormInputGroup); - expect(token.props('value')).toBe('mock-agent-token'); + it('renders a close button', () => { + expect(findActionButton().isVisible()).toBe(true); + expect(findActionButton().text()).toBe(i18n.close); + expectDisabledAttribute(findActionButton(), false); + }); - const alert = findModal().findComponent(GlAlert); - expect(alert.props('title')).toBe(i18n.tokenSingleUseWarningTitle); + it('shows agent instructions', () => { + const modalText = findModal().text(); + expect(modalText).toContain(i18n.basicInstallTitle); + expect(modalText).toContain(i18n.basicInstallBody); - const code = findModal().findComponent(CodeBlock).props('code'); - expect(code).toContain('--agent-token=mock-agent-token'); - expect(code).toContain('--kas-address=kas.example.com'); - }); + const token = findModal().findComponent(GlFormInputGroup); + expect(token.props('value')).toBe('mock-agent-token'); - describe('error creating agent', () => { - beforeEach(() => { - apolloProvider = createMockApollo([ - [createAgentMutation, jest.fn().mockResolvedValue(createAgentErrorResponse)], - ]); + const alert = findModal().findComponent(GlAlert); + expect(alert.props('title')).toBe(i18n.tokenSingleUseWarningTitle); - return mockSelectedAgentResponse(); + const code = findModal().findComponent(CodeBlock).props('code'); + expect(code).toContain('--agent-token=mock-agent-token'); + expect(code).toContain('--kas-address=kas.example.com'); + }); + + describe('error creating agent', () => { + beforeEach(() => { + apolloProvider = createMockApollo([ + [getAgentConfigurations, jest.fn().mockResolvedValue(apolloQueryResponse)], + [createAgentMutation, jest.fn().mockResolvedValue(createAgentErrorResponse)], + ]); + + return mockSelectedAgentResponse(); + }); + + it('displays the error message', () => { + expect(findAlert().text()).toBe( + createAgentErrorResponse.data.createClusterAgent.errors[0], + ); + }); }); - it('displays the error message', () => { - expect(findAlert().text()).toBe(createAgentErrorResponse.data.createClusterAgent.errors[0]); + describe('error creating token', () => { + beforeEach(() => { + apolloProvider = createMockApollo([ + [getAgentConfigurations, jest.fn().mockResolvedValue(apolloQueryResponse)], + [createAgentMutation, jest.fn().mockResolvedValue(createAgentResponse)], + [createAgentTokenMutation, jest.fn().mockResolvedValue(createAgentTokenErrorResponse)], + ]); + + return mockSelectedAgentResponse(); + }); + + it('displays the error message', async () => { + expect(findAlert().text()).toBe( + createAgentTokenErrorResponse.data.clusterAgentTokenCreate.errors[0], + ); + }); }); }); + }); - describe('error creating token', () => { - beforeEach(() => { - apolloProvider = createMockApollo([ - [createAgentMutation, jest.fn().mockResolvedValue(createAgentResponse)], - [createAgentTokenMutation, jest.fn().mockResolvedValue(createAgentTokenErrorResponse)], - ]); + describe('when there are no agent configurations present', () => { + const i18n = I18N_AGENT_MODAL.empty_state; + const apolloQueryEmptyResponse = { + data: { + project: { + clusterAgents: { nodes: [] }, + agentConfigurations: { nodes: [] }, + }, + }, + }; - return mockSelectedAgentResponse(); - }); + beforeEach(() => { + apolloProvider = createMockApollo([ + [getAgentConfigurations, jest.fn().mockResolvedValue(apolloQueryEmptyResponse)], + ]); + createWrapper(); + }); + + it('renders empty state image', () => { + expect(findImage().attributes('src')).toBe(emptyStateImage); + }); + + it('renders a secondary button', () => { + expect(findSecondaryButton().isVisible()).toBe(true); + expect(findSecondaryButton().text()).toBe(i18n.secondaryButton); + }); - it('displays the error message', () => { - expect(findAlert().text()).toBe( - createAgentTokenErrorResponse.data.clusterAgentTokenCreate.errors[0], - ); + it('sends the event with the modalType', () => { + findModal().vm.$emit('show'); + expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTIONS_OPEN, { + label: EVENT_LABEL_MODAL, + property: MODAL_TYPE_EMPTY, }); }); }); diff --git a/spec/frontend/clusters_list/mocks/apollo.js b/spec/frontend/clusters_list/mocks/apollo.js index 1a7ef84a6d9..804f9834506 100644 --- a/spec/frontend/clusters_list/mocks/apollo.js +++ b/spec/frontend/clusters_list/mocks/apollo.js @@ -65,6 +65,7 @@ export const createAgentTokenErrorResponse = { export const getAgentResponse = { data: { project: { + id: 'project-1', clusterAgents: { nodes: [{ ...agent, tokens }], pageInfo, count }, repository: { tree: { diff --git a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap index 118d8ceceb9..97d9be110c8 100644 --- a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap +++ b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap @@ -42,6 +42,8 @@ exports[`Code navigation popover component renders popover 1`] = ` <span> main() { </span> + + <br /> </span> <span class="line" @@ -50,6 +52,8 @@ exports[`Code navigation popover component renders popover 1`] = ` <span> } </span> + + <br /> </span> </pre> </div> 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 178c7d749c8..7abd6b422ad 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 @@ -19,7 +19,7 @@ exports[`content_editor/components/toolbar_link_button renders dropdown componen <div placeholder=\\"Link URL\\"> <div role=\\"group\\" class=\\"input-group\\"> <!----> - <!----> <input type=\\"text\\" placeholder=\\"Link URL\\" class=\\"gl-form-input form-control\\"> + <!----> <input type=\\"text\\" placeholder=\\"Link URL\\" class=\\"form-control gl-form-input\\"> <div class=\\"input-group-append\\"><button type=\\"button\\" class=\\"btn btn-confirm btn-md gl-button\\"> <!----> <!----> <span class=\\"gl-button-text\\">Apply</span></button></div> diff --git a/spec/frontend/content_editor/markdown_processing_examples.js b/spec/frontend/content_editor/markdown_processing_examples.js deleted file mode 100644 index da895970289..00000000000 --- a/spec/frontend/content_editor/markdown_processing_examples.js +++ /dev/null @@ -1,27 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import jsYaml from 'js-yaml'; -// eslint-disable-next-line import/no-deprecated -import { getJSONFixture } from 'helpers/fixtures'; - -export const loadMarkdownApiResult = (testName) => { - const fixturePathPrefix = `api/markdown/${testName}.json`; - - // eslint-disable-next-line import/no-deprecated - const fixture = getJSONFixture(fixturePathPrefix); - return fixture.body || fixture.html; -}; - -export const loadMarkdownApiExamples = () => { - const apiMarkdownYamlPath = path.join(__dirname, '..', 'fixtures', 'api_markdown.yml'); - const apiMarkdownYamlText = fs.readFileSync(apiMarkdownYamlPath); - const apiMarkdownExampleObjects = jsYaml.safeLoad(apiMarkdownYamlText); - - return apiMarkdownExampleObjects.map(({ name, context, markdown }) => [name, context, markdown]); -}; - -export const loadMarkdownApiExample = (testName) => { - return loadMarkdownApiExamples().find(([name, context]) => { - return (context ? `${context}_${name}` : name) === testName; - })[2]; -}; diff --git a/spec/frontend/content_editor/markdown_processing_spec.js b/spec/frontend/content_editor/markdown_processing_spec.js index 71565768558..3930f47289a 100644 --- a/spec/frontend/content_editor/markdown_processing_spec.js +++ b/spec/frontend/content_editor/markdown_processing_spec.js @@ -1,20 +1,16 @@ -import { createContentEditor } from '~/content_editor'; -import { loadMarkdownApiExamples, loadMarkdownApiResult } from './markdown_processing_examples'; +import path from 'path'; +import { describeMarkdownProcessing } from 'jest/content_editor/markdown_processing_spec_helper'; jest.mock('~/emoji'); -describe('markdown processing', () => { - // Ensure we generate same markdown that was provided to Markdown API. - it.each(loadMarkdownApiExamples())( - 'correctly handles %s (context: %s)', - async (name, context, markdown) => { - const testName = context ? `${context}_${name}` : name; - const contentEditor = createContentEditor({ - renderMarkdown: () => loadMarkdownApiResult(testName), - }); - await contentEditor.setSerializedContent(markdown); +const markdownYamlPath = path.join( + __dirname, + '..', + '..', + 'fixtures', + 'markdown', + 'markdown_golden_master_examples.yml', +); - expect(contentEditor.getSerializedContent()).toBe(markdown); - }, - ); -}); +// See spec/fixtures/markdown/markdown_golden_master_examples.yml for documentation on how this spec works. +describeMarkdownProcessing('CE markdown processing in ContentEditor', markdownYamlPath); diff --git a/spec/frontend/content_editor/markdown_processing_spec_helper.js b/spec/frontend/content_editor/markdown_processing_spec_helper.js new file mode 100644 index 00000000000..bb7ec0030a2 --- /dev/null +++ b/spec/frontend/content_editor/markdown_processing_spec_helper.js @@ -0,0 +1,86 @@ +import fs from 'fs'; +import jsYaml from 'js-yaml'; +import { memoize } from 'lodash'; +import { createContentEditor } from '~/content_editor'; +import { setTestTimeoutOnce } from 'helpers/timeout'; + +const getFocusedMarkdownExamples = memoize( + () => process.env.FOCUSED_MARKDOWN_EXAMPLES?.split(',') || [], +); + +const includeExample = ({ name }) => { + const focusedMarkdownExamples = getFocusedMarkdownExamples(); + if (!focusedMarkdownExamples.length) { + return true; + } + return focusedMarkdownExamples.includes(name); +}; + +const getPendingReason = (pendingStringOrObject) => { + if (!pendingStringOrObject) { + return null; + } + if (typeof pendingStringOrObject === 'string') { + return pendingStringOrObject; + } + if (pendingStringOrObject.frontend) { + return pendingStringOrObject.frontend; + } + + return null; +}; + +const loadMarkdownApiExamples = (markdownYamlPath) => { + const apiMarkdownYamlText = fs.readFileSync(markdownYamlPath); + const apiMarkdownExampleObjects = jsYaml.safeLoad(apiMarkdownYamlText); + + return apiMarkdownExampleObjects + .filter(includeExample) + .map(({ name, pending, markdown, html }) => [ + name, + { pendingReason: getPendingReason(pending), markdown, html }, + ]); +}; + +const testSerializesHtmlToMarkdownForElement = async ({ markdown, html }) => { + const contentEditor = createContentEditor({ + // Overwrite renderMarkdown to always return this specific html + renderMarkdown: () => html, + }); + + await contentEditor.setSerializedContent(markdown); + + // This serializes the ContentEditor document, which was based on the HTML, to markdown + const serializedContent = contentEditor.getSerializedContent(); + + // Assert that the markdown we ended up with after sending it through all the ContentEditor + // plumbing matches the original markdown from the YAML. + expect(serializedContent).toBe(markdown); +}; + +// describeMarkdownProcesssing +// +// This is used to dynamically generate examples (for both CE and EE) to ensure +// we generate same markdown that was provided to Markdown API. +// +// eslint-disable-next-line jest/no-export +export const describeMarkdownProcessing = (description, markdownYamlPath) => { + const examples = loadMarkdownApiExamples(markdownYamlPath); + + describe(description, () => { + describe.each(examples)('%s', (name, { pendingReason, ...example }) => { + const exampleName = 'correctly serializes HTML to markdown'; + if (pendingReason) { + it.todo(`${exampleName}: ${pendingReason}`); + return; + } + + it(exampleName, async () => { + if (name === 'frontmatter_toml') { + setTestTimeoutOnce(2000); + } + await testSerializesHtmlToMarkdownForElement(example); + }); + }); + }); +}; diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index cfd93c2df10..97f6d8f6334 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -11,6 +11,9 @@ import Division from '~/content_editor/extensions/division'; import Emoji from '~/content_editor/extensions/emoji'; import Figure from '~/content_editor/extensions/figure'; import FigureCaption from '~/content_editor/extensions/figure_caption'; +import FootnoteDefinition from '~/content_editor/extensions/footnote_definition'; +import FootnoteReference from '~/content_editor/extensions/footnote_reference'; +import FootnotesSection from '~/content_editor/extensions/footnotes_section'; import HardBreak from '~/content_editor/extensions/hard_break'; import Heading from '~/content_editor/extensions/heading'; import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; @@ -28,7 +31,6 @@ import TableHeader from '~/content_editor/extensions/table_header'; import TableRow from '~/content_editor/extensions/table_row'; import TaskItem from '~/content_editor/extensions/task_item'; import TaskList from '~/content_editor/extensions/task_list'; -import Text from '~/content_editor/extensions/text'; import markdownSerializer from '~/content_editor/services/markdown_serializer'; import { createTestEditor, createDocBuilder } from '../test_utils'; @@ -47,6 +49,9 @@ const tiptapEditor = createTestEditor({ DetailsContent, Division, Emoji, + FootnoteDefinition, + FootnoteReference, + FootnotesSection, Figure, FigureCaption, HardBreak, @@ -58,7 +63,6 @@ const tiptapEditor = createTestEditor({ Link, ListItem, OrderedList, - Paragraph, Strike, Table, TableCell, @@ -66,7 +70,6 @@ const tiptapEditor = createTestEditor({ TableRow, TaskItem, TaskList, - Text, ], }); @@ -84,6 +87,9 @@ const { descriptionItem, descriptionList, emoji, + footnoteDefinition, + footnoteReference, + footnotesSection, figure, figureCaption, heading, @@ -120,6 +126,9 @@ const { emoji: { markType: Emoji.name }, figure: { nodeType: Figure.name }, figureCaption: { nodeType: FigureCaption.name }, + footnoteDefinition: { nodeType: FootnoteDefinition.name }, + footnoteReference: { nodeType: FootnoteReference.name }, + footnotesSection: { nodeType: FootnotesSection.name }, hardBreak: { nodeType: HardBreak.name }, heading: { nodeType: Heading.name }, horizontalRule: { nodeType: HorizontalRule.name }, @@ -1108,4 +1117,22 @@ there `.trim(), ); }); + + it('correctly serializes footnotes', () => { + expect( + serialize( + paragraph( + 'Oranges are orange ', + footnoteReference({ footnoteId: '1', footnoteNumber: '1' }), + ), + footnotesSection(footnoteDefinition(paragraph('Oranges are fruits'))), + ), + ).toBe( + ` +Oranges are orange [^1] + +[^1]: Oranges are fruits + `.trim(), + ); + }); }); diff --git a/spec/frontend/crm/contact_form_spec.js b/spec/frontend/crm/contact_form_spec.js new file mode 100644 index 00000000000..b2753ad8cf5 --- /dev/null +++ b/spec/frontend/crm/contact_form_spec.js @@ -0,0 +1,157 @@ +import { GlAlert } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import ContactForm from '~/crm/components/contact_form.vue'; +import createContactMutation from '~/crm/components/queries/create_contact.mutation.graphql'; +import updateContactMutation from '~/crm/components/queries/update_contact.mutation.graphql'; +import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql'; +import { + createContactMutationErrorResponse, + createContactMutationResponse, + getGroupContactsQueryResponse, + updateContactMutationErrorResponse, + updateContactMutationResponse, +} from './mock_data'; + +describe('Customer relations contact form component', () => { + Vue.use(VueApollo); + let wrapper; + let fakeApollo; + let mutation; + let queryHandler; + + const findSaveContactButton = () => wrapper.findByTestId('save-contact-button'); + const findCancelButton = () => wrapper.findByTestId('cancel-button'); + const findForm = () => wrapper.find('form'); + const findError = () => wrapper.findComponent(GlAlert); + + const mountComponent = ({ mountFunction = shallowMountExtended, editForm = false } = {}) => { + fakeApollo = createMockApollo([[mutation, queryHandler]]); + fakeApollo.clients.defaultClient.cache.writeQuery({ + query: getGroupContactsQuery, + variables: { groupFullPath: 'flightjs' }, + data: getGroupContactsQueryResponse.data, + }); + const propsData = { drawerOpen: true }; + if (editForm) + propsData.contact = { firstName: 'First', lastName: 'Last', email: 'email@example.com' }; + wrapper = mountFunction(ContactForm, { + provide: { groupId: 26, groupFullPath: 'flightjs' }, + apolloProvider: fakeApollo, + propsData, + }); + }; + + beforeEach(() => { + mutation = createContactMutation; + queryHandler = jest.fn().mockResolvedValue(createContactMutationResponse); + }); + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + describe('Save contact button', () => { + it('should be disabled when required fields are empty', () => { + mountComponent(); + + expect(findSaveContactButton().props('disabled')).toBe(true); + }); + + it('should not be disabled when required fields have values', async () => { + mountComponent(); + + wrapper.find('#contact-first-name').vm.$emit('input', 'A'); + wrapper.find('#contact-last-name').vm.$emit('input', 'B'); + wrapper.find('#contact-email').vm.$emit('input', 'C'); + await waitForPromises(); + + expect(findSaveContactButton().props('disabled')).toBe(false); + }); + }); + + it("should emit 'close' when cancel button is clicked", () => { + mountComponent(); + + findCancelButton().vm.$emit('click'); + + expect(wrapper.emitted().close).toBeTruthy(); + }); + + describe('when create mutation is successful', () => { + it("should emit 'close'", async () => { + mountComponent(); + + findForm().trigger('submit'); + await waitForPromises(); + + expect(wrapper.emitted().close).toBeTruthy(); + }); + }); + + describe('when create mutation fails', () => { + it('should show error on reject', async () => { + queryHandler = jest.fn().mockRejectedValue('ERROR'); + mountComponent(); + + findForm().trigger('submit'); + await waitForPromises(); + + expect(findError().exists()).toBe(true); + }); + + it('should show error on error response', async () => { + queryHandler = jest.fn().mockResolvedValue(createContactMutationErrorResponse); + mountComponent(); + + findForm().trigger('submit'); + await waitForPromises(); + + expect(findError().exists()).toBe(true); + expect(findError().text()).toBe('Phone is invalid.'); + }); + }); + + describe('when update mutation is successful', () => { + it("should emit 'close'", async () => { + mutation = updateContactMutation; + queryHandler = jest.fn().mockResolvedValue(updateContactMutationResponse); + mountComponent({ editForm: true }); + + findForm().trigger('submit'); + await waitForPromises(); + + expect(wrapper.emitted().close).toBeTruthy(); + }); + }); + + describe('when update mutation fails', () => { + beforeEach(() => { + mutation = updateContactMutation; + }); + + it('should show error on reject', async () => { + queryHandler = jest.fn().mockRejectedValue('ERROR'); + mountComponent({ editForm: true }); + findForm().trigger('submit'); + await waitForPromises(); + + expect(findError().exists()).toBe(true); + }); + + it('should show error on error response', async () => { + queryHandler = jest.fn().mockResolvedValue(updateContactMutationErrorResponse); + mountComponent({ editForm: true }); + + findForm().trigger('submit'); + await waitForPromises(); + + expect(findError().exists()).toBe(true); + expect(findError().text()).toBe('Email is invalid.'); + }); + }); +}); diff --git a/spec/frontend/crm/contacts_root_spec.js b/spec/frontend/crm/contacts_root_spec.js index 79b85969eb4..b30349305a3 100644 --- a/spec/frontend/crm/contacts_root_spec.js +++ b/spec/frontend/crm/contacts_root_spec.js @@ -1,39 +1,65 @@ -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; import ContactsRoot from '~/crm/components/contacts_root.vue'; +import ContactForm from '~/crm/components/contact_form.vue'; import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql'; +import { NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '~/crm/constants'; +import routes from '~/crm/routes'; import { getGroupContactsQueryResponse } from './mock_data'; -jest.mock('~/flash'); - describe('Customer relations contacts root app', () => { Vue.use(VueApollo); + Vue.use(VueRouter); let wrapper; let fakeApollo; + let router; const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName }); + const findIssuesLinks = () => wrapper.findAllByTestId('issues-link'); + const findNewContactButton = () => wrapper.findByTestId('new-contact-button'); + const findEditContactButton = () => wrapper.findByTestId('edit-contact-button'); + const findContactForm = () => wrapper.findComponent(ContactForm); + const findError = () => wrapper.findComponent(GlAlert); const successQueryHandler = jest.fn().mockResolvedValue(getGroupContactsQueryResponse); + const basePath = '/groups/flightjs/-/crm/contacts'; + const mountComponent = ({ queryHandler = successQueryHandler, mountFunction = shallowMountExtended, + canAdminCrmContact = true, } = {}) => { fakeApollo = createMockApollo([[getGroupContactsQuery, queryHandler]]); wrapper = mountFunction(ContactsRoot, { - provide: { groupFullPath: 'flightjs' }, + router, + provide: { + groupFullPath: 'flightjs', + groupIssuesPath: '/issues', + groupId: 26, + canAdminCrmContact, + }, apolloProvider: fakeApollo, }); }; + beforeEach(() => { + router = new VueRouter({ + base: basePath, + mode: 'history', + routes, + }); + }); + afterEach(() => { wrapper.destroy(); fakeApollo = null; + router = null; }); it('should render loading spinner', () => { @@ -42,19 +68,113 @@ describe('Customer relations contacts root app', () => { expect(findLoadingIcon().exists()).toBe(true); }); - it('should render error message on reject', async () => { - mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') }); - await waitForPromises(); + describe('new contact button', () => { + it('should exist when user has permission', () => { + mountComponent(); + + expect(findNewContactButton().exists()).toBe(true); + }); + + it('should not exist when user has no permission', () => { + mountComponent({ canAdminCrmContact: false }); + + expect(findNewContactButton().exists()).toBe(false); + }); + }); + + describe('contact form', () => { + it('should not exist by default', async () => { + mountComponent(); + await waitForPromises(); + + expect(findContactForm().exists()).toBe(false); + }); + + it('should exist when user clicks new contact button', async () => { + mountComponent(); + + findNewContactButton().vm.$emit('click'); + await waitForPromises(); + + expect(findContactForm().exists()).toBe(true); + }); + + it('should exist when user navigates directly to `new` route', async () => { + router.replace({ name: NEW_ROUTE_NAME }); + mountComponent(); + await waitForPromises(); + + expect(findContactForm().exists()).toBe(true); + }); + + it('should exist when user clicks edit contact button', async () => { + mountComponent({ mountFunction: mountExtended }); + await waitForPromises(); + + findEditContactButton().vm.$emit('click'); + await waitForPromises(); + + expect(findContactForm().exists()).toBe(true); + }); + + it('should exist when user navigates directly to `edit` route', async () => { + router.replace({ name: EDIT_ROUTE_NAME, params: { id: 16 } }); + mountComponent(); + await waitForPromises(); + + expect(findContactForm().exists()).toBe(true); + }); + + it('should not exist when new form emits close', async () => { + router.replace({ name: NEW_ROUTE_NAME }); + mountComponent(); + + findContactForm().vm.$emit('close'); + await waitForPromises(); + + expect(findContactForm().exists()).toBe(false); + }); + + it('should not exist when edit form emits close', async () => { + router.replace({ name: EDIT_ROUTE_NAME, params: { id: 16 } }); + mountComponent(); + await waitForPromises(); + + findContactForm().vm.$emit('close'); + await waitForPromises(); + + expect(findContactForm().exists()).toBe(false); + }); + }); + + describe('error', () => { + it('should exist on reject', async () => { + mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') }); + await waitForPromises(); - expect(createFlash).toHaveBeenCalled(); + expect(findError().exists()).toBe(true); + }); }); - it('renders correct results', async () => { - mountComponent({ mountFunction: mountExtended }); - await waitForPromises(); + describe('on successful load', () => { + it('should not render error', async () => { + mountComponent(); + await waitForPromises(); - expect(findRowByName(/Marty/i)).toHaveLength(1); - expect(findRowByName(/George/i)).toHaveLength(1); - expect(findRowByName(/jd@gitlab.com/i)).toHaveLength(1); + expect(findError().exists()).toBe(false); + }); + + it('renders correct results', async () => { + mountComponent({ mountFunction: mountExtended }); + await waitForPromises(); + + expect(findRowByName(/Marty/i)).toHaveLength(1); + expect(findRowByName(/George/i)).toHaveLength(1); + expect(findRowByName(/jd@gitlab.com/i)).toHaveLength(1); + + const issueLink = findIssuesLinks().at(0); + expect(issueLink.exists()).toBe(true); + expect(issueLink.attributes('href')).toBe('/issues?scope=all&state=opened&crm_contact_id=16'); + }); }); }); diff --git a/spec/frontend/crm/mock_data.js b/spec/frontend/crm/mock_data.js index 4197621aaa6..f7af2ccdb72 100644 --- a/spec/frontend/crm/mock_data.js +++ b/spec/frontend/crm/mock_data.js @@ -40,7 +40,6 @@ export const getGroupContactsQueryResponse = { organization: null, }, ], - __typename: 'CustomerRelationsContactConnection', }, }, }, @@ -79,3 +78,84 @@ export const getGroupOrganizationsQueryResponse = { }, }, }; + +export const createContactMutationResponse = { + data: { + customerRelationsContactCreate: { + __typeName: 'CustomerRelationsContactCreatePayload', + contact: { + __typename: 'CustomerRelationsContact', + id: 'gid://gitlab/CustomerRelations::Contact/1', + firstName: 'A', + lastName: 'B', + email: 'C', + phone: null, + description: null, + organization: null, + }, + errors: [], + }, + }, +}; + +export const createContactMutationErrorResponse = { + data: { + customerRelationsContactCreate: { + contact: null, + errors: ['Phone is invalid.'], + }, + }, +}; + +export const updateContactMutationResponse = { + data: { + customerRelationsContactUpdate: { + __typeName: 'CustomerRelationsContactCreatePayload', + contact: { + __typename: 'CustomerRelationsContact', + id: 'gid://gitlab/CustomerRelations::Contact/1', + firstName: 'First', + lastName: 'Last', + email: 'email@example.com', + phone: null, + description: null, + organization: null, + }, + errors: [], + }, + }, +}; + +export const updateContactMutationErrorResponse = { + data: { + customerRelationsContactUpdate: { + contact: null, + errors: ['Email is invalid.'], + }, + }, +}; + +export const createOrganizationMutationResponse = { + data: { + customerRelationsOrganizationCreate: { + __typeName: 'CustomerRelationsOrganizationCreatePayload', + organization: { + __typename: 'CustomerRelationsOrganization', + id: 'gid://gitlab/CustomerRelations::Organization/2', + name: 'A', + defaultRate: null, + description: null, + }, + errors: [], + }, + }, +}; + +export const createOrganizationMutationErrorResponse = { + data: { + customerRelationsOrganizationCreate: { + organization: null, + errors: ['Name cannot be blank.'], + }, + }, +}; diff --git a/spec/frontend/crm/new_organization_form_spec.js b/spec/frontend/crm/new_organization_form_spec.js new file mode 100644 index 00000000000..976b626f35f --- /dev/null +++ b/spec/frontend/crm/new_organization_form_spec.js @@ -0,0 +1,109 @@ +import { GlAlert } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import NewOrganizationForm from '~/crm/components/new_organization_form.vue'; +import createOrganizationMutation from '~/crm/components/queries/create_organization.mutation.graphql'; +import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql'; +import { + createOrganizationMutationErrorResponse, + createOrganizationMutationResponse, + getGroupOrganizationsQueryResponse, +} from './mock_data'; + +describe('Customer relations organizations root app', () => { + Vue.use(VueApollo); + let wrapper; + let fakeApollo; + let queryHandler; + + const findCreateNewOrganizationButton = () => + wrapper.findByTestId('create-new-organization-button'); + const findCancelButton = () => wrapper.findByTestId('cancel-button'); + const findForm = () => wrapper.find('form'); + const findError = () => wrapper.findComponent(GlAlert); + + const mountComponent = () => { + fakeApollo = createMockApollo([[createOrganizationMutation, queryHandler]]); + fakeApollo.clients.defaultClient.cache.writeQuery({ + query: getGroupOrganizationsQuery, + variables: { groupFullPath: 'flightjs' }, + data: getGroupOrganizationsQueryResponse.data, + }); + wrapper = shallowMountExtended(NewOrganizationForm, { + provide: { groupId: 26, groupFullPath: 'flightjs' }, + apolloProvider: fakeApollo, + propsData: { drawerOpen: true }, + }); + }; + + beforeEach(() => { + queryHandler = jest.fn().mockResolvedValue(createOrganizationMutationResponse); + }); + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + describe('Create new organization button', () => { + it('should be disabled by default', () => { + mountComponent(); + + expect(findCreateNewOrganizationButton().attributes('disabled')).toBeTruthy(); + }); + + it('should not be disabled when first, last and email have values', async () => { + mountComponent(); + + wrapper.find('#organization-name').vm.$emit('input', 'A'); + await waitForPromises(); + + expect(findCreateNewOrganizationButton().attributes('disabled')).toBeFalsy(); + }); + }); + + it("should emit 'close' when cancel button is clicked", () => { + mountComponent(); + + findCancelButton().vm.$emit('click'); + + expect(wrapper.emitted().close).toBeTruthy(); + }); + + describe('when query is successful', () => { + it("should emit 'close'", async () => { + mountComponent(); + + findForm().trigger('submit'); + await waitForPromises(); + + expect(wrapper.emitted().close).toBeTruthy(); + }); + }); + + describe('when query fails', () => { + it('should show error on reject', async () => { + queryHandler = jest.fn().mockRejectedValue('ERROR'); + mountComponent(); + + findForm().trigger('submit'); + await waitForPromises(); + + expect(findError().exists()).toBe(true); + }); + + it('should show error on error response', async () => { + queryHandler = jest.fn().mockResolvedValue(createOrganizationMutationErrorResponse); + mountComponent(); + + findForm().trigger('submit'); + await waitForPromises(); + + expect(findError().exists()).toBe(true); + expect(findError().text()).toBe('Name cannot be blank.'); + }); + }); +}); diff --git a/spec/frontend/crm/organizations_root_spec.js b/spec/frontend/crm/organizations_root_spec.js index a69a099e03d..aef417964f4 100644 --- a/spec/frontend/crm/organizations_root_spec.js +++ b/spec/frontend/crm/organizations_root_spec.js @@ -1,39 +1,59 @@ -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; import OrganizationsRoot from '~/crm/components/organizations_root.vue'; +import NewOrganizationForm from '~/crm/components/new_organization_form.vue'; +import { NEW_ROUTE_NAME } from '~/crm/constants'; +import routes from '~/crm/routes'; import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql'; import { getGroupOrganizationsQueryResponse } from './mock_data'; -jest.mock('~/flash'); - describe('Customer relations organizations root app', () => { Vue.use(VueApollo); + Vue.use(VueRouter); let wrapper; let fakeApollo; + let router; const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName }); + const findIssuesLinks = () => wrapper.findAllByTestId('issues-link'); + const findNewOrganizationButton = () => wrapper.findByTestId('new-organization-button'); + const findNewOrganizationForm = () => wrapper.findComponent(NewOrganizationForm); + const findError = () => wrapper.findComponent(GlAlert); const successQueryHandler = jest.fn().mockResolvedValue(getGroupOrganizationsQueryResponse); + const basePath = '/groups/flightjs/-/crm/organizations'; + const mountComponent = ({ queryHandler = successQueryHandler, mountFunction = shallowMountExtended, + canAdminCrmOrganization = true, } = {}) => { fakeApollo = createMockApollo([[getGroupOrganizationsQuery, queryHandler]]); wrapper = mountFunction(OrganizationsRoot, { - provide: { groupFullPath: 'flightjs' }, + router, + provide: { canAdminCrmOrganization, groupFullPath: 'flightjs', groupIssuesPath: '/issues' }, apolloProvider: fakeApollo, }); }; + beforeEach(() => { + router = new VueRouter({ + base: basePath, + mode: 'history', + routes, + }); + }); + afterEach(() => { wrapper.destroy(); fakeApollo = null; + router = null; }); it('should render loading spinner', () => { @@ -42,19 +62,84 @@ describe('Customer relations organizations root app', () => { expect(findLoadingIcon().exists()).toBe(true); }); + describe('new organization button', () => { + it('should exist when user has permission', () => { + mountComponent(); + + expect(findNewOrganizationButton().exists()).toBe(true); + }); + + it('should not exist when user has no permission', () => { + mountComponent({ canAdminCrmOrganization: false }); + + expect(findNewOrganizationButton().exists()).toBe(false); + }); + }); + + describe('new organization form', () => { + it('should not exist by default', async () => { + mountComponent(); + await waitForPromises(); + + expect(findNewOrganizationForm().exists()).toBe(false); + }); + + it('should exist when user clicks new contact button', async () => { + mountComponent(); + + findNewOrganizationButton().vm.$emit('click'); + await waitForPromises(); + + expect(findNewOrganizationForm().exists()).toBe(true); + }); + + it('should exist when user navigates directly to /new', async () => { + router.replace({ name: NEW_ROUTE_NAME }); + mountComponent(); + await waitForPromises(); + + expect(findNewOrganizationForm().exists()).toBe(true); + }); + + it('should not exist when form emits close', async () => { + router.replace({ name: NEW_ROUTE_NAME }); + mountComponent(); + + findNewOrganizationForm().vm.$emit('close'); + await waitForPromises(); + + expect(findNewOrganizationForm().exists()).toBe(false); + }); + }); + it('should render error message on reject', async () => { mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') }); await waitForPromises(); - expect(createFlash).toHaveBeenCalled(); + expect(findError().exists()).toBe(true); }); - it('renders correct results', async () => { - mountComponent({ mountFunction: mountExtended }); - await waitForPromises(); + describe('on successful load', () => { + it('should not render error', async () => { + mountComponent(); + await waitForPromises(); + + expect(findError().exists()).toBe(false); + }); + + it('renders correct results', async () => { + mountComponent({ mountFunction: mountExtended }); + await waitForPromises(); - expect(findRowByName(/Test Inc/i)).toHaveLength(1); - expect(findRowByName(/VIP/i)).toHaveLength(1); - expect(findRowByName(/120/i)).toHaveLength(1); + expect(findRowByName(/Test Inc/i)).toHaveLength(1); + expect(findRowByName(/VIP/i)).toHaveLength(1); + expect(findRowByName(/120/i)).toHaveLength(1); + + const issueLink = findIssuesLinks().at(0); + expect(issueLink.exists()).toBe(true); + expect(issueLink.attributes('href')).toBe( + '/issues?scope=all&state=opened&crm_organization_id=2', + ); + }); }); }); diff --git a/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap deleted file mode 100644 index ed8ed3254ba..00000000000 --- a/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap +++ /dev/null @@ -1,28 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Design note pin component should match the snapshot of note with index 1`] = ` -<button - aria-label="Comment '1' position" - class="gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-font-lg gl-outline-0! js-image-badge badge badge-pill" - style="left: 10px; top: 10px;" - type="button" -> - - 1 - -</button> -`; - -exports[`Design note pin component should match the snapshot of note without index 1`] = ` -<button - aria-label="Comment form position" - class="gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-font-lg gl-outline-0! btn-transparent comment-indicator gl-p-0" - style="left: 10px; top: 10px;" - type="button" -> - <gl-icon-stub - name="image-comment-dark" - size="24" - /> -</button> -`; diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js index cdd07a16e90..2a43b5debee 100644 --- a/spec/frontend/design_management/mock_data/apollo_mock.js +++ b/spec/frontend/design_management/mock_data/apollo_mock.js @@ -5,6 +5,7 @@ export const designListQueryResponse = { id: '1', issue: { __typename: 'Issue', + id: 'issue-1', designCollection: { __typename: 'DesignCollection', copyState: 'READY', @@ -97,6 +98,7 @@ export const permissionsQueryResponse = { id: '1', issue: { __typename: 'Issue', + id: 'issue-1', userPermissions: { __typename: 'UserPermissions', createDesign: true }, }, }, diff --git a/spec/frontend/diffs/components/diff_discussions_spec.js b/spec/frontend/diffs/components/diff_discussions_spec.js index c847a79435a..bd6f4cd2545 100644 --- a/spec/frontend/diffs/components/diff_discussions_spec.js +++ b/spec/frontend/diffs/components/diff_discussions_spec.js @@ -1,7 +1,6 @@ import { GlIcon } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import DiffDiscussions from '~/diffs/components/diff_discussions.vue'; -import { discussionIntersectionObserverHandlerFactory } from '~/diffs/utils/discussions'; import { createStore } from '~/mr_notes/stores'; import DiscussionNotes from '~/notes/components/discussion_notes.vue'; import NoteableDiscussion from '~/notes/components/noteable_discussion.vue'; @@ -20,9 +19,6 @@ describe('DiffDiscussions', () => { store = createStore(); wrapper = mount(localVue.extend(DiffDiscussions), { store, - provide: { - discussionObserverHandler: discussionIntersectionObserverHandlerFactory(), - }, propsData: { discussions: getDiscussionsMockData(), ...props, diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js index feb7118744b..dc0ed621a64 100644 --- a/spec/frontend/diffs/components/diff_file_spec.js +++ b/spec/frontend/diffs/components/diff_file_spec.js @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import Vuex from 'vuex'; -import DiffContentComponent from '~/diffs/components/diff_content.vue'; +import DiffContentComponent from 'jh_else_ce/diffs/components/diff_content.vue'; import DiffFileComponent from '~/diffs/components/diff_file.vue'; import DiffFileHeaderComponent from '~/diffs/components/diff_file_header.vue'; diff --git a/spec/frontend/diffs/components/diff_row_spec.js b/spec/frontend/diffs/components/diff_row_spec.js index c0c92908701..4c5ce429c9d 100644 --- a/spec/frontend/diffs/components/diff_row_spec.js +++ b/spec/frontend/diffs/components/diff_row_spec.js @@ -277,3 +277,36 @@ describe('DiffRow', () => { }); }); }); + +describe('coverage state memoization', () => { + it('updates when coverage is loaded', () => { + const lineWithoutCoverage = {}; + const lineWithCoverage = { + text: 'Test coverage: 5 hits', + class: 'coverage', + }; + + const unchangedProps = { + inline: true, + filePath: 'file/path', + line: { left: { new_line: 3 } }, + }; + + const noCoverageProps = { + fileLineCoverage: () => lineWithoutCoverage, + coverageLoaded: false, + ...unchangedProps, + }; + const coverageProps = { + fileLineCoverage: () => lineWithCoverage, + coverageLoaded: true, + ...unchangedProps, + }; + + // this caches no coverage for the line + expect(DiffRow.coverageStateLeft(noCoverageProps)).toStrictEqual(lineWithoutCoverage); + + // this retrieves coverage for the line because it has been recached + expect(DiffRow.coverageStateLeft(coverageProps)).toStrictEqual(lineWithCoverage); + }); +}); diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js index c104fcd5fb9..d8611b1ce1b 100644 --- a/spec/frontend/diffs/store/mutations_spec.js +++ b/spec/frontend/diffs/store/mutations_spec.js @@ -112,6 +112,7 @@ describe('DiffsStoreMutations', () => { mutations[types.SET_COVERAGE_DATA](state, coverage); expect(state.coverageFiles).toEqual(coverage); + expect(state.coverageLoaded).toEqual(true); }); }); diff --git a/spec/frontend/diffs/utils/discussions_spec.js b/spec/frontend/diffs/utils/discussions_spec.js deleted file mode 100644 index 9a3d442d943..00000000000 --- a/spec/frontend/diffs/utils/discussions_spec.js +++ /dev/null @@ -1,133 +0,0 @@ -import { discussionIntersectionObserverHandlerFactory } from '~/diffs/utils/discussions'; - -describe('Diff Discussions Utils', () => { - describe('discussionIntersectionObserverHandlerFactory', () => { - it('creates a handler function', () => { - expect(discussionIntersectionObserverHandlerFactory()).toBeInstanceOf(Function); - }); - - describe('intersection observer handler', () => { - const functions = { - setCurrentDiscussionId: jest.fn(), - getPreviousUnresolvedDiscussionId: jest.fn().mockImplementation((id) => { - return Number(id) - 1; - }), - }; - const defaultProcessableWrapper = { - entry: { - time: 0, - isIntersecting: true, - rootBounds: { - bottom: 0, - }, - boundingClientRect: { - top: 0, - }, - }, - currentDiscussion: { - id: 1, - }, - isFirstUnresolved: false, - isDiffsPage: true, - }; - let handler; - let getMock; - let setMock; - - beforeEach(() => { - functions.setCurrentDiscussionId.mockClear(); - functions.getPreviousUnresolvedDiscussionId.mockClear(); - - defaultProcessableWrapper.functions = functions; - - setMock = functions.setCurrentDiscussionId.mock; - getMock = functions.getPreviousUnresolvedDiscussionId.mock; - handler = discussionIntersectionObserverHandlerFactory(); - }); - - it('debounces multiple simultaneous requests into one queue', () => { - handler(defaultProcessableWrapper); - handler(defaultProcessableWrapper); - handler(defaultProcessableWrapper); - handler(defaultProcessableWrapper); - - expect(setTimeout).toHaveBeenCalledTimes(4); - expect(clearTimeout).toHaveBeenCalledTimes(3); - - // By only advancing to one timer, we ensure it's all being batched into one queue - jest.advanceTimersToNextTimer(); - - expect(functions.setCurrentDiscussionId).toHaveBeenCalledTimes(4); - }); - - it('properly processes, sorts and executes the correct actions for a set of observed intersections', () => { - handler(defaultProcessableWrapper); - handler({ - // This observation is here to be filtered out because it's a scrollDown - ...defaultProcessableWrapper, - entry: { - ...defaultProcessableWrapper.entry, - isIntersecting: false, - boundingClientRect: { top: 10 }, - rootBounds: { bottom: 100 }, - }, - }); - handler({ - ...defaultProcessableWrapper, - entry: { - ...defaultProcessableWrapper.entry, - time: 101, - isIntersecting: false, - rootBounds: { bottom: -100 }, - }, - currentDiscussion: { id: 20 }, - }); - handler({ - ...defaultProcessableWrapper, - entry: { - ...defaultProcessableWrapper.entry, - time: 100, - isIntersecting: false, - boundingClientRect: { top: 100 }, - }, - currentDiscussion: { id: 30 }, - isDiffsPage: false, - }); - handler({ - ...defaultProcessableWrapper, - isFirstUnresolved: true, - entry: { - ...defaultProcessableWrapper.entry, - time: 100, - isIntersecting: false, - boundingClientRect: { top: 200 }, - }, - }); - - jest.advanceTimersToNextTimer(); - - expect(setMock.calls.length).toBe(4); - expect(setMock.calls[0]).toEqual([1]); - expect(setMock.calls[1]).toEqual([29]); - expect(setMock.calls[2]).toEqual([null]); - expect(setMock.calls[3]).toEqual([19]); - - expect(getMock.calls.length).toBe(2); - expect(getMock.calls[0]).toEqual([30, false]); - expect(getMock.calls[1]).toEqual([20, true]); - - [ - setMock.invocationCallOrder[0], - getMock.invocationCallOrder[0], - setMock.invocationCallOrder[1], - setMock.invocationCallOrder[2], - getMock.invocationCallOrder[1], - setMock.invocationCallOrder[3], - ].forEach((order, idx, list) => { - // Compare each invocation sequence to the one before it (except the first one) - expect(list[idx - 1] || -1).toBeLessThan(order); - }); - }); - }); - }); -}); diff --git a/spec/frontend/dropzone_input_spec.js b/spec/frontend/dropzone_input_spec.js index 12e10f7c5f4..11414e8890d 100644 --- a/spec/frontend/dropzone_input_spec.js +++ b/spec/frontend/dropzone_input_spec.js @@ -32,6 +32,8 @@ describe('dropzone_input', () => { }); describe('handlePaste', () => { + let form; + const triggerPasteEvent = (clipboardData = {}) => { const event = $.Event('paste'); const origEvent = new Event('paste'); @@ -45,11 +47,15 @@ describe('dropzone_input', () => { beforeEach(() => { loadFixtures('issues/new-issue.html'); - const form = $('#new_issue'); + form = $('#new_issue'); form.data('uploads-path', TEST_UPLOAD_PATH); dropzoneInput(form); }); + afterEach(() => { + form = null; + }); + it('pastes Markdown tables', () => { jest.spyOn(PasteMarkdownTable.prototype, 'isTable'); jest.spyOn(PasteMarkdownTable.prototype, 'convertToTableMarkdown'); @@ -86,6 +92,27 @@ describe('dropzone_input', () => { expect(axiosMock.history.post[0].data.get('file').name).toHaveLength(246); }); + it('disables generated image file when clipboardData have both image and text', () => { + const TEST_PLAIN_TEXT = 'This wording is a plain text.'; + triggerPasteEvent({ + types: ['text/plain', 'Files'], + getData: () => TEST_PLAIN_TEXT, + items: [ + { + kind: 'text', + type: 'text/plain', + }, + { + kind: 'file', + type: 'image/png', + getAsFile: () => new Blob(), + }, + ], + }); + + expect(form.find('.js-gfm-input')[0].value).toBe(''); + }); + it('display original file name in comment box', async () => { const axiosMock = new MockAdapter(axios); triggerPasteEvent({ diff --git a/spec/frontend/editor/helpers.js b/spec/frontend/editor/helpers.js index 6f7cdf6efb3..252d783ad6d 100644 --- a/spec/frontend/editor/helpers.js +++ b/spec/frontend/editor/helpers.js @@ -1,4 +1,22 @@ -export class MyClassExtension { +/* eslint-disable max-classes-per-file */ + +// Helpers +export const spyOnApi = (extension, spiesObj = {}) => { + const origApi = extension.api; + if (extension?.obj) { + jest.spyOn(extension.obj, 'provides').mockReturnValue({ + ...origApi, + ...spiesObj, + }); + } +}; + +// Dummy Extensions +export class SEClassExtension { + static get extensionName() { + return 'SEClassExtension'; + } + // eslint-disable-next-line class-methods-use-this provides() { return { @@ -8,8 +26,9 @@ export class MyClassExtension { } } -export function MyFnExtension() { +export function SEFnExtension() { return { + extensionName: 'SEFnExtension', fnExtMethod: () => 'fn own method', provides: () => { return { @@ -19,8 +38,9 @@ export function MyFnExtension() { }; } -export const MyConstExt = () => { +export const SEConstExt = () => { return { + extensionName: 'SEConstExt', provides: () => { return { constExtMethod: () => 'const own method', @@ -29,9 +49,39 @@ export const MyConstExt = () => { }; }; +export class SEWithSetupExt { + static get extensionName() { + return 'SEWithSetupExt'; + } + // eslint-disable-next-line class-methods-use-this + onSetup(instance, setupOptions = {}) { + if (setupOptions && !Array.isArray(setupOptions)) { + Object.entries(setupOptions).forEach(([key, value]) => { + Object.assign(instance, { + [key]: value, + }); + }); + } + } + provides() { + return { + returnInstanceAndProps: (instance, stringProp, objProp = {}) => { + return [stringProp, objProp, instance]; + }, + returnInstance: (instance) => { + return instance; + }, + giveMeContext: () => { + return this; + }, + }; + } +} + export const conflictingExtensions = { WithInstanceExt: () => { return { + extensionName: 'WithInstanceExt', provides: () => { return { use: () => 'A conflict with instance', @@ -42,6 +92,7 @@ export const conflictingExtensions = { }, WithAnotherExt: () => { return { + extensionName: 'WithAnotherExt', provides: () => { return { shared: () => 'A conflict with extension', diff --git a/spec/frontend/editor/source_editor_ci_schema_ext_spec.js b/spec/frontend/editor/source_editor_ci_schema_ext_spec.js index 8a0d1ecf1af..5eaac9e9ef9 100644 --- a/spec/frontend/editor/source_editor_ci_schema_ext_spec.js +++ b/spec/frontend/editor/source_editor_ci_schema_ext_spec.js @@ -23,7 +23,7 @@ describe('~/editor/editor_ci_config_ext', () => { blobPath, blobContent: '', }); - instance.use(new CiSchemaExtension()); + instance.use({ definition: CiSchemaExtension }); }; beforeAll(() => { diff --git a/spec/frontend/editor/source_editor_extension_base_spec.js b/spec/frontend/editor/source_editor_extension_base_spec.js index a0fb1178b3b..6606557fd1f 100644 --- a/spec/frontend/editor/source_editor_extension_base_spec.js +++ b/spec/frontend/editor/source_editor_extension_base_spec.js @@ -2,40 +2,25 @@ 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, EDITOR_TYPE_DIFF, + EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS, + EXTENSION_BASE_LINE_NUMBERS_CLASS, } from '~/editor/constants'; import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; - -jest.mock('~/helpers/startup_css_helper', () => { - return { - waitForCSSLoaded: jest.fn().mockImplementation((cb) => { - // We have to artificially put the callback's execution - // to the end of the current call stack to be able to - // test that the callback is called after waitForCSSLoaded. - // setTimeout with 0 delay does exactly that. - // Otherwise we might end up with false positive results - setTimeout(() => { - cb.apply(); - }, 0); - }), - }; -}); +import EditorInstance from '~/editor/source_editor_instance'; describe('The basis for an Source Editor extension', () => { const defaultLine = 3; - let ext; let event; - const defaultOptions = { foo: 'bar' }; const findLine = (num) => { - return document.querySelector(`.line-numbers:nth-child(${num})`); + return document.querySelector(`.${EXTENSION_BASE_LINE_NUMBERS_CLASS}:nth-child(${num})`); }; const generateLines = () => { let res = ''; for (let line = 1, lines = 5; line <= lines; line += 1) { - res += `<div class="line-numbers">${line}</div>`; + res += `<div class="${EXTENSION_BASE_LINE_NUMBERS_CLASS}">${line}</div>`; } return res; }; @@ -49,6 +34,9 @@ describe('The basis for an Source Editor extension', () => { }, }; }; + const createInstance = (baseInstance = {}) => { + return new EditorInstance(baseInstance); + }; beforeEach(() => { setFixtures(generateLines()); @@ -59,95 +47,47 @@ describe('The basis for an Source Editor extension', () => { jest.clearAllMocks(); }); - describe('constructor', () => { - it('resets the layout in waitForCSSLoaded callback', async () => { - const instance = { - layout: jest.fn(), - }; - ext = new SourceEditorExtension({ instance }); - expect(instance.layout).not.toHaveBeenCalled(); - - // We're waiting for the waitForCSSLoaded mock to kick in - await jest.runOnlyPendingTimers(); + describe('onUse callback', () => { + it('initializes the line highlighting', () => { + const instance = createInstance(); + const spy = jest.spyOn(SourceEditorExtension, 'highlightLines'); - expect(instance.layout).toHaveBeenCalled(); + instance.use({ definition: SourceEditorExtension }); + expect(spy).toHaveBeenCalled(); }); it.each` - description | instance | options - ${'accepts configuration options and instance'} | ${{}} | ${defaultOptions} - ${'leaves instance intact if no options are passed'} | ${{}} | ${undefined} - ${'does not fail if both instance and the options are omitted'} | ${undefined} | ${undefined} - ${'throws if only options are passed'} | ${undefined} | ${defaultOptions} - `('$description', ({ instance, options } = {}) => { - SourceEditorExtension.deferRerender = jest.fn(); - const originalInstance = { ...instance }; - - if (instance) { - if (options) { - Object.entries(options).forEach((prop) => { - expect(instance[prop]).toBeUndefined(); - }); - // Both instance and options are passed - ext = new SourceEditorExtension({ instance, ...options }); - Object.entries(options).forEach(([prop, value]) => { - expect(ext[prop]).toBeUndefined(); - expect(instance[prop]).toBe(value); - }); + description | instanceType | shouldBeCalled + ${'Sets up'} | ${EDITOR_TYPE_CODE} | ${true} + ${'Does not set up'} | ${EDITOR_TYPE_DIFF} | ${false} + `( + '$description the line linking for $instanceType instance', + ({ instanceType, shouldBeCalled }) => { + const instance = createInstance({ + getEditorType: jest.fn().mockReturnValue(instanceType), + onMouseMove: jest.fn(), + onMouseDown: jest.fn(), + }); + const spy = jest.spyOn(SourceEditorExtension, 'setupLineLinking'); + + instance.use({ definition: SourceEditorExtension }); + if (shouldBeCalled) { + expect(spy).toHaveBeenCalledWith(instance); } else { - ext = new SourceEditorExtension({ instance }); - expect(instance).toEqual(originalInstance); + expect(spy).not.toHaveBeenCalled(); } - } else if (options) { - // Options are passed without instance - expect(() => { - ext = new SourceEditorExtension({ ...options }); - }).toThrow(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION); - } else { - // Neither options nor instance are passed - expect(() => { - ext = new SourceEditorExtension(); - }).not.toThrow(); - } - }); - - it('initializes the line highlighting', () => { - SourceEditorExtension.deferRerender = jest.fn(); - const spy = jest.spyOn(SourceEditorExtension, 'highlightLines'); - ext = new SourceEditorExtension({ instance: {} }); - expect(spy).toHaveBeenCalled(); - }); - - it('sets up the line linking for code instance', () => { - SourceEditorExtension.deferRerender = jest.fn(); - const spy = jest.spyOn(SourceEditorExtension, 'setupLineLinking'); - const instance = { - getEditorType: jest.fn().mockReturnValue(EDITOR_TYPE_CODE), - onMouseMove: jest.fn(), - onMouseDown: jest.fn(), - }; - ext = new SourceEditorExtension({ instance }); - expect(spy).toHaveBeenCalledWith(instance); - }); - - it('does not set up the line linking for diff instance', () => { - SourceEditorExtension.deferRerender = jest.fn(); - const spy = jest.spyOn(SourceEditorExtension, 'setupLineLinking'); - const instance = { - getEditorType: jest.fn().mockReturnValue(EDITOR_TYPE_DIFF), - }; - ext = new SourceEditorExtension({ instance }); - expect(spy).not.toHaveBeenCalled(); - }); + }, + ); }); describe('highlightLines', () => { const revealSpy = jest.fn(); const decorationsSpy = jest.fn(); - const instance = { + const instance = createInstance({ revealLineInCenter: revealSpy, deltaDecorations: decorationsSpy, - }; + }); + instance.use({ definition: SourceEditorExtension }); const defaultDecorationOptions = { isWholeLine: true, className: 'active-line-text', @@ -175,7 +115,7 @@ describe('The basis for an Source Editor extension', () => { ${'uses bounds if both hash and bounds exist'} | ${'#L7-42'} | ${[3, 5]} | ${true} | ${[3, 1, 5, 1]} `('$desc', ({ hash, bounds, shouldReveal, expectedRange } = {}) => { window.location.hash = hash; - SourceEditorExtension.highlightLines(instance, bounds); + instance.highlightLines(bounds); if (!shouldReveal) { expect(revealSpy).not.toHaveBeenCalled(); expect(decorationsSpy).not.toHaveBeenCalled(); @@ -193,11 +133,11 @@ describe('The basis for an Source Editor extension', () => { } }); - it('stores the line decorations on the instance', () => { + it('stores the line decorations on the instance', () => { decorationsSpy.mockReturnValue('foo'); window.location.hash = '#L10'; expect(instance.lineDecorations).toBeUndefined(); - SourceEditorExtension.highlightLines(instance); + instance.highlightLines(); expect(instance.lineDecorations).toBe('foo'); }); @@ -215,7 +155,7 @@ describe('The basis for an Source Editor extension', () => { }, ]; instance.lineDecorations = oldLineDecorations; - SourceEditorExtension.highlightLines(instance, [7, 10]); + instance.highlightLines([7, 10]); expect(decorationsSpy).toHaveBeenCalledWith(oldLineDecorations, newLineDecorations); }); }); @@ -228,13 +168,18 @@ describe('The basis for an Source Editor extension', () => { options: { isWholeLine: true, className: 'active-line-text' }, }, ]; - const instance = { - deltaDecorations: decorationsSpy, - lineDecorations, - }; + let instance; + + beforeEach(() => { + instance = createInstance({ + deltaDecorations: decorationsSpy, + lineDecorations, + }); + instance.use({ definition: SourceEditorExtension }); + }); it('removes all existing decorations', () => { - SourceEditorExtension.removeHighlights(instance); + instance.removeHighlights(); expect(decorationsSpy).toHaveBeenCalledWith(lineDecorations, []); }); }); @@ -261,9 +206,9 @@ describe('The basis for an Source Editor extension', () => { }); it.each` - desc | eventTrigger | shouldRemove - ${'does not remove the line decorations if the event is triggered on a wrong node'} | ${null} | ${false} - ${'removes existing line decorations when clicking a line number'} | ${'.link-anchor'} | ${true} + desc | eventTrigger | shouldRemove + ${'does not remove the line decorations if the event is triggered on a wrong node'} | ${null} | ${false} + ${'removes existing line decorations when clicking a line number'} | ${`.${EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS}`} | ${true} `('$desc', ({ eventTrigger, shouldRemove } = {}) => { event = generateEventMock({ el: eventTrigger ? document.querySelector(eventTrigger) : null }); instance.onMouseDown.mockImplementation((fn) => { diff --git a/spec/frontend/editor/source_editor_extension_spec.js b/spec/frontend/editor/source_editor_extension_spec.js index 6f2eb07a043..c5fa795f3b7 100644 --- a/spec/frontend/editor/source_editor_extension_spec.js +++ b/spec/frontend/editor/source_editor_extension_spec.js @@ -22,15 +22,15 @@ describe('Editor Extension', () => { it.each` definition | setupOptions | expectedName - ${helpers.MyClassExtension} | ${undefined} | ${'MyClassExtension'} - ${helpers.MyClassExtension} | ${{}} | ${'MyClassExtension'} - ${helpers.MyClassExtension} | ${dummyObj} | ${'MyClassExtension'} - ${helpers.MyFnExtension} | ${undefined} | ${'MyFnExtension'} - ${helpers.MyFnExtension} | ${{}} | ${'MyFnExtension'} - ${helpers.MyFnExtension} | ${dummyObj} | ${'MyFnExtension'} - ${helpers.MyConstExt} | ${undefined} | ${'MyConstExt'} - ${helpers.MyConstExt} | ${{}} | ${'MyConstExt'} - ${helpers.MyConstExt} | ${dummyObj} | ${'MyConstExt'} + ${helpers.SEClassExtension} | ${undefined} | ${'SEClassExtension'} + ${helpers.SEClassExtension} | ${{}} | ${'SEClassExtension'} + ${helpers.SEClassExtension} | ${dummyObj} | ${'SEClassExtension'} + ${helpers.SEFnExtension} | ${undefined} | ${'SEFnExtension'} + ${helpers.SEFnExtension} | ${{}} | ${'SEFnExtension'} + ${helpers.SEFnExtension} | ${dummyObj} | ${'SEFnExtension'} + ${helpers.SEConstExt} | ${undefined} | ${'SEConstExt'} + ${helpers.SEConstExt} | ${{}} | ${'SEConstExt'} + ${helpers.SEConstExt} | ${dummyObj} | ${'SEConstExt'} `( 'correctly creates extension for definition = $definition and setupOptions = $setupOptions', ({ definition, setupOptions, expectedName }) => { @@ -40,7 +40,7 @@ describe('Editor Extension', () => { expect(extension).toEqual( expect.objectContaining({ - name: expectedName, + extensionName: expectedName, setupOptions, }), ); @@ -51,9 +51,9 @@ describe('Editor Extension', () => { describe('api', () => { it.each` definition | expectedKeys - ${helpers.MyClassExtension} | ${['shared', 'classExtMethod']} - ${helpers.MyFnExtension} | ${['fnExtMethod']} - ${helpers.MyConstExt} | ${['constExtMethod']} + ${helpers.SEClassExtension} | ${['shared', 'classExtMethod']} + ${helpers.SEFnExtension} | ${['fnExtMethod']} + ${helpers.SEConstExt} | ${['constExtMethod']} `('correctly returns API for $definition', ({ definition, expectedKeys }) => { const extension = new EditorExtension({ definition }); const expectedApi = Object.fromEntries( diff --git a/spec/frontend/editor/source_editor_instance_spec.js b/spec/frontend/editor/source_editor_instance_spec.js index 87b20a4ba73..f9518743ef8 100644 --- a/spec/frontend/editor/source_editor_instance_spec.js +++ b/spec/frontend/editor/source_editor_instance_spec.js @@ -6,31 +6,43 @@ import { EDITOR_EXTENSION_NOT_REGISTERED_ERROR, EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR, } from '~/editor/constants'; -import Instance from '~/editor/source_editor_instance'; +import SourceEditorInstance from '~/editor/source_editor_instance'; import { sprintf } from '~/locale'; -import { MyClassExtension, conflictingExtensions, MyFnExtension, MyConstExt } from './helpers'; +import { + SEClassExtension, + conflictingExtensions, + SEFnExtension, + SEConstExt, + SEWithSetupExt, +} from './helpers'; describe('Source Editor Instance', () => { let seInstance; const defSetupOptions = { foo: 'bar' }; const fullExtensionsArray = [ - { definition: MyClassExtension }, - { definition: MyFnExtension }, - { definition: MyConstExt }, + { definition: SEClassExtension }, + { definition: SEFnExtension }, + { definition: SEConstExt }, ]; const fullExtensionsArrayWithOptions = [ - { definition: MyClassExtension, setupOptions: defSetupOptions }, - { definition: MyFnExtension, setupOptions: defSetupOptions }, - { definition: MyConstExt, setupOptions: defSetupOptions }, + { definition: SEClassExtension, setupOptions: defSetupOptions }, + { definition: SEFnExtension, setupOptions: defSetupOptions }, + { definition: SEConstExt, setupOptions: defSetupOptions }, ]; const fooFn = jest.fn(); + const fooProp = 'foo'; class DummyExt { // eslint-disable-next-line class-methods-use-this + get extensionName() { + return 'DummyExt'; + } + // eslint-disable-next-line class-methods-use-this provides() { return { fooFn, + fooProp, }; } } @@ -40,26 +52,26 @@ describe('Source Editor Instance', () => { }); it('sets up the registry for the methods coming from extensions', () => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); expect(seInstance.methods).toBeDefined(); - seInstance.use({ definition: MyClassExtension }); + seInstance.use({ definition: SEClassExtension }); expect(seInstance.methods).toEqual({ - shared: 'MyClassExtension', - classExtMethod: 'MyClassExtension', + shared: 'SEClassExtension', + classExtMethod: 'SEClassExtension', }); - seInstance.use({ definition: MyFnExtension }); + seInstance.use({ definition: SEFnExtension }); expect(seInstance.methods).toEqual({ - shared: 'MyClassExtension', - classExtMethod: 'MyClassExtension', - fnExtMethod: 'MyFnExtension', + shared: 'SEClassExtension', + classExtMethod: 'SEClassExtension', + fnExtMethod: 'SEFnExtension', }); }); describe('proxy', () => { - it('returns prop from an extension if extension provides it', () => { - seInstance = new Instance(); + it('returns a method from an extension if extension provides it', () => { + seInstance = new SourceEditorInstance(); seInstance.use({ definition: DummyExt }); expect(fooFn).not.toHaveBeenCalled(); @@ -67,20 +79,77 @@ describe('Source Editor Instance', () => { expect(fooFn).toHaveBeenCalled(); }); + it('returns a prop from an extension if extension provides it', () => { + seInstance = new SourceEditorInstance(); + seInstance.use({ definition: DummyExt }); + + expect(seInstance.fooProp).toBe('foo'); + }); + + it.each` + stringPropToPass | objPropToPass | setupOptions + ${undefined} | ${undefined} | ${undefined} + ${'prop'} | ${undefined} | ${undefined} + ${'prop'} | ${[]} | ${undefined} + ${'prop'} | ${{}} | ${undefined} + ${'prop'} | ${{ alpha: 'beta' }} | ${undefined} + ${'prop'} | ${{ alpha: 'beta' }} | ${defSetupOptions} + ${'prop'} | ${undefined} | ${defSetupOptions} + ${undefined} | ${undefined} | ${defSetupOptions} + ${''} | ${{}} | ${defSetupOptions} + `( + 'correctly passes arguments ("$stringPropToPass", "$objPropToPass") and instance (with "$setupOptions" setupOptions) to extension methods', + ({ stringPropToPass, objPropToPass, setupOptions }) => { + seInstance = new SourceEditorInstance(); + seInstance.use({ definition: SEWithSetupExt, setupOptions }); + + const [stringProp, objProp, instance] = seInstance.returnInstanceAndProps( + stringPropToPass, + objPropToPass, + ); + const expectedObjProps = objPropToPass || {}; + + expect(instance).toBe(seInstance); + expect(stringProp).toBe(stringPropToPass); + expect(objProp).toEqual(expectedObjProps); + if (setupOptions) { + Object.keys(setupOptions).forEach((key) => { + expect(instance[key]).toBe(setupOptions[key]); + }); + } + }, + ); + + it('correctly passes instance to the methods even if no additional props have been passed', () => { + seInstance = new SourceEditorInstance(); + seInstance.use({ definition: SEWithSetupExt }); + + const instance = seInstance.returnInstance(); + + expect(instance).toBe(seInstance); + }); + + it("correctly sets the context of the 'this' keyword for the extension's methods", () => { + seInstance = new SourceEditorInstance(); + const extension = seInstance.use({ definition: SEWithSetupExt }); + + expect(seInstance.giveMeContext()).toEqual(extension.obj); + }); + it('returns props from SE instance itself if no extension provides the prop', () => { - seInstance = new Instance({ + seInstance = new SourceEditorInstance({ use: fooFn, }); - jest.spyOn(seInstance, 'use').mockImplementation(() => {}); - expect(seInstance.use).not.toHaveBeenCalled(); + const spy = jest.spyOn(seInstance.constructor.prototype, 'use').mockImplementation(() => {}); + expect(spy).not.toHaveBeenCalled(); expect(fooFn).not.toHaveBeenCalled(); seInstance.use(); - expect(seInstance.use).toHaveBeenCalled(); + expect(spy).toHaveBeenCalled(); expect(fooFn).not.toHaveBeenCalled(); }); it('returns props from Monaco instance when the prop does not exist on the SE instance', () => { - seInstance = new Instance({ + seInstance = new SourceEditorInstance({ fooFn, }); @@ -92,13 +161,13 @@ describe('Source Editor Instance', () => { describe('public API', () => { it.each(['use', 'unuse'], 'provides "%s" as public method by default', (method) => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); expect(seInstance[method]).toBeDefined(); }); describe('use', () => { it('extends the SE instance with methods provided by an extension', () => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); seInstance.use({ definition: DummyExt }); expect(fooFn).not.toHaveBeenCalled(); @@ -108,15 +177,15 @@ describe('Source Editor Instance', () => { it.each` extensions | expectedProps - ${{ definition: MyClassExtension }} | ${['shared', 'classExtMethod']} - ${{ definition: MyFnExtension }} | ${['fnExtMethod']} - ${{ definition: MyConstExt }} | ${['constExtMethod']} + ${{ definition: SEClassExtension }} | ${['shared', 'classExtMethod']} + ${{ definition: SEFnExtension }} | ${['fnExtMethod']} + ${{ definition: SEConstExt }} | ${['constExtMethod']} ${fullExtensionsArray} | ${['shared', 'classExtMethod', 'fnExtMethod', 'constExtMethod']} ${fullExtensionsArrayWithOptions} | ${['shared', 'classExtMethod', 'fnExtMethod', 'constExtMethod']} `( 'Should register $expectedProps when extension is "$extensions"', ({ extensions, expectedProps }) => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); expect(seInstance.extensionsAPI).toHaveLength(0); seInstance.use(extensions); @@ -127,15 +196,15 @@ describe('Source Editor Instance', () => { it.each` definition | preInstalledExtDefinition | expectedErrorProp - ${conflictingExtensions.WithInstanceExt} | ${MyClassExtension} | ${'use'} + ${conflictingExtensions.WithInstanceExt} | ${SEClassExtension} | ${'use'} ${conflictingExtensions.WithInstanceExt} | ${null} | ${'use'} ${conflictingExtensions.WithAnotherExt} | ${null} | ${undefined} - ${conflictingExtensions.WithAnotherExt} | ${MyClassExtension} | ${'shared'} - ${MyClassExtension} | ${conflictingExtensions.WithAnotherExt} | ${'shared'} + ${conflictingExtensions.WithAnotherExt} | ${SEClassExtension} | ${'shared'} + ${SEClassExtension} | ${conflictingExtensions.WithAnotherExt} | ${'shared'} `( 'logs the naming conflict error when registering $definition', ({ definition, preInstalledExtDefinition, expectedErrorProp }) => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); jest.spyOn(console, 'error').mockImplementation(() => {}); if (preInstalledExtDefinition) { @@ -175,7 +244,7 @@ describe('Source Editor Instance', () => { `( 'Should throw $thrownError when extension is "$extensions"', ({ extensions, thrownError }) => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); const useExtension = () => { seInstance.use(extensions); }; @@ -188,24 +257,24 @@ describe('Source Editor Instance', () => { beforeEach(() => { extensionStore = new Map(); - seInstance = new Instance({}, extensionStore); + seInstance = new SourceEditorInstance({}, extensionStore); }); it('stores _instances_ of the used extensions in a global registry', () => { - const extension = seInstance.use({ definition: MyClassExtension }); + const extension = seInstance.use({ definition: SEClassExtension }); expect(extensionStore.size).toBe(1); - expect(extensionStore.entries().next().value).toEqual(['MyClassExtension', extension]); + expect(extensionStore.entries().next().value).toEqual(['SEClassExtension', extension]); }); it('does not duplicate entries in the registry', () => { jest.spyOn(extensionStore, 'set'); - const extension1 = seInstance.use({ definition: MyClassExtension }); - seInstance.use({ definition: MyClassExtension }); + const extension1 = seInstance.use({ definition: SEClassExtension }); + seInstance.use({ definition: SEClassExtension }); expect(extensionStore.set).toHaveBeenCalledTimes(1); - expect(extensionStore.set).toHaveBeenCalledWith('MyClassExtension', extension1); + expect(extensionStore.set).toHaveBeenCalledWith('SEClassExtension', extension1); }); it.each` @@ -222,20 +291,20 @@ describe('Source Editor Instance', () => { jest.spyOn(extensionStore, 'set'); const extension1 = seInstance.use({ - definition: MyClassExtension, + definition: SEClassExtension, setupOptions: currentSetupOptions, }); const extension2 = seInstance.use({ - definition: MyClassExtension, + definition: SEClassExtension, setupOptions: newSetupOptions, }); expect(extensionStore.size).toBe(1); expect(extensionStore.set).toHaveBeenCalledTimes(expectedCallTimes); if (expectedCallTimes > 1) { - expect(extensionStore.set).toHaveBeenCalledWith('MyClassExtension', extension2); + expect(extensionStore.set).toHaveBeenCalledWith('SEClassExtension', extension2); } else { - expect(extensionStore.set).toHaveBeenCalledWith('MyClassExtension', extension1); + expect(extensionStore.set).toHaveBeenCalledWith('SEClassExtension', extension1); } }, ); @@ -252,7 +321,7 @@ describe('Source Editor Instance', () => { `( `Should throw "${EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR}" when extension is "$unuseExtension"`, ({ unuseExtension, thrownError }) => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); const unuse = () => { seInstance.unuse(unuseExtension); }; @@ -262,16 +331,16 @@ describe('Source Editor Instance', () => { it.each` initExtensions | unuseExtensionIndex | remainingAPI - ${{ definition: MyClassExtension }} | ${0} | ${[]} - ${{ definition: MyFnExtension }} | ${0} | ${[]} - ${{ definition: MyConstExt }} | ${0} | ${[]} + ${{ definition: SEClassExtension }} | ${0} | ${[]} + ${{ definition: SEFnExtension }} | ${0} | ${[]} + ${{ definition: SEConstExt }} | ${0} | ${[]} ${fullExtensionsArray} | ${0} | ${['fnExtMethod', 'constExtMethod']} ${fullExtensionsArray} | ${1} | ${['shared', 'classExtMethod', 'constExtMethod']} ${fullExtensionsArray} | ${2} | ${['shared', 'classExtMethod', 'fnExtMethod']} `( 'un-registers properties introduced by single extension $unuseExtension', ({ initExtensions, unuseExtensionIndex, remainingAPI }) => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); const extensions = seInstance.use(initExtensions); if (Array.isArray(initExtensions)) { @@ -291,7 +360,7 @@ describe('Source Editor Instance', () => { `( 'un-registers properties introduced by multiple extensions $unuseExtension', ({ unuseExtensionIndex, remainingAPI }) => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); const extensions = seInstance.use(fullExtensionsArray); const extensionsToUnuse = extensions.filter((ext, index) => unuseExtensionIndex.includes(index), @@ -304,11 +373,11 @@ describe('Source Editor Instance', () => { it('it does not remove entry from the global registry to keep for potential future re-use', () => { const extensionStore = new Map(); - seInstance = new Instance({}, extensionStore); + seInstance = new SourceEditorInstance({}, extensionStore); const extensions = seInstance.use(fullExtensionsArray); const verifyExpectations = () => { const entries = extensionStore.entries(); - const mockExtensions = ['MyClassExtension', 'MyFnExtension', 'MyConstExt']; + const mockExtensions = ['SEClassExtension', 'SEFnExtension', 'SEConstExt']; expect(extensionStore.size).toBe(mockExtensions.length); mockExtensions.forEach((ext, index) => { expect(entries.next().value).toEqual([ext, extensions[index]]); @@ -326,7 +395,7 @@ describe('Source Editor Instance', () => { beforeEach(() => { instanceModel = monacoEditor.createModel(''); - seInstance = new Instance({ + seInstance = new SourceEditorInstance({ getModel: () => instanceModel, }); }); @@ -363,17 +432,17 @@ describe('Source Editor Instance', () => { }; it('passes correct arguments to callback fns when using an extension', () => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); seInstance.use({ definition: MyFullExtWithCallbacks, setupOptions: defSetupOptions, }); - expect(onSetup).toHaveBeenCalledWith(defSetupOptions, seInstance); + expect(onSetup).toHaveBeenCalledWith(seInstance, defSetupOptions); expect(onUse).toHaveBeenCalledWith(seInstance); }); it('passes correct arguments to callback fns when un-using an extension', () => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); const extension = seInstance.use({ definition: MyFullExtWithCallbacks, setupOptions: defSetupOptions, diff --git a/spec/frontend/editor/source_editor_markdown_ext_spec.js b/spec/frontend/editor/source_editor_markdown_ext_spec.js index 245c6c28d31..eecd23bff6e 100644 --- a/spec/frontend/editor/source_editor_markdown_ext_spec.js +++ b/spec/frontend/editor/source_editor_markdown_ext_spec.js @@ -1,36 +1,19 @@ 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 { Range, Position } from 'monaco-editor'; 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 previewMarkdownPath = '/gitlab/fooGroup/barProj/preview_markdown'; const firstLine = 'This is a'; const secondLine = 'multiline'; const thirdLine = 'string with some **markup**'; const text = `${firstLine}\n${secondLine}\n${thirdLine}`; - const plaintextPath = 'foo.txt'; const markdownPath = 'foo.md'; - const responseData = '<div>FooBar</div>'; const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => { const selection = new Range(startLineNumber, startColumn, endLineNumber, endColumn); @@ -42,11 +25,6 @@ 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>'); @@ -57,8 +35,7 @@ describe('Markdown Extension for Source Editor', () => { blobPath: markdownPath, blobContent: text, }); - editor.use(new EditorMarkdownExtension({ instance, previewMarkdownPath })); - panelSpy = jest.spyOn(EditorMarkdownExtension, 'togglePreviewPanel'); + instance.use({ definition: EditorMarkdownExtension }); }); afterEach(() => { @@ -67,345 +44,6 @@ describe('Markdown Extension for Source Editor', () => { mockAxios.restore(); }); - it('sets up the instance', () => { - expect(instance.preview).toEqual({ - el: undefined, - action: expect.any(Object), - shown: false, - modelChangeListener: undefined, - }); - expect(instance.previewMarkdownPath).toBe(previewMarkdownPath); - }); - - 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 fetchPreview = async () => { - instance.fetchPreview(); - await waitForPromises(); - }; - - let previewMarkdownSpy; - - beforeEach(() => { - previewMarkdownSpy = jest.fn().mockImplementation(() => [200, { body: responseData }]); - mockAxios.onPost(previewMarkdownPath).replyOnce((req) => previewMarkdownSpy(req)); - }); - - it('correctly fetches preview based on previewMarkdownPath', async () => { - await fetchPreview(); - - expect(previewMarkdownSpy).toHaveBeenCalledWith( - expect.objectContaining({ data: 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', () => { it('does not fail if there is no selection and returns the empty string', () => { jest.spyOn(instance, 'getSelection'); @@ -525,13 +163,11 @@ describe('Markdown Extension for Source Editor', () => { }); it('does not fail when only `toSelect` is supplied and fetches the text from selection', () => { - jest.spyOn(instance, 'getSelectedText'); const toSelect = 'string'; selectSecondAndThirdLines(); instance.selectWithinSelection(toSelect); - expect(instance.getSelectedText).toHaveBeenCalled(); expect(selectionToString()).toBe(`[3,1 -> 3,${toSelect.length + 1}]`); }); diff --git a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js new file mode 100644 index 00000000000..c8d016e10ac --- /dev/null +++ b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js @@ -0,0 +1,421 @@ +import MockAdapter from 'axios-mock-adapter'; +import { 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 { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext'; +import SourceEditor from '~/editor/source_editor'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import syntaxHighlight from '~/syntax_highlight'; +import { spyOnApi } from './helpers'; + +jest.mock('~/syntax_highlight'); +jest.mock('~/flash'); + +describe('Markdown Live Preview Extension for Source Editor', () => { + let editor; + let instance; + let editorEl; + let panelSpy; + let mockAxios; + let extension; + const previewMarkdownPath = '/gitlab/fooGroup/barProj/preview_markdown'; + const firstLine = 'This is a'; + const secondLine = 'multiline'; + const thirdLine = 'string with some **markup**'; + const text = `${firstLine}\n${secondLine}\n${thirdLine}`; + const plaintextPath = 'foo.txt'; + const markdownPath = 'foo.md'; + const responseData = '<div>FooBar</div>'; + + 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: markdownPath, + blobContent: text, + }); + extension = instance.use({ + definition: EditorMarkdownPreviewExtension, + setupOptions: { previewMarkdownPath }, + }); + panelSpy = jest.spyOn(extension.obj.constructor.prototype, 'togglePreviewPanel'); + }); + + afterEach(() => { + instance.dispose(); + editorEl.remove(); + mockAxios.restore(); + }); + + it('sets up the preview on the instance', () => { + expect(instance.markdownPreview).toEqual({ + el: undefined, + action: expect.any(Object), + shown: false, + modelChangeListener: undefined, + path: previewMarkdownPath, + }); + }); + + describe('model language changes listener', () => { + let cleanupSpy; + let actionSpy; + + beforeEach(async () => { + cleanupSpy = jest.fn(); + actionSpy = jest.fn(); + spyOnApi(extension, { + cleanup: cleanupSpy, + setupPreviewAction: actionSpy, + }); + await togglePreview(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('cleans up when switching away from markdown', () => { + expect(cleanupSpy).not.toHaveBeenCalled(); + expect(actionSpy).not.toHaveBeenCalled(); + + instance.updateModelLanguage(plaintextPath); + + expect(cleanupSpy).toHaveBeenCalled(); + expect(actionSpy).not.toHaveBeenCalled(); + }); + + it.each` + oldLanguage | newLanguage | setupCalledTimes + ${'plaintext'} | ${'markdown'} | ${1} + ${'markdown'} | ${'markdown'} | ${0} + ${'markdown'} | ${'plaintext'} | ${0} + ${'markdown'} | ${undefined} | ${0} + ${undefined} | ${'markdown'} | ${1} + `( + 'correctly handles re-enabling of the action when switching from $oldLanguage to $newLanguage', + ({ oldLanguage, newLanguage, setupCalledTimes } = {}) => { + expect(actionSpy).not.toHaveBeenCalled(); + instance.updateModelLanguage(oldLanguage); + instance.updateModelLanguage(newLanguage); + expect(actionSpy).toHaveBeenCalledTimes(setupCalledTimes); + }, + ); + }); + + describe('model change listener', () => { + let cleanupSpy; + let actionSpy; + + beforeEach(() => { + cleanupSpy = jest.fn(); + actionSpy = jest.fn(); + spyOnApi(extension, { + cleanup: cleanupSpy, + setupPreviewAction: actionSpy, + }); + 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.markdownPreview.modelChangeListener).toBeDefined(); + const fetchPreviewSpy = jest.fn(); + spyOnApi(extension, { + fetchPreview: fetchPreviewSpy, + }); + + instance.cleanup(); + instance.setValue('Foo Bar'); + jest.advanceTimersByTime(EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY); + + expect(fetchPreviewSpy).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.markdownPreview.shown).toBe(true); + instance.cleanup(); + expect(instance.markdownPreview.shown).toBe(false); + }); + + it('toggles the panel only if the preview is visible', () => { + const { el: previewEl } = instance.markdownPreview; + const parentEl = previewEl.parentElement; + + 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.markdownPreview.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 fetchPreview = async () => { + instance.fetchPreview(); + await waitForPromises(); + }; + + let previewMarkdownSpy; + + beforeEach(() => { + previewMarkdownSpy = jest.fn().mockImplementation(() => [200, { body: responseData }]); + mockAxios.onPost(previewMarkdownPath).replyOnce((req) => previewMarkdownSpy(req)); + }); + + it('correctly fetches preview based on previewMarkdownPath', async () => { + await fetchPreview(); + + expect(previewMarkdownSpy).toHaveBeenCalledWith( + expect.objectContaining({ data: JSON.stringify({ text }) }), + ); + }); + + it('puts the fetched content into the preview DOM element', async () => { + instance.markdownPreview.el = editorEl.parentElement; + await fetchPreview(); + expect(instance.markdownPreview.el.innerHTML).toEqual(responseData); + }); + + it('applies syntax highlighting to the preview content', async () => { + instance.markdownPreview.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', () => { + const togglePreviewSpy = jest.fn(); + spyOnApi(extension, { + togglePreview: togglePreviewSpy, + }); + + expect(togglePreviewSpy).not.toHaveBeenCalled(); + + const action = instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID); + action.run(); + + expect(togglePreviewSpy).toHaveBeenCalled(); + }); + }); + + describe('togglePreview', () => { + beforeEach(() => { + mockAxios.onPost().reply(200, { body: responseData }); + }); + + it('toggles preview flag on instance', () => { + expect(instance.markdownPreview.shown).toBe(false); + + instance.togglePreview(); + expect(instance.markdownPreview.shown).toBe(true); + + instance.togglePreview(); + expect(instance.markdownPreview.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.markdownPreview.el).toBeUndefined(); + + instance.togglePreview(); + + expect(instance.markdownPreview.el).toBeDefined(); + expect( + instance.markdownPreview.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.markdownPreview.el; + instance.togglePreview(); + + expect(instance.markdownPreview.el).toBe(origPreviewEl); + }); + + it('hides the preview DOM element by default', () => { + panelSpy.mockImplementation(); + instance.togglePreview(); + expect(instance.markdownPreview.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.markdownPreview.el.style.display).toBe('block'); + await togglePreview(); + expect(instance.markdownPreview.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.markdownPreview.modelChangeListener).toBeUndefined(); + await togglePreview(); + expect(instance.markdownPreview.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.markdownPreview.modelChangeListener = { + dispose: disposeSpy, + }; + instance.togglePreview(); + expect(disposeSpy).toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/spec/frontend/editor/source_editor_spec.js b/spec/frontend/editor/source_editor_spec.js index d87d373c952..bc53202c919 100644 --- a/spec/frontend/editor/source_editor_spec.js +++ b/spec/frontend/editor/source_editor_spec.js @@ -1,16 +1,28 @@ -/* eslint-disable max-classes-per-file */ import { editor as monacoEditor, languages as monacoLanguages } from 'monaco-editor'; -import waitForPromises from 'helpers/wait_for_promises'; import { SOURCE_EDITOR_INSTANCE_ERROR_NO_EL, URI_PREFIX, EDITOR_READY_EVENT, } from '~/editor/constants'; -import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import SourceEditor from '~/editor/source_editor'; import { DEFAULT_THEME, themes } from '~/ide/lib/themes'; import { joinPaths } from '~/lib/utils/url_utility'; +jest.mock('~/helpers/startup_css_helper', () => { + return { + waitForCSSLoaded: jest.fn().mockImplementation((cb) => { + // We have to artificially put the callback's execution + // to the end of the current call stack to be able to + // test that the callback is called after waitForCSSLoaded. + // setTimeout with 0 delay does exactly that. + // Otherwise we might end up with false positive results + setTimeout(() => { + cb.apply(); + }, 0); + }), + }; +}); + describe('Base editor', () => { let editorEl; let editor; @@ -19,7 +31,6 @@ describe('Base editor', () => { const blobContent = 'Foo Bar'; const blobPath = 'test.md'; const blobGlobalId = 'snippet_777'; - const fakeModel = { foo: 'bar', dispose: jest.fn() }; beforeEach(() => { setFixtures('<div id="editor" data-editor-loading></div>'); @@ -52,16 +63,6 @@ describe('Base editor', () => { describe('instance of the Source Editor', () => { let modelSpy; let instanceSpy; - const setModel = jest.fn(); - const dispose = jest.fn(); - const mockModelReturn = (res = fakeModel) => { - modelSpy = jest.spyOn(monacoEditor, 'createModel').mockImplementation(() => res); - }; - const mockDecorateInstance = (decorations = {}) => { - jest.spyOn(SourceEditor, 'convertMonacoToELInstance').mockImplementation((inst) => { - return Object.assign(inst, decorations); - }); - }; beforeEach(() => { modelSpy = jest.spyOn(monacoEditor, 'createModel'); @@ -73,46 +74,38 @@ describe('Base editor', () => { }); it('throws an error if no dom element is supplied', () => { - mockDecorateInstance(); - expect(() => { + const create = () => { editor.createInstance(); - }).toThrow(SOURCE_EDITOR_INSTANCE_ERROR_NO_EL); + }; + expect(create).toThrow(SOURCE_EDITOR_INSTANCE_ERROR_NO_EL); expect(modelSpy).not.toHaveBeenCalled(); expect(instanceSpy).not.toHaveBeenCalled(); - expect(SourceEditor.convertMonacoToELInstance).not.toHaveBeenCalled(); }); - it('creates model to be supplied to Monaco editor', () => { - mockModelReturn(); - mockDecorateInstance({ - setModel, - }); - editor.createInstance(defaultArguments); + it('creates model and attaches it to the instance', () => { + jest.spyOn(monacoEditor, 'createModel'); + const instance = editor.createInstance(defaultArguments); - expect(modelSpy).toHaveBeenCalledWith( + expect(monacoEditor.createModel).toHaveBeenCalledWith( blobContent, undefined, expect.objectContaining({ path: uriFilePath, }), ); - expect(setModel).toHaveBeenCalledWith(fakeModel); + expect(instance.getModel().getValue()).toEqual(defaultArguments.blobContent); }); it('does not create a model automatically if model is passed as `null`', () => { - mockDecorateInstance({ - setModel, - }); - editor.createInstance({ ...defaultArguments, model: null }); - expect(modelSpy).not.toHaveBeenCalled(); - expect(setModel).not.toHaveBeenCalled(); + const instance = editor.createInstance({ ...defaultArguments, model: null }); + expect(instance.getModel()).toBeNull(); }); it('initializes the instance on a supplied DOM node', () => { editor.createInstance({ el: editorEl }); - expect(editor.editorEl).not.toBe(null); + expect(editor.editorEl).not.toBeNull(); expect(instanceSpy).toHaveBeenCalledWith(editorEl, expect.anything()); }); @@ -143,32 +136,43 @@ describe('Base editor', () => { }); it('disposes instance when the global editor is disposed', () => { - mockDecorateInstance({ - dispose, - }); - editor.createInstance(defaultArguments); + const instance = editor.createInstance(defaultArguments); + instance.dispose = jest.fn(); - expect(dispose).not.toHaveBeenCalled(); + expect(instance.dispose).not.toHaveBeenCalled(); editor.dispose(); - expect(dispose).toHaveBeenCalled(); + expect(instance.dispose).toHaveBeenCalled(); }); it("removes the disposed instance from the global editor's storage and disposes the associated model", () => { - mockModelReturn(); - mockDecorateInstance({ - setModel, - }); const instance = editor.createInstance(defaultArguments); expect(editor.instances).toHaveLength(1); - expect(fakeModel.dispose).not.toHaveBeenCalled(); + expect(instance.getModel()).not.toBeNull(); instance.dispose(); expect(editor.instances).toHaveLength(0); - expect(fakeModel.dispose).toHaveBeenCalled(); + expect(instance.getModel()).toBeNull(); + }); + + it('resets the layout in waitForCSSLoaded callback', async () => { + const layoutSpy = jest.fn(); + jest.spyOn(monacoEditor, 'create').mockReturnValue({ + layout: layoutSpy, + setModel: jest.fn(), + onDidDispose: jest.fn(), + dispose: jest.fn(), + }); + editor.createInstance(defaultArguments); + expect(layoutSpy).not.toHaveBeenCalled(); + + // We're waiting for the waitForCSSLoaded mock to kick in + await jest.runOnlyPendingTimers(); + + expect(layoutSpy).toHaveBeenCalled(); }); }); @@ -214,26 +218,17 @@ describe('Base editor', () => { }); it('correctly disposes the diff editor model', () => { - const modifiedModel = fakeModel; - const originalModel = { ...fakeModel }; - mockDecorateInstance({ - getModel: jest.fn().mockReturnValue({ - original: originalModel, - modified: modifiedModel, - }), - }); - const instance = editor.createDiffInstance({ ...defaultArguments, blobOriginalContent }); expect(editor.instances).toHaveLength(1); - expect(originalModel.dispose).not.toHaveBeenCalled(); - expect(modifiedModel.dispose).not.toHaveBeenCalled(); + expect(instance.getOriginalEditor().getModel()).not.toBeNull(); + expect(instance.getModifiedEditor().getModel()).not.toBeNull(); instance.dispose(); expect(editor.instances).toHaveLength(0); - expect(originalModel.dispose).toHaveBeenCalled(); - expect(modifiedModel.dispose).toHaveBeenCalled(); + expect(instance.getOriginalEditor().getModel()).toBeNull(); + expect(instance.getModifiedEditor().getModel()).toBeNull(); }); }); }); @@ -355,282 +350,19 @@ describe('Base editor', () => { expect(instance.getValue()).toBe(blobContent); }); - it('is capable of changing the language of the model', () => { - // ignore warnings and errors Monaco posts during setup - // (due to being called from Jest/Node.js environment) - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); - - const blobRenamedPath = 'test.js'; - - expect(instance.getModel().getLanguageIdentifier().language).toBe('markdown'); - instance.updateModelLanguage(blobRenamedPath); - - expect(instance.getModel().getLanguageIdentifier().language).toBe('javascript'); - }); - - it('falls back to plaintext if there is no language associated with an extension', () => { - const blobRenamedPath = 'test.myext'; - const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - instance.updateModelLanguage(blobRenamedPath); - - expect(spy).not.toHaveBeenCalled(); - expect(instance.getModel().getLanguageIdentifier().language).toBe('plaintext'); - }); - }); - - describe('extensions', () => { - let instance; - const alphaRes = jest.fn(); - const betaRes = jest.fn(); - const fooRes = jest.fn(); - const barRes = jest.fn(); - class AlphaClass { - constructor() { - this.res = alphaRes; - } - alpha() { - return this?.nonExistentProp || alphaRes; - } - } - class BetaClass { - beta() { - return this?.nonExistentProp || betaRes; - } - } - class WithStaticMethod { - constructor({ instance: inst, ...options } = {}) { - Object.assign(inst, options); - } - static computeBoo(a) { - return a + 1; - } - boo() { - return WithStaticMethod.computeBoo(this.base); - } - } - class WithStaticMethodExtended extends SourceEditorExtension { - static computeBoo(a) { - return a + 1; - } - boo() { - return WithStaticMethodExtended.computeBoo(this.base); - } - } - const AlphaExt = new AlphaClass(); - const BetaExt = new BetaClass(); - const FooObjExt = { - foo() { - return fooRes; - }, - }; - const BarObjExt = { - bar() { - return barRes; - }, - }; - - describe('basic functionality', () => { - beforeEach(() => { - instance = editor.createInstance({ el: editorEl, blobPath, blobContent }); - }); - - it('does not fail if no extensions supplied', () => { - const spy = jest.spyOn(global.console, 'error'); - instance.use(); - - expect(spy).not.toHaveBeenCalled(); - }); - - it("does not extend instance with extension's constructor", () => { - expect(instance.constructor).toBeDefined(); - const { constructor } = instance; - - expect(AlphaExt.constructor).toBeDefined(); - expect(AlphaExt.constructor).not.toEqual(constructor); - - instance.use(AlphaExt); - expect(instance.constructor).toBe(constructor); - }); - - it.each` - type | extensions | methods | expectations - ${'ES6 classes'} | ${AlphaExt} | ${['alpha']} | ${[alphaRes]} - ${'multiple ES6 classes'} | ${[AlphaExt, BetaExt]} | ${['alpha', 'beta']} | ${[alphaRes, betaRes]} - ${'simple objects'} | ${FooObjExt} | ${['foo']} | ${[fooRes]} - ${'multiple simple objects'} | ${[FooObjExt, BarObjExt]} | ${['foo', 'bar']} | ${[fooRes, barRes]} - ${'combination of ES6 classes and objects'} | ${[AlphaExt, BarObjExt]} | ${['alpha', 'bar']} | ${[alphaRes, barRes]} - `('is extensible with $type', ({ extensions, methods, expectations } = {}) => { - methods.forEach((method) => { - expect(instance[method]).toBeUndefined(); - }); - - instance.use(extensions); - - methods.forEach((method) => { - expect(instance[method]).toBeDefined(); - }); - - expectations.forEach((expectation, i) => { - expect(instance[methods[i]].call()).toEqual(expectation); - }); - }); - - it('does not extend instance with private data of an extension', () => { - const ext = new WithStaticMethod({ instance }); - ext.staticMethod = () => { - return 'foo'; - }; - ext.staticProp = 'bar'; - - expect(instance.boo).toBeUndefined(); - expect(instance.staticMethod).toBeUndefined(); - expect(instance.staticProp).toBeUndefined(); - - instance.use(ext); - - expect(instance.boo).toBeDefined(); - expect(instance.staticMethod).toBeUndefined(); - expect(instance.staticProp).toBeUndefined(); - }); - - it.each([WithStaticMethod, WithStaticMethodExtended])( - 'properly resolves data for an extension with private data', - (ExtClass) => { - const base = 1; - expect(instance.base).toBeUndefined(); - expect(instance.boo).toBeUndefined(); - - const ext = new ExtClass({ instance, base }); - - instance.use(ext); - expect(instance.base).toBe(1); - expect(instance.boo()).toBe(2); - }, - ); - - it('uses the last definition of a method in case of an overlap', () => { - const FooObjExt2 = { foo: 'foo2' }; - instance.use([FooObjExt, BarObjExt, FooObjExt2]); - expect(instance).toMatchObject({ - foo: 'foo2', - ...BarObjExt, - }); - }); - - it('correctly resolves references withing extensions', () => { - const FunctionExt = { - inst() { - return this; - }, - mod() { - return this.getModel(); - }, + it('emits the EDITOR_READY_EVENT event after setting up the instance', () => { + jest.spyOn(monacoEditor, 'create').mockImplementation(() => { + return { + setModel: jest.fn(), + onDidDispose: jest.fn(), + layout: jest.fn(), }; - instance.use(FunctionExt); - expect(instance.inst()).toEqual(editor.instances[0]); - }); - }); - - describe('extensions as an instance parameter', () => { - let editorExtensionSpy; - const instanceConstructor = (extensions = []) => { - return editor.createInstance({ - el: editorEl, - blobPath, - blobContent, - extensions, - }); - }; - - beforeEach(() => { - editorExtensionSpy = jest - .spyOn(SourceEditor, 'pushToImportsArray') - .mockImplementation((arr) => { - arr.push( - Promise.resolve({ - default: {}, - }), - ); - }); - }); - - it.each([undefined, [], [''], ''])( - 'does not fail and makes no fetch if extensions is %s', - () => { - instance = instanceConstructor(null); - expect(editorExtensionSpy).not.toHaveBeenCalled(); - }, - ); - - it.each` - type | value | callsCount - ${'simple string'} | ${'foo'} | ${1} - ${'combined string'} | ${'foo, bar'} | ${2} - ${'array of strings'} | ${['foo', 'bar']} | ${2} - `('accepts $type as an extension parameter', ({ value, callsCount }) => { - instance = instanceConstructor(value); - expect(editorExtensionSpy).toHaveBeenCalled(); - expect(editorExtensionSpy.mock.calls).toHaveLength(callsCount); - }); - - it.each` - desc | path | expectation - ${'~/editor'} | ${'foo'} | ${'~/editor/foo'} - ${'~/CUSTOM_PATH with leading slash'} | ${'/my_custom_path/bar'} | ${'~/my_custom_path/bar'} - ${'~/CUSTOM_PATH without leading slash'} | ${'my_custom_path/delta'} | ${'~/my_custom_path/delta'} - `('fetches extensions from $desc path', ({ path, expectation }) => { - instance = instanceConstructor(path); - expect(editorExtensionSpy).toHaveBeenCalledWith(expect.any(Array), expectation); - }); - - it('emits EDITOR_READY_EVENT event after all extensions were applied', async () => { - const calls = []; - const eventSpy = jest.fn().mockImplementation(() => { - calls.push('event'); - }); - const useSpy = jest.fn().mockImplementation(() => { - calls.push('use'); - }); - jest.spyOn(SourceEditor, 'convertMonacoToELInstance').mockImplementation((inst) => { - const decoratedInstance = inst; - decoratedInstance.use = useSpy; - return decoratedInstance; - }); - editorEl.addEventListener(EDITOR_READY_EVENT, eventSpy); - instance = instanceConstructor('foo, bar'); - await waitForPromises(); - expect(useSpy.mock.calls).toHaveLength(2); - expect(calls).toEqual(['use', 'use', 'event']); - }); - }); - - describe('multiple instances', () => { - let inst1; - let inst2; - let editorEl1; - let editorEl2; - - beforeEach(() => { - setFixtures('<div id="editor1"></div><div id="editor2"></div>'); - editorEl1 = document.getElementById('editor1'); - editorEl2 = document.getElementById('editor2'); - inst1 = editor.createInstance({ el: editorEl1, blobPath: `foo-${blobPath}` }); - inst2 = editor.createInstance({ el: editorEl2, blobPath: `bar-${blobPath}` }); - }); - - afterEach(() => { - editor.dispose(); - editorEl1.remove(); - editorEl2.remove(); - }); - - it('extends all instances if no specific instance is passed', () => { - editor.use(AlphaExt); - expect(inst1.alpha()).toEqual(alphaRes); - expect(inst2.alpha()).toEqual(alphaRes); }); + const eventSpy = jest.fn(); + editorEl.addEventListener(EDITOR_READY_EVENT, eventSpy); + expect(eventSpy).not.toHaveBeenCalled(); + editor.createInstance({ el: editorEl }); + expect(eventSpy).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/editor/source_editor_yaml_ext_spec.js b/spec/frontend/editor/source_editor_yaml_ext_spec.js index 97d2b0b21d0..a861d9c7a45 100644 --- a/spec/frontend/editor/source_editor_yaml_ext_spec.js +++ b/spec/frontend/editor/source_editor_yaml_ext_spec.js @@ -2,6 +2,10 @@ import { Document } from 'yaml'; import SourceEditor from '~/editor/source_editor'; import { YamlEditorExtension } from '~/editor/extensions/source_editor_yaml_ext'; import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; +import { spyOnApi } from 'jest/editor/helpers'; + +let baseExtension; +let yamlExtension; const getEditorInstance = (editorInstanceOptions = {}) => { setFixtures('<div id="editor"></div>'); @@ -16,7 +20,10 @@ const getEditorInstance = (editorInstanceOptions = {}) => { const getEditorInstanceWithExtension = (extensionOptions = {}, editorInstanceOptions = {}) => { setFixtures('<div id="editor"></div>'); const instance = getEditorInstance(editorInstanceOptions); - instance.use(new YamlEditorExtension({ instance, ...extensionOptions })); + [baseExtension, yamlExtension] = instance.use([ + { definition: SourceEditorExtension }, + { definition: YamlEditorExtension, setupOptions: extensionOptions }, + ]); // Remove the below once // https://gitlab.com/gitlab-org/gitlab/-/issues/325992 is resolved @@ -29,19 +36,16 @@ const getEditorInstanceWithExtension = (extensionOptions = {}, editorInstanceOpt describe('YamlCreatorExtension', () => { describe('constructor', () => { - it('saves constructor options', () => { + it('saves setupOptions options on the extension, but does not expose those to instance', () => { + const highlightPath = 'foo'; const instance = getEditorInstanceWithExtension({ - highlightPath: 'foo', + highlightPath, enableComments: true, }); - expect(instance).toEqual( - expect.objectContaining({ - options: expect.objectContaining({ - highlightPath: 'foo', - enableComments: true, - }), - }), - ); + expect(yamlExtension.obj.highlightPath).toBe(highlightPath); + expect(yamlExtension.obj.enableComments).toBe(true); + expect(instance.highlightPath).toBeUndefined(); + expect(instance.enableComments).toBeUndefined(); }); it('dumps values loaded with the model constructor options', () => { @@ -55,7 +59,7 @@ describe('YamlCreatorExtension', () => { it('registers the onUpdate() function', () => { const instance = getEditorInstance(); const onDidChangeModelContent = jest.spyOn(instance, 'onDidChangeModelContent'); - instance.use(new YamlEditorExtension({ instance })); + instance.use({ definition: YamlEditorExtension }); expect(onDidChangeModelContent).toHaveBeenCalledWith(expect.any(Function)); }); @@ -82,21 +86,21 @@ describe('YamlCreatorExtension', () => { it('should call transformComments if enableComments is true', () => { const instance = getEditorInstanceWithExtension({ enableComments: true }); const transformComments = jest.spyOn(YamlEditorExtension, 'transformComments'); - YamlEditorExtension.initFromModel(instance, model); + instance.initFromModel(model); expect(transformComments).toHaveBeenCalled(); }); it('should not call transformComments if enableComments is false', () => { const instance = getEditorInstanceWithExtension({ enableComments: false }); const transformComments = jest.spyOn(YamlEditorExtension, 'transformComments'); - YamlEditorExtension.initFromModel(instance, model); + instance.initFromModel(model); expect(transformComments).not.toHaveBeenCalled(); }); it('should call setValue with the stringified model', () => { const instance = getEditorInstanceWithExtension(); const setValue = jest.spyOn(instance, 'setValue'); - YamlEditorExtension.initFromModel(instance, model); + instance.initFromModel(model); expect(setValue).toHaveBeenCalledWith(doc.toString()); }); }); @@ -240,26 +244,35 @@ foo: it("should call setValue with the stringified doc if the editor's value is empty", () => { const instance = getEditorInstanceWithExtension(); const setValue = jest.spyOn(instance, 'setValue'); - const updateValue = jest.spyOn(instance, 'updateValue'); + const updateValueSpy = jest.fn(); + spyOnApi(yamlExtension, { + updateValue: updateValueSpy, + }); instance.setDoc(doc); expect(setValue).toHaveBeenCalledWith(doc.toString()); - expect(updateValue).not.toHaveBeenCalled(); + expect(updateValueSpy).not.toHaveBeenCalled(); }); it("should call updateValue with the stringified doc if the editor's value is not empty", () => { const instance = getEditorInstanceWithExtension({}, { value: 'asjkdhkasjdh' }); const setValue = jest.spyOn(instance, 'setValue'); - const updateValue = jest.spyOn(instance, 'updateValue'); + const updateValueSpy = jest.fn(); + spyOnApi(yamlExtension, { + updateValue: updateValueSpy, + }); instance.setDoc(doc); expect(setValue).not.toHaveBeenCalled(); - expect(updateValue).toHaveBeenCalledWith(doc.toString()); + expect(updateValueSpy).toHaveBeenCalledWith(instance, doc.toString()); }); it('should trigger the onUpdate method', () => { const instance = getEditorInstanceWithExtension(); - const onUpdate = jest.spyOn(instance, 'onUpdate'); + const onUpdateSpy = jest.fn(); + spyOnApi(yamlExtension, { + onUpdate: onUpdateSpy, + }); instance.setDoc(doc); - expect(onUpdate).toHaveBeenCalled(); + expect(onUpdateSpy).toHaveBeenCalled(); }); }); @@ -320,9 +333,12 @@ foo: it('calls highlight', () => { const highlightPath = 'foo'; const instance = getEditorInstanceWithExtension({ highlightPath }); - instance.highlight = jest.fn(); + // Here we do not spy on the public API method of the extension, but rather + // the public method of the extension's instance. + // This is required based on how `onUpdate` works + const highlightSpy = jest.spyOn(yamlExtension.obj, 'highlight'); instance.onUpdate(); - expect(instance.highlight).toHaveBeenCalledWith(highlightPath); + expect(highlightSpy).toHaveBeenCalledWith(instance, highlightPath); }); }); @@ -350,8 +366,12 @@ foo: beforeEach(() => { instance = getEditorInstanceWithExtension({ highlightPath: highlightPathOnSetup }, { value }); - highlightLinesSpy = jest.spyOn(SourceEditorExtension, 'highlightLines'); - removeHighlightsSpy = jest.spyOn(SourceEditorExtension, 'removeHighlights'); + highlightLinesSpy = jest.fn(); + removeHighlightsSpy = jest.fn(); + spyOnApi(baseExtension, { + highlightLines: highlightLinesSpy, + removeHighlights: removeHighlightsSpy, + }); }); afterEach(() => { @@ -361,7 +381,7 @@ foo: it('saves the highlighted path in highlightPath', () => { const path = 'foo.bar'; instance.highlight(path); - expect(instance.options.highlightPath).toEqual(path); + expect(yamlExtension.obj.highlightPath).toEqual(path); }); it('calls highlightLines with a number of lines', () => { @@ -374,14 +394,14 @@ foo: instance.highlight(null); expect(removeHighlightsSpy).toHaveBeenCalledWith(instance); expect(highlightLinesSpy).not.toHaveBeenCalled(); - expect(instance.options.highlightPath).toBeNull(); + expect(yamlExtension.obj.highlightPath).toBeNull(); }); it('throws an error if path is invalid and does not change the highlighted path', () => { expect(() => instance.highlight('invalidPath[0]')).toThrow( 'The node invalidPath[0] could not be found inside the document.', ); - expect(instance.options.highlightPath).toEqual(highlightPathOnSetup); + expect(yamlExtension.obj.highlightPath).toEqual(highlightPathOnSetup); expect(highlightLinesSpy).not.toHaveBeenCalled(); expect(removeHighlightsSpy).not.toHaveBeenCalled(); }); diff --git a/spec/frontend/emoji/index_spec.js b/spec/frontend/emoji/index_spec.js index 9652c513671..cc037586496 100644 --- a/spec/frontend/emoji/index_spec.js +++ b/spec/frontend/emoji/index_spec.js @@ -1,6 +1,21 @@ -import { emojiFixtureMap, mockEmojiData, initEmojiMock } from 'helpers/emoji'; +import { + emojiFixtureMap, + mockEmojiData, + initEmojiMock, + validEmoji, + invalidEmoji, + clearEmojiMock, +} from 'helpers/emoji'; import { trimText } from 'helpers/text_helper'; -import { glEmojiTag, searchEmoji, getEmojiInfo, sortEmoji } from '~/emoji'; +import { + glEmojiTag, + searchEmoji, + getEmojiInfo, + sortEmoji, + initEmojiMap, + getAllEmoji, +} from '~/emoji'; + import isEmojiUnicodeSupported, { isFlagEmoji, isRainbowFlagEmoji, @@ -9,7 +24,6 @@ import isEmojiUnicodeSupported, { isHorceRacingSkinToneComboEmoji, isPersonZwjEmoji, } from '~/emoji/support/is_emoji_unicode_supported'; -import { sanitize } from '~/lib/dompurify'; const emptySupportMap = { personZwj: false, @@ -31,14 +45,55 @@ const emptySupportMap = { }; describe('emoji', () => { - let mock; - beforeEach(async () => { - mock = await initEmojiMock(); + await initEmojiMock(); }); afterEach(() => { - mock.restore(); + clearEmojiMock(); + }); + + describe('initEmojiMap', () => { + it('should contain valid emoji', async () => { + await initEmojiMap(); + + const allEmoji = Object.keys(getAllEmoji()); + Object.keys(validEmoji).forEach((key) => { + expect(allEmoji.includes(key)).toBe(true); + }); + }); + + it('should not contain invalid emoji', async () => { + await initEmojiMap(); + + const allEmoji = Object.keys(getAllEmoji()); + Object.keys(invalidEmoji).forEach((key) => { + expect(allEmoji.includes(key)).toBe(false); + }); + }); + + it('fixes broken pride emoji', async () => { + clearEmojiMock(); + await initEmojiMock({ + gay_pride_flag: { + c: 'flags', + // Without a zero-width joiner + e: '🏳🌈', + name: 'gay_pride_flag', + u: '6.0', + }, + }); + + expect(getAllEmoji()).toEqual({ + gay_pride_flag: { + c: 'flags', + // With a zero-width joiner + e: '🏳️🌈', + name: 'gay_pride_flag', + u: '6.0', + }, + }); + }); }); describe('glEmojiTag', () => { @@ -378,32 +433,14 @@ describe('emoji', () => { }); describe('searchEmoji', () => { - const emojiFixture = Object.keys(mockEmojiData).reduce((acc, k) => { - const { name, e, u, d } = mockEmojiData[k]; - acc[k] = { name, e: sanitize(e), u, d }; - - return acc; - }, {}); - it.each([undefined, null, ''])("should return all emoji when the input is '%s'", (input) => { const search = searchEmoji(input); - const expected = [ - 'atom', - 'bomb', - 'construction_worker_tone5', - 'five', - 'grey_question', - 'black_heart', - 'heart', - 'custard', - 'star', - 'xss', - ].map((name) => { + const expected = Object.keys(validEmoji).map((name) => { return { - emoji: emojiFixture[name], + emoji: mockEmojiData[name], field: 'd', - fieldValue: emojiFixture[name].d, + fieldValue: mockEmojiData[name].d, score: 0, }; }); @@ -453,7 +490,7 @@ describe('emoji', () => { const { field, score, fieldValue, name } = item; return { - emoji: emojiFixture[name], + emoji: mockEmojiData[name], field, fieldValue, score, @@ -564,9 +601,9 @@ describe('emoji', () => { const { field, score, name } = item; return { - emoji: emojiFixture[name], + emoji: mockEmojiData[name], field, - fieldValue: emojiFixture[name][field], + fieldValue: mockEmojiData[name][field], score, }; }); @@ -622,13 +659,4 @@ describe('emoji', () => { expect(sortEmoji(scoredItems)).toEqual(expected); }); }); - - describe('sanitize emojis', () => { - it('should return sanitized emoji', () => { - expect(getEmojiInfo('xss')).toEqual({ - ...mockEmojiData.xss, - e: '<img src="x">', - }); - }); - }); }); diff --git a/spec/frontend/environments/confirm_rollback_modal_spec.js b/spec/frontend/environments/confirm_rollback_modal_spec.js index d62aaec4f69..b699f953945 100644 --- a/spec/frontend/environments/confirm_rollback_modal_spec.js +++ b/spec/frontend/environments/confirm_rollback_modal_spec.js @@ -1,6 +1,9 @@ import { GlModal, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; import eventHub from '~/environments/event_hub'; describe('Confirm Rollback Modal Component', () => { @@ -17,6 +20,17 @@ describe('Confirm Rollback Modal Component', () => { modalId: 'test', }; + const envWithLastDeploymentGraphql = { + name: 'test', + lastDeployment: { + commit: { + shortId: 'abc0123', + }, + 'last?': true, + }, + modalId: 'test', + }; + const envWithoutLastDeployment = { name: 'test', modalId: 'test', @@ -26,7 +40,7 @@ describe('Confirm Rollback Modal Component', () => { const retryPath = 'test/-/jobs/123/retry'; - const createComponent = (props = {}) => { + const createComponent = (props = {}, options = {}) => { component = shallowMount(ConfirmRollbackModal, { propsData: { ...props, @@ -34,6 +48,7 @@ describe('Confirm Rollback Modal Component', () => { stubs: { GlSprintf, }, + ...options, }); }; @@ -101,4 +116,121 @@ describe('Confirm Rollback Modal Component', () => { }); }, ); + + describe('graphql', () => { + describe.each` + hasMultipleCommits | environmentData | retryUrl | primaryPropsAttrs + ${true} | ${envWithLastDeploymentGraphql} | ${null} | ${[{ variant: 'danger' }]} + ${false} | ${envWithoutLastDeployment} | ${retryPath} | ${[{ variant: 'danger' }, { 'data-method': 'post' }, { href: retryPath }]} + `( + 'when hasMultipleCommits=$hasMultipleCommits', + ({ hasMultipleCommits, environmentData, retryUrl, primaryPropsAttrs }) => { + Vue.use(VueApollo); + + let apolloProvider; + let rollbackResolver; + + beforeEach(() => { + rollbackResolver = jest.fn(); + apolloProvider = createMockApollo([], { + Mutation: { rollbackEnvironment: rollbackResolver }, + }); + environment = environmentData; + }); + + it('should set contain the commit hash and ask for confirmation', () => { + createComponent( + { + environment: { + ...environment, + lastDeployment: { + ...environment.lastDeployment, + 'last?': false, + }, + }, + hasMultipleCommits, + retryUrl, + graphql: true, + }, + { apolloProvider }, + ); + const modal = component.find(GlModal); + + expect(modal.text()).toContain('commit abc0123'); + expect(modal.text()).toContain('Are you sure you want to continue?'); + }); + + it('should show "Rollback" when isLastDeployment is false', () => { + createComponent( + { + environment: { + ...environment, + lastDeployment: { + ...environment.lastDeployment, + 'last?': false, + }, + }, + hasMultipleCommits, + retryUrl, + graphql: true, + }, + { apolloProvider }, + ); + 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); + }); + + it('should show "Re-deploy" when isLastDeployment is true', () => { + createComponent( + { + environment: { + ...environment, + lastDeployment: { + ...environment.lastDeployment, + 'last?': true, + }, + }, + hasMultipleCommits, + graphql: true, + }, + { apolloProvider }, + ); + + 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'); + }); + + it('should commit the "rollback" mutation when "ok" is clicked', async () => { + const env = { ...environmentData, isLastDeployment: true }; + + createComponent( + { + environment: env, + hasMultipleCommits, + graphql: true, + }, + { apolloProvider }, + ); + + const modal = component.find(GlModal); + modal.vm.$emit('ok'); + + await nextTick(); + expect(rollbackResolver).toHaveBeenCalledWith( + expect.anything(), + { environment: env }, + expect.anything(), + expect.anything(), + ); + }); + }, + ); + }); }); diff --git a/spec/frontend/environments/delete_environment_modal_spec.js b/spec/frontend/environments/delete_environment_modal_spec.js new file mode 100644 index 00000000000..50c4ca00009 --- /dev/null +++ b/spec/frontend/environments/delete_environment_modal_spec.js @@ -0,0 +1,64 @@ +import { GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { s__, sprintf } from '~/locale'; +import DeleteEnvironmentModal from '~/environments/components/delete_environment_modal.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { resolvedEnvironment } from './graphql/mock_data'; + +Vue.use(VueApollo); + +describe('~/environments/components/delete_environment_modal.vue', () => { + let mockApollo; + let deleteResolver; + let wrapper; + + const createComponent = ({ props = {}, apolloProvider } = {}) => { + wrapper = shallowMount(DeleteEnvironmentModal, { + propsData: { + graphql: true, + environment: resolvedEnvironment, + ...props, + }, + apolloProvider, + }); + }; + + beforeEach(() => { + deleteResolver = jest.fn(); + mockApollo = createMockApollo([], { + Mutation: { deleteEnvironment: deleteResolver }, + }); + }); + + it('should confirm the environment to delete', () => { + createComponent({ apolloProvider: mockApollo }); + + expect(wrapper.text()).toBe( + sprintf( + s__( + `Environments|Deleting the '%{environmentName}' environment cannot be undone. Do you want to delete it anyway?`, + ), + { + environmentName: resolvedEnvironment.name, + }, + ), + ); + }); + + it('should send the delete mutation on primary', async () => { + createComponent({ apolloProvider: mockApollo }); + + wrapper.findComponent(GlModal).vm.$emit('primary'); + + await nextTick(); + + expect(deleteResolver).toHaveBeenCalledWith( + expect.anything(), + { environment: resolvedEnvironment }, + expect.anything(), + expect.anything(), + ); + }); +}); diff --git a/spec/frontend/environments/enable_review_app_modal_spec.js b/spec/frontend/environments/enable_review_app_modal_spec.js index 9a3f13f19d5..17ae10a2884 100644 --- a/spec/frontend/environments/enable_review_app_modal_spec.js +++ b/spec/frontend/environments/enable_review_app_modal_spec.js @@ -1,10 +1,12 @@ import { shallowMount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import EnableReviewAppButton from '~/environments/components/enable_review_app_modal.vue'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; describe('Enable Review App Button', () => { let wrapper; + let modal; afterEach(() => { wrapper.destroy(); @@ -16,12 +18,15 @@ describe('Enable Review App Button', () => { shallowMount(EnableReviewAppButton, { propsData: { modalId: 'fake-id', + visible: true, }, provide: { defaultBranchName: 'main', }, }), ); + + modal = wrapper.findComponent(GlModal); }); it('renders the defaultBranchName copy', () => { @@ -32,5 +37,15 @@ describe('Enable Review App Button', () => { it('renders the copyToClipboard button', () => { expect(wrapper.findComponent(ModalCopyButton).exists()).toBe(true); }); + + it('emits change events from the modal up', () => { + modal.vm.$emit('change', false); + + expect(wrapper.emitted('change')).toEqual([[false]]); + }); + + it('passes visible to the modal', () => { + expect(modal.props('visible')).toBe(true); + }); }); }); diff --git a/spec/frontend/environments/environment_delete_spec.js b/spec/frontend/environments/environment_delete_spec.js index 2d8cff0c74a..057cb9858c4 100644 --- a/spec/frontend/environments/environment_delete_spec.js +++ b/spec/frontend/environments/environment_delete_spec.js @@ -1,37 +1,71 @@ import { GlDropdownItem } from '@gitlab/ui'; - import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import setEnvironmentToDelete from '~/environments/graphql/mutations/set_environment_to_delete.mutation.graphql'; import DeleteComponent from '~/environments/components/environment_delete.vue'; import eventHub from '~/environments/event_hub'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { resolvedEnvironment } from './graphql/mock_data'; describe('External URL Component', () => { let wrapper; - const createWrapper = () => { + const createWrapper = (props = {}, options = {}) => { wrapper = shallowMount(DeleteComponent, { + ...options, propsData: { - environment: {}, + environment: resolvedEnvironment, + ...props, }, }); }; const findDropdownItem = () => wrapper.find(GlDropdownItem); - beforeEach(() => { - jest.spyOn(window, 'confirm'); + describe('event hub', () => { + beforeEach(() => { + createWrapper(); + }); - createWrapper(); - }); + it('should render a dropdown item to delete the environment', () => { + expect(findDropdownItem().exists()).toBe(true); + expect(wrapper.text()).toEqual('Delete environment'); + expect(findDropdownItem().attributes('variant')).toBe('danger'); + }); - it('should render a dropdown item to delete the environment', () => { - expect(findDropdownItem().exists()).toBe(true); - expect(wrapper.text()).toEqual('Delete environment'); - expect(findDropdownItem().attributes('variant')).toBe('danger'); + it('emits requestDeleteEnvironment in the event hub when button is clicked', () => { + jest.spyOn(eventHub, '$emit'); + findDropdownItem().vm.$emit('click'); + expect(eventHub.$emit).toHaveBeenCalledWith('requestDeleteEnvironment', resolvedEnvironment); + }); }); - it('emits requestDeleteEnvironment in the event hub when button is clicked', () => { - jest.spyOn(eventHub, '$emit'); - findDropdownItem().vm.$emit('click'); - expect(eventHub.$emit).toHaveBeenCalledWith('requestDeleteEnvironment', wrapper.vm.environment); + describe('graphql', () => { + Vue.use(VueApollo); + let mockApollo; + + beforeEach(() => { + mockApollo = createMockApollo(); + createWrapper( + { graphql: true, environment: resolvedEnvironment }, + { apolloProvider: mockApollo }, + ); + }); + + it('should render a dropdown item to delete the environment', () => { + expect(findDropdownItem().exists()).toBe(true); + expect(wrapper.text()).toEqual('Delete environment'); + expect(findDropdownItem().attributes('variant')).toBe('danger'); + }); + + it('emits requestDeleteEnvironment in the event hub when button is clicked', () => { + jest.spyOn(mockApollo.defaultClient, 'mutate'); + findDropdownItem().vm.$emit('click'); + expect(mockApollo.defaultClient.mutate).toHaveBeenCalledWith({ + mutation: setEnvironmentToDelete, + variables: { environment: resolvedEnvironment }, + }); + }); }); }); diff --git a/spec/frontend/environments/environment_rollback_spec.js b/spec/frontend/environments/environment_rollback_spec.js index cde675cd9e7..7eff46baaf7 100644 --- a/spec/frontend/environments/environment_rollback_spec.js +++ b/spec/frontend/environments/environment_rollback_spec.js @@ -1,7 +1,11 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import RollbackComponent from '~/environments/components/environment_rollback.vue'; import eventHub from '~/environments/event_hub'; +import setEnvironmentToRollback from '~/environments/graphql/mutations/set_environment_to_rollback.mutation.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; describe('Rollback Component', () => { const retryUrl = 'https://gitlab.com/retry'; @@ -50,4 +54,29 @@ describe('Rollback Component', () => { name: 'test', }); }); + + it('should trigger a graphql mutation when graphql is enabled', () => { + Vue.use(VueApollo); + + const apolloProvider = createMockApollo(); + jest.spyOn(apolloProvider.defaultClient, 'mutate'); + const environment = { + name: 'test', + }; + const wrapper = shallowMount(RollbackComponent, { + propsData: { + retryUrl, + graphql: true, + environment, + }, + apolloProvider, + }); + const button = wrapper.find(GlDropdownItem); + button.vm.$emit('click'); + + expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({ + mutation: setEnvironmentToRollback, + variables: { environment }, + }); + }); }); diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js index e56b6448b7d..e75d3ac0321 100644 --- a/spec/frontend/environments/graphql/mock_data.js +++ b/spec/frontend/environments/graphql/mock_data.js @@ -469,6 +469,33 @@ export const folder = { stopped_count: 0, }; +export const resolvedEnvironment = { + id: 41, + globalId: 'gid://gitlab/Environment/41', + name: 'review/hello', + state: 'available', + externalUrl: 'https://example.org', + environmentType: 'review', + nameWithoutType: 'hello', + lastDeployment: null, + hasStopAction: false, + rolloutStatus: null, + environmentPath: '/h5bp/html5-boilerplate/-/environments/41', + stopPath: '/h5bp/html5-boilerplate/-/environments/41/stop', + cancelAutoStopPath: '/h5bp/html5-boilerplate/-/environments/41/cancel_auto_stop', + deletePath: '/api/v4/projects/8/environments/41', + folderPath: '/h5bp/html5-boilerplate/-/environments/folders/review', + createdAt: '2021-10-04T19:27:00.527Z', + updatedAt: '2021-10-04T19:27:00.527Z', + canStop: true, + logsPath: '/h5bp/html5-boilerplate/-/logs?environment_name=review%2Fhello', + logsApiPath: '/h5bp/html5-boilerplate/-/logs/k8s.json?environment_name=review%2Fhello', + enableAdvancedLogsQuerying: false, + canDelete: false, + hasOpenedAlert: false, + __typename: 'LocalEnvironment', +}; + export const resolvedFolder = { availableCount: 2, environments: [ diff --git a/spec/frontend/environments/graphql/resolvers_spec.js b/spec/frontend/environments/graphql/resolvers_spec.js index 4d2a0818996..d8d26b74504 100644 --- a/spec/frontend/environments/graphql/resolvers_spec.js +++ b/spec/frontend/environments/graphql/resolvers_spec.js @@ -1,18 +1,33 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import { resolvers } from '~/environments/graphql/resolvers'; +import environmentToRollback from '~/environments/graphql/queries/environment_to_rollback.query.graphql'; +import environmentToDelete from '~/environments/graphql/queries/environment_to_delete.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import pollIntervalQuery from '~/environments/graphql/queries/poll_interval.query.graphql'; +import pageInfoQuery from '~/environments/graphql/queries/page_info.query.graphql'; import { TEST_HOST } from 'helpers/test_constants'; -import { environmentsApp, resolvedEnvironmentsApp, folder, resolvedFolder } from './mock_data'; +import { + environmentsApp, + resolvedEnvironmentsApp, + resolvedEnvironment, + folder, + resolvedFolder, +} from './mock_data'; const ENDPOINT = `${TEST_HOST}/environments`; describe('~/frontend/environments/graphql/resolvers', () => { let mockResolvers; let mock; + let mockApollo; + let localState; beforeEach(() => { mockResolvers = resolvers(ENDPOINT); mock = new MockAdapter(axios); + mockApollo = createMockApollo(); + localState = mockApollo.defaultClient.localState; }); afterEach(() => { @@ -21,10 +36,87 @@ describe('~/frontend/environments/graphql/resolvers', () => { describe('environmentApp', () => { it('should fetch environments and map them to frontend data', async () => { - mock.onGet(ENDPOINT, { params: { nested: true } }).reply(200, environmentsApp); + const cache = { writeQuery: jest.fn() }; + const scope = 'available'; + mock + .onGet(ENDPOINT, { params: { nested: true, scope, page: 1 } }) + .reply(200, environmentsApp, {}); - const app = await mockResolvers.Query.environmentApp(); + const app = await mockResolvers.Query.environmentApp(null, { scope, page: 1 }, { cache }); expect(app).toEqual(resolvedEnvironmentsApp); + expect(cache.writeQuery).toHaveBeenCalledWith({ + query: pollIntervalQuery, + data: { interval: undefined }, + }); + }); + it('should set the poll interval when there is one', async () => { + const cache = { writeQuery: jest.fn() }; + const scope = 'stopped'; + const interval = 3000; + mock + .onGet(ENDPOINT, { params: { nested: true, scope, page: 1 } }) + .reply(200, environmentsApp, { + 'poll-interval': interval, + }); + + await mockResolvers.Query.environmentApp(null, { scope, page: 1 }, { cache }); + expect(cache.writeQuery).toHaveBeenCalledWith({ + query: pollIntervalQuery, + data: { interval }, + }); + }); + it('should set page info if there is any', async () => { + const cache = { writeQuery: jest.fn() }; + const scope = 'stopped'; + mock + .onGet(ENDPOINT, { params: { nested: true, scope, page: 1 } }) + .reply(200, environmentsApp, { + 'x-next-page': '2', + 'x-page': '1', + 'X-Per-Page': '2', + 'X-Prev-Page': '', + 'X-TOTAL': '37', + 'X-Total-Pages': '5', + }); + + await mockResolvers.Query.environmentApp(null, { scope, page: 1 }, { cache }); + expect(cache.writeQuery).toHaveBeenCalledWith({ + query: pageInfoQuery, + data: { + pageInfo: { + total: 37, + perPage: 2, + previousPage: NaN, + totalPages: 5, + nextPage: 2, + page: 1, + __typename: 'LocalPageInfo', + }, + }, + }); + }); + it('should not set page info if there is none', async () => { + const cache = { writeQuery: jest.fn() }; + const scope = 'stopped'; + mock + .onGet(ENDPOINT, { params: { nested: true, scope, page: 1 } }) + .reply(200, environmentsApp, {}); + + await mockResolvers.Query.environmentApp(null, { scope, page: 1 }, { cache }); + expect(cache.writeQuery).toHaveBeenCalledWith({ + query: pageInfoQuery, + data: { + pageInfo: { + __typename: 'LocalPageInfo', + nextPage: NaN, + page: NaN, + perPage: NaN, + previousPage: NaN, + total: NaN, + totalPages: NaN, + }, + }, + }); }); }); describe('folder', () => { @@ -42,7 +134,7 @@ describe('~/frontend/environments/graphql/resolvers', () => { it('should post to the stop environment path', async () => { mock.onPost(ENDPOINT).reply(200); - await mockResolvers.Mutations.stopEnvironment(null, { environment: { stopPath: ENDPOINT } }); + await mockResolvers.Mutation.stopEnvironment(null, { environment: { stopPath: ENDPOINT } }); expect(mock.history.post).toContainEqual( expect.objectContaining({ url: ENDPOINT, method: 'post' }), @@ -53,7 +145,7 @@ describe('~/frontend/environments/graphql/resolvers', () => { it('should post to the retry environment path', async () => { mock.onPost(ENDPOINT).reply(200); - await mockResolvers.Mutations.rollbackEnvironment(null, { + await mockResolvers.Mutation.rollbackEnvironment(null, { environment: { retryUrl: ENDPOINT }, }); @@ -66,7 +158,7 @@ describe('~/frontend/environments/graphql/resolvers', () => { it('should DELETE to the delete environment path', async () => { mock.onDelete(ENDPOINT).reply(200); - await mockResolvers.Mutations.deleteEnvironment(null, { + await mockResolvers.Mutation.deleteEnvironment(null, { environment: { deletePath: ENDPOINT }, }); @@ -79,7 +171,7 @@ describe('~/frontend/environments/graphql/resolvers', () => { it('should post to the auto stop path', async () => { mock.onPost(ENDPOINT).reply(200); - await mockResolvers.Mutations.cancelAutoStop(null, { + await mockResolvers.Mutation.cancelAutoStop(null, { environment: { autoStopPath: ENDPOINT }, }); @@ -88,4 +180,34 @@ describe('~/frontend/environments/graphql/resolvers', () => { ); }); }); + describe('setEnvironmentToRollback', () => { + it('should write the given environment to the cache', () => { + localState.client.writeQuery = jest.fn(); + mockResolvers.Mutation.setEnvironmentToRollback( + null, + { environment: resolvedEnvironment }, + localState, + ); + + expect(localState.client.writeQuery).toHaveBeenCalledWith({ + query: environmentToRollback, + data: { environmentToRollback: resolvedEnvironment }, + }); + }); + }); + describe('setEnvironmentToDelete', () => { + it('should write the given environment to the cache', () => { + localState.client.writeQuery = jest.fn(); + mockResolvers.Mutation.setEnvironmentToDelete( + null, + { environment: resolvedEnvironment }, + localState, + ); + + expect(localState.client.writeQuery).toHaveBeenCalledWith({ + query: environmentToDelete, + data: { environmentToDelete: resolvedEnvironment }, + }); + }); + }); }); diff --git a/spec/frontend/environments/new_environment_folder_spec.js b/spec/frontend/environments/new_environment_folder_spec.js index 5696e187a86..27d27d5869a 100644 --- a/spec/frontend/environments/new_environment_folder_spec.js +++ b/spec/frontend/environments/new_environment_folder_spec.js @@ -3,8 +3,8 @@ import Vue from 'vue'; import { GlCollapse, GlIcon } from '@gitlab/ui'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { __, s__ } from '~/locale'; import EnvironmentsFolder from '~/environments/components/new_environment_folder.vue'; -import { s__ } from '~/locale'; import { resolvedEnvironmentsApp, resolvedFolder } from './graphql/mock_data'; Vue.use(VueApollo); @@ -14,6 +14,7 @@ describe('~/environments/components/new_environments_folder.vue', () => { let environmentFolderMock; let nestedEnvironment; let folderName; + let button; const findLink = () => wrapper.findByRole('link', { name: s__('Environments|Show all') }); @@ -32,6 +33,7 @@ describe('~/environments/components/new_environments_folder.vue', () => { environmentFolderMock.mockReturnValue(resolvedFolder); wrapper = createWrapper({ nestedEnvironment }, createApolloProvider()); folderName = wrapper.findByText(nestedEnvironment.name); + button = wrapper.findByRole('button', { name: __('Expand') }); }); afterEach(() => { @@ -61,10 +63,11 @@ describe('~/environments/components/new_environments_folder.vue', () => { }); it('opens on click', async () => { - await folderName.trigger('click'); + await button.trigger('click'); const link = findLink(); + expect(button.attributes('aria-label')).toBe(__('Collapse')); expect(collapse.attributes('visible')).toBe('true'); expect(icons.wrappers.map((i) => i.props('name'))).toEqual(['angle-down', 'folder-open']); expect(folderName.classes('gl-font-weight-bold')).toBe(true); diff --git a/spec/frontend/environments/new_environments_app_spec.js b/spec/frontend/environments/new_environments_app_spec.js index 0ad8e8f442c..1e9bd4d64c9 100644 --- a/spec/frontend/environments/new_environments_app_spec.js +++ b/spec/frontend/environments/new_environments_app_spec.js @@ -1,8 +1,11 @@ -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -import { mount } from '@vue/test-utils'; +import { GlPagination } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { sprintf, __, s__ } from '~/locale'; import EnvironmentsApp from '~/environments/components/new_environments_app.vue'; import EnvironmentsFolder from '~/environments/components/new_environment_folder.vue'; import { resolvedEnvironmentsApp, resolvedFolder } from './graphql/mock_data'; @@ -13,20 +16,59 @@ describe('~/environments/components/new_environments_app.vue', () => { let wrapper; let environmentAppMock; let environmentFolderMock; + let paginationMock; const createApolloProvider = () => { const mockResolvers = { - Query: { environmentApp: environmentAppMock, folder: environmentFolderMock }, + Query: { + environmentApp: environmentAppMock, + folder: environmentFolderMock, + pageInfo: paginationMock, + }, }; return createMockApollo([], mockResolvers); }; - const createWrapper = (apolloProvider) => mount(EnvironmentsApp, { apolloProvider }); + const createWrapper = ({ provide = {}, apolloProvider } = {}) => + mountExtended(EnvironmentsApp, { + provide: { + newEnvironmentPath: '/environments/new', + canCreateEnvironment: true, + defaultBranchName: 'main', + ...provide, + }, + apolloProvider, + }); + + const createWrapperWithMocked = async ({ + provide = {}, + environmentsApp, + folder, + pageInfo = { + total: 20, + perPage: 5, + nextPage: 3, + page: 2, + previousPage: 1, + __typename: 'LocalPageInfo', + }, + }) => { + setWindowLocation('?scope=available&page=2'); + environmentAppMock.mockReturnValue(environmentsApp); + environmentFolderMock.mockReturnValue(folder); + paginationMock.mockReturnValue(pageInfo); + const apolloProvider = createApolloProvider(); + wrapper = createWrapper({ apolloProvider, provide }); + + await waitForPromises(); + await nextTick(); + }; beforeEach(() => { environmentAppMock = jest.fn(); environmentFolderMock = jest.fn(); + paginationMock = jest.fn(); }); afterEach(() => { @@ -34,17 +76,196 @@ describe('~/environments/components/new_environments_app.vue', () => { }); it('should show all the folders that are fetched', async () => { - environmentAppMock.mockReturnValue(resolvedEnvironmentsApp); - environmentFolderMock.mockReturnValue(resolvedFolder); - const apolloProvider = createApolloProvider(); - wrapper = createWrapper(apolloProvider); - - await waitForPromises(); - await Vue.nextTick(); + await createWrapperWithMocked({ + environmentsApp: resolvedEnvironmentsApp, + folder: resolvedFolder, + }); const text = wrapper.findAllComponents(EnvironmentsFolder).wrappers.map((w) => w.text()); expect(text).toContainEqual(expect.stringMatching('review')); expect(text).not.toContainEqual(expect.stringMatching('production')); }); + + it('should show a button to create a new environment', async () => { + await createWrapperWithMocked({ + environmentsApp: resolvedEnvironmentsApp, + folder: resolvedFolder, + }); + + const button = wrapper.findByRole('link', { name: s__('Environments|New environment') }); + expect(button.attributes('href')).toBe('/environments/new'); + }); + + it('should not show a button to create a new environment if the user has no permissions', async () => { + await createWrapperWithMocked({ + environmentsApp: resolvedEnvironmentsApp, + folder: resolvedFolder, + provide: { canCreateEnvironment: false, newEnvironmentPath: '' }, + }); + + const button = wrapper.findByRole('link', { name: s__('Environments|New environment') }); + expect(button.exists()).toBe(false); + }); + + it('should show a button to open the review app modal', async () => { + await createWrapperWithMocked({ + environmentsApp: resolvedEnvironmentsApp, + folder: resolvedFolder, + }); + + const button = wrapper.findByRole('button', { name: s__('Environments|Enable review app') }); + button.trigger('click'); + + await nextTick(); + + expect(wrapper.findByText(s__('ReviewApp|Enable Review App')).exists()).toBe(true); + }); + + it('should not show a button to open the review app modal if review apps are configured', async () => { + await createWrapperWithMocked({ + environmentsApp: { + ...resolvedEnvironmentsApp, + reviewApp: { canSetupReviewApp: false }, + }, + folder: resolvedFolder, + }); + await waitForPromises(); + await nextTick(); + + const button = wrapper.findByRole('button', { name: s__('Environments|Enable review app') }); + expect(button.exists()).toBe(false); + }); + + describe('tabs', () => { + it('should show tabs for available and stopped environmets', async () => { + await createWrapperWithMocked({ + environmentsApp: resolvedEnvironmentsApp, + folder: resolvedFolder, + }); + + const [available, stopped] = wrapper.findAllByRole('tab').wrappers; + + expect(available.text()).toContain(__('Available')); + expect(available.text()).toContain(resolvedEnvironmentsApp.availableCount); + expect(stopped.text()).toContain(__('Stopped')); + expect(stopped.text()).toContain(resolvedEnvironmentsApp.stoppedCount); + }); + + it('should change the requested scope on tab change', async () => { + await createWrapperWithMocked({ + environmentsApp: resolvedEnvironmentsApp, + folder: resolvedFolder, + }); + const stopped = wrapper.findByRole('tab', { + name: `${__('Stopped')} ${resolvedEnvironmentsApp.stoppedCount}`, + }); + + stopped.trigger('click'); + + await nextTick(); + await waitForPromises(); + + expect(environmentAppMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ scope: 'stopped' }), + expect.anything(), + expect.anything(), + ); + }); + }); + + describe('pagination', () => { + it('should sync page from query params on load', async () => { + await createWrapperWithMocked({ + environmentsApp: resolvedEnvironmentsApp, + folder: resolvedFolder, + }); + + expect(wrapper.findComponent(GlPagination).props('value')).toBe(2); + }); + + it('should change the requested page on next page click', async () => { + await createWrapperWithMocked({ + environmentsApp: resolvedEnvironmentsApp, + folder: resolvedFolder, + }); + const next = wrapper.findByRole('link', { + name: __('Go to next page'), + }); + + next.trigger('click'); + + await nextTick(); + await waitForPromises(); + + expect(environmentAppMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ page: 3 }), + expect.anything(), + expect.anything(), + ); + }); + + it('should change the requested page on previous page click', async () => { + await createWrapperWithMocked({ + environmentsApp: resolvedEnvironmentsApp, + folder: resolvedFolder, + }); + const prev = wrapper.findByRole('link', { + name: __('Go to previous page'), + }); + + prev.trigger('click'); + + await nextTick(); + await waitForPromises(); + + expect(environmentAppMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ page: 1 }), + expect.anything(), + expect.anything(), + ); + }); + + it('should change the requested page on specific page click', async () => { + await createWrapperWithMocked({ + environmentsApp: resolvedEnvironmentsApp, + folder: resolvedFolder, + }); + + const page = 1; + const pageButton = wrapper.findByRole('link', { + name: sprintf(__('Go to page %{page}'), { page }), + }); + + pageButton.trigger('click'); + + await nextTick(); + await waitForPromises(); + + expect(environmentAppMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ page }), + expect.anything(), + expect.anything(), + ); + }); + + it('should sync the query params to the new page', async () => { + await createWrapperWithMocked({ + environmentsApp: resolvedEnvironmentsApp, + folder: resolvedFolder, + }); + const next = wrapper.findByRole('link', { + name: __('Go to next page'), + }); + + next.trigger('click'); + + await nextTick(); + expect(window.location.search).toBe('?scope=available&page=3'); + }); + }); }); diff --git a/spec/frontend/experimentation/utils_spec.js b/spec/frontend/experimentation/utils_spec.js index 923795ca3f3..0d663fd055e 100644 --- a/spec/frontend/experimentation/utils_spec.js +++ b/spec/frontend/experimentation/utils_spec.js @@ -51,6 +51,29 @@ describe('experiment Utilities', () => { expect(experimentUtils.getExperimentData(...input)).toEqual(output); }); }); + + it('only collects the data properties which are supported by the schema', () => { + origGl = window.gl; + window.gl.experiments = { + my_experiment: { + experiment: 'my_experiment', + variant: 'control', + key: 'randomization-unit-key', + migration_keys: 'migration_keys object', + excluded: false, + other: 'foobar', + }, + }; + + expect(experimentUtils.getExperimentData('my_experiment')).toEqual({ + experiment: 'my_experiment', + variant: 'control', + key: 'randomization-unit-key', + migration_keys: 'migration_keys object', + }); + + window.gl = origGl; + }); }); describe('getAllExperimentContexts', () => { @@ -72,29 +95,17 @@ describe('experiment Utilities', () => { it('returns an empty array if there are no experiments', () => { expect(experimentUtils.getAllExperimentContexts()).toEqual([]); }); - - it('only collects the data properties which are supported by the schema', () => { - origGl = window.gl; - window.gl.experiments = { - my_experiment: { experiment: 'my_experiment', variant: 'control', excluded: false }, - }; - - expect(experimentUtils.getAllExperimentContexts()).toEqual([ - { schema, data: { experiment: 'my_experiment', variant: 'control' } }, - ]); - - window.gl = origGl; - }); }); describe('isExperimentVariant', () => { describe.each` - experiment | variant | input | output - ${ABC_KEY} | ${DEFAULT_VARIANT} | ${[ABC_KEY, DEFAULT_VARIANT]} | ${true} - ${ABC_KEY} | ${'_variant_name'} | ${[ABC_KEY, '_variant_name']} | ${true} - ${ABC_KEY} | ${'_variant_name'} | ${[ABC_KEY, '_bogus_name']} | ${false} - ${ABC_KEY} | ${'_variant_name'} | ${['boguskey', '_variant_name']} | ${false} - ${undefined} | ${undefined} | ${[ABC_KEY, '_variant_name']} | ${false} + experiment | variant | input | output + ${ABC_KEY} | ${CANDIDATE_VARIANT} | ${[ABC_KEY]} | ${true} + ${ABC_KEY} | ${DEFAULT_VARIANT} | ${[ABC_KEY, DEFAULT_VARIANT]} | ${true} + ${ABC_KEY} | ${'_variant_name'} | ${[ABC_KEY, '_variant_name']} | ${true} + ${ABC_KEY} | ${'_variant_name'} | ${[ABC_KEY, '_bogus_name']} | ${false} + ${ABC_KEY} | ${'_variant_name'} | ${['boguskey', '_variant_name']} | ${false} + ${undefined} | ${undefined} | ${[ABC_KEY, '_variant_name']} | ${false} `( 'with input=$input, experiment=$experiment, variant=$variant', ({ experiment, variant, input, output }) => { diff --git a/spec/frontend/fixtures/api_deploy_keys.rb b/spec/frontend/fixtures/api_deploy_keys.rb new file mode 100644 index 00000000000..7027b8c975b --- /dev/null +++ b/spec/frontend/fixtures/api_deploy_keys.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::DeployKeys, '(JavaScript fixtures)', type: :request do + include ApiHelpers + include JavaScriptFixturesHelpers + + let_it_be(:admin) { create(:admin) } + let_it_be(:project) { create(:project) } + let_it_be(:project2) { create(:project) } + let_it_be(:deploy_key) { create(:deploy_key, public: true) } + let_it_be(:deploy_key2) { create(:deploy_key, public: true) } + let_it_be(:deploy_keys_project) { create(:deploy_keys_project, :write_access, project: project, deploy_key: deploy_key) } + let_it_be(:deploy_keys_project2) { create(:deploy_keys_project, :write_access, project: project2, deploy_key: deploy_key) } + let_it_be(:deploy_keys_project3) { create(:deploy_keys_project, :write_access, project: project, deploy_key: deploy_key2) } + let_it_be(:deploy_keys_project4) { create(:deploy_keys_project, :write_access, project: project2, deploy_key: deploy_key2) } + + it 'api/deploy_keys/index.json' do + get api("/deploy_keys", admin) + + expect(response).to be_successful + end +end diff --git a/spec/frontend/fixtures/api_markdown.rb b/spec/frontend/fixtures/api_markdown.rb deleted file mode 100644 index 89f012a5110..00000000000 --- a/spec/frontend/fixtures/api_markdown.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe API::MergeRequests, '(JavaScript fixtures)', type: :request do - include ApiHelpers - include WikiHelpers - include JavaScriptFixturesHelpers - - 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(: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) } - - before(:all) do - group.add_owner(user) - project.add_maintainer(user) - end - - before do - sign_in(user) - end - - markdown_examples = begin - yaml_file_path = File.expand_path('api_markdown.yml', __dir__) - yaml = File.read(yaml_file_path) - YAML.safe_load(yaml, symbolize_names: true) - end - - markdown_examples.each do |markdown_example| - context = markdown_example.fetch(:context, '') - name = markdown_example.fetch(:name) - - context "for #{name}#{!context.empty? ? " (context: #{context})" : ''}" do - let(:markdown) { markdown_example.fetch(:markdown) } - - name = "#{context}_#{name}" unless context.empty? - - it "api/markdown/#{name}.json" do - api_url = case context - when 'project' - "/#{project.full_path}/preview_markdown" - when 'group' - "/groups/#{group.full_path}/preview_markdown" - when 'project_wiki' - "/#{project.full_path}/-/wikis/#{project_wiki_page.slug}/preview_markdown" - else - api "/markdown" - end - - post api_url, params: { text: markdown, gfm: true } - expect(response).to be_successful - end - end - end -end diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml deleted file mode 100644 index 8fd6a5531db..00000000000 --- a/spec/frontend/fixtures/api_markdown.yml +++ /dev/null @@ -1,289 +0,0 @@ -# This data file drives the specs in -# spec/frontend/fixtures/api_markdown.rb and -# spec/frontend/content_editor/extensions/markdown_processing_spec.js ---- -- name: attachment_image - context: group - markdown: '![test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png)' -- name: attachment_image - context: project - markdown: '![test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png)' -- name: attachment_image - context: project_wiki - markdown: '![test-file](test-file.png)' -- name: attachment_link - context: group - markdown: '[test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.zip)' -- name: attachment_link - context: project - markdown: '[test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.zip)' -- name: attachment_link - context: project_wiki - markdown: '[test-file](test-file.zip)' -- name: audio - markdown: '![Sample Audio](https://gitlab.com/gitlab.mp3)' -- name: audio_and_video_in_lists - markdown: |- - * ![Sample Audio](https://gitlab.com/1.mp3) - * ![Sample Video](https://gitlab.com/2.mp4) - - 1. ![Sample Video](https://gitlab.com/1.mp4) - 2. ![Sample Audio](https://gitlab.com/2.mp3) - - * [x] ![Sample Audio](https://gitlab.com/1.mp3) - * [x] ![Sample Audio](https://gitlab.com/2.mp3) - * [x] ![Sample Video](https://gitlab.com/3.mp4) -- name: blockquote - markdown: |- - > This is a blockquote - > - > This is another one -- name: bold - markdown: '**bold**' -- name: bullet_list_style_1 - markdown: |- - * list item 1 - * list item 2 - * embedded list item 3 -- name: bullet_list_style_2 - markdown: |- - - list item 1 - - list item 2 - * embedded list item 3 -- name: bullet_list_style_3 - markdown: |- - + list item 1 - + list item 2 - - embedded list item 3 -- name: code_block - markdown: |- - ```javascript - console.log('hello world') - ``` -- name: color_chips - markdown: |- - - `#F00` - - `#F00A` - - `#FF0000` - - `#FF0000AA` - - `RGB(0,255,0)` - - `RGB(0%,100%,0%)` - - `RGBA(0,255,0,0.3)` - - `HSL(540,70%,50%)` - - `HSLA(540,70%,50%,0.3)` -- name: description_list - markdown: |- - <dl> - <dt>Frog</dt> - <dd>Wet green thing</dd> - <dt>Rabbit</dt> - <dd>Warm fluffy thing</dd> - <dt>Punt</dt> - <dd>Kick a ball</dd> - <dd>Take a bet</dd> - <dt>Color</dt> - <dt>Colour</dt> - <dd> - - Any hue except _white_ or **black** - - </dd> - </dl> -- name: details - markdown: |- - <details> - <summary>Apply this patch</summary> - - ```diff - diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml - index 8433efaf00c..69b12c59d46 100644 - --- a/spec/frontend/fixtures/api_markdown.yml - +++ b/spec/frontend/fixtures/api_markdown.yml - @@ -33,6 +33,13 @@ - * <ruby>漢<rt>ㄏㄢˋ</rt></ruby> - * C<sub>7</sub>H<sub>16</sub> + O<sub>2</sub> → CO<sub>2</sub> + H<sub>2</sub>O - * The **Pythagorean theorem** is often expressed as <var>a<sup>2</sup></var> + <var>b<sup>2</sup></var> = <var>c<sup>2</sup></var>.The **Pythagorean theorem** is often expressed as <var>a<sup>2</sup></var> + <var>b<sup>2</sup></var> = <var>c<sup>2</sup></var> - +- name: details - + markdown: |- - + <details> - + <summary>Apply this patch</summary> - + - + 🐶 much meta, 🐶 many patch - + 🐶 such diff, 🐶 very meme - + 🐶 wow! - + </details> - - name: link - markdown: '[GitLab](https://gitlab.com)' - - name: attachment_link - ``` - - </details> -- name: div - markdown: |- - <div>plain text</div> - <div> - - just a plain ol' div, not much to _expect_! - - </div> -- name: emoji - markdown: ':sparkles: :heart: :100:' -- name: emphasis - markdown: '_emphasized text_' -- name: figure - markdown: |- - <figure> - - ![Elephant at sunset](elephant-sunset.jpg) - - <figcaption>An elephant at sunset</figcaption> - </figure> - <figure> - - ![A crocodile wearing crocs](croc-crocs.jpg) - - <figcaption> - - A crocodile wearing _crocs_! - - </figcaption> - </figure> -- name: frontmatter_json - markdown: |- - ;;; - { - "title": "Page title" - } - ;;; -- name: frontmatter_toml - markdown: |- - +++ - title = "Page title" - +++ -- name: frontmatter_yaml - markdown: |- - --- - title: Page title - --- -- name: hard_break - markdown: |- - This is a line after a\ - hard break -- name: headings - markdown: |- - # Heading 1 - - ## Heading 2 - - ### Heading 3 - - #### Heading 4 - - ##### Heading 5 - - ###### Heading 6 -- name: horizontal_rule - markdown: '---' -- name: html_marks - markdown: |- - * Content editor is ~~great~~<ins>amazing</ins>. - * If the changes <abbr title="Looks good to merge">LGTM</abbr>, please <abbr title="Merge when pipeline succeeds">MWPS</abbr>. - * The English song <q>Oh I do like to be beside the seaside</q> looks like this in Hebrew: <span dir="rtl">אה, אני אוהב להיות ליד חוף הים</span>. In the computer's memory, this is stored as <bdo dir="ltr">אה, אני אוהב להיות ליד חוף הים</bdo>. - * <cite>The Scream</cite> by Edvard Munch. Painted in 1893. - * <dfn>HTML</dfn> is the standard markup language for creating web pages. - * Do not forget to buy <mark>milk</mark> today. - * This is a paragraph and <small>smaller text goes here</small>. - * The concert starts at <time datetime="20:00">20:00</time> and you'll be able to enjoy the band for at least <time datetime="PT2H30M">2h 30m</time>. - * Press <kbd>Ctrl</kbd> + <kbd>C</kbd> to copy text (Windows). - * WWF's goal is to: <q>Build a future where people live in harmony with nature.</q> We hope they succeed. - * The error occured was: <samp>Keyboard not found. Press F1 to continue.</samp> - * The area of a triangle is: 1/2 x <var>b</var> x <var>h</var>, where <var>b</var> is the base, and <var>h</var> is the vertical height. - * <ruby>漢<rt>ㄏㄢˋ</rt></ruby> - * C<sub>7</sub>H<sub>16</sub> + O<sub>2</sub> → CO<sub>2</sub> + H<sub>2</sub>O - * The **Pythagorean theorem** is often expressed as <var>a<sup>2</sup></var> + <var>b<sup>2</sup></var> = <var>c<sup>2</sup></var> -- name: image - markdown: '![alt text](https://gitlab.com/logo.png)' -- name: inline_code - markdown: '`code`' -- name: inline_diff - markdown: |- - * {-deleted-} - * {+added+} -- name: link - markdown: '[GitLab](https://gitlab.com)' -- name: math - markdown: |- - This math is inline $`a^2+b^2=c^2`$. - - This is on a separate line: - - ```math - a^2+b^2=c^2 - ``` -- name: ordered_list - markdown: |- - 1. list item 1 - 2. list item 2 - 3. list item 3 -- name: ordered_list_with_start_order - markdown: |- - 134. list item 1 - 135. list item 2 - 136. list item 3 -- name: ordered_task_list - markdown: |- - 1. [x] hello - 2. [x] world - 3. [ ] example - 1. [ ] of nested - 1. [x] task list - 2. [ ] items -- name: ordered_task_list_with_order - markdown: |- - 4893. [x] hello - 4894. [x] world - 4895. [ ] example -- 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 -- name: strike - markdown: '~~del~~' -- name: table - markdown: |- - | header | header | - |--------|--------| - | `code` | cell with **bold** | - | ~~strike~~ | cell with _italic_ | - - # content after table -- name: table_of_contents - markdown: |- - [[_TOC_]] - - # Lorem - - Well, that's just like... your opinion.. man. - - ## Ipsum - - ### Dolar - - # Sit amit - - ### I don't know -- name: task_list - markdown: |- - * [x] hello - * [x] world - * [ ] example - * [ ] of nested - * [x] task list - * [ ] items -- name: thematic_break - markdown: |- - --- -- name: video - markdown: '![Sample Video](https://gitlab.com/gitlab.mp4)' -- name: word_break - markdown: Fernstraßen<wbr>bau<wbr>privat<wbr>finanzierungs<wbr>gesetz diff --git a/spec/frontend/fixtures/blob.rb b/spec/frontend/fixtures/blob.rb index f90e3662e98..bfdeee0881b 100644 --- a/spec/frontend/fixtures/blob.rb +++ b/spec/frontend/fixtures/blob.rb @@ -34,7 +34,7 @@ RSpec.describe Projects::BlobController, '(JavaScript fixtures)', type: :control get(:show, params: { namespace_id: project.namespace, project_id: project, - id: 'master/README.md' + id: "#{project.default_branch}/README.md" }) expect(response).to be_successful diff --git a/spec/frontend/fixtures/projects.rb b/spec/frontend/fixtures/projects.rb index 23c18c97df2..3c8964d398a 100644 --- a/spec/frontend/fixtures/projects.rb +++ b/spec/frontend/fixtures/projects.rb @@ -65,31 +65,5 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do expect_graphql_errors_to_be_empty end end - - context 'project storage count query' do - before do - project.statistics.update!( - repository_size: 3900000, - lfs_objects_size: 4800000, - build_artifacts_size: 400000, - pipeline_artifacts_size: 400000, - wiki_size: 300000, - packages_size: 3800000, - uploads_size: 900000 - ) - end - - base_input_path = 'projects/storage_counter/queries/' - base_output_path = 'graphql/projects/storage_counter/' - query_name = 'project_storage.query.graphql' - - it "#{base_output_path}#{query_name}.json" do - query = get_graphql_query_as_string("#{base_input_path}#{query_name}") - - post_graphql(query, current_user: user, variables: { fullPath: project.full_path }) - - expect_graphql_errors_to_be_empty - end - end end end diff --git a/spec/frontend/fixtures/raw.rb b/spec/frontend/fixtures/raw.rb index 211c4e7c048..b117cfea5fa 100644 --- a/spec/frontend/fixtures/raw.rb +++ b/spec/frontend/fixtures/raw.rb @@ -7,41 +7,45 @@ RSpec.describe 'Raw files', '(JavaScript fixtures)' do let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project, :repository, namespace: namespace, path: 'raw-project') } - let(:response) { @blob.data.force_encoding('UTF-8') } + let(:response) { @response } + + def blob_at(commit, path) + @response = project.repository.blob_at(commit, path).data.force_encoding('UTF-8') + end after do remove_repository(project) end it 'blob/notebook/basic.json' do - @blob = project.repository.blob_at('6d85bb69', 'files/ipython/basic.ipynb') + blob_at('6d85bb69', 'files/ipython/basic.ipynb') end it 'blob/notebook/markdown-table.json' do - @blob = project.repository.blob_at('f6b7a707', 'files/ipython/markdown-table.ipynb') + blob_at('f6b7a707', 'files/ipython/markdown-table.ipynb') end it 'blob/notebook/worksheets.json' do - @blob = project.repository.blob_at('6d85bb69', 'files/ipython/worksheets.ipynb') + blob_at('6d85bb69', 'files/ipython/worksheets.ipynb') end it 'blob/notebook/math.json' do - @blob = project.repository.blob_at('93ee732', 'files/ipython/math.ipynb') + blob_at('93ee732', 'files/ipython/math.ipynb') end it 'blob/pdf/test.pdf' do - @blob = project.repository.blob_at('e774ebd33', 'files/pdf/test.pdf') + blob_at('e774ebd33', 'files/pdf/test.pdf') end it 'blob/text/README.md' do - @blob = project.repository.blob_at('e774ebd33', 'README.md') + blob_at('e774ebd33', 'README.md') end it 'blob/images/logo-white.png' do - @blob = project.repository.blob_at('e774ebd33', 'files/images/logo-white.png') + blob_at('e774ebd33', 'files/images/logo-white.png') end it 'blob/binary/Gemfile.zip' do - @blob = project.repository.blob_at('e774ebd33', 'Gemfile.zip') + blob_at('e774ebd33', 'Gemfile.zip') end end diff --git a/spec/frontend/fixtures/tabs.rb b/spec/frontend/fixtures/tabs.rb new file mode 100644 index 00000000000..697ff1c7c20 --- /dev/null +++ b/spec/frontend/fixtures/tabs.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'GlTabsBehavior', '(JavaScript fixtures)', type: :helper do + include JavaScriptFixturesHelpers + include TabHelper + + let(:response) { @tabs } + + it 'tabs/tabs.html' do + tabs = gl_tabs_nav({ data: { testid: 'tabs' } }) do + gl_tab_link_to('Foo', '#foo', item_active: true, data: { testid: 'foo-tab' }) + + gl_tab_link_to('Bar', '#bar', item_active: false, data: { testid: 'bar-tab' }) + + gl_tab_link_to('Qux', '#qux', item_active: false, data: { testid: 'qux-tab' }) + end + + panels = content_tag(:div, class: 'tab-content') do + content_tag(:div, 'Foo', { id: 'foo', class: 'tab-pane active', data: { testid: 'foo-panel' } }) + + content_tag(:div, 'Bar', { id: 'bar', class: 'tab-pane', data: { testid: 'bar-panel' } }) + + content_tag(:div, 'Qux', { id: 'qux', class: 'tab-pane', data: { testid: 'qux-panel' } }) + end + + @tabs = tabs + panels + end +end diff --git a/spec/frontend/fixtures/timezones.rb b/spec/frontend/fixtures/timezones.rb index 157f47855ea..2393f4e797d 100644 --- a/spec/frontend/fixtures/timezones.rb +++ b/spec/frontend/fixtures/timezones.rb @@ -8,11 +8,9 @@ RSpec.describe TimeZoneHelper, '(JavaScript fixtures)' do let(:response) { @timezones.sort_by! { |tz| tz[:name] }.to_json } - it 'timezones/short.json' do - @timezones = timezone_data(format: :short) - end - - it 'timezones/full.json' do - @timezones = timezone_data(format: :full) + %I[short abbr full].each do |format| + it "timezones/#{format}.json" do + @timezones = timezone_data(format: format) + end end end diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js index f7bde8d2f16..fc736f2d155 100644 --- a/spec/frontend/flash_spec.js +++ b/spec/frontend/flash_spec.js @@ -1,38 +1,14 @@ +import * as Sentry from '@sentry/browser'; import createFlash, { - createFlashEl, - createAction, hideFlash, - removeFlashClickListener, + addDismissFlashClickListener, + FLASH_TYPES, FLASH_CLOSED_EVENT, } from '~/flash'; -describe('Flash', () => { - describe('createFlashEl', () => { - let el; - - beforeEach(() => { - el = document.createElement('div'); - }); - - afterEach(() => { - el.innerHTML = ''; - }); - - it('creates flash element with type', () => { - el.innerHTML = createFlashEl('testing', 'alert'); - - expect(el.querySelector('.flash-alert')).not.toBeNull(); - }); - - it('escapes text', () => { - el.innerHTML = createFlashEl('<script>alert("a");</script>', 'alert'); - - expect(el.querySelector('.flash-text').textContent.trim()).toBe( - '<script>alert("a");</script>', - ); - }); - }); +jest.mock('@sentry/browser'); +describe('Flash', () => { describe('hideFlash', () => { let el; @@ -92,59 +68,12 @@ describe('Flash', () => { }); }); - describe('createAction', () => { - let el; - - beforeEach(() => { - el = document.createElement('div'); - }); - - it('creates link with href', () => { - el.innerHTML = createAction({ - href: 'testing', - title: 'test', - }); - - expect(el.querySelector('.flash-action').href).toContain('testing'); - }); - - it('uses hash as href when no href is present', () => { - el.innerHTML = createAction({ - title: 'test', - }); - - expect(el.querySelector('.flash-action').href).toContain('#'); - }); - - it('adds role when no href is present', () => { - el.innerHTML = createAction({ - title: 'test', - }); - - expect(el.querySelector('.flash-action').getAttribute('role')).toBe('button'); - }); - - it('escapes the title text', () => { - el.innerHTML = createAction({ - title: '<script>alert("a")</script>', - }); - - expect(el.querySelector('.flash-action').textContent.trim()).toBe( - '<script>alert("a")</script>', - ); - }); - }); - describe('createFlash', () => { const message = 'test'; - const type = 'alert'; - const parent = document; const fadeTransition = false; const addBodyClass = true; const defaultParams = { message, - type, - parent, actionConfig: null, fadeTransition, addBodyClass, @@ -171,14 +100,28 @@ describe('Flash', () => { document.querySelector('.js-content-wrapper').remove(); }); - it('adds flash element into container', () => { + it('adds flash alert element into the document by default', () => { createFlash({ ...defaultParams }); - expect(document.querySelector('.flash-alert')).not.toBeNull(); + expect(document.querySelector('.flash-container .flash-alert')).not.toBeNull(); + expect(document.body.className).toContain('flash-shown'); + }); + + it('adds flash of a warning type', () => { + createFlash({ ...defaultParams, type: FLASH_TYPES.WARNING }); + expect(document.querySelector('.flash-container .flash-warning')).not.toBeNull(); expect(document.body.className).toContain('flash-shown'); }); + it('escapes text', () => { + createFlash({ ...defaultParams, message: '<script>alert("a");</script>' }); + + expect(document.querySelector('.flash-text').textContent.trim()).toBe( + '<script>alert("a");</script>', + ); + }); + it('adds flash into specified parent', () => { createFlash({ ...defaultParams, parent: document.querySelector('.content-wrapper') }); @@ -210,7 +153,26 @@ describe('Flash', () => { expect(document.body.className).not.toContain('flash-shown'); }); + it('does not capture error using Sentry', () => { + createFlash({ ...defaultParams, captureError: false, error: new Error('Error!') }); + + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('captures error using Sentry', () => { + createFlash({ ...defaultParams, captureError: true, error: new Error('Error!') }); + + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error)); + expect(Sentry.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Error!', + }), + ); + }); + describe('with actionConfig', () => { + const findFlashAction = () => document.querySelector('.flash-container .flash-action'); + it('adds action link', () => { createFlash({ ...defaultParams, @@ -219,20 +181,69 @@ describe('Flash', () => { }, }); - expect(document.querySelector('.flash-action')).not.toBeNull(); + expect(findFlashAction()).not.toBeNull(); + }); + + it('creates link with href', () => { + createFlash({ + ...defaultParams, + actionConfig: { + href: 'testing', + title: 'test', + }, + }); + + expect(findFlashAction().href).toBe(`${window.location}testing`); + expect(findFlashAction().textContent.trim()).toBe('test'); + }); + + it('uses hash as href when no href is present', () => { + createFlash({ + ...defaultParams, + actionConfig: { + title: 'test', + }, + }); + + expect(findFlashAction().href).toBe(`${window.location}#`); + }); + + it('adds role when no href is present', () => { + createFlash({ + ...defaultParams, + actionConfig: { + title: 'test', + }, + }); + + expect(findFlashAction().getAttribute('role')).toBe('button'); + }); + + it('escapes the title text', () => { + createFlash({ + ...defaultParams, + actionConfig: { + title: '<script>alert("a")</script>', + }, + }); + + expect(findFlashAction().textContent.trim()).toBe('<script>alert("a")</script>'); }); it('calls actionConfig clickHandler on click', () => { - const actionConfig = { - title: 'test', - clickHandler: jest.fn(), - }; + const clickHandler = jest.fn(); - createFlash({ ...defaultParams, actionConfig }); + createFlash({ + ...defaultParams, + actionConfig: { + title: 'test', + clickHandler, + }, + }); - document.querySelector('.flash-action').click(); + findFlashAction().click(); - expect(actionConfig.clickHandler).toHaveBeenCalled(); + expect(clickHandler).toHaveBeenCalled(); }); }); @@ -252,7 +263,7 @@ describe('Flash', () => { }); }); - describe('removeFlashClickListener', () => { + describe('addDismissFlashClickListener', () => { let el; describe('with close icon', () => { @@ -268,7 +279,7 @@ describe('Flash', () => { }); it('removes global flash on click', (done) => { - removeFlashClickListener(el, false); + addDismissFlashClickListener(el, false); el.querySelector('.js-close-icon').click(); @@ -292,7 +303,7 @@ describe('Flash', () => { }); it('does not throw', () => { - expect(() => removeFlashClickListener(el, false)).not.toThrow(); + expect(() => addDismissFlashClickListener(el, false)).not.toThrow(); }); }); }); diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 631e3307f7f..1ab3286fe4c 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import labelsFixture from 'test_fixtures/autocomplete_sources/labels.json'; import GfmAutoComplete, { membersBeforeSave, highlighter } from 'ee_else_ce/gfm_auto_complete'; -import { initEmojiMock } from 'helpers/emoji'; +import { initEmojiMock, clearEmojiMock } from 'helpers/emoji'; import '~/lib/utils/jquery_at_who'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; @@ -803,8 +803,6 @@ describe('GfmAutoComplete', () => { }); describe('emoji', () => { - let mock; - const mockItem = { 'atwho-at': ':', emoji: { @@ -818,14 +816,14 @@ describe('GfmAutoComplete', () => { }; beforeEach(async () => { - mock = await initEmojiMock(); + await initEmojiMock(); await new GfmAutoComplete({}).loadEmojiData({ atwho() {}, trigger() {} }, ':'); if (!GfmAutoComplete.glEmojiTag) throw new Error('emoji not loaded'); }); afterEach(() => { - mock.restore(); + clearEmojiMock(); }); describe('Emoji.templateFunction', () => { diff --git a/spec/frontend/google_cloud/components/app_spec.js b/spec/frontend/google_cloud/components/app_spec.js index bb86eb5c22e..570ac1e6ed1 100644 --- a/spec/frontend/google_cloud/components/app_spec.js +++ b/spec/frontend/google_cloud/components/app_spec.js @@ -1,65 +1,71 @@ import { shallowMount } from '@vue/test-utils'; -import { GlTab, GlTabs } from '@gitlab/ui'; +import { mapValues } from 'lodash'; import App from '~/google_cloud/components/app.vue'; +import Home from '~/google_cloud/components/home.vue'; import IncubationBanner from '~/google_cloud/components/incubation_banner.vue'; -import ServiceAccounts from '~/google_cloud/components/service_accounts.vue'; +import ServiceAccountsForm from '~/google_cloud/components/service_accounts_form.vue'; +import GcpError from '~/google_cloud/components/errors/gcp_error.vue'; +import NoGcpProjects from '~/google_cloud/components/errors/no_gcp_projects.vue'; + +const BASE_FEEDBACK_URL = + 'https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/meta/-/issues/new'; +const SCREEN_COMPONENTS = { + Home, + ServiceAccountsForm, + GcpError, + NoGcpProjects, +}; +const SERVICE_ACCOUNTS_FORM_PROPS = { + gcpProjects: [1, 2, 3], + environments: [4, 5, 6], + cancelPath: '', +}; +const HOME_PROPS = { + serviceAccounts: [{}, {}], + createServiceAccountUrl: '#url-create-service-account', + emptyIllustrationUrl: '#url-empty-illustration', +}; describe('google_cloud App component', () => { let wrapper; const findIncubationBanner = () => wrapper.findComponent(IncubationBanner); - const findTabs = () => wrapper.findComponent(GlTabs); - const findTabItems = () => findTabs().findAllComponents(GlTab); - const findConfigurationTab = () => findTabItems().at(0); - const findDeploymentTab = () => findTabItems().at(1); - const findServicesTab = () => findTabItems().at(2); - const findServiceAccounts = () => findConfigurationTab().findComponent(ServiceAccounts); - - beforeEach(() => { - const propsData = { - serviceAccounts: [{}, {}], - createServiceAccountUrl: '#url-create-service-account', - emptyIllustrationUrl: '#url-empty-illustration', - }; - wrapper = shallowMount(App, { propsData }); - }); afterEach(() => { wrapper.destroy(); }); - it('should contain incubation banner', () => { - expect(findIncubationBanner().exists()).toBe(true); - }); - - describe('google_cloud App tabs', () => { - it('should contain tabs', () => { - expect(findTabs().exists()).toBe(true); - }); + describe.each` + screen | extraProps | componentName + ${'gcp_error'} | ${{ error: 'mock_gcp_client_error' }} | ${'GcpError'} + ${'no_gcp_projects'} | ${{}} | ${'NoGcpProjects'} + ${'service_accounts_form'} | ${SERVICE_ACCOUNTS_FORM_PROPS} | ${'ServiceAccountsForm'} + ${'home'} | ${HOME_PROPS} | ${'Home'} + `('for screen=$screen', ({ screen, extraProps, componentName }) => { + const component = SCREEN_COMPONENTS[componentName]; - it('should contain three tab items', () => { - expect(findTabItems().length).toBe(3); + beforeEach(() => { + wrapper = shallowMount(App, { propsData: { screen, ...extraProps } }); }); - describe('configuration tab', () => { - it('should exist', () => { - expect(findConfigurationTab().exists()).toBe(true); - }); + it(`renders only ${componentName}`, () => { + const existences = mapValues(SCREEN_COMPONENTS, (x) => wrapper.findComponent(x).exists()); - it('should contain service accounts component', () => { - expect(findServiceAccounts().exists()).toBe(true); + expect(existences).toEqual({ + ...mapValues(SCREEN_COMPONENTS, () => false), + [componentName]: true, }); }); - describe('deployments tab', () => { - it('should exist', () => { - expect(findDeploymentTab().exists()).toBe(true); - }); + it(`renders the ${componentName} with props`, () => { + expect(wrapper.findComponent(component).props()).toEqual(extraProps); }); - describe('services tab', () => { - it('should exist', () => { - expect(findServicesTab().exists()).toBe(true); + it('renders incubation banner', () => { + expect(findIncubationBanner().props()).toEqual({ + shareFeedbackUrl: `${BASE_FEEDBACK_URL}?issuable_template=general_feedback`, + reportBugUrl: `${BASE_FEEDBACK_URL}?issuable_template=report_bug`, + featureRequestUrl: `${BASE_FEEDBACK_URL}?issuable_template=feature_request`, }); }); }); diff --git a/spec/frontend/google_cloud/components/errors/gcp_error_spec.js b/spec/frontend/google_cloud/components/errors/gcp_error_spec.js new file mode 100644 index 00000000000..4062a8b902a --- /dev/null +++ b/spec/frontend/google_cloud/components/errors/gcp_error_spec.js @@ -0,0 +1,34 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlAlert } from '@gitlab/ui'; +import GcpError from '~/google_cloud/components/errors/gcp_error.vue'; + +describe('GcpError component', () => { + let wrapper; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findBlockquote = () => wrapper.find('blockquote'); + + const propsData = { error: 'IAM and CloudResourceManager API disabled' }; + + beforeEach(() => { + wrapper = shallowMount(GcpError, { propsData }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains alert', () => { + expect(findAlert().exists()).toBe(true); + }); + + it('contains relevant text', () => { + const alertText = findAlert().text(); + expect(findAlert().props('title')).toBe(GcpError.i18n.title); + expect(alertText).toContain(GcpError.i18n.description); + }); + + it('contains error stacktrace', () => { + expect(findBlockquote().text()).toBe(propsData.error); + }); +}); diff --git a/spec/frontend/google_cloud/components/errors/no_gcp_projects_spec.js b/spec/frontend/google_cloud/components/errors/no_gcp_projects_spec.js new file mode 100644 index 00000000000..e1e20377880 --- /dev/null +++ b/spec/frontend/google_cloud/components/errors/no_gcp_projects_spec.js @@ -0,0 +1,33 @@ +import { mount } from '@vue/test-utils'; +import { GlAlert, GlButton } from '@gitlab/ui'; +import NoGcpProjects from '~/google_cloud/components/errors/no_gcp_projects.vue'; + +describe('NoGcpProjects component', () => { + let wrapper; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findButton = () => wrapper.findComponent(GlButton); + + beforeEach(() => { + wrapper = mount(NoGcpProjects); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains alert', () => { + expect(findAlert().exists()).toBe(true); + }); + + it('contains relevant text', () => { + expect(findAlert().props('title')).toBe(NoGcpProjects.i18n.title); + expect(findAlert().text()).toContain(NoGcpProjects.i18n.description); + }); + + it('contains create gcp project button', () => { + const button = findButton(); + expect(button.text()).toBe(NoGcpProjects.i18n.createLabel); + expect(button.attributes('href')).toBe('https://console.cloud.google.com/projectcreate'); + }); +}); diff --git a/spec/frontend/google_cloud/components/home_spec.js b/spec/frontend/google_cloud/components/home_spec.js new file mode 100644 index 00000000000..9b4c3a79f11 --- /dev/null +++ b/spec/frontend/google_cloud/components/home_spec.js @@ -0,0 +1,61 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlTab, GlTabs } from '@gitlab/ui'; +import Home from '~/google_cloud/components/home.vue'; +import ServiceAccountsList from '~/google_cloud/components/service_accounts_list.vue'; + +describe('google_cloud Home component', () => { + let wrapper; + + const findTabs = () => wrapper.findComponent(GlTabs); + const findTabItems = () => findTabs().findAllComponents(GlTab); + const findTabItemsModel = () => + findTabs() + .findAllComponents(GlTab) + .wrappers.map((x) => ({ + title: x.attributes('title'), + disabled: x.attributes('disabled'), + })); + + const TEST_HOME_PROPS = { + serviceAccounts: [{}, {}], + createServiceAccountUrl: '#url-create-service-account', + emptyIllustrationUrl: '#url-empty-illustration', + }; + + beforeEach(() => { + const propsData = { + screen: 'home', + ...TEST_HOME_PROPS, + }; + wrapper = shallowMount(Home, { propsData }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('google_cloud App tabs', () => { + it('should contain tabs', () => { + expect(findTabs().exists()).toBe(true); + }); + + it('should contain three tab items', () => { + expect(findTabItemsModel()).toEqual([ + { title: 'Configuration', disabled: undefined }, + { title: 'Deployments', disabled: '' }, + { title: 'Services', disabled: '' }, + ]); + }); + + describe('configuration tab', () => { + it('should contain service accounts component', () => { + const serviceAccounts = findTabItems().at(0).findComponent(ServiceAccountsList); + expect(serviceAccounts.props()).toEqual({ + list: TEST_HOME_PROPS.serviceAccounts, + createUrl: TEST_HOME_PROPS.createServiceAccountUrl, + emptyIllustrationUrl: TEST_HOME_PROPS.emptyIllustrationUrl, + }); + }); + }); + }); +}); diff --git a/spec/frontend/google_cloud/components/service_accounts_form_spec.js b/spec/frontend/google_cloud/components/service_accounts_form_spec.js new file mode 100644 index 00000000000..5394d0cdaef --- /dev/null +++ b/spec/frontend/google_cloud/components/service_accounts_form_spec.js @@ -0,0 +1,59 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui'; +import ServiceAccountsForm from '~/google_cloud/components/service_accounts_form.vue'; + +describe('ServiceAccountsForm component', () => { + let wrapper; + + const findHeader = () => wrapper.find('header'); + const findAllFormGroups = () => wrapper.findAllComponents(GlFormGroup); + const findAllFormSelects = () => wrapper.findAllComponents(GlFormSelect); + const findAllButtons = () => wrapper.findAllComponents(GlButton); + + const propsData = { gcpProjects: [], environments: [], cancelPath: '#cancel-url' }; + + beforeEach(() => { + wrapper = shallowMount(ServiceAccountsForm, { propsData }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains header', () => { + expect(findHeader().exists()).toBe(true); + }); + + it('contains GCP project form group', () => { + const formGroup = findAllFormGroups().at(0); + expect(formGroup.exists()).toBe(true); + }); + + it('contains GCP project dropdown', () => { + const select = findAllFormSelects().at(0); + expect(select.exists()).toBe(true); + }); + + it('contains Environments form group', () => { + const formGorup = findAllFormGroups().at(1); + expect(formGorup.exists()).toBe(true); + }); + + it('contains Environments dropdown', () => { + const select = findAllFormSelects().at(1); + expect(select.exists()).toBe(true); + }); + + it('contains Submit button', () => { + const button = findAllButtons().at(0); + expect(button.exists()).toBe(true); + expect(button.text()).toBe(ServiceAccountsForm.i18n.submitLabel); + }); + + it('contains Cancel button', () => { + const button = findAllButtons().at(1); + expect(button.exists()).toBe(true); + expect(button.text()).toBe(ServiceAccountsForm.i18n.cancelLabel); + expect(button.attributes('href')).toBe('#cancel-url'); + }); +}); diff --git a/spec/frontend/google_cloud/components/service_accounts_spec.js b/spec/frontend/google_cloud/components/service_accounts_list_spec.js index 3d097078f03..cdb3f74051c 100644 --- a/spec/frontend/google_cloud/components/service_accounts_spec.js +++ b/spec/frontend/google_cloud/components/service_accounts_list_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui'; -import ServiceAccounts from '~/google_cloud/components/service_accounts.vue'; +import ServiceAccountsList from '~/google_cloud/components/service_accounts_list.vue'; describe('ServiceAccounts component', () => { describe('when the project does not have any service accounts', () => { @@ -15,7 +15,7 @@ describe('ServiceAccounts component', () => { createUrl: '#create-url', emptyIllustrationUrl: '#empty-illustration-url', }; - wrapper = mount(ServiceAccounts, { propsData }); + wrapper = mount(ServiceAccountsList, { propsData }); }); afterEach(() => { @@ -48,7 +48,7 @@ describe('ServiceAccounts component', () => { createUrl: '#create-url', emptyIllustrationUrl: '#empty-illustration-url', }; - wrapper = mount(ServiceAccounts, { propsData }); + wrapper = mount(ServiceAccountsList, { propsData }); }); it('shows the title', () => { diff --git a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap index 33e2c0db5e5..9447e7daba8 100644 --- a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap +++ b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap @@ -47,6 +47,7 @@ exports[`grafana integration component default state to match the default snapsh label="Enable authentication" label-for="grafana-integration-enabled" labeldescription="" + optionaltext="(optional)" > <gl-form-checkbox-stub id="grafana-integration-enabled" @@ -62,6 +63,7 @@ exports[`grafana integration component default state to match the default snapsh label="Grafana URL" label-for="grafana-url" labeldescription="" + optionaltext="(optional)" > <gl-form-input-stub id="grafana-url" @@ -74,6 +76,7 @@ exports[`grafana integration component default state to match the default snapsh label="API token" label-for="grafana-token" labeldescription="" + optionaltext="(optional)" > <gl-form-input-stub id="grafana-token" diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js index 2ea2693a978..3200c6614f1 100644 --- a/spec/frontend/header_search/components/app_spec.js +++ b/spec/frontend/header_search/components/app_spec.js @@ -6,9 +6,17 @@ import HeaderSearchApp from '~/header_search/components/app.vue'; import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue'; import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue'; import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue'; -import { ENTER_KEY, ESC_KEY } from '~/lib/utils/keys'; +import { SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION } from '~/header_search/constants'; +import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; +import { ENTER_KEY } from '~/lib/utils/keys'; import { visitUrl } from '~/lib/utils/url_utility'; -import { MOCK_SEARCH, MOCK_SEARCH_QUERY, MOCK_USERNAME } from '../mock_data'; +import { + MOCK_SEARCH, + MOCK_SEARCH_QUERY, + MOCK_USERNAME, + MOCK_DEFAULT_SEARCH_OPTIONS, + MOCK_SCOPED_SEARCH_OPTIONS, +} from '../mock_data'; Vue.use(Vuex); @@ -22,9 +30,10 @@ describe('HeaderSearchApp', () => { const actionSpies = { setSearch: jest.fn(), fetchAutocompleteOptions: jest.fn(), + clearAutocomplete: jest.fn(), }; - const createComponent = (initialState) => { + const createComponent = (initialState, mockGetters) => { const store = new Vuex.Store({ state: { ...initialState, @@ -32,6 +41,8 @@ describe('HeaderSearchApp', () => { actions: actionSpies, getters: { searchQuery: () => MOCK_SEARCH_QUERY, + searchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS, + ...mockGetters, }, }); @@ -50,11 +61,27 @@ describe('HeaderSearchApp', () => { const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems); const findHeaderSearchAutocompleteItems = () => wrapper.findComponent(HeaderSearchAutocompleteItems); + const findDropdownKeyboardNavigation = () => wrapper.findComponent(DropdownKeyboardNavigation); + const findSearchInputDescription = () => wrapper.find(`#${SEARCH_INPUT_DESCRIPTION}`); + const findSearchResultsDescription = () => wrapper.findByTestId(SEARCH_RESULTS_DESCRIPTION); describe('template', () => { - it('always renders Header Search Input', () => { - createComponent(); - expect(findHeaderSearchInput().exists()).toBe(true); + describe('always renders', () => { + beforeEach(() => { + createComponent(); + }); + + it('Header Search Input', () => { + expect(findHeaderSearchInput().exists()).toBe(true); + }); + + it('Search Input Description', () => { + expect(findSearchInputDescription().exists()).toBe(true); + }); + + it('Search Results Description', () => { + expect(findSearchResultsDescription().exists()).toBe(true); + }); }); describe.each` @@ -66,9 +93,9 @@ describe('HeaderSearchApp', () => { `('Header Search Dropdown', ({ showDropdown, username, showSearchDropdown }) => { describe(`when showDropdown is ${showDropdown} and current_username is ${username}`, () => { beforeEach(() => { - createComponent(); window.gon.current_username = username; - wrapper.setData({ showDropdown }); + createComponent(); + findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : ''); }); it(`should${showSearchDropdown ? '' : ' not'} render`, () => { @@ -78,31 +105,89 @@ describe('HeaderSearchApp', () => { }); describe.each` - search | showDefault | showScoped | showAutocomplete - ${null} | ${true} | ${false} | ${false} - ${''} | ${true} | ${false} | ${false} - ${MOCK_SEARCH} | ${false} | ${true} | ${true} - `('Header Search Dropdown Items', ({ search, showDefault, showScoped, showAutocomplete }) => { - describe(`when search is ${search}`, () => { - beforeEach(() => { - createComponent({ search }); - window.gon.current_username = MOCK_USERNAME; - wrapper.setData({ showDropdown: true }); - }); - - it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => { - expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault); + search | showDefault | showScoped | showAutocomplete | showDropdownNavigation + ${null} | ${true} | ${false} | ${false} | ${true} + ${''} | ${true} | ${false} | ${false} | ${true} + ${MOCK_SEARCH} | ${false} | ${true} | ${true} | ${true} + `( + 'Header Search Dropdown Items', + ({ search, showDefault, showScoped, showAutocomplete, showDropdownNavigation }) => { + describe(`when search is ${search}`, () => { + beforeEach(() => { + window.gon.current_username = MOCK_USERNAME; + createComponent({ search }); + findHeaderSearchInput().vm.$emit('click'); + }); + + it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => { + expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault); + }); + + it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => { + expect(findHeaderSearchScopedItems().exists()).toBe(showScoped); + }); + + it(`should${ + showAutocomplete ? '' : ' not' + } render the Autocomplete Dropdown Items`, () => { + expect(findHeaderSearchAutocompleteItems().exists()).toBe(showAutocomplete); + }); + + it(`should${ + showDropdownNavigation ? '' : ' not' + } render the Dropdown Navigation Component`, () => { + expect(findDropdownKeyboardNavigation().exists()).toBe(showDropdownNavigation); + }); }); + }, + ); - it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => { - expect(findHeaderSearchScopedItems().exists()).toBe(showScoped); + describe.each` + username | showDropdown | expectedDesc + ${null} | ${false} | ${HeaderSearchApp.i18n.searchInputDescribeByNoDropdown} + ${null} | ${true} | ${HeaderSearchApp.i18n.searchInputDescribeByNoDropdown} + ${MOCK_USERNAME} | ${false} | ${HeaderSearchApp.i18n.searchInputDescribeByWithDropdown} + ${MOCK_USERNAME} | ${true} | ${HeaderSearchApp.i18n.searchInputDescribeByWithDropdown} + `('Search Input Description', ({ username, showDropdown, expectedDesc }) => { + describe(`current_username is ${username} and showDropdown is ${showDropdown}`, () => { + beforeEach(() => { + window.gon.current_username = username; + createComponent(); + findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : ''); }); - it(`should${showAutocomplete ? '' : ' not'} render the Autocomplete Dropdown Items`, () => { - expect(findHeaderSearchAutocompleteItems().exists()).toBe(showAutocomplete); + it(`sets description to ${expectedDesc}`, () => { + expect(findSearchInputDescription().text()).toBe(expectedDesc); }); }); }); + + describe.each` + username | showDropdown | search | loading | searchOptions | expectedDesc + ${null} | ${true} | ${''} | ${false} | ${[]} | ${''} + ${MOCK_USERNAME} | ${false} | ${''} | ${false} | ${[]} | ${''} + ${MOCK_USERNAME} | ${true} | ${''} | ${false} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`} + ${MOCK_USERNAME} | ${true} | ${''} | ${true} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`} + ${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${false} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${`Results updated. ${MOCK_SCOPED_SEARCH_OPTIONS.length} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.`} + ${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${true} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${HeaderSearchApp.i18n.searchResultsLoading} + `( + 'Search Results Description', + ({ username, showDropdown, search, loading, searchOptions, expectedDesc }) => { + describe(`search is ${search}, loading is ${loading}, and showSearchDropdown is ${ + Boolean(username) && showDropdown + }`, () => { + beforeEach(() => { + window.gon.current_username = username; + createComponent({ search, loading }, { searchOptions: () => searchOptions }); + findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : ''); + }); + + it(`sets description to ${expectedDesc}`, () => { + expect(findSearchResultsDescription().text()).toBe(expectedDesc); + }); + }); + }, + ); }); describe('events', () => { @@ -132,36 +217,86 @@ describe('HeaderSearchApp', () => { }); }); - describe('when dropdown is opened', () => { - beforeEach(() => { - wrapper.setData({ showDropdown: true }); + describe('onInput', () => { + describe('when search has text', () => { + beforeEach(() => { + findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH); + }); + + it('calls setSearch with search term', () => { + expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), MOCK_SEARCH); + }); + + it('calls fetchAutocompleteOptions', () => { + expect(actionSpies.fetchAutocompleteOptions).toHaveBeenCalled(); + }); + + it('does not call clearAutocomplete', () => { + expect(actionSpies.clearAutocomplete).not.toHaveBeenCalled(); + }); }); - it('onKey-Escape closes dropdown', async () => { - expect(findHeaderSearchDropdown().exists()).toBe(true); - findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ESC_KEY })); + describe('when search is emptied', () => { + beforeEach(() => { + findHeaderSearchInput().vm.$emit('input', ''); + }); - await wrapper.vm.$nextTick(); + it('calls setSearch with empty term', () => { + expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), ''); + }); - expect(findHeaderSearchDropdown().exists()).toBe(false); + it('does not call fetchAutocompleteOptions', () => { + expect(actionSpies.fetchAutocompleteOptions).not.toHaveBeenCalled(); + }); + + it('calls clearAutocomplete', () => { + expect(actionSpies.clearAutocomplete).toHaveBeenCalled(); + }); }); }); + }); - describe('onInput', () => { - beforeEach(() => { - findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH); - }); + describe('Dropdown Keyboard Navigation', () => { + beforeEach(() => { + findHeaderSearchInput().vm.$emit('click'); + }); - it('calls setSearch with search term', () => { - expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), MOCK_SEARCH); - }); + it('closes dropdown when @tab is emitted', async () => { + expect(findHeaderSearchDropdown().exists()).toBe(true); + findDropdownKeyboardNavigation().vm.$emit('tab'); - it('calls fetchAutocompleteOptions', () => { - expect(actionSpies.fetchAutocompleteOptions).toHaveBeenCalled(); - }); + await wrapper.vm.$nextTick(); + + expect(findHeaderSearchDropdown().exists()).toBe(false); + }); + }); + }); + + describe('computed', () => { + describe('currentFocusedOption', () => { + const MOCK_INDEX = 1; + + beforeEach(() => { + createComponent(); + window.gon.current_username = MOCK_USERNAME; + findHeaderSearchInput().vm.$emit('click'); + }); + + it(`when currentFocusIndex changes to ${MOCK_INDEX} updates the data to searchOptions[${MOCK_INDEX}]`, async () => { + findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.currentFocusedOption).toBe(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX]); }); + }); + }); - it('submits a search onKey-Enter', async () => { + describe('Submitting a search', () => { + describe('with no currentFocusedOption', () => { + beforeEach(() => { + createComponent(); + }); + + it('onKey-enter submits a search', async () => { findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); await wrapper.vm.$nextTick(); @@ -169,5 +304,22 @@ describe('HeaderSearchApp', () => { expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY); }); }); + + describe('with currentFocusedOption', () => { + const MOCK_INDEX = 1; + + beforeEach(() => { + createComponent(); + window.gon.current_username = MOCK_USERNAME; + findHeaderSearchInput().vm.$emit('click'); + }); + + it('onKey-enter clicks the selected dropdown item rather than submitting a search', async () => { + findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX); + await wrapper.vm.$nextTick(); + findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); + expect(visitUrl).toHaveBeenCalledWith(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX].url); + }); + }); }); }); diff --git a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js index 6b84e63989d..bec0cbc8a5c 100644 --- a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js +++ b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js @@ -9,14 +9,14 @@ import { PROJECTS_CATEGORY, SMALL_AVATAR_PX, } from '~/header_search/constants'; -import { MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, MOCK_AUTOCOMPLETE_OPTIONS } from '../mock_data'; +import { MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, MOCK_SORTED_AUTOCOMPLETE_OPTIONS } from '../mock_data'; Vue.use(Vuex); describe('HeaderSearchAutocompleteItems', () => { let wrapper; - const createComponent = (initialState, mockGetters) => { + const createComponent = (initialState, mockGetters, props) => { const store = new Vuex.Store({ state: { loading: false, @@ -30,6 +30,9 @@ describe('HeaderSearchAutocompleteItems', () => { wrapper = shallowMount(HeaderSearchAutocompleteItems, { store, + propsData: { + ...props, + }, }); }; @@ -38,6 +41,7 @@ describe('HeaderSearchAutocompleteItems', () => { }); const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findFirstDropdownItem = () => findDropdownItems().at(0); const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => w.text()); const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); @@ -69,16 +73,16 @@ describe('HeaderSearchAutocompleteItems', () => { describe('Dropdown items', () => { it('renders item for each option in autocomplete option', () => { - expect(findDropdownItems()).toHaveLength(MOCK_AUTOCOMPLETE_OPTIONS.length); + expect(findDropdownItems()).toHaveLength(MOCK_SORTED_AUTOCOMPLETE_OPTIONS.length); }); it('renders titles correctly', () => { - const expectedTitles = MOCK_AUTOCOMPLETE_OPTIONS.map((o) => o.label); + const expectedTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.label); expect(findDropdownItemTitles()).toStrictEqual(expectedTitles); }); it('renders links correctly', () => { - const expectedLinks = MOCK_AUTOCOMPLETE_OPTIONS.map((o) => o.url); + const expectedLinks = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.url); expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); }); }); @@ -104,5 +108,46 @@ describe('HeaderSearchAutocompleteItems', () => { }); }); }); + + describe.each` + currentFocusedOption | isFocused | ariaSelected + ${null} | ${false} | ${undefined} + ${{ html_id: 'not-a-match' }} | ${false} | ${undefined} + ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS[0]} | ${true} | ${'true'} + `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => { + describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => { + beforeEach(() => { + createComponent({}, {}, { currentFocusedOption }); + }); + + it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => { + expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused); + }); + + it(`sets "aria-selected to ${ariaSelected}`, () => { + expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected); + }); + }); + }); + }); + + describe('watchers', () => { + describe('currentFocusedOption', () => { + beforeEach(() => { + createComponent(); + }); + + it('when focused changes to existing element calls scroll into view on the newly focused element', async () => { + const focusedElement = findFirstDropdownItem().element; + const scrollSpy = jest.spyOn(focusedElement, 'scrollIntoView'); + + wrapper.setProps({ currentFocusedOption: MOCK_SORTED_AUTOCOMPLETE_OPTIONS[0] }); + + await wrapper.vm.$nextTick(); + + expect(scrollSpy).toHaveBeenCalledWith(false); + scrollSpy.mockRestore(); + }); + }); }); }); diff --git a/spec/frontend/header_search/components/header_search_default_items_spec.js b/spec/frontend/header_search/components/header_search_default_items_spec.js index ce083d0df72..abcacc487df 100644 --- a/spec/frontend/header_search/components/header_search_default_items_spec.js +++ b/spec/frontend/header_search/components/header_search_default_items_spec.js @@ -10,7 +10,7 @@ Vue.use(Vuex); describe('HeaderSearchDefaultItems', () => { let wrapper; - const createComponent = (initialState) => { + const createComponent = (initialState, props) => { const store = new Vuex.Store({ state: { searchContext: MOCK_SEARCH_CONTEXT, @@ -23,6 +23,9 @@ describe('HeaderSearchDefaultItems', () => { wrapper = shallowMount(HeaderSearchDefaultItems, { store, + propsData: { + ...props, + }, }); }; @@ -32,6 +35,7 @@ describe('HeaderSearchDefaultItems', () => { const findDropdownHeader = () => wrapper.findComponent(GlDropdownSectionHeader); const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findFirstDropdownItem = () => findDropdownItems().at(0); const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => w.text()); const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); @@ -77,5 +81,26 @@ describe('HeaderSearchDefaultItems', () => { }); }); }); + + describe.each` + currentFocusedOption | isFocused | ariaSelected + ${null} | ${false} | ${undefined} + ${{ html_id: 'not-a-match' }} | ${false} | ${undefined} + ${MOCK_DEFAULT_SEARCH_OPTIONS[0]} | ${true} | ${'true'} + `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => { + describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => { + beforeEach(() => { + createComponent({}, { currentFocusedOption }); + }); + + it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => { + expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused); + }); + + it(`sets "aria-selected to ${ariaSelected}`, () => { + expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected); + }); + }); + }); }); }); diff --git a/spec/frontend/header_search/components/header_search_scoped_items_spec.js b/spec/frontend/header_search/components/header_search_scoped_items_spec.js index f0e5e182ec4..a65b4d8b813 100644 --- a/spec/frontend/header_search/components/header_search_scoped_items_spec.js +++ b/spec/frontend/header_search/components/header_search_scoped_items_spec.js @@ -11,7 +11,7 @@ Vue.use(Vuex); describe('HeaderSearchScopedItems', () => { let wrapper; - const createComponent = (initialState) => { + const createComponent = (initialState, props) => { const store = new Vuex.Store({ state: { search: MOCK_SEARCH, @@ -24,6 +24,9 @@ describe('HeaderSearchScopedItems', () => { wrapper = shallowMount(HeaderSearchScopedItems, { store, + propsData: { + ...props, + }, }); }; @@ -32,7 +35,10 @@ describe('HeaderSearchScopedItems', () => { }); const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findFirstDropdownItem = () => findDropdownItems().at(0); const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text())); + const findDropdownItemAriaLabels = () => + findDropdownItems().wrappers.map((w) => trimText(w.attributes('aria-label'))); const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); describe('template', () => { @@ -52,10 +58,38 @@ describe('HeaderSearchScopedItems', () => { expect(findDropdownItemTitles()).toStrictEqual(expectedTitles); }); + it('renders aria-labels correctly', () => { + const expectedLabels = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => + trimText(`${MOCK_SEARCH} ${o.description} ${o.scope || ''}`), + ); + expect(findDropdownItemAriaLabels()).toStrictEqual(expectedLabels); + }); + it('renders links correctly', () => { const expectedLinks = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => o.url); expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); }); }); + + describe.each` + currentFocusedOption | isFocused | ariaSelected + ${null} | ${false} | ${undefined} + ${{ html_id: 'not-a-match' }} | ${false} | ${undefined} + ${MOCK_SCOPED_SEARCH_OPTIONS[0]} | ${true} | ${'true'} + `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => { + describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => { + beforeEach(() => { + createComponent({}, { currentFocusedOption }); + }); + + it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => { + expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused); + }); + + it(`sets "aria-selected to ${ariaSelected}`, () => { + expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected); + }); + }); + }); }); }); diff --git a/spec/frontend/header_search/mock_data.js b/spec/frontend/header_search/mock_data.js index 915b3a4a678..1d980679547 100644 --- a/spec/frontend/header_search/mock_data.js +++ b/spec/frontend/header_search/mock_data.js @@ -46,22 +46,27 @@ export const MOCK_SEARCH_CONTEXT = { export const MOCK_DEFAULT_SEARCH_OPTIONS = [ { + html_id: 'default-issues-assigned', title: MSG_ISSUES_ASSIGNED_TO_ME, url: `${MOCK_ISSUE_PATH}/?assignee_username=${MOCK_USERNAME}`, }, { + html_id: 'default-issues-created', title: MSG_ISSUES_IVE_CREATED, url: `${MOCK_ISSUE_PATH}/?author_username=${MOCK_USERNAME}`, }, { + html_id: 'default-mrs-assigned', title: MSG_MR_ASSIGNED_TO_ME, url: `${MOCK_MR_PATH}/?assignee_username=${MOCK_USERNAME}`, }, { + html_id: 'default-mrs-reviewer', title: MSG_MR_IM_REVIEWER, url: `${MOCK_MR_PATH}/?reviewer_username=${MOCK_USERNAME}`, }, { + html_id: 'default-mrs-created', title: MSG_MR_IVE_CREATED, url: `${MOCK_MR_PATH}/?author_username=${MOCK_USERNAME}`, }, @@ -69,22 +74,25 @@ export const MOCK_DEFAULT_SEARCH_OPTIONS = [ export const MOCK_SCOPED_SEARCH_OPTIONS = [ { + html_id: 'scoped-in-project', scope: MOCK_PROJECT.name, description: MSG_IN_PROJECT, url: MOCK_PROJECT.path, }, { + html_id: 'scoped-in-group', scope: MOCK_GROUP.name, description: MSG_IN_GROUP, url: MOCK_GROUP.path, }, { + html_id: 'scoped-in-all', description: MSG_IN_ALL_GITLAB, url: MOCK_ALL_PATH, }, ]; -export const MOCK_AUTOCOMPLETE_OPTIONS = [ +export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [ { category: 'Projects', id: 1, @@ -92,19 +100,49 @@ export const MOCK_AUTOCOMPLETE_OPTIONS = [ url: 'project/1', }, { + category: 'Groups', + id: 1, + label: 'MockGroup1', + url: 'group/1', + }, + { category: 'Projects', id: 2, label: 'MockProject2', url: 'project/2', }, { + category: 'Help', + label: 'GitLab Help', + url: 'help/gitlab', + }, +]; + +export const MOCK_AUTOCOMPLETE_OPTIONS = [ + { + category: 'Projects', + html_id: 'autocomplete-Projects-0', + id: 1, + label: 'MockProject1', + url: 'project/1', + }, + { category: 'Groups', + html_id: 'autocomplete-Groups-1', id: 1, label: 'MockGroup1', url: 'group/1', }, { + category: 'Projects', + html_id: 'autocomplete-Projects-2', + id: 2, + label: 'MockProject2', + url: 'project/2', + }, + { category: 'Help', + html_id: 'autocomplete-Help-3', label: 'GitLab Help', url: 'help/gitlab', }, @@ -116,12 +154,16 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ data: [ { category: 'Projects', + html_id: 'autocomplete-Projects-0', + id: 1, label: 'MockProject1', url: 'project/1', }, { category: 'Projects', + html_id: 'autocomplete-Projects-2', + id: 2, label: 'MockProject2', url: 'project/2', @@ -133,6 +175,8 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ data: [ { category: 'Groups', + html_id: 'autocomplete-Groups-1', + id: 1, label: 'MockGroup1', url: 'group/1', @@ -144,9 +188,41 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ data: [ { category: 'Help', + html_id: 'autocomplete-Help-3', + label: 'GitLab Help', url: 'help/gitlab', }, ], }, ]; + +export const MOCK_SORTED_AUTOCOMPLETE_OPTIONS = [ + { + category: 'Projects', + html_id: 'autocomplete-Projects-0', + id: 1, + label: 'MockProject1', + url: 'project/1', + }, + { + category: 'Projects', + html_id: 'autocomplete-Projects-2', + id: 2, + label: 'MockProject2', + url: 'project/2', + }, + { + category: 'Groups', + html_id: 'autocomplete-Groups-1', + id: 1, + label: 'MockGroup1', + url: 'group/1', + }, + { + category: 'Help', + html_id: 'autocomplete-Help-3', + label: 'GitLab Help', + url: 'help/gitlab', + }, +]; diff --git a/spec/frontend/header_search/store/actions_spec.js b/spec/frontend/header_search/store/actions_spec.js index ee2c72df77b..6599115f017 100644 --- a/spec/frontend/header_search/store/actions_spec.js +++ b/spec/frontend/header_search/store/actions_spec.js @@ -5,7 +5,7 @@ import * as actions from '~/header_search/store/actions'; import * as types from '~/header_search/store/mutation_types'; import createState from '~/header_search/store/state'; import axios from '~/lib/utils/axios_utils'; -import { MOCK_SEARCH, MOCK_AUTOCOMPLETE_OPTIONS } from '../mock_data'; +import { MOCK_SEARCH, MOCK_AUTOCOMPLETE_OPTIONS_RES } from '../mock_data'; jest.mock('~/flash'); @@ -29,9 +29,9 @@ describe('Header Search Store Actions', () => { }); describe.each` - axiosMock | type | expectedMutations | flashCallCount - ${{ method: 'onGet', code: 200, res: MOCK_AUTOCOMPLETE_OPTIONS }} | ${'success'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_SUCCESS, payload: MOCK_AUTOCOMPLETE_OPTIONS }]} | ${0} - ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_ERROR }]} | ${1} + axiosMock | type | expectedMutations | flashCallCount + ${{ method: 'onGet', code: 200, res: MOCK_AUTOCOMPLETE_OPTIONS_RES }} | ${'success'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_SUCCESS, payload: MOCK_AUTOCOMPLETE_OPTIONS_RES }]} | ${0} + ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_ERROR }]} | ${1} `('fetchAutocompleteOptions', ({ axiosMock, type, expectedMutations, flashCallCount }) => { describe(`on ${type}`, () => { beforeEach(() => { @@ -47,6 +47,16 @@ describe('Header Search Store Actions', () => { }); }); + describe('clearAutocomplete', () => { + it('calls the CLEAR_AUTOCOMPLETE mutation', () => { + return testAction({ + action: actions.clearAutocomplete, + state, + expectedMutations: [{ type: types.CLEAR_AUTOCOMPLETE }], + }); + }); + }); + describe('setSearch', () => { it('calls the SET_SEARCH mutation', () => { return testAction({ diff --git a/spec/frontend/header_search/store/getters_spec.js b/spec/frontend/header_search/store/getters_spec.js index d55db07188e..35d1bf350d7 100644 --- a/spec/frontend/header_search/store/getters_spec.js +++ b/spec/frontend/header_search/store/getters_spec.js @@ -15,6 +15,7 @@ import { MOCK_SEARCH, MOCK_AUTOCOMPLETE_OPTIONS, MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, + MOCK_SORTED_AUTOCOMPLETE_OPTIONS, } from '../mock_data'; describe('Header Search Store Getters', () => { @@ -36,18 +37,20 @@ describe('Header Search Store Getters', () => { }); describe.each` - group | project | expectedPath - ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=undefined&group_id=undefined&scope=issues`} - ${MOCK_GROUP} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=undefined&group_id=${MOCK_GROUP.id}&scope=issues`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`} - `('searchQuery', ({ group, project, expectedPath }) => { - describe(`when group is ${group?.name} and project is ${project?.name}`, () => { + group | project | scope | expectedPath + ${null} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`} + ${MOCK_GROUP} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`} + ${null} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}`} + ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}`} + ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`} + `('searchQuery', ({ group, project, scope, expectedPath }) => { + describe(`when group is ${group?.name}, project is ${project?.name}, and scope is ${scope}`, () => { beforeEach(() => { createState({ searchContext: { group, project, - scope: 'issues', + scope, }, }); state.search = MOCK_SEARCH; @@ -61,8 +64,9 @@ describe('Header Search Store Getters', () => { describe.each` project | ref | expectedPath - ${null} | ${null} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=undefined&project_ref=null`} - ${MOCK_PROJECT} | ${null} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=${MOCK_PROJECT.id}&project_ref=null`} + ${null} | ${null} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}`} + ${MOCK_PROJECT} | ${null} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=${MOCK_PROJECT.id}`} + ${null} | ${MOCK_PROJECT.id} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_ref=${MOCK_PROJECT.id}`} ${MOCK_PROJECT} | ${MOCK_PROJECT.id} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=${MOCK_PROJECT.id}&project_ref=${MOCK_PROJECT.id}`} `('autocompleteQuery', ({ project, ref, expectedPath }) => { describe(`when project is ${project?.name} and project ref is ${ref}`, () => { @@ -131,18 +135,20 @@ describe('Header Search Store Getters', () => { }); describe.each` - group | project | expectedPath - ${null} | ${null} | ${null} - ${MOCK_GROUP} | ${null} | ${null} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`} - `('projectUrl', ({ group, project, expectedPath }) => { - describe(`when group is ${group?.name} and project is ${project?.name}`, () => { + group | project | scope | expectedPath + ${null} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`} + ${MOCK_GROUP} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`} + ${null} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}`} + ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}`} + ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`} + `('projectUrl', ({ group, project, scope, expectedPath }) => { + describe(`when group is ${group?.name}, project is ${project?.name}, and scope is ${scope}`, () => { beforeEach(() => { createState({ searchContext: { group, project, - scope: 'issues', + scope, }, }); state.search = MOCK_SEARCH; @@ -155,18 +161,20 @@ describe('Header Search Store Getters', () => { }); describe.each` - group | project | expectedPath - ${null} | ${null} | ${null} - ${MOCK_GROUP} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`} - `('groupUrl', ({ group, project, expectedPath }) => { - describe(`when group is ${group?.name} and project is ${project?.name}`, () => { + group | project | scope | expectedPath + ${null} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`} + ${MOCK_GROUP} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`} + ${null} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`} + ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`} + ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`} + `('groupUrl', ({ group, project, scope, expectedPath }) => { + describe(`when group is ${group?.name}, project is ${project?.name}, and scope is ${scope}`, () => { beforeEach(() => { createState({ searchContext: { group, project, - scope: 'issues', + scope, }, }); state.search = MOCK_SEARCH; @@ -178,20 +186,29 @@ describe('Header Search Store Getters', () => { }); }); - describe('allUrl', () => { - const expectedPath = `${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues`; - - beforeEach(() => { - createState({ - searchContext: { - scope: 'issues', - }, + describe.each` + group | project | scope | expectedPath + ${null} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`} + ${MOCK_GROUP} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`} + ${null} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`} + ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`} + ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues`} + `('allUrl', ({ group, project, scope, expectedPath }) => { + describe(`when group is ${group?.name}, project is ${project?.name}, and scope is ${scope}`, () => { + beforeEach(() => { + createState({ + searchContext: { + group, + project, + scope, + }, + }); + state.search = MOCK_SEARCH; }); - state.search = MOCK_SEARCH; - }); - it(`should return ${expectedPath}`, () => { - expect(getters.allUrl(state)).toBe(expectedPath); + it(`should return ${expectedPath}`, () => { + expect(getters.allUrl(state)).toBe(expectedPath); + }); }); }); @@ -248,4 +265,44 @@ describe('Header Search Store Getters', () => { ); }); }); + + describe.each` + search | defaultSearchOptions | scopedSearchOptions | autocompleteGroupedSearchOptions | expectedArray + ${null} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_DEFAULT_SEARCH_OPTIONS} + ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${[]} | ${MOCK_SCOPED_SEARCH_OPTIONS} + ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS} + ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS.concat(MOCK_SORTED_AUTOCOMPLETE_OPTIONS)} + `( + 'searchOptions', + ({ + search, + defaultSearchOptions, + scopedSearchOptions, + autocompleteGroupedSearchOptions, + expectedArray, + }) => { + describe(`when search is ${search} and the defaultSearchOptions${ + defaultSearchOptions.length ? '' : ' do not' + } exist, scopedSearchOptions${ + scopedSearchOptions.length ? '' : ' do not' + } exist, and autocompleteGroupedSearchOptions${ + autocompleteGroupedSearchOptions.length ? '' : ' do not' + } exist`, () => { + const mockGetters = { + defaultSearchOptions, + scopedSearchOptions, + autocompleteGroupedSearchOptions, + }; + + beforeEach(() => { + createState(); + state.search = search; + }); + + it(`should return the correct combined array`, () => { + expect(getters.searchOptions(state, mockGetters)).toStrictEqual(expectedArray); + }); + }); + }, + ); }); diff --git a/spec/frontend/header_search/store/mutations_spec.js b/spec/frontend/header_search/store/mutations_spec.js index 7f9b7631a7e..7bcf8e49118 100644 --- a/spec/frontend/header_search/store/mutations_spec.js +++ b/spec/frontend/header_search/store/mutations_spec.js @@ -1,7 +1,11 @@ import * as types from '~/header_search/store/mutation_types'; import mutations from '~/header_search/store/mutations'; import createState from '~/header_search/store/state'; -import { MOCK_SEARCH, MOCK_AUTOCOMPLETE_OPTIONS } from '../mock_data'; +import { + MOCK_SEARCH, + MOCK_AUTOCOMPLETE_OPTIONS_RES, + MOCK_AUTOCOMPLETE_OPTIONS, +} from '../mock_data'; describe('Header Search Store Mutations', () => { let state; @@ -20,8 +24,8 @@ describe('Header Search Store Mutations', () => { }); describe('RECEIVE_AUTOCOMPLETE_SUCCESS', () => { - it('sets loading to false and sets autocompleteOptions array', () => { - mutations[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, MOCK_AUTOCOMPLETE_OPTIONS); + it('sets loading to false and then formats and sets the autocompleteOptions array', () => { + mutations[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, MOCK_AUTOCOMPLETE_OPTIONS_RES); expect(state.loading).toBe(false); expect(state.autocompleteOptions).toStrictEqual(MOCK_AUTOCOMPLETE_OPTIONS); @@ -37,6 +41,14 @@ describe('Header Search Store Mutations', () => { }); }); + describe('CLEAR_AUTOCOMPLETE', () => { + it('empties autocompleteOptions array', () => { + mutations[types.CLEAR_AUTOCOMPLETE](state); + + expect(state.autocompleteOptions).toStrictEqual([]); + }); + }); + describe('SET_SEARCH', () => { it('sets search to value', () => { mutations[types.SET_SEARCH](state, MOCK_SEARCH); diff --git a/spec/frontend/ide/components/ide_tree_list_spec.js b/spec/frontend/ide/components/ide_tree_list_spec.js index 85d9feb0c09..ace51204374 100644 --- a/spec/frontend/ide/components/ide_tree_list_spec.js +++ b/spec/frontend/ide/components/ide_tree_list_spec.js @@ -38,9 +38,16 @@ describe('IDE tree list', () => { beforeEach(() => { bootstrapWithTree(); + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + vm.$mount(); }); + it('emits tree-ready event', () => { + expect(vm.$emit).toHaveBeenCalledTimes(1); + expect(vm.$emit).toHaveBeenCalledWith('tree-ready'); + }); + it('renders loading indicator', (done) => { store.state.trees['abcproject/main'].loading = true; @@ -61,9 +68,15 @@ describe('IDE tree list', () => { beforeEach(() => { bootstrapWithTree(emptyBranchTree); + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + vm.$mount(); }); + it('still emits tree-ready event', () => { + expect(vm.$emit).toHaveBeenCalledWith('tree-ready'); + }); + it('does not load files if the branch is empty', () => { expect(vm.$el.textContent).not.toContain('fileName'); expect(vm.$el.textContent).toContain('No files'); diff --git a/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap b/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap index 47e3a56e83d..069b6927bac 100644 --- a/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap +++ b/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap @@ -6,10 +6,10 @@ exports[`IDE pipelines list when loaded renders empty state when no latestPipeli > <!----> - <empty-state-stub - cansetci="true" - class="gl-p-5" - emptystatesvgpath="http://test.host" - /> + <div + class="gl-h-full gl-display-flex gl-flex-direction-column gl-justify-content-center" + > + <empty-state-stub /> + </div> </div> `; diff --git a/spec/frontend/ide/components/pipelines/empty_state_spec.js b/spec/frontend/ide/components/pipelines/empty_state_spec.js new file mode 100644 index 00000000000..f7409fc36be --- /dev/null +++ b/spec/frontend/ide/components/pipelines/empty_state_spec.js @@ -0,0 +1,44 @@ +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import EmptyState from '~/ide/components/pipelines/empty_state.vue'; +import { createStore } from '~/ide/stores'; + +const TEST_PIPELINES_EMPTY_STATE_SVG_PATH = 'illustrations/test/pipelines.svg'; + +describe('~/ide/components/pipelines/empty_state.vue', () => { + let store; + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(EmptyState, { + store, + }); + }; + + beforeEach(() => { + store = createStore(); + store.dispatch('setEmptyStateSvgs', { + pipelinesEmptyStateSvgPath: TEST_PIPELINES_EMPTY_STATE_SVG_PATH, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders empty state', () => { + expect(wrapper.find(GlEmptyState).props()).toMatchObject({ + title: EmptyState.i18n.title, + description: EmptyState.i18n.description, + primaryButtonText: EmptyState.i18n.primaryButtonText, + primaryButtonLink: '/help/ci/quick_start/index.md', + svgPath: TEST_PIPELINES_EMPTY_STATE_SVG_PATH, + }); + }); + }); +}); diff --git a/spec/frontend/ide/components/pipelines/list_spec.js b/spec/frontend/ide/components/pipelines/list_spec.js index a917f4c0230..8a3606e27eb 100644 --- a/spec/frontend/ide/components/pipelines/list_spec.js +++ b/spec/frontend/ide/components/pipelines/list_spec.js @@ -2,10 +2,10 @@ import { GlLoadingIcon, GlTab } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; -import { TEST_HOST } from 'helpers/test_constants'; import { pipelines } from 'jest/ide/mock_data'; import JobsList from '~/ide/components/jobs/list.vue'; import List from '~/ide/components/pipelines/list.vue'; +import EmptyState from '~/ide/components/pipelines/empty_state.vue'; import IDEServices from '~/ide/services'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; @@ -18,9 +18,6 @@ jest.mock('~/ide/services', () => ({ describe('IDE pipelines list', () => { let wrapper; - const defaultState = { - pipelinesEmptyStateSvgPath: TEST_HOST, - }; const defaultPipelinesState = { stages: [], failedStages: [], @@ -38,7 +35,6 @@ describe('IDE pipelines list', () => { currentProject: () => ({ web_url: 'some/url ', path_with_namespace: fakeProjectPath }), }, state: { - ...defaultState, ...rootState, }, modules: { @@ -131,6 +127,8 @@ describe('IDE pipelines list', () => { it('renders empty state when no latestPipeline', () => { createComponent({}, { ...defaultPipelinesLoadedState, latestPipeline: null }); + + expect(wrapper.find(EmptyState).exists()).toBe(true); expect(wrapper.element).toMatchSnapshot(); }); diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index c2212eea849..c957c64aa10 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -9,7 +9,7 @@ import waitUsingRealTimer from 'helpers/wait_using_real_timer'; import { exampleConfigs, exampleFiles } from 'jest/ide/lib/editorconfig/mock_data'; import { EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN } from '~/editor/constants'; import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; -import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext'; +import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext'; import SourceEditor from '~/editor/source_editor'; import RepoEditor from '~/ide/components/repo_editor.vue'; import { @@ -23,6 +23,8 @@ import service from '~/ide/services'; import { createStoreOptions } from '~/ide/stores'; import axios from '~/lib/utils/axios_utils'; import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; +import SourceEditorInstance from '~/editor/source_editor_instance'; +import { spyOnApi } from 'jest/editor/helpers'; import { file } from '../helpers'; const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown'; @@ -101,6 +103,7 @@ describe('RepoEditor', () => { let createDiffInstanceSpy; let createModelSpy; let applyExtensionSpy; + let extensionsStore; const waitForEditorSetup = () => new Promise((resolve) => { @@ -120,6 +123,7 @@ describe('RepoEditor', () => { }); await waitForPromises(); vm = wrapper.vm; + extensionsStore = wrapper.vm.globalEditor.extensionsStore; jest.spyOn(vm, 'getFileData').mockResolvedValue(); jest.spyOn(vm, 'getRawFileData').mockResolvedValue(); }; @@ -127,28 +131,12 @@ describe('RepoEditor', () => { const findEditor = () => wrapper.find('[data-testid="editor-container"]'); const findTabs = () => wrapper.findAll('.ide-mode-tabs .nav-links li'); const findPreviewTab = () => wrapper.find('[data-testid="preview-tab"]'); - const expectEditorMarkdownExtension = (shouldHaveExtension) => { - if (shouldHaveExtension) { - expect(applyExtensionSpy).toHaveBeenCalledWith( - wrapper.vm.editor, - expect.any(EditorMarkdownExtension), - ); - // TODO: spying on extensions causes Jest to blow up, so we have to assert on - // the public property the extension adds, as opposed to the args passed to the ctor - expect(wrapper.vm.editor.previewMarkdownPath).toBe(PREVIEW_MARKDOWN_PATH); - } else { - expect(applyExtensionSpy).not.toHaveBeenCalledWith( - wrapper.vm.editor, - expect.any(EditorMarkdownExtension), - ); - } - }; beforeEach(() => { createInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_CODE_INSTANCE_FN); createDiffInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_DIFF_INSTANCE_FN); createModelSpy = jest.spyOn(monacoEditor, 'createModel'); - applyExtensionSpy = jest.spyOn(SourceEditor, 'instanceApplyExtension'); + applyExtensionSpy = jest.spyOn(SourceEditorInstance.prototype, 'use'); jest.spyOn(service, 'getFileData').mockResolvedValue(); jest.spyOn(service, 'getRawFileData').mockResolvedValue(); }); @@ -275,14 +263,13 @@ describe('RepoEditor', () => { ); it('installs the WebIDE extension', async () => { - const extensionSpy = jest.spyOn(SourceEditor, 'instanceApplyExtension'); await createComponent(); - expect(extensionSpy).toHaveBeenCalled(); - Reflect.ownKeys(EditorWebIdeExtension.prototype) - .filter((fn) => fn !== 'constructor') - .forEach((fn) => { - expect(vm.editor[fn]).toBe(EditorWebIdeExtension.prototype[fn]); - }); + expect(applyExtensionSpy).toHaveBeenCalled(); + const ideExtensionApi = extensionsStore.get('EditorWebIde').api; + Reflect.ownKeys(ideExtensionApi).forEach((fn) => { + expect(vm.editor[fn]).toBeDefined(); + expect(vm.editor.methods[fn]).toBe('EditorWebIde'); + }); }); it.each` @@ -301,7 +288,20 @@ describe('RepoEditor', () => { async ({ activeFile, viewer, shouldHaveMarkdownExtension } = {}) => { await createComponent({ state: { viewer }, activeFile }); - expectEditorMarkdownExtension(shouldHaveMarkdownExtension); + if (shouldHaveMarkdownExtension) { + expect(applyExtensionSpy).toHaveBeenCalledWith({ + definition: EditorMarkdownPreviewExtension, + setupOptions: { previewMarkdownPath: PREVIEW_MARKDOWN_PATH }, + }); + // TODO: spying on extensions causes Jest to blow up, so we have to assert on + // the public property the extension adds, as opposed to the args passed to the ctor + expect(wrapper.vm.editor.markdownPreview.path).toBe(PREVIEW_MARKDOWN_PATH); + } else { + expect(applyExtensionSpy).not.toHaveBeenCalledWith( + wrapper.vm.editor, + expect.any(EditorMarkdownExtension), + ); + } }, ); }); @@ -329,18 +329,6 @@ describe('RepoEditor', () => { expect(vm.model).toBe(existingModel); }); - it('adds callback methods', () => { - jest.spyOn(vm.editor, 'onPositionChange'); - jest.spyOn(vm.model, 'onChange'); - jest.spyOn(vm.model, 'updateOptions'); - - vm.setupEditor(); - - expect(vm.editor.onPositionChange).toHaveBeenCalledTimes(1); - expect(vm.model.onChange).toHaveBeenCalledTimes(1); - expect(vm.model.updateOptions).toHaveBeenCalledWith(vm.rules); - }); - it('updates state with the value of the model', () => { const newContent = 'As Gregor Samsa\n awoke one morning\n'; vm.model.setValue(newContent); @@ -366,53 +354,48 @@ describe('RepoEditor', () => { describe('editor updateDimensions', () => { let updateDimensionsSpy; - let updateDiffViewSpy; beforeEach(async () => { await createComponent(); - updateDimensionsSpy = jest.spyOn(vm.editor, 'updateDimensions'); - updateDiffViewSpy = jest.spyOn(vm.editor, 'updateDiffView').mockImplementation(); + const ext = extensionsStore.get('EditorWebIde'); + updateDimensionsSpy = jest.fn(); + spyOnApi(ext, { + updateDimensions: updateDimensionsSpy, + }); }); it('calls updateDimensions only when panelResizing is false', async () => { expect(updateDimensionsSpy).not.toHaveBeenCalled(); - expect(updateDiffViewSpy).not.toHaveBeenCalled(); expect(vm.$store.state.panelResizing).toBe(false); // default value vm.$store.state.panelResizing = true; await vm.$nextTick(); expect(updateDimensionsSpy).not.toHaveBeenCalled(); - expect(updateDiffViewSpy).not.toHaveBeenCalled(); vm.$store.state.panelResizing = false; await vm.$nextTick(); expect(updateDimensionsSpy).toHaveBeenCalledTimes(1); - expect(updateDiffViewSpy).toHaveBeenCalledTimes(1); vm.$store.state.panelResizing = true; await vm.$nextTick(); expect(updateDimensionsSpy).toHaveBeenCalledTimes(1); - expect(updateDiffViewSpy).toHaveBeenCalledTimes(1); }); it('calls updateDimensions when rightPane is toggled', async () => { expect(updateDimensionsSpy).not.toHaveBeenCalled(); - expect(updateDiffViewSpy).not.toHaveBeenCalled(); expect(vm.$store.state.rightPane.isOpen).toBe(false); // default value vm.$store.state.rightPane.isOpen = true; await vm.$nextTick(); expect(updateDimensionsSpy).toHaveBeenCalledTimes(1); - expect(updateDiffViewSpy).toHaveBeenCalledTimes(1); vm.$store.state.rightPane.isOpen = false; await vm.$nextTick(); expect(updateDimensionsSpy).toHaveBeenCalledTimes(2); - expect(updateDiffViewSpy).toHaveBeenCalledTimes(2); }); }); @@ -447,7 +430,11 @@ describe('RepoEditor', () => { activeFile: dummyFile.markdown, }); - updateDimensionsSpy = jest.spyOn(vm.editor, 'updateDimensions'); + const ext = extensionsStore.get('EditorWebIde'); + updateDimensionsSpy = jest.fn(); + spyOnApi(ext, { + updateDimensions: updateDimensionsSpy, + }); changeViewMode(FILE_VIEW_MODE_PREVIEW); await vm.$nextTick(); diff --git a/spec/frontend/ide/ide_router_spec.js b/spec/frontend/ide/ide_router_spec.js index 3fb7781b176..cd10812f8ea 100644 --- a/spec/frontend/ide/ide_router_spec.js +++ b/spec/frontend/ide/ide_router_spec.js @@ -6,6 +6,7 @@ describe('IDE router', () => { const PROJECT_NAMESPACE = 'my-group/sub-group'; const PROJECT_NAME = 'my-project'; const TEST_PATH = `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/merge_requests/2`; + const DEFAULT_BRANCH = 'default-main'; let store; let router; @@ -13,34 +14,46 @@ describe('IDE router', () => { beforeEach(() => { window.history.replaceState({}, '', '/'); store = createStore(); - router = createRouter(store); + router = createRouter(store, DEFAULT_BRANCH); jest.spyOn(store, 'dispatch').mockReturnValue(new Promise(() => {})); }); - [ - `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/main/-/src/blob/`, - `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/main/-/src/blob`, - `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/blob/-/src/blob`, - `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/main/-/src/tree/`, - `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/weird:branch/name-123/-/src/tree/`, - `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/main/-/src/blob`, - `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/main/-/src/edit`, - `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/main/-/src/merge_requests/2`, - `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/blob/-/src/blob`, - `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/edit/blob/-/src/blob`, - `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/merge_requests/2`, - `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/blob`, - `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/edit`, - `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}`, - ].forEach((route) => { - it(`finds project path when route is "${route}"`, () => { - router.push(route); - - expect(store.dispatch).toHaveBeenCalledWith('getProjectData', { - namespace: PROJECT_NAMESPACE, - projectId: PROJECT_NAME, - }); + it.each` + route | expectedBranchId | expectedBasePath + ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/main/-/src/blob/`} | ${'main'} | ${'src/blob/'} + ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/main/-/src/blob`} | ${'main'} | ${'src/blob'} + ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/blob/-/src/blob`} | ${'blob'} | ${'src/blob'} + ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/main/-/src/tree/`} | ${'main'} | ${'src/tree/'} + ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/weird:branch/name-123/-/src/tree/`} | ${'weird:branch/name-123'} | ${'src/tree/'} + ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/main/-/src/blob`} | ${'main'} | ${'src/blob'} + ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/main/-/src/edit`} | ${'main'} | ${'src/edit'} + ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/main/-/src/merge_requests/2`} | ${'main'} | ${'src/merge_requests/2'} + ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/blob/-/src/blob`} | ${'blob'} | ${'src/blob'} + ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/edit/blob/-/src/blob`} | ${'blob'} | ${'src/blob'} + ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/blob`} | ${'blob'} | ${''} + ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/edit`} | ${DEFAULT_BRANCH} | ${''} + ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}`} | ${DEFAULT_BRANCH} | ${''} + `('correctly opens Web IDE for $route', ({ route, expectedBranchId, expectedBasePath } = {}) => { + router.push(route); + + expect(store.dispatch).toHaveBeenCalledWith('openBranch', { + projectId: `${PROJECT_NAMESPACE}/${PROJECT_NAME}`, + branchId: expectedBranchId, + basePath: expectedBasePath, + }); + }); + + it('correctly opens an MR', () => { + const expectedId = '2'; + + router.push(`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/merge_requests/${expectedId}`); + + expect(store.dispatch).toHaveBeenCalledWith('openMergeRequest', { + projectId: `${PROJECT_NAMESPACE}/${PROJECT_NAME}`, + mergeRequestId: expectedId, + targetProjectId: undefined, }); + expect(store.dispatch).not.toHaveBeenCalledWith('openBranch'); }); it('keeps router in sync when store changes', async () => { diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js index eacf1244d55..0fab828dfb3 100644 --- a/spec/frontend/ide/services/index_spec.js +++ b/spec/frontend/ide/services/index_spec.js @@ -6,7 +6,7 @@ import dismissUserCallout from '~/graphql_shared/mutations/dismiss_user_callout. import services from '~/ide/services'; import { query, mutate } from '~/ide/services/gql'; import { escapeFileUrl } from '~/lib/utils/url_utility'; -import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.graphql'; +import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.query.graphql'; import { projectData } from '../mock_data'; jest.mock('~/api'); @@ -216,29 +216,6 @@ describe('IDE services', () => { ); }); - describe('getProjectData', () => { - it('combines gql and API requests', () => { - const gqlProjectData = { - userPermissions: { - bogus: true, - }, - }; - Api.project.mockReturnValue(Promise.resolve({ data: { ...projectData } })); - query.mockReturnValue(Promise.resolve({ data: { project: gqlProjectData } })); - - return services.getProjectData(TEST_NAMESPACE, TEST_PROJECT).then((response) => { - expect(response).toEqual({ data: { ...projectData, ...gqlProjectData } }); - expect(Api.project).toHaveBeenCalledWith(TEST_PROJECT_ID); - expect(query).toHaveBeenCalledWith({ - query: getIdeProject, - variables: { - projectPath: TEST_PROJECT_ID, - }, - }); - }); - }); - }); - describe('getFiles', () => { let mock; let relativeUrlRoot; @@ -330,4 +307,38 @@ describe('IDE services', () => { }); }); }); + + describe('getProjectPermissionsData', () => { + const TEST_PROJECT_PATH = 'foo/bar'; + + it('queries for the project permissions', () => { + const result = { data: { project: projectData } }; + query.mockResolvedValue(result); + + return services.getProjectPermissionsData(TEST_PROJECT_PATH).then((data) => { + expect(data).toEqual(result.data.project); + expect(query).toHaveBeenCalledWith( + expect.objectContaining({ + query: getIdeProject, + variables: { projectPath: TEST_PROJECT_PATH }, + }), + ); + }); + }); + + it('converts the returned GraphQL id to the regular ID number', () => { + const projectId = 2; + const gqlProjectData = { + id: `gid://gitlab/Project/${projectId}`, + userPermissions: { + bogus: true, + }, + }; + + query.mockResolvedValue({ data: { project: gqlProjectData } }); + return services.getProjectPermissionsData(TEST_PROJECT_PATH).then((data) => { + expect(data.id).toBe(projectId); + }); + }); + }); }); diff --git a/spec/frontend/ide/stores/actions/project_spec.js b/spec/frontend/ide/stores/actions/project_spec.js index ca6f7169059..e07dcf22860 100644 --- a/spec/frontend/ide/stores/actions/project_spec.js +++ b/spec/frontend/ide/stores/actions/project_spec.js @@ -2,9 +2,12 @@ import MockAdapter from 'axios-mock-adapter'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import testAction from 'helpers/vuex_action_helper'; import api from '~/api'; +import createFlash from '~/flash'; import service from '~/ide/services'; import { createStore } from '~/ide/stores'; import { + setProject, + fetchProjectPermissions, refreshLastCommitData, showBranchNotFoundError, createNewBranchFromDefault, @@ -13,8 +16,12 @@ import { loadFile, loadBranch, } from '~/ide/stores/actions'; +import { logError } from '~/lib/logger'; import axios from '~/lib/utils/axios_utils'; +jest.mock('~/flash'); +jest.mock('~/lib/logger'); + const TEST_PROJECT_ID = 'abc/def'; describe('IDE store project actions', () => { @@ -34,6 +41,92 @@ describe('IDE store project actions', () => { mock.restore(); }); + describe('setProject', () => { + const project = { id: 'foo', path_with_namespace: TEST_PROJECT_ID }; + const baseMutations = [ + { + type: 'SET_PROJECT', + payload: { + projectPath: TEST_PROJECT_ID, + project, + }, + }, + { + type: 'SET_CURRENT_PROJECT', + payload: TEST_PROJECT_ID, + }, + ]; + + it.each` + desc | payload | expectedMutations + ${'does not commit any action if project is not passed'} | ${undefined} | ${[]} + ${'commits correct actions in the correct order by default'} | ${{ project }} | ${[...baseMutations]} + `('$desc', async ({ payload, expectedMutations } = {}) => { + await testAction({ + action: setProject, + payload, + state: store.state, + expectedMutations, + expectedActions: [], + }); + }); + }); + + describe('fetchProjectPermissions', () => { + const permissionsData = { + userPermissions: { + bogus: true, + }, + }; + const permissionsMutations = [ + { + type: 'UPDATE_PROJECT', + payload: { + projectPath: TEST_PROJECT_ID, + props: { + ...permissionsData, + }, + }, + }, + ]; + + let spy; + + beforeEach(() => { + spy = jest.spyOn(service, 'getProjectPermissionsData'); + }); + + afterEach(() => { + createFlash.mockRestore(); + }); + + it.each` + desc | projectPath | responseSuccess | expectedMutations + ${'does not fetch permissions if project does not exist'} | ${undefined} | ${true} | ${[]} + ${'fetches permission when project is specified'} | ${TEST_PROJECT_ID} | ${true} | ${[...permissionsMutations]} + ${'flashes an error if the request fails'} | ${TEST_PROJECT_ID} | ${false} | ${[]} + `('$desc', async ({ projectPath, expectedMutations, responseSuccess } = {}) => { + store.state.currentProjectId = projectPath; + if (responseSuccess) { + spy.mockResolvedValue(permissionsData); + } else { + spy.mockRejectedValue(); + } + + await testAction({ + action: fetchProjectPermissions, + state: store.state, + expectedMutations, + expectedActions: [], + }); + + if (!responseSuccess) { + expect(logError).toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalled(); + } + }); + }); + describe('refreshLastCommitData', () => { beforeEach(() => { store.state.currentProjectId = 'abc/def'; diff --git a/spec/frontend/ide/stores/mutations/project_spec.js b/spec/frontend/ide/stores/mutations/project_spec.js index b3ce39c33d2..0fdd7798f00 100644 --- a/spec/frontend/ide/stores/mutations/project_spec.js +++ b/spec/frontend/ide/stores/mutations/project_spec.js @@ -3,21 +3,48 @@ import state from '~/ide/stores/state'; describe('Multi-file store branch mutations', () => { let localState; + const nonExistentProj = 'nonexistent'; + const existingProj = 'abcproject'; beforeEach(() => { localState = state(); - localState.projects = { abcproject: { empty_repo: true } }; + localState.projects = { [existingProj]: { empty_repo: true } }; }); describe('TOGGLE_EMPTY_STATE', () => { it('sets empty_repo for project to passed value', () => { - mutations.TOGGLE_EMPTY_STATE(localState, { projectPath: 'abcproject', value: false }); + mutations.TOGGLE_EMPTY_STATE(localState, { projectPath: existingProj, value: false }); - expect(localState.projects.abcproject.empty_repo).toBe(false); + expect(localState.projects[existingProj].empty_repo).toBe(false); - mutations.TOGGLE_EMPTY_STATE(localState, { projectPath: 'abcproject', value: true }); + mutations.TOGGLE_EMPTY_STATE(localState, { projectPath: existingProj, value: true }); - expect(localState.projects.abcproject.empty_repo).toBe(true); + expect(localState.projects[existingProj].empty_repo).toBe(true); + }); + }); + + describe('UPDATE_PROJECT', () => { + it.each` + desc | projectPath | props | expectedProps + ${'extends existing project with the passed props'} | ${existingProj} | ${{ foo1: 'bar' }} | ${{ foo1: 'bar' }} + ${'overrides existing props on the exsiting project'} | ${existingProj} | ${{ empty_repo: false }} | ${{ empty_repo: false }} + ${'does nothing if the project does not exist'} | ${nonExistentProj} | ${{ foo2: 'bar' }} | ${undefined} + ${'does nothing if project is not passed'} | ${undefined} | ${{ foo3: 'bar' }} | ${undefined} + ${'does nothing if the props are not passed'} | ${existingProj} | ${undefined} | ${{}} + ${'does nothing if the props are empty'} | ${existingProj} | ${{}} | ${{}} + `('$desc', ({ projectPath, props, expectedProps } = {}) => { + const origProject = localState.projects[projectPath]; + + mutations.UPDATE_PROJECT(localState, { projectPath, props }); + + if (!expectedProps) { + expect(localState.projects[projectPath]).toBeUndefined(); + } else { + expect(localState.projects[projectPath]).toEqual({ + ...origProject, + ...expectedProps, + }); + } }); }); }); 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 6e3df21e30a..b17ff2e0f52 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 @@ -1,4 +1,4 @@ -import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; +import { GlAlert, GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; @@ -33,13 +33,23 @@ describe('import table', () => { generateFakeEntry({ id: 2, status: STATUSES.FINISHED }), ]; const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 }; + const FAKE_VERSION_VALIDATION = { + features: { + projectMigration: { available: false, minVersion: '14.8.0' }, + sourceInstanceVersion: '14.6.0', + }, + }; const findImportSelectedButton = () => wrapper.findAll('button').wrappers.find((w) => w.text() === 'Import selected'); const findImportButtons = () => wrapper.findAll('button').wrappers.filter((w) => w.text() === 'Import'); - const findPaginationDropdown = () => wrapper.find('[aria-label="Page size"]'); + const findPaginationDropdown = () => wrapper.find('[data-testid="page-size"]'); const findPaginationDropdownText = () => findPaginationDropdown().find('button').text(); + const findSelectionCount = () => wrapper.find('[data-test-id="selection-count"]'); + + const triggerSelectAllCheckbox = () => + wrapper.find('thead input[type=checkbox]').trigger('click'); const selectRow = (idx) => wrapper.findAll('tbody td input[type=checkbox]').at(idx).trigger('click'); @@ -104,6 +114,7 @@ describe('import table', () => { bulkImportSourceGroups: () => ({ nodes: [], pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, }), }); await waitForPromises(); @@ -117,6 +128,7 @@ describe('import table', () => { bulkImportSourceGroups: () => ({ nodes: FAKE_GROUPS, pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, }), }); await waitForPromises(); @@ -129,6 +141,7 @@ describe('import table', () => { bulkImportSourceGroups: jest.fn().mockResolvedValue({ nodes: [], pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, }), }); await waitForPromises(); @@ -138,7 +151,11 @@ describe('import table', () => { it('invokes importGroups mutation when row button is clicked', async () => { createComponent({ - bulkImportSourceGroups: () => ({ nodes: [FAKE_GROUP], pageInfo: FAKE_PAGE_INFO }), + bulkImportSourceGroups: () => ({ + nodes: [FAKE_GROUP], + pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, + }), }); jest.spyOn(apolloProvider.defaultClient, 'mutate'); @@ -162,7 +179,11 @@ describe('import table', () => { it('displays error if importing group fails', async () => { createComponent({ - bulkImportSourceGroups: () => ({ nodes: [FAKE_GROUP], pageInfo: FAKE_PAGE_INFO }), + bulkImportSourceGroups: () => ({ + nodes: [FAKE_GROUP], + pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, + }), importGroups: () => { throw new Error(); }, @@ -182,9 +203,11 @@ describe('import table', () => { }); describe('pagination', () => { - const bulkImportSourceGroupsQueryMock = jest - .fn() - .mockResolvedValue({ nodes: [FAKE_GROUP], pageInfo: FAKE_PAGE_INFO }); + const bulkImportSourceGroupsQueryMock = jest.fn().mockResolvedValue({ + nodes: [FAKE_GROUP], + pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, + }); beforeEach(() => { createComponent({ @@ -205,7 +228,13 @@ describe('import table', () => { const otherOption = findPaginationDropdown().findAll('li p').at(1); expect(otherOption.text()).toMatchInterpolatedText('50 items per page'); + bulkImportSourceGroupsQueryMock.mockResolvedValue({ + nodes: [FAKE_GROUP], + pageInfo: { ...FAKE_PAGE_INFO, perPage: 50 }, + versionValidation: FAKE_VERSION_VALIDATION, + }); await otherOption.trigger('click'); + await waitForPromises(); expect(findPaginationDropdownText()).toMatchInterpolatedText('50 items per page'); @@ -234,6 +263,7 @@ describe('import table', () => { perPage: 20, totalPages: 2, }, + versionValidation: FAKE_VERSION_VALIDATION, }); wrapper.find(PaginationLinks).props().change(REQUESTED_PAGE); await waitForPromises(); @@ -243,9 +273,11 @@ describe('import table', () => { }); describe('filters', () => { - const bulkImportSourceGroupsQueryMock = jest - .fn() - .mockResolvedValue({ nodes: [FAKE_GROUP], pageInfo: FAKE_PAGE_INFO }); + const bulkImportSourceGroupsQueryMock = jest.fn().mockResolvedValue({ + nodes: [FAKE_GROUP], + pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, + }); beforeEach(() => { createComponent({ @@ -313,11 +345,28 @@ describe('import table', () => { }); describe('bulk operations', () => { + it('import all button correctly selects/deselects all groups', async () => { + createComponent({ + bulkImportSourceGroups: () => ({ + nodes: FAKE_GROUPS, + pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, + }), + }); + await waitForPromises(); + expect(findSelectionCount().text()).toMatchInterpolatedText('0 selected'); + await triggerSelectAllCheckbox(); + expect(findSelectionCount().text()).toMatchInterpolatedText('2 selected'); + await triggerSelectAllCheckbox(); + expect(findSelectionCount().text()).toMatchInterpolatedText('0 selected'); + }); + it('import selected button is disabled when no groups selected', async () => { createComponent({ bulkImportSourceGroups: () => ({ nodes: FAKE_GROUPS, pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, }), }); await waitForPromises(); @@ -330,6 +379,7 @@ describe('import table', () => { bulkImportSourceGroups: () => ({ nodes: FAKE_GROUPS, pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, }), }); await waitForPromises(); @@ -346,6 +396,7 @@ describe('import table', () => { bulkImportSourceGroups: () => ({ nodes: NEW_GROUPS, pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, }), }); await waitForPromises(); @@ -368,6 +419,7 @@ describe('import table', () => { bulkImportSourceGroups: () => ({ nodes: NEW_GROUPS, pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, }), }); await waitForPromises(); @@ -391,6 +443,7 @@ describe('import table', () => { bulkImportSourceGroups: () => ({ nodes: NEW_GROUPS, pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, }), }); jest.spyOn(apolloProvider.defaultClient, 'mutate'); @@ -421,4 +474,38 @@ describe('import table', () => { }); }); }); + + describe('unavailable features warning', () => { + it('renders alert when there are unavailable features', async () => { + createComponent({ + bulkImportSourceGroups: () => ({ + nodes: FAKE_GROUPS, + pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, + }), + }); + await waitForPromises(); + + expect(wrapper.find(GlAlert).exists()).toBe(true); + expect(wrapper.find(GlAlert).text()).toContain('projects (require v14.8.0)'); + }); + + it('does not renders alert when there are no unavailable features', async () => { + createComponent({ + bulkImportSourceGroups: () => ({ + nodes: FAKE_GROUPS, + pageInfo: FAKE_PAGE_INFO, + versionValidation: { + features: { + projectMigration: { available: true, minVersion: '14.8.0' }, + sourceInstanceVersion: '14.6.0', + }, + }, + }), + }); + await waitForPromises(); + + expect(wrapper.find(GlAlert).exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js b/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js index 3c2367e22f5..d3f86672f33 100644 --- a/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js @@ -123,13 +123,22 @@ describe('import target cell', () => { }); describe('when entity is available for import', () => { + const FAKE_PROGRESS_MESSAGE = 'progress message'; beforeEach(() => { - group = generateFakeTableEntry({ id: 1, flags: { isAvailableForImport: true } }); + group = generateFakeTableEntry({ + id: 1, + flags: { isAvailableForImport: true }, + progress: { message: FAKE_PROGRESS_MESSAGE }, + }); createComponent({ group }); }); it('renders namespace dropdown as enabled', () => { expect(findNamespaceDropdown().attributes('disabled')).toBe(undefined); }); + + it('renders progress message as error if it exists', () => { + expect(wrapper.find('[role=alert]').text()).toBe(FAKE_PROGRESS_MESSAGE); + }); }); }); 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 f3447494578..c6ddce17fe4 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 @@ -163,12 +163,34 @@ describe('Bulk import resolvers', () => { }); describe('mutations', () => { - beforeEach(() => { - axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK, { id: 1 }); - }); + beforeEach(() => {}); describe('importGroup', () => { - it('sets import status to CREATED when request completes', async () => { + it('sets import status to CREATED for successful groups when request completes', async () => { + axiosMockAdapter + .onPost(FAKE_ENDPOINTS.createBulkImport) + .reply(httpStatus.OK, [{ success: true, id: 1 }]); + + await client.mutate({ + mutation: importGroupsMutation, + variables: { + importRequests: [ + { + sourceGroupId: statusEndpointFixture.importable_data[0].id, + newName: 'test', + targetNamespace: 'root', + }, + ], + }, + }); + + await axios.waitForAll(); + expect(results[0].progress.status).toBe(STATUSES.CREATED); + }); + + it('sets import status to CREATED for successful groups when request completes with legacy response', async () => { + axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK, { id: 1 }); + await client.mutate({ mutation: importGroupsMutation, variables: { @@ -185,9 +207,37 @@ describe('Bulk import resolvers', () => { await axios.waitForAll(); expect(results[0].progress.status).toBe(STATUSES.CREATED); }); + + it('sets import status to FAILED and sets progress message for failed groups when request completes', async () => { + const FAKE_ERROR_MESSAGE = 'foo'; + axiosMockAdapter + .onPost(FAKE_ENDPOINTS.createBulkImport) + .reply(httpStatus.OK, [{ success: false, id: 1, message: FAKE_ERROR_MESSAGE }]); + + await client.mutate({ + mutation: importGroupsMutation, + variables: { + importRequests: [ + { + sourceGroupId: statusEndpointFixture.importable_data[0].id, + newName: 'test', + targetNamespace: 'root', + }, + ], + }, + }); + + await axios.waitForAll(); + expect(results[0].progress.status).toBe(STATUSES.FAILED); + expect(results[0].progress.message).toBe(FAKE_ERROR_MESSAGE); + }); }); it('updateImportStatus updates status', async () => { + axiosMockAdapter + .onPost(FAKE_ENDPOINTS.createBulkImport) + .reply(httpStatus.OK, [{ success: true, id: 1 }]); + const NEW_STATUS = 'dummy'; await client.mutate({ mutation: importGroupsMutation, @@ -216,6 +266,7 @@ describe('Bulk import resolvers', () => { expect(statusInResponse).toStrictEqual({ __typename: clientTypenames.BulkImportProgress, id, + message: null, status: NEW_STATUS, }); }); diff --git a/spec/frontend/import_entities/import_groups/graphql/fixtures.js b/spec/frontend/import_entities/import_groups/graphql/fixtures.js index 5f6f9987a8f..ed4e343f331 100644 --- a/spec/frontend/import_entities/import_groups/graphql/fixtures.js +++ b/spec/frontend/import_entities/import_groups/graphql/fixtures.js @@ -1,7 +1,7 @@ import { STATUSES } from '~/import_entities/constants'; import { clientTypenames } from '~/import_entities/import_groups/graphql/client_factory'; -export const generateFakeEntry = ({ id, status, ...rest }) => ({ +export const generateFakeEntry = ({ id, status, message, ...rest }) => ({ __typename: clientTypenames.BulkImportSourceGroup, webUrl: `https://fake.host/${id}`, fullPath: `fake_group_${id}`, @@ -18,6 +18,7 @@ export const generateFakeEntry = ({ id, status, ...rest }) => ({ : { id, status, + message: message || '', }, ...rest, }); @@ -49,6 +50,12 @@ export const statusEndpointFixture = { web_url: 'https://gitlab.com/groups/gitlab-examples', }, ], + version_validation: { + features: { + project_migration: { available: false, min_version: '14.8.0' }, + source_instance_version: '14.6.0', + }, + }, }; export const availableNamespacesFixture = Object.freeze([ diff --git a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap index 2a976c04319..feee14c9c40 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap +++ b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap @@ -14,6 +14,7 @@ exports[`Alert integration settings form should match the default snapshot 1`] = <gl-form-group-stub class="col-8 col-md-9 gl-p-0" labeldescription="" + optionaltext="(optional)" > <gl-toggle-stub id="active" @@ -28,10 +29,12 @@ exports[`Alert integration settings form should match the default snapshot 1`] = label="Webhook URL" label-for="url" labeldescription="" + optionaltext="(optional)" > <gl-form-input-group-stub data-testid="webhook-url" id="url" + inputclass="" predefinedoptions="[object Object]" readonly="" value="pagerduty.webhook.com" diff --git a/spec/frontend/integrations/edit/components/active_checkbox_spec.js b/spec/frontend/integrations/edit/components/active_checkbox_spec.js index df7ffd19747..0dc31616166 100644 --- a/spec/frontend/integrations/edit/components/active_checkbox_spec.js +++ b/spec/frontend/integrations/edit/components/active_checkbox_spec.js @@ -34,16 +34,22 @@ describe('ActiveCheckbox', () => { }); }); - describe('initialActivated is false', () => { - it('renders GlFormCheckbox as unchecked', () => { + describe('initialActivated is `false`', () => { + beforeEach(() => { createComponent({ initialActivated: false, }); + }); + it('renders GlFormCheckbox as unchecked', () => { expect(findGlFormCheckbox().exists()).toBe(true); expect(findGlFormCheckbox().vm.$attrs.checked).toBe(false); expect(findInputInCheckbox().attributes('disabled')).toBeUndefined(); }); + + it('emits `toggle-integration-active` event with `false` on mount', () => { + expect(wrapper.emitted('toggle-integration-active')[0]).toEqual([false]); + }); }); describe('initialActivated is true', () => { @@ -63,10 +69,21 @@ describe('ActiveCheckbox', () => { findInputInCheckbox().trigger('click'); await wrapper.vm.$nextTick(); - expect(findGlFormCheckbox().vm.$attrs.checked).toBe(false); }); }); + + it('emits `toggle-integration-active` event with `true` on mount', () => { + expect(wrapper.emitted('toggle-integration-active')[0]).toEqual([true]); + }); + + describe('on checkbox `change` event', () => { + it('emits `toggle-integration-active` event', () => { + findGlFormCheckbox().vm.$emit('change', false); + + expect(wrapper.emitted('toggle-integration-active')[1]).toEqual([false]); + }); + }); }); }); }); diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index 0a9cbadb249..4c1394f3a87 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -1,6 +1,8 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import * as Sentry from '@sentry/browser'; import { setHTMLFixture } from 'helpers/fixtures'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; - import { mockIntegrationProps } from 'jest/integrations/edit/mock_data'; import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue'; import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue'; @@ -11,11 +13,27 @@ import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_field import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue'; import ResetConfirmationModal from '~/integrations/edit/components/reset_confirmation_modal.vue'; import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; -import { integrationLevels } from '~/integrations/constants'; +import waitForPromises from 'helpers/wait_for_promises'; +import { + integrationLevels, + I18N_SUCCESSFUL_CONNECTION_MESSAGE, + VALIDATE_INTEGRATION_FORM_EVENT, + I18N_DEFAULT_ERROR_MESSAGE, +} from '~/integrations/constants'; import { createStore } from '~/integrations/edit/store'; +import eventHub from '~/integrations/edit/event_hub'; +import httpStatus from '~/lib/utils/http_status'; + +jest.mock('~/integrations/edit/event_hub'); +jest.mock('@sentry/browser'); describe('IntegrationForm', () => { + const mockToastShow = jest.fn(); + let wrapper; + let dispatch; + let mockAxios; + let mockForm; const createComponent = ({ customStateProps = {}, @@ -23,12 +41,18 @@ describe('IntegrationForm', () => { initialState = {}, props = {}, } = {}) => { + const store = createStore({ + customState: { ...mockIntegrationProps, ...customStateProps }, + ...initialState, + }); + dispatch = jest.spyOn(store, 'dispatch').mockImplementation(); + wrapper = shallowMountExtended(IntegrationForm, { - propsData: { ...props }, - store: createStore({ - customState: { ...mockIntegrationProps, ...customStateProps }, - ...initialState, - }), + propsData: { ...props, formSelector: '.test' }, + provide: { + glFeatures: featureFlags, + }, + store, stubs: { OverrideDropdown, ActiveCheckbox, @@ -36,46 +60,42 @@ describe('IntegrationForm', () => { JiraTriggerFields, TriggerFields, }, - provide: { - glFeatures: featureFlags, + mocks: { + $toast: { + show: mockToastShow, + }, }, }); }; - afterEach(() => { - wrapper.destroy(); - }); + const createForm = ({ isValid = true } = {}) => { + mockForm = document.createElement('form'); + jest.spyOn(document, 'querySelector').mockReturnValue(mockForm); + jest.spyOn(mockForm, 'checkValidity').mockReturnValue(isValid); + jest.spyOn(mockForm, 'submit'); + }; const findOverrideDropdown = () => wrapper.findComponent(OverrideDropdown); const findActiveCheckbox = () => wrapper.findComponent(ActiveCheckbox); const findConfirmationModal = () => wrapper.findComponent(ConfirmationModal); const findResetConfirmationModal = () => wrapper.findComponent(ResetConfirmationModal); const findResetButton = () => wrapper.findByTestId('reset-button'); + const findSaveButton = () => wrapper.findByTestId('save-button'); + const findTestButton = () => wrapper.findByTestId('test-button'); const findJiraTriggerFields = () => wrapper.findComponent(JiraTriggerFields); const findJiraIssuesFields = () => wrapper.findComponent(JiraIssuesFields); const findTriggerFields = () => wrapper.findComponent(TriggerFields); - describe('template', () => { - describe('showActive is true', () => { - it('renders ActiveCheckbox', () => { - createComponent(); - - expect(findActiveCheckbox().exists()).toBe(true); - }); - }); - - describe('showActive is false', () => { - it('does not render ActiveCheckbox', () => { - createComponent({ - customStateProps: { - showActive: false, - }, - }); + beforeEach(() => { + mockAxios = new MockAdapter(axios); + }); - expect(findActiveCheckbox().exists()).toBe(false); - }); - }); + afterEach(() => { + wrapper.destroy(); + mockAxios.restore(); + }); + describe('template', () => { describe('integrationLevel is instance', () => { it('renders ConfirmationModal', () => { createComponent({ @@ -195,13 +215,29 @@ describe('IntegrationForm', () => { }); describe('type is "jira"', () => { - it('renders JiraTriggerFields', () => { + beforeEach(() => { + jest.spyOn(document, 'querySelector').mockReturnValue(document.createElement('form')); + createComponent({ - customStateProps: { type: 'jira' }, + customStateProps: { type: 'jira', testPath: '/test' }, }); + }); + it('renders JiraTriggerFields', () => { expect(findJiraTriggerFields().exists()).toBe(true); }); + + it('renders JiraIssuesFields', () => { + expect(findJiraIssuesFields().exists()).toBe(true); + }); + + describe('when JiraIssueFields emits `request-jira-issue-types` event', () => { + it('dispatches `requestJiraIssueTypes` action', () => { + findJiraIssuesFields().vm.$emit('request-jira-issue-types'); + + expect(dispatch).toHaveBeenCalledWith('requestJiraIssueTypes', expect.any(FormData)); + }); + }); }); describe('triggerEvents is present', () => { @@ -303,4 +339,210 @@ describe('IntegrationForm', () => { }); }); }); + + describe('ActiveCheckbox', () => { + describe.each` + showActive + ${true} + ${false} + `('when `showActive` is $showActive', ({ showActive }) => { + it(`${showActive ? 'renders' : 'does not render'} ActiveCheckbox`, () => { + createComponent({ + customStateProps: { + showActive, + }, + }); + + expect(findActiveCheckbox().exists()).toBe(showActive); + }); + }); + + describe.each` + formActive | novalidate + ${true} | ${null} + ${false} | ${'true'} + `( + 'when `toggle-integration-active` is emitted with $formActive', + ({ formActive, novalidate }) => { + beforeEach(async () => { + createForm(); + createComponent({ + customStateProps: { + showActive: true, + initialActivated: false, + }, + }); + + await findActiveCheckbox().vm.$emit('toggle-integration-active', formActive); + }); + + it(`sets noValidate to ${novalidate}`, () => { + expect(mockForm.getAttribute('novalidate')).toBe(novalidate); + }); + }, + ); + }); + + describe('when `save` button is clicked', () => { + describe('buttons', () => { + beforeEach(async () => { + createForm(); + createComponent({ + customStateProps: { + showActive: true, + canTest: true, + initialActivated: true, + }, + }); + + await findSaveButton().vm.$emit('click', new Event('click')); + }); + + it('sets save button `loading` prop to `true`', () => { + expect(findSaveButton().props('loading')).toBe(true); + }); + + it('sets test button `disabled` prop to `true`', () => { + expect(findTestButton().props('disabled')).toBe(true); + }); + }); + + describe.each` + checkValidityReturn | integrationActive + ${true} | ${false} + ${true} | ${true} + ${false} | ${false} + `( + 'when form is valid (checkValidity returns $checkValidityReturn and integrationActive is $integrationActive)', + ({ integrationActive, checkValidityReturn }) => { + beforeEach(async () => { + createForm({ isValid: checkValidityReturn }); + createComponent({ + customStateProps: { + showActive: true, + canTest: true, + initialActivated: integrationActive, + }, + }); + + await findSaveButton().vm.$emit('click', new Event('click')); + }); + + it('submit form', () => { + expect(mockForm.submit).toHaveBeenCalledTimes(1); + }); + }, + ); + + describe('when form is invalid (checkValidity returns false and integrationActive is true)', () => { + beforeEach(async () => { + createForm({ isValid: false }); + createComponent({ + customStateProps: { + showActive: true, + canTest: true, + initialActivated: true, + }, + }); + + await findSaveButton().vm.$emit('click', new Event('click')); + }); + + it('does not submit form', () => { + expect(mockForm.submit).not.toHaveBeenCalled(); + }); + + it('sets save button `loading` prop to `false`', () => { + expect(findSaveButton().props('loading')).toBe(false); + }); + + it('sets test button `disabled` prop to `false`', () => { + expect(findTestButton().props('disabled')).toBe(false); + }); + + it('emits `VALIDATE_INTEGRATION_FORM_EVENT`', () => { + expect(eventHub.$emit).toHaveBeenCalledWith(VALIDATE_INTEGRATION_FORM_EVENT); + }); + }); + }); + + describe('when `test` button is clicked', () => { + describe('when form is invalid', () => { + it('emits `VALIDATE_INTEGRATION_FORM_EVENT` event to the event hub', () => { + createForm({ isValid: false }); + createComponent({ + customStateProps: { + showActive: true, + canTest: true, + }, + }); + + findTestButton().vm.$emit('click', new Event('click')); + + expect(eventHub.$emit).toHaveBeenCalledWith(VALIDATE_INTEGRATION_FORM_EVENT); + }); + }); + + describe('when form is valid', () => { + const mockTestPath = '/test'; + + beforeEach(() => { + createForm({ isValid: true }); + createComponent({ + customStateProps: { + showActive: true, + canTest: true, + testPath: mockTestPath, + }, + }); + }); + + describe('buttons', () => { + beforeEach(async () => { + await findTestButton().vm.$emit('click', new Event('click')); + }); + + it('sets test button `loading` prop to `true`', () => { + expect(findTestButton().props('loading')).toBe(true); + }); + + it('sets save button `disabled` prop to `true`', () => { + expect(findSaveButton().props('disabled')).toBe(true); + }); + }); + + describe.each` + scenario | replyStatus | errorMessage | expectToast | expectSentry + ${'when "test settings" request fails'} | ${httpStatus.INTERNAL_SERVER_ERROR} | ${undefined} | ${I18N_DEFAULT_ERROR_MESSAGE} | ${true} + ${'when "test settings" returns an error'} | ${httpStatus.OK} | ${'an error'} | ${'an error'} | ${false} + ${'when "test settings" succeeds'} | ${httpStatus.OK} | ${undefined} | ${I18N_SUCCESSFUL_CONNECTION_MESSAGE} | ${false} + `('$scenario', ({ replyStatus, errorMessage, expectToast, expectSentry }) => { + beforeEach(async () => { + mockAxios.onPut(mockTestPath).replyOnce(replyStatus, { + error: Boolean(errorMessage), + message: errorMessage, + }); + + await findTestButton().vm.$emit('click', new Event('click')); + await waitForPromises(); + }); + + it(`calls toast with '${expectToast}'`, () => { + expect(mockToastShow).toHaveBeenCalledWith(expectToast); + }); + + it('sets `loading` prop of test button to `false`', () => { + expect(findTestButton().props('loading')).toBe(false); + }); + + it('sets save button `disabled` prop to `false`', () => { + expect(findSaveButton().props('disabled')).toBe(false); + }); + + it(`${expectSentry ? 'does' : 'does not'} capture exception in Sentry`, () => { + expect(Sentry.captureException).toHaveBeenCalledTimes(expectSentry ? 1 : 0); + }); + }); + }); + }); }); diff --git a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js index 3a664b652ac..b5a8eed3598 100644 --- a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js @@ -1,10 +1,7 @@ import { GlFormCheckbox, GlFormInput } from '@gitlab/ui'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { - GET_JIRA_ISSUE_TYPES_EVENT, - VALIDATE_INTEGRATION_FORM_EVENT, -} from '~/integrations/constants'; +import { VALIDATE_INTEGRATION_FORM_EVENT } from '~/integrations/constants'; import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue'; import eventHub from '~/integrations/edit/event_hub'; import { createStore } from '~/integrations/edit/store'; @@ -216,13 +213,11 @@ describe('JiraIssuesFields', () => { ); }); - it('emits "getJiraIssueTypes" to the eventHub when the jira-vulnerabilities component requests to fetch issue types', async () => { - const eventHubEmitSpy = jest.spyOn(eventHub, '$emit'); - + it('emits "request-jira-issue-types` when the jira-vulnerabilities component requests to fetch issue types', async () => { await setEnableCheckbox(true); - await findJiraForVulnerabilities().vm.$emit('request-get-issue-types'); + await findJiraForVulnerabilities().vm.$emit('request-jira-issue-types'); - expect(eventHubEmitSpy).toHaveBeenCalledWith(GET_JIRA_ISSUE_TYPES_EVENT); + expect(wrapper.emitted('request-jira-issue-types')).toHaveLength(1); }); }); diff --git a/spec/frontend/integrations/edit/mock_data.js b/spec/frontend/integrations/edit/mock_data.js index 27ba0768331..3c45ed0fb1b 100644 --- a/spec/frontend/integrations/edit/mock_data.js +++ b/spec/frontend/integrations/edit/mock_data.js @@ -14,3 +14,9 @@ export const mockIntegrationProps = { type: '', inheritFromId: 25, }; + +export const mockJiraIssueTypes = [ + { id: '1', name: 'issue', description: 'issue' }, + { id: '2', name: 'bug', description: 'bug' }, + { id: '3', name: 'epic', description: 'epic' }, +]; diff --git a/spec/frontend/integrations/edit/store/actions_spec.js b/spec/frontend/integrations/edit/store/actions_spec.js index e2f4c138ece..b413de2b286 100644 --- a/spec/frontend/integrations/edit/store/actions_spec.js +++ b/spec/frontend/integrations/edit/store/actions_spec.js @@ -1,8 +1,9 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; +import { I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE } from '~/integrations/constants'; import { setOverride, - setIsSaving, - setIsTesting, setIsResetting, requestResetIntegration, receiveResetIntegrationSuccess, @@ -14,14 +15,21 @@ import { import * as types from '~/integrations/edit/store/mutation_types'; import createState from '~/integrations/edit/store/state'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import { mockJiraIssueTypes } from '../mock_data'; jest.mock('~/lib/utils/url_utility'); describe('Integration form store actions', () => { let state; + let mockAxios; beforeEach(() => { state = createState(); + mockAxios = new MockAdapter(axios); + }); + + afterEach(() => { + mockAxios.restore(); }); describe('setOverride', () => { @@ -30,18 +38,6 @@ describe('Integration form store actions', () => { }); }); - describe('setIsSaving', () => { - it('should commit isSaving mutation', () => { - return testAction(setIsSaving, true, state, [{ type: types.SET_IS_SAVING, payload: true }]); - }); - }); - - describe('setIsTesting', () => { - it('should commit isTesting mutation', () => { - return testAction(setIsTesting, true, state, [{ type: types.SET_IS_TESTING, payload: true }]); - }); - }); - describe('setIsResetting', () => { it('should commit isResetting mutation', () => { return testAction(setIsResetting, true, state, [ @@ -75,11 +71,28 @@ describe('Integration form store actions', () => { }); describe('requestJiraIssueTypes', () => { - it('should commit SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE and SET_IS_LOADING_JIRA_ISSUE_TYPES mutations', () => { - return testAction(requestJiraIssueTypes, null, state, [ - { type: types.SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE, payload: '' }, - { type: types.SET_IS_LOADING_JIRA_ISSUE_TYPES, payload: true }, - ]); + describe.each` + scenario | responseCode | response | action + ${'when successful'} | ${200} | ${{ issuetypes: mockJiraIssueTypes }} | ${{ type: 'receiveJiraIssueTypesSuccess', payload: mockJiraIssueTypes }} + ${'when response has no issue types'} | ${200} | ${{ issuetypes: [] }} | ${{ type: 'receiveJiraIssueTypesError', payload: I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE }} + ${'when response includes error'} | ${200} | ${{ error: new Error() }} | ${{ type: 'receiveJiraIssueTypesError', payload: I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE }} + ${'when error occurs'} | ${500} | ${{}} | ${{ type: 'receiveJiraIssueTypesError', payload: expect.any(String) }} + `('$scenario', ({ responseCode, response, action }) => { + it(`should commit SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE and SET_IS_LOADING_JIRA_ISSUE_TYPES mutations, and dispatch ${action.type}`, () => { + mockAxios.onPut('/test').replyOnce(responseCode, response); + + return testAction( + requestJiraIssueTypes, + new FormData(), + { propsSource: { testPath: '/test' } }, + [ + // should clear the error messages and set the loading state + { type: types.SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE, payload: '' }, + { type: types.SET_IS_LOADING_JIRA_ISSUE_TYPES, payload: true }, + ], + [action], + ); + }); }); }); diff --git a/spec/frontend/integrations/edit/store/getters_spec.js b/spec/frontend/integrations/edit/store/getters_spec.js index ad7a887dff2..3353e0c84cc 100644 --- a/spec/frontend/integrations/edit/store/getters_spec.js +++ b/spec/frontend/integrations/edit/store/getters_spec.js @@ -1,11 +1,4 @@ -import { - currentKey, - isInheriting, - isDisabled, - propsSource, -} from '~/integrations/edit/store/getters'; -import * as types from '~/integrations/edit/store/mutation_types'; -import mutations from '~/integrations/edit/store/mutations'; +import { currentKey, isInheriting, propsSource } from '~/integrations/edit/store/getters'; import createState from '~/integrations/edit/store/state'; import { mockIntegrationProps } from '../mock_data'; @@ -52,29 +45,6 @@ describe('Integration form store getters', () => { }); }); - describe('isDisabled', () => { - it.each` - isSaving | isTesting | isResetting | expected - ${false} | ${false} | ${false} | ${false} - ${true} | ${false} | ${false} | ${true} - ${false} | ${true} | ${false} | ${true} - ${false} | ${false} | ${true} | ${true} - ${false} | ${true} | ${true} | ${true} - ${true} | ${false} | ${true} | ${true} - ${true} | ${true} | ${false} | ${true} - ${true} | ${true} | ${true} | ${true} - `( - 'when isSaving = $isSaving, isTesting = $isTesting, isResetting = $isResetting then isDisabled = $expected', - ({ isSaving, isTesting, isResetting, expected }) => { - mutations[types.SET_IS_SAVING](state, isSaving); - mutations[types.SET_IS_TESTING](state, isTesting); - mutations[types.SET_IS_RESETTING](state, isResetting); - - expect(isDisabled(state)).toBe(expected); - }, - ); - }); - describe('propsSource', () => { beforeEach(() => { state.defaultState = defaultState; diff --git a/spec/frontend/integrations/edit/store/mutations_spec.js b/spec/frontend/integrations/edit/store/mutations_spec.js index 18faa2f6bba..641547550d1 100644 --- a/spec/frontend/integrations/edit/store/mutations_spec.js +++ b/spec/frontend/integrations/edit/store/mutations_spec.js @@ -17,22 +17,6 @@ describe('Integration form store mutations', () => { }); }); - describe(`${types.SET_IS_SAVING}`, () => { - it('sets isSaving', () => { - mutations[types.SET_IS_SAVING](state, true); - - expect(state.isSaving).toBe(true); - }); - }); - - describe(`${types.SET_IS_TESTING}`, () => { - it('sets isTesting', () => { - mutations[types.SET_IS_TESTING](state, true); - - expect(state.isTesting).toBe(true); - }); - }); - describe(`${types.SET_IS_RESETTING}`, () => { it('sets isResetting', () => { mutations[types.SET_IS_RESETTING](state, true); diff --git a/spec/frontend/integrations/edit/store/state_spec.js b/spec/frontend/integrations/edit/store/state_spec.js index 6cd84836395..5582be7fd3c 100644 --- a/spec/frontend/integrations/edit/store/state_spec.js +++ b/spec/frontend/integrations/edit/store/state_spec.js @@ -6,7 +6,6 @@ describe('Integration form state factory', () => { defaultState: null, customState: {}, isSaving: false, - isTesting: false, isResetting: false, override: false, isLoadingJiraIssueTypes: false, diff --git a/spec/frontend/integrations/integration_settings_form_spec.js b/spec/frontend/integrations/integration_settings_form_spec.js deleted file mode 100644 index c35d178e518..00000000000 --- a/spec/frontend/integrations/integration_settings_form_spec.js +++ /dev/null @@ -1,248 +0,0 @@ -import MockAdaptor from 'axios-mock-adapter'; -import IntegrationSettingsForm from '~/integrations/integration_settings_form'; -import eventHub from '~/integrations/edit/event_hub'; -import axios from '~/lib/utils/axios_utils'; -import toast from '~/vue_shared/plugins/global_toast'; -import { - I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, - I18N_SUCCESSFUL_CONNECTION_MESSAGE, - I18N_DEFAULT_ERROR_MESSAGE, - GET_JIRA_ISSUE_TYPES_EVENT, - TOGGLE_INTEGRATION_EVENT, - TEST_INTEGRATION_EVENT, - SAVE_INTEGRATION_EVENT, -} from '~/integrations/constants'; -import waitForPromises from 'helpers/wait_for_promises'; - -jest.mock('~/vue_shared/plugins/global_toast'); -jest.mock('lodash/delay', () => (callback) => callback()); - -const FIXTURE = 'services/edit_service.html'; - -describe('IntegrationSettingsForm', () => { - let integrationSettingsForm; - - const mockStoreDispatch = () => jest.spyOn(integrationSettingsForm.vue.$store, 'dispatch'); - - beforeEach(() => { - loadFixtures(FIXTURE); - - integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); - integrationSettingsForm.init(); - }); - - describe('constructor', () => { - it('should initialize form element refs on class object', () => { - expect(integrationSettingsForm.$form).toBeDefined(); - expect(integrationSettingsForm.$form.nodeName).toBe('FORM'); - expect(integrationSettingsForm.formActive).toBeDefined(); - }); - - it('should initialize form metadata on class object', () => { - expect(integrationSettingsForm.testEndPoint).toBeDefined(); - }); - }); - - describe('event handling', () => { - let mockAxios; - - beforeEach(() => { - mockAxios = new MockAdaptor(axios); - jest.spyOn(axios, 'put'); - }); - - afterEach(() => { - mockAxios.restore(); - eventHub.dispose(); // clear event hub handlers - }); - - describe('when event hub receives `TOGGLE_INTEGRATION_EVENT`', () => { - it('should remove `novalidate` attribute to form when called with `true`', () => { - eventHub.$emit(TOGGLE_INTEGRATION_EVENT, true); - - expect(integrationSettingsForm.$form.getAttribute('novalidate')).toBe(null); - }); - - it('should set `novalidate` attribute to form when called with `false`', () => { - eventHub.$emit(TOGGLE_INTEGRATION_EVENT, false); - - expect(integrationSettingsForm.$form.getAttribute('novalidate')).toBe('novalidate'); - }); - }); - - describe('when event hub receives `TEST_INTEGRATION_EVENT`', () => { - describe('when form is valid', () => { - beforeEach(() => { - jest.spyOn(integrationSettingsForm.$form, 'checkValidity').mockReturnValue(true); - }); - - it('should make an ajax request with provided `formData`', async () => { - eventHub.$emit(TEST_INTEGRATION_EVENT); - await waitForPromises(); - - expect(axios.put).toHaveBeenCalledWith( - integrationSettingsForm.testEndPoint, - new FormData(integrationSettingsForm.$form), - ); - }); - - it('should show success message if test is successful', async () => { - jest.spyOn(integrationSettingsForm.$form, 'submit').mockImplementation(() => {}); - - mockAxios.onPut(integrationSettingsForm.testEndPoint).reply(200, { - error: false, - }); - - eventHub.$emit(TEST_INTEGRATION_EVENT); - await waitForPromises(); - - expect(toast).toHaveBeenCalledWith(I18N_SUCCESSFUL_CONNECTION_MESSAGE); - }); - - it('should show error message if ajax request responds with test error', async () => { - const errorMessage = 'Test failed.'; - const serviceResponse = 'some error'; - - mockAxios.onPut(integrationSettingsForm.testEndPoint).reply(200, { - error: true, - message: errorMessage, - service_response: serviceResponse, - test_failed: false, - }); - - eventHub.$emit(TEST_INTEGRATION_EVENT); - await waitForPromises(); - - expect(toast).toHaveBeenCalledWith(`${errorMessage} ${serviceResponse}`); - }); - - it('should show error message if ajax request failed', async () => { - mockAxios.onPut(integrationSettingsForm.testEndPoint).networkError(); - - eventHub.$emit(TEST_INTEGRATION_EVENT); - await waitForPromises(); - - expect(toast).toHaveBeenCalledWith(I18N_DEFAULT_ERROR_MESSAGE); - }); - - it('should always dispatch `setIsTesting` with `false` once request is completed', async () => { - const dispatchSpy = mockStoreDispatch(); - mockAxios.onPut(integrationSettingsForm.testEndPoint).networkError(); - - eventHub.$emit(TEST_INTEGRATION_EVENT); - await waitForPromises(); - - expect(dispatchSpy).toHaveBeenCalledWith('setIsTesting', false); - }); - }); - - describe('when form is invalid', () => { - beforeEach(() => { - jest.spyOn(integrationSettingsForm.$form, 'checkValidity').mockReturnValue(false); - jest.spyOn(integrationSettingsForm, 'testSettings'); - }); - - it('should dispatch `setIsTesting` with `false` and not call `testSettings`', async () => { - const dispatchSpy = mockStoreDispatch(); - - eventHub.$emit(TEST_INTEGRATION_EVENT); - await waitForPromises(); - - expect(dispatchSpy).toHaveBeenCalledWith('setIsTesting', false); - expect(integrationSettingsForm.testSettings).not.toHaveBeenCalled(); - }); - }); - }); - - describe('when event hub receives `GET_JIRA_ISSUE_TYPES_EVENT`', () => { - it('should always dispatch `requestJiraIssueTypes`', () => { - const dispatchSpy = mockStoreDispatch(); - mockAxios.onPut(integrationSettingsForm.testEndPoint).networkError(); - - eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT); - - expect(dispatchSpy).toHaveBeenCalledWith('requestJiraIssueTypes'); - }); - - it('should make an ajax request with provided `formData`', () => { - eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT); - - expect(axios.put).toHaveBeenCalledWith( - integrationSettingsForm.testEndPoint, - new FormData(integrationSettingsForm.$form), - ); - }); - - it('should dispatch `receiveJiraIssueTypesSuccess` with the correct payload if ajax request is successful', async () => { - const dispatchSpy = mockStoreDispatch(); - const mockData = ['ISSUE', 'EPIC']; - mockAxios.onPut(integrationSettingsForm.testEndPoint).reply(200, { - error: false, - issuetypes: mockData, - }); - - eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT); - await waitForPromises(); - - expect(dispatchSpy).toHaveBeenCalledWith('receiveJiraIssueTypesSuccess', mockData); - }); - - it.each(['Custom error message here', undefined])( - 'should dispatch "receiveJiraIssueTypesError" with a message if the backend responds with error', - async (responseErrorMessage) => { - const dispatchSpy = mockStoreDispatch(); - - const expectedErrorMessage = - responseErrorMessage || I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE; - mockAxios.onPut(integrationSettingsForm.testEndPoint).reply(200, { - error: true, - message: responseErrorMessage, - }); - - eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT); - await waitForPromises(); - - expect(dispatchSpy).toHaveBeenCalledWith( - 'receiveJiraIssueTypesError', - expectedErrorMessage, - ); - }, - ); - }); - - describe('when event hub receives `SAVE_INTEGRATION_EVENT`', () => { - describe('when form is valid', () => { - beforeEach(() => { - jest.spyOn(integrationSettingsForm.$form, 'checkValidity').mockReturnValue(true); - jest.spyOn(integrationSettingsForm.$form, 'submit'); - }); - - it('should submit the form', async () => { - eventHub.$emit(SAVE_INTEGRATION_EVENT); - await waitForPromises(); - - expect(integrationSettingsForm.$form.submit).toHaveBeenCalled(); - expect(integrationSettingsForm.$form.submit).toHaveBeenCalledTimes(1); - }); - }); - - describe('when form is invalid', () => { - beforeEach(() => { - jest.spyOn(integrationSettingsForm.$form, 'checkValidity').mockReturnValue(false); - jest.spyOn(integrationSettingsForm.$form, 'submit'); - }); - - it('should dispatch `setIsSaving` with `false` and not submit form', async () => { - const dispatchSpy = mockStoreDispatch(); - - eventHub.$emit(SAVE_INTEGRATION_EVENT); - - await waitForPromises(); - - expect(dispatchSpy).toHaveBeenCalledWith('setIsSaving', false); - expect(integrationSettingsForm.$form.submit).not.toHaveBeenCalled(); - }); - }); - }); - }); -}); diff --git a/spec/frontend/integrations/overrides/components/integration_overrides_spec.js b/spec/frontend/integrations/overrides/components/integration_overrides_spec.js index ae89d05cead..8abd83887f7 100644 --- a/spec/frontend/integrations/overrides/components/integration_overrides_spec.js +++ b/spec/frontend/integrations/overrides/components/integration_overrides_spec.js @@ -8,6 +8,7 @@ import IntegrationOverrides from '~/integrations/overrides/components/integratio import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; +import UrlSync from '~/vue_shared/components/url_sync.vue'; const mockOverrides = Array(DEFAULT_PER_PAGE * 3) .fill(1) @@ -26,9 +27,10 @@ describe('IntegrationOverrides', () => { overridesPath: 'mock/overrides', }; - const createComponent = ({ mountFn = shallowMount } = {}) => { + const createComponent = ({ mountFn = shallowMount, stubs } = {}) => { wrapper = mountFn(IntegrationOverrides, { propsData: defaultProps, + stubs, }); }; @@ -127,27 +129,58 @@ describe('IntegrationOverrides', () => { }); describe('pagination', () => { - it('triggers fetch when `input` event is emitted', async () => { - createComponent(); - jest.spyOn(axios, 'get'); - await waitForPromises(); + describe('when total items does not exceed the page limit', () => { + it('does not render', async () => { + mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.OK, [mockOverrides[0]], { + 'X-TOTAL': DEFAULT_PER_PAGE - 1, + 'X-PAGE': 1, + }); + + createComponent(); + + // wait for initial load + await waitForPromises(); - await findPagination().vm.$emit('input', 2); - expect(axios.get).toHaveBeenCalledWith(defaultProps.overridesPath, { - params: { page: 2, per_page: DEFAULT_PER_PAGE }, + expect(findPagination().exists()).toBe(false); }); }); - it('does not render with <=1 page', async () => { - mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.OK, [mockOverrides[0]], { - 'X-TOTAL': 1, - 'X-PAGE': 1, + describe('when total items exceeds the page limit', () => { + const mockPage = 2; + + beforeEach(async () => { + createComponent({ stubs: { UrlSync } }); + mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.OK, [mockOverrides[0]], { + 'X-TOTAL': DEFAULT_PER_PAGE * 2, + 'X-PAGE': mockPage, + }); + + // wait for initial load + await waitForPromises(); }); - createComponent(); - await waitForPromises(); + it('renders', () => { + expect(findPagination().exists()).toBe(true); + }); - expect(findPagination().exists()).toBe(false); + describe('when navigating to a page', () => { + beforeEach(async () => { + jest.spyOn(axios, 'get'); + + // trigger a page change + await findPagination().vm.$emit('input', mockPage); + }); + + it('performs GET request with correct params', () => { + expect(axios.get).toHaveBeenCalledWith(defaultProps.overridesPath, { + params: { page: mockPage, per_page: DEFAULT_PER_PAGE }, + }); + }); + + it('updates `page` URL parameter', () => { + expect(window.location.search).toBe(`?page=${mockPage}`); + }); + }); }); }); }); 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 5be79004640..e190ddf243e 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -6,7 +6,6 @@ import { GlSprintf, GlLink, GlModal, - GlFormCheckboxGroup, } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import { stubComponent } from 'helpers/stub_component'; @@ -18,8 +17,6 @@ import InviteMembersModal from '~/invite_members/components/invite_members_modal import ModalConfetti from '~/invite_members/components/confetti.vue'; import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; import { - INVITE_MEMBERS_IN_COMMENT, - MEMBER_AREAS_OF_FOCUS, INVITE_MEMBERS_FOR_TASK, CANCEL_BUTTON_TEXT, INVITE_BUTTON_TEXT, @@ -28,6 +25,7 @@ import { MEMBERS_MODAL_DEFAULT_TITLE, MEMBERS_PLACEHOLDER, MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT, + LEARN_GITLAB, } from '~/invite_members/constants'; import eventHub from '~/invite_members/event_hub'; import axios from '~/lib/utils/axios_utils'; @@ -51,12 +49,7 @@ 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 tasksToBeDoneOptions = [ { text: 'First task', value: 'first' }, { text: 'Second task', value: 'second' }, @@ -95,9 +88,7 @@ const createComponent = (data = {}, props = {}) => { isProject, inviteeType, accessLevels, - areasOfFocusOptions, defaultAccessLevel, - noSelectionAreasOfFocus, tasksToBeDoneOptions, projects, helpLink, @@ -163,7 +154,6 @@ describe('InviteMembersModal', () => { const membersFormGroupInvalidFeedback = () => findMembersFormGroup().props('invalidFeedback'); const membersFormGroupDescription = () => findMembersFormGroup().props('description'); const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect); - const findAreaofFocusCheckBoxGroup = () => wrapper.findComponent(GlFormCheckboxGroup); const findTasksToBeDone = () => wrapper.findByTestId('invite-members-modal-tasks-to-be-done'); const findTasks = () => wrapper.findByTestId('invite-members-modal-tasks'); const findProjectSelect = () => wrapper.findByTestId('invite-members-modal-project-select'); @@ -214,21 +204,6 @@ describe('InviteMembersModal', () => { }); }); - 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('rendering the tasks to be done', () => { const setupComponent = ( extraData = {}, @@ -268,6 +243,14 @@ describe('InviteMembersModal', () => { expect(findTasksToBeDone().exists()).toBe(false); }); + + describe('when opened from the Learn GitLab page', () => { + it('does render the tasks to be done', () => { + setupComponent({ source: LEARN_GITLAB }, {}, []); + + expect(findTasksToBeDone().exists()).toBe(true); + }); + }); }); describe('rendering the tasks', () => { @@ -433,20 +416,6 @@ describe('InviteMembersModal', () => { "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups."; 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', @@ -454,7 +423,6 @@ describe('InviteMembersModal', () => { expires_at: undefined, invite_source: inviteSource, format: 'json', - areas_of_focus: noSelectionAreasOfFocus, tasks_to_be_done: [], tasks_project_id: '', }; @@ -465,17 +433,6 @@ 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); }); describe('when triggered from regular mounting', () => { @@ -492,7 +449,23 @@ describe('InviteMembersModal', () => { }); it('displays the successful toastMessage', () => { - expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', { + onComplete: expect.any(Function), + }); + }); + }); + + describe('when opened from a Learn GitLab page', () => { + it('emits the `showSuccessfulInvitationsAlert` event', async () => { + eventHub.$emit('openModal', { inviteeType: 'members', source: LEARN_GITLAB }); + + jest.spyOn(eventHub, '$emit').mockImplementation(); + + clickInviteButton(); + + await waitForPromises(); + + expect(eventHub.$emit).toHaveBeenCalledWith('showSuccessfulInvitationsAlert'); }); }); }); @@ -637,7 +610,6 @@ describe('InviteMembersModal', () => { expires_at: undefined, email: 'email@example.com', invite_source: inviteSource, - areas_of_focus: noSelectionAreasOfFocus, tasks_to_be_done: [], tasks_project_id: '', format: 'json', @@ -649,17 +621,6 @@ 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(); - - expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, updatedPostData); }); describe('when triggered from regular mounting', () => { @@ -672,7 +633,9 @@ describe('InviteMembersModal', () => { }); it('displays the successful toastMessage', () => { - expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', { + onComplete: expect.any(Function), + }); }); }); }); @@ -711,13 +674,14 @@ describe('InviteMembersModal', () => { it('displays the successful toast message when email has already been invited', async () => { mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN); wrapper.vm.$toast = { show: jest.fn() }; - jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); clickInviteButton(); await waitForPromises(); - expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', { + onComplete: expect.any(Function), + }); expect(findMembersSelect().props('validationState')).toBe(null); }); @@ -766,7 +730,6 @@ describe('InviteMembersModal', () => { access_level: defaultAccessLevel, expires_at: undefined, invite_source: inviteSource, - areas_of_focus: noSelectionAreasOfFocus, format: 'json', tasks_to_be_done: [], tasks_project_id: '', @@ -782,8 +745,6 @@ describe('InviteMembersModal', () => { wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); - jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); - jest.spyOn(wrapper.vm, 'trackInvite'); }); describe('when triggered from regular mounting', () => { @@ -800,7 +761,9 @@ describe('InviteMembersModal', () => { }); it('displays the successful toastMessage', () => { - expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', { + onComplete: expect.any(Function), + }); }); }); @@ -855,7 +818,6 @@ describe('InviteMembersModal', () => { wrapper.setData({ inviteeType: 'group' }); wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData }); - jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); clickInviteButton(); }); @@ -865,7 +827,9 @@ describe('InviteMembersModal', () => { }); it('displays the successful toastMessage', () => { - expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', { + onComplete: expect.any(Function), + }); }); }); @@ -898,47 +862,11 @@ describe('InviteMembersModal', () => { jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({}); }); - it('tracks the invite', () => { - eventHub.$emit('openModal', { inviteeType: 'members', source: INVITE_MEMBERS_IN_COMMENT }); - - clickInviteButton(); - - expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_IN_COMMENT); - expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('comment_invite_success'); - }); - - it('does not track invite for unknown source', () => { - eventHub.$emit('openModal', { inviteeType: 'members', source: 'unknown' }); - - clickInviteButton(); - - expect(ExperimentTracking).not.toHaveBeenCalledWith(INVITE_MEMBERS_IN_COMMENT); - }); - - it('does not track invite undefined source', () => { - 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' }); + it('tracks the view for learn_gitlab source', () => { + eventHub.$emit('openModal', { inviteeType: 'members', source: LEARN_GITLAB }); - 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).toHaveBeenCalledWith(MEMBER_AREAS_OF_FOCUS.name); - expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith( - MEMBER_AREAS_OF_FOCUS.submit, - ); + expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name); + expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(LEARN_GITLAB); }); }); }); diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js index 3fce23f854c..429b6fad24a 100644 --- a/spec/frontend/invite_members/components/invite_members_trigger_spec.js +++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js @@ -1,6 +1,5 @@ import { GlButton, GlLink, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import ExperimentTracking from '~/experimentation/experiment_tracking'; import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; import eventHub from '~/invite_members/event_hub'; import { TRIGGER_ELEMENT_BUTTON, TRIGGER_ELEMENT_SIDE_NAV } from '~/invite_members/constants'; @@ -79,19 +78,6 @@ describe.each(triggerItems)('with triggerElement as %s', (triggerItem) => { }); describe('tracking', () => { - it('tracks on mounting', () => { - createComponent({ trackExperiment: '_track_experiment_' }); - - expect(ExperimentTracking).toHaveBeenCalledWith('_track_experiment_'); - expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('comment_invite_shown'); - }); - - it('does not track on mounting', () => { - createComponent(); - - expect(ExperimentTracking).not.toHaveBeenCalledWith('_track_experiment_'); - }); - it('does not add tracking attributes', () => { createComponent(); diff --git a/spec/frontend/issuable_bulk_update_sidebar/components/status_select_spec.js b/spec/frontend/issuable/bulk_update_sidebar/components/status_select_spec.js index 09dcb963154..8ecbf41ce56 100644 --- a/spec/frontend/issuable_bulk_update_sidebar/components/status_select_spec.js +++ b/spec/frontend/issuable/bulk_update_sidebar/components/status_select_spec.js @@ -1,7 +1,7 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import StatusSelect from '~/issuable_bulk_update_sidebar/components/status_select.vue'; -import { ISSUE_STATUS_SELECT_OPTIONS } from '~/issuable_bulk_update_sidebar/constants'; +import StatusSelect from '~/issuable/bulk_update_sidebar/components/status_select.vue'; +import { ISSUE_STATUS_SELECT_OPTIONS } from '~/issuable/bulk_update_sidebar/constants'; describe('StatusSelect', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js b/spec/frontend/issuable/components/issuable_header_warnings_spec.js index ad8331afcff..c8380e42787 100644 --- a/spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js +++ b/spec/frontend/issuable/components/issuable_header_warnings_spec.js @@ -1,16 +1,15 @@ -import { createLocalVue } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createStore as createMrStore } from '~/mr_notes/stores'; import createIssueStore from '~/notes/stores'; -import IssuableHeaderWarnings from '~/vue_shared/components/issuable/issuable_header_warnings.vue'; +import IssuableHeaderWarnings from '~/issuable/components/issuable_header_warnings.vue'; const ISSUABLE_TYPE_ISSUE = 'issue'; const ISSUABLE_TYPE_MR = 'merge request'; -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); describe('IssuableHeaderWarnings', () => { let wrapper; @@ -24,7 +23,6 @@ describe('IssuableHeaderWarnings', () => { const createComponent = ({ store, provide }) => { wrapper = shallowMountExtended(IssuableHeaderWarnings, { store, - localVue, provide, directives: { GlTooltip: createMockDirective(), diff --git a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js b/spec/frontend/issuable/components/issue_assignees_spec.js index f74b9b37197..713c8b1dfdd 100644 --- a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js +++ b/spec/frontend/issuable/components/issue_assignees_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { mockAssigneesList } from 'jest/boards/mock_data'; -import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; +import IssueAssignees from '~/issuable/components/issue_assignees.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; const TEST_CSS_CLASSES = 'test-classes'; diff --git a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js b/spec/frontend/issuable/components/issue_milestone_spec.js index 9a121050225..44416676180 100644 --- a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js +++ b/spec/frontend/issuable/components/issue_milestone_spec.js @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import { mockMilestone } from 'jest/boards/mock_data'; -import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue'; +import IssueMilestone from '~/issuable/components/issue_milestone.vue'; const createComponent = (milestone = mockMilestone) => { const Component = Vue.extend(IssueMilestone); diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/issuable/components/related_issuable_item_spec.js index 6ab828efebe..6ac4c9e8546 100644 --- a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js +++ b/spec/frontend/issuable/components/related_issuable_item_spec.js @@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; import IssueDueDate from '~/boards/components/issue_due_date.vue'; import { formatDate } from '~/lib/utils/datetime_utility'; -import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; +import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue'; import { defaultAssignees, defaultMilestone } from './related_issuable_mock_data'; describe('RelatedIssuableItem', () => { diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_mock_data.js b/spec/frontend/issuable/components/related_issuable_mock_data.js index 6cdb945ec20..6cdb945ec20 100644 --- a/spec/frontend/vue_shared/components/issue/related_issuable_mock_data.js +++ b/spec/frontend/issuable/components/related_issuable_mock_data.js diff --git a/spec/frontend/issuable_form_spec.js b/spec/frontend/issuable/issuable_form_spec.js index c77fde4261e..321c61ead1e 100644 --- a/spec/frontend/issuable_form_spec.js +++ b/spec/frontend/issuable/issuable_form_spec.js @@ -1,6 +1,6 @@ import $ from 'jquery'; -import IssuableForm from '~/issuable_form'; +import IssuableForm from '~/issuable/issuable_form'; function createIssuable() { const instance = new IssuableForm($(document.createElement('form'))); diff --git a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js index a450f912c4e..608fec45bbd 100644 --- a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js +++ b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js @@ -4,7 +4,7 @@ import { issuable1, issuable2, issuable3, -} from 'jest/vue_shared/components/issue/related_issuable_mock_data'; +} from 'jest/issuable/components/related_issuable_mock_data'; import RelatedIssuesBlock from '~/related_issues/components/related_issues_block.vue'; import { linkedIssueTypesMap, diff --git a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js index ffd9683cd6b..c7df3755e88 100644 --- a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js +++ b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js @@ -5,7 +5,7 @@ import { issuable3, issuable4, issuable5, -} from 'jest/vue_shared/components/issue/related_issuable_mock_data'; +} from 'jest/issuable/components/related_issuable_mock_data'; import IssueDueDate from '~/boards/components/issue_due_date.vue'; import RelatedIssuesList from '~/related_issues/components/related_issues_list.vue'; import { PathIdSeparator } from '~/related_issues/constants'; diff --git a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js index 3099e0b639b..01de4da7900 100644 --- a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js +++ b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js @@ -5,7 +5,7 @@ import { defaultProps, issuable1, issuable2, -} from 'jest/vue_shared/components/issue/related_issuable_mock_data'; +} from 'jest/issuable/components/related_issuable_mock_data'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue'; diff --git a/spec/frontend/issuable/related_issues/stores/related_issues_store_spec.js b/spec/frontend/issuable/related_issues/stores/related_issues_store_spec.js index ada1c44560f..4a6bd832fba 100644 --- a/spec/frontend/issuable/related_issues/stores/related_issues_store_spec.js +++ b/spec/frontend/issuable/related_issues/stores/related_issues_store_spec.js @@ -4,7 +4,7 @@ import { issuable3, issuable4, issuable5, -} from 'jest/vue_shared/components/issue/related_issuable_mock_data'; +} from 'jest/issuable/components/related_issuable_mock_data'; import RelatedIssuesStore from '~/related_issues/stores/related_issues_store'; describe('RelatedIssuesStore', () => { diff --git a/spec/frontend/issuable_spec.js b/spec/frontend/issuable_spec.js deleted file mode 100644 index e0bd7b802c9..00000000000 --- a/spec/frontend/issuable_spec.js +++ /dev/null @@ -1,22 +0,0 @@ -import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar'; -import IssuableIndex from '~/issuable_index'; - -describe('Issuable', () => { - describe('initBulkUpdate', () => { - it('should not set bulkUpdateSidebar', () => { - new IssuableIndex('issue_'); // eslint-disable-line no-new - - expect(issuableInitBulkUpdateSidebar.bulkUpdateSidebar).toBeNull(); - }); - - it('should set bulkUpdateSidebar', () => { - const element = document.createElement('div'); - element.classList.add('issues-bulk-update'); - document.body.appendChild(element); - - new IssuableIndex('issue_'); // eslint-disable-line no-new - - expect(issuableInitBulkUpdateSidebar.bulkUpdateSidebar).toBeDefined(); - }); - }); -}); diff --git a/spec/frontend/issue_spec.js b/spec/frontend/issues/issue_spec.js index 952ef54d286..8a089b372ff 100644 --- a/spec/frontend/issue_spec.js +++ b/spec/frontend/issues/issue_spec.js @@ -1,7 +1,7 @@ import { getByText } from '@testing-library/dom'; import MockAdapter from 'axios-mock-adapter'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; -import Issue from '~/issue'; +import Issue from '~/issues/issue'; import axios from '~/lib/utils/axios_utils'; describe('Issue', () => { diff --git a/spec/frontend/issuable_type_selector/components/__snapshots__/info_popover_spec.js.snap b/spec/frontend/issues/new/components/__snapshots__/type_popover_spec.js.snap index 196fbb8a643..881dcda126f 100644 --- a/spec/frontend/issuable_type_selector/components/__snapshots__/info_popover_spec.js.snap +++ b/spec/frontend/issues/new/components/__snapshots__/type_popover_spec.js.snap @@ -1,12 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Issuable type info popover renders 1`] = ` +exports[`Issue type info popover renders 1`] = ` <span id="popovercontainer" > <gl-icon-stub class="gl-ml-5 gl-text-gray-500" - id="issuable-type-info" + id="issue-type-info" name="question-o" size="16" /> @@ -14,7 +14,7 @@ exports[`Issuable type info popover renders 1`] = ` <gl-popover-stub container="popovercontainer" cssclasses="" - target="issuable-type-info" + target="issue-type-info" title="Issue types" triggers="focus hover" > diff --git a/spec/frontend/issuable_suggestions/components/item_spec.js b/spec/frontend/issues/new/components/title_suggestions_item_spec.js index 45f96103e3e..5eb30b52de5 100644 --- a/spec/frontend/issuable_suggestions/components/item_spec.js +++ b/spec/frontend/issues/new/components/title_suggestions_item_spec.js @@ -1,15 +1,15 @@ import { GlTooltip, GlLink, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; -import Suggestion from '~/issuable_suggestions/components/item.vue'; +import TitleSuggestionsItem from '~/issues/new/components/title_suggestions_item.vue'; import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; import mockData from '../mock_data'; -describe('Issuable suggestions suggestion component', () => { +describe('Issue title suggestions item component', () => { let wrapper; function createComponent(suggestion = {}) { - wrapper = shallowMount(Suggestion, { + wrapper = shallowMount(TitleSuggestionsItem, { propsData: { suggestion: { ...mockData(), diff --git a/spec/frontend/issuable_suggestions/components/app_spec.js b/spec/frontend/issues/new/components/title_suggestions_spec.js index fb8ef00567c..984d0c9d25b 100644 --- a/spec/frontend/issuable_suggestions/components/app_spec.js +++ b/spec/frontend/issues/new/components/title_suggestions_spec.js @@ -1,12 +1,12 @@ import { shallowMount } from '@vue/test-utils'; -import App from '~/issuable_suggestions/components/app.vue'; -import Suggestion from '~/issuable_suggestions/components/item.vue'; +import TitleSuggestions from '~/issues/new/components/title_suggestions.vue'; +import TitleSuggestionsItem from '~/issues/new/components/title_suggestions_item.vue'; -describe('Issuable suggestions app component', () => { +describe('Issue title suggestions component', () => { let wrapper; function createComponent(search = 'search') { - wrapper = shallowMount(App, { + wrapper = shallowMount(TitleSuggestions, { propsData: { search, projectPath: 'project', @@ -77,7 +77,7 @@ describe('Issuable suggestions app component', () => { wrapper.setData(data); return wrapper.vm.$nextTick(() => { - expect(wrapper.findAll(Suggestion).length).toBe(2); + expect(wrapper.findAll(TitleSuggestionsItem).length).toBe(2); }); }); diff --git a/spec/frontend/issuable_type_selector/components/info_popover_spec.js b/spec/frontend/issues/new/components/type_popover_spec.js index 975977ffeb3..fe3d5207516 100644 --- a/spec/frontend/issuable_type_selector/components/info_popover_spec.js +++ b/spec/frontend/issues/new/components/type_popover_spec.js @@ -1,11 +1,11 @@ import { shallowMount } from '@vue/test-utils'; -import InfoPopover from '~/issuable_type_selector/components/info_popover.vue'; +import TypePopover from '~/issues/new/components/type_popover.vue'; -describe('Issuable type info popover', () => { +describe('Issue type info popover', () => { let wrapper; function createComponent() { - wrapper = shallowMount(InfoPopover); + wrapper = shallowMount(TypePopover); } afterEach(() => { diff --git a/spec/frontend/issuable_suggestions/mock_data.js b/spec/frontend/issues/new/mock_data.js index 74b569d9833..74b569d9833 100644 --- a/spec/frontend/issuable_suggestions/mock_data.js +++ b/spec/frontend/issues/new/mock_data.js diff --git a/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js b/spec/frontend/issues/related_merge_requests/components/related_merge_requests_spec.js index 486fb699275..4d780a674be 100644 --- a/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js +++ b/spec/frontend/issues/related_merge_requests/components/related_merge_requests_spec.js @@ -2,9 +2,9 @@ import { mount, createLocalVue } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import mockData from 'test_fixtures/issues/related_merge_requests.json'; import axios from '~/lib/utils/axios_utils'; -import RelatedMergeRequests from '~/related_merge_requests/components/related_merge_requests.vue'; -import createStore from '~/related_merge_requests/store/index'; -import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; +import RelatedMergeRequests from '~/issues/related_merge_requests/components/related_merge_requests.vue'; +import createStore from '~/issues/related_merge_requests/store/index'; +import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue'; const API_ENDPOINT = '/api/v4/projects/2/issues/33/related_merge_requests'; const localVue = createLocalVue(); diff --git a/spec/frontend/related_merge_requests/store/actions_spec.js b/spec/frontend/issues/related_merge_requests/store/actions_spec.js index 3bd07c34b6f..5f232fee09b 100644 --- a/spec/frontend/related_merge_requests/store/actions_spec.js +++ b/spec/frontend/issues/related_merge_requests/store/actions_spec.js @@ -2,8 +2,8 @@ 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 '~/related_merge_requests/store/actions'; -import * as types from '~/related_merge_requests/store/mutation_types'; +import * as actions from '~/issues/related_merge_requests/store/actions'; +import * as types from '~/issues/related_merge_requests/store/mutation_types'; jest.mock('~/flash'); diff --git a/spec/frontend/related_merge_requests/store/mutations_spec.js b/spec/frontend/issues/related_merge_requests/store/mutations_spec.js index 436c7dca6ce..0e3d26b3879 100644 --- a/spec/frontend/related_merge_requests/store/mutations_spec.js +++ b/spec/frontend/issues/related_merge_requests/store/mutations_spec.js @@ -1,5 +1,5 @@ -import * as types from '~/related_merge_requests/store/mutation_types'; -import mutations from '~/related_merge_requests/store/mutations'; +import * as types from '~/issues/related_merge_requests/store/mutation_types'; +import mutations from '~/issues/related_merge_requests/store/mutations'; describe('RelatedMergeRequests Store Mutations', () => { describe('SET_INITIAL_STATE', () => { diff --git a/spec/frontend/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js b/spec/frontend/issues/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js index 772d6903052..5a51ae3cfe0 100644 --- a/spec/frontend/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js +++ b/spec/frontend/issues/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js @@ -2,7 +2,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { createLocalVue, shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; import Stacktrace from '~/error_tracking/components/stacktrace.vue'; -import SentryErrorStackTrace from '~/sentry_error_stack_trace/components/sentry_error_stack_trace.vue'; +import SentryErrorStackTrace from '~/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue'; const localVue = createLocalVue(); localVue.use(Vuex); diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issues/show/components/app_spec.js index e32215b4aa6..02db82b84dc 100644 --- a/spec/frontend/issue_show/components/app_spec.js +++ b/spec/frontend/issues/show/components/app_spec.js @@ -4,12 +4,13 @@ import { nextTick } from 'vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import '~/behaviors/markdown/render_gfm'; -import IssuableApp from '~/issue_show/components/app.vue'; -import DescriptionComponent from '~/issue_show/components/description.vue'; -import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue'; -import PinnedLinks from '~/issue_show/components/pinned_links.vue'; -import { IssuableStatus, IssuableStatusText, POLLING_DELAY } from '~/issue_show/constants'; -import eventHub from '~/issue_show/event_hub'; +import { IssuableStatus, IssuableStatusText } from '~/issues/constants'; +import IssuableApp from '~/issues/show/components/app.vue'; +import DescriptionComponent from '~/issues/show/components/description.vue'; +import IncidentTabs from '~/issues/show/components/incidents/incident_tabs.vue'; +import PinnedLinks from '~/issues/show/components/pinned_links.vue'; +import { POLLING_DELAY } from '~/issues/show/constants'; +import eventHub from '~/issues/show/event_hub'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; import { @@ -25,7 +26,7 @@ function formatText(text) { } jest.mock('~/lib/utils/url_utility'); -jest.mock('~/issue_show/event_hub'); +jest.mock('~/issues/show/event_hub'); const REALTIME_REQUEST_STACK = [initialRequest, secondRequest]; @@ -325,44 +326,6 @@ describe('Issuable output', () => { }); }); - describe('deleteIssuable', () => { - it('changes URL when deleted', () => { - jest.spyOn(wrapper.vm.service, 'deleteIssuable').mockResolvedValue({ - data: { - web_url: '/test', - }, - }); - - return wrapper.vm.deleteIssuable().then(() => { - expect(visitUrl).toHaveBeenCalledWith('/test'); - }); - }); - - it('stops polling when deleting', () => { - const spy = jest.spyOn(wrapper.vm.poll, 'stop'); - jest.spyOn(wrapper.vm.service, 'deleteIssuable').mockResolvedValue({ - data: { - web_url: '/test', - }, - }); - - return wrapper.vm.deleteIssuable().then(() => { - expect(spy).toHaveBeenCalledWith(); - }); - }); - - it('closes form on error', () => { - jest.spyOn(wrapper.vm.service, 'deleteIssuable').mockRejectedValue(); - - return wrapper.vm.deleteIssuable().then(() => { - expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form'); - expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe( - 'Error deleting issue', - ); - }); - }); - }); - describe('updateAndShowForm', () => { it('shows locked warning if form is open & data is different', () => { return wrapper.vm diff --git a/spec/frontend/issues/show/components/delete_issue_modal_spec.js b/spec/frontend/issues/show/components/delete_issue_modal_spec.js new file mode 100644 index 00000000000..97a091a1748 --- /dev/null +++ b/spec/frontend/issues/show/components/delete_issue_modal_spec.js @@ -0,0 +1,108 @@ +import { GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +describe('DeleteIssueModal component', () => { + let wrapper; + + const defaultProps = { + issuePath: 'gitlab-org/gitlab-test/-/issues/1', + issueType: 'issue', + modalId: 'modal-id', + title: 'Delete issue', + }; + + const findForm = () => wrapper.find('form'); + const findModal = () => wrapper.findComponent(GlModal); + + const mountComponent = (props = {}) => + shallowMount(DeleteIssueModal, { propsData: { ...defaultProps, ...props } }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('modal', () => { + it('renders', () => { + wrapper = mountComponent(); + + expect(findModal().props()).toMatchObject({ + actionCancel: DeleteIssueModal.actionCancel, + actionPrimary: { + attributes: { variant: 'danger' }, + text: defaultProps.title, + }, + modalId: defaultProps.modalId, + size: 'sm', + title: defaultProps.title, + }); + }); + + describe('when "primary" event is emitted', () => { + let formSubmitSpy; + + beforeEach(() => { + wrapper = mountComponent(); + formSubmitSpy = jest.spyOn(wrapper.vm.$refs.form, 'submit'); + findModal().vm.$emit('primary'); + }); + + it('"delete" event is emitted by DeleteIssueModal', () => { + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + + it('submits the form', () => { + expect(formSubmitSpy).toHaveBeenCalled(); + }); + }); + }); + + describe('form', () => { + beforeEach(() => { + wrapper = mountComponent(); + }); + + it('renders with action and method', () => { + expect(findForm().attributes()).toEqual({ + action: defaultProps.issuePath, + method: 'post', + }); + }); + + it('contains form data', () => { + const formData = wrapper.findAll('input').wrappers.reduce( + (acc, input) => ({ + ...acc, + [input.element.name]: input.element.value, + }), + {}, + ); + + expect(formData).toEqual({ + _method: 'delete', + authenticity_token: 'mock-csrf-token', + destroy_confirm: 'true', + }); + }); + }); + + describe('body text', () => { + describe('when issue type is not epic', () => { + it('renders', () => { + wrapper = mountComponent(); + + expect(findForm().text()).toBe('Issue will be removed! Are you sure?'); + }); + }); + + describe('when issue type is epic', () => { + it('renders', () => { + wrapper = mountComponent({ issueType: 'epic' }); + + expect(findForm().text()).toBe('Delete this epic and all descendants?'); + }); + }); + }); +}); diff --git a/spec/frontend/issue_show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js index bdcc82cab81..d39e00b9c9e 100644 --- a/spec/frontend/issue_show/components/description_spec.js +++ b/spec/frontend/issues/show/components/description_spec.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import '~/behaviors/markdown/render_gfm'; import { TEST_HOST } from 'helpers/test_constants'; import mountComponent from 'helpers/vue_mount_component_helper'; -import Description from '~/issue_show/components/description.vue'; +import Description from '~/issues/show/components/description.vue'; import TaskList from '~/task_list'; import { descriptionProps as props } from '../mock_data/mock_data'; diff --git a/spec/frontend/issue_show/components/edit_actions_spec.js b/spec/frontend/issues/show/components/edit_actions_spec.js index 50c27cb5bda..79368023d76 100644 --- a/spec/frontend/issue_show/components/edit_actions_spec.js +++ b/spec/frontend/issues/show/components/edit_actions_spec.js @@ -1,25 +1,25 @@ -import { GlButton, GlModal } from '@gitlab/ui'; -import { createLocalVue } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { mockTracking } from 'helpers/tracking_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import IssuableEditActions from '~/issue_show/components/edit_actions.vue'; -import eventHub from '~/issue_show/event_hub'; - +import IssuableEditActions from '~/issues/show/components/edit_actions.vue'; +import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue'; +import eventHub from '~/issues/show/event_hub'; import { getIssueStateQueryResponse, updateIssueStateQueryResponse, } from '../mock_data/apollo_mock'; -const localVue = createLocalVue(); -localVue.use(VueApollo); - describe('Edit Actions component', () => { let wrapper; let fakeApollo; let mockIssueStateData; + Vue.use(VueApollo); + const mockResolvers = { Query: { issueState() { @@ -43,6 +43,7 @@ describe('Edit Actions component', () => { title: 'GitLab Issue', }, canDestroy: true, + endpoint: 'gitlab-org/gitlab-test/-/issues/1', issuableType: 'issue', ...props, }, @@ -56,11 +57,7 @@ describe('Edit Actions component', () => { }); }; - async function deleteIssuable(localWrapper) { - localWrapper.findComponent(GlModal).vm.$emit('primary'); - } - - const findModal = () => wrapper.findComponent(GlModal); + const findModal = () => wrapper.findComponent(DeleteIssueModal); const findEditButtons = () => wrapper.findAllComponents(GlButton); const findDeleteButton = () => wrapper.findByTestId('issuable-delete-button'); const findSaveButton = () => wrapper.findByTestId('issuable-save-button'); @@ -123,9 +120,30 @@ describe('Edit Actions component', () => { }); }); - describe('renders create modal with the correct information', () => { - it('renders correct modal id', () => { - expect(findModal().attributes('modalid')).toBe(modalId); + describe('delete issue button', () => { + let trackingSpy; + + beforeEach(() => { + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + it('tracks clicking on button', () => { + findDeleteButton().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', { + label: 'delete_issue', + }); + }); + }); + + describe('delete issue modal', () => { + it('renders', () => { + expect(findModal().props()).toEqual({ + issuePath: 'gitlab-org/gitlab-test/-/issues/1', + issueType: 'Issue', + modalId, + title: 'Delete issue', + }); }); }); @@ -141,8 +159,8 @@ describe('Edit Actions component', () => { it('sends the `delete.issuable` event when clicking the delete confirm button', async () => { expect(eventHub.$emit).toHaveBeenCalledTimes(0); - await deleteIssuable(wrapper); - expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable', { destroy_confirm: true }); + findModal().vm.$emit('delete'); + expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable'); expect(eventHub.$emit).toHaveBeenCalledTimes(1); }); }); diff --git a/spec/frontend/issue_show/components/edited_spec.js b/spec/frontend/issues/show/components/edited_spec.js index a1683f060c0..8a8fe23230a 100644 --- a/spec/frontend/issue_show/components/edited_spec.js +++ b/spec/frontend/issues/show/components/edited_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import edited from '~/issue_show/components/edited.vue'; +import edited from '~/issues/show/components/edited.vue'; function formatText(text) { return text.trim().replace(/\s\s+/g, ' '); diff --git a/spec/frontend/issue_show/components/fields/description_spec.js b/spec/frontend/issues/show/components/fields/description_spec.js index a50be30cf4c..3043c4c3673 100644 --- a/spec/frontend/issue_show/components/fields/description_spec.js +++ b/spec/frontend/issues/show/components/fields/description_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import DescriptionField from '~/issue_show/components/fields/description.vue'; -import eventHub from '~/issue_show/event_hub'; +import DescriptionField from '~/issues/show/components/fields/description.vue'; +import eventHub from '~/issues/show/event_hub'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; describe('Description field component', () => { diff --git a/spec/frontend/issue_show/components/fields/description_template_spec.js b/spec/frontend/issues/show/components/fields/description_template_spec.js index dc126c53f5e..abe2805e5b2 100644 --- a/spec/frontend/issue_show/components/fields/description_template_spec.js +++ b/spec/frontend/issues/show/components/fields/description_template_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import descriptionTemplate from '~/issue_show/components/fields/description_template.vue'; +import descriptionTemplate from '~/issues/show/components/fields/description_template.vue'; describe('Issue description template component with templates as hash', () => { let vm; diff --git a/spec/frontend/issue_show/components/fields/title_spec.js b/spec/frontend/issues/show/components/fields/title_spec.js index 783ce9eb76c..efd0b6fbd30 100644 --- a/spec/frontend/issue_show/components/fields/title_spec.js +++ b/spec/frontend/issues/show/components/fields/title_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import TitleField from '~/issue_show/components/fields/title.vue'; -import eventHub from '~/issue_show/event_hub'; +import TitleField from '~/issues/show/components/fields/title.vue'; +import eventHub from '~/issues/show/event_hub'; describe('Title field component', () => { let wrapper; diff --git a/spec/frontend/issue_show/components/fields/type_spec.js b/spec/frontend/issues/show/components/fields/type_spec.js index 95ae6f37877..3ece10e70db 100644 --- a/spec/frontend/issue_show/components/fields/type_spec.js +++ b/spec/frontend/issues/show/components/fields/type_spec.js @@ -3,8 +3,8 @@ 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 IssueTypeField, { i18n } from '~/issue_show/components/fields/type.vue'; -import { IssuableTypes } from '~/issue_show/constants'; +import IssueTypeField, { i18n } from '~/issues/show/components/fields/type.vue'; +import { IssuableTypes } from '~/issues/show/constants'; import { getIssueStateQueryResponse, updateIssueStateQueryResponse, diff --git a/spec/frontend/issue_show/components/form_spec.js b/spec/frontend/issues/show/components/form_spec.js index 28498cb90ec..db49d2635ba 100644 --- a/spec/frontend/issue_show/components/form_spec.js +++ b/spec/frontend/issues/show/components/form_spec.js @@ -1,11 +1,11 @@ import { GlAlert } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Autosave from '~/autosave'; -import DescriptionTemplate from '~/issue_show/components/fields/description_template.vue'; -import IssueTypeField from '~/issue_show/components/fields/type.vue'; -import formComponent from '~/issue_show/components/form.vue'; -import LockedWarning from '~/issue_show/components/locked_warning.vue'; -import eventHub from '~/issue_show/event_hub'; +import DescriptionTemplate from '~/issues/show/components/fields/description_template.vue'; +import IssueTypeField from '~/issues/show/components/fields/type.vue'; +import formComponent from '~/issues/show/components/form.vue'; +import LockedWarning from '~/issues/show/components/locked_warning.vue'; +import eventHub from '~/issues/show/event_hub'; jest.mock('~/autosave'); @@ -13,6 +13,7 @@ describe('Inline edit form component', () => { let wrapper; const defaultProps = { canDestroy: true, + endpoint: 'gitlab-org/gitlab-test/-/issues/1', formState: { title: 'b', description: 'a', diff --git a/spec/frontend/issue_show/components/header_actions_spec.js b/spec/frontend/issues/show/components/header_actions_spec.js index 4df62ec8717..2a16c699c4d 100644 --- a/spec/frontend/issue_show/components/header_actions_spec.js +++ b/spec/frontend/issues/show/components/header_actions_spec.js @@ -1,11 +1,15 @@ import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; +import { mockTracking } from 'helpers/tracking_helper'; import createFlash, { FLASH_TYPES } from '~/flash'; -import { IssuableType } from '~/issuable_show/constants'; -import HeaderActions from '~/issue_show/components/header_actions.vue'; -import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants'; -import promoteToEpicMutation from '~/issue_show/queries/promote_to_epic.mutation.graphql'; +import { IssuableType } from '~/vue_shared/issuable/show/constants'; +import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue'; +import HeaderActions from '~/issues/show/components/header_actions.vue'; +import { IssuableStatus } from '~/issues/constants'; +import { IssueStateEvent } from '~/issues/show/constants'; +import promoteToEpicMutation from '~/issues/show/queries/promote_to_epic.mutation.graphql'; import * as urlUtility from '~/lib/utils/url_utility'; import eventHub from '~/notes/event_hub'; import createStore from '~/notes/stores'; @@ -18,18 +22,20 @@ describe('HeaderActions component', () => { let wrapper; let visitUrlSpy; - const localVue = createLocalVue(); - localVue.use(Vuex); + Vue.use(Vuex); + const store = createStore(); const defaultProps = { canCreateIssue: true, + canDestroyIssue: true, canPromoteToEpic: true, canReopenIssue: true, canReportSpam: true, canUpdateIssue: true, iid: '32', isIssueAuthor: true, + issuePath: 'gitlab-org/gitlab-test/-/issues/1', issueType: IssuableType.Issue, newIssuePath: 'gitlab-org/gitlab-test/-/issues/new', projectPath: 'gitlab-org/gitlab-test', @@ -60,17 +66,12 @@ describe('HeaderActions component', () => { }, }; - const findToggleIssueStateButton = () => wrapper.find(GlButton); - - const findDropdownAt = (index) => wrapper.findAll(GlDropdown).at(index); - - const findMobileDropdownItems = () => findDropdownAt(0).findAll(GlDropdownItem); - - const findDesktopDropdownItems = () => findDropdownAt(1).findAll(GlDropdownItem); - - const findModal = () => wrapper.find(GlModal); - - const findModalLinkAt = (index) => findModal().findAll(GlLink).at(index); + const findToggleIssueStateButton = () => wrapper.findComponent(GlButton); + const findDropdownAt = (index) => wrapper.findAllComponents(GlDropdown).at(index); + const findMobileDropdownItems = () => findDropdownAt(0).findAllComponents(GlDropdownItem); + const findDesktopDropdownItems = () => findDropdownAt(1).findAllComponents(GlDropdownItem); + const findModal = () => wrapper.findComponent(GlModal); + const findModalLinkAt = (index) => findModal().findAllComponents(GlLink).at(index); const mountComponent = ({ props = {}, @@ -86,7 +87,6 @@ describe('HeaderActions component', () => { }); return shallowMount(HeaderActions, { - localVue, store, provide: { ...defaultProps, @@ -167,17 +167,19 @@ describe('HeaderActions component', () => { ${'desktop dropdown'} | ${false} | ${findDesktopDropdownItems} `('$description', ({ isCloseIssueItemVisible, findDropdownItems }) => { describe.each` - description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam | canPromoteToEpic - ${`when user can update ${issueType}`} | ${`Close ${issueType}`} | ${isCloseIssueItemVisible} | ${true} | ${true} | ${true} | ${true} | ${true} - ${`when user cannot update ${issueType}`} | ${`Close ${issueType}`} | ${false} | ${false} | ${true} | ${true} | ${true} | ${true} - ${`when user can create ${issueType}`} | ${`New ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} - ${`when user cannot create ${issueType}`} | ${`New ${issueType}`} | ${false} | ${true} | ${false} | ${true} | ${true} | ${true} - ${'when user can promote to epic'} | ${'Promote to epic'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} - ${'when user cannot promote to epic'} | ${'Promote to epic'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} - ${'when user can report abuse'} | ${'Report abuse'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} - ${'when user cannot report abuse'} | ${'Report abuse'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} - ${'when user can submit as spam'} | ${'Submit as spam'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} - ${'when user cannot submit as spam'} | ${'Submit as spam'} | ${false} | ${true} | ${true} | ${true} | ${false} | ${true} + description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam | canPromoteToEpic | canDestroyIssue + ${`when user can update ${issueType}`} | ${`Close ${issueType}`} | ${isCloseIssueItemVisible} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${`when user cannot update ${issueType}`} | ${`Close ${issueType}`} | ${false} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} + ${`when user can create ${issueType}`} | ${`New ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${`when user cannot create ${issueType}`} | ${`New ${issueType}`} | ${false} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} + ${'when user can promote to epic'} | ${'Promote to epic'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${'when user cannot promote to epic'} | ${'Promote to epic'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${true} + ${'when user can report abuse'} | ${'Report abuse'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} + ${'when user cannot report abuse'} | ${'Report abuse'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${'when user can submit as spam'} | ${'Submit as spam'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${'when user cannot submit as spam'} | ${'Submit as spam'} | ${false} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} + ${`when user can delete ${issueType}`} | ${`Delete ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${`when user cannot delete ${issueType}`} | ${`Delete ${issueType}`} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} `( '$description', ({ @@ -188,6 +190,7 @@ describe('HeaderActions component', () => { isIssueAuthor, canReportSpam, canPromoteToEpic, + canDestroyIssue, }) => { beforeEach(() => { wrapper = mountComponent({ @@ -198,6 +201,7 @@ describe('HeaderActions component', () => { issueType, canReportSpam, canPromoteToEpic, + canDestroyIssue, }, }); }); @@ -214,6 +218,23 @@ describe('HeaderActions component', () => { }); }); + describe('delete issue button', () => { + let trackingSpy; + + beforeEach(() => { + wrapper = mountComponent(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + it('tracks clicking on button', () => { + findDesktopDropdownItems().at(3).vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_dropdown', { + label: 'delete_issue', + }); + }); + }); + describe('when "Promote to epic" button is clicked', () => { describe('when response is successful', () => { beforeEach(() => { @@ -267,7 +288,7 @@ describe('HeaderActions component', () => { it('shows an error message', () => { expect(createFlash).toHaveBeenCalledWith({ - message: promoteToEpicMutationErrorResponse.data.promoteToEpic.errors.join('; '), + message: HeaderActions.i18n.promoteErrorMessage, }); }); }); @@ -293,7 +314,7 @@ describe('HeaderActions component', () => { }); }); - describe('modal', () => { + describe('blocked by issues modal', () => { const blockedByIssues = [ { iid: 13, web_url: 'gitlab-org/gitlab-test/-/issues/13' }, { iid: 79, web_url: 'gitlab-org/gitlab-test/-/issues/79' }, @@ -345,4 +366,17 @@ describe('HeaderActions component', () => { }); }); }); + + describe('delete issue modal', () => { + it('renders', () => { + wrapper = mountComponent(); + + expect(wrapper.findComponent(DeleteIssueModal).props()).toEqual({ + issuePath: defaultProps.issuePath, + issueType: defaultProps.issueType, + modalId: HeaderActions.deleteModalId, + title: 'Delete issue', + }); + }); + }); }); diff --git a/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js b/spec/frontend/issues/show/components/incidents/highlight_bar_spec.js index 6758e6192b8..a4910d63bb5 100644 --- a/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js +++ b/spec/frontend/issues/show/components/incidents/highlight_bar_spec.js @@ -1,7 +1,7 @@ import { GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import merge from 'lodash/merge'; -import HighlightBar from '~/issue_show/components/incidents/highlight_bar.vue'; +import HighlightBar from '~/issues/show/components/incidents/highlight_bar.vue'; import { formatDate } from '~/lib/utils/datetime_utility'; jest.mock('~/lib/utils/datetime_utility'); diff --git a/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js index 6b9f5b17e99..9bf0e106194 100644 --- a/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js +++ b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js @@ -3,9 +3,9 @@ import { shallowMount } from '@vue/test-utils'; import merge from 'lodash/merge'; import waitForPromises from 'helpers/wait_for_promises'; import { trackIncidentDetailsViewsOptions } from '~/incidents/constants'; -import DescriptionComponent from '~/issue_show/components/description.vue'; -import HighlightBar from '~/issue_show/components/incidents/highlight_bar.vue'; -import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue'; +import DescriptionComponent from '~/issues/show/components/description.vue'; +import HighlightBar from '~/issues/show/components/incidents/highlight_bar.vue'; +import IncidentTabs from '~/issues/show/components/incidents/incident_tabs.vue'; import INVALID_URL from '~/lib/utils/invalid_url'; import Tracking from '~/tracking'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; diff --git a/spec/frontend/issue_show/components/pinned_links_spec.js b/spec/frontend/issues/show/components/pinned_links_spec.js index 3fe1f9fd6d9..aac720df6e9 100644 --- a/spec/frontend/issue_show/components/pinned_links_spec.js +++ b/spec/frontend/issues/show/components/pinned_links_spec.js @@ -1,7 +1,7 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import PinnedLinks from '~/issue_show/components/pinned_links.vue'; -import { STATUS_PAGE_PUBLISHED, JOIN_ZOOM_MEETING } from '~/issue_show/constants'; +import PinnedLinks from '~/issues/show/components/pinned_links.vue'; +import { STATUS_PAGE_PUBLISHED, JOIN_ZOOM_MEETING } from '~/issues/show/constants'; const plainZoomUrl = 'https://zoom.us/j/123456789'; const plainStatusUrl = 'https://status.com'; diff --git a/spec/frontend/issue_show/components/title_spec.js b/spec/frontend/issues/show/components/title_spec.js index 78880a7f540..f9026557be2 100644 --- a/spec/frontend/issue_show/components/title_spec.js +++ b/spec/frontend/issues/show/components/title_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; -import titleComponent from '~/issue_show/components/title.vue'; -import eventHub from '~/issue_show/event_hub'; -import Store from '~/issue_show/stores'; +import titleComponent from '~/issues/show/components/title.vue'; +import eventHub from '~/issues/show/event_hub'; +import Store from '~/issues/show/stores'; describe('Title component', () => { let vm; diff --git a/spec/frontend/issue_show/issue_spec.js b/spec/frontend/issues/show/issue_spec.js index 76989413edb..6d7a31a6c8c 100644 --- a/spec/frontend/issue_show/issue_spec.js +++ b/spec/frontend/issues/show/issue_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; -import { initIssuableApp } from '~/issue_show/issue'; -import * as parseData from '~/issue_show/utils/parse_data'; +import { initIssuableApp } from '~/issues/show/issue'; +import * as parseData from '~/issues/show/utils/parse_data'; import axios from '~/lib/utils/axios_utils'; import createStore from '~/notes/stores'; import { appProps } from './mock_data/mock_data'; @@ -17,7 +17,7 @@ const setupHTML = (initialData) => { }; describe('Issue show index', () => { - describe('initIssueableApp', () => { + describe('initIssuableApp', () => { it('should initialize app with no potential XSS attack', async () => { const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {}); const parseDataSpy = jest.spyOn(parseData, 'parseIssuableData'); diff --git a/spec/frontend/issue_show/mock_data/apollo_mock.js b/spec/frontend/issues/show/mock_data/apollo_mock.js index bfd31e74393..bfd31e74393 100644 --- a/spec/frontend/issue_show/mock_data/apollo_mock.js +++ b/spec/frontend/issues/show/mock_data/apollo_mock.js diff --git a/spec/frontend/issue_show/mock_data/mock_data.js b/spec/frontend/issues/show/mock_data/mock_data.js index a73826954c3..a73826954c3 100644 --- a/spec/frontend/issue_show/mock_data/mock_data.js +++ b/spec/frontend/issues/show/mock_data/mock_data.js diff --git a/spec/frontend/issue_show/store_spec.js b/spec/frontend/issues/show/store_spec.js index b7fd70bf00e..20d3a6cdaae 100644 --- a/spec/frontend/issue_show/store_spec.js +++ b/spec/frontend/issues/show/store_spec.js @@ -1,7 +1,7 @@ -import Store from '~/issue_show/stores'; -import updateDescription from '~/issue_show/utils/update_description'; +import Store from '~/issues/show/stores'; +import updateDescription from '~/issues/show/utils/update_description'; -jest.mock('~/issue_show/utils/update_description'); +jest.mock('~/issues/show/utils/update_description'); describe('Store', () => { let store; diff --git a/spec/frontend/issue_show/utils/update_description_spec.js b/spec/frontend/issues/show/utils/update_description_spec.js index b2c6bd3c302..f4afef8af12 100644 --- a/spec/frontend/issue_show/utils/update_description_spec.js +++ b/spec/frontend/issues/show/utils/update_description_spec.js @@ -1,4 +1,4 @@ -import updateDescription from '~/issue_show/utils/update_description'; +import updateDescription from '~/issues/show/utils/update_description'; describe('updateDescription', () => { it('returns the correct value to be set as descriptionHtml', () => { diff --git a/spec/frontend/issues_list/components/issuable_spec.js b/spec/frontend/issues_list/components/issuable_spec.js index 97d841c861d..f3c2ae1f9dc 100644 --- a/spec/frontend/issues_list/components/issuable_spec.js +++ b/spec/frontend/issues_list/components/issuable_spec.js @@ -7,7 +7,7 @@ import { isScopedLabel } from '~/lib/utils/common_utils'; import { formatDate } from '~/lib/utils/datetime_utility'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import initUserPopovers from '~/user_popovers'; -import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; +import IssueAssignees from '~/issuable/components/issue_assignees.vue'; import { simpleIssue, testAssignees, testLabels } from '../issuable_list_test_data'; jest.mock('~/user_popovers'); 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 5ef2a2e0525..11854db534e 100644 --- a/spec/frontend/issues_list/components/issuables_list_app_spec.js +++ b/spec/frontend/issues_list/components/issuables_list_app_spec.js @@ -13,7 +13,7 @@ import createFlash from '~/flash'; import Issuable from '~/issues_list/components/issuable.vue'; import IssuablesListApp from '~/issues_list/components/issuables_list_app.vue'; import { PAGE_SIZE, PAGE_SIZE_MANUAL, RELATIVE_POSITION } from '~/issues_list/constants'; -import issueablesEventBus from '~/issues_list/eventhub'; +import issuablesEventBus from '~/issues_list/eventhub'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; jest.mock('~/flash'); @@ -185,8 +185,8 @@ describe('Issuables list component', () => { describe('with bulk editing enabled', () => { beforeEach(() => { - issueablesEventBus.$on.mockReset(); - issueablesEventBus.$emit.mockReset(); + issuablesEventBus.$on.mockReset(); + issuablesEventBus.$emit.mockReset(); setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); factory({ canBulkEdit: true }); @@ -239,19 +239,19 @@ describe('Issuables list component', () => { }); it('broadcasts a message to the bulk edit sidebar when a value is added to selection', () => { - issueablesEventBus.$emit.mockReset(); + issuablesEventBus.$emit.mockReset(); const i1 = wrapper.vm.issuables[1]; wrapper.vm.onSelectIssuable({ issuable: i1, selected: true }); return wrapper.vm.$nextTick().then(() => { - expect(issueablesEventBus.$emit).toHaveBeenCalledTimes(1); - expect(issueablesEventBus.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit'); + expect(issuablesEventBus.$emit).toHaveBeenCalledTimes(1); + expect(issuablesEventBus.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit'); }); }); it('does not broadcast a message to the bulk edit sidebar when a value is not added to selection', () => { - issueablesEventBus.$emit.mockReset(); + issuablesEventBus.$emit.mockReset(); return wrapper.vm .$nextTick() @@ -263,19 +263,19 @@ describe('Issuables list component', () => { }) .then(wrapper.vm.$nextTick) .then(() => { - expect(issueablesEventBus.$emit).toHaveBeenCalledTimes(0); + expect(issuablesEventBus.$emit).toHaveBeenCalledTimes(0); }); }); it('listens to a message to toggle bulk editing', () => { expect(wrapper.vm.isBulkEditing).toBe(false); - expect(issueablesEventBus.$on.mock.calls[0][0]).toBe('issuables:toggleBulkEdit'); - issueablesEventBus.$on.mock.calls[0][1](true); // Call the message handler + expect(issuablesEventBus.$on.mock.calls[0][0]).toBe('issuables:toggleBulkEdit'); + issuablesEventBus.$on.mock.calls[0][1](true); // Call the message handler return waitForPromises() .then(() => { expect(wrapper.vm.isBulkEditing).toBe(true); - issueablesEventBus.$on.mock.calls[0][1](false); + issuablesEventBus.$on.mock.calls[0][1](false); }) .then(() => { expect(wrapper.vm.isBulkEditing).toBe(false); 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 d195c159cbb..7c5faeb8dc1 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 @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import { useFakeDate } from 'helpers/fake_date'; import IssueCardTimeInfo from '~/issues_list/components/issue_card_time_info.vue'; -describe('IssuesListApp component', () => { +describe('CE IssueCardTimeInfo component', () => { useFakeDate(2020, 11, 11); let wrapper; 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 3f52c7b4afe..f24c090fa92 100644 --- a/spec/frontend/issues_list/components/issues_list_app_spec.js +++ b/spec/frontend/issues_list/components/issues_list_app_spec.js @@ -1,8 +1,9 @@ import { GlButton, GlEmptyState, GlLink } from '@gitlab/ui'; -import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; +import * as Sentry from '@sentry/browser'; +import { mount, shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import { cloneDeep } from 'lodash'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql'; import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_counts.query.graphql'; @@ -17,29 +18,28 @@ import { locationSearch, urlParams, } from 'jest/issues_list/mock_data'; -import createFlash from '~/flash'; +import createFlash, { FLASH_TYPES } from '~/flash'; 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'; -import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants'; +import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; +import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; import IssuesListApp from '~/issues_list/components/issues_list_app.vue'; import NewIssueDropdown from '~/issues_list/components/new_issue_dropdown.vue'; import { CREATED_DESC, DUE_DATE_OVERDUE, PARAM_DUE_DATE, + RELATIVE_POSITION, + RELATIVE_POSITION_ASC, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, - TOKEN_TYPE_EPIC, - TOKEN_TYPE_ITERATION, TOKEN_TYPE_LABEL, TOKEN_TYPE_MILESTONE, TOKEN_TYPE_MY_REACTION, TOKEN_TYPE_RELEASE, TOKEN_TYPE_TYPE, - TOKEN_TYPE_WEIGHT, urlSortParams, } from '~/issues_list/constants'; import eventHub from '~/issues_list/eventhub'; @@ -48,17 +48,17 @@ import axios from '~/lib/utils/axios_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; import { joinPaths } from '~/lib/utils/url_utility'; +jest.mock('@sentry/browser'); jest.mock('~/flash'); jest.mock('~/lib/utils/scroll_utils', () => ({ scrollUp: jest.fn().mockName('scrollUpMock'), })); -describe('IssuesListApp component', () => { +describe('CE IssuesListApp component', () => { let axiosMock; let wrapper; - const localVue = createLocalVue(); - localVue.use(VueApollo); + Vue.use(VueApollo); const defaultProvide = { calendarPath: 'calendar/path', @@ -69,6 +69,7 @@ describe('IssuesListApp component', () => { hasAnyIssues: true, hasAnyProjects: true, hasBlockedIssuesFeature: true, + hasIssuableHealthStatusFeature: true, hasIssueWeightsFeature: true, hasIterationsFeature: true, isProject: true, @@ -111,7 +112,6 @@ describe('IssuesListApp component', () => { const apolloProvider = createMockApollo(requestHandlers); return mountFn(IssuesListApp, { - localVue, apolloProvider, provide: { ...defaultProvide, @@ -314,6 +314,29 @@ describe('IssuesListApp component', () => { }, }); }); + + describe('when issue repositioning is disabled and the sort is manual', () => { + beforeEach(() => { + setWindowLocation(`?sort=${RELATIVE_POSITION}`); + wrapper = mountComponent({ provide: { isIssueRepositioningDisabled: true } }); + }); + + it('changes the sort to the default of created descending', () => { + expect(findIssuableList().props()).toMatchObject({ + initialSortBy: CREATED_DESC, + urlParams: { + sort: urlSortParams[CREATED_DESC], + }, + }); + }); + + it('shows an alert to tell the user that manual reordering is disabled', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: IssuesListApp.i18n.issueRepositioningMessage, + type: FLASH_TYPES.NOTICE, + }); + }); + }); }); describe('state', () => { @@ -336,6 +359,27 @@ describe('IssuesListApp component', () => { expect(findIssuableList().props('initialFilterValue')).toEqual(filteredTokens); }); + + describe('when anonymous searching is performed', () => { + beforeEach(() => { + setWindowLocation(locationSearch); + + wrapper = mountComponent({ + provide: { isAnonymousSearchDisabled: true, isSignedIn: false }, + }); + }); + + it('is not set from url params', () => { + expect(findIssuableList().props('initialFilterValue')).toEqual([]); + }); + + it('shows an alert to tell the user they must be signed in to search', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: IssuesListApp.i18n.anonymousSearchingMessage, + type: FLASH_TYPES.NOTICE, + }); + }); + }); }); }); @@ -484,11 +528,7 @@ describe('IssuesListApp component', () => { describe('when user is signed out', () => { beforeEach(() => { - wrapper = mountComponent({ - provide: { - isSignedIn: false, - }, - }); + wrapper = mountComponent({ provide: { isSignedIn: false } }); }); it('does not render My-Reaction or Confidential tokens', () => { @@ -501,54 +541,6 @@ describe('IssuesListApp component', () => { }); }); - describe('when iterations are not available', () => { - beforeEach(() => { - wrapper = mountComponent({ - provide: { - projectIterationsPath: '', - }, - }); - }); - - it('does not render Iteration token', () => { - expect(findIssuableList().props('searchTokens')).not.toMatchObject([ - { type: TOKEN_TYPE_ITERATION }, - ]); - }); - }); - - describe('when epics are not available', () => { - beforeEach(() => { - wrapper = mountComponent({ - provide: { - groupPath: '', - }, - }); - }); - - it('does not render Epic token', () => { - expect(findIssuableList().props('searchTokens')).not.toMatchObject([ - { type: TOKEN_TYPE_EPIC }, - ]); - }); - }); - - describe('when weights are not available', () => { - beforeEach(() => { - wrapper = mountComponent({ - provide: { - groupPath: '', - }, - }); - }); - - it('does not render Weight token', () => { - expect(findIssuableList().props('searchTokens')).not.toMatchObject([ - { type: TOKEN_TYPE_WEIGHT }, - ]); - }); - }); - describe('when all tokens are available', () => { const originalGon = window.gon; @@ -561,33 +553,27 @@ describe('IssuesListApp component', () => { current_user_avatar_url: mockCurrentUser.avatar_url, }; - wrapper = mountComponent({ - provide: { - isSignedIn: true, - projectIterationsPath: 'project/iterations/path', - groupPath: 'group/path', - hasIssueWeightsFeature: true, - }, - }); + wrapper = mountComponent({ provide: { isSignedIn: true } }); }); - it('renders all tokens', () => { + afterEach(() => { + window.gon = originalGon; + }); + + it('renders all tokens alphabetically', () => { const preloadedAuthors = [ { ...mockCurrentUser, id: convertToGraphQLId('User', mockCurrentUser.id) }, ]; expect(findIssuableList().props('searchTokens')).toMatchObject([ - { type: TOKEN_TYPE_AUTHOR, preloadedAuthors }, { type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors }, - { type: TOKEN_TYPE_MILESTONE }, + { type: TOKEN_TYPE_AUTHOR, preloadedAuthors }, + { type: TOKEN_TYPE_CONFIDENTIAL }, { type: TOKEN_TYPE_LABEL }, - { type: TOKEN_TYPE_TYPE }, - { type: TOKEN_TYPE_RELEASE }, + { type: TOKEN_TYPE_MILESTONE }, { type: TOKEN_TYPE_MY_REACTION }, - { type: TOKEN_TYPE_CONFIDENTIAL }, - { type: TOKEN_TYPE_ITERATION }, - { type: TOKEN_TYPE_EPIC }, - { type: TOKEN_TYPE_WEIGHT }, + { type: TOKEN_TYPE_RELEASE }, + { type: TOKEN_TYPE_TYPE }, ]); }); }); @@ -607,13 +593,18 @@ describe('IssuesListApp component', () => { }); it('shows an error message', () => { - expect(createFlash).toHaveBeenCalledWith({ - captureError: true, - error: new Error('Network error: ERROR'), - message, - }); + expect(findIssuableList().props('error')).toBe(message); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Network error: ERROR')); }); }); + + it('clears error message when "dismiss-alert" event is emitted from IssuableList', () => { + wrapper = mountComponent({ issuesQueryResponse: jest.fn().mockRejectedValue(new Error()) }); + + findIssuableList().vm.$emit('dismiss-alert'); + + expect(findIssuableList().props('error')).toBeNull(); + }); }); describe('events', () => { @@ -676,6 +667,7 @@ describe('IssuesListApp component', () => { const response = (isProject = true) => ({ data: { [isProject ? 'project' : 'group']: { + id: '1', issues: { ...defaultQueryResponse.data.project.issues, nodes: [issueOne, issueTwo, issueThree, issueFour], @@ -737,11 +729,10 @@ describe('IssuesListApp component', () => { await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ - message: IssuesListApp.i18n.reorderError, - captureError: true, - error: new Error('Request failed with status code 500'), - }); + expect(findIssuableList().props('error')).toBe(IssuesListApp.i18n.reorderError); + expect(Sentry.captureException).toHaveBeenCalledWith( + new Error('Request failed with status code 500'), + ); }); }); }); @@ -762,6 +753,30 @@ describe('IssuesListApp component', () => { }); }, ); + + describe('when issue repositioning is disabled', () => { + const initialSort = CREATED_DESC; + + beforeEach(() => { + setWindowLocation(`?sort=${initialSort}`); + wrapper = mountComponent({ provide: { isIssueRepositioningDisabled: true } }); + + findIssuableList().vm.$emit('sort', RELATIVE_POSITION_ASC); + }); + + it('does not update the sort to manual', () => { + expect(findIssuableList().props('urlParams')).toMatchObject({ + sort: urlSortParams[initialSort], + }); + }); + + it('shows an alert to tell the user that manual reordering is disabled', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: IssuesListApp.i18n.issueRepositioningMessage, + type: FLASH_TYPES.NOTICE, + }); + }); + }); }); describe('when "update-legacy-bulk-edit" event is emitted by IssuableList', () => { @@ -778,15 +793,37 @@ describe('IssuesListApp component', () => { }); describe('when "filter" event is emitted by IssuableList', () => { - beforeEach(() => { + it('updates IssuableList with url params', async () => { wrapper = mountComponent(); findIssuableList().vm.$emit('filter', filteredTokens); - }); + await nextTick(); - it('updates IssuableList with url params', () => { expect(findIssuableList().props('urlParams')).toMatchObject(urlParams); }); + + describe('when anonymous searching is performed', () => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { isAnonymousSearchDisabled: true, isSignedIn: false }, + }); + + findIssuableList().vm.$emit('filter', filteredTokens); + }); + + it('does not update IssuableList with url params ', async () => { + const defaultParams = { sort: 'created_date', state: 'opened' }; + + expect(findIssuableList().props('urlParams')).toEqual(defaultParams); + }); + + it('shows an alert to tell the user they must be signed in to search', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: IssuesListApp.i18n.anonymousSearchingMessage, + type: FLASH_TYPES.NOTICE, + }); + }); + }); }); }); }); diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js index 19a8af4d9c2..948699876ce 100644 --- a/spec/frontend/issues_list/mock_data.js +++ b/spec/frontend/issues_list/mock_data.js @@ -6,6 +6,7 @@ import { export const getIssuesQueryResponse = { data: { project: { + id: '1', issues: { pageInfo: { hasNextPage: true, @@ -22,6 +23,7 @@ export const getIssuesQueryResponse = { createdAt: '2021-05-22T04:08:01Z', downvotes: 2, dueDate: '2021-05-29', + hidden: false, humanTimeEstimate: null, mergeRequestsCount: false, moved: false, @@ -74,6 +76,7 @@ export const getIssuesQueryResponse = { export const getIssuesCountsQueryResponse = { data: { project: { + id: '1', openedIssues: { count: 1, }, @@ -287,6 +290,7 @@ export const project3 = { export const searchProjectsQueryResponse = { data: { group: { + id: '1', projects: { nodes: [project1, project2, project3], }, @@ -297,6 +301,7 @@ export const searchProjectsQueryResponse = { export const emptySearchProjectsQueryResponse = { data: { group: { + id: '1', projects: { nodes: [], }, diff --git a/spec/frontend/jira_connect/subscriptions/components/app_spec.js b/spec/frontend/jira_connect/subscriptions/components/app_spec.js index 8e464968453..47fe96262ee 100644 --- a/spec/frontend/jira_connect/subscriptions/components/app_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/app_spec.js @@ -5,6 +5,7 @@ import JiraConnectApp from '~/jira_connect/subscriptions/components/app.vue'; import AddNamespaceButton from '~/jira_connect/subscriptions/components/add_namespace_button.vue'; import SignInButton from '~/jira_connect/subscriptions/components/sign_in_button.vue'; import SubscriptionsList from '~/jira_connect/subscriptions/components/subscriptions_list.vue'; +import UserLink from '~/jira_connect/subscriptions/components/user_link.vue'; import createStore from '~/jira_connect/subscriptions/store'; import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types'; import { __ } from '~/locale'; @@ -12,6 +13,7 @@ import { mockSubscription } from '../mock_data'; jest.mock('~/jira_connect/subscriptions/utils', () => ({ retrieveAlert: jest.fn().mockReturnValue({ message: 'error message' }), + getGitlabSignInURL: jest.fn(), })); describe('JiraConnectApp', () => { @@ -83,6 +85,22 @@ describe('JiraConnectApp', () => { }); }, ); + + it('renders UserLink component', () => { + createComponent({ + provide: { + usersPath: '/user', + subscriptions: [], + }, + }); + + const userLink = wrapper.findComponent(UserLink); + expect(userLink.exists()).toBe(true); + expect(userLink.props()).toEqual({ + hasSubscriptions: false, + userSignedIn: false, + }); + }); }); describe('alert', () => { diff --git a/spec/frontend/jira_connect/subscriptions/components/user_link_spec.js b/spec/frontend/jira_connect/subscriptions/components/user_link_spec.js new file mode 100644 index 00000000000..b98a36269a3 --- /dev/null +++ b/spec/frontend/jira_connect/subscriptions/components/user_link_spec.js @@ -0,0 +1,91 @@ +import { GlSprintf } from '@gitlab/ui'; +import UserLink from '~/jira_connect/subscriptions/components/user_link.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +jest.mock('~/jira_connect/subscriptions/utils', () => ({ + getGitlabSignInURL: jest.fn().mockImplementation((path) => Promise.resolve(path)), +})); + +describe('SubscriptionsList', () => { + let wrapper; + + const createComponent = (propsData = {}, { provide } = {}) => { + wrapper = shallowMountExtended(UserLink, { + propsData, + provide, + stubs: { + GlSprintf, + }, + }); + }; + + const findSignInLink = () => wrapper.findByTestId('sign-in-link'); + const findGitlabUserLink = () => wrapper.findByTestId('gitlab-user-link'); + const findSprintf = () => wrapper.findComponent(GlSprintf); + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each` + userSignedIn | hasSubscriptions | expectGlSprintf | expectGlLink + ${true} | ${false} | ${true} | ${false} + ${false} | ${true} | ${false} | ${true} + ${true} | ${true} | ${true} | ${false} + ${false} | ${false} | ${false} | ${false} + `( + 'when `userSignedIn` is $userSignedIn and `hasSubscriptions` is $hasSubscriptions', + ({ userSignedIn, hasSubscriptions, expectGlSprintf, expectGlLink }) => { + it('renders template correctly', () => { + createComponent({ + userSignedIn, + hasSubscriptions, + }); + + expect(findSprintf().exists()).toBe(expectGlSprintf); + expect(findSignInLink().exists()).toBe(expectGlLink); + }); + }, + ); + + describe('sign in link', () => { + it('renders with correct href', async () => { + const mockUsersPath = '/user'; + createComponent( + { + userSignedIn: false, + hasSubscriptions: true, + }, + { provide: { usersPath: mockUsersPath } }, + ); + + await waitForPromises(); + + expect(findSignInLink().exists()).toBe(true); + expect(findSignInLink().attributes('href')).toBe(mockUsersPath); + }); + }); + + describe('gitlab user link', () => { + window.gon = { current_username: 'root' }; + + beforeEach(() => { + createComponent( + { + userSignedIn: true, + hasSubscriptions: true, + }, + { provide: { gitlabUserPath: '/root' } }, + ); + }); + + it('renders with correct href', () => { + expect(findGitlabUserLink().attributes('href')).toBe('/root'); + }); + + it('contains GitLab user handle', () => { + expect(findGitlabUserLink().text()).toBe('@root'); + }); + }); +}); diff --git a/spec/frontend/jira_connect/subscriptions/index_spec.js b/spec/frontend/jira_connect/subscriptions/index_spec.js deleted file mode 100644 index b97918a198e..00000000000 --- a/spec/frontend/jira_connect/subscriptions/index_spec.js +++ /dev/null @@ -1,36 +0,0 @@ -import { initJiraConnect } from '~/jira_connect/subscriptions'; -import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils'; - -jest.mock('~/jira_connect/subscriptions/utils'); - -describe('initJiraConnect', () => { - const mockInitialHref = 'https://gitlab.com'; - - beforeEach(() => { - setFixtures(` - <a class="js-jira-connect-sign-in" href="${mockInitialHref}">Sign In</a> - <a class="js-jira-connect-sign-in" href="${mockInitialHref}">Another Sign In</a> - `); - }); - - const assertSignInLinks = (expectedLink) => { - Array.from(document.querySelectorAll('.js-jira-connect-sign-in')).forEach((el) => { - expect(el.getAttribute('href')).toBe(expectedLink); - }); - }; - - describe('Sign in links', () => { - it('are updated on initialization', async () => { - const mockSignInLink = `https://gitlab.com?return_to=${encodeURIComponent('/test/location')}`; - getGitlabSignInURL.mockResolvedValue(mockSignInLink); - - // assert the initial state - assertSignInLinks(mockInitialHref); - - await initJiraConnect(); - - // assert the update has occurred - assertSignInLinks(mockSignInLink); - }); - }); -}); diff --git a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap index 9f5b772a5c7..a72528ae36b 100644 --- a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap +++ b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap @@ -152,7 +152,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = aria-label="Search" class="gl-form-input gl-search-box-by-type-input form-control" placeholder="Search" - type="text" + type="search" /> <div @@ -283,7 +283,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = aria-label="Search" class="gl-form-input gl-search-box-by-type-input form-control" placeholder="Search" - type="text" + type="search" /> <div diff --git a/spec/frontend/jobs/bridge/app_spec.js b/spec/frontend/jobs/bridge/app_spec.js new file mode 100644 index 00000000000..0e232ab240d --- /dev/null +++ b/spec/frontend/jobs/bridge/app_spec.js @@ -0,0 +1,33 @@ +import { shallowMount } from '@vue/test-utils'; +import BridgeApp from '~/jobs/bridge/app.vue'; +import BridgeEmptyState from '~/jobs/bridge/components/empty_state.vue'; +import BridgeSidebar from '~/jobs/bridge/components/sidebar.vue'; + +describe('Bridge Show Page', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(BridgeApp, {}); + }; + + const findEmptyState = () => wrapper.findComponent(BridgeEmptyState); + const findSidebar = () => wrapper.findComponent(BridgeSidebar); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders empty state', () => { + expect(findEmptyState().exists()).toBe(true); + }); + + it('renders sidebar', () => { + expect(findSidebar().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/jobs/bridge/components/empty_state_spec.js b/spec/frontend/jobs/bridge/components/empty_state_spec.js new file mode 100644 index 00000000000..83642450118 --- /dev/null +++ b/spec/frontend/jobs/bridge/components/empty_state_spec.js @@ -0,0 +1,59 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import BridgeEmptyState from '~/jobs/bridge/components/empty_state.vue'; +import { MOCK_EMPTY_ILLUSTRATION_PATH, MOCK_PATH_TO_DOWNSTREAM } from '../mock_data'; + +describe('Bridge Empty State', () => { + let wrapper; + + const createComponent = (props) => { + wrapper = shallowMount(BridgeEmptyState, { + provide: { + emptyStateIllustrationPath: MOCK_EMPTY_ILLUSTRATION_PATH, + }, + propsData: { + downstreamPipelinePath: MOCK_PATH_TO_DOWNSTREAM, + ...props, + }, + }); + }; + + const findSvg = () => wrapper.find('img'); + const findTitle = () => wrapper.find('h1'); + const findLinkBtn = () => wrapper.findComponent(GlButton); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders illustration', () => { + expect(findSvg().exists()).toBe(true); + }); + + it('renders title', () => { + expect(findTitle().exists()).toBe(true); + expect(findTitle().text()).toBe(wrapper.vm.$options.i18n.title); + }); + + it('renders CTA button', () => { + expect(findLinkBtn().exists()).toBe(true); + expect(findLinkBtn().text()).toBe(wrapper.vm.$options.i18n.linkBtnText); + expect(findLinkBtn().attributes('href')).toBe(MOCK_PATH_TO_DOWNSTREAM); + }); + }); + + describe('without downstream pipeline', () => { + beforeEach(() => { + createComponent({ downstreamPipelinePath: undefined }); + }); + + it('does not render CTA button', () => { + expect(findLinkBtn().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/jobs/bridge/components/sidebar_spec.js b/spec/frontend/jobs/bridge/components/sidebar_spec.js new file mode 100644 index 00000000000..ba4018753af --- /dev/null +++ b/spec/frontend/jobs/bridge/components/sidebar_spec.js @@ -0,0 +1,76 @@ +import { GlButton, GlDropdown } from '@gitlab/ui'; +import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import BridgeSidebar from '~/jobs/bridge/components/sidebar.vue'; +import { BUILD_NAME } from '../mock_data'; + +describe('Bridge Sidebar', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(BridgeSidebar, { + provide: { + buildName: BUILD_NAME, + }, + }); + }; + + const findSidebar = () => wrapper.find('aside'); + const findRetryDropdown = () => wrapper.find(GlDropdown); + const findToggle = () => wrapper.find(GlButton); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders retry dropdown', () => { + expect(findRetryDropdown().exists()).toBe(true); + }); + }); + + describe('sidebar expansion', () => { + beforeEach(() => { + createComponent(); + }); + + it('toggles expansion on button click', async () => { + expect(findSidebar().classes()).not.toContain('gl-display-none'); + + findToggle().vm.$emit('click'); + await nextTick(); + + expect(findSidebar().classes()).toContain('gl-display-none'); + }); + + describe('on resize', () => { + it.each` + breakpoint | isSidebarExpanded + ${'xs'} | ${false} + ${'sm'} | ${false} + ${'md'} | ${true} + ${'lg'} | ${true} + ${'xl'} | ${true} + `( + 'sets isSidebarExpanded to `$isSidebarExpanded` when the breakpoint is "$breakpoint"', + async ({ breakpoint, isSidebarExpanded }) => { + jest.spyOn(GlBreakpointInstance, 'getBreakpointSize').mockReturnValue(breakpoint); + + window.dispatchEvent(new Event('resize')); + await nextTick(); + + if (isSidebarExpanded) { + expect(findSidebar().classes()).not.toContain('gl-display-none'); + } else { + expect(findSidebar().classes()).toContain('gl-display-none'); + } + }, + ); + }); + }); +}); diff --git a/spec/frontend/jobs/bridge/mock_data.js b/spec/frontend/jobs/bridge/mock_data.js new file mode 100644 index 00000000000..146d1a062ac --- /dev/null +++ b/spec/frontend/jobs/bridge/mock_data.js @@ -0,0 +1,3 @@ +export const MOCK_EMPTY_ILLUSTRATION_PATH = '/path/to/svg'; +export const MOCK_PATH_TO_DOWNSTREAM = '/path/to/downstream/pipeline'; +export const BUILD_NAME = 'Child Pipeline Trigger'; diff --git a/spec/frontend/jobs/components/job_sidebar_details_container_spec.js b/spec/frontend/jobs/components/job_sidebar_details_container_spec.js index ad0368555fa..cc9a5e4ee25 100644 --- a/spec/frontend/jobs/components/job_sidebar_details_container_spec.js +++ b/spec/frontend/jobs/components/job_sidebar_details_container_spec.js @@ -56,7 +56,7 @@ describe('Job Sidebar Details Container', () => { beforeEach(createWrapper); it.each([ - ['duration', 'Duration: 6 seconds'], + ['duration', 'Elapsed time: 6 seconds'], ['erased_at', 'Erased: 3 weeks ago'], ['finished_at', 'Finished: 3 weeks ago'], ['queued', 'Queued: 9 seconds'], @@ -86,6 +86,15 @@ describe('Job Sidebar Details Container', () => { expect(findAllDetailsRow()).toHaveLength(7); }); + + describe('duration row', () => { + it('renders all the details components', async () => { + createWrapper(); + await store.dispatch('receiveJobSuccess', job); + + expect(findAllDetailsRow().at(0).text()).toBe('Duration: 6 seconds'); + }); + }); }); describe('timeout', () => { diff --git a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js index 1b1e2d4df8f..6caf36e1461 100644 --- a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js +++ b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js @@ -5,7 +5,14 @@ import ActionsCell from '~/jobs/components/table/cells/actions_cell.vue'; import JobPlayMutation from '~/jobs/components/table/graphql/mutations/job_play.mutation.graphql'; import JobRetryMutation from '~/jobs/components/table/graphql/mutations/job_retry.mutation.graphql'; import JobUnscheduleMutation from '~/jobs/components/table/graphql/mutations/job_unschedule.mutation.graphql'; -import { playableJob, retryableJob, scheduledJob } from '../../../mock_data'; +import { + playableJob, + retryableJob, + scheduledJob, + cannotRetryJob, + cannotPlayJob, + cannotPlayScheduledJob, +} from '../../../mock_data'; describe('Job actions cell', () => { let wrapper; @@ -51,6 +58,14 @@ describe('Job actions cell', () => { wrapper.destroy(); }); + it('displays the artifacts download button with correct link', () => { + createComponent(playableJob); + + expect(findDownloadArtifactsButton().attributes('href')).toBe( + playableJob.artifacts.nodes[0].downloadPath, + ); + }); + it('does not display an artifacts download button', () => { createComponent(retryableJob); @@ -58,6 +73,17 @@ describe('Job actions cell', () => { }); it.each` + button | action | jobType + ${findPlayButton} | ${'play'} | ${cannotPlayJob} + ${findRetryButton} | ${'retry'} | ${cannotRetryJob} + ${findPlayScheduledJobButton} | ${'play scheduled'} | ${cannotPlayScheduledJob} + `('does not display the $action button if user cannot update build', ({ button, jobType }) => { + createComponent(jobType); + + expect(button().exists()).toBe(false); + }); + + it.each` button | action | jobType ${findPlayButton} | ${'play'} | ${playableJob} ${findRetryButton} | ${'retry'} | ${retryableJob} diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js index 43755b46bc9..45d297ba364 100644 --- a/spec/frontend/jobs/mock_data.js +++ b/spec/frontend/jobs/mock_data.js @@ -1474,6 +1474,7 @@ export const mockJobsInTable = [ export const mockJobsQueryResponse = { data: { project: { + id: '1', jobs: { pageInfo: { endCursor: 'eyJpZCI6IjIzMTcifQ', @@ -1488,15 +1489,18 @@ export const mockJobsQueryResponse = { nodes: [ { downloadPath: '/root/ci-project/-/jobs/2336/artifacts/download?file_type=trace', + fileType: 'TRACE', __typename: 'CiJobArtifact', }, { downloadPath: '/root/ci-project/-/jobs/2336/artifacts/download?file_type=metadata', + fileType: 'METADATA', __typename: 'CiJobArtifact', }, { downloadPath: '/root/ci-project/-/jobs/2336/artifacts/download?file_type=archive', + fileType: 'ARCHIVE', __typename: 'CiJobArtifact', }, ], @@ -1509,6 +1513,7 @@ export const mockJobsQueryResponse = { triggered: null, createdByTag: false, detailedStatus: { + id: 'status-1', detailsPath: '/root/ci-project/-/jobs/2336', group: 'success', icon: 'status_success', @@ -1516,6 +1521,7 @@ export const mockJobsQueryResponse = { text: 'passed', tooltip: 'passed', action: { + id: 'action-1', buttonTitle: 'Retry this job', icon: 'retry', method: 'post', @@ -1535,6 +1541,7 @@ export const mockJobsQueryResponse = { id: 'gid://gitlab/Ci::Pipeline/473', path: '/root/ci-project/-/pipelines/473', user: { + id: 'user-1', webPath: '/root', avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', @@ -1543,6 +1550,7 @@ export const mockJobsQueryResponse = { __typename: 'Pipeline', }, stage: { + id: 'stage-1', name: 'deploy', __typename: 'CiStage', }, @@ -1558,6 +1566,7 @@ export const mockJobsQueryResponse = { userPermissions: { readBuild: true, readJobArtifacts: true, + updateBuild: true, __typename: 'JobPermissions', }, __typename: 'CiJob', @@ -1573,13 +1582,23 @@ export const mockJobsQueryResponse = { export const mockJobsQueryEmptyResponse = { data: { project: { + id: '1', jobs: [], }, }, }; export const retryableJob = { - artifacts: { nodes: [], __typename: 'CiJobArtifactConnection' }, + artifacts: { + nodes: [ + { + downloadPath: '/root/ci-project/-/jobs/847/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, allowFailure: false, status: 'SUCCESS', scheduledAt: null, @@ -1630,15 +1649,31 @@ export const retryableJob = { cancelable: false, active: false, stuck: false, - userPermissions: { readBuild: true, __typename: 'JobPermissions' }, + userPermissions: { readBuild: true, updateBuild: true, __typename: 'JobPermissions' }, __typename: 'CiJob', }; +export const cannotRetryJob = { + ...retryableJob, + userPermissions: { readBuild: true, updateBuild: false, __typename: 'JobPermissions' }, +}; + export const playableJob = { artifacts: { nodes: [ { - downloadPath: '/root/test-job-artifacts/-/jobs/1982/artifacts/download?file_type=trace', + downloadPath: '/root/ci-project/-/jobs/621/artifacts/download?file_type=archive', + fileType: 'ARCHIVE', + __typename: 'CiJobArtifact', + }, + { + downloadPath: '/root/ci-project/-/jobs/621/artifacts/download?file_type=metadata', + fileType: 'METADATA', + __typename: 'CiJobArtifact', + }, + { + downloadPath: '/root/ci-project/-/jobs/621/artifacts/download?file_type=trace', + fileType: 'TRACE', __typename: 'CiJobArtifact', }, ], @@ -1694,10 +1729,25 @@ export const playableJob = { cancelable: false, active: false, stuck: false, - userPermissions: { readBuild: true, readJobArtifacts: true, __typename: 'JobPermissions' }, + userPermissions: { + readBuild: true, + readJobArtifacts: true, + updateBuild: true, + __typename: 'JobPermissions', + }, __typename: 'CiJob', }; +export const cannotPlayJob = { + ...playableJob, + userPermissions: { + readBuild: true, + readJobArtifacts: true, + updateBuild: false, + __typename: 'JobPermissions', + }, +}; + export const scheduledJob = { artifacts: { nodes: [], __typename: 'CiJobArtifactConnection' }, allowFailure: false, @@ -1750,6 +1800,16 @@ export const scheduledJob = { cancelable: false, active: false, stuck: false, - userPermissions: { readBuild: true, __typename: 'JobPermissions' }, + userPermissions: { readBuild: true, updateBuild: true, __typename: 'JobPermissions' }, __typename: 'CiJob', }; + +export const cannotPlayScheduledJob = { + ...scheduledJob, + userPermissions: { + readBuild: true, + readJobArtifacts: true, + updateBuild: false, + __typename: 'JobPermissions', + }, +}; diff --git a/spec/frontend/vue_shared/components/delete_label_modal_spec.js b/spec/frontend/labels/components/delete_label_modal_spec.js index 3905690dab4..6204138f885 100644 --- a/spec/frontend/vue_shared/components/delete_label_modal_spec.js +++ b/spec/frontend/labels/components/delete_label_modal_spec.js @@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils'; import { stubComponent } from 'helpers/stub_component'; import { TEST_HOST } from 'helpers/test_constants'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import DeleteLabelModal from '~/vue_shared/components/delete_label_modal.vue'; +import DeleteLabelModal from '~/labels/components/delete_label_modal.vue'; const MOCK_MODAL_DATA = { labelName: 'label 1', @@ -11,7 +11,7 @@ const MOCK_MODAL_DATA = { destroyPath: `${TEST_HOST}/1`, }; -describe('vue_shared/components/delete_label_modal', () => { +describe('~/labels/components/delete_label_modal', () => { let wrapper; const createComponent = () => { diff --git a/spec/frontend/pages/labels/components/promote_label_modal_spec.js b/spec/frontend/labels/components/promote_label_modal_spec.js index 4d5d1f98b59..d2fbdfc9a8d 100644 --- a/spec/frontend/pages/labels/components/promote_label_modal_spec.js +++ b/spec/frontend/labels/components/promote_label_modal_spec.js @@ -2,8 +2,8 @@ import Vue from 'vue'; import { TEST_HOST } from 'helpers/test_constants'; import mountComponent from 'helpers/vue_mount_component_helper'; import axios from '~/lib/utils/axios_utils'; -import promoteLabelModal from '~/pages/projects/labels/components/promote_label_modal.vue'; -import eventHub from '~/pages/projects/labels/event_hub'; +import promoteLabelModal from '~/labels/components/promote_label_modal.vue'; +import eventHub from '~/labels/event_hub'; describe('Promote label modal', () => { let vm; diff --git a/spec/frontend/delete_label_modal_spec.js b/spec/frontend/labels/delete_label_modal_spec.js index 0b3e6fe652a..c1e6ce87990 100644 --- a/spec/frontend/delete_label_modal_spec.js +++ b/spec/frontend/labels/delete_label_modal_spec.js @@ -1,5 +1,5 @@ import { TEST_HOST } from 'helpers/test_constants'; -import initDeleteLabelModal from '~/delete_label_modal'; +import { initDeleteLabelModal } from '~/labels'; describe('DeleteLabelModal', () => { const buttons = [ diff --git a/spec/frontend/labels_select_spec.js b/spec/frontend/labels/labels_select_spec.js index cbc9a923f8b..f6e280564cc 100644 --- a/spec/frontend/labels_select_spec.js +++ b/spec/frontend/labels/labels_select_spec.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import LabelsSelect from '~/labels_select'; +import LabelsSelect from '~/labels/labels_select'; const mockUrl = '/foo/bar/url'; diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js index de1be5bc337..3e2ba918d9b 100644 --- a/spec/frontend/lib/utils/common_utils_spec.js +++ b/spec/frontend/lib/utils/common_utils_spec.js @@ -1040,4 +1040,15 @@ describe('common_utils', () => { expect(result).toEqual(['hello', 'helloWorld']); }); }); + + describe('convertArrayOfObjectsToCamelCase', () => { + it('returns a new array with snake_case object property names converted camelCase', () => { + const result = commonUtils.convertArrayOfObjectsToCamelCase([ + { hello: '' }, + { hello_world: '' }, + ]); + + expect(result).toEqual([{ hello: '' }, { helloWorld: '' }]); + }); + }); }); diff --git a/spec/frontend/lib/utils/dom_utils_spec.js b/spec/frontend/lib/utils/dom_utils_spec.js index cb8b1c7ca9a..2f240f25d2a 100644 --- a/spec/frontend/lib/utils/dom_utils_spec.js +++ b/spec/frontend/lib/utils/dom_utils_spec.js @@ -6,6 +6,7 @@ import { isElementVisible, isElementHidden, getParents, + setAttributes, } from '~/lib/utils/dom_utils'; const TEST_MARGIN = 5; @@ -208,4 +209,15 @@ describe('DOM Utils', () => { ]); }); }); + + describe('setAttributes', () => { + it('sets multiple attribues on element', () => { + const div = document.createElement('div'); + + setAttributes(div, { class: 'test', title: 'another test' }); + + expect(div.getAttribute('class')).toBe('test'); + expect(div.getAttribute('title')).toBe('another test'); + }); + }); }); diff --git a/spec/frontend/lib/utils/intersection_observer_spec.js b/spec/frontend/lib/utils/intersection_observer_spec.js new file mode 100644 index 00000000000..71b1daffe0d --- /dev/null +++ b/spec/frontend/lib/utils/intersection_observer_spec.js @@ -0,0 +1,86 @@ +import { create } from '~/lib/utils/intersection_observer'; + +describe('IntersectionObserver Utility', () => { + beforeAll(() => { + global.IntersectionObserver = class MockIntersectionObserver { + constructor(callback) { + this.callback = callback; + + this.entries = []; + } + + addEntry(entry) { + this.entries.push(entry); + } + + trigger() { + this.callback(this.entries); + } + }; + }); + describe('create', () => { + describe('memoization', () => { + const options = { rootMargin: '1px 1px 1px 1px' }; + let expectedOutput; + + beforeEach(() => { + create.cache.clear(); + expectedOutput = create(options); + }); + + it('returns the same Observer for the same options input', () => { + expect(expectedOutput.id).toBe(create(options).id); + }); + + it('creates a new Observer for unique input options', () => { + expect(expectedOutput.id).not.toBe(create({ rootMargin: '1px 2px 3px 4px' })); + }); + + it('creates a new Observer for the same input options in different object references', () => { + expect(expectedOutput.id).not.toBe(create({ rootMargin: '1px 1px 1px 1px' })); + }); + }); + }); + + describe('Observer behavior', () => { + let observer = null; + let id = null; + + beforeEach(() => { + create.cache.clear(); + ({ observer, id } = create()); + }); + + it.each` + isIntersecting | event + ${false} | ${'IntersectionDisappear'} + ${true} | ${'IntersectionAppear'} + `( + 'should emit the correct event on the entry target based on the computed Intersection', + async ({ isIntersecting, event }) => { + const target = document.createElement('div'); + observer.addEntry({ target, isIntersecting }); + + target.addEventListener(event, (e) => { + expect(e.detail.observer).toBe(id); + }); + + observer.trigger(); + }, + ); + + it('should always emit an Update event with the entry and the observer', () => { + const target = document.createElement('div'); + const entry = { target }; + + observer.addEntry(entry); + + target.addEventListener('IntersectionUpdate', (e) => { + expect(e.detail.observer).toBe(id); + expect(e.detail.entry).toStrictEqual(entry); + }); + + observer.trigger(); + }); + }); +}); diff --git a/spec/frontend/lib/utils/navigation_utility_spec.js b/spec/frontend/lib/utils/navigation_utility_spec.js index 88172f38894..6a880a0f354 100644 --- a/spec/frontend/lib/utils/navigation_utility_spec.js +++ b/spec/frontend/lib/utils/navigation_utility_spec.js @@ -1,4 +1,5 @@ import findAndFollowLink from '~/lib/utils/navigation_utility'; +import * as navigationUtils from '~/lib/utils/navigation_utility'; import { visitUrl } from '~/lib/utils/url_utility'; jest.mock('~/lib/utils/url_utility'); @@ -21,3 +22,91 @@ describe('findAndFollowLink', () => { expect(visitUrl).not.toHaveBeenCalled(); }); }); + +describe('prefetchDocument', () => { + it('creates a prefetch link tag', () => { + const linkElement = document.createElement('link'); + + jest.spyOn(document, 'createElement').mockImplementation(() => linkElement); + jest.spyOn(document.head, 'appendChild'); + + navigationUtils.prefetchDocument('index.htm'); + + expect(document.head.appendChild).toHaveBeenCalledWith(linkElement); + expect(linkElement.href).toEqual('http://test.host/index.htm'); + expect(linkElement.rel).toEqual('prefetch'); + expect(linkElement.getAttribute('as')).toEqual('document'); + }); +}); + +describe('initPrefetchLinks', () => { + let newLink; + + beforeEach(() => { + newLink = document.createElement('a'); + newLink.href = 'index_prefetch.htm'; + newLink.classList.add('js-test-prefetch-link'); + document.body.appendChild(newLink); + }); + + it('adds to all links mouse out handlers when hovered', () => { + const mouseOverEvent = new Event('mouseover'); + + jest.spyOn(newLink, 'addEventListener'); + + navigationUtils.initPrefetchLinks('.js-test-prefetch-link'); + newLink.dispatchEvent(mouseOverEvent); + + expect(newLink.addEventListener).toHaveBeenCalled(); + }); + + it('it is not fired when less then 100ms over link', () => { + const mouseOverEvent = new Event('mouseover'); + const mouseOutEvent = new Event('mouseout'); + + jest.spyOn(newLink, 'addEventListener'); + jest.spyOn(navigationUtils, 'prefetchDocument').mockImplementation(() => true); + + navigationUtils.initPrefetchLinks('.js-test-prefetch-link'); + newLink.dispatchEvent(mouseOverEvent); + newLink.dispatchEvent(mouseOutEvent); + + expect(navigationUtils.prefetchDocument).not.toHaveBeenCalled(); + }); + + describe('executes correctly when hovering long enough', () => { + const mouseOverEvent = new Event('mouseover'); + + beforeEach(() => { + jest.useFakeTimers(); + + jest.spyOn(global, 'setTimeout'); + jest.spyOn(newLink, 'removeEventListener'); + }); + + it('calls prefetchDocument which adds to document', () => { + jest.spyOn(document.head, 'appendChild'); + + navigationUtils.initPrefetchLinks('.js-test-prefetch-link'); + newLink.dispatchEvent(mouseOverEvent); + + jest.runAllTimers(); + + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 100); + expect(document.head.appendChild).toHaveBeenCalled(); + }); + + it('removes Event Listener when fired so only done once', () => { + navigationUtils.initPrefetchLinks('.js-test-prefetch-link'); + newLink.dispatchEvent(mouseOverEvent); + + jest.runAllTimers(); + + expect(newLink.removeEventListener).toHaveBeenCalledWith( + 'mouseover', + expect.any(Function), + true, + ); + }); + }); +}); 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 7eb0ea37fe6..1a031cc56d6 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 @@ -54,6 +54,8 @@ describe('RemoveMemberButton', () => { }); }; + const findButton = () => wrapper.findComponent(GlButton); + beforeEach(() => { createComponent(); }); @@ -66,7 +68,6 @@ describe('RemoveMemberButton', () => { expect(wrapper.attributes()).toMatchObject({ 'aria-label': 'Remove member', title: 'Remove member', - icon: 'remove', }); }); @@ -75,8 +76,22 @@ describe('RemoveMemberButton', () => { }); it('calls Vuex action to show `remove member` modal when clicked', () => { - wrapper.findComponent(GlButton).vm.$emit('click'); + findButton().vm.$emit('click'); expect(actions.showRemoveMemberModal).toHaveBeenCalledWith(expect.any(Object), modalData); }); + + describe('button optional properties', () => { + it('has default value for category and text', () => { + createComponent(); + expect(findButton().props('category')).toBe('secondary'); + expect(findButton().text()).toBe(''); + }); + + it('allow changing value of button category and text', () => { + createComponent({ buttonCategory: 'primary', buttonText: 'Decline request' }); + expect(findButton().props('category')).toBe('primary'); + expect(findButton().text()).toBe('Decline request'); + }); + }); }); diff --git a/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js index 10e451376c8..356df7e7b11 100644 --- a/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js +++ b/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js @@ -13,6 +13,7 @@ describe('UserActionButtons', () => { propsData: { member, isCurrentUser: false, + isInvitedUser: false, ...propsData, }, }); @@ -45,7 +46,9 @@ describe('UserActionButtons', () => { title: 'Remove member', isAccessRequest: false, isInvite: false, - icon: 'remove', + icon: '', + buttonCategory: 'secondary', + buttonText: 'Remove user', userDeletionObstacles: { name: member.user.name, obstacles: parseUserDeletionObstacles(member.user), @@ -129,4 +132,30 @@ describe('UserActionButtons', () => { expect(findRemoveMemberButton().props().memberType).toBe('ProjectMember'); }); }); + + describe('isInvitedUser', () => { + it.each` + isInvitedUser | icon | buttonText | buttonCategory + ${true} | ${'remove'} | ${null} | ${'primary'} + ${false} | ${''} | ${'Remove user'} | ${'secondary'} + `( + 'passes the correct props to remove-member-button when isInvitedUser is $isInvitedUser', + ({ isInvitedUser, icon, buttonText, buttonCategory }) => { + createComponent({ + isInvitedUser, + permissions: { + canRemove: true, + }, + }); + + expect(findRemoveMemberButton().props()).toEqual( + expect.objectContaining({ + icon, + buttonText, + buttonCategory, + }), + ); + }, + ); + }); }); diff --git a/spec/frontend/members/components/table/member_action_buttons_spec.js b/spec/frontend/members/components/table/member_action_buttons_spec.js index 546d09732d6..1379b2d26ce 100644 --- a/spec/frontend/members/components/table/member_action_buttons_spec.js +++ b/spec/frontend/members/components/table/member_action_buttons_spec.js @@ -14,6 +14,7 @@ describe('MemberActionButtons', () => { wrapper = shallowMount(MemberActionButtons, { propsData: { isCurrentUser: false, + isInvitedUser: false, permissions: { canRemove: true, }, diff --git a/spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js b/spec/frontend/milestones/components/delete_milestone_modal_spec.js index 1fbec0d996d..8978de0e0e0 100644 --- a/spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js +++ b/spec/frontend/milestones/components/delete_milestone_modal_spec.js @@ -3,8 +3,8 @@ import { TEST_HOST } from 'helpers/test_constants'; import mountComponent from 'helpers/vue_mount_component_helper'; import axios from '~/lib/utils/axios_utils'; import { redirectTo } from '~/lib/utils/url_utility'; -import deleteMilestoneModal from '~/pages/milestones/shared/components/delete_milestone_modal.vue'; -import eventHub from '~/pages/milestones/shared/event_hub'; +import deleteMilestoneModal from '~/milestones/components/delete_milestone_modal.vue'; +import eventHub from '~/milestones/event_hub'; jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), diff --git a/spec/frontend/milestones/milestone_combobox_spec.js b/spec/frontend/milestones/components/milestone_combobox_spec.js index 4d1a0a0a440..1af39aff30c 100644 --- a/spec/frontend/milestones/milestone_combobox_spec.js +++ b/spec/frontend/milestones/components/milestone_combobox_spec.js @@ -7,7 +7,7 @@ import Vuex from 'vuex'; import { ENTER_KEY } from '~/lib/utils/keys'; import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue'; import createStore from '~/milestones/stores/'; -import { projectMilestones, groupMilestones } from './mock_data'; +import { projectMilestones, groupMilestones } from '../mock_data'; const extraLinks = [ { text: 'Create new', url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/new' }, diff --git a/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js b/spec/frontend/milestones/components/promote_milestone_modal_spec.js index 4280a78c202..11eaa92f2b0 100644 --- a/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js +++ b/spec/frontend/milestones/components/promote_milestone_modal_spec.js @@ -6,7 +6,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as urlUtils from '~/lib/utils/url_utility'; -import PromoteMilestoneModal from '~/pages/milestones/shared/components/promote_milestone_modal.vue'; +import PromoteMilestoneModal from '~/milestones/components/promote_milestone_modal.vue'; jest.mock('~/lib/utils/url_utility'); jest.mock('~/flash'); diff --git a/spec/frontend/milestones/milestone_utils_spec.js b/spec/frontend/milestones/utils_spec.js index f863f31e5a9..82e31c98398 100644 --- a/spec/frontend/milestones/milestone_utils_spec.js +++ b/spec/frontend/milestones/utils_spec.js @@ -1,5 +1,5 @@ import { useFakeDate } from 'helpers/fake_date'; -import { sortMilestonesByDueDate } from '~/milestones/milestone_utils'; +import { sortMilestonesByDueDate } from '~/milestones/utils'; describe('sortMilestonesByDueDate', () => { useFakeDate(2021, 6, 22); diff --git a/spec/frontend/mocks/mocks_helper.js b/spec/frontend/mocks/mocks_helper.js deleted file mode 100644 index 295483cd64c..00000000000 --- a/spec/frontend/mocks/mocks_helper.js +++ /dev/null @@ -1,58 +0,0 @@ -/** - * @module - * - * This module implements auto-injected manual mocks that are cleaner than Jest's approach. - * - * See https://docs.gitlab.com/ee/development/testing_guide/frontend_testing.html - */ - -import fs from 'fs'; -import path from 'path'; - -import readdir from 'readdir-enhanced'; - -const MAX_DEPTH = 20; -const prefixMap = [ - // E.g. the mock ce/foo/bar maps to require path ~/foo/bar - { mocksRoot: 'ce', requirePrefix: '~' }, - // { mocksRoot: 'ee', requirePrefix: 'ee' }, // We'll deal with EE-specific mocks later - // { mocksRoot: 'virtual', requirePrefix: '' }, // We'll deal with virtual mocks later -]; - -const mockFileFilter = (stats) => stats.isFile() && stats.path.endsWith('.js'); - -const getMockFiles = (root) => readdir.sync(root, { deep: MAX_DEPTH, filter: mockFileFilter }); - -// Function that performs setting a mock. This has to be overridden by the unit test, because -// jest.setMock can't be overwritten across files. -// Use require() because jest.setMock expects the CommonJS exports object -const defaultSetMock = (srcPath, mockPath) => - jest.mock(srcPath, () => jest.requireActual(mockPath)); - -export const setupManualMocks = function setupManualMocks(setMock = defaultSetMock) { - prefixMap.forEach(({ mocksRoot, requirePrefix }) => { - const mocksRootAbsolute = path.join(__dirname, mocksRoot); - if (!fs.existsSync(mocksRootAbsolute)) { - return; - } - - getMockFiles(path.join(__dirname, mocksRoot)).forEach((mockPath) => { - const mockPathNoExt = mockPath.substring(0, mockPath.length - path.extname(mockPath).length); - const sourcePath = path.join(requirePrefix, mockPathNoExt); - const mockPathRelative = `./${path.join(mocksRoot, mockPathNoExt)}`; - - try { - setMock(sourcePath, mockPathRelative); - } catch (e) { - if (e.message.includes('Could not locate module')) { - // The corresponding mocked module doesn't exist. Raise a better error. - // Eventualy, we may support virtual mocks (mocks whose path doesn't directly correspond - // to a module, like with the `ee_else_ce` prefix). - throw new Error( - `A manual mock was defined for module ${sourcePath}, but the module doesn't exist!`, - ); - } - } - }); - }); -}; diff --git a/spec/frontend/mocks/mocks_helper_spec.js b/spec/frontend/mocks/mocks_helper_spec.js deleted file mode 100644 index 0abe5c6b949..00000000000 --- a/spec/frontend/mocks/mocks_helper_spec.js +++ /dev/null @@ -1,131 +0,0 @@ -/* eslint-disable global-require */ - -import path from 'path'; - -import axios from '~/lib/utils/axios_utils'; - -const absPath = path.join.bind(null, __dirname); - -jest.mock('fs'); -jest.mock('readdir-enhanced'); - -describe('mocks_helper.js', () => { - let setupManualMocks; - const setMock = jest.fn().mockName('setMock'); - let fs; - let readdir; - - beforeAll(() => { - jest.resetModules(); - jest.setMock = jest.fn().mockName('jest.setMock'); - fs = require('fs'); - readdir = require('readdir-enhanced'); - - // We need to provide setupManualMocks with a mock function that pretends to do the setup of - // the mock. This is because we can't mock jest.setMock across files. - setupManualMocks = () => require('./mocks_helper').setupManualMocks(setMock); - }); - - afterEach(() => { - fs.existsSync.mockReset(); - readdir.sync.mockReset(); - setMock.mockReset(); - }); - - it('enumerates through mock file roots', () => { - setupManualMocks(); - expect(fs.existsSync).toHaveBeenCalledTimes(1); - expect(fs.existsSync).toHaveBeenNthCalledWith(1, absPath('ce')); - - expect(readdir.sync).toHaveBeenCalledTimes(0); - }); - - it("doesn't traverse the directory tree infinitely", () => { - fs.existsSync.mockReturnValue(true); - readdir.sync.mockReturnValue([]); - setupManualMocks(); - - const readdirSpy = readdir.sync; - expect(readdirSpy).toHaveBeenCalled(); - readdirSpy.mock.calls.forEach((call) => { - expect(call[1].deep).toBeLessThan(100); - }); - }); - - it('sets up mocks for CE (the ~/ prefix)', () => { - fs.existsSync.mockImplementation((root) => root.endsWith('ce')); - readdir.sync.mockReturnValue(['root.js', 'lib/utils/util.js']); - setupManualMocks(); - - expect(readdir.sync).toHaveBeenCalledTimes(1); - expect(readdir.sync.mock.calls[0][0]).toBe(absPath('ce')); - - expect(setMock).toHaveBeenCalledTimes(2); - expect(setMock).toHaveBeenNthCalledWith(1, '~/root', './ce/root'); - expect(setMock).toHaveBeenNthCalledWith(2, '~/lib/utils/util', './ce/lib/utils/util'); - }); - - it('sets up mocks for all roots', () => { - const files = { - [absPath('ce')]: ['root', 'lib/utils/util'], - [absPath('node')]: ['jquery', '@babel/core'], - }; - - fs.existsSync.mockReturnValue(true); - readdir.sync.mockImplementation((root) => files[root]); - setupManualMocks(); - - expect(readdir.sync).toHaveBeenCalledTimes(1); - expect(readdir.sync.mock.calls[0][0]).toBe(absPath('ce')); - - expect(setMock).toHaveBeenCalledTimes(2); - expect(setMock).toHaveBeenNthCalledWith(1, '~/root', './ce/root'); - expect(setMock).toHaveBeenNthCalledWith(2, '~/lib/utils/util', './ce/lib/utils/util'); - }); - - it('fails when given a virtual mock', () => { - fs.existsSync.mockImplementation((p) => p.endsWith('ce')); - readdir.sync.mockReturnValue(['virtual', 'shouldntBeImported']); - setMock.mockImplementation(() => { - throw new Error('Could not locate module'); - }); - - expect(setupManualMocks).toThrow( - new Error("A manual mock was defined for module ~/virtual, but the module doesn't exist!"), - ); - - expect(readdir.sync).toHaveBeenCalledTimes(1); - expect(readdir.sync.mock.calls[0][0]).toBe(absPath('ce')); - }); - - describe('auto-injection', () => { - it('handles ambiguous paths', () => { - jest.isolateModules(() => { - const axios2 = require('../../../app/assets/javascripts/lib/utils/axios_utils').default; - expect(axios2.isMock).toBe(true); - }); - }); - - it('survives jest.isolateModules()', (done) => { - jest.isolateModules(() => { - const axios2 = require('~/lib/utils/axios_utils').default; - expect(axios2.isMock).toBe(true); - done(); - }); - }); - - it('can be unmocked and remocked', () => { - jest.dontMock('~/lib/utils/axios_utils'); - jest.resetModules(); - const axios2 = require('~/lib/utils/axios_utils').default; - expect(axios2).not.toBe(axios); - expect(axios2.isMock).toBeUndefined(); - - jest.doMock('~/lib/utils/axios_utils'); - jest.resetModules(); - const axios3 = require('~/lib/utils/axios_utils').default; - expect(axios3).not.toBe(axios2); - expect(axios3.isMock).toBe(true); - }); - }); -}); diff --git a/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap b/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap index 3229492506a..5d84b4660c9 100644 --- a/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap +++ b/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap @@ -26,7 +26,7 @@ exports[`MR Popover loaded state matches the snapshot 1`] = ` </div> <span - class="text-secondary" + class="gl-text-secondary" > Opened <time> @@ -45,11 +45,11 @@ exports[`MR Popover loaded state matches the snapshot 1`] = ` <h5 class="my-2" > - MR Title + Updated Title </h5> <div - class="text-secondary" + class="gl-text-secondary" > foo/bar!1 @@ -77,14 +77,10 @@ exports[`MR Popover shows skeleton-loader while apollo is loading 1`] = ` /> </div> - <h5 - class="my-2" - > - MR Title - </h5> + <!----> <div - class="text-secondary" + class="gl-text-secondary" > foo/bar!1 diff --git a/spec/frontend/mr_popover/mr_popover_spec.js b/spec/frontend/mr_popover/mr_popover_spec.js index 094d1a6472c..0c6e4211b10 100644 --- a/spec/frontend/mr_popover/mr_popover_spec.js +++ b/spec/frontend/mr_popover/mr_popover_spec.js @@ -15,14 +15,18 @@ describe('MR Popover', () => { }, mocks: { $apollo: { - loading: false, + queries: { + mergeRequest: { + loading: false, + }, + }, }, }, }); }); it('shows skeleton-loader while apollo is loading', () => { - wrapper.vm.$apollo.loading = true; + wrapper.vm.$apollo.queries.mergeRequest.loading = true; return wrapper.vm.$nextTick().then(() => { expect(wrapper.element).toMatchSnapshot(); @@ -33,6 +37,7 @@ describe('MR Popover', () => { it('matches the snapshot', () => { wrapper.setData({ mergeRequest: { + title: 'Updated Title', state: 'opened', createdAt: new Date(), headPipeline: { @@ -64,5 +69,11 @@ describe('MR Popover', () => { expect(wrapper.find(CiIcon).exists()).toBe(false); }); }); + + it('falls back to cached MR title when request fails', () => { + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.text()).toContain('MR Title'); + }); + }); }); }); diff --git a/spec/frontend/notes/components/__snapshots__/notes_app_spec.js.snap b/spec/frontend/notes/components/__snapshots__/notes_app_spec.js.snap new file mode 100644 index 00000000000..5f4b3e04a79 --- /dev/null +++ b/spec/frontend/notes/components/__snapshots__/notes_app_spec.js.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`note_app when sort direction is asc shows skeleton notes after the loaded discussions 1`] = ` +"<ul id=\\"notes-list\\" class=\\"notes main-notes-list timeline\\"> + <noteable-discussion-stub discussion=\\"[object Object]\\" renderdifffile=\\"true\\" helppagepath=\\"\\" isoverviewtab=\\"true\\"></noteable-discussion-stub> + <skeleton-loading-container-stub></skeleton-loading-container-stub> + <discussion-filter-note-stub style=\\"display: none;\\"></discussion-filter-note-stub> +</ul>" +`; + +exports[`note_app when sort direction is desc shows skeleton notes before the loaded discussions 1`] = ` +"<ul id=\\"notes-list\\" class=\\"notes main-notes-list timeline\\"> + <skeleton-loading-container-stub></skeleton-loading-container-stub> + <noteable-discussion-stub discussion=\\"[object Object]\\" renderdifffile=\\"true\\" helppagepath=\\"\\" isoverviewtab=\\"true\\"></noteable-discussion-stub> + <discussion-filter-note-stub style=\\"display: none;\\"></discussion-filter-note-stub> +</ul>" +`; diff --git a/spec/frontend/notes/components/discussion_filter_spec.js b/spec/frontend/notes/components/discussion_filter_spec.js index 6f62b8ba528..17998dfc9d5 100644 --- a/spec/frontend/notes/components/discussion_filter_spec.js +++ b/spec/frontend/notes/components/discussion_filter_spec.js @@ -1,3 +1,4 @@ +import { GlDropdown } from '@gitlab/ui'; import { createLocalVue, mount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import Vuex from 'vuex'; @@ -88,6 +89,12 @@ describe('DiscussionFilter component', () => { ); }); + it('disables the dropdown when discussions are loading', () => { + store.state.isLoading = true; + + expect(wrapper.findComponent(GlDropdown).props('disabled')).toBe(true); + }); + it('updates to the selected item', () => { const filterItem = findFilter(DISCUSSION_FILTER_TYPES.ALL); diff --git a/spec/frontend/notes/components/discussion_notes_spec.js b/spec/frontend/notes/components/discussion_notes_spec.js index ff840a55535..59ac75f00e6 100644 --- a/spec/frontend/notes/components/discussion_notes_spec.js +++ b/spec/frontend/notes/components/discussion_notes_spec.js @@ -1,7 +1,6 @@ import { getByRole } from '@testing-library/dom'; import { shallowMount, mount } from '@vue/test-utils'; import '~/behaviors/markdown/render_gfm'; -import { discussionIntersectionObserverHandlerFactory } from '~/diffs/utils/discussions'; import DiscussionNotes from '~/notes/components/discussion_notes.vue'; import NoteableNote from '~/notes/components/noteable_note.vue'; import { SYSTEM_NOTE } from '~/notes/constants'; @@ -27,9 +26,6 @@ describe('DiscussionNotes', () => { const createComponent = (props, mountingMethod = shallowMount) => { wrapper = mountingMethod(DiscussionNotes, { store, - provide: { - discussionObserverHandler: discussionIntersectionObserverHandlerFactory(), - }, propsData: { discussion: discussionMock, isExpanded: false, diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js index 6aab60edc4e..727ef02dcbb 100644 --- a/spec/frontend/notes/components/noteable_discussion_spec.js +++ b/spec/frontend/notes/components/noteable_discussion_spec.js @@ -3,7 +3,6 @@ import { nextTick } from 'vue'; import discussionWithTwoUnresolvedNotes from 'test_fixtures/merge_requests/resolved_diff_discussion.json'; import { trimText } from 'helpers/text_helper'; import mockDiffFile from 'jest/diffs/mock_data/diff_file'; -import { discussionIntersectionObserverHandlerFactory } from '~/diffs/utils/discussions'; import DiscussionNotes from '~/notes/components/discussion_notes.vue'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; import ResolveWithIssueButton from '~/notes/components/discussion_resolve_with_issue_button.vue'; @@ -32,9 +31,6 @@ describe('noteable_discussion component', () => { wrapper = mount(NoteableDiscussion, { store, - provide: { - discussionObserverHandler: discussionIntersectionObserverHandlerFactory(), - }, propsData: { discussion: discussionMock }, }); }); @@ -171,9 +167,6 @@ describe('noteable_discussion component', () => { wrapper = mount(NoteableDiscussion, { store, - provide: { - discussionObserverHandler: discussionIntersectionObserverHandlerFactory(), - }, propsData: { discussion: discussionMock }, }); }); @@ -192,9 +185,6 @@ describe('noteable_discussion component', () => { wrapper = mount(NoteableDiscussion, { store, - provide: { - discussionObserverHandler: discussionIntersectionObserverHandlerFactory(), - }, propsData: { discussion: discussionMock }, }); }); diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js index b3dbc26878f..84d94857fe5 100644 --- a/spec/frontend/notes/components/notes_app_spec.js +++ b/spec/frontend/notes/components/notes_app_spec.js @@ -9,7 +9,6 @@ import DraftNote from '~/batch_comments/components/draft_note.vue'; import batchComments from '~/batch_comments/stores/modules/batch_comments'; import axios from '~/lib/utils/axios_utils'; import * as urlUtility from '~/lib/utils/url_utility'; -import { discussionIntersectionObserverHandlerFactory } from '~/diffs/utils/discussions'; import CommentForm from '~/notes/components/comment_form.vue'; import NotesApp from '~/notes/components/notes_app.vue'; import * as constants from '~/notes/constants'; @@ -79,9 +78,6 @@ describe('note_app', () => { </div>`, }, { - provide: { - discussionObserverHandler: discussionIntersectionObserverHandlerFactory(), - }, propsData, store, }, @@ -378,6 +374,9 @@ describe('note_app', () => { beforeEach(() => { store = createStore(); store.state.discussionSortOrder = constants.DESC; + store.state.isLoading = true; + store.state.discussions = [mockData.discussionMock]; + wrapper = shallowMount(NotesApp, { propsData, store, @@ -390,11 +389,18 @@ describe('note_app', () => { it('finds CommentForm before notes list', () => { expect(getComponentOrder()).toStrictEqual([TYPE_COMMENT_FORM, TYPE_NOTES_LIST]); }); + + it('shows skeleton notes before the loaded discussions', () => { + expect(wrapper.find('#notes-list').html()).toMatchSnapshot(); + }); }); describe('when sort direction is asc', () => { beforeEach(() => { store = createStore(); + store.state.isLoading = true; + store.state.discussions = [mockData.discussionMock]; + wrapper = shallowMount(NotesApp, { propsData, store, @@ -407,6 +413,10 @@ describe('note_app', () => { it('finds CommentForm after notes list', () => { expect(getComponentOrder()).toStrictEqual([TYPE_NOTES_LIST, TYPE_COMMENT_FORM]); }); + + it('shows skeleton notes after the loaded discussions', () => { + expect(wrapper.find('#notes-list').html()).toMatchSnapshot(); + }); }); describe('when multiple draft types are present', () => { diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index bbe074f0105..7424a87bc0f 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -1183,8 +1183,14 @@ describe('Actions Notes Store', () => { dispatch.mockReturnValue(new Promise(() => {})); }); + it('clears existing discussions', () => { + actions.filterDiscussion({ commit, dispatch }, { path, filter, persistFilter: false }); + + expect(commit.mock.calls).toEqual([[mutationTypes.CLEAR_DISCUSSIONS]]); + }); + it('fetches discussions with filter and persistFilter false', () => { - actions.filterDiscussion({ dispatch }, { path, filter, persistFilter: false }); + actions.filterDiscussion({ commit, dispatch }, { path, filter, persistFilter: false }); expect(dispatch.mock.calls).toEqual([ ['setLoadingState', true], @@ -1193,7 +1199,7 @@ describe('Actions Notes Store', () => { }); it('fetches discussions with filter and persistFilter true', () => { - actions.filterDiscussion({ dispatch }, { path, filter, persistFilter: true }); + actions.filterDiscussion({ commit, dispatch }, { path, filter, persistFilter: true }); expect(dispatch.mock.calls).toEqual([ ['setLoadingState', true], diff --git a/spec/frontend/notes/stores/mutation_spec.js b/spec/frontend/notes/stores/mutation_spec.js index c9e24039b64..da1547ab6e7 100644 --- a/spec/frontend/notes/stores/mutation_spec.js +++ b/spec/frontend/notes/stores/mutation_spec.js @@ -159,6 +159,18 @@ describe('Notes Store mutations', () => { }); }); + describe('CLEAR_DISCUSSIONS', () => { + it('should set discussions to an empty array', () => { + const state = { + discussions: [discussionMock], + }; + + mutations.CLEAR_DISCUSSIONS(state); + + expect(state.discussions).toEqual([]); + }); + }); + describe('ADD_OR_UPDATE_DISCUSSIONS', () => { it('should set the initial notes received', () => { const state = { diff --git a/spec/frontend/packages/shared/utils_spec.js b/spec/frontend/packages/shared/utils_spec.js deleted file mode 100644 index a1076b729f8..00000000000 --- a/spec/frontend/packages/shared/utils_spec.js +++ /dev/null @@ -1,69 +0,0 @@ -import { PackageType, TrackingCategories } from '~/packages/shared/constants'; -import { - packageTypeToTrackCategory, - beautifyPath, - getPackageTypeLabel, - getCommitLink, -} from '~/packages/shared/utils'; -import { packageList } from '../mock_data'; - -describe('Packages shared utils', () => { - describe('packageTypeToTrackCategory', () => { - it('prepend UI to package category', () => { - expect(packageTypeToTrackCategory()).toMatchInlineSnapshot(`"UI::undefined"`); - }); - - it.each(Object.keys(PackageType))('returns a correct category string for %s', (packageKey) => { - const packageName = PackageType[packageKey]; - expect(packageTypeToTrackCategory(packageName)).toBe( - `UI::${TrackingCategories[packageName]}`, - ); - }); - }); - - describe('beautifyPath', () => { - it('returns a string with spaces around /', () => { - expect(beautifyPath('foo/bar')).toBe('foo / bar'); - }); - it('does not fail for empty string', () => { - expect(beautifyPath()).toBe(''); - }); - }); - - 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); - }); - }); - }); - - describe('getCommitLink', () => { - it('returns a relative link when isGroup is false', () => { - const link = getCommitLink(packageList[0], false); - - expect(link).toContain('../commit'); - }); - - describe('when isGroup is true', () => { - it('returns an absolute link matching project path', () => { - const mavenPackage = packageList[0]; - const link = getCommitLink(mavenPackage, true); - - expect(link).toContain(`/${mavenPackage.project_path}/commit`); - }); - }); - }); -}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js index 9a42c82d7e0..56f12e2f0bb 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js @@ -1,18 +1,16 @@ -import { GlButton, GlKeysetPagination } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { stripTypenames } from 'helpers/graphql_helpers'; import EmptyTagsState from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue'; import component from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue'; import TagsListRow from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue'; import TagsLoader from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue'; -import { - TAGS_LIST_TITLE, - REMOVE_TAGS_BUTTON_TITLE, -} from '~/packages_and_registries/container_registry/explorer/constants/index'; +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; import getContainerRepositoryTagsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql'; +import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/container_registry/explorer/constants/index'; import { tagsMock, imageTagsMock, tagsPageInfo } from '../../mock_data'; const localVue = createLocalVue(); @@ -20,25 +18,20 @@ const localVue = createLocalVue(); describe('Tags List', () => { let wrapper; let apolloProvider; + let resolver; const tags = [...tagsMock]; - const readOnlyTags = tags.map((t) => ({ ...t, canDelete: false })); - const findTagsListRow = () => wrapper.findAll(TagsListRow); - const findDeleteButton = () => wrapper.find(GlButton); - const findListTitle = () => wrapper.find('[data-testid="list-title"]'); - const findPagination = () => wrapper.find(GlKeysetPagination); - const findEmptyState = () => wrapper.find(EmptyTagsState); - const findTagsLoader = () => wrapper.find(TagsLoader); + const findTagsListRow = () => wrapper.findAllComponents(TagsListRow); + const findRegistryList = () => wrapper.findComponent(RegistryList); + const findEmptyState = () => wrapper.findComponent(EmptyTagsState); + const findTagsLoader = () => wrapper.findComponent(TagsLoader); const waitForApolloRequestRender = async () => { await waitForPromises(); await nextTick(); }; - const mountComponent = ({ - propsData = { isMobile: false, id: 1 }, - resolver = jest.fn().mockResolvedValue(imageTagsMock()), - } = {}) => { + const mountComponent = ({ propsData = { isMobile: false, id: 1 } } = {}) => { localVue.use(VueApollo); const requestHandlers = [[getContainerRepositoryTagsQuery, resolver]]; @@ -48,6 +41,7 @@ describe('Tags List', () => { localVue, apolloProvider, propsData, + stubs: { RegistryList }, provide() { return { config: {}, @@ -56,99 +50,58 @@ describe('Tags List', () => { }); }; + beforeEach(() => { + resolver = jest.fn().mockResolvedValue(imageTagsMock()); + }); + afterEach(() => { wrapper.destroy(); - wrapper = null; }); - describe('List title', () => { - it('exists', async () => { + describe('registry list', () => { + beforeEach(() => { mountComponent(); - await waitForApolloRequestRender(); - - expect(findListTitle().exists()).toBe(true); + return waitForApolloRequestRender(); }); - it('has the correct text', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findListTitle().text()).toBe(TAGS_LIST_TITLE); + it('binds the correct props', () => { + expect(findRegistryList().props()).toMatchObject({ + title: '2 tags', + pagination: stripTypenames(tagsPageInfo), + items: stripTypenames(tags), + idProperty: 'name', + }); }); - }); - describe('delete button', () => { - it.each` - inputTags | isMobile | isVisible - ${tags} | ${false} | ${true} - ${tags} | ${true} | ${false} - ${readOnlyTags} | ${false} | ${false} - ${readOnlyTags} | ${true} | ${false} - `( - 'is $isVisible that delete button exists when tags is $inputTags and isMobile is $isMobile', - async ({ inputTags, isMobile, isVisible }) => { - mountComponent({ - propsData: { tags: inputTags, isMobile, id: 1 }, - resolver: jest.fn().mockResolvedValue(imageTagsMock(inputTags)), + describe('events', () => { + it('prev-page fetch the previous page', () => { + findRegistryList().vm.$emit('prev-page'); + + expect(resolver).toHaveBeenCalledWith({ + first: null, + before: tagsPageInfo.startCursor, + last: GRAPHQL_PAGE_SIZE, + id: '1', }); - - await waitForApolloRequestRender(); - - expect(findDeleteButton().exists()).toBe(isVisible); - }, - ); - - it('has the correct text', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findDeleteButton().text()).toBe(REMOVE_TAGS_BUTTON_TITLE); - }); - - it('has the correct props', async () => { - mountComponent(); - await waitForApolloRequestRender(); - - expect(findDeleteButton().attributes()).toMatchObject({ - category: 'secondary', - variant: 'danger', }); - }); - - it.each` - disabled | doSelect | buttonDisabled - ${true} | ${false} | ${'true'} - ${true} | ${true} | ${'true'} - ${false} | ${false} | ${'true'} - ${false} | ${true} | ${undefined} - `( - 'is $buttonDisabled that the button is disabled when the component disabled state is $disabled and is $doSelect that the user selected a tag', - async ({ disabled, buttonDisabled, doSelect }) => { - mountComponent({ propsData: { tags, disabled, isMobile: false, id: 1 } }); - - await waitForApolloRequestRender(); - - if (doSelect) { - findTagsListRow().at(0).vm.$emit('select'); - await nextTick(); - } - expect(findDeleteButton().attributes('disabled')).toBe(buttonDisabled); - }, - ); + it('next-page fetch the previous page', () => { + findRegistryList().vm.$emit('next-page'); - it('click event emits a deleted event with selected items', async () => { - mountComponent(); - - await waitForApolloRequestRender(); + expect(resolver).toHaveBeenCalledWith({ + after: tagsPageInfo.endCursor, + first: GRAPHQL_PAGE_SIZE, + id: '1', + }); + }); - findTagsListRow().at(0).vm.$emit('select'); - findDeleteButton().vm.$emit('click'); + it('emits a delete event when list emits delete', () => { + const eventPayload = 'foo'; + findRegistryList().vm.$emit('delete', eventPayload); - expect(wrapper.emitted('delete')[0][0][0].name).toBe(tags[0].name); + expect(wrapper.emitted('delete')).toEqual([[eventPayload]]); + }); }); }); @@ -199,10 +152,12 @@ describe('Tags List', () => { }); describe('when the list of tags is empty', () => { - const resolver = jest.fn().mockResolvedValue(imageTagsMock([])); + beforeEach(() => { + resolver = jest.fn().mockResolvedValue(imageTagsMock([])); + }); it('has the empty state', async () => { - mountComponent({ resolver }); + mountComponent(); await waitForApolloRequestRender(); @@ -210,7 +165,7 @@ describe('Tags List', () => { }); it('does not show the loader', async () => { - mountComponent({ resolver }); + mountComponent(); await waitForApolloRequestRender(); @@ -218,76 +173,13 @@ describe('Tags List', () => { }); it('does not show the list', async () => { - mountComponent({ resolver }); - - await waitForApolloRequestRender(); - - expect(findTagsListRow().exists()).toBe(false); - expect(findListTitle().exists()).toBe(false); - }); - }); - - describe('pagination', () => { - it('exists', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findPagination().exists()).toBe(true); - }); - - it('is hidden when loading', () => { mountComponent(); - expect(findPagination().exists()).toBe(false); - }); - - it('is hidden when there are no more pages', async () => { - mountComponent({ resolver: jest.fn().mockResolvedValue(imageTagsMock([])) }); - await waitForApolloRequestRender(); - expect(findPagination().exists()).toBe(false); - }); - - it('is wired to the correct pagination props', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findPagination().props()).toMatchObject({ - hasNextPage: tagsPageInfo.hasNextPage, - hasPreviousPage: tagsPageInfo.hasPreviousPage, - }); - }); - - it('fetch next page when user clicks next', async () => { - const resolver = jest.fn().mockResolvedValue(imageTagsMock()); - mountComponent({ resolver }); - - await waitForApolloRequestRender(); - - findPagination().vm.$emit('next'); - - expect(resolver).toHaveBeenCalledWith( - expect.objectContaining({ after: tagsPageInfo.endCursor }), - ); - }); - - it('fetch previous page when user clicks prev', async () => { - const resolver = jest.fn().mockResolvedValue(imageTagsMock()); - mountComponent({ resolver }); - - await waitForApolloRequestRender(); - - findPagination().vm.$emit('prev'); - - expect(resolver).toHaveBeenCalledWith( - expect.objectContaining({ first: null, before: tagsPageInfo.startCursor }), - ); + expect(findRegistryList().exists()).toBe(false); }); }); - describe('loading state', () => { it.each` isImageLoading | queryExecuting | loadingVisible @@ -306,8 +198,6 @@ describe('Tags List', () => { expect(findTagsLoader().exists()).toBe(loadingVisible); expect(findTagsListRow().exists()).toBe(!loadingVisible); - expect(findListTitle().exists()).toBe(!loadingVisible); - expect(findPagination().exists()).toBe(!loadingVisible); }, ); }); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap index 46b07b4c2d6..4b52e84d1a6 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap @@ -36,6 +36,7 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` <gl-form-input-group-stub class="gl-mb-4" + inputclass="" predefinedoptions="[object Object]" value="" > @@ -57,6 +58,7 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` <gl-form-input-group-stub class="gl-mb-4" + inputclass="" predefinedoptions="[object Object]" value="" > @@ -69,6 +71,7 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` </gl-form-input-group-stub> <gl-form-input-group-stub + inputclass="" predefinedoptions="[object Object]" value="" > diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js index 6a835a28807..16625d913a5 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js @@ -37,6 +37,7 @@ export const graphQLImageListMock = { data: { project: { __typename: 'Project', + id: '1', containerRepositoriesCount: 2, containerRepositories: { __typename: 'ContainerRepositoryConnection', @@ -51,6 +52,7 @@ export const graphQLEmptyImageListMock = { data: { project: { __typename: 'Project', + id: '1', containerRepositoriesCount: 2, containerRepositories: { __typename: 'ContainerRepositoryConnection', @@ -65,6 +67,7 @@ export const graphQLEmptyGroupImageListMock = { data: { group: { __typename: 'Group', + id: '1', containerRepositoriesCount: 2, containerRepositories: { __typename: 'ContainerRepositoryConnection', @@ -120,6 +123,7 @@ export const containerRepositoryMock = { project: { visibility: 'public', path: 'gitlab-test', + id: '1', containerExpirationPolicy: { enabled: false, nextRunAt: '2020-11-27T08:59:27Z', @@ -167,6 +171,7 @@ export const imageTagsMock = (nodes = tagsMock) => ({ data: { containerRepository: { id: containerRepositoryMock.id, + tagsCount: nodes.length, tags: { nodes, pageInfo: { ...tagsPageInfo }, @@ -191,7 +196,7 @@ export const graphQLImageDetailsMock = (override) => ({ data: { containerRepository: { ...containerRepositoryMock, - + tagsCount: tagsMock.length, tags: { nodes: tagsMock, pageInfo: { ...tagsPageInfo }, @@ -242,6 +247,7 @@ export const dockerCommands = { export const graphQLProjectImageRepositoriesDetailsMock = { data: { project: { + id: '1', containerRepositories: { nodes: [ { diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js index adc9a64e5c9..9b821ba8ef3 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js @@ -1,6 +1,7 @@ import { GlKeysetPagination } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; +import { nextTick } from 'vue'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; @@ -22,6 +23,7 @@ import { } from '~/packages_and_registries/container_registry/explorer/constants'; import deleteContainerRepositoryTagsMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql'; import getContainerRepositoryDetailsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql'; +import getContainerRepositoryTagsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql'; import component from '~/packages_and_registries/container_registry/explorer/pages/details.vue'; import Tracking from '~/tracking'; @@ -32,6 +34,7 @@ import { containerRepositoryMock, graphQLEmptyImageDetailsMock, tagsMock, + imageTagsMock, } from '../mock_data'; import { DeleteModal } from '../stubs'; @@ -67,12 +70,13 @@ describe('Details Page', () => { const waitForApolloRequestRender = async () => { await waitForPromises(); - await wrapper.vm.$nextTick(); + await nextTick(); }; const mountComponent = ({ resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock()), mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock), + tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock)), options, config = {}, } = {}) => { @@ -81,6 +85,7 @@ describe('Details Page', () => { const requestHandlers = [ [getContainerRepositoryDetailsQuery, resolver], [deleteContainerRepositoryTagsMutation, mutationResolver], + [getContainerRepositoryTagsQuery, tagsResolver], ]; apolloProvider = createMockApollo(requestHandlers); @@ -242,38 +247,49 @@ describe('Details Page', () => { describe('confirmDelete event', () => { let mutationResolver; + let tagsResolver; beforeEach(() => { mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock); - mountComponent({ mutationResolver }); + tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock)); + mountComponent({ mutationResolver, tagsResolver }); return waitForApolloRequestRender(); }); + describe('when one item is selected to be deleted', () => { - it('calls apollo mutation with the right parameters', async () => { + it('calls apollo mutation with the right parameters and refetches the tags list query', async () => { findTagsList().vm.$emit('delete', [cleanTags[0]]); - await wrapper.vm.$nextTick(); + await nextTick(); findDeleteModal().vm.$emit('confirmDelete'); expect(mutationResolver).toHaveBeenCalledWith( expect.objectContaining({ tagNames: [cleanTags[0].name] }), ); + + await waitForPromises(); + + expect(tagsResolver).toHaveBeenCalled(); }); }); describe('when more than one item is selected to be deleted', () => { - it('calls apollo mutation with the right parameters', async () => { + it('calls apollo mutation with the right parameters and refetches the tags list query', async () => { findTagsList().vm.$emit('delete', tagsMock); - await wrapper.vm.$nextTick(); + await nextTick(); findDeleteModal().vm.$emit('confirmDelete'); expect(mutationResolver).toHaveBeenCalledWith( expect.objectContaining({ tagNames: tagsMock.map((t) => t.name) }), ); + + await waitForPromises(); + + expect(tagsResolver).toHaveBeenCalled(); }); }); }); @@ -382,7 +398,7 @@ describe('Details Page', () => { findPartialCleanupAlert().vm.$emit('dismiss'); - await wrapper.vm.$nextTick(); + await nextTick(); expect(axios.post).toHaveBeenCalledWith(config.userCalloutsPath, { feature_name: config.userCalloutId, @@ -472,7 +488,7 @@ describe('Details Page', () => { await waitForApolloRequestRender(); findDetailsHeader().vm.$emit('delete'); - await wrapper.vm.$nextTick(); + await nextTick(); }; it('on delete event it deletes the image', async () => { @@ -497,13 +513,13 @@ describe('Details Page', () => { findDeleteImage().vm.$emit('start'); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findTagsLoader().exists()).toBe(true); findDeleteImage().vm.$emit('end'); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findTagsLoader().exists()).toBe(false); }); @@ -513,7 +529,7 @@ describe('Details Page', () => { findDeleteImage().vm.$emit('error'); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findDeleteAlert().props('deleteAlertType')).toBe(ALERT_DANGER_IMAGE); }); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js index 625f00a8666..44a7186904d 100644 --- a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js +++ b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js @@ -54,7 +54,6 @@ describe('DependencyProxyApp', () => { } const findProxyNotAvailableAlert = () => wrapper.findByTestId('proxy-not-available'); - const findProxyDisabledAlert = () => wrapper.findByTestId('proxy-disabled'); const findClipBoardButton = () => wrapper.findComponent(ClipboardButton); const findFormGroup = () => wrapper.findComponent(GlFormGroup); const findFormInputGroup = () => wrapper.findComponent(GlFormInputGroup); @@ -219,28 +218,6 @@ describe('DependencyProxyApp', () => { }); }); }); - - describe('when the dependency proxy is disabled', () => { - beforeEach(() => { - resolver = jest - .fn() - .mockResolvedValue(proxyDetailsQuery({ extendSettings: { enabled: false } })); - createComponent(); - return waitForPromises(); - }); - - it('does not show the main area', () => { - expect(findMainArea().exists()).toBe(false); - }); - - it('does not show the loader', () => { - expect(findSkeletonLoader().exists()).toBe(false); - }); - - it('shows a proxy disabled alert', () => { - expect(findProxyDisabledAlert().text()).toBe(DependencyProxyApp.i18n.proxyDisabledText); - }); - }); }); }); }); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js b/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js index 8bad22b5287..2aa427bc6af 100644 --- a/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js +++ b/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js @@ -8,8 +8,8 @@ export const proxyData = () => ({ export const proxySettings = (extend = {}) => ({ enabled: true, ...extend }); export const proxyManifests = () => [ - { createdAt: '2021-09-22T09:45:28Z', imageName: 'alpine:latest' }, - { createdAt: '2021-09-21T09:45:28Z', imageName: 'alpine:stable' }, + { id: 'proxy-1', createdAt: '2021-09-22T09:45:28Z', imageName: 'alpine:latest' }, + { id: 'proxy-2', createdAt: '2021-09-21T09:45:28Z', imageName: 'alpine:stable' }, ]; export const pagination = (extend) => ({ @@ -26,6 +26,7 @@ export const proxyDetailsQuery = ({ extendSettings = {}, extend } = {}) => ({ group: { ...proxyData(), __typename: 'Group', + id: '1', dependencyProxySetting: { ...proxySettings(extendSettings), __typename: 'DependencyProxySetting', diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js index c7c10cef504..2868af84181 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js @@ -9,15 +9,15 @@ import PackagesApp from '~/packages_and_registries/infrastructure_registry/detai import PackageFiles from '~/packages_and_registries/infrastructure_registry/details/components/package_files.vue'; import PackageHistory from '~/packages_and_registries/infrastructure_registry/details/components/package_history.vue'; import * as getters from '~/packages_and_registries/infrastructure_registry/details/store/getters'; -import PackageListRow from '~/packages/shared/components/package_list_row.vue'; -import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; -import { TrackingActions } from '~/packages/shared/constants'; -import * as SharedUtils from '~/packages/shared/utils'; +import PackageListRow from '~/packages_and_registries/infrastructure_registry/shared/package_list_row.vue'; +import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; +import { TRACKING_ACTIONS } from '~/packages_and_registries/shared/constants'; +import { TRACK_CATEGORY } from '~/packages_and_registries/infrastructure_registry/shared/constants'; import TerraformTitle from '~/packages_and_registries/infrastructure_registry/details/components/details_title.vue'; import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue'; import Tracking from '~/tracking'; -import { mavenPackage, mavenFiles, npmPackage } from 'jest/packages/mock_data'; +import { mavenPackage, mavenFiles, npmPackage } from '../../mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -232,87 +232,78 @@ describe('PackagesApp', () => { describe('tracking', () => { let eventSpy; - let utilSpy; - const category = 'foo'; beforeEach(() => { eventSpy = jest.spyOn(Tracking, 'event'); - utilSpy = jest.spyOn(SharedUtils, 'packageTypeToTrackCategory').mockReturnValue(category); }); - it('tracking category calls packageTypeToTrackCategory', () => { - createComponent({ packageEntity: npmPackage }); - expect(wrapper.vm.tracking.category).toBe(category); - expect(utilSpy).toHaveBeenCalledWith('npm'); - }); - - it(`delete button on delete modal call event with ${TrackingActions.DELETE_PACKAGE}`, () => { + it(`delete button on delete modal call event with ${TRACKING_ACTIONS.DELETE_PACKAGE}`, () => { createComponent({ packageEntity: npmPackage }); findDeleteModal().vm.$emit('primary'); expect(eventSpy).toHaveBeenCalledWith( - category, - TrackingActions.DELETE_PACKAGE, + TRACK_CATEGORY, + TRACKING_ACTIONS.DELETE_PACKAGE, expect.any(Object), ); }); - it(`canceling a package deletion tracks ${TrackingActions.CANCEL_DELETE_PACKAGE}`, () => { + it(`canceling a package deletion tracks ${TRACKING_ACTIONS.CANCEL_DELETE_PACKAGE}`, () => { createComponent({ packageEntity: npmPackage }); findDeleteModal().vm.$emit('canceled'); expect(eventSpy).toHaveBeenCalledWith( - category, - TrackingActions.CANCEL_DELETE_PACKAGE, + TRACK_CATEGORY, + TRACKING_ACTIONS.CANCEL_DELETE_PACKAGE, expect.any(Object), ); }); - it(`request a file deletion tracks ${TrackingActions.REQUEST_DELETE_PACKAGE_FILE}`, () => { + it(`request a file deletion tracks ${TRACKING_ACTIONS.REQUEST_DELETE_PACKAGE_FILE}`, () => { createComponent({ packageEntity: npmPackage }); findPackageFiles().vm.$emit('delete-file', mavenFiles[0]); expect(eventSpy).toHaveBeenCalledWith( - category, - TrackingActions.REQUEST_DELETE_PACKAGE_FILE, + TRACK_CATEGORY, + TRACKING_ACTIONS.REQUEST_DELETE_PACKAGE_FILE, expect.any(Object), ); }); - it(`confirming a file deletion tracks ${TrackingActions.DELETE_PACKAGE_FILE}`, () => { + it(`confirming a file deletion tracks ${TRACKING_ACTIONS.DELETE_PACKAGE_FILE}`, () => { createComponent({ packageEntity: npmPackage }); findPackageFiles().vm.$emit('delete-file', npmPackage); findDeleteFileModal().vm.$emit('primary'); expect(eventSpy).toHaveBeenCalledWith( - category, - TrackingActions.REQUEST_DELETE_PACKAGE_FILE, + TRACK_CATEGORY, + TRACKING_ACTIONS.REQUEST_DELETE_PACKAGE_FILE, expect.any(Object), ); }); - it(`canceling a file deletion tracks ${TrackingActions.CANCEL_DELETE_PACKAGE_FILE}`, () => { + it(`canceling a file deletion tracks ${TRACKING_ACTIONS.CANCEL_DELETE_PACKAGE_FILE}`, () => { createComponent({ packageEntity: npmPackage }); findPackageFiles().vm.$emit('delete-file', npmPackage); findDeleteFileModal().vm.$emit('canceled'); expect(eventSpy).toHaveBeenCalledWith( - category, - TrackingActions.CANCEL_DELETE_PACKAGE_FILE, + TRACK_CATEGORY, + TRACKING_ACTIONS.CANCEL_DELETE_PACKAGE_FILE, expect.any(Object), ); }); - it(`file download link call event with ${TrackingActions.PULL_PACKAGE}`, () => { + it(`file download link call event with ${TRACKING_ACTIONS.PULL_PACKAGE}`, () => { createComponent({ packageEntity: npmPackage }); findPackageFiles().vm.$emit('download-file'); expect(eventSpy).toHaveBeenCalledWith( - category, - TrackingActions.PULL_PACKAGE, + TRACK_CATEGORY, + TRACKING_ACTIONS.PULL_PACKAGE, expect.any(Object), ); }); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js index a012ec4ab05..24bd80ba80c 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js @@ -1,8 +1,8 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; -import { terraformModule, mavenFiles, npmPackage } from 'jest/packages/mock_data'; import component from '~/packages_and_registries/infrastructure_registry/details/components/details_title.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import { terraformModule, mavenFiles, npmPackage } from '../../mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js index 0c5aa30223b..6b6c33b7561 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js @@ -6,7 +6,7 @@ import component from '~/packages_and_registries/infrastructure_registry/details import FileIcon from '~/vue_shared/components/file_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import { npmFiles, mavenFiles } from 'jest/packages/mock_data'; +import { npmFiles, mavenFiles } from '../../mock_data'; describe('Package Files', () => { let wrapper; diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js index 4987af9f5b0..f10f05f4a0d 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js @@ -6,7 +6,7 @@ import { HISTORY_PIPELINES_LIMIT } from '~/packages_and_registries/shared/consta import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import { mavenPackage, mockPipelineInfo } from 'jest/packages/mock_data'; +import { mavenPackage, mockPipelineInfo } from '../../mock_data'; describe('Package History', () => { let wrapper; diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/terraform_installation_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/terraform_installation_spec.js index c26784a4b75..6ff4a4c51ef 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/terraform_installation_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/terraform_installation_spec.js @@ -1,8 +1,8 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; -import { terraformModule as packageEntity } from 'jest/packages/mock_data'; import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue'; import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; +import { terraformModule as packageEntity } from '../../mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js index 61fa69c2f7a..b9383d6c38c 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js @@ -12,8 +12,8 @@ import { DELETE_PACKAGE_ERROR_MESSAGE, DELETE_PACKAGE_FILE_ERROR_MESSAGE, DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, -} from '~/packages/shared/constants'; -import { npmPackage as packageEntity } from '../../../../../packages/mock_data'; +} from '~/packages_and_registries/shared/constants'; +import { npmPackage as packageEntity } from '../../mock_data'; jest.mock('~/flash.js'); jest.mock('~/api.js'); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/getters_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/getters_spec.js index 8740691a8ee..b14aaa93e1f 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/getters_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/getters_spec.js @@ -3,7 +3,7 @@ import { npmPackage, mockPipelineInfo, mavenPackage as packageWithoutBuildInfo, -} from 'jest/packages/mock_data'; +} from '../../mock_data'; describe('Getters PackageDetails Store', () => { let state; diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/mutations_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/mutations_spec.js index 6efefea4a14..0f0c84af7da 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/mutations_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/mutations_spec.js @@ -1,6 +1,6 @@ import * as types from '~/packages_and_registries/infrastructure_registry/details/store/mutation_types'; import mutations from '~/packages_and_registries/infrastructure_registry/details/store/mutations'; -import { npmPackage as packageEntity } from 'jest/packages/mock_data'; +import { npmPackage as packageEntity } from '../../mock_data'; describe('Mutations package details Store', () => { let mockState; diff --git a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap index 67e2594d29f..99a7b8e427a 100644 --- a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap @@ -34,12 +34,16 @@ exports[`packages_list_app renders 1`] = ` class="text-content gl-mx-auto gl-my-0 gl-p-5" > <h1 - class="h4" + class="gl-font-size-h-display gl-line-height-36 h4" > - There are no packages yet + + There are no packages yet + </h1> - <p> + <p + class="gl-mt-3" + > Learn how to <b-link-stub class="gl-link" diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_search_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_search_spec.js index 119b678cc37..b519ab00d06 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_search_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_search_spec.js @@ -1,6 +1,6 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; -import component from '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue'; +import component from '~/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_title_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_title_spec.js index db6e175b054..b0e586f189a 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_title_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_title_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import component from '~/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue'; +import component from '~/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; diff --git a/spec/frontend/packages/list/components/packages_list_app_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_app_spec.js index 5f7555a3a2b..cad75d2a858 100644 --- a/spec/frontend/packages/list/components/packages_list_app_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_app_spec.js @@ -4,12 +4,15 @@ 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'; -import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants'; -import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import PackageListApp from '~/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue'; +import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages_and_registries/infrastructure_registry/list/constants'; +import { + SHOW_DELETE_SUCCESS_ALERT, + FILTERED_SEARCH_TERM, +} from '~/packages_and_registries/shared/constants'; + import * as packageUtils from '~/packages_and_registries/shared/utils'; -import InfrastructureSearch from '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue'; +import InfrastructureSearch from '~/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue'; jest.mock('~/lib/utils/common_utils'); jest.mock('~/flash'); diff --git a/spec/frontend/packages/list/components/packages_list_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js index b1478a5e6dc..2fb76b98925 100644 --- a/spec/frontend/packages/list/components/packages_list_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js @@ -3,11 +3,11 @@ import { mount, createLocalVue } from '@vue/test-utils'; import { last } from 'lodash'; import Vuex from 'vuex'; import stubChildren from 'helpers/stub_children'; -import PackagesList from '~/packages/list/components/packages_list.vue'; -import PackagesListRow from '~/packages/shared/components/package_list_row.vue'; -import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; -import { TrackingActions } from '~/packages/shared/constants'; -import * as SharedUtils from '~/packages/shared/utils'; +import PackagesList from '~/packages_and_registries/infrastructure_registry/list/components/packages_list.vue'; +import PackagesListRow from '~/packages_and_registries/infrastructure_registry/shared/package_list_row.vue'; +import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; +import { TRACKING_ACTIONS } from '~/packages_and_registries/shared/constants'; +import { TRACK_CATEGORY } from '~/packages_and_registries/infrastructure_registry/shared/constants'; import Tracking from '~/tracking'; import { packageList } from '../../mock_data'; @@ -190,26 +190,18 @@ describe('packages_list', () => { describe('tracking', () => { let eventSpy; - let utilSpy; - const category = 'foo'; beforeEach(() => { mountComponent(); eventSpy = jest.spyOn(Tracking, 'event'); - utilSpy = jest.spyOn(SharedUtils, 'packageTypeToTrackCategory').mockReturnValue(category); wrapper.setData({ itemToBeDeleted: { package_type: 'conan' } }); }); - it('tracking category calls packageTypeToTrackCategory', () => { - expect(wrapper.vm.tracking.category).toBe(category); - expect(utilSpy).toHaveBeenCalledWith('conan'); - }); - it('deleteItemConfirmation calls event', () => { wrapper.vm.deleteItemConfirmation(); expect(eventSpy).toHaveBeenCalledWith( - category, - TrackingActions.DELETE_PACKAGE, + TRACK_CATEGORY, + TRACKING_ACTIONS.DELETE_PACKAGE, expect.any(Object), ); }); diff --git a/spec/frontend/packages/list/stores/actions_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js index adccb7436e1..3fbfe1060dc 100644 --- a/spec/frontend/packages/list/stores/actions_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js @@ -3,10 +3,10 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import Api from '~/api'; import createFlash from '~/flash'; -import { MISSING_DELETE_PATH_ERROR } from '~/packages/list/constants'; -import * as actions from '~/packages/list/stores/actions'; -import * as types from '~/packages/list/stores/mutation_types'; -import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages/shared/constants'; +import { MISSING_DELETE_PATH_ERROR } from '~/packages_and_registries/infrastructure_registry/list/constants'; +import * as actions from '~/packages_and_registries/infrastructure_registry/list/stores/actions'; +import * as types from '~/packages_and_registries/infrastructure_registry/list/stores/mutation_types'; +import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages_and_registries/shared/constants'; jest.mock('~/flash.js'); jest.mock('~/api.js'); diff --git a/spec/frontend/packages/list/stores/getters_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/getters_spec.js index 080bbc21d9f..f2d52ace34e 100644 --- a/spec/frontend/packages/list/stores/getters_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/getters_spec.js @@ -1,4 +1,4 @@ -import getList from '~/packages/list/stores/getters'; +import getList from '~/packages_and_registries/infrastructure_registry/list/stores/getters'; import { packageList } from '../../mock_data'; describe('Getters registry list store', () => { diff --git a/spec/frontend/packages/list/stores/mutations_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/mutations_spec.js index 2ddf3a1da33..afd7a7e5439 100644 --- a/spec/frontend/packages/list/stores/mutations_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/mutations_spec.js @@ -1,7 +1,7 @@ import * as commonUtils from '~/lib/utils/common_utils'; -import * as types from '~/packages/list/stores/mutation_types'; -import mutations from '~/packages/list/stores/mutations'; -import createState from '~/packages/list/stores/state'; +import * as types from '~/packages_and_registries/infrastructure_registry/list/stores/mutation_types'; +import mutations from '~/packages_and_registries/infrastructure_registry/list/stores/mutations'; +import createState from '~/packages_and_registries/infrastructure_registry/list/stores/state'; import { npmPackage, mavenPackage } from '../../mock_data'; describe('Mutations Registry Store', () => { diff --git a/spec/frontend/packages/list/utils_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/utils_spec.js index 4e4f7b8a723..a897fb90522 100644 --- a/spec/frontend/packages/list/utils_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/utils_spec.js @@ -1,5 +1,8 @@ -import { SORT_FIELDS } from '~/packages/list/constants'; -import { getNewPaginationPage, sortableFields } from '~/packages/list/utils'; +import { SORT_FIELDS } from '~/packages_and_registries/infrastructure_registry/list/constants'; +import { + getNewPaginationPage, + sortableFields, +} from '~/packages_and_registries/infrastructure_registry/list/utils'; describe('Packages list utils', () => { describe('sortableFields', () => { diff --git a/spec/frontend/packages/mock_data.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/mock_data.js index 33b47cca68b..33b47cca68b 100644 --- a/spec/frontend/packages/mock_data.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/mock_data.js diff --git a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/__snapshots__/package_list_row_spec.js.snap index b576f1b2553..67c3b8b795a 100644 --- a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/__snapshots__/package_list_row_spec.js.snap @@ -6,7 +6,7 @@ exports[`packages_list_row renders 1`] = ` data-qa-selector="package_row" > <div - class="gl-display-flex gl-align-items-center gl-py-3 gl-px-5" + class="gl-display-flex gl-align-items-center gl-py-3" > <!----> @@ -86,7 +86,7 @@ exports[`packages_list_row renders 1`] = ` </div> <div - class="gl-w-9 gl-display-none gl-sm-display-flex gl-justify-content-end gl-pr-1" + class="gl-w-9 gl-display-flex gl-justify-content-end gl-pr-1" > <gl-button-stub aria-label="Remove package" diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/infrastructure_icon_and_name_spec.js index ef26c729691..abb0d23b6e4 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/infrastructure_icon_and_name_spec.js @@ -1,6 +1,6 @@ import { GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import InfrastructureIconAndName from '~/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue'; +import InfrastructureIconAndName from '~/packages_and_registries/infrastructure_registry/shared/infrastructure_icon_and_name.vue'; describe('InfrastructureIconAndName', () => { let wrapper; diff --git a/spec/frontend/packages/shared/components/package_list_row_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/package_list_row_spec.js index 5f2fc8ddfbd..1052fdd1dda 100644 --- a/spec/frontend/packages/shared/components/package_list_row_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/package_list_row_spec.js @@ -2,13 +2,13 @@ import { GlLink } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import PackagesListRow from '~/packages/shared/components/package_list_row.vue'; -import PackagePath from '~/packages/shared/components/package_path.vue'; -import PackageTags from '~/packages/shared/components/package_tags.vue'; -import { PACKAGE_ERROR_STATUS } from '~/packages/shared/constants'; +import PackagesListRow from '~/packages_and_registries/infrastructure_registry/shared/package_list_row.vue'; +import PackagePath from '~/packages_and_registries/shared/components/package_path.vue'; +import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; +import { PACKAGE_ERROR_STATUS } from '~/packages_and_registries/shared/constants'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; -import { packageList } from '../../mock_data'; +import { packageList } from '../mock_data'; describe('packages_list_row', () => { let wrapper; @@ -17,12 +17,10 @@ describe('packages_list_row', () => { const [packageWithoutTags, packageWithTags] = packageList; const InfrastructureIconAndName = { name: 'InfrastructureIconAndName', template: '<div></div>' }; - const PackageIconAndName = { name: 'PackageIconAndName', template: '<div></div>' }; const findPackageTags = () => wrapper.findComponent(PackageTags); const findPackagePath = () => wrapper.findComponent(PackagePath); const findDeleteButton = () => wrapper.findByTestId('action-delete'); - const findPackageIconAndName = () => wrapper.findComponent(PackageIconAndName); const findInfrastructureIconAndName = () => wrapper.findComponent(InfrastructureIconAndName); const findListItem = () => wrapper.findComponent(ListItem); const findPackageLink = () => wrapper.findComponent(GlLink); @@ -41,7 +39,6 @@ describe('packages_list_row', () => { stubs: { ListItem, InfrastructureIconAndName, - PackageIconAndName, }, propsData: { packageLink: 'foo', @@ -93,13 +90,13 @@ describe('packages_list_row', () => { it('shows the type when set', () => { mountComponent(); - expect(findPackageIconAndName().exists()).toBe(true); + expect(findInfrastructureIconAndName().exists()).toBe(true); }); it('does not show the type when not set', () => { mountComponent({ showPackageType: false }); - expect(findPackageIconAndName().exists()).toBe(false); + expect(findInfrastructureIconAndName().exists()).toBe(false); }); }); @@ -135,27 +132,6 @@ describe('packages_list_row', () => { }); }); - describe('Infrastructure config', () => { - it('defaults to package registry components', () => { - mountComponent(); - - expect(findPackageIconAndName().exists()).toBe(true); - expect(findInfrastructureIconAndName().exists()).toBe(false); - }); - - it('mounts different component based on the provided values', () => { - mountComponent({ - provide: { - iconComponent: 'InfrastructureIconAndName', - }, - }); - - expect(findPackageIconAndName().exists()).toBe(false); - - expect(findInfrastructureIconAndName().exists()).toBe(true); - }); - }); - describe(`when the package is in ${PACKAGE_ERROR_STATUS} status`, () => { beforeEach(() => { mountComponent({ packageEntity: { ...packageWithoutTags, status: PACKAGE_ERROR_STATUS } }); 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 index c95538546c1..7aa42a1f1e5 100644 --- 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 @@ -5,7 +5,7 @@ exports[`VersionRow renders 1`] = ` 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" + class="gl-display-flex gl-align-items-center gl-py-3" > <!----> diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js index d59c3184e4e..6ad6007c9da 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js @@ -2,7 +2,7 @@ import { GlIcon, GlSprintf } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import PackageTags from '~/packages/shared/components/package_tags.vue'; +import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue'; import { PACKAGE_TYPE_CONAN, 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 index f7613949fe4..faeca76d746 100644 --- 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 @@ -1,8 +1,8 @@ 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 PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; +import PublishMethod from '~/packages_and_registries/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'; diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap index 2f2be797251..165ee962417 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap @@ -6,7 +6,7 @@ exports[`packages_list_row renders 1`] = ` data-qa-selector="package_row" > <div - class="gl-display-flex gl-align-items-center gl-py-3 gl-px-5" + class="gl-display-flex gl-align-items-center gl-py-3" > <!----> @@ -77,7 +77,9 @@ exports[`packages_list_row renders 1`] = ` <div class="gl-display-flex gl-align-items-center gl-min-h-6" > - <span> + <span + data-testid="created-date" + > Created <timeago-tooltip-stub cssclass="" @@ -90,7 +92,7 @@ exports[`packages_list_row renders 1`] = ` </div> <div - class="gl-w-9 gl-display-none gl-sm-display-flex gl-justify-content-end gl-pr-1" + class="gl-w-9 gl-display-flex gl-justify-content-end gl-pr-1" > <gl-button-stub aria-label="Remove package" diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/publish_method_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/publish_method_spec.js.snap index 919dbe25ffe..4407c4a2003 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/publish_method_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/publish_method_spec.js.snap @@ -37,6 +37,7 @@ exports[`publish_method renders 1`] = ` text="b83d6e391c22777fca1ed3012fce84f633d7fed0" title="Copy commit SHA" tooltipplacement="top" + variant="default" /> </div> `; diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js index a276db104d7..292667ec47c 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js @@ -3,9 +3,11 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue'; -import PackagePath from '~/packages/shared/components/package_path.vue'; -import PackageTags from '~/packages/shared/components/package_tags.vue'; -import PackageIconAndName from '~/packages/shared/components/package_icon_and_name.vue'; +import PackagePath from '~/packages_and_registries/shared/components/package_path.vue'; +import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; +import PackageIconAndName from '~/packages_and_registries/shared/components/package_icon_and_name.vue'; +import PublishMethod from '~/packages_and_registries/package_registry/components/list/publish_method.vue'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { PACKAGE_ERROR_STATUS } from '~/packages_and_registries/package_registry/constants'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; @@ -29,6 +31,9 @@ describe('packages_list_row', () => { const findPackageLink = () => wrapper.findComponent(GlLink); const findWarningIcon = () => wrapper.findByTestId('warning-icon'); const findLeftSecondaryInfos = () => wrapper.findByTestId('left-secondary-infos'); + const findPublishMethod = () => wrapper.findComponent(PublishMethod); + const findCreatedDateText = () => wrapper.findByTestId('created-date'); + const findTimeAgoTooltip = () => wrapper.findComponent(TimeagoTooltip); const mountComponent = ({ packageEntity = packageWithoutTags, @@ -153,4 +158,23 @@ describe('packages_list_row', () => { expect(findPackageIconAndName().text()).toBe(packageWithoutTags.packageType.toLowerCase()); }); }); + + describe('right info', () => { + it('has publish method component', () => { + mountComponent({ + packageEntity: { ...packageWithoutTags, pipelines: { nodes: packagePipelines() } }, + }); + + expect(findPublishMethod().props('pipeline')).toEqual(packagePipelines()[0]); + }); + + it('has the created date', () => { + mountComponent(); + + expect(findCreatedDateText().text()).toMatchInterpolatedText(PackagesListRow.i18n.createdAt); + expect(findTimeAgoTooltip().props()).toMatchObject({ + time: packageData().createdAt, + }); + }); + }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js index de4e9c8ae5b..97978dee909 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js @@ -1,8 +1,8 @@ import { GlKeysetPagination, GlModal, GlSprintf } from '@gitlab/ui'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import PackagesListRow from '~/packages/shared/components/package_list_row.vue'; -import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; +import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue'; +import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; import { DELETE_PACKAGE_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_TRACKING_ACTION, diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js index bacc748db81..4c23b52b8a2 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -16,11 +16,13 @@ export const packagePipelines = (extend) => [ ref: 'master', sha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0', project: { + id: '1', name: 'project14', webUrl: 'http://gdk.test:3000/namespace14/project14', __typename: 'Project', }, user: { + id: 'user-1', name: 'Administrator', }, ...extend, @@ -89,6 +91,7 @@ export const dependencyLinks = () => [ ]; export const packageProject = () => ({ + id: '1', fullPath: 'gitlab-org/gitlab-test', webUrl: 'http://gdk.test:3000/gitlab-org/gitlab-test', __typename: 'Project', @@ -127,6 +130,7 @@ export const packageData = (extend) => ({ }); export const conanMetadata = () => ({ + id: 'conan-1', packageChannel: 'stable', packageUsername: 'gitlab-org+gitlab-test', recipe: 'package-8/1.0.0@gitlab-org+gitlab-test/stable', @@ -179,6 +183,7 @@ export const packageDetailsQuery = (extendPackage) => ({ ...nugetMetadata(), }, project: { + id: '1', path: 'projectPath', }, tags: { @@ -270,6 +275,7 @@ export const packageDestroyFileMutationError = () => ({ export const packagesListQuery = ({ type = 'group', extend = {}, extendPagination = {} } = {}) => ({ data: { [type]: { + id: '1', packages: { count: 2, nodes: [ diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/app_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap index 5af75868084..dbe3c70c3cb 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/app_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap @@ -4,7 +4,7 @@ exports[`PackagesListApp renders 1`] = ` <div> <package-title-stub count="2" - helpurl="packageHelpUrl" + helpurl="/help/user/packages/index" /> <package-search-stub /> @@ -35,17 +35,21 @@ exports[`PackagesListApp renders 1`] = ` class="text-content gl-mx-auto gl-my-0 gl-p-5" > <h1 - class="h4" + class="gl-font-size-h-display gl-line-height-36 h4" > - There are no packages yet + + There are no packages yet + </h1> - <p> + <p + class="gl-mt-3" + > Learn how to <b-link-stub class="gl-link" event="click" - href="emptyListHelpUrl" + href="/help/user/packages/package_registry/index" routertag="a" target="_blank" > diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/app_spec.js b/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js index ad848f367e0..2ac2a6455ef 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/app_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js @@ -6,7 +6,7 @@ import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import PackageListApp from '~/packages_and_registries/package_registry/components/list/app.vue'; +import ListPage from '~/packages_and_registries/package_registry/pages/list.vue'; import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue'; import PackageSearch from '~/packages_and_registries/package_registry/components/list/package_search.vue'; import OriginalPackageList from '~/packages_and_registries/package_registry/components/list/packages_list.vue'; @@ -16,11 +16,13 @@ import { PROJECT_RESOURCE_TYPE, GROUP_RESOURCE_TYPE, GRAPHQL_PAGE_SIZE, + EMPTY_LIST_HELP_URL, + PACKAGE_HELP_URL, } from '~/packages_and_registries/package_registry/constants'; import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql'; -import { packagesListQuery, packageData, pagination } from '../../mock_data'; +import { packagesListQuery, packageData, pagination } from '../mock_data'; jest.mock('~/lib/utils/common_utils'); jest.mock('~/flash'); @@ -32,9 +34,7 @@ describe('PackagesListApp', () => { let apolloProvider; const defaultProvide = { - packageHelpUrl: 'packageHelpUrl', emptyListIllustration: 'emptyListIllustration', - emptyListHelpUrl: 'emptyListHelpUrl', isGroupPage: true, fullPath: 'gitlab-org', }; @@ -66,7 +66,7 @@ describe('PackagesListApp', () => { const requestHandlers = [[getPackagesQuery, resolver]]; apolloProvider = createMockApollo(requestHandlers); - wrapper = shallowMountExtended(PackageListApp, { + wrapper = shallowMountExtended(ListPage, { localVue, apolloProvider, provide, @@ -113,7 +113,10 @@ describe('PackagesListApp', () => { await waitForFirstRequest(); expect(findPackageTitle().exists()).toBe(true); - expect(findPackageTitle().props('count')).toBe(2); + expect(findPackageTitle().props()).toMatchObject({ + count: 2, + helpUrl: PACKAGE_HELP_URL, + }); }); describe('search component', () => { @@ -213,12 +216,12 @@ describe('PackagesListApp', () => { it('generate the correct empty list link', () => { const link = findListComponent().findComponent(GlLink); - expect(link.attributes('href')).toBe(defaultProvide.emptyListHelpUrl); + expect(link.attributes('href')).toBe(EMPTY_LIST_HELP_URL); expect(link.text()).toBe('publish and share your packages'); }); it('includes the right content on the default tab', () => { - expect(findEmptyState().text()).toContain(PackageListApp.i18n.emptyPageTitle); + expect(findEmptyState().text()).toContain(ListPage.i18n.emptyPageTitle); }); }); @@ -234,8 +237,8 @@ describe('PackagesListApp', () => { }); it('should show specific empty message', () => { - expect(findEmptyState().text()).toContain(PackageListApp.i18n.noResultsTitle); - expect(findEmptyState().text()).toContain(PackageListApp.i18n.widenFilters); + expect(findEmptyState().text()).toContain(ListPage.i18n.noResultsTitle); + expect(findEmptyState().text()).toContain(ListPage.i18n.widenFilters); }); }); diff --git a/spec/frontend/packages_and_registries/settings/group/components/__snapshots__/settings_titles_spec.js.snap b/spec/frontend/packages_and_registries/settings/group/components/__snapshots__/settings_titles_spec.js.snap index f2087733d2b..5b56cb7f74e 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/__snapshots__/settings_titles_spec.js.snap +++ b/spec/frontend/packages_and_registries/settings/group/components/__snapshots__/settings_titles_spec.js.snap @@ -3,7 +3,7 @@ exports[`settings_titles renders properly 1`] = ` <div> <h5 - class="gl-border-b-solid gl-border-b-1 gl-border-gray-200" + class="gl-border-b-solid gl-border-b-1 gl-border-gray-200 gl-pb-3" > foo diff --git a/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js index d3a970e86eb..f6c1d212b51 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js @@ -1,6 +1,7 @@ -import { GlSprintf, GlLink, GlToggle } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlSprintf, GlToggle } from '@gitlab/ui'; +import { createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -12,14 +13,21 @@ import { } from '~/packages_and_registries/settings/group/constants'; import updateDependencyProxySettings from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql'; +import updateDependencyProxyImageTtlGroupPolicy from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_image_ttl_group_policy.mutation.graphql'; import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql'; import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; -import { updateGroupDependencyProxySettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; +import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue'; import { - dependencyProxySettings, + updateGroupDependencyProxySettingsOptimisticResponse, + updateDependencyProxyImageTtlGroupPolicyOptimisticResponse, +} from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; +import { + dependencyProxySettings as dependencyProxySettingsMock, + dependencyProxyImageTtlPolicy as dependencyProxyImageTtlPolicyMock, dependencyProxySettingMutationMock, groupPackageSettingsMock, - dependencyProxySettingMutationErrorMock, + mutationErrorMock, + dependencyProxyUpdateTllPolicyMutationMock, } from '../mock_data'; jest.mock('~/flash'); @@ -30,46 +38,68 @@ const localVue = createLocalVue(); describe('DependencyProxySettings', () => { let wrapper; let apolloProvider; + let updateSettingsMutationResolver; + let updateTtlPoliciesMutationResolver; const defaultProvide = { defaultExpanded: false, groupPath: 'foo_group_path', + groupDependencyProxyPath: 'group_dependency_proxy_path', }; localVue.use(VueApollo); const mountComponent = ({ provide = defaultProvide, - mutationResolver = jest.fn().mockResolvedValue(dependencyProxySettingMutationMock()), isLoading = false, + dependencyProxySettings = dependencyProxySettingsMock(), + dependencyProxyImageTtlPolicy = dependencyProxyImageTtlPolicyMock(), } = {}) => { - const requestHandlers = [[updateDependencyProxySettings, mutationResolver]]; + const requestHandlers = [ + [updateDependencyProxySettings, updateSettingsMutationResolver], + [updateDependencyProxyImageTtlGroupPolicy, updateTtlPoliciesMutationResolver], + ]; apolloProvider = createMockApollo(requestHandlers); - wrapper = shallowMount(component, { + wrapper = shallowMountExtended(component, { localVue, apolloProvider, provide, propsData: { - dependencyProxySettings: dependencyProxySettings(), + dependencyProxySettings, + dependencyProxyImageTtlPolicy, isLoading, }, stubs: { GlSprintf, + GlToggle, SettingsBlock, }, }); }; + beforeEach(() => { + updateSettingsMutationResolver = jest + .fn() + .mockResolvedValue(dependencyProxySettingMutationMock()); + updateTtlPoliciesMutationResolver = jest + .fn() + .mockResolvedValue(dependencyProxyUpdateTllPolicyMutationMock()); + }); + afterEach(() => { wrapper.destroy(); }); const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); - const findDescription = () => wrapper.find('[data-testid="description"'); - const findLink = () => wrapper.findComponent(GlLink); - const findToggle = () => wrapper.findComponent(GlToggle); + const findSettingsTitles = () => wrapper.findComponent(SettingsTitles); + const findDescription = () => wrapper.findByTestId('description'); + const findDescriptionLink = () => wrapper.findByTestId('description-link'); + const findEnableProxyToggle = () => wrapper.findByTestId('dependency-proxy-setting-toggle'); + const findEnableTtlPoliciesToggle = () => + wrapper.findByTestId('dependency-proxy-ttl-policies-toggle'); + const findToggleHelpLink = () => wrapper.findByTestId('toggle-help-link'); const fillApolloCache = () => { apolloProvider.defaultClient.cache.writeQuery({ @@ -81,10 +111,6 @@ describe('DependencyProxySettings', () => { }); }; - const emitSettingsUpdate = (value = false) => { - findToggle().vm.$emit('change', value); - }; - it('renders a settings block', () => { mountComponent(); @@ -112,19 +138,93 @@ describe('DependencyProxySettings', () => { it('has the correct link', () => { mountComponent(); - expect(findLink().attributes()).toMatchObject({ + expect(findDescriptionLink().attributes()).toMatchObject({ href: DEPENDENCY_PROXY_DOCS_PATH, }); - expect(findLink().text()).toBe('Learn more'); + expect(findDescriptionLink().text()).toBe('Learn more'); + }); + + describe('enable toggle', () => { + it('exists', () => { + mountComponent(); + + expect(findEnableProxyToggle().props()).toMatchObject({ + label: component.i18n.enabledProxyLabel, + }); + }); + + describe('when enabled', () => { + beforeEach(() => { + mountComponent(); + }); + + it('has the help prop correctly set', () => { + expect(findEnableProxyToggle().props()).toMatchObject({ + help: component.i18n.enabledProxyHelpText, + }); + }); + + it('has help text with a link', () => { + expect(findEnableProxyToggle().text()).toContain( + 'To see the image prefix and what is in the cache, visit the Dependency Proxy', + ); + expect(findToggleHelpLink().attributes()).toMatchObject({ + href: defaultProvide.groupDependencyProxyPath, + }); + }); + }); + + describe('when disabled', () => { + beforeEach(() => { + mountComponent({ + dependencyProxySettings: dependencyProxySettingsMock({ enabled: false }), + }); + }); + + it('has the help prop set to empty', () => { + expect(findEnableProxyToggle().props()).toMatchObject({ + help: '', + }); + }); + + it('the help text is not visible', () => { + expect(findToggleHelpLink().exists()).toBe(false); + }); + }); + }); + + describe('storage settings', () => { + it('the component has the settings title', () => { + mountComponent(); + + expect(findSettingsTitles().props()).toMatchObject({ + title: component.i18n.storageSettingsTitle, + }); + }); + + describe('enable proxy ttl policies', () => { + it('exists', () => { + mountComponent(); + + expect(findEnableTtlPoliciesToggle().props()).toMatchObject({ + label: component.i18n.ttlPolicyEnabledLabel, + help: component.i18n.ttlPolicyEnabledHelpText, + }); + }); + }); }); - describe('settings update', () => { + describe.each` + toggleName | toggleFinder | localErrorMock | optimisticResponse + ${'enable proxy'} | ${findEnableProxyToggle} | ${dependencyProxySettingMutationMock} | ${updateGroupDependencyProxySettingsOptimisticResponse} + ${'enable ttl policies'} | ${findEnableTtlPoliciesToggle} | ${dependencyProxyUpdateTllPolicyMutationMock} | ${updateDependencyProxyImageTtlGroupPolicyOptimisticResponse} + `('$toggleName settings update ', ({ optimisticResponse, toggleFinder, localErrorMock }) => { describe('success state', () => { it('emits a success event', async () => { mountComponent(); fillApolloCache(); - emitSettingsUpdate(); + toggleFinder().vm.$emit('change', false); await waitForPromises(); @@ -136,26 +236,28 @@ describe('DependencyProxySettings', () => { fillApolloCache(); - expect(findToggle().props('value')).toBe(true); + expect(toggleFinder().props('value')).toBe(true); - emitSettingsUpdate(); + toggleFinder().vm.$emit('change', false); - expect(updateGroupDependencyProxySettingsOptimisticResponse).toHaveBeenCalledWith({ - enabled: false, - }); + expect(optimisticResponse).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + }), + ); }); }); describe('errors', () => { it('mutation payload with root level errors', async () => { - const mutationResolver = jest - .fn() - .mockResolvedValue(dependencyProxySettingMutationErrorMock); - mountComponent({ mutationResolver }); + updateSettingsMutationResolver = jest.fn().mockResolvedValue(mutationErrorMock); + updateTtlPoliciesMutationResolver = jest.fn().mockResolvedValue(mutationErrorMock); + + mountComponent(); fillApolloCache(); - emitSettingsUpdate(); + toggleFinder().vm.$emit('change', false); await waitForPromises(); @@ -163,14 +265,16 @@ describe('DependencyProxySettings', () => { }); it.each` - type | mutationResolver - ${'local'} | ${jest.fn().mockResolvedValue(dependencyProxySettingMutationMock({ errors: ['foo'] }))} + type | mutationResolverMock + ${'local'} | ${jest.fn().mockResolvedValue(localErrorMock({ errors: ['foo'] }))} ${'network'} | ${jest.fn().mockRejectedValue()} - `('mutation payload with $type error', async ({ mutationResolver }) => { - mountComponent({ mutationResolver }); + `('mutation payload with $type error', async ({ mutationResolverMock }) => { + updateSettingsMutationResolver = mutationResolverMock; + updateTtlPoliciesMutationResolver = mutationResolverMock; + mountComponent(); fillApolloCache(); - emitSettingsUpdate(); + toggleFinder().vm.$emit('change', false); await waitForPromises(); @@ -180,10 +284,16 @@ describe('DependencyProxySettings', () => { }); describe('when isLoading is true', () => { - it('disables enable toggle', () => { + it('disables enable proxy toggle', () => { + mountComponent({ isLoading: true }); + + expect(findEnableProxyToggle().props('disabled')).toBe(true); + }); + + it('disables enable ttl policies toggle', () => { mountComponent({ isLoading: true }); - expect(findToggle().props('disabled')).toBe(true); + expect(findEnableTtlPoliciesToggle().props('disabled')).toBe(true); }); }); }); diff --git a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js index e4d62bc6a6e..933dac7f5a8 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js @@ -10,7 +10,12 @@ import DependencyProxySettings from '~/packages_and_registries/settings/group/co import component from '~/packages_and_registries/settings/group/components/group_settings_app.vue'; import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql'; -import { groupPackageSettingsMock, packageSettings, dependencyProxySettings } from '../mock_data'; +import { + groupPackageSettingsMock, + packageSettings, + dependencyProxySettings, + dependencyProxyImageTtlPolicy, +} from '../mock_data'; jest.mock('~/flash'); @@ -66,11 +71,17 @@ describe('Group Settings App', () => { await nextTick(); }; + const packageSettingsProps = { packageSettings: packageSettings() }; + const dependencyProxyProps = { + dependencyProxySettings: dependencyProxySettings(), + dependencyProxyImageTtlPolicy: dependencyProxyImageTtlPolicy(), + }; + describe.each` - finder | entityProp | entityValue | successMessage | errorMessage - ${findPackageSettings} | ${'packageSettings'} | ${packageSettings()} | ${'Settings saved successfully'} | ${'An error occurred while saving the settings'} - ${findDependencyProxySettings} | ${'dependencyProxySettings'} | ${dependencyProxySettings()} | ${'Setting saved successfully'} | ${'An error occurred while saving the setting'} - `('settings blocks', ({ finder, entityProp, entityValue, successMessage, errorMessage }) => { + finder | entitySpecificProps | successMessage | errorMessage + ${findPackageSettings} | ${packageSettingsProps} | ${'Settings saved successfully'} | ${'An error occurred while saving the settings'} + ${findDependencyProxySettings} | ${dependencyProxyProps} | ${'Setting saved successfully'} | ${'An error occurred while saving the setting'} + `('settings blocks', ({ finder, entitySpecificProps, successMessage, errorMessage }) => { beforeEach(() => { mountComponent(); return waitForApolloQueryAndRender(); @@ -83,7 +94,7 @@ describe('Group Settings App', () => { it('binds the correctProps', () => { expect(finder().props()).toMatchObject({ isLoading: false, - [entityProp]: entityValue, + ...entitySpecificProps, }); }); diff --git a/spec/frontend/packages_and_registries/settings/group/components/settings_titles_spec.js b/spec/frontend/packages_and_registries/settings/group/components/settings_titles_spec.js index a61edad8685..fcfad4b42b8 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/settings_titles_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/settings_titles_spec.js @@ -4,15 +4,19 @@ import SettingsTitles from '~/packages_and_registries/settings/group/components/ describe('settings_titles', () => { let wrapper; - const mountComponent = () => { + const defaultProps = { + title: 'foo', + subTitle: 'bar', + }; + + const mountComponent = (propsData = defaultProps) => { wrapper = shallowMount(SettingsTitles, { - propsData: { - title: 'foo', - subTitle: 'bar', - }, + propsData, }); }; + const findSubTitle = () => wrapper.find('p'); + afterEach(() => { wrapper.destroy(); }); @@ -22,4 +26,10 @@ describe('settings_titles', () => { expect(wrapper.element).toMatchSnapshot(); }); + + it('does not render the subtitle paragraph when no subtitle is passed', () => { + mountComponent({ title: defaultProps.title }); + + expect(findSubTitle().exists()).toBe(false); + }); }); diff --git a/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js b/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js index 9d8504a1124..a5b571a0241 100644 --- a/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js @@ -17,6 +17,13 @@ describe('Package and Registries settings group cache updates', () => { }, }; + const updateDependencyProxyImageTtlGroupPolicyPayload = { + dependencyProxyImageTtlPolicy: { + enabled: false, + ttl: 45, + }, + }; + const cacheMock = { group: { packageSettings: { @@ -26,6 +33,10 @@ describe('Package and Registries settings group cache updates', () => { dependencyProxySetting: { enabled: true, }, + dependencyProxyImageTtlPolicy: { + enabled: true, + ttl: 45, + }, }, }; @@ -42,15 +53,26 @@ describe('Package and Registries settings group cache updates', () => { }); describe.each` - updateNamespacePackageSettings | updateDependencyProxySettings - ${updateNamespacePackageSettingsPayload} | ${updateDependencyProxySettingsPayload} - ${undefined} | ${updateDependencyProxySettingsPayload} - ${updateNamespacePackageSettingsPayload} | ${undefined} - ${undefined} | ${undefined} + updateNamespacePackageSettings | updateDependencyProxySettings | updateDependencyProxyImageTtlGroupPolicy + ${updateNamespacePackageSettingsPayload} | ${updateDependencyProxySettingsPayload} | ${undefined} + ${undefined} | ${updateDependencyProxySettingsPayload} | ${undefined} + ${updateNamespacePackageSettingsPayload} | ${undefined} | ${undefined} + ${undefined} | ${undefined} | ${updateDependencyProxyImageTtlGroupPolicyPayload} + ${undefined} | ${undefined} | ${undefined} `( 'updateGroupPackageSettings', - ({ updateNamespacePackageSettings, updateDependencyProxySettings }) => { - const payload = { data: { updateNamespacePackageSettings, updateDependencyProxySettings } }; + ({ + updateNamespacePackageSettings, + updateDependencyProxySettings, + updateDependencyProxyImageTtlGroupPolicy, + }) => { + const payload = { + data: { + updateNamespacePackageSettings, + updateDependencyProxySettings, + updateDependencyProxyImageTtlGroupPolicy, + }, + }; it('calls readQuery', () => { updateGroupPackageSettings('foo')(client, payload); expect(client.readQuery).toHaveBeenCalledWith(queryAndVariables); @@ -65,6 +87,7 @@ describe('Package and Registries settings group cache updates', () => { ...cacheMock.group, ...payload.data.updateNamespacePackageSettings, ...payload.data.updateDependencyProxySettings, + ...payload.data.updateDependencyProxyImageTtlGroupPolicy, }, }, }); diff --git a/spec/frontend/packages_and_registries/settings/group/graphl/utils/optimistic_responses_spec.js b/spec/frontend/packages_and_registries/settings/group/graphl/utils/optimistic_responses_spec.js index debeb9aa89c..b4efda3e7b2 100644 --- a/spec/frontend/packages_and_registries/settings/group/graphl/utils/optimistic_responses_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/graphl/utils/optimistic_responses_spec.js @@ -1,6 +1,7 @@ import { updateGroupPackagesSettingsOptimisticResponse, updateGroupDependencyProxySettingsOptimisticResponse, + updateDependencyProxyImageTtlGroupPolicyOptimisticResponse, } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; describe('Optimistic responses', () => { @@ -38,4 +39,22 @@ describe('Optimistic responses', () => { `); }); }); + + describe('updateDependencyProxyImageTtlGroupPolicyOptimisticResponse', () => { + it('returns the correct structure', () => { + expect(updateDependencyProxyImageTtlGroupPolicyOptimisticResponse({ foo: 'bar' })) + .toMatchInlineSnapshot(` + Object { + "__typename": "Mutation", + "updateDependencyProxyImageTtlGroupPolicy": Object { + "__typename": "UpdateDependencyProxyImageTtlGroupPolicyPayload", + "dependencyProxyImageTtlPolicy": Object { + "foo": "bar", + }, + "errors": Array [], + }, + } + `); + }); + }); }); diff --git a/spec/frontend/packages_and_registries/settings/group/mock_data.js b/spec/frontend/packages_and_registries/settings/group/mock_data.js index 81ba0795b7d..d53446de910 100644 --- a/spec/frontend/packages_and_registries/settings/group/mock_data.js +++ b/spec/frontend/packages_and_registries/settings/group/mock_data.js @@ -5,16 +5,25 @@ export const packageSettings = () => ({ genericDuplicateExceptionRegex: '', }); -export const dependencyProxySettings = () => ({ +export const dependencyProxySettings = (extend) => ({ enabled: true, + ...extend, +}); + +export const dependencyProxyImageTtlPolicy = (extend) => ({ + ttl: 90, + enabled: true, + ...extend, }); export const groupPackageSettingsMock = { data: { group: { + id: '1', fullPath: 'foo_group_path', packageSettings: packageSettings(), dependencyProxySetting: dependencyProxySettings(), + dependencyProxyImageTtlPolicy: dependencyProxyImageTtlPolicy(), }, }, }; @@ -44,6 +53,16 @@ export const dependencyProxySettingMutationMock = (override) => ({ }, }); +export const dependencyProxyUpdateTllPolicyMutationMock = (override) => ({ + data: { + updateDependencyProxyImageTtlGroupPolicy: { + dependencyProxyImageTtlPolicy: dependencyProxyImageTtlPolicy(), + errors: [], + ...override, + }, + }, +}); + export const groupPackageSettingsMutationErrorMock = { errors: [ { @@ -68,7 +87,8 @@ export const groupPackageSettingsMutationErrorMock = { }, ], }; -export const dependencyProxySettingMutationErrorMock = { + +export const mutationErrorMock = { errors: [ { message: 'Some error', diff --git a/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js index 9778f409010..a56bb75f8ed 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js @@ -11,6 +11,7 @@ export const containerExpirationPolicyData = () => ({ export const expirationPolicyPayload = (override) => ({ data: { project: { + id: '1', containerExpirationPolicy: { ...containerExpirationPolicyData(), ...override, diff --git a/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap b/spec/frontend/packages_and_registries/shared/__snapshots__/publish_method_spec.js.snap index acdf7c49ebd..5f243799bae 100644 --- a/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap +++ b/spec/frontend/packages_and_registries/shared/__snapshots__/publish_method_spec.js.snap @@ -37,6 +37,7 @@ exports[`publish_method renders 1`] = ` text="sha-baz" title="Copy commit SHA" tooltipplacement="top" + variant="default" /> </div> `; diff --git a/spec/frontend/packages_and_registries/shared/components/registry_list_spec.js b/spec/frontend/packages_and_registries/shared/components/registry_list_spec.js new file mode 100644 index 00000000000..aaca58d21bb --- /dev/null +++ b/spec/frontend/packages_and_registries/shared/components/registry_list_spec.js @@ -0,0 +1,199 @@ +import { GlButton, GlFormCheckbox, GlKeysetPagination } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import component from '~/packages_and_registries/shared/components/registry_list.vue'; + +describe('Registry List', () => { + let wrapper; + + const items = [{ id: 'a' }, { id: 'b' }]; + const defaultPropsData = { + title: 'test_title', + items, + }; + + const rowScopedSlot = ` + <div data-testid="scoped-slot"> + <button @click="props.selectItem(props.item)">Select</button> + <span>{{props.first}}</span> + <p>{{props.isSelected(props.item)}}</p> + </div>`; + + const mountComponent = ({ propsData = defaultPropsData } = {}) => { + wrapper = shallowMountExtended(component, { + propsData, + scopedSlots: { + default: rowScopedSlot, + }, + }); + }; + + const findSelectAll = () => wrapper.findComponent(GlFormCheckbox); + const findDeleteSelected = () => wrapper.findComponent(GlButton); + const findPagination = () => wrapper.findComponent(GlKeysetPagination); + const findScopedSlots = () => wrapper.findAllByTestId('scoped-slot'); + const findScopedSlotSelectButton = (index) => findScopedSlots().at(index).find('button'); + const findScopedSlotFirstValue = (index) => findScopedSlots().at(index).find('span'); + const findScopedSlotIsSelectedValue = (index) => findScopedSlots().at(index).find('p'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('header', () => { + it('renders the title passed in the prop', () => { + mountComponent(); + + expect(wrapper.text()).toContain(defaultPropsData.title); + }); + + describe('select all checkbox', () => { + beforeEach(() => { + mountComponent(); + }); + + it('exists', () => { + expect(findSelectAll().exists()).toBe(true); + }); + + it('select and unselect all', async () => { + // no row is not selected + items.forEach((item, index) => { + expect(findScopedSlotIsSelectedValue(index).text()).toBe(''); + }); + + // simulate selection + findSelectAll().vm.$emit('input', true); + await nextTick(); + + // all rows selected + items.forEach((item, index) => { + expect(findScopedSlotIsSelectedValue(index).text()).toBe('true'); + }); + + // simulate de-selection + findSelectAll().vm.$emit('input', ''); + await nextTick(); + + // no row is not selected + items.forEach((item, index) => { + expect(findScopedSlotIsSelectedValue(index).text()).toBe(''); + }); + }); + }); + + describe('delete button', () => { + it('has the correct text', () => { + mountComponent(); + + expect(findDeleteSelected().text()).toBe(component.i18n.deleteSelected); + }); + + it('is hidden when hiddenDelete is true', () => { + mountComponent({ propsData: { ...defaultPropsData, hiddenDelete: true } }); + + expect(findDeleteSelected().exists()).toBe(false); + }); + + it('is disabled when isLoading is true', () => { + mountComponent({ propsData: { ...defaultPropsData, isLoading: true } }); + + expect(findDeleteSelected().props('disabled')).toBe(true); + }); + + it('is disabled when no row is selected', async () => { + mountComponent(); + + expect(findDeleteSelected().props('disabled')).toBe(true); + + await findScopedSlotSelectButton(0).trigger('click'); + + expect(findDeleteSelected().props('disabled')).toBe(false); + }); + + it('on click emits the delete event with the selected rows', async () => { + mountComponent(); + + await findScopedSlotSelectButton(0).trigger('click'); + + findDeleteSelected().vm.$emit('click'); + + expect(wrapper.emitted('delete')).toEqual([[[items[0]]]]); + }); + }); + }); + + describe('main area', () => { + beforeEach(() => { + mountComponent(); + }); + + it('renders scopedSlots based on the items props', () => { + expect(findScopedSlots()).toHaveLength(items.length); + }); + + it('populates the scope of the slot correctly', async () => { + expect(findScopedSlots().at(0).exists()).toBe(true); + + // it's the first slot + expect(findScopedSlotFirstValue(0).text()).toBe('true'); + + // item is not selected, falsy is translated to empty string + expect(findScopedSlotIsSelectedValue(0).text()).toBe(''); + + // find the button with the bound function + await findScopedSlotSelectButton(0).trigger('click'); + + // the item is selected + expect(findScopedSlotIsSelectedValue(0).text()).toBe('true'); + }); + }); + + describe('footer', () => { + let pagination; + + beforeEach(() => { + pagination = { hasPreviousPage: false, hasNextPage: true }; + }); + + it('has a pagination', () => { + mountComponent({ + propsData: { ...defaultPropsData, pagination }, + }); + + expect(findPagination().props()).toMatchObject(pagination); + }); + + it.each` + hasPreviousPage | hasNextPage | visible + ${true} | ${true} | ${true} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + ${false} | ${false} | ${false} + `( + 'when hasPreviousPage is $hasPreviousPage and hasNextPage is $hasNextPage is $visible that the pagination is shown', + ({ hasPreviousPage, hasNextPage, visible }) => { + pagination = { hasPreviousPage, hasNextPage }; + mountComponent({ + propsData: { ...defaultPropsData, pagination }, + }); + + expect(findPagination().exists()).toBe(visible); + }, + ); + + it('pagination emits the correct events', () => { + mountComponent({ + propsData: { ...defaultPropsData, pagination }, + }); + + findPagination().vm.$emit('prev'); + + expect(wrapper.emitted('prev-page')).toEqual([[]]); + + findPagination().vm.$emit('next'); + + expect(wrapper.emitted('next-page')).toEqual([[]]); + }); + }); +}); diff --git a/spec/frontend/packages/shared/components/package_icon_and_name_spec.js b/spec/frontend/packages_and_registries/shared/package_icon_and_name_spec.js index c96a570a29c..d6d1970cb12 100644 --- a/spec/frontend/packages/shared/components/package_icon_and_name_spec.js +++ b/spec/frontend/packages_and_registries/shared/package_icon_and_name_spec.js @@ -1,6 +1,6 @@ import { GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import PackageIconAndName from '~/packages/shared/components/package_icon_and_name.vue'; +import PackageIconAndName from '~/packages_and_registries/shared/components/package_icon_and_name.vue'; describe('PackageIconAndName', () => { let wrapper; diff --git a/spec/frontend/packages/shared/components/package_path_spec.js b/spec/frontend/packages_and_registries/shared/package_path_spec.js index edbdd55c1d7..93425d4f399 100644 --- a/spec/frontend/packages/shared/components/package_path_spec.js +++ b/spec/frontend/packages_and_registries/shared/package_path_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import PackagePath from '~/packages/shared/components/package_path.vue'; +import PackagePath from '~/packages_and_registries/shared/components/package_path.vue'; describe('PackagePath', () => { let wrapper; diff --git a/spec/frontend/packages/shared/components/package_tags_spec.js b/spec/frontend/packages_and_registries/shared/package_tags_spec.js index d26e4e76b87..33e96c0775e 100644 --- a/spec/frontend/packages/shared/components/package_tags_spec.js +++ b/spec/frontend/packages_and_registries/shared/package_tags_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; -import PackageTags from '~/packages/shared/components/package_tags.vue'; -import { mockTags } from '../../mock_data'; +import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; +import { mockTags } from 'jest/packages_and_registries/infrastructure_registry/components/mock_data'; describe('PackageTags', () => { let wrapper; diff --git a/spec/frontend/packages/shared/components/packages_list_loader_spec.js b/spec/frontend/packages_and_registries/shared/packages_list_loader_spec.js index 4ff01068f92..0005162e0bb 100644 --- a/spec/frontend/packages/shared/components/packages_list_loader_spec.js +++ b/spec/frontend/packages_and_registries/shared/packages_list_loader_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; +import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; describe('PackagesListLoader', () => { let wrapper; diff --git a/spec/frontend/packages/shared/components/publish_method_spec.js b/spec/frontend/packages_and_registries/shared/publish_method_spec.js index 6014774990c..fa8f8f7641a 100644 --- a/spec/frontend/packages/shared/components/publish_method_spec.js +++ b/spec/frontend/packages_and_registries/shared/publish_method_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import PublishMethod from '~/packages/shared/components/publish_method.vue'; -import { packageList } from '../../mock_data'; +import PublishMethod from '~/packages_and_registries/shared/components/publish_method.vue'; +import { packageList } from 'jest/packages_and_registries/infrastructure_registry/components/mock_data'; describe('publish_method', () => { let wrapper; diff --git a/spec/frontend/packages_and_registries/shared/utils_spec.js b/spec/frontend/packages_and_registries/shared/utils_spec.js index bbc8791ca21..962cb2257ce 100644 --- a/spec/frontend/packages_and_registries/shared/utils_spec.js +++ b/spec/frontend/packages_and_registries/shared/utils_spec.js @@ -4,8 +4,12 @@ import { keyValueToFilterToken, searchArrayToFilterTokens, extractFilterAndSorting, + beautifyPath, + getCommitLink, } from '~/packages_and_registries/shared/utils'; +import { packageList } from 'jest/packages_and_registries/infrastructure_registry/components/mock_data'; + describe('Packages And Registries shared utils', () => { describe('getQueryParams', () => { it('returns an object from a query string, with arrays', () => { @@ -56,4 +60,30 @@ describe('Packages And Registries shared utils', () => { }, ); }); + + describe('beautifyPath', () => { + it('returns a string with spaces around /', () => { + expect(beautifyPath('foo/bar')).toBe('foo / bar'); + }); + it('does not fail for empty string', () => { + expect(beautifyPath()).toBe(''); + }); + }); + + describe('getCommitLink', () => { + it('returns a relative link when isGroup is false', () => { + const link = getCommitLink(packageList[0], false); + + expect(link).toContain('../commit'); + }); + + describe('when isGroup is true', () => { + it('returns an absolute link matching project path', () => { + const mavenPackage = packageList[0]; + const link = getCommitLink(mavenPackage, true); + + expect(link).toContain(`/${mavenPackage.project_path}/commit`); + }); + }); + }); }); diff --git a/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js b/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js deleted file mode 100644 index f84800d8266..00000000000 --- a/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js +++ /dev/null @@ -1,108 +0,0 @@ -import { GlBanner } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper'; -import axios from '~/lib/utils/axios_utils'; -import CustomizeHomepageBanner from '~/pages/dashboard/projects/index/components/customize_homepage_banner.vue'; - -const svgPath = '/illustrations/background'; -const provide = { - svgPath, - preferencesBehaviorPath: 'some/behavior/path', - calloutsPath: 'call/out/path', - calloutsFeatureId: 'some-feature-id', - trackLabel: 'home_page', -}; - -const createComponent = () => { - return shallowMount(CustomizeHomepageBanner, { provide, stubs: { GlBanner } }); -}; - -describe('CustomizeHomepageBanner', () => { - let trackingSpy; - let mockAxios; - let wrapper; - - beforeEach(() => { - mockAxios = new MockAdapter(axios); - document.body.dataset.page = 'some:page'; - trackingSpy = mockTracking('_category_', undefined, jest.spyOn); - wrapper = createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - mockAxios.restore(); - unmockTracking(); - }); - - it('should render the banner when not dismissed', () => { - expect(wrapper.find(GlBanner).exists()).toBe(true); - }); - - it('should close the banner when dismiss is clicked', async () => { - mockAxios.onPost(provide.calloutsPath).replyOnce(200); - expect(wrapper.find(GlBanner).exists()).toBe(true); - wrapper.find(GlBanner).vm.$emit('close'); - - await wrapper.vm.$nextTick(); - expect(wrapper.find(GlBanner).exists()).toBe(false); - }); - - it('includes the body text from options', () => { - expect(wrapper.html()).toContain(wrapper.vm.$options.i18n.body); - }); - - describe('tracking', () => { - const preferencesTrackingEvent = 'click_go_to_preferences'; - const mockTrackingOnWrapper = () => { - unmockTracking(); - trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); - }; - - it('sets the needed data attributes for tracking button', async () => { - await wrapper.vm.$nextTick(); - const button = wrapper.find(`[href='${wrapper.vm.preferencesBehaviorPath}']`); - - expect(button.attributes('data-track-action')).toEqual(preferencesTrackingEvent); - expect(button.attributes('data-track-label')).toEqual(provide.trackLabel); - }); - - it('sends a tracking event when the banner is shown', () => { - const trackCategory = undefined; - const trackEvent = 'show_home_page_banner'; - - expect(trackingSpy).toHaveBeenCalledWith(trackCategory, trackEvent, { - label: provide.trackLabel, - }); - }); - - it('sends a tracking event when the banner is dismissed', async () => { - mockTrackingOnWrapper(); - mockAxios.onPost(provide.calloutsPath).replyOnce(200); - const trackCategory = undefined; - const trackEvent = 'click_dismiss'; - - wrapper.find(GlBanner).vm.$emit('close'); - - await wrapper.vm.$nextTick(); - expect(trackingSpy).toHaveBeenCalledWith(trackCategory, trackEvent, { - label: provide.trackLabel, - }); - }); - - it('sends a tracking event when the button is clicked', async () => { - mockTrackingOnWrapper(); - mockAxios.onPost(provide.calloutsPath).replyOnce(200); - const button = wrapper.find(`[href='${wrapper.vm.preferencesBehaviorPath}']`); - - triggerEvent(button.element); - - await wrapper.vm.$nextTick(); - expect(trackingSpy).toHaveBeenCalledWith('_category_', preferencesTrackingEvent, { - label: provide.trackLabel, - }); - }); - }); -}); diff --git a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js index d6b394a42c6..6fb03fa28fe 100644 --- a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js +++ b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js @@ -2,7 +2,7 @@ import { GlEmptyState, GlLoadingIcon, GlTable } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; -import PaginationBar from '~/import_entities/components/pagination_bar.vue'; +import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import BulkImportsHistoryApp from '~/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap index 3e371a8765f..1586aded6e6 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap +++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap @@ -2,6 +2,8 @@ exports[`Learn GitLab renders correctly 1`] = ` <div> + <!----> + <div class="row" > @@ -131,66 +133,60 @@ exports[`Learn GitLab renders correctly 1`] = ` <div class="gl-mb-4" > - <span> - <a - class="gl-link" - data-track-action="click_link" - data-track-experiment="change_continuous_onboarding_link_urls" - data-track-label="Set up CI/CD" - data-track-property="Growth::Conversion::Experiment::LearnGitLab" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - - Set up CI/CD - - </a> - </span> + <a + class="gl-link" + data-track-action="click_link" + data-track-experiment="change_continuous_onboarding_link_urls" + data-track-label="Set up CI/CD" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + + Set up CI/CD + + </a> <!----> </div> <div class="gl-mb-4" > - <span> - <a - class="gl-link" - data-track-action="click_link" - data-track-experiment="change_continuous_onboarding_link_urls" - data-track-label="Start a free Ultimate trial" - data-track-property="Growth::Conversion::Experiment::LearnGitLab" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - - Start a free Ultimate trial - - </a> - </span> + <a + class="gl-link" + data-track-action="click_link" + data-track-experiment="change_continuous_onboarding_link_urls" + data-track-label="Start a free Ultimate trial" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + + Start a free Ultimate trial + + </a> <!----> </div> <div class="gl-mb-4" > - <span> - <a - class="gl-link" - data-track-action="click_link" - data-track-experiment="change_continuous_onboarding_link_urls" - data-track-label="Add code owners" - data-track-property="Growth::Conversion::Experiment::LearnGitLab" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - - Add code owners - - </a> - </span> + <a + class="gl-link" + data-track-action="click_link" + data-track-experiment="change_continuous_onboarding_link_urls" + data-track-label="Add code owners" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + + Add code owners + + </a> <span class="gl-font-style-italic gl-text-gray-500" @@ -204,22 +200,20 @@ exports[`Learn GitLab renders correctly 1`] = ` <div class="gl-mb-4" > - <span> - <a - class="gl-link" - data-track-action="click_link" - data-track-experiment="change_continuous_onboarding_link_urls" - data-track-label="Add merge request approval" - data-track-property="Growth::Conversion::Experiment::LearnGitLab" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - - Add merge request approval - - </a> - </span> + <a + class="gl-link" + data-track-action="click_link" + data-track-experiment="change_continuous_onboarding_link_urls" + data-track-label="Add merge request approval" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + + Add merge request approval + + </a> <span class="gl-font-style-italic gl-text-gray-500" @@ -269,44 +263,40 @@ exports[`Learn GitLab renders correctly 1`] = ` <div class="gl-mb-4" > - <span> - <a - class="gl-link" - data-track-action="click_link" - data-track-experiment="change_continuous_onboarding_link_urls" - data-track-label="Create an issue" - data-track-property="Growth::Conversion::Experiment::LearnGitLab" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - - Create an issue - - </a> - </span> + <a + class="gl-link" + data-track-action="click_link" + data-track-experiment="change_continuous_onboarding_link_urls" + data-track-label="Create an issue" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + + Create an issue + + </a> <!----> </div> <div class="gl-mb-4" > - <span> - <a - class="gl-link" - data-track-action="click_link" - data-track-experiment="change_continuous_onboarding_link_urls" - data-track-label="Submit a merge request" - data-track-property="Growth::Conversion::Experiment::LearnGitLab" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - - Submit a merge request - - </a> - </span> + <a + class="gl-link" + data-track-action="click_link" + data-track-experiment="change_continuous_onboarding_link_urls" + data-track-label="Submit a merge request" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + + Submit a merge request + + </a> <!----> </div> @@ -349,22 +339,20 @@ exports[`Learn GitLab renders correctly 1`] = ` <div class="gl-mb-4" > - <span> - <a - class="gl-link" - data-track-action="click_link" - data-track-experiment="change_continuous_onboarding_link_urls" - data-track-label="Run a Security scan using CI/CD" - data-track-property="Growth::Conversion::Experiment::LearnGitLab" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - - Run a Security scan using CI/CD - - </a> - </span> + <a + class="gl-link" + data-track-action="click_link" + data-track-experiment="change_continuous_onboarding_link_urls" + data-track-label="Run a Security scan using CI/CD" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + + Run a Security scan using CI/CD + + </a> <!----> </div> diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js index 882d233a239..f7b2154a935 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js +++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js @@ -1,4 +1,7 @@ import { shallowMount } from '@vue/test-utils'; +import { stubExperiments } from 'helpers/experimentation_helper'; +import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper'; +import eventHub from '~/invite_members/event_hub'; import LearnGitlabSectionLink from '~/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue'; const defaultAction = 'gitWrite'; @@ -23,6 +26,9 @@ describe('Learn GitLab Section Link', () => { }); }; + const openInviteMembesrModalLink = () => + wrapper.find('[data-testid="invite-for-help-continuous-onboarding-experiment-link"]'); + it('renders no icon when not completed', () => { createWrapper(undefined, { completed: false }); @@ -46,4 +52,54 @@ describe('Learn GitLab Section Link', () => { expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(true); }); + + describe('rendering a link to open the invite_members modal instead of a regular link', () => { + it.each` + action | experimentVariant | showModal + ${'userAdded'} | ${'candidate'} | ${true} + ${'userAdded'} | ${'control'} | ${false} + ${defaultAction} | ${'candidate'} | ${false} + ${defaultAction} | ${'control'} | ${false} + `( + 'when the invite_for_help_continuous_onboarding experiment has variant: $experimentVariant and action is $action, the modal link is shown: $showModal', + ({ action, experimentVariant, showModal }) => { + stubExperiments({ invite_for_help_continuous_onboarding: experimentVariant }); + createWrapper(action); + + expect(openInviteMembesrModalLink().exists()).toBe(showModal); + }, + ); + }); + + describe('clicking the link to open the invite_members modal', () => { + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(); + + stubExperiments({ invite_for_help_continuous_onboarding: 'candidate' }); + createWrapper('userAdded'); + }); + + it('calls the eventHub', () => { + openInviteMembesrModalLink().vm.$emit('click'); + + expect(eventHub.$emit).toHaveBeenCalledWith('openModal', { + inviteeType: 'members', + source: 'learn_gitlab', + tasksToBeDoneEnabled: true, + }); + }); + + it('tracks the click', async () => { + const trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); + + triggerEvent(openInviteMembesrModalLink().element); + + expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_link', { + label: 'Invite your colleagues', + property: 'Growth::Activation::Experiment::InviteForHelpContinuousOnboarding', + }); + + unmockTracking(); + }); + }); }); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js index 7e97a539a99..7e71622770f 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js +++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js @@ -1,20 +1,35 @@ -import { GlProgressBar } from '@gitlab/ui'; +import { GlProgressBar, GlAlert } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import LearnGitlab from '~/pages/projects/learn_gitlab/components/learn_gitlab.vue'; import eventHub from '~/invite_members/event_hub'; -import { testActions, testSections } from './mock_data'; +import { testActions, testSections, testProject } from './mock_data'; describe('Learn GitLab', () => { let wrapper; + let sidebar; let inviteMembersOpen = false; const createWrapper = () => { wrapper = mount(LearnGitlab, { - propsData: { actions: testActions, sections: testSections, inviteMembersOpen }, + propsData: { + actions: testActions, + sections: testSections, + project: testProject, + inviteMembersOpen, + }, }); }; beforeEach(() => { + sidebar = document.createElement('div'); + sidebar.innerHTML = ` + <div class="sidebar-top-level-items"> + <div class="active"> + <div class="count"></div> + </div> + </div> + `; + document.body.appendChild(sidebar); createWrapper(); }); @@ -22,6 +37,7 @@ describe('Learn GitLab', () => { wrapper.destroy(); wrapper = null; inviteMembersOpen = false; + sidebar.remove(); }); it('renders correctly', () => { @@ -66,4 +82,26 @@ describe('Learn GitLab', () => { expect(spy).not.toHaveBeenCalled(); }); }); + + describe('when the showSuccessfulInvitationsAlert event is fired', () => { + const findAlert = () => wrapper.findComponent(GlAlert); + + beforeEach(() => { + eventHub.$emit('showSuccessfulInvitationsAlert'); + }); + + it('displays the successful invitations alert', () => { + expect(findAlert().exists()).toBe(true); + }); + + it('displays a message with the project name', () => { + expect(findAlert().text()).toBe( + "Your team is growing! You've successfully invited new team members to the test-project project.", + ); + }); + + it('modifies the sidebar percentage', () => { + expect(sidebar.textContent.trim()).toBe('22%'); + }); + }); }); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js b/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js index 8d6ac737db8..1e633cb7cf5 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js +++ b/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js @@ -57,3 +57,7 @@ export const testSections = { svg: 'plan.svg', }, }; + +export const testProject = { + name: 'test-project', +}; 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 9d510b3d231..f4236146d33 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -1,5 +1,6 @@ +import { nextTick } from 'vue'; import { GlLoadingIcon, GlModal } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { mount, shallowMount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { mockTracking } from 'helpers/tracking_helper'; @@ -32,12 +33,15 @@ describe('WikiForm', () => { const findSubmitButton = () => wrapper.findByTestId('wiki-submit-button'); const findCancelButton = () => wrapper.findByRole('link', { name: 'Cancel' }); const findUseNewEditorButton = () => wrapper.findByRole('button', { name: 'Use the new editor' }); + const findToggleEditingModeButton = () => wrapper.findByTestId('toggle-editing-mode-button'); const findDismissContentEditorAlertButton = () => wrapper.findByRole('button', { name: 'Try this later' }); const findSwitchToOldEditorButton = () => wrapper.findByRole('button', { name: 'Switch me back to the classic editor.' }); - const findTitleHelpLink = () => wrapper.findByRole('link', { name: 'More Information.' }); + const findTitleHelpLink = () => wrapper.findByRole('link', { name: 'Learn more.' }); const findMarkdownHelpLink = () => wrapper.findByTestId('wiki-markdown-help-link'); + const findContentEditor = () => wrapper.findComponent(ContentEditor); + const findClassicEditor = () => wrapper.findComponent(MarkdownField); const setFormat = (value) => { const format = findFormat(); @@ -73,18 +77,24 @@ describe('WikiForm', () => { path: '/project/path/-/wikis/home', }; - function createWrapper(persisted = false, { pageInfo } = {}) { + const formatOptions = { + Markdown: 'markdown', + RDoc: 'rdoc', + AsciiDoc: 'asciidoc', + Org: 'org', + }; + + function createWrapper( + persisted = false, + { pageInfo, glFeatures = { wikiSwitchBetweenContentEditorRawMarkdown: false } } = {}, + ) { wrapper = extendedWrapper( mount( WikiForm, { provide: { - formatOptions: { - Markdown: 'markdown', - RDoc: 'rdoc', - AsciiDoc: 'asciidoc', - Org: 'org', - }, + formatOptions, + glFeatures, pageInfo: { ...(persisted ? pageInfoPersisted : pageInfoNew), ...pageInfo, @@ -96,6 +106,27 @@ describe('WikiForm', () => { ); } + const createShallowWrapper = ( + persisted = false, + { pageInfo, glFeatures = { wikiSwitchBetweenContentEditorRawMarkdown: false } } = {}, + ) => { + wrapper = extendedWrapper( + shallowMount(WikiForm, { + provide: { + formatOptions, + glFeatures, + pageInfo: { + ...(persisted ? pageInfoPersisted : pageInfoNew), + ...pageInfo, + }, + }, + stubs: { + MarkdownField, + }, + }), + ); + }; + beforeEach(() => { trackingSpy = mockTracking(undefined, null, jest.spyOn); mock = new MockAdapter(axios); @@ -193,14 +224,13 @@ describe('WikiForm', () => { }); describe('when wiki content is updated', () => { - beforeEach(() => { + beforeEach(async () => { createWrapper(true); const input = findContent(); input.setValue(' Lorem ipsum dolar sit! '); - input.element.dispatchEvent(new Event('input')); - return wrapper.vm.$nextTick(); + await input.trigger('input'); }); it('sets before unload warning', () => { @@ -279,6 +309,100 @@ describe('WikiForm', () => { ); }); + describe('when wikiSwitchBetweenContentEditorRawMarkdown feature flag is not enabled', () => { + beforeEach(() => { + createShallowWrapper(true, { + glFeatures: { wikiSwitchBetweenContentEditorRawMarkdown: false }, + }); + }); + + it('hides toggle editing mode button', () => { + expect(findToggleEditingModeButton().exists()).toBe(false); + }); + }); + + describe('when wikiSwitchBetweenContentEditorRawMarkdown feature flag is enabled', () => { + beforeEach(() => { + createShallowWrapper(true, { + glFeatures: { wikiSwitchBetweenContentEditorRawMarkdown: true }, + }); + }); + + it('hides gl-alert containing "use new editor" button', () => { + expect(findUseNewEditorButton().exists()).toBe(false); + }); + + it('displays toggle editing mode button', () => { + expect(findToggleEditingModeButton().exists()).toBe(true); + }); + + describe('when content editor is not active', () => { + it('displays "Edit rich text" label in the toggle editing mode button', () => { + expect(findToggleEditingModeButton().text()).toBe('Edit rich text'); + }); + + describe('when clicking the toggle editing mode button', () => { + beforeEach(() => { + findToggleEditingModeButton().vm.$emit('click'); + }); + + it('hides the classic editor', () => { + expect(findClassicEditor().exists()).toBe(false); + }); + + it('hides the content editor', () => { + expect(findContentEditor().exists()).toBe(true); + }); + }); + }); + + describe('when content editor is active', () => { + let mockContentEditor; + + beforeEach(() => { + mockContentEditor = { + getSerializedContent: jest.fn(), + setSerializedContent: jest.fn(), + }; + + findToggleEditingModeButton().vm.$emit('click'); + }); + + it('hides switch to old editor button', () => { + expect(findSwitchToOldEditorButton().exists()).toBe(false); + }); + + it('displays "Edit source" label in the toggle editing mode button', () => { + expect(findToggleEditingModeButton().text()).toBe('Edit source'); + }); + + describe('when clicking the toggle editing mode button', () => { + const contentEditorFakeSerializedContent = 'fake content'; + + beforeEach(() => { + mockContentEditor.getSerializedContent.mockReturnValueOnce( + contentEditorFakeSerializedContent, + ); + + findContentEditor().vm.$emit('initialized', mockContentEditor); + findToggleEditingModeButton().vm.$emit('click'); + }); + + it('hides the content editor', () => { + expect(findContentEditor().exists()).toBe(false); + }); + + it('displays the classic editor', () => { + expect(findClassicEditor().exists()).toBe(true); + }); + + it('updates the classic editor content field', () => { + expect(findContent().element.value).toBe(contentEditorFakeSerializedContent); + }); + }); + }); + }); + describe('wiki content editor', () => { beforeEach(() => { createWrapper(true); @@ -306,8 +430,8 @@ describe('WikiForm', () => { }); const assertOldEditorIsVisible = () => { - expect(wrapper.findComponent(ContentEditor).exists()).toBe(false); - expect(wrapper.findComponent(MarkdownField).exists()).toBe(true); + expect(findContentEditor().exists()).toBe(false); + expect(findClassicEditor().exists()).toBe(true); expect(findSubmitButton().props('disabled')).toBe(false); expect(wrapper.text()).not.toContain( @@ -376,10 +500,6 @@ describe('WikiForm', () => { findUseNewEditorButton().trigger('click'); }); - it('shows a loading indicator for the rich text editor', () => { - expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); - }); - it('shows a tip to send feedback', () => { expect(wrapper.text()).toContain('Tell us your experiences with the new Markdown editor'); }); @@ -412,16 +532,8 @@ describe('WikiForm', () => { }); describe('when wiki content is updated', () => { - beforeEach(async () => { - // wait for content editor to load - await waitForPromises(); - - wrapper.vm.contentEditor.tiptapEditor.commands.setContent( - '<p>hello __world__ from content editor</p>', - true, - ); - - return wrapper.vm.$nextTick(); + beforeEach(() => { + findContentEditor().vm.$emit('change', { empty: false }); }); it('sets before unload warning', () => { @@ -432,7 +544,7 @@ describe('WikiForm', () => { it('unsets before unload warning on form submit', async () => { triggerFormSubmit(); - await wrapper.vm.$nextTick(); + await nextTick(); const e = dispatchBeforeUnload(); expect(e.preventDefault).not.toHaveBeenCalled(); @@ -450,8 +562,8 @@ describe('WikiForm', () => { expect(trackingSpy).toHaveBeenCalledWith(undefined, WIKI_FORMAT_UPDATED_ACTION, { label: WIKI_FORMAT_LABEL, - value: findFormat().element.value, extra: { + value: findFormat().element.value, old_format: pageInfoPersisted.format, project_path: pageInfoPersisted.path, }, diff --git a/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js b/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js index 23219042008..7244a179820 100644 --- a/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js +++ b/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js @@ -1,3 +1,4 @@ +import { nextTick } from 'vue'; import { GlFormInput, GlFormTextarea } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; @@ -32,7 +33,6 @@ describe('Pipeline Editor | Commit Form', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('when the form is displayed', () => { @@ -78,7 +78,7 @@ describe('Pipeline Editor | Commit Form', () => { it('emits an event when the form resets', () => { findCancelBtn().trigger('click'); - expect(wrapper.emitted('cancel')).toHaveLength(1); + expect(wrapper.emitted('resetContent')).toHaveLength(1); }); }); @@ -121,7 +121,7 @@ describe('Pipeline Editor | Commit Form', () => { beforeEach(async () => { createComponent(); wrapper.setProps({ scrollToCommitForm: true }); - await wrapper.vm.$nextTick(); + await nextTick(); }); it('scrolls into view', () => { diff --git a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js index efc345d8877..bc77b7045eb 100644 --- a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js +++ b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js @@ -1,5 +1,7 @@ +import VueApollo from 'vue-apollo'; import { GlFormTextarea, GlFormInput, GlLoadingIcon } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { createLocalVue, mount } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { objectToQuery, redirectTo } from '~/lib/utils/url_utility'; import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue'; @@ -10,18 +12,22 @@ import { COMMIT_SUCCESS, } from '~/pipeline_editor/constants'; import commitCreate from '~/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql'; +import updatePipelineEtag from '~/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql'; import { mockCiConfigPath, mockCiYml, + mockCommitCreateResponse, + mockCommitCreateResponseNewEtag, mockCommitSha, - mockCommitNextSha, mockCommitMessage, mockDefaultBranch, mockProjectFullPath, mockNewMergeRequestPath, } from '../../mock_data'; +const localVue = createLocalVue(); + jest.mock('~/lib/utils/url_utility', () => ({ redirectTo: jest.fn(), refreshCurrentPage: jest.fn(), @@ -47,7 +53,8 @@ const mockProvide = { describe('Pipeline Editor | Commit section', () => { let wrapper; - let mockMutate; + let mockApollo; + const mockMutateCommitData = jest.fn(); const defaultProps = { ciFileContent: mockCiYml, @@ -55,18 +62,7 @@ describe('Pipeline Editor | Commit section', () => { isNewCiConfigFile: false, }; - const createComponent = ({ props = {}, options = {}, provide = {} } = {}) => { - mockMutate = jest.fn().mockResolvedValue({ - data: { - commitCreate: { - errors: [], - commit: { - sha: mockCommitNextSha, - }, - }, - }, - }); - + const createComponent = ({ apolloConfig = {}, props = {}, options = {}, provide = {} } = {}) => { wrapper = mount(CommitSection, { propsData: { ...defaultProps, ...props }, provide: { ...mockProvide, ...provide }, @@ -75,16 +71,25 @@ describe('Pipeline Editor | Commit section', () => { currentBranch: mockDefaultBranch, }; }, - mocks: { - $apollo: { - mutate: mockMutate, - }, - }, attachTo: document.body, + ...apolloConfig, ...options, }); }; + const createComponentWithApollo = (options) => { + const handlers = [[commitCreate, mockMutateCommitData]]; + localVue.use(VueApollo); + mockApollo = createMockApollo(handlers); + + const apolloConfig = { + localVue, + apolloProvider: mockApollo, + }; + + createComponent({ ...options, apolloConfig }); + }; + const findCommitForm = () => wrapper.findComponent(CommitForm); const findCommitBtnLoadingIcon = () => wrapper.find('[type="submit"]').findComponent(GlLoadingIcon); @@ -103,72 +108,54 @@ describe('Pipeline Editor | Commit section', () => { await waitForPromises(); }; - const cancelCommitForm = async () => { - const findCancelBtn = () => wrapper.find('[type="reset"]'); - await findCancelBtn().trigger('click'); - }; - afterEach(() => { - mockMutate.mockReset(); wrapper.destroy(); }); describe('when the user commits a new file', () => { beforeEach(async () => { - createComponent({ props: { isNewCiConfigFile: true } }); + mockMutateCommitData.mockResolvedValue(mockCommitCreateResponse); + createComponentWithApollo({ props: { isNewCiConfigFile: true } }); await submitCommit(); }); it('calls the mutation with the CREATE action', () => { - // the extra calls are for updating client queries (currentBranch and lastCommitBranch) - expect(mockMutate).toHaveBeenCalledTimes(3); - expect(mockMutate).toHaveBeenCalledWith({ - mutation: commitCreate, - update: expect.any(Function), - variables: { - ...mockVariables, - action: COMMIT_ACTION_CREATE, - branch: mockDefaultBranch, - }, + expect(mockMutateCommitData).toHaveBeenCalledTimes(1); + expect(mockMutateCommitData).toHaveBeenCalledWith({ + ...mockVariables, + action: COMMIT_ACTION_CREATE, + branch: mockDefaultBranch, }); }); }); describe('when the user commits an update to an existing file', () => { beforeEach(async () => { - createComponent(); + createComponentWithApollo(); await submitCommit(); }); it('calls the mutation with the UPDATE action', () => { - expect(mockMutate).toHaveBeenCalledTimes(3); - expect(mockMutate).toHaveBeenCalledWith({ - mutation: commitCreate, - update: expect.any(Function), - variables: { - ...mockVariables, - action: COMMIT_ACTION_UPDATE, - branch: mockDefaultBranch, - }, + expect(mockMutateCommitData).toHaveBeenCalledTimes(1); + expect(mockMutateCommitData).toHaveBeenCalledWith({ + ...mockVariables, + action: COMMIT_ACTION_UPDATE, + branch: mockDefaultBranch, }); }); }); describe('when the user commits changes to the current branch', () => { beforeEach(async () => { - createComponent(); + createComponentWithApollo(); await submitCommit(); }); it('calls the mutation with the current branch', () => { - expect(mockMutate).toHaveBeenCalledTimes(3); - expect(mockMutate).toHaveBeenCalledWith({ - mutation: commitCreate, - update: expect.any(Function), - variables: { - ...mockVariables, - branch: mockDefaultBranch, - }, + expect(mockMutateCommitData).toHaveBeenCalledTimes(1); + expect(mockMutateCommitData).toHaveBeenCalledWith({ + ...mockVariables, + branch: mockDefaultBranch, }); }); @@ -188,14 +175,10 @@ describe('Pipeline Editor | Commit section', () => { it('a second commit submits the latest sha, keeping the form updated', async () => { await submitCommit(); - expect(mockMutate).toHaveBeenCalledTimes(6); - expect(mockMutate).toHaveBeenCalledWith({ - mutation: commitCreate, - update: expect.any(Function), - variables: { - ...mockVariables, - branch: mockDefaultBranch, - }, + expect(mockMutateCommitData).toHaveBeenCalledTimes(2); + expect(mockMutateCommitData).toHaveBeenCalledWith({ + ...mockVariables, + branch: mockDefaultBranch, }); }); }); @@ -204,20 +187,16 @@ describe('Pipeline Editor | Commit section', () => { const newBranch = 'new-branch'; beforeEach(async () => { - createComponent(); + createComponentWithApollo(); await submitCommit({ branch: newBranch, }); }); it('calls the mutation with the new branch', () => { - expect(mockMutate).toHaveBeenCalledWith({ - mutation: commitCreate, - update: expect.any(Function), - variables: { - ...mockVariables, - branch: newBranch, - }, + expect(mockMutateCommitData).toHaveBeenCalledWith({ + ...mockVariables, + branch: newBranch, }); }); @@ -230,7 +209,7 @@ describe('Pipeline Editor | Commit section', () => { const newBranch = 'new-branch'; beforeEach(async () => { - createComponent(); + createComponentWithApollo(); await submitCommit({ branch: newBranch, openMergeRequest: true, @@ -249,11 +228,11 @@ describe('Pipeline Editor | Commit section', () => { describe('when the commit is ocurring', () => { beforeEach(() => { - createComponent(); + createComponentWithApollo(); }); it('shows a saving state', async () => { - mockMutate.mockImplementationOnce(() => { + mockMutateCommitData.mockImplementationOnce(() => { expect(findCommitBtnLoadingIcon().exists()).toBe(true); return Promise.resolve(); }); @@ -266,15 +245,23 @@ describe('Pipeline Editor | Commit section', () => { }); }); - describe('when the commit form is cancelled', () => { + describe('when the commit returns a different etag path', () => { beforeEach(async () => { - createComponent(); + createComponentWithApollo(); + jest.spyOn(wrapper.vm.$apollo, 'mutate'); + mockMutateCommitData.mockResolvedValue(mockCommitCreateResponseNewEtag); + await submitCommit(); }); - it('emits an event so that it cab be reseted', async () => { - await cancelCommitForm(); - - expect(wrapper.emitted('resetContent')).toHaveLength(1); + it('calls the client mutation to update the etag', () => { + // 1:Commit submission, 2:etag update, 3:currentBranch update, 4:lastCommit update + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(4); + expect(wrapper.vm.$apollo.mutate).toHaveBeenNthCalledWith(2, { + mutation: updatePipelineEtag, + variables: { + pipelineEtag: mockCommitCreateResponseNewEtag.data.commitCreate.commitPipelinePath, + }, + }); }); }); diff --git a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js index a43da4b0f19..cab4810cbf1 100644 --- a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js +++ b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js @@ -1,7 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { EDITOR_READY_EVENT } from '~/editor/constants'; -import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue'; import { mockCiConfigPath, @@ -59,10 +58,6 @@ describe('Pipeline Editor | Text editor component', () => { const findEditor = () => wrapper.findComponent(MockSourceEditor); - beforeEach(() => { - SourceEditorExtension.deferRerender = jest.fn(); - }); - afterEach(() => { wrapper.destroy(); diff --git a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js index 6532c4e289d..ab9027a56a4 100644 --- a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js +++ b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js @@ -11,7 +11,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import BranchSwitcher from '~/pipeline_editor/components/file_nav/branch_switcher.vue'; import { DEFAULT_FAILURE } from '~/pipeline_editor/constants'; -import getAvailableBranchesQuery from '~/pipeline_editor/graphql/queries/available_branches.graphql'; +import getAvailableBranchesQuery from '~/pipeline_editor/graphql/queries/available_branches.query.graphql'; import { mockBranchPaginationLimit, mockDefaultBranch, @@ -22,7 +22,6 @@ import { mockTotalBranches, mockTotalBranchResults, mockTotalSearchResults, - mockNewBranch, } from '../../mock_data'; const localVue = createLocalVue(); @@ -32,18 +31,14 @@ describe('Pipeline editor branch switcher', () => { let wrapper; let mockApollo; let mockAvailableBranchQuery; - let mockCurrentBranchQuery; - let mockLastCommitBranchQuery; - - const createComponent = ( - { currentBranch, isQueryLoading, mountFn, options, props } = { - currentBranch: mockDefaultBranch, - hasUnsavedChanges: false, - isQueryLoading: false, - mountFn: shallowMount, - options: {}, - }, - ) => { + + const createComponent = ({ + currentBranch = mockDefaultBranch, + isQueryLoading = false, + mountFn = shallowMount, + options = {}, + props = {}, + } = {}) => { wrapper = mountFn(BranchSwitcher, { propsData: { ...props, @@ -74,17 +69,7 @@ describe('Pipeline editor branch switcher', () => { const createComponentWithApollo = ({ mountFn = shallowMount, props = {} } = {}) => { const handlers = [[getAvailableBranchesQuery, mockAvailableBranchQuery]]; - const resolvers = { - Query: { - currentBranch() { - return mockCurrentBranchQuery(); - }, - lastCommitBranch() { - return mockLastCommitBranchQuery(); - }, - }, - }; - mockApollo = createMockApollo(handlers, resolvers); + mockApollo = createMockApollo(handlers); createComponent({ mountFn, @@ -104,22 +89,12 @@ describe('Pipeline editor branch switcher', () => { const findInfiniteScroll = () => wrapper.findComponent(GlInfiniteScroll); const defaultBranchInDropdown = () => findDropdownItems().at(0); - const setMockResolvedValues = ({ availableBranches, currentBranch, lastCommitBranch }) => { - if (availableBranches) { - mockAvailableBranchQuery.mockResolvedValue(availableBranches); - } - - if (currentBranch) { - mockCurrentBranchQuery.mockResolvedValue(currentBranch); - } - - mockLastCommitBranchQuery.mockResolvedValue(lastCommitBranch || ''); + const setAvailableBranchesMock = (availableBranches) => { + mockAvailableBranchQuery.mockResolvedValue(availableBranches); }; beforeEach(() => { mockAvailableBranchQuery = jest.fn(); - mockCurrentBranchQuery = jest.fn(); - mockLastCommitBranchQuery = jest.fn(); }); afterEach(() => { @@ -148,10 +123,7 @@ describe('Pipeline editor branch switcher', () => { describe('after querying', () => { beforeEach(async () => { - setMockResolvedValues({ - availableBranches: mockProjectBranches, - currentBranch: mockDefaultBranch, - }); + setAvailableBranchesMock(mockProjectBranches); createComponentWithApollo({ mountFn: mount }); await waitForPromises(); }); @@ -180,10 +152,7 @@ describe('Pipeline editor branch switcher', () => { describe('on fetch error', () => { beforeEach(async () => { - setMockResolvedValues({ - availableBranches: new Error(), - currentBranch: mockDefaultBranch, - }); + setAvailableBranchesMock(new Error()); createComponentWithApollo(); await waitForPromises(); }); @@ -200,10 +169,7 @@ describe('Pipeline editor branch switcher', () => { describe('when switching branches', () => { beforeEach(async () => { jest.spyOn(window.history, 'pushState').mockImplementation(() => {}); - setMockResolvedValues({ - availableBranches: mockProjectBranches, - currentBranch: mockDefaultBranch, - }); + setAvailableBranchesMock(mockProjectBranches); createComponentWithApollo({ mountFn: mount }); await waitForPromises(); }); @@ -271,10 +237,7 @@ describe('Pipeline editor branch switcher', () => { describe('when searching', () => { beforeEach(async () => { - setMockResolvedValues({ - availableBranches: mockProjectBranches, - currentBranch: mockDefaultBranch, - }); + setAvailableBranchesMock(mockProjectBranches); createComponentWithApollo({ mountFn: mount }); await waitForPromises(); }); @@ -374,10 +337,7 @@ describe('Pipeline editor branch switcher', () => { describe('when scrolling to the bottom of the list', () => { beforeEach(async () => { - setMockResolvedValues({ - availableBranches: mockProjectBranches, - currentBranch: mockDefaultBranch, - }); + setAvailableBranchesMock(mockProjectBranches); createComponentWithApollo(); await waitForPromises(); }); @@ -433,35 +393,4 @@ describe('Pipeline editor branch switcher', () => { }); }); }); - - describe('when committing a new branch', () => { - const createNewBranch = async () => { - setMockResolvedValues({ - currentBranch: mockNewBranch, - lastCommitBranch: mockNewBranch, - }); - await wrapper.vm.$apollo.queries.currentBranch.refetch(); - await wrapper.vm.$apollo.queries.lastCommitBranch.refetch(); - }; - - beforeEach(async () => { - setMockResolvedValues({ - availableBranches: mockProjectBranches, - currentBranch: mockDefaultBranch, - }); - createComponentWithApollo({ mountFn: mount }); - await waitForPromises(); - await createNewBranch(); - }); - - it('sets new branch as current branch', () => { - expect(defaultBranchInDropdown().text()).toBe(mockNewBranch); - expect(defaultBranchInDropdown().props('isChecked')).toBe(true); - }); - - it('adds new branch to branch switcher', () => { - expect(defaultBranchInDropdown().text()).toBe(mockNewBranch); - expect(findDropdownItems()).toHaveLength(mockTotalBranchResults + 1); - }); - }); }); 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 29ab52bde8f..c101b1d21c7 100644 --- a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js +++ b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js @@ -4,7 +4,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import PipelineStatus, { i18n } from '~/pipeline_editor/components/header/pipeline_status.vue'; -import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql'; +import getPipelineQuery from '~/pipeline_editor/graphql/queries/pipeline.query.graphql'; import PipelineEditorMiniGraph from '~/pipeline_editor/components/header/pipeline_editor_mini_graph.vue'; import { mockCommitSha, mockProjectPipeline, mockProjectFullPath } from '../../mock_data'; @@ -39,8 +39,6 @@ describe('Pipeline Status', () => { const findPipelineId = () => wrapper.find('[data-testid="pipeline-id"]'); const findPipelineCommit = () => wrapper.find('[data-testid="pipeline-commit"]'); const findPipelineErrorMsg = () => wrapper.find('[data-testid="pipeline-error-msg"]'); - const findPipelineNotTriggeredErrorMsg = () => - wrapper.find('[data-testid="pipeline-not-triggered-error-msg"]'); const findPipelineLoadingMsg = () => wrapper.find('[data-testid="pipeline-loading-msg"]'); const findPipelineViewBtn = () => wrapper.find('[data-testid="pipeline-view-btn"]'); const findStatusIcon = () => wrapper.find('[data-testid="pipeline-status-icon"]'); @@ -119,8 +117,7 @@ describe('Pipeline Status', () => { await waitForPromises(); }); - it('renders api error', () => { - expect(findPipelineNotTriggeredErrorMsg().exists()).toBe(false); + it('renders error', () => { expect(findIcon().attributes('name')).toBe('warning-solid'); expect(findPipelineErrorMsg().text()).toBe(i18n.fetchError); }); @@ -132,22 +129,5 @@ describe('Pipeline Status', () => { expect(findPipelineViewBtn().exists()).toBe(false); }); }); - - describe('when pipeline is null', () => { - beforeEach(() => { - mockPipelineQuery.mockResolvedValue({ - data: { project: { pipeline: null } }, - }); - - createComponentWithApollo(); - waitForPromises(); - }); - - it('renders pipeline not triggered error', () => { - expect(findPipelineErrorMsg().exists()).toBe(false); - expect(findIcon().attributes('name')).toBe('information-o'); - expect(findPipelineNotTriggeredErrorMsg().text()).toBe(i18n.pipelineNotTriggeredMsg); - }); - }); }); }); diff --git a/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js b/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js index 5fc0880b09e..ae19ed9ab02 100644 --- a/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js +++ b/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js @@ -1,4 +1,4 @@ -import { GlTable, GlLink } from '@gitlab/ui'; +import { GlTableLite, GlLink } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue'; @@ -24,7 +24,7 @@ describe('CI Lint Results', () => { }); }; - const findTable = () => wrapper.find(GlTable); + const findTable = () => wrapper.find(GlTableLite); const findByTestId = (selector) => () => wrapper.find(`[data-testid="ci-lint-${selector}"]`); const findAllByTestId = (selector) => () => wrapper.findAll(`[data-testid="ci-lint-${selector}"]`); diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index 1bfc5c3b93d..fc2cbdeda0a 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -39,6 +39,7 @@ job_build: export const mockCiTemplateQueryResponse = { data: { project: { + id: 'project-1', ciTemplate: { content: mockCiYml, }, @@ -48,19 +49,22 @@ export const mockCiTemplateQueryResponse = { export const mockBlobContentQueryResponse = { data: { - project: { repository: { blobs: { nodes: [{ rawBlob: mockCiYml }] } } }, + project: { + id: 'project-1', + repository: { blobs: { nodes: [{ id: 'blob-1', rawBlob: mockCiYml }] } }, + }, }, }; export const mockBlobContentQueryResponseNoCiFile = { data: { - project: { repository: { blobs: { nodes: [] } } }, + project: { id: 'project-1', repository: { blobs: { nodes: [] } } }, }, }; export const mockBlobContentQueryResponseEmptyCiFile = { data: { - project: { repository: { blobs: { nodes: [{ rawBlob: '' }] } } }, + project: { id: 'project-1', repository: { blobs: { nodes: [{ rawBlob: '' }] } } }, }, }; @@ -93,6 +97,7 @@ export const mockCiConfigQueryResponse = { groups: { nodes: [ { + id: 'group-1', name: 'job_test_1', size: 1, jobs: { @@ -108,6 +113,7 @@ export const mockCiConfigQueryResponse = { __typename: 'CiConfigGroup', }, { + id: 'group-2', name: 'job_test_2', size: 1, jobs: { @@ -170,9 +176,11 @@ export const mergeUnwrappedCiConfig = (mergedConfig) => { export const mockCommitShaResults = { data: { project: { + id: '1', repository: { tree: { lastCommit: { + id: 'commit-1', sha: mockCommitSha, }, }, @@ -184,9 +192,11 @@ export const mockCommitShaResults = { export const mockNewCommitShaResults = { data: { project: { + id: '1', repository: { tree: { lastCommit: { + id: 'commit-1', sha: 'eeff1122', }, }, @@ -198,9 +208,11 @@ export const mockNewCommitShaResults = { export const mockEmptyCommitShaResults = { data: { project: { + id: '1', repository: { tree: { lastCommit: { + id: 'commit-1', sha: '', }, }, @@ -212,6 +224,7 @@ export const mockEmptyCommitShaResults = { export const mockProjectBranches = { data: { project: { + id: '1', repository: { branchNames: [ 'main', @@ -236,6 +249,7 @@ export const mockTotalBranchResults = export const mockSearchBranches = { data: { project: { + id: '1', repository: { branchNames: ['test', 'better-feature', 'update-ci', 'test-merge-request'], }, @@ -248,6 +262,7 @@ export const mockTotalSearchResults = mockSearchBranches.data.project.repository export const mockEmptySearchBranches = { data: { project: { + id: '1', repository: { branchNames: [], }, @@ -284,16 +299,19 @@ export const mockProjectPipeline = ({ hasStages = true } = {}) => { : null; return { + id: '1', pipeline: { id: 'gid://gitlab/Ci::Pipeline/118', iid: '28', shortSha: mockCommitSha, status: 'SUCCESS', commit: { + id: 'commit-1', title: 'Update .gitlabe-ci.yml', webPath: '/-/commit/aabbccdd', }, detailedStatus: { + id: 'status-1', detailsPath: '/root/sample-ci-project/-/pipelines/118', group: 'success', icon: 'status_success', @@ -453,3 +471,33 @@ export const mockErrors = [ export const mockWarnings = [ '"jobs:multi_project_job may allow multiple pipelines to run for a single action due to `rules:when` clause with no `workflow:rules` - read more: https://docs.gitlab.com/ee/ci/troubleshooting.html#pipeline-warnings"', ]; + +export const mockCommitCreateResponse = { + data: { + commitCreate: { + __typename: 'CommitCreatePayload', + errors: [], + commit: { + __typename: 'Commit', + id: 'commit-1', + sha: mockCommitNextSha, + }, + commitPipelinePath: '', + }, + }, +}; + +export const mockCommitCreateResponseNewEtag = { + data: { + commitCreate: { + __typename: 'CommitCreatePayload', + errors: [], + commit: { + __typename: 'Commit', + id: 'commit-2', + sha: mockCommitNextSha, + }, + commitPipelinePath: '/api/graphql:pipelines/sha/550ceace1acd373c84d02bd539cb9d4614f786db', + }, + }, +}; diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index f6afef595c6..09d7d4f7ca6 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -8,13 +8,12 @@ import waitForPromises from 'helpers/wait_for_promises'; import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue'; import PipelineEditorMessages from '~/pipeline_editor/components/ui/pipeline_editor_messages.vue'; -import { COMMIT_SUCCESS, COMMIT_FAILURE } from '~/pipeline_editor/constants'; -import getBlobContent from '~/pipeline_editor/graphql/queries/blob_content.graphql'; -import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.graphql'; +import { COMMIT_SUCCESS, COMMIT_FAILURE, LOAD_FAILURE_UNKNOWN } from '~/pipeline_editor/constants'; +import getBlobContent from '~/pipeline_editor/graphql/queries/blob_content.query.graphql'; +import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.query.graphql'; import getTemplate from '~/pipeline_editor/graphql/queries/get_starter_template.query.graphql'; import getLatestCommitShaQuery from '~/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql'; - -import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql'; +import getPipelineQuery from '~/pipeline_editor/graphql/queries/pipeline.query.graphql'; import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue'; import PipelineEditorHome from '~/pipeline_editor/pipeline_editor_home.vue'; @@ -412,6 +411,94 @@ describe('Pipeline editor app component', () => { }); }); + describe('when multiple errors occurs in a row', () => { + const updateFailureMessage = 'The GitLab CI configuration could not be updated.'; + const unknownFailureMessage = 'The CI configuration was not loaded, please try again.'; + const unknownReasons = ['Commit failed']; + const alertErrorMessage = `${updateFailureMessage} ${unknownReasons[0]}`; + + const emitError = (type = COMMIT_FAILURE, reasons = unknownReasons) => + findEditorHome().vm.$emit('showError', { + type, + reasons, + }); + + beforeEach(async () => { + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); + mockCiConfigData.mockResolvedValue(mockCiConfigQueryResponse); + mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults); + + window.scrollTo = jest.fn(); + + await createComponentWithApollo({ stubs: { PipelineEditorMessages } }); + await emitError(); + }); + + it('shows an error message for the first error', () => { + expect(findAlert().text()).toMatchInterpolatedText(alertErrorMessage); + }); + + it('scrolls to the top of the page to bring attention to the error message', () => { + expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); + expect(window.scrollTo).toHaveBeenCalledTimes(1); + }); + + it('does not scroll to the top of the page if the same error occur multiple times in a row', async () => { + await emitError(); + + expect(window.scrollTo).toHaveBeenCalledTimes(1); + expect(findAlert().text()).toMatchInterpolatedText(alertErrorMessage); + }); + + it('scrolls to the top if the error is different', async () => { + await emitError(LOAD_FAILURE_UNKNOWN, []); + + expect(findAlert().text()).toMatchInterpolatedText(unknownFailureMessage); + expect(window.scrollTo).toHaveBeenCalledTimes(2); + }); + + describe('when a user dismiss the alert', () => { + beforeEach(async () => { + await findAlert().vm.$emit('dismiss'); + }); + + it('shows an error if the type is the same, but the reason is different', async () => { + const newReason = 'Something broke'; + + await emitError(COMMIT_FAILURE, [newReason]); + + expect(window.scrollTo).toHaveBeenCalledTimes(2); + expect(findAlert().text()).toMatchInterpolatedText(`${updateFailureMessage} ${newReason}`); + }); + + it('does not show an error or scroll if a new error with the same type occurs', async () => { + await emitError(); + + expect(window.scrollTo).toHaveBeenCalledTimes(1); + expect(findAlert().exists()).toBe(false); + }); + + it('it shows an error and scroll when a new type is emitted', async () => { + await emitError(LOAD_FAILURE_UNKNOWN, []); + + expect(window.scrollTo).toHaveBeenCalledTimes(2); + expect(findAlert().text()).toMatchInterpolatedText(unknownFailureMessage); + }); + + it('it shows an error and scroll if a previously shown type happen again', async () => { + await emitError(LOAD_FAILURE_UNKNOWN, []); + + expect(window.scrollTo).toHaveBeenCalledTimes(2); + expect(findAlert().text()).toMatchInterpolatedText(unknownFailureMessage); + + await emitError(); + + expect(window.scrollTo).toHaveBeenCalledTimes(3); + expect(findAlert().text()).toMatchInterpolatedText(alertErrorMessage); + }); + }); + }); + describe('when add_new_config_file query param is present', () => { const originalLocation = window.location.href; diff --git a/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap index 60625d301c0..99de0d2a3ef 100644 --- a/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap +++ b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap @@ -6,9 +6,11 @@ Array [ "groups": Array [ Object { "__typename": "CiGroup", + "id": "4", "jobs": Array [ Object { "__typename": "CiJob", + "id": "6", "name": "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", "needs": Array [], "scheduledAt": null, @@ -18,6 +20,7 @@ Array [ "__typename": "StatusAction", "buttonTitle": "Retry this job", "icon": "retry", + "id": "8", "path": "/root/abcd-dag/-/jobs/1482/retry", "title": "Retry", }, @@ -25,6 +28,7 @@ Array [ "group": "success", "hasDetails": true, "icon": "status_success", + "id": "7", "tooltip": "passed", }, }, @@ -36,14 +40,17 @@ Array [ "__typename": "DetailedStatus", "group": "success", "icon": "status_success", + "id": "5", "label": "passed", }, }, Object { "__typename": "CiGroup", + "id": "9", "jobs": Array [ Object { "__typename": "CiJob", + "id": "11", "name": "build_b", "needs": Array [], "scheduledAt": null, @@ -53,6 +60,7 @@ Array [ "__typename": "StatusAction", "buttonTitle": "Retry this job", "icon": "retry", + "id": "13", "path": "/root/abcd-dag/-/jobs/1515/retry", "title": "Retry", }, @@ -60,6 +68,7 @@ Array [ "group": "success", "hasDetails": true, "icon": "status_success", + "id": "12", "tooltip": "passed", }, }, @@ -71,14 +80,17 @@ Array [ "__typename": "DetailedStatus", "group": "success", "icon": "status_success", + "id": "10", "label": "passed", }, }, Object { "__typename": "CiGroup", + "id": "14", "jobs": Array [ Object { "__typename": "CiJob", + "id": "16", "name": "build_c", "needs": Array [], "scheduledAt": null, @@ -88,6 +100,7 @@ Array [ "__typename": "StatusAction", "buttonTitle": "Retry this job", "icon": "retry", + "id": "18", "path": "/root/abcd-dag/-/jobs/1484/retry", "title": "Retry", }, @@ -95,6 +108,7 @@ Array [ "group": "success", "hasDetails": true, "icon": "status_success", + "id": "17", "tooltip": "passed", }, }, @@ -106,14 +120,17 @@ Array [ "__typename": "DetailedStatus", "group": "success", "icon": "status_success", + "id": "15", "label": "passed", }, }, Object { "__typename": "CiGroup", + "id": "19", "jobs": Array [ Object { "__typename": "CiJob", + "id": "21", "name": "build_d 1/3", "needs": Array [], "scheduledAt": null, @@ -123,6 +140,7 @@ Array [ "__typename": "StatusAction", "buttonTitle": "Retry this job", "icon": "retry", + "id": "23", "path": "/root/abcd-dag/-/jobs/1485/retry", "title": "Retry", }, @@ -130,11 +148,13 @@ Array [ "group": "success", "hasDetails": true, "icon": "status_success", + "id": "22", "tooltip": "passed", }, }, Object { "__typename": "CiJob", + "id": "24", "name": "build_d 2/3", "needs": Array [], "scheduledAt": null, @@ -144,6 +164,7 @@ Array [ "__typename": "StatusAction", "buttonTitle": "Retry this job", "icon": "retry", + "id": "26", "path": "/root/abcd-dag/-/jobs/1486/retry", "title": "Retry", }, @@ -151,11 +172,13 @@ Array [ "group": "success", "hasDetails": true, "icon": "status_success", + "id": "25", "tooltip": "passed", }, }, Object { "__typename": "CiJob", + "id": "27", "name": "build_d 3/3", "needs": Array [], "scheduledAt": null, @@ -165,6 +188,7 @@ Array [ "__typename": "StatusAction", "buttonTitle": "Retry this job", "icon": "retry", + "id": "29", "path": "/root/abcd-dag/-/jobs/1487/retry", "title": "Retry", }, @@ -172,6 +196,7 @@ Array [ "group": "success", "hasDetails": true, "icon": "status_success", + "id": "28", "tooltip": "passed", }, }, @@ -183,14 +208,17 @@ Array [ "__typename": "DetailedStatus", "group": "success", "icon": "status_success", + "id": "20", "label": "passed", }, }, Object { "__typename": "CiGroup", + "id": "57", "jobs": Array [ Object { "__typename": "CiJob", + "id": "59", "name": "test_c", "needs": Array [], "scheduledAt": null, @@ -201,6 +229,7 @@ Array [ "group": "success", "hasDetails": true, "icon": "status_success", + "id": "60", "tooltip": null, }, }, @@ -212,6 +241,7 @@ Array [ "__typename": "DetailedStatus", "group": "success", "icon": "status_success", + "id": "58", "label": null, }, }, @@ -226,9 +256,11 @@ Array [ "groups": Array [ Object { "__typename": "CiGroup", + "id": "32", "jobs": Array [ Object { "__typename": "CiJob", + "id": "34", "name": "test_a", "needs": Array [ "build_c", @@ -242,6 +274,7 @@ Array [ "__typename": "StatusAction", "buttonTitle": "Retry this job", "icon": "retry", + "id": "36", "path": "/root/abcd-dag/-/jobs/1514/retry", "title": "Retry", }, @@ -249,6 +282,7 @@ Array [ "group": "success", "hasDetails": true, "icon": "status_success", + "id": "35", "tooltip": "passed", }, }, @@ -260,14 +294,17 @@ Array [ "__typename": "DetailedStatus", "group": "success", "icon": "status_success", + "id": "33", "label": "passed", }, }, Object { "__typename": "CiGroup", + "id": "40", "jobs": Array [ Object { "__typename": "CiJob", + "id": "42", "name": "test_b 1/2", "needs": Array [ "build_d 3/3", @@ -283,6 +320,7 @@ Array [ "__typename": "StatusAction", "buttonTitle": "Retry this job", "icon": "retry", + "id": "44", "path": "/root/abcd-dag/-/jobs/1489/retry", "title": "Retry", }, @@ -290,11 +328,13 @@ Array [ "group": "success", "hasDetails": true, "icon": "status_success", + "id": "43", "tooltip": "passed", }, }, Object { "__typename": "CiJob", + "id": "67", "name": "test_b 2/2", "needs": Array [ "build_d 3/3", @@ -310,6 +350,7 @@ Array [ "__typename": "StatusAction", "buttonTitle": "Retry this job", "icon": "retry", + "id": "51", "path": "/root/abcd-dag/-/jobs/1490/retry", "title": "Retry", }, @@ -317,6 +358,7 @@ Array [ "group": "success", "hasDetails": true, "icon": "status_success", + "id": "50", "tooltip": "passed", }, }, @@ -328,14 +370,17 @@ Array [ "__typename": "DetailedStatus", "group": "success", "icon": "status_success", + "id": "41", "label": "passed", }, }, Object { "__typename": "CiGroup", + "id": "61", "jobs": Array [ Object { "__typename": "CiJob", + "id": "53", "name": "test_d", "needs": Array [ "build_b", @@ -348,6 +393,7 @@ Array [ "group": "success", "hasDetails": true, "icon": "status_success", + "id": "64", "tooltip": null, }, }, @@ -359,6 +405,7 @@ Array [ "__typename": "DetailedStatus", "group": "success", "icon": "status_success", + "id": "62", "label": null, }, }, diff --git a/spec/frontend/pipelines/components/jobs/jobs_app_spec.js b/spec/frontend/pipelines/components/jobs/jobs_app_spec.js new file mode 100644 index 00000000000..1ea6096c922 --- /dev/null +++ b/spec/frontend/pipelines/components/jobs/jobs_app_spec.js @@ -0,0 +1,106 @@ +import { GlIntersectionObserver, GlSkeletonLoader } from '@gitlab/ui'; +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 createFlash from '~/flash'; +import JobsApp from '~/pipelines/components/jobs/jobs_app.vue'; +import JobsTable from '~/jobs/components/table/jobs_table.vue'; +import getPipelineJobsQuery from '~/pipelines/graphql/queries/get_pipeline_jobs.query.graphql'; +import { mockPipelineJobsQueryResponse } from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +jest.mock('~/flash'); + +describe('Jobs app', () => { + let wrapper; + let resolverSpy; + + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findJobsTable = () => wrapper.findComponent(JobsTable); + + const triggerInfiniteScroll = () => + wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear'); + + const createMockApolloProvider = (resolver) => { + const requestHandlers = [[getPipelineJobsQuery, resolver]]; + + return createMockApollo(requestHandlers); + }; + + const createComponent = (resolver) => { + wrapper = shallowMount(JobsApp, { + provide: { + fullPath: 'root/ci-project', + pipelineIid: 1, + }, + localVue, + apolloProvider: createMockApolloProvider(resolver), + }); + }; + + beforeEach(() => { + resolverSpy = jest.fn().mockResolvedValue(mockPipelineJobsQueryResponse); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays the loading state', () => { + createComponent(resolverSpy); + + expect(findSkeletonLoader().exists()).toBe(true); + expect(findJobsTable().exists()).toBe(false); + }); + + it('displays the jobs table', async () => { + createComponent(resolverSpy); + + await waitForPromises(); + + expect(findJobsTable().exists()).toBe(true); + expect(findSkeletonLoader().exists()).toBe(false); + expect(createFlash).not.toHaveBeenCalled(); + }); + + it('handles job fetch error correctly', async () => { + resolverSpy = jest.fn().mockRejectedValue(new Error('GraphQL error')); + + createComponent(resolverSpy); + + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'An error occured while fetching the pipelines jobs.', + }); + }); + + it('handles infinite scrolling by calling fetchMore', async () => { + createComponent(resolverSpy); + + await waitForPromises(); + + triggerInfiniteScroll(); + + expect(resolverSpy).toHaveBeenCalledWith({ + after: 'eyJpZCI6Ijg0NyJ9', + fullPath: 'root/ci-project', + iid: 1, + }); + }); + + it('does not display main loading state again after fetchMore', async () => { + createComponent(resolverSpy); + + expect(findSkeletonLoader().exists()).toBe(true); + + await waitForPromises(); + + triggerInfiniteScroll(); + + expect(findSkeletonLoader().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js index db4de6deeb7..04e004dc6c1 100644 --- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -1,7 +1,7 @@ -import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -98,7 +98,6 @@ describe('Pipeline graph wrapper', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); beforeAll(() => { @@ -136,7 +135,7 @@ describe('Pipeline graph wrapper', () => { beforeEach(async () => { createComponentWithApollo(); jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); + await nextTick(); }); it('does not display the loading icon', () => { @@ -165,7 +164,7 @@ describe('Pipeline graph wrapper', () => { getPipelineDetailsHandler: jest.fn().mockRejectedValue(new Error('GraphQL error')), }); jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); + await nextTick(); }); it('does not display the loading icon', () => { @@ -189,7 +188,7 @@ describe('Pipeline graph wrapper', () => { }, }); jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); + await nextTick(); }); it('does not display the loading icon', () => { @@ -211,7 +210,7 @@ describe('Pipeline graph wrapper', () => { createComponentWithApollo(); jest.spyOn(wrapper.vm.$apollo.queries.headerPipeline, 'refetch'); jest.spyOn(wrapper.vm.$apollo.queries.pipeline, 'refetch'); - await wrapper.vm.$nextTick(); + await nextTick(); getGraph().vm.$emit('refreshPipelineGraph'); }); @@ -225,8 +224,8 @@ describe('Pipeline graph wrapper', () => { describe('when query times out', () => { const advanceApolloTimers = async () => { jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); - await wrapper.vm.$nextTick(); + await nextTick(); + await nextTick(); }; beforeEach(async () => { @@ -246,7 +245,7 @@ describe('Pipeline graph wrapper', () => { .mockResolvedValueOnce(errorData); createComponentWithApollo({ getPipelineDetailsHandler: failSucceedFail }); - await wrapper.vm.$nextTick(); + await nextTick(); }); it('shows correct errors and does not overwrite populated data when data is empty', async () => { @@ -276,7 +275,7 @@ describe('Pipeline graph wrapper', () => { }); jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); + await nextTick(); }); it('appears when pipeline uses needs', () => { @@ -319,7 +318,7 @@ describe('Pipeline graph wrapper', () => { }); jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); + await nextTick(); }); it('sets showLinks to true', async () => { @@ -329,7 +328,7 @@ describe('Pipeline graph wrapper', () => { expect(getViewSelector().props('type')).toBe(LAYER_VIEW); await getDependenciesToggle().vm.$emit('change', true); jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); + await nextTick(); expect(wrapper.findComponent(LinksLayer).props('showLinks')).toBe(true); }); }); @@ -345,7 +344,7 @@ describe('Pipeline graph wrapper', () => { }); jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); + await nextTick(); }); it('shows the hover tip in the view selector', async () => { @@ -366,7 +365,7 @@ describe('Pipeline graph wrapper', () => { }); jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); + await nextTick(); }); it('does not show the hover tip', async () => { @@ -384,7 +383,7 @@ describe('Pipeline graph wrapper', () => { }); jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); + await nextTick(); }); afterEach(() => { @@ -393,9 +392,10 @@ describe('Pipeline graph wrapper', () => { it('reads the view type from localStorage when available', () => { const viewSelectorNeedsSegment = wrapper - .findAll('[data-testid="pipeline-view-selector"] > label') + .find(GlButtonGroup) + .findAllComponents(GlButton) .at(1); - expect(viewSelectorNeedsSegment.classes()).toContain('active'); + expect(viewSelectorNeedsSegment.classes()).toContain('selected'); }); }); @@ -412,7 +412,7 @@ describe('Pipeline graph wrapper', () => { }); jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); + await nextTick(); }); afterEach(() => { @@ -435,7 +435,7 @@ describe('Pipeline graph wrapper', () => { }); jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); + await nextTick(); }); it('does not appear when pipeline does not use needs', () => { @@ -462,7 +462,7 @@ describe('Pipeline graph wrapper', () => { beforeEach(async () => { createComponentWithApollo(); jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); + await nextTick(); }); it('is not called', () => { @@ -506,7 +506,7 @@ describe('Pipeline graph wrapper', () => { }); jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); + await nextTick(); }); it('attempts to collect metrics', () => { diff --git a/spec/frontend/pipelines/graph/graph_view_selector_spec.js b/spec/frontend/pipelines/graph/graph_view_selector_spec.js index f4faa25545b..f574f4dccc5 100644 --- a/spec/frontend/pipelines/graph/graph_view_selector_spec.js +++ b/spec/frontend/pipelines/graph/graph_view_selector_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlLoadingIcon, GlSegmentedControl } from '@gitlab/ui'; +import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import { LAYER_VIEW, STAGE_VIEW } from '~/pipelines/components/graph/constants'; import GraphViewSelector from '~/pipelines/components/graph/graph_view_selector.vue'; @@ -7,9 +7,9 @@ describe('the graph view selector component', () => { let wrapper; const findDependenciesToggle = () => wrapper.find('[data-testid="show-links-toggle"]'); - const findViewTypeSelector = () => wrapper.findComponent(GlSegmentedControl); - const findStageViewLabel = () => findViewTypeSelector().findAll('label').at(0); - const findLayersViewLabel = () => findViewTypeSelector().findAll('label').at(1); + const findViewTypeSelector = () => wrapper.findComponent(GlButtonGroup); + const findStageViewButton = () => findViewTypeSelector().findAllComponents(GlButton).at(0); + const findLayerViewButton = () => findViewTypeSelector().findAllComponents(GlButton).at(1); const findSwitcherLoader = () => wrapper.find('[data-testid="switcher-loading-state"]'); const findToggleLoader = () => findDependenciesToggle().find(GlLoadingIcon); const findHoverTip = () => wrapper.findComponent(GlAlert); @@ -51,8 +51,13 @@ describe('the graph view selector component', () => { createComponent({ mountFn: mount }); }); - it('shows the Stage view label as active in the selector', () => { - expect(findStageViewLabel().classes()).toContain('active'); + it('shows the Stage view button as selected', () => { + expect(findStageViewButton().classes('selected')).toBe(true); + }); + + it('shows the Job dependencies view button not selected', () => { + expect(findLayerViewButton().exists()).toBe(true); + expect(findLayerViewButton().classes('selected')).toBe(false); }); it('does not show the Job dependencies (links) toggle', () => { @@ -70,8 +75,13 @@ describe('the graph view selector component', () => { }); }); - it('shows the Job dependencies view label as active in the selector', () => { - expect(findLayersViewLabel().classes()).toContain('active'); + it('shows the Job dependencies view as selected', () => { + expect(findLayerViewButton().classes('selected')).toBe(true); + }); + + it('shows the Stage button as not selected', () => { + expect(findStageViewButton().exists()).toBe(true); + expect(findStageViewButton().classes('selected')).toBe(false); }); it('shows the Job dependencies (links) toggle', () => { @@ -94,7 +104,7 @@ describe('the graph view selector component', () => { expect(wrapper.emitted().updateViewType).toBeUndefined(); expect(findSwitcherLoader().exists()).toBe(false); - await findStageViewLabel().trigger('click'); + await findStageViewButton().trigger('click'); /* Loading happens before the event is emitted or timers are run. Then we run the timer because the event is emitted in setInterval @@ -123,6 +133,14 @@ describe('the graph view selector component', () => { expect(wrapper.emitted().updateShowLinksState).toHaveLength(1); expect(wrapper.emitted().updateShowLinksState).toEqual([[true]]); }); + + it('does not emit an event if the click occurs on the currently selected view button', async () => { + expect(wrapper.emitted().updateShowLinksState).toBeUndefined(); + + await findLayerViewButton().trigger('click'); + + expect(wrapper.emitted().updateShowLinksState).toBeUndefined(); + }); }); describe('hover tip callout', () => { diff --git a/spec/frontend/pipelines/graph/mock_data.js b/spec/frontend/pipelines/graph/mock_data.js index 3812483766d..dcbbde7bf36 100644 --- a/spec/frontend/pipelines/graph/mock_data.js +++ b/spec/frontend/pipelines/graph/mock_data.js @@ -4,6 +4,7 @@ export const mockPipelineResponse = { data: { project: { __typename: 'Project', + id: '1', pipeline: { __typename: 'Pipeline', id: 163, @@ -21,9 +22,11 @@ export const mockPipelineResponse = { nodes: [ { __typename: 'CiStage', + id: '2', name: 'build', status: { __typename: 'DetailedStatus', + id: '3', action: null, }, groups: { @@ -31,10 +34,12 @@ export const mockPipelineResponse = { nodes: [ { __typename: 'CiGroup', + id: '4', name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', size: 1, status: { __typename: 'DetailedStatus', + id: '5', label: 'passed', group: 'success', icon: 'status_success', @@ -44,10 +49,12 @@ export const mockPipelineResponse = { nodes: [ { __typename: 'CiJob', + id: '6', name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', scheduledAt: null, status: { __typename: 'DetailedStatus', + id: '7', icon: 'status_success', tooltip: 'passed', hasDetails: true, @@ -55,6 +62,7 @@ export const mockPipelineResponse = { group: 'success', action: { __typename: 'StatusAction', + id: '8', buttonTitle: 'Retry this job', icon: 'retry', path: '/root/abcd-dag/-/jobs/1482/retry', @@ -72,9 +80,11 @@ export const mockPipelineResponse = { { __typename: 'CiGroup', name: 'build_b', + id: '9', size: 1, status: { __typename: 'DetailedStatus', + id: '10', label: 'passed', group: 'success', icon: 'status_success', @@ -84,10 +94,12 @@ export const mockPipelineResponse = { nodes: [ { __typename: 'CiJob', + id: '11', name: 'build_b', scheduledAt: null, status: { __typename: 'DetailedStatus', + id: '12', icon: 'status_success', tooltip: 'passed', hasDetails: true, @@ -95,6 +107,7 @@ export const mockPipelineResponse = { group: 'success', action: { __typename: 'StatusAction', + id: '13', buttonTitle: 'Retry this job', icon: 'retry', path: '/root/abcd-dag/-/jobs/1515/retry', @@ -111,10 +124,12 @@ export const mockPipelineResponse = { }, { __typename: 'CiGroup', + id: '14', name: 'build_c', size: 1, status: { __typename: 'DetailedStatus', + id: '15', label: 'passed', group: 'success', icon: 'status_success', @@ -124,10 +139,12 @@ export const mockPipelineResponse = { nodes: [ { __typename: 'CiJob', + id: '16', name: 'build_c', scheduledAt: null, status: { __typename: 'DetailedStatus', + id: '17', icon: 'status_success', tooltip: 'passed', hasDetails: true, @@ -135,6 +152,7 @@ export const mockPipelineResponse = { group: 'success', action: { __typename: 'StatusAction', + id: '18', buttonTitle: 'Retry this job', icon: 'retry', path: '/root/abcd-dag/-/jobs/1484/retry', @@ -151,10 +169,12 @@ export const mockPipelineResponse = { }, { __typename: 'CiGroup', + id: '19', name: 'build_d', size: 3, status: { __typename: 'DetailedStatus', + id: '20', label: 'passed', group: 'success', icon: 'status_success', @@ -164,10 +184,12 @@ export const mockPipelineResponse = { nodes: [ { __typename: 'CiJob', + id: '21', name: 'build_d 1/3', scheduledAt: null, status: { __typename: 'DetailedStatus', + id: '22', icon: 'status_success', tooltip: 'passed', hasDetails: true, @@ -175,6 +197,7 @@ export const mockPipelineResponse = { group: 'success', action: { __typename: 'StatusAction', + id: '23', buttonTitle: 'Retry this job', icon: 'retry', path: '/root/abcd-dag/-/jobs/1485/retry', @@ -188,10 +211,12 @@ export const mockPipelineResponse = { }, { __typename: 'CiJob', + id: '24', name: 'build_d 2/3', scheduledAt: null, status: { __typename: 'DetailedStatus', + id: '25', icon: 'status_success', tooltip: 'passed', hasDetails: true, @@ -199,6 +224,7 @@ export const mockPipelineResponse = { group: 'success', action: { __typename: 'StatusAction', + id: '26', buttonTitle: 'Retry this job', icon: 'retry', path: '/root/abcd-dag/-/jobs/1486/retry', @@ -212,10 +238,12 @@ export const mockPipelineResponse = { }, { __typename: 'CiJob', + id: '27', name: 'build_d 3/3', scheduledAt: null, status: { __typename: 'DetailedStatus', + id: '28', icon: 'status_success', tooltip: 'passed', hasDetails: true, @@ -223,6 +251,7 @@ export const mockPipelineResponse = { group: 'success', action: { __typename: 'StatusAction', + id: '29', buttonTitle: 'Retry this job', icon: 'retry', path: '/root/abcd-dag/-/jobs/1487/retry', @@ -242,9 +271,11 @@ export const mockPipelineResponse = { }, { __typename: 'CiStage', + id: '30', name: 'test', status: { __typename: 'DetailedStatus', + id: '31', action: null, }, groups: { @@ -252,10 +283,12 @@ export const mockPipelineResponse = { nodes: [ { __typename: 'CiGroup', + id: '32', name: 'test_a', size: 1, status: { __typename: 'DetailedStatus', + id: '33', label: 'passed', group: 'success', icon: 'status_success', @@ -265,10 +298,12 @@ export const mockPipelineResponse = { nodes: [ { __typename: 'CiJob', + id: '34', name: 'test_a', scheduledAt: null, status: { __typename: 'DetailedStatus', + id: '35', icon: 'status_success', tooltip: 'passed', hasDetails: true, @@ -276,6 +311,7 @@ export const mockPipelineResponse = { group: 'success', action: { __typename: 'StatusAction', + id: '36', buttonTitle: 'Retry this job', icon: 'retry', path: '/root/abcd-dag/-/jobs/1514/retry', @@ -287,14 +323,17 @@ export const mockPipelineResponse = { nodes: [ { __typename: 'CiBuildNeed', + id: '37', name: 'build_c', }, { __typename: 'CiBuildNeed', + id: '38', name: 'build_b', }, { __typename: 'CiBuildNeed', + id: '39', name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', }, @@ -306,10 +345,12 @@ export const mockPipelineResponse = { }, { __typename: 'CiGroup', + id: '40', name: 'test_b', size: 2, status: { __typename: 'DetailedStatus', + id: '41', label: 'passed', group: 'success', icon: 'status_success', @@ -319,10 +360,12 @@ export const mockPipelineResponse = { nodes: [ { __typename: 'CiJob', + id: '42', name: 'test_b 1/2', scheduledAt: null, status: { __typename: 'DetailedStatus', + id: '43', icon: 'status_success', tooltip: 'passed', hasDetails: true, @@ -330,6 +373,7 @@ export const mockPipelineResponse = { group: 'success', action: { __typename: 'StatusAction', + id: '44', buttonTitle: 'Retry this job', icon: 'retry', path: '/root/abcd-dag/-/jobs/1489/retry', @@ -341,22 +385,27 @@ export const mockPipelineResponse = { nodes: [ { __typename: 'CiBuildNeed', + id: '45', name: 'build_d 3/3', }, { __typename: 'CiBuildNeed', + id: '46', name: 'build_d 2/3', }, { __typename: 'CiBuildNeed', + id: '47', name: 'build_d 1/3', }, { __typename: 'CiBuildNeed', + id: '48', name: 'build_b', }, { __typename: 'CiBuildNeed', + id: '49', name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', }, @@ -365,10 +414,12 @@ export const mockPipelineResponse = { }, { __typename: 'CiJob', + id: '67', name: 'test_b 2/2', scheduledAt: null, status: { __typename: 'DetailedStatus', + id: '50', icon: 'status_success', tooltip: 'passed', hasDetails: true, @@ -376,6 +427,7 @@ export const mockPipelineResponse = { group: 'success', action: { __typename: 'StatusAction', + id: '51', buttonTitle: 'Retry this job', icon: 'retry', path: '/root/abcd-dag/-/jobs/1490/retry', @@ -387,22 +439,27 @@ export const mockPipelineResponse = { nodes: [ { __typename: 'CiBuildNeed', + id: '52', name: 'build_d 3/3', }, { __typename: 'CiBuildNeed', + id: '53', name: 'build_d 2/3', }, { __typename: 'CiBuildNeed', + id: '54', name: 'build_d 1/3', }, { __typename: 'CiBuildNeed', + id: '55', name: 'build_b', }, { __typename: 'CiBuildNeed', + id: '56', name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', }, @@ -415,9 +472,11 @@ export const mockPipelineResponse = { { __typename: 'CiGroup', name: 'test_c', + id: '57', size: 1, status: { __typename: 'DetailedStatus', + id: '58', label: null, group: 'success', icon: 'status_success', @@ -427,10 +486,12 @@ export const mockPipelineResponse = { nodes: [ { __typename: 'CiJob', + id: '59', name: 'test_c', scheduledAt: null, status: { __typename: 'DetailedStatus', + id: '60', icon: 'status_success', tooltip: null, hasDetails: true, @@ -448,9 +509,11 @@ export const mockPipelineResponse = { }, { __typename: 'CiGroup', + id: '61', name: 'test_d', size: 1, status: { + id: '62', __typename: 'DetailedStatus', label: null, group: 'success', @@ -461,10 +524,12 @@ export const mockPipelineResponse = { nodes: [ { __typename: 'CiJob', + id: '53', name: 'test_d', scheduledAt: null, status: { __typename: 'DetailedStatus', + id: '64', icon: 'status_success', tooltip: null, hasDetails: true, @@ -477,6 +542,7 @@ export const mockPipelineResponse = { nodes: [ { __typename: 'CiBuildNeed', + id: '65', name: 'build_b', }, ], @@ -502,6 +568,7 @@ export const downstream = { iid: '31', path: '/root/elemenohpee/-/pipelines/175', status: { + id: '70', group: 'success', label: 'passed', icon: 'status_success', @@ -509,6 +576,7 @@ export const downstream = { }, sourceJob: { name: 'test_c', + id: '71', __typename: 'CiJob', }, project: { @@ -525,12 +593,14 @@ export const downstream = { iid: '27', path: '/root/abcd-dag/-/pipelines/181', status: { + id: '72', group: 'success', label: 'passed', icon: 'status_success', __typename: 'DetailedStatus', }, sourceJob: { + id: '73', name: 'test_d', __typename: 'CiJob', }, @@ -551,6 +621,7 @@ export const upstream = { iid: '24', path: '/root/abcd-dag/-/pipelines/161', status: { + id: '74', group: 'success', label: 'passed', icon: 'status_success', @@ -571,6 +642,7 @@ export const wrappedPipelineReturn = { data: { project: { __typename: 'Project', + id: '75', pipeline: { __typename: 'Pipeline', id: 'gid://gitlab/Ci::Pipeline/175', @@ -592,12 +664,14 @@ export const wrappedPipelineReturn = { __typename: 'Pipeline', status: { __typename: 'DetailedStatus', + id: '77', group: 'success', label: 'passed', icon: 'status_success', }, sourceJob: { name: 'test_c', + id: '78', __typename: 'CiJob', }, project: { @@ -613,8 +687,10 @@ export const wrappedPipelineReturn = { { name: 'build', __typename: 'CiStage', + id: '79', status: { action: null, + id: '80', __typename: 'DetailedStatus', }, groups: { @@ -622,8 +698,10 @@ export const wrappedPipelineReturn = { nodes: [ { __typename: 'CiGroup', + id: '81', status: { __typename: 'DetailedStatus', + id: '82', label: 'passed', group: 'success', icon: 'status_success', @@ -635,6 +713,7 @@ export const wrappedPipelineReturn = { nodes: [ { __typename: 'CiJob', + id: '83', name: 'build_n', scheduledAt: null, needs: { @@ -643,6 +722,7 @@ export const wrappedPipelineReturn = { }, status: { __typename: 'DetailedStatus', + id: '84', icon: 'status_success', tooltip: 'passed', hasDetails: true, @@ -650,6 +730,7 @@ export const wrappedPipelineReturn = { group: 'success', action: { __typename: 'StatusAction', + id: '85', buttonTitle: 'Retry this job', icon: 'retry', path: '/root/elemenohpee/-/jobs/1662/retry', diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js index fdc78d48901..b9d20eb7ca5 100644 --- a/spec/frontend/pipelines/mock_data.js +++ b/spec/frontend/pipelines/mock_data.js @@ -14,6 +14,7 @@ export const mockPipelineHeader = { }, createdAt: threeWeeksAgo.toISOString(), user: { + id: 'user-1', name: 'Foo', username: 'foobar', email: 'foo@bar.com', @@ -27,6 +28,7 @@ export const mockFailedPipelineHeader = { retryable: true, cancelable: false, detailedStatus: { + id: 'status-1', group: 'failed', icon: 'status_failed', label: 'failed', @@ -43,6 +45,7 @@ export const mockFailedPipelineNoPermissions = { }, createdAt: threeWeeksAgo.toISOString(), user: { + id: 'user-1', name: 'Foo', username: 'foobar', email: 'foo@bar.com', @@ -52,6 +55,7 @@ export const mockFailedPipelineNoPermissions = { retryable: true, cancelable: false, detailedStatus: { + id: 'status-1', group: 'running', icon: 'status_running', label: 'running', @@ -66,6 +70,7 @@ export const mockRunningPipelineHeader = { retryable: false, cancelable: true, detailedStatus: { + id: 'status-1', group: 'running', icon: 'status_running', label: 'running', @@ -82,6 +87,7 @@ export const mockRunningPipelineNoPermissions = { }, createdAt: threeWeeksAgo.toISOString(), user: { + id: 'user-1', name: 'Foo', username: 'foobar', email: 'foo@bar.com', @@ -91,6 +97,7 @@ export const mockRunningPipelineNoPermissions = { retryable: false, cancelable: true, detailedStatus: { + id: 'status-1', group: 'running', icon: 'status_running', label: 'running', @@ -105,6 +112,7 @@ export const mockCancelledPipelineHeader = { retryable: true, cancelable: false, detailedStatus: { + id: 'status-1', group: 'cancelled', icon: 'status_cancelled', label: 'cancelled', @@ -119,6 +127,7 @@ export const mockSuccessfulPipelineHeader = { retryable: false, cancelable: false, detailedStatus: { + id: 'status-1', group: 'success', icon: 'status_success', label: 'success', @@ -130,13 +139,16 @@ export const mockSuccessfulPipelineHeader = { export const mockRunningPipelineHeaderData = { data: { project: { + id: '1', pipeline: { ...mockRunningPipelineHeader, iid: '28', user: { + id: 'user-1', name: 'Foo', username: 'foobar', webPath: '/foo', + webUrl: '/foo', email: 'foo@bar.com', avatarUrl: 'link', status: null, @@ -493,3 +505,132 @@ export const mockSearch = [ export const mockBranchesAfterMap = ['branch-1', 'branch-10', 'branch-11']; export const mockTagsAfterMap = ['tag-3', 'tag-2', 'tag-1', 'main-tag']; + +export const mockPipelineJobsQueryResponse = { + data: { + project: { + id: 'gid://gitlab/Project/20', + __typename: 'Project', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/224', + __typename: 'Pipeline', + jobs: { + __typename: 'CiJobConnection', + pageInfo: { + endCursor: 'eyJpZCI6Ijg0NyJ9', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'eyJpZCI6IjYyMCJ9', + __typename: 'PageInfo', + }, + nodes: [ + { + artifacts: { + nodes: [ + { + downloadPath: '/root/ci-project/-/jobs/620/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + allowFailure: false, + status: 'SUCCESS', + scheduledAt: null, + manualJob: false, + triggered: null, + createdByTag: false, + detailedStatus: { + id: 'success-620-620', + detailsPath: '/root/ci-project/-/jobs/620', + group: 'success', + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed (retried)', + action: null, + __typename: 'DetailedStatus', + }, + id: 'gid://gitlab/Ci::Build/620', + refName: 'main', + refPath: '/root/ci-project/-/commits/main', + tags: [], + shortSha: '5acce24b', + commitPath: '/root/ci-project/-/commit/5acce24b3737d4f0d649ad0a26ae1903a2b35f5e', + stage: { id: 'gid://gitlab/Ci::Stage/148', name: 'test', __typename: 'CiStage' }, + name: 'coverage_job', + duration: 4, + finishedAt: '2021-12-06T14:13:49Z', + coverage: 82.71, + retryable: false, + playable: false, + cancelable: false, + active: false, + stuck: false, + userPermissions: { + readBuild: true, + readJobArtifacts: true, + updateBuild: true, + __typename: 'JobPermissions', + }, + __typename: 'CiJob', + }, + { + artifacts: { + nodes: [ + { + downloadPath: '/root/ci-project/-/jobs/619/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + allowFailure: false, + status: 'SUCCESS', + scheduledAt: null, + manualJob: false, + triggered: null, + createdByTag: false, + detailedStatus: { + id: 'success-619-619', + detailsPath: '/root/ci-project/-/jobs/619', + group: 'success', + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed (retried)', + action: null, + __typename: 'DetailedStatus', + }, + id: 'gid://gitlab/Ci::Build/619', + refName: 'main', + refPath: '/root/ci-project/-/commits/main', + tags: [], + shortSha: '5acce24b', + commitPath: '/root/ci-project/-/commit/5acce24b3737d4f0d649ad0a26ae1903a2b35f5e', + stage: { id: 'gid://gitlab/Ci::Stage/148', name: 'test', __typename: 'CiStage' }, + name: 'test_job_two', + duration: 4, + finishedAt: '2021-12-06T14:13:44Z', + coverage: null, + retryable: false, + playable: false, + cancelable: false, + active: false, + stuck: false, + userPermissions: { + readBuild: true, + readJobArtifacts: true, + updateBuild: true, + __typename: 'JobPermissions', + }, + __typename: 'CiJob', + }, + ], + }, + }, + }, + }, +}; diff --git a/spec/frontend/projects/new/components/new_project_url_select_spec.js b/spec/frontend/projects/new/components/new_project_url_select_spec.js index b3f177a1f12..258fa7636d4 100644 --- a/spec/frontend/projects/new/components/new_project_url_select_spec.js +++ b/spec/frontend/projects/new/components/new_project_url_select_spec.js @@ -5,7 +5,8 @@ import { GlDropdownSectionHeader, GlSearchBoxByType, } from '@gitlab/ui'; -import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; +import { mount, shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; @@ -19,6 +20,7 @@ describe('NewProjectUrlSelect component', () => { const data = { currentUser: { + id: 'user-1', groups: { nodes: [ { @@ -51,8 +53,7 @@ describe('NewProjectUrlSelect component', () => { }, }; - const localVue = createLocalVue(); - localVue.use(VueApollo); + Vue.use(VueApollo); const defaultProvide = { namespaceFullPath: 'h5bp', @@ -63,17 +64,19 @@ describe('NewProjectUrlSelect component', () => { userNamespaceId: '1', }; + let mockQueryResponse; + const mountComponent = ({ search = '', queryResponse = data, provide = defaultProvide, mountFn = shallowMount, } = {}) => { - const requestHandlers = [[searchQuery, jest.fn().mockResolvedValue({ data: queryResponse })]]; + mockQueryResponse = jest.fn().mockResolvedValue({ data: queryResponse }); + const requestHandlers = [[searchQuery, mockQueryResponse]]; const apolloProvider = createMockApollo(requestHandlers); return mountFn(NewProjectUrlSelect, { - localVue, apolloProvider, provide, data() { @@ -87,12 +90,19 @@ describe('NewProjectUrlSelect component', () => { const findButtonLabel = () => wrapper.findComponent(GlButton); const findDropdown = () => wrapper.findComponent(GlDropdown); const findInput = () => wrapper.findComponent(GlSearchBoxByType); - const findHiddenInput = () => wrapper.find('input'); + const findHiddenInput = () => wrapper.find('[name="project[namespace_id]"]'); + const clickDropdownItem = async () => { wrapper.findComponent(GlDropdownItem).vm.$emit('click'); await wrapper.vm.$nextTick(); }; + const showDropdown = async () => { + findDropdown().vm.$emit('shown'); + await wrapper.vm.$apollo.queries.currentUser.refetch(); + jest.runOnlyPendingTimers(); + }; + afterEach(() => { wrapper.destroy(); }); @@ -140,20 +150,18 @@ describe('NewProjectUrlSelect component', () => { it('focuses on the input when the dropdown is opened', async () => { wrapper = mountComponent({ mountFn: mount }); - jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); const spy = jest.spyOn(findInput().vm, 'focusInput'); - findDropdown().vm.$emit('shown'); + await showDropdown(); expect(spy).toHaveBeenCalledTimes(1); }); it('renders expected dropdown items', async () => { wrapper = mountComponent({ mountFn: mount }); - jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); + + await showDropdown(); const listItems = wrapper.findAll('li'); @@ -166,15 +174,36 @@ describe('NewProjectUrlSelect component', () => { expect(listItems.at(5).text()).toBe(data.currentUser.namespace.fullPath); }); + describe('query fetching', () => { + describe('on component mount', () => { + it('does not fetch query', () => { + wrapper = mountComponent({ mountFn: mount }); + + expect(mockQueryResponse).not.toHaveBeenCalled(); + }); + }); + + describe('on dropdown shown', () => { + it('fetches query', async () => { + wrapper = mountComponent({ mountFn: mount }); + + await showDropdown(); + + expect(mockQueryResponse).toHaveBeenCalled(); + }); + }); + }); + describe('when selecting from a group template', () => { - const groupId = getIdFromGraphQLId(data.currentUser.groups.nodes[1].id); + const { fullPath, id } = data.currentUser.groups.nodes[1]; beforeEach(async () => { wrapper = mountComponent({ mountFn: mount }); - jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); - eventHub.$emit('select-template', groupId); + // Show dropdown to fetch projects + await showDropdown(); + + eventHub.$emit('select-template', getIdFromGraphQLId(id), fullPath); }); it('filters the dropdown items to the selected group and children', async () => { @@ -187,13 +216,14 @@ describe('NewProjectUrlSelect component', () => { }); it('sets the selection to the group', async () => { - expect(findDropdown().props('text')).toBe(data.currentUser.groups.nodes[1].fullPath); + expect(findDropdown().props('text')).toBe(fullPath); }); }); it('renders `No matches found` when there are no matching dropdown items', async () => { const queryResponse = { currentUser: { + id: 'user-1', groups: { nodes: [], }, @@ -212,12 +242,13 @@ describe('NewProjectUrlSelect component', () => { }); it('emits `update-visibility` event to update the visibility radio options', async () => { - wrapper = mountComponent(); - jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); + wrapper = mountComponent({ mountFn: mount }); const spy = jest.spyOn(eventHub, '$emit'); + // Show dropdown to fetch projects + await showDropdown(); + await clickDropdownItem(); const namespace = data.currentUser.groups.nodes[0]; @@ -231,16 +262,16 @@ describe('NewProjectUrlSelect component', () => { }); it('updates hidden input with selected namespace', async () => { - wrapper = mountComponent(); - jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); + wrapper = mountComponent({ mountFn: mount }); + + // Show dropdown to fetch projects + await showDropdown(); await clickDropdownItem(); - expect(findHiddenInput().attributes()).toMatchObject({ - name: 'project[namespace_id]', - value: getIdFromGraphQLId(data.currentUser.groups.nodes[0].id).toString(), - }); + expect(findHiddenInput().attributes('value')).toBe( + getIdFromGraphQLId(data.currentUser.groups.nodes[0].id).toString(), + ); }); it('tracks clicking on the dropdown', () => { diff --git a/spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap b/spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap index be3716c24e6..5ec0ad794fb 100644 --- a/spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap +++ b/spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap @@ -25,9 +25,13 @@ exports[`StatisticsList displays the counts data with labels 1`] = ` Failed: </span> - <strong> - 2 pipelines - </strong> + <gl-link-stub + href="/flightjs/Flight/-/pipelines?page=1&scope=all&status=failed" + > + + 2 pipelines + + </gl-link-stub> </li> <li> <span> diff --git a/spec/frontend/projects/pipelines/charts/components/app_spec.js b/spec/frontend/projects/pipelines/charts/components/app_spec.js index b4067f6a72b..574756322c7 100644 --- a/spec/frontend/projects/pipelines/charts/components/app_spec.js +++ b/spec/frontend/projects/pipelines/charts/components/app_spec.js @@ -1,11 +1,12 @@ import { GlTabs, GlTab } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import { merge } from 'lodash'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import { mergeUrlParams, updateHistory, getParameterValues } from '~/lib/utils/url_utility'; import Component from '~/projects/pipelines/charts/components/app.vue'; import PipelineCharts from '~/projects/pipelines/charts/components/pipeline_charts.vue'; +import API from '~/api'; jest.mock('~/lib/utils/url_utility'); @@ -17,7 +18,7 @@ describe('ProjectsPipelinesChartsApp', () => { let wrapper; function createComponent(mountOptions = {}) { - wrapper = shallowMount( + wrapper = shallowMountExtended( Component, merge( {}, @@ -118,6 +119,23 @@ describe('ProjectsPipelinesChartsApp', () => { expect(updateHistory).not.toHaveBeenCalled(); }); + + describe('event tracking', () => { + it.each` + testId | event + ${'pipelines-tab'} | ${'p_analytics_ci_cd_pipelines'} + ${'deployment-frequency-tab'} | ${'p_analytics_ci_cd_deployment_frequency'} + ${'lead-time-tab'} | ${'p_analytics_ci_cd_lead_time'} + `('tracks the $event event when clicked', ({ testId, event }) => { + jest.spyOn(API, 'trackRedisHllUserEvent'); + + expect(API.trackRedisHllUserEvent).not.toHaveBeenCalled(); + + wrapper.findByTestId(testId).vm.$emit('click'); + + expect(API.trackRedisHllUserEvent).toHaveBeenCalledWith(event); + }); + }); }); describe('when provided with a query param', () => { diff --git a/spec/frontend/projects/pipelines/charts/components/statistics_list_spec.js b/spec/frontend/projects/pipelines/charts/components/statistics_list_spec.js index 4e79f62ce81..57a864cb2c4 100644 --- a/spec/frontend/projects/pipelines/charts/components/statistics_list_spec.js +++ b/spec/frontend/projects/pipelines/charts/components/statistics_list_spec.js @@ -1,3 +1,4 @@ +import { GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Component from '~/projects/pipelines/charts/components/statistics_list.vue'; import { counts } from '../mock_data'; @@ -5,8 +6,15 @@ import { counts } from '../mock_data'; describe('StatisticsList', () => { let wrapper; + const failedPipelinesLink = '/flightjs/Flight/-/pipelines?page=1&scope=all&status=failed'; + + const findFailedPipelinesLink = () => wrapper.findComponent(GlLink); + beforeEach(() => { wrapper = shallowMount(Component, { + provide: { + failedPipelinesLink, + }, propsData: { counts, }, @@ -15,10 +23,13 @@ describe('StatisticsList', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); it('displays the counts data with labels', () => { expect(wrapper.element).toMatchSnapshot(); }); + + it('displays failed pipelines link', () => { + expect(findFailedPipelinesLink().attributes('href')).toBe(failedPipelinesLink); + }); }); diff --git a/spec/frontend/projects/pipelines/charts/mock_data.js b/spec/frontend/projects/pipelines/charts/mock_data.js index 2e2c594102c..04971b5b20e 100644 --- a/spec/frontend/projects/pipelines/charts/mock_data.js +++ b/spec/frontend/projects/pipelines/charts/mock_data.js @@ -48,6 +48,7 @@ export const transformedAreaChartData = [ export const mockPipelineCount = { data: { project: { + id: '1', totalPipelines: { count: 34, __typename: 'PipelineConnection' }, successfulPipelines: { count: 23, __typename: 'PipelineConnection' }, failedPipelines: { count: 1, __typename: 'PipelineConnection' }, @@ -70,6 +71,7 @@ export const chartOptions = { export const mockPipelineStatistics = { data: { project: { + id: '1', pipelineAnalytics: { weekPipelinesTotals: [0, 0, 0, 0, 0, 0, 0, 0], weekPipelinesLabels: [ diff --git a/spec/frontend/projects/settings/components/transfer_project_form_spec.js b/spec/frontend/projects/settings/components/transfer_project_form_spec.js new file mode 100644 index 00000000000..f7ce7c6f840 --- /dev/null +++ b/spec/frontend/projects/settings/components/transfer_project_form_spec.js @@ -0,0 +1,68 @@ +import { namespaces } from 'jest/vue_shared/components/namespace_select/mock_data'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import TransferProjectForm from '~/projects/settings/components/transfer_project_form.vue'; +import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue'; +import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue'; + +describe('Transfer project form', () => { + let wrapper; + + const confirmButtonText = 'Confirm'; + const confirmationPhrase = 'You must construct additional pylons!'; + + const createComponent = () => + shallowMountExtended(TransferProjectForm, { + propsData: { + namespaces, + confirmButtonText, + confirmationPhrase, + }, + }); + + const findNamespaceSelect = () => wrapper.findComponent(NamespaceSelect); + const findConfirmDanger = () => wrapper.findComponent(ConfirmDanger); + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the namespace selector', () => { + expect(findNamespaceSelect().exists()).toBe(true); + }); + + it('renders the confirm button', () => { + expect(findConfirmDanger().exists()).toBe(true); + }); + + it('disables the confirm button by default', () => { + expect(findConfirmDanger().attributes('disabled')).toBe('true'); + }); + + describe('with a selected namespace', () => { + const [selectedItem] = namespaces.group; + + beforeEach(() => { + findNamespaceSelect().vm.$emit('select', selectedItem); + }); + + it('emits the `selectNamespace` event when a namespace is selected', () => { + const args = [selectedItem.id]; + + expect(wrapper.emitted('selectNamespace')).toEqual([args]); + }); + + it('enables the confirm button', () => { + expect(findConfirmDanger().attributes('disabled')).toBeUndefined(); + }); + + it('clicking the confirm button emits the `confirm` event', () => { + findConfirmDanger().vm.$emit('confirm'); + + expect(wrapper.emitted('confirm')).toBeDefined(); + }); + }); +}); diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js index 0fd3e7446da..875c58583df 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js @@ -1,5 +1,5 @@ import { GlButton, GlDropdown, GlLoadingIcon, GlToggle } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import ServiceDeskSetting from '~/projects/settings_service_desk/components/service_desk_setting.vue'; @@ -11,14 +11,14 @@ describe('ServiceDeskSetting', () => { const findButton = () => wrapper.find(GlButton); const findClipboardButton = () => wrapper.find(ClipboardButton); const findIncomingEmail = () => wrapper.findByTestId('incoming-email'); - const findIncomingEmailLabel = () => wrapper.findByTestId('incoming-email-describer'); + const findIncomingEmailLabel = () => wrapper.findByTestId('incoming-email-label'); const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findTemplateDropdown = () => wrapper.find(GlDropdown); const findToggle = () => wrapper.find(GlToggle); - const createComponent = ({ props = {}, mountFunction = shallowMount } = {}) => + const createComponent = ({ props = {} } = {}) => extendedWrapper( - mountFunction(ServiceDeskSetting, { + mount(ServiceDeskSetting, { propsData: { isEnabled: true, ...props, @@ -131,8 +131,7 @@ describe('ServiceDeskSetting', () => { it('shows error when value contains uppercase or special chars', async () => { wrapper = createComponent({ - props: { customEmailEnabled: true }, - mountFunction: mount, + props: { email: 'foo@bar.com', customEmailEnabled: true }, }); const input = wrapper.findByTestId('project-suffix'); @@ -142,7 +141,7 @@ describe('ServiceDeskSetting', () => { await wrapper.vm.$nextTick(); - const errorText = wrapper.find('.text-danger'); + const errorText = wrapper.find('.invalid-feedback'); expect(errorText.exists()).toBe(true); }); }); diff --git a/spec/frontend/projects/storage_counter/components/app_spec.js b/spec/frontend/projects/storage_counter/components/app_spec.js deleted file mode 100644 index f3da01e0602..00000000000 --- a/spec/frontend/projects/storage_counter/components/app_spec.js +++ /dev/null @@ -1,150 +0,0 @@ -import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import StorageCounterApp from '~/projects/storage_counter/components/app.vue'; -import { TOTAL_USAGE_DEFAULT_TEXT } from '~/projects/storage_counter/constants'; -import getProjectStorageCount from '~/projects/storage_counter/queries/project_storage.query.graphql'; -import UsageGraph from '~/vue_shared/components/storage_counter/usage_graph.vue'; -import { - mockGetProjectStorageCountGraphQLResponse, - mockEmptyResponse, - projectData, - defaultProvideValues, -} from '../mock_data'; - -const localVue = createLocalVue(); -localVue.use(VueApollo); - -describe('Storage counter app', () => { - let wrapper; - - const createMockApolloProvider = ({ reject = false, mockedValue } = {}) => { - let response; - - if (reject) { - response = jest.fn().mockRejectedValue(mockedValue || new Error('GraphQL error')); - } else { - response = jest.fn().mockResolvedValue(mockedValue); - } - - const requestHandlers = [[getProjectStorageCount, response]]; - - return createMockApollo(requestHandlers); - }; - - const createComponent = ({ provide = {}, mockApollo } = {}) => { - wrapper = extendedWrapper( - shallowMount(StorageCounterApp, { - localVue, - apolloProvider: mockApollo, - provide: { - ...defaultProvideValues, - ...provide, - }, - }), - ); - }; - - const findAlert = () => wrapper.findComponent(GlAlert); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findUsagePercentage = () => wrapper.findByTestId('total-usage'); - const findUsageQuotasHelpLink = () => wrapper.findByTestId('usage-quotas-help-link'); - const findUsageGraph = () => wrapper.findComponent(UsageGraph); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('with apollo fetching successful', () => { - let mockApollo; - - beforeEach(async () => { - mockApollo = createMockApolloProvider({ - mockedValue: mockGetProjectStorageCountGraphQLResponse, - }); - createComponent({ mockApollo }); - await waitForPromises(); - }); - - it('renders correct total usage', () => { - expect(findUsagePercentage().text()).toBe(projectData.storage.totalUsage); - }); - - it('renders correct usage quotas help link', () => { - expect(findUsageQuotasHelpLink().attributes('href')).toBe( - defaultProvideValues.helpLinks.usageQuotasHelpPagePath, - ); - }); - }); - - describe('with apollo loading', () => { - let mockApollo; - - beforeEach(() => { - mockApollo = createMockApolloProvider({ - mockedValue: new Promise(() => {}), - }); - createComponent({ mockApollo }); - }); - - it('should show loading icon', () => { - expect(findLoadingIcon().exists()).toBe(true); - }); - }); - - describe('with apollo returning empty data', () => { - let mockApollo; - - beforeEach(async () => { - mockApollo = createMockApolloProvider({ - mockedValue: mockEmptyResponse, - }); - createComponent({ mockApollo }); - await waitForPromises(); - }); - - it('shows default text for total usage', () => { - expect(findUsagePercentage().text()).toBe(TOTAL_USAGE_DEFAULT_TEXT); - }); - }); - - describe('with apollo fetching error', () => { - let mockApollo; - - beforeEach(() => { - mockApollo = createMockApolloProvider(); - createComponent({ mockApollo, reject: true }); - }); - - it('renders gl-alert', () => { - expect(findAlert().exists()).toBe(true); - }); - }); - - describe('rendering <usage-graph />', () => { - let mockApollo; - - beforeEach(async () => { - mockApollo = createMockApolloProvider({ - mockedValue: mockGetProjectStorageCountGraphQLResponse, - }); - createComponent({ mockApollo }); - await waitForPromises(); - }); - - it('renders usage-graph component if project.statistics exists', () => { - expect(findUsageGraph().exists()).toBe(true); - }); - - it('passes project.statistics to usage-graph component', () => { - const { - __typename, - ...statistics - } = mockGetProjectStorageCountGraphQLResponse.data.project.statistics; - expect(findUsageGraph().props('rootStorageStatistics')).toMatchObject(statistics); - }); - }); -}); diff --git a/spec/frontend/projects/storage_counter/components/storage_table_spec.js b/spec/frontend/projects/storage_counter/components/storage_table_spec.js deleted file mode 100644 index c9e56d8f033..00000000000 --- a/spec/frontend/projects/storage_counter/components/storage_table_spec.js +++ /dev/null @@ -1,63 +0,0 @@ -import { GlTableLite } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import StorageTable from '~/projects/storage_counter/components/storage_table.vue'; -import { projectData, defaultProvideValues } from '../mock_data'; - -describe('StorageTable', () => { - let wrapper; - - const defaultProps = { - storageTypes: projectData.storage.storageTypes, - }; - - const createComponent = (props = {}) => { - wrapper = extendedWrapper( - mount(StorageTable, { - propsData: { - ...defaultProps, - ...props, - }, - }), - ); - }; - - const findTable = () => wrapper.findComponent(GlTableLite); - - beforeEach(() => { - createComponent(); - }); - afterEach(() => { - wrapper.destroy(); - }); - - describe('with storage types', () => { - it.each(projectData.storage.storageTypes)( - 'renders table row correctly %o', - ({ storageType: { id, name, description } }) => { - expect(wrapper.findByTestId(`${id}-name`).text()).toBe(name); - expect(wrapper.findByTestId(`${id}-description`).text()).toBe(description); - expect(wrapper.findByTestId(`${id}-icon`).props('name')).toBe(id); - expect(wrapper.findByTestId(`${id}-help-link`).attributes('href')).toBe( - defaultProvideValues.helpLinks[id.replace(`Size`, `HelpPagePath`)] - .replace(`Size`, ``) - .replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`), - ); - }, - ); - }); - - describe('without storage types', () => { - beforeEach(() => { - createComponent({ storageTypes: [] }); - }); - - it('should render the table header <th>', () => { - expect(findTable().find('th').exists()).toBe(true); - }); - - it('should not render any table data <td>', () => { - expect(findTable().find('td').exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/projects/storage_counter/components/storage_type_icon_spec.js b/spec/frontend/projects/storage_counter/components/storage_type_icon_spec.js deleted file mode 100644 index 01efd6f14bd..00000000000 --- a/spec/frontend/projects/storage_counter/components/storage_type_icon_spec.js +++ /dev/null @@ -1,41 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { GlIcon } from '@gitlab/ui'; -import StorageTypeIcon from '~/projects/storage_counter/components/storage_type_icon.vue'; - -describe('StorageTypeIcon', () => { - let wrapper; - - const createComponent = (props = {}) => { - wrapper = mount(StorageTypeIcon, { - propsData: { - ...props, - }, - }); - }; - - const findGlIcon = () => wrapper.findComponent(GlIcon); - - describe('rendering icon', () => { - afterEach(() => { - wrapper.destroy(); - }); - - it.each` - expected | provided - ${'doc-image'} | ${'lfsObjectsSize'} - ${'snippet'} | ${'snippetsSize'} - ${'infrastructure-registry'} | ${'repositorySize'} - ${'package'} | ${'packagesSize'} - ${'upload'} | ${'uploadsSize'} - ${'disk'} | ${'wikiSize'} - ${'disk'} | ${'anything-else'} - `( - 'renders icon with name of $expected when name prop is $provided', - ({ expected, provided }) => { - createComponent({ name: provided }); - - expect(findGlIcon().props('name')).toBe(expected); - }, - ); - }); -}); diff --git a/spec/frontend/projects/storage_counter/mock_data.js b/spec/frontend/projects/storage_counter/mock_data.js deleted file mode 100644 index 6b3e23ac386..00000000000 --- a/spec/frontend/projects/storage_counter/mock_data.js +++ /dev/null @@ -1,92 +0,0 @@ -import mockGetProjectStorageCountGraphQLResponse from 'test_fixtures/graphql/projects/storage_counter/project_storage.query.graphql.json'; - -export { mockGetProjectStorageCountGraphQLResponse }; - -export const mockEmptyResponse = { data: { project: null } }; - -export const defaultProvideValues = { - projectPath: '/project-path', - helpLinks: { - usageQuotasHelpPagePath: '/usage-quotas', - buildArtifactsHelpPagePath: '/build-artifacts', - lfsObjectsHelpPagePath: '/lsf-objects', - packagesHelpPagePath: '/packages', - repositoryHelpPagePath: '/repository', - snippetsHelpPagePath: '/snippets', - uploadsHelpPagePath: '/uploads', - wikiHelpPagePath: '/wiki', - }, -}; - -export const projectData = { - storage: { - totalUsage: '13.8 MiB', - storageTypes: [ - { - storageType: { - id: 'buildArtifactsSize', - name: 'Artifacts', - description: 'Pipeline artifacts and job artifacts, created with CI/CD.', - warningMessage: - 'Because of a known issue, the artifact total for some projects may be incorrect. For more details, read %{warningLinkStart}the epic%{warningLinkEnd}.', - helpPath: '/build-artifacts', - }, - value: 400000, - }, - { - storageType: { - id: 'lfsObjectsSize', - name: 'LFS storage', - description: 'Audio samples, videos, datasets, and graphics.', - helpPath: '/lsf-objects', - }, - value: 4800000, - }, - { - storageType: { - id: 'packagesSize', - name: 'Packages', - description: 'Code packages and container images.', - helpPath: '/packages', - }, - value: 3800000, - }, - { - storageType: { - id: 'repositorySize', - name: 'Repository', - description: 'Git repository.', - helpPath: '/repository', - }, - value: 3900000, - }, - { - storageType: { - id: 'snippetsSize', - name: 'Snippets', - description: 'Shared bits of code and text.', - helpPath: '/snippets', - }, - value: 0, - }, - { - storageType: { - id: 'uploadsSize', - name: 'Uploads', - description: 'File attachments and smaller design graphics.', - helpPath: '/uploads', - }, - value: 900000, - }, - { - storageType: { - id: 'wikiSize', - name: 'Wiki', - description: 'Wiki content.', - helpPath: '/wiki', - }, - value: 300000, - }, - ], - }, -}; diff --git a/spec/frontend/projects/storage_counter/utils_spec.js b/spec/frontend/projects/storage_counter/utils_spec.js deleted file mode 100644 index fb91975a3cf..00000000000 --- a/spec/frontend/projects/storage_counter/utils_spec.js +++ /dev/null @@ -1,34 +0,0 @@ -import { parseGetProjectStorageResults } from '~/projects/storage_counter/utils'; -import { - mockGetProjectStorageCountGraphQLResponse, - projectData, - defaultProvideValues, -} from './mock_data'; - -describe('parseGetProjectStorageResults', () => { - it('parses project statistics correctly', () => { - expect( - parseGetProjectStorageResults( - mockGetProjectStorageCountGraphQLResponse.data, - defaultProvideValues.helpLinks, - ), - ).toMatchObject(projectData); - }); - - it('includes storage type with size of 0 in returned value', () => { - const mockedResponse = mockGetProjectStorageCountGraphQLResponse.data; - // ensuring a specific storage type item has size of 0 - mockedResponse.project.statistics.repositorySize = 0; - - const response = parseGetProjectStorageResults(mockedResponse, defaultProvideValues.helpLinks); - - expect(response.storage.storageTypes).toEqual( - expect.arrayContaining([ - { - storageType: expect.any(Object), - value: 0, - }, - ]), - ); - }); -}); diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap index b2580d47549..fd2a8eec4d4 100644 --- a/spec/frontend/releases/__snapshots__/util_spec.js.snap +++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap @@ -44,6 +44,7 @@ Object { "author": Object { "__typename": "UserCore", "avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon", + "id": Any<String>, "username": "administrator", "webUrl": "http://localhost/administrator", }, @@ -139,6 +140,7 @@ Object { "author": Object { "__typename": "UserCore", "avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon", + "id": Any<String>, "username": "administrator", "webUrl": "http://localhost/administrator", }, @@ -153,6 +155,7 @@ Object { "__typename": "ReleaseEvidence", "collectedAt": "2018-12-03T00:00:00Z", "filepath": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/evidences/1.json", + "id": "gid://gitlab/Releases::Evidence/1", "sha": "760d6cdfb0879c3ffedec13af470e0f71cf52c6cde4d", }, ], @@ -247,6 +250,7 @@ Object { "evidences": Array [], "milestones": Array [ Object { + "id": "gid://gitlab/Milestone/123", "issueStats": Object {}, "stats": undefined, "title": "12.3", @@ -254,6 +258,7 @@ Object { "webUrl": undefined, }, Object { + "id": "gid://gitlab/Milestone/124", "issueStats": Object {}, "stats": undefined, "title": "12.4", @@ -347,6 +352,7 @@ Object { "author": Object { "__typename": "UserCore", "avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon", + "id": Any<String>, "username": "administrator", "webUrl": "http://localhost/administrator", }, @@ -361,6 +367,7 @@ Object { "__typename": "ReleaseEvidence", "collectedAt": "2018-12-03T00:00:00Z", "filepath": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/evidences/1.json", + "id": "gid://gitlab/Releases::Evidence/1", "sha": "760d6cdfb0879c3ffedec13af470e0f71cf52c6cde4d", }, ], diff --git a/spec/frontend/releases/components/app_show_spec.js b/spec/frontend/releases/components/app_show_spec.js index 72ebaaaf76c..a60b9bda66a 100644 --- a/spec/frontend/releases/components/app_show_spec.js +++ b/spec/frontend/releases/components/app_show_spec.js @@ -58,7 +58,6 @@ describe('Release show component', () => { const expectFlashWithMessage = (message) => { it(`shows a flash message that reads "${message}"`, () => { - expect(createFlash).toHaveBeenCalledTimes(1); expect(createFlash).toHaveBeenCalledWith({ message, captureError: true, diff --git a/spec/frontend/releases/util_spec.js b/spec/frontend/releases/util_spec.js index 3c1060cb0e8..055c8e8b39f 100644 --- a/spec/frontend/releases/util_spec.js +++ b/spec/frontend/releases/util_spec.js @@ -104,13 +104,32 @@ describe('releases/util.js', () => { describe('convertAllReleasesGraphQLResponse', () => { it('matches snapshot', () => { - expect(convertAllReleasesGraphQLResponse(originalAllReleasesQueryResponse)).toMatchSnapshot(); + expect(convertAllReleasesGraphQLResponse(originalAllReleasesQueryResponse)).toMatchSnapshot({ + data: [ + { + author: { + id: expect.any(String), + }, + }, + { + author: { + id: expect.any(String), + }, + }, + ], + }); }); }); describe('convertOneReleaseGraphQLResponse', () => { it('matches snapshot', () => { - expect(convertOneReleaseGraphQLResponse(originalOneReleaseQueryResponse)).toMatchSnapshot(); + expect(convertOneReleaseGraphQLResponse(originalOneReleaseQueryResponse)).toMatchSnapshot({ + data: { + author: { + id: expect.any(String), + }, + }, + }); }); }); diff --git a/spec/frontend/repository/commits_service_spec.js b/spec/frontend/repository/commits_service_spec.js index d924974aede..697fa7c4fd1 100644 --- a/spec/frontend/repository/commits_service_spec.js +++ b/spec/frontend/repository/commits_service_spec.js @@ -52,13 +52,6 @@ describe('commits service', () => { expect(axios.get.mock.calls.length).toEqual(1); }); - it('calls axios get twice if an offset is larger than 25', async () => { - await requestCommits(100); - - expect(axios.get.mock.calls[0][1]).toEqual({ params: { format: 'json', offset: 75 } }); - expect(axios.get.mock.calls[1][1]).toEqual({ params: { format: 'json', offset: 100 } }); - }); - it('updates the list of requested offsets', async () => { await requestCommits(200); diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap index be4f8a688e0..7854325e4ed 100644 --- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap @@ -2,7 +2,7 @@ exports[`Repository last commit component renders commit widget 1`] = ` <div - class="info-well d-none d-sm-flex project-last-commit commit p-3" + class="well-segment commit gl-p-5 gl-w-full" > <user-avatar-link-stub class="avatar-cell" @@ -99,6 +99,7 @@ exports[`Repository last commit component renders commit widget 1`] = ` text="123456789" title="Copy commit SHA" tooltipplacement="top" + variant="default" /> </gl-button-group-stub> </div> @@ -108,7 +109,7 @@ exports[`Repository last commit component renders commit widget 1`] = ` exports[`Repository last commit component renders the signature HTML as returned by the backend 1`] = ` <div - class="info-well d-none d-sm-flex project-last-commit commit p-3" + class="well-segment commit gl-p-5 gl-w-full" > <user-avatar-link-stub class="avatar-cell" @@ -209,6 +210,7 @@ exports[`Repository last commit component renders the signature HTML as returned text="123456789" title="Copy commit SHA" tooltipplacement="top" + variant="default" /> </gl-button-group-stub> </div> diff --git a/spec/frontend/repository/components/blob_button_group_spec.js b/spec/frontend/repository/components/blob_button_group_spec.js index f2a3354f204..9f9d574a8ed 100644 --- a/spec/frontend/repository/components/blob_button_group_spec.js +++ b/spec/frontend/repository/components/blob_button_group_spec.js @@ -9,6 +9,7 @@ const DEFAULT_PROPS = { name: 'some name', path: 'some/path', canPushCode: true, + canPushToBranch: true, replacePath: 'some/replace/path', deletePath: 'some/delete/path', emptyRepo: false, diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index d40e97bf5a3..9e00a2d0408 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -15,7 +15,7 @@ import ForkSuggestion from '~/repository/components/fork_suggestion.vue'; import { loadViewer, viewerProps } from '~/repository/components/blob_viewers'; import DownloadViewer from '~/repository/components/blob_viewers/download_viewer.vue'; import EmptyViewer from '~/repository/components/blob_viewers/empty_viewer.vue'; -import TextViewer from '~/repository/components/blob_viewers/text_viewer.vue'; +import SourceViewer from '~/vue_shared/components/source_viewer.vue'; import blobInfoQuery from '~/repository/queries/blob_info.query.graphql'; import { redirectTo } from '~/lib/utils/url_utility'; import { isLoggedIn } from '~/lib/utils/common_utils'; @@ -98,7 +98,7 @@ describe('Blob content viewer component', () => { const findForkSuggestion = () => wrapper.findComponent(ForkSuggestion); beforeEach(() => { - gon.features = { refactorTextViewer: true }; + gon.features = { highlightJs: true }; isLoggedIn.mockReturnValue(true); }); @@ -215,7 +215,7 @@ describe('Blob content viewer component', () => { viewer | loadViewerReturnValue | viewerPropsReturnValue ${'empty'} | ${EmptyViewer} | ${{}} ${'download'} | ${DownloadViewer} | ${{ filePath: '/some/file/path', fileName: 'test.js', fileSize: 100 }} - ${'text'} | ${TextViewer} | ${{ content: 'test', fileName: 'test.js', readOnly: true }} + ${'text'} | ${SourceViewer} | ${{ content: 'test', autoDetect: true }} `( 'renders viewer component for $viewer files', async ({ viewer, loadViewerReturnValue, viewerPropsReturnValue }) => { @@ -318,8 +318,14 @@ describe('Blob content viewer component', () => { repository: { empty }, } = projectMock; + afterEach(() => { + delete gon.current_user_id; + delete gon.current_username; + }); + it('renders component', async () => { window.gon.current_user_id = 1; + window.gon.current_username = 'root'; await createComponent({ pushCode, downloadCode, empty }, mount); @@ -330,28 +336,34 @@ describe('Blob content viewer component', () => { deletePath: webPath, canPushCode: pushCode, canLock: true, - isLocked: false, + isLocked: true, 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 }) => { - await createComponent( - { - pushCode: canPushCode, - downloadCode: canDownloadCode, - empty, - }, - mount, - ); + canPushCode | canDownloadCode | username | canLock + ${true} | ${true} | ${'root'} | ${true} + ${false} | ${true} | ${'root'} | ${false} + ${true} | ${false} | ${'root'} | ${false} + ${true} | ${true} | ${'peter'} | ${false} + `( + 'passes the correct lock states', + async ({ canPushCode, canDownloadCode, username, canLock }) => { + gon.current_username = username; + + await createComponent( + { + pushCode: canPushCode, + downloadCode: canDownloadCode, + empty, + }, + mount, + ); - expect(findBlobButtonGroup().props('canLock')).toBe(canLock); - }); + expect(findBlobButtonGroup().props('canLock')).toBe(canLock); + }, + ); it('does not render if not logged in', async () => { isLoggedIn.mockReturnValueOnce(false); diff --git a/spec/frontend/repository/components/blob_viewers/pdf_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/pdf_viewer_spec.js new file mode 100644 index 00000000000..fd910002529 --- /dev/null +++ b/spec/frontend/repository/components/blob_viewers/pdf_viewer_spec.js @@ -0,0 +1,59 @@ +import { GlButton } from '@gitlab/ui'; +import Component from '~/repository/components/blob_viewers/pdf_viewer.vue'; +import PdfViewer from '~/blob/pdf/pdf_viewer.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +describe('PDF Viewer', () => { + let wrapper; + + const defaultPropsData = { url: 'some/pdf_blob.pdf' }; + + const createComponent = (fileSize = 999) => { + wrapper = shallowMountExtended(Component, { propsData: { ...defaultPropsData, fileSize } }); + }; + + const findPDFViewer = () => wrapper.findComponent(PdfViewer); + const findHelpText = () => wrapper.find('p'); + const findDownLoadButton = () => wrapper.findComponent(GlButton); + + it('renders a PDF Viewer component', () => { + createComponent(); + + expect(findPDFViewer().exists()).toBe(true); + expect(findPDFViewer().props('pdf')).toBe(defaultPropsData.url); + }); + + describe('Too large', () => { + beforeEach(() => createComponent(20000000)); + + it('does not a PDF Viewer component', () => { + expect(findPDFViewer().exists()).toBe(false); + }); + + it('renders help text', () => { + expect(findHelpText().text()).toBe( + 'This PDF is too large to display. Please download to view.', + ); + }); + + it('renders a download button', () => { + expect(findDownLoadButton().text()).toBe('Download PDF'); + expect(findDownLoadButton().props('icon')).toBe('download'); + }); + }); + + describe('Too many pages', () => { + beforeEach(() => { + createComponent(); + findPDFViewer().vm.$emit('pdflabload', 100); + }); + + it('does not a PDF Viewer component', () => { + expect(findPDFViewer().exists()).toBe(false); + }); + + it('renders a download button', () => { + expect(findDownLoadButton().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/repository/components/blob_viewers/text_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/text_viewer_spec.js deleted file mode 100644 index 88c5bee6564..00000000000 --- a/spec/frontend/repository/components/blob_viewers/text_viewer_spec.js +++ /dev/null @@ -1,30 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import waitForPromises from 'helpers/wait_for_promises'; -import TextViewer from '~/repository/components/blob_viewers/text_viewer.vue'; -import SourceEditor from '~/vue_shared/components/source_editor.vue'; - -describe('Text Viewer', () => { - let wrapper; - const propsData = { - content: 'Some content', - fileName: 'file_name.js', - readOnly: true, - }; - - const createComponent = () => { - wrapper = shallowMount(TextViewer, { propsData }); - }; - - const findEditor = () => wrapper.findComponent(SourceEditor); - - it('renders a Source Editor component', async () => { - createComponent(); - - await waitForPromises(); - - expect(findEditor().exists()).toBe(true); - expect(findEditor().props('value')).toBe(propsData.content); - expect(findEditor().props('fileName')).toBe(propsData.fileName); - expect(findEditor().props('editorOptions')).toEqual({ readOnly: propsData.readOnly }); - }); -}); diff --git a/spec/frontend/repository/components/delete_blob_modal_spec.js b/spec/frontend/repository/components/delete_blob_modal_spec.js index 2c62868f391..785783b2e75 100644 --- a/spec/frontend/repository/components/delete_blob_modal_spec.js +++ b/spec/frontend/repository/components/delete_blob_modal_spec.js @@ -13,6 +13,7 @@ const initialProps = { targetBranch: 'some-target-branch', originalBranch: 'main', canPushCode: true, + canPushToBranch: true, emptyRepo: false, }; @@ -103,22 +104,25 @@ describe('DeleteBlobModal', () => { ); it.each` - input | value | emptyRepo | canPushCode | exist - ${'authenticity_token'} | ${'mock-csrf-token'} | ${false} | ${true} | ${true} - ${'authenticity_token'} | ${'mock-csrf-token'} | ${true} | ${false} | ${true} - ${'_method'} | ${'delete'} | ${false} | ${true} | ${true} - ${'_method'} | ${'delete'} | ${true} | ${false} | ${true} - ${'original_branch'} | ${initialProps.originalBranch} | ${false} | ${true} | ${true} - ${'original_branch'} | ${undefined} | ${true} | ${true} | ${false} - ${'create_merge_request'} | ${'1'} | ${false} | ${false} | ${true} - ${'create_merge_request'} | ${'1'} | ${false} | ${true} | ${true} - ${'create_merge_request'} | ${undefined} | ${true} | ${false} | ${false} + input | value | emptyRepo | canPushCode | canPushToBranch | exist + ${'authenticity_token'} | ${'mock-csrf-token'} | ${false} | ${true} | ${true} | ${true} + ${'authenticity_token'} | ${'mock-csrf-token'} | ${true} | ${false} | ${true} | ${true} + ${'_method'} | ${'delete'} | ${false} | ${true} | ${true} | ${true} + ${'_method'} | ${'delete'} | ${true} | ${false} | ${true} | ${true} + ${'original_branch'} | ${initialProps.originalBranch} | ${false} | ${true} | ${true} | ${true} + ${'original_branch'} | ${undefined} | ${true} | ${true} | ${true} | ${false} + ${'create_merge_request'} | ${'1'} | ${false} | ${false} | ${true} | ${true} + ${'create_merge_request'} | ${'1'} | ${false} | ${true} | ${true} | ${true} + ${'create_merge_request'} | ${'1'} | ${false} | ${false} | ${false} | ${true} + ${'create_merge_request'} | ${'1'} | ${false} | ${false} | ${true} | ${true} + ${'create_merge_request'} | ${undefined} | ${true} | ${false} | ${true} | ${false} `( 'passes $input as a hidden input with the correct value', - ({ input, value, emptyRepo, canPushCode, exist }) => { + ({ input, value, emptyRepo, canPushCode, canPushToBranch, exist }) => { createComponent({ emptyRepo, canPushCode, + canPushToBranch, }); const inputMethod = findForm().find(`input[name="${input}"]`); diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js index 76e9f7da011..7f59dbfe0d1 100644 --- a/spec/frontend/repository/components/table/row_spec.js +++ b/spec/frontend/repository/components/table/row_spec.js @@ -4,6 +4,7 @@ import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import TableRow from '~/repository/components/table/row.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import { FILE_SYMLINK_MODE } from '~/vue_shared/constants'; +import { ROW_APPEAR_DELAY } from '~/repository/constants'; const COMMIT_MOCK = { lockLabel: 'Locked by Root', committedDate: '2019-01-01' }; @@ -17,12 +18,12 @@ function factory(propsData = {}) { vm = shallowMount(TableRow, { propsData: { + commitInfo: COMMIT_MOCK, ...propsData, name: propsData.path, projectPath: 'gitlab-org/gitlab-ce', url: `https://test.com`, totalEntries: 10, - commitInfo: COMMIT_MOCK, rowNumber: 123, }, directives: { @@ -251,6 +252,8 @@ describe('Repository table row component', () => { }); describe('row visibility', () => { + beforeAll(() => jest.useFakeTimers()); + beforeEach(() => { factory({ id: '1', @@ -258,18 +261,20 @@ describe('Repository table row component', () => { path: 'test', type: 'tree', currentPath: '/', + commitInfo: null, }); }); - it('emits a `row-appear` event', () => { + + afterAll(() => jest.useRealTimers()); + + it('emits a `row-appear` event', async () => { findIntersectionObserver().vm.$emit('appear'); - expect(vm.emitted('row-appear')).toEqual([ - [ - { - hasCommit: true, - rowNumber: 123, - }, - ], - ]); + + jest.runAllTimers(); + + expect(setTimeout).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), ROW_APPEAR_DELAY); + expect(vm.emitted('row-appear')).toEqual([[123]]); }); }); }); diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js index 49397c77215..9c5d07eede3 100644 --- a/spec/frontend/repository/components/tree_content_spec.js +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql'; import FilePreview from '~/repository/components/preview/index.vue'; import FileTable from '~/repository/components/table/index.vue'; -import TreeContent from '~/repository/components/tree_content.vue'; +import TreeContent from 'jh_else_ce/repository/components/tree_content.vue'; import { loadCommits, isRequested, resetRequestedCommits } from '~/repository/commits_service'; jest.mock('~/repository/commits_service', () => ({ @@ -190,14 +190,28 @@ describe('Repository table component', () => { }); }); - it('loads commit data when row-appear event is emitted', () => { + describe('commit data', () => { const path = 'some/path'; - const rowNumber = 1; - factory(path); - findFileTable().vm.$emit('row-appear', { hasCommit: false, rowNumber }); + it('loads commit data for both top and bottom batches when row-appear event is emitted', () => { + const rowNumber = 50; - expect(isRequested).toHaveBeenCalledWith(rowNumber); - expect(loadCommits).toHaveBeenCalledWith('', path, '', rowNumber); + factory(path); + findFileTable().vm.$emit('row-appear', rowNumber); + + expect(isRequested).toHaveBeenCalledWith(rowNumber); + + expect(loadCommits.mock.calls).toEqual([ + ['', path, '', rowNumber], + ['', path, '', rowNumber - 25], + ]); + }); + + it('loads commit data once if rowNumber is zero', () => { + factory(path); + findFileTable().vm.$emit('row-appear', 0); + + expect(loadCommits.mock.calls).toEqual([['', path, '', 0]]); + }); }); }); diff --git a/spec/frontend/repository/components/upload_blob_modal_spec.js b/spec/frontend/repository/components/upload_blob_modal_spec.js index 36847107558..e9dfa3cd495 100644 --- a/spec/frontend/repository/components/upload_blob_modal_spec.js +++ b/spec/frontend/repository/components/upload_blob_modal_spec.js @@ -212,8 +212,8 @@ describe('UploadBlobModal', () => { createComponent(); }); - it('displays the default "Upload New File" modal title ', () => { - expect(findModal().props('title')).toBe('Upload New File'); + it('displays the default "Upload new file" modal title ', () => { + expect(findModal().props('title')).toBe('Upload new file'); }); it('display the defaul primary button text', () => { diff --git a/spec/frontend/repository/mock_data.js b/spec/frontend/repository/mock_data.js index adf5991ac3c..74d35daf578 100644 --- a/spec/frontend/repository/mock_data.js +++ b/spec/frontend/repository/mock_data.js @@ -1,4 +1,5 @@ export const simpleViewerMock = { + id: '1', name: 'some_file.js', size: 123, rawSize: 123, @@ -11,6 +12,7 @@ export const simpleViewerMock = { forkAndEditPath: 'some_file.js/fork/edit', ideForkAndEditPath: 'some_file.js/fork/ide', canModifyBlob: true, + canCurrentUserPushToBranch: true, storedExternally: false, rawPath: 'some_file.js', replacePath: 'some_file.js/replace', @@ -45,7 +47,13 @@ export const projectMock = { id: '1234', userPermissions: userPermissionsMock, pathLocks: { - nodes: [], + nodes: [ + { + id: 'test', + path: simpleViewerMock.path, + user: { id: '123', username: 'root' }, + }, + ], }, repository: { empty: false, diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js index 7eda9aa2850..7015fe809b0 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -147,7 +147,7 @@ describe('AdminRunnersApp', () => { }), expect.objectContaining({ type: PARAM_KEY_TAG, - recentTokenValuesStorageKey: `${ADMIN_FILTERED_SEARCH_NAMESPACE}-recent-tags`, + recentSuggestionsStorageKey: `${ADMIN_FILTERED_SEARCH_NAMESPACE}-recent-tags`, }), ]); }); @@ -155,9 +155,7 @@ describe('AdminRunnersApp', () => { it('shows the active runner count', () => { createComponent({ mountFn: mount }); - expect(findRunnerFilteredSearchBar().text()).toMatch( - `Runners currently online: ${mockActiveRunnersCount}`, - ); + expect(wrapper.text()).toMatch(new RegExp(`Online Runners ${mockActiveRunnersCount}`)); }); describe('when a filter is preselected', () => { diff --git a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js index 2874bdbe280..95c212cb0a9 100644 --- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js @@ -3,13 +3,17 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + +import { captureException } from '~/runner/sentry_utils'; import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue'; +import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue'; import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql'; import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql'; import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql'; -import { captureException } from '~/runner/sentry_utils'; import { runnersData } from '../../mock_data'; const mockRunner = runnersData.data.runners.nodes[0]; @@ -25,12 +29,16 @@ jest.mock('~/runner/sentry_utils'); describe('RunnerTypeCell', () => { let wrapper; + + const mockToastShow = jest.fn(); const runnerDeleteMutationHandler = jest.fn(); const runnerActionsUpdateMutationHandler = jest.fn(); const findEditBtn = () => wrapper.findByTestId('edit-runner'); const findToggleActiveBtn = () => wrapper.findByTestId('toggle-active-runner'); + const findRunnerDeleteModal = () => wrapper.findComponent(RunnerDeleteModal); const findDeleteBtn = () => wrapper.findByTestId('delete-runner'); + const getTooltip = (w) => getBinding(w.element, 'gl-tooltip')?.value; const createComponent = ({ active = true } = {}, options) => { wrapper = extendedWrapper( @@ -38,6 +46,7 @@ describe('RunnerTypeCell', () => { propsData: { runner: { id: mockRunner.id, + shortSha: mockRunner.shortSha, adminUrl: mockRunner.adminUrl, active, }, @@ -47,6 +56,15 @@ describe('RunnerTypeCell', () => { [runnerDeleteMutation, runnerDeleteMutationHandler], [runnerActionsUpdateMutation, runnerActionsUpdateMutationHandler], ]), + directives: { + GlTooltip: createMockDirective(), + GlModal: createMockDirective(), + }, + mocks: { + $toast: { + show: mockToastShow, + }, + }, ...options, }), ); @@ -72,197 +90,85 @@ describe('RunnerTypeCell', () => { }); afterEach(() => { + mockToastShow.mockReset(); runnerDeleteMutationHandler.mockReset(); runnerActionsUpdateMutationHandler.mockReset(); wrapper.destroy(); }); - it('Displays the runner edit link with the correct href', () => { - createComponent(); - - expect(findEditBtn().attributes('href')).toBe(mockRunner.adminUrl); - }); - - describe.each` - state | label | icon | isActive | newActiveValue - ${'active'} | ${'Pause'} | ${'pause'} | ${true} | ${false} - ${'paused'} | ${'Resume'} | ${'play'} | ${false} | ${true} - `('When the runner is $state', ({ label, icon, isActive, newActiveValue }) => { - beforeEach(() => { - createComponent({ active: isActive }); - }); - - it(`Displays a ${icon} button`, () => { - expect(findToggleActiveBtn().props('loading')).toBe(false); - expect(findToggleActiveBtn().props('icon')).toBe(icon); - expect(findToggleActiveBtn().attributes('title')).toBe(label); - expect(findToggleActiveBtn().attributes('aria-label')).toBe(label); - }); - - it(`After clicking the ${icon} button, the button has a loading state`, async () => { - await findToggleActiveBtn().vm.$emit('click'); - - expect(findToggleActiveBtn().props('loading')).toBe(true); - }); - - it(`After the ${icon} button is clicked, stale tooltip is removed`, async () => { - await findToggleActiveBtn().vm.$emit('click'); + describe('Edit Action', () => { + it('Displays the runner edit link with the correct href', () => { + createComponent(); - expect(findToggleActiveBtn().attributes('title')).toBe(''); - expect(findToggleActiveBtn().attributes('aria-label')).toBe(''); + expect(findEditBtn().attributes('href')).toBe(mockRunner.adminUrl); }); + }); - describe(`When clicking on the ${icon} button`, () => { - it(`The apollo mutation to set active to ${newActiveValue} is called`, async () => { - expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(0); - - await findToggleActiveBtn().vm.$emit('click'); + describe('Toggle active action', () => { + describe.each` + state | label | icon | isActive | newActiveValue + ${'active'} | ${'Pause'} | ${'pause'} | ${true} | ${false} + ${'paused'} | ${'Resume'} | ${'play'} | ${false} | ${true} + `('When the runner is $state', ({ label, icon, isActive, newActiveValue }) => { + beforeEach(() => { + createComponent({ active: isActive }); + }); - expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(1); - expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledWith({ - input: { - id: mockRunner.id, - active: newActiveValue, - }, - }); + it(`Displays a ${icon} button`, () => { + expect(findToggleActiveBtn().props('loading')).toBe(false); + expect(findToggleActiveBtn().props('icon')).toBe(icon); + expect(getTooltip(findToggleActiveBtn())).toBe(label); + expect(findToggleActiveBtn().attributes('aria-label')).toBe(label); }); - it('The button does not have a loading state after the mutation occurs', async () => { + it(`After clicking the ${icon} button, the button has a loading state`, async () => { await findToggleActiveBtn().vm.$emit('click'); expect(findToggleActiveBtn().props('loading')).toBe(true); - - await waitForPromises(); - - expect(findToggleActiveBtn().props('loading')).toBe(false); }); - }); - describe('When update fails', () => { - describe('On a network error', () => { - const mockErrorMsg = 'Update error!'; - - beforeEach(async () => { - runnerActionsUpdateMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); - - await findToggleActiveBtn().vm.$emit('click'); - }); - - it('error is reported to sentry', () => { - expect(captureException).toHaveBeenCalledWith({ - error: new Error(`Network error: ${mockErrorMsg}`), - component: 'RunnerActionsCell', - }); - }); + it(`After the ${icon} button is clicked, stale tooltip is removed`, async () => { + await findToggleActiveBtn().vm.$emit('click'); - it('error is shown to the user', () => { - expect(createFlash).toHaveBeenCalledTimes(1); - }); + expect(getTooltip(findToggleActiveBtn())).toBe(''); + expect(findToggleActiveBtn().attributes('aria-label')).toBe(''); }); - describe('On a validation error', () => { - const mockErrorMsg = 'Runner not found!'; - const mockErrorMsg2 = 'User not allowed!'; - - beforeEach(async () => { - runnerActionsUpdateMutationHandler.mockResolvedValue({ - data: { - runnerUpdate: { - runner: mockRunner, - errors: [mockErrorMsg, mockErrorMsg2], - }, - }, - }); + describe(`When clicking on the ${icon} button`, () => { + it(`The apollo mutation to set active to ${newActiveValue} is called`, async () => { + expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(0); await findToggleActiveBtn().vm.$emit('click'); - }); - - it('error is reported to sentry', () => { - expect(captureException).toHaveBeenCalledWith({ - error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`), - component: 'RunnerActionsCell', - }); - }); - it('error is shown to the user', () => { - expect(createFlash).toHaveBeenCalledTimes(1); - }); - }); - }); - }); - - describe('When the user clicks a runner', () => { - beforeEach(() => { - jest.spyOn(window, 'confirm'); - - createComponent(); - }); - - afterEach(() => { - window.confirm.mockRestore(); - }); - - describe('When the user confirms deletion', () => { - beforeEach(async () => { - window.confirm.mockReturnValue(true); - await findDeleteBtn().vm.$emit('click'); - }); - - it('The user sees a confirmation alert', () => { - expect(window.confirm).toHaveBeenCalledTimes(1); - expect(window.confirm).toHaveBeenCalledWith(expect.any(String)); - }); - - it('The delete mutation is called correctly', () => { - expect(runnerDeleteMutationHandler).toHaveBeenCalledTimes(1); - expect(runnerDeleteMutationHandler).toHaveBeenCalledWith({ - input: { id: mockRunner.id }, - }); - }); - - it('When delete mutation is called, current runners are refetched', async () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate'); - - await findDeleteBtn().vm.$emit('click'); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: runnerDeleteMutation, - variables: { + expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(1); + expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledWith({ input: { id: mockRunner.id, + active: newActiveValue, }, - }, - awaitRefetchQueries: true, - refetchQueries: [getRunnersQueryName, getGroupRunnersQueryName], + }); }); - }); - - it('The delete button does not have a loading state', () => { - expect(findDeleteBtn().props('loading')).toBe(false); - expect(findDeleteBtn().attributes('title')).toBe('Remove'); - }); - it('After the delete button is clicked, loading state is shown', async () => { - await findDeleteBtn().vm.$emit('click'); + it('The button does not have a loading state after the mutation occurs', async () => { + await findToggleActiveBtn().vm.$emit('click'); - expect(findDeleteBtn().props('loading')).toBe(true); - }); + expect(findToggleActiveBtn().props('loading')).toBe(true); - it('After the delete button is clicked, stale tooltip is removed', async () => { - await findDeleteBtn().vm.$emit('click'); + await waitForPromises(); - expect(findDeleteBtn().attributes('title')).toBe(''); + expect(findToggleActiveBtn().props('loading')).toBe(false); + }); }); - describe('When delete fails', () => { + describe('When update fails', () => { describe('On a network error', () => { - const mockErrorMsg = 'Delete error!'; + const mockErrorMsg = 'Update error!'; beforeEach(async () => { - runnerDeleteMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); + runnerActionsUpdateMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); - await findDeleteBtn().vm.$emit('click'); + await findToggleActiveBtn().vm.$emit('click'); }); it('error is reported to sentry', () => { @@ -282,15 +188,16 @@ describe('RunnerTypeCell', () => { const mockErrorMsg2 = 'User not allowed!'; beforeEach(async () => { - runnerDeleteMutationHandler.mockResolvedValue({ + runnerActionsUpdateMutationHandler.mockResolvedValue({ data: { - runnerDelete: { + runnerUpdate: { + runner: mockRunner, errors: [mockErrorMsg, mockErrorMsg2], }, }, }); - await findDeleteBtn().vm.$emit('click'); + await findToggleActiveBtn().vm.$emit('click'); }); it('error is reported to sentry', () => { @@ -306,24 +213,129 @@ describe('RunnerTypeCell', () => { }); }); }); + }); - describe('When the user does not confirm deletion', () => { - beforeEach(async () => { - window.confirm.mockReturnValue(false); - await findDeleteBtn().vm.$emit('click'); + describe('Delete action', () => { + beforeEach(() => { + createComponent( + {}, + { + stubs: { RunnerDeleteModal }, + }, + ); + }); + + it('Delete button opens delete modal', () => { + const modalId = getBinding(findDeleteBtn().element, 'gl-modal').value; + + expect(findRunnerDeleteModal().attributes('modal-id')).toBeDefined(); + expect(findRunnerDeleteModal().attributes('modal-id')).toBe(modalId); + }); + + it('Delete modal shows the runner name', () => { + expect(findRunnerDeleteModal().props('runnerName')).toBe( + `#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`, + ); + }); + it('The delete button does not have a loading icon', () => { + expect(findDeleteBtn().props('loading')).toBe(false); + expect(getTooltip(findDeleteBtn())).toBe('Delete runner'); + }); + + it('When delete mutation is called, current runners are refetched', () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate'); + + findRunnerDeleteModal().vm.$emit('primary'); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: runnerDeleteMutation, + variables: { + input: { + id: mockRunner.id, + }, + }, + awaitRefetchQueries: true, + refetchQueries: [getRunnersQueryName, getGroupRunnersQueryName], }); + }); - it('The user sees a confirmation alert', () => { - expect(window.confirm).toHaveBeenCalledTimes(1); + describe('When delete is clicked', () => { + beforeEach(() => { + findRunnerDeleteModal().vm.$emit('primary'); }); - it('The delete mutation is not called', () => { - expect(runnerDeleteMutationHandler).toHaveBeenCalledTimes(0); + it('The delete mutation is called correctly', () => { + expect(runnerDeleteMutationHandler).toHaveBeenCalledTimes(1); + expect(runnerDeleteMutationHandler).toHaveBeenCalledWith({ + input: { id: mockRunner.id }, + }); }); - it('The delete button does not have a loading state', () => { - expect(findDeleteBtn().props('loading')).toBe(false); - expect(findDeleteBtn().attributes('title')).toBe('Remove'); + it('The delete button has a loading icon', () => { + expect(findDeleteBtn().props('loading')).toBe(true); + expect(getTooltip(findDeleteBtn())).toBe(''); + }); + + it('The toast notification is shown', () => { + expect(mockToastShow).toHaveBeenCalledTimes(1); + expect(mockToastShow).toHaveBeenCalledWith( + expect.stringContaining(`#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`), + ); + }); + }); + + describe('When delete fails', () => { + describe('On a network error', () => { + const mockErrorMsg = 'Delete error!'; + + beforeEach(() => { + runnerDeleteMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); + + findRunnerDeleteModal().vm.$emit('primary'); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error(`Network error: ${mockErrorMsg}`), + component: 'RunnerActionsCell', + }); + }); + + it('error is shown to the user', () => { + expect(createFlash).toHaveBeenCalledTimes(1); + }); + + it('toast notification is not shown', () => { + expect(mockToastShow).not.toHaveBeenCalled(); + }); + }); + + describe('On a validation error', () => { + const mockErrorMsg = 'Runner not found!'; + const mockErrorMsg2 = 'User not allowed!'; + + beforeEach(() => { + runnerDeleteMutationHandler.mockResolvedValue({ + data: { + runnerDelete: { + errors: [mockErrorMsg, mockErrorMsg2], + }, + }, + }); + + findRunnerDeleteModal().vm.$emit('primary'); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`), + component: 'RunnerActionsCell', + }); + }); + + it('error is shown to the user', () => { + expect(createFlash).toHaveBeenCalledTimes(1); + }); }); }); }); diff --git a/spec/frontend/runner/components/runner_contacted_state_badge_spec.js b/spec/frontend/runner/components/runner_contacted_state_badge_spec.js deleted file mode 100644 index 57a27f39826..00000000000 --- a/spec/frontend/runner/components/runner_contacted_state_badge_spec.js +++ /dev/null @@ -1,86 +0,0 @@ -import { GlBadge } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import RunnerContactedStateBadge from '~/runner/components/runner_contacted_state_badge.vue'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_NOT_CONNECTED } from '~/runner/constants'; - -describe('RunnerTypeBadge', () => { - let wrapper; - - const findBadge = () => wrapper.findComponent(GlBadge); - const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip'); - - const createComponent = ({ runner = {} } = {}) => { - wrapper = shallowMount(RunnerContactedStateBadge, { - propsData: { - runner: { - contactedAt: '2021-01-01T00:00:00Z', - status: STATUS_ONLINE, - ...runner, - }, - }, - directives: { - GlTooltip: createMockDirective(), - }, - }); - }; - - beforeEach(() => { - jest.useFakeTimers('modern'); - }); - - afterEach(() => { - jest.useFakeTimers('legacy'); - - wrapper.destroy(); - }); - - it('renders online state', () => { - jest.setSystemTime(new Date('2021-01-01T00:01:00Z')); - - createComponent(); - - expect(wrapper.text()).toBe('online'); - expect(findBadge().props('variant')).toBe('success'); - expect(getTooltip().value).toBe('Runner is online; last contact was 1 minute ago'); - }); - - it('renders offline state', () => { - jest.setSystemTime(new Date('2021-01-02T00:00:00Z')); - - createComponent({ - runner: { - status: STATUS_OFFLINE, - }, - }); - - expect(wrapper.text()).toBe('offline'); - expect(findBadge().props('variant')).toBe('muted'); - expect(getTooltip().value).toBe( - 'No recent contact from this runner; last contact was 1 day ago', - ); - }); - - it('renders not connected state', () => { - createComponent({ - runner: { - contactedAt: null, - status: STATUS_NOT_CONNECTED, - }, - }); - - expect(wrapper.text()).toBe('not connected'); - expect(findBadge().props('variant')).toBe('muted'); - expect(getTooltip().value).toMatch('This runner has never connected'); - }); - - it('does not fail when data is missing', () => { - createComponent({ - runner: { - status: null, - }, - }); - - expect(wrapper.text()).toBe(''); - }); -}); diff --git a/spec/frontend/runner/components/runner_delete_modal_spec.js b/spec/frontend/runner/components/runner_delete_modal_spec.js new file mode 100644 index 00000000000..3e5b634d815 --- /dev/null +++ b/spec/frontend/runner/components/runner_delete_modal_spec.js @@ -0,0 +1,60 @@ +import { GlModal } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue'; + +describe('RunnerDeleteModal', () => { + let wrapper; + + const findGlModal = () => wrapper.findComponent(GlModal); + + const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => { + wrapper = mountFn(RunnerDeleteModal, { + attachTo: document.body, + propsData: { + runnerName: '#99 (AABBCCDD)', + ...props, + }, + attrs: { + modalId: 'delete-runner-modal-99', + }, + }); + }; + + it('Displays title', () => { + createComponent(); + + expect(findGlModal().props('title')).toBe('Delete runner #99 (AABBCCDD)?'); + }); + + it('Displays buttons', () => { + createComponent(); + + expect(findGlModal().props('actionPrimary')).toMatchObject({ text: 'Delete runner' }); + expect(findGlModal().props('actionCancel')).toMatchObject({ text: 'Cancel' }); + }); + + it('Displays contents', () => { + createComponent(); + + expect(findGlModal().html()).toContain( + 'The runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?', + ); + }); + + describe('When modal is confirmed by the user', () => { + let hideModalSpy; + + beforeEach(() => { + createComponent({}, mount); + hideModalSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide').mockImplementation(() => {}); + }); + + it('Modal gets hidden', () => { + expect(hideModalSpy).toHaveBeenCalledTimes(0); + + findGlModal().vm.$emit('primary'); + + expect(hideModalSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js index 9ea0955f2a1..5ab0db019a3 100644 --- a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js +++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js @@ -15,7 +15,6 @@ describe('RunnerList', () => { const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem); - const findActiveRunnersMessage = () => wrapper.findByTestId('runner-count'); const mockDefaultSort = 'CREATED_DESC'; const mockOtherSort = 'CONTACTED_DESC'; @@ -23,7 +22,6 @@ describe('RunnerList', () => { { type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }, { type: 'filtered-search-term', value: { data: '' } }, ]; - const mockActiveRunnersCount = 2; const expectToHaveLastEmittedInput = (value) => { const inputs = wrapper.emitted('input'); @@ -43,9 +41,6 @@ describe('RunnerList', () => { }, ...props, }, - slots: { - 'runner-count': `Runners currently online: ${mockActiveRunnersCount}`, - }, stubs: { FilteredSearch, GlFilteredSearch, @@ -69,12 +64,6 @@ describe('RunnerList', () => { expect(findFilteredSearch().props('namespace')).toBe('runners'); }); - it('Displays an active runner count', () => { - expect(findActiveRunnersMessage().text()).toBe( - `Runners currently online: ${mockActiveRunnersCount}`, - ); - }); - it('sets sorting options', () => { const SORT_OPTIONS_COUNT = 2; diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index 986e55a2132..5a14fa5a2d5 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -46,12 +46,19 @@ describe('RunnerList', () => { 'Runner ID', 'Version', 'IP Address', + 'Jobs', 'Tags', 'Last contact', '', // actions has no label ]); }); + it('Sets runner id as a row key', () => { + createComponent({}, shallowMount); + + expect(findTable().attributes('primary-key')).toBe('id'); + }); + it('Displays a list of runners', () => { expect(findRows()).toHaveLength(4); @@ -73,6 +80,7 @@ describe('RunnerList', () => { // Other fields expect(findCell({ fieldKey: 'version' }).text()).toBe(version); expect(findCell({ fieldKey: 'ipAddress' }).text()).toBe(ipAddress); + expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('0'); expect(findCell({ fieldKey: 'tagList' }).text()).toBe(''); expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String)); @@ -83,6 +91,42 @@ describe('RunnerList', () => { expect(actions.findByTestId('toggle-active-runner').exists()).toBe(true); }); + describe('Table data formatting', () => { + let mockRunnersCopy; + + beforeEach(() => { + mockRunnersCopy = [ + { + ...mockRunners[0], + }, + ]; + }); + + it('Formats job counts', () => { + mockRunnersCopy[0].jobCount = 1; + + createComponent({ props: { runners: mockRunnersCopy } }, mount); + + expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('1'); + }); + + it('Formats large job counts', () => { + mockRunnersCopy[0].jobCount = 1000; + + createComponent({ props: { runners: mockRunnersCopy } }, mount); + + expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('1,000'); + }); + + it('Formats large job counts with a plus symbol', () => { + mockRunnersCopy[0].jobCount = 1001; + + createComponent({ props: { runners: mockRunnersCopy } }, mount); + + expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('1,000+'); + }); + }); + it('Shows runner identifier', () => { const { id, shortSha } = mockRunners[0]; const numericId = getIdFromGraphQLId(id); diff --git a/spec/frontend/runner/components/runner_status_badge_spec.js b/spec/frontend/runner/components/runner_status_badge_spec.js new file mode 100644 index 00000000000..a19515d6ed2 --- /dev/null +++ b/spec/frontend/runner/components/runner_status_badge_spec.js @@ -0,0 +1,130 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerStatusBadge from '~/runner/components/runner_status_badge.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { + STATUS_ONLINE, + STATUS_OFFLINE, + STATUS_STALE, + STATUS_NOT_CONNECTED, + STATUS_NEVER_CONTACTED, +} from '~/runner/constants'; + +describe('RunnerTypeBadge', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip'); + + const createComponent = (props = {}) => { + wrapper = shallowMount(RunnerStatusBadge, { + propsData: { + runner: { + contactedAt: '2020-12-31T23:59:00Z', + status: STATUS_ONLINE, + }, + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + beforeEach(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(new Date('2021-01-01T00:00:00Z')); + }); + + afterEach(() => { + jest.useFakeTimers('legacy'); + + wrapper.destroy(); + }); + + it('renders online state', () => { + createComponent(); + + expect(wrapper.text()).toBe('online'); + expect(findBadge().props('variant')).toBe('success'); + expect(getTooltip().value).toBe('Runner is online; last contact was 1 minute ago'); + }); + + it('renders not connected state', () => { + createComponent({ + runner: { + contactedAt: null, + status: STATUS_NOT_CONNECTED, + }, + }); + + expect(wrapper.text()).toBe('not connected'); + expect(findBadge().props('variant')).toBe('muted'); + expect(getTooltip().value).toMatch('This runner has never connected'); + }); + + it('renders never contacted state as not connected, for backwards compatibility', () => { + createComponent({ + runner: { + contactedAt: null, + status: STATUS_NEVER_CONTACTED, + }, + }); + + expect(wrapper.text()).toBe('not connected'); + expect(findBadge().props('variant')).toBe('muted'); + expect(getTooltip().value).toMatch('This runner has never connected'); + }); + + it('renders offline state', () => { + createComponent({ + runner: { + contactedAt: '2020-12-31T00:00:00Z', + status: STATUS_OFFLINE, + }, + }); + + expect(wrapper.text()).toBe('offline'); + expect(findBadge().props('variant')).toBe('muted'); + expect(getTooltip().value).toBe( + 'No recent contact from this runner; last contact was 1 day ago', + ); + }); + + it('renders stale state', () => { + createComponent({ + runner: { + contactedAt: '2020-01-01T00:00:00Z', + status: STATUS_STALE, + }, + }); + + expect(wrapper.text()).toBe('stale'); + expect(findBadge().props('variant')).toBe('warning'); + expect(getTooltip().value).toBe('No contact from this runner in over 3 months'); + }); + + describe('does not fail when data is missing', () => { + it('contacted_at is missing', () => { + createComponent({ + runner: { + contactedAt: null, + status: STATUS_ONLINE, + }, + }); + + expect(wrapper.text()).toBe('online'); + expect(getTooltip().value).toBe('Runner is online; last contact was n/a'); + }); + + it('status is missing', () => { + createComponent({ + runner: { + status: null, + }, + }); + + expect(wrapper.text()).toBe(''); + }); + }); +}); diff --git a/spec/frontend/runner/components/search_tokens/tag_token_spec.js b/spec/frontend/runner/components/search_tokens/tag_token_spec.js index 52b87542243..89c06ba2df4 100644 --- a/spec/frontend/runner/components/search_tokens/tag_token_spec.js +++ b/spec/frontend/runner/components/search_tokens/tag_token_spec.js @@ -41,7 +41,7 @@ const mockTagTokenConfig = { title: 'Tags', type: 'tag', token: TagToken, - recentTokenValuesStorageKey: mockStorageKey, + recentSuggestionsStorageKey: mockStorageKey, operators: OPERATOR_IS_ONLY, }; diff --git a/spec/frontend/runner/components/stat/runner_online_stat_spec.js b/spec/frontend/runner/components/stat/runner_online_stat_spec.js new file mode 100644 index 00000000000..18f865aa22c --- /dev/null +++ b/spec/frontend/runner/components/stat/runner_online_stat_spec.js @@ -0,0 +1,34 @@ +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { shallowMount, mount } from '@vue/test-utils'; +import RunnerOnlineBadge from '~/runner/components/stat/runner_online_stat.vue'; + +describe('RunnerOnlineBadge', () => { + let wrapper; + + const findSingleStat = () => wrapper.findComponent(GlSingleStat); + + const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => { + wrapper = mountFn(RunnerOnlineBadge, { + propsData: { + value: '99', + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Uses a success appearance', () => { + createComponent({}, shallowMount); + + expect(findSingleStat().props('variant')).toBe('success'); + }); + + it('Renders a value', () => { + createComponent({}, mount); + + expect(wrapper.text()).toMatch(new RegExp(`Online Runners 99\\s+online`)); + }); +}); diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js index 39bca743c80..4451100de19 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -130,24 +130,24 @@ describe('GroupRunnersApp', () => { }); describe('shows the active runner count', () => { + const expectedOnlineCount = (count) => new RegExp(`Online Runners ${count}`); + it('with a regular value', () => { createComponent({ mountFn: mount }); - expect(findRunnerFilteredSearchBar().text()).toMatch( - `Runners in this group: ${mockGroupRunnersLimitedCount}`, - ); + expect(wrapper.text()).toMatch(expectedOnlineCount(mockGroupRunnersLimitedCount)); }); it('at the limit', () => { createComponent({ props: { groupRunnersLimitedCount: 1000 }, mountFn: mount }); - expect(findRunnerFilteredSearchBar().text()).toMatch(`Runners in this group: 1,000`); + expect(wrapper.text()).toMatch(expectedOnlineCount('1,000')); }); it('over the limit', () => { createComponent({ props: { groupRunnersLimitedCount: 1001 }, mountFn: mount }); - expect(findRunnerFilteredSearchBar().text()).toMatch(`Runners in this group: 1,000+`); + expect(wrapper.text()).toMatch(expectedOnlineCount('1,000\\+')); }); }); diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index d4ee9e6e43d..0a2b18caf25 100644 --- a/spec/frontend/security_configuration/components/app_spec.js +++ b/spec/frontend/security_configuration/components/app_spec.js @@ -20,6 +20,7 @@ import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY, } from '~/security_configuration/components/constants'; import FeatureCard from '~/security_configuration/components/feature_card.vue'; +import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue'; import UpgradeBanner from '~/security_configuration/components/upgrade_banner.vue'; import { @@ -39,7 +40,11 @@ describe('App component', () => { let wrapper; let userCalloutDismissSpy; - const createComponent = ({ shouldShowCallout = true, ...propsData }) => { + const createComponent = ({ + shouldShowCallout = true, + secureVulnerabilityTraining = true, + ...propsData + }) => { userCalloutDismissSpy = jest.fn(); wrapper = extendedWrapper( @@ -50,6 +55,9 @@ describe('App component', () => { autoDevopsHelpPagePath, autoDevopsPath, projectPath, + glFeatures: { + secureVulnerabilityTraining, + }, }, stubs: { ...stubChildren(SecurityConfigurationApp), @@ -71,6 +79,7 @@ describe('App component', () => { const findTabs = () => wrapper.findAllComponents(GlTab); const findByTestId = (id) => wrapper.findByTestId(id); const findFeatureCards = () => wrapper.findAllComponents(FeatureCard); + const findTrainingProviderList = () => wrapper.findComponent(TrainingProviderList); const findManageViaMRErrorAlert = () => wrapper.findByTestId('manage-via-mr-error-alert'); const findLink = ({ href, text, container = wrapper }) => { const selector = `a[href="${href}"]`; @@ -138,20 +147,20 @@ describe('App component', () => { expect(mainHeading.text()).toContain('Security Configuration'); }); - it('renders GlTab Component ', () => { - expect(findTab().exists()).toBe(true); - }); + describe('tabs', () => { + const expectedTabs = ['security-testing', 'compliance-testing', 'vulnerability-management']; - it('renders right amount of tabs with correct title ', () => { - expect(findTabs()).toHaveLength(2); - }); + it('renders GlTab Component', () => { + expect(findTab().exists()).toBe(true); + }); - it('renders security-testing tab', () => { - expect(findByTestId('security-testing-tab').exists()).toBe(true); - }); + it('renders correct amount of tabs', () => { + expect(findTabs()).toHaveLength(expectedTabs.length); + }); - it('renders compliance-testing tab', () => { - expect(findByTestId('compliance-testing-tab').exists()).toBe(true); + it.each(expectedTabs)('renders the %s tab', (tabName) => { + expect(findByTestId(`${tabName}-tab`).exists()).toBe(true); + }); }); it('renders right amount of feature cards for given props with correct props', () => { @@ -173,6 +182,10 @@ describe('App component', () => { expect(findComplianceViewHistoryLink().exists()).toBe(false); expect(findSecurityViewHistoryLink().exists()).toBe(false); }); + + it('renders TrainingProviderList component', () => { + expect(findTrainingProviderList().exists()).toBe(true); + }); }); describe('Manage via MR Error Alert', () => { @@ -418,4 +431,22 @@ describe('App component', () => { expect(findSecurityViewHistoryLink().attributes('href')).toBe('test/historyPath'); }); }); + + describe('when secureVulnerabilityTraining feature flag is disabled', () => { + beforeEach(() => { + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock, + secureVulnerabilityTraining: false, + }); + }); + + it('renders correct amount of tabs', () => { + expect(findTabs()).toHaveLength(2); + }); + + it('does not render the vulnerability-management tab', () => { + expect(wrapper.findByTestId('vulnerability-management-tab').exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/security_configuration/components/training_provider_list_spec.js b/spec/frontend/security_configuration/components/training_provider_list_spec.js new file mode 100644 index 00000000000..60cc36a634c --- /dev/null +++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js @@ -0,0 +1,88 @@ +import { GlLink, GlToggle, GlCard, GlSkeletonLoader } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue'; +import waitForPromises from 'helpers/wait_for_promises'; +import { securityTrainingProviders, mockResolvers } from '../mock_data'; + +Vue.use(VueApollo); + +describe('TrainingProviderList component', () => { + let wrapper; + let mockApollo; + let mockSecurityTrainingProvidersData; + + const createComponent = () => { + mockApollo = createMockApollo([], mockResolvers); + + wrapper = shallowMount(TrainingProviderList, { + apolloProvider: mockApollo, + }); + }; + + const waitForQueryToBeLoaded = () => waitForPromises(); + + const findCards = () => wrapper.findAllComponents(GlCard); + const findLinks = () => wrapper.findAllComponents(GlLink); + const findToggles = () => wrapper.findAllComponents(GlToggle); + const findLoader = () => wrapper.findComponent(GlSkeletonLoader); + + beforeEach(() => { + mockSecurityTrainingProvidersData = jest.fn(); + mockSecurityTrainingProvidersData.mockResolvedValue(securityTrainingProviders); + + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + mockApollo = null; + }); + + describe('when loading', () => { + it('shows the loader', () => { + expect(findLoader().exists()).toBe(true); + }); + + it('does not show the cards', () => { + expect(findCards().exists()).toBe(false); + }); + }); + + describe('basic structure', () => { + beforeEach(async () => { + await waitForQueryToBeLoaded(); + }); + + it('renders correct amount of cards', () => { + expect(findCards()).toHaveLength(securityTrainingProviders.length); + }); + + securityTrainingProviders.forEach(({ name, description, url, isEnabled }, index) => { + it(`shows the name for card ${index}`, () => { + expect(findCards().at(index).text()).toContain(name); + }); + + it(`shows the description for card ${index}`, () => { + expect(findCards().at(index).text()).toContain(description); + }); + + it(`shows the learn more link for card ${index}`, () => { + expect(findLinks().at(index).attributes()).toEqual({ + target: '_blank', + href: url, + }); + }); + + it(`shows the toggle with the correct value for card ${index}`, () => { + expect(findToggles().at(index).props('value')).toEqual(isEnabled); + }); + + it('does not show loader when query is populated', () => { + expect(findLoader().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/security_configuration/mock_data.js b/spec/frontend/security_configuration/mock_data.js new file mode 100644 index 00000000000..cdb859c3800 --- /dev/null +++ b/spec/frontend/security_configuration/mock_data.js @@ -0,0 +1,30 @@ +export const securityTrainingProviders = [ + { + id: 101, + name: 'Kontra', + description: 'Interactive developer security education.', + url: 'https://application.security/', + isEnabled: false, + }, + { + id: 102, + name: 'SecureCodeWarrior', + description: 'Security training with guide and learning pathways.', + url: 'https://www.securecodewarrior.com/', + isEnabled: true, + }, +]; + +export const securityTrainingProvidersResponse = { + data: { + securityTrainingProviders, + }, +}; + +export const mockResolvers = { + Query: { + securityTrainingProviders() { + return securityTrainingProviders; + }, + }, +}; diff --git a/spec/frontend/security_configuration/utils_spec.js b/spec/frontend/security_configuration/utils_spec.js index eaed4532baa..241e69204d2 100644 --- a/spec/frontend/security_configuration/utils_spec.js +++ b/spec/frontend/security_configuration/utils_spec.js @@ -1,101 +1,120 @@ -import { augmentFeatures } from '~/security_configuration/utils'; - -const mockSecurityFeatures = [ - { - name: 'SAST', - type: 'SAST', - }, -]; - -const mockComplianceFeatures = [ - { - name: 'LICENSE_COMPLIANCE', - type: 'LICENSE_COMPLIANCE', - }, -]; - -const mockFeaturesWithSecondary = [ - { - name: 'DAST', - type: 'DAST', - secondary: { - type: 'DAST PROFILES', - name: 'DAST PROFILES', +import { augmentFeatures, translateScannerNames } from '~/security_configuration/utils'; +import { SCANNER_NAMES_MAP } from '~/security_configuration/components/constants'; + +describe('augmentFeatures', () => { + const mockSecurityFeatures = [ + { + name: 'SAST', + type: 'SAST', }, - }, -]; - -const mockInvalidCustomFeature = [ - { - foo: 'bar', - }, -]; - -const mockValidCustomFeature = [ - { - name: 'SAST', - type: 'SAST', - customField: 'customvalue', - }, -]; - -const mockValidCustomFeatureSnakeCase = [ - { - name: 'SAST', - type: 'SAST', - custom_field: 'customvalue', - }, -]; - -const expectedOutputDefault = { - augmentedSecurityFeatures: mockSecurityFeatures, - augmentedComplianceFeatures: mockComplianceFeatures, -}; - -const expectedOutputSecondary = { - augmentedSecurityFeatures: mockSecurityFeatures, - augmentedComplianceFeatures: mockFeaturesWithSecondary, -}; - -const expectedOutputCustomFeature = { - augmentedSecurityFeatures: mockValidCustomFeature, - augmentedComplianceFeatures: mockComplianceFeatures, -}; - -describe('returns an object with augmentedSecurityFeatures and augmentedComplianceFeatures when', () => { - it('given an empty array', () => { - expect(augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, [])).toEqual( - expectedOutputDefault, - ); + ]; + + const mockComplianceFeatures = [ + { + name: 'LICENSE_COMPLIANCE', + type: 'LICENSE_COMPLIANCE', + }, + ]; + + const mockFeaturesWithSecondary = [ + { + name: 'DAST', + type: 'DAST', + secondary: { + type: 'DAST PROFILES', + name: 'DAST PROFILES', + }, + }, + ]; + + const mockInvalidCustomFeature = [ + { + foo: 'bar', + }, + ]; + + const mockValidCustomFeature = [ + { + name: 'SAST', + type: 'SAST', + customField: 'customvalue', + }, + ]; + + const mockValidCustomFeatureSnakeCase = [ + { + name: 'SAST', + type: 'SAST', + custom_field: 'customvalue', + }, + ]; + + const expectedOutputDefault = { + augmentedSecurityFeatures: mockSecurityFeatures, + augmentedComplianceFeatures: mockComplianceFeatures, + }; + + const expectedOutputSecondary = { + augmentedSecurityFeatures: mockSecurityFeatures, + augmentedComplianceFeatures: mockFeaturesWithSecondary, + }; + + const expectedOutputCustomFeature = { + augmentedSecurityFeatures: mockValidCustomFeature, + augmentedComplianceFeatures: mockComplianceFeatures, + }; + + describe('returns an object with augmentedSecurityFeatures and augmentedComplianceFeatures when', () => { + it('given an empty array', () => { + expect(augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, [])).toEqual( + expectedOutputDefault, + ); + }); + + it('given an invalid populated array', () => { + expect( + augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, mockInvalidCustomFeature), + ).toEqual(expectedOutputDefault); + }); + + it('features have secondary key', () => { + expect(augmentFeatures(mockSecurityFeatures, mockFeaturesWithSecondary, [])).toEqual( + expectedOutputSecondary, + ); + }); + + it('given a valid populated array', () => { + expect( + augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, mockValidCustomFeature), + ).toEqual(expectedOutputCustomFeature); + }); }); - it('given an invalid populated array', () => { - expect( - augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, mockInvalidCustomFeature), - ).toEqual(expectedOutputDefault); + describe('returns an object with camelcased keys', () => { + it('given a customfeature in snakecase', () => { + expect( + augmentFeatures( + mockSecurityFeatures, + mockComplianceFeatures, + mockValidCustomFeatureSnakeCase, + ), + ).toEqual(expectedOutputCustomFeature); + }); }); +}); - it('features have secondary key', () => { - expect(augmentFeatures(mockSecurityFeatures, mockFeaturesWithSecondary, [])).toEqual( - expectedOutputSecondary, - ); +describe('translateScannerNames', () => { + it.each(['', undefined, null, 1, 'UNKNOWN_SCANNER_KEY'])('returns %p as is', (key) => { + expect(translateScannerNames([key])).toEqual([key]); }); - it('given a valid populated array', () => { - expect( - augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, mockValidCustomFeature), - ).toEqual(expectedOutputCustomFeature); + it('returns an empty array if no input is provided', () => { + expect(translateScannerNames([])).toEqual([]); }); -}); -describe('returns an object with camelcased keys', () => { - it('given a customfeature in snakecase', () => { - expect( - augmentFeatures( - mockSecurityFeatures, - mockComplianceFeatures, - mockValidCustomFeatureSnakeCase, - ), - ).toEqual(expectedOutputCustomFeature); + it('returns translated scanner names', () => { + expect(translateScannerNames(Object.keys(SCANNER_NAMES_MAP))).toEqual( + Object.values(SCANNER_NAMES_MAP), + ); }); }); 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 1a874c3dcd6..c968c28c811 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 @@ -52,6 +52,7 @@ exports[`self monitor component When the self monitor project has not been creat <gl-form-group-stub labeldescription="" + optionaltext="(optional)" > <gl-toggle-stub label="Self monitoring" diff --git a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap index 53bef449c2f..c25a8d4bb92 100644 --- a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap +++ b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap @@ -7,8 +7,10 @@ exports[`EmptyStateComponent should render content 1`] = ` </div> <div class=\\"col-12\\"> <div class=\\"text-content gl-mx-auto gl-my-0 gl-p-5\\"> - <h1 class=\\"h4\\">Getting started with serverless</h1> - <p>In order to start using functions as a service, you must first install Knative on your Kubernetes cluster. <gl-link-stub href=\\"/help\\">More information</gl-link-stub> + <h1 class=\\"gl-font-size-h-display gl-line-height-36 h4\\"> + Getting started with serverless + </h1> + <p class=\\"gl-mt-3\\">In order to start using functions as a service, you must first install Knative on your Kubernetes cluster. <gl-link-stub href=\\"/help\\">More information</gl-link-stub> </p> <div class=\\"gl-display-flex gl-flex-wrap gl-justify-content-center\\"> <!----> diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js index 3ff6d1f9597..d7261784edc 100644 --- a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js +++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js @@ -1,6 +1,6 @@ import { GlModal, GlFormCheckbox } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { initEmojiMock } from 'helpers/emoji'; +import { initEmojiMock, clearEmojiMock } from 'helpers/emoji'; import * as UserApi from '~/api/user_api'; import EmojiPicker from '~/emoji/components/picker.vue'; import createFlash from '~/flash'; @@ -12,7 +12,6 @@ jest.mock('~/flash'); describe('SetStatusModalWrapper', () => { let wrapper; - let mockEmoji; const $toast = { show: jest.fn(), }; @@ -63,12 +62,12 @@ describe('SetStatusModalWrapper', () => { afterEach(() => { wrapper.destroy(); - mockEmoji.restore(); + clearEmojiMock(); }); describe('with minimum props', () => { beforeEach(async () => { - mockEmoji = await initEmojiMock(); + await initEmojiMock(); wrapper = createComponent(); return initModal(); }); @@ -112,7 +111,7 @@ describe('SetStatusModalWrapper', () => { describe('improvedEmojiPicker is true', () => { beforeEach(async () => { - mockEmoji = await initEmojiMock(); + await initEmojiMock(); wrapper = createComponent({}, true); return initModal(); }); @@ -126,7 +125,7 @@ describe('SetStatusModalWrapper', () => { describe('with no currentMessage set', () => { beforeEach(async () => { - mockEmoji = await initEmojiMock(); + await initEmojiMock(); wrapper = createComponent({ currentMessage: '' }); return initModal(); }); @@ -146,7 +145,7 @@ describe('SetStatusModalWrapper', () => { describe('with no currentEmoji set', () => { beforeEach(async () => { - mockEmoji = await initEmojiMock(); + await initEmojiMock(); wrapper = createComponent({ currentEmoji: '' }); return initModal(); }); @@ -161,7 +160,7 @@ describe('SetStatusModalWrapper', () => { describe('with no currentMessage set', () => { beforeEach(async () => { - mockEmoji = await initEmojiMock(); + await initEmojiMock(); wrapper = createComponent({ currentEmoji: '', currentMessage: '' }); return initModal(); }); @@ -174,7 +173,7 @@ describe('SetStatusModalWrapper', () => { describe('with currentClearStatusAfter set', () => { beforeEach(async () => { - mockEmoji = await initEmojiMock(); + await initEmojiMock(); wrapper = createComponent({ currentClearStatusAfter: '2021-01-01 00:00:00 UTC' }); return initModal(); }); @@ -190,7 +189,7 @@ describe('SetStatusModalWrapper', () => { describe('update status', () => { describe('succeeds', () => { beforeEach(async () => { - mockEmoji = await initEmojiMock(); + await initEmojiMock(); wrapper = createComponent(); await initModal(); @@ -198,7 +197,7 @@ describe('SetStatusModalWrapper', () => { }); it('clicking "removeStatus" clears the emoji and message fields', async () => { - findModal().vm.$emit('cancel'); + findModal().vm.$emit('secondary'); await wrapper.vm.$nextTick(); expect(findFormField('message').element.value).toBe(''); @@ -206,7 +205,7 @@ describe('SetStatusModalWrapper', () => { }); it('clicking "setStatus" submits the user status', async () => { - findModal().vm.$emit('ok'); + findModal().vm.$emit('primary'); await wrapper.vm.$nextTick(); // set the availability status @@ -215,7 +214,7 @@ describe('SetStatusModalWrapper', () => { // set the currentClearStatusAfter to 30 minutes wrapper.find('[data-testid="thirtyMinutes"]').vm.$emit('click'); - findModal().vm.$emit('ok'); + findModal().vm.$emit('primary'); await wrapper.vm.$nextTick(); const commonParams = { @@ -237,7 +236,7 @@ describe('SetStatusModalWrapper', () => { }); it('calls the "onUpdateSuccess" handler', async () => { - findModal().vm.$emit('ok'); + findModal().vm.$emit('primary'); await wrapper.vm.$nextTick(); expect(wrapper.vm.onUpdateSuccess).toHaveBeenCalled(); @@ -246,14 +245,14 @@ describe('SetStatusModalWrapper', () => { describe('success message', () => { beforeEach(async () => { - mockEmoji = await initEmojiMock(); + await initEmojiMock(); wrapper = createComponent({ currentEmoji: '', currentMessage: '' }); jest.spyOn(UserApi, 'updateUserStatus').mockResolvedValue(); return initModal({ mockOnUpdateSuccess: false }); }); it('displays a toast success message', async () => { - findModal().vm.$emit('ok'); + findModal().vm.$emit('primary'); await wrapper.vm.$nextTick(); expect($toast.show).toHaveBeenCalledWith('Status updated'); @@ -262,7 +261,7 @@ describe('SetStatusModalWrapper', () => { describe('with errors', () => { beforeEach(async () => { - mockEmoji = await initEmojiMock(); + await initEmojiMock(); wrapper = createComponent(); await initModal(); @@ -270,7 +269,7 @@ describe('SetStatusModalWrapper', () => { }); it('calls the "onUpdateFail" handler', async () => { - findModal().vm.$emit('ok'); + findModal().vm.$emit('primary'); await wrapper.vm.$nextTick(); expect(wrapper.vm.onUpdateFail).toHaveBeenCalled(); @@ -279,14 +278,14 @@ describe('SetStatusModalWrapper', () => { describe('error message', () => { beforeEach(async () => { - mockEmoji = await initEmojiMock(); + await initEmojiMock(); wrapper = createComponent({ currentEmoji: '', currentMessage: '' }); jest.spyOn(UserApi, 'updateUserStatus').mockRejectedValue(); return initModal({ mockOnUpdateFailure: false }); }); it('flashes an error message', async () => { - findModal().vm.$emit('ok'); + findModal().vm.$emit('primary'); await wrapper.vm.$nextTick(); expect(createFlash).toHaveBeenCalledWith({ diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js index 455db325066..49148123a1c 100644 --- a/spec/frontend/shortcuts_spec.js +++ b/spec/frontend/shortcuts_spec.js @@ -25,6 +25,7 @@ describe('Shortcuts', () => { jest.spyOn(document.querySelector('.js-new-note-form .js-md-preview-button'), 'focus'); jest.spyOn(document.querySelector('.edit-note .js-md-preview-button'), 'focus'); + jest.spyOn(document.querySelector('#search'), 'focus'); new Shortcuts(); // eslint-disable-line no-new }); @@ -111,4 +112,12 @@ describe('Shortcuts', () => { }); }); }); + + describe('focusSearch', () => { + it('focuses the search bar', () => { + Shortcuts.focusSearch(createEvent('KeyboardEvent')); + + expect(document.querySelector('#search').focus).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js index 39f63b2a9f4..07da4acef8c 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js @@ -5,7 +5,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; diff --git a/spec/frontend/sidebar/components/attention_required_toggle_spec.js b/spec/frontend/sidebar/components/attention_requested_toggle_spec.js index 8555068cdd8..0939297a754 100644 --- a/spec/frontend/sidebar/components/attention_required_toggle_spec.js +++ b/spec/frontend/sidebar/components/attention_requested_toggle_spec.js @@ -23,8 +23,8 @@ describe('Attention require toggle', () => { it.each` attentionRequested | icon - ${true} | ${'star'} - ${false} | ${'star-o'} + ${true} | ${'attention-solid'} + ${false} | ${'attention'} `( 'renders $icon icon when attention_requested is $attentionRequested', ({ attentionRequested, icon }) => { diff --git a/spec/frontend/sidebar/components/crm_contacts_spec.js b/spec/frontend/sidebar/components/crm_contacts_spec.js new file mode 100644 index 00000000000..758cff30e2d --- /dev/null +++ b/spec/frontend/sidebar/components/crm_contacts_spec.js @@ -0,0 +1,87 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import CrmContacts from '~/sidebar/components/crm_contacts/crm_contacts.vue'; +import getIssueCrmContactsQuery from '~/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql'; +import issueCrmContactsSubscription from '~/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql'; +import { + getIssueCrmContactsQueryResponse, + issueCrmContactsUpdateResponse, + issueCrmContactsUpdateNullResponse, +} from './mock_data'; + +jest.mock('~/flash'); + +describe('Issue crm contacts component', () => { + Vue.use(VueApollo); + let wrapper; + let fakeApollo; + + const successQueryHandler = jest.fn().mockResolvedValue(getIssueCrmContactsQueryResponse); + const successSubscriptionHandler = jest.fn().mockResolvedValue(issueCrmContactsUpdateResponse); + const nullSubscriptionHandler = jest.fn().mockResolvedValue(issueCrmContactsUpdateNullResponse); + + const mountComponent = ({ + queryHandler = successQueryHandler, + subscriptionHandler = successSubscriptionHandler, + } = {}) => { + fakeApollo = createMockApollo([ + [getIssueCrmContactsQuery, queryHandler], + [issueCrmContactsSubscription, subscriptionHandler], + ]); + wrapper = shallowMountExtended(CrmContacts, { + propsData: { issueId: '123' }, + apolloProvider: fakeApollo, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + it('should render error message on reject', async () => { + mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') }); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalled(); + }); + + it('calls the query with correct variables', () => { + mountComponent(); + + expect(successQueryHandler).toHaveBeenCalledWith({ + id: 'gid://gitlab/Issue/123', + }); + }); + + it('calls the subscription with correct variable for issue', () => { + mountComponent(); + + expect(successSubscriptionHandler).toHaveBeenCalledWith({ + id: 'gid://gitlab/Issue/123', + }); + }); + + it('renders correct initial results', async () => { + mountComponent({ subscriptionHandler: nullSubscriptionHandler }); + await waitForPromises(); + + expect(wrapper.find('#contact_0').text()).toContain('Someone Important'); + expect(wrapper.find('#contact_container_0').text()).toContain('si@gitlab.com'); + expect(wrapper.find('#contact_1').text()).toContain('Marty McFly'); + }); + + it('renders correct results after subscription update', async () => { + mountComponent(); + await waitForPromises(); + + const contact = ['Dave Davies', 'dd@gitlab.com', '+44 20 1111 2222', 'Vice President']; + contact.forEach((property) => { + expect(wrapper.find('#contact_container_0').text()).toContain(property); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js b/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js index 619e89beb23..1e2173e2988 100644 --- a/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js +++ b/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js @@ -145,13 +145,20 @@ describe('Sidebar date Widget', () => { ${false} | ${SidebarInheritDate} | ${'SidebarInheritDate'} | ${false} `( 'when canInherit is $canInherit, $componentName display is $expected', - ({ canInherit, component, expected }) => { + async ({ canInherit, component, expected }) => { createComponent({ canInherit }); + await waitForPromises(); expect(wrapper.find(component).exists()).toBe(expected); }, ); + it('does not render SidebarInheritDate when canInherit is true and date is loading', async () => { + createComponent({ canInherit: true }); + + expect(wrapper.find(SidebarInheritDate).exists()).toBe(false); + }); + it('displays a flash message when query is rejected', async () => { createComponent({ dueDateQueryHandler: jest.fn().mockRejectedValue('Houston, we have a problem'), diff --git a/spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js b/spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js index 4d38eba8035..fda21e06987 100644 --- a/spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js +++ b/spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js @@ -10,7 +10,7 @@ describe('SidebarInheritDate', () => { const findFixedRadio = () => wrapper.findAll(GlFormRadio).at(0); const findInheritRadio = () => wrapper.findAll(GlFormRadio).at(1); - const createComponent = () => { + const createComponent = ({ dueDateIsFixed = false } = {}) => { wrapper = shallowMount(SidebarInheritDate, { provide: { canUpdate: true, @@ -18,11 +18,10 @@ describe('SidebarInheritDate', () => { propsData: { issuable: { dueDate: '2021-04-15', - dueDateIsFixed: true, + dueDateIsFixed, dueDateFixed: '2021-04-15', dueDateFromMilestones: '2021-05-15', }, - isLoading: false, dateType: 'dueDate', }, }); @@ -45,6 +44,13 @@ describe('SidebarInheritDate', () => { expect(findInheritRadio().text()).toBe('Inherited:'); }); + it('does not emit set-date if fixed value does not change', () => { + createComponent({ dueDateIsFixed: true }); + findFixedRadio().vm.$emit('input', true); + + expect(wrapper.emitted('set-date')).toBeUndefined(); + }); + it('emits set-date event on click on radio button', () => { findFixedRadio().vm.$emit('input', true); diff --git a/spec/frontend/sidebar/components/mock_data.js b/spec/frontend/sidebar/components/mock_data.js new file mode 100644 index 00000000000..70c3f8a3012 --- /dev/null +++ b/spec/frontend/sidebar/components/mock_data.js @@ -0,0 +1,56 @@ +export const getIssueCrmContactsQueryResponse = { + data: { + issue: { + id: 'gid://gitlab/Issue/123', + customerRelationsContacts: { + nodes: [ + { + id: 'gid://gitlab/CustomerRelations::Contact/1', + firstName: 'Someone', + lastName: 'Important', + email: 'si@gitlab.com', + phone: null, + description: null, + organization: null, + }, + { + id: 'gid://gitlab/CustomerRelations::Contact/5', + firstName: 'Marty', + lastName: 'McFly', + email: null, + phone: null, + description: null, + organization: null, + }, + ], + }, + }, + }, +}; + +export const issueCrmContactsUpdateNullResponse = { + data: { + issueCrmContactsUpdated: null, + }, +}; + +export const issueCrmContactsUpdateResponse = { + data: { + issueCrmContactsUpdated: { + id: 'gid://gitlab/Issue/123', + customerRelationsContacts: { + nodes: [ + { + id: 'gid://gitlab/CustomerRelations::Contact/13', + firstName: 'Dave', + lastName: 'Davies', + email: 'dd@gitlab.com', + phone: '+44 20 1111 2222', + description: 'Vice President', + organization: null, + }, + ], + }, + }, + }, +}; diff --git a/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js b/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js index cc428693930..69e35cd1d05 100644 --- a/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js +++ b/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue'; import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql'; import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql'; diff --git a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js index ca6e5ac5e7f..d7471d99477 100644 --- a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js +++ b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js @@ -17,7 +17,7 @@ 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 { IssuableType } from '~/issues/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'; @@ -369,16 +369,18 @@ describe('SidebarDropdownWidget', () => { describe('when a user is searching', () => { describe('when search result is not found', () => { - it('renders "No milestone found"', async () => { - createComponent(); + describe('when milestone', () => { + it('renders "No milestone found"', async () => { + createComponent(); - await toggleDropdown(); + await toggleDropdown(); - findSearchBox().vm.$emit('input', 'non existing milestones'); + findSearchBox().vm.$emit('input', 'non existing milestones'); - await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); - expect(findDropdownText().text()).toBe('No milestone found'); + expect(findDropdownText().text()).toBe('No milestone found'); + }); }); }); }); diff --git a/spec/frontend/sidebar/components/time_tracking/mock_data.js b/spec/frontend/sidebar/components/time_tracking/mock_data.js index 938750bd58b..3f1b3fa8ec1 100644 --- a/spec/frontend/sidebar/components/time_tracking/mock_data.js +++ b/spec/frontend/sidebar/components/time_tracking/mock_data.js @@ -11,11 +11,13 @@ export const getIssueTimelogsQueryResponse = { __typename: 'Timelog', timeSpent: 14400, user: { + id: 'user-1', name: 'John Doe18', __typename: 'UserCore', }, spentAt: '2020-05-01T00:00:00Z', note: { + id: 'note-1', body: 'A note', __typename: 'Note', }, @@ -25,6 +27,7 @@ export const getIssueTimelogsQueryResponse = { __typename: 'Timelog', timeSpent: 1800, user: { + id: 'user-2', name: 'Administrator', __typename: 'UserCore', }, @@ -36,11 +39,13 @@ export const getIssueTimelogsQueryResponse = { __typename: 'Timelog', timeSpent: 14400, user: { + id: 'user-2', name: 'Administrator', __typename: 'UserCore', }, spentAt: '2021-05-01T00:00:00Z', note: { + id: 'note-2', body: 'A note', __typename: 'Note', }, @@ -65,11 +70,13 @@ export const getMrTimelogsQueryResponse = { __typename: 'Timelog', timeSpent: 1800, user: { + id: 'user-1', name: 'Administrator', __typename: 'UserCore', }, spentAt: '2021-05-07T14:44:55Z', note: { + id: 'note-1', body: 'Thirty minutes!', __typename: 'Note', }, @@ -79,6 +86,7 @@ export const getMrTimelogsQueryResponse = { __typename: 'Timelog', timeSpent: 3600, user: { + id: 'user-1', name: 'Administrator', __typename: 'UserCore', }, @@ -90,11 +98,13 @@ export const getMrTimelogsQueryResponse = { __typename: 'Timelog', timeSpent: 300, user: { + id: 'user-1', name: 'Administrator', __typename: 'UserCore', }, spentAt: '2021-03-10T00:00:00Z', note: { + id: 'note-2', body: 'A note with some time', __typename: 'Note', }, diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index 1ebd3c622ca..42e89a3ba84 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -223,6 +223,7 @@ const mockData = { export const issueConfidentialityResponse = (confidential = false) => ({ data: { workspace: { + id: '1', __typename: 'Project', issuable: { __typename: 'Issue', @@ -236,6 +237,7 @@ export const issueConfidentialityResponse = (confidential = false) => ({ export const issuableDueDateResponse = (dueDate = null) => ({ data: { workspace: { + id: '1', __typename: 'Project', issuable: { __typename: 'Issue', @@ -249,6 +251,7 @@ export const issuableDueDateResponse = (dueDate = null) => ({ export const issuableStartDateResponse = (startDate = null) => ({ data: { workspace: { + id: '1', __typename: 'Group', issuable: { __typename: 'Epic', @@ -265,6 +268,7 @@ export const issuableStartDateResponse = (startDate = null) => ({ export const epicParticipantsResponse = () => ({ data: { workspace: { + id: '1', __typename: 'Group', issuable: { __typename: 'Epic', @@ -290,6 +294,7 @@ export const epicParticipantsResponse = () => ({ export const issueReferenceResponse = (reference) => ({ data: { workspace: { + id: '1', __typename: 'Project', issuable: { __typename: 'Issue', @@ -303,6 +308,7 @@ export const issueReferenceResponse = (reference) => ({ export const issueSubscriptionsResponse = (subscribed = false, emailsDisabled = false) => ({ data: { workspace: { + id: '1', __typename: 'Project', issuable: { __typename: 'Issue', @@ -318,6 +324,7 @@ export const issuableQueryResponse = { data: { workspace: { __typename: 'Project', + id: '1', issuable: { __typename: 'Issue', id: 'gid://gitlab/Issue/1', @@ -344,6 +351,7 @@ export const searchQueryResponse = { data: { workspace: { __typename: 'Project', + id: '1', users: { nodes: [ { @@ -428,12 +436,15 @@ export const searchResponse = { data: { workspace: { __typename: 'Project', + id: '1', users: { nodes: [ { + id: 'gid://gitlab/User/1', user: mockUser1, }, { + id: 'gid://gitlab/User/4', user: mockUser2, }, ], @@ -445,6 +456,7 @@ export const searchResponse = { export const projectMembersResponse = { data: { workspace: { + id: '1', __typename: 'Project', users: { nodes: [ @@ -452,10 +464,11 @@ export const projectMembersResponse = { null, null, // Remove duplicated entry https://gitlab.com/gitlab-org/gitlab/-/issues/327822 - { user: mockUser1 }, - { user: mockUser1 }, - { user: mockUser2 }, + { id: 'user-1', user: mockUser1 }, + { id: 'user-2', user: mockUser1 }, + { id: 'user-3', user: mockUser2 }, { + id: 'user-4', user: { id: 'gid://gitlab/User/2', avatarUrl: @@ -477,16 +490,18 @@ export const projectMembersResponse = { export const groupMembersResponse = { data: { workspace: { - __typename: 'roup', + id: '1', + __typename: 'Group', 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 }, + { id: 'user-1', user: mockUser1 }, + { id: 'user-2', user: mockUser1 }, { + id: 'user-3', user: { id: 'gid://gitlab/User/2', avatarUrl: @@ -509,6 +524,7 @@ export const participantsQueryResponse = { data: { workspace: { __typename: 'Project', + id: '1', issuable: { __typename: 'Issue', id: 'gid://gitlab/Issue/1', @@ -578,6 +594,7 @@ export const mockMilestone2 = { export const mockProjectMilestonesResponse = { data: { workspace: { + id: 'gid://gitlab/Project/1', attributes: { nodes: [mockMilestone1, mockMilestone2], }, @@ -663,6 +680,7 @@ export const todosResponse = { data: { workspace: { __typename: 'Group', + id: '1', issuable: { __typename: 'Epic', id: 'gid://gitlab/Epic/4', @@ -681,6 +699,7 @@ export const todosResponse = { export const noTodosResponse = { data: { workspace: { + id: '1', __typename: 'Group', issuable: { __typename: 'Epic', diff --git a/spec/frontend/sidebar/sidebar_labels_spec.js b/spec/frontend/sidebar/sidebar_labels_spec.js deleted file mode 100644 index 8437ee1b723..00000000000 --- a/spec/frontend/sidebar/sidebar_labels_spec.js +++ /dev/null @@ -1,190 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { - mockLabels, - mockRegularLabel, -} from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; -import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; -import { MutationOperationMode } from '~/graphql_shared/utils'; -import { IssuableType } from '~/issue_show/constants'; -import SidebarLabels from '~/sidebar/components/labels/sidebar_labels.vue'; -import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql'; -import { toLabelGid } from '~/sidebar/utils'; -import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; -import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; - -describe('sidebar labels', () => { - let wrapper; - - const defaultProps = { - allowLabelCreate: true, - allowLabelEdit: true, - allowScopedLabels: true, - canEdit: true, - iid: '1', - initiallySelectedLabels: mockLabels, - issuableType: 'issue', - labelsFetchPath: '/gitlab-org/gitlab-test/-/labels.json?include_ancestor_groups=true', - labelsManagePath: '/gitlab-org/gitlab-test/-/labels', - projectIssuesPath: '/gitlab-org/gitlab-test/-/issues', - projectPath: 'gitlab-org/gitlab-test', - fullPath: 'gitlab-org/gitlab-test', - }; - - const $apollo = { - mutate: jest.fn().mockResolvedValue(), - }; - - const userUpdatedLabels = [ - { - ...mockRegularLabel, - set: false, - }, - { - id: 40, - title: 'Security', - color: '#ddd', - text_color: '#fff', - set: true, - }, - { - id: 55, - title: 'Tooling', - color: '#ddd', - text_color: '#fff', - set: false, - }, - ]; - - const findLabelsSelect = () => wrapper.find(LabelsSelect); - - const mountComponent = (props = {}) => { - wrapper = shallowMount(SidebarLabels, { - provide: { - ...defaultProps, - ...props, - }, - mocks: { - $apollo, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('LabelsSelect props', () => { - beforeEach(() => { - mountComponent(); - }); - - it('are as expected', () => { - expect(findLabelsSelect().props()).toMatchObject({ - allowLabelCreate: defaultProps.allowLabelCreate, - allowLabelEdit: defaultProps.allowLabelEdit, - allowMultiselect: true, - allowScopedLabels: defaultProps.allowScopedLabels, - footerCreateLabelTitle: 'Create project label', - footerManageLabelTitle: 'Manage project labels', - labelsCreateTitle: 'Create project label', - labelsFetchPath: defaultProps.labelsFetchPath, - labelsFilterBasePath: defaultProps.projectIssuesPath, - labelsManagePath: defaultProps.labelsManagePath, - labelsSelectInProgress: false, - selectedLabels: defaultProps.initiallySelectedLabels, - variant: DropdownVariant.Sidebar, - }); - }); - }); - - describe('when type is issue', () => { - beforeEach(() => { - mountComponent({ issuableType: IssuableType.Issue }); - }); - - describe('when labels are updated', () => { - it('invokes a mutation', () => { - findLabelsSelect().vm.$emit('updateSelectedLabels', userUpdatedLabels); - - const expected = { - mutation: updateIssueLabelsMutation, - variables: { - input: { - iid: defaultProps.iid, - projectPath: defaultProps.projectPath, - labelIds: [toLabelGid(29), toLabelGid(28), toLabelGid(27), toLabelGid(40)], - }, - }, - }; - - expect($apollo.mutate).toHaveBeenCalledWith(expected); - }); - }); - - describe('when label `x` is clicked', () => { - it('invokes a mutation', () => { - findLabelsSelect().vm.$emit('onLabelRemove', 27); - - const expected = { - mutation: updateIssueLabelsMutation, - variables: { - input: { - iid: defaultProps.iid, - projectPath: defaultProps.projectPath, - removeLabelIds: [27], - }, - }, - }; - - expect($apollo.mutate).toHaveBeenCalledWith(expected); - }); - }); - }); - - describe('when type is merge_request', () => { - beforeEach(() => { - mountComponent({ issuableType: IssuableType.MergeRequest }); - }); - - describe('when labels are updated', () => { - it('invokes a mutation', () => { - findLabelsSelect().vm.$emit('updateSelectedLabels', userUpdatedLabels); - - const expected = { - mutation: updateMergeRequestLabelsMutation, - variables: { - input: { - iid: defaultProps.iid, - labelIds: [toLabelGid(29), toLabelGid(28), toLabelGid(27), toLabelGid(40)], - operationMode: MutationOperationMode.Replace, - projectPath: defaultProps.projectPath, - }, - }, - }; - - expect($apollo.mutate).toHaveBeenCalledWith(expected); - }); - }); - - describe('when label `x` is clicked', () => { - it('invokes a mutation', () => { - findLabelsSelect().vm.$emit('onLabelRemove', 27); - - const expected = { - mutation: updateMergeRequestLabelsMutation, - variables: { - input: { - iid: defaultProps.iid, - labelIds: [toLabelGid(27)], - operationMode: MutationOperationMode.Remove, - projectPath: defaultProps.projectPath, - }, - }, - }; - - expect($apollo.mutate).toHaveBeenCalledWith(expected); - }); - }); - }); -}); diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap index 5df69ffb5f8..f4ebc5c3e3f 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap @@ -23,6 +23,7 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] = class="gl-mb-0" id="visibility-level-setting" labeldescription="" + optionaltext="(optional)" > <gl-form-radio-group-stub checked="private" diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index 4e88ab9504e..80a8b8ec489 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -53,6 +53,7 @@ const createMutationResponse = (key, obj = {}) => ({ errors: [], snippet: { __typename: 'Snippet', + id: 1, webUrl: TEST_WEB_URL, }, }, diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js index 552a1c6fcde..2d5e0cfd615 100644 --- a/spec/frontend/snippets/components/snippet_header_spec.js +++ b/spec/frontend/snippets/components/snippet_header_spec.js @@ -252,7 +252,7 @@ describe('Snippet header component', () => { disabled: false, href: `/foo/-/snippets/new`, text: 'New snippet', - variant: 'success', + variant: 'confirm', }, ]), ); diff --git a/spec/frontend/snippets/test_utils.js b/spec/frontend/snippets/test_utils.js index 8ba5a2fe5dc..dcef8fc9a8b 100644 --- a/spec/frontend/snippets/test_utils.js +++ b/spec/frontend/snippets/test_utils.js @@ -27,6 +27,7 @@ export const createGQLSnippet = () => ({ }, project: { __typename: 'Project', + id: 'project-1', fullPath: 'group/project', webUrl: `${TEST_HOST}/group/project`, }, diff --git a/spec/frontend/tabs/index_spec.js b/spec/frontend/tabs/index_spec.js new file mode 100644 index 00000000000..98617b404ff --- /dev/null +++ b/spec/frontend/tabs/index_spec.js @@ -0,0 +1,260 @@ +import { GlTabsBehavior, TAB_SHOWN_EVENT } from '~/tabs'; +import { ACTIVE_PANEL_CLASS, ACTIVE_TAB_CLASSES } from '~/tabs/constants'; +import { getFixture, setHTMLFixture } from 'helpers/fixtures'; + +const tabsFixture = getFixture('tabs/tabs.html'); + +describe('GlTabsBehavior', () => { + let glTabs; + let tabShownEventSpy; + + const findByTestId = (testId) => document.querySelector(`[data-testid="${testId}"]`); + const findTab = (name) => findByTestId(`${name}-tab`); + const findPanel = (name) => findByTestId(`${name}-panel`); + + const getAttributes = (element) => + Array.from(element.attributes).reduce((acc, attr) => { + acc[attr.name] = attr.value; + return acc; + }, {}); + + const expectActiveTabAndPanel = (name) => { + const tab = findTab(name); + const panel = findPanel(name); + + expect(glTabs.activeTab).toBe(tab); + + expect(getAttributes(tab)).toMatchObject({ + 'aria-controls': panel.id, + 'aria-selected': 'true', + role: 'tab', + id: expect.any(String), + }); + + ACTIVE_TAB_CLASSES.forEach((klass) => { + expect(tab.classList.contains(klass)).toBe(true); + }); + + expect(getAttributes(panel)).toMatchObject({ + 'aria-labelledby': tab.id, + role: 'tabpanel', + }); + + expect(panel.classList.contains(ACTIVE_PANEL_CLASS)).toBe(true); + }; + + const expectInactiveTabAndPanel = (name) => { + const tab = findTab(name); + const panel = findPanel(name); + + expect(glTabs.activeTab).not.toBe(tab); + + expect(getAttributes(tab)).toMatchObject({ + 'aria-controls': panel.id, + 'aria-selected': 'false', + role: 'tab', + tabindex: '-1', + id: expect.any(String), + }); + + ACTIVE_TAB_CLASSES.forEach((klass) => { + expect(tab.classList.contains(klass)).toBe(false); + }); + + expect(getAttributes(panel)).toMatchObject({ + 'aria-labelledby': tab.id, + role: 'tabpanel', + }); + + expect(panel.classList.contains(ACTIVE_PANEL_CLASS)).toBe(false); + }; + + const expectGlTabShownEvent = (name) => { + expect(tabShownEventSpy).toHaveBeenCalledTimes(1); + + const [event] = tabShownEventSpy.mock.calls[0]; + expect(event.target).toBe(findTab(name)); + + expect(event.detail).toEqual({ + activeTabPanel: findPanel(name), + }); + }; + + const triggerKeyDown = (code, element) => { + const event = new KeyboardEvent('keydown', { code }); + + element.dispatchEvent(event); + }; + + it('throws when instantiated without an element', () => { + expect(() => new GlTabsBehavior()).toThrow('Cannot instantiate'); + }); + + describe('when given an element', () => { + afterEach(() => { + glTabs.destroy(); + }); + + beforeEach(() => { + setHTMLFixture(tabsFixture); + + const tabsEl = findByTestId('tabs'); + tabShownEventSpy = jest.fn(); + tabsEl.addEventListener(TAB_SHOWN_EVENT, tabShownEventSpy); + + glTabs = new GlTabsBehavior(tabsEl); + }); + + it('instantiates', () => { + expect(glTabs).toEqual(expect.any(GlTabsBehavior)); + }); + + it('sets the active tab', () => { + expectActiveTabAndPanel('foo'); + }); + + it(`does not fire an initial ${TAB_SHOWN_EVENT} event`, () => { + expect(tabShownEventSpy).not.toHaveBeenCalled(); + }); + + describe('clicking on an inactive tab', () => { + beforeEach(() => { + findTab('bar').click(); + }); + + it('changes the active tab', () => { + expectActiveTabAndPanel('bar'); + }); + + it('deactivates the previously active tab', () => { + expectInactiveTabAndPanel('foo'); + }); + + it(`dispatches a ${TAB_SHOWN_EVENT} event`, () => { + expectGlTabShownEvent('bar'); + }); + }); + + describe('clicking on the active tab', () => { + beforeEach(() => { + findTab('foo').click(); + }); + + it('does nothing', () => { + expectActiveTabAndPanel('foo'); + expect(tabShownEventSpy).not.toHaveBeenCalled(); + }); + }); + + describe('keyboard navigation', () => { + it.each(['ArrowRight', 'ArrowDown'])('pressing %s moves to next tab', (code) => { + expectActiveTabAndPanel('foo'); + + triggerKeyDown(code, glTabs.activeTab); + + expectActiveTabAndPanel('bar'); + expectInactiveTabAndPanel('foo'); + expectGlTabShownEvent('bar'); + tabShownEventSpy.mockClear(); + + triggerKeyDown(code, glTabs.activeTab); + + expectActiveTabAndPanel('qux'); + expectInactiveTabAndPanel('bar'); + expectGlTabShownEvent('qux'); + tabShownEventSpy.mockClear(); + + // We're now on the last tab, so the active tab should not change + triggerKeyDown(code, glTabs.activeTab); + + expectActiveTabAndPanel('qux'); + expect(tabShownEventSpy).not.toHaveBeenCalled(); + }); + + it.each(['ArrowLeft', 'ArrowUp'])('pressing %s moves to previous tab', (code) => { + // First, make the last tab active + findTab('qux').click(); + tabShownEventSpy.mockClear(); + + // Now start moving backwards + expectActiveTabAndPanel('qux'); + + triggerKeyDown(code, glTabs.activeTab); + + expectActiveTabAndPanel('bar'); + expectInactiveTabAndPanel('qux'); + expectGlTabShownEvent('bar'); + tabShownEventSpy.mockClear(); + + triggerKeyDown(code, glTabs.activeTab); + + expectActiveTabAndPanel('foo'); + expectInactiveTabAndPanel('bar'); + expectGlTabShownEvent('foo'); + tabShownEventSpy.mockClear(); + + // We're now on the first tab, so the active tab should not change + triggerKeyDown(code, glTabs.activeTab); + + expectActiveTabAndPanel('foo'); + expect(tabShownEventSpy).not.toHaveBeenCalled(); + }); + }); + + describe('destroying', () => { + beforeEach(() => { + glTabs.destroy(); + }); + + it('removes interactivity', () => { + const inactiveTab = findTab('bar'); + + // clicks do nothing + inactiveTab.click(); + expectActiveTabAndPanel('foo'); + expect(tabShownEventSpy).not.toHaveBeenCalled(); + + // keydown events do nothing + triggerKeyDown('ArrowDown', inactiveTab); + expectActiveTabAndPanel('foo'); + expect(tabShownEventSpy).not.toHaveBeenCalled(); + }); + }); + + describe('activateTab method', () => { + it.each` + tabState | name + ${'active'} | ${'foo'} + ${'inactive'} | ${'bar'} + `('can programmatically activate an $tabState tab', ({ name }) => { + glTabs.activateTab(findTab(name)); + expectActiveTabAndPanel(name); + expectGlTabShownEvent(name, 'foo'); + }); + }); + }); + + describe('using aria-controls instead of href to link tabs to panels', () => { + beforeEach(() => { + setHTMLFixture(tabsFixture); + + const tabsEl = findByTestId('tabs'); + ['foo', 'bar', 'qux'].forEach((name) => { + const tab = findTab(name); + const panel = findPanel(name); + + tab.setAttribute('href', '#'); + tab.setAttribute('aria-controls', panel.id); + }); + + glTabs = new GlTabsBehavior(tabsEl); + }); + + it('connects the panels to their tabs correctly', () => { + findTab('bar').click(); + + expectActiveTabAndPanel('bar'); + expectInactiveTabAndPanel('foo'); + }); + }); +}); diff --git a/spec/frontend/terraform/components/terraform_list_spec.js b/spec/frontend/terraform/components/terraform_list_spec.js index c622f86072d..8e565df81ae 100644 --- a/spec/frontend/terraform/components/terraform_list_spec.js +++ b/spec/frontend/terraform/components/terraform_list_spec.js @@ -23,6 +23,7 @@ describe('TerraformList', () => { const apolloQueryResponse = { data: { project: { + id: '1', terraformStates, }, }, diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js index 40f68c6385f..4fe51db8412 100644 --- a/spec/frontend/test_setup.js +++ b/spec/frontend/test_setup.js @@ -1,30 +1,10 @@ -import { config as testUtilsConfig } from '@vue/test-utils'; -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 { loadHTMLFixture, setHTMLFixture } from './__helpers__/fixtures'; -import { initializeTestTimeout } from './__helpers__/timeout'; -import customMatchers from './matchers'; -import { setupManualMocks } from './mocks/mocks_helper'; +/* Setup for unit test environment */ +import 'helpers/shared_test_setup'; +import { initializeTestTimeout } from 'helpers/timeout'; -import './__helpers__/dom_shims'; -import './__helpers__/jquery'; -import '~/commons/bootstrap'; +jest.mock('~/lib/utils/axios_utils', () => jest.requireActual('helpers/mocks/axios_utils')); -// This module has some fairly decent visual test coverage in it's own repository. -jest.mock('@gitlab/favicon-overlay'); - -process.on('unhandledRejection', global.promiseRejectionHandler); - -setupManualMocks(); - -// Fake the `Date` for the rest of the jest spec runtime environment. -// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39496#note_503084332 -setGlobalDateToFakeDate(); +initializeTestTimeout(process.env.CI ? 6000 : 500); afterEach(() => // give Promises a bit more time so they fail the right test @@ -33,71 +13,3 @@ afterEach(() => jest.runOnlyPendingTimers(); }), ); - -initializeTestTimeout(process.env.CI ? 6000 : 500); - -Vue.config.devtools = false; -Vue.config.productionTip = false; - -Vue.use(Translate); - -// convenience wrapper for migration from Karma -Object.assign(global, { - loadFixtures: loadHTMLFixture, - setFixtures: setHTMLFixture, -}); - -const JQUERY_MATCHERS_TO_EXCLUDE = ['toHaveLength', 'toExist']; - -// custom-jquery-matchers was written for an old Jest version, we need to make it compatible -Object.entries(jqueryMatchers).forEach(([matcherName, matcherFactory]) => { - // Exclude these jQuery matchers - if (JQUERY_MATCHERS_TO_EXCLUDE.includes(matcherName)) { - return; - } - - expect.extend({ - [matcherName]: matcherFactory().compare, - }); -}); - -expect.extend(customMatchers); - -testUtilsConfig.deprecationWarningHandler = (method, message) => { - const ALLOWED_DEPRECATED_METHODS = [ - // https://gitlab.com/gitlab-org/gitlab/-/issues/295679 - 'finding components with `find` or `get`', - - // https://gitlab.com/gitlab-org/gitlab/-/issues/295680 - 'finding components with `findAll`', - ]; - if (!ALLOWED_DEPRECATED_METHODS.includes(method)) { - global.console.error(message); - } -}; - -Object.assign(global, { - requestIdleCallback(cb) { - const start = Date.now(); - return setTimeout(() => { - cb({ - didTimeout: false, - timeRemaining: () => Math.max(0, 50 - (Date.now() - start)), - }); - }); - }, - cancelIdleCallback(id) { - clearTimeout(id); - }, -}); - -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/mock_data.js b/spec/frontend/token_access/mock_data.js index 14d7b00cb6d..0f121fd1beb 100644 --- a/spec/frontend/token_access/mock_data.js +++ b/spec/frontend/token_access/mock_data.js @@ -1,6 +1,7 @@ export const enabledJobTokenScope = { data: { project: { + id: '1', ciCdSettings: { jobTokenScopeEnabled: true, __typename: 'ProjectCiCdSetting', @@ -13,6 +14,7 @@ export const enabledJobTokenScope = { export const disabledJobTokenScope = { data: { project: { + id: '1', ciCdSettings: { jobTokenScopeEnabled: false, __typename: 'ProjectCiCdSetting', @@ -39,12 +41,14 @@ export const projectsWithScope = { data: { project: { __typename: 'Project', + id: '1', ciJobTokenScope: { __typename: 'CiJobTokenScopeType', projects: { __typename: 'ProjectConnection', nodes: [ { + id: '2', fullPath: 'root/332268-test', name: 'root/332268-test', }, @@ -75,10 +79,17 @@ export const removeProjectSuccess = { export const mockProjects = [ { + id: '1', name: 'merge-train-stuff', fullPath: 'root/merge-train-stuff', isLocked: false, __typename: 'Project', }, - { name: 'ci-project', fullPath: 'root/ci-project', isLocked: true, __typename: 'Project' }, + { + id: '2', + name: 'ci-project', + fullPath: 'root/ci-project', + isLocked: true, + __typename: 'Project', + }, ]; diff --git a/spec/frontend/transfer_edit_spec.js b/spec/frontend/transfer_edit_spec.js index ad8c9c68f37..4091d753fe5 100644 --- a/spec/frontend/transfer_edit_spec.js +++ b/spec/frontend/transfer_edit_spec.js @@ -4,11 +4,11 @@ import { loadHTMLFixture } from 'helpers/fixtures'; import setupTransferEdit from '~/transfer_edit'; describe('setupTransferEdit', () => { - const formSelector = '.js-project-transfer-form'; - const targetSelector = 'select.select2'; + const formSelector = '.js-group-transfer-form'; + const targetSelector = '#new_parent_group_id'; beforeEach(() => { - loadHTMLFixture('projects/edit.html'); + loadHTMLFixture('groups/edit.html'); setupTransferEdit(formSelector, targetSelector); }); @@ -17,8 +17,8 @@ describe('setupTransferEdit', () => { }); it('enables submit button when selection changes to non-empty value', () => { - const nonEmptyValue = $(formSelector).find(targetSelector).find('option').not(':empty').val(); - $(formSelector).find(targetSelector).val(nonEmptyValue).trigger('change'); + const lastValue = $(formSelector).find(targetSelector).find('.dropdown-content li').last(); + $(formSelector).find(targetSelector).val(lastValue).trigger('change'); expect($(formSelector).find(':submit').prop('disabled')).toBeFalsy(); }); diff --git a/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js b/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js new file mode 100644 index 00000000000..64e802c4fa5 --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js @@ -0,0 +1,18 @@ +import { generateText } from '~/vue_merge_request_widget/components/extensions/utils'; + +describe('generateText', () => { + it.each` + text | expectedText + ${'%{strong_start}Hello world%{strong_end}'} | ${'<span class="gl-font-weight-bold">Hello world</span>'} + ${'%{success_start}Hello world%{success_end}'} | ${'<span class="gl-font-weight-bold gl-text-green-500">Hello world</span>'} + ${'%{danger_start}Hello world%{danger_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-500">Hello world</span>'} + ${'%{critical_start}Hello world%{critical_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-800">Hello world</span>'} + ${'%{same_start}Hello world%{same_end}'} | ${'<span class="gl-font-weight-bold gl-text-gray-700">Hello world</span>'} + ${'%{small_start}Hello world%{small_end}'} | ${'<span class="gl-font-sm">Hello world</span>'} + ${'%{strong_start}%{danger_start}Hello world%{danger_end}%{strong_end}'} | ${'<span class="gl-font-weight-bold"><span class="gl-font-weight-bold gl-text-red-500">Hello world</span></span>'} + ${'%{no_exist_start}Hello world%{no_exist_end}'} | ${'Hello world'} + ${['array']} | ${null} + `('generates $expectedText from $text', ({ text, expectedText }) => { + expect(generateText(text)).toBe(expectedText); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js b/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js index f965fc32dc1..c30f6f1dfd1 100644 --- a/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js @@ -3,7 +3,6 @@ import CommitEdit from '~/vue_merge_request_widget/components/states/commit_edit const testCommitMessage = 'Test commit message'; const testLabel = 'Test label'; -const testTextMuted = 'Test text muted'; const testInputId = 'test-input-id'; describe('Commits edit component', () => { @@ -64,7 +63,6 @@ describe('Commits edit component', () => { beforeEach(() => { createComponent({ header: `<div class="test-header">${testCommitMessage}</div>`, - 'text-muted': `<p class="test-text-muted">${testTextMuted}</p>`, }); }); @@ -74,12 +72,5 @@ describe('Commits edit component', () => { expect(headerSlotElement.exists()).toBe(true); expect(headerSlotElement.text()).toBe(testCommitMessage); }); - - it('renders text-muted slot correctly', () => { - const textMutedElement = wrapper.find('.test-text-muted'); - - expect(textMutedElement.exists()).toBe(true); - expect(textMutedElement.text()).toBe(testTextMuted); - }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_archived_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_archived_spec.js index 4bdc6c95f22..f3061d792d0 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_archived_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_archived_spec.js @@ -25,7 +25,7 @@ describe('MRWidgetArchived', () => { it('renders information', () => { expect(vm.$el.querySelector('.bold').textContent.trim()).toEqual( - 'This project is archived, write access has been disabled', + 'Merge unavailable: merge requests are read-only on archived projects.', ); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js index e1bce7f0474..89de160b02f 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js @@ -12,6 +12,14 @@ describe('MRWidgetConflicts', () => { const findResolveButton = () => wrapper.findByTestId('resolve-conflicts-button'); const findMergeLocalButton = () => wrapper.findByTestId('merge-locally-button'); + const mergeConflictsText = 'Merge blocked: merge conflicts must be resolved.'; + const fastForwardMergeText = + 'Merge blocked: fast-forward merge is not possible. To merge this request, first rebase locally.'; + const userCannotMergeText = + 'Users who can write to the source or target branches can resolve the conflicts.'; + const resolveConflictsBtnText = 'Resolve conflicts'; + const mergeLocallyBtnText = 'Merge locally'; + function createComponent(propsData = {}) { wrapper = extendedWrapper( shallowMount(ConflictsComponent, { @@ -81,16 +89,16 @@ describe('MRWidgetConflicts', () => { }); it('should tell you about conflicts without bothering other people', () => { - expect(wrapper.text()).toContain('There are merge conflicts'); - expect(wrapper.text()).not.toContain('ask someone with write access'); + expect(wrapper.text()).toContain(mergeConflictsText); + expect(wrapper.text()).not.toContain(userCannotMergeText); }); it('should not allow you to resolve the conflicts', () => { - expect(wrapper.text()).not.toContain('Resolve conflicts'); + expect(wrapper.text()).not.toContain(resolveConflictsBtnText); }); it('should have merge buttons', () => { - expect(findMergeLocalButton().text()).toContain('Merge locally'); + expect(findMergeLocalButton().text()).toContain(mergeLocallyBtnText); }); }); @@ -107,17 +115,17 @@ describe('MRWidgetConflicts', () => { }); it('should tell you about conflicts', () => { - expect(wrapper.text()).toContain('There are merge conflicts'); - expect(wrapper.text()).toContain('ask someone with write access'); + expect(wrapper.text()).toContain(mergeConflictsText); + expect(wrapper.text()).toContain(userCannotMergeText); }); it('should allow you to resolve the conflicts', () => { - expect(findResolveButton().text()).toContain('Resolve conflicts'); + expect(findResolveButton().text()).toContain(resolveConflictsBtnText); expect(findResolveButton().attributes('href')).toEqual(path); }); it('should not have merge buttons', () => { - expect(wrapper.text()).not.toContain('Merge locally'); + expect(wrapper.text()).not.toContain(mergeLocallyBtnText); }); }); @@ -134,17 +142,17 @@ describe('MRWidgetConflicts', () => { }); it('should tell you about conflicts without bothering other people', () => { - expect(wrapper.text()).toContain('There are merge conflicts'); - expect(wrapper.text()).not.toContain('ask someone with write access'); + expect(wrapper.text()).toContain(mergeConflictsText); + expect(wrapper.text()).not.toContain(userCannotMergeText); }); it('should allow you to resolve the conflicts', () => { - expect(findResolveButton().text()).toContain('Resolve conflicts'); + expect(findResolveButton().text()).toContain(resolveConflictsBtnText); expect(findResolveButton().attributes('href')).toEqual(path); }); it('should have merge buttons', () => { - expect(findMergeLocalButton().text()).toContain('Merge locally'); + expect(findMergeLocalButton().text()).toContain(mergeLocallyBtnText); }); }); @@ -158,9 +166,7 @@ describe('MRWidgetConflicts', () => { }, }); - expect(wrapper.text().trim().replace(/\s\s+/g, ' ')).toContain( - 'ask someone with write access', - ); + expect(wrapper.text().trim().replace(/\s\s+/g, ' ')).toContain(userCannotMergeText); }); it('should not have action buttons', async () => { @@ -198,9 +204,7 @@ describe('MRWidgetConflicts', () => { }, }); - expect(removeBreakLine(wrapper.text()).trim()).toContain( - 'Merge blocked: fast-forward merge is not possible. To merge this request, first rebase locally.', - ); + expect(removeBreakLine(wrapper.text()).trim()).toContain(fastForwardMergeText); }); }); @@ -236,7 +240,7 @@ describe('MRWidgetConflicts', () => { }); it('should allow you to resolve the conflicts', () => { - expect(findResolveButton().text()).toContain('Resolve conflicts'); + expect(findResolveButton().text()).toContain(resolveConflictsBtnText); expect(findResolveButton().attributes('href')).toEqual(TEST_HOST); }); }); 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 016b6b2220b..7082a19a8e7 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,5 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; +import { GlSprintf } from '@gitlab/ui'; import simplePoll from '~/lib/utils/simple_poll'; import CommitEdit from '~/vue_merge_request_widget/components/states/commit_edit.vue'; import CommitMessageDropdown from '~/vue_merge_request_widget/components/states/commit_message_dropdown.vue'; @@ -487,6 +488,7 @@ describe('ReadyToMerge', () => { const findCommitEditElements = () => wrapper.findAll(CommitEdit); const findCommitDropdownElement = () => wrapper.find(CommitMessageDropdown); const findFirstCommitEditLabel = () => findCommitEditElements().at(0).props('label'); + const findTipLink = () => wrapper.find(GlSprintf); describe('squash checkbox', () => { it('should be rendered when squash before merge is enabled and there is more than 1 commit', () => { @@ -503,10 +505,10 @@ describe('ReadyToMerge', () => { expect(findCheckboxElement().exists()).toBeFalsy(); }); - it('should not be rendered when there is only 1 commit', () => { + it('should be rendered when there is only 1 commit', () => { createComponent({ mr: { commitsCount: 1, enableSquashBeforeMerge: true } }); - expect(findCheckboxElement().exists()).toBeFalsy(); + expect(findCheckboxElement().exists()).toBe(true); }); describe('squash options', () => { @@ -751,6 +753,12 @@ describe('ReadyToMerge', () => { expect(findCommitDropdownElement().exists()).toBeTruthy(); }); }); + + it('renders a tip including a link to docs on templates', () => { + createComponent(); + + expect(findTipLink().exists()).toBe(true); + }); }); describe('Merge request project settings', () => { diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js index 0fb0d5b0b68..4070ca8d8dc 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js @@ -81,7 +81,9 @@ describe('Wip', () => { it('should have correct elements', () => { expect(el.classList.contains('mr-widget-body')).toBeTruthy(); - expect(el.innerText).toContain('This merge request is still a draft.'); + expect(el.innerText).toContain( + "Merge blocked: merge request must be marked as ready. It's still marked as draft.", + ); expect(el.querySelector('button').getAttribute('disabled')).toBeTruthy(); expect(el.querySelector('button').innerText).toContain('Merge'); expect(el.querySelector('.js-remove-draft').innerText.replace(/\s\s+/g, ' ')).toContain( diff --git a/spec/frontend/vue_mr_widget/components/terraform/terraform_plan_spec.js b/spec/frontend/vue_mr_widget/components/terraform/terraform_plan_spec.js index f95a92c2cb1..3c9f6c2e165 100644 --- a/spec/frontend/vue_mr_widget/components/terraform/terraform_plan_spec.js +++ b/spec/frontend/vue_mr_widget/components/terraform/terraform_plan_spec.js @@ -32,9 +32,7 @@ describe('TerraformPlan', () => { }); it('diplays the header text with a name', () => { - expect(wrapper.text()).toContain( - `The report ${validPlanWithName.job_name} was generated in your pipelines.`, - ); + expect(wrapper.text()).toContain(`The job ${validPlanWithName.job_name} generated a report.`); }); it('diplays the reported changes', () => { @@ -70,7 +68,7 @@ describe('TerraformPlan', () => { it('diplays the header text with a name', () => { expect(wrapper.text()).toContain( - `The report ${invalidPlanWithName.job_name} failed to generate.`, + `The job ${invalidPlanWithName.job_name} failed to generate a report.`, ); }); diff --git a/spec/frontend/vue_mr_widget/mock_data.js b/spec/frontend/vue_mr_widget/mock_data.js index f0c1da346a1..4538c1320d0 100644 --- a/spec/frontend/vue_mr_widget/mock_data.js +++ b/spec/frontend/vue_mr_widget/mock_data.js @@ -271,8 +271,6 @@ export default { mr_troubleshooting_docs_path: 'help', ci_troubleshooting_docs_path: 'help2', merge_request_pipelines_docs_path: '/help/ci/pipelines/merge_request_pipelines.md', - merge_train_when_pipeline_succeeds_docs_path: - '/help/ci/pipelines/merge_trains.md#startadd-to-merge-train-when-pipeline-succeeds', squash: true, visual_review_app_available: true, merge_trains_enabled: true, 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 550f156d095..8d41f6620ff 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; +import * as Sentry from '@sentry/browser'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { securityReportMergeRequestDownloadPathsQueryResponse } from 'jest/vue_shared/security_reports/mock_data'; @@ -19,10 +20,15 @@ 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 StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; 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'; -import testExtension from './test_extension'; +import { + workingExtension, + collapsedDataErrorExtension, + fullDataErrorExtension, +} from './test_extensions'; jest.mock('~/api.js'); @@ -892,7 +898,7 @@ describe('MrWidgetOptions', () => { describe('mock extension', () => { beforeEach(() => { - registerExtension(testExtension); + registerExtension(workingExtension); createComponent(); }); @@ -914,7 +920,7 @@ describe('MrWidgetOptions', () => { .find('[data-testid="widget-extension"] [data-testid="toggle-button"]') .trigger('click'); - await Vue.nextTick(); + await nextTick(); expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith('test_expand_event'); }); @@ -926,7 +932,7 @@ describe('MrWidgetOptions', () => { .find('[data-testid="widget-extension"] [data-testid="toggle-button"]') .trigger('click'); - await Vue.nextTick(); + await nextTick(); expect( wrapper.find('[data-testid="widget-extension-top-level"]').find(GlDropdown).exists(), @@ -952,4 +958,50 @@ describe('MrWidgetOptions', () => { expect(collapsedSection.find(GlButton).text()).toBe('Full report'); }); }); + + describe('mock extension errors', () => { + let captureException; + + const itHandlesTheException = () => { + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith(new Error('Fetch error')); + expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('error'); + }; + + beforeEach(() => { + captureException = jest.spyOn(Sentry, 'captureException'); + }); + + afterEach(() => { + registeredExtensions.extensions = []; + captureException = null; + }); + + it('handles collapsed data fetch errors', async () => { + registerExtension(collapsedDataErrorExtension); + createComponent(); + await waitForPromises(); + + expect( + wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]').exists(), + ).toBe(false); + itHandlesTheException(); + }); + + it('handles full data fetch errors', async () => { + registerExtension(fullDataErrorExtension); + createComponent(); + await waitForPromises(); + + expect(wrapper.findComponent(StatusIcon).props('iconName')).not.toBe('error'); + wrapper + .find('[data-testid="widget-extension"] [data-testid="toggle-button"]') + .trigger('click'); + + await nextTick(); + await waitForPromises(); + + itHandlesTheException(); + }); + }); }); diff --git a/spec/frontend/vue_mr_widget/test_extension.js b/spec/frontend/vue_mr_widget/test_extension.js deleted file mode 100644 index 65c1bd8473b..00000000000 --- a/spec/frontend/vue_mr_widget/test_extension.js +++ /dev/null @@ -1,39 +0,0 @@ -import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants'; - -export default { - name: 'WidgetTestExtension', - props: ['targetProjectFullPath'], - expandEvent: 'test_expand_event', - computed: { - summary({ count, targetProjectFullPath }) { - return `Test extension summary count: ${count} & ${targetProjectFullPath}`; - }, - statusIcon({ count }) { - return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success; - }, - }, - methods: { - fetchCollapsedData({ targetProjectFullPath }) { - return Promise.resolve({ targetProjectFullPath, count: 1 }); - }, - fetchFullData() { - return Promise.resolve([ - { - id: 1, - text: 'Hello world', - icon: { - name: EXTENSION_ICONS.failed, - }, - badge: { - text: 'Closed', - }, - link: { - href: 'https://gitlab.com', - text: 'GitLab.com', - }, - actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }], - }, - ]); - }, - }, -}; diff --git a/spec/frontend/vue_mr_widget/test_extensions.js b/spec/frontend/vue_mr_widget/test_extensions.js new file mode 100644 index 00000000000..c7ff02ab726 --- /dev/null +++ b/spec/frontend/vue_mr_widget/test_extensions.js @@ -0,0 +1,99 @@ +import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants'; + +export const workingExtension = { + name: 'WidgetTestExtension', + props: ['targetProjectFullPath'], + expandEvent: 'test_expand_event', + computed: { + summary({ count, targetProjectFullPath }) { + return `Test extension summary count: ${count} & ${targetProjectFullPath}`; + }, + statusIcon({ count }) { + return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success; + }, + }, + methods: { + fetchCollapsedData({ targetProjectFullPath }) { + return Promise.resolve({ targetProjectFullPath, count: 1 }); + }, + fetchFullData() { + return Promise.resolve([ + { + id: 1, + text: 'Hello world', + icon: { + name: EXTENSION_ICONS.failed, + }, + badge: { + text: 'Closed', + }, + link: { + href: 'https://gitlab.com', + text: 'GitLab.com', + }, + actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }], + }, + ]); + }, + }, +}; + +export const collapsedDataErrorExtension = { + name: 'WidgetTestCollapsedErrorExtension', + props: ['targetProjectFullPath'], + expandEvent: 'test_expand_event', + computed: { + summary({ count, targetProjectFullPath }) { + return `Test extension summary count: ${count} & ${targetProjectFullPath}`; + }, + statusIcon({ count }) { + return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success; + }, + }, + methods: { + fetchCollapsedData() { + return Promise.reject(new Error('Fetch error')); + }, + fetchFullData() { + return Promise.resolve([ + { + id: 1, + text: 'Hello world', + icon: { + name: EXTENSION_ICONS.failed, + }, + badge: { + text: 'Closed', + }, + link: { + href: 'https://gitlab.com', + text: 'GitLab.com', + }, + actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }], + }, + ]); + }, + }, +}; + +export const fullDataErrorExtension = { + name: 'WidgetTestCollapsedErrorExtension', + props: ['targetProjectFullPath'], + expandEvent: 'test_expand_event', + computed: { + summary({ count, targetProjectFullPath }) { + return `Test extension summary count: ${count} & ${targetProjectFullPath}`; + }, + statusIcon({ count }) { + return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success; + }, + }, + methods: { + fetchCollapsedData({ targetProjectFullPath }) { + return Promise.resolve({ targetProjectFullPath, count: 1 }); + }, + fetchFullData() { + return Promise.reject(new Error('Fetch error')); + }, + }, +}; diff --git a/spec/frontend/vue_shared/components/__snapshots__/source_editor_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/source_editor_spec.js.snap index 7ce155f6a5d..f414359fef2 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/source_editor_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/source_editor_spec.js.snap @@ -3,6 +3,7 @@ exports[`Source Editor component rendering matches the snapshot 1`] = ` <div data-editor-loading="" + data-qa-selector="source_editor_container" id="source-editor-snippet_777" > <pre diff --git a/spec/frontend/vue_shared/components/chronic_duration_input_spec.js b/spec/frontend/vue_shared/components/chronic_duration_input_spec.js new file mode 100644 index 00000000000..530d01402c6 --- /dev/null +++ b/spec/frontend/vue_shared/components/chronic_duration_input_spec.js @@ -0,0 +1,390 @@ +import { mount } from '@vue/test-utils'; +import ChronicDurationInput from '~/vue_shared/components/chronic_duration_input.vue'; + +const MOCK_VALUE = 2 * 3600 + 20 * 60; + +describe('vue_shared/components/chronic_duration_input', () => { + let wrapper; + let textElement; + let hiddenElement; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + textElement = null; + hiddenElement = null; + }); + + const findComponents = () => { + textElement = wrapper.find('input[type=text]').element; + hiddenElement = wrapper.find('input[type=hidden]').element; + }; + + const createComponent = (props = {}) => { + if (wrapper) { + throw new Error('There should only be one wrapper created per test'); + } + + wrapper = mount(ChronicDurationInput, { propsData: props }); + findComponents(); + }; + + describe('value', () => { + it('has human-readable output with value', () => { + createComponent({ value: MOCK_VALUE }); + + expect(textElement.value).toBe('2 hrs 20 mins'); + expect(hiddenElement.value).toBe(MOCK_VALUE.toString()); + }); + + it('has empty output with no value', () => { + createComponent({ value: null }); + + expect(textElement.value).toBe(''); + expect(hiddenElement.value).toBe(''); + }); + }); + + describe('change', () => { + const createAndDispatch = async (initialValue, humanReadableInput) => { + createComponent({ value: initialValue }); + await wrapper.vm.$nextTick(); + textElement.value = humanReadableInput; + textElement.dispatchEvent(new Event('input')); + }; + + describe('when starting with no value and receiving human-readable input', () => { + beforeEach(() => { + createAndDispatch(null, '2hr20min'); + }); + + it('updates hidden field', () => { + expect(textElement.value).toBe('2hr20min'); + expect(hiddenElement.value).toBe(MOCK_VALUE.toString()); + }); + + it('emits change event', () => { + expect(wrapper.emitted('change')).toEqual([[MOCK_VALUE]]); + }); + }); + + describe('when starting with a value and receiving empty input', () => { + beforeEach(() => { + createAndDispatch(MOCK_VALUE, ''); + }); + + it('updates hidden field', () => { + expect(textElement.value).toBe(''); + expect(hiddenElement.value).toBe(''); + }); + + it('emits change event', () => { + expect(wrapper.emitted('change')).toEqual([[null]]); + }); + }); + + describe('when starting with a value and receiving invalid input', () => { + beforeEach(() => { + createAndDispatch(MOCK_VALUE, 'gobbledygook'); + }); + + it('does not update hidden field', () => { + expect(textElement.value).toBe('gobbledygook'); + expect(hiddenElement.value).toBe(MOCK_VALUE.toString()); + }); + + it('does not emit change event', () => { + expect(wrapper.emitted('change')).toBeUndefined(); + }); + }); + }); + + describe('valid', () => { + describe('initial value', () => { + beforeEach(() => { + createComponent({ value: MOCK_VALUE }); + }); + + it('emits valid with initial value', () => { + expect(wrapper.emitted('valid')).toEqual([[{ valid: true, feedback: '' }]]); + expect(textElement.validity.valid).toBe(true); + expect(textElement.validity.customError).toBe(false); + expect(textElement.validationMessage).toBe(''); + expect(hiddenElement.validity.valid).toBe(true); + expect(hiddenElement.validity.customError).toBe(false); + expect(hiddenElement.validationMessage).toBe(''); + }); + + it('emits valid with user input', async () => { + textElement.value = '1m10s'; + textElement.dispatchEvent(new Event('input')); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('valid')).toEqual([ + [{ valid: true, feedback: '' }], + [{ valid: true, feedback: '' }], + ]); + expect(textElement.validity.valid).toBe(true); + expect(textElement.validity.customError).toBe(false); + expect(textElement.validationMessage).toBe(''); + expect(hiddenElement.validity.valid).toBe(true); + expect(hiddenElement.validity.customError).toBe(false); + expect(hiddenElement.validationMessage).toBe(''); + + textElement.value = ''; + textElement.dispatchEvent(new Event('input')); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('valid')).toEqual([ + [{ valid: true, feedback: '' }], + [{ valid: true, feedback: '' }], + [{ valid: null, feedback: '' }], + ]); + expect(textElement.validity.valid).toBe(true); + expect(textElement.validity.customError).toBe(false); + expect(textElement.validationMessage).toBe(''); + expect(hiddenElement.validity.valid).toBe(true); + expect(hiddenElement.validity.customError).toBe(false); + expect(hiddenElement.validationMessage).toBe(''); + }); + + it('emits invalid with user input', async () => { + textElement.value = 'gobbledygook'; + textElement.dispatchEvent(new Event('input')); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('valid')).toEqual([ + [{ valid: true, feedback: '' }], + [{ valid: false, feedback: ChronicDurationInput.i18n.INVALID_INPUT_FEEDBACK }], + ]); + expect(textElement.validity.valid).toBe(false); + expect(textElement.validity.customError).toBe(true); + expect(textElement.validationMessage).toBe( + ChronicDurationInput.i18n.INVALID_INPUT_FEEDBACK, + ); + expect(hiddenElement.validity.valid).toBe(false); + expect(hiddenElement.validity.customError).toBe(true); + // Hidden elements do not have validationMessage + expect(hiddenElement.validationMessage).toBe(''); + }); + }); + + describe('no initial value', () => { + beforeEach(() => { + createComponent({ value: null }); + }); + + it('emits valid with no initial value', () => { + expect(wrapper.emitted('valid')).toEqual([[{ valid: null, feedback: '' }]]); + expect(textElement.validity.valid).toBe(true); + expect(textElement.validity.customError).toBe(false); + expect(textElement.validationMessage).toBe(''); + expect(hiddenElement.validity.valid).toBe(true); + expect(hiddenElement.validity.customError).toBe(false); + expect(hiddenElement.validationMessage).toBe(''); + }); + + it('emits valid with updated value', async () => { + wrapper.setProps({ value: MOCK_VALUE }); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('valid')).toEqual([ + [{ valid: null, feedback: '' }], + [{ valid: true, feedback: '' }], + ]); + expect(textElement.validity.valid).toBe(true); + expect(textElement.validity.customError).toBe(false); + expect(textElement.validationMessage).toBe(''); + expect(hiddenElement.validity.valid).toBe(true); + expect(hiddenElement.validity.customError).toBe(false); + expect(hiddenElement.validationMessage).toBe(''); + }); + }); + + describe('decimal input', () => { + describe('when integerRequired is false', () => { + beforeEach(() => { + createComponent({ value: null, integerRequired: false }); + }); + + it('emits valid when input is integer', async () => { + textElement.value = '2hr20min'; + textElement.dispatchEvent(new Event('input')); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('change')).toEqual([[MOCK_VALUE]]); + expect(wrapper.emitted('valid')).toEqual([ + [{ valid: null, feedback: '' }], + [{ valid: true, feedback: '' }], + ]); + expect(textElement.validity.valid).toBe(true); + expect(textElement.validity.customError).toBe(false); + expect(textElement.validationMessage).toBe(''); + expect(hiddenElement.validity.valid).toBe(true); + expect(hiddenElement.validity.customError).toBe(false); + expect(hiddenElement.validationMessage).toBe(''); + }); + + it('emits valid when input is decimal', async () => { + textElement.value = '1.5s'; + textElement.dispatchEvent(new Event('input')); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('change')).toEqual([[1.5]]); + expect(wrapper.emitted('valid')).toEqual([ + [{ valid: null, feedback: '' }], + [{ valid: true, feedback: '' }], + ]); + expect(textElement.validity.valid).toBe(true); + expect(textElement.validity.customError).toBe(false); + expect(textElement.validationMessage).toBe(''); + expect(hiddenElement.validity.valid).toBe(true); + expect(hiddenElement.validity.customError).toBe(false); + expect(hiddenElement.validationMessage).toBe(''); + }); + }); + + describe('when integerRequired is unspecified', () => { + beforeEach(() => { + createComponent({ value: null }); + }); + + it('emits valid when input is integer', async () => { + textElement.value = '2hr20min'; + textElement.dispatchEvent(new Event('input')); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('change')).toEqual([[MOCK_VALUE]]); + expect(wrapper.emitted('valid')).toEqual([ + [{ valid: null, feedback: '' }], + [{ valid: true, feedback: '' }], + ]); + expect(textElement.validity.valid).toBe(true); + expect(textElement.validity.customError).toBe(false); + expect(textElement.validationMessage).toBe(''); + expect(hiddenElement.validity.valid).toBe(true); + expect(hiddenElement.validity.customError).toBe(false); + expect(hiddenElement.validationMessage).toBe(''); + }); + + it('emits invalid when input is decimal', async () => { + textElement.value = '1.5s'; + textElement.dispatchEvent(new Event('input')); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('change')).toBeUndefined(); + expect(wrapper.emitted('valid')).toEqual([ + [{ valid: null, feedback: '' }], + [ + { + valid: false, + feedback: ChronicDurationInput.i18n.INVALID_DECIMAL_FEEDBACK, + }, + ], + ]); + expect(textElement.validity.valid).toBe(false); + expect(textElement.validity.customError).toBe(true); + expect(textElement.validationMessage).toBe( + ChronicDurationInput.i18n.INVALID_DECIMAL_FEEDBACK, + ); + expect(hiddenElement.validity.valid).toBe(false); + expect(hiddenElement.validity.customError).toBe(true); + // Hidden elements do not have validationMessage + expect(hiddenElement.validationMessage).toBe(''); + }); + }); + }); + }); + + describe('v-model', () => { + beforeEach(() => { + wrapper = mount({ + data() { + return { value: 1 * 60 + 10 }; + }, + components: { ChronicDurationInput }, + template: '<div><chronic-duration-input v-model="value"/></div>', + }); + findComponents(); + }); + + describe('value', () => { + it('passes initial prop via v-model', () => { + expect(textElement.value).toBe('1 min 10 secs'); + expect(hiddenElement.value).toBe((1 * 60 + 10).toString()); + }); + + it('passes updated prop via v-model', async () => { + wrapper.setData({ value: MOCK_VALUE }); + await wrapper.vm.$nextTick(); + + expect(textElement.value).toBe('2 hrs 20 mins'); + expect(hiddenElement.value).toBe(MOCK_VALUE.toString()); + }); + }); + + describe('change', () => { + it('passes user input to parent via v-model', async () => { + textElement.value = '2hr20min'; + textElement.dispatchEvent(new Event('input')); + await wrapper.vm.$nextTick(); + + expect(wrapper.findComponent(ChronicDurationInput).props('value')).toBe(MOCK_VALUE); + expect(textElement.value).toBe('2hr20min'); + expect(hiddenElement.value).toBe(MOCK_VALUE.toString()); + }); + }); + }); + + describe('name', () => { + beforeEach(() => { + createComponent({ name: 'myInput' }); + }); + + it('sets name of hidden field', () => { + expect(hiddenElement.name).toBe('myInput'); + }); + + it('does not set name of text field', () => { + expect(textElement.name).toBe(''); + }); + }); + + describe('form submission', () => { + beforeEach(() => { + wrapper = mount({ + template: `<form data-testid="myForm"><chronic-duration-input name="myInput" :value="${MOCK_VALUE}"/></form>`, + components: { + ChronicDurationInput, + }, + }); + findComponents(); + }); + + it('creates form data with initial value', () => { + const formData = new FormData(wrapper.find('[data-testid=myForm]').element); + const iter = formData.entries(); + + expect(iter.next()).toEqual({ + value: ['myInput', MOCK_VALUE.toString()], + done: false, + }); + expect(iter.next()).toEqual({ value: undefined, done: true }); + }); + + it('creates form data with user-specified value', async () => { + textElement.value = '1m10s'; + textElement.dispatchEvent(new Event('input')); + await wrapper.vm.$nextTick(); + + const formData = new FormData(wrapper.find('[data-testid=myForm]').element); + const iter = formData.entries(); + + expect(iter.next()).toEqual({ + value: ['myInput', (1 * 60 + 10).toString()], + done: false, + }); + expect(iter.next()).toEqual({ value: undefined, done: true }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/clipboard_button_spec.js b/spec/frontend/vue_shared/components/clipboard_button_spec.js index ab4008484e5..33445923a49 100644 --- a/spec/frontend/vue_shared/components/clipboard_button_spec.js +++ b/spec/frontend/vue_shared/components/clipboard_button_spec.js @@ -89,6 +89,16 @@ describe('clipboard button', () => { expect(onClick).toHaveBeenCalled(); }); + it('passes the category and variant props to the GlButton', () => { + const category = 'tertiary'; + const variant = 'confirm'; + + createWrapper({ title: '', text: '', category, variant }); + + expect(findButton().props('category')).toBe(category); + expect(findButton().props('variant')).toBe(variant); + }); + describe('integration', () => { it('actually copies to clipboard', () => { initCopyToClipboard(); diff --git a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js index 220f897c035..af7f85769aa 100644 --- a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js +++ b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js @@ -9,6 +9,7 @@ describe('Confirm Danger Modal', () => { const phrase = 'En Taro Adun'; const buttonText = 'Click me!'; + const buttonClass = 'gl-w-full'; const modalId = CONFIRM_DANGER_MODAL_ID; const findBtn = () => wrapper.findComponent(GlButton); @@ -19,6 +20,7 @@ describe('Confirm Danger Modal', () => { shallowMountExtended(ConfirmDanger, { propsData: { buttonText, + buttonClass, phrase, ...props, }, @@ -51,6 +53,10 @@ describe('Confirm Danger Modal', () => { expect(findBtn().attributes('disabled')).toBe('true'); }); + it('passes `buttonClass` prop to button', () => { + expect(findBtn().classes()).toContain(buttonClass); + }); + it('will emit `confirm` when the modal confirms', () => { expect(wrapper.emitted('confirm')).toBeUndefined(); diff --git a/spec/frontend/vue_shared/components/confirm_modal_spec.js b/spec/frontend/vue_shared/components/confirm_modal_spec.js index db8d0674121..3ca1c943398 100644 --- a/spec/frontend/vue_shared/components/confirm_modal_spec.js +++ b/spec/frontend/vue_shared/components/confirm_modal_spec.js @@ -1,6 +1,9 @@ import { shallowMount } from '@vue/test-utils'; +import { merge } from 'lodash'; import { TEST_HOST } from 'helpers/test_constants'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import ConfirmModal from '~/vue_shared/components/confirm_modal.vue'; +import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; jest.mock('~/lib/utils/csrf', () => ({ token: 'test-csrf-token' })); @@ -54,12 +57,50 @@ describe('vue_shared/components/confirm_modal', () => { findForm() .findAll('input') .wrappers.map((x) => ({ name: x.attributes('name'), value: x.attributes('value') })); + const findDomElementListener = () => wrapper.find(DomElementListener); + const triggerOpenWithEventHub = (modalData) => { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, modalData); + }; + const triggerOpenWithDomListener = (modalData) => { + const element = document.createElement('button'); + + element.dataset.path = modalData.path; + element.dataset.method = modalData.method; + element.dataset.modalAttributes = JSON.stringify(modalData.modalAttributes); + + findDomElementListener().vm.$emit('click', { + preventDefault: jest.fn(), + currentTarget: element, + }); + }; + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders empty GlModal', () => { + expect(findModal().props()).toEqual({}); + }); + + it('renders form missing values', () => { + expect(findForm().attributes('action')).toBe(''); + expect(findFormData()).toEqual([ + { name: '_method', value: undefined }, + { name: 'authenticity_token', value: 'test-csrf-token' }, + ]); + }); + }); describe('template', () => { - describe('when modal data is set', () => { + describe.each` + desc | trigger + ${'when opened from eventhub'} | ${triggerOpenWithEventHub} + ${'when opened from dom listener'} | ${triggerOpenWithDomListener} + `('$desc', ({ trigger }) => { beforeEach(() => { createComponent(); - wrapper.vm.modalAttributes = MOCK_MODAL_DATA.modalAttributes; + trigger(MOCK_MODAL_DATA); }); it('renders GlModal with data', () => { @@ -71,6 +112,14 @@ describe('vue_shared/components/confirm_modal', () => { }), ); }); + + it('renders form', () => { + expect(findForm().attributes('action')).toBe(MOCK_MODAL_DATA.path); + expect(findFormData()).toEqual([ + { name: '_method', value: MOCK_MODAL_DATA.method }, + { name: 'authenticity_token', value: 'test-csrf-token' }, + ]); + }); }); describe.each` @@ -79,11 +128,10 @@ describe('vue_shared/components/confirm_modal', () => { ${'when message has html'} | ${{ messageHtml: '<p>Header</p><ul onhover="alert(1)"><li>First</li></ul>' }} | ${'<p>Header</p><ul><li>First</li></ul>'} `('$desc', ({ attrs, expectation }) => { beforeEach(() => { + const modalData = merge({ ...MOCK_MODAL_DATA }, { modalAttributes: attrs }); + createComponent(); - wrapper.vm.modalAttributes = { - ...MOCK_MODAL_DATA.modalAttributes, - ...attrs, - }; + triggerOpenWithEventHub(modalData); }); it('renders message', () => { @@ -96,8 +144,7 @@ describe('vue_shared/components/confirm_modal', () => { describe('submitModal', () => { beforeEach(() => { createComponent(); - wrapper.vm.path = MOCK_MODAL_DATA.path; - wrapper.vm.method = MOCK_MODAL_DATA.method; + triggerOpenWithEventHub(MOCK_MODAL_DATA); }); it('does not submit form', () => { diff --git a/spec/frontend/vue_shared/components/design_management/__snapshots__/design_note_pin_spec.js.snap b/spec/frontend/vue_shared/components/design_management/__snapshots__/design_note_pin_spec.js.snap new file mode 100644 index 00000000000..eb0adb0bebd --- /dev/null +++ b/spec/frontend/vue_shared/components/design_management/__snapshots__/design_note_pin_spec.js.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design note pin component should match the snapshot of note with index 1`] = ` +<button + aria-label="Comment '1' position" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-font-sm js-image-badge design-note-pin gl-absolute" + style="left: 10px; top: 10px;" + type="button" +> + + 1 + +</button> +`; + +exports[`Design note pin component should match the snapshot of note without index 1`] = ` +<button + aria-label="Comment form position" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-font-sm btn-transparent comment-indicator gl-absolute" + style="left: 10px; top: 10px;" + type="button" +> + <gl-icon-stub + name="image-comment-dark" + size="24" + /> +</button> +`; + +exports[`Design note pin component should match the snapshot when pin is resolved 1`] = ` +<button + aria-label="Comment form position" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-font-sm btn-transparent comment-indicator resolved gl-absolute" + style="left: 10px; top: 10px;" + type="button" +> + <gl-icon-stub + name="image-comment-dark" + size="24" + /> +</button> +`; + +exports[`Design note pin component should match the snapshot when position is absent 1`] = ` +<button + aria-label="Comment form position" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-font-sm btn-transparent comment-indicator" + type="button" +> + <gl-icon-stub + name="image-comment-dark" + size="24" + /> +</button> +`; diff --git a/spec/frontend/design_management/components/design_note_pin_spec.js b/spec/frontend/vue_shared/components/design_management/design_note_pin_spec.js index a6219923aca..984a28c93d6 100644 --- a/spec/frontend/design_management/components/design_note_pin_spec.js +++ b/spec/frontend/vue_shared/components/design_management/design_note_pin_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import DesignNotePin from '~/design_management/components/design_note_pin.vue'; +import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue'; describe('Design note pin component', () => { let wrapper; @@ -29,4 +29,14 @@ describe('Design note pin component', () => { createComponent({ label: 1 }); expect(wrapper.element).toMatchSnapshot(); }); + + it('should match the snapshot when pin is resolved', () => { + createComponent({ isResolved: true }); + expect(wrapper.element).toMatchSnapshot(); + }); + + it('should match the snapshot when position is absent', () => { + createComponent({ position: null }); + expect(wrapper.element).toMatchSnapshot(); + }); }); diff --git a/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js b/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js index 9f433816b34..b8d3cbebe16 100644 --- a/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js +++ b/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js @@ -1,4 +1,5 @@ -import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; +import { shallowMount, mount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import { TRANSITION_LOAD_START, @@ -11,15 +12,13 @@ import { } from '~/diffs/constants'; import Renamed from '~/vue_shared/components/diff_viewer/viewers/renamed.vue'; -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); function createRenamedComponent({ props = {}, store = new Vuex.Store({}), deep = false }) { const mnt = deep ? mount : shallowMount; return mnt(Renamed, { propsData: { ...props }, - localVue, store, }); } diff --git a/spec/frontend/vue_shared/components/dismissible_alert_spec.js b/spec/frontend/vue_shared/components/dismissible_alert_spec.js index fcd004d35a7..879d4aba441 100644 --- a/spec/frontend/vue_shared/components/dismissible_alert_spec.js +++ b/spec/frontend/vue_shared/components/dismissible_alert_spec.js @@ -43,6 +43,10 @@ describe('vue_shared/components/dismissible_alert', () => { it('hides the alert', () => { expect(findAlert().exists()).toBe(false); }); + + it('emmits alertDismissed', () => { + expect(wrapper.emitted('alertDismissed')).toBeTruthy(); + }); }); }); diff --git a/spec/frontend/vue_shared/components/dom_element_listener_spec.js b/spec/frontend/vue_shared/components/dom_element_listener_spec.js new file mode 100644 index 00000000000..a848c34b7ce --- /dev/null +++ b/spec/frontend/vue_shared/components/dom_element_listener_spec.js @@ -0,0 +1,116 @@ +import { mount } from '@vue/test-utils'; +import { setHTMLFixture } from 'helpers/fixtures'; +import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; + +const DEFAULT_SLOT_CONTENT = 'Default slot content'; +const SELECTOR = '.js-test-include'; +const HTML = ` +<div> + <button class="js-test-include" data-testid="lorem">Lorem</button> + <button class="js-test-include" data-testid="ipsum">Ipsum</button> + <button data-testid="hello">Hello</a> +</div> +`; + +describe('~/vue_shared/components/dom_element_listener.vue', () => { + let wrapper; + let spies; + + const createComponent = () => { + wrapper = mount(DomElementListener, { + propsData: { + selector: SELECTOR, + }, + listeners: spies, + slots: { + default: DEFAULT_SLOT_CONTENT, + }, + }); + }; + + const findElement = (testId) => document.querySelector(`[data-testid="${testId}"]`); + const spiesCallCount = () => + Object.values(spies) + .map((x) => x.mock.calls.length) + .reduce((a, b) => a + b); + + beforeEach(() => { + setHTMLFixture(HTML); + spies = { + click: jest.fn(), + focus: jest.fn(), + }; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders default slot', () => { + expect(wrapper.text()).toBe(DEFAULT_SLOT_CONTENT); + }); + + it('does not initially trigger listeners', () => { + expect(spiesCallCount()).toBe(0); + }); + + describe.each` + event | testId + ${'click'} | ${'lorem'} + ${'focus'} | ${'ipsum'} + `( + 'when matching element triggers event (testId=$testId, event=$event)', + ({ event, testId }) => { + beforeEach(() => { + findElement(testId).dispatchEvent(new Event(event)); + }); + + it('triggers listener', () => { + expect(spiesCallCount()).toBe(1); + expect(spies[event]).toHaveBeenCalledWith(expect.any(Event)); + expect(spies[event]).toHaveBeenCalledWith( + expect.objectContaining({ + target: findElement(testId), + }), + ); + }); + }, + ); + + describe.each` + desc | event | testId + ${'when non-matching element triggers event'} | ${'click'} | ${'hello'} + ${'when matching element triggers unlistened event'} | ${'hover'} | ${'lorem'} + `('$desc', ({ event, testId }) => { + beforeEach(() => { + findElement(testId).dispatchEvent(new Event(event)); + }); + + it('does not trigger listeners', () => { + expect(spiesCallCount()).toBe(0); + }); + }); + }); + + describe('after destroyed', () => { + beforeEach(() => { + createComponent(); + wrapper.destroy(); + }); + + describe('when matching element triggers event', () => { + beforeEach(() => { + findElement('lorem').dispatchEvent(new Event('click')); + }); + + it('does not trigger any listeners', () => { + expect(spiesCallCount()).toBe(0); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/file_icon_spec.js b/spec/frontend/vue_shared/components/file_icon_spec.js index c10663f6c14..b0e623520a8 100644 --- a/spec/frontend/vue_shared/components/file_icon_spec.js +++ b/spec/frontend/vue_shared/components/file_icon_spec.js @@ -34,7 +34,7 @@ describe('File Icon component', () => { it.each` fileName | iconName - ${'test.js'} | ${'javascript'} + ${'index.js'} | ${'javascript'} ${'test.png'} | ${'image'} ${'test.PNG'} | ${'image'} ${'.npmrc'} | ${'npm'} diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js index 238c5d16db5..e3e2ef5610d 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -5,12 +5,9 @@ import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/co import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue'; import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'; -import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue'; -import IterationToken from '~/vue_shared/components/filtered_search_bar/tokens/iteration_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 ReleaseToken from '~/vue_shared/components/filtered_search_bar/tokens/release_token.vue'; -import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue'; export const mockAuthor1 = { id: 1, @@ -65,11 +62,6 @@ export const mockMilestones = [ mockEscapedMilestone, ]; -export const mockEpics = [ - { iid: 1, id: 1, title: 'Foo', group_full_path: 'gitlab-org' }, - { iid: 2, id: 2, title: 'Bar', group_full_path: 'gitlab-org/design' }, -]; - export const mockEmoji1 = { name: 'thumbsup', }; @@ -102,27 +94,6 @@ export const mockAuthorToken = { fetchAuthors: Api.projectUsers.bind(Api), }; -export const mockIterationToken = { - type: 'iteration', - icon: 'iteration', - title: 'Iteration', - unique: true, - token: IterationToken, - fetchIterations: () => Promise.resolve(), -}; - -export const mockIterations = [ - { - id: 1, - title: 'Iteration 1', - startDate: '2021-11-05', - dueDate: '2021-11-10', - iterationCadence: { - title: 'Cadence 1', - }, - }, -]; - export const mockLabelToken = { type: 'label_name', icon: 'labels', @@ -153,73 +124,6 @@ export const mockReleaseToken = { fetchReleases: () => Promise.resolve(), }; -export const mockEpicToken = { - type: 'epic_iid', - icon: 'clock', - title: 'Epic', - unique: true, - symbol: '&', - token: EpicToken, - operators: OPERATOR_IS_ONLY, - idProperty: 'iid', - fullPath: 'gitlab-org', -}; - -export const mockEpicNode1 = { - __typename: 'Epic', - parent: null, - id: 'gid://gitlab/Epic/40', - iid: '2', - title: 'Marketing epic', - description: 'Mock epic description', - state: 'opened', - startDate: '2017-12-25', - dueDate: '2018-02-15', - webUrl: 'http://gdk.test:3000/groups/gitlab-org/marketing/-/epics/1', - hasChildren: false, - hasParent: false, - confidential: false, -}; - -export const mockEpicNode2 = { - __typename: 'Epic', - parent: null, - id: 'gid://gitlab/Epic/41', - iid: '3', - title: 'Another marketing', - startDate: '2017-12-26', - dueDate: '2018-03-10', - state: 'opened', - webUrl: 'http://gdk.test:3000/groups/gitlab-org/marketing/-/epics/2', -}; - -export const mockGroupEpicsQueryResponse = { - data: { - group: { - id: 'gid://gitlab/Group/1', - name: 'Gitlab Org', - epics: { - edges: [ - { - node: { - ...mockEpicNode1, - }, - __typename: 'EpicEdge', - }, - { - node: { - ...mockEpicNode2, - }, - __typename: 'EpicEdge', - }, - ], - __typename: 'EpicConnection', - }, - __typename: 'Group', - }, - }, -}; - export const mockReactionEmojiToken = { type: 'my_reaction_emoji', icon: 'thumb-up', @@ -243,14 +147,6 @@ export const mockMembershipToken = { ], }; -export const mockWeightToken = { - type: 'weight', - icon: 'weight', - title: 'Weight', - unique: true, - token: WeightToken, -}; - export const mockMembershipTokenOptionsWithoutTitles = { ...mockMembershipToken, options: [{ value: 'exclude' }, { value: 'only' }], 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 f9ce0338d2f..84f0151d9db 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 @@ -14,7 +14,13 @@ import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_t import { mockLabelToken } from '../mock_data'; -jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils'); +jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({ + getRecentlyUsedSuggestions: jest.fn(), + setTokenValueToRecentlyUsed: jest.fn(), + stripQuotes: jest.requireActual( + '~/vue_shared/components/filtered_search_bar/filtered_search_utils', + ).stripQuotes, +})); const mockStorageKey = 'recent-tokens-label_name'; @@ -46,13 +52,13 @@ const defaultSlots = { }; const mockProps = { - config: mockLabelToken, + config: { ...mockLabelToken, recentSuggestionsStorageKey: mockStorageKey }, value: { data: '' }, active: false, suggestions: [], suggestionsLoading: false, defaultSuggestions: DEFAULT_NONE_ANY, - recentSuggestionsStorageKey: mockStorageKey, + getActiveTokenValue: (labels, data) => labels.find((label) => label.title === data), }; function createComponent({ @@ -152,30 +158,22 @@ describe('BaseToken', () => { describe('methods', () => { describe('handleTokenValueSelected', () => { - it('calls `setTokenValueToRecentlyUsed` when `recentSuggestionsStorageKey` is defined', () => { - const mockTokenValue = { - id: 1, - title: 'Foo', - }; + const mockTokenValue = mockLabels[0]; - wrapper.vm.handleTokenValueSelected(mockTokenValue); + it('calls `setTokenValueToRecentlyUsed` when `recentSuggestionsStorageKey` is defined', () => { + wrapper.vm.handleTokenValueSelected(mockTokenValue.title); expect(setTokenValueToRecentlyUsed).toHaveBeenCalledWith(mockStorageKey, mockTokenValue); }); it('does not add token from preloadedSuggestions', async () => { - const mockTokenValue = { - id: 1, - title: 'Foo', - }; - wrapper.setProps({ preloadedSuggestions: [mockTokenValue], }); await wrapper.vm.$nextTick(); - wrapper.vm.handleTokenValueSelected(mockTokenValue); + wrapper.vm.handleTokenValueSelected(mockTokenValue.title); expect(setTokenValueToRecentlyUsed).not.toHaveBeenCalled(); }); @@ -190,7 +188,7 @@ describe('BaseToken', () => { const glFilteredSearchToken = wrapperWithNoStubs.find(GlFilteredSearchToken); expect(glFilteredSearchToken.exists()).toBe(true); - expect(glFilteredSearchToken.props('config')).toBe(mockLabelToken); + expect(glFilteredSearchToken.props('config')).toEqual(mockProps.config); wrapperWithNoStubs.destroy(); }); @@ -239,6 +237,7 @@ describe('BaseToken', () => { stubs: { Portal: true }, }); }); + it('emits `fetch-suggestions` event on component after a delay when component emits `input` event', async () => { jest.useFakeTimers(); @@ -250,6 +249,32 @@ describe('BaseToken', () => { expect(wrapperWithNoStubs.emitted('fetch-suggestions')).toBeTruthy(); expect(wrapperWithNoStubs.emitted('fetch-suggestions')[2]).toEqual(['foo']); }); + + describe('when search is started with a quote', () => { + it('emits `fetch-suggestions` with filtered value', async () => { + jest.useFakeTimers(); + + wrapperWithNoStubs.find(GlFilteredSearchToken).vm.$emit('input', { data: '"foo' }); + await wrapperWithNoStubs.vm.$nextTick(); + + jest.runAllTimers(); + + expect(wrapperWithNoStubs.emitted('fetch-suggestions')[2]).toEqual(['foo']); + }); + }); + + describe('when search starts and ends with a quote', () => { + it('emits `fetch-suggestions` with filtered value', async () => { + jest.useFakeTimers(); + + wrapperWithNoStubs.find(GlFilteredSearchToken).vm.$emit('input', { data: '"foo"' }); + await wrapperWithNoStubs.vm.$nextTick(); + + jest.runAllTimers(); + + expect(wrapperWithNoStubs.emitted('fetch-suggestions')[2]).toEqual(['foo']); + }); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js deleted file mode 100644 index 6ee5d50d396..00000000000 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js +++ /dev/null @@ -1,169 +0,0 @@ -import { GlFilteredSearchTokenSegment } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; -import axios from '~/lib/utils/axios_utils'; - -import searchEpicsQuery from '~/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql'; -import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue'; -import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; - -import { mockEpicToken, mockEpics, mockGroupEpicsQueryResponse } from '../mock_data'; - -jest.mock('~/flash'); -Vue.use(VueApollo); - -const defaultStubs = { - Portal: true, - GlFilteredSearchSuggestionList: { - template: '<div></div>', - methods: { - getValue: () => '=', - }, - }, -}; - -describe('EpicToken', () => { - let mock; - let wrapper; - let fakeApollo; - - const findBaseToken = () => wrapper.findComponent(BaseToken); - - function createComponent( - options = {}, - epicsQueryHandler = jest.fn().mockResolvedValue(mockGroupEpicsQueryResponse), - ) { - fakeApollo = createMockApollo([[searchEpicsQuery, epicsQueryHandler]]); - const { - config = mockEpicToken, - value = { data: '' }, - active = false, - stubs = defaultStubs, - } = options; - return mount(EpicToken, { - apolloProvider: fakeApollo, - propsData: { - config, - value, - active, - }, - provide: { - portalName: 'fake target', - alignSuggestions: function fakeAlignSuggestions() {}, - suggestionsListClass: 'custom-class', - }, - stubs, - }); - } - - beforeEach(() => { - mock = new MockAdapter(axios); - wrapper = createComponent(); - }); - - afterEach(() => { - mock.restore(); - wrapper.destroy(); - }); - - describe('computed', () => { - beforeEach(async () => { - wrapper = createComponent({ - data: { - epics: mockEpics, - }, - }); - - await wrapper.vm.$nextTick(); - }); - }); - - describe('methods', () => { - describe('fetchEpicsBySearchTerm', () => { - it('calls fetchEpics with provided searchTerm param', () => { - jest.spyOn(wrapper.vm, 'fetchEpics'); - - findBaseToken().vm.$emit('fetch-suggestions', 'foo'); - - expect(wrapper.vm.fetchEpics).toHaveBeenCalledWith('foo'); - }); - - it('sets response to `epics` when request is successful', async () => { - jest.spyOn(wrapper.vm, 'fetchEpics').mockResolvedValue({ - data: mockEpics, - }); - - findBaseToken().vm.$emit('fetch-suggestions'); - - await waitForPromises(); - - expect(wrapper.vm.epics).toEqual(mockEpics); - }); - - it('calls `createFlash` with flash error message when request fails', async () => { - jest.spyOn(wrapper.vm, 'fetchEpics').mockRejectedValue({}); - - findBaseToken().vm.$emit('fetch-suggestions', 'foo'); - - await waitForPromises(); - - expect(createFlash).toHaveBeenCalledWith({ - message: 'There was a problem fetching epics.', - }); - }); - - it('sets `loading` to false when request completes', async () => { - jest.spyOn(wrapper.vm, 'fetchEpics').mockRejectedValue({}); - - findBaseToken().vm.$emit('fetch-suggestions', 'foo'); - - await waitForPromises(); - - expect(wrapper.vm.loading).toBe(false); - }); - }); - }); - - describe('template', () => { - const getTokenValueEl = () => wrapper.findAllComponents(GlFilteredSearchTokenSegment).at(2); - - beforeEach(async () => { - wrapper = createComponent({ - value: { data: `${mockEpics[0].title}::&${mockEpics[0].iid}` }, - data: { epics: mockEpics }, - }); - - await wrapper.vm.$nextTick(); - }); - - it('renders BaseToken component', () => { - expect(findBaseToken().exists()).toBe(true); - }); - - it('renders token item when value is selected', () => { - const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); - - expect(tokenSegments).toHaveLength(3); - expect(tokenSegments.at(2).text()).toBe(`${mockEpics[0].title}::&${mockEpics[0].iid}`); - }); - - it.each` - value | valueType | tokenValueString - ${`${mockEpics[0].title}::&${mockEpics[0].iid}`} | ${'string'} | ${`${mockEpics[0].title}::&${mockEpics[0].iid}`} - ${`${mockEpics[1].title}::&${mockEpics[1].iid}`} | ${'number'} | ${`${mockEpics[1].title}::&${mockEpics[1].iid}`} - `('renders token item when selection is a $valueType', async ({ value, tokenValueString }) => { - wrapper.setProps({ - value: { data: value }, - }); - - await wrapper.vm.$nextTick(); - - expect(getTokenValueEl().text()).toBe(tokenValueString); - }); - }); -}); 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 deleted file mode 100644 index 44bc16adb97..00000000000 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js +++ /dev/null @@ -1,116 +0,0 @@ -import { - GlFilteredSearchToken, - GlFilteredSearchTokenSegment, - GlFilteredSearchSuggestion, -} 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, mockIterations } from '../mock_data'; - -jest.mock('~/flash'); - -describe('IterationToken', () => { - const id = 123; - let wrapper; - - const createComponent = ({ - config = mockIterationToken, - value = { data: '' }, - active = false, - stubs = {}, - provide = {}, - } = {}) => - mount(IterationToken, { - propsData: { - active, - config, - value, - }, - provide: { - portalName: 'fake target', - alignSuggestions: function fakeAlignSuggestions() {}, - suggestionsListClass: () => 'custom-class', - ...provide, - }, - stubs, - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('when iteration cadence feature is available', () => { - beforeEach(async () => { - wrapper = createComponent({ - active: true, - config: { ...mockIterationToken, initialIterations: mockIterations }, - value: { data: 'i' }, - stubs: { Portal: true }, - provide: { - glFeatures: { - iterationCadences: true, - }, - }, - }); - - await wrapper.setData({ loading: false }); - }); - - it('renders iteration start date and due date', () => { - const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); - - expect(suggestions.at(3).text()).toContain('Nov 5, 2021 - Nov 10, 2021'); - }); - }); - - it('renders iteration value', async () => { - wrapper = createComponent({ value: { data: id } }); - - await wrapper.vm.$nextTick(); - - const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment); - - expect(tokenSegments).toHaveLength(3); // `Iteration` `=` `gitlab-org: #1` - expect(tokenSegments.at(2).text()).toBe(id.toString()); - }); - - it('fetches initial values', () => { - const fetchIterationsSpy = jest.fn().mockResolvedValue(); - - wrapper = createComponent({ - config: { ...mockIterationToken, fetchIterations: fetchIterationsSpy }, - value: { data: id }, - }); - - expect(fetchIterationsSpy).toHaveBeenCalledWith(id); - }); - - it('fetches iterations on user input', () => { - const search = 'hello'; - const fetchIterationsSpy = jest.fn().mockResolvedValue(); - - wrapper = createComponent({ - config: { ...mockIterationToken, fetchIterations: fetchIterationsSpy }, - }); - - wrapper.findComponent(GlFilteredSearchToken).vm.$emit('input', { data: search }); - - expect(fetchIterationsSpy).toHaveBeenCalledWith(search); - }); - - it('renders error message when request fails', async () => { - const fetchIterationsSpy = jest.fn().mockRejectedValue(); - - wrapper = createComponent({ - config: { ...mockIterationToken, fetchIterations: fetchIterationsSpy }, - }); - - await waitForPromises(); - - expect(createFlash).toHaveBeenCalledWith({ - message: 'There was a problem fetching iterations.', - }); - }); -}); 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 936841651d1..4a098db33c5 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 @@ -9,18 +9,15 @@ import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import { sortMilestonesByDueDate } from '~/milestones/milestone_utils'; +import { sortMilestonesByDueDate } from '~/milestones/utils'; -import { - DEFAULT_MILESTONES, - DEFAULT_MILESTONES_GRAPHQL, -} from '~/vue_shared/components/filtered_search_bar/constants'; +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 } from '../mock_data'; jest.mock('~/flash'); -jest.mock('~/milestones/milestone_utils'); +jest.mock('~/milestones/utils'); const defaultStubs = { Portal: true, @@ -199,12 +196,12 @@ describe('MilestoneToken', () => { beforeEach(() => { wrapper = createComponent({ active: true, - config: { ...mockMilestoneToken, defaultMilestones: DEFAULT_MILESTONES_GRAPHQL }, + config: { ...mockMilestoneToken, defaultMilestones: DEFAULT_MILESTONES }, }); }); it('finds the correct value from the activeToken', () => { - DEFAULT_MILESTONES_GRAPHQL.forEach(({ value, title }) => { + DEFAULT_MILESTONES.forEach(({ value, title }) => { const activeToken = wrapper.vm.getActiveMilestone([], value); expect(activeToken.title).toEqual(title); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js index b804ff97b82..b2f246a5985 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js @@ -8,7 +8,7 @@ import { mockReleaseToken } from '../mock_data'; jest.mock('~/flash'); describe('ReleaseToken', () => { - const id = 123; + const id = '123'; let wrapper; const createComponent = ({ config = mockReleaseToken, value = { data: '' } } = {}) => 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 deleted file mode 100644 index 4277899f8db..00000000000 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js +++ /dev/null @@ -1,38 +0,0 @@ -import { GlFilteredSearchTokenSegment } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue'; -import { mockWeightToken } from '../mock_data'; - -jest.mock('~/flash'); - -describe('WeightToken', () => { - const weight = '3'; - let wrapper; - - const createComponent = ({ config = mockWeightToken, value = { data: '' } } = {}) => - mount(WeightToken, { - propsData: { - active: false, - config, - value, - }, - provide: { - portalName: 'fake target', - alignSuggestions: function fakeAlignSuggestions() {}, - suggestionsListClass: () => 'custom-class', - }, - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders weight value', () => { - wrapper = createComponent({ value: { data: weight } }); - - const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment); - - expect(tokenSegments).toHaveLength(3); // `Weight` `=` `3` - expect(tokenSegments.at(2).text()).toBe(weight); - }); -}); diff --git a/spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap b/spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap index ff1dad2de68..58ad1f681bc 100644 --- a/spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap +++ b/spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap @@ -5,6 +5,7 @@ exports[`Title edit field matches the snapshot 1`] = ` label="Title" label-for="title-field-edit" labeldescription="" + optionaltext="(optional)" > <gl-form-input-stub /> </gl-form-group-stub> diff --git a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js new file mode 100644 index 00000000000..b67385cc43e --- /dev/null +++ b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js @@ -0,0 +1,231 @@ +import { merge } from 'lodash'; +import { GlFormInputGroup } from '@gitlab/ui'; + +import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +import { mountExtended } from 'helpers/vue_test_utils_helper'; + +describe('InputCopyToggleVisibility', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + }); + + const valueProp = 'hR8x1fuJbzwu5uFKLf9e'; + + const createComponent = (options = {}) => { + wrapper = mountExtended( + InputCopyToggleVisibility, + merge({}, options, { + directives: { + GlTooltip: createMockDirective(), + }, + }), + ); + }; + + const findFormInputGroup = () => wrapper.findComponent(GlFormInputGroup); + const findFormInput = () => findFormInputGroup().find('input'); + const findRevealButton = () => + wrapper.findByRole('button', { + name: InputCopyToggleVisibility.i18n.toggleVisibilityLabelReveal, + }); + const findHideButton = () => + wrapper.findByRole('button', { + name: InputCopyToggleVisibility.i18n.toggleVisibilityLabelHide, + }); + const findCopyButton = () => wrapper.findComponent(ClipboardButton); + const createCopyEvent = () => { + const event = new Event('copy', { cancelable: true }); + Object.assign(event, { preventDefault: jest.fn(), clipboardData: { setData: jest.fn() } }); + + return event; + }; + + const itDoesNotModifyCopyEvent = () => { + it('does not modify copy event', () => { + const event = createCopyEvent(); + + findFormInput().element.dispatchEvent(event); + + expect(event.clipboardData.setData).not.toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + }; + + describe('when `value` prop is passed', () => { + beforeEach(() => { + createComponent({ + propsData: { + value: valueProp, + }, + }); + }); + + it('displays value as hidden', () => { + expect(findFormInputGroup().props('value')).toBe('********************'); + }); + + it('saves actual value to clipboard when manually copied', () => { + const event = createCopyEvent(); + findFormInput().element.dispatchEvent(event); + + expect(event.clipboardData.setData).toHaveBeenCalledWith('text/plain', valueProp); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + describe('visibility toggle button', () => { + it('renders a reveal button', () => { + const revealButton = findRevealButton(); + + expect(revealButton.exists()).toBe(true); + + const tooltip = getBinding(revealButton.element, 'gl-tooltip'); + + expect(tooltip.value).toBe(InputCopyToggleVisibility.i18n.toggleVisibilityLabelReveal); + }); + + describe('when clicked', () => { + beforeEach(async () => { + await findRevealButton().trigger('click'); + }); + + it('displays value', () => { + expect(findFormInputGroup().props('value')).toBe(valueProp); + }); + + it('renders a hide button', () => { + const hideButton = findHideButton(); + + expect(hideButton.exists()).toBe(true); + + const tooltip = getBinding(hideButton.element, 'gl-tooltip'); + + expect(tooltip.value).toBe(InputCopyToggleVisibility.i18n.toggleVisibilityLabelHide); + }); + + it('emits `visibility-change` event', () => { + expect(wrapper.emitted('visibility-change')[0]).toEqual([true]); + }); + }); + }); + + describe('copy button', () => { + it('renders button with correct props passed', () => { + expect(findCopyButton().props()).toMatchObject({ + text: valueProp, + title: 'Copy', + }); + }); + + describe('when clicked', () => { + beforeEach(async () => { + await findCopyButton().trigger('click'); + }); + + it('emits `copy` event', () => { + expect(wrapper.emitted('copy')[0]).toEqual([]); + }); + }); + }); + }); + + describe('when `value` prop is not passed', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays value as hidden with 20 asterisks', () => { + expect(findFormInputGroup().props('value')).toBe('********************'); + }); + }); + + describe('when `initialVisibility` prop is `true`', () => { + beforeEach(() => { + createComponent({ + propsData: { + value: valueProp, + initialVisibility: true, + }, + }); + }); + + it('displays value', () => { + expect(findFormInputGroup().props('value')).toBe(valueProp); + }); + + itDoesNotModifyCopyEvent(); + }); + + describe('when `showToggleVisibilityButton` is `false`', () => { + beforeEach(() => { + createComponent({ + propsData: { + value: valueProp, + showToggleVisibilityButton: false, + }, + }); + }); + + it('does not render visibility toggle button', () => { + expect(findRevealButton().exists()).toBe(false); + expect(findHideButton().exists()).toBe(false); + }); + + it('displays value', () => { + expect(findFormInputGroup().props('value')).toBe(valueProp); + }); + + itDoesNotModifyCopyEvent(); + }); + + describe('when `showCopyButton` is `false`', () => { + beforeEach(() => { + createComponent({ + propsData: { + showCopyButton: false, + }, + }); + }); + + it('does not render copy button', () => { + expect(findCopyButton().exists()).toBe(false); + }); + }); + + it('passes `formInputGroupProps` prop to `GlFormInputGroup`', () => { + createComponent({ + propsData: { + formInputGroupProps: { + label: 'Foo bar', + }, + }, + }); + + expect(findFormInputGroup().props('label')).toBe('Foo bar'); + }); + + it('passes `copyButtonTitle` prop to `ClipboardButton`', () => { + createComponent({ + propsData: { + copyButtonTitle: 'Copy token', + }, + }); + + expect(findCopyButton().props('title')).toBe('Copy token'); + }); + + it('renders slots in `gl-form-group`', () => { + const description = 'Mock input description'; + createComponent({ + slots: { + description, + }, + }); + + expect(wrapper.findByText(description).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js b/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js index 390a70792f3..b837a998cd6 100644 --- a/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js +++ b/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js @@ -1,12 +1,12 @@ import { GlModal } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue'; import createState from '~/vuex_shared/modules/modal/state'; -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); const TEST_SLOT = 'Lorem ipsum modal dolar sit.'; const TEST_MODAL_ID = 'my-modal-id'; @@ -36,7 +36,6 @@ describe('GlModalVuex', () => { wrapper = shallowMount(GlModalVuex, { ...options, - localVue, store, propsData, stubs: { diff --git a/spec/frontend/vue_shared/components/header_ci_component_spec.js b/spec/frontend/vue_shared/components/header_ci_component_spec.js index b76f475a6fb..aea76f164f0 100644 --- a/spec/frontend/vue_shared/components/header_ci_component_spec.js +++ b/spec/frontend/vue_shared/components/header_ci_component_spec.js @@ -1,4 +1,4 @@ -import { GlButton, GlAvatarLink } from '@gitlab/ui'; +import { GlButton, GlAvatarLink, GlTooltip } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import CiIconBadge from '~/vue_shared/components/ci_badge_link.vue'; @@ -32,6 +32,7 @@ describe('Header CI Component', () => { const findTimeAgo = () => wrapper.findComponent(TimeagoTooltip); const findUserLink = () => wrapper.findComponent(GlAvatarLink); const findSidebarToggleBtn = () => wrapper.findComponent(GlButton); + const findStatusTooltip = () => wrapper.findComponent(GlTooltip); const findActionButtons = () => wrapper.findByTestId('ci-header-action-buttons'); const findHeaderItemText = () => wrapper.findByTestId('ci-header-item-text'); @@ -91,6 +92,21 @@ describe('Header CI Component', () => { }); }); + describe('when the user has a status', () => { + const STATUS_MESSAGE = 'Working on exciting features...'; + + beforeEach(() => { + createComponent({ + itemName: 'Pipeline', + user: { ...defaultProps.user, status: { message: STATUS_MESSAGE } }, + }); + }); + + it('renders a tooltip', () => { + expect(findStatusTooltip().text()).toBe(STATUS_MESSAGE); + }); + }); + describe('with data from GraphQL', () => { const userId = 1; diff --git a/spec/frontend/vue_shared/components/line_numbers_spec.js b/spec/frontend/vue_shared/components/line_numbers_spec.js new file mode 100644 index 00000000000..5bedd0ccd02 --- /dev/null +++ b/spec/frontend/vue_shared/components/line_numbers_spec.js @@ -0,0 +1,71 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon, GlLink } from '@gitlab/ui'; +import LineNumbers from '~/vue_shared/components/line_numbers.vue'; + +describe('Line Numbers component', () => { + let wrapper; + const lines = 10; + + const createComponent = () => { + wrapper = shallowMount(LineNumbers, { propsData: { lines } }); + }; + + const findGlIcon = () => wrapper.findComponent(GlIcon); + const findLineNumbers = () => wrapper.findAllComponents(GlLink); + const findFirstLineNumber = () => findLineNumbers().at(0); + const findSecondLineNumber = () => findLineNumbers().at(1); + + beforeEach(() => createComponent()); + + afterEach(() => wrapper.destroy()); + + describe('rendering', () => { + it('renders Line Numbers', () => { + expect(findLineNumbers().length).toBe(lines); + expect(findFirstLineNumber().attributes()).toMatchObject({ + id: 'L1', + href: '#L1', + }); + }); + + it('renders a link icon', () => { + expect(findGlIcon().props()).toMatchObject({ + size: 12, + name: 'link', + }); + }); + }); + + describe('clicking a line number', () => { + let firstLineNumber; + let firstLineNumberElement; + + beforeEach(() => { + firstLineNumber = findFirstLineNumber(); + firstLineNumberElement = firstLineNumber.element; + + jest.spyOn(firstLineNumberElement, 'scrollIntoView'); + jest.spyOn(firstLineNumberElement.classList, 'add'); + jest.spyOn(firstLineNumberElement.classList, 'remove'); + + firstLineNumber.vm.$emit('click'); + }); + + it('adds the highlight (hll) class', () => { + expect(firstLineNumberElement.classList.add).toHaveBeenCalledWith('hll'); + }); + + it('removes the highlight (hll) class from a previously highlighted line', () => { + findSecondLineNumber().vm.$emit('click'); + + expect(firstLineNumberElement.classList.remove).toHaveBeenCalledWith('hll'); + }); + + it('scrolls the line into view', () => { + expect(firstLineNumberElement.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'center', + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js index eddc4033a65..8bff85b0bda 100644 --- a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js +++ b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js @@ -1,24 +1,17 @@ import { mount } from '@vue/test-utils'; -import { isExperimentVariant } from '~/experimentation/utils'; -import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; -import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants'; import Toolbar from '~/vue_shared/components/markdown/toolbar.vue'; -jest.mock('~/experimentation/utils', () => ({ isExperimentVariant: jest.fn() })); - describe('toolbar', () => { let wrapper; const createMountedWrapper = (props = {}) => { wrapper = mount(Toolbar, { propsData: { markdownDocsPath: '', ...props }, - stubs: { 'invite-members-trigger': true }, }); }; afterEach(() => { wrapper.destroy(); - isExperimentVariant.mockReset(); }); describe('user can attach file', () => { @@ -40,36 +33,4 @@ describe('toolbar', () => { expect(wrapper.vm.$el.querySelector('.uploading-container')).toBeNull(); }); }); - - describe('user can invite member', () => { - const findInviteLink = () => wrapper.find(InviteMembersTrigger); - - beforeEach(() => { - isExperimentVariant.mockReturnValue(true); - createMountedWrapper(); - }); - - it('should render the invite members trigger', () => { - expect(findInviteLink().exists()).toBe(true); - }); - - it('should have correct props', () => { - expect(findInviteLink().props().displayText).toBe('Invite Member'); - expect(findInviteLink().props().trackExperiment).toBe(INVITE_MEMBERS_IN_COMMENT); - expect(findInviteLink().props().triggerSource).toBe(INVITE_MEMBERS_IN_COMMENT); - }); - }); - - describe('user can not invite member', () => { - const findInviteLink = () => wrapper.find(InviteMembersTrigger); - - beforeEach(() => { - isExperimentVariant.mockReturnValue(false); - createMountedWrapper(); - }); - - it('should render the invite members trigger', () => { - expect(findInviteLink().exists()).toBe(false); - }); - }); }); diff --git a/spec/frontend/vue_shared/components/namespace_select/mock_data.js b/spec/frontend/vue_shared/components/namespace_select/mock_data.js new file mode 100644 index 00000000000..c9d96672e85 --- /dev/null +++ b/spec/frontend/vue_shared/components/namespace_select/mock_data.js @@ -0,0 +1,11 @@ +export const group = [ + { id: 1, name: 'Group 1', humanName: 'Group 1' }, + { id: 2, name: 'Subgroup 1', humanName: 'Group 1 / Subgroup 1' }, +]; + +export const user = [{ id: 3, name: 'User namespace 1', humanName: 'User namespace 1' }]; + +export const namespaces = { + group, + user, +}; diff --git a/spec/frontend/vue_shared/components/namespace_select/namespace_select_spec.js b/spec/frontend/vue_shared/components/namespace_select/namespace_select_spec.js new file mode 100644 index 00000000000..8f07f63993d --- /dev/null +++ b/spec/frontend/vue_shared/components/namespace_select/namespace_select_spec.js @@ -0,0 +1,86 @@ +import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import NamespaceSelect, { + i18n, +} from '~/vue_shared/components/namespace_select/namespace_select.vue'; +import { user, group, namespaces } from './mock_data'; + +describe('Namespace Select', () => { + let wrapper; + + const createComponent = (props = {}) => + shallowMountExtended(NamespaceSelect, { + propsData: { + data: namespaces, + ...props, + }, + }); + + const wrappersText = (arr) => arr.wrappers.map((w) => w.text()); + const flatNamespaces = () => [...group, ...user]; + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownAttributes = (attr) => findDropdown().attributes(attr); + const selectedDropdownItemText = () => findDropdownAttributes('text'); + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findSectionHeaders = () => wrapper.findAllComponents(GlDropdownSectionHeader); + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the dropdown', () => { + expect(findDropdown().exists()).toBe(true); + }); + + it('renders each dropdown item', () => { + const items = findDropdownItems().wrappers; + expect(items).toHaveLength(flatNamespaces().length); + }); + + it('renders the human name for each item', () => { + const dropdownItems = wrappersText(findDropdownItems()); + const flatNames = flatNamespaces().map(({ humanName }) => humanName); + expect(dropdownItems).toEqual(flatNames); + }); + + it('sets the initial dropdown text', () => { + expect(selectedDropdownItemText()).toBe(i18n.DEFAULT_TEXT); + }); + + it('splits group and user namespaces', () => { + const headers = findSectionHeaders(); + expect(headers).toHaveLength(2); + expect(wrappersText(headers)).toEqual([i18n.GROUPS, i18n.USERS]); + }); + + it('sets the dropdown to full width', () => { + expect(findDropdownAttributes('block')).toBeUndefined(); + + wrapper = createComponent({ fullWidth: true }); + + expect(findDropdownAttributes('block')).not.toBeUndefined(); + expect(findDropdownAttributes('block')).toBe('true'); + }); + + describe('with a selected namespace', () => { + const selectedGroupIndex = 1; + const selectedItem = group[selectedGroupIndex]; + + beforeEach(() => { + findDropdownItems().at(selectedGroupIndex).vm.$emit('click'); + }); + + it('sets the dropdown text', () => { + expect(selectedDropdownItemText()).toBe(selectedItem.humanName); + }); + + it('emits the `select` event when a namespace is selected', () => { + const args = [selectedItem]; + expect(wrapper.emitted('select')).toEqual([args]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js index 0f30b50da0b..c8dab0204d3 100644 --- a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js @@ -1,10 +1,11 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import IssuePlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import { userDataMock } from '../../../notes/mock_data'; -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); const getters = { getUserData: () => userDataMock, @@ -15,9 +16,8 @@ describe('Issue placeholder note component', () => { const findNote = () => wrapper.find({ ref: 'note' }); - const createComponent = (isIndividual = false) => { + const createComponent = (isIndividual = false, propsData = {}) => { wrapper = shallowMount(IssuePlaceholderNote, { - localVue, store: new Vuex.Store({ getters, }), @@ -26,6 +26,7 @@ describe('Issue placeholder note component', () => { body: 'Foo', individual_note: isIndividual, }, + ...propsData, }, }); }; @@ -52,4 +53,17 @@ describe('Issue placeholder note component', () => { expect(findNote().classes()).toContain('discussion'); }); + + describe('avatar size', () => { + it.each` + size | line | isOverviewTab + ${40} | ${null} | ${false} + ${24} | ${{ line_code: '123' }} | ${false} + ${40} | ${{ line_code: '123' }} | ${true} + `('renders avatar $size for $line and $isOverviewTab', ({ size, line, isOverviewTab }) => { + createComponent(false, { line, isOverviewTab }); + + expect(wrapper.findComponent(UserAvatarLink).props('imgSize')).toBe(size); + }); + }); }); diff --git a/spec/frontend/import_entities/components/pagination_bar_spec.js b/spec/frontend/vue_shared/components/pagination_bar/pagination_bar_spec.js index 163ce11a8db..08119dee8af 100644 --- a/spec/frontend/import_entities/components/pagination_bar_spec.js +++ b/spec/frontend/vue_shared/components/pagination_bar/pagination_bar_spec.js @@ -1,16 +1,16 @@ import { GlPagination, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import PaginationBar from '~/import_entities/components/pagination_bar.vue'; +import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; describe('Pagination bar', () => { const DEFAULT_PROPS = { pageInfo: { total: 50, - page: 1, + totalPages: 3, + page: 3, perPage: 20, }, - itemsCount: 17, }; let wrapper; @@ -73,7 +73,7 @@ describe('Pagination bar', () => { createComponent(); expect(wrapper.find('[data-testid="information"]').text()).toMatchInterpolatedText( - 'Showing 1 - 17 of 50', + 'Showing 41 - 50 of 50', ); }); @@ -82,11 +82,12 @@ describe('Pagination bar', () => { pageInfo: { ...DEFAULT_PROPS.pageInfo, total: 1200, + page: 2, }, }); expect(wrapper.find('[data-testid="information"]').text()).toMatchInterpolatedText( - 'Showing 1 - 17 of 1000+', + 'Showing 21 - 40 of 1000+', ); }); }); diff --git a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js index 7fdacbe83a2..5afa017aa76 100644 --- a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js +++ b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js @@ -1,13 +1,12 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import mockProjects from 'test_fixtures_static/projects.json'; import { trimText } from 'helpers/text_helper'; import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/default.vue'; import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; -const localVue = createLocalVue(); - describe('ProjectListItem component', () => { - const Component = localVue.extend(ProjectListItem); + const Component = Vue.extend(ProjectListItem); let wrapper; let vm; let options; @@ -20,7 +19,6 @@ describe('ProjectListItem component', () => { project, selected: false, }, - localVue, }; }); diff --git a/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js index de5cee846a1..34cee10392d 100644 --- a/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js +++ b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js @@ -1,5 +1,5 @@ import { GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui'; -import { mount, createLocalVue } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import { head } from 'lodash'; import Vue from 'vue'; import mockProjects from 'test_fixtures_static/projects.json'; @@ -7,8 +7,6 @@ import { trimText } from 'helpers/text_helper'; import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue'; -const localVue = createLocalVue(); - describe('ProjectSelector component', () => { let wrapper; let vm; @@ -28,7 +26,6 @@ describe('ProjectSelector component', () => { beforeEach(() => { wrapper = mount(Vue.extend(ProjectSelector), { - localVue, propsData: { projectSearchResults: searchResults, selectedProjects: selected, diff --git a/spec/frontend/vue_shared/components/registry/metadata_item_spec.js b/spec/frontend/vue_shared/components/registry/metadata_item_spec.js index 1ccf3ddc5a5..e4abdc15fd5 100644 --- a/spec/frontend/vue_shared/components/registry/metadata_item_spec.js +++ b/spec/frontend/vue_shared/components/registry/metadata_item_spec.js @@ -2,7 +2,7 @@ import { GlIcon, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import component from '~/vue_shared/components/registry/metadata_item.vue'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; describe('Metadata Item', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js index 8536ffed573..e74a867ec97 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js +++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js @@ -1,7 +1,7 @@ import { GlAlert, GlModal, GlButton, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -16,8 +16,7 @@ import { mockGraphqlInstructionsWindows, } from './mock_data'; -const localVue = createLocalVue(); -localVue.use(VueApollo); +Vue.use(VueApollo); let resizeCallback; const MockResizeObserver = { @@ -33,7 +32,7 @@ const MockResizeObserver = { }, }; -localVue.directive('gl-resize-observer', MockResizeObserver); +Vue.directive('gl-resize-observer', MockResizeObserver); jest.mock('@gitlab/ui/dist/utils'); @@ -67,7 +66,6 @@ describe('RunnerInstructionsModal component', () => { registrationToken: 'MY_TOKEN', ...props, }, - localVue, apolloProvider: fakeApollo, ...options, }), diff --git a/spec/frontend/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js b/spec/frontend/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js deleted file mode 100644 index e72b3bf45c4..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js +++ /dev/null @@ -1,103 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; - -import CollapsedGroupedDatePicker from '~/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue'; -import CollapsedCalendarIcon from '~/vue_shared/components/sidebar/collapsed_calendar_icon.vue'; - -describe('CollapsedGroupedDatePicker', () => { - let wrapper; - - const defaultProps = { - showToggleSidebar: true, - }; - - const minDate = new Date('07/17/2016'); - const maxDate = new Date('07/17/2017'); - - const createComponent = ({ props = {} } = {}) => { - wrapper = shallowMount(CollapsedGroupedDatePicker, { - propsData: { ...defaultProps, ...props }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - const findCollapsedCalendarIcon = () => wrapper.findComponent(CollapsedCalendarIcon); - const findAllCollapsedCalendarIcons = () => wrapper.findAllComponents(CollapsedCalendarIcon); - - describe('toggleCollapse events', () => { - it('should emit when collapsed-calendar-icon is clicked', () => { - createComponent(); - - findCollapsedCalendarIcon().trigger('click'); - - expect(wrapper.emitted('toggleCollapse')[0]).toBeDefined(); - }); - }); - - describe('minDate and maxDate', () => { - it('should render both collapsed-calendar-icon', () => { - createComponent({ - props: { - minDate, - maxDate, - }, - }); - - const icons = findAllCollapsedCalendarIcons(); - - expect(icons.length).toBe(2); - expect(icons.at(0).text()).toBe('Jul 17 2016'); - expect(icons.at(1).text()).toBe('Jul 17 2017'); - }); - }); - - describe('minDate', () => { - it('should render minDate in collapsed-calendar-icon', () => { - createComponent({ - props: { - minDate, - }, - }); - - const icons = findAllCollapsedCalendarIcons(); - - expect(icons.length).toBe(1); - expect(icons.at(0).text()).toBe('From Jul 17 2016'); - }); - }); - - describe('maxDate', () => { - it('should render maxDate in collapsed-calendar-icon', () => { - createComponent({ - props: { - maxDate, - }, - }); - const icons = findAllCollapsedCalendarIcons(); - - expect(icons.length).toBe(1); - expect(icons.at(0).text()).toBe('Until Jul 17 2017'); - }); - }); - - describe('no dates', () => { - beforeEach(() => { - createComponent(); - }); - - it('should render None', () => { - const icons = findAllCollapsedCalendarIcons(); - - expect(icons.length).toBe(1); - expect(icons.at(0).text()).toBe('None'); - }); - - it('should have tooltip as `Start and due date`', () => { - const icons = findAllCollapsedCalendarIcons(); - - expect(icons.at(0).props('tooltipText')).toBe('Start and due date'); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js index 59b170bfba9..c4ed975e746 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js @@ -1,5 +1,6 @@ import { GlIcon, GlButton } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import DropdownButton from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue'; @@ -9,8 +10,7 @@ import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue import { mockConfig } from './mock_data'; let store; -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); const createComponent = (initialState = mockConfig) => { store = new Vuex.Store(labelSelectModule()); @@ -18,7 +18,6 @@ const createComponent = (initialState = mockConfig) => { store.dispatch('setInitialState', initialState); return shallowMount(DropdownButton, { - localVue, store, }); }; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js index c4a645082e6..1fe85637a62 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js @@ -1,5 +1,6 @@ import { GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue'; @@ -8,8 +9,7 @@ import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue import { mockConfig, mockSuggestedColors } from './mock_data'; -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); const createComponent = (initialState = mockConfig) => { const store = new Vuex.Store(labelSelectModule()); @@ -17,7 +17,6 @@ const createComponent = (initialState = mockConfig) => { store.dispatch('setInitialState', initialState); return shallowMount(DropdownContentsCreateView, { - localVue, store, }); }; 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 e39e8794fdd..80b8edd28ba 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 @@ -5,7 +5,8 @@ import { GlSearchBoxByType, GlLink, } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue'; @@ -18,8 +19,7 @@ import defaultState from '~/vue_shared/components/sidebar/labels_select_vue/stor import { mockConfig, mockLabels, mockRegularLabel } from './mock_data'; -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); describe('DropdownContentsLabelsView', () => { let wrapper; @@ -43,7 +43,6 @@ describe('DropdownContentsLabelsView', () => { store.dispatch('receiveLabelsSuccess', mockLabels); wrapper = shallowMount(DropdownContentsLabelsView, { - localVue, store, }); }; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js index 88557917cb5..9781d9c4de0 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js @@ -1,4 +1,5 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; @@ -7,8 +8,7 @@ import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vu import { mockConfig } from './mock_data'; -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); const createComponent = (initialState = mockConfig, propsData = {}) => { const store = new Vuex.Store(labelsSelectModule()); @@ -17,7 +17,6 @@ const createComponent = (initialState = mockConfig, propsData = {}) => { return shallowMount(DropdownContents, { propsData, - localVue, store, }); }; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_title_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_title_spec.js index 726a113dbd9..110c1d1b7eb 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_title_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_title_spec.js @@ -1,5 +1,6 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue'; @@ -8,8 +9,7 @@ import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vu import { mockConfig } from './mock_data'; -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); const createComponent = (initialState = mockConfig) => { const store = new Vuex.Store(labelsSelectModule()); @@ -17,7 +17,6 @@ const createComponent = (initialState = mockConfig) => { store.dispatch('setInitialState', initialState); return shallowMount(DropdownTitle, { - localVue, store, propsData: { labelsSelectInProgress: false, diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js index 960ea77cb6e..f3c4839002b 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js @@ -1,5 +1,6 @@ import { GlLabel } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import DropdownValue from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue'; @@ -8,8 +9,7 @@ import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vu import { mockConfig, mockLabels, mockRegularLabel, mockScopedLabel } from './mock_data'; -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); describe('DropdownValue', () => { let wrapper; @@ -23,7 +23,6 @@ describe('DropdownValue', () => { store.dispatch('setInitialState', { ...mockConfig, ...initialState }); wrapper = shallowMount(DropdownValue, { - localVue, store, slots, }); 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 bc1ec8b812b..4b0ba075eda 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 @@ -1,4 +1,5 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import { isInViewport } from '~/lib/utils/common_utils'; @@ -18,8 +19,7 @@ jest.mock('~/lib/utils/common_utils', () => ({ isInViewport: jest.fn().mockReturnValue(true), })); -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); describe('LabelsSelectRoot', () => { let wrapper; @@ -27,7 +27,6 @@ describe('LabelsSelectRoot', () => { const createComponent = (config = mockConfig, slots = {}) => { wrapper = shallowMount(LabelsSelectRoot, { - localVue, slots, store, propsData: config, diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js index 1faa3b0af1d..884bc4684ba 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js @@ -75,7 +75,7 @@ export const mockSuggestedColors = { '#013220': 'Dark green', '#6699cc': 'Blue-gray', '#0000ff': 'Blue', - '#e6e6fa': 'Lavendar', + '#e6e6fa': 'Lavender', '#9400d3': 'Dark violet', '#330066': 'Deep violet', '#808080': 'Gray', 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 bf873f9162b..d8491334b5d 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 { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -18,8 +18,7 @@ jest.mock('~/flash'); const colors = Object.keys(mockSuggestedColors); -const localVue = createLocalVue(); -localVue.use(VueApollo); +Vue.use(VueApollo); const userRecoverableError = { ...createLabelSuccessfulResponse, @@ -63,7 +62,6 @@ describe('DropdownContentsCreateView', () => { }); wrapper = shallowMount(DropdownContentsCreateView, { - localVue, apolloProvider: mockApollo, propsData: { fullPath: '', 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 2980409fdce..6f5a4b7e613 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 @@ -4,8 +4,8 @@ import { GlDropdownItem, GlIntersectionObserver, } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -19,8 +19,7 @@ import { mockConfig, workspaceLabelsQueryResponse } from './mock_data'; jest.mock('~/flash'); -const localVue = createLocalVue(); -localVue.use(VueApollo); +Vue.use(VueApollo); const localSelectedLabels = [ { @@ -47,7 +46,6 @@ describe('DropdownContentsLabelsView', () => { const mockApollo = createMockApollo([[projectLabelsQuery, queryHandler]]); wrapper = shallowMount(DropdownContentsLabelsView, { - localVue, apolloProvider: mockApollo, provide: { variant: DropdownVariant.Sidebar, 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 8bcef347c96..00da9b74957 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 @@ -4,12 +4,12 @@ import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_w import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue'; import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue'; -import DropdownHeader from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue'; import DropdownFooter from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue'; import { mockLabels } from './mock_data'; const showDropdown = jest.fn(); +const focusInput = jest.fn(); const GlDropdownStub = { template: ` @@ -25,6 +25,15 @@ const GlDropdownStub = { }, }; +const DropdownHeaderStub = { + template: ` + <div>Hello, I am a header</div> + `, + methods: { + focusInput, + }, +}; + describe('DropdownContent', () => { let wrapper; @@ -52,6 +61,7 @@ describe('DropdownContent', () => { }, stubs: { GlDropdown: GlDropdownStub, + DropdownHeader: DropdownHeaderStub, }, }); }; @@ -62,7 +72,7 @@ describe('DropdownContent', () => { const findCreateView = () => wrapper.findComponent(DropdownContentsCreateView); const findLabelsView = () => wrapper.findComponent(DropdownContentsLabelsView); - const findDropdownHeader = () => wrapper.findComponent(DropdownHeader); + const findDropdownHeader = () => wrapper.findComponent(DropdownHeaderStub); const findDropdownFooter = () => wrapper.findComponent(DropdownFooter); const findDropdown = () => wrapper.findComponent(GlDropdownStub); @@ -114,19 +124,7 @@ describe('DropdownContent', () => { expect(wrapper.emitted('setLabels')).toEqual([[[updatedLabel]]]); }); - it('does not render header on standalone variant', () => { - createComponent({ props: { variant: DropdownVariant.Standalone } }); - - expect(findDropdownHeader().exists()).toBe(false); - }); - - it('renders header on embedded variant', () => { - createComponent({ props: { variant: DropdownVariant.Embedded } }); - - expect(findDropdownHeader().exists()).toBe(true); - }); - - it('renders header on sidebar variant', () => { + it('renders header', () => { createComponent(); expect(findDropdownHeader().exists()).toBe(true); @@ -135,11 +133,20 @@ describe('DropdownContent', () => { it('sets searchKey for labels view on input event from header', async () => { createComponent(); - expect(wrapper.vm.searchKey).toEqual(''); + expect(findLabelsView().props('searchKey')).toBe(''); findDropdownHeader().vm.$emit('input', '123'); await nextTick(); - expect(findLabelsView().props('searchKey')).toEqual('123'); + expect(findLabelsView().props('searchKey')).toBe('123'); + }); + + it('clears and focuses search input on selecting a label', () => { + createComponent(); + findDropdownHeader().vm.$emit('input', '123'); + findLabelsView().vm.$emit('input', []); + + expect(findLabelsView().props('searchKey')).toBe(''); + expect(focusInput).toHaveBeenCalled(); }); describe('Create view', () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_header_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_header_spec.js index 592559ef305..c4faef8ccdd 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_header_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_header_spec.js @@ -9,6 +9,7 @@ describe('DropdownHeader', () => { const createComponent = ({ showDropdownContentsCreateView = false, labelsFetchInProgress = false, + isStandalone = false, } = {}) => { wrapper = extendedWrapper( shallowMount(DropdownHeader, { @@ -18,6 +19,7 @@ describe('DropdownHeader', () => { labelsCreateTitle: 'Create label', labelsListTitle: 'Select label', searchKey: '', + isStandalone, }, stubs: { GlSearchBoxByType, @@ -32,6 +34,7 @@ describe('DropdownHeader', () => { const findSearchInput = () => wrapper.findComponent(GlSearchBoxByType); const findGoBackButton = () => wrapper.findByTestId('go-back-button'); + const findDropdownTitle = () => wrapper.findByTestId('dropdown-header-title'); beforeEach(() => { createComponent(); @@ -72,4 +75,18 @@ describe('DropdownHeader', () => { }, ); }); + + describe('Standalone variant', () => { + beforeEach(() => { + createComponent({ isStandalone: true }); + }); + + it('renders search input', () => { + expect(findSearchInput().exists()).toBe(true); + }); + + it('does not render title', async () => { + expect(findDropdownTitle().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js index e7e78cd7a33..0c4f4b7d504 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js @@ -95,5 +95,10 @@ describe('DropdownValue', () => { findRegularLabel().vm.$emit('close'); expect(wrapper.emitted('onLabelRemove')).toEqual([[mockRegularLabel.id]]); }); + + it('emits `onCollapsedValueClick` when clicking on collapsed value', () => { + wrapper.find('.sidebar-collapsed-icon').trigger('click'); + expect(wrapper.emitted('onCollapsedValueClick')).toEqual([[]]); + }); }); }); 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 d4203528874..a4199bb3e27 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 @@ -1,25 +1,34 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue'; import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue'; import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql'; +import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; +import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql'; +import updateEpicLabelsMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql'; import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; -import { mockConfig, issuableLabelsQueryResponse } from './mock_data'; +import { mockConfig, issuableLabelsQueryResponse, updateLabelsMutationResponse } from './mock_data'; jest.mock('~/flash'); -const localVue = createLocalVue(); -localVue.use(VueApollo); +Vue.use(VueApollo); const successfulQueryHandler = jest.fn().mockResolvedValue(issuableLabelsQueryResponse); +const successfulMutationHandler = jest.fn().mockResolvedValue(updateLabelsMutationResponse); const errorQueryHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); +const updateLabelsMutation = { + [IssuableType.Issue]: updateIssueLabelsMutation, + [IssuableType.MergeRequest]: updateMergeRequestLabelsMutation, + [IssuableType.Epic]: updateEpicLabelsMutation, +}; + describe('LabelsSelectRoot', () => { let wrapper; @@ -30,17 +39,21 @@ describe('LabelsSelectRoot', () => { const createComponent = ({ config = mockConfig, slots = {}, + issuableType = IssuableType.Issue, queryHandler = successfulQueryHandler, + mutationHandler = successfulMutationHandler, } = {}) => { - const mockApollo = createMockApollo([[issueLabelsQuery, queryHandler]]); + const mockApollo = createMockApollo([ + [issueLabelsQuery, queryHandler], + [updateLabelsMutation[issuableType], mutationHandler], + ]); wrapper = shallowMount(LabelsSelectRoot, { slots, apolloProvider: mockApollo, - localVue, propsData: { ...config, - issuableType: IssuableType.Issue, + issuableType, labelCreateType: 'project', workspaceType: 'project', }, @@ -60,9 +73,9 @@ describe('LabelsSelectRoot', () => { wrapper.destroy(); }); - it('renders component with classes `labels-select-wrapper position-relative`', () => { + it('renders component with classes `labels-select-wrapper gl-relative`', () => { createComponent(); - expect(wrapper.classes()).toEqual(['labels-select-wrapper', 'position-relative']); + expect(wrapper.classes()).toEqual(['labels-select-wrapper', 'gl-relative']); }); it.each` @@ -130,4 +143,46 @@ describe('LabelsSelectRoot', () => { findDropdownContents().vm.$emit('setLabels', [label]); expect(wrapper.emitted('updateSelectedLabels')).toEqual([[{ labels: [label] }]]); }); + + describe.each` + issuableType + ${IssuableType.Issue} + ${IssuableType.MergeRequest} + ${IssuableType.Epic} + `('when updating labels for $issuableType', ({ issuableType }) => { + const label = { id: 'gid://gitlab/ProjectLabel/2' }; + + it('sets the loading state', async () => { + createComponent({ issuableType }); + await nextTick(); + findDropdownContents().vm.$emit('setLabels', [label]); + await nextTick(); + + expect(findSidebarEditableItem().props('loading')).toBe(true); + }); + + it('updates labels correctly after successful mutation', async () => { + createComponent({ issuableType }); + await nextTick(); + findDropdownContents().vm.$emit('setLabels', [label]); + await waitForPromises(); + + expect(findDropdownValue().props('selectedLabels')).toEqual( + updateLabelsMutationResponse.data.updateIssuableLabels.issuable.labels.nodes, + ); + }); + + it('displays an error if mutation was rejected', async () => { + createComponent({ issuableType, mutationHandler: errorQueryHandler }); + await nextTick(); + findDropdownContents().vm.$emit('setLabels', [label]); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + captureError: true, + error: expect.anything(), + message: 'An error occurred while updating labels.', + }); + }); + }); }); 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 5c5bf5f2187..6ef54ce37ce 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 @@ -118,7 +118,9 @@ export const workspaceLabelsQueryResponse = { export const issuableLabelsQueryResponse = { data: { workspace: { + id: 'workspace-1', issuable: { + __typename: 'Issue', id: '1', labels: { nodes: [ @@ -135,3 +137,18 @@ export const issuableLabelsQueryResponse = { }, }, }; + +export const updateLabelsMutationResponse = { + data: { + updateIssuableLabels: { + errors: [], + issuable: { + __typename: 'Issue', + id: '1', + labels: { + nodes: [], + }, + }, + }, + }, +}; diff --git a/spec/frontend/vue_shared/components/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer_spec.js new file mode 100644 index 00000000000..758068379de --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer_spec.js @@ -0,0 +1,59 @@ +import hljs from 'highlight.js/lib/core'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import SourceViewer from '~/vue_shared/components/source_viewer.vue'; +import LineNumbers from '~/vue_shared/components/line_numbers.vue'; +import waitForPromises from 'helpers/wait_for_promises'; + +jest.mock('highlight.js/lib/core'); + +describe('Source Viewer component', () => { + let wrapper; + const content = `// Some source code`; + const highlightedContent = `<span data-testid='test-highlighted'>${content}</span>`; + const language = 'javascript'; + + hljs.highlight.mockImplementation(() => ({ value: highlightedContent })); + hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent })); + + const createComponent = async (props = {}) => { + wrapper = shallowMountExtended(SourceViewer, { propsData: { content, language, ...props } }); + await waitForPromises(); + }; + + const findLineNumbers = () => wrapper.findComponent(LineNumbers); + const findHighlightedContent = () => wrapper.findByTestId('test-highlighted'); + + beforeEach(() => createComponent()); + + afterEach(() => wrapper.destroy()); + + describe('highlight.js', () => { + it('registers the language definition', async () => { + const languageDefinition = await import(`highlight.js/lib/languages/${language}`); + + expect(hljs.registerLanguage).toHaveBeenCalledWith(language, languageDefinition.default); + }); + + it('highlights the content', () => { + expect(hljs.highlight).toHaveBeenCalledWith(content, { language }); + }); + + describe('auto-detect enabled', () => { + beforeEach(() => createComponent({ autoDetect: true })); + + it('highlights the content with auto-detection', () => { + expect(hljs.highlightAuto).toHaveBeenCalledWith(content); + }); + }); + }); + + describe('rendering', () => { + it('renders Line Numbers', () => { + expect(findLineNumbers().props('lines')).toBe(1); + }); + + it('renders the highlighted content', () => { + expect(findHighlightedContent().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/storage_counter/usage_graph_spec.js b/spec/frontend/vue_shared/components/storage_counter/usage_graph_spec.js deleted file mode 100644 index 103eee4b9a8..00000000000 --- a/spec/frontend/vue_shared/components/storage_counter/usage_graph_spec.js +++ /dev/null @@ -1,137 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { numberToHumanSize } from '~/lib/utils/number_utils'; -import UsageGraph from '~/vue_shared/components/storage_counter/usage_graph.vue'; - -let data; -let wrapper; - -function mountComponent({ rootStorageStatistics, limit }) { - wrapper = shallowMount(UsageGraph, { - propsData: { - rootStorageStatistics, - limit, - }, - }); -} -function findStorageTypeUsagesSerialized() { - return wrapper - .findAll('[data-testid="storage-type-usage"]') - .wrappers.map((wp) => wp.element.style.flex); -} - -describe('Storage Counter usage graph component', () => { - beforeEach(() => { - data = { - rootStorageStatistics: { - wikiSize: 5000, - repositorySize: 4000, - packagesSize: 3000, - lfsObjectsSize: 2000, - buildArtifactsSize: 500, - pipelineArtifactsSize: 500, - snippetsSize: 2000, - storageSize: 17000, - uploadsSize: 1000, - }, - limit: 2000, - }; - mountComponent(data); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders the legend in order', () => { - const types = wrapper.findAll('[data-testid="storage-type-legend"]'); - - const { - buildArtifactsSize, - pipelineArtifactsSize, - lfsObjectsSize, - packagesSize, - repositorySize, - wikiSize, - snippetsSize, - uploadsSize, - } = data.rootStorageStatistics; - - expect(types.at(0).text()).toMatchInterpolatedText(`Wikis ${numberToHumanSize(wikiSize)}`); - expect(types.at(1).text()).toMatchInterpolatedText( - `Repositories ${numberToHumanSize(repositorySize)}`, - ); - expect(types.at(2).text()).toMatchInterpolatedText( - `Packages ${numberToHumanSize(packagesSize)}`, - ); - expect(types.at(3).text()).toMatchInterpolatedText( - `LFS Objects ${numberToHumanSize(lfsObjectsSize)}`, - ); - expect(types.at(4).text()).toMatchInterpolatedText( - `Snippets ${numberToHumanSize(snippetsSize)}`, - ); - expect(types.at(5).text()).toMatchInterpolatedText( - `Artifacts ${numberToHumanSize(buildArtifactsSize + pipelineArtifactsSize)}`, - ); - expect(types.at(6).text()).toMatchInterpolatedText(`Uploads ${numberToHumanSize(uploadsSize)}`); - }); - - describe('when storage type is not used', () => { - beforeEach(() => { - data.rootStorageStatistics.wikiSize = 0; - mountComponent(data); - }); - - it('filters the storage type', () => { - expect(wrapper.text()).not.toContain('Wikis'); - }); - }); - - describe('when there is no storage usage', () => { - beforeEach(() => { - data.rootStorageStatistics.storageSize = 0; - mountComponent(data); - }); - - it('it does not render', () => { - expect(wrapper.html()).toEqual(''); - }); - }); - - describe('when limit is 0', () => { - beforeEach(() => { - data.limit = 0; - mountComponent(data); - }); - - it('sets correct flex values', () => { - expect(findStorageTypeUsagesSerialized()).toStrictEqual([ - '0.29411764705882354', - '0.23529411764705882', - '0.17647058823529413', - '0.11764705882352941', - '0.11764705882352941', - '0.058823529411764705', - '0.058823529411764705', - ]); - }); - }); - - describe('when storage exceeds limit', () => { - beforeEach(() => { - data.limit = data.rootStorageStatistics.storageSize - 1; - mountComponent(data); - }); - - it('it does render correclty', () => { - expect(findStorageTypeUsagesSerialized()).toStrictEqual([ - '0.29411764705882354', - '0.23529411764705882', - '0.17647058823529413', - '0.11764705882352941', - '0.11764705882352941', - '0.058823529411764705', - '0.058823529411764705', - ]); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js index 380b7231acd..9e7e5c1263f 100644 --- a/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js +++ b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js @@ -1,25 +1,20 @@ import { mount, shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import { hasHorizontalOverflow } from '~/lib/utils/dom_utils'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -const DUMMY_TEXT = 'lorem-ipsum-dolar-sit-amit-consectur-adipiscing-elit-sed-do'; +const MOCK_TITLE = 'lorem-ipsum-dolar-sit-amit-consectur-adipiscing-elit-sed-do'; +const SHORT_TITLE = 'my-text'; -const createChildElement = () => `<a href="#">${DUMMY_TEXT}</a>`; +const createChildElement = () => `<a href="#">${MOCK_TITLE}</a>`; jest.mock('~/lib/utils/dom_utils', () => ({ - hasHorizontalOverflow: jest.fn(() => { + ...jest.requireActual('~/lib/utils/dom_utils'), + hasHorizontalOverflow: jest.fn().mockImplementation(() => { throw new Error('this needs to be mocked'); }), })); -jest.mock('@gitlab/ui', () => ({ - GlTooltipDirective: { - bind(el, binding) { - el.classList.add('gl-tooltip'); - el.setAttribute('data-original-title', el.title); - el.dataset.placement = binding.value.placement; - }, - }, -})); describe('TooltipOnTruncate component', () => { let wrapper; @@ -27,15 +22,31 @@ describe('TooltipOnTruncate component', () => { const createComponent = ({ propsData, ...options } = {}) => { wrapper = shallowMount(TooltipOnTruncate, { - attachTo: document.body, propsData: { + title: MOCK_TITLE, ...propsData, }, + slots: { + default: [MOCK_TITLE], + }, + directives: { + GlTooltip: createMockDirective(), + GlResizeObserver: createMockDirective(), + }, ...options, }); }; const createWrappedComponent = ({ propsData, ...options }) => { + const WrappedTooltipOnTruncate = { + ...TooltipOnTruncate, + directives: { + ...TooltipOnTruncate.directives, + GlTooltip: createMockDirective(), + GlResizeObserver: createMockDirective(), + }, + }; + // set a parent around the tested component parent = mount( { @@ -43,74 +54,85 @@ describe('TooltipOnTruncate component', () => { title: { default: '' }, }, template: ` - <TooltipOnTruncate :title="title" truncate-target="child"> - <div>{{title}}</div> - </TooltipOnTruncate> + <TooltipOnTruncate :title="title" truncate-target="child"> + <div>{{title}}</div> + </TooltipOnTruncate> `, components: { - TooltipOnTruncate, + TooltipOnTruncate: WrappedTooltipOnTruncate, }, }, { propsData: { ...propsData }, - attachTo: document.body, ...options, }, ); - wrapper = parent.find(TooltipOnTruncate); + wrapper = parent.find(WrappedTooltipOnTruncate); }; - const hasTooltip = () => wrapper.classes('gl-tooltip'); + const getTooltipValue = () => getBinding(wrapper.element, 'gl-tooltip')?.value; + const resize = async ({ truncate }) => { + hasHorizontalOverflow.mockReturnValueOnce(truncate); + getBinding(wrapper.element, 'gl-resize-observer').value(); + await nextTick(); + }; afterEach(() => { wrapper.destroy(); }); - describe('with default target', () => { - it('renders tooltip if truncated', () => { + describe('when truncated', () => { + beforeEach(async () => { hasHorizontalOverflow.mockReturnValueOnce(true); - createComponent({ - propsData: { - title: DUMMY_TEXT, - }, - slots: { - default: [DUMMY_TEXT], - }, - }); + createComponent(); + }); - return wrapper.vm.$nextTick().then(() => { - expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element); - expect(hasTooltip()).toBe(true); - expect(wrapper.attributes('data-original-title')).toEqual(DUMMY_TEXT); - expect(wrapper.attributes('data-placement')).toEqual('top'); + it('renders tooltip', async () => { + expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element); + expect(getTooltipValue()).toMatchObject({ + title: MOCK_TITLE, + placement: 'top', + disabled: false, }); + expect(wrapper.classes('js-show-tooltip')).toBe(true); }); + }); - it('does not render tooltip if normal', () => { + describe('with default target', () => { + beforeEach(async () => { hasHorizontalOverflow.mockReturnValueOnce(false); - createComponent({ - propsData: { - title: DUMMY_TEXT, - }, - slots: { - default: [DUMMY_TEXT], - }, + createComponent(); + }); + + it('does not render tooltip if not truncated', () => { + expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element); + expect(getTooltipValue()).toMatchObject({ + disabled: true, }); + expect(wrapper.classes('js-show-tooltip')).toBe(false); + }); - return wrapper.vm.$nextTick().then(() => { - expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element); - expect(hasTooltip()).toBe(false); + it('renders tooltip on resize', async () => { + await resize({ truncate: true }); + + expect(getTooltipValue()).toMatchObject({ + disabled: false, + }); + + await resize({ truncate: false }); + + expect(getTooltipValue()).toMatchObject({ + disabled: true, }); }); }); describe('with child target', () => { - it('renders tooltip if truncated', () => { + it('renders tooltip if truncated', async () => { hasHorizontalOverflow.mockReturnValueOnce(true); createComponent({ propsData: { - title: DUMMY_TEXT, truncateTarget: 'child', }, slots: { @@ -118,13 +140,18 @@ describe('TooltipOnTruncate component', () => { }, }); - return wrapper.vm.$nextTick().then(() => { - expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[0]); - expect(hasTooltip()).toBe(true); + expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element.childNodes[0]); + + await nextTick(); + + expect(getTooltipValue()).toMatchObject({ + title: MOCK_TITLE, + placement: 'top', + disabled: false, }); }); - it('does not render tooltip if normal', () => { + it('does not render tooltip if normal', async () => { hasHorizontalOverflow.mockReturnValueOnce(false); createComponent({ propsData: { @@ -135,19 +162,21 @@ describe('TooltipOnTruncate component', () => { }, }); - return wrapper.vm.$nextTick().then(() => { - expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[0]); - expect(hasTooltip()).toBe(false); + expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element.childNodes[0]); + + await nextTick(); + + expect(getTooltipValue()).toMatchObject({ + disabled: true, }); }); }); describe('with fn target', () => { - it('renders tooltip if truncated', () => { + it('renders tooltip if truncated', async () => { hasHorizontalOverflow.mockReturnValueOnce(true); createComponent({ propsData: { - title: DUMMY_TEXT, truncateTarget: (el) => el.childNodes[1], }, slots: { @@ -155,93 +184,97 @@ describe('TooltipOnTruncate component', () => { }, }); - return wrapper.vm.$nextTick().then(() => { - expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[1]); - expect(hasTooltip()).toBe(true); + expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element.childNodes[1]); + + await nextTick(); + + expect(getTooltipValue()).toMatchObject({ + disabled: false, }); }); }); describe('placement', () => { - it('sets data-placement when tooltip is rendered', () => { - const placement = 'bottom'; + it('sets placement when tooltip is rendered', () => { + const mockPlacement = 'bottom'; hasHorizontalOverflow.mockReturnValueOnce(true); createComponent({ propsData: { - placement, - }, - slots: { - default: DUMMY_TEXT, + placement: mockPlacement, }, }); - return wrapper.vm.$nextTick().then(() => { - expect(hasTooltip()).toBe(true); - expect(wrapper.attributes('data-placement')).toEqual(placement); + expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element); + expect(getTooltipValue()).toMatchObject({ + placement: mockPlacement, }); }); }); describe('updates when title and slot content changes', () => { describe('is initialized with a long text', () => { - beforeEach(() => { + beforeEach(async () => { hasHorizontalOverflow.mockReturnValueOnce(true); createWrappedComponent({ - propsData: { title: DUMMY_TEXT }, + propsData: { title: MOCK_TITLE }, }); - return parent.vm.$nextTick(); + await nextTick(); }); it('renders tooltip', () => { - expect(hasTooltip()).toBe(true); - expect(wrapper.attributes('data-original-title')).toEqual(DUMMY_TEXT); - expect(wrapper.attributes('data-placement')).toEqual('top'); + expect(getTooltipValue()).toMatchObject({ + title: MOCK_TITLE, + placement: 'top', + disabled: false, + }); }); - it('does not render tooltip after updated to a short text', () => { + it('does not render tooltip after updated to a short text', async () => { hasHorizontalOverflow.mockReturnValueOnce(false); parent.setProps({ - title: 'new-text', + title: SHORT_TITLE, }); - return wrapper.vm - .$nextTick() - .then(() => wrapper.vm.$nextTick()) // wait 2 times to get an updated slot - .then(() => { - expect(hasTooltip()).toBe(false); - }); + await nextTick(); + await nextTick(); // wait 2 times to get an updated slot + + expect(getTooltipValue()).toMatchObject({ + title: SHORT_TITLE, + disabled: true, + }); }); }); - describe('is initialized with a short text', () => { - beforeEach(() => { + describe('is initialized with a short text that does not overflow', () => { + beforeEach(async () => { hasHorizontalOverflow.mockReturnValueOnce(false); createWrappedComponent({ - propsData: { title: DUMMY_TEXT }, + propsData: { title: MOCK_TITLE }, }); - return wrapper.vm.$nextTick(); + await nextTick(); }); it('does not render tooltip', () => { - expect(hasTooltip()).toBe(false); + expect(getTooltipValue()).toMatchObject({ + title: MOCK_TITLE, + disabled: true, + }); }); - it('renders tooltip after text is updated', () => { + it('renders tooltip after text is updated', async () => { hasHorizontalOverflow.mockReturnValueOnce(true); - const newText = 'new-text'; parent.setProps({ - title: newText, + title: SHORT_TITLE, }); - return wrapper.vm - .$nextTick() - .then(() => wrapper.vm.$nextTick()) // wait 2 times to get an updated slot - .then(() => { - expect(hasTooltip()).toBe(true); - expect(wrapper.attributes('data-original-title')).toEqual(newText); - expect(wrapper.attributes('data-placement')).toEqual('top'); - }); + await nextTick(); + await nextTick(); // wait 2 times to get an updated slot + + expect(getTooltipValue()).toMatchObject({ + title: SHORT_TITLE, + disabled: false, + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/user_select_spec.js b/spec/frontend/vue_shared/components/user_select_spec.js index b777ac0a0a4..8994e16e517 100644 --- a/spec/frontend/vue_shared/components/user_select_spec.js +++ b/spec/frontend/vue_shared/components/user_select_spec.js @@ -1,7 +1,7 @@ import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import { cloneDeep } from 'lodash'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -33,8 +33,7 @@ const waitForSearch = async () => { await waitForPromises(); }; -const localVue = createLocalVue(); -localVue.use(VueApollo); +Vue.use(VueApollo); describe('User select dropdown', () => { let wrapper; @@ -62,7 +61,6 @@ describe('User select dropdown', () => { [getIssueParticipantsQuery, participantsQueryHandler], ]); wrapper = shallowMount(UserSelect, { - localVue, apolloProvider: fakeApollo, propsData: { headerText: 'test', diff --git a/spec/frontend/vue_shared/components/vuex_module_provider_spec.js b/spec/frontend/vue_shared/components/vuex_module_provider_spec.js index ebd396bd87c..c136c2054ac 100644 --- a/spec/frontend/vue_shared/components/vuex_module_provider_spec.js +++ b/spec/frontend/vue_shared/components/vuex_module_provider_spec.js @@ -1,4 +1,4 @@ -import { mount, createLocalVue } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue'; @@ -38,10 +38,9 @@ describe('~/vue_shared/components/vuex_module_provider', () => { it('does not blow up when used with vue-apollo', () => { // See https://github.com/vuejs/vue-apollo/pull/1153 for details - const localVue = createLocalVue(); - localVue.use(VueApollo); + Vue.use(VueApollo); - createComponent({ localVue }); + createComponent(); expect(findProvidedVuexModule()).toBe(TEST_VUEX_MODULE); }); }); diff --git a/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js b/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js index 3fb60c254c9..7738a69a174 100644 --- a/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js +++ b/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js @@ -1,9 +1,8 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import GlFeatureFlags from '~/vue_shared/gl_feature_flags_plugin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -const localVue = createLocalVue(); - describe('GitLab Feature Flags Plugin', () => { beforeEach(() => { window.gon = { @@ -17,7 +16,7 @@ describe('GitLab Feature Flags Plugin', () => { }, }; - localVue.use(GlFeatureFlags); + Vue.use(GlFeatureFlags); }); it('should provide glFeatures to components', () => { @@ -25,7 +24,7 @@ describe('GitLab Feature Flags Plugin', () => { template: `<span></span>`, inject: ['glFeatures'], }; - const wrapper = shallowMount(component, { localVue }); + const wrapper = shallowMount(component); expect(wrapper.vm.glFeatures).toEqual({ aFeature: true, bFeature: false, @@ -39,7 +38,7 @@ describe('GitLab Feature Flags Plugin', () => { template: `<span></span>`, mixins: [glFeatureFlagsMixin()], }; - const wrapper = shallowMount(component, { localVue }); + const wrapper = shallowMount(component); expect(wrapper.vm.glFeatures).toEqual({ aFeature: true, bFeature: false, diff --git a/spec/frontend/issuable_create/components/issuable_create_root_spec.js b/spec/frontend/vue_shared/issuable/create/components/issuable_create_root_spec.js index 675d01ae4af..81362edaf37 100644 --- a/spec/frontend/issuable_create/components/issuable_create_root_spec.js +++ b/spec/frontend/vue_shared/issuable/create/components/issuable_create_root_spec.js @@ -1,7 +1,7 @@ import { mount } from '@vue/test-utils'; -import IssuableCreateRoot from '~/issuable_create/components/issuable_create_root.vue'; -import IssuableForm from '~/issuable_create/components/issuable_form.vue'; +import IssuableCreateRoot from '~/vue_shared/issuable/create/components/issuable_create_root.vue'; +import IssuableForm from '~/vue_shared/issuable/create/components/issuable_form.vue'; const createComponent = ({ descriptionPreviewPath = '/gitlab-org/gitlab-shell/preview_markdown', diff --git a/spec/frontend/issuable_create/components/issuable_form_spec.js b/spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js index 30b116bc35c..cbfd05e7903 100644 --- a/spec/frontend/issuable_create/components/issuable_form_spec.js +++ b/spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js @@ -1,7 +1,7 @@ import { GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import IssuableForm from '~/issuable_create/components/issuable_form.vue'; +import IssuableForm from '~/vue_shared/issuable/create/components/issuable_form.vue'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; diff --git a/spec/frontend/issuable_list/components/issuable_bulk_edit_sidebar_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar_spec.js index 52a238eac7c..0f33a3d1122 100644 --- a/spec/frontend/issuable_list/components/issuable_bulk_edit_sidebar_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import IssuableBulkEditSidebar from '~/issuable_list/components/issuable_bulk_edit_sidebar.vue'; +import IssuableBulkEditSidebar from '~/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue'; const createComponent = ({ expanded = true } = {}) => shallowMount(IssuableBulkEditSidebar, { diff --git a/spec/frontend/issuable_list/components/issuable_item_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js index ac3bf7f3269..e38a80e7734 100644 --- a/spec/frontend/issuable_list/components/issuable_item_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js @@ -1,19 +1,25 @@ import { GlLink, GlLabel, GlIcon, GlFormCheckbox, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import { useFakeDate } from 'helpers/fake_date'; -import IssuableItem from '~/issuable_list/components/issuable_item.vue'; -import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; +import { shallowMountExtended as shallowMount } from 'helpers/vue_test_utils_helper'; +import IssuableItem from '~/vue_shared/issuable/list/components/issuable_item.vue'; +import IssuableAssignees from '~/issuable/components/issue_assignees.vue'; import { mockIssuable, mockRegularLabel, mockScopedLabel } from '../mock_data'; -const createComponent = ({ issuableSymbol = '#', issuable = mockIssuable, slots = {} } = {}) => +const createComponent = ({ + issuableSymbol = '#', + issuable = mockIssuable, + enableLabelPermalinks = true, + showCheckbox = true, + slots = {}, +} = {}) => shallowMount(IssuableItem, { propsData: { issuableSymbol, issuable, - enableLabelPermalinks: true, + enableLabelPermalinks, showDiscussions: true, - showCheckbox: false, + showCheckbox, }, slots, stubs: { @@ -34,7 +40,6 @@ describe('IssuableItem', () => { beforeEach(() => { gon.gitlab_url = MOCK_GITLAB_URL; - wrapper = createComponent(); }); afterEach(() => { @@ -45,6 +50,8 @@ describe('IssuableItem', () => { describe('computed', () => { describe('author', () => { it('returns `issuable.author` reference', () => { + wrapper = createComponent(); + expect(wrapper.vm.author).toEqual(mockIssuable.author); }); }); @@ -59,7 +66,7 @@ describe('IssuableItem', () => { `( 'returns $returnValue when value of `issuable.author.id` is $authorId', async ({ authorId, returnValue }) => { - wrapper.setProps({ + wrapper = createComponent({ issuable: { ...mockIssuable, author: { @@ -86,7 +93,7 @@ describe('IssuableItem', () => { `( 'returns $returnValue when `issuable.webUrl` is $urlType', async ({ issuableWebUrl, returnValue }) => { - wrapper.setProps({ + wrapper = createComponent({ issuable: { ...mockIssuable, webUrl: issuableWebUrl, @@ -102,11 +109,13 @@ describe('IssuableItem', () => { describe('labels', () => { it('returns `issuable.labels.nodes` reference when it is available', () => { + wrapper = createComponent(); + expect(wrapper.vm.labels).toEqual(mockLabels); }); it('returns `issuable.labels` reference when it is available', async () => { - wrapper.setProps({ + wrapper = createComponent({ issuable: { ...mockIssuable, labels: mockLabels, @@ -119,7 +128,7 @@ describe('IssuableItem', () => { }); it('returns empty array when none of `issuable.labels.nodes` or `issuable.labels` are available', async () => { - wrapper.setProps({ + wrapper = createComponent({ issuable: { ...mockIssuable, labels: null, @@ -134,12 +143,16 @@ describe('IssuableItem', () => { describe('assignees', () => { it('returns `issuable.assignees` reference when it is available', () => { + wrapper = createComponent(); + expect(wrapper.vm.assignees).toBe(mockIssuable.assignees); }); }); describe('updatedAt', () => { it('returns string containing timeago string based on `issuable.updatedAt`', () => { + wrapper = createComponent(); + expect(wrapper.vm.updatedAt).toContain('updated'); expect(wrapper.vm.updatedAt).toContain('ago'); }); @@ -155,7 +168,7 @@ describe('IssuableItem', () => { `( 'returns $returnValue when issuable.userDiscussionsCount is $userDiscussionsCount', ({ userDiscussionsCount, returnValue }) => { - const wrapperWithDiscussions = createComponent({ + wrapper = createComponent({ issuableSymbol: '#', issuable: { ...mockIssuable, @@ -163,9 +176,7 @@ describe('IssuableItem', () => { }, }); - expect(wrapperWithDiscussions.vm.showDiscussions).toBe(returnValue); - - wrapperWithDiscussions.destroy(); + expect(wrapper.findByTestId('issuable-discussions').exists()).toBe(returnValue); }, ); }); @@ -180,6 +191,8 @@ describe('IssuableItem', () => { `( 'return $returnValue when provided label param is a $labelType label', ({ label, returnValue }) => { + wrapper = createComponent(); + expect(wrapper.vm.scopedLabel(label)).toBe(returnValue); }, ); @@ -191,19 +204,23 @@ describe('IssuableItem', () => { ${{ title: 'foo' }} | ${'title'} | ${'foo'} ${{ name: 'foo' }} | ${'name'} | ${'foo'} `('returns string value of `label.$propWithTitle`', ({ label, returnValue }) => { + wrapper = createComponent(); + expect(wrapper.vm.labelTitle(label)).toBe(returnValue); }); }); describe('labelTarget', () => { it('returns target string for a provided label param when `enableLabelPermalinks` is true', () => { + wrapper = createComponent(); + expect(wrapper.vm.labelTarget(mockRegularLabel)).toBe( '?label_name[]=Documentation%20Update', ); }); it('returns string "#" for a provided label param when `enableLabelPermalinks` is false', async () => { - wrapper.setProps({ + wrapper = createComponent({ enableLabelPermalinks: false, }); @@ -223,7 +240,7 @@ describe('IssuableItem', () => { `( 'renders issuable title correctly when `gitlabWebUrl` is `$gitlabWebUrl` and webUrl is `$webUrl`', async ({ webUrl, gitlabWebUrl, expectedHref, expectedTarget }) => { - wrapper.setProps({ + wrapper = createComponent({ issuable: { ...mockIssuable, webUrl, @@ -243,7 +260,7 @@ describe('IssuableItem', () => { ); it('renders checkbox when `showCheckbox` prop is true', async () => { - wrapper.setProps({ + wrapper = createComponent({ showCheckbox: true, }); @@ -262,7 +279,7 @@ describe('IssuableItem', () => { }); it('renders issuable title with `target` set as "_blank" when issuable.webUrl is external', async () => { - wrapper.setProps({ + wrapper = createComponent({ issuable: { ...mockIssuable, webUrl: 'http://jira.atlassian.net/browse/IG-1', @@ -277,7 +294,7 @@ describe('IssuableItem', () => { }); it('renders issuable confidential icon when issuable is confidential', async () => { - wrapper.setProps({ + wrapper = createComponent({ issuable: { ...mockIssuable, confidential: true, @@ -296,7 +313,21 @@ describe('IssuableItem', () => { }); }); + it('renders spam icon when issuable is hidden', async () => { + wrapper = createComponent({ issuable: { ...mockIssuable, hidden: true } }); + + const hiddenIcon = wrapper.findComponent(GlIcon); + + expect(hiddenIcon.props('name')).toBe('spam'); + expect(hiddenIcon.attributes()).toMatchObject({ + title: 'This issue is hidden because its author has been banned', + arialabel: 'Hidden', + }); + }); + it('renders task status', () => { + wrapper = createComponent(); + const taskStatus = wrapper.find('[data-testid="task-status"]'); const expected = `${mockIssuable.taskCompletionStatus.completedCount} of ${mockIssuable.taskCompletionStatus.count} tasks completed`; @@ -304,6 +335,8 @@ describe('IssuableItem', () => { }); it('renders issuable reference', () => { + wrapper = createComponent(); + const referenceEl = wrapper.find('[data-testid="issuable-reference"]'); expect(referenceEl.exists()).toBe(true); @@ -311,7 +344,7 @@ describe('IssuableItem', () => { }); it('renders issuable reference via slot', () => { - const wrapperWithRefSlot = createComponent({ + wrapper = createComponent({ issuableSymbol: '#', issuable: mockIssuable, slots: { @@ -320,15 +353,15 @@ describe('IssuableItem', () => { `, }, }); - const referenceEl = wrapperWithRefSlot.find('.js-reference'); + const referenceEl = wrapper.find('.js-reference'); expect(referenceEl.exists()).toBe(true); expect(referenceEl.text()).toBe(`${mockIssuable.iid}`); - - wrapperWithRefSlot.destroy(); }); it('renders issuable createdAt info', () => { + wrapper = createComponent(); + const createdAtEl = wrapper.find('[data-testid="issuable-created-at"]'); expect(createdAtEl.exists()).toBe(true); @@ -337,6 +370,8 @@ describe('IssuableItem', () => { }); it('renders issuable author info', () => { + wrapper = createComponent(); + const authorEl = wrapper.find('[data-testid="issuable-author"]'); expect(authorEl.exists()).toBe(true); @@ -351,7 +386,7 @@ describe('IssuableItem', () => { }); it('renders issuable author info via slot', () => { - const wrapperWithAuthorSlot = createComponent({ + wrapper = createComponent({ issuableSymbol: '#', issuable: mockIssuable, slots: { @@ -360,16 +395,14 @@ describe('IssuableItem', () => { `, }, }); - const authorEl = wrapperWithAuthorSlot.find('.js-author'); + const authorEl = wrapper.find('.js-author'); expect(authorEl.exists()).toBe(true); expect(authorEl.text()).toBe(mockAuthor.name); - - wrapperWithAuthorSlot.destroy(); }); it('renders timeframe via slot', () => { - const wrapperWithTimeframeSlot = createComponent({ + wrapper = createComponent({ issuableSymbol: '#', issuable: mockIssuable, slots: { @@ -378,15 +411,15 @@ describe('IssuableItem', () => { `, }, }); - const timeframeEl = wrapperWithTimeframeSlot.find('.js-timeframe'); + const timeframeEl = wrapper.find('.js-timeframe'); expect(timeframeEl.exists()).toBe(true); expect(timeframeEl.text()).toBe('Jan 1, 2020 - Mar 31, 2020'); - - wrapperWithTimeframeSlot.destroy(); }); it('renders gl-label component for each label present within `issuable` prop', () => { + wrapper = createComponent(); + const labelsEl = wrapper.findAll(GlLabel); expect(labelsEl.exists()).toBe(true); @@ -402,7 +435,7 @@ describe('IssuableItem', () => { }); it('renders issuable status via slot', () => { - const wrapperWithStatusSlot = createComponent({ + wrapper = createComponent({ issuableSymbol: '#', issuable: mockIssuable, slots: { @@ -411,15 +444,15 @@ describe('IssuableItem', () => { `, }, }); - const statusEl = wrapperWithStatusSlot.find('.js-status'); + const statusEl = wrapper.find('.js-status'); expect(statusEl.exists()).toBe(true); expect(statusEl.text()).toBe(`${mockIssuable.state}`); - - wrapperWithStatusSlot.destroy(); }); it('renders discussions count', () => { + wrapper = createComponent(); + const discussionsEl = wrapper.find('[data-testid="issuable-discussions"]'); expect(discussionsEl.exists()).toBe(true); @@ -432,6 +465,8 @@ describe('IssuableItem', () => { }); it('renders issuable-assignees component', () => { + wrapper = createComponent(); + const assigneesEl = wrapper.find(IssuableAssignees); expect(assigneesEl.exists()).toBe(true); @@ -443,6 +478,8 @@ describe('IssuableItem', () => { }); it('renders issuable updatedAt info', () => { + wrapper = createComponent(); + const updatedAtEl = wrapper.find('[data-testid="issuable-updated-at"]'); expect(updatedAtEl.attributes('title')).toBe('Sep 10, 2020 11:41am UTC'); diff --git a/spec/frontend/issuable_list/components/issuable_list_root_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js index 7dddd2c3405..5979a65e3cd 100644 --- a/spec/frontend/issuable_list/components/issuable_list_root_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js @@ -1,12 +1,12 @@ -import { GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui'; +import { GlAlert, GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import VueDraggable from 'vuedraggable'; import { TEST_HOST } from 'helpers/test_constants'; -import IssuableItem from '~/issuable_list/components/issuable_item.vue'; -import IssuableListRoot from '~/issuable_list/components/issuable_list_root.vue'; -import IssuableTabs from '~/issuable_list/components/issuable_tabs.vue'; +import IssuableItem from '~/vue_shared/issuable/list/components/issuable_item.vue'; +import IssuableListRoot from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; +import IssuableTabs from '~/vue_shared/issuable/list/components/issuable_tabs.vue'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { mockIssuableListProps, mockIssuables } from '../mock_data'; @@ -36,6 +36,7 @@ const createComponent = ({ props = {}, data = {} } = {}) => describe('IssuableListRoot', () => { let wrapper; + const findAlert = () => wrapper.findComponent(GlAlert); const findFilteredSearchBar = () => wrapper.findComponent(FilteredSearchBar); const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination); const findGlPagination = () => wrapper.findComponent(GlPagination); @@ -310,6 +311,30 @@ describe('IssuableListRoot', () => { hasPreviousPage: true, }); }); + + describe('alert', () => { + const error = 'oopsie!'; + + it('shows alert when there is an error', () => { + wrapper = createComponent({ props: { error } }); + + expect(findAlert().text()).toBe(error); + }); + + it('emits "dismiss-alert" event when dismissed', () => { + wrapper = createComponent({ props: { error } }); + + findAlert().vm.$emit('dismiss'); + + expect(wrapper.emitted('dismiss-alert')).toEqual([[]]); + }); + + it('does not render when there is no error', () => { + wrapper = createComponent(); + + expect(findAlert().exists()).toBe(false); + }); + }); }); describe('events', () => { diff --git a/spec/frontend/issuable_list/components/issuable_tabs_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_tabs_spec.js index cbf5765078a..8c22b67bdbe 100644 --- a/spec/frontend/issuable_list/components/issuable_tabs_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_tabs_spec.js @@ -1,7 +1,7 @@ import { GlTab, GlBadge } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import IssuableTabs from '~/issuable_list/components/issuable_tabs.vue'; +import IssuableTabs from '~/vue_shared/issuable/list/components/issuable_tabs.vue'; import { mockIssuableListProps } from '../mock_data'; diff --git a/spec/frontend/issuable_list/mock_data.js b/spec/frontend/vue_shared/issuable/list/mock_data.js index e2fa99f7cc9..e2fa99f7cc9 100644 --- a/spec/frontend/issuable_list/mock_data.js +++ b/spec/frontend/vue_shared/issuable/list/mock_data.js diff --git a/spec/frontend/issuable_show/components/issuable_body_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js index 6fa298ca3f2..41bacf18a68 100644 --- a/spec/frontend/issuable_show/components/issuable_body_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js @@ -1,11 +1,11 @@ import { shallowMount } from '@vue/test-utils'; import { useFakeDate } from 'helpers/fake_date'; -import IssuableBody from '~/issuable_show/components/issuable_body.vue'; +import IssuableBody from '~/vue_shared/issuable/show/components/issuable_body.vue'; -import IssuableDescription from '~/issuable_show/components/issuable_description.vue'; -import IssuableEditForm from '~/issuable_show/components/issuable_edit_form.vue'; -import IssuableTitle from '~/issuable_show/components/issuable_title.vue'; +import IssuableDescription from '~/vue_shared/issuable/show/components/issuable_description.vue'; +import IssuableEditForm from '~/vue_shared/issuable/show/components/issuable_edit_form.vue'; +import IssuableTitle from '~/vue_shared/issuable/show/components/issuable_title.vue'; import TaskList from '~/task_list'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; diff --git a/spec/frontend/issuable_show/components/issuable_description_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_description_spec.js index 1058e5decfd..f2211e5b2bb 100644 --- a/spec/frontend/issuable_show/components/issuable_description_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_description_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import $ from 'jquery'; -import IssuableDescription from '~/issuable_show/components/issuable_description.vue'; +import IssuableDescription from '~/vue_shared/issuable/show/components/issuable_description.vue'; import { mockIssuable } from '../mock_data'; diff --git a/spec/frontend/issuable_show/components/issuable_edit_form_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js index 184c9fe251c..051ffd27af4 100644 --- a/spec/frontend/issuable_show/components/issuable_edit_form_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js @@ -1,8 +1,8 @@ import { GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import IssuableEditForm from '~/issuable_show/components/issuable_edit_form.vue'; -import IssuableEventHub from '~/issuable_show/event_hub'; +import IssuableEditForm from '~/vue_shared/issuable/show/components/issuable_edit_form.vue'; +import IssuableEventHub from '~/vue_shared/issuable/show/event_hub'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { mockIssuableShowProps, mockIssuable } from '../mock_data'; diff --git a/spec/frontend/issuable_show/components/issuable_header_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js index b85f2dd1999..41735923957 100644 --- a/spec/frontend/issuable_show/components/issuable_header_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js @@ -2,7 +2,7 @@ import { GlIcon, GlAvatarLabeled } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import IssuableHeader from '~/issuable_show/components/issuable_header.vue'; +import IssuableHeader from '~/vue_shared/issuable/show/components/issuable_header.vue'; import { mockIssuableShowProps, mockIssuable } from '../mock_data'; diff --git a/spec/frontend/issuable_show/components/issuable_show_root_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js index 7ad409c3a74..d1eb1366225 100644 --- a/spec/frontend/issuable_show/components/issuable_show_root_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js @@ -1,10 +1,10 @@ import { shallowMount } from '@vue/test-utils'; -import IssuableBody from '~/issuable_show/components/issuable_body.vue'; -import IssuableHeader from '~/issuable_show/components/issuable_header.vue'; -import IssuableShowRoot from '~/issuable_show/components/issuable_show_root.vue'; +import IssuableBody from '~/vue_shared/issuable/show/components/issuable_body.vue'; +import IssuableHeader from '~/vue_shared/issuable/show/components/issuable_header.vue'; +import IssuableShowRoot from '~/vue_shared/issuable/show/components/issuable_show_root.vue'; -import IssuableSidebar from '~/issuable_sidebar/components/issuable_sidebar_root.vue'; +import IssuableSidebar from '~/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue'; import { mockIssuableShowProps, mockIssuable } from '../mock_data'; diff --git a/spec/frontend/issuable_show/components/issuable_title_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js index df6fbdea76b..1fcf37a0477 100644 --- a/spec/frontend/issuable_show/components/issuable_title_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js @@ -2,7 +2,7 @@ import { GlIcon, GlButton, GlIntersectionObserver } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import IssuableTitle from '~/issuable_show/components/issuable_title.vue'; +import IssuableTitle from '~/vue_shared/issuable/show/components/issuable_title.vue'; import { mockIssuableShowProps, mockIssuable } from '../mock_data'; diff --git a/spec/frontend/issuable_show/mock_data.js b/spec/frontend/vue_shared/issuable/show/mock_data.js index 986d32b4982..f5f3ed58655 100644 --- a/spec/frontend/issuable_show/mock_data.js +++ b/spec/frontend/vue_shared/issuable/show/mock_data.js @@ -1,4 +1,4 @@ -import { mockIssuable as issuable } from '../issuable_list/mock_data'; +import { mockIssuable as issuable } from 'jest/vue_shared/issuable/list/mock_data'; export const mockIssuable = { ...issuable, diff --git a/spec/frontend/issuable_sidebar/components/issuable_sidebar_root_spec.js b/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js index c872925cca2..788ba70ddc0 100644 --- a/spec/frontend/issuable_sidebar/components/issuable_sidebar_root_spec.js +++ b/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js @@ -2,8 +2,8 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Cookies from 'js-cookie'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import IssuableSidebarRoot from '~/issuable_sidebar/components/issuable_sidebar_root.vue'; -import { USER_COLLAPSED_GUTTER_COOKIE } from '~/issuable_sidebar/constants'; +import IssuableSidebarRoot from '~/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue'; +import { USER_COLLAPSED_GUTTER_COOKIE } from '~/vue_shared/issuable/sidebar/constants'; const MOCK_LAYOUT_PAGE_CLASS = 'layout-page'; diff --git a/spec/frontend/vue_shared/security_reports/mock_data.js b/spec/frontend/vue_shared/security_reports/mock_data.js index cdaeec78e47..2b1513bb0f8 100644 --- a/spec/frontend/vue_shared/security_reports/mock_data.js +++ b/spec/frontend/vue_shared/security_reports/mock_data.js @@ -341,12 +341,15 @@ export const securityReportMergeRequestDownloadPathsQueryNoArtifactsResponse = { export const securityReportMergeRequestDownloadPathsQueryResponse = { project: { + id: '1', mergeRequest: { + id: 'mr-1', headPipeline: { id: 'gid://gitlab/Ci::Pipeline/176', jobs: { nodes: [ { + id: 'job-1', name: 'secret_detection', artifacts: { nodes: [ @@ -368,6 +371,7 @@ export const securityReportMergeRequestDownloadPathsQueryResponse = { __typename: 'CiJob', }, { + id: 'job-2', name: 'bandit-sast', artifacts: { nodes: [ @@ -389,6 +393,7 @@ export const securityReportMergeRequestDownloadPathsQueryResponse = { __typename: 'CiJob', }, { + id: 'job-3', name: 'eslint-sast', artifacts: { nodes: [ @@ -410,6 +415,7 @@ export const securityReportMergeRequestDownloadPathsQueryResponse = { __typename: 'CiJob', }, { + id: 'job-4', name: 'all_artifacts', artifacts: { nodes: [ @@ -449,11 +455,13 @@ export const securityReportMergeRequestDownloadPathsQueryResponse = { export const securityReportPipelineDownloadPathsQueryResponse = { project: { + id: 'project-1', pipeline: { id: 'gid://gitlab/Ci::Pipeline/176', jobs: { nodes: [ { + id: 'job-1', name: 'secret_detection', artifacts: { nodes: [ @@ -475,6 +483,7 @@ export const securityReportPipelineDownloadPathsQueryResponse = { __typename: 'CiJob', }, { + id: 'job-2', name: 'bandit-sast', artifacts: { nodes: [ @@ -496,6 +505,7 @@ export const securityReportPipelineDownloadPathsQueryResponse = { __typename: 'CiJob', }, { + id: 'job-3', name: 'eslint-sast', artifacts: { nodes: [ @@ -517,6 +527,7 @@ export const securityReportPipelineDownloadPathsQueryResponse = { __typename: 'CiJob', }, { + id: 'job-4', name: 'all_artifacts', artifacts: { nodes: [ diff --git a/spec/frontend/vue_shared/translate_spec.js b/spec/frontend/vue_shared/translate_spec.js index 42aa28a6309..30417161968 100644 --- a/spec/frontend/vue_shared/translate_spec.js +++ b/spec/frontend/vue_shared/translate_spec.js @@ -1,9 +1,9 @@ -import { mount, createLocalVue } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; +import Vue from 'vue'; import locale from '~/locale'; import Translate from '~/vue_shared/translate'; -const localVue = createLocalVue(); -localVue.use(Translate); +Vue.use(Translate); describe('Vue translate filter', () => { const createTranslationMock = (key, ...translations) => { @@ -26,16 +26,13 @@ describe('Vue translate filter', () => { const translation = 'singular_translated'; createTranslationMock(key, translation); - const wrapper = mount( - { - template: ` + const wrapper = mount({ + template: ` <span> {{ __('${key}') }} </span> `, - }, - { localVue }, - ); + }); expect(wrapper.text()).toBe(translation); }); @@ -45,16 +42,13 @@ describe('Vue translate filter', () => { const translationPlural = 'plural_multiple translation'; createTranslationMock(key, 'plural_singular translation', translationPlural); - const wrapper = mount( - { - template: ` + const wrapper = mount({ + template: ` <span> {{ n__('${key}', 'plurals', 2) }} </span> `, - }, - { localVue }, - ); + }); expect(wrapper.text()).toBe(translationPlural); }); @@ -67,31 +61,25 @@ describe('Vue translate filter', () => { }); it('and n === 1', () => { - const wrapper = mount( - { - template: ` + const wrapper = mount({ + template: ` <span> {{ n__('${key}', '%d days', 1) }} </span> `, - }, - { localVue }, - ); + }); expect(wrapper.text()).toBe('1 singular translated'); }); it('and n > 1', () => { - const wrapper = mount( - { - template: ` + const wrapper = mount({ + template: ` <span> {{ n__('${key}', '%d days', 2) }} </span> `, - }, - { localVue }, - ); + }); expect(wrapper.text()).toBe('2 plural translated'); }); @@ -107,31 +95,25 @@ describe('Vue translate filter', () => { }); it('and using two parameters', () => { - const wrapper = mount( - { - template: ` + const wrapper = mount({ + template: ` <span> {{ s__('Context', 'Foobar') }} </span> `, - }, - { localVue }, - ); + }); expect(wrapper.text()).toBe(expectation); }); it('and using the pipe syntax', () => { - const wrapper = mount( - { - template: ` + const wrapper = mount({ + template: ` <span> {{ s__('${key}') }} </span> `, - }, - { localVue }, - ); + }); expect(wrapper.text()).toBe(expectation); }); @@ -141,9 +123,8 @@ describe('Vue translate filter', () => { const translation = 'multiline string translated'; createTranslationMock('multiline string', translation); - const wrapper = mount( - { - template: ` + const wrapper = mount({ + template: ` <span> {{ __(\` multiline @@ -151,9 +132,7 @@ describe('Vue translate filter', () => { \`) }} </span> `, - }, - { localVue }, - ); + }); expect(wrapper.text()).toBe(translation); }); @@ -163,9 +142,8 @@ describe('Vue translate filter', () => { createTranslationMock('multiline string', 'multiline string singular', translation); - const wrapper = mount( - { - template: ` + const wrapper = mount({ + template: ` <span> {{ n__( \` @@ -180,9 +158,7 @@ describe('Vue translate filter', () => { ) }} </span> `, - }, - { localVue }, - ); + }); expect(wrapper.text()).toBe(translation); }); @@ -192,9 +168,8 @@ describe('Vue translate filter', () => { createTranslationMock('Context| multiline string', translation); - const wrapper = mount( - { - template: ` + const wrapper = mount({ + template: ` <span> {{ s__( \` @@ -205,9 +180,7 @@ describe('Vue translate filter', () => { ) }} </span> `, - }, - { localVue }, - ); + }); expect(wrapper.text()).toBe(translation); }); diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js new file mode 100644 index 00000000000..0f6e7091c59 --- /dev/null +++ b/spec/frontend/work_items/components/item_title_spec.js @@ -0,0 +1,56 @@ +import { shallowMount } from '@vue/test-utils'; +import { escape } from 'lodash'; +import ItemTitle from '~/work_items/components/item_title.vue'; + +jest.mock('lodash/escape', () => jest.fn((fn) => fn)); + +const createComponent = ({ initialTitle = 'Sample title', disabled = false } = {}) => + shallowMount(ItemTitle, { + propsData: { + initialTitle, + disabled, + }, + }); + +describe('ItemTitle', () => { + let wrapper; + const mockUpdatedTitle = 'Updated title'; + const findInputEl = () => wrapper.find('span#item-title'); + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders title contents', () => { + expect(findInputEl().attributes()).toMatchObject({ + 'data-placeholder': 'Add a title...', + contenteditable: 'true', + }); + expect(findInputEl().text()).toBe('Sample title'); + }); + + it('renders title contents with editing disabled', () => { + wrapper = createComponent({ + disabled: true, + }); + + expect(wrapper.classes()).toContain('gl-cursor-not-allowed'); + expect(findInputEl().attributes('contenteditable')).toBe('false'); + }); + + it.each` + eventName | sourceEvent + ${'title-changed'} | ${'blur'} + ${'title-input'} | ${'keyup'} + `('emits "$eventName" event on input $sourceEvent', async ({ eventName, sourceEvent }) => { + findInputEl().element.innerText = mockUpdatedTitle; + await findInputEl().trigger(sourceEvent); + + expect(wrapper.emitted(eventName)).toBeTruthy(); + expect(escape).toHaveBeenCalledWith(mockUpdatedTitle); + }); +}); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index efb4aa2feb2..9741a193258 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -1,13 +1,13 @@ export const workItemQueryResponse = { workItem: { - __typename: 'WorkItem', + __typename: 'LocalWorkItem', id: '1', type: 'FEATURE', widgets: { - __typename: 'WorkItemWidgetConnection', + __typename: 'LocalWorkItemWidgetConnection', nodes: [ { - __typename: 'TitleWidget', + __typename: 'LocalTitleWidget', type: 'TITLE', contentText: 'Test', }, @@ -15,3 +15,22 @@ export const workItemQueryResponse = { }, }, }; + +export const updateWorkItemMutationResponse = { + __typename: 'LocalUpdateWorkItemPayload', + workItem: { + __typename: 'LocalWorkItem', + id: '1', + widgets: { + __typename: 'LocalWorkItemWidgetConnection', + nodes: [ + { + __typename: 'LocalTitleWidget', + type: 'TITLE', + enabled: true, + contentText: 'Updated title', + }, + ], + }, + }, +}; diff --git a/spec/frontend/work_items/pages/create_work_item_spec.js b/spec/frontend/work_items/pages/create_work_item_spec.js new file mode 100644 index 00000000000..71e153d30c3 --- /dev/null +++ b/spec/frontend/work_items/pages/create_work_item_spec.js @@ -0,0 +1,94 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; +import ItemTitle from '~/work_items/components/item_title.vue'; +import { resolvers } from '~/work_items/graphql/resolvers'; + +Vue.use(VueApollo); + +describe('Create work item component', () => { + let wrapper; + let fakeApollo; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findTitleInput = () => wrapper.findComponent(ItemTitle); + const findCreateButton = () => wrapper.find('[data-testid="create-button"]'); + const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]'); + + const createComponent = ({ data = {} } = {}) => { + fakeApollo = createMockApollo([], resolvers); + wrapper = shallowMount(CreateWorkItem, { + apolloProvider: fakeApollo, + data() { + return { + ...data, + }; + }, + mocks: { + $router: { + go: jest.fn(), + push: jest.fn(), + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + it('does not render error by default', () => { + createComponent(); + + expect(findAlert().exists()).toBe(false); + }); + + it('renders a disabled Create button when title input is empty', () => { + createComponent(); + + expect(findCreateButton().props('disabled')).toBe(true); + }); + + it('redirects to the previous page on Cancel button click', () => { + createComponent(); + findCancelButton().vm.$emit('click'); + + expect(wrapper.vm.$router.go).toHaveBeenCalledWith(-1); + }); + + it('hides the alert on dismissing the error', async () => { + createComponent({ data: { error: true } }); + expect(findAlert().exists()).toBe(true); + + findAlert().vm.$emit('dismiss'); + await nextTick(); + expect(findAlert().exists()).toBe(false); + }); + + describe('when title input field has a text', () => { + beforeEach(async () => { + const mockTitle = 'Test title'; + createComponent(); + await findTitleInput().vm.$emit('title-input', mockTitle); + }); + + it('renders a non-disabled Create button', () => { + expect(findCreateButton().props('disabled')).toBe(false); + }); + + it('redirects to the work item page on successful mutation', async () => { + wrapper.find('form').trigger('submit'); + await waitForPromises(); + + expect(wrapper.vm.$router.push).toHaveBeenCalled(); + }); + + // TODO: write a proper test here when we have a backend implementation + it.todo('shows an alert on mutation error'); + }); +}); diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js index 64d02baed36..02795751f33 100644 --- a/spec/frontend/work_items/pages/work_item_root_spec.js +++ b/spec/frontend/work_items/pages/work_item_root_spec.js @@ -1,12 +1,16 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vue from 'vue'; +import { 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 workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import WorkItemsRoot from '~/work_items/pages/work_item_root.vue'; +import ItemTitle from '~/work_items/components/item_title.vue'; +import { resolvers } from '~/work_items/graphql/resolvers'; import { workItemQueryResponse } from '../mock_data'; -const localVue = createLocalVue(); -localVue.use(VueApollo); +Vue.use(VueApollo); const WORK_ITEM_ID = '1'; @@ -14,10 +18,10 @@ describe('Work items root component', () => { let wrapper; let fakeApollo; - const findTitle = () => wrapper.find('[data-testid="title"]'); + const findTitle = () => wrapper.findComponent(ItemTitle); const createComponent = ({ queryResponse = workItemQueryResponse } = {}) => { - fakeApollo = createMockApollo(); + fakeApollo = createMockApollo([], resolvers); fakeApollo.clients.defaultClient.cache.writeQuery({ query: workItemQuery, variables: { @@ -30,7 +34,6 @@ describe('Work items root component', () => { propsData: { id: WORK_ITEM_ID, }, - localVue, apolloProvider: fakeApollo, }); }; @@ -44,7 +47,28 @@ describe('Work items root component', () => { createComponent(); expect(findTitle().exists()).toBe(true); - expect(findTitle().text()).toBe('Test'); + expect(findTitle().props('initialTitle')).toBe('Test'); + }); + + it('updates the title when it is edited', async () => { + createComponent(); + jest.spyOn(wrapper.vm.$apollo, 'mutate'); + const mockUpdatedTitle = 'Updated title'; + + await findTitle().vm.$emit('title-changed', mockUpdatedTitle); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: WORK_ITEM_ID, + title: mockUpdatedTitle, + }, + }, + }); + + await waitForPromises(); + expect(findTitle().props('initialTitle')).toBe(mockUpdatedTitle); }); it('does not render the title if title is not in the widgets list', () => { diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js index 0a57eab753f..6017c9d9dbb 100644 --- a/spec/frontend/work_items/router_spec.js +++ b/spec/frontend/work_items/router_spec.js @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils'; import App from '~/work_items/components/app.vue'; +import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; import WorkItemsRoot from '~/work_items/pages/work_item_root.vue'; import { createRouter } from '~/work_items/router'; @@ -27,4 +28,10 @@ describe('Work items router', () => { expect(wrapper.find(WorkItemsRoot).exists()).toBe(true); }); + + it('renders create work item page on `/new` route', async () => { + await createComponent('/new'); + + expect(wrapper.findComponent(CreateWorkItem).exists()).toBe(true); + }); }); |