diff options
Diffstat (limited to 'spec/frontend')
444 files changed, 17158 insertions, 7777 deletions
diff --git a/spec/frontend/__helpers__/fixtures.js b/spec/frontend/__helpers__/fixtures.js index c66411979e9..5ae63bb1744 100644 --- a/spec/frontend/__helpers__/fixtures.js +++ b/spec/frontend/__helpers__/fixtures.js @@ -1,28 +1,3 @@ -import fs from 'fs'; -import path from 'path'; - -import { ErrorWithStack } from 'jest-util'; - -export function getFixture(relativePath) { - const basePath = relativePath.startsWith('static/') - ? global.staticFixturesBasePath - : global.fixturesBasePath; - const absolutePath = path.join(basePath, relativePath); - if (!fs.existsSync(absolutePath)) { - throw new ErrorWithStack( - `Fixture file ${relativePath} does not exist. - -Did you run bin/rake frontend:fixtures? You can also download fixtures from the gitlab-org/gitlab package registry. - -See https://docs.gitlab.com/ee/development/testing_guide/frontend_testing.html#download-fixtures for more info. -`, - getFixture, - ); - } - - return fs.readFileSync(absolutePath, 'utf8'); -} - export const resetHTMLFixture = () => { document.head.innerHTML = ''; document.body.innerHTML = ''; @@ -31,7 +6,3 @@ export const resetHTMLFixture = () => { export const setHTMLFixture = (htmlContent) => { document.body.innerHTML = htmlContent; }; - -export const loadHTMLFixture = (relativePath) => { - setHTMLFixture(getFixture(relativePath)); -}; diff --git a/spec/frontend/__helpers__/mock_dom_observer.js b/spec/frontend/__helpers__/mock_dom_observer.js index 8c9c435041e..fd3945adfd8 100644 --- a/spec/frontend/__helpers__/mock_dom_observer.js +++ b/spec/frontend/__helpers__/mock_dom_observer.js @@ -22,9 +22,9 @@ class MockObserver { takeRecords() {} - $_triggerObserve(node, { entry = {}, options = {} } = {}) { + $_triggerObserve(node, { entry = {}, observer = {}, options = {} } = {}) { if (this.$_hasObserver(node, options)) { - this.$_cb([{ target: node, ...entry }]); + this.$_cb([{ target: node, ...entry }], observer); } } diff --git a/spec/frontend/__helpers__/mock_window_location_helper.js b/spec/frontend/__helpers__/mock_window_location_helper.js index de1e8c99b54..577d8226fad 100644 --- a/spec/frontend/__helpers__/mock_window_location_helper.js +++ b/spec/frontend/__helpers__/mock_window_location_helper.js @@ -1,3 +1,5 @@ +import { TEST_HOST } from 'helpers/test_constants'; + /** * Manage the instance of a custom `window.location` * @@ -12,6 +14,7 @@ const useMockLocation = (fn) => { Object.defineProperty(window, 'location', { get: () => currentWindowLocation, + assign: jest.fn(), }); beforeEach(() => { @@ -41,6 +44,8 @@ export const createWindowLocationSpy = () => { replace: jest.fn(), toString: jest.fn(), origin, + protocol: 'http:', + host: TEST_HOST, // TODO: Do we need to update `origin` if `href` is changed? href, }; diff --git a/spec/frontend/__helpers__/mocks/mr_notes/stores/index.js b/spec/frontend/__helpers__/mocks/mr_notes/stores/index.js new file mode 100644 index 00000000000..a983edbbb72 --- /dev/null +++ b/spec/frontend/__helpers__/mocks/mr_notes/stores/index.js @@ -0,0 +1,15 @@ +import { Store } from 'vuex-mock-store'; +import createDiffState from 'ee_else_ce/diffs/store/modules/diff_state'; +import createNotesState from '~/notes/stores/state'; + +const store = new Store({ + state: { + diffs: createDiffState(), + notes: createNotesState(), + }, + spy: { + create: (handler) => jest.fn(handler).mockImplementation(() => Promise.resolve()), + }, +}); + +export default store; diff --git a/spec/frontend/__helpers__/test_constants.js b/spec/frontend/__helpers__/test_constants.js index 628b9b054d3..b5a585811d1 100644 --- a/spec/frontend/__helpers__/test_constants.js +++ b/spec/frontend/__helpers__/test_constants.js @@ -1,5 +1,6 @@ const FIXTURES_PATH = `/fixtures`; const TEST_HOST = 'http://test.host'; +const DRAWIO_ORIGIN = 'https://embed.diagrams.net'; const DUMMY_IMAGE_URL = `${FIXTURES_PATH}/static/images/one_white_pixel.png`; @@ -15,6 +16,7 @@ const DUMMY_IMAGE_BLOB_PATH = 'SpongeBlob.png'; module.exports = { FIXTURES_PATH, TEST_HOST, + DRAWIO_ORIGIN, DUMMY_IMAGE_URL, GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL, diff --git a/spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js b/spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js index cabbb5e1591..e519684bbc5 100644 --- a/spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js +++ b/spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js @@ -1,14 +1,17 @@ import { shallowMount } from '@vue/test-utils'; +import { GlAlert } from '@gitlab/ui'; import AbuseReportApp from '~/admin/abuse_report/components/abuse_report_app.vue'; import ReportHeader from '~/admin/abuse_report/components/report_header.vue'; import UserDetails from '~/admin/abuse_report/components/user_details.vue'; import ReportedContent from '~/admin/abuse_report/components/reported_content.vue'; import HistoryItems from '~/admin/abuse_report/components/history_items.vue'; +import { SUCCESS_ALERT } from '~/admin/abuse_report/constants'; import { mockAbuseReport } from '../mock_data'; describe('AbuseReportApp', () => { let wrapper; + const findAlert = () => wrapper.findComponent(GlAlert); const findReportHeader = () => wrapper.findComponent(ReportHeader); const findUserDetails = () => wrapper.findComponent(UserDetails); const findReportedContent = () => wrapper.findComponent(ReportedContent); @@ -27,10 +30,44 @@ describe('AbuseReportApp', () => { createComponent(); }); + it('does not show the alert by default', () => { + expect(findAlert().exists()).toBe(false); + }); + + describe('when emitting the showAlert event from the report header', () => { + const message = 'alert message'; + + beforeEach(() => { + findReportHeader().vm.$emit('showAlert', SUCCESS_ALERT, message); + }); + + it('shows the alert', () => { + expect(findAlert().exists()).toBe(true); + }); + + it('displays the message', () => { + expect(findAlert().text()).toBe(message); + }); + + it('sets the variant property', () => { + expect(findAlert().props('variant')).toBe(SUCCESS_ALERT); + }); + + describe('when dismissing the alert', () => { + beforeEach(() => { + findAlert().vm.$emit('dismiss'); + }); + + it('hides the alert', () => { + expect(findAlert().exists()).toBe(false); + }); + }); + }); + describe('ReportHeader', () => { it('renders ReportHeader', () => { expect(findReportHeader().props('user')).toBe(mockAbuseReport.user); - expect(findReportHeader().props('actions')).toBe(mockAbuseReport.actions); + expect(findReportHeader().props('report')).toBe(mockAbuseReport.report); }); describe('when no user is present', () => { diff --git a/spec/frontend/admin/abuse_report/components/report_actions_spec.js b/spec/frontend/admin/abuse_report/components/report_actions_spec.js new file mode 100644 index 00000000000..ec7dd31a046 --- /dev/null +++ b/spec/frontend/admin/abuse_report/components/report_actions_spec.js @@ -0,0 +1,194 @@ +import MockAdapter from 'axios-mock-adapter'; +import { GlDrawer } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import axios from '~/lib/utils/axios_utils'; +import { + HTTP_STATUS_OK, + HTTP_STATUS_UNPROCESSABLE_ENTITY, + HTTP_STATUS_INTERNAL_SERVER_ERROR, +} from '~/lib/utils/http_status'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import ReportActions from '~/admin/abuse_report/components/report_actions.vue'; +import { + ACTIONS_I18N, + SUCCESS_ALERT, + FAILED_ALERT, + ERROR_MESSAGE, + NO_ACTION, + USER_ACTION_OPTIONS, +} from '~/admin/abuse_report/constants'; +import { mockAbuseReport } from '../mock_data'; + +describe('ReportActions', () => { + let wrapper; + let axiosMock; + + const params = { + user_action: 'ban_user', + close: true, + comment: 'my comment', + reason: 'spam', + }; + + const { user, report } = mockAbuseReport; + + const clickActionsButton = () => wrapper.findByTestId('actions-button').vm.$emit('click'); + const isDrawerOpen = () => wrapper.findComponent(GlDrawer).props('open'); + const findErrorFor = (id) => wrapper.findByTestId(id).find('.d-block.invalid-feedback'); + const findUserActionOptions = () => wrapper.findByTestId('action-select'); + const setCloseReport = (close) => wrapper.findByTestId('close').find('input').setChecked(close); + const setSelectOption = (id, value) => + wrapper.findByTestId(`${id}-select`).find(`option[value=${value}]`).setSelected(); + const selectAction = (action) => setSelectOption('action', action); + const selectReason = (reason) => setSelectOption('reason', reason); + const setComment = (comment) => wrapper.findByTestId('comment').find('input').setValue(comment); + const submitForm = () => wrapper.findByTestId('submit-button').vm.$emit('click'); + + const createComponent = (props = {}) => { + wrapper = mountExtended(ReportActions, { + propsData: { + user, + report, + ...props, + }, + }); + }; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + createComponent(); + }); + + afterEach(() => { + axiosMock.restore(); + }); + + it('initially hides the drawer', () => { + expect(isDrawerOpen()).toBe(false); + }); + + describe('actions', () => { + describe('when logged in user is not the user being reported', () => { + beforeEach(() => { + clickActionsButton(); + }); + + it('shows "No action", "Block user", "Ban user" and "Delete user" options', () => { + const options = findUserActionOptions().findAll('option'); + + expect(options).toHaveLength(USER_ACTION_OPTIONS.length); + + USER_ACTION_OPTIONS.forEach((action, index) => { + expect(options.at(index).text()).toBe(action.text); + }); + }); + }); + + describe('when logged in user is the user being reported', () => { + beforeEach(() => { + gon.current_username = user.username; + clickActionsButton(); + }); + + it('only shows "No action" option', () => { + const options = findUserActionOptions().findAll('option'); + + expect(options).toHaveLength(1); + expect(options.at(0).text()).toBe(NO_ACTION.text); + }); + }); + }); + + describe('when clicking the actions button', () => { + beforeEach(() => { + clickActionsButton(); + }); + + it('shows the drawer', () => { + expect(isDrawerOpen()).toBe(true); + }); + + describe.each` + input | errorFor | messageShown + ${null} | ${'action'} | ${true} + ${null} | ${'reason'} | ${true} + ${'close'} | ${'action'} | ${false} + ${'action'} | ${'action'} | ${false} + ${'reason'} | ${'reason'} | ${false} + `('when submitting an invalid form', ({ input, errorFor, messageShown }) => { + describe(`when ${ + input ? `providing a value for the ${input} field` : 'not providing any values' + }`, () => { + beforeEach(() => { + submitForm(); + + if (input === 'close') { + setCloseReport(params.close); + } else if (input === 'action') { + selectAction(params.user_action); + } else if (input === 'reason') { + selectReason(params.reason); + } + }); + + it(`${messageShown ? 'shows' : 'hides'} ${errorFor} error message`, () => { + if (messageShown) { + expect(findErrorFor(errorFor).text()).toBe(ACTIONS_I18N.requiredFieldFeedback); + } else { + expect(findErrorFor(errorFor).exists()).toBe(false); + } + }); + }); + }); + + describe('when submitting a valid form', () => { + describe.each` + response | success | responseStatus | responseData | alertType | alertMessage + ${'successful'} | ${true} | ${HTTP_STATUS_OK} | ${{ message: 'success!' }} | ${SUCCESS_ALERT} | ${'success!'} + ${'custom failure'} | ${false} | ${HTTP_STATUS_UNPROCESSABLE_ENTITY} | ${{ message: 'fail!' }} | ${FAILED_ALERT} | ${'fail!'} + ${'generic failure'} | ${false} | ${HTTP_STATUS_INTERNAL_SERVER_ERROR} | ${{}} | ${FAILED_ALERT} | ${ERROR_MESSAGE} + `( + 'when the server responds with a $response response', + ({ success, responseStatus, responseData, alertType, alertMessage }) => { + beforeEach(async () => { + jest.spyOn(axios, 'put'); + + axiosMock.onPut(report.updatePath).replyOnce(responseStatus, responseData); + + selectAction(params.user_action); + setCloseReport(params.close); + selectReason(params.reason); + setComment(params.comment); + + await nextTick(); + + submitForm(); + + await waitForPromises(); + }); + + it('does a put call with the right data', () => { + expect(axios.put).toHaveBeenCalledWith(report.updatePath, params); + }); + + it('closes the drawer', () => { + expect(isDrawerOpen()).toBe(false); + }); + + it('emits the showAlert event', () => { + expect(wrapper.emitted('showAlert')).toStrictEqual([[alertType, alertMessage]]); + }); + + it(`${success ? 'does' : 'does not'} emit the closeReport event`, () => { + if (success) { + expect(wrapper.emitted('closeReport')).toBeDefined(); + } else { + expect(wrapper.emitted('closeReport')).toBeUndefined(); + } + }); + }, + ); + }); + }); +}); diff --git a/spec/frontend/admin/abuse_report/components/report_header_spec.js b/spec/frontend/admin/abuse_report/components/report_header_spec.js index d584cab05b3..f22f3af091f 100644 --- a/spec/frontend/admin/abuse_report/components/report_header_spec.js +++ b/spec/frontend/admin/abuse_report/components/report_header_spec.js @@ -1,25 +1,27 @@ -import { GlAvatar, GlLink, GlButton } from '@gitlab/ui'; +import { GlBadge, GlIcon, GlAvatar, GlLink, GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import ReportHeader from '~/admin/abuse_report/components/report_header.vue'; -import AbuseReportActions from '~/admin/abuse_reports/components/abuse_report_actions.vue'; -import { REPORT_HEADER_I18N } from '~/admin/abuse_report/constants'; +import ReportActions from '~/admin/abuse_report/components/report_actions.vue'; +import { REPORT_HEADER_I18N, STATUS_OPEN, STATUS_CLOSED } from '~/admin/abuse_report/constants'; import { mockAbuseReport } from '../mock_data'; describe('ReportHeader', () => { let wrapper; - const { user, actions } = mockAbuseReport; + const { user, report } = mockAbuseReport; + const findBadge = () => wrapper.findComponent(GlBadge); + const findIcon = () => wrapper.findComponent(GlIcon); const findAvatar = () => wrapper.findComponent(GlAvatar); const findLink = () => wrapper.findComponent(GlLink); const findButton = () => wrapper.findComponent(GlButton); - const findActions = () => wrapper.findComponent(AbuseReportActions); + const findActions = () => wrapper.findComponent(ReportActions); const createComponent = (props = {}) => { wrapper = shallowMount(ReportHeader, { propsData: { user, - actions, + report, ...props, }, }); @@ -51,9 +53,42 @@ describe('ReportHeader', () => { expect(button.text()).toBe(REPORT_HEADER_I18N.adminProfile); }); + describe.each` + status | text | variant | className | badgeIcon + ${STATUS_OPEN} | ${REPORT_HEADER_I18N[STATUS_OPEN]} | ${'success'} | ${'issuable-status-badge-open'} | ${'issues'} + ${STATUS_CLOSED} | ${REPORT_HEADER_I18N[STATUS_CLOSED]} | ${'info'} | ${'issuable-status-badge-closed'} | ${'issue-closed'} + `( + 'rendering the report $status status badge', + ({ status, text, variant, className, badgeIcon }) => { + beforeEach(() => { + createComponent({ report: { ...report, status } }); + }); + + it(`indicates the ${status} status`, () => { + expect(findBadge().text()).toBe(text); + }); + + it(`with the ${variant} variant`, () => { + expect(findBadge().props('variant')).toBe(variant); + }); + + it(`with the text '${text}' as 'aria-label'`, () => { + expect(findBadge().attributes('aria-label')).toBe(text); + }); + + it(`contains the ${className} class`, () => { + expect(findBadge().element.classList).toContain(className); + }); + + it(`has an icon with the ${badgeIcon} name`, () => { + expect(findIcon().props('name')).toBe(badgeIcon); + }); + }, + ); + it('renders the actions', () => { const actionsComponent = findActions(); - expect(actionsComponent.props('report')).toMatchObject(actions); + expect(actionsComponent.props('report')).toMatchObject(report); }); }); diff --git a/spec/frontend/admin/abuse_report/components/reported_content_spec.js b/spec/frontend/admin/abuse_report/components/reported_content_spec.js index ecc5ad6ad47..9fc49f08f8c 100644 --- a/spec/frontend/admin/abuse_report/components/reported_content_spec.js +++ b/spec/frontend/admin/abuse_report/components/reported_content_spec.js @@ -1,9 +1,8 @@ -import { GlSprintf, GlButton, GlModal, GlCard, GlAvatar, GlLink } from '@gitlab/ui'; +import { GlSprintf, GlButton, GlModal, GlCard, GlAvatar, GlLink, GlTruncateText } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { sprintf } from '~/locale'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; import ReportedContent from '~/admin/abuse_report/components/reported_content.vue'; -import TruncatedText from '~/vue_shared/components/truncated_text/truncated_text.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { REPORTED_CONTENT_I18N } from '~/admin/abuse_report/constants'; import { mockAbuseReport } from '../mock_data'; @@ -22,7 +21,7 @@ describe('ReportedContent', () => { const findModal = () => wrapper.findComponent(GlModal); const findCard = () => wrapper.findComponent(GlCard); const findCardHeader = () => findCard().find('.js-test-card-header'); - const findTruncatedText = () => findCardHeader().findComponent(TruncatedText); + const findTruncatedText = () => findCardHeader().findComponent(GlTruncateText); const findCardBody = () => findCard().find('.js-test-card-body'); const findCardFooter = () => findCard().find('.js-test-card-footer'); const findAvatar = () => findCardFooter().findComponent(GlAvatar); @@ -40,7 +39,7 @@ describe('ReportedContent', () => { GlSprintf, GlButton, GlCard, - TruncatedText, + GlTruncateText, }, }); }; diff --git a/spec/frontend/admin/abuse_report/mock_data.js b/spec/frontend/admin/abuse_report/mock_data.js index ee0f0967735..8c0ae223c87 100644 --- a/spec/frontend/admin/abuse_report/mock_data.js +++ b/spec/frontend/admin/abuse_report/mock_data.js @@ -40,6 +40,7 @@ export const mockAbuseReport = { path: '/reporter', }, report: { + status: 'open', message: 'This is obvious spam', reportedAt: '2023-03-29T09:39:50.502Z', category: 'spam', @@ -49,13 +50,6 @@ export const mockAbuseReport = { url: 'http://localhost:3000/spamuser417/project/-/merge_requests/1#note_1375', screenshot: '/uploads/-/system/abuse_report/screenshot/27/Screenshot_2023-03-30_at_16.56.37.png', - }, - actions: { - reportedUser: { name: 'Sp4m User', createdAt: '2023-03-29T09:30:23.885Z' }, - userBlocked: false, - blockUserPath: '/admin/users/spamuser417/block', - removeReportPath: '/admin/abuse_reports/27', - removeUserAndReportPath: '/admin/abuse_reports/27?remove_user=true', - redirectPath: '/admin/abuse_reports', + updatePath: '/admin/abuse_reports/27', }, }; diff --git a/spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js b/spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js deleted file mode 100644 index 09b6b1edc44..00000000000 --- a/spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js +++ /dev/null @@ -1,202 +0,0 @@ -import { nextTick } from 'vue'; -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; -import { GlDisclosureDropdown, GlDisclosureDropdownItem, GlModal } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import AbuseReportActions from '~/admin/abuse_reports/components/abuse_report_actions.vue'; -import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import { redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated -import { createAlert, VARIANT_SUCCESS } from '~/alert'; -import { sprintf } from '~/locale'; -import { ACTIONS_I18N } from '~/admin/abuse_reports/constants'; -import { mockAbuseReports } from '../mock_data'; - -jest.mock('~/alert'); -jest.mock('~/lib/utils/url_utility'); - -describe('AbuseReportActions', () => { - let wrapper; - - const findRemoveUserAndReportButton = () => wrapper.findByText('Remove user & report'); - const findBlockUserButton = () => wrapper.findByTestId('block-user-button'); - const findRemoveReportButton = () => wrapper.findByText('Remove report'); - const findConfirmationModal = () => wrapper.findComponent(GlModal); - - const report = mockAbuseReports[0]; - - const createComponent = (props = {}) => { - wrapper = shallowMountExtended(AbuseReportActions, { - propsData: { - report, - ...props, - }, - stubs: { - GlDisclosureDropdown, - GlDisclosureDropdownItem, - }, - }); - }; - - describe('default', () => { - beforeEach(() => { - createComponent(); - }); - - it('displays "Block user", "Remove user & report", and "Remove report" buttons', () => { - expect(findRemoveUserAndReportButton().text()).toBe(ACTIONS_I18N.removeUserAndReport); - - const blockButton = findBlockUserButton(); - expect(blockButton.text()).toBe(ACTIONS_I18N.blockUser); - expect(blockButton.attributes('disabled')).toBeUndefined(); - - expect(findRemoveReportButton().text()).toBe(ACTIONS_I18N.removeReport); - }); - - it('does not show the confirmation modal initially', () => { - expect(findConfirmationModal().props('visible')).toBe(false); - }); - }); - - describe('block button when user is already blocked', () => { - it('is disabled and has the correct text', () => { - createComponent({ report: { ...report, userBlocked: true } }); - - const button = findBlockUserButton(); - expect(button.text()).toBe(ACTIONS_I18N.alreadyBlocked); - expect(button.attributes('disabled')).toBeDefined(); - }); - }); - - describe('actions', () => { - let axiosMock; - - beforeEach(() => { - axiosMock = new MockAdapter(axios); - - createComponent(); - }); - - afterEach(() => { - axiosMock.restore(); - createAlert.mockClear(); - }); - - describe('on remove user and report', () => { - it('shows confirmation modal and reloads the page on success', async () => { - findRemoveUserAndReportButton().trigger('click'); - await nextTick(); - - expect(findConfirmationModal().props()).toMatchObject({ - visible: true, - title: sprintf(ACTIONS_I18N.removeUserAndReportConfirm, { - user: report.reportedUser.name, - }), - }); - - axiosMock.onDelete(report.removeUserAndReportPath).reply(HTTP_STATUS_OK); - - findConfirmationModal().vm.$emit('primary'); - await axios.waitForAll(); - - expect(refreshCurrentPage).toHaveBeenCalled(); - }); - - describe('when a redirect path is present', () => { - beforeEach(() => { - createComponent({ report: { ...report, redirectPath: '/redirect_path' } }); - }); - - it('redirects to the given path', async () => { - findRemoveUserAndReportButton().trigger('click'); - await nextTick(); - - axiosMock.onDelete(report.removeUserAndReportPath).reply(HTTP_STATUS_OK); - - findConfirmationModal().vm.$emit('primary'); - await axios.waitForAll(); - - expect(redirectTo).toHaveBeenCalledWith('/redirect_path'); // eslint-disable-line import/no-deprecated - }); - }); - }); - - describe('on block user', () => { - beforeEach(async () => { - findBlockUserButton().trigger('click'); - await nextTick(); - }); - - it('shows confirmation modal', () => { - expect(findConfirmationModal().props()).toMatchObject({ - visible: true, - title: ACTIONS_I18N.blockUserConfirm, - }); - }); - - describe.each([ - { - responseData: { notice: 'Notice' }, - createAlertArgs: { message: 'Notice', variant: VARIANT_SUCCESS }, - blockButtonText: ACTIONS_I18N.alreadyBlocked, - blockButtonDisabled: 'disabled', - }, - { - responseData: { error: 'Error' }, - createAlertArgs: { message: 'Error' }, - blockButtonText: ACTIONS_I18N.blockUser, - blockButtonDisabled: undefined, - }, - ])( - 'when response JSON is $responseData', - ({ responseData, createAlertArgs, blockButtonText, blockButtonDisabled }) => { - beforeEach(async () => { - axiosMock.onPut(report.blockUserPath).reply(HTTP_STATUS_OK, responseData); - - findConfirmationModal().vm.$emit('primary'); - await axios.waitForAll(); - }); - - it('updates the block button correctly', () => { - const button = findBlockUserButton(); - expect(button.text()).toBe(blockButtonText); - expect(button.attributes('disabled')).toBe(blockButtonDisabled); - }); - - it('displays the returned message', () => { - expect(createAlert).toHaveBeenCalledWith(createAlertArgs); - }); - }, - ); - }); - - describe('on remove report', () => { - it('reloads the page on success', async () => { - axiosMock.onDelete(report.removeReportPath).reply(HTTP_STATUS_OK); - - findRemoveReportButton().trigger('click'); - - expect(findConfirmationModal().props('visible')).toBe(false); - - await axios.waitForAll(); - - expect(refreshCurrentPage).toHaveBeenCalled(); - }); - - describe('when a redirect path is present', () => { - beforeEach(() => { - createComponent({ report: { ...report, redirectPath: '/redirect_path' } }); - }); - - it('redirects to the given path', async () => { - axiosMock.onDelete(report.removeReportPath).reply(HTTP_STATUS_OK); - - findRemoveReportButton().trigger('click'); - - await axios.waitForAll(); - - expect(redirectTo).toHaveBeenCalledWith('/redirect_path'); // eslint-disable-line import/no-deprecated - }); - }); - }); - }); -}); diff --git a/spec/frontend/admin/broadcast_messages/components/message_form_spec.js b/spec/frontend/admin/broadcast_messages/components/message_form_spec.js index 212f26b8faf..dca77e67cac 100644 --- a/spec/frontend/admin/broadcast_messages/components/message_form_spec.js +++ b/spec/frontend/admin/broadcast_messages/components/message_form_spec.js @@ -34,7 +34,9 @@ describe('MessageForm', () => { const findDismissable = () => wrapper.findComponent('[data-testid=dismissable-checkbox]'); const findTargetRoles = () => wrapper.findComponent('[data-testid=target-roles-checkboxes]'); const findSubmitButton = () => wrapper.findComponent('[data-testid=submit-button]'); + const findCancelButton = () => wrapper.findComponent('[data-testid=cancel-button]'); const findForm = () => wrapper.findComponent(GlForm); + const findShowInCli = () => wrapper.findComponent('[data-testid=show-in-cli-checkbox]'); function createComponent({ broadcastMessage = {} } = {}) { wrapper = mount(MessageForm, { @@ -98,6 +100,18 @@ describe('MessageForm', () => { }); }); + describe('showInCli checkbox', () => { + it('renders for Banners', () => { + createComponent({ broadcastMessage: { broadcastType: TYPE_BANNER } }); + expect(findShowInCli().exists()).toBe(true); + }); + + it('does not render for Notifications', () => { + createComponent({ broadcastMessage: { broadcastType: TYPE_NOTIFICATION } }); + expect(findShowInCli().exists()).toBe(false); + }); + }); + describe('target roles checkboxes', () => { it('renders target roles', () => { createComponent(); @@ -127,6 +141,14 @@ describe('MessageForm', () => { }); }); + describe('form cancel button', () => { + it('renders when the editing a message and has href back to message index page', () => { + createComponent({ broadcastMessage: { id: 100 } }); + expect(wrapper.text()).toContain('Cancel'); + expect(findCancelButton().attributes('href')).toBe(wrapper.vm.messagesPath); + }); + }); + describe('form submission', () => { const defaultPayload = { message: defaultProps.message, diff --git a/spec/frontend/admin/users/components/user_actions_spec.js b/spec/frontend/admin/users/components/user_actions_spec.js index 73d8c082bb9..69755c6142a 100644 --- a/spec/frontend/admin/users/components/user_actions_spec.js +++ b/spec/frontend/admin/users/components/user_actions_spec.js @@ -91,7 +91,7 @@ describe('AdminUserActions component', () => { initComponent({ actions: [LDAP] }); }); - it('renders the LDAP dropdown item without a link', () => { + it('renders the LDAP dropdown footer without a link', () => { const dropdownAction = wrapper.find(`[data-testid="${LDAP}"]`); expect(dropdownAction.exists()).toBe(true); expect(dropdownAction.attributes('href')).toBe(undefined); 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 202a0a04192..80d3676ffee 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 @@ -61,10 +61,11 @@ exports[`Alert integration settings form default state should match the default items="[object Object]" noresultstext="No results found" placement="left" - popperoptions="[object Object]" + positioningstrategy="absolute" resetbuttonlabel="" searchplaceholder="Search" selected="selecte_tmpl" + showselectallbuttonlabel="" size="medium" toggletext="" variant="default" diff --git a/spec/frontend/analytics/cycle_analytics/components/filter_bar_spec.js b/spec/frontend/analytics/cycle_analytics/components/filter_bar_spec.js index f1b3af39199..f57d8559ddf 100644 --- a/spec/frontend/analytics/cycle_analytics/components/filter_bar_spec.js +++ b/spec/frontend/analytics/cycle_analytics/components/filter_bar_spec.js @@ -119,6 +119,10 @@ describe('Filter bar', () => { it('renders FilteredSearchBar component', () => { expect(findFilteredSearch().exists()).toBe(true); }); + + it('passes the `terms-as-tokens` prop', () => { + expect(findFilteredSearch().props('termsAsTokens')).toBe(true); + }); }); describe('when the state has data', () => { diff --git a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js index 33801fb8552..4e0b546b3d2 100644 --- a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js +++ b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js @@ -1,7 +1,6 @@ -import { GlDropdown, GlDropdownItem, GlTruncate, GlSearchBoxByType } from '@gitlab/ui'; +import { GlButton, GlTruncate, GlCollapsibleListbox, GlListboxItem, GlAvatar } from '@gitlab/ui'; import { nextTick } from 'vue'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import { stubComponent } from 'helpers/stub_component'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue'; @@ -28,18 +27,6 @@ const projects = [ }, ]; -const MockGlDropdown = stubComponent(GlDropdown, { - template: ` - <div> - <slot name="header"></slot> - <div data-testid="vsa-highlighted-items"> - <slot name="highlighted-items"></slot> - </div> - <div data-testid="vsa-default-items"><slot></slot></div> - </div> - `, -}); - const defaultMocks = { $apollo: { query: jest.fn().mockResolvedValue({ @@ -53,42 +40,36 @@ let spyQuery; describe('ProjectsDropdownFilter component', () => { let wrapper; - const createComponent = (props = {}, stubs = {}) => { + const createComponent = ({ mountFn = shallowMountExtended, props = {}, stubs = {} } = {}) => { spyQuery = defaultMocks.$apollo.query; - wrapper = mountExtended(ProjectsDropdownFilter, { + wrapper = mountFn(ProjectsDropdownFilter, { mocks: { ...defaultMocks }, propsData: { groupId: 1, groupNamespace: 'gitlab-org', ...props, }, - stubs, + stubs: { + GlButton, + GlCollapsibleListbox, + ...stubs, + }, }); }; - const createWithMockDropdown = (props) => { - createComponent(props, { GlDropdown: MockGlDropdown }); - return waitForPromises(); - }; - - const findHighlightedItems = () => wrapper.findByTestId('vsa-highlighted-items'); - const findUnhighlightedItems = () => wrapper.findByTestId('vsa-default-items'); - const findClearAllButton = () => wrapper.findByText('Clear all'); + const findClearAllButton = () => wrapper.findByTestId('listbox-reset-button'); const findSelectedProjectsLabel = () => wrapper.findComponent(GlTruncate); - const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); - const findDropdownItems = () => - findDropdown() - .findAllComponents(GlDropdownItem) - .filter((w) => w.text() !== 'No matching results'); + const findDropdownItems = () => findDropdown().findAllComponents(GlListboxItem); const findDropdownAtIndex = (index) => findDropdownItems().at(index); - const findDropdownButton = () => findDropdown().find('.dropdown-toggle'); + const findDropdownButton = () => findDropdown().findComponent(GlButton); const findDropdownButtonAvatar = () => findDropdown().find('.gl-avatar'); const findDropdownButtonAvatarAtIndex = (index) => - findDropdownAtIndex(index).find('img.gl-avatar'); + findDropdownAtIndex(index).findComponent(GlAvatar); const findDropdownButtonIdentIconAtIndex = (index) => findDropdownAtIndex(index).find('div.gl-avatar-identicon'); @@ -97,13 +78,15 @@ describe('ProjectsDropdownFilter component', () => { const findDropdownFullPathAtIndex = (index) => findDropdownAtIndex(index).find('[data-testid="project-full-path"]'); - const selectDropdownItemAtIndex = async (index) => { - findDropdownAtIndex(index).find('button').trigger('click'); + const selectDropdownItemAtIndex = async (indexes, multi = true) => { + const payload = indexes.map((index) => projects[index]?.id).filter(Boolean); + findDropdown().vm.$emit('select', multi ? payload : payload[0]); await nextTick(); }; // NOTE: Selected items are now visually separated from unselected items - const findSelectedDropdownItems = () => findHighlightedItems().findAllComponents(GlDropdownItem); + const findSelectedDropdownItems = () => + findDropdownItems().filter((component) => component.props('isSelected') === true); const findSelectedDropdownAtIndex = (index) => findSelectedDropdownItems().at(index); const findSelectedButtonIdentIconAtIndex = (index) => @@ -111,22 +94,20 @@ describe('ProjectsDropdownFilter component', () => { const findSelectedButtonAvatarItemAtIndex = (index) => findSelectedDropdownAtIndex(index).find('img.gl-avatar'); - const selectedIds = () => wrapper.vm.selectedProjects.map(({ id }) => id); - - const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType); - describe('queryParams are applied when fetching data', () => { beforeEach(() => { createComponent({ - queryParams: { - first: 50, - includeSubgroups: true, + props: { + queryParams: { + first: 50, + includeSubgroups: true, + }, }, }); }); it('applies the correct queryParams when making an api call', async () => { - findSearchBoxByType().vm.$emit('input', 'gitlab'); + findDropdown().vm.$emit('search', 'gitlab'); expect(spyQuery).toHaveBeenCalledTimes(1); @@ -147,17 +128,19 @@ describe('ProjectsDropdownFilter component', () => { const blockDefaultProps = { multiSelect: true }; beforeEach(() => { - createComponent(blockDefaultProps); + createComponent({ + props: blockDefaultProps, + }); }); describe('with no project selected', () => { - it('does not render the highlighted items', async () => { - await createWithMockDropdown(blockDefaultProps); - - expect(findSelectedDropdownItems().length).toBe(0); + it('does not render the highlighted items', () => { + expect(findSelectedDropdownItems()).toHaveLength(0); }); it('renders the default project label text', () => { + createComponent({ mountFn: mountExtended, props: blockDefaultProps }); + expect(findSelectedProjectsLabel().text()).toBe('Select projects'); }); @@ -167,31 +150,43 @@ describe('ProjectsDropdownFilter component', () => { }); describe('with a selected project', () => { - beforeEach(async () => { - await selectDropdownItemAtIndex(0); + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + props: blockDefaultProps, + }); }); it('renders the highlighted items', async () => { - await createWithMockDropdown(blockDefaultProps); - await selectDropdownItemAtIndex(0); + await selectDropdownItemAtIndex([0], false); - expect(findSelectedDropdownItems().length).toBe(1); + expect(findSelectedDropdownItems()).toHaveLength(1); }); - it('renders the highlighted items title', () => { + it('renders the highlighted items title', async () => { + await selectDropdownItemAtIndex([0], false); + expect(findSelectedProjectsLabel().text()).toBe(projects[0].name); }); - it('renders the clear all button', () => { + it('renders the clear all button', async () => { + await selectDropdownItemAtIndex([0], false); + expect(findClearAllButton().exists()).toBe(true); }); it('clears all selected items when the clear all button is clicked', async () => { - await selectDropdownItemAtIndex(1); + createComponent({ + mountFn: mountExtended, + props: blockDefaultProps, + }); + await waitForPromises(); + + await selectDropdownItemAtIndex([0, 1]); expect(findSelectedProjectsLabel().text()).toBe('2 projects selected'); - await findClearAllButton().trigger('click'); + await findClearAllButton().vm.$emit('click'); expect(findSelectedProjectsLabel().text()).toBe('Select projects'); }); @@ -200,27 +195,35 @@ describe('ProjectsDropdownFilter component', () => { describe('with a selected project and search term', () => { beforeEach(async () => { - await createWithMockDropdown({ multiSelect: true }); + createComponent({ + props: { multiSelect: true }, + }); + await waitForPromises(); - selectDropdownItemAtIndex(0); - findSearchBoxByType().vm.$emit('input', 'this is a very long search string'); + await selectDropdownItemAtIndex([0]); + + findDropdown().vm.$emit('search', 'this is a very long search string'); }); it('renders the highlighted items', () => { - expect(findUnhighlightedItems().findAll('li').length).toBe(1); + expect(findSelectedDropdownItems()).toHaveLength(1); }); it('hides the unhighlighted items that do not match the string', () => { - expect(findUnhighlightedItems().findAll('li').length).toBe(1); - expect(findUnhighlightedItems().text()).toContain('No matching results'); + expect(wrapper.find(`[name="Selected"]`).findAllComponents(GlListboxItem).length).toBe(1); + expect(wrapper.find(`[name="Unselected"]`).findAllComponents(GlListboxItem).length).toBe(0); }); }); describe('when passed an array of defaultProject as prop', () => { - beforeEach(() => { + beforeEach(async () => { createComponent({ - defaultProjects: [projects[0]], + mountFn: mountExtended, + props: { + defaultProjects: [projects[0]], + }, }); + await waitForPromises(); }); it("displays the defaultProject's name", () => { @@ -232,14 +235,18 @@ describe('ProjectsDropdownFilter component', () => { }); it('marks the defaultProject as selected', () => { - expect(findDropdownAtIndex(0).props('isChecked')).toBe(true); + expect( + wrapper.findAll('[role="group"]').at(0).findAllComponents(GlListboxItem).at(0).text(), + ).toContain(projects[0].name); }); }); describe('when multiSelect is false', () => { const blockDefaultProps = { multiSelect: false }; beforeEach(() => { - createComponent(blockDefaultProps); + createComponent({ + props: blockDefaultProps, + }); }); describe('displays the correct information', () => { @@ -248,13 +255,12 @@ describe('ProjectsDropdownFilter component', () => { }); it('renders an avatar when the project has an avatarUrl', () => { - expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true); + expect(findDropdownButtonAvatarAtIndex(0).props('src')).toBe(projects[0].avatarUrl); expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false); }); - it("renders an identicon when the project doesn't have an avatarUrl", () => { - expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false); - expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true); + it("does not render an avatar when the project doesn't have an avatarUrl", () => { + expect(findDropdownButtonAvatarAtIndex(1).props('src')).toEqual(null); }); it('renders the project name', () => { @@ -271,37 +277,46 @@ describe('ProjectsDropdownFilter component', () => { }); describe('on project click', () => { - it('should emit the "selected" event with the selected project', () => { - selectDropdownItemAtIndex(0); + it('should emit the "selected" event with the selected project', async () => { + await selectDropdownItemAtIndex([0], false); - expect(wrapper.emitted().selected).toEqual([[[projects[0]]]]); + expect(wrapper.emitted('selected')).toEqual([[[projects[0]]]]); }); it('should change selection when new project is clicked', () => { - selectDropdownItemAtIndex(1); + selectDropdownItemAtIndex([1], false); - expect(wrapper.emitted().selected).toEqual([[[projects[1]]]]); + expect(wrapper.emitted('selected')).toEqual([[[projects[1]]]]); }); - it('selection should be emptied when a project is deselected', () => { - selectDropdownItemAtIndex(0); // Select the item - selectDropdownItemAtIndex(0); // deselect it + it('selection should be emptied when a project is deselected', async () => { + await selectDropdownItemAtIndex([0], false); // Select the item + await selectDropdownItemAtIndex([0], false); - expect(wrapper.emitted().selected).toEqual([[[projects[0]]], [[]]]); + expect(wrapper.emitted('selected')).toEqual([[[projects[0]]], [[]]]); }); it('renders an avatar in the dropdown button when the project has an avatarUrl', async () => { - await createWithMockDropdown(blockDefaultProps); - await selectDropdownItemAtIndex(0); + createComponent({ + mountFn: mountExtended, + props: blockDefaultProps, + }); + await waitForPromises(); + + await selectDropdownItemAtIndex([0], false); expect(findSelectedButtonAvatarItemAtIndex(0).exists()).toBe(true); expect(findSelectedButtonIdentIconAtIndex(0).exists()).toBe(false); }); it("renders an identicon in the dropdown button when the project doesn't have an avatarUrl", async () => { - await createWithMockDropdown(blockDefaultProps); - await selectDropdownItemAtIndex(1); + createComponent({ + mountFn: mountExtended, + props: blockDefaultProps, + }); + await waitForPromises(); + await selectDropdownItemAtIndex([1], false); expect(findSelectedButtonAvatarItemAtIndex(0).exists()).toBe(false); expect(findSelectedButtonIdentIconAtIndex(0).exists()).toBe(true); }); @@ -310,7 +325,9 @@ describe('ProjectsDropdownFilter component', () => { describe('when multiSelect is true', () => { beforeEach(() => { - createComponent({ multiSelect: true }); + createComponent({ + props: { multiSelect: true }, + }); }); describe('displays the correct information', () => { @@ -319,13 +336,12 @@ describe('ProjectsDropdownFilter component', () => { }); it('renders an avatar when the project has an avatarUrl', () => { - expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true); + expect(findDropdownButtonAvatarAtIndex(0).props('src')).toBe(projects[0].avatarUrl); expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false); }); it("renders an identicon when the project doesn't have an avatarUrl", () => { - expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false); - expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true); + expect(findDropdownButtonAvatarAtIndex(1).props('src')).toEqual(null); }); it('renders the project name', () => { @@ -342,27 +358,31 @@ describe('ProjectsDropdownFilter component', () => { }); describe('on project click', () => { - it('should add to selection when new project is clicked', () => { - selectDropdownItemAtIndex(0); - selectDropdownItemAtIndex(1); + it('should add to selection when new project is clicked', async () => { + await selectDropdownItemAtIndex([0, 1]); - expect(selectedIds()).toEqual([projects[0].id, projects[1].id]); + expect(findSelectedDropdownItems().at(0).text()).toContain(projects[1].name); + expect(findSelectedDropdownItems().at(1).text()).toContain(projects[0].name); }); - it('should remove from selection when clicked again', () => { - selectDropdownItemAtIndex(0); + it('should remove from selection when clicked again', async () => { + await selectDropdownItemAtIndex([0]); - expect(selectedIds()).toEqual([projects[0].id]); + expect(findSelectedDropdownItems().at(0).text()).toContain(projects[0].name); - selectDropdownItemAtIndex(0); + await selectDropdownItemAtIndex([]); - expect(selectedIds()).toEqual([]); + expect(findSelectedDropdownItems()).toHaveLength(0); }); it('renders the correct placeholder text when multiple projects are selected', async () => { - selectDropdownItemAtIndex(0); - selectDropdownItemAtIndex(1); - await nextTick(); + createComponent({ + props: { multiSelect: true }, + mountFn: mountExtended, + }); + await waitForPromises(); + + await selectDropdownItemAtIndex([0, 1]); expect(findDropdownButton().text()).toBe('2 projects selected'); }); diff --git a/spec/frontend/api/user_api_spec.js b/spec/frontend/api/user_api_spec.js index a879c229581..b2ecfeb8394 100644 --- a/spec/frontend/api/user_api_spec.js +++ b/spec/frontend/api/user_api_spec.js @@ -1,12 +1,14 @@ import MockAdapter from 'axios-mock-adapter'; import projects from 'test_fixtures/api/users/projects/get.json'; +import followers from 'test_fixtures/api/users/followers/get.json'; import { followUser, unfollowUser, associationsCount, updateUserStatus, getUserProjects, + getUserFollowers, } from '~/api/user_api'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; @@ -16,6 +18,7 @@ import { } from 'jest/admin/users/mock_data'; import { AVAILABILITY_STATUS } from '~/set_status_modal/constants'; import { timeRanges } from '~/vue_shared/constants'; +import { DEFAULT_PER_PAGE } from '~/api'; describe('~/api/user_api', () => { let axiosMock; @@ -112,4 +115,20 @@ describe('~/api/user_api', () => { expect(axiosMock.history.get[0].url).toBe(expectedUrl); }); }); + + describe('getUserFollowers', () => { + it('calls correct URL and returns expected response', async () => { + const expectedUrl = '/api/v4/users/1/followers'; + const expectedResponse = { data: followers }; + const params = { page: 2 }; + + axiosMock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, expectedResponse); + + await expect(getUserFollowers(1, params)).resolves.toEqual( + expect.objectContaining({ data: expectedResponse }), + ); + expect(axiosMock.history.get[0].url).toBe(expectedUrl); + expect(axiosMock.history.get[0].params).toEqual({ ...params, per_page: DEFAULT_PER_PAGE }); + }); + }); }); diff --git a/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap b/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap index ba8215f4e00..0bee37dbf15 100644 --- a/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap +++ b/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap @@ -1,32 +1,57 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Keep latest artifact checkbox when application keep latest artifact setting is enabled sets correct setting value in checkbox with query result 1`] = ` +exports[`Keep latest artifact toggle when application keep latest artifact setting is enabled sets correct setting value in toggle with query result 1`] = ` <div> <!----> - <b-form-checkbox-stub - checked="true" - class="gl-form-checkbox" - id="4" - value="true" + <div + class="gl-toggle-wrapper gl-display-flex gl-mb-0 gl-flex-direction-column" + data-testid="toggle-wrapper" > - <strong - class="gl-mr-3" + <span + class="gl-toggle-label gl-flex-shrink-0 gl-mb-3" + data-testid="toggle-label" + id="toggle-label-4" > Keep artifacts from most recent successful jobs - </strong> + </span> - <gl-link-stub - href="/help/ci/pipelines/job_artifacts" + <!----> + + <!----> + + <button + aria-checked="true" + aria-describedby="toggle-help-2" + aria-labelledby="toggle-label-4" + class="gl-flex-shrink-0 gl-toggle is-checked" + role="switch" + type="button" > - More information - </gl-link-stub> + <span + class="toggle-icon" + > + <gl-icon-stub + name="mobile-issue-close" + size="16" + /> + </span> + </button> - <p - class="help-text" + <span + class="gl-help-label" + data-testid="toggle-help" + id="toggle-help-2" > + The latest artifacts created by jobs in the most recent successful pipeline will be stored. - </p> - </b-form-checkbox-stub> + + <gl-link-stub + href="/help/ci/pipelines/job_artifacts" + > + Learn more. + </gl-link-stub> + </span> + </div> </div> `; 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 8dafff350f2..d0a7515432b 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 @@ -1,4 +1,4 @@ -import { GlFormCheckbox, GlLink } from '@gitlab/ui'; +import { GlToggle, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; @@ -7,7 +7,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import UpdateKeepLatestArtifactProjectSetting from '~/artifacts_settings/graphql/mutations/update_keep_latest_artifact_project_setting.mutation.graphql'; import GetKeepLatestArtifactApplicationSetting from '~/artifacts_settings/graphql/queries/get_keep_latest_artifact_application_setting.query.graphql'; import GetKeepLatestArtifactProjectSetting from '~/artifacts_settings/graphql/queries/get_keep_latest_artifact_project_setting.query.graphql'; -import KeepLatestArtifactCheckbox from '~/artifacts_settings/keep_latest_artifact_checkbox.vue'; +import KeepLatestArtifactToggle from '~/artifacts_settings/keep_latest_artifact_toggle.vue'; Vue.use(VueApollo); @@ -34,7 +34,7 @@ const keepLatestArtifactMockResponse = { }, }; -describe('Keep latest artifact checkbox', () => { +describe('Keep latest artifact toggle', () => { let wrapper; let apolloProvider; let requestHandlers; @@ -42,7 +42,7 @@ describe('Keep latest artifact checkbox', () => { const fullPath = 'gitlab-org/gitlab'; const helpPagePath = '/help/ci/pipelines/job_artifacts'; - const findCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findToggle = () => wrapper.findComponent(GlToggle); const findHelpLink = () => wrapper.findComponent(GlLink); const createComponent = (handlers) => { @@ -68,13 +68,13 @@ describe('Keep latest artifact checkbox', () => { [UpdateKeepLatestArtifactProjectSetting, requestHandlers.keepLatestArtifactMutationHandler], ]); - wrapper = shallowMount(KeepLatestArtifactCheckbox, { + wrapper = shallowMount(KeepLatestArtifactToggle, { provide: { fullPath, helpPagePath, }, stubs: { - GlFormCheckbox, + GlToggle, }, apolloProvider, }); @@ -89,13 +89,13 @@ describe('Keep latest artifact checkbox', () => { createComponent(); }); - it('displays the checkbox and the help link', () => { - expect(findCheckbox().exists()).toBe(true); + it('displays the toggle and the help link', () => { + expect(findToggle().exists()).toBe(true); expect(findHelpLink().exists()).toBe(true); }); it('calls mutation on artifact setting change with correct payload', () => { - findCheckbox().vm.$emit('change', false); + findToggle().vm.$emit('change', false); expect(requestHandlers.keepLatestArtifactMutationHandler).toHaveBeenCalledWith({ fullPath, @@ -110,12 +110,12 @@ describe('Keep latest artifact checkbox', () => { await waitForPromises(); }); - it('sets correct setting value in checkbox with query result', () => { + it('sets correct setting value in toggle with query result', () => { expect(wrapper.element).toMatchSnapshot(); }); - it('checkbox is enabled when application setting is enabled', () => { - expect(findCheckbox().attributes('disabled')).toBeUndefined(); + it('toggle is enabled when application setting is enabled', () => { + expect(findToggle().attributes('disabled')).toBeUndefined(); }); }); }); diff --git a/spec/frontend/batch_comments/components/diff_file_drafts_spec.js b/spec/frontend/batch_comments/components/diff_file_drafts_spec.js index f667ebc0fcb..014e28b7509 100644 --- a/spec/frontend/batch_comments/components/diff_file_drafts_spec.js +++ b/spec/frontend/batch_comments/components/diff_file_drafts_spec.js @@ -16,7 +16,10 @@ describe('Batch comments diff file drafts component', () => { batchComments: { namespaced: true, getters: { - draftsForFile: () => () => [{ id: 1 }, { id: 2 }], + draftsForFile: () => () => [ + { id: 1, position: { position_type: 'file' } }, + { id: 2, position: { position_type: 'file' } }, + ], }, }, }, @@ -24,7 +27,7 @@ describe('Batch comments diff file drafts component', () => { vm = shallowMount(DiffFileDrafts, { store, - propsData: { fileHash: 'filehash' }, + propsData: { fileHash: 'filehash', positionType: 'file' }, }); } diff --git a/spec/frontend/batch_comments/components/preview_item_spec.js b/spec/frontend/batch_comments/components/preview_item_spec.js index a19a72af813..191586e44cc 100644 --- a/spec/frontend/batch_comments/components/preview_item_spec.js +++ b/spec/frontend/batch_comments/components/preview_item_spec.js @@ -1,29 +1,33 @@ import { mount } from '@vue/test-utils'; import PreviewItem from '~/batch_comments/components/preview_item.vue'; -import { createStore } from '~/batch_comments/stores'; -import diffsModule from '~/diffs/store/modules'; -import notesModule from '~/notes/stores/modules'; +import store from '~/mr_notes/stores'; import { createDraft } from '../mock_data'; jest.mock('~/behaviors/markdown/render_gfm'); +jest.mock('~/mr_notes/stores', () => jest.requireActual('helpers/mocks/mr_notes/stores')); describe('Batch comments draft preview item component', () => { let wrapper; let draft; - function createComponent(isLast = false, extra = {}, extendStore = () => {}) { - const store = createStore(); - store.registerModule('diffs', diffsModule()); - store.registerModule('notes', notesModule()); + beforeEach(() => { + store.reset(); - extendStore(store); + store.getters.getDiscussion = jest.fn(() => null); + }); + function createComponent(isLast = false, extra = {}) { draft = { ...createDraft(), ...extra, }; - wrapper = mount(PreviewItem, { store, propsData: { draft, isLast } }); + wrapper = mount(PreviewItem, { + mocks: { + $store: store, + }, + propsData: { draft, isLast }, + }); } it('renders text content', () => { @@ -87,18 +91,19 @@ describe('Batch comments draft preview item component', () => { describe('for thread', () => { beforeEach(() => { - createComponent(false, { discussion_id: '1', resolve_discussion: true }, (store) => { - store.state.notes.discussions.push({ - id: '1', - notes: [ - { - author: { - name: "Author 'Nick' Name", - }, + store.getters.getDiscussion.mockReturnValue({ + id: '1', + notes: [ + { + author: { + name: "Author 'Nick' Name", }, - ], - }); + }, + ], }); + store.getters.isDiscussionResolved = jest.fn().mockReturnValue(false); + + createComponent(false, { discussion_id: '1', resolve_discussion: true }); }); it('renders title', () => { @@ -114,9 +119,7 @@ describe('Batch comments draft preview item component', () => { describe('for new comment', () => { it('renders title', () => { - createComponent(false, {}, (store) => { - store.state.notes.discussions.push({}); - }); + createComponent(); expect(wrapper.find('.review-preview-item-header-text').text()).toContain('Your new comment'); }); diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js index 57bafb51cd6..521bbf06b02 100644 --- a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js +++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js @@ -70,6 +70,19 @@ describe('Batch comments store actions', () => { ); }); + it('dispatchs addDraftToFile if draft is on file', () => { + res = { id: 1, position: { position_type: 'file' }, file_path: 'index.js' }; + mock.onAny().reply(HTTP_STATUS_OK, res); + + return testAction( + actions.createNewDraft, + { endpoint: TEST_HOST, data: 'test' }, + null, + [{ type: 'ADD_NEW_DRAFT', payload: res }], + [{ type: 'diffs/addDraftToFile', payload: { draft: res, filePath: 'index.js' } }], + ); + }); + it('does not commit ADD_NEW_DRAFT if errors returned', () => { mock.onAny().reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); diff --git a/spec/frontend/behaviors/markdown/utils_spec.js b/spec/frontend/behaviors/markdown/utils_spec.js new file mode 100644 index 00000000000..f4e7ca621d9 --- /dev/null +++ b/spec/frontend/behaviors/markdown/utils_spec.js @@ -0,0 +1,18 @@ +import { toggleMarkCheckboxes } from '~/behaviors/markdown/utils'; + +describe('toggleMarkCheckboxes', () => { + const rawMarkdown = `- [x] todo 1\n- [ ] todo 2`; + + it.each` + assertionName | sourcepos | checkboxChecked | expectedMarkdown + ${'marks'} | ${'2:1-2:12'} | ${true} | ${'- [x] todo 1\n- [x] todo 2'} + ${'unmarks'} | ${'1:1-1:12'} | ${false} | ${'- [ ] todo 1\n- [ ] todo 2'} + `( + '$assertionName the checkbox at correct position', + ({ sourcepos, checkboxChecked, expectedMarkdown }) => { + expect(toggleMarkCheckboxes({ rawMarkdown, sourcepos, checkboxChecked })).toEqual( + expectedMarkdown, + ); + }, + ); +}); diff --git a/spec/frontend/blame/streaming/index_spec.js b/spec/frontend/blame/streaming/index_spec.js index e048ce3f70e..29beb6beffa 100644 --- a/spec/frontend/blame/streaming/index_spec.js +++ b/spec/frontend/blame/streaming/index_spec.js @@ -4,12 +4,14 @@ import { setHTMLFixture } from 'helpers/fixtures'; import { renderHtmlStreams } from '~/streaming/render_html_streams'; import { rateLimitStreamRequests } from '~/streaming/rate_limit_stream_requests'; import { handleStreamedAnchorLink } from '~/streaming/handle_streamed_anchor_link'; +import { handleStreamedRelativeTimestamps } from '~/streaming/handle_streamed_relative_timestamps'; import { toPolyfillReadable } from '~/streaming/polyfills'; import { createAlert } from '~/alert'; jest.mock('~/streaming/render_html_streams'); jest.mock('~/streaming/rate_limit_stream_requests'); jest.mock('~/streaming/handle_streamed_anchor_link'); +jest.mock('~/streaming/handle_streamed_relative_timestamps'); jest.mock('~/streaming/polyfills'); jest.mock('~/sentry'); jest.mock('~/alert'); @@ -18,6 +20,7 @@ global.fetch = jest.fn(); describe('renderBlamePageStreams', () => { let stopAnchor; + let stopTimetamps; const PAGES_URL = 'https://example.com/'; const findStreamContainer = () => document.querySelector('#blame-stream-container'); const findStreamLoadingIndicator = () => document.querySelector('#blame-stream-loading'); @@ -34,6 +37,7 @@ describe('renderBlamePageStreams', () => { }; handleStreamedAnchorLink.mockImplementation(() => stopAnchor); + handleStreamedRelativeTimestamps.mockImplementation(() => Promise.resolve(stopTimetamps)); rateLimitStreamRequests.mockImplementation(({ factory, total }) => { return Array.from({ length: total }, (_, i) => { return Promise.resolve(factory(i)); @@ -43,6 +47,7 @@ describe('renderBlamePageStreams', () => { beforeEach(() => { stopAnchor = jest.fn(); + stopTimetamps = jest.fn(); fetch.mockClear(); }); @@ -50,6 +55,7 @@ describe('renderBlamePageStreams', () => { await renderBlamePageStreams(); expect(handleStreamedAnchorLink).not.toHaveBeenCalled(); + expect(handleStreamedRelativeTimestamps).not.toHaveBeenCalled(); expect(renderHtmlStreams).not.toHaveBeenCalled(); }); @@ -64,7 +70,9 @@ describe('renderBlamePageStreams', () => { renderBlamePageStreams(stream); expect(handleStreamedAnchorLink).toHaveBeenCalledTimes(1); + expect(handleStreamedRelativeTimestamps).toHaveBeenCalledTimes(1); expect(stopAnchor).toHaveBeenCalledTimes(0); + expect(stopTimetamps).toHaveBeenCalledTimes(0); expect(renderHtmlStreams).toHaveBeenCalledWith([stream], findStreamContainer()); expect(findStreamLoadingIndicator()).not.toBe(null); @@ -72,6 +80,7 @@ describe('renderBlamePageStreams', () => { await waitForPromises(); expect(stopAnchor).toHaveBeenCalledTimes(1); + expect(stopTimetamps).toHaveBeenCalledTimes(1); expect(findStreamLoadingIndicator()).toBe(null); }); diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js index 43cf6ead1c1..e3cdec1ab6e 100644 --- a/spec/frontend/boards/board_list_helper.js +++ b/spec/frontend/boards/board_list_helper.js @@ -39,7 +39,7 @@ export default function createComponent({ Vue.use(Vuex); const fakeApollo = createMockApollo([ - [listQuery, jest.fn().mockResolvedValue(boardListQueryResponse(issuesCount))], + [listQuery, jest.fn().mockResolvedValue(boardListQueryResponse({ issuesCount }))], ...apolloQueryHandlers, ]); diff --git a/spec/frontend/boards/boards_util_spec.js b/spec/frontend/boards/boards_util_spec.js index ab3cf072357..3601bf14703 100644 --- a/spec/frontend/boards/boards_util_spec.js +++ b/spec/frontend/boards/boards_util_spec.js @@ -1,4 +1,5 @@ -import { formatIssueInput, filterVariables } from '~/boards/boards_util'; +import { formatIssueInput, filterVariables, FiltersInfo } from '~/boards/boards_util'; +import { FilterFields } from '~/boards/constants'; describe('formatIssueInput', () => { const issueInput = { @@ -149,4 +150,40 @@ describe('filterVariables', () => { expect(result).toEqual(expected); }); + + it.each([ + [ + 'converts milestone wild card', + { + filters: { + milestoneTitle: 'Started', + }, + expected: { + milestoneWildcardId: 'STARTED', + not: {}, + }, + }, + ], + [ + 'converts assignee wild card', + { + filters: { + assigneeUsername: 'Any', + }, + expected: { + assigneeWildcardId: 'ANY', + not: {}, + }, + }, + ], + ])('%s', (_, { filters, issuableType = 'issue', expected }) => { + const result = filterVariables({ + filters, + issuableType, + filterInfo: FiltersInfo, + filterFields: FilterFields, + }); + + expect(result).toEqual(expected); + }); }); diff --git a/spec/frontend/boards/components/board_add_new_column_form_spec.js b/spec/frontend/boards/components/board_add_new_column_form_spec.js index 4fc9a6859a6..35296f36b89 100644 --- a/spec/frontend/boards/components/board_add_new_column_form_spec.js +++ b/spec/frontend/boards/components/board_add_new_column_form_spec.js @@ -29,10 +29,7 @@ describe('BoardAddNewColumnForm', () => { }, slots, store: createStore({ - actions: { - setAddColumnFormVisibility: jest.fn(), - ...actions, - }, + actions, }), }); }; @@ -48,16 +45,11 @@ describe('BoardAddNewColumnForm', () => { }); it('clicking cancel hides the form', () => { - const setAddColumnFormVisibility = jest.fn(); - mountComponent({ - actions: { - setAddColumnFormVisibility, - }, - }); + mountComponent(); cancelButton().vm.$emit('click'); - expect(setAddColumnFormVisibility).toHaveBeenCalledWith(expect.anything(), false); + expect(wrapper.emitted('setAddColumnFormVisibility')).toEqual([[false]]); }); describe('Add list button', () => { diff --git a/spec/frontend/boards/components/board_add_new_column_spec.js b/spec/frontend/boards/components/board_add_new_column_spec.js index a09c3aaa55e..8d6cc9373af 100644 --- a/spec/frontend/boards/components/board_add_new_column_spec.js +++ b/spec/frontend/boards/components/board_add_new_column_spec.js @@ -1,18 +1,36 @@ import { GlCollapsibleListbox } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import Vuex from 'vuex'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import BoardAddNewColumn from '~/boards/components/board_add_new_column.vue'; import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue'; import defaultState from '~/boards/stores/state'; -import { mockLabelList } from '../mock_data'; +import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; +import boardLabelsQuery from '~/boards/graphql/board_labels.query.graphql'; +import { + mockLabelList, + createBoardListResponse, + labelsQueryResponse, + boardListsQueryResponse, +} from '../mock_data'; Vue.use(Vuex); +Vue.use(VueApollo); -describe('Board card layout', () => { +describe('BoardAddNewColumn', () => { let wrapper; + const createBoardListQueryHandler = jest.fn().mockResolvedValue(createBoardListResponse); + const labelsQueryHandler = jest.fn().mockResolvedValue(labelsQueryResponse); + const mockApollo = createMockApollo([ + [boardLabelsQuery, labelsQueryHandler], + [createBoardListMutation, createBoardListQueryHandler], + ]); + const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); + const findAddNewColumnForm = () => wrapper.findComponent(BoardAddNewColumnForm); const selectLabel = (id) => { findDropdown().vm.$emit('select', id); }; @@ -33,8 +51,22 @@ describe('Board card layout', () => { labels = [], getListByLabelId = jest.fn(), actions = {}, + provide = {}, + lists = {}, } = {}) => { wrapper = shallowMountExtended(BoardAddNewColumn, { + apolloProvider: mockApollo, + propsData: { + listQueryVariables: { + isGroup: false, + isProject: true, + fullPath: 'gitlab-org/gitlab', + boardId: 'gid://gitlab/Board/1', + filters: {}, + }, + boardId: 'gid://gitlab/Board/1', + lists, + }, data() { return { selectedId, @@ -43,7 +75,6 @@ describe('Board card layout', () => { store: createStore({ actions: { fetchLabels: jest.fn(), - setAddColumnFormVisibility: jest.fn(), ...actions, }, getters: { @@ -57,6 +88,11 @@ describe('Board card layout', () => { provide: { scopedLabelsAvailable: true, isEpicBoard: false, + issuableType: 'issue', + fullPath: 'gitlab-org/gitlab', + boardType: 'project', + isApolloBoard: false, + ...provide, }, stubs: { GlCollapsibleListbox, @@ -67,6 +103,12 @@ describe('Board card layout', () => { if (selectedId) { selectLabel(selectedId); } + + // Necessary for cache update + mockApollo.clients.defaultClient.cache.readQuery = jest + .fn() + .mockReturnValue(boardListsQueryResponse.data); + mockApollo.clients.defaultClient.cache.writeQuery = jest.fn(); }; describe('Add list button', () => { @@ -85,7 +127,7 @@ describe('Board card layout', () => { }, }); - wrapper.findComponent(BoardAddNewColumnForm).vm.$emit('add-list'); + findAddNewColumnForm().vm.$emit('add-list'); await nextTick(); @@ -110,7 +152,7 @@ describe('Board card layout', () => { }, }); - wrapper.findComponent(BoardAddNewColumnForm).vm.$emit('add-list'); + findAddNewColumnForm().vm.$emit('add-list'); await nextTick(); @@ -118,4 +160,59 @@ describe('Board card layout', () => { expect(createList).not.toHaveBeenCalled(); }); }); + + describe('Apollo boards', () => { + describe('when list is new', () => { + beforeEach(() => { + mountComponent({ selectedId: mockLabelList.label.id, provide: { isApolloBoard: true } }); + }); + + it('fetches labels and adds list', async () => { + findDropdown().vm.$emit('show'); + + await nextTick(); + expect(labelsQueryHandler).toHaveBeenCalled(); + + selectLabel(mockLabelList.label.id); + + findAddNewColumnForm().vm.$emit('add-list'); + + await nextTick(); + + expect(wrapper.emitted('highlight-list')).toBeUndefined(); + expect(createBoardListQueryHandler).toHaveBeenCalledWith({ + labelId: mockLabelList.label.id, + boardId: 'gid://gitlab/Board/1', + }); + }); + }); + + describe('when list already exists in board', () => { + beforeEach(() => { + mountComponent({ + lists: { + [mockLabelList.id]: mockLabelList, + }, + selectedId: mockLabelList.label.id, + provide: { isApolloBoard: true }, + }); + }); + + it('highlights existing list if trying to re-add', async () => { + findDropdown().vm.$emit('show'); + + await nextTick(); + expect(labelsQueryHandler).toHaveBeenCalled(); + + selectLabel(mockLabelList.label.id); + + findAddNewColumnForm().vm.$emit('add-list'); + + await nextTick(); + + expect(wrapper.emitted('highlight-list')).toEqual([[mockLabelList.id]]); + expect(createBoardListQueryHandler).not.toHaveBeenCalledWith(); + }); + }); + }); }); diff --git a/spec/frontend/boards/components/board_add_new_column_trigger_spec.js b/spec/frontend/boards/components/board_add_new_column_trigger_spec.js index d8b93e1f3b6..825cfc9453a 100644 --- a/spec/frontend/boards/components/board_add_new_column_trigger_spec.js +++ b/spec/frontend/boards/components/board_add_new_column_trigger_spec.js @@ -1,5 +1,5 @@ import { GlButton } from '@gitlab/ui'; -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; import Vuex from 'vuex'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue'; @@ -13,12 +13,16 @@ describe('BoardAddNewColumnTrigger', () => { const findBoardsCreateList = () => wrapper.findByTestId('boards-create-list'); const findTooltipText = () => getBinding(findBoardsCreateList().element, 'gl-tooltip'); + const findCreateButton = () => wrapper.findComponent(GlButton); - const mountComponent = () => { + const mountComponent = ({ isNewListShowing = false } = {}) => { wrapper = mountExtended(BoardAddNewColumnTrigger, { directives: { GlTooltip: createMockDirective('gl-tooltip'), }, + propsData: { + isNewListShowing, + }, store: createStore(), }); }; @@ -35,17 +39,19 @@ describe('BoardAddNewColumnTrigger', () => { }); it('renders an enabled button', () => { - const button = wrapper.findComponent(GlButton); + expect(findCreateButton().props('disabled')).toBe(false); + }); - expect(button.props('disabled')).toBe(false); + it('shows form on click button', () => { + findCreateButton().vm.$emit('click'); + + expect(wrapper.emitted('setAddColumnFormVisibility')).toEqual([[true]]); }); }); describe('when button is disabled', () => { - it('shows the tooltip', async () => { - wrapper.findComponent(GlButton).vm.$emit('click'); - - await nextTick(); + it('shows the tooltip', () => { + mountComponent({ isNewListShowing: true }); const tooltip = findTooltipText(); diff --git a/spec/frontend/boards/components/board_card_move_to_position_spec.js b/spec/frontend/boards/components/board_card_move_to_position_spec.js index 8af772ba6d0..5f308be5580 100644 --- a/spec/frontend/boards/components/board_card_move_to_position_spec.js +++ b/spec/frontend/boards/components/board_card_move_to_position_spec.js @@ -51,9 +51,12 @@ describe('Board Card Move to position', () => { }; }; - const createComponent = (propsData) => { + const createComponent = (propsData, isApolloBoard = false) => { wrapper = shallowMount(BoardCardMoveToPosition, { store, + provide: { + isApolloBoard, + }, propsData: { item: mockIssue2, list: mockList, @@ -134,5 +137,39 @@ describe('Board Card Move to position', () => { }, ); }); + + describe('Apollo boards', () => { + beforeEach(() => { + createComponent({ index: itemIndex }, true); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it.each` + dropdownIndex | dropdownItem | trackLabel | positionInList + ${0} | ${dropdownOptions[0]} | ${'move_to_start'} | ${0} + ${1} | ${dropdownOptions[1]} | ${'move_to_end'} | ${-1} + `( + 'on click of dropdown index $dropdownIndex with label $dropdownLabel emits moveToPosition event with tracking label $trackLabel', + async ({ dropdownIndex, dropdownItem, trackLabel, positionInList }) => { + await findMoveToPositionDropdown().vm.$emit('shown'); + + expect(findDropdownItemAtIndex(dropdownIndex).text()).toBe(dropdownItem.text); + + await findMoveToPositionDropdown().vm.$emit('action', dropdownItem); + + expect(trackingSpy).toHaveBeenCalledWith('boards:list', 'click_toggle_button', { + category: 'boards:list', + label: trackLabel, + property: 'type_card', + }); + + expect(wrapper.emitted('moveToPosition')).toEqual([[positionInList]]); + }, + ); + }); }); }); diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js index e14f661a8bd..9260718a94b 100644 --- a/spec/frontend/boards/components/board_content_spec.js +++ b/spec/frontend/boards/components/board_content_spec.js @@ -1,9 +1,11 @@ import { GlAlert } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; import Vue from 'vue'; import Draggable from 'vuedraggable'; import Vuex from 'vuex'; import eventHub from '~/boards/eventhub'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue'; @@ -11,8 +13,18 @@ import getters from 'ee_else_ce/boards/stores/getters'; import BoardColumn from '~/boards/components/board_column.vue'; import BoardContent from '~/boards/components/board_content.vue'; import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue'; -import { mockLists, mockListsById } from '../mock_data'; - +import updateBoardListMutation from '~/boards/graphql/board_list_update.mutation.graphql'; +import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue'; +import { DraggableItemTypes } from 'ee_else_ce/boards/constants'; +import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql'; +import { + mockLists, + mockListsById, + updateBoardListResponse, + boardListsQueryResponse, +} from '../mock_data'; + +Vue.use(VueApollo); Vue.use(Vuex); const actions = { @@ -21,10 +33,13 @@ const actions = { describe('BoardContent', () => { let wrapper; + let mockApollo; + + const updateListHandler = jest.fn().mockResolvedValue(updateBoardListResponse); const defaultState = { isShowingEpicsSwimlanes: false, - boardLists: mockLists, + boardLists: mockListsById, error: undefined, issuableType: 'issue', }; @@ -46,19 +61,32 @@ describe('BoardContent', () => { isIssueBoard = true, isEpicBoard = false, } = {}) => { + mockApollo = createMockApollo([[updateBoardListMutation, updateListHandler]]); + const listQueryVariables = { isProject: true }; + + mockApollo.clients.defaultClient.writeQuery({ + query: boardListsQuery, + variables: listQueryVariables, + data: boardListsQueryResponse.data, + }); + const store = createStore({ ...defaultState, ...state, }); wrapper = shallowMount(BoardContent, { + apolloProvider: mockApollo, propsData: { boardId: 'gid://gitlab/Board/1', filterParams: {}, isSwimlanesOn: false, boardListsApollo: mockListsById, + listQueryVariables, + addColumnFormVisible: false, ...props, }, provide: { + boardType: 'project', canAdminList, issuableType, isIssueBoard, @@ -76,6 +104,10 @@ describe('BoardContent', () => { }); }; + const findBoardColumns = () => wrapper.findAllComponents(BoardColumn); + const findBoardAddNewColumn = () => wrapper.findComponent(BoardAddNewColumn); + const findDraggable = () => wrapper.findComponent(Draggable); + describe('default', () => { beforeEach(() => { createComponent(); @@ -100,6 +132,10 @@ describe('BoardContent', () => { expect(listEl.attributes('delay')).toBe('100'); expect(listEl.attributes('delayontouchonly')).toBe('true'); }); + + it('does not show the "add column" form', () => { + expect(findBoardAddNewColumn().exists()).toBe(false); + }); }); describe('when issuableType is not issue', () => { @@ -118,7 +154,7 @@ describe('BoardContent', () => { }); it('renders draggable component', () => { - expect(wrapper.findComponent(Draggable).exists()).toBe(true); + expect(findDraggable().exists()).toBe(true); }); }); @@ -128,7 +164,7 @@ describe('BoardContent', () => { }); it('does not render draggable component', () => { - expect(wrapper.findComponent(Draggable).exists()).toBe(false); + expect(findDraggable().exists()).toBe(false); }); }); @@ -154,5 +190,36 @@ describe('BoardContent', () => { expect(eventHub.$on).toHaveBeenCalledWith('updateBoard', wrapper.vm.refetchLists); }); + + it('reorders lists', async () => { + const movableListsOrder = [mockLists[0].id, mockLists[1].id]; + + findDraggable().vm.$emit('end', { + item: { dataset: { listId: mockLists[0].id, draggableItemType: DraggableItemTypes.list } }, + newIndex: 1, + to: { + children: movableListsOrder.map((listId) => ({ dataset: { listId } })), + }, + }); + await waitForPromises(); + + expect(updateListHandler).toHaveBeenCalled(); + }); + }); + + describe('when "add column" form is visible', () => { + beforeEach(() => { + createComponent({ props: { addColumnFormVisible: true } }); + }); + + it('shows the "add column" form', () => { + expect(findBoardAddNewColumn().exists()).toBe(true); + }); + + it('hides other columns on mobile viewports', () => { + findBoardColumns().wrappers.forEach((column) => { + expect(column.classes()).toEqual(['gl-display-none!', 'gl-sm-display-inline-block!']); + }); + }); }); }); diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index f340dfab359..5604c589e37 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -1,9 +1,11 @@ import { GlModal } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; +import VueApollo from 'vue-apollo'; import setWindowLocation from 'helpers/set_window_location_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import createApolloProvider from 'helpers/mock_apollo_helper'; import BoardForm from '~/boards/components/board_form.vue'; import { formType } from '~/boards/constants'; @@ -42,7 +44,7 @@ const defaultProps = { describe('BoardForm', () => { let wrapper; - let mutate; + let requestHandlers; const findModal = () => wrapper.findComponent(GlModal); const findModalActionPrimary = () => findModal().props('actionPrimary'); @@ -61,8 +63,43 @@ describe('BoardForm', () => { }, }); - const createComponent = (props, provide) => { + const defaultHandlers = { + createBoardMutationHandler: jest.fn().mockResolvedValue({ + data: { + createBoard: { + board: { id: '1' }, + errors: [], + }, + }, + }), + destroyBoardMutationHandler: jest.fn().mockResolvedValue({ + data: { + destroyBoard: { + board: { id: '1' }, + }, + }, + }), + updateBoardMutationHandler: jest.fn().mockResolvedValue({ + data: { + updateBoard: { board: { id: 'gid://gitlab/Board/321', webPath: 'test-path' }, errors: [] }, + }, + }), + }; + + const createMockApolloProvider = (handlers = {}) => { + Vue.use(VueApollo); + requestHandlers = handlers; + + return createApolloProvider([ + [createBoardMutation, handlers.createBoardMutationHandler], + [destroyBoardMutation, handlers.destroyBoardMutationHandler], + [updateBoardMutation, handlers.updateBoardMutationHandler], + ]); + }; + + const createComponent = ({ props, provide, handlers = defaultHandlers } = {}) => { wrapper = shallowMountExtended(BoardForm, { + apolloProvider: createMockApolloProvider(handlers), propsData: { ...defaultProps, ...props }, provide: { boardBaseUrl: 'root', @@ -70,23 +107,16 @@ describe('BoardForm', () => { isProjectBoard: false, ...provide, }, - mocks: { - $apollo: { - mutate, - }, - }, store, attachTo: document.body, }); }; - afterEach(() => { - mutate = null; - }); - describe('when user can not admin the board', () => { beforeEach(() => { - createComponent({ currentPage: formType.new }); + createComponent({ + props: { currentPage: formType.new }, + }); }); it('hides modal footer when user is not a board admin', () => { @@ -104,7 +134,9 @@ describe('BoardForm', () => { describe('when user can admin the board', () => { beforeEach(() => { - createComponent({ canAdminBoard: true, currentPage: formType.new }); + createComponent({ + props: { canAdminBoard: true, currentPage: formType.new }, + }); }); it('shows modal footer when user is a board admin', () => { @@ -123,7 +155,9 @@ describe('BoardForm', () => { describe('when creating a new board', () => { describe('on non-scoped-board', () => { beforeEach(() => { - createComponent({ canAdminBoard: true, currentPage: formType.new }); + createComponent({ + props: { canAdminBoard: true, currentPage: formType.new }, + }); }); it('clears the form', () => { @@ -155,36 +189,30 @@ describe('BoardForm', () => { findInput().trigger('keyup.enter', { metaKey: true }); }; - beforeEach(() => { - mutate = jest.fn().mockResolvedValue({ - data: { - createBoard: { board: { id: 'gid://gitlab/Board/123', webPath: 'test-path' } }, - }, - }); - }); - it('does not call API if board name is empty', async () => { - createComponent({ canAdminBoard: true, currentPage: formType.new }); + createComponent({ + props: { canAdminBoard: true, currentPage: formType.new }, + }); findInput().trigger('keyup.enter', { metaKey: true }); await waitForPromises(); - expect(mutate).not.toHaveBeenCalled(); + expect(requestHandlers.createBoardMutationHandler).not.toHaveBeenCalled(); }); it('calls a correct GraphQL mutation and sets board in state', async () => { - createComponent({ canAdminBoard: true, currentPage: formType.new }); + createComponent({ + props: { canAdminBoard: true, currentPage: formType.new }, + }); + fillForm(); await waitForPromises(); - expect(mutate).toHaveBeenCalledWith({ - mutation: createBoardMutation, - variables: { - input: expect.objectContaining({ - name: 'test', - }), - }, + expect(requestHandlers.createBoardMutationHandler).toHaveBeenCalledWith({ + input: expect.objectContaining({ + name: 'test', + }), }); await waitForPromises(); @@ -192,14 +220,19 @@ describe('BoardForm', () => { }); it('sets error in state if GraphQL mutation fails', async () => { - mutate = jest.fn().mockRejectedValue('Houston, we have a problem'); - createComponent({ canAdminBoard: true, currentPage: formType.new }); + createComponent({ + props: { canAdminBoard: true, currentPage: formType.new }, + handlers: { + ...defaultHandlers, + createBoardMutationHandler: jest.fn().mockRejectedValue('Houston, we have a problem'), + }, + }); fillForm(); await waitForPromises(); - expect(mutate).toHaveBeenCalled(); + expect(requestHandlers.createBoardMutationHandler).toHaveBeenCalled(); await waitForPromises(); expect(setBoardMock).not.toHaveBeenCalled(); @@ -208,21 +241,19 @@ describe('BoardForm', () => { describe('when Apollo boards FF is on', () => { it('calls a correct GraphQL mutation and emits addBoard event when creating a board', async () => { - createComponent( - { canAdminBoard: true, currentPage: formType.new }, - { isApolloBoard: true }, - ); + createComponent({ + props: { canAdminBoard: true, currentPage: formType.new }, + provide: { isApolloBoard: true }, + }); + fillForm(); await waitForPromises(); - expect(mutate).toHaveBeenCalledWith({ - mutation: createBoardMutation, - variables: { - input: expect.objectContaining({ - name: 'test', - }), - }, + expect(requestHandlers.createBoardMutationHandler).toHaveBeenCalledWith({ + input: expect.objectContaining({ + name: 'test', + }), }); await waitForPromises(); @@ -235,7 +266,9 @@ describe('BoardForm', () => { describe('when editing a board', () => { describe('on non-scoped-board', () => { beforeEach(() => { - createComponent({ canAdminBoard: true, currentPage: formType.edit }); + createComponent({ + props: { canAdminBoard: true, currentPage: formType.edit }, + }); }); it('clears the form', () => { @@ -261,25 +294,19 @@ describe('BoardForm', () => { }); it('calls GraphQL mutation with correct parameters when issues are not grouped', async () => { - mutate = jest.fn().mockResolvedValue({ - data: { - updateBoard: { board: { id: 'gid://gitlab/Board/321', webPath: 'test-path' } }, - }, - }); setWindowLocation('https://test/boards/1'); - createComponent({ canAdminBoard: true, currentPage: formType.edit }); + createComponent({ + props: { canAdminBoard: true, currentPage: formType.edit }, + }); findInput().trigger('keyup.enter', { metaKey: true }); await waitForPromises(); - expect(mutate).toHaveBeenCalledWith({ - mutation: updateBoardMutation, - variables: { - input: expect.objectContaining({ - id: currentBoard.id, - }), - }, + expect(requestHandlers.updateBoardMutationHandler).toHaveBeenCalledWith({ + input: expect.objectContaining({ + id: currentBoard.id, + }), }); await waitForPromises(); @@ -288,25 +315,19 @@ describe('BoardForm', () => { }); it('calls GraphQL mutation with correct parameters when issues are grouped by epic', async () => { - mutate = jest.fn().mockResolvedValue({ - data: { - updateBoard: { board: { id: 'gid://gitlab/Board/321', webPath: 'test-path' } }, - }, - }); setWindowLocation('https://test/boards/1?group_by=epic'); - createComponent({ canAdminBoard: true, currentPage: formType.edit }); + createComponent({ + props: { canAdminBoard: true, currentPage: formType.edit }, + }); findInput().trigger('keyup.enter', { metaKey: true }); await waitForPromises(); - expect(mutate).toHaveBeenCalledWith({ - mutation: updateBoardMutation, - variables: { - input: expect.objectContaining({ - id: currentBoard.id, - }), - }, + expect(requestHandlers.updateBoardMutationHandler).toHaveBeenCalledWith({ + input: expect.objectContaining({ + id: currentBoard.id, + }), }); await waitForPromises(); @@ -315,14 +336,19 @@ describe('BoardForm', () => { }); it('sets error in state if GraphQL mutation fails', async () => { - mutate = jest.fn().mockRejectedValue('Houston, we have a problem'); - createComponent({ canAdminBoard: true, currentPage: formType.edit }); + createComponent({ + props: { canAdminBoard: true, currentPage: formType.edit }, + handlers: { + ...defaultHandlers, + updateBoardMutationHandler: jest.fn().mockRejectedValue('Houston, we have a problem'), + }, + }); findInput().trigger('keyup.enter', { metaKey: true }); await waitForPromises(); - expect(mutate).toHaveBeenCalled(); + expect(requestHandlers.updateBoardMutationHandler).toHaveBeenCalled(); await waitForPromises(); expect(setBoardMock).not.toHaveBeenCalled(); @@ -331,28 +357,20 @@ describe('BoardForm', () => { describe('when Apollo boards FF is on', () => { it('calls a correct GraphQL mutation and emits updateBoard event when updating a board', async () => { - mutate = jest.fn().mockResolvedValue({ - data: { - updateBoard: { board: { id: 'gid://gitlab/Board/321', webPath: 'test-path' } }, - }, - }); setWindowLocation('https://test/boards/1'); - createComponent( - { canAdminBoard: true, currentPage: formType.edit }, - { isApolloBoard: true }, - ); + createComponent({ + props: { canAdminBoard: true, currentPage: formType.edit }, + provide: { isApolloBoard: true }, + }); findInput().trigger('keyup.enter', { metaKey: true }); await waitForPromises(); - expect(mutate).toHaveBeenCalledWith({ - mutation: updateBoardMutation, - variables: { - input: expect.objectContaining({ - id: currentBoard.id, - }), - }, + expect(requestHandlers.updateBoardMutationHandler).toHaveBeenCalledWith({ + input: expect.objectContaining({ + id: currentBoard.id, + }), }); await waitForPromises(); @@ -367,28 +385,30 @@ describe('BoardForm', () => { describe('when deleting a board', () => { it('passes correct primary action text and variant', () => { - createComponent({ canAdminBoard: true, currentPage: formType.delete }); + createComponent({ + props: { canAdminBoard: true, currentPage: formType.delete }, + }); expect(findModalActionPrimary().text).toBe('Delete'); expect(findModalActionPrimary().attributes.variant).toBe('danger'); }); it('renders delete confirmation message', () => { - createComponent({ canAdminBoard: true, currentPage: formType.delete }); + createComponent({ + props: { canAdminBoard: true, currentPage: formType.delete }, + }); expect(findDeleteConfirmation().exists()).toBe(true); }); it('calls a correct GraphQL mutation and redirects to correct page after deleting board', async () => { - mutate = jest.fn().mockResolvedValue({}); - createComponent({ canAdminBoard: true, currentPage: formType.delete }); + createComponent({ + props: { canAdminBoard: true, currentPage: formType.delete }, + }); findModal().vm.$emit('primary'); await waitForPromises(); - expect(mutate).toHaveBeenCalledWith({ - mutation: destroyBoardMutation, - variables: { - id: currentBoard.id, - }, + expect(requestHandlers.destroyBoardMutationHandler).toHaveBeenCalledWith({ + id: currentBoard.id, }); await waitForPromises(); @@ -396,19 +416,26 @@ describe('BoardForm', () => { }); it('dispatches `setError` action when GraphQL mutation fails', async () => { - mutate = jest.fn().mockRejectedValue('Houston, we have a problem'); - createComponent({ canAdminBoard: true, currentPage: formType.delete }); - jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); + createComponent({ + props: { canAdminBoard: true, currentPage: formType.delete }, + handlers: { + ...defaultHandlers, + destroyBoardMutationHandler: jest.fn().mockRejectedValue('Houston, we have a problem'), + }, + }); + jest.spyOn(store, 'dispatch').mockImplementation(() => {}); findModal().vm.$emit('primary'); await waitForPromises(); - expect(mutate).toHaveBeenCalled(); + expect(requestHandlers.destroyBoardMutationHandler).toHaveBeenCalled(); await waitForPromises(); expect(visitUrl).not.toHaveBeenCalled(); - expect(wrapper.vm.setError).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith('setError', { + message: 'Failed to delete board. Please try again.', + }); }); }); }); diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js index d4489b3c535..ad2674f9d3b 100644 --- a/spec/frontend/boards/components/board_list_header_spec.js +++ b/spec/frontend/boards/components/board_list_header_spec.js @@ -105,6 +105,18 @@ describe('Board List Header Component', () => { const findCaret = () => wrapper.findByTestId('board-title-caret'); const findNewIssueButton = () => wrapper.findByTestId('newIssueBtn'); const findSettingsButton = () => wrapper.findByTestId('settingsBtn'); + const findBoardListHeader = () => wrapper.findByTestId('board-list-header'); + + it('renders border when label color is present', () => { + createComponent({ listType: ListType.label }); + + expect(findBoardListHeader().classes()).toContain( + 'gl-border-t-solid', + 'gl-border-4', + 'gl-rounded-top-left-base', + 'gl-rounded-top-right-base', + ); + }); describe('Add issue button', () => { const hasNoAddButton = [ListType.closed]; diff --git a/spec/frontend/boards/components/board_top_bar_spec.js b/spec/frontend/boards/components/board_top_bar_spec.js index d97a1dbff47..afc7da97617 100644 --- a/spec/frontend/boards/components/board_top_bar_spec.js +++ b/spec/frontend/boards/components/board_top_bar_spec.js @@ -46,6 +46,7 @@ describe('BoardTopBar', () => { propsData: { boardId: 'gid://gitlab/Board/1', isSwimlanesOn: false, + addColumnFormVisible: false, }, provide: { swimlanesFeatureAvailable: false, diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index ec3ae27b6a1..447aacd9cea 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -526,6 +526,27 @@ export const mockList = { __typename: 'BoardList', }; +export const labelsQueryResponse = { + data: { + project: { + id: 'gid://gitlab/Project/33', + labels: { + nodes: [ + { + id: 'gid://gitlab/GroupLabel/121', + title: 'To Do', + color: '#F0AD4E', + textColor: '#FFFFFF', + description: null, + descriptionHtml: null, + }, + ], + }, + __typename: 'Project', + }, + }, +}; + export const mockLabelList = { id: 'gid://gitlab/List/2', title: 'To Do', @@ -913,8 +934,8 @@ export const mockGroupLabelsResponse = { export const boardListsQueryResponse = { data: { - group: { - id: 'gid://gitlab/Group/1', + project: { + id: 'gid://gitlab/Project/1', board: { id: 'gid://gitlab/Board/1', hideBacklogList: false, @@ -922,7 +943,7 @@ export const boardListsQueryResponse = { nodes: mockLists, }, }, - __typename: 'Group', + __typename: 'Project', }, }, }; @@ -943,11 +964,14 @@ export const issueBoardListsQueryResponse = { }, }; -export const boardListQueryResponse = (issuesCount = 20) => ({ +export const boardListQueryResponse = ({ + listId = 'gid://gitlab/List/5', + issuesCount = 20, +} = {}) => ({ data: { boardList: { __typename: 'BoardList', - id: 'gid://gitlab/BoardList/5', + id: listId, totalWeight: 5, issuesCount, }, @@ -989,10 +1013,20 @@ export const updateEpicTitleResponse = { }, }; +export const createBoardListResponse = { + data: { + boardListCreate: { + list: mockLabelList, + errors: [], + }, + }, +}; + export const updateBoardListResponse = { data: { updateBoardList: { list: mockList, + errors: [], }, }, }; diff --git a/spec/frontend/boards/project_select_spec.js b/spec/frontend/boards/project_select_spec.js index 74ce4b6b786..b4308b38947 100644 --- a/spec/frontend/boards/project_select_spec.js +++ b/spec/frontend/boards/project_select_spec.js @@ -1,17 +1,11 @@ -import { - GlDropdown, - GlDropdownItem, - GlFormInput, - GlSearchBoxByType, - GlLoadingIcon, -} from '@gitlab/ui'; +import { GlCollapsibleListbox, GlListboxItem, GlLoadingIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import ProjectSelect from '~/boards/components/project_select.vue'; import defaultState from '~/boards/stores/state'; -import { mockList, mockActiveGroupProjects } from './mock_data'; +import { mockActiveGroupProjects, mockList } from './mock_data'; const mockProjectsList1 = mockActiveGroupProjects.slice(0, 1); @@ -20,14 +14,17 @@ describe('ProjectSelect component', () => { let store; const findLabel = () => wrapper.find("[data-testid='header-label']"); - const findGlDropdown = () => wrapper.findComponent(GlDropdown); + const findGlCollapsibleListBox = () => wrapper.findComponent(GlCollapsibleListbox); const findGlDropdownLoadingIcon = () => - findGlDropdown().find('button:first-child').findComponent(GlLoadingIcon); - const findGlSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType); - const findGlDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findFirstGlDropdownItem = () => findGlDropdownItems().at(0); - const findInMenuLoadingIcon = () => wrapper.find("[data-testid='dropdown-text-loading-icon']"); - const findEmptySearchMessage = () => wrapper.find("[data-testid='empty-result-message']"); + findGlCollapsibleListBox() + .find("[data-testid='base-dropdown-toggle'") + .findComponent(GlLoadingIcon); + const findGlListboxSearchInput = () => + wrapper.find("[data-testid='listbox-search-input'] > .gl-listbox-search-input"); + const findGlListboxItem = () => wrapper.findAllComponents(GlListboxItem); + const findFirstGlDropdownItem = () => findGlListboxItem().at(0); + const findInMenuLoadingIcon = () => wrapper.find("[data-testid='listbox-search-loader']"); + const findEmptySearchMessage = () => wrapper.find("[data-testid='listbox-no-results-text']"); const createStore = ({ state, activeGroupProjects }) => { Vue.use(Vuex); @@ -80,8 +77,8 @@ describe('ProjectSelect component', () => { it('renders a default dropdown text', () => { createWrapper(); - expect(findGlDropdown().exists()).toBe(true); - expect(findGlDropdown().text()).toContain('Select a project'); + expect(findGlCollapsibleListBox().exists()).toBe(true); + expect(findGlCollapsibleListBox().text()).toContain('Select a project'); }); describe('when mounted', () => { @@ -102,12 +99,9 @@ describe('ProjectSelect component', () => { createWrapper({ activeGroupProjects: mockActiveGroupProjects }); }); - it('shows GlSearchBoxByType with default attributes', () => { - expect(findGlSearchBoxByType().exists()).toBe(true); - expect(findGlSearchBoxByType().vm.$attrs).toMatchObject({ - placeholder: 'Search projects', - debounce: '250', - }); + it('shows GlListboxSearchInput with placeholder text', () => { + expect(findGlListboxSearchInput().exists()).toBe(true); + expect(findGlListboxSearchInput().attributes('placeholder')).toBe('Search projects'); }); it("displays the fetched project's name", () => { @@ -116,23 +110,12 @@ describe('ProjectSelect component', () => { }); it("doesn't render loading icon in the menu", () => { - expect(findInMenuLoadingIcon().isVisible()).toBe(false); + expect(findInMenuLoadingIcon().exists()).toBe(false); }); it('does not render empty search result message', () => { expect(findEmptySearchMessage().exists()).toBe(false); }); - - it('focuses on the search input', async () => { - const dropdownToggle = findGlDropdown().find('.dropdown-toggle'); - - await dropdownToggle.trigger('click'); - jest.runOnlyPendingTimers(); - await nextTick(); - - const searchInput = findGlDropdown().findComponent(GlFormInput).element; - expect(document.activeElement).toBe(searchInput); - }); }); describe('when no projects are being returned', () => { @@ -147,11 +130,11 @@ describe('ProjectSelect component', () => { beforeEach(() => { createWrapper({ activeGroupProjects: mockProjectsList1 }); - findFirstGlDropdownItem().find('button').trigger('click'); + findFirstGlDropdownItem().find('li').trigger('click'); }); it('renders the name of the selected project', () => { - expect(findGlDropdown().find('.gl-dropdown-button-text').text()).toBe( + expect(findGlCollapsibleListBox().find('.gl-new-dropdown-button-text').text()).toBe( mockProjectsList1[0].name, ); }); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index b8d3be28ca6..f3800ce8324 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -1340,8 +1340,8 @@ describe('updateIssueOrder', () => { }; jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { - issueMoveList: { - issue: rawIssue, + issuableMoveList: { + issuable: rawIssue, errors: [], }, }, @@ -1355,8 +1355,8 @@ describe('updateIssueOrder', () => { it('should commit MUTATE_ISSUE_SUCCESS mutation when successful', () => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { - issueMoveList: { - issue: rawIssue, + issuableMoveList: { + issuable: rawIssue, errors: [], }, }, @@ -1387,8 +1387,8 @@ describe('updateIssueOrder', () => { it('should commit SET_ERROR and dispatch undoMoveIssueCard', () => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { - issueMoveList: { - issue: {}, + issuableMoveList: { + issuable: {}, errors: [{ foo: 'bar' }], }, }, diff --git a/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap b/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap index 300b6f4a39a..9db6a523dec 100644 --- a/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap +++ b/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap @@ -4,12 +4,13 @@ exports[`Delete merged branches component Delete merged branches confirmation mo <div> <gl-base-dropdown-stub category="tertiary" - class="gl-disclosure-dropdown" + class="gl-disclosure-dropdown gl-display-none gl-md-display-block!" data-qa-selector="delete_merged_branches_dropdown_button" icon="ellipsis_v" nocaret="true" + offset="[object Object]" placement="right" - popperoptions="[object Object]" + positioningstrategy="absolute" size="medium" textsronly="true" toggleid="dropdown-toggle-btn-25" @@ -31,6 +32,27 @@ exports[`Delete merged branches component Delete merged branches confirmation mo </gl-base-dropdown-stub> + <b-button-stub + class="gl-display-block gl-md-display-none! gl-button btn-danger-secondary" + data-qa-selector="delete_merged_branches_button" + size="md" + tag="button" + type="button" + variant="danger" + > + <!----> + + <!----> + + <span + class="gl-button-text" + > + + Delete merged branches + + </span> + </b-button-stub> + <div> <form action="/namespace/project/-/merged_branches" diff --git a/spec/frontend/branches/components/branch_more_actions_spec.js b/spec/frontend/branches/components/branch_more_actions_spec.js new file mode 100644 index 00000000000..32b850a62a0 --- /dev/null +++ b/spec/frontend/branches/components/branch_more_actions_spec.js @@ -0,0 +1,70 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import BranchMoreDropdown from '~/branches/components/branch_more_actions.vue'; +import eventHub from '~/branches/event_hub'; + +describe('Delete branch button', () => { + let wrapper; + let eventHubSpy; + + const findCompareButton = () => wrapper.findByTestId('compare-branch-button'); + const findDeleteButton = () => wrapper.findByTestId('delete-branch-button'); + + const createComponent = (props = {}) => { + wrapper = mountExtended(BranchMoreDropdown, { + propsData: { + branchName: 'test', + defaultBranchName: 'main', + canDeleteBranch: true, + isProtectedBranch: false, + merged: false, + comparePath: '/path/to/branch', + deletePath: '/path/to/branch', + ...props, + }, + }); + }; + + beforeEach(() => { + eventHubSpy = jest.spyOn(eventHub, '$emit'); + }); + + it('renders the compare action', () => { + createComponent(); + + expect(findCompareButton().exists()).toBe(true); + expect(findCompareButton().text()).toBe('Compare'); + }); + + it('renders the delete action', () => { + createComponent(); + + expect(findDeleteButton().exists()).toBe(true); + expect(findDeleteButton().text()).toBe('Delete branch'); + }); + + it('renders a different text for a protected branch', () => { + createComponent({ isProtectedBranch: true }); + + expect(findDeleteButton().text()).toBe('Delete protected branch'); + }); + + it('emits the data to eventHub when button is clicked', async () => { + createComponent({ merged: true }); + + await findDeleteButton().trigger('click'); + + expect(eventHubSpy).toHaveBeenCalledWith('openModal', { + branchName: 'test', + defaultBranchName: 'main', + deletePath: '/path/to/branch', + isProtectedBranch: false, + merged: true, + }); + }); + + it('doesn`t render the delete action when user cannot delete branch', () => { + createComponent({ canDeleteBranch: false }); + + expect(findDeleteButton().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/branches/components/delete_branch_button_spec.js b/spec/frontend/branches/components/delete_branch_button_spec.js deleted file mode 100644 index 5b2ec443c59..00000000000 --- a/spec/frontend/branches/components/delete_branch_button_spec.js +++ /dev/null @@ -1,92 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import DeleteBranchButton from '~/branches/components/delete_branch_button.vue'; -import eventHub from '~/branches/event_hub'; - -let wrapper; -let findDeleteButton; - -const createComponent = (props = {}) => { - wrapper = shallowMount(DeleteBranchButton, { - propsData: { - branchName: 'test', - deletePath: '/path/to/branch', - defaultBranchName: 'main', - ...props, - }, - }); -}; - -describe('Delete branch button', () => { - let eventHubSpy; - - beforeEach(() => { - findDeleteButton = () => wrapper.findComponent(GlButton); - eventHubSpy = jest.spyOn(eventHub, '$emit'); - }); - - it('renders the button with default tooltip, style, and icon', () => { - createComponent(); - - expect(findDeleteButton().attributes()).toMatchObject({ - title: 'Delete branch', - variant: 'default', - icon: 'remove', - }); - }); - - it('renders a different tooltip for a protected branch', () => { - createComponent({ isProtectedBranch: true }); - - expect(findDeleteButton().attributes()).toMatchObject({ - title: 'Delete protected branch', - variant: 'default', - icon: 'remove', - }); - }); - - it('renders a different protected tooltip when it is both protected and disabled', () => { - createComponent({ isProtectedBranch: true, disabled: true }); - - expect(findDeleteButton().attributes()).toMatchObject({ - title: 'Only a project maintainer or owner can delete a protected branch', - variant: 'default', - }); - }); - - it('emits the data to eventHub when button is clicked', () => { - createComponent({ merged: true }); - - findDeleteButton().vm.$emit('click'); - - expect(eventHubSpy).toHaveBeenCalledWith('openModal', { - branchName: 'test', - defaultBranchName: 'main', - deletePath: '/path/to/branch', - isProtectedBranch: false, - merged: true, - }); - }); - - describe('#disabled', () => { - it('does not disable the button by default when mounted', () => { - createComponent(); - - expect(findDeleteButton().attributes()).toMatchObject({ - title: 'Delete branch', - variant: 'default', - }); - }); - - // Used for unallowed users and for the default branch. - it('disables the button when mounted for a disabled modal', () => { - createComponent({ disabled: true, tooltip: 'The default branch cannot be deleted' }); - - expect(findDeleteButton().attributes()).toMatchObject({ - title: 'The default branch cannot be deleted', - disabled: 'true', - variant: 'default', - }); - }); - }); -}); diff --git a/spec/frontend/branches/components/delete_merged_branches_spec.js b/spec/frontend/branches/components/delete_merged_branches_spec.js index 4d8b887efd3..3e47e76622d 100644 --- a/spec/frontend/branches/components/delete_merged_branches_spec.js +++ b/spec/frontend/branches/components/delete_merged_branches_spec.js @@ -44,7 +44,7 @@ const findConfirmationButton = () => const findCancelButton = () => wrapper.findByTestId('delete-merged-branches-cancel-button'); const findFormInput = () => wrapper.findComponent(GlFormInput); const findForm = () => wrapper.find('form'); -const submitFormSpy = () => jest.spyOn(wrapper.vm.$refs.form, 'submit'); +const submitFormSpy = () => jest.spyOn(findForm().element, 'submit'); describe('Delete merged branches component', () => { beforeEach(() => { diff --git a/spec/frontend/ci/artifacts/components/artifact_row_spec.js b/spec/frontend/ci/artifacts/components/artifact_row_spec.js index 96ddedc3a9d..8bf1138bc85 100644 --- a/spec/frontend/ci/artifacts/components/artifact_row_spec.js +++ b/spec/frontend/ci/artifacts/components/artifact_row_spec.js @@ -4,7 +4,7 @@ import { numberToHumanSize } from '~/lib/utils/number_utils'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import ArtifactRow from '~/ci/artifacts/components/artifact_row.vue'; -import { BULK_DELETE_FEATURE_FLAG, I18N_BULK_DELETE_MAX_SELECTED } from '~/ci/artifacts/constants'; +import { I18N_BULK_DELETE_MAX_SELECTED } from '~/ci/artifacts/constants'; describe('ArtifactRow component', () => { let wrapper; @@ -18,7 +18,7 @@ describe('ArtifactRow component', () => { const findDeleteButton = () => wrapper.findByTestId('job-artifact-row-delete-button'); const findCheckbox = () => wrapper.findComponent(GlFormCheckbox); - const createComponent = ({ canDestroyArtifacts = true, glFeatures = {}, props = {} } = {}) => { + const createComponent = ({ canDestroyArtifacts = true, props = {} } = {}) => { wrapper = shallowMountExtended(ArtifactRow, { propsData: { artifact, @@ -28,7 +28,7 @@ describe('ArtifactRow component', () => { isSelectedArtifactsLimitReached: false, ...props, }, - provide: { canDestroyArtifacts, glFeatures }, + provide: { canDestroyArtifacts }, stubs: { GlBadge, GlFriendlyWrap }, }); }; @@ -80,35 +80,31 @@ describe('ArtifactRow component', () => { }); describe('bulk delete checkbox', () => { - describe('with permission and feature flag enabled', () => { - it('emits selectArtifact when toggled', () => { - createComponent({ glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true } }); - - findCheckbox().vm.$emit('input', true); + it('emits selectArtifact when toggled', () => { + createComponent(); - expect(wrapper.emitted('selectArtifact')).toStrictEqual([[artifact, true]]); - }); + findCheckbox().vm.$emit('input', true); - describe('when the selected artifacts limit is reached', () => { - it('remains enabled if the artifact was selected', () => { - createComponent({ - glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true }, - props: { isSelected: true, isSelectedArtifactsLimitReached: true }, - }); + expect(wrapper.emitted('selectArtifact')).toStrictEqual([[artifact, true]]); + }); - expect(findCheckbox().attributes('disabled')).toBeUndefined(); - expect(findCheckbox().attributes('title')).toBe(''); + describe('when the selected artifacts limit is reached', () => { + it('remains enabled if the artifact was selected', () => { + createComponent({ + props: { isSelected: true, isSelectedArtifactsLimitReached: true }, }); - it('is disabled if the artifact was not selected', () => { - createComponent({ - glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true }, - props: { isSelected: false, isSelectedArtifactsLimitReached: true }, - }); + expect(findCheckbox().attributes('disabled')).toBeUndefined(); + expect(findCheckbox().attributes('title')).toBe(''); + }); - expect(findCheckbox().attributes('disabled')).toBeDefined(); - expect(findCheckbox().attributes('title')).toBe(I18N_BULK_DELETE_MAX_SELECTED); + it('is disabled if the artifact was not selected', () => { + createComponent({ + props: { isSelected: false, isSelectedArtifactsLimitReached: true }, }); + + expect(findCheckbox().attributes('disabled')).toBeDefined(); + expect(findCheckbox().attributes('title')).toBe(I18N_BULK_DELETE_MAX_SELECTED); }); }); @@ -117,11 +113,5 @@ describe('ArtifactRow component', () => { expect(findCheckbox().exists()).toBe(false); }); - - it('is not shown with feature flag disabled', () => { - createComponent(); - - expect(findCheckbox().exists()).toBe(false); - }); }); }); diff --git a/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js b/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js index 514644a92f2..e062140246b 100644 --- a/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js +++ b/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js @@ -30,7 +30,6 @@ import { JOBS_PER_PAGE, I18N_FETCH_ERROR, INITIAL_CURRENT_PAGE, - BULK_DELETE_FEATURE_FLAG, I18N_BULK_DELETE_ERROR, SELECTED_ARTIFACTS_MAX_COUNT, } from '~/ci/artifacts/constants'; @@ -79,6 +78,16 @@ describe('JobArtifactsTable component', () => { const findDeleteButton = () => wrapper.findByTestId('job-artifacts-delete-button'); const findArtifactDeleteButton = () => wrapper.findByTestId('job-artifact-row-delete-button'); + // first checkbox is the "select all" checkbox in the table header + const findSelectAllCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findSelectAllCheckboxChecked = () => findSelectAllCheckbox().find('input').element.checked; + const findSelectAllCheckboxIndeterminate = () => + findSelectAllCheckbox().find('input').element.indeterminate; + const findSelectAllCheckboxDisabled = () => + findSelectAllCheckbox().find('input').element.disabled; + const toggleSelectAllCheckbox = () => + findSelectAllCheckbox().vm.$emit('change', !findSelectAllCheckboxChecked()); + // first checkbox is a "select all", this finder should get the first job checkbox const findJobCheckbox = (i = 1) => wrapper.findAllComponents(GlFormCheckbox).at(i); const findAnyCheckbox = () => wrapper.findComponent(GlFormCheckbox); @@ -125,7 +134,15 @@ describe('JobArtifactsTable component', () => { }, }); - const maxSelectedArtifacts = new Array(SELECTED_ARTIFACTS_MAX_COUNT).fill({}); + const allArtifacts = getJobArtifactsResponse.data.project.jobs.nodes + .map((jobNode) => jobNode.artifacts.nodes.map((artifactNode) => artifactNode.id)) + .reduce((artifacts, jobArtifacts) => artifacts.concat(jobArtifacts)); + + const maxSelectedArtifacts = new Array(SELECTED_ARTIFACTS_MAX_COUNT).fill('artifact-id'); + const maxSelectedArtifactsIncludingCurrentPage = [ + ...allArtifacts, + ...new Array(SELECTED_ARTIFACTS_MAX_COUNT - allArtifacts.length).fill('artifact-id'), + ]; const createComponent = ({ handlers = { @@ -134,7 +151,6 @@ describe('JobArtifactsTable component', () => { }, data = {}, canDestroyArtifacts = true, - glFeatures = {}, } = {}) => { requestHandlers = handlers; wrapper = mountExtended(JobArtifactsTable, { @@ -147,7 +163,6 @@ describe('JobArtifactsTable component', () => { projectId, canDestroyArtifacts, artifactsManagementFeedbackImagePath: 'banner/image/path', - glFeatures, }, mocks: { $toast: { @@ -314,6 +329,7 @@ describe('JobArtifactsTable component', () => { it('is disabled when there is no download path', async () => { const jobWithoutDownloadPath = { ...job, + hasArtifacts: true, archive: { downloadPath: null }, }; @@ -340,6 +356,7 @@ describe('JobArtifactsTable component', () => { it('is disabled when there is no browse path', async () => { const jobWithoutBrowsePath = { ...job, + hasArtifacts: true, browseArtifactsPath: null, }; @@ -352,80 +369,108 @@ describe('JobArtifactsTable component', () => { expect(findBrowseButton().attributes('disabled')).toBeDefined(); }); - }); - describe('delete button', () => { - const artifactsFromJob = job.artifacts.nodes.map((node) => node.id); + it('is disabled when job has no metadata.gz', async () => { + const jobWithoutMetadata = { + ...job, + artifacts: { nodes: [archiveArtifact] }, + }; - describe('with delete permission and bulk delete feature flag enabled', () => { - beforeEach(async () => { - createComponent({ - canDestroyArtifacts: true, - glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true }, - }); + createComponent({ + handlers: { getJobArtifactsQuery: jest.fn() }, + data: { jobArtifacts: [jobWithoutMetadata] }, + }); - await waitForPromises(); + await waitForPromises(); + + expect(findBrowseButton().attributes('disabled')).toBe('disabled'); + }); + + it('is disabled when job has no artifacts', async () => { + const jobWithoutArtifacts = { + ...job, + artifacts: { nodes: [] }, + }; + + createComponent({ + handlers: { getJobArtifactsQuery: jest.fn() }, + data: { jobArtifacts: [jobWithoutArtifacts] }, }); - it('opens the confirmation modal with the artifacts from the job', async () => { - await findDeleteButton().vm.$emit('click'); + await waitForPromises(); - expect(findBulkDeleteModal().props()).toMatchObject({ - visible: true, - artifactsToDelete: artifactsFromJob, - }); + expect(findBrowseButton().attributes('disabled')).toBe('disabled'); + }); + }); + + describe('delete button', () => { + const artifactsFromJob = job.artifacts.nodes.map((node) => node.id); + + beforeEach(async () => { + createComponent({ + canDestroyArtifacts: true, }); - it('on confirm, deletes the artifacts from the job and shows a toast', async () => { - findDeleteButton().vm.$emit('click'); - findBulkDeleteModal().vm.$emit('primary'); + await waitForPromises(); + }); - expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({ - projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId), - ids: artifactsFromJob, - }); + it('opens the confirmation modal with the artifacts from the job', async () => { + await findDeleteButton().vm.$emit('click'); - await waitForPromises(); + expect(findBulkDeleteModal().props()).toMatchObject({ + visible: true, + artifactsToDelete: artifactsFromJob, + }); + }); - expect(mockToastShow).toHaveBeenCalledWith( - `${artifactsFromJob.length} selected artifacts deleted`, - ); + it('on confirm, deletes the artifacts from the job and shows a toast', async () => { + findDeleteButton().vm.$emit('click'); + findBulkDeleteModal().vm.$emit('primary'); + + expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({ + projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId), + ids: artifactsFromJob, }); - it('does not clear selected artifacts on success', async () => { - // select job 2 via checkbox - findJobCheckbox(2).vm.$emit('input', true); + await waitForPromises(); - // click delete button job 1 - findDeleteButton().vm.$emit('click'); + expect(mockToastShow).toHaveBeenCalledWith( + `${artifactsFromJob.length} selected artifacts deleted`, + ); + }); - // job 2's artifacts should still be selected - expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual( - job2.artifacts.nodes.map((node) => node.id), - ); + it('does not clear selected artifacts on success', async () => { + // select job 2 via checkbox + findJobCheckbox(2).vm.$emit('change', true); - // confirm delete - findBulkDeleteModal().vm.$emit('primary'); + // click delete button job 1 + findDeleteButton().vm.$emit('click'); - // job 1's artifacts should be deleted - expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({ - projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId), - ids: artifactsFromJob, - }); + // job 2's artifacts should still be selected + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual( + job2.artifacts.nodes.map((node) => node.id), + ); - await waitForPromises(); + // confirm delete + findBulkDeleteModal().vm.$emit('primary'); - // job 2's artifacts should still be selected - expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual( - job2.artifacts.nodes.map((node) => node.id), - ); + // job 1's artifacts should be deleted + expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({ + projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId), + ids: artifactsFromJob, }); + + await waitForPromises(); + + // job 2's artifacts should still be selected + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual( + job2.artifacts.nodes.map((node) => node.id), + ); }); it('shows an alert and does not clear selected artifacts on error', async () => { createComponent({ canDestroyArtifacts: true, - glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true }, handlers: { getJobArtifactsQuery: jest.fn().mockResolvedValue(getJobArtifactsResponse), bulkDestroyArtifactsMutation: jest.fn().mockRejectedValue(), @@ -434,7 +479,7 @@ describe('JobArtifactsTable component', () => { await waitForPromises(); // select job 2 via checkbox - findJobCheckbox(2).vm.$emit('input', true); + findJobCheckbox(2).vm.$emit('change', true); // click delete button job 1 findDeleteButton().vm.$emit('click'); @@ -455,131 +500,290 @@ describe('JobArtifactsTable component', () => { }); }); - it('is disabled when bulk delete feature flag is disabled', async () => { + it('is hidden when user does not have delete permission', async () => { createComponent({ - canDestroyArtifacts: true, - glFeatures: { [BULK_DELETE_FEATURE_FLAG]: false }, + canDestroyArtifacts: false, }); await waitForPromises(); - expect(findDeleteButton().attributes('disabled')).toBeDefined(); + expect(findDeleteButton().exists()).toBe(false); }); + }); - it('is hidden when user does not have delete permission', async () => { + describe('bulk delete', () => { + const selectedArtifacts = job.artifacts.nodes.map((node) => node.id); + + beforeEach(async () => { createComponent({ - canDestroyArtifacts: false, - glFeatures: { [BULK_DELETE_FEATURE_FLAG]: false }, + canDestroyArtifacts: true, }); await waitForPromises(); + }); - expect(findDeleteButton().exists()).toBe(false); + it('shows selected artifacts when a job is checked', async () => { + expect(findBulkDeleteContainer().exists()).toBe(false); + + await findJobCheckbox().vm.$emit('change', true); + + expect(findBulkDeleteContainer().exists()).toBe(true); + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual(selectedArtifacts); }); - }); - describe('bulk delete', () => { - const selectedArtifacts = job.artifacts.nodes.map((node) => node.id); + it('disappears when selected artifacts are cleared', async () => { + await findJobCheckbox().vm.$emit('change', true); - describe('with permission and feature flag enabled', () => { - beforeEach(async () => { - createComponent({ - canDestroyArtifacts: true, - glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true }, - }); + expect(findBulkDeleteContainer().exists()).toBe(true); - await waitForPromises(); - }); + await findBulkDelete().vm.$emit('clearSelectedArtifacts'); + + expect(findBulkDeleteContainer().exists()).toBe(false); + }); - it('shows selected artifacts when a job is checked', async () => { - expect(findBulkDeleteContainer().exists()).toBe(false); + it('shows a modal to confirm bulk delete', async () => { + findJobCheckbox().vm.$emit('change', true); + findBulkDelete().vm.$emit('showBulkDeleteModal'); - await findJobCheckbox().vm.$emit('input', true); + await nextTick(); - expect(findBulkDeleteContainer().exists()).toBe(true); - expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual(selectedArtifacts); + expect(findBulkDeleteModal().props('visible')).toBe(true); + }); + + it('deletes the selected artifacts and shows a toast', async () => { + findJobCheckbox().vm.$emit('change', true); + findBulkDelete().vm.$emit('showBulkDeleteModal'); + findBulkDeleteModal().vm.$emit('primary'); + + expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({ + projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId), + ids: selectedArtifacts, }); - it('disappears when selected artifacts are cleared', async () => { - await findJobCheckbox().vm.$emit('input', true); + await waitForPromises(); + + expect(mockToastShow).toHaveBeenCalledWith( + `${selectedArtifacts.length} selected artifacts deleted`, + ); + }); + + it('clears selected artifacts on success', async () => { + findJobCheckbox().vm.$emit('change', true); + findBulkDelete().vm.$emit('showBulkDeleteModal'); + findBulkDeleteModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual([]); + }); + + describe('select all checkbox', () => { + describe('when no artifacts are selected', () => { + it('is not checked', () => { + expect(findSelectAllCheckboxChecked()).toBe(false); + expect(findSelectAllCheckboxIndeterminate()).toBe(false); + }); - expect(findBulkDeleteContainer().exists()).toBe(true); + it('selects all artifacts when toggled', async () => { + toggleSelectAllCheckbox(); - await findBulkDelete().vm.$emit('clearSelectedArtifacts'); + await nextTick(); - expect(findBulkDeleteContainer().exists()).toBe(false); + expect(findSelectAllCheckboxChecked()).toBe(true); + expect(findSelectAllCheckboxIndeterminate()).toBe(false); + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual(allArtifacts); + }); }); - it('shows a modal to confirm bulk delete', async () => { - findJobCheckbox().vm.$emit('input', true); - findBulkDelete().vm.$emit('showBulkDeleteModal'); + describe('when some artifacts are selected', () => { + beforeEach(async () => { + findJobCheckbox().vm.$emit('change', true); - await nextTick(); + await nextTick(); + }); - expect(findBulkDeleteModal().props('visible')).toBe(true); + it('is indeterminate', () => { + expect(findSelectAllCheckboxChecked()).toBe(true); + expect(findSelectAllCheckboxIndeterminate()).toBe(true); + }); + + it('deselects all artifacts when toggled', async () => { + toggleSelectAllCheckbox(); + + await nextTick(); + + expect(findSelectAllCheckboxChecked()).toBe(false); + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual([]); + }); }); - it('deletes the selected artifacts and shows a toast', async () => { - findJobCheckbox().vm.$emit('input', true); - findBulkDelete().vm.$emit('showBulkDeleteModal'); - findBulkDeleteModal().vm.$emit('primary'); + describe('when all artifacts are selected', () => { + beforeEach(async () => { + findJobCheckbox(1).vm.$emit('change', true); + findJobCheckbox(2).vm.$emit('change', true); - expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({ - projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId), - ids: selectedArtifacts, + await nextTick(); }); - await waitForPromises(); + it('is checked', () => { + expect(findSelectAllCheckboxChecked()).toBe(true); + expect(findSelectAllCheckboxIndeterminate()).toBe(false); + }); + + it('deselects all artifacts when toggled', async () => { + toggleSelectAllCheckbox(); + + await nextTick(); - expect(mockToastShow).toHaveBeenCalledWith( - `${selectedArtifacts.length} selected artifacts deleted`, - ); + expect(findSelectAllCheckboxChecked()).toBe(false); + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual([]); + }); }); - it('clears selected artifacts on success', async () => { - findJobCheckbox().vm.$emit('input', true); - findBulkDelete().vm.$emit('showBulkDeleteModal'); - findBulkDeleteModal().vm.$emit('primary'); + describe('when an artifact is selected on another page', () => { + const otherPageArtifact = { id: 'gid://gitlab/Ci::JobArtifact/some/other/id' }; - await waitForPromises(); + beforeEach(async () => { + // expand the first job row to access the details component + findCount().trigger('click'); + + await nextTick(); + + // mock the selection of an artifact on another page by emitting a select event + findDetailsInRow(1).vm.$emit('selectArtifact', otherPageArtifact, true); + }); + + it('is not checked even though an artifact is selected', () => { + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual([otherPageArtifact.id]); + expect(findSelectAllCheckboxChecked()).toBe(false); + expect(findSelectAllCheckboxIndeterminate()).toBe(false); + }); - expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual([]); + it('only toggles selection of visible artifacts, leaving the other artifact selected', async () => { + toggleSelectAllCheckbox(); + + await nextTick(); + + expect(findSelectAllCheckboxChecked()).toBe(true); + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual([ + otherPageArtifact.id, + ...allArtifacts, + ]); + + toggleSelectAllCheckbox(); + + await nextTick(); + + expect(findSelectAllCheckboxChecked()).toBe(false); + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual([otherPageArtifact.id]); + }); }); }); - describe('when the selected artifacts limit is reached', () => { - beforeEach(async () => { - createComponent({ - canDestroyArtifacts: true, - glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true }, - data: { selectedArtifacts: maxSelectedArtifacts }, + describe('select all checkbox respects selected artifacts limit', () => { + describe('when selecting all visible artifacts would exceed the limit', () => { + const selectedArtifactsLength = SELECTED_ARTIFACTS_MAX_COUNT - 1; + + beforeEach(async () => { + createComponent({ + canDestroyArtifacts: true, + data: { + selectedArtifacts: new Array(selectedArtifactsLength).fill('artifact-id'), + }, + }); + + await nextTick(); }); - await nextTick(); - }); + it('selects only up to the limit', async () => { + expect(findSelectAllCheckboxChecked()).toBe(false); + expect(findBulkDelete().props('selectedArtifacts')).toHaveLength(selectedArtifactsLength); + + toggleSelectAllCheckbox(); - it('passes isSelectedArtifactsLimitReached to bulk delete', () => { - expect(findBulkDelete().props('isSelectedArtifactsLimitReached')).toBe(true); + await nextTick(); + + expect(findSelectAllCheckboxChecked()).toBe(true); + expect(findBulkDelete().props('selectedArtifacts')).toHaveLength( + SELECTED_ARTIFACTS_MAX_COUNT, + ); + expect(findBulkDelete().props('selectedArtifacts')).not.toContain( + allArtifacts[allArtifacts.length - 1], + ); + }); }); - it('passes isSelectedArtifactsLimitReached to job checkbox', () => { - expect(wrapper.findComponent(JobCheckbox).props('isSelectedArtifactsLimitReached')).toBe( - true, - ); + describe('when limit has been reached without artifacts on the current page', () => { + beforeEach(async () => { + createComponent({ + canDestroyArtifacts: true, + data: { selectedArtifacts: maxSelectedArtifacts }, + }); + + await nextTick(); + }); + + it('passes isSelectedArtifactsLimitReached to bulk delete', () => { + expect(findBulkDelete().props('isSelectedArtifactsLimitReached')).toBe(true); + }); + + it('passes isSelectedArtifactsLimitReached to job checkbox', () => { + expect(wrapper.findComponent(JobCheckbox).props('isSelectedArtifactsLimitReached')).toBe( + true, + ); + }); + + it('passes isSelectedArtifactsLimitReached to table row details', async () => { + findCount().trigger('click'); + await nextTick(); + + expect(findDetailsInRow(1).props('isSelectedArtifactsLimitReached')).toBe(true); + }); + + it('disables the select all checkbox', () => { + expect(findSelectAllCheckboxDisabled()).toBe(true); + }); }); - it('passes isSelectedArtifactsLimitReached to table row details', async () => { - findCount().trigger('click'); - await nextTick(); + describe('when limit has been reached including artifacts on the current page', () => { + beforeEach(async () => { + createComponent({ + canDestroyArtifacts: true, + data: { + selectedArtifacts: maxSelectedArtifactsIncludingCurrentPage, + }, + }); + + await nextTick(); + }); + + describe('the select all checkbox', () => { + it('is checked', () => { + expect(findSelectAllCheckboxChecked()).toBe(true); + expect(findSelectAllCheckboxIndeterminate()).toBe(false); + }); + + it('deselects all artifacts when toggled', async () => { + expect(findBulkDelete().props('selectedArtifacts')).toHaveLength( + SELECTED_ARTIFACTS_MAX_COUNT, + ); + + toggleSelectAllCheckbox(); + + await nextTick(); - expect(findDetailsInRow(1).props('isSelectedArtifactsLimitReached')).toBe(true); + expect(findSelectAllCheckboxChecked()).toBe(false); + expect(findBulkDelete().props('selectedArtifacts')).toHaveLength( + SELECTED_ARTIFACTS_MAX_COUNT - allArtifacts.length, + ); + }); + }); }); }); it('shows an alert and does not clear selected artifacts on error', async () => { createComponent({ canDestroyArtifacts: true, - glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true }, handlers: { getJobArtifactsQuery: jest.fn().mockResolvedValue(getJobArtifactsResponse), bulkDestroyArtifactsMutation: jest.fn().mockRejectedValue(), @@ -588,7 +792,7 @@ describe('JobArtifactsTable component', () => { await waitForPromises(); - findJobCheckbox().vm.$emit('input', true); + findJobCheckbox().vm.$emit('change', true); findBulkDelete().vm.$emit('showBulkDeleteModal'); findBulkDeleteModal().vm.$emit('primary'); @@ -605,18 +809,6 @@ describe('JobArtifactsTable component', () => { it('shows no checkboxes without permission', async () => { createComponent({ canDestroyArtifacts: false, - glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true }, - }); - - await waitForPromises(); - - expect(findAnyCheckbox().exists()).toBe(false); - }); - - it('shows no checkboxes with feature flag disabled', async () => { - createComponent({ - canDestroyArtifacts: true, - glFeatures: { [BULK_DELETE_FEATURE_FLAG]: false }, }); await waitForPromises(); diff --git a/spec/frontend/ci/artifacts/components/job_checkbox_spec.js b/spec/frontend/ci/artifacts/components/job_checkbox_spec.js index 8b47571239c..73a49506564 100644 --- a/spec/frontend/ci/artifacts/components/job_checkbox_spec.js +++ b/spec/frontend/ci/artifacts/components/job_checkbox_spec.js @@ -48,7 +48,7 @@ describe('JobCheckbox component', () => { }); it('selects the unselected artifacts on click', () => { - findCheckbox().vm.$emit('input', true); + findCheckbox().vm.$emit('change', true); expect(wrapper.emitted('selectArtifact')).toMatchObject([ [mockUnselectedArtifacts[0], true], @@ -83,7 +83,7 @@ describe('JobCheckbox component', () => { }); it('deselects the selected artifacts on click', () => { - findCheckbox().vm.$emit('input', false); + findCheckbox().vm.$emit('change', false); expect(wrapper.emitted('selectArtifact')).toMatchObject([ [mockSelectedArtifacts[0], false], @@ -105,7 +105,7 @@ describe('JobCheckbox component', () => { }); it('selects the artifacts on click', () => { - findCheckbox().vm.$emit('input', true); + findCheckbox().vm.$emit('change', true); expect(wrapper.emitted('selectArtifact')).toMatchObject([ [mockUnselectedArtifacts[0], true], diff --git a/spec/frontend/ci/artifacts/utils_spec.js b/spec/frontend/ci/artifacts/utils_spec.js new file mode 100644 index 00000000000..17b4a9f162b --- /dev/null +++ b/spec/frontend/ci/artifacts/utils_spec.js @@ -0,0 +1,16 @@ +import getJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql.json'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { totalArtifactsSizeForJob } from '~/ci/artifacts/utils'; + +const job = getJobArtifactsResponse.data.project.jobs.nodes[0]; +const artifacts = job.artifacts.nodes; + +describe('totalArtifactsSizeForJob', () => { + it('adds artifact sizes together', () => { + expect(totalArtifactsSizeForJob(job)).toBe( + numberToHumanSize( + Number(artifacts[0].size) + Number(artifacts[1].size) + Number(artifacts[2].size), + ), + ); + }); +}); diff --git a/spec/frontend/ci/ci_lint/components/ci_lint_spec.js b/spec/frontend/ci/ci_lint/components/ci_lint_spec.js index 4b7ca36f331..7c8863adddd 100644 --- a/spec/frontend/ci/ci_lint/components/ci_lint_spec.js +++ b/spec/frontend/ci/ci_lint/components/ci_lint_spec.js @@ -41,6 +41,7 @@ describe('CI Lint', () => { const findCiLintResults = () => wrapper.findComponent(CiLintResults); const findValidateBtn = () => wrapper.find('[data-testid="ci-lint-validate"]'); const findClearBtn = () => wrapper.find('[data-testid="ci-lint-clear"]'); + const findDryRunToggle = () => wrapper.find('[data-testid="ci-lint-dryrun"]'); beforeEach(() => { createComponent(); @@ -63,18 +64,13 @@ describe('CI Lint', () => { }); }); - it('validate action calls mutation with dry run', async () => { - const dryRunEnabled = true; - - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - await wrapper.setData({ dryRun: dryRunEnabled }); - + it('validate action calls mutation with dry run', () => { + findDryRunToggle().vm.$emit('input', true); findValidateBtn().vm.$emit('click'); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ mutation: lintCIMutation, - variables: { content, dry: dryRunEnabled, endpoint }, + variables: { content, dry: true, endpoint }, }); }); diff --git a/spec/frontend/ci/ci_lint/mock_data.js b/spec/frontend/ci/ci_lint/mock_data.js index 05582470dfa..1a9888817d0 100644 --- a/spec/frontend/ci/ci_lint/mock_data.js +++ b/spec/frontend/ci/ci_lint/mock_data.js @@ -1,4 +1,5 @@ import { mockJobs } from 'jest/ci/pipeline_editor/mock_data'; +import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils'; export const mockLintDataError = { data: { @@ -6,7 +7,11 @@ export const mockLintDataError = { errors: ['Error message'], warnings: ['Warning message'], valid: false, - jobs: mockJobs, + jobs: mockJobs.map((j) => { + const job = { ...j, tags: j.tagList }; + delete job.tagList; + return job; + }), }, }, }; @@ -17,7 +22,21 @@ export const mockLintDataValid = { errors: [], warnings: [], valid: true, - jobs: mockJobs, + jobs: mockJobs.map((j) => { + const job = { ...j, tags: j.tagList }; + delete job.tagList; + return job; + }), }, }, }; + +export const mockLintDataErrorRest = { + ...mockLintDataError.data.lintCI, + jobs: mockJobs.map((j) => convertObjectPropsToSnakeCase(j)), +}; + +export const mockLintDataValidRest = { + ...mockLintDataValid.data.lintCI, + jobs: mockJobs.map((j) => convertObjectPropsToSnakeCase(j)), +}; diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js index b6ffde9b33f..e9484cfce57 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js @@ -458,7 +458,8 @@ describe('Ci variable modal', () => { }); describe('Validations', () => { - const maskError = 'This variable can not be masked.'; + const maskError = 'This variable value does not meet the masking requirements.'; + const helpText = 'Value must meet regular expression requirements to be masked.'; describe('when the variable is raw', () => { const [variable] = mockVariables; @@ -488,6 +489,25 @@ describe('Ci variable modal', () => { expect(findModal().text()).toContain(maskError); }); + + it('does not show the masked variable help text', () => { + expect(findModal().text()).not.toContain(helpText); + }); + }); + + describe('when the value is empty', () => { + beforeEach(() => { + const [variable] = mockVariables; + const emptyValueVariable = { ...variable, value: '' }; + createComponent({ + mountFn: mountExtended, + props: { selectedVariable: emptyValueVariable }, + }); + }); + + it('allows user to submit', () => { + expect(findAddorUpdateButton().attributes('disabled')).toBeUndefined(); + }); }); describe('when the mask state is invalid', () => { @@ -510,8 +530,9 @@ describe('Ci variable modal', () => { expect(findAddorUpdateButton().attributes('disabled')).toBeDefined(); }); - it('shows the correct error text', () => { + it('shows the correct error text and help text', () => { expect(findModal().text()).toContain(maskError); + expect(findModal().text()).toContain(helpText); }); it('sends the correct tracking event', () => { @@ -578,6 +599,10 @@ describe('Ci variable modal', () => { }); }); + it('shows the help text', () => { + expect(findModal().text()).toContain(helpText); + }); + it('does not disable the submit button', () => { expect(findAddorUpdateButton().attributes('disabled')).toBeUndefined(); }); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js index a25d325f7a1..f7b90c3da30 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js @@ -46,6 +46,7 @@ Vue.use(VueApollo); const mockProvide = { endpoint: '/variables', isGroup: false, + isInheritedGroupVars: false, isProject: false, }; diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js index 0b28cb06cec..f3f1c5bd2c5 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js @@ -1,9 +1,9 @@ -import { GlAlert } from '@gitlab/ui'; +import { GlAlert, GlBadge, GlKeysetPagination } from '@gitlab/ui'; import { sprintf } from '~/locale'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import CiVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue'; import { EXCEEDS_VARIABLE_LIMIT_TEXT, projectString } from '~/ci/ci_variable_list/constants'; -import { mockVariables } from '../mocks'; +import { mockInheritedVariables, mockVariables } from '../mocks'; describe('Ci variable table', () => { let wrapper; @@ -29,6 +29,7 @@ describe('Ci variable table', () => { glFeatures: { ciVariablesPages: false, }, + isInheritedGroupVars: false, ...provide, }, }); @@ -41,8 +42,14 @@ describe('Ci variable table', () => { const findHiddenValues = () => wrapper.findAllByTestId('hiddenValue'); const findLimitReachedAlerts = () => wrapper.findAllComponents(GlAlert); const findRevealedValues = () => wrapper.findAllByTestId('revealedValue'); - const findOptionsValues = (rowIndex) => - wrapper.findAllByTestId('ci-variable-table-row-options').at(rowIndex).text(); + const findAttributesRow = (rowIndex) => + wrapper.findAllByTestId('ci-variable-table-row-attributes').at(rowIndex); + const findAttributeByIndex = (rowIndex, attributeIndex) => + findAttributesRow(rowIndex).findAllComponents(GlBadge).at(attributeIndex).text(); + const findTableColumnText = (index) => wrapper.findAll('th').at(index).text(); + const findGroupCiCdSettingsLink = (rowIndex) => + wrapper.findAllByTestId('ci-variable-table-row-cicd-path').at(rowIndex).attributes('href'); + const findKeysetPagination = () => wrapper.findComponent(GlKeysetPagination); const generateExceedsVariableLimitText = (entity, currentVariableCount, maxVariableLimit) => { return sprintf(EXCEEDS_VARIABLE_LIMIT_TEXT, { entity, currentVariableCount, maxVariableLimit }); @@ -69,26 +76,48 @@ describe('Ci variable table', () => { }); }); - describe('When table has variables', () => { + describe('When table has CI variables', () => { beforeEach(() => { createComponent({ provide }); }); - it('does not display the empty message', () => { - expect(findEmptyVariablesPlaceholder().exists()).toBe(false); + // last column is for the edit button, which has no text + it.each` + index | text + ${0} | ${'Key (Click to sort descending)'} + ${1} | ${'Value'} + ${2} | ${'Attributes'} + ${3} | ${'Environments'} + ${4} | ${''} + `('renders the $text column', ({ index, text }) => { + expect(findTableColumnText(index)).toEqual(text); }); - it('displays the reveal button', () => { - expect(findRevealButton().exists()).toBe(true); + it('does not display the empty message', () => { + expect(findEmptyVariablesPlaceholder().exists()).toBe(false); }); it('displays the correct amount of variables', () => { expect(wrapper.findAll('.js-ci-variable-row')).toHaveLength(defaultProps.variables.length); }); - it('displays the correct variable options', () => { - expect(findOptionsValues(0)).toBe('Protected, Expanded'); - expect(findOptionsValues(1)).toBe('Masked'); + it.each` + rowIndex | attributeIndex | text + ${0} | ${0} | ${'Protected'} + ${0} | ${1} | ${'Expanded'} + ${1} | ${0} | ${'File'} + ${1} | ${1} | ${'Masked'} + `( + 'displays variable attribute $text for row $rowIndex', + ({ rowIndex, attributeIndex, text }) => { + expect(findAttributeByIndex(rowIndex, attributeIndex)).toBe(text); + }, + ); + + it('renders action buttons', () => { + expect(findRevealButton().exists()).toBe(true); + expect(findAddButton().exists()).toBe(true); + expect(findEditButton().exists()).toBe(true); }); it('enables the Add Variable button', () => { @@ -96,6 +125,55 @@ describe('Ci variable table', () => { }); }); + describe('When table has inherited CI variables', () => { + beforeEach(() => { + createComponent({ + props: { variables: mockInheritedVariables }, + provide: { isInheritedGroupVars: true, ...provide }, + }); + }); + + it.each` + index | text + ${0} | ${'Key'} + ${1} | ${'Attributes'} + ${2} | ${'Environments'} + ${3} | ${'Group'} + `('renders the $text column', ({ index, text }) => { + expect(findTableColumnText(index)).toEqual(text); + }); + + it('does not render action buttons', () => { + expect(findRevealButton().exists()).toBe(false); + expect(findAddButton().exists()).toBe(false); + expect(findEditButton().exists()).toBe(false); + expect(findKeysetPagination().exists()).toBe(false); + }); + + it('displays the correct amount of variables', () => { + expect(wrapper.findAll('.js-ci-variable-row')).toHaveLength(mockInheritedVariables.length); + }); + + it.each` + rowIndex | attributeIndex | text + ${0} | ${0} | ${'Protected'} + ${0} | ${1} | ${'Masked'} + ${0} | ${2} | ${'Expanded'} + ${2} | ${0} | ${'File'} + ${2} | ${1} | ${'Protected'} + `( + 'displays variable attribute $text for row $rowIndex', + ({ rowIndex, attributeIndex, text }) => { + expect(findAttributeByIndex(rowIndex, attributeIndex)).toBe(text); + }, + ); + + it('displays link to the group settings', () => { + expect(findGroupCiCdSettingsLink(0)).toBe(mockInheritedVariables[0].groupCiCdSettingsPath); + expect(findGroupCiCdSettingsLink(1)).toBe(mockInheritedVariables[1].groupCiCdSettingsPath); + }); + }); + describe('When variables have exceeded the max limit', () => { beforeEach(() => { createComponent({ diff --git a/spec/frontend/ci/ci_variable_list/mocks.js b/spec/frontend/ci/ci_variable_list/mocks.js index f9450803308..9c9c99ad5ea 100644 --- a/spec/frontend/ci/ci_variable_list/mocks.js +++ b/spec/frontend/ci/ci_variable_list/mocks.js @@ -51,6 +51,45 @@ export const mockVariables = (kind) => { ]; }; +export const mockInheritedVariables = [ + { + id: 'gid://gitlab/Ci::GroupVariable/120', + key: 'INHERITED_VAR_1', + variableType: 'ENV_VAR', + environmentScope: '*', + masked: true, + protected: true, + raw: false, + groupName: 'group-name', + groupCiCdSettingsPath: '/groups/group-name/-/settings/ci_cd', + __typename: 'InheritedCiVariable', + }, + { + id: 'gid://gitlab/Ci::GroupVariable/121', + key: 'INHERITED_VAR_2', + variableType: 'ENV_VAR', + environmentScope: 'staging', + masked: false, + protected: false, + raw: true, + groupName: 'subgroup-name', + groupCiCdSettingsPath: '/groups/group-name/subgroup-name/-/settings/ci_cd', + __typename: 'InheritedCiVariable', + }, + { + id: 'gid://gitlab/Ci::GroupVariable/122', + key: 'INHERITED_VAR_3', + variableType: 'FILE', + environmentScope: 'production', + masked: false, + protected: true, + raw: true, + groupName: 'subgroup-name', + groupCiCdSettingsPath: '/groups/group-name/subgroup-name/-/settings/ci_cd', + __typename: 'InheritedCiVariable', + }, +]; + export const mockVariablesWithScopes = (kind) => mockVariables(kind).map((variable) => { return { ...variable, environmentScope: '*' }; diff --git a/spec/frontend/ci/inherited_ci_variables/components/inherited_ci_variables_app_spec.js b/spec/frontend/ci/inherited_ci_variables/components/inherited_ci_variables_app_spec.js new file mode 100644 index 00000000000..0af026cfec4 --- /dev/null +++ b/spec/frontend/ci/inherited_ci_variables/components/inherited_ci_variables_app_spec.js @@ -0,0 +1,114 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMount } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import CiVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue'; +import InheritedCiVariablesApp, { + i18n, + FETCH_LIMIT, + VARIABLES_PER_FETCH, +} from '~/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue'; +import getInheritedCiVariables from '~/ci/inherited_ci_variables/graphql/queries/inherited_ci_variables.query.graphql'; +import { mockInheritedCiVariables } from '../mocks'; + +jest.mock('~/alert'); +Vue.use(VueApollo); + +describe('Inherited CI Variables Component', () => { + let wrapper; + let mockApollo; + let mockVariables; + + const defaultProvide = { + projectPath: 'namespace/project', + projectId: '1', + }; + + const findCiTable = () => wrapper.findComponent(CiVariableTable); + + // eslint-disable-next-line consistent-return + function createComponentWithApollo({ isLoading = false } = {}) { + const handlers = [[getInheritedCiVariables, mockVariables]]; + + mockApollo = createMockApollo(handlers); + + wrapper = shallowMount(InheritedCiVariablesApp, { + provide: defaultProvide, + apolloProvider: mockApollo, + }); + + if (!isLoading) { + return waitForPromises(); + } + } + + beforeEach(() => { + mockVariables = jest.fn(); + }); + + describe('while variables are being fetched', () => { + beforeEach(() => { + mockVariables.mockResolvedValue(mockInheritedCiVariables()); + createComponentWithApollo({ isLoading: true }); + }); + + it('shows a loading icon', () => { + expect(findCiTable().props('isLoading')).toBe(true); + }); + }); + + describe('when there are more variables to fetch', () => { + beforeEach(async () => { + mockVariables.mockResolvedValue(mockInheritedCiVariables({ withNextPage: true })); + + await createComponentWithApollo(); + }); + + it('re-fetches the query up to <FETCH_LIMIT> times', () => { + expect(mockVariables).toHaveBeenCalledTimes(FETCH_LIMIT); + }); + + it('shows alert message when calls have exceeded FETCH_LIMIT', () => { + expect(createAlert).toHaveBeenCalledWith({ message: i18n.tooManyCallsError }); + }); + }); + + describe('when variables are fetched successfully', () => { + beforeEach(async () => { + mockVariables.mockResolvedValue(mockInheritedCiVariables()); + + await createComponentWithApollo(); + }); + + it('query was called with the correct arguments', () => { + expect(mockVariables).toHaveBeenCalledWith({ + first: VARIABLES_PER_FETCH, + fullPath: defaultProvide.projectPath, + }); + }); + + it('passes down variables to the table component', () => { + expect(findCiTable().props('variables')).toEqual( + mockInheritedCiVariables().data.project.inheritedCiVariables.nodes, + ); + }); + + it('createAlert was not called', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); + }); + + describe('when fetch error occurs', () => { + beforeEach(async () => { + mockVariables.mockRejectedValue(); + + await createComponentWithApollo(); + }); + + it('shows alert message with the expected error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: i18n.fetchError }); + }); + }); +}); diff --git a/spec/frontend/ci/inherited_ci_variables/mocks.js b/spec/frontend/ci/inherited_ci_variables/mocks.js new file mode 100644 index 00000000000..841ba0a0043 --- /dev/null +++ b/spec/frontend/ci/inherited_ci_variables/mocks.js @@ -0,0 +1,44 @@ +export const mockInheritedCiVariables = ({ withNextPage = false } = {}) => ({ + data: { + project: { + __typename: 'Project', + id: 'gid://gitlab/Project/38', + inheritedCiVariables: { + __typename: `InheritedCiVariableConnection`, + pageInfo: { + startCursor: 'adsjsd12kldpsa', + endCursor: 'adsjsd12kldpsa', + hasPreviousPage: withNextPage, + hasNextPage: withNextPage, + __typename: 'PageInfo', + }, + nodes: [ + { + __typename: `InheritedCiVariable`, + id: 'gid://gitlab/Ci::GroupVariable/1', + environmentScope: '*', + groupName: 'group_abc', + groupCiCdSettingsPath: '/groups/group_abc/-/settings/ci_cd', + key: 'GROUP_VAR', + masked: false, + protected: true, + raw: false, + variableType: 'ENV_VAR', + }, + { + __typename: `InheritedCiVariable`, + id: 'gid://gitlab/Ci::GroupVariable/2', + environmentScope: '*', + groupName: 'subgroup_xyz', + groupCiCdSettingsPath: '/groups/group_abc/subgroup_xyz/-/settings/ci_cd', + key: 'SUB_GROUP_VAR', + masked: true, + protected: false, + raw: true, + variableType: 'ENV_VAR', + }, + ], + }, + }, + }, +}); diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js index b07d63dd5d9..2845f76209b 100644 --- a/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js @@ -1,6 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import { GlDrawer } from '@gitlab/ui'; import PipelineEditorDrawer from '~/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue'; +import { EDITOR_APP_DRAWER_NONE } from '~/ci/pipeline_editor/constants'; describe('Pipeline editor drawer', () => { let wrapper; @@ -14,10 +15,10 @@ describe('Pipeline editor drawer', () => { it('emits close event when closing the drawer', () => { createComponent(); - expect(wrapper.emitted('close-drawer')).toBeUndefined(); + expect(wrapper.emitted('switch-drawer')).toBeUndefined(); findDrawer().vm.$emit('close'); - expect(wrapper.emitted('close-drawer')).toHaveLength(1); + expect(wrapper.emitted('switch-drawer')).toEqual([[EDITOR_APP_DRAWER_NONE]]); }); }); diff --git a/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js b/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js index f1a5c4169fb..f6247fb4a19 100644 --- a/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js @@ -5,6 +5,8 @@ import CiEditorHeader from '~/ci/pipeline_editor/components/editor/ci_editor_hea import { pipelineEditorTrackingOptions, TEMPLATE_REPOSITORY_URL, + EDITOR_APP_DRAWER_HELP, + EDITOR_APP_DRAWER_NONE, } from '~/ci/pipeline_editor/constants'; describe('CI Editor Header', () => { @@ -12,7 +14,7 @@ describe('CI Editor Header', () => { let trackingSpy = null; const createComponent = ({ - showDrawer = false, + showHelpDrawer = false, showJobAssistantDrawer = false, showAiAssistantDrawer = false, aiChatAvailable = false, @@ -27,7 +29,7 @@ describe('CI Editor Header', () => { }, }, propsData: { - showDrawer, + showHelpDrawer, showJobAssistantDrawer, showAiAssistantDrawer, }, @@ -116,15 +118,15 @@ describe('CI Editor Header', () => { describe('when pipeline editor drawer is closed', () => { beforeEach(() => { - createComponent({ showDrawer: false }); + createComponent({ showHelpDrawer: false }); }); - it('emits open drawer event when clicked', () => { - expect(wrapper.emitted('open-drawer')).toBeUndefined(); + it('emits switch drawer event when clicked', () => { + expect(wrapper.emitted('switch-drawer')).toBeUndefined(); findHelpBtn().vm.$emit('click'); - expect(wrapper.emitted('open-drawer')).toHaveLength(1); + expect(wrapper.emitted('switch-drawer')).toEqual([[EDITOR_APP_DRAWER_HELP]]); }); it('tracks open help drawer action', () => { @@ -136,15 +138,15 @@ describe('CI Editor Header', () => { describe('when pipeline editor drawer is open', () => { beforeEach(() => { - createComponent({ showDrawer: true }); + createComponent({ showHelpDrawer: true }); }); it('emits close drawer event when clicked', () => { - expect(wrapper.emitted('close-drawer')).toBeUndefined(); + expect(wrapper.emitted('switch-drawer')).toBeUndefined(); findHelpBtn().vm.$emit('click'); - expect(wrapper.emitted('close-drawer')).toHaveLength(1); + expect(wrapper.emitted('switch-drawer')).toEqual([[EDITOR_APP_DRAWER_NONE]]); }); }); }); diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js index b8526e569ec..29759f828e4 100644 --- a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js @@ -5,7 +5,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import PipelineEditorMiniGraph from '~/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue'; import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; -import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql'; +import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql'; import { PIPELINE_FAILURE } from '~/ci/pipeline_editor/constants'; import { mockLinkedPipelines, mockProjectFullPath, mockProjectPipeline } from '../../mock_data'; diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js index 8ca88472bf1..9d93ba332e9 100644 --- a/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js @@ -6,6 +6,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import PipelineStatus, { i18n } from '~/ci/pipeline_editor/components/header/pipeline_status.vue'; import getPipelineQuery from '~/ci/pipeline_editor/graphql/queries/pipeline.query.graphql'; +import GraphqlPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue'; import PipelineEditorMiniGraph from '~/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue'; import { mockCommitSha, mockProjectPipeline, mockProjectFullPath } from '../../mock_data'; @@ -16,7 +17,7 @@ describe('Pipeline Status', () => { let mockApollo; let mockPipelineQuery; - const createComponentWithApollo = () => { + const createComponentWithApollo = ({ ciGraphqlPipelineMiniGraph = false } = {}) => { const handlers = [[getPipelineQuery, mockPipelineQuery]]; mockApollo = createMockApollo(handlers); @@ -26,6 +27,9 @@ describe('Pipeline Status', () => { commitSha: mockCommitSha, }, provide: { + glFeatures: { + ciGraphqlPipelineMiniGraph, + }, projectFullPath: mockProjectFullPath, }, stubs: { GlLink, GlSprintf }, @@ -34,6 +38,7 @@ describe('Pipeline Status', () => { const findIcon = () => wrapper.findComponent(GlIcon); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findGraphqlPipelineMiniGraph = () => wrapper.findComponent(GraphqlPipelineMiniGraph); const findPipelineEditorMiniGraph = () => wrapper.findComponent(PipelineEditorMiniGraph); const findPipelineId = () => wrapper.find('[data-testid="pipeline-id"]'); const findPipelineCommit = () => wrapper.find('[data-testid="pipeline-commit"]'); @@ -128,4 +133,28 @@ describe('Pipeline Status', () => { }); }); }); + + describe('feature flag behavior', () => { + beforeEach(() => { + mockPipelineQuery.mockResolvedValue({ + data: { project: mockProjectPipeline() }, + }); + }); + + it.each` + state | provide | showPipelineMiniGraph | showGraphqlPipelineMiniGraph + ${true} | ${{ ciGraphqlPipelineMiniGraph: true }} | ${false} | ${true} + ${false} | ${{}} | ${true} | ${false} + `( + 'renders the correct component when the feature flag is set to $state', + async ({ provide, showPipelineMiniGraph, showGraphqlPipelineMiniGraph }) => { + createComponentWithApollo(provide); + + await waitForPromises(); + + expect(findPipelineEditorMiniGraph().exists()).toBe(showPipelineMiniGraph); + expect(findGraphqlPipelineMiniGraph().exists()).toBe(showGraphqlPipelineMiniGraph); + }, + ); + }); }); diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item_spec.js index 9046be4a45e..b30a8e64f87 100644 --- a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item_spec.js @@ -1,10 +1,15 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; import ArtifactsAndCacheItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { JOB_TEMPLATE } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants'; +import { + JOB_TEMPLATE, + HELP_PATHS, +} from '~/ci/pipeline_editor/components/job_assistant_drawer/constants'; describe('Artifacts and cache item', () => { let wrapper; + const findLinks = () => wrapper.findAllComponents(GlLink); const findArtifactsPathsInputByIndex = (index) => wrapper.findByTestId(`artifacts-paths-input-${index}`); const findArtifactsExcludeInputByIndex = (index) => @@ -31,9 +36,19 @@ describe('Artifacts and cache item', () => { propsData: { job, }, + stubs: { + GlSprintf, + }, }); }; + it('should render help links with correct hrefs', () => { + createComponent(); + + const hrefs = findLinks().wrappers.map((w) => w.attributes('href')); + expect(hrefs).toEqual([HELP_PATHS.artifactsHelpPath, HELP_PATHS.cacheHelpPath]); + }); + it('should emit update job event when filling inputs', () => { createComponent(); diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item_spec.js index f99d7277612..5625b2577e3 100644 --- a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item_spec.js @@ -1,10 +1,15 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; import ImageItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { JOB_TEMPLATE } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants'; +import { + HELP_PATHS, + JOB_TEMPLATE, +} from '~/ci/pipeline_editor/components/job_assistant_drawer/constants'; describe('Image item', () => { let wrapper; + const findLink = () => wrapper.findComponent(GlLink); const findImageNameInput = () => wrapper.findByTestId('image-name-input'); const findImageEntrypointInput = () => wrapper.findByTestId('image-entrypoint-input'); @@ -16,6 +21,9 @@ describe('Image item', () => { propsData: { job, }, + stubs: { + GlSprintf, + }, }); }; @@ -23,6 +31,12 @@ describe('Image item', () => { createComponent(); }); + it('should render help link with correct href', () => { + createComponent(); + + expect(findLink().attributes('href')).toEqual(HELP_PATHS.imageHelpPath); + }); + it('should emit update job event when filling inputs', () => { expect(wrapper.emitted('update-job')).toBeUndefined(); diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js index 659ccb25996..edaa96a197a 100644 --- a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js @@ -1,14 +1,17 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; import RulesItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { JOB_TEMPLATE, JOB_RULES_WHEN, JOB_RULES_START_IN, + HELP_PATHS, } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants'; describe('Rules item', () => { let wrapper; + const findLink = () => wrapper.findComponent(GlLink); const findRulesWhenSelect = () => wrapper.findByTestId('rules-when-select'); const findRulesStartInNumberInput = () => wrapper.findByTestId('rules-start-in-number-input'); const findRulesStartInUnitSelect = () => wrapper.findByTestId('rules-start-in-unit-select'); @@ -25,6 +28,9 @@ describe('Rules item', () => { isStartValid: true, job: JSON.parse(JSON.stringify(JOB_TEMPLATE)), }, + stubs: { + GlSprintf, + }, }); }; @@ -32,6 +38,12 @@ describe('Rules item', () => { createComponent(); }); + it('should render help link with correct href', () => { + createComponent(); + + expect(findLink().attributes('href')).toEqual(HELP_PATHS.rulesHelpPath); + }); + it('should emit update job event when filling inputs', () => { expect(wrapper.emitted('update-job')).toBeUndefined(); diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item_spec.js index 284d639c77f..f664547bbcc 100644 --- a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item_spec.js @@ -1,10 +1,15 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; import ServicesItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { JOB_TEMPLATE } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants'; +import { + HELP_PATHS, + JOB_TEMPLATE, +} from '~/ci/pipeline_editor/components/job_assistant_drawer/constants'; describe('Services item', () => { let wrapper; + const findLink = () => wrapper.findComponent(GlLink); const findServiceNameInputByIndex = (index) => wrapper.findByTestId(`service-name-input-${index}`); const findServiceEntrypointInputByIndex = (index) => @@ -21,9 +26,18 @@ describe('Services item', () => { propsData: { job, }, + stubs: { + GlSprintf, + }, }); }; + it('should render help links with correct hrefs', () => { + createComponent(); + + expect(findLink().attributes('href')).toEqual(HELP_PATHS.servicesHelpPath); + }); + it('should emit update job event when filling inputs', () => { createComponent(); diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js index 0258a1a8c7f..cf2797c255f 100644 --- a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js @@ -15,6 +15,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub'; +import { EDITOR_APP_DRAWER_NONE } from '~/ci/pipeline_editor/constants'; import { mockRunnersTagsQueryResponse, mockLintResponse, mockCiYml } from '../../mock_data'; Vue.use(VueApollo); @@ -96,20 +97,20 @@ describe('Job assistant drawer', () => { expect(findRulesItem().exists()).toBe(true); }); - it('should emit close job assistant drawer event when closing the drawer', () => { - expect(wrapper.emitted('close-job-assistant-drawer')).toBeUndefined(); + it('should emit switch drawer event when closing the drawer', () => { + expect(wrapper.emitted('switch-drawer')).toBeUndefined(); findDrawer().vm.$emit('close'); - expect(wrapper.emitted('close-job-assistant-drawer')).toHaveLength(1); + expect(wrapper.emitted('switch-drawer')).toEqual([[EDITOR_APP_DRAWER_NONE]]); }); - it('should emit close job assistant drawer event when click cancel button', () => { - expect(wrapper.emitted('close-job-assistant-drawer')).toBeUndefined(); + it('should emit switch drawer event when click cancel button', () => { + expect(wrapper.emitted('switch-drawer')).toBeUndefined(); findCancelButton().trigger('click'); - expect(wrapper.emitted('close-job-assistant-drawer')).toHaveLength(1); + expect(wrapper.emitted('switch-drawer')).toEqual([[EDITOR_APP_DRAWER_NONE]]); }); it('should block submit if job name is empty', async () => { diff --git a/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js index 471b033913b..77252a5c0b6 100644 --- a/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js @@ -1,5 +1,3 @@ -// TODO - import { GlAlert, GlBadge, GlLoadingIcon, GlTabs } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; @@ -55,7 +53,7 @@ describe('Pipeline editor tabs component', () => { ciFileContent: mockCiYml, currentTab: CREATE_TAB, isNewCiConfigFile: true, - showDrawer: false, + showHelpDrawer: false, showJobAssistantDrawer: false, showAiAssistantDrawer: false, ...props, diff --git a/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js b/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js index 2349816fa86..f2818277c59 100644 --- a/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js @@ -1,15 +1,20 @@ +import Vue from 'vue'; import { GlAlert, GlDisclosureDropdown, GlIcon, GlLoadingIcon, GlPopover } from '@gitlab/ui'; -import { nextTick } from 'vue'; -import { createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; +import MockAdapter from 'axios-mock-adapter'; + import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers'; import CiLintResults from '~/ci/pipeline_editor/components/lint/ci_lint_results.vue'; import CiValidate, { i18n } from '~/ci/pipeline_editor/components/validate/ci_validate.vue'; import ValidatePipelinePopover from '~/ci/pipeline_editor/components/popovers/validate_pipeline_popover.vue'; import getBlobContent from '~/ci/pipeline_editor/graphql/queries/blob_content.query.graphql'; -import lintCIMutation from '~/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql'; import { pipelineEditorTrackingOptions } from '~/ci/pipeline_editor/constants'; import { mockBlobContentQueryResponse, @@ -17,68 +22,45 @@ import { mockCiYml, mockSimulatePipelineHelpPagePath, } from '../../mock_data'; -import { mockLintDataError, mockLintDataValid } from '../../../ci_lint/mock_data'; +import { + mockLintDataError, + mockLintDataValid, + mockLintDataErrorRest, + mockLintDataValidRest, +} from '../../../ci_lint/mock_data'; + +let mockAxios; + +Vue.use(VueApollo); -const localVue = createLocalVue(); -localVue.use(VueApollo); +const defaultProvide = { + ciConfigPath: '/path/to/ci-config', + ciLintPath: mockCiLintPath, + currentBranch: 'main', + projectFullPath: '/path/to/project', + validateTabIllustrationPath: '/path/to/img', + simulatePipelineHelpPagePath: mockSimulatePipelineHelpPagePath, +}; describe('Pipeline Editor Validate Tab', () => { let wrapper; - let mockApollo; let mockBlobContentData; let trackingSpy; - const createComponent = ({ - props, - stubs, - options, - isBlobLoading = false, - isSimulationLoading = false, - } = {}) => { + const createComponent = ({ props, stubs } = {}) => { + const handlers = [[getBlobContent, mockBlobContentData]]; + const mockApollo = createMockApollo(handlers, resolvers); + wrapper = shallowMountExtended(CiValidate, { propsData: { ciFileContent: mockCiYml, ...props, }, - provide: { - ciConfigPath: '/path/to/ci-config', - ciLintPath: mockCiLintPath, - currentBranch: 'main', - projectFullPath: '/path/to/project', - validateTabIllustrationPath: '/path/to/img', - simulatePipelineHelpPagePath: mockSimulatePipelineHelpPagePath, - }, - stubs, - mocks: { - $apollo: { - queries: { - initialBlobContent: { - loading: isBlobLoading, - }, - }, - mutations: { - lintCiMutation: { - loading: isSimulationLoading, - }, - }, - }, - }, - ...options, - }); - }; - - const createComponentWithApollo = ({ props, stubs } = {}) => { - const handlers = [[getBlobContent, mockBlobContentData]]; - mockApollo = createMockApollo(handlers); - - createComponent({ - props, stubs, - options: { - localVue, - apolloProvider: mockApollo, - mocks: {}, + provide: { + ...defaultProvide, }, + apolloProvider: mockApollo, }); }; @@ -96,12 +78,21 @@ describe('Pipeline Editor Validate Tab', () => { const findResultsCta = () => wrapper.findByTestId('resimulate-pipeline-button'); beforeEach(() => { + mockAxios = new MockAdapter(axios); + mockAxios.onPost(defaultProvide.ciLintPath).reply(HTTP_STATUS_OK, mockLintDataValidRest); + mockBlobContentData = jest.fn(); }); + afterEach(() => { + mockAxios.restore(); + }); + describe('while initial CI content is loading', () => { beforeEach(() => { - createComponent({ isBlobLoading: true }); + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); + + createComponent(); }); it('renders disabled CTA with tooltip', () => { @@ -113,7 +104,7 @@ describe('Pipeline Editor Validate Tab', () => { describe('after initial CI content is loaded', () => { beforeEach(async () => { mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); - await createComponentWithApollo({ stubs: { GlPopover, ValidatePipelinePopover } }); + await createComponent({ stubs: { GlPopover, ValidatePipelinePopover } }); }); it('renders disabled pipeline source dropdown', () => { @@ -137,10 +128,9 @@ describe('Pipeline Editor Validate Tab', () => { describe('simulating the pipeline', () => { beforeEach(async () => { mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); - await createComponentWithApollo(); + await createComponent(); trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid); }); afterEach(() => { @@ -158,32 +148,32 @@ describe('Pipeline Editor Validate Tab', () => { }); it('renders loading state while simulation is ongoing', async () => { - findCta().vm.$emit('click'); - await nextTick(); + await findCta().vm.$emit('click'); expect(findLoadingIcon().exists()).toBe(true); expect(findCancelBtn().exists()).toBe(true); expect(findCta().props('loading')).toBe(true); }); - it('calls mutation with the correct input', async () => { - await findCta().vm.$emit('click'); + it('calls endpoint with the correct input', async () => { + findCta().vm.$emit('click'); + + await waitForPromises(); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: lintCIMutation, - variables: { - dry: true, + expect(mockAxios.history.post).toHaveLength(1); + expect(mockAxios.history.post[0].data).toBe( + JSON.stringify({ content: mockCiYml, - endpoint: mockCiLintPath, - }, - }); + dry_run: true, + }), + ); }); describe('when results are successful', () => { beforeEach(async () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid); - await findCta().vm.$emit('click'); + findCta().vm.$emit('click'); + + await waitForPromises(); }); it('renders success alert', () => { @@ -210,8 +200,10 @@ describe('Pipeline Editor Validate Tab', () => { describe('when results have errors', () => { beforeEach(async () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataError); - await findCta().vm.$emit('click'); + mockAxios.onPost(defaultProvide.ciLintPath).reply(HTTP_STATUS_OK, mockLintDataErrorRest); + findCta().vm.$emit('click'); + + await waitForPromises(); }); it('renders error alert', () => { @@ -236,11 +228,11 @@ describe('Pipeline Editor Validate Tab', () => { describe('when CI content has changed after a simulation', () => { beforeEach(async () => { mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); - await createComponentWithApollo(); + await createComponent(); trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid); - await findCta().vm.$emit('click'); + findCta().vm.$emit('click'); + await waitForPromises(); }); afterEach(() => { @@ -267,25 +259,26 @@ describe('Pipeline Editor Validate Tab', () => { }); it('calls mutation with new content', async () => { - await wrapper.setProps({ ciFileContent: 'new yaml content' }); - await findResultsCta().vm.$emit('click'); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(2); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: lintCIMutation, - variables: { - dry: true, - content: 'new yaml content', - endpoint: mockCiLintPath, - }, - }); + const newContent = 'new yaml content'; + await wrapper.setProps({ ciFileContent: newContent }); + findResultsCta().vm.$emit('click'); + + await waitForPromises(); + + expect(mockAxios.history.post).toHaveLength(2); + expect(mockAxios.history.post[1].data).toBe( + JSON.stringify({ + content: newContent, + dry_run: true, + }), + ); }); }); describe('canceling a simulation', () => { beforeEach(async () => { mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); - await createComponentWithApollo(); + await createComponent(); }); it('returns to init state', async () => { @@ -294,9 +287,7 @@ describe('Pipeline Editor Validate Tab', () => { expect(findCiLintResults().exists()).toBe(false); // mutations should have successful results - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid); - findCta().vm.$emit('click'); - await nextTick(); + await findCta().vm.$emit('click'); // cancel before simulation succeeds expect(findCancelBtn().exists()).toBe(true); diff --git a/spec/frontend/ci/pipeline_editor/index_spec.js b/spec/frontend/ci/pipeline_editor/index_spec.js new file mode 100644 index 00000000000..530a441bde1 --- /dev/null +++ b/spec/frontend/ci/pipeline_editor/index_spec.js @@ -0,0 +1,27 @@ +import { initPipelineEditor } from '~/ci/pipeline_editor'; +import * as optionsCE from '~/ci/pipeline_editor/options'; + +describe('initPipelineEditor', () => { + let el; + const selector = 'SELECTOR'; + + beforeEach(() => { + jest.spyOn(optionsCE, 'createAppOptions').mockReturnValue({ option: 2 }); + + el = document.createElement('div'); + el.id = selector; + document.body.appendChild(el); + }); + + afterEach(() => { + document.body.removeChild(el); + }); + + it('returns null if there are no elements found', () => { + expect(initPipelineEditor()).toBeNull(); + }); + + it('returns an object if there is an element found', () => { + expect(initPipelineEditor(`#${selector}`)).toMatchObject({}); + }); +}); diff --git a/spec/frontend/ci/pipeline_editor/mock_data.js b/spec/frontend/ci/pipeline_editor/mock_data.js index 865dd34fbfe..a3294cdc269 100644 --- a/spec/frontend/ci/pipeline_editor/mock_data.js +++ b/spec/frontend/ci/pipeline_editor/mock_data.js @@ -1,6 +1,42 @@ import { CI_CONFIG_STATUS_INVALID, CI_CONFIG_STATUS_VALID } from '~/ci/pipeline_editor/constants'; import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; +export const commonOptions = { + ciConfigPath: '/ci/config', + ciExamplesHelpPagePath: 'help/ci/examples', + ciHelpPagePath: 'help/ci/', + ciLintPath: 'ci/lint', + ciTroubleshootingPath: 'help/troubleshoot', + defaultBranch: 'main', + emptyStateIllustrationPath: 'illustrations/svg', + helpPaths: '/ads', + includesHelpPagePath: 'help/includes', + needsHelpPagePath: 'help/ci/needs', + newMergeRequestPath: 'merge_request/new', + pipelinePagePath: '/pipelines/1', + projectFullPath: 'root/my-project', + projectNamespace: 'root', + simulatePipelineHelpPagePath: 'help/ci/simulate', + totalBranches: '10', + usesExternalConfig: 'false', + validateTabIllustrationPath: 'illustrations/tab', + ymlHelpPagePath: 'help/ci/yml', + aiChatAvailable: 'true', +}; + +export const editorDatasetOptions = { + initialBranchName: 'production', + pipelineEtag: 'pipelineEtag', + ...commonOptions, +}; + +export const expectedInjectValues = { + ...commonOptions, + aiChatAvailable: true, + usesExternalConfig: false, + totalBranches: 10, +}; + export const mockProjectNamespace = 'user1'; export const mockProjectPath = 'project1'; export const mockProjectFullPath = `${mockProjectNamespace}/${mockProjectPath}`; @@ -43,7 +79,7 @@ job_build: export const mockCiTemplateQueryResponse = { data: { project: { - id: 'project-1', + id: 'gid://gitlab/Project/1', ciTemplate: { content: mockCiYml, }, @@ -54,7 +90,7 @@ export const mockCiTemplateQueryResponse = { export const mockBlobContentQueryResponse = { data: { project: { - id: 'project-1', + id: 'gid://gitlab/Project/1', repository: { blobs: { nodes: [{ id: 'blob-1', rawBlob: mockCiYml }] } }, }, }, @@ -62,13 +98,13 @@ export const mockBlobContentQueryResponse = { export const mockBlobContentQueryResponseNoCiFile = { data: { - project: { id: 'project-1', repository: { blobs: { nodes: [] } } }, + project: { id: 'gid://gitlab/Project/1', repository: { blobs: { nodes: [] } } }, }, }; export const mockBlobContentQueryResponseEmptyCiFile = { data: { - project: { id: 'project-1', repository: { blobs: { nodes: [{ rawBlob: '' }] } } }, + project: { id: 'gid://gitlab/Project/1', repository: { blobs: { nodes: [{ rawBlob: '' }] } } }, }, }; diff --git a/spec/frontend/ci/pipeline_editor/options_spec.js b/spec/frontend/ci/pipeline_editor/options_spec.js new file mode 100644 index 00000000000..b8f4105c923 --- /dev/null +++ b/spec/frontend/ci/pipeline_editor/options_spec.js @@ -0,0 +1,27 @@ +import { createAppOptions } from '~/ci/pipeline_editor/options'; +import { editorDatasetOptions, expectedInjectValues } from './mock_data'; + +describe('createAppOptions', () => { + let el; + + const createElement = () => { + el = document.createElement('div'); + + document.body.appendChild(el); + Object.entries(editorDatasetOptions).forEach(([k, v]) => { + el.dataset[k] = v; + }); + }; + + afterEach(() => { + el = null; + }); + + it("extracts the properties from the element's dataset", () => { + createElement(); + const options = createAppOptions(el); + Object.entries(expectedInjectValues).forEach(([key, value]) => { + expect(options.provide).toMatchObject({ [key]: value }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js index cc4a022c2df..89ce3a2e18c 100644 --- a/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js @@ -1,5 +1,6 @@ +import Vue from 'vue'; import { GlAlert, GlButton, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; @@ -53,9 +54,6 @@ jest.mock('~/lib/utils/url_utility', () => ({ redirectTo: jest.fn(), })); -const localVue = createLocalVue(); -localVue.use(VueApollo); - const defaultProvide = { ciConfigPath: mockCiConfigPath, defaultBranch: mockDefaultBranch, @@ -74,24 +72,10 @@ describe('Pipeline editor app component', () => { let mockLatestCommitShaQuery; let mockPipelineQuery; - const createComponent = ({ - blobLoading = false, - options = {}, - provide = {}, - stubs = {}, - } = {}) => { + const createComponent = ({ options = {}, provide = {}, stubs = {} } = {}) => { wrapper = shallowMount(PipelineEditorApp, { provide: { ...defaultProvide, ...provide }, stubs, - mocks: { - $apollo: { - queries: { - initialCiFileContent: { - loading: blobLoading, - }, - }, - }, - }, ...options, }); }; @@ -101,6 +85,8 @@ describe('Pipeline editor app component', () => { stubs = {}, withUndefinedBranch = false, } = {}) => { + Vue.use(VueApollo); + const handlers = [ [getBlobContent, mockBlobContentData], [getCiConfigData, mockCiConfigData], @@ -137,7 +123,6 @@ describe('Pipeline editor app component', () => { }); const options = { - localVue, mocks: {}, apolloProvider: mockApollo, }; @@ -164,7 +149,7 @@ describe('Pipeline editor app component', () => { describe('loading state', () => { it('displays a loading icon if the blob query is loading', () => { - createComponent({ blobLoading: true }); + createComponentWithApollo(); expect(findLoadingIcon().exists()).toBe(true); expect(findEditorHome().exists()).toBe(false); @@ -246,10 +231,6 @@ describe('Pipeline editor app component', () => { describe('when file exists', () => { beforeEach(async () => { await createComponentWithApollo(); - - jest - .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling') - .mockImplementation(jest.fn()); }); it('shows pipeline editor home component', () => { @@ -268,8 +249,8 @@ describe('Pipeline editor app component', () => { }); }); - it('does not poll for the commit sha', () => { - expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(0); + it('calls once and does not start poll for the commit sha', () => { + expect(mockLatestCommitShaQuery).toHaveBeenCalledTimes(1); }); }); @@ -281,10 +262,6 @@ describe('Pipeline editor app component', () => { PipelineEditorEmptyState, }, }); - - jest - .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling') - .mockImplementation(jest.fn()); }); it('shows an empty state and does not show editor home component', () => { @@ -293,8 +270,8 @@ describe('Pipeline editor app component', () => { expect(findEditorHome().exists()).toBe(false); }); - it('does not poll for the commit sha', () => { - expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(0); + it('calls once and does not start poll for the commit sha', () => { + expect(mockLatestCommitShaQuery).toHaveBeenCalledTimes(1); }); describe('because of a fetching error', () => { @@ -381,38 +358,27 @@ describe('Pipeline editor app component', () => { }); it('polls for commit sha while pipeline data is not yet available for current branch', async () => { - jest - .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling') - .mockImplementation(jest.fn()); - - // simulate a commit to the current branch findEditorHome().vm.$emit('updateCommitSha'); await waitForPromises(); - expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(1); + expect(mockLatestCommitShaQuery).toHaveBeenCalledTimes(2); }); it('stops polling for commit sha when pipeline data is available for newly committed branch', async () => { - jest - .spyOn(wrapper.vm.$apollo.queries.commitSha, 'stopPolling') - .mockImplementation(jest.fn()); - mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults); - await wrapper.vm.$apollo.queries.commitSha.refetch(); + await waitForPromises(); + + await findEditorHome().vm.$emit('updateCommitSha'); - expect(wrapper.vm.$apollo.queries.commitSha.stopPolling).toHaveBeenCalledTimes(1); + expect(mockLatestCommitShaQuery).toHaveBeenCalledTimes(2); }); it('stops polling for commit sha when pipeline data is available for current branch', async () => { - jest - .spyOn(wrapper.vm.$apollo.queries.commitSha, 'stopPolling') - .mockImplementation(jest.fn()); - mockLatestCommitShaQuery.mockResolvedValue(mockNewCommitShaResults); findEditorHome().vm.$emit('updateCommitSha'); await waitForPromises(); - expect(wrapper.vm.$apollo.queries.commitSha.stopPolling).toHaveBeenCalledTimes(1); + expect(mockLatestCommitShaQuery).toHaveBeenCalledTimes(2); }); }); @@ -497,15 +463,12 @@ describe('Pipeline editor app component', () => { it('refetches blob content', async () => { await createComponentWithApollo(); - jest - .spyOn(wrapper.vm.$apollo.queries.initialCiFileContent, 'refetch') - .mockImplementation(jest.fn()); - expect(wrapper.vm.$apollo.queries.initialCiFileContent.refetch).toHaveBeenCalledTimes(0); + expect(mockBlobContentData).toHaveBeenCalledTimes(1); - await wrapper.vm.refetchContent(); + findEditorHome().vm.$emit('refetchContent'); - expect(wrapper.vm.$apollo.queries.initialCiFileContent.refetch).toHaveBeenCalledTimes(1); + expect(mockBlobContentData).toHaveBeenCalledTimes(2); }); it('hides start screen when refetch fetches CI file', async () => { @@ -516,7 +479,8 @@ describe('Pipeline editor app component', () => { expect(findEditorHome().exists()).toBe(false); mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); - await wrapper.vm.$apollo.queries.initialCiFileContent.refetch(); + findEmptyState().vm.$emit('refetchContent'); + await waitForPromises(); expect(findEmptyState().exists()).toBe(false); expect(findEditorHome().exists()).toBe(true); @@ -573,10 +537,6 @@ describe('Pipeline editor app component', () => { mockGetTemplate.mockResolvedValue(mockCiTemplateQueryResponse); await createComponentWithApollo(); - - jest - .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling') - .mockImplementation(jest.fn()); }); it('skips empty state and shows editor home component', () => { diff --git a/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js b/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js index 4c56dd74f1a..75bca68b888 100644 --- a/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js +++ b/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js @@ -16,14 +16,14 @@ import { WINDOWS_PLATFORM, } from '~/ci/runner/constants'; import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue'; -import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { visitUrl } from '~/lib/utils/url_utility'; import { runnerCreateResult } from '../mock_data'; jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage'); jest.mock('~/alert'); jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), - redirectTo: jest.fn(), + visitUrl: jest.fn(), })); const mockCreatedRunner = runnerCreateResult.data.runnerCreate.runner; @@ -87,7 +87,7 @@ describe('AdminNewRunnerApp', () => { it('redirects to the registration page', () => { const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${DEFAULT_PLATFORM}`; - expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated + expect(visitUrl).toHaveBeenCalledWith(url); }); }); @@ -100,7 +100,7 @@ describe('AdminNewRunnerApp', () => { it('redirects to the registration page with the platform', () => { const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`; - expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated + expect(visitUrl).toHaveBeenCalledWith(url); }); }); diff --git a/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js index 9787b1ef83f..c4ed6d1bdb5 100644 --- a/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js +++ b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js @@ -5,7 +5,7 @@ import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_help import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert, VARIANT_SUCCESS } from '~/alert'; -import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { visitUrl } from '~/lib/utils/url_utility'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerHeader from '~/ci/runner/components/runner_header.vue'; @@ -26,11 +26,15 @@ import { runnerData } from '../mock_data'; jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage'); jest.mock('~/alert'); jest.mock('~/ci/runner/sentry_utils'); -jest.mock('~/lib/utils/url_utility'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + visitUrl: jest.fn(), +})); const mockRunner = runnerData.data.runner; const mockRunnerGraphqlId = mockRunner.id; const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; +const mockRunnerSha = mockRunner.shortSha; const mockRunnersPath = '/admin/runners'; Vue.use(VueApollo); @@ -86,7 +90,7 @@ describe('AdminRunnerShowApp', () => { }); it('displays the runner header', () => { - expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`); + expect(findRunnerHeader().text()).toContain(`#${mockRunnerId} (${mockRunnerSha})`); }); it('displays the runner edit and pause buttons', () => { @@ -180,7 +184,7 @@ describe('AdminRunnerShowApp', () => { message: 'Runner deleted', variant: VARIANT_SUCCESS, }); - expect(redirectTo).toHaveBeenCalledWith(mockRunnersPath); // eslint-disable-line import/no-deprecated + expect(visitUrl).toHaveBeenCalledWith(mockRunnersPath); }); }); diff --git a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js index c3d33c88422..fc74e2947b6 100644 --- a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js @@ -84,7 +84,7 @@ const COUNT_QUERIES = TAB_COUNT_QUERIES + STATUS_COUNT_QUERIES; describe('AdminRunnersApp', () => { let wrapper; - let showToast; + const showToast = jest.fn(); const findRunnerStats = () => wrapper.findComponent(RunnerStats); const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell); @@ -122,11 +122,14 @@ describe('AdminRunnersApp', () => { staleTimeoutSecs, ...provide, }, + mocks: { + $toast: { + show: showToast, + }, + }, ...options, }); - showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show'); - return waitForPromises(); }; @@ -153,7 +156,9 @@ describe('AdminRunnersApp', () => { await createComponent({ mountFn: mountExtended }); }); - it('fetches counts', () => { + // https://gitlab.com/gitlab-org/gitlab/-/issues/414975 + // eslint-disable-next-line jest/no-disabled-tests + it.skip('fetches counts', () => { expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); }); diff --git a/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js index c435dd57de2..88d4398aa70 100644 --- a/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js +++ b/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js @@ -24,7 +24,7 @@ describe('RunnerStatusCell', () => { propsData: { runner: { runnerType: INSTANCE_TYPE, - active: true, + paused: false, status: STATUS_ONLINE, jobExecutionStatus: JOB_STATUS_IDLE, ...runner, @@ -59,7 +59,7 @@ describe('RunnerStatusCell', () => { it('Displays paused status', () => { createComponent({ runner: { - active: false, + paused: true, status: STATUS_ONLINE, }, }); diff --git a/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js index 64e9c11a584..cda3876f9b2 100644 --- a/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js +++ b/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js @@ -3,6 +3,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import RunnerSummaryCell from '~/ci/runner/components/cells/runner_summary_cell.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import RunnerManagersBadge from '~/ci/runner/components/runner_managers_badge.vue'; import RunnerTags from '~/ci/runner/components/runner_tags.vue'; import RunnerSummaryField from '~/ci/runner/components/cells/runner_summary_field.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -23,6 +24,7 @@ const mockRunner = allRunnersWithCreatorData.data.runners.nodes[0]; describe('RunnerTypeCell', () => { let wrapper; + const findRunnerManagersBadge = () => wrapper.findComponent(RunnerManagersBadge); const findLockIcon = () => wrapper.findByTestId('lock-icon'); const findRunnerTags = () => wrapper.findComponent(RunnerTags); const findRunnerSummaryField = (icon) => @@ -54,6 +56,18 @@ describe('RunnerTypeCell', () => { ); }); + it('Displays no runner manager count', () => { + createComponent({ + managers: { count: 0 }, + }); + + expect(findRunnerManagersBadge().html()).toBe(''); + }); + + it('Displays runner manager count', () => { + expect(findRunnerManagersBadge().text()).toBe('2'); + }); + it('Does not display the locked icon', () => { expect(findLockIcon().exists()).toBe(false); }); diff --git a/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js b/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js index bfdde922e17..db54bf0c80e 100644 --- a/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js +++ b/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js @@ -33,6 +33,8 @@ describe('RegistrationTokenResetDropdownItem', () => { const clickSubmit = () => findModal().vm.$emit('primary', mockEvent); const createComponent = ({ props, provide = {} } = {}) => { + showToast = jest.fn(); + wrapper = shallowMount(RegistrationTokenResetDropdownItem, { provide, propsData: { @@ -45,9 +47,12 @@ describe('RegistrationTokenResetDropdownItem', () => { directives: { GlModal: createMockDirective('gl-modal'), }, + mocks: { + $toast: { + show: showToast, + }, + }, }); - - showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null; }; beforeEach(() => { diff --git a/spec/frontend/ci/runner/components/runner_create_form_spec.js b/spec/frontend/ci/runner/components/runner_create_form_spec.js index 329dd2f73ee..c452e32b0e4 100644 --- a/spec/frontend/ci/runner/components/runner_create_form_spec.js +++ b/spec/frontend/ci/runner/components/runner_create_form_spec.js @@ -11,6 +11,7 @@ import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE, + I18N_CREATE_ERROR, } from '~/ci/runner/constants'; import runnerCreateMutation from '~/ci/runner/graphql/new/runner_create.mutation.graphql'; import { captureException } from '~/ci/runner/sentry_utils'; @@ -21,12 +22,14 @@ jest.mock('~/ci/runner/sentry_utils'); const mockCreatedRunner = runnerCreateResult.data.runnerCreate.runner; const defaultRunnerModel = { + runnerType: INSTANCE_TYPE, description: '', accessLevel: DEFAULT_ACCESS_LEVEL, paused: false, maintenanceNote: '', maximumTimeout: '', runUntagged: false, + locked: false, tagList: '', }; @@ -81,6 +84,7 @@ describe('RunnerCreateForm', () => { findRunnerFormFields().vm.$emit('input', { ...defaultRunnerModel, + runnerType: props.runnerType, description: 'My runner', maximumTimeout: 0, tagList: 'tag1, tag2', @@ -123,8 +127,8 @@ describe('RunnerCreateForm', () => { expect(wrapper.emitted('saved')[0]).toEqual([mockCreatedRunner]); }); - it('does not show a saving state', () => { - expect(findSubmitBtn().props('loading')).toBe(false); + it('maintains a saving state before navigating away', () => { + expect(findSubmitBtn().props('loading')).toBe(true); }); }); @@ -185,5 +189,37 @@ describe('RunnerCreateForm', () => { expect(captureException).not.toHaveBeenCalled(); }); }); + + describe('when no runner information is returned', () => { + beforeEach(async () => { + runnerCreateHandler.mockResolvedValue({ + data: { + runnerCreate: { + errors: [], + runner: null, + }, + }, + }); + + findForm().vm.$emit('submit', { preventDefault }); + await waitForPromises(); + }); + + it('emits "error" result', () => { + expect(wrapper.emitted('error')[0]).toEqual([new TypeError(I18N_CREATE_ERROR)]); + }); + + it('does not show a saving state', () => { + expect(findSubmitBtn().props('loading')).toBe(false); + }); + + it('reports error', () => { + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith({ + component: 'RunnerCreateForm', + error: new Error(I18N_CREATE_ERROR), + }); + }); + }); }); }); diff --git a/spec/frontend/ci/runner/components/runner_delete_button_spec.js b/spec/frontend/ci/runner/components/runner_delete_button_spec.js index 3123f2894fb..3b3f3b1770d 100644 --- a/spec/frontend/ci/runner/components/runner_delete_button_spec.js +++ b/spec/frontend/ci/runner/components/runner_delete_button_spec.js @@ -236,7 +236,7 @@ describe('RunnerDeleteButton', () => { createComponent({ props: { runner: { - active: true, + paused: false, }, compact: true, }, diff --git a/spec/frontend/ci/runner/components/runner_delete_modal_spec.js b/spec/frontend/ci/runner/components/runner_delete_modal_spec.js index f2fb0206763..606cc46c018 100644 --- a/spec/frontend/ci/runner/components/runner_delete_modal_spec.js +++ b/spec/frontend/ci/runner/components/runner_delete_modal_spec.js @@ -20,25 +20,50 @@ describe('RunnerDeleteModal', () => { }); }; - it('Displays title', () => { - createComponent(); + describe.each([null, 0, 1])('for %o runners', (managersCount) => { + beforeEach(() => { + createComponent({ props: { managersCount } }); + }); - expect(findGlModal().props('title')).toBe('Delete runner #99 (AABBCCDD)?'); - }); + it('Displays title', () => { + expect(findGlModal().props('title')).toBe('Delete runner #99 (AABBCCDD)?'); + }); - it('Displays buttons', () => { - createComponent(); + it('Displays buttons', () => { + expect(findGlModal().props('actionPrimary')).toMatchObject({ + text: 'Permanently delete runner', + }); + expect(findGlModal().props('actionCancel')).toMatchObject({ text: 'Cancel' }); + }); - expect(findGlModal().props('actionPrimary')).toMatchObject({ text: 'Delete runner' }); - expect(findGlModal().props('actionCancel')).toMatchObject({ text: 'Cancel' }); + it('Displays contents', () => { + expect(findGlModal().text()).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?', + ); + }); }); - it('Displays contents', () => { - createComponent(); + describe('for 2 runners', () => { + beforeEach(() => { + createComponent({ props: { managersCount: 2 } }); + }); + + it('Displays title', () => { + expect(findGlModal().props('title')).toBe('Delete 2 runners?'); + }); - 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?', - ); + it('Displays buttons', () => { + expect(findGlModal().props('actionPrimary')).toMatchObject({ + text: 'Permanently delete 2 runners', + }); + expect(findGlModal().props('actionCancel')).toMatchObject({ text: 'Cancel' }); + }); + + it('Displays contents', () => { + expect(findGlModal().text()).toContain( + '2 runners 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', () => { diff --git a/spec/frontend/ci/runner/components/runner_details_spec.js b/spec/frontend/ci/runner/components/runner_details_spec.js index c2d9e86aa91..cc91340655b 100644 --- a/spec/frontend/ci/runner/components/runner_details_spec.js +++ b/spec/frontend/ci/runner/components/runner_details_spec.js @@ -1,4 +1,5 @@ import { GlSprintf, GlIntersperse } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { useFakeDate } from 'helpers/fake_date'; @@ -10,6 +11,7 @@ import RunnerDetail from '~/ci/runner/components/runner_detail.vue'; import RunnerGroups from '~/ci/runner/components/runner_groups.vue'; import RunnerTags from '~/ci/runner/components/runner_tags.vue'; import RunnerTag from '~/ci/runner/components/runner_tag.vue'; +import RunnerManagersDetail from '~/ci/runner/components/runner_managers_detail.vue'; import { runnerData, runnerWithGroupData } from '../mock_data'; @@ -24,6 +26,9 @@ describe('RunnerDetails', () => { useFakeDate(mockNow); const findDetailGroups = () => wrapper.findComponent(RunnerGroups); + const findRunnerManagersDetail = () => wrapper.findComponent(RunnerManagersDetail); + + const findDdContent = (label) => findDd(label, wrapper).text().replace(/\s+/g, ' '); const createComponent = ({ props = {}, stubs, mountFn = shallowMountExtended } = {}) => { wrapper = mountFn(RunnerDetails, { @@ -61,6 +66,7 @@ describe('RunnerDetails', () => { ${'Maximum job timeout'} | ${{ maximumTimeout: 10 * 60 + 5 }} | ${'10 minutes 5 seconds'} ${'Token expiry'} | ${{ tokenExpiresAt: mockOneHourAgo }} | ${'1 hour ago'} ${'Token expiry'} | ${{ tokenExpiresAt: null }} | ${'Never expires'} + ${'Runners'} | ${{ managers: { count: 2 } }} | ${`2 ${__('Show details')}`} `('"$field" field', ({ field, runner, expectedValue }) => { beforeEach(() => { createComponent({ @@ -74,12 +80,13 @@ describe('RunnerDetails', () => { GlIntersperse, GlSprintf, TimeAgo, + RunnerManagersDetail, }, }); }); it(`displays expected value "${expectedValue}"`, () => { - expect(findDd(field, wrapper).text()).toBe(expectedValue); + expect(findDdContent(field)).toBe(expectedValue); }); }); @@ -94,7 +101,7 @@ describe('RunnerDetails', () => { stubs, }); - expect(findDd('Tags', wrapper).text().replace(/\s+/g, ' ')).toBe('tag-1 tag-2'); + expect(findDdContent(s__('Runners|Tags'))).toBe('tag-1 tag-2'); }); it('displays "None" when runner has no tags', () => { @@ -105,7 +112,19 @@ describe('RunnerDetails', () => { stubs, }); - expect(findDd('Tags', wrapper).text().replace(/\s+/g, ' ')).toBe('None'); + expect(findDdContent(s__('Runners|Tags'))).toBe('None'); + }); + }); + + describe('"Runners" field', () => { + it('displays runner managers count of $count', () => { + createComponent({ + props: { + runner: mockRunner, + }, + }); + + expect(findRunnerManagersDetail().props('runner')).toEqual(mockRunner); }); }); diff --git a/spec/frontend/ci/runner/components/runner_details_tabs_spec.js b/spec/frontend/ci/runner/components/runner_details_tabs_spec.js index a59c5a21377..689d0575726 100644 --- a/spec/frontend/ci/runner/components/runner_details_tabs_spec.js +++ b/spec/frontend/ci/runner/components/runner_details_tabs_spec.js @@ -16,9 +16,17 @@ import { runnerData } from '../mock_data'; // Vue Test Utils `stubs` option does not stub components mounted // in <router-view>. Use mocking instead: jest.mock('~/ci/runner/components/runner_jobs.vue', () => { - const ActualRunnerJobs = jest.requireActual('~/ci/runner/components/runner_jobs.vue').default; + const { props } = jest.requireActual('~/ci/runner/components/runner_jobs.vue').default; return { - props: ActualRunnerJobs.props, + props, + render() {}, + }; +}); + +jest.mock('~/ci/runner/components/runner_managers_detail.vue', () => { + const { props } = jest.requireActual('~/ci/runner/components/runner_managers_detail.vue').default; + return { + props, render() {}, }; }); diff --git a/spec/frontend/ci/runner/components/runner_form_fields_spec.js b/spec/frontend/ci/runner/components/runner_form_fields_spec.js index 5b429645d17..93be4d9d35e 100644 --- a/spec/frontend/ci/runner/components/runner_form_fields_spec.js +++ b/spec/frontend/ci/runner/components/runner_form_fields_spec.js @@ -1,71 +1,158 @@ import { nextTick } from 'vue'; +import { GlSkeletonLoader } from '@gitlab/ui'; +import { s__ } from '~/locale'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue'; -import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED } from '~/ci/runner/constants'; +import { + ACCESS_LEVEL_NOT_PROTECTED, + ACCESS_LEVEL_REF_PROTECTED, + PROJECT_TYPE, +} from '~/ci/runner/constants'; const mockDescription = 'My description'; +const mockNewDescription = 'My new description'; const mockMaxTimeout = 60; const mockTags = 'tag, tag2'; describe('RunnerFormFields', () => { let wrapper; + const findInputByLabel = (label) => wrapper.findByLabelText(label); const findInput = (name) => wrapper.find(`input[name="${name}"]`); - const createComponent = ({ runner } = {}) => { + const expectRendersFields = () => { + expect(wrapper.text()).toContain(s__('Runners|Tags')); + expect(wrapper.text()).toContain(s__('Runners|Details')); + expect(wrapper.text()).toContain(s__('Runners|Configuration')); + + expect(wrapper.findAllComponents(GlSkeletonLoader)).toHaveLength(0); + expect(wrapper.findAll('input')).toHaveLength(6); + }; + + const createComponent = ({ ...props } = {}) => { wrapper = mountExtended(RunnerFormFields, { propsData: { - value: runner, + ...props, }, }); }; + describe('when runner is loading', () => { + beforeEach(() => { + createComponent({ loading: true }); + }); + + it('renders a loading frame', () => { + expect(wrapper.text()).toContain(s__('Runners|Tags')); + expect(wrapper.text()).toContain(s__('Runners|Details')); + expect(wrapper.text()).toContain(s__('Runners|Configuration')); + + expect(wrapper.findAllComponents(GlSkeletonLoader)).toHaveLength(3); + expect(wrapper.findAll('input')).toHaveLength(0); + }); + + describe('and then is loaded', () => { + beforeEach(() => { + wrapper.setProps({ loading: false, value: { description: mockDescription } }); + }); + + it('renders fields', () => { + expectRendersFields(); + }); + }); + }); + + it('when runner is loaded, renders fields', () => { + createComponent({ + value: { description: mockDescription }, + }); + + expectRendersFields(); + }); + + it('when runner is updated with the same value, only emits when changed (avoids infinite loop)', async () => { + createComponent({ value: null, loading: true }); + await wrapper.setProps({ value: { description: mockDescription }, loading: false }); + await wrapper.setProps({ value: { description: mockDescription }, loading: false }); + + expect(wrapper.emitted('input')).toHaveLength(1); + }); + it('updates runner fields', async () => { - createComponent(); + createComponent({ + value: { description: mockDescription }, + }); expect(wrapper.emitted('input')).toBe(undefined); - findInput('description').setValue(mockDescription); + findInputByLabel(s__('Runners|Runner description')).setValue(mockNewDescription); findInput('max-timeout').setValue(mockMaxTimeout); - findInput('paused').setChecked(true); - findInput('protected').setChecked(true); - findInput('run-untagged').setChecked(true); findInput('tags').setValue(mockTags); await nextTick(); - expect(wrapper.emitted('input')[0][0]).toMatchObject({ - description: mockDescription, - maximumTimeout: mockMaxTimeout, - tagList: mockTags, - }); + expect(wrapper.emitted('input').at(-1)).toEqual([ + { + description: mockNewDescription, + maximumTimeout: mockMaxTimeout, + tagList: mockTags, + }, + ]); }); it('checks checkbox fields', async () => { createComponent({ - runner: { + value: { + runUntagged: false, paused: false, accessLevel: ACCESS_LEVEL_NOT_PROTECTED, - runUntagged: false, }, }); + findInput('run-untagged').setChecked(true); findInput('paused').setChecked(true); findInput('protected').setChecked(true); - findInput('run-untagged').setChecked(true); await nextTick(); - expect(wrapper.emitted('input')[0][0]).toEqual({ - paused: true, - accessLevel: ACCESS_LEVEL_REF_PROTECTED, - runUntagged: true, + expect(wrapper.emitted('input').at(-1)).toEqual([ + { + runUntagged: true, + paused: true, + accessLevel: ACCESS_LEVEL_REF_PROTECTED, + }, + ]); + }); + + it('locked checkbox is not shown', () => { + createComponent(); + + expect(findInput('locked').exists()).toBe(false); + }); + + it('when runner is of project type, locked checkbox can be checked', async () => { + createComponent({ + value: { + runnerType: PROJECT_TYPE, + locked: false, + }, }); + + findInput('locked').setChecked(true); + + await nextTick(); + + expect(wrapper.emitted('input').at(-1)).toEqual([ + { + runnerType: PROJECT_TYPE, + locked: true, + }, + ]); }); it('unchecks checkbox fields', async () => { createComponent({ - runner: { + value: { paused: true, accessLevel: ACCESS_LEVEL_REF_PROTECTED, runUntagged: true, @@ -78,10 +165,12 @@ describe('RunnerFormFields', () => { await nextTick(); - expect(wrapper.emitted('input')[0][0]).toEqual({ - paused: false, - accessLevel: ACCESS_LEVEL_NOT_PROTECTED, - runUntagged: false, - }); + expect(wrapper.emitted('input').at(-1)).toEqual([ + { + paused: false, + accessLevel: ACCESS_LEVEL_NOT_PROTECTED, + runUntagged: false, + }, + ]); }); }); diff --git a/spec/frontend/ci/runner/components/runner_header_spec.js b/spec/frontend/ci/runner/components/runner_header_spec.js index c851966431d..f5091226eaa 100644 --- a/spec/frontend/ci/runner/components/runner_header_spec.js +++ b/spec/frontend/ci/runner/components/runner_header_spec.js @@ -17,6 +17,7 @@ import RunnerStatusBadge from '~/ci/runner/components/runner_status_badge.vue'; import { runnerData } from '../mock_data'; const mockRunner = runnerData.data.runner; +const mockRunnerSha = mockRunner.shortSha; describe('RunnerHeader', () => { let wrapper; @@ -71,7 +72,7 @@ describe('RunnerHeader', () => { }, }); - expect(wrapper.text()).toContain('Runner #99'); + expect(wrapper.text()).toContain(`#99 (${mockRunnerSha})`); }); it('displays the runner locked icon', () => { @@ -100,7 +101,7 @@ describe('RunnerHeader', () => { }, }); - expect(wrapper.text()).toContain('Runner #99'); + expect(wrapper.text()).toContain(`#99 (${mockRunnerSha})`); expect(wrapper.text()).not.toMatch(/created .+/); expect(findTimeAgo().exists()).toBe(false); }); diff --git a/spec/frontend/ci/runner/components/runner_jobs_empty_state_spec.js b/spec/frontend/ci/runner/components/runner_jobs_empty_state_spec.js index 59c9383cb31..b2dfc77bd99 100644 --- a/spec/frontend/ci/runner/components/runner_jobs_empty_state_spec.js +++ b/spec/frontend/ci/runner/components/runner_jobs_empty_state_spec.js @@ -1,4 +1,4 @@ -import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/pipelines_empty.svg?url'; +import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-pipeline-md.svg?url'; import { shallowMount } from '@vue/test-utils'; import { GlEmptyState } from '@gitlab/ui'; diff --git a/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js b/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js index 0de2759ea8a..22797433b58 100644 --- a/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js +++ b/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js @@ -1,27 +1,46 @@ -import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/pipelines_empty.svg?url'; -import FILTERED_SVG_URL from '@gitlab/svgs/dist/illustrations/magnifying-glass.svg?url'; +import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-pipeline-md.svg?url'; +import FILTERED_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-search-md.svg?url'; import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; -import { s__ } from '~/locale'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; - -import { mockRegistrationToken, newRunnerPath } from 'jest/ci/runner/mock_data'; +import { + I18N_GET_STARTED, + I18N_RUNNERS_ARE_AGENTS, + I18N_CREATE_RUNNER_LINK, + I18N_STILL_USING_REGISTRATION_TOKENS, + I18N_CONTACT_ADMIN_TO_REGISTER, + I18N_FOLLOW_REGISTRATION_INSTRUCTIONS, + I18N_NO_RESULTS, + I18N_EDIT_YOUR_SEARCH, +} from '~/ci/runner/constants'; + +import { + mockRegistrationToken, + newRunnerPath as mockNewRunnerPath, +} from 'jest/ci/runner/mock_data'; import RunnerListEmptyState from '~/ci/runner/components/runner_list_empty_state.vue'; describe('RunnerListEmptyState', () => { let wrapper; + let glFeatures; const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findLinks = () => wrapper.findAllComponents(GlLink); const findLink = () => wrapper.findComponent(GlLink); const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal); - const createComponent = ({ props, mountFn = shallowMountExtended, ...options } = {}) => { + const expectTitleToBe = (title) => { + expect(findEmptyState().find('h1').text()).toBe(title); + }; + const expectDescriptionToBe = (sentences) => { + expect(findEmptyState().find('p').text()).toMatchInterpolatedText(sentences.join(' ')); + }; + + const createComponent = ({ props, mountFn = shallowMountExtended } = {}) => { wrapper = mountFn(RunnerListEmptyState, { propsData: { - registrationToken: mockRegistrationToken, - newRunnerPath, ...props, }, directives: { @@ -30,109 +49,146 @@ describe('RunnerListEmptyState', () => { stubs: { GlEmptyState, GlSprintf, - GlLink, }, - ...options, + provide: { glFeatures }, }); }; - describe('when search is not filtered', () => { - const title = s__('Runners|Get started with runners'); + beforeEach(() => { + glFeatures = null; + }); - describe('when there is a registration token', () => { + describe('when search is not filtered', () => { + describe.each([ + { createRunnerWorkflowForAdmin: true }, + { createRunnerWorkflowForNamespace: true }, + ])('when createRunnerWorkflow is enabled by %o', (currentGlFeatures) => { beforeEach(() => { - createComponent(); - }); - - it('renders an illustration', () => { - expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL); - }); - - it('displays "no results" text with instructions', () => { - const desc = s__( - 'Runners|Runners are the agents that run your CI/CD jobs. Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.', - ); - - expect(findEmptyState().text()).toMatchInterpolatedText(`${title} ${desc}`); + glFeatures = currentGlFeatures; }); - describe.each([ - { createRunnerWorkflowForAdmin: true }, - { createRunnerWorkflowForNamespace: true }, - ])('when %o', (glFeatures) => { - describe('when newRunnerPath is defined', () => { + describe.each` + newRunnerPath | registrationToken | expectedMessages + ${mockNewRunnerPath} | ${mockRegistrationToken} | ${[I18N_CREATE_RUNNER_LINK, I18N_STILL_USING_REGISTRATION_TOKENS]} + ${mockNewRunnerPath} | ${null} | ${[I18N_CREATE_RUNNER_LINK]} + ${null} | ${mockRegistrationToken} | ${[I18N_STILL_USING_REGISTRATION_TOKENS]} + ${null} | ${null} | ${[I18N_CONTACT_ADMIN_TO_REGISTER]} + `( + 'when newRunnerPath is $newRunnerPath and registrationToken is $registrationToken', + ({ newRunnerPath, registrationToken, expectedMessages }) => { beforeEach(() => { createComponent({ - provide: { - glFeatures, + props: { + newRunnerPath, + registrationToken, }, }); }); - it('shows a link to the new runner page', () => { - expect(findLink().attributes('href')).toBe(newRunnerPath); + it('shows title', () => { + expectTitleToBe(I18N_GET_STARTED); }); - }); - describe('when newRunnerPath not defined', () => { - beforeEach(() => { - createComponent({ - props: { - newRunnerPath: null, - }, - provide: { - glFeatures, - }, - }); + it('renders an illustration', () => { + expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL); }); - it('opens a runner registration instructions modal with a link', () => { - const { value } = getBinding(findLink().element, 'gl-modal'); + it(`shows description: "${expectedMessages.join(' ')}"`, () => { + expectDescriptionToBe([I18N_RUNNERS_ARE_AGENTS, ...expectedMessages]); + }); + }, + ); - expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); + describe('with newRunnerPath and registration token', () => { + beforeEach(() => { + createComponent({ + props: { + registrationToken: mockRegistrationToken, + newRunnerPath: mockNewRunnerPath, + }, }); }); + + it('shows links to the new runner page and registration instructions', () => { + expect(findLinks().at(0).attributes('href')).toBe(mockNewRunnerPath); + + const { value } = getBinding(findLinks().at(1).element, 'gl-modal'); + expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); + }); }); - describe.each([ - { createRunnerWorkflowForAdmin: false }, - { createRunnerWorkflowForNamespace: false }, - ])('when %o', (glFeatures) => { + describe('with newRunnerPath and no registration token', () => { beforeEach(() => { createComponent({ - provide: { - glFeatures, + props: { + registrationToken: mockRegistrationToken, + newRunnerPath: null, }, }); }); it('opens a runner registration instructions modal with a link', () => { const { value } = getBinding(findLink().element, 'gl-modal'); - expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); }); }); - }); - describe('when there is no registration token', () => { - beforeEach(() => { - createComponent({ props: { registrationToken: null } }); - }); + describe('with no newRunnerPath nor registration token', () => { + beforeEach(() => { + createComponent({ + props: { + registrationToken: null, + newRunnerPath: null, + }, + }); + }); - it('renders an illustration', () => { - expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL); + it('has no link', () => { + expect(findLink().exists()).toBe(false); + }); }); + }); + + describe('when createRunnerWorkflow is disabled', () => { + describe('when there is a registration token', () => { + beforeEach(() => { + createComponent({ + props: { + registrationToken: mockRegistrationToken, + }, + }); + }); + + it('renders an illustration', () => { + expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL); + }); + + it('opens a runner registration instructions modal with a link', () => { + const { value } = getBinding(findLink().element, 'gl-modal'); + expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); + }); - it('displays "no results" text', () => { - const desc = s__( - 'Runners|Runners are the agents that run your CI/CD jobs. To register new runners, please contact your administrator.', - ); + it('displays text with registration instructions', () => { + expectTitleToBe(I18N_GET_STARTED); - expect(findEmptyState().text()).toMatchInterpolatedText(`${title} ${desc}`); + expectDescriptionToBe([I18N_RUNNERS_ARE_AGENTS, I18N_FOLLOW_REGISTRATION_INSTRUCTIONS]); + }); }); - it('has no registration instructions link', () => { - expect(findLink().exists()).toBe(false); + describe('when there is no registration token', () => { + beforeEach(() => { + createComponent({ props: { registrationToken: null } }); + }); + + it('displays "contact admin" text', () => { + expectTitleToBe(I18N_GET_STARTED); + + expectDescriptionToBe([I18N_RUNNERS_ARE_AGENTS, I18N_CONTACT_ADMIN_TO_REGISTER]); + }); + + it('has no registration instructions link', () => { + expect(findLink().exists()).toBe(false); + }); }); }); }); @@ -147,8 +203,9 @@ describe('RunnerListEmptyState', () => { }); it('displays "no filtered results" text', () => { - expect(findEmptyState().text()).toContain(s__('Runners|No results found')); - expect(findEmptyState().text()).toContain(s__('Runners|Edit your search and try again')); + expectTitleToBe(I18N_NO_RESULTS); + + expectDescriptionToBe([I18N_EDIT_YOUR_SEARCH]); }); }); }); diff --git a/spec/frontend/ci/runner/components/runner_list_spec.js b/spec/frontend/ci/runner/components/runner_list_spec.js index 0f4ec717c3e..9da640afeb7 100644 --- a/spec/frontend/ci/runner/components/runner_list_spec.js +++ b/spec/frontend/ci/runner/components/runner_list_spec.js @@ -18,7 +18,6 @@ import { I18N_PROJECT_TYPE, I18N_STATUS_NEVER_CONTACTED } from '~/ci/runner/cons import { allRunnersData, onlineContactTimeoutSecs, staleTimeoutSecs } from '../mock_data'; const mockRunners = allRunnersData.data.runners.nodes; -const mockActiveRunnersCount = mockRunners.length; describe('RunnerList', () => { let wrapper; @@ -44,7 +43,6 @@ describe('RunnerList', () => { apolloProvider: createMockApollo([], {}, cacheConfig), propsData: { runners: mockRunners, - activeRunnersCount: mockActiveRunnersCount, ...props, }, provide: { diff --git a/spec/frontend/ci/runner/components/runner_managers_badge_spec.js b/spec/frontend/ci/runner/components/runner_managers_badge_spec.js new file mode 100644 index 00000000000..185172ba02b --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_managers_badge_spec.js @@ -0,0 +1,57 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerManagersBadge from '~/ci/runner/components/runner_managers_badge.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +const mockCount = 2; + +describe('RunnerTypeBadge', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + const getTooltip = () => getBinding(findBadge()?.element, 'gl-tooltip'); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(RunnerManagersBadge, { + propsData: { + ...props, + }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + }); + }; + + it.each([null, 0, 1])('renders no badge when count is %s', (count) => { + createComponent({ props: { count } }); + + expect(findBadge().exists()).toBe(false); + }); + + it('renders badge with tooltip', () => { + createComponent({ props: { count: mockCount } }); + + expect(findBadge().text()).toBe(`${mockCount}`); + expect(getTooltip().value).toContain(`${mockCount}`); + }); + + it('renders badge with icon and variant', () => { + createComponent({ props: { count: mockCount } }); + + expect(findBadge().props('icon')).toBe('container-image'); + expect(findBadge().props('variant')).toBe('muted'); + }); + + it('renders badge and tooltip with formatted count', () => { + createComponent({ props: { count: 1000 } }); + + expect(findBadge().text()).toBe('1,000'); + expect(getTooltip().value).toContain('1,000'); + }); + + it('passes arbitrary attributes to badge', () => { + createComponent({ props: { count: 2, size: 'sm' } }); + + expect(findBadge().props('size')).toBe('sm'); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_managers_detail_spec.js b/spec/frontend/ci/runner/components/runner_managers_detail_spec.js new file mode 100644 index 00000000000..3435292394f --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_managers_detail_spec.js @@ -0,0 +1,169 @@ +import { GlCollapse, GlSkeletonLoader, GlTableLite } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { __ } from '~/locale'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +import RunnerManagersDetail from '~/ci/runner/components/runner_managers_detail.vue'; +import RunnerManagersTable from '~/ci/runner/components/runner_managers_table.vue'; + +import runnerManagersQuery from '~/ci/runner/graphql/show/runner_managers.query.graphql'; +import { runnerData, runnerManagersData } from '../mock_data'; + +jest.mock('~/alert'); +jest.mock('~/ci/runner/sentry_utils'); + +const mockRunner = runnerData.data.runner; +const mockRunnerManagers = runnerManagersData.data.runner.managers.nodes; + +Vue.use(VueApollo); + +describe('RunnerJobs', () => { + let wrapper; + let mockRunnerManagersHandler; + + const findShowDetails = () => wrapper.findByText(__('Show details')); + const findHideDetails = () => wrapper.findByText(__('Hide details')); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + + const findCollapse = () => wrapper.findComponent(GlCollapse); + const findRunnerManagersTable = () => wrapper.findComponent(RunnerManagersTable); + + const createComponent = ({ props, mountFn = shallowMountExtended } = {}) => { + wrapper = mountFn(RunnerManagersDetail, { + apolloProvider: createMockApollo([[runnerManagersQuery, mockRunnerManagersHandler]]), + propsData: { + runner: mockRunner, + ...props, + }, + stubs: { + GlTableLite, + }, + }); + }; + + beforeEach(() => { + mockRunnerManagersHandler = jest.fn(); + }); + + afterEach(() => { + mockRunnerManagersHandler.mockReset(); + }); + + describe('Runners count', () => { + it.each` + count | expected + ${0} | ${'0'} + ${1} | ${'1'} + ${1000} | ${'1,000'} + `('displays runner managers count of $count', ({ count, expected }) => { + createComponent({ + props: { + runner: { + ...mockRunner, + managers: { + count, + }, + }, + }, + }); + + expect(wrapper.text()).toContain(expected); + }); + }); + + describe('Expand and collapse', () => { + beforeEach(() => { + createComponent(); + }); + + it('shows link to expand', () => { + expect(findShowDetails().exists()).toBe(true); + expect(findHideDetails().exists()).toBe(false); + }); + + it('is collapsed', () => { + expect(findCollapse().attributes('visible')).toBeUndefined(); + }); + + describe('when expanded', () => { + beforeEach(() => { + findShowDetails().vm.$emit('click'); + }); + + it('shows link to collapse', () => { + expect(findShowDetails().exists()).toBe(false); + expect(findHideDetails().exists()).toBe(true); + }); + + it('shows loading state', () => { + expect(findCollapse().attributes('visible')).toBe('true'); + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('fetches data', () => { + expect(mockRunnerManagersHandler).toHaveBeenCalledTimes(1); + expect(mockRunnerManagersHandler).toHaveBeenCalledWith({ + runnerId: mockRunner.id, + }); + }); + }); + }); + + describe('Prefetches data upon user interation', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('does not fetch initially', () => { + expect(mockRunnerManagersHandler).not.toHaveBeenCalled(); + }); + + describe.each(['focus', 'mouseover'])('fetches data after %s', (event) => { + beforeEach(() => { + findShowDetails().vm.$emit(event); + }); + + it('fetches data', () => { + expect(mockRunnerManagersHandler).toHaveBeenCalledTimes(1); + expect(mockRunnerManagersHandler).toHaveBeenCalledWith({ + runnerId: mockRunner.id, + }); + }); + + it('fetches data only once', async () => { + findShowDetails().vm.$emit(event); + await waitForPromises(); + + expect(mockRunnerManagersHandler).toHaveBeenCalledTimes(1); + expect(mockRunnerManagersHandler).toHaveBeenCalledWith({ + runnerId: mockRunner.id, + }); + }); + }); + }); + + describe('Shows data', () => { + beforeEach(async () => { + mockRunnerManagersHandler.mockResolvedValue(runnerManagersData); + + createComponent({ mountFn: mountExtended }); + + await findShowDetails().trigger('click'); + }); + + it('shows rows', () => { + expect(findCollapse().attributes('visible')).toBe('true'); + expect(findRunnerManagersTable().props('items')).toEqual(mockRunnerManagers); + }); + + it('collapses when clicked', async () => { + await findHideDetails().trigger('click'); + + expect(findCollapse().attributes('visible')).toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_managers_table_spec.js b/spec/frontend/ci/runner/components/runner_managers_table_spec.js new file mode 100644 index 00000000000..cde6ee6eea0 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_managers_table_spec.js @@ -0,0 +1,144 @@ +import { GlTableLite } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; + +import RunnerManagersTable from '~/ci/runner/components/runner_managers_table.vue'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { I18N_STATUS_NEVER_CONTACTED } from '~/ci/runner/constants'; + +import { runnerManagersData } from '../mock_data'; + +jest.mock('~/alert'); +jest.mock('~/ci/runner/sentry_utils'); + +const mockItems = runnerManagersData.data.runner.managers.nodes; + +describe('RunnerJobs', () => { + let wrapper; + + const findHeaders = () => wrapper.findAll('thead th'); + const findRows = () => wrapper.findAll('tbody tr'); + const findCell = ({ field, i }) => extendedWrapper(findRows().at(i)).findByTestId(`td-${field}`); + const findCellText = (opts) => findCell(opts).text().replace(/\s+/g, ' '); + + const createComponent = ({ item } = {}) => { + const [mockItem, ...otherItems] = mockItems; + + wrapper = mountExtended(RunnerManagersTable, { + propsData: { + items: [{ ...mockItem, ...item }, ...otherItems], + }, + stubs: { + GlTableLite, + }, + }); + }; + + it('shows headers', () => { + createComponent(); + expect(findHeaders().wrappers.map((w) => w.text())).toEqual([ + expect.stringContaining(s__('Runners|System ID')), + s__('Runners|Status'), + s__('Runners|Version'), + s__('Runners|IP Address'), + s__('Runners|Executor'), + s__('Runners|Arch/Platform'), + s__('Runners|Last contact'), + ]); + }); + + it('shows rows', () => { + createComponent(); + expect(findRows()).toHaveLength(2); + }); + + it('shows system id', () => { + createComponent(); + expect(findCellText({ field: 'systemId', i: 0 })).toBe(mockItems[0].systemId); + expect(findCellText({ field: 'systemId', i: 1 })).toBe(mockItems[1].systemId); + }); + + it('shows status', () => { + createComponent(); + expect(findCellText({ field: 'status', i: 0 })).toBe(s__('Runners|Online')); + expect(findCellText({ field: 'status', i: 1 })).toBe(s__('Runners|Online')); + }); + + it('shows version', () => { + createComponent({ + item: { version: '1.0' }, + }); + + expect(findCellText({ field: 'version', i: 0 })).toBe('1.0'); + }); + + it('shows version with revision', () => { + createComponent({ + item: { version: '1.0', revision: '123456' }, + }); + + expect(findCellText({ field: 'version', i: 0 })).toBe('1.0 (123456)'); + }); + + it('shows revision without version', () => { + createComponent({ + item: { version: null, revision: '123456' }, + }); + + expect(findCellText({ field: 'version', i: 0 })).toBe('(123456)'); + }); + + it('shows ip address', () => { + createComponent({ + item: { ipAddress: '127.0.0.1' }, + }); + + expect(findCellText({ field: 'ipAddress', i: 0 })).toBe('127.0.0.1'); + }); + + it('shows executor', () => { + createComponent({ + item: { executorName: 'shell' }, + }); + + expect(findCellText({ field: 'executorName', i: 0 })).toBe('shell'); + }); + + it('shows architecture', () => { + createComponent({ + item: { architectureName: 'x64' }, + }); + + expect(findCellText({ field: 'architecturePlatform', i: 0 })).toBe('x64'); + }); + + it('shows platform', () => { + createComponent({ + item: { platformName: 'darwin' }, + }); + + expect(findCellText({ field: 'architecturePlatform', i: 0 })).toBe('darwin'); + }); + + it('shows architecture and platform', () => { + createComponent({ + item: { architectureName: 'x64', platformName: 'darwin' }, + }); + + expect(findCellText({ field: 'architecturePlatform', i: 0 })).toBe('x64/darwin'); + }); + + it('shows contacted at', () => { + createComponent(); + expect(findCell({ field: 'contactedAt', i: 0 }).findComponent(TimeAgo).props('time')).toBe( + mockItems[0].contactedAt, + ); + }); + + it('shows missing contacted at', () => { + createComponent({ + item: { contactedAt: null }, + }); + expect(findCellText({ field: 'contactedAt', i: 0 })).toBe(I18N_STATUS_NEVER_CONTACTED); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_pause_button_spec.js b/spec/frontend/ci/runner/components/runner_pause_button_spec.js index 350d029f3fc..1ea870e004a 100644 --- a/spec/frontend/ci/runner/components/runner_pause_button_spec.js +++ b/spec/frontend/ci/runner/components/runner_pause_button_spec.js @@ -4,7 +4,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; -import runnerToggleActiveMutation from '~/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql'; +import runnerTogglePausedMutation from '~/ci/runner/graphql/shared/runner_toggle_paused.mutation.graphql'; import waitForPromises from 'helpers/wait_for_promises'; import { captureException } from '~/ci/runner/sentry_utils'; import { createAlert } from '~/alert'; @@ -27,7 +27,7 @@ jest.mock('~/ci/runner/sentry_utils'); describe('RunnerPauseButton', () => { let wrapper; - let runnerToggleActiveHandler; + let runnerTogglePausedHandler; const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value; const findBtn = () => wrapper.findComponent(GlButton); @@ -39,12 +39,12 @@ describe('RunnerPauseButton', () => { propsData: { runner: { id: mockRunner.id, - active: mockRunner.active, + paused: mockRunner.paused, ...runner, }, ...propsData, }, - apolloProvider: createMockApollo([[runnerToggleActiveMutation, runnerToggleActiveHandler]]), + apolloProvider: createMockApollo([[runnerTogglePausedMutation, runnerTogglePausedHandler]]), directives: { GlTooltip: createMockDirective('gl-tooltip'), }, @@ -57,13 +57,13 @@ describe('RunnerPauseButton', () => { }; beforeEach(() => { - runnerToggleActiveHandler = jest.fn().mockImplementation(({ input }) => { + runnerTogglePausedHandler = jest.fn().mockImplementation(({ input }) => { return Promise.resolve({ data: { runnerUpdate: { runner: { id: input.id, - active: input.active, + paused: !input.paused, }, errors: [], }, @@ -76,15 +76,15 @@ describe('RunnerPauseButton', () => { describe('Pause/Resume action', () => { describe.each` - runnerState | icon | content | tooltip | isActive | newActiveValue - ${'paused'} | ${'play'} | ${I18N_RESUME} | ${I18N_RESUME_TOOLTIP} | ${false} | ${true} - ${'active'} | ${'pause'} | ${I18N_PAUSE} | ${I18N_PAUSE_TOOLTIP} | ${true} | ${false} - `('When the runner is $runnerState', ({ icon, content, tooltip, isActive, newActiveValue }) => { + runnerState | icon | content | tooltip | isPaused | newPausedValue + ${'paused'} | ${'play'} | ${I18N_RESUME} | ${I18N_RESUME_TOOLTIP} | ${true} | ${false} + ${'active'} | ${'pause'} | ${I18N_PAUSE} | ${I18N_PAUSE_TOOLTIP} | ${false} | ${true} + `('When the runner is $runnerState', ({ icon, content, tooltip, isPaused, newPausedValue }) => { beforeEach(() => { createComponent({ props: { runner: { - active: isActive, + paused: isPaused, }, }, }); @@ -106,7 +106,7 @@ describe('RunnerPauseButton', () => { describe(`Before the ${icon} button is clicked`, () => { it('The mutation has not been called', () => { - expect(runnerToggleActiveHandler).toHaveBeenCalledTimes(0); + expect(runnerTogglePausedHandler).not.toHaveBeenCalled(); }); }); @@ -134,12 +134,12 @@ describe('RunnerPauseButton', () => { await clickAndWait(); }); - it(`The mutation to that sets active to ${newActiveValue} is called`, () => { - expect(runnerToggleActiveHandler).toHaveBeenCalledTimes(1); - expect(runnerToggleActiveHandler).toHaveBeenCalledWith({ + it(`The mutation to that sets "paused" to ${newPausedValue} is called`, () => { + expect(runnerTogglePausedHandler).toHaveBeenCalledTimes(1); + expect(runnerTogglePausedHandler).toHaveBeenCalledWith({ input: { id: mockRunner.id, - active: newActiveValue, + paused: newPausedValue, }, }); }); @@ -158,7 +158,7 @@ describe('RunnerPauseButton', () => { const mockErrorMsg = 'Update error!'; beforeEach(async () => { - runnerToggleActiveHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); + runnerTogglePausedHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); await clickAndWait(); }); @@ -180,12 +180,12 @@ describe('RunnerPauseButton', () => { const mockErrorMsg2 = 'User not allowed!'; beforeEach(async () => { - runnerToggleActiveHandler.mockResolvedValueOnce({ + runnerTogglePausedHandler.mockResolvedValueOnce({ data: { runnerUpdate: { runner: { id: mockRunner.id, - active: isActive, + paused: isPaused, }, errors: [mockErrorMsg, mockErrorMsg2], }, @@ -215,7 +215,7 @@ describe('RunnerPauseButton', () => { createComponent({ props: { runner: { - active: true, + paused: false, }, compact: true, }, diff --git a/spec/frontend/ci/runner/components/runner_status_badge_spec.js b/spec/frontend/ci/runner/components/runner_status_badge_spec.js index e1eb81f2d23..781193d8afa 100644 --- a/spec/frontend/ci/runner/components/runner_status_badge_spec.js +++ b/spec/frontend/ci/runner/components/runner_status_badge_spec.js @@ -21,13 +21,11 @@ describe('RunnerTypeBadge', () => { const findBadge = () => wrapper.findComponent(GlBadge); const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip'); - const createComponent = (props = {}) => { + const createComponent = ({ props = {} } = {}) => { wrapper = shallowMount(RunnerStatusBadge, { propsData: { - runner: { - contactedAt: '2020-12-31T23:59:00Z', - status: STATUS_ONLINE, - }, + contactedAt: '2020-12-31T23:59:00Z', + status: STATUS_ONLINE, ...props, }, directives: { @@ -55,7 +53,7 @@ describe('RunnerTypeBadge', () => { it('renders never contacted state', () => { createComponent({ - runner: { + props: { contactedAt: null, status: STATUS_NEVER_CONTACTED, }, @@ -68,7 +66,7 @@ describe('RunnerTypeBadge', () => { it('renders offline state', () => { createComponent({ - runner: { + props: { contactedAt: '2020-12-31T00:00:00Z', status: STATUS_OFFLINE, }, @@ -81,7 +79,7 @@ describe('RunnerTypeBadge', () => { it('renders stale state', () => { createComponent({ - runner: { + props: { contactedAt: '2020-01-01T00:00:00Z', status: STATUS_STALE, }, @@ -94,7 +92,7 @@ describe('RunnerTypeBadge', () => { it('renders stale state with no contact time', () => { createComponent({ - runner: { + props: { contactedAt: null, status: STATUS_STALE, }, @@ -108,7 +106,7 @@ describe('RunnerTypeBadge', () => { describe('does not fail when data is missing', () => { it('contacted_at is missing', () => { createComponent({ - runner: { + props: { contactedAt: null, status: STATUS_ONLINE, }, @@ -120,7 +118,7 @@ describe('RunnerTypeBadge', () => { it('status is missing', () => { createComponent({ - runner: { + props: { status: null, }, }); diff --git a/spec/frontend/ci/runner/components/runner_update_form_spec.js b/spec/frontend/ci/runner/components/runner_update_form_spec.js index db4c236bfff..5851078a8d3 100644 --- a/spec/frontend/ci/runner/components/runner_update_form_spec.js +++ b/spec/frontend/ci/runner/components/runner_update_form_spec.js @@ -1,20 +1,17 @@ -import Vue, { nextTick } from 'vue'; -import { GlForm, GlSkeletonLoader } from '@gitlab/ui'; +import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { GlForm } from '@gitlab/ui'; import { __ } from '~/locale'; +import { createAlert, VARIANT_SUCCESS } from '~/alert'; +import { visitUrl } from '~/lib/utils/url_utility'; + import createMockApollo from 'helpers/mock_apollo_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { createAlert, VARIANT_SUCCESS } from '~/alert'; -import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated + +import { runnerToModel } from 'ee_else_ce/ci/runner/runner_update_form_utils'; +import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue'; import RunnerUpdateForm from '~/ci/runner/components/runner_update_form.vue'; -import { - INSTANCE_TYPE, - GROUP_TYPE, - PROJECT_TYPE, - ACCESS_LEVEL_REF_PROTECTED, - ACCESS_LEVEL_NOT_PROTECTED, -} from '~/ci/runner/constants'; import runnerUpdateMutation from '~/ci/runner/graphql/edit/runner_update.mutation.graphql'; import { captureException } from '~/ci/runner/sentry_utils'; import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_alert_to_local_storage'; @@ -23,7 +20,10 @@ import { runnerFormData } from '../mock_data'; jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage'); jest.mock('~/alert'); jest.mock('~/ci/runner/sentry_utils'); -jest.mock('~/lib/utils/url_utility'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + visitUrl: jest.fn(), +})); const mockRunner = runnerFormData.data.runner; const mockRunnerPath = '/admin/runners/1'; @@ -35,16 +35,7 @@ describe('RunnerUpdateForm', () => { let runnerUpdateHandler; const findForm = () => wrapper.findComponent(GlForm); - const findPausedCheckbox = () => wrapper.findByTestId('runner-field-paused'); - const findProtectedCheckbox = () => wrapper.findByTestId('runner-field-protected'); - const findRunUntaggedCheckbox = () => wrapper.findByTestId('runner-field-run-untagged'); - const findLockedCheckbox = () => wrapper.findByTestId('runner-field-locked'); - const findFields = () => wrapper.findAll('[data-testid^="runner-field"'); - - const findDescriptionInput = () => wrapper.findByTestId('runner-field-description').find('input'); - const findMaxJobTimeoutInput = () => - wrapper.findByTestId('runner-field-max-timeout').find('input'); - const findTagsInput = () => wrapper.findByTestId('runner-field-tags').find('input'); + const findRunnerFormFields = () => wrapper.findComponent(RunnerFormFields); const findSubmit = () => wrapper.find('[type="submit"]'); const findSubmitDisabledAttr = () => findSubmit().attributes('disabled'); @@ -52,21 +43,10 @@ describe('RunnerUpdateForm', () => { const submitForm = () => findForm().trigger('submit'); const submitFormAndWait = () => submitForm().then(waitForPromises); - const getFieldsModel = () => ({ - active: !findPausedCheckbox().element.checked, - accessLevel: findProtectedCheckbox().element.checked - ? ACCESS_LEVEL_REF_PROTECTED - : ACCESS_LEVEL_NOT_PROTECTED, - runUntagged: findRunUntaggedCheckbox().element.checked, - locked: findLockedCheckbox().element?.checked || false, - maximumTimeout: findMaxJobTimeoutInput().element.value || null, - tagList: findTagsInput().element.value.split(',').filter(Boolean), - }); - const createComponent = ({ props } = {}) => { wrapper = mountExtended(RunnerUpdateForm, { propsData: { - runner: mockRunner, + runner: null, runnerPath: mockRunnerPath, ...props, }, @@ -86,7 +66,7 @@ describe('RunnerUpdateForm', () => { variant: VARIANT_SUCCESS, }), ); - expect(redirectTo).toHaveBeenCalledWith(mockRunnerPath); // eslint-disable-line import/no-deprecated + expect(visitUrl).toHaveBeenCalledWith(mockRunnerPath); }; beforeEach(() => { @@ -103,141 +83,82 @@ describe('RunnerUpdateForm', () => { }, }); }); + }); + it('form has fields, submit and cancel buttons', () => { createComponent(); - }); - it('Form has a submit button', () => { + expect(findRunnerFormFields().exists()).toBe(true); expect(findSubmit().exists()).toBe(true); - }); - - it('Form fields match data', () => { - expect(mockRunner).toMatchObject(getFieldsModel()); - }); - - it('Form shows a cancel button', () => { - expect(runnerUpdateHandler).not.toHaveBeenCalled(); expect(findCancelBtn().attributes('href')).toBe(mockRunnerPath); }); - it('Form prevent multiple submissions', async () => { - await submitForm(); - - expect(findSubmitDisabledAttr()).toBe('disabled'); - }); - - it('Updates runner with no changes', async () => { - await submitFormAndWait(); - - // Some read-only fields are not submitted - const { __typename, shortSha, runnerType, createdAt, status, ...submitted } = mockRunner; - - expectToHaveSubmittedRunnerContaining(submitted); - }); - describe('When data is being loaded', () => { beforeEach(() => { createComponent({ props: { loading: true } }); }); - it('Form skeleton is shown', () => { - expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true); - expect(findFields()).toHaveLength(0); + it('form has no runner', () => { + expect(findRunnerFormFields().props('value')).toBe(null); }); - it('Form cannot be submitted', () => { + it('form cannot be submitted', () => { expect(findSubmit().props('loading')).toBe(true); }); + }); + + describe('When runner has loaded', () => { + beforeEach(async () => { + createComponent({ props: { loading: true } }); - it('Form is updated when data loads', async () => { - wrapper.setProps({ + await wrapper.setProps({ loading: false, + runner: mockRunner, }); - - await nextTick(); - - expect(findFields()).not.toHaveLength(0); - expect(mockRunner).toMatchObject(getFieldsModel()); }); - }); - it.each` - runnerType | exists | outcome - ${INSTANCE_TYPE} | ${false} | ${'hidden'} - ${GROUP_TYPE} | ${false} | ${'hidden'} - ${PROJECT_TYPE} | ${true} | ${'shown'} - `(`When runner is $runnerType, locked field is $outcome`, ({ runnerType, exists }) => { - const runner = { ...mockRunner, runnerType }; - createComponent({ props: { runner } }); + it('shows runner fields', () => { + expect(findRunnerFormFields().props('value')).toEqual(runnerToModel(mockRunner)); + }); - expect(findLockedCheckbox().exists()).toBe(exists); - }); + it('form has not been submitted', () => { + expect(runnerUpdateHandler).not.toHaveBeenCalled(); + }); - describe('On submit, runner gets updated', () => { - it.each` - test | initialValue | findCheckbox | checked | submitted - ${'pauses'} | ${{ active: true }} | ${findPausedCheckbox} | ${true} | ${{ active: false }} - ${'activates'} | ${{ active: false }} | ${findPausedCheckbox} | ${false} | ${{ active: true }} - ${'unprotects'} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED }} | ${findProtectedCheckbox} | ${true} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED }} - ${'protects'} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED }} | ${findProtectedCheckbox} | ${false} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED }} - ${'"runs untagged jobs"'} | ${{ runUntagged: true }} | ${findRunUntaggedCheckbox} | ${false} | ${{ runUntagged: false }} - ${'"runs tagged jobs"'} | ${{ runUntagged: false }} | ${findRunUntaggedCheckbox} | ${true} | ${{ runUntagged: true }} - ${'locks'} | ${{ runnerType: PROJECT_TYPE, locked: true }} | ${findLockedCheckbox} | ${false} | ${{ locked: false }} - ${'unlocks'} | ${{ runnerType: PROJECT_TYPE, locked: false }} | ${findLockedCheckbox} | ${true} | ${{ locked: true }} - `('Checkbox $test runner', async ({ initialValue, findCheckbox, checked, submitted }) => { - const runner = { ...mockRunner, ...initialValue }; - createComponent({ props: { runner } }); - - await findCheckbox().setChecked(checked); - await submitFormAndWait(); + it('Form prevents multiple submissions', async () => { + await submitForm(); - expectToHaveSubmittedRunnerContaining({ - id: runner.id, - ...submitted, - }); + expect(findSubmitDisabledAttr()).toBe('disabled'); }); - it.each` - test | initialValue | findInput | value | submitted - ${'description'} | ${{ description: 'Desc. 1' }} | ${findDescriptionInput} | ${'Desc. 2'} | ${{ description: 'Desc. 2' }} - ${'max timeout'} | ${{ maximumTimeout: 36000 }} | ${findMaxJobTimeoutInput} | ${'40000'} | ${{ maximumTimeout: 40000 }} - ${'tags'} | ${{ tagList: ['tag1'] }} | ${findTagsInput} | ${'tag2, tag3'} | ${{ tagList: ['tag2', 'tag3'] }} - `("Field updates runner's $test", async ({ initialValue, findInput, value, submitted }) => { - const runner = { ...mockRunner, ...initialValue }; - createComponent({ props: { runner } }); - - await findInput().setValue(value); + it('Updates runner with no changes', async () => { await submitFormAndWait(); - expectToHaveSubmittedRunnerContaining({ - id: runner.id, - ...submitted, - }); + // Some read-only fields are not submitted + const { __typename, shortSha, runnerType, createdAt, status, ...submitted } = mockRunner; + + expectToHaveSubmittedRunnerContaining(submitted); }); - it.each` - value | submitted - ${''} | ${{ tagList: [] }} - ${'tag1, tag2'} | ${{ tagList: ['tag1', 'tag2'] }} - ${'with spaces'} | ${{ tagList: ['with spaces'] }} - ${'more ,,,,, commas'} | ${{ tagList: ['more', 'commas'] }} - `('Field updates runner\'s tags for "$value"', async ({ value, submitted }) => { - const runner = { ...mockRunner, tagList: ['tag1'] }; - createComponent({ props: { runner } }); - - await findTagsInput().setValue(value); + it('Updates runner with changes', async () => { + findRunnerFormFields().vm.$emit( + 'input', + runnerToModel({ ...mockRunner, description: 'A new description' }), + ); await submitFormAndWait(); - expectToHaveSubmittedRunnerContaining({ - id: runner.id, - ...submitted, - }); + expectToHaveSubmittedRunnerContaining({ description: 'A new description' }); }); }); describe('On error', () => { - beforeEach(() => { + beforeEach(async () => { createComponent(); + + await wrapper.setProps({ + loading: false, + runner: mockRunner, + }); }); it('On network error, error message is shown', async () => { @@ -278,7 +199,7 @@ describe('RunnerUpdateForm', () => { expect(captureException).not.toHaveBeenCalled(); expect(saveAlertToLocalStorage).not.toHaveBeenCalled(); - expect(redirectTo).not.toHaveBeenCalled(); // eslint-disable-line import/no-deprecated + expect(visitUrl).not.toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js b/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js index 1c052b00fc3..177fd9bcd9a 100644 --- a/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js +++ b/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js @@ -16,7 +16,7 @@ import { WINDOWS_PLATFORM, } from '~/ci/runner/constants'; import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue'; -import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { visitUrl } from '~/lib/utils/url_utility'; import { runnerCreateResult } from '../mock_data'; const mockGroupId = 'gid://gitlab/Group/72'; @@ -25,7 +25,7 @@ jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage'); jest.mock('~/alert'); jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), - redirectTo: jest.fn(), + visitUrl: jest.fn(), })); const mockCreatedRunner = runnerCreateResult.data.runnerCreate.runner; @@ -92,7 +92,7 @@ describe('GroupRunnerRunnerApp', () => { it('redirects to the registration page', () => { const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${DEFAULT_PLATFORM}`; - expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated + expect(visitUrl).toHaveBeenCalledWith(url); }); }); @@ -105,7 +105,7 @@ describe('GroupRunnerRunnerApp', () => { it('redirects to the registration page with the platform', () => { const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`; - expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated + expect(visitUrl).toHaveBeenCalledWith(url); }); }); diff --git a/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js b/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js index 0c594e8005c..120388900b5 100644 --- a/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js +++ b/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js @@ -5,7 +5,7 @@ import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_help import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert, VARIANT_SUCCESS } from '~/alert'; -import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { visitUrl } from '~/lib/utils/url_utility'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerHeader from '~/ci/runner/components/runner_header.vue'; @@ -26,11 +26,15 @@ import { runnerData } from '../mock_data'; jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage'); jest.mock('~/alert'); jest.mock('~/ci/runner/sentry_utils'); -jest.mock('~/lib/utils/url_utility'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + visitUrl: jest.fn(), +})); const mockRunner = runnerData.data.runner; const mockRunnerGraphqlId = mockRunner.id; const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; +const mockRunnerSha = mockRunner.shortSha; const mockRunnersPath = '/groups/group1/-/runners'; const mockEditGroupRunnerPath = `/groups/group1/-/runners/${mockRunnerId}/edit`; @@ -88,7 +92,7 @@ describe('GroupRunnerShowApp', () => { }); it('displays the runner header', () => { - expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`); + expect(findRunnerHeader().text()).toContain(`#${mockRunnerId} (${mockRunnerSha})`); }); it('displays the runner edit and pause buttons', () => { @@ -185,7 +189,7 @@ describe('GroupRunnerShowApp', () => { message: 'Runner deleted', variant: VARIANT_SUCCESS, }); - expect(redirectTo).toHaveBeenCalledWith(mockRunnersPath); // eslint-disable-line import/no-deprecated + expect(visitUrl).toHaveBeenCalledWith(mockRunnersPath); }); }); }); diff --git a/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js index 41be72b1645..74eeb864cd8 100644 --- a/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js @@ -82,6 +82,7 @@ jest.mock('~/lib/utils/url_utility', () => ({ describe('GroupRunnersApp', () => { let wrapper; + const showToast = jest.fn(); const findRunnerStats = () => wrapper.findComponent(RunnerStats); const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell); @@ -123,6 +124,11 @@ describe('GroupRunnersApp', () => { staleTimeoutSecs, ...provide, }, + mocks: { + $toast: { + show: showToast, + }, + }, ...options, }); @@ -250,8 +256,6 @@ describe('GroupRunnersApp', () => { }); describe('Single runner row', () => { - let showToast; - const { webUrl, editUrl, node } = mockGroupRunnersEdges[0]; const { id: graphqlId, shortSha, jobExecutionStatus } = node; const id = getIdFromGraphQLId(graphqlId); @@ -260,7 +264,6 @@ describe('GroupRunnersApp', () => { beforeEach(async () => { await createComponent({ mountFn: mountExtended }); - showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show'); }); it('Shows job status and links to jobs', () => { diff --git a/spec/frontend/ci/runner/mock_data.js b/spec/frontend/ci/runner/mock_data.js index 223a156795c..d72f93ad574 100644 --- a/spec/frontend/ci/runner/mock_data.js +++ b/spec/frontend/ci/runner/mock_data.js @@ -18,6 +18,7 @@ import runnerData from 'test_fixtures/graphql/ci/runner/show/runner.query.graphq import runnerWithGroupData from 'test_fixtures/graphql/ci/runner/show/runner.query.graphql.with_group.json'; import runnerProjectsData from 'test_fixtures/graphql/ci/runner/show/runner_projects.query.graphql.json'; import runnerJobsData from 'test_fixtures/graphql/ci/runner/show/runner_jobs.query.graphql.json'; +import runnerManagersData from 'test_fixtures/graphql/ci/runner/show/runner_managers.query.graphql.json'; // Edit runner queries import runnerFormData from 'test_fixtures/graphql/ci/runner/edit/runner_form.query.graphql.json'; @@ -336,6 +337,7 @@ export { runnerWithGroupData, runnerProjectsData, runnerJobsData, + runnerManagersData, runnerFormData, runnerCreateResult, runnerForRegistration, diff --git a/spec/frontend/ci/runner/project_new_runner_app/project_new_runner_app_spec.js b/spec/frontend/ci/runner/project_new_runner_app/project_new_runner_app_spec.js index 5bfbbfdc074..22d8e243f7b 100644 --- a/spec/frontend/ci/runner/project_new_runner_app/project_new_runner_app_spec.js +++ b/spec/frontend/ci/runner/project_new_runner_app/project_new_runner_app_spec.js @@ -16,7 +16,7 @@ import { WINDOWS_PLATFORM, } from '~/ci/runner/constants'; import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue'; -import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { visitUrl } from '~/lib/utils/url_utility'; import { runnerCreateResult, mockRegistrationToken } from '../mock_data'; const mockProjectId = 'gid://gitlab/Project/72'; @@ -25,7 +25,7 @@ jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage'); jest.mock('~/alert'); jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), - redirectTo: jest.fn(), + visitUrl: jest.fn(), })); const mockCreatedRunner = runnerCreateResult.data.runnerCreate.runner; @@ -93,7 +93,7 @@ describe('ProjectRunnerRunnerApp', () => { it('redirects to the registration page', () => { const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${DEFAULT_PLATFORM}`; - expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated + expect(visitUrl).toHaveBeenCalledWith(url); }); }); @@ -106,7 +106,7 @@ describe('ProjectRunnerRunnerApp', () => { it('redirects to the registration page with the platform', () => { const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`; - expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated + expect(visitUrl).toHaveBeenCalledWith(url); }); }); diff --git a/spec/frontend/ci/runner/runner_edit/runner_edit_app_spec.js b/spec/frontend/ci/runner/runner_edit/runner_edit_app_spec.js index 79bbf95f8f0..ee4bd9ccc92 100644 --- a/spec/frontend/ci/runner/runner_edit/runner_edit_app_spec.js +++ b/spec/frontend/ci/runner/runner_edit/runner_edit_app_spec.js @@ -21,6 +21,7 @@ jest.mock('~/ci/runner/sentry_utils'); const mockRunner = runnerFormData.data.runner; const mockRunnerGraphqlId = mockRunner.id; const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; +const mockRunnerSha = mockRunner.shortSha; const mockRunnerPath = `/admin/runners/${mockRunnerId}`; Vue.use(VueApollo); @@ -62,7 +63,7 @@ describe('RunnerEditApp', () => { it('displays the runner id and creation date', async () => { await createComponentWithApollo({ mountFn: mount }); - expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`); + expect(findRunnerHeader().text()).toContain(`#${mockRunnerId} (${mockRunnerSha})`); expect(findRunnerHeader().text()).toContain('created'); }); diff --git a/spec/frontend/ci/runner/runner_update_form_utils_spec.js b/spec/frontend/ci/runner/runner_update_form_utils_spec.js index b2f7bbc49a9..80c492bb431 100644 --- a/spec/frontend/ci/runner/runner_update_form_utils_spec.js +++ b/spec/frontend/ci/runner/runner_update_form_utils_spec.js @@ -12,7 +12,7 @@ const mockRunner = { description: mockDescription, maximumTimeout: 100, accessLevel: ACCESS_LEVEL_NOT_PROTECTED, - active: true, + paused: false, locked: true, runUntagged: true, tagList: ['tag-1', 'tag-2'], @@ -79,7 +79,7 @@ describe('~/ci/runner/runner_update_form_utils', () => { ${',,,,, commas'} | ${['commas']} ${'more ,,,,, commas'} | ${['more', 'commas']} ${' trimmed , trimmed2 '} | ${['trimmed', 'trimmed2']} - `('collect tags separated by commas for "$value"', ({ tagList, tagListInput }) => { + `('collect comma-separated tags "$tagList" as $tagListInput', ({ tagList, tagListInput }) => { const variables = modelToUpdateMutationVariables({ ...mockModel, tagList, diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js index 0f68a69458e..71a56eba22a 100644 --- a/spec/frontend/clusters_list/components/agent_table_spec.js +++ b/spec/frontend/clusters_list/components/agent_table_spec.js @@ -1,4 +1,4 @@ -import { GlLink, GlIcon } from '@gitlab/ui'; +import { GlLink, GlIcon, GlBadge, GlTable, GlPagination } from '@gitlab/ui'; import { sprintf } from '~/locale'; import AgentTable from '~/clusters_list/components/agent_table.vue'; import DeleteAgentButton from '~/clusters_list/components/delete_agent_button.vue'; @@ -17,6 +17,7 @@ const provideData = { }; const defaultProps = { agents: clusterAgents, + maxAgents: null, }; const DeleteAgentButtonStub = stubComponent(DeleteAgentButton, { @@ -39,7 +40,11 @@ describe('AgentTable', () => { const findAgentId = (at) => wrapper.findAllByTestId('cluster-agent-id').at(at); const findConfiguration = (at) => wrapper.findAllByTestId('cluster-agent-configuration-link').at(at); - const findDeleteAgentButton = () => wrapper.findAllComponents(DeleteAgentButton); + const findDeleteAgentButtons = () => wrapper.findAllComponents(DeleteAgentButton); + const findTableRow = (at) => wrapper.findComponent(GlTable).find('tbody').findAll('tr').at(at); + const findSharedBadgeByRow = (at) => findTableRow(at).findComponent(GlBadge); + const findDeleteAgentButtonByRow = (at) => findTableRow(at).findComponent(DeleteAgentButton); + const findPagination = () => wrapper.findComponent(GlPagination); const createWrapper = ({ provide = provideData, propsData = defaultProps } = {}) => { wrapper = mountExtended(AgentTable, { @@ -64,6 +69,11 @@ describe('AgentTable', () => { `('displays agent link for $agentName', ({ agentName, link, lineNumber }) => { expect(findAgentLink(lineNumber).text()).toBe(agentName); expect(findAgentLink(lineNumber).attributes('href')).toBe(link); + expect(findSharedBadgeByRow(lineNumber).exists()).toBe(false); + }); + + it('displays "shared" badge if the agent is shared', () => { + expect(findSharedBadgeByRow(9).text()).toBe(I18N_AGENT_TABLE.sharedBadgeText); }); it.each` @@ -116,8 +126,9 @@ describe('AgentTable', () => { }, ); - it('displays actions menu for each agent', () => { - expect(findDeleteAgentButton()).toHaveLength(clusterAgents.length); + it('displays actions menu for each agent except the shared agents', () => { + expect(findDeleteAgentButtons()).toHaveLength(clusterAgents.length - 1); + expect(findDeleteAgentButtonByRow(9).exists()).toBe(false); }); }); @@ -132,6 +143,7 @@ describe('AgentTable', () => { ${6} | ${'14.8.0'} | ${'15.0.0'} | ${false} | ${true} | ${outdatedTitle} ${7} | ${'14.8.0'} | ${'15.0.0-rc1'} | ${false} | ${true} | ${outdatedTitle} ${8} | ${'14.8.0'} | ${'14.8.10'} | ${false} | ${false} | ${''} + ${9} | ${''} | ${'14.8.0'} | ${false} | ${false} | ${''} `( 'when agent version is "$agentVersion", KAS version is "$kasVersion" and version mismatch is "$versionMismatch"', ({ agentMockIdx, agentVersion, kasVersion, versionMismatch, versionOutdated, title }) => { @@ -181,5 +193,32 @@ describe('AgentTable', () => { } }, ); + + describe('pagination', () => { + it('should not render pagination buttons when there are no additional pages', () => { + createWrapper(); + + expect(findPagination().exists()).toBe(false); + }); + + it('should render pagination buttons when there are additional pages', () => { + createWrapper({ + propsData: { agents: [...clusterAgents, ...clusterAgents, ...clusterAgents] }, + }); + + expect(findPagination().exists()).toBe(true); + }); + + it('should not render pagination buttons when maxAgents is passed from the parent component', () => { + createWrapper({ + propsData: { + agents: [...clusterAgents, ...clusterAgents, ...clusterAgents], + maxAgents: 6, + }, + }); + + expect(findPagination().exists()).toBe(false); + }); + }); }); }); diff --git a/spec/frontend/clusters_list/components/agents_spec.js b/spec/frontend/clusters_list/components/agents_spec.js index d91245ba9b4..d6ede01fac4 100644 --- a/spec/frontend/clusters_list/components/agents_spec.js +++ b/spec/frontend/clusters_list/components/agents_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlKeysetPagination, GlLoadingIcon, GlBanner } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlBanner } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import Vue, { nextTick } from 'vue'; @@ -19,6 +19,7 @@ Vue.use(VueApollo); describe('Agents', () => { let wrapper; + let testDate = new Date(); const defaultProps = { defaultBranchName: 'default', @@ -31,9 +32,9 @@ describe('Agents', () => { props = {}, glFeatures = {}, agents = [], - pageInfo = null, + ciAccessAuthorizedAgentsNodes = [], + userAccessAuthorizedAgentsNodes = [], trees = [], - count = 0, queryResponse = null, }) => { const provide = provideData; @@ -43,12 +44,16 @@ describe('Agents', () => { id: '1', clusterAgents: { nodes: agents, - pageInfo, connections: { nodes: [] }, tokens: { nodes: [] }, - count, }, - repository: { tree: { trees: { nodes: trees, pageInfo } } }, + ciAccessAuthorizedAgents: { + nodes: ciAccessAuthorizedAgentsNodes, + }, + userAccessAuthorizedAgents: { + nodes: userAccessAuthorizedAgentsNodes, + }, + repository: { tree: { trees: { nodes: trees } } }, }, }, }; @@ -78,7 +83,6 @@ describe('Agents', () => { const findAgentTable = () => wrapper.findComponent(AgentTable); const findEmptyState = () => wrapper.findComponent(AgentEmptyState); - const findPaginationButtons = () => wrapper.findComponent(GlKeysetPagination); const findAlert = () => wrapper.findComponent(GlAlert); const findBanner = () => wrapper.findComponent(GlBanner); @@ -87,13 +91,13 @@ describe('Agents', () => { }); describe('when there is a list of agents', () => { - let testDate = new Date(); const agents = [ { __typename: 'ClusterAgent', id: '1', name: 'agent-1', webPath: '/agent-1', + createdAt: testDate, connections: null, tokens: null, }, @@ -102,6 +106,7 @@ describe('Agents', () => { id: '2', name: 'agent-2', webPath: '/agent-2', + createdAt: testDate, connections: null, tokens: { nodes: [ @@ -113,8 +118,26 @@ describe('Agents', () => { }, }, ]; - - const count = 2; + const ciAccessAuthorizedAgentsNodes = [ + { + agent: { + __typename: 'ClusterAgent', + id: '3', + name: 'ci-agent-1', + webPath: 'shared-project/agent-1', + createdAt: testDate, + connections: null, + tokens: null, + }, + }, + ]; + const userAccessAuthorizedAgentsNodes = [ + { + agent: { + ...agents[0], + }, + }, + ]; const trees = [ { @@ -156,10 +179,26 @@ describe('Agents', () => { ], }, }, + { + id: '3', + name: 'ci-agent-1', + configFolder: undefined, + webPath: 'shared-project/agent-1', + status: 'unused', + isShared: true, + lastContact: null, + connections: null, + tokens: null, + }, ]; beforeEach(() => { - return createWrapper({ agents, count, trees }); + return createWrapper({ + agents, + ciAccessAuthorizedAgentsNodes, + userAccessAuthorizedAgentsNodes, + trees, + }); }); it('should render agent table', () => { @@ -172,7 +211,7 @@ describe('Agents', () => { }); it('should emit agents count to the parent component', () => { - expect(wrapper.emitted().onAgentsLoad).toEqual([[count]]); + expect(wrapper.emitted().onAgentsLoad).toEqual([[expectedAgentsList.length]]); }); describe.each` @@ -192,7 +231,7 @@ describe('Agents', () => { localStorage.setItem(AGENT_FEEDBACK_KEY, true); } - return createWrapper({ glFeatures, agents, count, trees }); + return createWrapper({ glFeatures, agents, trees }); }); it(`should ${bannerShown ? 'show' : 'hide'} the feedback banner`, () => { @@ -206,7 +245,7 @@ describe('Agents', () => { showGitlabAgentFeedback: true, }; beforeEach(() => { - return createWrapper({ glFeatures, agents, count, trees }); + return createWrapper({ glFeatures, agents, trees }); }); it('should render the correct title', () => { @@ -238,51 +277,6 @@ describe('Agents', () => { expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList); }); }); - - it('should not render pagination buttons when there are no additional pages', () => { - expect(findPaginationButtons().exists()).toBe(false); - }); - - describe('when the list has additional pages', () => { - const pageInfo = { - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'prev', - endCursor: 'next', - }; - - beforeEach(() => { - return createWrapper({ - agents, - pageInfo: { - ...pageInfo, - __typename: 'PageInfo', - }, - }); - }); - - it('should render pagination buttons', () => { - expect(findPaginationButtons().exists()).toBe(true); - }); - - it('should pass pageInfo to the pagination component', () => { - expect(findPaginationButtons().props()).toMatchObject(pageInfo); - }); - - describe('when limit is passed from the parent component', () => { - beforeEach(() => { - return createWrapper({ - props: { limit: 6 }, - agents, - pageInfo, - }); - }); - - it('should not render pagination buttons', () => { - expect(findPaginationButtons().exists()).toBe(false); - }); - }); - }); }); describe('when the agent list is empty', () => { @@ -302,7 +296,10 @@ describe('Agents', () => { describe('when agents query has errored', () => { beforeEach(() => { - return createWrapper({ agents: null }); + createWrapper({ + queryResponse: jest.fn().mockRejectedValue({}), + }); + return waitForPromises(); }); it('displays an alert message', () => { 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 02b455d0b61..1ec8764705c 100644 --- a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js +++ b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js @@ -20,7 +20,6 @@ describe('AvailableAgentsDropdown', () => { propsData, stubs: { GlCollapsibleListbox }, }); - wrapper.vm.$refs.dropdown.closeAndFocus = jest.fn(); }; describe('there are agents available', () => { diff --git a/spec/frontend/clusters_list/components/delete_agent_button_spec.js b/spec/frontend/clusters_list/components/delete_agent_button_spec.js index 2c9a6b11671..8bbb5ec92a7 100644 --- a/spec/frontend/clusters_list/components/delete_agent_button_spec.js +++ b/spec/frontend/clusters_list/components/delete_agent_button_spec.js @@ -8,7 +8,7 @@ import deleteAgentMutation from '~/clusters_list/graphql/mutations/delete_agent. import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import DeleteAgentButton from '~/clusters_list/components/delete_agent_button.vue'; -import { MAX_LIST_COUNT, DELETE_AGENT_BUTTON } from '~/clusters_list/constants'; +import { DELETE_AGENT_BUTTON } from '~/clusters_list/constants'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { getAgentResponse, mockDeleteResponse, mockErrorDeleteResponse } from '../mocks/apollo'; @@ -16,7 +16,6 @@ Vue.use(VueApollo); const projectPath = 'path/to/project'; const defaultBranchName = 'default'; -const maxAgents = MAX_LIST_COUNT; const agent = { id: 'agent-id', name: 'agent-name', @@ -53,8 +52,6 @@ describe('DeleteAgentButton', () => { variables: { projectPath, defaultBranchName, - first: maxAgents, - last: null, }, data: getAgentResponse.data, }); @@ -71,7 +68,6 @@ describe('DeleteAgentButton', () => { }; const propsData = { defaultBranchName, - maxAgents, agent, }; diff --git a/spec/frontend/clusters_list/components/mock_data.js b/spec/frontend/clusters_list/components/mock_data.js index af1fb496118..161ea4566e1 100644 --- a/spec/frontend/clusters_list/components/mock_data.js +++ b/spec/frontend/clusters_list/components/mock_data.js @@ -205,4 +205,14 @@ export const clusterAgents = [ ], }, }, + { + name: 'ci-agent-1', + id: '3', + webPath: 'shared-project/agent-1', + status: 'inactive', + lastContact: connectedTimeInactive.getTime(), + isShared: true, + connections: null, + tokens: null, + }, ]; diff --git a/spec/frontend/clusters_list/mocks/apollo.js b/spec/frontend/clusters_list/mocks/apollo.js index 3467b4c665c..c0e25d174ae 100644 --- a/spec/frontend/clusters_list/mocks/apollo.js +++ b/spec/frontend/clusters_list/mocks/apollo.js @@ -3,6 +3,7 @@ const agent = { id: 'agent-id', name: 'agent-name', webPath: 'agent-webPath', + createdAt: new Date(), }; const token = { id: 'token-id', @@ -14,13 +15,6 @@ const tokens = { const connections = { nodes: [], }; -const pageInfo = { - endCursor: '', - hasNextPage: false, - hasPreviousPage: false, - startCursor: '', -}; -const count = 1; export const createAgentResponse = { data: { @@ -73,10 +67,12 @@ export const getAgentResponse = { project: { __typename: 'Project', id: 'project-1', - clusterAgents: { nodes: [{ ...agent, connections, tokens }], pageInfo, count }, + clusterAgents: { nodes: [{ ...agent, connections, tokens }] }, + ciAccessAuthorizedAgents: { nodes: [] }, + userAccessAuthorizedAgents: { nodes: [] }, repository: { tree: { - trees: { nodes: [{ ...agent, path: null }], pageInfo }, + trees: { nodes: [{ ...agent, path: null }] }, }, }, }, diff --git a/spec/frontend/code_review/signals_spec.js b/spec/frontend/code_review/signals_spec.js index 03c3580860e..3758dd1222b 100644 --- a/spec/frontend/code_review/signals_spec.js +++ b/spec/frontend/code_review/signals_spec.js @@ -1,5 +1,4 @@ import { start } from '~/code_review/signals'; - import diffsEventHub from '~/diffs/event_hub'; import { EVT_MR_PREPARED } from '~/diffs/constants'; import { getDerivedMergeRequestInformation } from '~/diffs/utils/merge_request'; @@ -90,6 +89,20 @@ describe('~/code_review', () => { expect(apolloSubscribeSpy).not.toHaveBeenCalled(); }); + describe('when the project does not exist', () => { + beforeEach(() => { + querySpy.mockResolvedValue({ + data: { project: null }, + }); + }); + + it('does not fail and quits silently', () => { + expect(async () => { + await start(callArgs); + }).not.toThrow(); + }); + }); + describe('if the merge request is still asynchronously preparing', () => { beforeEach(() => { querySpy.mockResolvedValue({ diff --git a/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap b/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap index 0f158df6c05..8cad483e27e 100644 --- a/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap +++ b/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap @@ -58,7 +58,7 @@ exports[`Comment templates list item component renders list item 1`] = ` </button> <div - class="gl-new-dropdown-panel gl-w-31" + class="gl-new-dropdown-panel gl-w-31!" data-testid="base-dropdown-menu" id="base-dropdown-7" > diff --git a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js index 7be68df61de..7983f8fddf5 100644 --- a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js +++ b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js @@ -7,10 +7,11 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; import CommitBoxPipelineMiniGraph from '~/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue'; +import GraphqlPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue'; import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; import { COMMIT_BOX_POLL_INTERVAL } from '~/projects/commit_box/info/constants'; -import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql'; -import getPipelineStagesQuery from '~/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql'; +import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql'; +import getPipelineStagesQuery from '~/pipelines/graphql/queries/get_pipeline_stages.query.graphql'; import * as sharedGraphQlUtils from '~/graphql_shared/utils'; import { mockDownstreamQueryResponse, @@ -28,6 +29,7 @@ describe('Commit box pipeline mini graph', () => { let wrapper; const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findGraphqlPipelineMiniGraph = () => wrapper.findComponent(GraphqlPipelineMiniGraph); const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph); const downstreamHandler = jest.fn().mockResolvedValue(mockDownstreamQueryResponse); @@ -52,7 +54,7 @@ describe('Commit box pipeline mini graph', () => { return createMockApollo(requestHandlers); }; - const createComponent = (handler) => { + const createComponent = ({ handler, ciGraphqlPipelineMiniGraph = false } = {}) => { wrapper = extendedWrapper( shallowMount(CommitBoxPipelineMiniGraph, { propsData: { @@ -63,6 +65,9 @@ describe('Commit box pipeline mini graph', () => { iid, dataMethod: 'graphql', graphqlResourceEtag: '/api/graphql:pipelines/id/320', + glFeatures: { + ciGraphqlPipelineMiniGraph, + }, }, apolloProvider: createMockApolloProvider(handler), }), @@ -148,7 +153,7 @@ describe('Commit box pipeline mini graph', () => { }); it('should pass the pipeline path prop for the counter badge', async () => { - createComponent(downstreamHandler); + createComponent({ handler: downstreamHandler }); await waitForPromises(); @@ -159,7 +164,7 @@ describe('Commit box pipeline mini graph', () => { }); it('should render an upstream pipeline only', async () => { - createComponent(upstreamHandler); + createComponent({ handler: upstreamHandler }); await waitForPromises(); @@ -171,7 +176,7 @@ describe('Commit box pipeline mini graph', () => { }); it('should render downstream and upstream pipelines', async () => { - createComponent(upstreamDownstreamHandler); + createComponent({ handler: upstreamDownstreamHandler }); await waitForPromises(); @@ -255,4 +260,31 @@ describe('Commit box pipeline mini graph', () => { ); }); }); + + describe('feature flag behavior', () => { + it.each` + state | provide | showPipelineMiniGraph | showGraphqlPipelineMiniGraph + ${true} | ${{ ciGraphqlPipelineMiniGraph: true }} | ${false} | ${true} + ${false} | ${{}} | ${true} | ${false} + `( + 'renders the correct component when the feature flag is set to $state', + async ({ provide, showPipelineMiniGraph, showGraphqlPipelineMiniGraph }) => { + createComponent(provide); + + await waitForPromises(); + + expect(findPipelineMiniGraph().exists()).toBe(showPipelineMiniGraph); + expect(findGraphqlPipelineMiniGraph().exists()).toBe(showGraphqlPipelineMiniGraph); + }, + ); + + it('skips queries when the feature flag is enabled', async () => { + createComponent({ ciGraphqlPipelineMiniGraph: true }); + + await waitForPromises(); + + expect(stagesHandler).not.toHaveBeenCalled(); + expect(downstreamHandler).not.toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/commit/components/commit_refs_spec.js b/spec/frontend/commit/components/commit_refs_spec.js new file mode 100644 index 00000000000..380b2e07842 --- /dev/null +++ b/spec/frontend/commit/components/commit_refs_spec.js @@ -0,0 +1,97 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { createAlert } from '~/alert'; +import commitReferences from '~/projects/commit_box/info/graphql/queries/commit_references.query.graphql'; +import containingBranchesQuery from '~/projects/commit_box/info/graphql/queries/commit_containing_branches.query.graphql'; +import RefsList from '~/projects/commit_box/info/components/refs_list.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { + FETCH_CONTAINING_REFS_EVENT, + FETCH_COMMIT_REFERENCES_ERROR, +} from '~/projects/commit_box/info/constants'; +import CommitRefs from '~/projects/commit_box/info/components/commit_refs.vue'; + +import { + mockCommitReferencesResponse, + mockOnlyBranchesResponse, + mockContainingBranchesResponse, + refsListPropsMock, +} from '../mock_data'; + +Vue.use(VueApollo); + +jest.mock('~/alert'); + +describe('Commit references component', () => { + let wrapper; + + const successQueryHandler = (mockResponse) => jest.fn().mockResolvedValue(mockResponse); + const failedQueryHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + const containingBranchesQueryHandler = successQueryHandler(mockContainingBranchesResponse); + const findRefsLists = () => wrapper.findAllComponents(RefsList); + const branchesList = () => findRefsLists().at(0); + + const createComponent = async ( + commitReferencesQueryHandler = successQueryHandler(mockCommitReferencesResponse), + ) => { + wrapper = shallowMount(CommitRefs, { + apolloProvider: createMockApollo([ + [commitReferences, commitReferencesQueryHandler], + [containingBranchesQuery, containingBranchesQueryHandler], + ]), + provide: { + fullPath: 'some/project', + commitSha: 'xxx', + }, + }); + + await waitForPromises(); + }; + + beforeEach(async () => { + await createComponent(); + }); + + it('renders component correcrly', () => { + expect(findRefsLists()).toHaveLength(2); + }); + + it('passes props to refs list', () => { + expect(branchesList().props()).toEqual(refsListPropsMock); + }); + + it('shows alert when response fails', async () => { + await createComponent(failedQueryHandler); + expect(createAlert).toHaveBeenCalledWith({ + message: FETCH_COMMIT_REFERENCES_ERROR, + captureError: true, + }); + }); + + it('fetches containing refs on the fetch event', async () => { + await createComponent(); + branchesList().vm.$emit(FETCH_CONTAINING_REFS_EVENT); + await waitForPromises(); + expect(containingBranchesQueryHandler).toHaveBeenCalledTimes(1); + }); + + it('does not render list when there is no branches or tags', async () => { + await createComponent(successQueryHandler(mockOnlyBranchesResponse)); + expect(findRefsLists()).toHaveLength(1); + }); + + describe('with relative url', () => { + beforeEach(async () => { + gon.relative_url_root = '/gitlab'; + await createComponent(); + }); + + it('passes correct urlPart prop to refList', () => { + expect(branchesList().props('urlPart')).toBe( + `${gon.relative_url_root}${refsListPropsMock.urlPart}`, + ); + }); + }); +}); diff --git a/spec/frontend/commit/components/refs_list_spec.js b/spec/frontend/commit/components/refs_list_spec.js new file mode 100644 index 00000000000..cc783dc3b58 --- /dev/null +++ b/spec/frontend/commit/components/refs_list_spec.js @@ -0,0 +1,77 @@ +import { GlCollapse, GlButton, GlBadge, GlSkeletonLoader } from '@gitlab/ui'; +import RefsList from '~/projects/commit_box/info/components/refs_list.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + CONTAINING_COMMIT, + FETCH_CONTAINING_REFS_EVENT, +} from '~/projects/commit_box/info/constants'; +import { refsListPropsMock, containingBranchesMock } from '../mock_data'; + +describe('Commit references component', () => { + let wrapper; + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(RefsList, { + propsData: { + ...refsListPropsMock, + ...props, + }, + }); + }; + + const findTitle = () => wrapper.findByTestId('title'); + const findCollapseButton = () => wrapper.findComponent(GlButton); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findTippingRefs = () => wrapper.findAllComponents(GlBadge); + const findContainingRefs = () => wrapper.findComponent(GlCollapse); + + beforeEach(() => { + createComponent(); + }); + + it('renders the namespace passed', () => { + expect(findTitle().text()).toEqual(refsListPropsMock.namespace); + }); + + it('renders list of tipping branches or tags', () => { + expect(findTippingRefs()).toHaveLength(refsListPropsMock.tippingRefs.length); + }); + + it('does not render collapse with containing branches ot tags when there is no data', () => { + createComponent({ hasContainingRefs: false }); + expect(findCollapseButton().exists()).toBe(false); + }); + + it('renders collapse component if commit has containing branches', () => { + expect(findCollapseButton().text()).toContain(CONTAINING_COMMIT); + }); + + it('emits event when collapse button is clicked', () => { + findCollapseButton().vm.$emit('click'); + expect(wrapper.emitted()[FETCH_CONTAINING_REFS_EVENT]).toHaveLength(1); + }); + + it('renders the list of containing branches or tags when collapse is expanded', () => { + createComponent({ containingRefs: containingBranchesMock }); + const containingRefsList = findContainingRefs(); + expect(containingRefsList.findAllComponents(GlBadge)).toHaveLength( + containingBranchesMock.length, + ); + }); + + it('renders links to refs', () => { + const index = 0; + const refBadge = findTippingRefs().at(index); + const refUrl = `${refsListPropsMock.urlPart}${refsListPropsMock.tippingRefs[index]}?ref_type=${refsListPropsMock.refType}`; + expect(refBadge.attributes('href')).toBe(refUrl); + }); + + it('does not reneder list of tipping branches or tags if there is no data', () => { + createComponent({ tippingRefs: [] }); + expect(findTippingRefs().exists()).toBe(false); + }); + + it('renders skeleton loader when isLoading prop has true value', () => { + createComponent({ isLoading: true, containingRefs: [] }); + expect(findSkeletonLoader().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/commit/mock_data.js b/spec/frontend/commit/mock_data.js index 3b6971d9607..2a618e08c50 100644 --- a/spec/frontend/commit/mock_data.js +++ b/spec/frontend/commit/mock_data.js @@ -232,3 +232,62 @@ export const x509CertificateDetailsProp = { subject: 'CN=gitlab@example.org,OU=Example,O=World', subjectKeyIdentifier: 'BC BC BC BC BC BC BC BC', }; + +export const tippingBranchesMock = ['main', 'development']; + +export const containingBranchesMock = ['branch-1', 'branch-2', 'branch-3']; + +export const mockCommitReferencesResponse = { + data: { + project: { + id: 'gid://gitlab/Project/1', + commitReferences: { + containingBranches: { names: ['branch-1'], __typename: 'CommitParentNames' }, + containingTags: { names: ['tag-1'], __typename: 'CommitParentNames' }, + tippingBranches: { names: tippingBranchesMock, __typename: 'CommitParentNames' }, + tippingTags: { names: ['tag-latest'], __typename: 'CommitParentNames' }, + __typename: 'CommitReferences', + }, + __typename: 'Project', + }, + }, +}; + +export const mockOnlyBranchesResponse = { + data: { + project: { + id: 'gid://gitlab/Project/1', + commitReferences: { + containingBranches: { names: ['branch-1'], __typename: 'CommitParentNames' }, + containingTags: { names: [], __typename: 'CommitParentNames' }, + tippingBranches: { names: tippingBranchesMock, __typename: 'CommitParentNames' }, + tippingTags: { names: [], __typename: 'CommitParentNames' }, + __typename: 'CommitReferences', + }, + __typename: 'Project', + }, + }, +}; + +export const mockContainingBranchesResponse = { + data: { + project: { + id: 'gid://gitlab/Project/1', + commitReferences: { + containingBranches: { names: containingBranchesMock, __typename: 'CommitParentNames' }, + __typename: 'CommitReferences', + }, + __typename: 'Project', + }, + }, +}; + +export const refsListPropsMock = { + hasContainingRefs: true, + containingRefs: [], + namespace: 'Branches', + tippingRefs: tippingBranchesMock, + isLoading: false, + urlPart: '/some/project/-/commits/', + refType: 'heads', +}; diff --git a/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js index 97716ce848c..85eafa9e85c 100644 --- a/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js @@ -64,12 +64,12 @@ describe('content_editor/components/bubble_menus/bubble_menu', () => { tippyOptions: expect.objectContaining({ onHidden: expect.any(Function), onShow: expect.any(Function), - appendTo: expect.any(Function), + strategy: 'fixed', + maxWidth: 'auto', ...tippyOptions, }), }); - expect(BubbleMenuPlugin.mock.calls[0][0].tippyOptions.appendTo()).toBe(document.body); expect(tiptapEditor.registerPlugin).toHaveBeenCalledWith(pluginInitializationResult); }); diff --git a/spec/frontend/content_editor/components/bubble_menus/reference_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/reference_bubble_menu_spec.js new file mode 100644 index 00000000000..169f77dc054 --- /dev/null +++ b/spec/frontend/content_editor/components/bubble_menus/reference_bubble_menu_spec.js @@ -0,0 +1,247 @@ +import { GlLoadingIcon, GlListboxItem, GlCollapsibleListbox } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import eventHubFactory from '~/helpers/event_hub_factory'; +import ReferenceBubbleMenu from '~/content_editor/components/bubble_menus/reference_bubble_menu.vue'; +import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue'; +import { stubComponent } from 'helpers/stub_component'; +import Reference from '~/content_editor/extensions/reference'; +import { createTestEditor, emitEditorEvent, createDocBuilder } from '../../test_utils'; + +const mockIssue = { + href: 'https://gitlab.com/gitlab-org/gitlab-test/-/issues/24', + text: '#24', + expandedText: 'Et fuga quos omnis enim dolores amet impedit. (#24)', + fullyExpandedText: + 'Et fuga quos omnis enim dolores amet impedit. (#24) • Fernanda Adams • Sprint - Eligendi quas non inventore eum quaerat sit.', +}; +const mockMergeRequest = { + href: 'https://gitlab.com/gitlab-org/gitlab-test/-/merge_requests/2', + text: '!2', + expandedText: 'Qui possimus sit harum ut ipsam autem. (!2)', + fullyExpandedText: 'Qui possimus sit harum ut ipsam autem. (!2) • Margrett Wunsch • v0.0', +}; +const mockEpic = { + href: 'https://gitlab.com/groups/gitlab-org/-/epics/5', + text: '&5', + expandedText: 'Temporibus delectus distinctio quas sed non per... (&5)', +}; + +const supportedIssueDisplayFormats = ['Issue ID', 'Issue title', 'Issue summary']; + +const supportedMergeRequestDisplayFormats = [ + 'Merge request ID', + 'Merge request title', + 'Merge request summary', +]; + +const supportedEpicDisplayFormats = ['Epic ID', 'Epic title']; + +describe('content_editor/components/bubble_menus/reference_bubble_menu', () => { + let wrapper; + let tiptapEditor; + let contentEditor; + let eventHub; + let doc; + let p; + let reference; + + const buildExpectedDoc = (href, originalText, referenceType, text) => + doc(p(reference({ className: 'gfm', href, originalText, referenceType, text }))); + + const buildEditor = () => { + tiptapEditor = createTestEditor({ extensions: [Reference] }); + contentEditor = { resolveReference: jest.fn().mockImplementation(() => new Promise(() => {})) }; + eventHub = eventHubFactory(); + + ({ + builders: { doc, p, reference }, + } = createDocBuilder({ + tiptapEditor, + names: { + reference: { nodeType: Reference.name }, + }, + })); + }; + + const expectedDocs = { + issue: [ + () => + buildExpectedDoc( + 'https://gitlab.com/gitlab-org/gitlab-test/-/issues/24', + '#24', + 'issue', + '#24', + ), + () => + buildExpectedDoc( + 'https://gitlab.com/gitlab-org/gitlab-test/-/issues/24', + '#24+', + 'issue', + 'Et fuga quos omnis enim dolores amet impedit. (#24)', + ), + () => + buildExpectedDoc( + 'https://gitlab.com/gitlab-org/gitlab-test/-/issues/24', + '#24+s', + 'issue', + 'Et fuga quos omnis enim dolores amet impedit. (#24) • Fernanda Adams • Sprint - Eligendi quas non inventore eum quaerat sit.', + ), + ], + merge_request: [ + () => + buildExpectedDoc( + 'https://gitlab.com/gitlab-org/gitlab-test/-/merge_requests/2', + '!2', + 'merge_request', + '!2', + ), + () => + buildExpectedDoc( + 'https://gitlab.com/gitlab-org/gitlab-test/-/merge_requests/2', + '!2+', + 'merge_request', + 'Qui possimus sit harum ut ipsam autem. (!2)', + ), + () => + buildExpectedDoc( + 'https://gitlab.com/gitlab-org/gitlab-test/-/merge_requests/2', + '!2+s', + 'merge_request', + 'Qui possimus sit harum ut ipsam autem. (!2) • Margrett Wunsch • v0.0', + ), + ], + epic: [ + () => buildExpectedDoc('https://gitlab.com/groups/gitlab-org/-/epics/5', '&5', 'epic', '&5'), + () => + buildExpectedDoc( + 'https://gitlab.com/groups/gitlab-org/-/epics/5', + '&5+', + 'epic', + 'Temporibus delectus distinctio quas sed non per... (&5)', + ), + ], + }; + + const buildWrapper = () => { + wrapper = mountExtended(ReferenceBubbleMenu, { + provide: { + tiptapEditor, + contentEditor, + eventHub, + }, + stubs: { + BubbleMenu: stubComponent(BubbleMenu), + }, + }); + }; + + const showMenu = () => { + wrapper.findComponent(BubbleMenu).vm.$emit('show'); + return nextTick(); + }; + + const buildWrapperAndDisplayMenu = async () => { + buildWrapper(); + + await showMenu(); + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + }; + + beforeEach(() => { + buildEditor(); + + tiptapEditor + .chain() + .setContent( + '<a href="https://gitlab.com/gitlab-org/gitlab/issues/1" class="gfm" data-reference-type="issue" data-original="#1">#1</a>', + ) + .setNodeSelection(1) + .run(); + }); + + it('shows a loading indicator while the reference is being resolved', async () => { + await buildWrapperAndDisplayMenu(); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + + describe.each` + referenceType | mockReference | supportedDisplayFormats + ${'issue'} | ${mockIssue} | ${supportedIssueDisplayFormats} + ${'merge_request'} | ${mockMergeRequest} | ${supportedMergeRequestDisplayFormats} + ${'epic'} | ${mockEpic} | ${supportedEpicDisplayFormats} + `( + 'for reference type $referenceType', + ({ referenceType, mockReference, supportedDisplayFormats }) => { + beforeEach(async () => { + tiptapEditor + .chain() + .setContent( + `<a href="${mockReference.href}" class="gfm" data-reference-type="${referenceType}" data-original="${mockReference.text}">${mockReference.text}</a>`, + ) + .setNodeSelection(1) + .run(); + + contentEditor.resolveReference.mockImplementation(() => Promise.resolve(mockReference)); + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + }); + + it('shows a dropdown with supported display formats', async () => { + await buildWrapperAndDisplayMenu(); + + supportedDisplayFormats.forEach((format) => expect(wrapper.text()).toContain(format)); + }); + + describe.each` + option | displayFormat | selectedValue + ${0} | ${supportedDisplayFormats[0]} | ${''} + ${1} | ${supportedDisplayFormats[1]} | ${'+'} + ${2} | ${supportedDisplayFormats[2]} | ${'+s'} + `('on selecting option $option', ({ option, displayFormat, selectedValue }) => { + if (!displayFormat) return; + + const findDropdownItem = () => wrapper.findAllComponents(GlListboxItem).at(option); + + beforeEach(async () => { + await buildWrapperAndDisplayMenu(); + + findDropdownItem().trigger('click'); + }); + + it('selects the option', () => { + expect(wrapper.findComponent(GlCollapsibleListbox).props()).toMatchObject({ + selected: selectedValue, + toggleText: displayFormat, + }); + }); + + it('updates the reference in content editor', () => { + expect(tiptapEditor.getJSON()).toEqual(expectedDocs[referenceType][option]().toJSON()); + }); + }); + }, + ); + + describe('copy URL button', () => { + it('copies the reference link to clipboard', async () => { + jest.spyOn(navigator.clipboard, 'writeText'); + + await buildWrapperAndDisplayMenu(); + await wrapper.findByTestId('copy-reference-url').trigger('click'); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + 'https://gitlab.com/gitlab-org/gitlab/issues/1', + ); + }); + }); + + describe('remove reference button', () => { + it('removes the reference', async () => { + await buildWrapperAndDisplayMenu(); + await wrapper.findByTestId('remove-reference').trigger('click'); + + expect(tiptapEditor.getHTML()).toBe('<p></p>'); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index 852c8a9591a..0b8321ba8eb 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -9,6 +9,7 @@ import EditorStateObserver from '~/content_editor/components/editor_state_observ import CodeBlockBubbleMenu from '~/content_editor/components/bubble_menus/code_block_bubble_menu.vue'; import LinkBubbleMenu from '~/content_editor/components/bubble_menus/link_bubble_menu.vue'; import MediaBubbleMenu from '~/content_editor/components/bubble_menus/media_bubble_menu.vue'; +import ReferenceBubbleMenu from '~/content_editor/components/bubble_menus/reference_bubble_menu.vue'; import FormattingToolbar from '~/content_editor/components/formatting_toolbar.vue'; import LoadingIndicator from '~/content_editor/components/loading_indicator.vue'; import waitForPromises from 'helpers/wait_for_promises'; @@ -94,7 +95,7 @@ describe('ContentEditor', () => { it('renders footer containing quick actions help text if quick actions docs path is defined', () => { createWrapper({ quickActionsDocsPath: '/foo/bar' }); - expect(findEditorElement().text()).toContain('For quick actions, type /'); + expect(wrapper.text()).toContain('For quick actions, type /'); expect(wrapper.findComponent(GlLink).attributes('href')).toBe('/foo/bar'); }); @@ -104,6 +105,18 @@ describe('ContentEditor', () => { expect(findEditorElement().text()).not.toContain('For quick actions, type /'); }); + it('displays an attachment button', () => { + createWrapper(); + + expect(wrapper.findComponent(FormattingToolbar).props().hideAttachmentButton).toBe(false); + }); + + it('hides the attachment button if attachments are disabled', () => { + createWrapper({ disableAttachments: true }); + + expect(wrapper.findComponent(FormattingToolbar).props().hideAttachmentButton).toBe(true); + }); + describe('when setting initial content', () => { it('displays loading indicator', async () => { createWrapper(); @@ -267,7 +280,8 @@ describe('ContentEditor', () => { ${'link'} | ${LinkBubbleMenu} ${'media'} | ${MediaBubbleMenu} ${'codeBlock'} | ${CodeBlockBubbleMenu} - `('renders formatting bubble menu', ({ component }) => { + ${'reference'} | ${ReferenceBubbleMenu} + `('renders $name bubble menu', ({ component }) => { createWrapper(); expect(wrapper.findComponent(component).exists()).toBe(true); diff --git a/spec/frontend/content_editor/components/formatting_toolbar_spec.js b/spec/frontend/content_editor/components/formatting_toolbar_spec.js index e04c6a00765..9d835381ff4 100644 --- a/spec/frontend/content_editor/components/formatting_toolbar_spec.js +++ b/spec/frontend/content_editor/components/formatting_toolbar_spec.js @@ -12,13 +12,14 @@ describe('content_editor/components/formatting_toolbar', () => { let wrapper; let trackingSpy; - const buildWrapper = () => { + const buildWrapper = (props) => { wrapper = shallowMountExtended(FormattingToolbar, { stubs: { GlTabs, GlTab, EditorModeSwitcher, }, + propsData: props, }); }; @@ -73,4 +74,12 @@ describe('content_editor/components/formatting_toolbar', () => { expect(wrapper.findComponent(EditorModeSwitcher).exists()).toBe(true); }); + + describe('when attachment button is hidden', () => { + it('does not show the attachment button', () => { + buildWrapper({ hideAttachmentButton: true }); + + expect(wrapper.findByTestId('attachment').exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/content_editor/components/toolbar_table_button_spec.js b/spec/frontend/content_editor/components/toolbar_table_button_spec.js index 35741971488..be6e47e067f 100644 --- a/spec/frontend/content_editor/components/toolbar_table_button_spec.js +++ b/spec/frontend/content_editor/components/toolbar_table_button_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlButton } from '@gitlab/ui'; +import { GlDisclosureDropdown, GlButton } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import ToolbarTableButton from '~/content_editor/components/toolbar_table_button.vue'; import { stubComponent } from 'helpers/stub_component'; @@ -14,12 +14,13 @@ describe('content_editor/components/toolbar_table_button', () => { tiptapEditor: editor, }, stubs: { - GlDropdown: stubComponent(GlDropdown), + GlDisclosureDropdown: stubComponent(GlDisclosureDropdown), }, }); }; - const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findButton = (row, col) => wrapper.findComponent({ ref: `table-${row}-${col}` }); const getNumButtons = () => findDropdown().findAllComponents(GlButton).length; beforeEach(() => { @@ -32,32 +33,44 @@ describe('content_editor/components/toolbar_table_button', () => { editor.destroy(); }); - it('renders a grid of 5x5 buttons to create a table', () => { - expect(getNumButtons()).toBe(25); // 5x5 - }); - describe.each` row | col | numButtons | tableSize - ${3} | ${4} | ${25} | ${'3x4'} - ${4} | ${4} | ${25} | ${'4x4'} - ${4} | ${5} | ${30} | ${'4x5'} - ${5} | ${4} | ${30} | ${'5x4'} - ${5} | ${5} | ${36} | ${'5x5'} + ${3} | ${4} | ${25} | ${'3×4'} + ${4} | ${4} | ${25} | ${'4×4'} + ${4} | ${5} | ${30} | ${'4×5'} + ${5} | ${4} | ${30} | ${'5×4'} + ${5} | ${5} | ${36} | ${'5×5'} `('button($row, $col) in the table creator grid', ({ row, col, numButtons, tableSize }) => { - describe('on mouse over', () => { + describe('a11y tests', () => { + it('is in its own gridcell', () => { + expect(findButton(row, col).element.parentElement.getAttribute('role')).toBe('gridcell'); + }); + + it('has an aria-label', () => { + expect(findButton(row, col).attributes('aria-label')).toBe(`Insert a ${tableSize} table`); + }); + }); + + describe.each` + event | triggerEvent + ${'mouseover'} | ${(button) => button.trigger('mouseover')} + ${'focus'} | ${(button) => button.element.dispatchEvent(new FocusEvent('focus'))} + `('on $event', ({ triggerEvent }) => { beforeEach(async () => { - const button = wrapper.findByTestId(`table-${row}-${col}`); - await button.trigger('mouseover'); + const button = wrapper.findComponent({ ref: `table-${row}-${col}` }); + await triggerEvent(button); }); it('marks all rows and cols before it as active', () => { const prevRow = Math.max(1, row - 1); const prevCol = Math.max(1, col - 1); - expect(wrapper.findByTestId(`table-${prevRow}-${prevCol}`).element).toHaveClass('active'); + expect(wrapper.findComponent({ ref: `table-${prevRow}-${prevCol}` }).element).toHaveClass( + 'active', + ); }); it('shows a help text indicating the size of the table being inserted', () => { - expect(findDropdown().element).toHaveText(`Insert a ${tableSize} table.`); + expect(findDropdown().element).toHaveText(`Insert a ${tableSize} table`); }); it('adds another row and col of buttons to create a bigger table', () => { @@ -71,7 +84,7 @@ describe('content_editor/components/toolbar_table_button', () => { beforeEach(async () => { commands = mockChainedCommands(editor, ['focus', 'insertTable', 'run']); - const button = wrapper.findByTestId(`table-${row}-${col}`); + const button = wrapper.findComponent({ ref: `table-${row}-${col}` }); await button.trigger('mouseover'); await button.trigger('click'); }); @@ -95,8 +108,8 @@ describe('content_editor/components/toolbar_table_button', () => { expect(getNumButtons()).toBe(i * i); // eslint-disable-next-line no-await-in-loop - await wrapper.findByTestId(`table-${i}-${i}`).trigger('mouseover'); - expect(findDropdown().element).toHaveText(`Insert a ${i}x${i} table.`); + await wrapper.findComponent({ ref: `table-${i}-${i}` }).trigger('mouseover'); + expect(findDropdown().element).toHaveText(`Insert a ${i}×${i} table`); } expect(getNumButtons()).toBe(100); // 10x10 (and not 11x11) @@ -105,10 +118,50 @@ describe('content_editor/components/toolbar_table_button', () => { describe('a11y tests', () => { it('sets text, title, and text-sr-only properties to the table button dropdown', () => { expect(findDropdown().props()).toMatchObject({ - text: 'Insert table', + toggleText: 'Insert table', textSrOnly: true, }); - expect(findDropdown().attributes('title')).toBe('Insert table'); + expect(findDropdown().attributes('aria-label')).toBe('Insert table'); + }); + + it('renders a role=grid of 5x5 gridcells to create a table', () => { + expect(getNumButtons()).toBe(25); // 5x5 + expect(wrapper.find('[role="grid"]').exists()).toBe(true); + wrapper.findAll('[role="row"]').wrappers.forEach((row) => { + expect(row.findAll('[role="gridcell"]')).toHaveLength(5); + }); + }); + + it('sets aria-rowcount and aria-colcount on the dropdown contents', () => { + expect(wrapper.find('[role="grid"]').attributes()).toMatchObject({ + 'aria-rowcount': '10', + 'aria-colcount': '10', + }); + }); + + it('allows navigating the grid with the arrow keys', async () => { + const dispatchKeyboardEvent = (button, key) => + button.element.dispatchEvent(new KeyboardEvent('keydown', { key })); + + let button = findButton(3, 4); + await button.trigger('mouseover'); + expect(button.element).toHaveClass('active'); + + button = findButton(3, 5); + await dispatchKeyboardEvent(button, 'ArrowRight'); + expect(button.element).toHaveClass('active'); + + button = findButton(4, 5); + await dispatchKeyboardEvent(button, 'ArrowDown'); + expect(button.element).toHaveClass('active'); + + button = findButton(4, 4); + await dispatchKeyboardEvent(button, 'ArrowLeft'); + expect(button.element).toHaveClass('active'); + + button = findButton(3, 4); + await dispatchKeyboardEvent(button, 'ArrowUp'); + expect(button.element).toHaveClass('active'); }); }); }); diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js index 0d56280d630..275f48ea857 100644 --- a/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js +++ b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js @@ -1,8 +1,8 @@ -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { NodeViewWrapper } from '@tiptap/vue-2'; +import { GlDisclosureDropdown } from '@gitlab/ui'; +import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; import { selectedRect as getSelectedRect } from '@tiptap/pm/tables'; import { nextTick } from 'vue'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { stubComponent } from 'helpers/stub_component'; import TableCellBaseWrapper from '~/content_editor/components/wrappers/table_cell_base.vue'; import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../../test_utils'; @@ -15,32 +15,21 @@ describe('content/components/wrappers/table_cell_base', () => { let node; const createWrapper = (propsData = { cellType: 'td' }) => { - wrapper = shallowMountExtended(TableCellBaseWrapper, { + wrapper = mountExtended(TableCellBaseWrapper, { propsData: { editor, node, + getPos: () => 0, ...propsData, }, stubs: { - GlDropdown: stubComponent(GlDropdown, { - methods: { - hide: jest.fn(), - }, - }), + NodeViewWrapper: stubComponent(NodeViewWrapper), + NodeViewContent: stubComponent(NodeViewContent), }, }); }; - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownItemWithLabel = (name) => - wrapper - .findAllComponents(GlDropdownItem) - .filter((dropdownItem) => dropdownItem.text().includes(name)) - .at(0); - const findDropdownItemWithLabelExists = (name) => - wrapper - .findAllComponents(GlDropdownItem) - .filter((dropdownItem) => dropdownItem.text().includes(name)).length > 0; + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); const setCurrentPositionInCell = () => { const { $cursor } = editor.state.selection; @@ -48,7 +37,9 @@ describe('content/components/wrappers/table_cell_base', () => { }; beforeEach(() => { - node = {}; + node = { + attrs: {}, + }; editor = createTestEditor({}); }); @@ -68,11 +59,10 @@ describe('content/components/wrappers/table_cell_base', () => { category: 'tertiary', icon: 'chevron-down', size: 'small', - split: false, + noCaret: true, }); expect(findDropdown().attributes()).toMatchObject({ boundary: 'viewport', - 'no-caret': '', }); }); @@ -88,6 +78,10 @@ describe('content/components/wrappers/table_cell_base', () => { beforeEach(async () => { setCurrentPositionInCell(); getSelectedRect.mockReturnValue({ + top: 0, + left: 0, + bottom: 1, + right: 1, map: { height: 1, width: 1, @@ -107,81 +101,176 @@ describe('content/components/wrappers/table_cell_base', () => { ${'Delete table'} | ${'deleteTable'} `( 'executes $commandName when $dropdownItemLabel button is clicked', - ({ commandName, dropdownItemLabel }) => { + async ({ dropdownItemLabel, commandName }) => { const mocks = mockChainedCommands(editor, [commandName, 'run']); - findDropdownItemWithLabel(dropdownItemLabel).vm.$emit('click'); + await wrapper.findByRole('button', { name: dropdownItemLabel }).trigger('click'); expect(mocks[commandName]).toHaveBeenCalled(); }, ); - it('does not allow deleting rows and columns', () => { - expect(findDropdownItemWithLabelExists('Delete row')).toBe(false); - expect(findDropdownItemWithLabelExists('Delete column')).toBe(false); + it.each` + dropdownItemLabel + ${'Delete row'} + ${'Delete column'} + ${'Split cell'} + ${'Merge'} + `('does not have option $dropdownItemLabel available', ({ dropdownItemLabel }) => { + expect(findDropdown().text()).not.toContain(dropdownItemLabel); }); - it('allows deleting rows when there are more than 2 rows in the table', async () => { - const mocks = mockChainedCommands(editor, ['deleteRow', 'run']); + it.each` + dropdownItemLabel | commandName + ${'Delete row'} | ${'deleteRow'} + ${'Delete column'} | ${'deleteColumn'} + `( + 'allows $dropdownItemLabel operation when there are more than 2 rows and 1 column in the table', + async ({ dropdownItemLabel, commandName }) => { + const mocks = mockChainedCommands(editor, [commandName, 'run']); - getSelectedRect.mockReturnValue({ - map: { - height: 3, - }, - }); + getSelectedRect.mockReturnValue({ + top: 0, + left: 0, + bottom: 1, + right: 1, + map: { + height: 3, + width: 2, + }, + }); - emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' }); + emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' }); - await nextTick(); + await nextTick(); + await wrapper.findByRole('button', { name: dropdownItemLabel }).trigger('click'); - findDropdownItemWithLabel('Delete row').vm.$emit('click'); + expect(mocks[commandName]).toHaveBeenCalled(); + }, + ); - expect(mocks.deleteRow).toHaveBeenCalled(); - }); + describe("when current row is the table's header", () => { + beforeEach(async () => { + // Remove 2 rows condition + getSelectedRect.mockReturnValue({ + map: { + height: 3, + }, + }); - it('allows deleting columns when there are more than 1 column in the table', async () => { - const mocks = mockChainedCommands(editor, ['deleteColumn', 'run']); + createWrapper({ cellType: 'th' }); - getSelectedRect.mockReturnValue({ - map: { - width: 2, - }, + await nextTick(); }); - emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' }); + it('does not allow adding a row before the header', () => { + expect(findDropdown().text()).not.toContain('Insert row before'); + }); - await nextTick(); + it('does not allow removing the header row', async () => { + createWrapper({ cellType: 'th' }); - findDropdownItemWithLabel('Delete column').vm.$emit('click'); + await nextTick(); - expect(mocks.deleteColumn).toHaveBeenCalled(); + expect(findDropdown().text()).not.toContain('Delete row'); + }); }); - describe('when current row is the table’s header', () => { - beforeEach(async () => { - // Remove 2 rows condition + describe.each` + attrs | rect + ${{ rowspan: 2 }} | ${{ top: 0, left: 0, bottom: 2, right: 1 }} + ${{ colspan: 2 }} | ${{ top: 0, left: 0, bottom: 1, right: 2 }} + `('when selected cell has $attrs', ({ attrs, rect }) => { + beforeEach(() => { + node = { attrs }; + getSelectedRect.mockReturnValue({ + ...rect, map: { height: 3, + width: 2, }, }); - createWrapper({ cellType: 'th' }); + setCurrentPositionInCell(); + }); + + it('allows splitting the cell', async () => { + const mocks = mockChainedCommands(editor, ['splitCell', 'run']); + + createWrapper(); await nextTick(); + await wrapper.findByRole('button', { name: 'Split cell' }).trigger('click'); + + expect(mocks.splitCell).toHaveBeenCalled(); }); + }); - it('does not allow adding a row before the header', () => { - expect(findDropdownItemWithLabelExists('Insert row before')).toBe(false); + describe('when selected cell has rowspan=2 and colspan=2', () => { + beforeEach(() => { + node = { attrs: { rowspan: 2, colspan: 2 } }; + const rect = { top: 1, left: 1, bottom: 3, right: 3 }; + + getSelectedRect.mockReturnValue({ + ...rect, + map: { height: 5, width: 5 }, + }); + + setCurrentPositionInCell(); }); - it('does not allow removing the header row', async () => { - createWrapper({ cellType: 'th' }); + it.each` + type | dropdownItemLabel | commandName + ${'rows'} | ${'Delete 2 rows'} | ${'deleteRow'} + ${'columns'} | ${'Delete 2 columns'} | ${'deleteColumn'} + `('shows correct label for deleting $type', async ({ dropdownItemLabel, commandName }) => { + const mocks = mockChainedCommands(editor, [commandName, 'run']); + + createWrapper(); await nextTick(); + await wrapper.findByRole('button', { name: dropdownItemLabel }).trigger('click'); - expect(findDropdownItemWithLabelExists('Delete row')).toBe(false); + expect(mocks[commandName]).toHaveBeenCalled(); }); }); + + describe.each` + rows | cols | product + ${2} | ${1} | ${2} + ${1} | ${2} | ${2} + ${2} | ${2} | ${4} + `('when $rows x $cols ($product) cells are selected', ({ rows, cols, product }) => { + it.each` + dropdownItemLabel | commandName + ${`Merge ${product} cells`} | ${'mergeCells'} + ${rows === 1 ? 'Delete row' : `Delete ${rows} rows`} | ${'deleteRow'} + ${cols === 1 ? 'Delete column' : `Delete ${cols} columns`} | ${'deleteColumn'} + `( + 'executes $commandName when $dropdownItemLabel is clicked', + async ({ dropdownItemLabel, commandName }) => { + const mocks = mockChainedCommands(editor, [commandName, 'run']); + + getSelectedRect.mockReturnValue({ + top: 0, + left: 0, + bottom: rows, + right: cols, + map: { + height: 4, + width: 4, + }, + }); + + emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' }); + + await nextTick(); + await wrapper.findByRole('button', { name: dropdownItemLabel }).trigger('click'); + + expect(mocks[commandName]).toHaveBeenCalled(); + }, + ); + }); }); }); diff --git a/spec/frontend/content_editor/extensions/code_spec.js b/spec/frontend/content_editor/extensions/code_spec.js index 0a54ac6a96b..4d8629a35c0 100644 --- a/spec/frontend/content_editor/extensions/code_spec.js +++ b/spec/frontend/content_editor/extensions/code_spec.js @@ -1,8 +1,60 @@ +import Bold from '~/content_editor/extensions/bold'; import Code from '~/content_editor/extensions/code'; -import { EXTENSION_PRIORITY_LOWER } from '~/content_editor/constants'; +import { createTestEditor, createDocBuilder } from '../test_utils'; describe('content_editor/extensions/code', () => { - it('has a lower loading priority', () => { - expect(Code.config.priority).toBe(EXTENSION_PRIORITY_LOWER); + let tiptapEditor; + let doc; + let p; + let bold; + let code; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [Bold, Code] }); + + ({ + builders: { doc, p, bold, code }, + } = createDocBuilder({ + tiptapEditor, + names: { + bold: { markType: Bold.name }, + code: { markType: Code.name }, + }, + })); + }); + + it.each` + markOrder | description + ${['bold', 'code']} | ${'bold is toggled before code'} + ${['code', 'bold']} | ${'code is toggled before bold'} + `('has a lower loading priority, when $description', ({ markOrder }) => { + const initialDoc = doc(p('code block')); + const expectedDoc = doc(p(bold(code('code block')))); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.selectAll(); + markOrder.forEach((mark) => tiptapEditor.commands.toggleMark(mark)); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + + describe('shortcut: RightArrow', () => { + it('exits the code block', () => { + const initialDoc = doc(p('You can write ', code('java'))); + const expectedDoc = doc(p('You can write ', code('javascript'), ' here')); + const pos = 25; + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.setTextSelection(pos); + + // insert 'script' after 'java' within the code block + tiptapEditor.commands.insertContent({ type: 'text', text: 'script' }); + + // insert ' here' after the code block + tiptapEditor.commands.keyboardShortcut('ArrowRight'); + tiptapEditor.commands.insertContent({ type: 'text', text: 'here' }); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); }); }); diff --git a/spec/frontend/content_editor/extensions/description_item_spec.js b/spec/frontend/content_editor/extensions/description_item_spec.js new file mode 100644 index 00000000000..02b80d93886 --- /dev/null +++ b/spec/frontend/content_editor/extensions/description_item_spec.js @@ -0,0 +1,121 @@ +import DescriptionList from '~/content_editor/extensions/description_list'; +import DescriptionItem from '~/content_editor/extensions/description_item'; +import { createTestEditor, createDocBuilder, triggerKeyboardInput } from '../test_utils'; + +describe('content_editor/extensions/description_item', () => { + let tiptapEditor; + let doc; + let p; + let descriptionList; + let descriptionItem; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [DescriptionList, DescriptionItem] }); + + ({ + builders: { doc, p, descriptionList, descriptionItem }, + } = createDocBuilder({ + tiptapEditor, + names: { + descriptionList: { nodeType: DescriptionList.name }, + descriptionItem: { nodeType: DescriptionItem.name }, + }, + })); + }); + + describe('shortcut: Enter', () => { + it('splits a description item into two items', () => { + const initialDoc = doc(descriptionList(descriptionItem(p('Description item')))); + const expectedDoc = doc( + descriptionList(descriptionItem(p('Descrip')), descriptionItem(p('tion item'))), + ); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.setTextSelection(10); + tiptapEditor.commands.keyboardShortcut('Enter'); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + + describe('shortcut: Tab', () => { + it('converts a description term into a description details', () => { + const initialDoc = doc(descriptionList(descriptionItem(p('Description item')))); + const expectedDoc = doc( + descriptionList(descriptionItem({ isTerm: false }, p('Description item'))), + ); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.setTextSelection(10); + tiptapEditor.commands.keyboardShortcut('Tab'); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + + it('has no effect on a description details', () => { + const initialDoc = doc( + descriptionList(descriptionItem({ isTerm: false }, p('Description item'))), + ); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.setTextSelection(10); + tiptapEditor.commands.keyboardShortcut('Tab'); + + expect(tiptapEditor.getJSON()).toEqual(initialDoc.toJSON()); + }); + }); + + describe('shortcut: Shift-Tab', () => { + it('converts a description details into a description term', () => { + const initialDoc = doc( + descriptionList( + descriptionItem({ isTerm: false }, p('Description item')), + descriptionItem(p('Description item')), + descriptionItem(p('Description item')), + ), + ); + const expectedDoc = doc( + descriptionList( + descriptionItem(p('Description item')), + descriptionItem(p('Description item')), + descriptionItem(p('Description item')), + ), + ); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.setTextSelection(10); + tiptapEditor.commands.keyboardShortcut('Shift-Tab'); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + + it('lifts a description term', () => { + const initialDoc = doc(descriptionList(descriptionItem(p('Description item')))); + const expectedDoc = doc(p('Description item')); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.setTextSelection(10); + tiptapEditor.commands.keyboardShortcut('Shift-Tab'); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + + describe('capturing keyboard events', () => { + it.each` + key | shiftKey | nodeActive | captured | description + ${'Tab'} | ${false} | ${true} | ${true} | ${'captures Tab key when cursor is inside a description item'} + ${'Tab'} | ${false} | ${false} | ${false} | ${'does not capture Tab key when cursor is not inside a description item'} + ${'Tab'} | ${true} | ${true} | ${true} | ${'captures Shift-Tab key when cursor is inside a description item'} + ${'Tab'} | ${true} | ${false} | ${false} | ${'does not capture Shift-Tab key when cursor is not inside a description item'} + `('$description', ({ key, shiftKey, nodeActive, captured }) => { + const initialDoc = doc(descriptionList(descriptionItem(p('Text content')))); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + + jest.spyOn(tiptapEditor, 'isActive').mockReturnValue(nodeActive); + + expect(triggerKeyboardInput({ tiptapEditor, key, shiftKey })).toBe(captured); + }); + }); +}); diff --git a/spec/frontend/content_editor/extensions/description_list_spec.js b/spec/frontend/content_editor/extensions/description_list_spec.js new file mode 100644 index 00000000000..e46680956ec --- /dev/null +++ b/spec/frontend/content_editor/extensions/description_list_spec.js @@ -0,0 +1,36 @@ +import DescriptionList from '~/content_editor/extensions/description_list'; +import DescriptionItem from '~/content_editor/extensions/description_item'; +import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; + +describe('content_editor/extensions/description_list', () => { + let tiptapEditor; + let doc; + let p; + let descriptionList; + let descriptionItem; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [DescriptionList, DescriptionItem] }); + + ({ + builders: { doc, p, descriptionList, descriptionItem }, + } = createDocBuilder({ + tiptapEditor, + names: { + descriptionList: { nodeType: DescriptionList.name }, + descriptionItem: { nodeType: DescriptionItem.name }, + }, + })); + }); + + it.each` + inputRuleText | insertedNode | insertedNodeType + ${'<dl>'} | ${() => descriptionList(descriptionItem(p()))} | ${'descriptionList'} + ${'<dl'} | ${() => p()} | ${'paragraph'} + ${'dl>'} | ${() => p()} | ${'paragraph'} + `('with input=$input, it inserts a $insertedNodeType node', ({ inputRuleText, insertedNode }) => { + triggerNodeInputRule({ tiptapEditor, inputRuleText }); + + expect(tiptapEditor.getJSON()).toEqual(doc(insertedNode()).toJSON()); + }); +}); diff --git a/spec/frontend/content_editor/extensions/details_content_spec.js b/spec/frontend/content_editor/extensions/details_content_spec.js index 575f3bf65e4..02e2b51366a 100644 --- a/spec/frontend/content_editor/extensions/details_content_spec.js +++ b/spec/frontend/content_editor/extensions/details_content_spec.js @@ -1,6 +1,6 @@ import Details from '~/content_editor/extensions/details'; import DetailsContent from '~/content_editor/extensions/details_content'; -import { createTestEditor, createDocBuilder } from '../test_utils'; +import { createTestEditor, createDocBuilder, triggerKeyboardInput } from '../test_utils'; describe('content_editor/extensions/details_content', () => { let tiptapEditor; @@ -42,7 +42,6 @@ describe('content_editor/extensions/details_content', () => { ); tiptapEditor.commands.setContent(initialDoc.toJSON()); - tiptapEditor.commands.setTextSelection(10); tiptapEditor.commands.keyboardShortcut('Enter'); @@ -66,11 +65,26 @@ describe('content_editor/extensions/details_content', () => { ); tiptapEditor.commands.setContent(initialDoc.toJSON()); - tiptapEditor.commands.setTextSelection(20); tiptapEditor.commands.keyboardShortcut('Shift-Tab'); expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); }); }); + + describe('capturing keyboard events', () => { + it.each` + key | shiftKey | nodeActive | captured | description + ${'Tab'} | ${true} | ${true} | ${true} | ${'captures Shift-Tab key when cursor is inside a details content'} + ${'Tab'} | ${true} | ${false} | ${false} | ${'does not capture Shift-Tab key when cursor is not inside a details content'} + `('$description', ({ key, shiftKey, nodeActive, captured }) => { + const initialDoc = doc(details(detailsContent(p('Text content')))); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + + jest.spyOn(tiptapEditor, 'isActive').mockReturnValue(nodeActive); + + expect(triggerKeyboardInput({ tiptapEditor, key, shiftKey })).toBe(captured); + }); + }); }); diff --git a/spec/frontend/content_editor/extensions/details_spec.js b/spec/frontend/content_editor/extensions/details_spec.js index cd59943982f..ce97444ec19 100644 --- a/spec/frontend/content_editor/extensions/details_spec.js +++ b/spec/frontend/content_editor/extensions/details_spec.js @@ -1,6 +1,6 @@ import Details from '~/content_editor/extensions/details'; import DetailsContent from '~/content_editor/extensions/details_content'; -import { createTestEditor, createDocBuilder } from '../test_utils'; +import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; describe('content_editor/extensions/details', () => { let tiptapEditor; @@ -75,18 +75,13 @@ describe('content_editor/extensions/details', () => { }); it.each` - input | insertedNode - ${'<details>'} | ${(...args) => details(detailsContent(p(...args)))} - ${'<details'} | ${(...args) => p(...args)} - ${'details>'} | ${(...args) => p(...args)} - `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => { - const { view } = tiptapEditor; - const { selection } = view.state; - const expectedDoc = doc(insertedNode()); - - // Triggers the event handler that input rules listen to - view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, input)); - - expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + inputRuleText | insertedNode | insertedNodeType + ${'<details>'} | ${() => details(detailsContent(p()))} | ${'details'} + ${'<details'} | ${() => p()} | ${'paragraph'} + ${'details>'} | ${() => p()} | ${'paragraph'} + `('with input=$input, it inserts a $insertedNodeType node', ({ inputRuleText, insertedNode }) => { + triggerNodeInputRule({ tiptapEditor, inputRuleText }); + + expect(tiptapEditor.getJSON()).toEqual(doc(insertedNode()).toJSON()); }); }); diff --git a/spec/frontend/content_editor/extensions/drawio_diagram_spec.js b/spec/frontend/content_editor/extensions/drawio_diagram_spec.js index 61dc164c99a..63ed08096b2 100644 --- a/spec/frontend/content_editor/extensions/drawio_diagram_spec.js +++ b/spec/frontend/content_editor/extensions/drawio_diagram_spec.js @@ -1,6 +1,5 @@ import DrawioDiagram from '~/content_editor/extensions/drawio_diagram'; import Image from '~/content_editor/extensions/image'; -import createAssetResolver from '~/content_editor/services/asset_resolver'; import { create } from '~/drawio/content_editor_facade'; import { launchDrawioEditor } from '~/drawio/drawio_editor'; import { createTestEditor, createDocBuilder } from '../test_utils'; @@ -19,12 +18,15 @@ describe('content_editor/extensions/drawio_diagram', () => { let paragraph; let image; let drawioDiagram; + let assetResolver; + const uploadsPath = '/uploads'; - const renderMarkdown = () => {}; beforeEach(() => { + assetResolver = new (class {})(); + tiptapEditor = createTestEditor({ - extensions: [Image, DrawioDiagram.configure({ uploadsPath, renderMarkdown })], + extensions: [Image, DrawioDiagram.configure({ uploadsPath, assetResolver })], }); const { builders } = createDocBuilder({ tiptapEditor, @@ -72,19 +74,12 @@ describe('content_editor/extensions/drawio_diagram', () => { describe('createOrEditDiagram command', () => { let editorFacade; - let assetResolver; beforeEach(() => { editorFacade = {}; - assetResolver = {}; tiptapEditor.commands.createOrEditDiagram(); create.mockReturnValueOnce(editorFacade); - createAssetResolver.mockReturnValueOnce(assetResolver); - }); - - it('creates a new instance of asset resolver', () => { - expect(createAssetResolver).toHaveBeenCalledWith({ renderMarkdown }); }); it('creates a new instance of the content_editor_facade', () => { diff --git a/spec/frontend/content_editor/extensions/paste_markdown_spec.js b/spec/frontend/content_editor/extensions/paste_markdown_spec.js index c9997e3c58f..baf0919fec8 100644 --- a/spec/frontend/content_editor/extensions/paste_markdown_spec.js +++ b/spec/frontend/content_editor/extensions/paste_markdown_spec.js @@ -4,24 +4,28 @@ import Diagram from '~/content_editor/extensions/diagram'; import Frontmatter from '~/content_editor/extensions/frontmatter'; import Heading from '~/content_editor/extensions/heading'; import Bold from '~/content_editor/extensions/bold'; +import Italic from '~/content_editor/extensions/italic'; import { VARIANT_DANGER } from '~/alert'; import eventHubFactory from '~/helpers/event_hub_factory'; import { ALERT_EVENT } from '~/content_editor/constants'; import waitForPromises from 'helpers/wait_for_promises'; +import MarkdownSerializer from '~/content_editor/services/markdown_serializer'; import { createTestEditor, createDocBuilder, waitUntilNextDocTransaction } from '../test_utils'; const CODE_BLOCK_HTML = '<pre class="js-syntax-highlight" lang="javascript">var a = 2;</pre>'; const DIAGRAM_HTML = '<img data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,WzxmcmFtZT5EZWNvcmF0b3IgcGF0dGVybl0=">'; const FRONTMATTER_HTML = '<pre lang="yaml" data-lang-params="frontmatter">key: value</pre>'; -const PARAGRAPH_HTML = '<p>Just a regular paragraph</p>'; +const PARAGRAPH_HTML = '<p>Some text with <strong>bold</strong> and <em>italic</em> text.</p>'; describe('content_editor/extensions/paste_markdown', () => { let tiptapEditor; let doc; let p; let bold; + let italic; let heading; + let codeBlock; let renderMarkdown; let eventHub; const defaultData = { 'text/plain': '**bold text**' }; @@ -35,28 +39,36 @@ describe('content_editor/extensions/paste_markdown', () => { tiptapEditor = createTestEditor({ extensions: [ Bold, + Italic, CodeBlockHighlight, Diagram, Frontmatter, Heading, - PasteMarkdown.configure({ renderMarkdown, eventHub }), + PasteMarkdown.configure({ renderMarkdown, eventHub, serializer: new MarkdownSerializer() }), ], }); ({ - builders: { doc, p, bold, heading }, + builders: { doc, p, bold, italic, heading, codeBlock }, } = createDocBuilder({ tiptapEditor, names: { bold: { markType: Bold.name }, + italic: { markType: Italic.name }, heading: { nodeType: Heading.name }, + codeBlock: { nodeType: CodeBlockHighlight.name }, }, })); }); - const buildClipboardEvent = ({ data = {}, types = ['text/plain'] } = {}) => { - return Object.assign(new Event('paste'), { - clipboardData: { types, getData: jest.fn((type) => data[type] || defaultData[type]) }, + const buildClipboardEvent = ({ eventName = 'paste', data = {}, types = ['text/plain'] } = {}) => { + return Object.assign(new Event(eventName), { + clipboardData: { + types, + getData: jest.fn((type) => data[type] || defaultData[type]), + setData: jest.fn(), + clearData: jest.fn(), + }, }); }; @@ -80,13 +92,13 @@ describe('content_editor/extensions/paste_markdown', () => { }; it.each` - types | data | handled | desc - ${['text/plain']} | ${{}} | ${true} | ${'handles plain text'} - ${['text/plain', 'text/html']} | ${{}} | ${false} | ${'doesn’t handle html format'} - ${['text/plain', 'text/html', 'vscode-editor-data']} | ${{ 'vscode-editor-data': '{ "mode": "markdown" }' }} | ${true} | ${'handles vscode markdown'} - ${['text/plain', 'text/html', 'vscode-editor-data']} | ${{ 'vscode-editor-data': '{ "mode": "ruby" }' }} | ${false} | ${'doesn’t vscode code snippet'} - `('$desc', async ({ types, handled, data }) => { - expect(await triggerPasteEventHandler(buildClipboardEvent({ types, data }))).toBe(handled); + types | data | formatDesc + ${['text/plain']} | ${{}} | ${'plain text'} + ${['text/plain', 'text/html']} | ${{}} | ${'html format'} + ${['text/plain', 'text/html', 'vscode-editor-data']} | ${{ 'vscode-editor-data': '{ "mode": "markdown" }' }} | ${'vscode markdown'} + ${['text/plain', 'text/html', 'vscode-editor-data']} | ${{ 'vscode-editor-data': '{ "mode": "ruby" }' }} | ${'vscode snippet'} + `('handles $formatDesc', async ({ types, data }) => { + expect(await triggerPasteEventHandler(buildClipboardEvent({ types, data }))).toBe(true); }); it.each` @@ -101,6 +113,45 @@ describe('content_editor/extensions/paste_markdown', () => { expect(await triggerPasteEventHandler(buildClipboardEvent())).toBe(handled); }); + describe.each` + eventName | expectedDoc + ${'cut'} | ${() => doc(p())} + ${'copy'} | ${() => doc(p('Some text with ', bold('bold'), ' and ', italic('italic'), ' text.'))} + `('when $eventName event is triggered', ({ eventName, expectedDoc }) => { + let event; + beforeEach(() => { + event = buildClipboardEvent({ eventName }); + + jest.spyOn(event, 'preventDefault'); + jest.spyOn(event, 'stopPropagation'); + + tiptapEditor.commands.insertContent(PARAGRAPH_HTML); + tiptapEditor.commands.selectAll(); + tiptapEditor.view.dispatchEvent(event); + }); + + it('prevents default', () => { + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it('sets the clipboard data', () => { + expect(event.clipboardData.setData).toHaveBeenCalledWith( + 'text/plain', + 'Some text with bold and italic text.', + ); + expect(event.clipboardData.setData).toHaveBeenCalledWith('text/html', PARAGRAPH_HTML); + expect(event.clipboardData.setData).toHaveBeenCalledWith( + 'text/x-gfm', + 'Some text with **bold** and _italic_ text.', + ); + }); + + it('modifies the document', () => { + expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc().toJSON()); + }); + }); + describe('when pasting raw markdown source', () => { describe('when rendering markdown succeeds', () => { beforeEach(() => { @@ -162,6 +213,97 @@ describe('content_editor/extensions/paste_markdown', () => { }); }); + describe('when pasting html content', () => { + it('strips out any stray div, pre, span tags', async () => { + renderMarkdown.mockResolvedValueOnce( + '<div><span dir="auto"><strong>bold text</strong></span></div><pre><code>some code</code></pre>', + ); + + const expectedDoc = doc(p(bold('bold text')), p('some code')); + + await triggerPasteEventHandlerAndWaitForTransaction( + buildClipboardEvent({ + types: ['text/html'], + data: { + 'text/html': + '<div><span dir="auto"><strong>bold text</strong></span></div><pre><code>some code</code></pre>', + }, + }), + ); + + expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + + describe('when pasting text/x-gfm', () => { + it('processes the content as markdown, even if html content exists', async () => { + renderMarkdown.mockResolvedValueOnce('<strong>bold text</strong>'); + + const expectedDoc = doc(p(bold('bold text'))); + + await triggerPasteEventHandlerAndWaitForTransaction( + buildClipboardEvent({ + types: ['text/x-gfm'], + data: { + 'text/x-gfm': '**bold text**', + 'text/plain': 'irrelevant text', + 'text/html': '<div>some random irrelevant html</div>', + }, + }), + ); + + expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + + describe('when pasting vscode-editor-data', () => { + it('pastes the content as a code block', async () => { + renderMarkdown.mockResolvedValueOnce( + '<div class="gl-relative markdown-code-block js-markdown-code">
<pre data-sourcepos="1:1-3:3" data-canonical-lang="ruby" class="code highlight js-syntax-highlight language-ruby" lang="ruby" v-pre="true"><code><span id="LC1" class="line" lang="ruby"><span class="nb">puts</span> <span class="s2">"Hello World"</span></span></code></pre>
<copy-code></copy-code>
</div>', + ); + + const expectedDoc = doc( + codeBlock( + { language: 'ruby', class: 'code highlight js-syntax-highlight language-ruby' }, + 'puts "Hello World"', + ), + ); + + await triggerPasteEventHandlerAndWaitForTransaction( + buildClipboardEvent({ + types: ['vscode-editor-data', 'text/plain', 'text/html'], + data: { + 'vscode-editor-data': '{ "version": 1, "mode": "ruby" }', + 'text/plain': 'puts "Hello World"', + 'text/html': + '<meta charset=\'utf-8\'><div style="color: #d4d4d4;background-color: #1e1e1e;font-family: \'Fira Code\', Menlo, Monaco, \'Courier New\', monospace, Menlo, Monaco, \'Courier New\', monospace;font-weight: normal;font-size: 14px;line-height: 21px;white-space: pre;"><div><span style="color: #dcdcaa;">puts</span><span style="color: #d4d4d4;"> </span><span style="color: #ce9178;">"Hello world"</span></div></div>', + }, + }), + ); + + expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); + }); + + it('pastes as regular markdown if language is markdown', async () => { + renderMarkdown.mockResolvedValueOnce('<p><strong>bold text</strong></p>'); + + const expectedDoc = doc(p(bold('bold text'))); + + await triggerPasteEventHandlerAndWaitForTransaction( + buildClipboardEvent({ + types: ['vscode-editor-data', 'text/plain', 'text/html'], + data: { + 'vscode-editor-data': '{ "version": 1, "mode": "markdown" }', + 'text/plain': '**bold text**', + 'text/html': '<p><strong>bold text</strong></p>', + }, + }), + ); + + expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + describe('when rendering markdown fails', () => { beforeEach(() => { renderMarkdown.mockRejectedValueOnce(); diff --git a/spec/frontend/content_editor/extensions/reference_spec.js b/spec/frontend/content_editor/extensions/reference_spec.js new file mode 100644 index 00000000000..c25c7c41d75 --- /dev/null +++ b/spec/frontend/content_editor/extensions/reference_spec.js @@ -0,0 +1,162 @@ +import Reference from '~/content_editor/extensions/reference'; +import AssetResolver from '~/content_editor/services/asset_resolver'; +import { + RESOLVED_ISSUE_HTML, + RESOLVED_MERGE_REQUEST_HTML, + RESOLVED_EPIC_HTML, +} from '../test_constants'; +import { + createTestEditor, + createDocBuilder, + triggerNodeInputRule, + waitUntilTransaction, +} from '../test_utils'; + +describe('content_editor/extensions/reference', () => { + let tiptapEditor; + let doc; + let p; + let reference; + let renderMarkdown; + let assetResolver; + + beforeEach(() => { + renderMarkdown = jest.fn().mockImplementation(() => new Promise(() => {})); + assetResolver = new AssetResolver({ renderMarkdown }); + + tiptapEditor = createTestEditor({ + extensions: [Reference.configure({ assetResolver })], + }); + + ({ + builders: { doc, p, reference }, + } = createDocBuilder({ + tiptapEditor, + names: { + reference: { nodeType: Reference.name }, + }, + })); + }); + + describe('when typing a valid reference input rule', () => { + const buildExpectedDoc = (href, originalText, referenceType, text) => + doc(p(reference({ className: null, href, originalText, referenceType, text }), ' ')); + + it.each` + inputRuleText | mockReferenceHtml | expectedDoc + ${'#1 '} | ${RESOLVED_ISSUE_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/issues/1', '#1', 'issue', '#1 (closed)')} + ${'#1+ '} | ${RESOLVED_ISSUE_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/issues/1', '#1+', 'issue', '500 error on MR approvers edit page (#1 - closed)')} + ${'#1+s '} | ${RESOLVED_ISSUE_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/issues/1', '#1+s', 'issue', '500 error on MR approvers edit page (#1 - closed) • Unassigned')} + ${'!1 '} | ${RESOLVED_MERGE_REQUEST_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/merge_requests/1', '!1', 'merge_request', '!1 (merged)')} + ${'!1+ '} | ${RESOLVED_MERGE_REQUEST_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/merge_requests/1', '!1+', 'merge_request', 'Enhance the LDAP group synchronization (!1 - merged)')} + ${'!1+s '} | ${RESOLVED_MERGE_REQUEST_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/merge_requests/1', '!1+s', 'merge_request', 'Enhance the LDAP group synchronization (!1 - merged) • John Doe')} + ${'&1 '} | ${RESOLVED_EPIC_HTML} | ${() => buildExpectedDoc('/groups/gitlab-org/-/epics/1', '&1', 'epic', '&1')} + ${'&1+ '} | ${RESOLVED_EPIC_HTML} | ${() => buildExpectedDoc('/groups/gitlab-org/-/epics/1', '&1+', 'epic', 'Approvals in merge request list (&1)')} + `( + 'replaces the input rule ($inputRuleText) with a reference node', + async ({ inputRuleText, mockReferenceHtml, expectedDoc }) => { + await waitUntilTransaction({ + number: 2, + tiptapEditor, + action() { + renderMarkdown.mockResolvedValueOnce(mockReferenceHtml); + + tiptapEditor.commands.insertContent({ type: 'text', text: inputRuleText }); + triggerNodeInputRule({ tiptapEditor, inputRuleText }); + }, + }); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc().toJSON()); + }, + ); + + it('resolves multiple references in the same paragraph correctly', async () => { + await waitUntilTransaction({ + number: 2, + tiptapEditor, + action() { + renderMarkdown.mockResolvedValueOnce(RESOLVED_ISSUE_HTML); + + tiptapEditor.commands.insertContent({ type: 'text', text: '#1+ ' }); + triggerNodeInputRule({ tiptapEditor, inputRuleText: '#1+ ' }); + }, + }); + + await waitUntilTransaction({ + number: 2, + tiptapEditor, + action() { + renderMarkdown.mockResolvedValueOnce(RESOLVED_MERGE_REQUEST_HTML); + + tiptapEditor.commands.insertContent({ type: 'text', text: 'was resolved with !1+ ' }); + triggerNodeInputRule({ tiptapEditor, inputRuleText: 'was resolved with !1+ ' }); + }, + }); + + expect(tiptapEditor.getJSON()).toEqual( + doc( + p( + reference({ + referenceType: 'issue', + originalText: '#1+', + text: '500 error on MR approvers edit page (#1 - closed)', + href: '/gitlab-org/gitlab/-/issues/1', + }), + ' was resolved with ', + reference({ + referenceType: 'merge_request', + originalText: '!1+', + text: 'Enhance the LDAP group synchronization (!1 - merged)', + href: '/gitlab-org/gitlab/-/merge_requests/1', + }), + ' ', + ), + ).toJSON(), + ); + }); + + it('resolves the input rule lazily in the correct position if the user makes a change before the request resolves', async () => { + let resolvePromise; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + renderMarkdown.mockImplementation(() => promise); + + tiptapEditor.commands.insertContent({ type: 'text', text: '#1+ ' }); + triggerNodeInputRule({ tiptapEditor, inputRuleText: '#1+ ' }); + + // insert a new paragraph at a random location + tiptapEditor.commands.insertContentAt(0, { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello' }], + }); + + // update selection + tiptapEditor.commands.selectAll(); + + await waitUntilTransaction({ + number: 1, + tiptapEditor, + action() { + resolvePromise(RESOLVED_ISSUE_HTML); + }, + }); + + expect(tiptapEditor.state.doc).toEqual( + doc( + p('Hello'), + p( + reference({ + referenceType: 'issue', + originalText: '#1+', + text: '500 error on MR approvers edit page (#1 - closed)', + href: '/gitlab-org/gitlab/-/issues/1', + }), + ' ', + ), + ), + ); + }); + }); +}); diff --git a/spec/frontend/content_editor/remark_markdown_processing_spec.js b/spec/frontend/content_editor/remark_markdown_processing_spec.js index 359e69c083a..927a7d59899 100644 --- a/spec/frontend/content_editor/remark_markdown_processing_spec.js +++ b/spec/frontend/content_editor/remark_markdown_processing_spec.js @@ -30,7 +30,7 @@ import TaskList from '~/content_editor/extensions/task_list'; import TaskItem from '~/content_editor/extensions/task_item'; import Video from '~/content_editor/extensions/video'; import remarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; -import markdownSerializer from '~/content_editor/services/markdown_serializer'; +import MarkdownSerializer from '~/content_editor/services/markdown_serializer'; import { SAFE_VIDEO_EXT, SAFE_AUDIO_EXT, DIAGRAM_LANGUAGES } from '~/content_editor/constants'; import { createTestEditor, createDocBuilder } from './test_utils'; @@ -158,7 +158,7 @@ describe('Client side Markdown processing', () => { }; const serialize = (document) => - markdownSerializer({}).serialize({ + new MarkdownSerializer().serialize({ doc: document, pristineDoc: document, }); diff --git a/spec/frontend/content_editor/services/asset_resolver_spec.js b/spec/frontend/content_editor/services/asset_resolver_spec.js index 0a99f823be3..292eec6db77 100644 --- a/spec/frontend/content_editor/services/asset_resolver_spec.js +++ b/spec/frontend/content_editor/services/asset_resolver_spec.js @@ -1,4 +1,9 @@ -import createAssetResolver from '~/content_editor/services/asset_resolver'; +import AssetResolver from '~/content_editor/services/asset_resolver'; +import { + RESOLVED_ISSUE_HTML, + RESOLVED_MERGE_REQUEST_HTML, + RESOLVED_EPIC_HTML, +} from '../test_constants'; describe('content_editor/services/asset_resolver', () => { let renderMarkdown; @@ -6,7 +11,7 @@ describe('content_editor/services/asset_resolver', () => { beforeEach(() => { renderMarkdown = jest.fn(); - assetResolver = createAssetResolver({ renderMarkdown }); + assetResolver = new AssetResolver({ renderMarkdown }); }); describe('resolveUrl', () => { @@ -21,6 +26,65 @@ describe('content_editor/services/asset_resolver', () => { }); }); + describe('resolveReference', () => { + const resolvedEpic = { + expandedText: 'Approvals in merge request list (&1)', + fullyExpandedText: 'Approvals in merge request list (&1)', + href: '/groups/gitlab-org/-/epics/1', + text: '&1', + }; + + const resolvedIssue = { + expandedText: '500 error on MR approvers edit page (#1 - closed)', + fullyExpandedText: '500 error on MR approvers edit page (#1 - closed) • Unassigned', + href: '/gitlab-org/gitlab/-/issues/1', + text: '#1 (closed)', + }; + + const resolvedMergeRequest = { + expandedText: 'Enhance the LDAP group synchronization (!1 - merged)', + fullyExpandedText: 'Enhance the LDAP group synchronization (!1 - merged) • John Doe', + href: '/gitlab-org/gitlab/-/merge_requests/1', + text: '!1 (merged)', + }; + + describe.each` + referenceType | referenceId | sentMarkdown | returnedHtml | resolvedReference + ${'issue'} | ${'#1'} | ${'#1 #1+ #1+s'} | ${RESOLVED_ISSUE_HTML} | ${resolvedIssue} + ${'merge_request'} | ${'!1'} | ${'!1 !1+ !1+s'} | ${RESOLVED_MERGE_REQUEST_HTML} | ${resolvedMergeRequest} + ${'epic'} | ${'&1'} | ${'&1 &1+ &1+s'} | ${RESOLVED_EPIC_HTML} | ${resolvedEpic} + `( + 'for reference type $referenceType', + ({ referenceType, referenceId, sentMarkdown, returnedHtml, resolvedReference }) => { + it(`resolves ${referenceType} reference to href, text, title and summary`, async () => { + renderMarkdown.mockResolvedValue(returnedHtml); + + expect(await assetResolver.resolveReference(referenceId)).toEqual(resolvedReference); + }); + + it.each` + suffix + ${''} + ${'+'} + ${'+s'} + `('strips suffix ("$suffix") before resolving', ({ suffix }) => { + assetResolver.resolveReference(referenceId + suffix); + expect(renderMarkdown).toHaveBeenCalledWith(sentMarkdown); + }); + }, + ); + + it.each` + case | sentMarkdown | returnedHtml + ${'no html is returned'} | ${''} | ${''} + ${'html contains no anchor tags'} | ${'no anchor tags'} | ${'<p>no anchor tags</p>'} + `('returns an empty object if $case', async ({ sentMarkdown, returnedHtml }) => { + renderMarkdown.mockResolvedValue(returnedHtml); + + expect(await assetResolver.resolveReference(sentMarkdown)).toEqual({}); + }); + }); + describe('renderDiagram', () => { it('resolves a diagram code to a url containing the diagram image', async () => { renderMarkdown.mockResolvedValue( diff --git a/spec/frontend/content_editor/services/create_content_editor_spec.js b/spec/frontend/content_editor/services/create_content_editor_spec.js index 53cd51b8c5f..b9a9c3ccd17 100644 --- a/spec/frontend/content_editor/services/create_content_editor_spec.js +++ b/spec/frontend/content_editor/services/create_content_editor_spec.js @@ -2,6 +2,7 @@ import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '~/content_editor/constants import { createContentEditor } from '~/content_editor/services/create_content_editor'; import createGlApiMarkdownDeserializer from '~/content_editor/services/gl_api_markdown_deserializer'; import createRemarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; +import AssetResolver from '~/content_editor/services/asset_resolver'; import { createTestContentEditorExtension } from '../test_utils'; jest.mock('~/emoji'); @@ -89,7 +90,7 @@ describe('content_editor/services/create_content_editor', () => { .options, ).toMatchObject({ uploadsPath, - renderMarkdown, + assetResolver: expect.any(AssetResolver), }); }); }); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 3729b303cc6..4521822042c 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -26,6 +26,8 @@ import Link from '~/content_editor/extensions/link'; import ListItem from '~/content_editor/extensions/list_item'; import OrderedList from '~/content_editor/extensions/ordered_list'; import Paragraph from '~/content_editor/extensions/paragraph'; +import Reference from '~/content_editor/extensions/reference'; +import ReferenceLabel from '~/content_editor/extensions/reference_label'; import ReferenceDefinition from '~/content_editor/extensions/reference_definition'; import Sourcemap from '~/content_editor/extensions/sourcemap'; import Strike from '~/content_editor/extensions/strike'; @@ -35,7 +37,7 @@ 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 markdownSerializer from '~/content_editor/services/markdown_serializer'; +import MarkdownSerializer from '~/content_editor/services/markdown_serializer'; import remarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; import { createTiptapEditor, createDocBuilder } from '../test_utils'; @@ -43,6 +45,8 @@ jest.mock('~/emoji'); const tiptapEditor = createTiptapEditor([Sourcemap]); +const text = (val) => tiptapEditor.state.schema.text(val); + const { builders: { audio, @@ -76,6 +80,8 @@ const { orderedList, paragraph, referenceDefinition, + reference, + referenceLabel, strike, table, tableCell, @@ -116,6 +122,8 @@ const { orderedList: { nodeType: OrderedList.name }, paragraph: { nodeType: Paragraph.name }, referenceDefinition: { nodeType: ReferenceDefinition.name }, + reference: { nodeType: Reference.name }, + referenceLabel: { nodeType: ReferenceLabel.name }, strike: { markType: Strike.name }, table: { nodeType: Table.name }, tableCell: { nodeType: TableCell.name }, @@ -134,7 +142,7 @@ const { }); const serialize = (...content) => - markdownSerializer({}).serialize({ + new MarkdownSerializer().serialize({ doc: doc(...content), }); @@ -148,14 +156,18 @@ describe('markdownSerializer', () => { }); it('correctly serializes code blocks wrapped by italics and bold marks', () => { - const text = 'code block'; - - expect(serialize(paragraph(italic(code(text))))).toBe(`_\`${text}\`_`); - expect(serialize(paragraph(code(italic(text))))).toBe(`_\`${text}\`_`); - expect(serialize(paragraph(bold(code(text))))).toBe(`**\`${text}\`**`); - expect(serialize(paragraph(code(bold(text))))).toBe(`**\`${text}\`**`); - expect(serialize(paragraph(strike(code(text))))).toBe(`~~\`${text}\`~~`); - expect(serialize(paragraph(code(strike(text))))).toBe(`~~\`${text}\`~~`); + const codeBlockContent = 'code block'; + + expect(serialize(paragraph(italic(code(codeBlockContent))))).toBe(`_\`${codeBlockContent}\`_`); + expect(serialize(paragraph(code(italic(codeBlockContent))))).toBe(`_\`${codeBlockContent}\`_`); + expect(serialize(paragraph(bold(code(codeBlockContent))))).toBe(`**\`${codeBlockContent}\`**`); + expect(serialize(paragraph(code(bold(codeBlockContent))))).toBe(`**\`${codeBlockContent}\`**`); + expect(serialize(paragraph(strike(code(codeBlockContent))))).toBe( + `~~\`${codeBlockContent}\`~~`, + ); + expect(serialize(paragraph(code(strike(codeBlockContent))))).toBe( + `~~\`${codeBlockContent}\`~~`, + ); }); it('correctly serializes inline diff', () => { @@ -166,7 +178,7 @@ describe('markdownSerializer', () => { inlineDiff({ type: 'deletion' }, '-10 lines'), ), ), - ).toBe('{++30 lines+}{--10 lines-}'); + ).toBe('{+\\+30 lines+}{-\\-10 lines-}'); }); it('correctly serializes highlight', () => { @@ -199,6 +211,12 @@ hi ); }); + it('escapes < and > in a paragraph', () => { + expect( + serialize(paragraph(text("some prose: <this> and </this> looks like code, but isn't"))), + ).toBe("some prose: \\<this\\> and \\</this\\> looks like code, but isn't"); + }); + it('correctly serializes a line break', () => { expect(serialize(paragraph('hello', hardBreak(), 'world'))).toBe('hello\\\nworld'); }); @@ -281,6 +299,90 @@ hi ).toBe('![GitLab][gitlab-url]'); }); + it('correctly serializes references', () => { + expect( + serialize( + paragraph( + reference({ + referenceType: 'issue', + originalText: '#123', + href: '/gitlab-org/gitlab-test/-/issues/123', + text: '#123', + }), + ), + ), + ).toBe('#123'); + }); + + it('correctly renders a reference label', () => { + expect( + serialize( + paragraph( + referenceLabel({ + referenceType: 'label', + originalText: '~foo', + href: '/gitlab-org/gitlab-test/-/labels/foo', + text: '~foo', + }), + ), + ), + ).toBe('~foo'); + }); + + it('correctly renders a reference label without originalText', () => { + expect( + serialize( + paragraph( + referenceLabel({ + referenceType: 'label', + href: '/gitlab-org/gitlab-test/-/labels/foo', + text: 'Foo Bar', + }), + ), + ), + ).toBe('~"Foo Bar"'); + }); + + it('ensures spaces between multiple references', () => { + expect( + serialize( + paragraph( + reference({ + referenceType: 'issue', + originalText: '#123', + href: '/gitlab-org/gitlab-test/-/issues/123', + text: '#123', + }), + referenceLabel({ + referenceType: 'label', + originalText: '~foo', + href: '/gitlab-org/gitlab-test/-/labels/foo', + text: '~foo', + }), + reference({ + referenceType: 'issue', + originalText: '#456', + href: '/gitlab-org/gitlab-test/-/issues/456', + text: '#456', + }), + ), + paragraph( + reference({ + referenceType: 'command', + originalText: '/assign_reviewer', + text: '/assign_reviewer', + }), + reference({ + referenceType: 'user', + originalText: '@johndoe', + href: '/johndoe', + text: '@johndoe', + }), + ), + ), + ).toBe('#123 ~foo #456\n\n/assign_reviewer @johndoe'); + }); + it.each` src ${'data:image/png;base64,iVBORw0KGgoAAAAN'} @@ -789,7 +891,8 @@ content 2 expect( serialize( details( - detailsContent(paragraph('dream level 1')), + // if paragraph contains special characters, it should be escaped and rendered as block + detailsContent(paragraph('dream level 1*')), detailsContent( details( detailsContent(paragraph('dream level 2')), @@ -806,7 +909,10 @@ content 2 ).toBe( ` <details> -<summary>dream level 1</summary> +<summary> + +dream level 1\\* +</summary> <details> <summary>dream level 2</summary> @@ -912,6 +1018,31 @@ _An elephant at sunset_ ); }); + it('correctly serializes a table with a pipe in a cell', () => { + expect( + serialize( + table( + tableRow( + tableHeader(paragraph('header')), + tableHeader(paragraph('header')), + tableHeader(paragraph('header')), + ), + tableRow( + tableCell(paragraph('cell')), + tableCell(paragraph('cell | cell')), + tableCell(paragraph(bold('a|b|c'))), + ), + ), + ).trim(), + ).toBe( + ` +| header | header | header | +|--------|--------|--------| +| cell | cell \\| cell | **a\\|b\\|c** | + `.trim(), + ); + }); + it('correctly renders a table with checkboxes', () => { expect( serialize( @@ -1022,7 +1153,8 @@ _An elephant at sunset_ table( tableRow( tableHeader(paragraph('examples of')), - tableHeader(paragraph('block content')), + // if a node contains special characters, it should be escaped and rendered as block + tableHeader(paragraph('block content*')), tableHeader(paragraph('in tables')), tableHeader(paragraph('in content editor')), ), @@ -1079,7 +1211,10 @@ _An elephant at sunset_ <table> <tr> <th>examples of</th> -<th>block content</th> +<th> + +block content\\* +</th> <th>in tables</th> <th>in content editor</th> </tr> @@ -1425,9 +1560,6 @@ paragraph ${'link'} | ${'link(https://www.gitlab.com)'} | ${'modified link(https://www.gitlab.com)'} | ${prependContentEditAction} ${'link'} | ${'link(engineering@gitlab.com)'} | ${'modified link(engineering@gitlab.com)'} | ${prependContentEditAction} ${'link'} | ${'link <https://www.gitlab.com>'} | ${'modified link <https://www.gitlab.com>'} | ${prependContentEditAction} - ${'link'} | ${'link [https://www.gitlab.com>'} | ${'modified link \\[https://www.gitlab.com>'} | ${prependContentEditAction} - ${'link'} | ${'link <https://www.gitlab.com'} | ${'modified link <https://www.gitlab.com'} | ${prependContentEditAction} - ${'link'} | ${'link https://www.gitlab.com>'} | ${'modified link https://www.gitlab.com>'} | ${prependContentEditAction} ${'link'} | ${'link https://www.gitlab.com/path'} | ${'modified link https://www.gitlab.com/path'} | ${prependContentEditAction} ${'link'} | ${'link https://www.gitlab.com?query=search'} | ${'modified link https://www.gitlab.com?query=search'} | ${prependContentEditAction} ${'link'} | ${'link https://www.gitlab.com/#fragment'} | ${'modified link https://www.gitlab.com/#fragment'} | ${prependContentEditAction} @@ -1460,7 +1592,7 @@ paragraph editAction(document); - const serialized = markdownSerializer({}).serialize({ + const serialized = new MarkdownSerializer().serialize({ pristineDoc: document, doc: tiptapEditor.state.doc, }); diff --git a/spec/frontend/content_editor/test_constants.js b/spec/frontend/content_editor/test_constants.js index 749f1234de0..cbd4f555e97 100644 --- a/spec/frontend/content_editor/test_constants.js +++ b/spec/frontend/content_editor/test_constants.js @@ -35,3 +35,12 @@ export const PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML = `<p data-sourcepos="1 export const PROJECT_WIKI_ATTACHMENT_LINK_HTML = `<p data-sourcepos="1:1-1:26" dir="auto"> <a href="/group1/project1/-/wikis/test-file.zip" data-canonical-src="test-file.zip">test-file</a> </p>`; + +export const RESOLVED_ISSUE_HTML = + '<p data-sourcepos="1:1-1:11" dir="auto"><a href="/gitlab-org/gitlab/-/issues/1" data-reference-type="issue" data-original="#1" data-link="false" data-link-reference="false" data-project="278964" data-issue="382515" data-project-path="gitlab-org/gitlab" data-iid="1" data-issue-type="issue" data-container="body" data-placement="top" title="500 error on MR approvers edit page" class="gfm gfm-issue">#1 (closed)</a> <a href="/gitlab-org/gitlab/-/issues/1" data-reference-type="issue" data-original="#1+" data-link="false" data-link-reference="false" data-project="278964" data-issue="382515" data-project-path="gitlab-org/gitlab" data-iid="1" data-reference-format="+" data-issue-type="issue" data-container="body" data-placement="top" title="500 error on MR approvers edit page" class="gfm gfm-issue">500 error on MR approvers edit page (#1 - closed)</a> <a href="/gitlab-org/gitlab/-/issues/1" data-reference-type="issue" data-original="#1+s" data-link="false" data-link-reference="false" data-project="278964" data-issue="382515" data-project-path="gitlab-org/gitlab" data-iid="1" data-reference-format="+s" data-issue-type="issue" data-container="body" data-placement="top" title="500 error on MR approvers edit page" class="gfm gfm-issue">500 error on MR approvers edit page (#1 - closed) • Unassigned</a></p>'; + +export const RESOLVED_MERGE_REQUEST_HTML = + '<p data-sourcepos="1:1-1:11" dir="auto"><a href="/gitlab-org/gitlab/-/merge_requests/1" data-reference-type="merge_request" data-original="!1" data-link="false" data-link-reference="false" data-project="278964" data-merge-request="83382" data-project-path="gitlab-org/gitlab" data-iid="1" data-container="body" data-placement="top" title="Enhance the LDAP group synchronization" class="gfm gfm-merge_request">!1 (merged)</a> <a href="/gitlab-org/gitlab/-/merge_requests/1" data-reference-type="merge_request" data-original="!1+" data-link="false" data-link-reference="false" data-project="278964" data-merge-request="83382" data-project-path="gitlab-org/gitlab" data-iid="1" data-reference-format="+" data-container="body" data-placement="top" title="Enhance the LDAP group synchronization" class="gfm gfm-merge_request">Enhance the LDAP group synchronization (!1 - merged)</a> <a href="/gitlab-org/gitlab/-/merge_requests/1" data-reference-type="merge_request" data-original="!1+s" data-link="false" data-link-reference="false" data-project="278964" data-merge-request="83382" data-project-path="gitlab-org/gitlab" data-iid="1" data-reference-format="+s" data-container="body" data-placement="top" title="Enhance the LDAP group synchronization" class="gfm gfm-merge_request">Enhance the LDAP group synchronization (!1 - merged) • John Doe</a></p>'; + +export const RESOLVED_EPIC_HTML = + '<p data-sourcepos="1:1-1:11" dir="auto"><a href="/groups/gitlab-org/-/epics/1" data-reference-type="epic" data-original="&amp;1" data-link="false" data-link-reference="false" data-group="9970" data-epic="1" data-container="body" data-placement="top" title="Approvals in merge request list" class="gfm gfm-epic has-tooltip">&1</a> <a href="/groups/gitlab-org/-/epics/1" data-reference-type="epic" data-original="&amp;1+" data-link="false" data-link-reference="false" data-group="9970" data-epic="1" data-reference-format="+" data-container="body" data-placement="top" title="Approvals in merge request list" class="gfm gfm-epic has-tooltip">Approvals in merge request list (&1)</a> <a href="/groups/gitlab-org/-/epics/1" data-reference-type="epic" data-original="&amp;1+s" data-link="false" data-link-reference="false" data-group="9970" data-epic="1" data-reference-format="+s" data-container="body" data-placement="top" title="Approvals in merge request list" class="gfm gfm-epic has-tooltip">Approvals in merge request list (&1)</a></p>'; diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js index 1f4a367e46c..2184a829cf0 100644 --- a/spec/frontend/content_editor/test_utils.js +++ b/spec/frontend/content_editor/test_utils.js @@ -37,6 +37,8 @@ import Link from '~/content_editor/extensions/link'; import ListItem from '~/content_editor/extensions/list_item'; import OrderedList from '~/content_editor/extensions/ordered_list'; import ReferenceDefinition from '~/content_editor/extensions/reference_definition'; +import Reference from '~/content_editor/extensions/reference'; +import ReferenceLabel from '~/content_editor/extensions/reference_label'; import Strike from '~/content_editor/extensions/strike'; import Table from '~/content_editor/extensions/table'; import TableCell from '~/content_editor/extensions/table_cell'; @@ -192,6 +194,15 @@ export const triggerMarkInputRule = ({ tiptapEditor, inputRuleText }) => { ); }; +export const triggerKeyboardInput = ({ tiptapEditor, key, shiftKey = false }) => { + let isCaptured = false; + tiptapEditor.view.someProp('handleKeyDown', (f) => { + isCaptured = f(tiptapEditor.view, new KeyboardEvent('keydown', { key, shiftKey })); + return isCaptured; + }); + return isCaptured; +}; + /** * Executes an action that triggers a transaction in the * tiptap Editor. Returns a promise that resolves @@ -212,6 +223,22 @@ export const waitUntilNextDocTransaction = ({ tiptapEditor, action = () => {} }) }); }; +export const waitUntilTransaction = ({ tiptapEditor, number, action }) => { + return new Promise((resolve) => { + let counter = 0; + const handleTransaction = () => { + counter += 1; + if (counter === number) { + tiptapEditor.off('update', handleTransaction); + resolve(); + } + }; + + tiptapEditor.on('update', handleTransaction); + action(); + }); +}; + export const expectDocumentAfterTransaction = ({ tiptapEditor, number, expectedDoc, action }) => { return new Promise((resolve) => { let counter = 0; @@ -266,6 +293,8 @@ export const createTiptapEditor = (extensions = []) => ListItem, OrderedList, ReferenceDefinition, + Reference, + ReferenceLabel, Strike, Table, TableCell, diff --git a/spec/frontend/contribution_events/components/contribution_event/contribution_event_approved_spec.js b/spec/frontend/contribution_events/components/contribution_event/contribution_event_approved_spec.js new file mode 100644 index 00000000000..6672d3eb18b --- /dev/null +++ b/spec/frontend/contribution_events/components/contribution_event/contribution_event_approved_spec.js @@ -0,0 +1,47 @@ +import events from 'test_fixtures/controller/users/activity.json'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { EVENT_TYPE_APPROVED } from '~/contribution_events/constants'; +import ContributionEventApproved from '~/contribution_events/components/contribution_event/contribution_event_approved.vue'; +import ContributionEventBase from '~/contribution_events/components/contribution_event/contribution_event_base.vue'; +import TargetLink from '~/contribution_events/components/target_link.vue'; +import ResourceParentLink from '~/contribution_events/components/resource_parent_link.vue'; + +const eventApproved = events.find((event) => event.action === EVENT_TYPE_APPROVED); + +describe('ContributionEventApproved', () => { + let wrapper; + + const createComponent = () => { + wrapper = mountExtended(ContributionEventApproved, { + propsData: { + event: eventApproved, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders `ContributionEventBase`', () => { + expect(wrapper.findComponent(ContributionEventBase).props()).toEqual({ + event: eventApproved, + iconName: 'approval-solid', + iconClass: 'gl-text-green-500', + }); + }); + + it('renders message', () => { + expect(wrapper.findByTestId('event-body').text()).toBe( + `Approved merge request ${eventApproved.target.reference_link_text} in ${eventApproved.resource_parent.full_name}.`, + ); + }); + + it('renders target link', () => { + expect(wrapper.findComponent(TargetLink).props('event')).toEqual(eventApproved); + }); + + it('renders resource parent link', () => { + expect(wrapper.findComponent(ResourceParentLink).props('event')).toEqual(eventApproved); + }); +}); diff --git a/spec/frontend/contribution_events/components/contribution_event/contribution_event_base_spec.js b/spec/frontend/contribution_events/components/contribution_event/contribution_event_base_spec.js new file mode 100644 index 00000000000..8c951e20bed --- /dev/null +++ b/spec/frontend/contribution_events/components/contribution_event/contribution_event_base_spec.js @@ -0,0 +1,62 @@ +import { GlAvatarLabeled, GlAvatarLink, GlIcon } from '@gitlab/ui'; +import events from 'test_fixtures/controller/users/activity.json'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ContributionEventBase from '~/contribution_events/components/contribution_event/contribution_event_base.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +const [event] = events; + +describe('ContributionEventBase', () => { + let wrapper; + + const defaultPropsData = { + event, + iconName: 'approval-solid', + iconClass: 'gl-text-green-500', + }; + + const createComponent = () => { + wrapper = shallowMountExtended(ContributionEventBase, { + propsData: defaultPropsData, + scopedSlots: { + default: '<div data-testid="default-slot"></div>', + 'additional-info': '<div data-testid="additional-info-slot"></div>', + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders avatar', () => { + const avatarLink = wrapper.findComponent(GlAvatarLink); + + expect(avatarLink.attributes('href')).toBe(event.author.web_url); + expect(avatarLink.findComponent(GlAvatarLabeled).attributes()).toMatchObject({ + label: event.author.name, + sublabel: `@${event.author.username}`, + src: event.author.avatar_url, + size: '32', + }); + }); + + it('renders time ago tooltip', () => { + expect(wrapper.findComponent(TimeAgoTooltip).props('time')).toBe(event.created_at); + }); + + it('renders icon', () => { + const icon = wrapper.findComponent(GlIcon); + + expect(icon.props('name')).toBe(defaultPropsData.iconName); + expect(icon.classes()).toContain(defaultPropsData.iconClass); + }); + + it('renders `default` slot', () => { + expect(wrapper.findByTestId('default-slot').exists()).toBe(true); + }); + + it('renders `additional-info` slot', () => { + expect(wrapper.findByTestId('additional-info-slot').exists()).toBe(true); + }); +}); diff --git a/spec/frontend/contribution_events/components/contribution_events_spec.js b/spec/frontend/contribution_events/components/contribution_events_spec.js new file mode 100644 index 00000000000..4bc354c393f --- /dev/null +++ b/spec/frontend/contribution_events/components/contribution_events_spec.js @@ -0,0 +1,31 @@ +import events from 'test_fixtures/controller/users/activity.json'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { EVENT_TYPE_APPROVED } from '~/contribution_events/constants'; +import ContributionEvents from '~/contribution_events/components/contribution_events.vue'; +import ContributionEventApproved from '~/contribution_events/components/contribution_event/contribution_event_approved.vue'; + +const eventApproved = events.find((event) => event.action === EVENT_TYPE_APPROVED); + +describe('ContributionEvents', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(ContributionEvents, { + propsData: { + events, + }, + }); + }; + + it.each` + expectedComponent | expectedEvent + ${ContributionEventApproved} | ${eventApproved} + `( + 'renders `$expectedComponent.name` component and passes expected event', + ({ expectedComponent, expectedEvent }) => { + createComponent(); + + expect(wrapper.findComponent(expectedComponent).props('event')).toEqual(expectedEvent); + }, + ); +}); diff --git a/spec/frontend/contribution_events/components/resource_parent_link_spec.js b/spec/frontend/contribution_events/components/resource_parent_link_spec.js new file mode 100644 index 00000000000..8d586db2a30 --- /dev/null +++ b/spec/frontend/contribution_events/components/resource_parent_link_spec.js @@ -0,0 +1,30 @@ +import { GlLink } from '@gitlab/ui'; +import events from 'test_fixtures/controller/users/activity.json'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { EVENT_TYPE_APPROVED } from '~/contribution_events/constants'; +import ResourceParentLink from '~/contribution_events/components/resource_parent_link.vue'; + +const eventApproved = events.find((event) => event.action === EVENT_TYPE_APPROVED); + +describe('ResourceParentLink', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(ResourceParentLink, { + propsData: { + event: eventApproved, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders link', () => { + const link = wrapper.findComponent(GlLink); + + expect(link.attributes('href')).toBe(eventApproved.resource_parent.web_url); + expect(link.text()).toBe(eventApproved.resource_parent.full_name); + }); +}); diff --git a/spec/frontend/contribution_events/components/target_link_spec.js b/spec/frontend/contribution_events/components/target_link_spec.js new file mode 100644 index 00000000000..7944375487b --- /dev/null +++ b/spec/frontend/contribution_events/components/target_link_spec.js @@ -0,0 +1,33 @@ +import { GlLink } from '@gitlab/ui'; +import events from 'test_fixtures/controller/users/activity.json'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { EVENT_TYPE_APPROVED } from '~/contribution_events/constants'; +import TargetLink from '~/contribution_events/components/target_link.vue'; + +const eventApproved = events.find((event) => event.action === EVENT_TYPE_APPROVED); + +describe('TargetLink', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(TargetLink, { + propsData: { + event: eventApproved, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders link', () => { + const link = wrapper.findComponent(GlLink); + + expect(link.attributes()).toMatchObject({ + href: eventApproved.target.web_url, + title: eventApproved.target.title, + }); + expect(link.text()).toBe(eventApproved.target.reference_link_text); + }); +}); diff --git a/spec/frontend/design_management/components/design_description/description_form_spec.js b/spec/frontend/design_management/components/design_description/description_form_spec.js new file mode 100644 index 00000000000..8c01023b1a8 --- /dev/null +++ b/spec/frontend/design_management/components/design_description/description_form_spec.js @@ -0,0 +1,299 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; + +import { GlAlert } 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 DescriptionForm from '~/design_management/components/design_description/description_form.vue'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; +import updateDesignDescriptionMutation from '~/design_management/graphql/mutations/update_design_description.mutation.graphql'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; + +import { designFactory, designUpdateFactory } from '../../mock_data/apollo_mock'; + +jest.mock('~/behaviors/markdown/render_gfm'); + +Vue.use(VueApollo); + +describe('Design description form', () => { + const formFieldProps = { + id: 'design-description', + name: 'design-description', + placeholder: 'Write a comment or drag your files here…', + 'aria-label': 'Design description', + }; + const mockDesign = designFactory(); + const mockDesignVariables = { + fullPath: '', + iid: '1', + filenames: ['test.jpg'], + atVersion: null, + }; + + const mockDesignResponse = designUpdateFactory(); + const mockDesignUpdateMutationHandler = jest.fn().mockResolvedValue(mockDesignResponse); + let wrapper; + let mockApollo; + + const createComponent = ({ + design = mockDesign, + descriptionText = '', + showEditor = false, + isSubmitting = false, + designVariables = mockDesignVariables, + contentEditorOnIssues = false, + designUpdateMutationHandler = mockDesignUpdateMutationHandler, + } = {}) => { + mockApollo = createMockApollo([[updateDesignDescriptionMutation, designUpdateMutationHandler]]); + wrapper = mountExtended(DescriptionForm, { + propsData: { + design, + markdownPreviewPath: '/gitlab-org/gitlab-test/preview_markdown?target_type=Issue', + designVariables, + }, + provide: { + glFeatures: { + contentEditorOnIssues, + }, + }, + apolloProvider: mockApollo, + data() { + return { + formFieldProps, + descriptionText, + showEditor, + isSubmitting, + }; + }, + }); + }; + + afterEach(() => { + mockApollo = null; + }); + + const findDesignContent = () => wrapper.findByTestId('design-description-content'); + const findDesignNoneBlock = () => wrapper.findByTestId('design-description-none'); + const findEditDescriptionButton = () => wrapper.findByTestId('edit-description'); + const findSaveDescriptionButton = () => wrapper.findByTestId('save-description'); + const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor); + const findTextarea = () => wrapper.find('textarea'); + const findCheckboxAtIndex = (index) => wrapper.findAll('input[type="checkbox"]').at(index); + const findAlert = () => wrapper.findComponent(GlAlert); + + describe('user has updateDesign permission', () => { + const ctrlKey = { + ctrlKey: true, + }; + const metaKey = { + metaKey: true, + }; + const mockDescription = 'Hello world'; + const errorMessage = 'Could not update description. Please try again.'; + + beforeEach(() => { + createComponent(); + }); + + it('renders description content with the edit button', () => { + expect(findDesignContent().text()).toEqual('Test description'); + expect(findEditDescriptionButton().exists()).toBe(true); + }); + + it('renders none when description is empty', () => { + createComponent({ design: designFactory({ description: '', descriptionHtml: '' }) }); + + expect(findDesignNoneBlock().text()).toEqual('None'); + }); + + it('renders save button when editor is open', () => { + createComponent({ + design: designFactory({ description: '', descriptionHtml: '' }), + showEditor: true, + }); + + expect(findSaveDescriptionButton().exists()).toBe(true); + expect(findSaveDescriptionButton().attributes('disabled')).toBeUndefined(); + }); + + it('renders the markdown editor with default props', () => { + createComponent({ + showEditor: true, + descriptionText: 'Test description', + }); + + expect(findMarkdownEditor().exists()).toBe(true); + expect(findMarkdownEditor().props()).toMatchObject({ + value: 'Test description', + renderMarkdownPath: '/gitlab-org/gitlab-test/preview_markdown?target_type=Issue', + enableContentEditor: false, + formFieldProps, + autofocus: true, + enableAutocomplete: true, + supportsQuickActions: false, + autosaveKey: `Issue/${getIdFromGraphQLId(mockDesign.issue.id)}/Design/${getIdFromGraphQLId( + mockDesign.id, + )}`, + markdownDocsPath: '/help/user/markdown', + quickActionsDocsPath: '/help/user/project/quick_actions', + }); + }); + + it.each` + isKeyEvent | assertionName | key | keyData + ${true} | ${'Ctrl + Enter keypress'} | ${'ctrl'} | ${ctrlKey} + ${true} | ${'Meta + Enter keypress'} | ${'meta'} | ${metaKey} + ${false} | ${'Save button click'} | ${''} | ${null} + `( + 'hides form and calls mutation when form is submitted via $assertionName', + async ({ isKeyEvent, keyData }) => { + const mockDesignUpdateResponseHandler = jest.fn().mockResolvedValue( + designUpdateFactory({ + description: mockDescription, + descriptionHtml: `<p data-sourcepos="1:1-1:16" dir="auto">${mockDescription}</p>`, + }), + ); + + createComponent({ + showEditor: true, + designUpdateMutationHandler: mockDesignUpdateResponseHandler, + }); + + findMarkdownEditor().vm.$emit('input', 'Hello world'); + if (isKeyEvent) { + findTextarea().trigger('keydown.enter', keyData); + } else { + findSaveDescriptionButton().vm.$emit('click'); + } + + await nextTick(); + + expect(mockDesignUpdateResponseHandler).toHaveBeenCalledWith({ + input: { + description: 'Hello world', + id: 'gid::/gitlab/Design/1', + }, + }); + + await waitForPromises(); + + expect(findMarkdownEditor().exists()).toBe(false); + }, + ); + + it('shows error message when mutation fails', async () => { + const failureHandler = jest.fn().mockRejectedValue(new Error(errorMessage)); + createComponent({ + showEditor: true, + descriptionText: 'Hello world', + designUpdateMutationHandler: failureHandler, + }); + + findMarkdownEditor().vm.$emit('input', 'Hello world'); + findSaveDescriptionButton().vm.$emit('click'); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(errorMessage); + }); + }); + + describe('content has checkboxes', () => { + const mockCheckboxDescription = '- [x] todo 1\n- [ ] todo 2'; + const mockCheckboxDescriptionHtml = `<ul dir="auto" class="task-list" data-sourcepos="1:1-4:0"> + <li class="task-list-item" data-sourcepos="1:1-2:15"> + <input checked="" class="task-list-item-checkbox" type="checkbox"> todo 1</li> + <li class="task-list-item" data-sourcepos="2:1-2:15"> + <input class="task-list-item-checkbox" type="checkbox"> todo 2</li> + </ul>`; + const checkboxDesignDescription = designFactory({ + updateDesign: true, + description: mockCheckboxDescription, + descriptionHtml: mockCheckboxDescriptionHtml, + }); + const mockCheckedDescriptionUpdateResponseHandler = jest.fn().mockResolvedValue( + designUpdateFactory({ + description: '- [x] todo 1\n- [x] todo 2', + descriptionHtml: `<ul dir="auto" class="task-list" data-sourcepos="1:1-4:0"> + <li class="task-list-item" data-sourcepos="1:1-2:15"> + <input checked="" class="task-list-item-checkbox" type="checkbox"> todo 1</li> + <li class="task-list-item" data-sourcepos="2:1-2:15"> + <input class="task-list-item-checkbox" type="checkbox"> todo 2</li> + </ul>`, + }), + ); + const mockUnCheckedDescriptionUpdateResponseHandler = jest.fn().mockResolvedValue( + designUpdateFactory({ + description: '- [ ] todo 1\n- [ ] todo 2', + descriptionHtml: `<ul dir="auto" class="task-list" data-sourcepos="1:1-4:0"> + <li class="task-list-item" data-sourcepos="1:1-2:15"> + <input class="task-list-item-checkbox" type="checkbox"> todo 1</li> + <li class="task-list-item" data-sourcepos="2:1-2:15"> + <input class="task-list-item-checkbox" type="checkbox"> todo 2</li> + </ul>`, + }), + ); + + it.each` + assertionName | mockDesignUpdateResponseHandler | checkboxIndex | checked | expectedDesignDescription + ${'checked'} | ${mockCheckedDescriptionUpdateResponseHandler} | ${1} | ${true} | ${'- [x] todo 1\n- [x] todo 2'} + ${'unchecked'} | ${mockUnCheckedDescriptionUpdateResponseHandler} | ${0} | ${false} | ${'- [ ] todo 1\n- [ ] todo 2'} + `( + 'updates the store object when checkbox is $assertionName', + async ({ + mockDesignUpdateResponseHandler, + checkboxIndex, + checked, + expectedDesignDescription, + }) => { + createComponent({ + design: checkboxDesignDescription, + descriptionText: mockCheckboxDescription, + designUpdateMutationHandler: mockDesignUpdateResponseHandler, + }); + + findCheckboxAtIndex(checkboxIndex).setChecked(checked); + + expect(mockDesignUpdateResponseHandler).toHaveBeenCalledWith({ + input: { + description: expectedDesignDescription, + id: 'gid::/gitlab/Design/1', + }, + }); + + await waitForPromises(); + + expect(renderGFM).toHaveBeenCalled(); + }, + ); + + it('disables checkbox while updating', () => { + createComponent({ + design: checkboxDesignDescription, + descriptionText: mockCheckboxDescription, + }); + + findCheckboxAtIndex(1).setChecked(); + + expect(findCheckboxAtIndex(1).attributes().disabled).toBeDefined(); + }); + }); + + describe('user has no updateDesign permission', () => { + beforeEach(() => { + const designWithNoUpdateUserPermission = designFactory({ + updateDesign: false, + }); + createComponent({ design: designWithNoUpdateUserPermission }); + }); + + it('does not render edit button', () => { + expect(findEditDescriptionButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap index 3b407d11041..9bb85ecf569 100644 --- a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap +++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap @@ -1,15 +1,15 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Design note component should match the snapshot 1`] = ` -<timeline-entry-item-stub +<timelineentryitem-stub class="design-note note-form" id="note_123" > - <gl-avatar-link-stub + <glavatarlink-stub class="gl-float-left gl-mr-3" href="https://gitlab.com/user" > - <gl-avatar-stub + <glavatar-stub alt="avatar" entityid="0" entityname="foo-bar" @@ -17,13 +17,13 @@ exports[`Design note component should match the snapshot 1`] = ` size="32" src="https://gitlab.com/avatar" /> - </gl-avatar-link-stub> + </glavatarlink-stub> <div class="gl-display-flex gl-justify-content-space-between" > <div> - <gl-link-stub + <gllink-stub class="js-user-link" data-testid="user-link" data-user-id="1" @@ -43,7 +43,7 @@ exports[`Design note component should match the snapshot 1`] = ` > @foo-bar </span> - </gl-link-stub> + </gllink-stub> <span class="note-headline-light note-headline-meta" @@ -52,22 +52,22 @@ exports[`Design note component should match the snapshot 1`] = ` class="system-note-message" /> - <gl-link-stub - class="note-timestamp system-note-separator gl-display-block gl-mb-2" + <gllink-stub + class="note-timestamp system-note-separator gl-display-block gl-mb-2 gl-font-sm" href="#note_123" > - <time-ago-tooltip-stub + <timeagotooltip-stub cssclass="" datetimeformat="DATE_WITH_TIME_FORMAT" time="2019-07-26T15:02:20Z" tooltipplacement="bottom" /> - </gl-link-stub> + </gllink-stub> </span> </div> <div - class="gl-display-flex gl-align-items-baseline" + class="gl-display-flex gl-align-items-baseline gl-mt-n2 gl-mr-n2" > <!----> @@ -82,5 +82,5 @@ exports[`Design note component should match the snapshot 1`] = ` data-testid="note-text" /> -</timeline-entry-item-stub> +</timelineentryitem-stub> `; diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js index a6ab147884f..664a0974549 100644 --- a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js @@ -1,4 +1,4 @@ -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlFormCheckbox } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; @@ -36,7 +36,7 @@ describe('Design discussions component', () => { const findResolveButton = () => wrapper.find('[data-testid="resolve-button"]'); const findResolvedMessage = () => wrapper.find('[data-testid="resolved-message"]'); const findResolveLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findResolveCheckbox = () => wrapper.find('[data-testid="resolve-checkbox"]'); + const findResolveCheckbox = () => wrapper.findComponent(GlFormCheckbox); const registerPath = '/users/sign_up?redirect_to_referer=yes'; const signInPath = '/users/sign_in?redirect_to_referer=yes'; diff --git a/spec/frontend/design_management/components/design_notes/design_note_spec.js b/spec/frontend/design_management/components/design_notes/design_note_spec.js index 6f5b282fa3b..661d1ac4087 100644 --- a/spec/frontend/design_management/components/design_notes/design_note_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_note_spec.js @@ -1,7 +1,7 @@ import { ApolloMutation } from 'vue-apollo'; import { nextTick } from 'vue'; -import { GlAvatar, GlAvatarLink, GlDropdown } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { GlAvatar, GlAvatarLink, GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import DesignNote from '~/design_management/components/design_notes/design_note.vue'; import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -38,11 +38,13 @@ describe('Design note component', () => { const findReplyForm = () => wrapper.findComponent(DesignReplyForm); const findEditButton = () => wrapper.findByTestId('note-edit'); const findNoteContent = () => wrapper.findByTestId('note-text'); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDeleteNoteButton = () => wrapper.find('[data-testid="delete-note-button"]'); + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findDropdownItems = () => findDropdown().findAllComponents(GlDisclosureDropdownItem); + const findEditDropdownItem = () => findDropdownItems().at(0); + const findDeleteDropdownItem = () => findDropdownItems().at(1); function createComponent(props = {}, data = { isEditing: false }) { - wrapper = shallowMountExtended(DesignNote, { + wrapper = mountExtended(DesignNote, { propsData: { note: {}, noteableId: 'gid://gitlab/DesignManagement::Design/6', @@ -61,6 +63,13 @@ describe('Design note component', () => { }, stubs: { ApolloMutation, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + TimelineEntryItem: true, + TimeAgoTooltip: true, + GlAvatarLink: true, + GlAvatar: true, + GlLink: true, }, }); } @@ -151,6 +160,23 @@ describe('Design note component', () => { ); }); + it('should open an edit form on edit button click', async () => { + createComponent({ + note: { + ...note, + userPermissions: { + adminNote: true, + }, + }, + }); + + findEditDropdownItem().find('button').trigger('click'); + + await nextTick(); + expect(findReplyForm().exists()).toBe(true); + expect(findNoteContent().exists()).toBe(false); + }); + it('should not render note content and should render reply form', () => { expect(findNoteContent().exists()).toBe(false); expect(findReplyForm().exists()).toBe(true); @@ -174,7 +200,7 @@ describe('Design note component', () => { }); }); - describe('when user has a permission to delete note', () => { + describe('when user has admin permissions', () => { it('should display a dropdown', () => { createComponent({ note: { @@ -186,6 +212,9 @@ describe('Design note component', () => { }); expect(findDropdown().exists()).toBe(true); + expect(findEditDropdownItem().exists()).toBe(true); + expect(findDeleteDropdownItem().exists()).toBe(true); + expect(findDropdown().props('items')[0].extraAttrs.class).toBe('gl-sm-display-none!'); }); }); @@ -203,7 +232,7 @@ describe('Design note component', () => { }, }); - findDeleteNoteButton().vm.$emit('click'); + findDeleteDropdownItem().find('button').trigger('click'); expect(wrapper.emitted()).toEqual({ 'delete-note': [[{ ...payload }]] }); }); diff --git a/spec/frontend/design_management/components/design_sidebar_spec.js b/spec/frontend/design_management/components/design_sidebar_spec.js index 90424175417..e3f056df4c6 100644 --- a/spec/frontend/design_management/components/design_sidebar_spec.js +++ b/spec/frontend/design_management/components/design_sidebar_spec.js @@ -26,6 +26,13 @@ const $route = { }, }; +const mockDesignVariables = { + fullPath: 'project-path', + iid: '1', + filenames: ['gid::/gitlab/Design/1'], + atVersion: null, +}; + const mutate = jest.fn().mockResolvedValue(); describe('Design management design sidebar component', () => { @@ -47,6 +54,7 @@ describe('Design management design sidebar component', () => { resolvedDiscussionsExpanded: false, markdownPreviewPath: '', isLoading: false, + designVariables: mockDesignVariables, ...props, }, mocks: { diff --git a/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap index 9451f35ac5b..0bbb44bb517 100644 --- a/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap +++ b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap @@ -11,13 +11,13 @@ exports[`Design management list item component when item appears in view after i exports[`Design management list item component with notes renders item with multiple comments 1`] = ` <router-link-stub ariacurrentvalue="page" - class="card gl-cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new gl-mb-0" + class="card gl-cursor-pointer text-plain js-design-list-item design-list-item gl-mb-0" event="click" tag="a" to="[object Object]" > <div - class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative" + class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative gl-rounded-top-base" > <!----> @@ -91,13 +91,13 @@ exports[`Design management list item component with notes renders item with mult exports[`Design management list item component with notes renders item with single comment 1`] = ` <router-link-stub ariacurrentvalue="page" - class="card gl-cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new gl-mb-0" + class="card gl-cursor-pointer text-plain js-design-list-item design-list-item gl-mb-0" event="click" tag="a" to="[object Object]" > <div - class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative" + class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative gl-rounded-top-base" > <!----> diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js index 18e08ecd729..063df9366e9 100644 --- a/spec/frontend/design_management/mock_data/apollo_mock.js +++ b/spec/frontend/design_management/mock_data/apollo_mock.js @@ -119,6 +119,8 @@ export const reorderedDesigns = [ notesCount: 2, image: 'image-2', imageV432x230: 'image-2', + description: '', + descriptionHtml: '', currentUserTodos: { __typename: 'ToDo', nodes: [], @@ -132,6 +134,8 @@ export const reorderedDesigns = [ notesCount: 3, image: 'image-1', imageV432x230: 'image-1', + description: '', + descriptionHtml: '', currentUserTodos: { __typename: 'ToDo', nodes: [], @@ -145,6 +149,8 @@ export const reorderedDesigns = [ notesCount: 1, image: 'image-3', imageV432x230: 'image-3', + description: '', + descriptionHtml: '', currentUserTodos: { __typename: 'ToDo', nodes: [], @@ -320,3 +326,59 @@ export const mockCreateImageNoteDiffResponse = { }, }, }; + +export const designFactory = ({ + updateDesign = true, + discussions = {}, + description = 'Test description', + descriptionHtml = '<p data-sourcepos="1:1-1:16" dir="auto">Test description</p>', +} = {}) => ({ + id: 'gid::/gitlab/Design/1', + iid: 1, + filename: 'test.jpg', + fullPath: 'full-design-path', + image: 'test.jpg', + description, + descriptionHtml, + updatedAt: '01-01-2019', + updatedBy: { + name: 'test', + }, + issue: { + id: 'gid::/gitlab/Issue/1', + title: 'My precious issue', + webPath: 'full-issue-path', + webUrl: 'full-issue-url', + participants: { + nodes: [ + { + name: 'Administrator', + username: 'root', + webUrl: 'link-to-author', + avatarUrl: 'link-to-avatar', + __typename: 'UserCore', + }, + ], + __typename: 'UserCoreConnection', + }, + userPermissions: { + updateDesign, + __typename: 'IssuePermissions', + }, + __typename: 'Issue', + }, + discussions, + __typename: 'Design', +}); + +export const designUpdateFactory = (options) => { + return { + data: { + designManagementUpdate: { + errors: [], + design: designFactory(options), + }, + __typename: 'DesignManagementUpdatePayload', + }, + }; +}; diff --git a/spec/frontend/design_management/mock_data/design.js b/spec/frontend/design_management/mock_data/design.js index f2a3a800969..8379408b27c 100644 --- a/spec/frontend/design_management/mock_data/design.js +++ b/spec/frontend/design_management/mock_data/design.js @@ -3,6 +3,8 @@ export default { filename: 'test.jpg', fullPath: 'full-design-path', image: 'test.jpg', + description: 'Test description', + descriptionHtml: 'Test description', updatedAt: '01-01-2019', updatedBy: { name: 'test', diff --git a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap deleted file mode 100644 index 7da0652faba..00000000000 --- a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap +++ /dev/null @@ -1,60 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Design management index page designs renders error 1`] = ` -<div - class="gl-mt-4" - data-testid="designs-root" -> - <!----> - - <!----> - - <div - class="gl-bg-gray-10 gl-border gl-border-t-0 gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-5" - > - <gl-alert-stub - dismisslabel="Dismiss" - primarybuttonlink="" - primarybuttontext="" - secondarybuttonlink="" - secondarybuttontext="" - showicon="true" - title="" - variant="danger" - > - - An error occurred while loading designs. Please try again. - - </gl-alert-stub> - </div> - - <router-view-stub - name="default" - /> -</div> -`; - -exports[`Design management index page designs renders loading icon 1`] = ` -<div - class="gl-mt-4" - data-testid="designs-root" -> - <!----> - - <!----> - - <div - class="gl-bg-gray-10 gl-border gl-border-t-0 gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-5" - > - <gl-loading-icon-stub - color="dark" - label="Loading" - size="lg" - /> - </div> - - <router-view-stub - name="default" - /> -</div> -`; diff --git a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap index 18b63082e4a..bd37d917faa 100644 --- a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap @@ -61,6 +61,12 @@ exports[`Design management design index page renders design index 1`] = ` ull-issue-path </a> + <description-form-stub + design="[object Object]" + designvariables="[object Object]" + markdownpreviewpath="/project-path/preview_markdown?target_type=Issue" + /> + <participants-stub class="gl-mb-4" lazy="true" @@ -192,6 +198,12 @@ exports[`Design management design index page with error GlAlert is rendered in c ull-issue-path </a> + <description-form-stub + design="[object Object]" + designvariables="[object Object]" + markdownpreviewpath="/project-path/preview_markdown?target_type=Issue" + /> + <participants-stub class="gl-mb-4" lazy="true" diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js index fcb03ea3700..6cddb0cbbf1 100644 --- a/spec/frontend/design_management/pages/design/index_spec.js +++ b/spec/frontend/design_management/pages/design/index_spec.js @@ -188,6 +188,12 @@ describe('Design management design index page', () => { markdownPreviewPath: '/project-path/preview_markdown?target_type=Issue', resolvedDiscussionsExpanded: false, isLoading: false, + designVariables: { + fullPath: 'project-path', + iid: '1', + filenames: ['gid::/gitlab/Design/1'], + atVersion: null, + }, }); }); diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index 1a6403d3b87..961ea27f0f4 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -1,4 +1,4 @@ -import { GlEmptyState } from '@gitlab/ui'; +import { GlEmptyState, GlLoadingIcon, GlAlert } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo, { ApolloMutation } from 'vue-apollo'; @@ -16,7 +16,7 @@ import DesignDestroyer from '~/design_management/components/design_destroyer.vue import Design from '~/design_management/components/list/item.vue'; import moveDesignMutation from '~/design_management/graphql/mutations/move_design.mutation.graphql'; import uploadDesignMutation from '~/design_management/graphql/mutations/upload_design.mutation.graphql'; -import Index from '~/design_management/pages/index.vue'; +import Index, { i18n } from '~/design_management/pages/index.vue'; import createRouter from '~/design_management/router'; import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants'; import * as utils from '~/design_management/utils/design_management_utils'; @@ -117,6 +117,8 @@ describe('Design management index page', () => { const findDesignUploadButton = () => wrapper.findByTestId('design-upload-button'); const findDesignToolbarWrapper = () => wrapper.findByTestId('design-toolbar-wrapper'); const findDesignUpdateAlert = () => wrapper.findByTestId('design-update-alert'); + const findLoadinIcon = () => wrapper.findComponent(GlLoadingIcon); + const findAlert = () => wrapper.findComponent(GlAlert); async function moveDesigns(localWrapper) { await waitForPromises(); @@ -177,13 +179,14 @@ describe('Design management index page', () => { function createComponentWithApollo({ permissionsHandler = jest.fn().mockResolvedValue(getPermissionsQueryResponse()), moveHandler = jest.fn().mockResolvedValue(moveDesignMutationResponse), + getDesignListHandler = jest.fn().mockResolvedValue(getDesignListQueryResponse()), }) { Vue.use(VueApollo); permissionsQueryHandler = permissionsHandler; moveDesignHandler = moveHandler; const requestHandlers = [ - [getDesignListQuery, jest.fn().mockResolvedValue(getDesignListQueryResponse())], + [getDesignListQuery, getDesignListHandler], [permissionsQuery, permissionsQueryHandler], [moveDesignMutation, moveDesignHandler], ]; @@ -203,24 +206,12 @@ describe('Design management index page', () => { describe('designs', () => { it('renders loading icon', () => { createComponent({ loading: true }); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('renders error', async () => { - createComponent(); - - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ error: true }); - - await nextTick(); - expect(wrapper.element).toMatchSnapshot(); + expect(findLoadinIcon().exists()).toBe(true); }); it('renders a toolbar with buttons when there are designs', () => { createComponent({ allVersions: [mockVersion] }); - + expect(findLoadinIcon().exists()).toBe(false); expect(findToolbar().exists()).toBe(true); }); @@ -236,7 +227,6 @@ describe('Design management index page', () => { it('has correct classes applied to design dropzone', () => { createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); expect(dropzoneClasses()).toContain('design-list-item'); - expect(dropzoneClasses()).toContain('design-list-item-new'); }); it('has correct classes applied to dropzone wrapper', () => { @@ -262,7 +252,6 @@ describe('Design management index page', () => { it('has correct classes applied to design dropzone', () => { expect(dropzoneClasses()).not.toContain('design-list-item'); - expect(dropzoneClasses()).not.toContain('design-list-item-new'); }); it('has correct classes applied to dropzone wrapper', () => { @@ -319,6 +308,8 @@ describe('Design management index page', () => { }, image: '', imageV432x230: '', + description: '', + descriptionHtml: '', filename: 'test', fullPath: '', event: 'NONE', @@ -362,7 +353,6 @@ describe('Design management index page', () => { expect(wrapper.vm.filesToBeSaved).toEqual([{ name: 'test' }]); expect(wrapper.vm.isSaving).toBe(true); expect(dropzoneClasses()).toContain('design-list-item'); - expect(dropzoneClasses()).toContain('design-list-item-new'); }); it('sets isSaving', async () => { @@ -382,9 +372,8 @@ describe('Design management index page', () => { it('updates state appropriately after upload complete', async () => { createComponent({ stubs: { GlEmptyState } }); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ filesToBeSaved: [{ name: 'test' }] }); + const designDropzone = findFirstDropzoneWithDesign(); + designDropzone.vm.$emit('change', 'test'); wrapper.vm.onUploadDesignDone(designUploadMutationCreatedResponse); await nextTick(); @@ -396,10 +385,8 @@ describe('Design management index page', () => { it('updates state appropriately after upload error', async () => { createComponent({ stubs: { GlEmptyState } }); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ filesToBeSaved: [{ name: 'test' }] }); - + const designDropzone = findFirstDropzoneWithDesign(); + designDropzone.vm.$emit('change', 'test'); wrapper.vm.onUploadDesignError(); await nextTick(); expect(wrapper.vm.filesToBeSaved).toEqual([]); @@ -752,6 +739,16 @@ describe('Design management index page', () => { }); describe('with mocked Apollo client', () => { + it('renders error', async () => { + // eslint-disable-next-line no-console + console.error = jest.fn(); + + createComponentWithApollo({ + getDesignListHandler: jest.fn().mockRejectedValue(new Error('GraphQL error')), + }); + await waitForPromises(); + expect(findAlert().text()).toBe(i18n.designLoadingError); + }); it('has a design with id 1 as a first one', async () => { createComponentWithApollo({}); await waitForPromises(); diff --git a/spec/frontend/design_management/utils/design_management_utils_spec.js b/spec/frontend/design_management/utils/design_management_utils_spec.js index dc6056badb9..cbfe8e3a243 100644 --- a/spec/frontend/design_management/utils/design_management_utils_spec.js +++ b/spec/frontend/design_management/utils/design_management_utils_spec.js @@ -89,6 +89,8 @@ describe('optimistic responses', () => { id: -1, image: '', imageV432x230: '', + description: '', + descriptionHtml: '', filename: 'test', fullPath: '', notesCount: 0, diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index 42eec0af961..b69452069c0 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -43,7 +43,7 @@ describe('diffs/components/app', () => { let wrapper; let mock; - function createComponent(props = {}, extendStore = () => {}, provisions = {}) { + function createComponent(props = {}, extendStore = () => {}, provisions = {}, baseConfig = {}) { const provide = { ...provisions, glFeatures: { @@ -57,20 +57,24 @@ describe('diffs/components/app', () => { extendStore(store); + store.dispatch('diffs/setBaseConfig', { + endpoint: TEST_ENDPOINT, + endpointMetadata: `${TEST_HOST}/diff/endpointMetadata`, + endpointBatch: `${TEST_HOST}/diff/endpointBatch`, + endpointDiffForPath: TEST_ENDPOINT, + projectPath: 'namespace/project', + dismissEndpoint: '', + showSuggestPopover: true, + mrReviews: {}, + ...baseConfig, + }); + wrapper = shallowMount(App, { propsData: { - endpoint: TEST_ENDPOINT, - endpointMetadata: `${TEST_HOST}/diff/endpointMetadata`, - endpointBatch: `${TEST_HOST}/diff/endpointBatch`, - endpointDiffForPath: TEST_ENDPOINT, endpointCoverage: `${TEST_HOST}/diff/endpointCoverage`, endpointCodequality: '', - projectPath: 'namespace/project', currentUser: {}, changesEmptyStateIllustration: '', - dismissEndpoint: '', - showSuggestPopover: true, - fileByFileUserPreference: false, ...props, }, provide, @@ -653,13 +657,18 @@ describe('diffs/components/app', () => { describe('file-by-file', () => { it('renders a single diff', async () => { - createComponent({ fileByFileUserPreference: true }, ({ state }) => { - state.diffs.treeEntries = { - 123: { type: 'blob', fileHash: '123' }, - 312: { type: 'blob', fileHash: '312' }, - }; - state.diffs.diffFiles.push({ file_hash: '312' }); - }); + createComponent( + undefined, + ({ state }) => { + state.diffs.treeEntries = { + 123: { type: 'blob', fileHash: '123' }, + 312: { type: 'blob', fileHash: '312' }, + }; + state.diffs.diffFiles.push({ file_hash: '312' }); + }, + undefined, + { viewDiffsFileByFile: true }, + ); await nextTick(); @@ -671,12 +680,17 @@ describe('diffs/components/app', () => { const paginator = () => fileByFileNav().findComponent(GlPagination); it('sets previous button as disabled', async () => { - createComponent({ fileByFileUserPreference: true }, ({ state }) => { - state.diffs.treeEntries = { - 123: { type: 'blob', fileHash: '123' }, - 312: { type: 'blob', fileHash: '312' }, - }; - }); + createComponent( + undefined, + ({ state }) => { + state.diffs.treeEntries = { + 123: { type: 'blob', fileHash: '123' }, + 312: { type: 'blob', fileHash: '312' }, + }; + }, + undefined, + { viewDiffsFileByFile: true }, + ); await nextTick(); @@ -685,13 +699,18 @@ describe('diffs/components/app', () => { }); it('sets next button as disabled', async () => { - createComponent({ fileByFileUserPreference: true }, ({ state }) => { - state.diffs.treeEntries = { - 123: { type: 'blob', fileHash: '123' }, - 312: { type: 'blob', fileHash: '312' }, - }; - state.diffs.currentDiffFileId = '312'; - }); + createComponent( + undefined, + ({ state }) => { + state.diffs.treeEntries = { + 123: { type: 'blob', fileHash: '123' }, + 312: { type: 'blob', fileHash: '312' }, + }; + state.diffs.currentDiffFileId = '312'; + }, + undefined, + { viewDiffsFileByFile: true }, + ); await nextTick(); @@ -700,10 +719,15 @@ describe('diffs/components/app', () => { }); it("doesn't display when there's fewer than 2 files", async () => { - createComponent({ fileByFileUserPreference: true }, ({ state }) => { - state.diffs.treeEntries = { 123: { type: 'blob', fileHash: '123' } }; - state.diffs.currentDiffFileId = '123'; - }); + createComponent( + undefined, + ({ state }) => { + state.diffs.treeEntries = { 123: { type: 'blob', fileHash: '123' } }; + state.diffs.currentDiffFileId = '123'; + }, + undefined, + { viewDiffsFileByFile: true }, + ); await nextTick(); @@ -711,14 +735,14 @@ describe('diffs/components/app', () => { }); it.each` - currentDiffFileId | targetFile | newFileByFile - ${'123'} | ${2} | ${false} - ${'312'} | ${1} | ${true} + currentDiffFileId | targetFile + ${'123'} | ${2} + ${'312'} | ${1} `( 'calls navigateToDiffFileIndex with $index when $link is clicked', - async ({ currentDiffFileId, targetFile, newFileByFile }) => { + async ({ currentDiffFileId, targetFile }) => { createComponent( - { fileByFileUserPreference: true }, + undefined, ({ state }) => { state.diffs.treeEntries = { 123: { type: 'blob', fileHash: '123', filePaths: { old: '1234', new: '123' } }, @@ -726,11 +750,8 @@ describe('diffs/components/app', () => { }; state.diffs.currentDiffFileId = currentDiffFileId; }, - { - glFeatures: { - singleFileFileByFile: newFileByFile, - }, - }, + undefined, + { viewDiffsFileByFile: true }, ); await nextTick(); @@ -741,10 +762,7 @@ describe('diffs/components/app', () => { await nextTick(); - expect(wrapper.vm.navigateToDiffFileIndex).toHaveBeenCalledWith({ - index: targetFile - 1, - singleFile: newFileByFile, - }); + expect(wrapper.vm.navigateToDiffFileIndex).toHaveBeenCalledWith(targetFile - 1); }, ); }); diff --git a/spec/frontend/diffs/components/compare_versions_spec.js b/spec/frontend/diffs/components/compare_versions_spec.js index 47a266c2e36..cbbfd88260b 100644 --- a/spec/frontend/diffs/components/compare_versions_spec.js +++ b/spec/frontend/diffs/components/compare_versions_spec.js @@ -1,15 +1,14 @@ import { mount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import Vuex from 'vuex'; +import { nextTick } from 'vue'; import getDiffWithCommit from 'test_fixtures/merge_request_diffs/with_commit.json'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import { trimText } from 'helpers/text_helper'; import CompareVersionsComponent from '~/diffs/components/compare_versions.vue'; -import { createStore } from '~/mr_notes/stores'; +import store from '~/mr_notes/stores'; import diffsMockData from '../mock_data/merge_request_diffs'; -Vue.use(Vuex); +jest.mock('~/mr_notes/stores', () => jest.requireActual('helpers/mocks/mr_notes/stores')); const NEXT_COMMIT_URL = `${TEST_HOST}/?commit_id=next`; const PREV_COMMIT_URL = `${TEST_HOST}/?commit_id=prev`; @@ -20,8 +19,6 @@ beforeEach(() => { describe('CompareVersions', () => { let wrapper; - let store; - let dispatchMock; const targetBranchName = 'tmp-wine-dev'; const { commit } = getDiffWithCommit; @@ -30,10 +27,10 @@ describe('CompareVersions', () => { store.state.diffs.commit = { ...store.state.diffs.commit, ...commitArgs }; } - dispatchMock = jest.spyOn(store, 'dispatch'); - wrapper = mount(CompareVersionsComponent, { - store, + mocks: { + $store: store, + }, propsData: { mergeRequestDiffs: diffsMockData, diffFilesCountText: '1', @@ -50,8 +47,25 @@ describe('CompareVersions', () => { getCommitNavButtonsElement().find('.btn-group > *:first-child'); beforeEach(() => { - store = createStore(); + store.reset(); + const mergeRequestDiff = diffsMockData[0]; + const version = { + ...mergeRequestDiff, + href: `${TEST_HOST}/latest/version`, + versionName: 'latest version', + }; + store.getters['diffs/diffCompareDropdownSourceVersions'] = [version]; + store.getters['diffs/diffCompareDropdownTargetVersions'] = [ + { + ...version, + selected: true, + versionName: targetBranchName, + }, + ]; + store.getters['diffs/whichCollapsedTypes'] = { any: false }; + store.getters['diffs/isInlineView'] = false; + store.getters['diffs/isParallelView'] = false; store.state.diffs.addedLines = 10; store.state.diffs.removedLines = 20; @@ -104,7 +118,6 @@ describe('CompareVersions', () => { it('should not render Tree List toggle button when there are no changes', () => { createWrapper(); - const treeListBtn = wrapper.find('.js-toggle-tree-list'); expect(treeListBtn.exists()).toBe(false); @@ -118,7 +131,10 @@ describe('CompareVersions', () => { const viewTypeBtn = wrapper.find('#inline-diff-btn'); viewTypeBtn.trigger('click'); - expect(window.location.toString()).toContain('?view=inline'); + expect(store.dispatch).toHaveBeenCalledWith( + 'diffs/setInlineDiffViewType', + expect.any(MouseEvent), + ); }); }); @@ -128,13 +144,16 @@ describe('CompareVersions', () => { const viewTypeBtn = wrapper.find('#parallel-diff-btn'); viewTypeBtn.trigger('click'); - expect(window.location.toString()).toContain('?view=parallel'); + expect(store.dispatch).toHaveBeenCalledWith( + 'diffs/setParallelDiffViewType', + expect.any(MouseEvent), + ); }); }); describe('commit', () => { beforeEach(() => { - store.state.diffs.commit = getDiffWithCommit.commit; + store.state.diffs.commit = commit; createWrapper(); }); @@ -218,7 +237,7 @@ describe('CompareVersions', () => { link.trigger('click'); await nextTick(); - expect(dispatchMock).toHaveBeenCalledWith('diffs/moveToNeighboringCommit', { + expect(store.dispatch).toHaveBeenCalledWith('diffs/moveToNeighboringCommit', { direction: 'previous', }); }); @@ -248,7 +267,7 @@ describe('CompareVersions', () => { link.trigger('click'); await nextTick(); - expect(dispatchMock).toHaveBeenCalledWith('diffs/moveToNeighboringCommit', { + expect(store.dispatch).toHaveBeenCalledWith('diffs/moveToNeighboringCommit', { direction: 'next', }); }); diff --git a/spec/frontend/diffs/components/diff_content_spec.js b/spec/frontend/diffs/components/diff_content_spec.js index 3524973278c..39d9255aaf9 100644 --- a/spec/frontend/diffs/components/diff_content_spec.js +++ b/spec/frontend/diffs/components/diff_content_spec.js @@ -115,6 +115,35 @@ describe('DiffContent', () => { }); }); + describe('with whitespace only change', () => { + afterEach(() => { + [isParallelViewGetterMock, isInlineViewGetterMock].forEach((m) => m.mockRestore()); + }); + + const textDiffFile = { + ...defaultProps.diffFile, + viewer: { name: diffViewerModes.text, whitespace_only: true }, + }; + + it('should render empty state', () => { + createComponent({ + props: { diffFile: textDiffFile }, + }); + + expect(wrapper.find('[data-testid="diff-whitespace-only-state"]').exists()).toBe(true); + }); + + it('emits load-file event when clicking show changes button', () => { + createComponent({ + props: { diffFile: textDiffFile }, + }); + + wrapper.find('[data-testid="diff-load-file-button"]').vm.$emit('click'); + + expect(wrapper.emitted('load-file')).toEqual([[{ w: '0' }]]); + }); + }); + describe('with empty files', () => { const emptyDiffFile = { ...defaultProps.diffFile, @@ -147,7 +176,12 @@ describe('DiffContent', () => { getCommentFormForDiffFileGetterMock.mockReturnValue(() => true); createComponent({ props: { - diffFile: { ...imageDiffFile, discussions: [{ name: 'discussion-stub ' }] }, + diffFile: { + ...imageDiffFile, + discussions: [ + { name: 'discussion-stub', position: { position_type: IMAGE_DIFF_POSITION_TYPE } }, + ], + }, }, }); @@ -157,7 +191,12 @@ describe('DiffContent', () => { it('emits saveDiffDiscussion when note-form emits `handleFormUpdate`', () => { const noteStub = {}; getCommentFormForDiffFileGetterMock.mockReturnValue(() => true); - const currentDiffFile = { ...imageDiffFile, discussions: [{ name: 'discussion-stub ' }] }; + const currentDiffFile = { + ...imageDiffFile, + discussions: [ + { name: 'discussion-stub', position: { position_type: IMAGE_DIFF_POSITION_TYPE } }, + ], + }; createComponent({ props: { diffFile: currentDiffFile, diff --git a/spec/frontend/diffs/components/diff_file_header_spec.js b/spec/frontend/diffs/components/diff_file_header_spec.js index 900aa8d1469..3f75b086368 100644 --- a/spec/frontend/diffs/components/diff_file_header_spec.js +++ b/spec/frontend/diffs/components/diff_file_header_spec.js @@ -18,7 +18,10 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import testAction from '../../__helpers__/vuex_action_helper'; import diffDiscussionsMockData from '../mock_data/diff_discussions'; -jest.mock('~/lib/utils/common_utils'); +jest.mock('~/lib/utils/common_utils', () => ({ + scrollToElement: jest.fn(), + isLoggedIn: () => true, +})); const diffFile = Object.freeze( Object.assign(diffDiscussionsMockData.diff_file, { @@ -47,6 +50,9 @@ describe('DiffFileHeader component', () => { const diffHasDiscussionsResultMock = jest.fn(); const defaultMockStoreConfig = { state: {}, + getters: { + getNoteableData: () => ({ current_user: { can_create_note: true } }), + }, modules: { diffs: { namespaced: true, @@ -637,4 +643,23 @@ describe('DiffFileHeader component', () => { }, ); }); + + it.each` + commentOnFiles | exists | existsText + ${false} | ${false} | ${'does not'} + ${true} | ${true} | ${'does'} + `( + '$existsText render comment on files button when commentOnFiles is $commentOnFiles', + ({ commentOnFiles, exists }) => { + window.gon = { current_user_id: 1 }; + createComponent({ + props: { + addMergeRequestButtons: true, + }, + options: { provide: { glFeatures: { commentOnFiles } } }, + }); + + expect(wrapper.find('[data-testid="comment-files-button"]').exists()).toEqual(exists); + }, + ); }); diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js index 389b192a515..d9c57ed1470 100644 --- a/spec/frontend/diffs/components/diff_file_spec.js +++ b/spec/frontend/diffs/components/diff_file_spec.js @@ -553,4 +553,69 @@ describe('DiffFile', () => { expect(wrapper.find('[data-testid="conflictsAlert"]').exists()).toBe(true); }); }); + + describe('file discussions', () => { + it.each` + extraProps | exists | existsText + ${{}} | ${false} | ${'does not'} + ${{ hasCommentForm: false }} | ${false} | ${'does not'} + ${{ hasCommentForm: true }} | ${true} | ${'does'} + ${{ discussions: [{ id: 1, position: { position_type: 'file' } }] }} | ${true} | ${'does'} + ${{ drafts: [{ id: 1 }] }} | ${true} | ${'does'} + `( + 'discussions wrapper $existsText exist for file with $extraProps', + ({ extraProps, exists }) => { + const file = { + ...getReadableFile(), + ...extraProps, + }; + + ({ wrapper, store } = createComponent({ + file, + options: { provide: { glFeatures: { commentOnFiles: true } } }, + })); + + expect(wrapper.find('[data-testid="file-discussions"]').exists()).toEqual(exists); + }, + ); + + it.each` + hasCommentForm | exists | existsText + ${false} | ${false} | ${'does not'} + ${true} | ${true} | ${'does'} + `( + 'comment form $existsText exist for hasCommentForm with $hasCommentForm', + ({ hasCommentForm, exists }) => { + const file = { + ...getReadableFile(), + hasCommentForm, + }; + + ({ wrapper, store } = createComponent({ + file, + options: { provide: { glFeatures: { commentOnFiles: true } } }, + })); + + expect(wrapper.find('[data-testid="file-note-form"]').exists()).toEqual(exists); + }, + ); + + it.each` + discussions | exists | existsText + ${[]} | ${false} | ${'does not'} + ${[{ id: 1, position: { position_type: 'file' } }]} | ${true} | ${'does'} + `('discussions $existsText exist for $discussions', ({ discussions, exists }) => { + const file = { + ...getReadableFile(), + discussions, + }; + + ({ wrapper, store } = createComponent({ + file, + options: { provide: { glFeatures: { commentOnFiles: true } } }, + })); + + expect(wrapper.find('[data-testid="diff-file-discussions"]').exists()).toEqual(exists); + }); + }); }); diff --git a/spec/frontend/diffs/components/diff_line_note_form_spec.js b/spec/frontend/diffs/components/diff_line_note_form_spec.js index eb895bd9057..e42b98e4d68 100644 --- a/spec/frontend/diffs/components/diff_line_note_form_spec.js +++ b/spec/frontend/diffs/components/diff_line_note_form_spec.js @@ -1,8 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; -import Vuex from 'vuex'; import DiffLineNoteForm from '~/diffs/components/diff_line_note_form.vue'; -import { createModules } from '~/mr_notes/stores'; +import store from '~/mr_notes/stores'; import NoteForm from '~/notes/components/note_form.vue'; import MultilineCommentForm from '~/notes/components/multiline_comment_form.vue'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; @@ -10,51 +9,25 @@ import { noteableDataMock } from 'jest/notes/mock_data'; import { getDiffFileMock } from '../mock_data/diff_file'; jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); +jest.mock('~/mr_notes/stores', () => jest.requireActual('helpers/mocks/mr_notes/stores')); describe('DiffLineNoteForm', () => { let wrapper; let diffFile; let diffLines; - let actions; - let store; - const getSelectedLine = () => { - const lineCode = diffLines[1].line_code; - return diffFile.highlighted_diff_lines.find((l) => l.line_code === lineCode); - }; - - const createStore = (state) => { - const modules = createModules(); - modules.diffs.actions = { - ...modules.diffs.actions, - saveDiffDiscussion: jest.fn(() => Promise.resolve()), - }; - modules.diffs.getters = { - ...modules.diffs.getters, - diffCompareDropdownTargetVersions: jest.fn(), - diffCompareDropdownSourceVersions: jest.fn(), - selectedSourceIndex: jest.fn(), - }; - modules.notes.getters = { - ...modules.notes.getters, - noteableType: jest.fn(), - }; - actions = modules.diffs.actions; + beforeEach(() => { + diffFile = getDiffFileMock(); + diffLines = diffFile.highlighted_diff_lines; - store = new Vuex.Store({ modules }); - store.state.notes.userData.id = 1; store.state.notes.noteableData = noteableDataMock; - store.replaceState({ ...store.state, ...state }); - }; + store.getters.isLoggedIn = jest.fn().mockReturnValue(true); + store.getters['diffs/getDiffFileByHash'] = jest.fn().mockReturnValue(diffFile); + }); - const createComponent = ({ props, state } = {}) => { + const createComponent = ({ props } = {}) => { wrapper?.destroy(); - diffFile = getDiffFileMock(); - diffLines = diffFile.highlighted_diff_lines; - - createStore(state); - store.state.diffs.diffFiles = [diffFile]; const propsData = { diffFileHash: diffFile.file_hash, @@ -66,7 +39,9 @@ describe('DiffLineNoteForm', () => { }; wrapper = shallowMount(DiffLineNoteForm, { - store, + mocks: { + $store: store, + }, propsData, }); }; @@ -129,7 +104,10 @@ describe('DiffLineNoteForm', () => { expect(confirmAction).toHaveBeenCalled(); await nextTick(); - expect(getSelectedLine().hasForm).toBe(false); + expect(store.dispatch).toHaveBeenCalledWith('diffs/cancelCommentForm', { + lineCode: diffLines[1].line_code, + fileHash: diffFile.file_hash, + }); }); }); @@ -157,6 +135,10 @@ describe('DiffLineNoteForm', () => { }); describe('saving note', () => { + beforeEach(() => { + store.getters.noteableType = 'merge-request'; + }); + it('should save original line', async () => { const lineRange = { start: { @@ -172,20 +154,65 @@ describe('DiffLineNoteForm', () => { old_line: null, }, }; - await findNoteForm().vm.$emit('handleFormUpdate', 'note body'); - expect(actions.saveDiffDiscussion.mock.calls[0][1].formData).toMatchObject({ - lineRange, + + const noteBody = 'note body'; + await findNoteForm().vm.$emit('handleFormUpdate', noteBody); + + expect(store.dispatch).toHaveBeenCalledWith('diffs/saveDiffDiscussion', { + note: noteBody, + formData: { + noteableData: noteableDataMock, + noteableType: store.getters.noteableType, + noteTargetLine: diffLines[1], + diffViewType: store.state.diffs.diffViewType, + diffFile, + linePosition: '', + lineRange, + }, + }); + expect(store.dispatch).toHaveBeenCalledWith('diffs/cancelCommentForm', { + lineCode: diffLines[1].line_code, + fileHash: diffFile.file_hash, }); }); it('should save selected line from the store', async () => { const lineCode = 'test'; store.state.notes.selectedCommentPosition = { start: { line_code: lineCode } }; - createComponent({ state: store.state }); - await findNoteForm().vm.$emit('handleFormUpdate', 'note body'); - expect(actions.saveDiffDiscussion.mock.calls[0][1].formData.lineRange.start.line_code).toBe( - lineCode, - ); + createComponent(); + const noteBody = 'note body'; + + await findNoteForm().vm.$emit('handleFormUpdate', noteBody); + + expect(store.dispatch).toHaveBeenCalledWith('diffs/saveDiffDiscussion', { + note: noteBody, + formData: { + noteableData: noteableDataMock, + noteableType: store.getters.noteableType, + noteTargetLine: diffLines[1], + diffViewType: store.state.diffs.diffViewType, + diffFile, + linePosition: '', + lineRange: { + start: { + line_code: lineCode, + new_line: undefined, + old_line: undefined, + type: undefined, + }, + end: { + line_code: diffLines[1].line_code, + new_line: diffLines[1].new_line, + old_line: diffLines[1].old_line, + type: diffLines[1].type, + }, + }, + }, + }); + expect(store.dispatch).toHaveBeenCalledWith('diffs/cancelCommentForm', { + lineCode: diffLines[1].line_code, + fileHash: diffFile.file_hash, + }); }); }); }); diff --git a/spec/frontend/diffs/components/diff_view_spec.js b/spec/frontend/diffs/components/diff_view_spec.js index cfc80e61b30..8778683c135 100644 --- a/spec/frontend/diffs/components/diff_view_spec.js +++ b/spec/frontend/diffs/components/diff_view_spec.js @@ -1,10 +1,14 @@ import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; +import { throttle } from 'lodash'; import DiffView from '~/diffs/components/diff_view.vue'; import DiffLine from '~/diffs/components/diff_line.vue'; import { diffCodeQuality } from '../mock_data/diff_code_quality'; +jest.mock('lodash/throttle', () => jest.fn((fn) => fn)); +const lodash = jest.requireActual('lodash'); + describe('DiffView', () => { const DiffExpansionCell = { template: `<div/>` }; const DiffRow = { template: `<div/>` }; @@ -51,6 +55,14 @@ describe('DiffView', () => { return shallowMount(DiffView, { propsData, store, stubs }); }; + beforeEach(() => { + throttle.mockImplementation(lodash.throttle); + }); + + afterEach(() => { + throttle.mockReset(); + }); + it('does not render a diff-line component when there is no finding', () => { const wrapper = createWrapper(); expect(wrapper.findComponent(DiffLine).exists()).toBe(false); @@ -138,5 +150,18 @@ describe('DiffView', () => { expect(wrapper.vm.idState.dragStart).toBeNull(); expect(showCommentForm).toHaveBeenCalled(); }); + + it('throttles multiple calls to enterdragging', () => { + const wrapper = createWrapper({ diffLines: [{}] }); + const diffRow = getDiffRow(wrapper); + + diffRow.$emit('startdragging', { line: { chunk: 1, index: 1 } }); + diffRow.$emit('enterdragging', { chunk: 1, index: 2 }); + diffRow.$emit('enterdragging', { chunk: 1, index: 2 }); + + jest.runOnlyPendingTimers(); + + expect(setSelectedCommentPosition).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/spec/frontend/diffs/components/no_changes_spec.js b/spec/frontend/diffs/components/no_changes_spec.js index e637b1dd43d..fd89d52a59e 100644 --- a/spec/frontend/diffs/components/no_changes_spec.js +++ b/spec/frontend/diffs/components/no_changes_spec.js @@ -1,55 +1,53 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; import NoChanges from '~/diffs/components/no_changes.vue'; -import { createStore } from '~/mr_notes/stores'; +import store from '~/mr_notes/stores'; import diffsMockData from '../mock_data/merge_request_diffs'; -Vue.use(Vuex); +jest.mock('~/mr_notes/stores', () => jest.requireActual('helpers/mocks/mr_notes/stores')); const TEST_TARGET_BRANCH = 'foo'; const TEST_SOURCE_BRANCH = 'dev/update'; +const latestVersionNumber = Math.max(...diffsMockData.map((version) => version.version_index)); describe('Diff no changes empty state', () => { - let wrapper; - let store; - - function createComponent(mountFn = shallowMount) { - wrapper = mountFn(NoChanges, { - store, + const createComponent = (mountFn = shallowMount) => + mountFn(NoChanges, { + mocks: { + $store: store, + }, propsData: { changesEmptyStateIllustration: '', }, }); - } beforeEach(() => { - store = createStore(); - store.state.diffs.mergeRequestDiff = {}; - store.state.notes.noteableData = { + store.reset(); + + store.getters.getNoteableData = { target_branch: TEST_TARGET_BRANCH, source_branch: TEST_SOURCE_BRANCH, }; - store.state.diffs.mergeRequestDiffs = diffsMockData; + store.getters['diffs/diffCompareDropdownSourceVersions'] = []; + store.getters['diffs/diffCompareDropdownTargetVersions'] = []; }); - const findMessage = () => wrapper.find('[data-testid="no-changes-message"]'); + const findMessage = (wrapper) => wrapper.find('[data-testid="no-changes-message"]'); it('prevents XSS', () => { - store.state.notes.noteableData = { + store.getters.getNoteableData = { source_branch: '<script>alert("test");</script>', target_branch: '<script>alert("test");</script>', }; - createComponent(); + const wrapper = createComponent(); expect(wrapper.find('script').exists()).toBe(false); }); describe('Renders', () => { it('Show create commit button', () => { - createComponent(); + const wrapper = createComponent(); expect(wrapper.findComponent(GlButton).exists()).toBe(true); }); @@ -64,15 +62,28 @@ describe('Diff no changes empty state', () => { 'renders text "$expectedText" (sourceIndex=$sourceIndex and targetIndex=$targetIndex)', ({ expectedText, targetIndex, sourceIndex }) => { if (targetIndex !== null) { - store.state.diffs.startVersion = { version_index: targetIndex }; + store.getters['diffs/diffCompareDropdownTargetVersions'] = [ + { + selected: true, + version_index: targetIndex, + versionName: `version ${targetIndex}`, + }, + ]; } if (sourceIndex !== null) { - store.state.diffs.mergeRequestDiff.version_index = sourceIndex; + store.getters['diffs/diffCompareDropdownSourceVersions'] = [ + { + isLatestVersion: sourceIndex === latestVersionNumber, + selected: true, + version_index: targetIndex, + versionName: `version ${sourceIndex}`, + }, + ]; } - createComponent(mount); + const wrapper = createComponent(mount); - expect(findMessage().text()).toBe(expectedText); + expect(findMessage(wrapper).text()).toBe(expectedText); }, ); }); diff --git a/spec/frontend/diffs/components/settings_dropdown_spec.js b/spec/frontend/diffs/components/settings_dropdown_spec.js index 3d2bbe43746..cbd2ae3e525 100644 --- a/spec/frontend/diffs/components/settings_dropdown_spec.js +++ b/spec/frontend/diffs/components/settings_dropdown_spec.js @@ -5,44 +5,34 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import SettingsDropdown from '~/diffs/components/settings_dropdown.vue'; import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '~/diffs/constants'; import eventHub from '~/diffs/event_hub'; +import store from '~/mr_notes/stores'; -import createDiffsStore from '../create_diffs_store'; +jest.mock('~/mr_notes/stores', () => jest.requireActual('helpers/mocks/mr_notes/stores')); describe('Diff settings dropdown component', () => { - let wrapper; - let store; - - function createComponent(extendStore = () => {}) { - store = createDiffsStore(); - - extendStore(store); - - wrapper = extendedWrapper( + const createComponent = () => + extendedWrapper( mount(SettingsDropdown, { - store, + mocks: { + $store: store, + }, }), ); - } function getFileByFileCheckbox(vueWrapper) { return vueWrapper.findByTestId('file-by-file'); } - function setup({ storeUpdater } = {}) { - createComponent(storeUpdater); - jest.spyOn(store, 'dispatch').mockImplementation(() => {}); - } - beforeEach(() => { - setup(); - }); + store.reset(); - afterEach(() => { - store.dispatch.mockRestore(); + store.getters['diffs/isInlineView'] = false; + store.getters['diffs/isParallelView'] = false; }); describe('tree view buttons', () => { it('list view button dispatches setRenderTreeList with false', () => { + const wrapper = createComponent(); wrapper.find('.js-list-view').trigger('click'); expect(store.dispatch).toHaveBeenCalledWith('diffs/setRenderTreeList', { @@ -51,6 +41,7 @@ describe('Diff settings dropdown component', () => { }); it('tree view button dispatches setRenderTreeList with true', () => { + const wrapper = createComponent(); wrapper.find('.js-tree-view').trigger('click'); expect(store.dispatch).toHaveBeenCalledWith('diffs/setRenderTreeList', { @@ -59,19 +50,18 @@ describe('Diff settings dropdown component', () => { }); it('sets list button as selected when renderTreeList is false', () => { - setup({ - storeUpdater: (origStore) => - Object.assign(origStore.state.diffs, { renderTreeList: false }), - }); + store.state.diffs = { renderTreeList: false }; + + const wrapper = createComponent(); expect(wrapper.find('.js-list-view').classes('selected')).toBe(true); expect(wrapper.find('.js-tree-view').classes('selected')).toBe(false); }); it('sets tree button as selected when renderTreeList is true', () => { - setup({ - storeUpdater: (origStore) => Object.assign(origStore.state.diffs, { renderTreeList: true }), - }); + store.state.diffs = { renderTreeList: true }; + + const wrapper = createComponent(); expect(wrapper.find('.js-list-view').classes('selected')).toBe(false); expect(wrapper.find('.js-tree-view').classes('selected')).toBe(true); @@ -80,32 +70,36 @@ describe('Diff settings dropdown component', () => { describe('compare changes', () => { it('sets inline button as selected', () => { - setup({ - storeUpdater: (origStore) => - Object.assign(origStore.state.diffs, { diffViewType: INLINE_DIFF_VIEW_TYPE }), - }); + store.state.diffs = { diffViewType: INLINE_DIFF_VIEW_TYPE }; + store.getters['diffs/isInlineView'] = true; + + const wrapper = createComponent(); expect(wrapper.find('.js-inline-diff-button').classes('selected')).toBe(true); expect(wrapper.find('.js-parallel-diff-button').classes('selected')).toBe(false); }); it('sets parallel button as selected', () => { - setup({ - storeUpdater: (origStore) => - Object.assign(origStore.state.diffs, { diffViewType: PARALLEL_DIFF_VIEW_TYPE }), - }); + store.state.diffs = { diffViewType: PARALLEL_DIFF_VIEW_TYPE }; + store.getters['diffs/isParallelView'] = true; + + const wrapper = createComponent(); expect(wrapper.find('.js-inline-diff-button').classes('selected')).toBe(false); expect(wrapper.find('.js-parallel-diff-button').classes('selected')).toBe(true); }); it('calls setInlineDiffViewType when clicking inline button', () => { + const wrapper = createComponent(); + wrapper.find('.js-inline-diff-button').trigger('click'); expect(store.dispatch).toHaveBeenCalledWith('diffs/setInlineDiffViewType', expect.anything()); }); it('calls setParallelDiffViewType when clicking parallel button', () => { + const wrapper = createComponent(); + wrapper.find('.js-parallel-diff-button').trigger('click'); expect(store.dispatch).toHaveBeenCalledWith( @@ -117,23 +111,23 @@ describe('Diff settings dropdown component', () => { describe('whitespace toggle', () => { it('does not set as checked when showWhitespace is false', () => { - setup({ - storeUpdater: (origStore) => - Object.assign(origStore.state.diffs, { showWhitespace: false }), - }); + store.state.diffs = { showWhitespace: false }; + + const wrapper = createComponent(); expect(wrapper.findByTestId('show-whitespace').element.checked).toBe(false); }); it('sets as checked when showWhitespace is true', () => { - setup({ - storeUpdater: (origStore) => Object.assign(origStore.state.diffs, { showWhitespace: true }), - }); + store.state.diffs = { showWhitespace: true }; + + const wrapper = createComponent(); expect(wrapper.findByTestId('show-whitespace').element.checked).toBe(true); }); it('calls setShowWhitespace on change', async () => { + const wrapper = createComponent(); const checkbox = wrapper.findByTestId('show-whitespace'); const { checked } = checkbox.element; @@ -157,10 +151,9 @@ describe('Diff settings dropdown component', () => { `( 'sets the checkbox to { checked: $checked } if the fileByFile setting is $fileByFile', ({ fileByFile, checked }) => { - setup({ - storeUpdater: (origStore) => - Object.assign(origStore.state.diffs, { viewDiffsFileByFile: fileByFile }), - }); + store.state.diffs = { viewDiffsFileByFile: fileByFile }; + + const wrapper = createComponent(); expect(getFileByFileCheckbox(wrapper).element.checked).toBe(checked); }, @@ -173,11 +166,9 @@ describe('Diff settings dropdown component', () => { `( 'when the file by file setting starts as $start, toggling the checkbox should call setFileByFile with $setting', async ({ start, setting }) => { - setup({ - storeUpdater: (origStore) => - Object.assign(origStore.state.diffs, { viewDiffsFileByFile: start }), - }); + store.state.diffs = { viewDiffsFileByFile: start }; + const wrapper = createComponent(); await getFileByFileCheckbox(wrapper).setChecked(setting); expect(store.dispatch).toHaveBeenCalledWith('diffs/setFileByFile', { diff --git a/spec/frontend/diffs/components/tree_list_spec.js b/spec/frontend/diffs/components/tree_list_spec.js index 87c638d065a..1ec8547d325 100644 --- a/spec/frontend/diffs/components/tree_list_spec.js +++ b/spec/frontend/diffs/components/tree_list_spec.js @@ -3,6 +3,7 @@ import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import TreeList from '~/diffs/components/tree_list.vue'; import createStore from '~/diffs/store/modules'; +import batchComments from '~/batch_comments/stores/modules/batch_comments'; import DiffFileRow from '~/diffs/components//diff_file_row.vue'; import { stubComponent } from 'helpers/stub_component'; @@ -38,6 +39,7 @@ describe('Diffs tree list component', () => { store = new Vuex.Store({ modules: { diffs: createStore(), + batchComments: batchComments(), }, }); diff --git a/spec/frontend/diffs/mock_data/diff_file.js b/spec/frontend/diffs/mock_data/diff_file.js index e0e5778e0d5..eef68100378 100644 --- a/spec/frontend/diffs/mock_data/diff_file.js +++ b/spec/frontend/diffs/mock_data/diff_file.js @@ -334,5 +334,6 @@ export const getDiffFileMock = () => ({ }, ], discussions: [], + drafts: [], renderingLines: false, }); diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index f883aea764f..7534fe741e7 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -707,6 +707,7 @@ describe('DiffsStoreActions', () => { [{ type: types.SET_DIFF_VIEW_TYPE, payload: INLINE_DIFF_VIEW_TYPE }], [], ); + expect(window.location.toString()).toContain('?view=inline'); expect(Cookies.get('diff_view')).toEqual(INLINE_DIFF_VIEW_TYPE); }); }); @@ -720,6 +721,7 @@ describe('DiffsStoreActions', () => { [{ type: types.SET_DIFF_VIEW_TYPE, payload: PARALLEL_DIFF_VIEW_TYPE }], [], ); + expect(window.location.toString()).toContain('?view=parallel'); expect(Cookies.get(DIFF_VIEW_COOKIE_NAME)).toEqual(PARALLEL_DIFF_VIEW_TYPE); }); }); @@ -788,7 +790,7 @@ describe('DiffsStoreActions', () => { mock.onGet(file.loadCollapsedDiffUrl).reply(HTTP_STATUS_OK, data); return diffActions - .loadCollapsedDiff({ commit, getters: { commitId: null }, state }, file) + .loadCollapsedDiff({ commit, getters: { commitId: null }, state }, { file }) .then(() => { expect(commit).toHaveBeenCalledWith(types.ADD_COLLAPSED_DIFFS, { file, data }); }); @@ -802,13 +804,28 @@ describe('DiffsStoreActions', () => { jest.spyOn(axios, 'get').mockReturnValue(Promise.resolve({ data: {} })); - diffActions.loadCollapsedDiff({ commit() {}, getters, state }, file); + diffActions.loadCollapsedDiff({ commit() {}, getters, state }, { file }); expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, { params: { commit_id: null, w: '0' }, }); }); + it('should pass through params', () => { + const file = { load_collapsed_diff_url: '/load/collapsed/diff/url' }; + const getters = { + commitId: null, + }; + + jest.spyOn(axios, 'get').mockReturnValue(Promise.resolve({ data: {} })); + + diffActions.loadCollapsedDiff({ commit() {}, getters, state }, { file, params: { w: '1' } }); + + expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, { + params: { commit_id: null, w: '1' }, + }); + }); + it('should fetch data with commit ID', () => { const file = { load_collapsed_diff_url: '/load/collapsed/diff/url' }; const getters = { @@ -817,7 +834,7 @@ describe('DiffsStoreActions', () => { jest.spyOn(axios, 'get').mockReturnValue(Promise.resolve({ data: {} })); - diffActions.loadCollapsedDiff({ commit() {}, getters, state }, file); + diffActions.loadCollapsedDiff({ commit() {}, getters, state }, { file }); expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, { params: { commit_id: '123', w: '0' }, @@ -841,7 +858,7 @@ describe('DiffsStoreActions', () => { }); it('fetches the data when there is no mergeRequestDiff', () => { - diffActions.loadCollapsedDiff({ commit() {}, getters, state }, file); + diffActions.loadCollapsedDiff({ commit() {}, getters, state }, { file }); expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, { params: expect.any(Object), @@ -859,7 +876,7 @@ describe('DiffsStoreActions', () => { diffActions.loadCollapsedDiff( { commit() {}, getters, state: { mergeRequestDiff: { version_path: versionPath } } }, - file, + { file }, ); expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, { @@ -1115,67 +1132,50 @@ describe('DiffsStoreActions', () => { }); describe('when the app is in fileByFile mode', () => { - describe('when the singleFileFileByFile feature flag is enabled', () => { - it('commits SET_CURRENT_DIFF_FILE', () => { - diffActions.goToFile( - { state, commit, dispatch, getters }, - { path: file.path, singleFile: true }, - ); + it('commits SET_CURRENT_DIFF_FILE', () => { + diffActions.goToFile({ state, commit, dispatch, getters }, file); - expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, fileHash); - }); + expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, fileHash); + }); - it('does nothing more if the path has already been loaded', () => { - getters.isTreePathLoaded = () => true; + it('does nothing more if the path has already been loaded', () => { + getters.isTreePathLoaded = () => true; - diffActions.goToFile( - { state, dispatch, getters, commit }, - { path: file.path, singleFile: true }, - ); + diffActions.goToFile({ state, dispatch, getters, commit }, file); - expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, fileHash); - expect(dispatch).toHaveBeenCalledTimes(0); - }); + expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, fileHash); + expect(dispatch).toHaveBeenCalledTimes(0); + }); - describe('when the tree entry has not been loaded', () => { - it('updates location hash', () => { - diffActions.goToFile( - { state, commit, getters, dispatch }, - { path: file.path, singleFile: true }, - ); + describe('when the tree entry has not been loaded', () => { + it('updates location hash', () => { + diffActions.goToFile({ state, commit, getters, dispatch }, file); - expect(document.location.hash).toBe('#test'); - }); + expect(document.location.hash).toBe('#test'); + }); - it('loads the file and then scrolls to it', async () => { - diffActions.goToFile( - { state, commit, getters, dispatch }, - { path: file.path, singleFile: true }, - ); + it('loads the file and then scrolls to it', async () => { + diffActions.goToFile({ state, commit, getters, dispatch }, file); - // Wait for the fetchFileByFile dispatch to return, to trigger scrollToFile - await waitForPromises(); + // Wait for the fetchFileByFile dispatch to return, to trigger scrollToFile + await waitForPromises(); - expect(dispatch).toHaveBeenCalledWith('fetchFileByFile'); - expect(dispatch).toHaveBeenCalledWith('scrollToFile', file); - expect(dispatch).toHaveBeenCalledTimes(2); - }); + expect(dispatch).toHaveBeenCalledWith('fetchFileByFile'); + expect(dispatch).toHaveBeenCalledWith('scrollToFile', file); + expect(dispatch).toHaveBeenCalledTimes(2); + }); - it('shows an alert when there was an error fetching the file', async () => { - dispatch = jest.fn().mockRejectedValue(); + it('shows an alert when there was an error fetching the file', async () => { + dispatch = jest.fn().mockRejectedValue(); - diffActions.goToFile( - { state, commit, getters, dispatch }, - { path: file.path, singleFile: true }, - ); + diffActions.goToFile({ state, commit, getters, dispatch }, file); - // Wait for the fetchFileByFile dispatch to return, to trigger the catch - await waitForPromises(); + // Wait for the fetchFileByFile dispatch to return, to trigger the catch + await waitForPromises(); - expect(createAlert).toHaveBeenCalledTimes(1); - expect(createAlert).toHaveBeenCalledWith({ - message: expect.stringMatching(LOAD_SINGLE_DIFF_FAILED), - }); + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ + message: expect.stringMatching(LOAD_SINGLE_DIFF_FAILED), }); }); }); @@ -1796,17 +1796,17 @@ describe('DiffsStoreActions', () => { it('commits SET_CURRENT_DIFF_FILE', () => { return testAction( diffActions.navigateToDiffFileIndex, - { index: 0, singleFile: false }, + 0, { flatBlobsList: [{ fileHash: '123' }] }, [{ type: types.SET_CURRENT_DIFF_FILE, payload: '123' }], [], ); }); - it('dispatches the fetchFileByFile action when the state value viewDiffsFileByFile is true and the single-file file-by-file feature flag is enabled', () => { + it('dispatches the fetchFileByFile action when the state value viewDiffsFileByFile is true', () => { return testAction( diffActions.navigateToDiffFileIndex, - { index: 0, singleFile: true }, + 0, { viewDiffsFileByFile: true, flatBlobsList: [{ fileHash: '123' }] }, [{ type: types.SET_CURRENT_DIFF_FILE, payload: '123' }], [{ type: 'fetchFileByFile' }], @@ -1889,4 +1889,28 @@ describe('DiffsStoreActions', () => { }, ); }); + + describe('toggleFileCommentForm', () => { + it('commits TOGGLE_FILE_COMMENT_FORM', () => { + return testAction( + diffActions.toggleFileCommentForm, + 'path', + {}, + [{ type: types.TOGGLE_FILE_COMMENT_FORM, payload: 'path' }], + [], + ); + }); + }); + + describe('addDraftToFile', () => { + it('commits ADD_DRAFT_TO_FILE', () => { + return testAction( + diffActions.addDraftToFile, + { filePath: 'path', draft: 'draft' }, + {}, + [{ type: types.ADD_DRAFT_TO_FILE, payload: { filePath: 'path', draft: 'draft' } }], + [], + ); + }); + }); }); diff --git a/spec/frontend/diffs/store/getters_spec.js b/spec/frontend/diffs/store/getters_spec.js index ed7b6699e2c..8097f0976f6 100644 --- a/spec/frontend/diffs/store/getters_spec.js +++ b/spec/frontend/diffs/store/getters_spec.js @@ -188,6 +188,24 @@ describe('Diffs Module Getters', () => { expect(getters.diffHasExpandedDiscussions(localState)(diffFile)).toEqual(true); }); + it('returns true when file discussion is expanded', () => { + const diffFile = { + discussions: [{ ...discussionMock, expanded: true }], + highlighted_diff_lines: [], + }; + + expect(getters.diffHasExpandedDiscussions(localState)(diffFile)).toEqual(true); + }); + + it('returns false when file discussion is expanded', () => { + const diffFile = { + discussions: [{ ...discussionMock, expanded: false }], + highlighted_diff_lines: [], + }; + + expect(getters.diffHasExpandedDiscussions(localState)(diffFile)).toEqual(false); + }); + it('returns false when there are no discussions', () => { const diffFile = { parallel_diff_lines: [], @@ -231,6 +249,15 @@ describe('Diffs Module Getters', () => { expect(getters.diffHasDiscussions(localState)(diffFile)).toEqual(true); }); + it('returns true when file has discussions', () => { + const diffFile = { + discussions: [discussionMock, discussionMock], + highlighted_diff_lines: [], + }; + + expect(getters.diffHasDiscussions(localState)(diffFile)).toEqual(true); + }); + it('returns false when getDiffFileDiscussions returns no discussions', () => { const diffFile = { parallel_diff_lines: [], diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js index ed8d7397bbc..b089cf22b14 100644 --- a/spec/frontend/diffs/store/mutations_spec.js +++ b/spec/frontend/diffs/store/mutations_spec.js @@ -269,6 +269,53 @@ describe('DiffsStoreMutations', () => { expect(state.diffFiles[0][INLINE_DIFF_LINES_KEY][0].discussions[0].id).toEqual(1); }); + it('should add discussions to the given file', () => { + const diffPosition = { + base_sha: 'ed13df29948c41ba367caa757ab3ec4892509910', + head_sha: 'b921914f9a834ac47e6fd9420f78db0f83559130', + new_line: null, + new_path: '500-lines-4.txt', + old_line: 5, + old_path: '500-lines-4.txt', + start_sha: 'ed13df29948c41ba367caa757ab3ec4892509910', + type: 'file', + }; + + const state = { + latestDiff: true, + diffFiles: [ + { + file_hash: 'ABC', + [INLINE_DIFF_LINES_KEY]: [], + discussions: [], + }, + ], + }; + const discussion = { + id: 1, + line_code: 'ABC_1', + diff_discussion: true, + resolvable: true, + original_position: diffPosition, + position: diffPosition, + diff_file: { + file_hash: state.diffFiles[0].file_hash, + }, + }; + + const diffPositionByLineCode = { + ABC_1: diffPosition, + }; + + mutations[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { + discussion, + diffPositionByLineCode, + }); + + expect(state.diffFiles[0].discussions.length).toEqual(1); + expect(state.diffFiles[0].discussions[0].id).toEqual(1); + }); + it('should not duplicate discussions on line', () => { const diffPosition = { base_sha: 'ed13df29948c41ba367caa757ab3ec4892509910', @@ -957,4 +1004,25 @@ describe('DiffsStoreMutations', () => { expect(state.mrReviews).toStrictEqual(newReviews); }); }); + + describe('TOGGLE_FILE_COMMENT_FORM', () => { + it('toggles diff files hasCommentForm', () => { + const state = { diffFiles: [{ file_path: 'path', hasCommentForm: false }] }; + + mutations[types.TOGGLE_FILE_COMMENT_FORM](state, 'path'); + + expect(state.diffFiles[0].hasCommentForm).toEqual(true); + }); + }); + + describe('ADD_DRAFT_TO_FILE', () => { + it('adds draft to diff file', () => { + const state = { diffFiles: [{ file_path: 'path', drafts: [] }] }; + + mutations[types.ADD_DRAFT_TO_FILE](state, { filePath: 'path', draft: 'test' }); + + expect(state.diffFiles[0].drafts.length).toEqual(1); + expect(state.diffFiles[0].drafts[0]).toEqual('test'); + }); + }); }); diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index 4760a8b7166..888df06d6b9 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -140,6 +140,7 @@ describe('DiffsStoreUtils', () => { old_line: options.noteTargetLine.old_line, new_line: options.noteTargetLine.new_line, line_range: options.lineRange, + ignore_whitespace_change: true, }); const postData = { @@ -198,6 +199,7 @@ describe('DiffsStoreUtils', () => { position_type: TEXT_DIFF_POSITION_TYPE, old_line: options.noteTargetLine.old_line, new_line: options.noteTargetLine.new_line, + ignore_whitespace_change: true, }); const postData = { @@ -713,6 +715,14 @@ describe('DiffsStoreUtils', () => { ).toBe('mode_changed'); }); + it('returns no_preview if key has no match', () => { + expect( + utils.getDiffMode({ + viewer: { name: 'no_preview' }, + }), + ).toBe('no_preview'); + }); + it('defaults to replaced', () => { expect(utils.getDiffMode({})).toBe('replaced'); }); diff --git a/spec/frontend/drawio/drawio_editor_spec.js b/spec/frontend/drawio/drawio_editor_spec.js index d7d75922e1e..4d93908b757 100644 --- a/spec/frontend/drawio/drawio_editor_spec.js +++ b/spec/frontend/drawio/drawio_editor_spec.js @@ -1,6 +1,5 @@ import { launchDrawioEditor } from '~/drawio/drawio_editor'; import { - DRAWIO_EDITOR_URL, DRAWIO_FRAME_ID, DIAGRAM_BACKGROUND_COLOR, DRAWIO_IFRAME_TIMEOUT, @@ -8,6 +7,10 @@ import { } from '~/drawio/constants'; import { createAlert, VARIANT_SUCCESS } from '~/alert'; +const DRAWIO_EDITOR_URL = + 'https://embed.diagrams.net/?ui=sketch&noSaveBtn=1&saveAndExit=1&keepmodified=1&spin=1&embed=1&libraries=1&configure=1&proto=json&toSvg=1'; +const DRAWIO_EDITOR_ORIGIN = new URL(DRAWIO_EDITOR_URL).origin; + jest.mock('~/alert'); jest.useFakeTimers(); @@ -59,6 +62,7 @@ describe('drawio/drawio_editor', () => { updateDiagram: jest.fn(), }; drawioIFrameReceivedMessages = []; + gon.diagramsnet_url = DRAWIO_EDITOR_ORIGIN; }); afterEach(() => { @@ -356,7 +360,11 @@ describe('drawio/drawio_editor', () => { const TEST_FILENAME = 'diagram.drawio.svg'; beforeEach(() => { - launchDrawioEditor({ editorFacade, filename: TEST_FILENAME }); + launchDrawioEditor({ + editorFacade, + filename: TEST_FILENAME, + drawioUrl: DRAWIO_EDITOR_ORIGIN, + }); }); it('displays loading spinner in the draw.io editor', async () => { diff --git a/spec/frontend/editor/components/source_editor_toolbar_button_spec.js b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js index b5944a52af7..1e592f435e4 100644 --- a/spec/frontend/editor/components/source_editor_toolbar_button_spec.js +++ b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js @@ -7,6 +7,7 @@ import { buildButton } from './helpers'; describe('Source Editor Toolbar button', () => { let wrapper; const defaultBtn = buildButton(); + const tertiaryBtnWithIcon = buildButton({ category: 'tertiary' }); const findButton = () => wrapper.findComponent(GlButton); @@ -41,6 +42,16 @@ describe('Source Editor Toolbar button', () => { const btn = findButton(); expect(btn.exists()).toBe(true); expect(btn.props()).toMatchObject(defaultProps); + expect(btn.text()).toBe('Foo Bar Button'); + }); + + it('does not render button for tertiary button with icon', () => { + createComponent({ + button: { + tertiaryBtnWithIcon, + }, + }); + expect(findButton().text()).toBe(''); }); it('renders a button based on the props passed', () => { diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml index 996a48f7bc6..ba4b0db908d 100644 --- a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml @@ -49,7 +49,7 @@ coverage-report-is-string: coverage_report: cobertura # invalid artifact:reports:performance -# Superceded by: artifact:reports:browser_performance +# Superseded by: artifact:reports:browser_performance performance string path: artifacts: reports: diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml index 6afd8baa0e8..56941fcc6d5 100644 --- a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml @@ -1,3 +1,10 @@ +# invalid include:rules +include: + - local: builds.yml + rules: + - if: '$INCLUDE_BUILDS == "true"' + when: on_success + # invalid trigger:include trigger missing file property: stage: prepare diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml index c00ab0d464a..909911debf1 100644 --- a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml @@ -5,8 +5,34 @@ stages: include: - local: builds.yml rules: - - if: '$INCLUDE_BUILDS == "true"' + - if: $DONT_INCLUDE_BUILDS == "true" + when: never + - local: builds.yml + rules: + - if: $INCLUDE_BUILDS == "true" when: always + - local: deploys.yml + rules: + - if: $CI_COMMIT_BRANCH == "main" + - local: builds.yml + rules: + - exists: + - exception-file.md + when: never + - local: builds.yml + rules: + - exists: + - file.md + when: always + - local: builds.yml + rules: + - exists: + - file.md + when: null + - local: deploys.yml + rules: + - exists: + - file.md # valid trigger:include trigger:include accepts project and file properties: diff --git a/spec/frontend/editor/source_editor_extension_base_spec.js b/spec/frontend/editor/source_editor_extension_base_spec.js index b1b8173188c..70bc1dee0ee 100644 --- a/spec/frontend/editor/source_editor_extension_base_spec.js +++ b/spec/frontend/editor/source_editor_extension_base_spec.js @@ -19,12 +19,12 @@ describe('The basis for an Source Editor extension', () => { const findLine = (num) => { return document.querySelector(`.${EXTENSION_BASE_LINE_NUMBERS_CLASS}:nth-child(${num})`); }; - const generateLines = () => { + const generateFixture = () => { let res = ''; for (let line = 1, lines = 5; line <= lines; line += 1) { res += `<div class="${EXTENSION_BASE_LINE_NUMBERS_CLASS}">${line}</div>`; } - return res; + return `<span class="soft-wrap-toggle"></span>${res}`; }; const generateEventMock = ({ line = defaultLine, el = null } = {}) => { return { @@ -51,7 +51,7 @@ describe('The basis for an Source Editor extension', () => { }; beforeEach(() => { - setHTMLFixture(generateLines()); + setHTMLFixture(generateFixture()); event = generateEventMock(); }); @@ -156,12 +156,13 @@ describe('The basis for an Source Editor extension', () => { describe('toggleSoftwrap', () => { let instance; - beforeEach(() => { instance = createInstance(); instance.toolbar = toolbar; instance.use({ definition: SourceEditorExtension }); + + jest.spyOn(document.querySelector('.soft-wrap-toggle'), 'blur'); }); it.each` @@ -183,6 +184,7 @@ describe('The basis for an Source Editor extension', () => { expect(instance.toolbar.updateItem).toHaveBeenCalledWith(EXTENSION_SOFTWRAP_ID, { selected: expectSelected, }); + expect(document.querySelector('.soft-wrap-toggle').blur).toHaveBeenCalled(); }, ); }); diff --git a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js index fb5fce92482..512b298bbbd 100644 --- a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js +++ b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js @@ -206,9 +206,7 @@ describe('Markdown Live Preview Extension for Source Editor', () => { it('removes the registered buttons from the toolbar', () => { expect(instance.toolbar.removeItems).not.toHaveBeenCalled(); instance.unuse(extension); - expect(instance.toolbar.removeItems).toHaveBeenCalledWith([ - EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, - ]); + expect(instance.toolbar.removeItems).toHaveBeenCalledWith([]); }); it('disposes the modelChange listener and does not fetch preview on content changes', () => { diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js index 4e341b2bb2f..53fbe105ec6 100644 --- a/spec/frontend/environment.js +++ b/spec/frontend/environment.js @@ -1,6 +1,5 @@ /* eslint-disable import/no-commonjs, max-classes-per-file */ -const path = require('path'); const { TestEnvironment } = require('jest-environment-jsdom'); const { ErrorWithStack } = require('jest-util'); const { @@ -10,8 +9,6 @@ const { const { TEST_HOST } = require('./__helpers__/test_constants'); const { createGon } = require('./__helpers__/gon_helper'); -const ROOT_PATH = path.resolve(__dirname, '../..'); - class CustomEnvironment extends TestEnvironment { constructor({ globalConfig, projectConfig }, context) { // Setup testURL so that window.location is setup properly @@ -65,9 +62,6 @@ class CustomEnvironment extends TestEnvironment { this.rejectedPromises.push(error); }; - this.global.fixturesBasePath = `${ROOT_PATH}/tmp/tests/frontend/fixtures${IS_EE ? '-ee' : ''}`; - this.global.staticFixturesBasePath = `${ROOT_PATH}/spec/frontend/fixtures`; - /** * window.fetch() is required by the apollo-upload-client library otherwise * a ReferenceError is generated: https://github.com/jaydenseric/apollo-upload-client/issues/100 diff --git a/spec/frontend/environments/edit_environment_spec.js b/spec/frontend/environments/edit_environment_spec.js index 34f338fabe6..f436c96f4a5 100644 --- a/spec/frontend/environments/edit_environment_spec.js +++ b/spec/frontend/environments/edit_environment_spec.js @@ -1,5 +1,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import EditEnvironment from '~/environments/components/edit_environment.vue'; @@ -7,99 +9,213 @@ import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { visitUrl } from '~/lib/utils/url_utility'; +import getEnvironment from '~/environments/graphql/queries/environment.query.graphql'; +import updateEnvironment from '~/environments/graphql/mutations/update_environment.mutation.graphql'; +import { __ } from '~/locale'; +import createMockApollo from '../__helpers__/mock_apollo_helper'; jest.mock('~/lib/utils/url_utility'); jest.mock('~/alert'); -const DEFAULT_OPTS = { - provide: { - projectEnvironmentsPath: '/projects/environments', - updateEnvironmentPath: '/proejcts/environments/1', - protectedEnvironmentSettingsPath: '/projects/1/settings/ci_cd', - }, - propsData: { environment: { id: '0', name: 'foo', external_url: 'https://foo.example.com' } }, +const newExternalUrl = 'https://google.ca'; +const environment = { + id: '1', + name: 'foo', + externalUrl: 'https://foo.example.com', + clusterAgent: null, +}; +const resolvedEnvironment = { project: { id: '1', environment } }; +const environmentUpdate = { + environment: { id: '1', path: 'path/to/environment', clusterAgentId: null }, + errors: [], +}; +const environmentUpdateError = { + environment: null, + errors: [{ message: 'uh oh!' }], +}; + +const provide = { + projectEnvironmentsPath: '/projects/environments', + updateEnvironmentPath: '/projects/environments/1', + protectedEnvironmentSettingsPath: '/projects/1/settings/ci_cd', + projectPath: '/path/to/project', }; describe('~/environments/components/edit.vue', () => { let wrapper; let mock; - const createWrapper = (opts = {}) => - mountExtended(EditEnvironment, { - ...DEFAULT_OPTS, - ...opts, + const createMockApolloProvider = (mutationResult) => { + Vue.use(VueApollo); + + const mocks = [ + [getEnvironment, jest.fn().mockResolvedValue({ data: resolvedEnvironment })], + [ + updateEnvironment, + jest.fn().mockResolvedValue({ data: { environmentUpdate: mutationResult } }), + ], + ]; + + return createMockApollo(mocks); + }; + + const createWrapper = () => { + wrapper = mountExtended(EditEnvironment, { + propsData: { environment: { id: '1', name: 'foo', external_url: 'https://foo.example.com' } }, + provide, }); + }; - beforeEach(() => { - mock = new MockAdapter(axios); - wrapper = createWrapper(); - }); + const createWrapperWithApollo = async ({ mutationResult = environmentUpdate } = {}) => { + wrapper = mountExtended(EditEnvironment, { + propsData: { environment: {} }, + provide: { + ...provide, + glFeatures: { + environmentSettingsToGraphql: true, + }, + }, + apolloProvider: createMockApolloProvider(mutationResult), + }); - afterEach(() => { - mock.restore(); - }); + await waitForPromises(); + }; - const findNameInput = () => wrapper.findByLabelText('Name'); - const findExternalUrlInput = () => wrapper.findByLabelText('External URL'); - const findForm = () => wrapper.findByRole('form', { name: 'Edit environment' }); + const findNameInput = () => wrapper.findByLabelText(__('Name')); + const findExternalUrlInput = () => wrapper.findByLabelText(__('External URL')); + const findForm = () => wrapper.findByRole('form', { name: __('Edit environment') }); const showsLoading = () => wrapper.findComponent(GlLoadingIcon).exists(); - const submitForm = async (expected, response) => { - mock - .onPut(DEFAULT_OPTS.provide.updateEnvironmentPath, { - external_url: expected.url, - id: '0', - }) - .reply(...response); - await findExternalUrlInput().setValue(expected.url); - + const submitForm = async () => { + await findExternalUrlInput().setValue(newExternalUrl); await findForm().trigger('submit'); - await waitForPromises(); }; - it('sets the title to Edit environment', () => { - const header = wrapper.findByRole('heading', { name: 'Edit environment' }); - expect(header.exists()).toBe(true); - }); + describe('default', () => { + beforeEach(async () => { + await createWrapper(); + }); - it('shows loader after form is submitted', async () => { - const expected = { url: 'https://google.ca' }; + it('sets the title to Edit environment', () => { + const header = wrapper.findByRole('heading', { name: __('Edit environment') }); + expect(header.exists()).toBe(true); + }); - expect(showsLoading()).toBe(false); + it('renders a disabled "Name" field', () => { + const nameInput = findNameInput(); - await submitForm(expected, [HTTP_STATUS_OK, { path: '/test' }]); + expect(nameInput.attributes().disabled).toBe('disabled'); + expect(nameInput.element.value).toBe(environment.name); + }); - expect(showsLoading()).toBe(true); + it('renders an "External URL" field', () => { + const urlInput = findExternalUrlInput(); + + expect(urlInput.element.value).toBe(environment.externalUrl); + }); }); - it('submits the updated environment on submit', async () => { - const expected = { url: 'https://google.ca' }; + describe('when environmentSettingsToGraphql feature is enabled', () => { + describe('when mounted', () => { + beforeEach(() => { + createWrapperWithApollo(); + }); + it('renders loading icon when environment query is loading', () => { + expect(showsLoading()).toBe(true); + }); + }); - await submitForm(expected, [HTTP_STATUS_OK, { path: '/test' }]); + describe('when mutation successful', () => { + beforeEach(async () => { + await createWrapperWithApollo(); + }); - expect(visitUrl).toHaveBeenCalledWith('/test'); - }); + it('shows loader after form is submitted', async () => { + expect(showsLoading()).toBe(false); - it('shows errors on error', async () => { - const expected = { url: 'https://google.ca' }; + await submitForm(); - await submitForm(expected, [HTTP_STATUS_BAD_REQUEST, { message: ['uh oh!'] }]); + expect(showsLoading()).toBe(true); + }); - expect(createAlert).toHaveBeenCalledWith({ message: 'uh oh!' }); - expect(showsLoading()).toBe(false); - }); + it('submits the updated environment on submit', async () => { + await submitForm(); + await waitForPromises(); + + expect(visitUrl).toHaveBeenCalledWith(environmentUpdate.environment.path); + }); + }); + + describe('when mutation failed', () => { + beforeEach(async () => { + await createWrapperWithApollo({ + mutationResult: environmentUpdateError, + }); + }); - it('renders a disabled "Name" field', () => { - const nameInput = findNameInput(); + it('shows errors on error', async () => { + await submitForm(); + await waitForPromises(); - expect(nameInput.attributes().disabled).toBe('disabled'); - expect(nameInput.element.value).toBe('foo'); + expect(createAlert).toHaveBeenCalledWith({ message: 'uh oh!' }); + expect(showsLoading()).toBe(false); + }); + }); }); - it('renders an "External URL" field', () => { - const urlInput = findExternalUrlInput(); + describe('when environmentSettingsToGraphql feature is disabled', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + createWrapper(); + }); + + afterEach(() => { + mock.restore(); + }); + + it('shows loader after form is submitted', async () => { + expect(showsLoading()).toBe(false); - expect(urlInput.element.value).toBe('https://foo.example.com'); + mock + .onPut(provide.updateEnvironmentPath, { + external_url: newExternalUrl, + id: environment.id, + }) + .reply(...[HTTP_STATUS_OK, { path: '/test' }]); + + await submitForm(); + + expect(showsLoading()).toBe(true); + }); + + it('submits the updated environment on submit', async () => { + mock + .onPut(provide.updateEnvironmentPath, { + external_url: newExternalUrl, + id: environment.id, + }) + .reply(...[HTTP_STATUS_OK, { path: '/test' }]); + + await submitForm(); + await waitForPromises(); + + expect(visitUrl).toHaveBeenCalledWith('/test'); + }); + + it('shows errors on error', async () => { + mock + .onPut(provide.updateEnvironmentPath, { + external_url: newExternalUrl, + id: environment.id, + }) + .reply(...[HTTP_STATUS_BAD_REQUEST, { message: ['uh oh!'] }]); + + await submitForm(); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ message: 'uh oh!' }); + expect(showsLoading()).toBe(false); + }); }); }); diff --git a/spec/frontend/environments/environment_delete_spec.js b/spec/frontend/environments/environment_delete_spec.js index 530f9f55088..ea402f26426 100644 --- a/spec/frontend/environments/environment_delete_spec.js +++ b/spec/frontend/environments/environment_delete_spec.js @@ -1,4 +1,4 @@ -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; @@ -21,7 +21,7 @@ describe('External URL Component', () => { }); }; - const findDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem); describe('event hub', () => { beforeEach(() => { @@ -30,13 +30,13 @@ describe('External URL Component', () => { 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'); + expect(findDropdownItem().props('item').text).toBe('Delete environment'); + expect(findDropdownItem().props('item').extraAttrs.variant).toBe('danger'); }); it('emits requestDeleteEnvironment in the event hub when button is clicked', () => { jest.spyOn(eventHub, '$emit'); - findDropdownItem().vm.$emit('click'); + findDropdownItem().vm.$emit('action'); expect(eventHub.$emit).toHaveBeenCalledWith('requestDeleteEnvironment', resolvedEnvironment); }); }); @@ -55,13 +55,13 @@ describe('External URL Component', () => { 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'); + expect(findDropdownItem().props('item').text).toBe('Delete environment'); + expect(findDropdownItem().props('item').extraAttrs.variant).toBe('danger'); }); it('emits requestDeleteEnvironment in the event hub when button is clicked', () => { jest.spyOn(mockApollo.defaultClient, 'mutate'); - findDropdownItem().vm.$emit('click'); + findDropdownItem().vm.$emit('action'); expect(mockApollo.defaultClient.mutate).toHaveBeenCalledWith({ mutation: setEnvironmentToDelete, variables: { environment: resolvedEnvironment }, diff --git a/spec/frontend/environments/environment_folder_spec.js b/spec/frontend/environments/environment_folder_spec.js index 4716f807657..65c16697d44 100644 --- a/spec/frontend/environments/environment_folder_spec.js +++ b/spec/frontend/environments/environment_folder_spec.js @@ -35,7 +35,7 @@ describe('~/environments/components/environments_folder.vue', () => { ...propsData, }, stubs: { transition: stubTransition() }, - provide: { helpPagePath: '/help', projectId: '1' }, + provide: { helpPagePath: '/help', projectId: '1', projectPath: 'path/to/project' }, }); beforeEach(() => { diff --git a/spec/frontend/environments/environment_form_spec.js b/spec/frontend/environments/environment_form_spec.js index 50e4e637aa3..db81c490747 100644 --- a/spec/frontend/environments/environment_form_spec.js +++ b/spec/frontend/environments/environment_form_spec.js @@ -1,6 +1,11 @@ -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlCollapsibleListbox } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import waitForPromises from 'helpers/wait_for_promises'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import EnvironmentForm from '~/environments/components/environment_form.vue'; +import getUserAuthorizedAgents from '~/environments/graphql/queries/user_authorized_agents.query.graphql'; +import createMockApollo from '../__helpers__/mock_apollo_helper'; jest.mock('~/lib/utils/csrf'); @@ -11,6 +16,10 @@ const DEFAULT_PROPS = { }; const PROVIDE = { protectedEnvironmentSettingsPath: '/projects/not_real/settings/ci_cd' }; +const userAccessAuthorizedAgents = [ + { agent: { id: '1', name: 'agent-1' } }, + { agent: { id: '2', name: 'agent-2' } }, +]; describe('~/environments/components/form.vue', () => { let wrapper; @@ -25,6 +34,38 @@ describe('~/environments/components/form.vue', () => { }, }); + const createWrapperWithApollo = ({ propsData = {} } = {}) => { + Vue.use(VueApollo); + + return mountExtended(EnvironmentForm, { + provide: { + ...PROVIDE, + glFeatures: { + environmentSettingsToGraphql: true, + }, + }, + propsData: { + ...DEFAULT_PROPS, + ...propsData, + }, + apolloProvider: createMockApollo([ + [ + getUserAuthorizedAgents, + jest.fn().mockResolvedValue({ + data: { + project: { + id: '1', + userAccessAuthorizedAgents: { nodes: userAccessAuthorizedAgents }, + }, + }, + }), + ], + ]), + }); + }; + + const findAgentSelector = () => wrapper.findComponent(GlCollapsibleListbox); + describe('default', () => { beforeEach(() => { wrapper = createWrapper(); @@ -167,4 +208,83 @@ describe('~/environments/components/form.vue', () => { expect(urlInput.element.value).toBe('https://example.com'); }); }); + + describe('when `environmentSettingsToGraphql feature flag is enabled', () => { + beforeEach(() => { + wrapper = createWrapperWithApollo(); + }); + + it('renders an agent selector listbox', () => { + expect(findAgentSelector().props()).toMatchObject({ + searchable: true, + toggleText: EnvironmentForm.i18n.agentHelpText, + headerText: EnvironmentForm.i18n.agentHelpText, + resetButtonLabel: EnvironmentForm.i18n.reset, + loading: false, + items: [], + }); + }); + + it('sets the items prop of the agent selector after fetching the list', async () => { + findAgentSelector().vm.$emit('shown'); + await waitForPromises(); + + expect(findAgentSelector().props('items')).toEqual([ + { value: '1', text: 'agent-1' }, + { value: '2', text: 'agent-2' }, + ]); + }); + + it('sets the loading prop of the agent selector while fetching the list', async () => { + await findAgentSelector().vm.$emit('shown'); + expect(findAgentSelector().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findAgentSelector().props('loading')).toBe(false); + }); + + it('filters the agent list on user search', async () => { + findAgentSelector().vm.$emit('shown'); + await waitForPromises(); + await findAgentSelector().vm.$emit('search', 'agent-2'); + + expect(findAgentSelector().props('items')).toEqual([{ value: '2', text: 'agent-2' }]); + }); + + it('updates agent selector field with the name of selected agent', async () => { + findAgentSelector().vm.$emit('shown'); + await waitForPromises(); + await findAgentSelector().vm.$emit('select', '2'); + + expect(findAgentSelector().props('toggleText')).toBe('agent-2'); + }); + + it('emits changes to the clusterAgentId', async () => { + findAgentSelector().vm.$emit('shown'); + await waitForPromises(); + await findAgentSelector().vm.$emit('select', '2'); + + expect(wrapper.emitted('change')).toEqual([ + [{ name: '', externalUrl: '', clusterAgentId: '2' }], + ]); + }); + }); + + describe('when environment has an associated agent', () => { + const environmentWithAgent = { + ...DEFAULT_PROPS.environment, + clusterAgent: { id: '1', name: 'agent-1' }, + clusterAgentId: '1', + }; + beforeEach(() => { + wrapper = createWrapperWithApollo({ + propsData: { environment: environmentWithAgent }, + }); + }); + + it('updates agent selector field with the name of the associated agent', () => { + expect(findAgentSelector().props('toggleText')).toBe('agent-1'); + }); + }); }); diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js index e2b184adc8a..690db66efd1 100644 --- a/spec/frontend/environments/environment_item_spec.js +++ b/spec/frontend/environments/environment_item_spec.js @@ -51,7 +51,6 @@ describe('Environment item', () => { const findUpcomingDeploymentAvatarLink = () => findUpcomingDeployment().findComponent(GlAvatarLink); const findUpcomingDeploymentAvatar = () => findUpcomingDeployment().findComponent(GlAvatar); - const findMonitoringLink = () => wrapper.find('[data-testid="environment-monitoring"]'); describe('when item is not folder', () => { it('should render environment name', () => { @@ -435,25 +434,4 @@ describe('Environment item', () => { }); }); }); - - describe.each([true, false])( - 'when `remove_monitor_metrics` flag is %p', - (removeMonitorMetrics) => { - beforeEach(() => { - factory({ - propsData: { - model: { - metrics_path: 'http://0.0.0.0:3000/flightjs/Flight/-/metrics?environment=6', - }, - tableData, - }, - provide: { glFeatures: { removeMonitorMetrics } }, - }); - }); - - it(`${removeMonitorMetrics ? 'does not render' : 'renders'} link to metrics`, () => { - expect(findMonitoringLink().exists()).toBe(!removeMonitorMetrics); - }); - }, - ); }); diff --git a/spec/frontend/environments/environment_monitoring_spec.js b/spec/frontend/environments/environment_monitoring_spec.js deleted file mode 100644 index 98dd9edd812..00000000000 --- a/spec/frontend/environments/environment_monitoring_spec.js +++ /dev/null @@ -1,26 +0,0 @@ -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import MonitoringComponent from '~/environments/components/environment_monitoring.vue'; -import { __ } from '~/locale'; - -describe('Monitoring Component', () => { - let wrapper; - - const monitoringUrl = 'https://gitlab.com'; - - const createWrapper = () => { - wrapper = mountExtended(MonitoringComponent, { - propsData: { - monitoringUrl, - }, - }); - }; - - beforeEach(() => { - createWrapper(); - }); - - it('should render a link to environment monitoring page', () => { - const link = wrapper.findByRole('menuitem', { name: __('Monitoring') }); - expect(link.attributes('href')).toEqual(monitoringUrl); - }); -}); diff --git a/spec/frontend/environments/environment_pin_spec.js b/spec/frontend/environments/environment_pin_spec.js index ee195b41bc8..bf371978d72 100644 --- a/spec/frontend/environments/environment_pin_spec.js +++ b/spec/frontend/environments/environment_pin_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import cancelAutoStopMutation from '~/environments/graphql/mutations/cancel_auto_stop.mutation.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -18,6 +18,8 @@ describe('Pin Component', () => { const autoStopUrl = '/root/auto-stop-env-test/-/environments/38/cancel_auto_stop'; + const findDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem); + describe('without graphql', () => { beforeEach(() => { factory({ @@ -28,14 +30,13 @@ describe('Pin Component', () => { }); it('should render the component with descriptive text', () => { - expect(wrapper.text()).toBe('Prevent auto-stopping'); + expect(findDropdownItem().props('item').text).toBe('Prevent auto-stopping'); }); it('should emit onPinClick when clicked', () => { const eventHubSpy = jest.spyOn(eventHub, '$emit'); - const item = wrapper.findComponent(GlDropdownItem); - item.vm.$emit('click'); + findDropdownItem().vm.$emit('action'); expect(eventHubSpy).toHaveBeenCalledWith('cancelAutoStop', autoStopUrl); }); @@ -57,14 +58,13 @@ describe('Pin Component', () => { }); it('should render the component with descriptive text', () => { - expect(wrapper.text()).toBe('Prevent auto-stopping'); + expect(findDropdownItem().props('item').text).toBe('Prevent auto-stopping'); }); it('should emit onPinClick when clicked', () => { jest.spyOn(mockApollo.defaultClient, 'mutate'); - const item = wrapper.findComponent(GlDropdownItem); - item.vm.$emit('click'); + findDropdownItem().vm.$emit('action'); expect(mockApollo.defaultClient.mutate).toHaveBeenCalledWith({ mutation: cancelAutoStopMutation, diff --git a/spec/frontend/environments/environment_rollback_spec.js b/spec/frontend/environments/environment_rollback_spec.js index 5d36209f8a6..653be6c1fde 100644 --- a/spec/frontend/environments/environment_rollback_spec.js +++ b/spec/frontend/environments/environment_rollback_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import RollbackComponent from '~/environments/components/environment_rollback.vue'; import eventHub from '~/environments/event_hub'; @@ -8,10 +8,14 @@ import setEnvironmentToRollback from '~/environments/graphql/mutations/set_envir import createMockApollo from 'helpers/mock_apollo_helper'; describe('Rollback Component', () => { + let wrapper; + const retryUrl = 'https://gitlab.com/retry'; + const findDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem); + it('Should render Re-deploy label when isLastDeployment is true', () => { - const wrapper = shallowMount(RollbackComponent, { + wrapper = shallowMount(RollbackComponent, { propsData: { retryUrl, isLastDeployment: true, @@ -19,11 +23,11 @@ describe('Rollback Component', () => { }, }); - expect(wrapper.text()).toBe('Re-deploy to environment'); + expect(findDropdownItem().props('item').text).toBe('Re-deploy to environment'); }); it('Should render Rollback label when isLastDeployment is false', () => { - const wrapper = shallowMount(RollbackComponent, { + wrapper = shallowMount(RollbackComponent, { propsData: { retryUrl, isLastDeployment: false, @@ -31,12 +35,12 @@ describe('Rollback Component', () => { }, }); - expect(wrapper.text()).toBe('Rollback environment'); + expect(findDropdownItem().props('item').text).toBe('Rollback environment'); }); it('should emit a "rollback" event on button click', () => { const eventHubSpy = jest.spyOn(eventHub, '$emit'); - const wrapper = shallowMount(RollbackComponent, { + wrapper = shallowMount(RollbackComponent, { propsData: { retryUrl, environment: { @@ -44,9 +48,8 @@ describe('Rollback Component', () => { }, }, }); - const button = wrapper.findComponent(GlDropdownItem); - button.vm.$emit('click'); + findDropdownItem().vm.$emit('action'); expect(eventHubSpy).toHaveBeenCalledWith('requestRollbackEnvironment', { retryUrl, @@ -63,7 +66,8 @@ describe('Rollback Component', () => { const environment = { name: 'test', }; - const wrapper = shallowMount(RollbackComponent, { + + wrapper = shallowMount(RollbackComponent, { propsData: { retryUrl, graphql: true, @@ -71,8 +75,8 @@ describe('Rollback Component', () => { }, apolloProvider, }); - const button = wrapper.findComponent(GlDropdownItem); - button.vm.$emit('click'); + + findDropdownItem().vm.$emit('action'); expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({ mutation: setEnvironmentToRollback, diff --git a/spec/frontend/environments/environment_terminal_button_spec.js b/spec/frontend/environments/environment_terminal_button_spec.js index ab9f370595f..0a5ac96d26f 100644 --- a/spec/frontend/environments/environment_terminal_button_spec.js +++ b/spec/frontend/environments/environment_terminal_button_spec.js @@ -17,7 +17,7 @@ describe('Terminal Component', () => { }); it('should render a link to open a web terminal with the provided path', () => { - const link = wrapper.findByRole('menuitem', { name: __('Terminal') }); + const link = wrapper.findByRole('link', { name: __('Terminal') }); expect(link.attributes('href')).toBe(terminalPath); }); diff --git a/spec/frontend/environments/environments_detail_header_spec.js b/spec/frontend/environments/environments_detail_header_spec.js index 9464aeff028..5cbc16100be 100644 --- a/spec/frontend/environments/environments_detail_header_spec.js +++ b/spec/frontend/environments/environments_detail_header_spec.js @@ -1,6 +1,6 @@ import { GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { createMockDirective } from 'helpers/vue_mock_directive'; import DeleteEnvironmentModal from '~/environments/components/delete_environment_modal.vue'; import EnvironmentsDetailHeader from '~/environments/components/environments_detail_header.vue'; import StopEnvironmentModal from '~/environments/components/stop_environment_modal.vue'; @@ -11,7 +11,6 @@ import { createEnvironment } from './mock_data'; describe('Environments detail header component', () => { const cancelAutoStopPath = '/my-environment/cancel/path'; const terminalPath = '/my-environment/terminal/path'; - const metricsPath = '/my-environment/metrics/path'; const updatePath = '/my-environment/edit/path'; let wrapper; @@ -22,7 +21,6 @@ describe('Environments detail header component', () => { const findCancelAutoStopAtForm = () => wrapper.findByTestId('cancel-auto-stop-form'); const findTerminalButton = () => wrapper.findByTestId('terminal-button'); const findExternalUrlButton = () => wrapper.findComponentByTestId('external-url-button'); - const findMetricsButton = () => wrapper.findByTestId('metrics-button'); const findEditButton = () => wrapper.findByTestId('edit-button'); const findStopButton = () => wrapper.findByTestId('stop-button'); const findDestroyButton = () => wrapper.findByTestId('destroy-button'); @@ -34,7 +32,6 @@ describe('Environments detail header component', () => { ['Cancel Auto Stop At', findCancelAutoStopAtButton], ['Terminal', findTerminalButton], ['External Url', findExternalUrlButton], - ['Metrics', findMetricsButton], ['Edit', findEditButton], ['Stop', findStopButton], ['Destroy', findDestroyButton], @@ -178,48 +175,6 @@ describe('Environments detail header component', () => { }); }); - describe('when metrics are enabled', () => { - beforeEach(() => { - createWrapper({ - props: { - environment: createEnvironment({ metricsUrl: 'my metrics url' }), - metricsPath, - }, - }); - }); - - it('displays the metrics button with correct path', () => { - expect(findMetricsButton().attributes('href')).toBe(metricsPath); - }); - - it('uses a gl tooltip for the title', () => { - const button = findMetricsButton(); - const tooltip = getBinding(button.element, 'gl-tooltip'); - - expect(tooltip).toBeDefined(); - expect(button.attributes('title')).toBe('See metrics'); - }); - - describe.each([true, false])( - 'and `remove_monitor_metrics` flag is %p', - (removeMonitorMetrics) => { - beforeEach(() => { - createWrapper({ - props: { - environment: createEnvironment({ metricsUrl: 'my metrics url' }), - metricsPath, - }, - glFeatures: { removeMonitorMetrics }, - }); - }); - - it(`${removeMonitorMetrics ? 'does not render' : 'renders'} Metrics button`, () => { - expect(findMetricsButton().exists()).toBe(!removeMonitorMetrics); - }); - }, - ); - }); - describe('when has all admin rights', () => { beforeEach(() => { createWrapper({ diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js index addbf2c21dc..91268ade1e9 100644 --- a/spec/frontend/environments/graphql/mock_data.js +++ b/spec/frontend/environments/graphql/mock_data.js @@ -800,12 +800,14 @@ export const resolvedDeploymentDetails = { }; export const agent = { - project: 'agent-project', id: 'gid://gitlab/ClusterAgent/1', name: 'agent-name', - kubernetesNamespace: 'agent-namespace', + webPath: 'path/to/agent-page', + tokens: { nodes: [] }, }; +export const kubernetesNamespace = 'agent-namespace'; + const runningPod = { status: { phase: 'Running' } }; const pendingPod = { status: { phase: 'Pending' } }; const succeededPod = { status: { phase: 'Succeeded' } }; diff --git a/spec/frontend/environments/kubernetes_agent_info_spec.js b/spec/frontend/environments/kubernetes_agent_info_spec.js index b1795065281..9169b9284f4 100644 --- a/spec/frontend/environments/kubernetes_agent_info_spec.js +++ b/spec/frontend/environments/kubernetes_agent_info_spec.js @@ -1,26 +1,14 @@ import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import { GlIcon, GlLink, GlSprintf, GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import KubernetesAgentInfo from '~/environments/components/kubernetes_agent_info.vue'; import { AGENT_STATUSES, ACTIVE_CONNECTION_TIME } from '~/clusters_list/constants'; -import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import getK8sClusterAgentQuery from '~/environments/graphql/queries/k8s_cluster_agent.query.graphql'; -Vue.use(VueApollo); - -const propsData = { - agentName: 'my-agent', - agentId: '1', - agentProjectPath: 'path/to/agent-config-project', -}; - -const mockClusterAgent = { - id: '1', - name: 'token-1', +const defaultClusterAgent = { + name: 'my-agent', + id: 'gid://gitlab/ClusterAgent/1', webPath: 'path/to/agent-page', }; @@ -29,27 +17,16 @@ const connectedTimeInactive = new Date(connectedTimeNow.getTime() - ACTIVE_CONNE describe('~/environments/components/kubernetes_agent_info.vue', () => { let wrapper; - let agentQueryResponse; - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAgentLink = () => wrapper.findComponent(GlLink); const findAgentStatus = () => wrapper.findByTestId('agent-status'); const findAgentStatusIcon = () => findAgentStatus().findComponent(GlIcon); const findAgentLastUsedDate = () => wrapper.findByTestId('agent-last-used-date'); - const findAlert = () => wrapper.findComponent(GlAlert); - - const createWrapper = ({ tokens = [], queryResponse = null } = {}) => { - const clusterAgent = { ...mockClusterAgent, tokens: { nodes: tokens } }; - - agentQueryResponse = - queryResponse || - jest.fn().mockResolvedValue({ data: { project: { id: 'project-1', clusterAgent } } }); - const apolloProvider = createMockApollo([[getK8sClusterAgentQuery, agentQueryResponse]]); + const createWrapper = ({ tokens = [] } = {}) => { wrapper = extendedWrapper( shallowMount(KubernetesAgentInfo, { - apolloProvider, - propsData, + propsData: { clusterAgent: { ...defaultClusterAgent, tokens: { nodes: tokens } } }, stubs: { TimeAgoTooltip, GlSprintf }, }), ); @@ -60,28 +37,9 @@ describe('~/environments/components/kubernetes_agent_info.vue', () => { createWrapper(); }); - it('shows loading icon while fetching the agent details', async () => { - expect(findLoadingIcon().exists()).toBe(true); - await waitForPromises(); - expect(findLoadingIcon().exists()).toBe(false); - }); - - it('sends expected params', async () => { - await waitForPromises(); - - const variables = { - agentName: propsData.agentName, - projectPath: propsData.agentProjectPath, - }; - - expect(agentQueryResponse).toHaveBeenCalledWith(variables); - }); - - it('renders the agent name with the link', async () => { - await waitForPromises(); - - expect(findAgentLink().attributes('href')).toBe(mockClusterAgent.webPath); - expect(findAgentLink().text()).toContain(mockClusterAgent.id); + it('renders the agent name with the link', () => { + expect(findAgentLink().attributes('href')).toBe(defaultClusterAgent.webPath); + expect(findAgentLink().text()).toContain('1'); }); }); @@ -110,15 +68,4 @@ describe('~/environments/components/kubernetes_agent_info.vue', () => { expect(findAgentLastUsedDate().text()).toBe(lastUsedText); }); }); - - describe('when the agent query has errored', () => { - beforeEach(() => { - createWrapper({ clusterAgent: null, queryResponse: jest.fn().mockRejectedValue() }); - return waitForPromises(); - }); - - it('displays an alert message', () => { - expect(findAlert().text()).toBe(KubernetesAgentInfo.i18n.loadingError); - }); - }); }); diff --git a/spec/frontend/environments/kubernetes_overview_spec.js b/spec/frontend/environments/kubernetes_overview_spec.js index 394fd200edf..1c7ace00f48 100644 --- a/spec/frontend/environments/kubernetes_overview_spec.js +++ b/spec/frontend/environments/kubernetes_overview_spec.js @@ -5,14 +5,13 @@ import KubernetesOverview from '~/environments/components/kubernetes_overview.vu import KubernetesAgentInfo from '~/environments/components/kubernetes_agent_info.vue'; import KubernetesPods from '~/environments/components/kubernetes_pods.vue'; import KubernetesTabs from '~/environments/components/kubernetes_tabs.vue'; -import { agent } from './graphql/mock_data'; +import KubernetesStatusBar from '~/environments/components/kubernetes_status_bar.vue'; +import { agent, kubernetesNamespace } from './graphql/mock_data'; import { mockKasTunnelUrl } from './mock_data'; const propsData = { - agentId: agent.id, - agentName: agent.name, - agentProjectPath: agent.project, - namespace: agent.kubernetesNamespace, + clusterAgent: agent, + namespace: kubernetesNamespace, }; const provide = { @@ -23,6 +22,7 @@ const configuration = { basePath: provide.kasTunnelUrl.replace(/\/$/, ''), baseOptions: { headers: { 'GitLab-Agent-Id': '1' }, + withCredentials: true, }, }; @@ -34,6 +34,7 @@ describe('~/environments/components/kubernetes_overview.vue', () => { const findAgentInfo = () => wrapper.findComponent(KubernetesAgentInfo); const findKubernetesPods = () => wrapper.findComponent(KubernetesPods); const findKubernetesTabs = () => wrapper.findComponent(KubernetesTabs); + const findKubernetesStatusBar = () => wrapper.findComponent(KubernetesStatusBar); const findAlert = () => wrapper.findComponent(GlAlert); const createWrapper = () => { @@ -91,26 +92,65 @@ describe('~/environments/components/kubernetes_overview.vue', () => { }); it('renders kubernetes agent info', () => { - expect(findAgentInfo().props()).toEqual({ - agentName: agent.name, - agentId: agent.id, - agentProjectPath: agent.project, - }); + expect(findAgentInfo().props('clusterAgent')).toEqual(agent); }); it('renders kubernetes pods', () => { expect(findKubernetesPods().props()).toEqual({ - namespace: agent.kubernetesNamespace, + namespace: kubernetesNamespace, configuration, }); }); it('renders kubernetes tabs', () => { expect(findKubernetesTabs().props()).toEqual({ - namespace: agent.kubernetesNamespace, + namespace: kubernetesNamespace, configuration, }); }); + + it('renders kubernetes status bar', () => { + expect(findKubernetesStatusBar().exists()).toBe(true); + }); + }); + + describe('Kubernetes health status', () => { + beforeEach(() => { + createWrapper(); + toggleCollapse(); + }); + + it("doesn't set `clusterHealthStatus` when pods are still loading", async () => { + findKubernetesPods().vm.$emit('loading', true); + await nextTick(); + + expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe(''); + }); + + it("doesn't set `clusterHealthStatus` when workload types are still loading", async () => { + findKubernetesTabs().vm.$emit('loading', true); + await nextTick(); + + expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe(''); + }); + + it('sets `clusterHealthStatus` as error when pods emitted a failure', async () => { + findKubernetesPods().vm.$emit('failed'); + await nextTick(); + + expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('error'); + }); + + it('sets `clusterHealthStatus` as error when workload types emitted a failure', async () => { + findKubernetesTabs().vm.$emit('failed'); + await nextTick(); + + expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('error'); + }); + + it('sets `clusterHealthStatus` as success when data is loaded and no failures where emitted', () => { + expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('success'); + }); }); describe('on cluster error', () => { diff --git a/spec/frontend/environments/kubernetes_pods_spec.js b/spec/frontend/environments/kubernetes_pods_spec.js index 137309d7853..0420d8df1a9 100644 --- a/spec/frontend/environments/kubernetes_pods_spec.js +++ b/spec/frontend/environments/kubernetes_pods_spec.js @@ -50,6 +50,14 @@ describe('~/environments/components/kubernetes_pods.vue', () => { expect(findLoadingIcon().exists()).toBe(true); }); + it('emits loading state', async () => { + createWrapper(); + expect(wrapper.emitted('loading')[0]).toEqual([true]); + + await waitForPromises(); + expect(wrapper.emitted('loading')[1]).toEqual([false]); + }); + it('hides the loading icon when the list of pods loaded', async () => { createWrapper(); await waitForPromises(); @@ -84,6 +92,13 @@ describe('~/environments/components/kubernetes_pods.vue', () => { }); }, ); + + it('emits a failed event when there are failed pods', async () => { + createWrapper(); + await waitForPromises(); + + expect(wrapper.emitted('failed')).toHaveLength(1); + }); }); describe('when gets an error from the cluster_client API', () => { diff --git a/spec/frontend/environments/kubernetes_status_bar_spec.js b/spec/frontend/environments/kubernetes_status_bar_spec.js new file mode 100644 index 00000000000..2ebb30e2766 --- /dev/null +++ b/spec/frontend/environments/kubernetes_status_bar_spec.js @@ -0,0 +1,42 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon, GlBadge } from '@gitlab/ui'; +import KubernetesStatusBar from '~/environments/components/kubernetes_status_bar.vue'; +import { + CLUSTER_STATUS_HEALTHY_TEXT, + CLUSTER_STATUS_UNHEALTHY_TEXT, +} from '~/environments/constants'; + +describe('~/environments/components/kubernetes_status_bar.vue', () => { + let wrapper; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findHealthBadge = () => wrapper.findComponent(GlBadge); + + const createWrapper = ({ clusterHealthStatus = '' } = {}) => { + wrapper = shallowMount(KubernetesStatusBar, { + propsData: { clusterHealthStatus }, + }); + }; + + describe('health badge', () => { + it('shows loading icon when cluster health is not present', () => { + createWrapper(); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it.each([ + ['success', 'success', CLUSTER_STATUS_HEALTHY_TEXT], + ['error', 'danger', CLUSTER_STATUS_UNHEALTHY_TEXT], + ])( + 'when clusterHealthStatus is %s shows health badge with variant %s and text %s', + (status, variant, text) => { + createWrapper({ clusterHealthStatus: status }); + + expect(findLoadingIcon().exists()).toBe(false); + expect(findHealthBadge().props('variant')).toBe(variant); + expect(findHealthBadge().text()).toBe(text); + }, + ); + }); +}); diff --git a/spec/frontend/environments/kubernetes_summary_spec.js b/spec/frontend/environments/kubernetes_summary_spec.js index 53b83079486..22c81f29f64 100644 --- a/spec/frontend/environments/kubernetes_summary_spec.js +++ b/spec/frontend/environments/kubernetes_summary_spec.js @@ -59,6 +59,14 @@ describe('~/environments/components/kubernetes_summary.vue', () => { expect(findLoadingIcon().exists()).toBe(true); }); + it('emits loading state', async () => { + createWrapper(); + expect(wrapper.emitted('loading')[0]).toEqual([true]); + + await waitForPromises(); + expect(wrapper.emitted('loading')[1]).toEqual([false]); + }); + describe('when workloads data is loaded', () => { beforeEach(async () => { await createWrapper(); @@ -94,6 +102,10 @@ describe('~/environments/components/kubernetes_summary.vue', () => { ); }); + it('emits a failed event when there are failed workload types', () => { + expect(wrapper.emitted('failed')).toHaveLength(1); + }); + it('emits an error message when gets an error from the cluster_client API', async () => { const error = new Error('Error from the cluster_client API'); const createErroredApolloProvider = () => { diff --git a/spec/frontend/environments/kubernetes_tabs_spec.js b/spec/frontend/environments/kubernetes_tabs_spec.js index 429f267347b..81b0bb86e0e 100644 --- a/spec/frontend/environments/kubernetes_tabs_spec.js +++ b/spec/frontend/environments/kubernetes_tabs_spec.js @@ -165,4 +165,23 @@ describe('~/environments/components/kubernetes_tabs.vue', () => { expect(wrapper.emitted('cluster-error')).toEqual([[error]]); }); }); + + describe('summary tab', () => { + beforeEach(() => { + createWrapper(); + }); + + it('emits loading event when gets it from the component', () => { + findKubernetesSummary().vm.$emit('loading', true); + expect(wrapper.emitted('loading')[0]).toEqual([true]); + + findKubernetesSummary().vm.$emit('loading', false); + expect(wrapper.emitted('loading')[1]).toEqual([false]); + }); + + it('emits a failed event when gets it from the component', () => { + findKubernetesSummary().vm.$emit('failed'); + expect(wrapper.emitted('failed')).toHaveLength(1); + }); + }); }); diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js index 5583e737dd8..eb6990ba8a8 100644 --- a/spec/frontend/environments/new_environment_item_spec.js +++ b/spec/frontend/environments/new_environment_item_spec.js @@ -3,6 +3,7 @@ import Vue from 'vue'; import { GlCollapse, GlIcon } from '@gitlab/ui'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import { stubTransition } from 'helpers/stub_transition'; import { formatDate, getTimeago } from '~/lib/utils/datetime_utility'; import { __, s__, sprintf } from '~/locale'; @@ -11,6 +12,7 @@ import EnvironmentActions from '~/environments/components/environment_actions.vu import Deployment from '~/environments/components/deployment.vue'; import DeployBoardWrapper from '~/environments/components/deploy_board_wrapper.vue'; import KubernetesOverview from '~/environments/components/kubernetes_overview.vue'; +import getEnvironmentClusterAgent from '~/environments/graphql/queries/environment_cluster_agent.query.graphql'; import { resolvedEnvironment, rolloutStatus, agent } from './graphql/mock_data'; import { mockKasTunnelUrl } from './mock_data'; @@ -18,9 +20,24 @@ Vue.use(VueApollo); describe('~/environments/components/new_environment_item.vue', () => { let wrapper; + let queryResponseHandler; - const createApolloProvider = () => { - return createMockApollo(); + const projectPath = '/1'; + + const createApolloProvider = (clusterAgent = null) => { + const response = { + data: { + project: { + id: '1', + environment: { + id: '1', + clusterAgent, + }, + }, + }, + }; + queryResponseHandler = jest.fn().mockResolvedValue(response); + return createMockApollo([[getEnvironmentClusterAgent, queryResponseHandler]]); }; const createWrapper = ({ propsData = {}, provideData = {}, apolloProvider } = {}) => @@ -30,7 +47,7 @@ describe('~/environments/components/new_environment_item.vue', () => { provide: { helpPagePath: '/help', projectId: '1', - projectPath: '/1', + projectPath, kasTunnelUrl: mockKasTunnelUrl, ...provideData, }, @@ -40,7 +57,6 @@ describe('~/environments/components/new_environment_item.vue', () => { const findDeployment = () => wrapper.findComponent(Deployment); const findActions = () => wrapper.findComponent(EnvironmentActions); const findKubernetesOverview = () => wrapper.findComponent(KubernetesOverview); - const findMonitoringLink = () => wrapper.find('[data-testid="environment-monitoring"]'); const expandCollapsedSection = async () => { const button = wrapper.findByRole('button', { name: __('Expand') }); @@ -185,7 +201,7 @@ describe('~/environments/components/new_environment_item.vue', () => { it('shows the option to rollback/re-deploy if available', () => { wrapper = createWrapper({ apolloProvider: createApolloProvider() }); - const rollback = wrapper.findByRole('menuitem', { + const rollback = wrapper.findByRole('button', { name: s__('Environments|Re-deploy to environment'), }); @@ -198,7 +214,7 @@ describe('~/environments/components/new_environment_item.vue', () => { apolloProvider: createApolloProvider(), }); - const rollback = wrapper.findByRole('menuitem', { + const rollback = wrapper.findByRole('button', { name: s__('Environments|Re-deploy to environment'), }); @@ -224,7 +240,7 @@ describe('~/environments/components/new_environment_item.vue', () => { }); it('shows the option to pin the environment if there is an autostop date', () => { - const pin = wrapper.findByRole('menuitem', { name: __('Prevent auto-stopping') }); + const pin = wrapper.findByRole('button', { name: __('Prevent auto-stopping') }); expect(pin.exists()).toBe(true); }); @@ -244,7 +260,7 @@ describe('~/environments/components/new_environment_item.vue', () => { it('does not show the option to pin the environment if there is no autostop date', () => { wrapper = createWrapper({ apolloProvider: createApolloProvider() }); - const pin = wrapper.findByRole('menuitem', { name: __('Prevent auto-stopping') }); + const pin = wrapper.findByRole('button', { name: __('Prevent auto-stopping') }); expect(pin.exists()).toBe(false); }); @@ -279,7 +295,7 @@ describe('~/environments/components/new_environment_item.vue', () => { it('does not show the option to pin the environment if there is no autostop date', () => { wrapper = createWrapper({ apolloProvider: createApolloProvider() }); - const pin = wrapper.findByRole('menuitem', { name: __('Prevent auto-stopping') }); + const pin = wrapper.findByRole('button', { name: __('Prevent auto-stopping') }); expect(pin.exists()).toBe(false); }); @@ -296,44 +312,6 @@ describe('~/environments/components/new_environment_item.vue', () => { }); }); - describe('monitoring', () => { - it('shows the link to monitoring if metrics are set up', () => { - wrapper = createWrapper({ - propsData: { environment: { ...resolvedEnvironment, metricsPath: '/metrics' } }, - apolloProvider: createApolloProvider(), - }); - - const rollback = wrapper.findByRole('menuitem', { name: __('Monitoring') }); - - expect(rollback.exists()).toBe(true); - }); - - it('does not show the link to monitoring if metrics are not set up', () => { - wrapper = createWrapper({ apolloProvider: createApolloProvider() }); - - const rollback = wrapper.findByRole('menuitem', { name: __('Monitoring') }); - - expect(rollback.exists()).toBe(false); - }); - - describe.each([true, false])( - 'when `remove_monitor_metrics` flag is %p', - (removeMonitorMetrics) => { - beforeEach(() => { - wrapper = createWrapper({ - propsData: { environment: { ...resolvedEnvironment, metricsPath: '/metrics' } }, - apolloProvider: createApolloProvider(), - provideData: { glFeatures: { removeMonitorMetrics } }, - }); - }); - - it(`${removeMonitorMetrics ? 'does not render' : 'renders'} link to metrics`, () => { - expect(findMonitoringLink().exists()).toBe(!removeMonitorMetrics); - }); - }, - ); - }); - describe('terminal', () => { it('shows the link to the terminal if set up', () => { wrapper = createWrapper({ @@ -341,17 +319,17 @@ describe('~/environments/components/new_environment_item.vue', () => { apolloProvider: createApolloProvider(), }); - const rollback = wrapper.findByRole('menuitem', { name: __('Terminal') }); + const terminal = wrapper.findByRole('link', { name: __('Terminal') }); - expect(rollback.exists()).toBe(true); + expect(terminal.exists()).toBe(true); }); it('does not show the link to the terminal if not set up', () => { wrapper = createWrapper({ apolloProvider: createApolloProvider() }); - const rollback = wrapper.findByRole('menuitem', { name: __('Terminal') }); + const terminal = wrapper.findByRole('link', { name: __('Terminal') }); - expect(rollback.exists()).toBe(false); + expect(terminal.exists()).toBe(false); }); }); @@ -364,21 +342,21 @@ describe('~/environments/components/new_environment_item.vue', () => { apolloProvider: createApolloProvider(), }); - const rollback = wrapper.findByRole('menuitem', { + const deleteTrigger = wrapper.findByRole('button', { name: s__('Environments|Delete environment'), }); - expect(rollback.exists()).toBe(true); + expect(deleteTrigger.exists()).toBe(true); }); it('does not show the button to delete the environment if not possible', () => { wrapper = createWrapper({ apolloProvider: createApolloProvider() }); - const rollback = wrapper.findByRole('menuitem', { + const deleteTrigger = wrapper.findByRole('button', { name: s__('Environments|Delete environment'), }); - expect(rollback.exists()).toBe(false); + expect(deleteTrigger.exists()).toBe(false); }); }); @@ -540,68 +518,69 @@ describe('~/environments/components/new_environment_item.vue', () => { }); describe('kubernetes overview', () => { - const environmentWithAgent = { - ...resolvedEnvironment, - agent, - }; - - it('should render if the feature flag is enabled and the environment has an agent object with the required data specified', () => { + it('should request agent data when the environment is visible if the feature flag is enabled', async () => { wrapper = createWrapper({ - propsData: { environment: environmentWithAgent }, + propsData: { environment: resolvedEnvironment }, provideData: { glFeatures: { kasUserAccessProject: true, }, }, - apolloProvider: createApolloProvider(), + apolloProvider: createApolloProvider(agent), }); - expandCollapsedSection(); + await expandCollapsedSection(); - expect(findKubernetesOverview().props()).toMatchObject({ - agentProjectPath: agent.project, - agentName: agent.name, - agentId: agent.id, - namespace: agent.kubernetesNamespace, + expect(queryResponseHandler).toHaveBeenCalledWith({ + environmentName: resolvedEnvironment.name, + projectFullPath: projectPath, }); }); - it('should not render if the feature flag is not enabled', () => { + it('should render if the feature flag is enabled and the environment has an agent associated', async () => { wrapper = createWrapper({ - propsData: { environment: environmentWithAgent }, - apolloProvider: createApolloProvider(), + propsData: { environment: resolvedEnvironment }, + provideData: { + glFeatures: { + kasUserAccessProject: true, + }, + }, + apolloProvider: createApolloProvider(agent), }); - expandCollapsedSection(); + await expandCollapsedSection(); + await waitForPromises(); - expect(findKubernetesOverview().exists()).toBe(false); + expect(findKubernetesOverview().props()).toMatchObject({ + clusterAgent: agent, + }); }); - it('should not render if the environment has no agent object', () => { + it('should not render if the feature flag is not enabled', async () => { wrapper = createWrapper({ - apolloProvider: createApolloProvider(), + propsData: { environment: resolvedEnvironment }, + apolloProvider: createApolloProvider(agent), }); - expandCollapsedSection(); + await expandCollapsedSection(); + expect(queryResponseHandler).not.toHaveBeenCalled(); expect(findKubernetesOverview().exists()).toBe(false); }); - it('should not render if the environment has an agent object without agent id specified', () => { - const environment = { - ...resolvedEnvironment, - agent: { - project: agent.project, - name: agent.name, - }, - }; - + it('should not render if the environment has no agent object', async () => { wrapper = createWrapper({ - propsData: { environment }, + propsData: { environment: resolvedEnvironment }, + provideData: { + glFeatures: { + kasUserAccessProject: true, + }, + }, apolloProvider: createApolloProvider(), }); - expandCollapsedSection(); + await expandCollapsedSection(); + await waitForPromises(); expect(findKubernetesOverview().exists()).toBe(false); }); diff --git a/spec/frontend/environments/new_environment_spec.js b/spec/frontend/environments/new_environment_spec.js index 743f4ad6786..749e4e5caa4 100644 --- a/spec/frontend/environments/new_environment_spec.js +++ b/spec/frontend/environments/new_environment_spec.js @@ -1,103 +1,196 @@ import { GlLoadingIcon } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import NewEnvironment from '~/environments/components/new_environment.vue'; +import createEnvironment from '~/environments/graphql/mutations/create_environment.mutation.graphql'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { visitUrl } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import createMockApollo from '../__helpers__/mock_apollo_helper'; jest.mock('~/lib/utils/url_utility'); jest.mock('~/alert'); -const DEFAULT_OPTS = { - provide: { - projectEnvironmentsPath: '/projects/environments', - protectedEnvironmentSettingsPath: '/projects/not_real/settings/ci_cd', - }, +const newName = 'test'; +const newExternalUrl = 'https://google.ca'; + +const provide = { + projectEnvironmentsPath: '/projects/environments', + projectPath: '/path/to/project', +}; + +const environmentCreate = { environment: { id: '1', path: 'path/to/environment' }, errors: [] }; +const environmentCreateError = { + environment: null, + errors: [{ message: 'uh oh!' }], }; describe('~/environments/components/new.vue', () => { let wrapper; let mock; - let name; - let url; - let form; - - const createWrapper = (opts = {}) => - mountExtended(NewEnvironment, { - ...DEFAULT_OPTS, - ...opts, + + const createMockApolloProvider = (mutationResult) => { + Vue.use(VueApollo); + + return createMockApollo([ + [ + createEnvironment, + jest.fn().mockResolvedValue({ data: { environmentCreate: mutationResult } }), + ], + ]); + }; + + const createWrapperWithApollo = async (mutationResult = environmentCreate) => { + wrapper = mountExtended(NewEnvironment, { + provide: { + ...provide, + glFeatures: { + environmentSettingsToGraphql: true, + }, + }, + apolloProvider: createMockApolloProvider(mutationResult), }); - beforeEach(() => { - mock = new MockAdapter(axios); - wrapper = createWrapper(); - name = wrapper.findByLabelText('Name'); - url = wrapper.findByLabelText('External URL'); - form = wrapper.findByRole('form', { name: 'New environment' }); - }); + await waitForPromises(); + }; - afterEach(() => { - mock.restore(); - }); + const createWrapperWithAxios = () => { + wrapper = mountExtended(NewEnvironment, { + provide: { + ...provide, + glFeatures: { + environmentSettingsToGraphql: false, + }, + }, + }); + }; + const findNameInput = () => wrapper.findByLabelText(__('Name')); + const findExternalUrlInput = () => wrapper.findByLabelText(__('External URL')); + const findForm = () => wrapper.findByRole('form', { name: __('New environment') }); const showsLoading = () => wrapper.findComponent(GlLoadingIcon).exists(); - const submitForm = async (expected, response) => { - mock - .onPost(DEFAULT_OPTS.provide.projectEnvironmentsPath, { - name: expected.name, - external_url: expected.url, - }) - .reply(...response); - await name.setValue(expected.name); - await url.setValue(expected.url); - - await form.trigger('submit'); - await waitForPromises(); + const submitForm = async () => { + await findNameInput().setValue('test'); + await findExternalUrlInput().setValue('https://google.ca'); + + await findForm().trigger('submit'); }; - it('sets the title to New environment', () => { - const header = wrapper.findByRole('heading', { name: 'New environment' }); - expect(header.exists()).toBe(true); - }); + describe('default', () => { + beforeEach(() => { + createWrapperWithAxios(); + }); + + it('sets the title to New environment', () => { + const header = wrapper.findByRole('heading', { name: 'New environment' }); + expect(header.exists()).toBe(true); + }); - it.each` - input | value - ${() => name} | ${'test'} - ${() => url} | ${'https://example.org'} - `('changes the value of the input to $value', async ({ input, value }) => { - await input().setValue(value); + it.each` + input | value + ${() => findNameInput()} | ${'test'} + ${() => findExternalUrlInput()} | ${'https://example.org'} + `('changes the value of the input to $value', ({ input, value }) => { + input().setValue(value); - expect(input().element.value).toBe(value); + expect(input().element.value).toBe(value); + }); }); - it('shows loader after form is submitted', async () => { - const expected = { name: 'test', url: 'https://google.ca' }; + describe('when environmentSettingsToGraphql feature is enabled', () => { + describe('when mutation successful', () => { + beforeEach(() => { + createWrapperWithApollo(); + }); - expect(showsLoading()).toBe(false); + it('shows loader after form is submitted', async () => { + expect(showsLoading()).toBe(false); - await submitForm(expected, [HTTP_STATUS_OK, { path: '/test' }]); + await submitForm(); - expect(showsLoading()).toBe(true); - }); + expect(showsLoading()).toBe(true); + }); - it('submits the new environment on submit', async () => { - const expected = { name: 'test', url: 'https://google.ca' }; + it('submits the new environment on submit', async () => { + submitForm(); + await waitForPromises(); - await submitForm(expected, [HTTP_STATUS_OK, { path: '/test' }]); + expect(visitUrl).toHaveBeenCalledWith('path/to/environment'); + }); + }); - expect(visitUrl).toHaveBeenCalledWith('/test'); + describe('when failed', () => { + beforeEach(async () => { + createWrapperWithApollo(environmentCreateError); + submitForm(); + await waitForPromises(); + }); + + it('shows errors on error', () => { + expect(createAlert).toHaveBeenCalledWith({ message: 'uh oh!' }); + expect(showsLoading()).toBe(false); + }); + }); }); - it('shows errors on error', async () => { - const expected = { name: 'test', url: 'https://google.ca' }; + describe('when environmentSettingsToGraphql feature is disabled', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + createWrapperWithAxios(); + }); + + afterEach(() => { + mock.restore(); + }); + + it('shows loader after form is submitted', async () => { + expect(showsLoading()).toBe(false); + + mock + .onPost(provide.projectEnvironmentsPath, { + name: newName, + external_url: newExternalUrl, + }) + .reply(HTTP_STATUS_OK, { path: '/test' }); - await submitForm(expected, [HTTP_STATUS_BAD_REQUEST, { message: ['name taken'] }]); + await submitForm(); - expect(createAlert).toHaveBeenCalledWith({ message: 'name taken' }); - expect(showsLoading()).toBe(false); + expect(showsLoading()).toBe(true); + }); + + it('submits the new environment on submit', async () => { + mock + .onPost(provide.projectEnvironmentsPath, { + name: newName, + external_url: newExternalUrl, + }) + .reply(HTTP_STATUS_OK, { path: '/test' }); + + await submitForm(); + await waitForPromises(); + + expect(visitUrl).toHaveBeenCalledWith('/test'); + }); + + it('shows errors on error', async () => { + mock + .onPost(provide.projectEnvironmentsPath, { + name: newName, + external_url: newExternalUrl, + }) + .reply(HTTP_STATUS_BAD_REQUEST, { message: ['name taken'] }); + + await submitForm(); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ message: 'name taken' }); + expect(showsLoading()).toBe(false); + }); }); }); diff --git a/spec/frontend/error_tracking/components/error_details_info_spec.js b/spec/frontend/error_tracking/components/error_details_info_spec.js index 4a741a4c31e..a3f4b0e0dd8 100644 --- a/spec/frontend/error_tracking/components/error_details_info_spec.js +++ b/spec/frontend/error_tracking/components/error_details_info_spec.js @@ -40,43 +40,45 @@ describe('ErrorDetails', () => { }); it('should render a card with error counts', () => { - expect(wrapper.findByTestId('error-count-card').text()).toContain('Events 12'); + expect(wrapper.findByTestId('error-count-card').text()).toMatchInterpolatedText('Events 12'); }); it('should render a card with user counts', () => { - expect(wrapper.findByTestId('user-count-card').text()).toContain('Users 2'); + expect(wrapper.findByTestId('user-count-card').text()).toMatchInterpolatedText('Users 2'); }); - describe('release links', () => { - it('if firstReleaseVersion is missing, does not render a card', () => { + describe('first seen card', () => { + it('if firstSeen is missing, does not render a card', () => { + mountComponent({ + firstSeen: undefined, + }); expect(wrapper.findByTestId('first-release-card').exists()).toBe(false); }); - describe('if firstReleaseVersion link exists', () => { - it('renders the first release card', () => { - mountComponent({ - firstReleaseVersion: 'first-release-version', - }); - const card = wrapper.findByTestId('first-release-card'); - expect(card.exists()).toBe(true); - expect(card.text()).toContain('First seen'); - expect(card.findComponent(GlLink).exists()).toBe(true); - expect(card.findComponent(TimeAgoTooltip).exists()).toBe(true); + it('if firstSeen exists renders a card', () => { + mountComponent({ + firstSeen: '2017-05-26T13:32:48Z', }); + const card = wrapper.findByTestId('first-release-card'); + expect(card.exists()).toBe(true); + expect(card.text()).toContain('First seen'); + expect(card.findComponent(TimeAgoTooltip).exists()).toBe(true); + expect(card.findComponent(TimeAgoTooltip).props('time')).toBe('2017-05-26T13:32:48Z'); + }); - it('renders a link to the commit if error is integrated', () => { + describe('if firstReleaseVersion link exists', () => { + it('shows the shortened release tag as text, if error is integrated', () => { mountComponent({ - externalBaseUrl: 'external-base-url', firstReleaseVersion: 'first-release-version', firstSeen: '2023-04-20T17:02:06+00:00', integrated: true, }); - expect( - wrapper.findByTestId('first-release-card').findComponent(GlLink).attributes('href'), - ).toBe('external-base-url/-/commit/first-release-version'); + const card = wrapper.findByTestId('first-release-card'); + expect(card.text()).toMatchInterpolatedText('First seen first-rele'); + expect(card.findComponent(GlLink).exists()).toBe(false); }); - it('renders a link to the release if error is not integrated', () => { + it('renders a link to the release, if error is not integrated', () => { mountComponent({ externalBaseUrl: 'external-base-url', firstReleaseVersion: 'first-release-version', @@ -88,36 +90,40 @@ describe('ErrorDetails', () => { ).toBe('external-base-url/releases/first-release-version'); }); }); + }); - it('if lastReleaseVersion is missing, does not render a card', () => { + describe('last seen card', () => { + it('if lastSeen is missing, does not render a card', () => { + mountComponent({ + lastSeen: undefined, + }); expect(wrapper.findByTestId('last-release-card').exists()).toBe(false); }); - describe('if lastReleaseVersion link exists', () => { - it('renders the last release card', () => { - mountComponent({ - lastReleaseVersion: 'last-release-version', - }); - const card = wrapper.findByTestId('last-release-card'); - expect(card.exists()).toBe(true); - expect(card.text()).toContain('Last seen'); - expect(card.findComponent(GlLink).exists()).toBe(true); - expect(card.findComponent(TimeAgoTooltip).exists()).toBe(true); + it('if lastSeen exists renders a card', () => { + mountComponent({ + lastSeen: '2017-05-26T13:32:48Z', }); + const card = wrapper.findByTestId('last-release-card'); + expect(card.exists()).toBe(true); + expect(card.text()).toContain('Last seen'); + expect(card.findComponent(TimeAgoTooltip).exists()).toBe(true); + expect(card.findComponent(TimeAgoTooltip).props('time')).toBe('2017-05-26T13:32:48Z'); + }); - it('renders a link to the commit if error is integrated', () => { + describe('if lastReleaseVersion link exists', () => { + it('shows the shortened release tag as text, if error is integrated', () => { mountComponent({ - externalBaseUrl: 'external-base-url', lastReleaseVersion: 'last-release-version', lastSeen: '2023-04-20T17:02:06+00:00', integrated: true, }); - expect( - wrapper.findByTestId('last-release-card').findComponent(GlLink).attributes('href'), - ).toBe('external-base-url/-/commit/last-release-version'); + const card = wrapper.findByTestId('last-release-card'); + expect(card.text()).toMatchInterpolatedText('Last seen last-relea'); + expect(card.findComponent(GlLink).exists()).toBe(false); }); - it('renders a link to the release if error is integrated', () => { + it('renders a link to the release, if error is not integrated', () => { mountComponent({ externalBaseUrl: 'external-base-url', lastReleaseVersion: 'last-release-version', diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js index 8700301ef73..c9238c4b636 100644 --- a/spec/frontend/error_tracking/components/error_details_spec.js +++ b/spec/frontend/error_tracking/components/error_details_spec.js @@ -14,16 +14,13 @@ import { severityLevel, severityLevelVariant, errorStatus } from '~/error_tracki import ErrorDetails from '~/error_tracking/components/error_details.vue'; import Stacktrace from '~/error_tracking/components/stacktrace.vue'; import ErrorDetailsInfo from '~/error_tracking/components/error_details_info.vue'; -import { - trackErrorDetailsViewsOptions, - trackErrorStatusUpdateOptions, - trackCreateIssueFromError, -} from '~/error_tracking/events_tracking'; import { createAlert, VARIANT_WARNING } from '~/alert'; import { __ } from '~/locale'; import Tracking from '~/tracking'; +import TimelineChart from '~/error_tracking/components/timeline_chart.vue'; jest.mock('~/alert'); +jest.mock('~/tracking'); Vue.use(Vuex); @@ -33,7 +30,6 @@ describe('ErrorDetails', () => { let actions; let getters; let mocks; - const externalUrl = 'https://sentry.io/organizations/test-sentry-nk/issues/1/?project=1'; const findInput = (name) => { const inputs = wrapper @@ -48,7 +44,7 @@ describe('ErrorDetails', () => { wrapper.find('[data-testid="update-resolve-status-btn"]'); const findAlert = () => wrapper.findComponent(GlAlert); - function mountComponent() { + function mountComponent({ integratedErrorTrackingEnabled = false } = {}) { wrapper = shallowMount(ErrorDetails, { stubs: { GlButton, GlSprintf }, store, @@ -61,6 +57,7 @@ describe('ErrorDetails', () => { issueStackTracePath: '/stacktrace', projectIssuesPath: '/test-project/issues/', csrfToken: 'fakeToken', + integratedErrorTrackingEnabled, }, }); } @@ -163,6 +160,7 @@ describe('ErrorDetails', () => { mocks.$apollo.queries.error.loading = false; mountComponent(); // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216 // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { @@ -187,6 +185,7 @@ describe('ErrorDetails', () => { beforeEach(() => { store.state.details.loadingStacktrace = false; // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216 // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { @@ -208,6 +207,7 @@ describe('ErrorDetails', () => { describe('Badges', () => { it('should show language and error level badges', async () => { // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216 // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { @@ -220,6 +220,7 @@ describe('ErrorDetails', () => { it('should NOT show the badge if the tag is not present', async () => { // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216 // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { @@ -234,6 +235,7 @@ describe('ErrorDetails', () => { 'should set correct severity level variant for %s badge', async (level) => { // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216 // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { @@ -249,6 +251,7 @@ describe('ErrorDetails', () => { it('should fallback for ERROR severityLevelVariant when severityLevel is unknown', async () => { // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216 // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { @@ -272,6 +275,32 @@ describe('ErrorDetails', () => { }); }); + describe('timeline chart', () => { + it('should not show timeline chart if frequency data does not exist', () => { + expect(wrapper.findComponent(TimelineChart).exists()).toBe(false); + expect(wrapper.text()).not.toContain('Last 24 hours'); + }); + + it('should show timeline chart', async () => { + const mockFrequency = [ + [0, 1], + [2, 3], + ]; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216 + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + error: { + frequency: mockFrequency, + }, + }); + await nextTick(); + expect(wrapper.findComponent(TimelineChart).exists()).toBe(true); + expect(wrapper.findComponent(TimelineChart).props('timelineData')).toEqual(mockFrequency); + expect(wrapper.text()).toContain('Last 24 hours'); + }); + }); + describe('Stacktrace', () => { it('should show stacktrace', async () => { store.state.details.loadingStacktrace = false; @@ -406,6 +435,7 @@ describe('ErrorDetails', () => { it('should show alert with closed issueId', async () => { const closedIssueId = 123; // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216 // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isAlertVisible: true, @@ -428,6 +458,7 @@ describe('ErrorDetails', () => { describe('is present', () => { beforeEach(() => { // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216 // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { @@ -452,6 +483,7 @@ describe('ErrorDetails', () => { describe('is not present', () => { beforeEach(() => { // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216 // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { @@ -477,37 +509,56 @@ describe('ErrorDetails', () => { describe('Snowplow tracking', () => { beforeEach(() => { - jest.spyOn(Tracking, 'event'); mocks.$apollo.queries.error.loading = false; - mountComponent(); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - error: { externalUrl }, - }); }); - it('should track detail page views', () => { - const { category, action } = trackErrorDetailsViewsOptions; - expect(Tracking.event).toHaveBeenCalledWith(category, action); - }); + describe.each([true, false])(`when integratedErrorTracking is %s`, (integrated) => { + const category = 'Error Tracking'; - it('should track IGNORE status update', async () => { - await findUpdateIgnoreStatusButton().trigger('click'); - const { category, action } = trackErrorStatusUpdateOptions('ignored'); - expect(Tracking.event).toHaveBeenCalledWith(category, action); - }); + beforeEach(() => { + mountComponent({ integratedErrorTrackingEnabled: integrated }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216 + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + error: {}, + }); + }); - it('should track RESOLVE status update', async () => { - await findUpdateResolveStatusButton().trigger('click'); - const { category, action } = trackErrorStatusUpdateOptions('resolved'); - expect(Tracking.event).toHaveBeenCalledWith(category, action); - }); + it('should track detail page views', () => { + expect(Tracking.event).toHaveBeenCalledWith(category, 'view_error_details', { + extra: { + variant: integrated ? 'integrated' : 'external', + }, + }); + }); + + it('should track IGNORE status update', async () => { + await findUpdateIgnoreStatusButton().trigger('click'); + expect(Tracking.event).toHaveBeenCalledWith(category, 'update_ignored_status', { + extra: { + variant: integrated ? 'integrated' : 'external', + }, + }); + }); + + it('should track RESOLVE status update', async () => { + await findUpdateResolveStatusButton().trigger('click'); + expect(Tracking.event).toHaveBeenCalledWith(category, 'update_resolved_status', { + extra: { + variant: integrated ? 'integrated' : 'external', + }, + }); + }); - it('should track create issue button click', async () => { - await wrapper.find('[data-qa-selector="create_issue_button"]').vm.$emit('click'); - const { category, action } = trackCreateIssueFromError; - expect(Tracking.event).toHaveBeenCalledWith(category, action); + it('should track create issue button click', async () => { + await wrapper.find('[data-qa-selector="create_issue_button"]').vm.$emit('click'); + expect(Tracking.event).toHaveBeenCalledWith(category, 'click_create_issue_from_error', { + extra: { + variant: integrated ? 'integrated' : 'external', + }, + }); + }); }); }); }); diff --git a/spec/frontend/error_tracking/components/error_tracking_list_spec.js b/spec/frontend/error_tracking/components/error_tracking_list_spec.js index 6d4e92cf91f..49f365e8c60 100644 --- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js +++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js @@ -1,20 +1,24 @@ -import { GlEmptyState, GlLoadingIcon, GlFormInput, GlPagination, GlDropdown } from '@gitlab/ui'; +import { + GlEmptyState, + GlLoadingIcon, + GlFormInput, + GlPagination, + GlDropdown, + GlDropdownItem, +} from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import stubChildren from 'helpers/stub_children'; import ErrorTrackingActions from '~/error_tracking/components/error_tracking_actions.vue'; import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue'; -import { - trackErrorListViewsOptions, - trackErrorStatusUpdateOptions, - trackErrorStatusFilterOptions, - trackErrorSortedByField, -} from '~/error_tracking/events_tracking'; +import TimelineChart from '~/error_tracking/components/timeline_chart.vue'; import Tracking from '~/tracking'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import errorsList from './list_mock.json'; +jest.mock('~/tracking'); + Vue.use(Vuex); describe('ErrorTrackingList', () => { @@ -37,6 +41,7 @@ describe('ErrorTrackingList', () => { errorTrackingEnabled = true, userCanEnableErrorTracking = true, showIntegratedTrackingDisabledAlert = false, + integratedErrorTrackingEnabled = false, stubs = {}, } = {}) { wrapper = extendedWrapper( @@ -49,6 +54,7 @@ describe('ErrorTrackingList', () => { enableErrorTrackingLink: '/link', userCanEnableErrorTracking, errorTrackingEnabled, + integratedErrorTrackingEnabled, showIntegratedTrackingDisabledAlert, illustrationPath: 'illustration/path', }, @@ -122,8 +128,6 @@ describe('ErrorTrackingList', () => { mountComponent({ stubs: { GlTable: false, - GlDropdown: false, - GlDropdownItem: false, GlLink: false, }, }); @@ -155,6 +159,30 @@ describe('ErrorTrackingList', () => { }); }); + describe('timeline graph', () => { + it('should show the timeline chart', () => { + findErrorListRows().wrappers.forEach((row, index) => { + expect(row.findComponent(TimelineChart).exists()).toBe(true); + const mockFrequency = errorsList[index].frequency; + expect(row.findComponent(TimelineChart).props('timelineData')).toEqual(mockFrequency); + }); + }); + + it('should not show the timeline chart if frequency data does not exist', () => { + store.state.list.errors = errorsList.map((e) => ({ ...e, frequency: undefined })); + mountComponent({ + stubs: { + GlTable: false, + GlLink: false, + }, + }); + + findErrorListRows().wrappers.forEach((row) => { + expect(row.findComponent(TimelineChart).exists()).toBe(false); + }); + }); + }); + describe('filtering', () => { const findSearchBox = () => wrapper.findComponent(GlFormInput); @@ -170,14 +198,14 @@ describe('ErrorTrackingList', () => { }); it('sorts by fields', () => { - const findSortItem = () => findSortDropdown().find('.dropdown-item'); - findSortItem().trigger('click'); + const findSortItem = () => findSortDropdown().findComponent(GlDropdownItem); + findSortItem().vm.$emit('click'); expect(actions.sortByField).toHaveBeenCalled(); }); it('filters by status', () => { - const findStatusFilter = () => findStatusFilterDropdown().find('.dropdown-item'); - findStatusFilter().trigger('click'); + const findStatusFilter = () => findStatusFilterDropdown().findComponent(GlDropdownItem); + findStatusFilter().vm.$emit('click'); expect(actions.filterByStatus).toHaveBeenCalled(); }); }); @@ -244,9 +272,7 @@ describe('ErrorTrackingList', () => { describe('when alert is dismissed', () => { it('hides the alert box', async () => { - findIntegratedDisabledAlert().vm.$emit('dismiss'); - - await nextTick(); + await findIntegratedDisabledAlert().vm.$emit('dismiss'); expect(findIntegratedDisabledAlert().exists()).toBe(false); }); @@ -367,19 +393,12 @@ describe('ErrorTrackingList', () => { const emptyStatePrimaryDescription = emptyStateComponent.find('span', { exactText: 'Monitor your errors directly in GitLab.', }); - const emptyStateSecondaryDescription = emptyStateComponent.find('span', { - exactText: 'Error tracking is currently in', - }); const emptyStateLinks = emptyStateComponent.findAll('a'); expect(emptyStateComponent.isVisible()).toBe(true); expect(emptyStatePrimaryDescription.exists()).toBe(true); - expect(emptyStateSecondaryDescription.exists()).toBe(true); expect(emptyStateLinks.at(0).attributes('href')).toBe( '/help/operations/error_tracking.html#integrated-error-tracking', ); - expect(emptyStateLinks.at(1).attributes('href')).toBe( - 'https://about.gitlab.com/handbook/product/gitlab-the-product/#open-beta', - ); }); }); @@ -522,49 +541,67 @@ describe('ErrorTrackingList', () => { describe('Snowplow tracking', () => { beforeEach(() => { - jest.spyOn(Tracking, 'event'); store.state.list.loading = false; store.state.list.errors = errorsList; - mountComponent({ - stubs: { - GlTable: false, - GlLink: false, - GlDropdown: false, - GlDropdownItem: false, - }, - }); }); - it('should track list views', () => { - const { category, action } = trackErrorListViewsOptions; - expect(Tracking.event).toHaveBeenCalledWith(category, action); - }); + describe.each([true, false])(`when integratedErrorTracking is %s`, (integrated) => { + const category = 'Error Tracking'; - it('should track status updates', async () => { - const status = 'ignored'; - findErrorActions().vm.$emit('update-issue-status', { - errorId: 1, - status, + beforeEach(() => { + mountComponent({ + stubs: { + GlTable: false, + GlLink: false, + }, + integratedErrorTrackingEnabled: integrated, + }); }); - await nextTick(); + it('should track list views', () => { + expect(Tracking.event).toHaveBeenCalledWith(category, 'view_errors_list', { + extra: { + variant: integrated ? 'integrated' : 'external', + }, + }); + }); - const { category, action } = trackErrorStatusUpdateOptions(status); - expect(Tracking.event).toHaveBeenCalledWith(category, action); - }); + it('should track status updates', async () => { + const status = 'ignored'; + findErrorActions().vm.$emit('update-issue-status', { + errorId: 1, + status, + }); + await nextTick(); - it('should track error filter', () => { - const findStatusFilter = () => findStatusFilterDropdown().find('.dropdown-item'); - findStatusFilter().trigger('click'); - const { category, action } = trackErrorStatusFilterOptions('unresolved'); - expect(Tracking.event).toHaveBeenCalledWith(category, action); - }); + expect(Tracking.event).toHaveBeenCalledWith(category, 'update_ignored_status', { + extra: { + variant: integrated ? 'integrated' : 'external', + }, + }); + }); + + it('should track error filter', () => { + const findStatusFilter = () => findStatusFilterDropdown().findComponent(GlDropdownItem); + findStatusFilter().vm.$emit('click'); - it('should track error sorting', () => { - const findSortItem = () => findSortDropdown().find('.dropdown-item'); - findSortItem().trigger('click'); - const { category, action } = trackErrorSortedByField('last_seen'); - expect(Tracking.event).toHaveBeenCalledWith(category, action); + expect(Tracking.event).toHaveBeenCalledWith(category, 'filter_unresolved_status', { + extra: { + variant: integrated ? 'integrated' : 'external', + }, + }); + }); + + it('should track error sorting', () => { + const findSortItem = () => findSortDropdown().findComponent(GlDropdownItem); + findSortItem().vm.$emit('click'); + + expect(Tracking.event).toHaveBeenCalledWith(category, 'sort_by_last_seen', { + extra: { + variant: integrated ? 'integrated' : 'external', + }, + }); + }); }); }); }); diff --git a/spec/frontend/error_tracking/components/list_mock.json b/spec/frontend/error_tracking/components/list_mock.json index 54ae0a4c7cf..f8addef324e 100644 --- a/spec/frontend/error_tracking/components/list_mock.json +++ b/spec/frontend/error_tracking/components/list_mock.json @@ -7,7 +7,17 @@ "count": "52", "firstSeen": "2019-05-30T07:21:46Z", "lastSeen": "2019-11-06T03:21:39Z", - "status": "unresolved" + "status": "unresolved", + "frequency": [ + [ + 0, + 1 + ], + [ + 1, + 2 + ] + ] }, { "id": "2", @@ -17,7 +27,17 @@ "count": "12", "firstSeen": "2019-10-19T03:53:56Z", "lastSeen": "2019-11-05T03:51:54Z", - "status": "unresolved" + "status": "unresolved", + "frequency": [ + [ + 0, + 1 + ], + [ + 1, + 2 + ] + ] }, { "id": "3", @@ -27,6 +47,16 @@ "count": "275", "firstSeen": "2019-02-12T07:22:36Z", "lastSeen": "2019-10-22T03:20:48Z", - "status": "unresolved" + "status": "unresolved", + "frequency": [ + [ + 0, + 1 + ], + [ + 1, + 2 + ] + ] } -]
\ No newline at end of file +] diff --git a/spec/frontend/error_tracking/components/stacktrace_entry_spec.js b/spec/frontend/error_tracking/components/stacktrace_entry_spec.js index 45fc1ad04ff..9bb68c6f277 100644 --- a/spec/frontend/error_tracking/components/stacktrace_entry_spec.js +++ b/spec/frontend/error_tracking/components/stacktrace_entry_spec.js @@ -1,4 +1,4 @@ -import { GlSprintf, GlIcon } from '@gitlab/ui'; +import { GlSprintf, GlIcon, GlTruncate } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { trimText } from 'helpers/text_helper'; import StackTraceEntry from '~/error_tracking/components/stacktrace_entry.vue'; @@ -44,6 +44,21 @@ describe('Stacktrace Entry', () => { expect(wrapper.findAll('.line_content.old').length).toBe(1); }); + it('should render file information if filePath exists', () => { + mountComponent({ lines }); + expect(wrapper.findComponent(FileIcon).exists()).toBe(true); + expect(wrapper.findComponent(ClipboardButton).exists()).toBe(true); + expect(wrapper.findComponent(GlTruncate).exists()).toBe(true); + expect(wrapper.findComponent(GlTruncate).props('text')).toBe('sidekiq/util.rb'); + }); + + it('should not render file information if filePath does not exists', () => { + mountComponent({ lines, filePath: undefined }); + expect(wrapper.findComponent(FileIcon).exists()).toBe(false); + expect(wrapper.findComponent(ClipboardButton).exists()).toBe(false); + expect(wrapper.findComponent(GlTruncate).exists()).toBe(false); + }); + describe('entry caption', () => { const findFileHeaderContent = () => wrapper.find('.file-header-content').text(); diff --git a/spec/frontend/error_tracking/components/stacktrace_spec.js b/spec/frontend/error_tracking/components/stacktrace_spec.js index 29301c3e5ee..75c631617c3 100644 --- a/spec/frontend/error_tracking/components/stacktrace_spec.js +++ b/spec/frontend/error_tracking/components/stacktrace_spec.js @@ -14,6 +14,8 @@ describe('ErrorDetails', () => { [25, ' watchdog(name, \u0026block)\n'], ], lineNo: 24, + function: 'fn', + colNo: 1, }; function mountComponent(entries) { @@ -27,13 +29,33 @@ describe('ErrorDetails', () => { describe('Stacktrace', () => { it('should render single Stacktrace entry', () => { mountComponent([stackTraceEntry]); - expect(wrapper.findAllComponents(StackTraceEntry).length).toBe(1); + const allEntries = wrapper.findAllComponents(StackTraceEntry); + expect(allEntries.length).toBe(1); + const entry = allEntries.at(0); + expect(entry.props()).toEqual({ + lines: stackTraceEntry.context, + filePath: stackTraceEntry.filename, + errorLine: stackTraceEntry.lineNo, + errorFn: stackTraceEntry.function, + errorColumn: stackTraceEntry.colNo, + expanded: true, + }); }); it('should render multiple Stacktrace entry', () => { const entriesNum = 3; mountComponent(new Array(entriesNum).fill(stackTraceEntry)); - expect(wrapper.findAllComponents(StackTraceEntry).length).toBe(entriesNum); + const entries = wrapper.findAllComponents(StackTraceEntry); + expect(entries.length).toBe(entriesNum); + expect(entries.at(0).props('expanded')).toBe(true); + expect(entries.at(1).props('expanded')).toBe(false); + expect(entries.at(2).props('expanded')).toBe(false); + }); + + it('should use the entry abs_path if filename is missing', () => { + mountComponent([{ ...stackTraceEntry, filename: undefined, abs_path: 'abs_path' }]); + + expect(wrapper.findComponent(StackTraceEntry).props('filePath')).toBe('abs_path'); }); }); }); diff --git a/spec/frontend/error_tracking/components/timeline_chart_spec.js b/spec/frontend/error_tracking/components/timeline_chart_spec.js new file mode 100644 index 00000000000..f864d11804c --- /dev/null +++ b/spec/frontend/error_tracking/components/timeline_chart_spec.js @@ -0,0 +1,94 @@ +import { GlChart } from '@gitlab/ui/dist/charts'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import TimelineChart from '~/error_tracking/components/timeline_chart.vue'; + +const MOCK_HEIGHT = 123; + +describe('TimelineChart', () => { + let wrapper; + + function mountComponent(timelineData = []) { + wrapper = shallowMountExtended(TimelineChart, { + stubs: { GlChart: true }, + propsData: { + timelineData: [...timelineData], + height: MOCK_HEIGHT, + }, + }); + } + + beforeEach(() => { + mountComponent(); + }); + + it('renders the component', () => { + expect(wrapper.exists()).toBe(true); + }); + + it('does not render a chart if timelineData is missing', () => { + wrapper = shallowMountExtended(TimelineChart, { + stubs: { GlChart: true }, + propsData: { + timelineData: undefined, + height: MOCK_HEIGHT, + }, + }); + expect(wrapper.findComponent(GlChart).exists()).toBe(false); + }); + + it('renders a gl-chart', () => { + expect(wrapper.findComponent(GlChart).exists()).toBe(true); + expect(wrapper.findComponent(GlChart).props('height')).toBe(MOCK_HEIGHT); + }); + + describe('timeline-data', () => { + describe.each([ + { + mockItems: [ + [1686218400, 1], + [1686222000, 2], + ], + expectedX: ['Jun 8, 2023 10:00am UTC', 'Jun 8, 2023 11:00am UTC'], + expectedY: [1, 2], + description: 'tuples with dates as timestamps in seconds', + }, + { + mockItems: [ + ['06-05-2023', 1], + ['06-06-2023', 2], + ], + expectedX: ['Jun 5, 2023 12:00am UTC', 'Jun 6, 2023 12:00am UTC'], + expectedY: [1, 2], + description: 'tuples with non-numeric dates', + }, + { + mockItems: [ + { time: 1686218400, count: 1 }, + { time: 1686222000, count: 2 }, + ], + expectedX: ['Jun 8, 2023 10:00am UTC', 'Jun 8, 2023 11:00am UTC'], + expectedY: [1, 2], + description: 'objects with dates as timestamps in seconds', + }, + { + mockItems: [ + { time: '06-05-2023', count: 1 }, + { time: '06-06-2023', count: 2 }, + ], + expectedX: ['Jun 5, 2023 12:00am UTC', 'Jun 6, 2023 12:00am UTC'], + expectedY: [1, 2], + description: 'objects with non-numeric dates', + }, + ])('when timeline-data items are $description', ({ mockItems, expectedX, expectedY }) => { + it(`renders the chart correctly`, () => { + mountComponent(mockItems); + + const chartOptions = wrapper.findComponent(GlChart).props('options'); + const xData = chartOptions.xAxis.data; + const yData = chartOptions.series[0].data; + expect(xData).toEqual(expectedX); + expect(yData).toEqual(expectedY); + }); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js b/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js index 96b9434f3ec..133796df3e4 100644 --- a/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js +++ b/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; +import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; @@ -24,11 +24,10 @@ describe('~/feature_flags/components/strategies/gitlab_user_list.vue', () => { propsData: { ...DEFAULT_PROPS, ...props }, }); - const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); + const findGlListboxItem = () => wrapper.findAllComponents(GlListboxItem).at(0); describe('with user lists', () => { - const findDropdownItem = () => wrapper.findComponent(GlDropdownItem); - beforeEach(() => { Api.searchFeatureFlagUserLists.mockResolvedValue({ data: [userList] }); wrapper = factory(); @@ -37,22 +36,19 @@ describe('~/feature_flags/components/strategies/gitlab_user_list.vue', () => { it('should show the input for userListId with the correct value', () => { const dropdownWrapper = findDropdown(); expect(dropdownWrapper.exists()).toBe(true); - expect(dropdownWrapper.props('text')).toBe(userList.name); + expect(dropdownWrapper.props('toggleText')).toBe(userList.name); }); it('should show a check for the selected list', () => { - const itemWrapper = findDropdownItem(); - expect(itemWrapper.props('isChecked')).toBe(true); + expect(findGlListboxItem().props('isSelected')).toBe(true); }); it('should display the name of the list in the drop;down', () => { - const itemWrapper = findDropdownItem(); - expect(itemWrapper.text()).toBe(userList.name); + expect(findGlListboxItem().text()).toBe(userList.name); }); it('should emit a change event when altering the userListId', () => { - const inputWrapper = findDropdownItem(); - inputWrapper.vm.$emit('click'); + findDropdown().vm.$emit('select', userList.id); expect(wrapper.emitted('change')).toEqual([ [ { @@ -63,25 +59,19 @@ describe('~/feature_flags/components/strategies/gitlab_user_list.vue', () => { }); it('should search when the filter changes', async () => { + findDropdown().vm.$emit('search', 'new'); let r; Api.searchFeatureFlagUserLists.mockReturnValue( new Promise((resolve) => { r = resolve; }), ); - const searchWrapper = wrapper.findComponent(GlSearchBoxByType); - searchWrapper.vm.$emit('input', 'new'); - await nextTick(); - const loadingIcon = wrapper.findComponent(GlLoadingIcon); - expect(loadingIcon.exists()).toBe(true); expect(Api.searchFeatureFlagUserLists).toHaveBeenCalledWith('1', 'new'); r({ data: [userList] }); await nextTick(); - - expect(loadingIcon.exists()).toBe(false); }); }); diff --git a/spec/frontend/fixtures/merge_requests.rb b/spec/frontend/fixtures/merge_requests.rb index b6f6d149756..a1896a6470b 100644 --- a/spec/frontend/fixtures/merge_requests.rb +++ b/spec/frontend/fixtures/merge_requests.rb @@ -114,6 +114,10 @@ RSpec.describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: let(:group) { create(:group) } let(:description) { "@#{group.full_path} @all @#{user.username}" } + before do + stub_feature_flags(disable_all_mention: false) + end + it 'merge_requests/merge_request_with_mentions.html' do render_merge_request(merge_request) end diff --git a/spec/frontend/fixtures/pipeline_details.rb b/spec/frontend/fixtures/pipeline_details.rb new file mode 100644 index 00000000000..af9b11b0841 --- /dev/null +++ b/spec/frontend/fixtures/pipeline_details.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe "GraphQL Pipeline details", '(JavaScript fixtures)', type: :request, feature_category: :pipeline_composition do + include ApiHelpers + include GraphqlHelpers + include JavaScriptFixturesHelpers + + let_it_be(:namespace) { create(:namespace, name: 'frontend-fixtures') } + let_it_be(:project) { create(:project, :public, :repository) } + let_it_be(:admin) { project.first_owner } + let_it_be(:commit) { create(:commit, project: project) } + let_it_be(:pipeline) do + create(:ci_pipeline, project: project, sha: commit.id, ref: 'master', user: admin, status: :success) + end + + let_it_be(:build_success) do + create(:ci_build, :dependent, name: 'build_my_app', pipeline: pipeline, stage: 'build', status: :success) + end + + let_it_be(:build_test) { create(:ci_build, :dependent, name: 'test_my_app', pipeline: pipeline, stage: 'test') } + let_it_be(:build_deploy_failed) do + create(:ci_build, :dependent, name: 'deploy_my_app', status: :failed, pipeline: pipeline, stage: 'deploy') + end + + let_it_be(:bridge) { create(:ci_bridge, pipeline: pipeline) } + + let(:pipeline_details_query_path) { 'app/graphql/queries/pipelines/get_pipeline_details.query.graphql' } + + it "pipelines/pipeline_details.json" do + query = get_graphql_query_as_string(pipeline_details_query_path, with_base_path: false) + + post_graphql(query, current_user: admin, variables: { projectPath: project.full_path, iid: pipeline.iid }) + + expect_graphql_errors_to_be_empty + end +end diff --git a/spec/frontend/fixtures/pipeline_header.rb b/spec/frontend/fixtures/pipeline_header.rb new file mode 100644 index 00000000000..3fdc45b1194 --- /dev/null +++ b/spec/frontend/fixtures/pipeline_header.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe "GraphQL Pipeline Header", '(JavaScript fixtures)', type: :request, feature_category: :pipeline_composition do + include ApiHelpers + include GraphqlHelpers + include JavaScriptFixturesHelpers + + let_it_be(:namespace) { create(:namespace, name: 'frontend-fixtures') } + let_it_be(:project) { create(:project, :public, :repository) } + let_it_be(:user) { project.first_owner } + let_it_be(:commit) { create(:commit, project: project) } + + let(:query_path) { 'pipelines/graphql/queries/get_pipeline_header_data.query.graphql' } + + context 'with successful pipeline' do + let_it_be(:pipeline) do + create( + :ci_pipeline, + project: project, + sha: commit.id, + ref: 'master', + user: user, + status: :success, + duration: 7210, + created_at: 2.hours.ago, + started_at: 1.hour.ago, + finished_at: Time.current + ) + end + + it "graphql/pipelines/pipeline_header_success.json" do + query = get_graphql_query_as_string(query_path) + + post_graphql(query, current_user: user, variables: { fullPath: project.full_path, iid: pipeline.iid }) + + expect_graphql_errors_to_be_empty + end + end + + context 'with running pipeline' do + let_it_be(:pipeline) do + create( + :ci_pipeline, + project: project, + sha: commit.id, + ref: 'master', + user: user, + status: :running, + created_at: 2.hours.ago, + started_at: 1.hour.ago + ) + end + + let_it_be(:build) { create(:ci_build, :running, pipeline: pipeline, ref: 'master') } + + it "graphql/pipelines/pipeline_header_running.json" do + query = get_graphql_query_as_string(query_path) + + post_graphql(query, current_user: user, variables: { fullPath: project.full_path, iid: pipeline.iid }) + + expect_graphql_errors_to_be_empty + end + end + + context 'with running pipeline and duration' do + let_it_be(:pipeline) do + create( + :ci_pipeline, + project: project, + sha: commit.id, + ref: 'master', + user: user, + status: :running, + duration: 7210, + created_at: 2.hours.ago, + started_at: 1.hour.ago + ) + end + + let_it_be(:build) { create(:ci_build, :running, pipeline: pipeline, ref: 'master') } + + it "graphql/pipelines/pipeline_header_running_with_duration.json" do + query = get_graphql_query_as_string(query_path) + + post_graphql(query, current_user: user, variables: { fullPath: project.full_path, iid: pipeline.iid }) + + expect_graphql_errors_to_be_empty + end + end + + context 'with failed pipeline' do + let_it_be(:pipeline) do + create( + :ci_pipeline, + project: project, + sha: commit.id, + ref: 'master', + user: user, + status: :failed, + duration: 7210, + started_at: 1.hour.ago, + finished_at: Time.current + ) + end + + let_it_be(:build) { create(:ci_build, :canceled, pipeline: pipeline, ref: 'master') } + + it "graphql/pipelines/pipeline_header_failed.json" do + query = get_graphql_query_as_string(query_path) + + post_graphql(query, current_user: user, variables: { fullPath: project.full_path, iid: pipeline.iid }) + + expect_graphql_errors_to_be_empty + end + end +end diff --git a/spec/frontend/fixtures/project.rb b/spec/frontend/fixtures/project.rb new file mode 100644 index 00000000000..6100248d0a5 --- /dev/null +++ b/spec/frontend/fixtures/project.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Project (GraphQL fixtures)', feature_category: :groups_and_projects do + describe GraphQL::Query, type: :request do + include ApiHelpers + include GraphqlHelpers + include JavaScriptFixturesHelpers + include ProjectForksHelper + + let_it_be(:project) { create(:project, :repository) } + let_it_be(:current_user) { create(:user) } + + describe 'writable forks' do + writeable_forks_query_path = 'vue_shared/components/web_ide/get_writable_forks.query.graphql' + + let(:query) { get_graphql_query_as_string(writeable_forks_query_path) } + + subject { post_graphql(query, current_user: current_user, variables: { projectPath: project.full_path }) } + + before do + project.add_developer(current_user) + end + + context 'with none' do + it "graphql/#{writeable_forks_query_path}_none.json" do + subject + + expect_graphql_errors_to_be_empty + end + end + + context 'with some' do + let_it_be(:fork1) { fork_project(project, nil, repository: true) } + let_it_be(:fork2) { fork_project(project, nil, repository: true) } + + before_all do + fork1.add_developer(current_user) + fork2.add_developer(current_user) + end + + it "graphql/#{writeable_forks_query_path}_some.json" do + subject + + expect_graphql_errors_to_be_empty + end + end + end + end +end diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb index 099df607487..a73a0dcbdd1 100644 --- a/spec/frontend/fixtures/runner.rb +++ b/spec/frontend/fixtures/runner.rb @@ -14,6 +14,9 @@ RSpec.describe 'Runner (JavaScript fixtures)', feature_category: :runner_fleet d let_it_be(:project_2) { create(:project, :repository, :public) } let_it_be(:runner) { create(:ci_runner, :instance, description: 'My Runner', creator: admin, version: '1.0.0') } + let_it_be(:runner_manager_1) { create(:ci_runner_machine, runner: runner, contacted_at: Time.current) } + let_it_be(:runner_manager_2) { create(:ci_runner_machine, runner: runner, contacted_at: Time.current) } + let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], version: '2.0.0') } let_it_be(:group_runner_2) { create(:ci_runner, :group, groups: [group], version: '2.0.0') } let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project, project_2], version: '2.0.0') } @@ -137,6 +140,22 @@ RSpec.describe 'Runner (JavaScript fixtures)', feature_category: :runner_fleet d end end + describe 'runner_managers.query.graphql', type: :request do + runner_managers_query = 'show/runner_managers.query.graphql' + + let_it_be(:query) do + get_graphql_query_as_string("#{query_path}#{runner_managers_query}") + end + + it "#{fixtures_path}#{runner_managers_query}.json" do + post_graphql(query, current_user: admin, variables: { + runner_id: runner.to_global_id.to_s + }) + + expect_graphql_errors_to_be_empty + end + end + describe 'runner_form.query.graphql', type: :request do runner_jobs_query = 'edit/runner_form.query.graphql' diff --git a/spec/frontend/fixtures/startup_css.rb b/spec/frontend/fixtures/startup_css.rb index 5b09e1c9495..83e02470321 100644 --- a/spec/frontend/fixtures/startup_css.rb +++ b/spec/frontend/fixtures/startup_css.rb @@ -40,11 +40,8 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do expect(response).to be_successful end - # This Feature Flag is off by default # This ensures that the correct css is generated for super sidebar - # When the feature flag is off, the general startup will capture it it "startup_css/project-#{type}-super-sidebar.html" do - stub_feature_flags(super_sidebar_nav: true) user.update!(use_new_navigation: true) get :show, params: { diff --git a/spec/frontend/fixtures/static/whats_new_notification.html b/spec/frontend/fixtures/static/whats_new_notification.html index 3b4dbdf7d36..bc8a27c779f 100644 --- a/spec/frontend/fixtures/static/whats_new_notification.html +++ b/spec/frontend/fixtures/static/whats_new_notification.html @@ -1,5 +1,6 @@ <div class='whats-new-notification-fixture-root'> <div class='app' data-version-digest='version-digest'></div> + <div data-testid='without-digest'></div> <div class='header-help'> <div class='js-whats-new-notification-count'></div> </div> diff --git a/spec/frontend/fixtures/users.rb b/spec/frontend/fixtures/users.rb index 0e9d7475bf9..89bffea7e4c 100644 --- a/spec/frontend/fixtures/users.rb +++ b/spec/frontend/fixtures/users.rb @@ -2,18 +2,47 @@ require 'spec_helper' -RSpec.describe 'Users (GraphQL fixtures)', feature_category: :user_profile do +RSpec.describe 'Users (JavaScript fixtures)', feature_category: :user_profile do + include JavaScriptFixturesHelpers + include ApiHelpers + + let_it_be(:followers) { create_list(:user, 5) } + let_it_be(:user) { create(:user, followers: followers) } + + describe API::Users, '(JavaScript fixtures)', type: :request do + it 'api/users/followers/get.json' do + get api("/users/#{user.id}/followers", user) + + expect(response).to be_successful + end + end + + describe UsersController, '(JavaScript fixtures)', type: :controller do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project_empty_repo, group: group) } + + include_context 'with user contribution events' + + before do + group.add_owner(user) + project.add_maintainer(user) + sign_in(user) + end + + it 'controller/users/activity.json' do + get :activity, params: { username: user.username, limit: 50 }, format: :json + + expect(response).to be_successful + end + end + describe GraphQL::Query, type: :request do - include ApiHelpers include GraphqlHelpers - include JavaScriptFixturesHelpers - - let_it_be(:user) { create(:user) } context 'for user achievements' do let_it_be(:group) { create(:group, :public) } let_it_be(:private_group) { create(:group, :private) } - let_it_be(:achievement1) { create(:achievement, namespace: group) } + let_it_be(:achievement1) { create(:achievement, namespace: group, name: 'Multiple') } let_it_be(:achievement2) { create(:achievement, namespace: group) } let_it_be(:achievement3) { create(:achievement, namespace: group) } let_it_be(:achievement_from_private_group) { create(:achievement, namespace: private_group) } @@ -65,6 +94,7 @@ RSpec.describe 'Users (GraphQL fixtures)', feature_category: :user_profile do [achievement1, achievement2, achievement3, achievement_with_avatar_and_description].each do |achievement| create(:user_achievement, user: user, achievement: achievement) end + create(:user_achievement, user: user, achievement: achievement1) post_graphql(query, current_user: user, variables: { id: user.to_global_id }) 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 deleted file mode 100644 index 9447e7daba8..00000000000 --- a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap +++ /dev/null @@ -1,110 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`grafana integration component default state to match the default snapshot 1`] = ` -<section - class="settings no-animate js-grafana-integration" - id="grafana" -> - <div - class="settings-header" - > - <h4 - class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only" - > - - Grafana authentication - - </h4> - - <gl-button-stub - buttontextclasses="" - category="primary" - class="js-settings-toggle" - icon="" - size="medium" - variant="default" - > - Expand - </gl-button-stub> - - <p - class="js-section-sub-header" - > - - Set up Grafana authentication to embed Grafana panels in GitLab Flavored Markdown. - - <gl-link-stub> - Learn more. - </gl-link-stub> - </p> - </div> - - <div - class="settings-content" - > - <form> - <gl-form-group-stub - label="Enable authentication" - label-for="grafana-integration-enabled" - labeldescription="" - optionaltext="(optional)" - > - <gl-form-checkbox-stub - id="grafana-integration-enabled" - > - - Active - - </gl-form-checkbox-stub> - </gl-form-group-stub> - - <gl-form-group-stub - description="Enter the base URL of the Grafana instance." - label="Grafana URL" - label-for="grafana-url" - labeldescription="" - optionaltext="(optional)" - > - <gl-form-input-stub - id="grafana-url" - placeholder="https://my-grafana.example.com/" - value="http://test.host" - /> - </gl-form-group-stub> - - <gl-form-group-stub - label="API token" - label-for="grafana-token" - labeldescription="" - optionaltext="(optional)" - > - <gl-form-input-stub - id="grafana-token" - value="someToken" - /> - - <p - class="form-text text-muted" - > - <gl-sprintf-stub - message="Enter the %{docLinkStart}Grafana API token%{docLinkEnd}." - /> - </p> - </gl-form-group-stub> - - <gl-button-stub - buttontextclasses="" - category="primary" - data-testid="save-grafana-settings-button" - icon="" - size="medium" - variant="confirm" - > - - Save changes - - </gl-button-stub> - </form> - </div> -</section> -`; diff --git a/spec/frontend/grafana_integration/components/grafana_integration_spec.js b/spec/frontend/grafana_integration/components/grafana_integration_spec.js deleted file mode 100644 index 540fc597aa9..00000000000 --- a/spec/frontend/grafana_integration/components/grafana_integration_spec.js +++ /dev/null @@ -1,119 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { TEST_HOST } from 'helpers/test_constants'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import { createAlert } from '~/alert'; -import GrafanaIntegration from '~/grafana_integration/components/grafana_integration.vue'; -import { createStore } from '~/grafana_integration/store'; -import axios from '~/lib/utils/axios_utils'; -import { refreshCurrentPage } from '~/lib/utils/url_utility'; - -jest.mock('~/lib/utils/url_utility'); -jest.mock('~/alert'); - -describe('grafana integration component', () => { - let wrapper; - let store; - const operationsSettingsEndpoint = `${TEST_HOST}/mock/ops/settings/endpoint`; - const grafanaIntegrationUrl = `${TEST_HOST}`; - const grafanaIntegrationToken = 'someToken'; - - beforeEach(() => { - store = createStore({ - operationsSettingsEndpoint, - grafanaIntegrationUrl, - grafanaIntegrationToken, - }); - }); - - afterEach(() => { - createAlert.mockReset(); - refreshCurrentPage.mockReset(); - }); - - describe('default state', () => { - it('to match the default snapshot', () => { - wrapper = shallowMount(GrafanaIntegration, { store }); - - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - it('renders header text', () => { - wrapper = shallowMount(GrafanaIntegration, { store }); - - expect(wrapper.find('.js-section-header').text()).toBe('Grafana authentication'); - }); - - describe('expand/collapse button', () => { - it('renders as an expand button by default', () => { - wrapper = shallowMount(GrafanaIntegration, { store }); - - const button = wrapper.findComponent(GlButton); - expect(button.text()).toBe('Expand'); - }); - }); - - describe('sub-header', () => { - it('renders descriptive text', () => { - wrapper = shallowMount(GrafanaIntegration, { store }); - - expect(wrapper.find('.js-section-sub-header').text()).toContain( - 'Set up Grafana authentication to embed Grafana panels in GitLab Flavored Markdown.\n Learn more.', - ); - }); - }); - - describe('form', () => { - beforeEach(() => { - jest.spyOn(axios, 'patch').mockImplementation(); - wrapper = mountExtended(GrafanaIntegration, { store }); - }); - - afterEach(() => { - axios.patch.mockReset(); - }); - - describe('submit button', () => { - const findSubmitButton = () => wrapper.findByTestId('save-grafana-settings-button'); - - const endpointRequest = [ - operationsSettingsEndpoint, - { - project: { - grafana_integration_attributes: { - grafana_url: grafanaIntegrationUrl, - token: grafanaIntegrationToken, - enabled: false, - }, - }, - }, - ]; - - it('submits form on click', async () => { - axios.patch.mockResolvedValue(); - findSubmitButton(wrapper).trigger('click'); - - expect(axios.patch).toHaveBeenCalledWith(...endpointRequest); - await nextTick(); - expect(refreshCurrentPage).toHaveBeenCalled(); - }); - - it('creates alert banner on error', async () => { - const message = 'mockErrorMessage'; - axios.patch.mockRejectedValue({ response: { data: { message } } }); - - findSubmitButton().trigger('click'); - - expect(axios.patch).toHaveBeenCalledWith(...endpointRequest); - - await nextTick(); - await jest.runAllTicks(); - expect(createAlert).toHaveBeenCalledWith({ - message: `There was an error saving your changes. ${message}`, - }); - }); - }); - }); -}); diff --git a/spec/frontend/grafana_integration/store/mutations_spec.js b/spec/frontend/grafana_integration/store/mutations_spec.js deleted file mode 100644 index 18e87394189..00000000000 --- a/spec/frontend/grafana_integration/store/mutations_spec.js +++ /dev/null @@ -1,35 +0,0 @@ -import mutations from '~/grafana_integration/store/mutations'; -import createState from '~/grafana_integration/store/state'; - -describe('grafana integration mutations', () => { - let localState; - - beforeEach(() => { - localState = createState(); - }); - - describe('SET_GRAFANA_URL', () => { - it('sets grafanaUrl', () => { - const mockUrl = 'mockUrl'; - mutations.SET_GRAFANA_URL(localState, mockUrl); - - expect(localState.grafanaUrl).toBe(mockUrl); - }); - }); - - describe('SET_GRAFANA_TOKEN', () => { - it('sets grafanaToken', () => { - const mockToken = 'mockToken'; - mutations.SET_GRAFANA_TOKEN(localState, mockToken); - - expect(localState.grafanaToken).toBe(mockToken); - }); - }); - describe('SET_GRAFANA_ENABLED', () => { - it('updates grafanaEnabled for integration', () => { - mutations.SET_GRAFANA_ENABLED(localState, true); - - expect(localState.grafanaEnabled).toBe(true); - }); - }); -}); diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js index 7b42e50fee5..b474745790e 100644 --- a/spec/frontend/groups/components/app_spec.js +++ b/spec/frontend/groups/components/app_spec.js @@ -6,7 +6,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; import appComponent from '~/groups/components/app.vue'; import groupFolderComponent from '~/groups/components/group_folder.vue'; -import groupItemComponent from '~/groups/components/group_item.vue'; +import groupItemComponent from 'jh_else_ce/groups/components/group_item.vue'; import eventHub from '~/groups/event_hub'; import GroupsService from '~/groups/service/groups_service'; import GroupsStore from '~/groups/store/groups_store'; @@ -42,7 +42,7 @@ describe('AppComponent', () => { let mock; let getGroupsSpy; - const store = new GroupsStore({ hideProjects: false }); + const store = new GroupsStore({}); const service = new GroupsService(mockEndpoint); const createShallowComponent = ({ propsData = {} } = {}) => { @@ -51,7 +51,6 @@ describe('AppComponent', () => { propsData: { store, service, - hideProjects: false, containerId: 'js-groups-tree', ...propsData, }, diff --git a/spec/frontend/groups/components/group_folder_spec.js b/spec/frontend/groups/components/group_folder_spec.js index da31fb02f69..b274c01a43b 100644 --- a/spec/frontend/groups/components/group_folder_spec.js +++ b/spec/frontend/groups/components/group_folder_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import GroupFolder from '~/groups/components/group_folder.vue'; -import GroupItem from '~/groups/components/group_item.vue'; +import GroupItem from 'jh_else_ce/groups/components/group_item.vue'; import { MAX_CHILDREN_COUNT } from '~/groups/constants'; import { mockGroups, mockParentGroupItem } from '../mock_data'; diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js index 663dd341a58..94460de9dd6 100644 --- a/spec/frontend/groups/components/group_item_spec.js +++ b/spec/frontend/groups/components/group_item_spec.js @@ -1,7 +1,7 @@ import { GlPopover } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; import GroupFolder from '~/groups/components/group_folder.vue'; -import GroupItem from '~/groups/components/group_item.vue'; +import GroupItem from 'jh_else_ce/groups/components/group_item.vue'; import ItemActions from '~/groups/components/item_actions.vue'; import eventHub from '~/groups/event_hub'; import { getGroupItemMicrodata } from '~/groups/store/utils'; diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js index c04eaa501ba..3cdbd3e38be 100644 --- a/spec/frontend/groups/components/groups_spec.js +++ b/spec/frontend/groups/components/groups_spec.js @@ -3,7 +3,7 @@ import { GlEmptyState } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import GroupFolderComponent from '~/groups/components/group_folder.vue'; -import GroupItemComponent from '~/groups/components/group_item.vue'; +import GroupItemComponent from 'jh_else_ce/groups/components/group_item.vue'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import GroupsComponent from '~/groups/components/groups.vue'; import eventHub from '~/groups/event_hub'; diff --git a/spec/frontend/groups/components/overview_tabs_spec.js b/spec/frontend/groups/components/overview_tabs_spec.js index 101dd06d578..ca852f398d0 100644 --- a/spec/frontend/groups/components/overview_tabs_spec.js +++ b/spec/frontend/groups/components/overview_tabs_spec.js @@ -93,7 +93,6 @@ describe('OverviewTabs', () => { action: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, store: new GroupsStore({ showSchemaMarkup: true }), service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]), - hideProjects: false, }); await waitForPromises(); @@ -117,7 +116,6 @@ describe('OverviewTabs', () => { action: ACTIVE_TAB_SHARED, store: new GroupsStore(), service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_SHARED]), - hideProjects: false, }); expect(tabPanel.vm.$attrs.lazy).toBe(false); @@ -143,7 +141,6 @@ describe('OverviewTabs', () => { action: ACTIVE_TAB_ARCHIVED, store: new GroupsStore(), service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_ARCHIVED]), - hideProjects: false, }); expect(tabPanel.vm.$attrs.lazy).toBe(false); diff --git a/spec/frontend/header_search/init_spec.js b/spec/frontend/header_search/init_spec.js index 9ccc6919b81..baf3c6f08b2 100644 --- a/spec/frontend/header_search/init_spec.js +++ b/spec/frontend/header_search/init_spec.js @@ -8,7 +8,7 @@ describe('Header Search EventListener', () => { jest.restoreAllMocks(); setHTMLFixture(` <div class="js-header-content"> - <div class="header-search" id="js-header-search" data-autocomplete-path="/search/autocomplete" data-issues-path="/dashboard/issues" data-mr-path="/dashboard/merge_requests" data-search-context="{}" data-search-path="/search"> + <div class="header-search-form" id="js-header-search" data-autocomplete-path="/search/autocomplete" data-issues-path="/dashboard/issues" data-mr-path="/dashboard/merge_requests" data-search-context="{}" data-search-path="/search"> <input autocomplete="off" class="form-control gl-form-input gl-search-box-by-type-input" data-qa-selector="search_box" id="search" name="search" placeholder="Search GitLab" type="text"> </div> </div>`); diff --git a/spec/frontend/ide/stores/modules/file_templates/getters_spec.js b/spec/frontend/ide/stores/modules/file_templates/getters_spec.js index e237b167f96..02e0d55346e 100644 --- a/spec/frontend/ide/stores/modules/file_templates/getters_spec.js +++ b/spec/frontend/ide/stores/modules/file_templates/getters_spec.js @@ -5,7 +5,7 @@ import createState from '~/ide/stores/state'; describe('IDE file templates getters', () => { describe('templateTypes', () => { it('returns list of template types', () => { - expect(getters.templateTypes().length).toBe(5); + expect(getters.templateTypes().length).toBe(4); }); }); diff --git a/spec/frontend/import_entities/components/import_status_spec.js b/spec/frontend/import_entities/components/import_status_spec.js index 4c6fee35389..103a3e4ddd1 100644 --- a/spec/frontend/import_entities/components/import_status_spec.js +++ b/spec/frontend/import_entities/components/import_status_spec.js @@ -1,5 +1,7 @@ import { GlAccordionItem, GlBadge, GlIcon, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { __, s__ } from '~/locale'; + import ImportStatus from '~/import_entities/components/import_status.vue'; import { STATUSES } from '~/import_entities/constants'; @@ -25,7 +27,7 @@ describe('Import entities status component', () => { createComponent({ status: STATUSES.FINISHED, }); - expect(getStatusText()).toBe('Complete'); + expect(getStatusText()).toBe(__('Complete')); }); it('displays finished status as complete when all stats items were processed', () => { @@ -37,7 +39,7 @@ describe('Import entities status component', () => { }, }); - expect(getStatusText()).toBe('Complete'); + expect(getStatusText()).toBe(__('Complete')); expect(getStatusIcon()).toBe('status-success'); }); @@ -50,7 +52,7 @@ describe('Import entities status component', () => { }, }); - expect(getStatusText()).toBe('Partially completed'); + expect(getStatusText()).toBe(s__('Import|Partially completed')); expect(getStatusIcon()).toBe('status-alert'); }); }); diff --git a/spec/frontend/integrations/edit/components/jira_auth_fields_spec.js b/spec/frontend/integrations/edit/components/jira_auth_fields_spec.js new file mode 100644 index 00000000000..dcae2ceeeaa --- /dev/null +++ b/spec/frontend/integrations/edit/components/jira_auth_fields_spec.js @@ -0,0 +1,142 @@ +import { GlFormRadio, GlFormRadioGroup } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import JiraAuthFields from '~/integrations/edit/components/jira_auth_fields.vue'; +import { jiraAuthTypeFieldProps } from '~/integrations/constants'; +import { createStore } from '~/integrations/edit/store'; + +import { mockJiraAuthFields } from '../mock_data'; + +describe('JiraAuthFields', () => { + let wrapper; + + const defaultProps = { + fields: mockJiraAuthFields, + }; + + const createComponent = ({ props } = {}) => { + const store = createStore(); + + wrapper = shallowMountExtended(JiraAuthFields, { + propsData: { ...defaultProps, ...props }, + store, + }); + }; + + const findAuthTypeRadio = () => wrapper.findComponent(GlFormRadioGroup); + const findAuthTypeOptions = () => wrapper.findAllComponents(GlFormRadio); + const findUsernameField = () => wrapper.findByTestId('jira-auth-username'); + const findPasswordField = () => wrapper.findByTestId('jira-auth-password'); + + const selectRadioOption = (index) => findAuthTypeRadio().vm.$emit('input', index); + + describe('template', () => { + const mockFieldsWithPasswordValue = [ + mockJiraAuthFields[0], + mockJiraAuthFields[1], + { + ...mockJiraAuthFields[2], + value: 'hidden', + }, + ]; + + beforeEach(() => { + createComponent(); + }); + + it('renders auth type as radio buttons with correct options', () => { + expect(findAuthTypeRadio().exists()).toBe(true); + + findAuthTypeOptions().wrappers.forEach((option, index) => { + expect(option.text()).toBe(JiraAuthFields.authTypeOptions[index].text); + }); + }); + + it('selects "Basic" authentication by default', () => { + expect(findAuthTypeRadio().attributes('checked')).toBe('0'); + }); + + it('selects correct authentication when passed from backend', async () => { + createComponent({ + props: { + fields: [ + { + ...mockJiraAuthFields[0], + value: 1, + }, + mockJiraAuthFields[1], + mockJiraAuthFields[2], + ], + }, + }); + await nextTick(); + + expect(findAuthTypeRadio().attributes('checked')).toBe('1'); + }); + + describe('when "Basic" authentication is selected', () => { + it('renders username field as required', () => { + expect(findUsernameField().exists()).toBe(true); + expect(findUsernameField().props()).toMatchObject({ + title: jiraAuthTypeFieldProps[0].username, + required: true, + }); + }); + + it('renders password field with help', () => { + expect(findPasswordField().exists()).toBe(true); + expect(findPasswordField().props()).toMatchObject({ + title: jiraAuthTypeFieldProps[0].password, + help: jiraAuthTypeFieldProps[0].passwordHelp, + }); + }); + + it('renders new password title when value is present', () => { + createComponent({ + props: { + fields: mockFieldsWithPasswordValue, + }, + }); + + expect(findPasswordField().props('title')).toBe(jiraAuthTypeFieldProps[0].nonEmptyPassword); + }); + }); + + describe('when "Jira personal access token" authentication is selected', () => { + beforeEach(() => { + createComponent(); + + selectRadioOption(1); + }); + + it('selects "Jira personal access token" authentication', () => { + expect(findAuthTypeRadio().attributes('checked')).toBe('1'); + }); + + it('does not render username field', () => { + expect(findUsernameField().exists()).toBe(false); + }); + + it('renders password field without help', () => { + expect(findPasswordField().exists()).toBe(true); + expect(findPasswordField().props()).toMatchObject({ + title: jiraAuthTypeFieldProps[1].password, + help: null, + }); + }); + + it('renders new password title when value is present', async () => { + createComponent({ + props: { + fields: mockFieldsWithPasswordValue, + }, + }); + + await selectRadioOption(1); + + expect(findPasswordField().props('title')).toBe(jiraAuthTypeFieldProps[1].nonEmptyPassword); + }); + }); + }); +}); diff --git a/spec/frontend/integrations/edit/components/override_dropdown_spec.js b/spec/frontend/integrations/edit/components/override_dropdown_spec.js index 2d1a6b3ace1..a528816971a 100644 --- a/spec/frontend/integrations/edit/components/override_dropdown_spec.js +++ b/spec/frontend/integrations/edit/components/override_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlLink } from '@gitlab/ui'; +import { GlCollapsibleListbox, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue'; @@ -27,14 +27,14 @@ describe('OverrideDropdown', () => { }; const findGlLink = () => wrapper.findComponent(GlLink); - const findGlDropdown = () => wrapper.findComponent(GlDropdown); + const findGlCollapsibleListbox = () => wrapper.findComponent(GlCollapsibleListbox); describe('template', () => { describe('override prop is true', () => { it('renders GlToggle as disabled', () => { createComponent(); - expect(findGlDropdown().props('text')).toBe('Use custom settings'); + expect(findGlCollapsibleListbox().props('toggleText')).toBe('Use custom settings'); }); }); @@ -42,7 +42,7 @@ describe('OverrideDropdown', () => { it('renders GlToggle as disabled', () => { createComponent({ override: false }); - expect(findGlDropdown().props('text')).toBe('Use default settings'); + expect(findGlCollapsibleListbox().props('toggleText')).toBe('Use default settings'); }); }); diff --git a/spec/frontend/integrations/edit/components/sections/connection_spec.js b/spec/frontend/integrations/edit/components/sections/connection_spec.js index a24253d542d..7bd08a15ec1 100644 --- a/spec/frontend/integrations/edit/components/sections/connection_spec.js +++ b/spec/frontend/integrations/edit/components/sections/connection_spec.js @@ -1,15 +1,21 @@ import { shallowMount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; import IntegrationSectionConnection from '~/integrations/edit/components/sections/connection.vue'; import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue'; import DynamicField from '~/integrations/edit/components/dynamic_field.vue'; +import JiraAuthFields from '~/integrations/edit/components/jira_auth_fields.vue'; import { createStore } from '~/integrations/edit/store'; -import { mockIntegrationProps } from '../../mock_data'; +import { mockIntegrationProps, mockJiraAuthFields, mockField } from '../../mock_data'; describe('IntegrationSectionConnection', () => { let wrapper; + const JiraAuthFieldsStub = stubComponent(JiraAuthFields, { + template: `<div />`, + }); + const createComponent = ({ customStateProps = {}, props = {} } = {}) => { const store = createStore({ customState: { ...mockIntegrationProps, ...customStateProps }, @@ -17,11 +23,15 @@ describe('IntegrationSectionConnection', () => { wrapper = shallowMount(IntegrationSectionConnection, { propsData: { ...props }, store, + stubs: { + JiraAuthFields: JiraAuthFieldsStub, + }, }); }; const findActiveCheckbox = () => wrapper.findComponent(ActiveCheckbox); const findAllDynamicFields = () => wrapper.findAllComponents(DynamicField); + const findJiraAuthFields = () => wrapper.findComponent(JiraAuthFields); describe('template', () => { describe('ActiveCheckbox', () => { @@ -63,11 +73,42 @@ describe('IntegrationSectionConnection', () => { }); }); - it('does not render DynamicField when field is empty', () => { + it('does not render DynamicField when fields is empty', () => { createComponent(); expect(findAllDynamicFields()).toHaveLength(0); }); }); + + describe('when integration is not Jira', () => { + it('does not render JiraAuthFields', () => { + createComponent(); + + expect(findJiraAuthFields().exists()).toBe(false); + }); + }); + + describe('when integration is Jira', () => { + beforeEach(() => { + createComponent({ + customStateProps: { + type: 'jira', + }, + props: { + fields: [mockField, ...mockJiraAuthFields], + }, + }); + }); + + it('renders JiraAuthFields', () => { + expect(findJiraAuthFields().exists()).toBe(true); + expect(findJiraAuthFields().props('fields')).toEqual(mockJiraAuthFields); + }); + + it('filters out Jira auth fields for DynamicField', () => { + expect(findAllDynamicFields()).toHaveLength(1); + expect(findAllDynamicFields().at(0).props('name')).toBe(mockField.name); + }); + }); }); }); diff --git a/spec/frontend/integrations/edit/mock_data.js b/spec/frontend/integrations/edit/mock_data.js index c276d2e7364..31526eddd36 100644 --- a/spec/frontend/integrations/edit/mock_data.js +++ b/spec/frontend/integrations/edit/mock_data.js @@ -26,6 +26,24 @@ export const mockJiraIssueTypes = [ { id: '3', name: 'epic', description: 'epic' }, ]; +export const mockJiraAuthFields = [ + { + name: 'jira_auth_type', + type: 'select', + title: 'Authentication type', + }, + { + name: 'username', + type: 'text', + help: 'Email for Jira Cloud or username for Jira Data Center and Jira Server', + }, + { + name: 'password', + type: 'password', + help: 'API token for Jira Cloud or password for Jira Data Center and Jira Server', + }, +]; + export const mockField = { help: 'The URL of the project', name: 'project_url', diff --git a/spec/frontend/integrations/gitlab_slack_application/components/gitlab_slack_application_spec.js b/spec/frontend/integrations/gitlab_slack_application/components/gitlab_slack_application_spec.js new file mode 100644 index 00000000000..64b3b47d741 --- /dev/null +++ b/spec/frontend/integrations/gitlab_slack_application/components/gitlab_slack_application_spec.js @@ -0,0 +1,105 @@ +import { GlButton, GlLink } from '@gitlab/ui'; + +import { nextTick } from 'vue'; +import GitlabSlackApplication from '~/integrations/gitlab_slack_application/components/gitlab_slack_application.vue'; +import { addProjectToSlack } from '~/integrations/gitlab_slack_application/api'; +import { i18n } from '~/integrations/gitlab_slack_application/constants'; +import ProjectsDropdown from '~/integrations/gitlab_slack_application/components/projects_dropdown.vue'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { mockProjects } from '../mock_data'; + +jest.mock('~/integrations/gitlab_slack_application/api'); +jest.mock('~/lib/utils/url_utility'); + +describe('GitlabSlackApplication', () => { + let wrapper; + + const defaultProps = { + projects: [], + gitlabForSlackGifPath: '//gitlabForSlackGifPath', + signInPath: '//signInPath', + slackLinkPath: '//slackLinkPath', + docsPath: '//docsPath', + gitlabLogoPath: '//gitlabLogoPath', + slackLogoPath: '//slackLogoPath', + isSignedIn: true, + }; + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMountExtended(GitlabSlackApplication, { + propsData: { ...defaultProps, ...props }, + }); + }; + + const findGlButton = () => wrapper.findComponent(GlButton); + const findGlLink = () => wrapper.findComponent(GlLink); + const findProjectsDropdown = () => wrapper.findComponent(ProjectsDropdown); + const findAppContent = () => wrapper.findByTestId('gitlab-slack-content'); + + describe('template', () => { + describe('when user is not signed in', () => { + it('renders "Sign in" button', () => { + createComponent({ + props: { isSignedIn: false }, + }); + + expect(findGlButton().attributes('href')).toBe(defaultProps.signInPath); + }); + }); + + describe('when user is signed in', () => { + describe('user does not have any projects', () => { + it('renders empty text', () => { + createComponent(); + + expect(findAppContent().text()).toContain(i18n.noProjects); + expect(findAppContent().text()).toContain(i18n.noProjectsDescription); + }); + + it('renders "Learn more" link', () => { + createComponent(); + + expect(findGlLink().text()).toBe(i18n.learnMore); + }); + }); + + describe('user has projects', () => { + beforeEach(() => { + createComponent({ + props: { + projects: mockProjects, + }, + }); + }); + + it('renders ProjectsDropdown', () => { + expect(findProjectsDropdown().props('projects')).toBe(mockProjects); + }); + + it('redirects to slackLinkPath when submitted', async () => { + const redirectLink = '//redirectLink'; + const mockProject = mockProjects[1]; + const addToSlackData = { data: { add_to_slack_link: redirectLink } }; + + addProjectToSlack.mockResolvedValue(addToSlackData); + + findProjectsDropdown().vm.$emit('project-selected', mockProject); + await nextTick(); + + expect(findProjectsDropdown().props('selectedProject')).toBe(mockProject); + expect(findGlButton().props('disabled')).toBe(false); + + findGlButton().vm.$emit('click'); + + await waitForPromises(); + + expect(redirectTo).toHaveBeenCalledWith(redirectLink); // eslint-disable-line import/no-deprecated + }); + }); + }); + }); +}); diff --git a/spec/frontend/integrations/gitlab_slack_application/mock_data.js b/spec/frontend/integrations/gitlab_slack_application/mock_data.js new file mode 100644 index 00000000000..9ada528d69e --- /dev/null +++ b/spec/frontend/integrations/gitlab_slack_application/mock_data.js @@ -0,0 +1,14 @@ +export const mockProjects = [ + { + id: 1, + name: 'Test', + avatar_url: 'avatar.jpg', + name_with_namespace: 'Test org / Test', + }, + { + id: 2, + name: 'Shell', + avatar_url: 'avatar.jpg', + name_with_namespace: 'Test org / Shell', + }, +]; diff --git a/spec/frontend/invite_members/components/import_project_members_modal_spec.js b/spec/frontend/invite_members/components/import_project_members_modal_spec.js index 73634855850..224ebe18e2a 100644 --- a/spec/frontend/invite_members/components/import_project_members_modal_spec.js +++ b/spec/frontend/invite_members/components/import_project_members_modal_spec.js @@ -6,19 +6,28 @@ import { BV_HIDE_MODAL } from '~/lib/utils/constants'; import { stubComponent } from 'helpers/stub_component'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import * as ProjectsApi from '~/api/projects_api'; +import eventHub from '~/invite_members/event_hub'; import ImportProjectMembersModal from '~/invite_members/components/import_project_members_modal.vue'; import ProjectSelect from '~/invite_members/components/project_select.vue'; import axios from '~/lib/utils/axios_utils'; + import { displaySuccessfulInvitationAlert, reloadOnInvitationSuccess, } from '~/invite_members/utils/trigger_successful_invite_alert'; +import { + IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_CATEGORY, + IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_LABEL, +} from '~/invite_members/constants'; + jest.mock('~/invite_members/utils/trigger_successful_invite_alert'); let wrapper; let mock; +let trackingSpy; const projectId = '1'; const projectName = 'test name'; @@ -27,6 +36,18 @@ const $toast = { show: jest.fn(), }; +const expectTracking = (action) => + expect(trackingSpy).toHaveBeenCalledWith(IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_CATEGORY, action, { + label: IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_LABEL, + category: IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_CATEGORY, + property: undefined, + }); + +const triggerOpenModal = async () => { + eventHub.$emit('openProjectMembersModal'); + await nextTick(); +}; + const createComponent = ({ props = {} } = {}) => { wrapper = shallowMountExtended(ImportProjectMembersModal, { propsData: { @@ -48,6 +69,8 @@ const createComponent = ({ props = {} } = {}) => { $toast, }, }); + + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }; beforeEach(() => { @@ -57,6 +80,7 @@ beforeEach(() => { afterEach(() => { mock.restore(); + unmockTracking(); }); describe('ImportProjectMembersModal', () => { @@ -106,6 +130,24 @@ describe('ImportProjectMembersModal', () => { expect(findGlModal().props('actionPrimary').attributes.loading).toBe(true); }); + + it('tracks render', async () => { + await triggerOpenModal(); + + expectTracking('render'); + }); + + it('tracks cancel', () => { + findGlModal().vm.$emit('cancel'); + + expectTracking('click_cancel'); + }); + + it('tracks close', () => { + findGlModal().vm.$emit('close'); + + expectTracking('click_x'); + }); }); describe('submitting the import', () => { @@ -145,6 +187,10 @@ describe('ImportProjectMembersModal', () => { wrapper.vm.$options.toastOptions, ); }); + + it('tracks successful import', () => { + expectTracking('invite_successful'); + }); }); describe('when the import is successful', () => { @@ -189,6 +235,10 @@ describe('ImportProjectMembersModal', () => { it('sets isLoading to false after success', () => { expect(findGlModal().props('actionPrimary').attributes.loading).toBe(false); }); + + it('tracks successful import', () => { + expectTracking('invite_successful'); + }); }); describe('when the import fails', () => { 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 e080e665a3b..1a9b0fae52a 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -63,6 +63,7 @@ describe('InviteMembersModal', () => { let wrapper; let mock; let trackingSpy; + const showToast = jest.fn(); const expectTracking = (action, label = undefined, property = undefined) => expect(trackingSpy).toHaveBeenCalledWith(INVITE_MEMBER_MODAL_TRACKING_CATEGORY, action, { @@ -94,6 +95,11 @@ describe('InviteMembersModal', () => { GlEmoji, ...stubs, }, + mocks: { + $toast: { + show: showToast, + }, + }, }); }; @@ -470,7 +476,6 @@ describe('InviteMembersModal', () => { createComponent({ reloadPageOnSubmit: true }); await triggerMembersTokenSelect([user1, user2]); - wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData }); clickInviteButton(); }); @@ -484,7 +489,7 @@ describe('InviteMembersModal', () => { }); it('does not show the toast message', () => { - expect(wrapper.vm.$toast.show).not.toHaveBeenCalled(); + expect(showToast).not.toHaveBeenCalled(); }); }); @@ -493,7 +498,6 @@ describe('InviteMembersModal', () => { createComponent(); await triggerMembersTokenSelect([user1, user2]); - wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData }); }); @@ -507,7 +511,7 @@ describe('InviteMembersModal', () => { }); it('displays the successful toastMessage', () => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added'); + expect(showToast).toHaveBeenCalledWith('Members were successfully added'); }); it('does not call displaySuccessfulInvitationAlert on mount', () => { @@ -630,7 +634,6 @@ describe('InviteMembersModal', () => { await triggerMembersTokenSelect([user3]); trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: emailPostData }); }); @@ -644,7 +647,7 @@ describe('InviteMembersModal', () => { }); it('displays the successful toastMessage', () => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added'); + expect(showToast).toHaveBeenCalledWith('Members were successfully added'); }); it('does not call displaySuccessfulInvitationAlert on mount', () => { @@ -858,7 +861,6 @@ describe('InviteMembersModal', () => { await triggerMembersTokenSelect([user1, user3]); trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: singleUserPostData }); }); @@ -877,7 +879,7 @@ describe('InviteMembersModal', () => { }); it('displays the successful toastMessage', () => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added'); + expect(showToast).toHaveBeenCalledWith('Members were successfully added'); }); it('does not call displaySuccessfulInvitationAlert on mount', () => { diff --git a/spec/frontend/invite_members/components/members_token_select_spec.js b/spec/frontend/invite_members/components/members_token_select_spec.js index c7e9905dee3..ff0313cc49e 100644 --- a/spec/frontend/invite_members/components/members_token_select_spec.js +++ b/spec/frontend/invite_members/components/members_token_select_spec.js @@ -130,6 +130,18 @@ describe('MembersTokenSelect', () => { expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false); }); + it('calls the API with search parameter with whitespaces and is trimmed', async () => { + tokenSelector.vm.$emit('text-input', ' foo@bar.com '); + + await waitForPromises(); + + expect(UserApi.getUsers).toHaveBeenCalledWith('foo@bar.com', { + active: true, + without_project_bots: true, + }); + expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false); + }); + describe('when input text is an email', () => { it('allows user defined tokens', async () => { tokenSelector.vm.$emit('text-input', 'foo@bar.com'); diff --git a/spec/frontend/issuable/components/csv_import_export_buttons_spec.js b/spec/frontend/issuable/components/csv_import_export_buttons_spec.js index 0e2f71fa3ee..4b4deafcabd 100644 --- a/spec/frontend/issuable/components/csv_import_export_buttons_spec.js +++ b/spec/frontend/issuable/components/csv_import_export_buttons_spec.js @@ -32,9 +32,9 @@ describe('CsvImportExportButtons', () => { }); } - const findExportCsvButton = () => wrapper.findByRole('menuitem', { name: 'Export as CSV' }); - const findImportCsvButton = () => wrapper.findByRole('menuitem', { name: 'Import CSV' }); - const findImportFromJiraLink = () => wrapper.findByRole('menuitem', { name: 'Import from Jira' }); + const findExportCsvButton = () => wrapper.findByTestId('export-as-csv-button'); + const findImportCsvButton = () => wrapper.findByTestId('import-from-csv-button'); + const findImportFromJiraLink = () => wrapper.findByTestId('import-from-jira-link'); const findExportCsvModal = () => wrapper.findComponent(CsvExportModal); const findImportCsvModal = () => wrapper.findComponent(CsvImportModal); @@ -111,7 +111,7 @@ describe('CsvImportExportButtons', () => { }); it('passes the proper path to the link', () => { - expect(findImportFromJiraLink().attributes('href')).toBe(projectImportJiraPath); + expect(findImportFromJiraLink().props('item').href).toBe(projectImportJiraPath); }); }); diff --git a/spec/frontend/issuable/components/issuable_header_warnings_spec.js b/spec/frontend/issuable/components/issuable_header_warnings_spec.js index ff772040d22..34f36bdf6cb 100644 --- a/spec/frontend/issuable/components/issuable_header_warnings_spec.js +++ b/spec/frontend/issuable/components/issuable_header_warnings_spec.js @@ -1,15 +1,13 @@ -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 mrStore from '~/mr_notes/stores'; import createIssueStore from '~/notes/stores'; import IssuableHeaderWarnings from '~/issuable/components/issuable_header_warnings.vue'; const ISSUABLE_TYPE_ISSUE = 'issue'; const ISSUABLE_TYPE_MR = 'merge_request'; -Vue.use(Vuex); +jest.mock('~/mr_notes/stores', () => jest.requireActual('helpers/mocks/mr_notes/stores')); describe('IssuableHeaderWarnings', () => { let wrapper; @@ -22,7 +20,9 @@ describe('IssuableHeaderWarnings', () => { const createComponent = ({ store, provide }) => { wrapper = shallowMountExtended(IssuableHeaderWarnings, { - store, + mocks: { + $store: store, + }, provide, directives: { GlTooltip: createMockDirective('gl-tooltip'), @@ -47,9 +47,14 @@ describe('IssuableHeaderWarnings', () => { `( `when locked=$lockStatus, confidential=$confidentialStatus, and hidden=$hiddenStatus`, ({ lockStatus, confidentialStatus, hiddenStatus }) => { - const store = issuableType === ISSUABLE_TYPE_ISSUE ? createIssueStore() : createMrStore(); + const store = issuableType === ISSUABLE_TYPE_ISSUE ? createIssueStore() : mrStore; beforeEach(() => { + // TODO: simplify to single assignment after issue store is mock + if (store === mrStore) { + store.getters.getNoteableData = {}; + } + store.getters.getNoteableData.confidential = confidentialStatus; store.getters.getNoteableData.discussion_locked = lockStatus; store.getters.getNoteableData.targetType = issuableType; @@ -58,7 +63,16 @@ describe('IssuableHeaderWarnings', () => { }); it(`${renderTestMessage(lockStatus)} the locked icon`, () => { - expect(findLockedIcon().exists()).toBe(lockStatus); + const lockedIcon = findLockedIcon(); + + expect(lockedIcon.exists()).toBe(lockStatus); + + if (lockStatus) { + expect(lockedIcon.attributes('title')).toBe( + `This ${issuableType.replace('_', ' ')} is locked. Only project members can comment.`, + ); + expect(getBinding(lockedIcon.element, 'gl-tooltip')).not.toBeUndefined(); + } }); it(`${renderTestMessage(confidentialStatus)} the confidential icon`, () => { diff --git a/spec/frontend/issues/dashboard/mock_data.js b/spec/frontend/issues/dashboard/mock_data.js index e789360d1d5..adcd4268449 100644 --- a/spec/frontend/issues/dashboard/mock_data.js +++ b/spec/frontend/issues/dashboard/mock_data.js @@ -3,6 +3,7 @@ export const issuesQueryResponse = { issues: { nodes: [ { + __persist: true, __typename: 'Issue', id: 'gid://gitlab/Issue/123456', iid: '789', @@ -27,6 +28,7 @@ export const issuesQueryResponse = { assignees: { nodes: [ { + __persist: true, __typename: 'UserCore', id: 'gid://gitlab/User/234', avatarUrl: 'avatar/url', @@ -37,6 +39,7 @@ export const issuesQueryResponse = { ], }, author: { + __persist: true, __typename: 'UserCore', id: 'gid://gitlab/User/456', avatarUrl: 'avatar/url', @@ -47,6 +50,7 @@ export const issuesQueryResponse = { labels: { nodes: [ { + __persist: true, id: 'gid://gitlab/ProjectLabel/456', color: '#333', title: 'Label title', diff --git a/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js b/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js index 4ea3a39f15b..a61e7ed1e86 100644 --- a/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js +++ b/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlEmptyState, GlLink } from '@gitlab/ui'; +import { GlDisclosureDropdown, GlEmptyState, GlLink } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import EmptyStateWithoutAnyIssues from '~/issues/list/components/empty_state_without_any_issues.vue'; @@ -26,7 +26,7 @@ describe('EmptyStateWithoutAnyIssues component', () => { }; const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons); - const findCsvImportExportDropdown = () => wrapper.findComponent(GlDropdown); + const findCsvImportExportDropdown = () => wrapper.findComponent(GlDisclosureDropdown); const findGlEmptyState = () => wrapper.findComponent(GlEmptyState); const findGlLink = () => wrapper.findComponent(GlLink); const findIssuesHelpPageLink = () => @@ -136,7 +136,7 @@ describe('EmptyStateWithoutAnyIssues component', () => { it('renders', () => { mountComponent({ props: { showCsvButtons: true } }); - expect(findCsvImportExportDropdown().props('text')).toBe('Import issues'); + expect(findCsvImportExportDropdown().props('toggleText')).toBe('Import issues'); expect(findCsvImportExportButtons().props()).toMatchObject({ exportCsvPath: defaultProps.exportCsvPathWithQuery, issuableCount: 0, 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 af24b547545..0e87e5e6595 100644 --- a/spec/frontend/issues/list/components/issues_list_app_spec.js +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -1,4 +1,4 @@ -import { GlButton, GlDropdown } from '@gitlab/ui'; +import { GlButton, GlDisclosureDropdown } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { mount, shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; @@ -11,13 +11,14 @@ import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_coun import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import { + filteredTokens, getIssuesCountsQueryResponse, - getIssuesQueryResponse, getIssuesQueryEmptyResponse, - filteredTokens, + getIssuesQueryResponse, locationSearch, setSortPreferenceMutationResponse, setSortPreferenceMutationResponseWithErrors, @@ -34,6 +35,7 @@ import { issuableListTabs } from '~/vue_shared/issuable/list/constants'; import EmptyStateWithAnyIssues from '~/issues/list/components/empty_state_with_any_issues.vue'; import EmptyStateWithoutAnyIssues from '~/issues/list/components/empty_state_without_any_issues.vue'; import IssuesListApp from '~/issues/list/components/issues_list_app.vue'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue'; import { CREATED_DESC, @@ -127,16 +129,18 @@ describe('CE IssuesListApp component', () => { const mockIssuesQueryResponse = jest.fn().mockResolvedValue(defaultQueryResponse); const mockIssuesCountsQueryResponse = jest.fn().mockResolvedValue(getIssuesCountsQueryResponse); - const findCalendarButton = () => - wrapper.findByRole('menuitem', { name: IssuesListApp.i18n.calendarLabel }); const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons); - const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); const findIssuableByEmail = () => wrapper.findComponent(IssuableByEmail); const findGlButton = () => wrapper.findComponent(GlButton); const findGlButtons = () => wrapper.findAllComponents(GlButton); const findIssuableList = () => wrapper.findComponent(IssuableList); + const findListViewTypeBtn = () => wrapper.findByTestId('list-view-type'); + const findGridtViewTypeBtn = () => wrapper.findByTestId('grid-view-type'); + const findViewTypeLocalStorageSync = () => wrapper.findAllComponents(LocalStorageSync).at(0); const findNewResourceDropdown = () => wrapper.findComponent(NewResourceDropdown); - const findRssButton = () => wrapper.findByRole('menuitem', { name: IssuesListApp.i18n.rssLabel }); + const findCalendarButton = () => wrapper.findByTestId('subscribe-calendar'); + const findRssButton = () => wrapper.findByTestId('subscribe-rss'); const findLabelsToken = () => findIssuableList() @@ -233,6 +237,7 @@ describe('CE IssuesListApp component', () => { hasPreviousPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasPreviousPage, hasNextPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasNextPage, }); + expect(findIssuableList().props('isGridView')).toBe(false); }); }); @@ -244,7 +249,7 @@ describe('CE IssuesListApp component', () => { expect(findDropdown().props()).toMatchObject({ category: 'tertiary', icon: 'ellipsis_v', - text: 'Actions', + toggleText: 'Actions', textSrOnly: true, }); }); @@ -354,6 +359,37 @@ describe('CE IssuesListApp component', () => { }); }); + describe('header action buttons with the grid view enabled', () => { + beforeEach(() => { + wrapper = mountComponent({ + mountFn: shallowMountExtended, + provide: { + glFeatures: { + issuesGridView: true, + }, + }, + stubs: { + IssuableList: stubComponent(IssuableList, { + template: `<div><slot name="nav-actions" /></div>`, + }), + }, + }); + }); + + it('switch between list and grid', async () => { + findGridtViewTypeBtn().vm.$emit('click'); + await nextTick(); + + expect(findIssuableList().props('isGridView')).toBe(true); + expect(findViewTypeLocalStorageSync().props('value')).toBe('Grid'); + + findListViewTypeBtn().vm.$emit('click'); + await nextTick(); + expect(findIssuableList().props('isGridView')).toBe(false); + expect(findViewTypeLocalStorageSync().props('value')).toBe('List'); + }); + }); + describe('initial url params', () => { describe('page', () => { it('page_after is set from the url params', () => { diff --git a/spec/frontend/issues/list/mock_data.js b/spec/frontend/issues/list/mock_data.js index bd006a6b3ce..b9a8bc171db 100644 --- a/spec/frontend/issues/list/mock_data.js +++ b/spec/frontend/issues/list/mock_data.js @@ -154,6 +154,22 @@ export const setSortPreferenceMutationResponseWithErrors = { }, }; +export const setIdTypePreferenceMutationResponse = { + data: { + userPreferencesUpdate: { + errors: [], + }, + }, +}; + +export const setIdTypePreferenceMutationResponseWithErrors = { + data: { + userPreferencesUpdate: { + errors: ['oh no!'], + }, + }, +}; + export const locationSearch = [ '?search=find+issues', 'author_username=homer', diff --git a/spec/frontend/issues/show/components/app_spec.js b/spec/frontend/issues/show/components/app_spec.js index 83707dfd254..ecca3e69ef6 100644 --- a/spec/frontend/issues/show/components/app_spec.js +++ b/spec/frontend/issues/show/components/app_spec.js @@ -326,12 +326,14 @@ describe('Issuable output', () => { describe('when title is in view', () => { it('is not shown', () => { + wrapper.findComponent(GlIntersectionObserver).vm.$emit('disappear'); expect(findStickyHeader().exists()).toBe(false); }); }); describe('when title is not in view', () => { beforeEach(() => { + global.pageYOffset = 100; wrapper.findComponent(GlIntersectionObserver).vm.$emit('disappear'); }); @@ -395,7 +397,16 @@ describe('Issuable output', () => { `('$title', async ({ isLocked }) => { await wrapper.setProps({ isLocked }); - expect(findLockedBadge().exists()).toBe(isLocked); + const lockedBadge = findLockedBadge(); + + expect(lockedBadge.exists()).toBe(isLocked); + + if (isLocked) { + expect(lockedBadge.attributes('title')).toBe( + 'This issue is locked. Only project members can comment.', + ); + expect(getBinding(lockedBadge.element, 'gl-tooltip')).not.toBeUndefined(); + } }); it.each` diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js index 9a0cde15b24..93860aaa925 100644 --- a/spec/frontend/issues/show/components/description_spec.js +++ b/spec/frontend/issues/show/components/description_spec.js @@ -10,6 +10,7 @@ import Description from '~/issues/show/components/description.vue'; import eventHub from '~/issues/show/event_hub'; import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql'; import workItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; +import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import TaskList from '~/task_list'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { @@ -17,6 +18,7 @@ import { createWorkItemMutationResponse, getIssueDetailsResponse, projectWorkItemTypesQueryResponse, + workItemByIidResponseFactory, } from 'jest/work_items/mock_data'; import { descriptionProps as initialProps, @@ -52,9 +54,23 @@ describe('Description component', () => { issueDetailsQueryHandler = jest.fn().mockResolvedValue(issueDetailsResponse), createWorkItemMutationHandler, } = {}) { + const mockApollo = createMockApollo([ + [workItemTypesQuery, workItemTypesQueryHandler], + [getIssueDetailsQuery, issueDetailsQueryHandler], + [createWorkItemMutation, createWorkItemMutationHandler], + ]); + + mockApollo.clients.defaultClient.cache.writeQuery({ + query: workItemByIidQuery, + variables: { fullPath: 'gitlab-org/gitlab-test', iid: '1' }, + data: workItemByIidResponseFactory().data, + }); + wrapper = shallowMountExtended(Description, { + apolloProvider: mockApollo, propsData: { issueId: 1, + issueIid: 1, ...initialProps, ...props, }, @@ -63,11 +79,6 @@ describe('Description component', () => { hasIterationsFeature: true, ...provide, }, - apolloProvider: createMockApollo([ - [workItemTypesQuery, workItemTypesQueryHandler], - [getIssueDetailsQuery, issueDetailsQueryHandler], - [createWorkItemMutation, createWorkItemMutationHandler], - ]), mocks: { $toast, }, diff --git a/spec/frontend/issues/show/components/header_actions_spec.js b/spec/frontend/issues/show/components/header_actions_spec.js index a5ba512434c..9a503a2d882 100644 --- a/spec/frontend/issues/show/components/header_actions_spec.js +++ b/spec/frontend/issues/show/components/header_actions_spec.js @@ -103,7 +103,8 @@ describe('HeaderActions component', () => { }, }; - const findToggleIssueStateButton = () => wrapper.find(`[data-testid="toggle-button"]`); + const findToggleIssueStateButton = () => + wrapper.find(`[data-testid="toggle-issue-state-button"]`); const findEditButton = () => wrapper.find(`[data-testid="edit-button"]`); const findDropdownBy = (dataTestId) => wrapper.find(`[data-testid="${dataTestId}"]`); @@ -134,6 +135,7 @@ describe('HeaderActions component', () => { .mockResolvedValue(promoteToEpicMutationErrorResponse); const mountComponent = ({ + isLoggedIn = true, props = {}, issueState = STATUS_OPEN, blockedByIssues = [], @@ -151,6 +153,10 @@ describe('HeaderActions component', () => { [promoteToEpicMutation, promoteToEpicHandler], ]; + if (isLoggedIn) { + window.gon.current_user_id = 1; + } + return shallowMount(HeaderActions, { apolloProvider: createMockApollo(handlers), store, @@ -648,4 +654,40 @@ describe('HeaderActions component', () => { }); }); }); + + describe('when logged out', () => { + describe.each` + movedMrSidebarEnabled | issueType | headerActionsVisible + ${true} | ${TYPE_ISSUE} | ${true} + ${true} | ${TYPE_INCIDENT} | ${true} + ${false} | ${TYPE_ISSUE} | ${false} + ${false} | ${TYPE_INCIDENT} | ${false} + `( + `with movedMrSidebarEnabled flag is "$movedMrSidebarEnabled" with issue type "$issueType"`, + ({ movedMrSidebarEnabled, issueType, headerActionsVisible }) => { + beforeEach(async () => { + wrapper = mountComponent({ + props: { + issueType, + canCreateIssue: false, + canPromoteToEpic: false, + canReportSpam: false, + }, + movedMrSidebarEnabled, + isLoggedIn: false, + }); + + await waitForPromises(); + }); + + it(`${headerActionsVisible ? 'shows' : 'hides'} headers actions`, () => { + expect(findDesktopDropdown().exists()).toBe(headerActionsVisible); + expect(findCopyRefenceDropdownItem().exists()).toBe(headerActionsVisible); + expect(findNotificationWidget().exists()).toBe(false); + expect(findReportAbuseSelectorItem().exists()).toBe(false); + expect(findLockIssueWidget().exists()).toBe(false); + }); + }, + ); + }); }); diff --git a/spec/frontend/issues/show/components/task_list_item_actions_spec.js b/spec/frontend/issues/show/components/task_list_item_actions_spec.js index 7dacbefaeff..0b3ff0667b1 100644 --- a/spec/frontend/issues/show/components/task_list_item_actions_spec.js +++ b/spec/frontend/issues/show/components/task_list_item_actions_spec.js @@ -6,7 +6,7 @@ import eventHub from '~/issues/show/event_hub'; describe('TaskListItemActions component', () => { let wrapper; - const findGlDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findGlDisclosureDropdown = () => wrapper.findComponent(GlDisclosureDropdown); const findConvertToTaskItem = () => wrapper.findAllComponents(GlDisclosureDropdownItem).at(0); const findDeleteItem = () => wrapper.findAllComponents(GlDisclosureDropdownItem).at(1); @@ -20,7 +20,6 @@ describe('TaskListItemActions component', () => { provide: { canUpdate: true }, attachTo: document.querySelector('div'), }); - wrapper.vm.$refs.dropdown.close = jest.fn(); }; beforeEach(() => { @@ -28,7 +27,7 @@ describe('TaskListItemActions component', () => { }); it('renders dropdown', () => { - expect(findGlDropdown().props()).toMatchObject({ + expect(findGlDisclosureDropdown().props()).toMatchObject({ category: 'tertiary', icon: 'ellipsis_v', placement: 'right', diff --git a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_spec.js b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_spec.js index 9d5bc8dff2a..845ada187ef 100644 --- a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_spec.js @@ -77,7 +77,7 @@ describe('GroupsList', () => { expect(findGlLoadingIcon().exists()).toBe(false); expect(findGlAlert().exists()).toBe(true); - expect(findGlAlert().text()).toBe('Failed to load namespaces. Please try again.'); + expect(findGlAlert().text()).toBe('Failed to load groups. Please try again.'); }); }); @@ -89,7 +89,7 @@ describe('GroupsList', () => { await waitForPromises(); expect(findGlLoadingIcon().exists()).toBe(false); - expect(wrapper.text()).toContain('No available namespaces'); + expect(wrapper.text()).toContain('No groups found'); }); }); diff --git a/spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js b/spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js index d262f4b2735..4819a870a27 100644 --- a/spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js +++ b/spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js @@ -44,9 +44,7 @@ describe('SubscriptionsPage', () => { }); }); - it(`${ - subscriptionsLoading ? 'does not render' : 'renders' - } button to add namespace`, () => { + it(`${subscriptionsLoading ? 'does not render' : 'renders'} button to add group`, () => { expect(findAddNamespaceButton().exists()).toBe(!subscriptionsLoading); }); diff --git a/spec/frontend/jobs/components/job/manual_variables_form_spec.js b/spec/frontend/jobs/components/job/manual_variables_form_spec.js index a48155d93ac..989fe5c11e9 100644 --- a/spec/frontend/jobs/components/job/manual_variables_form_spec.js +++ b/spec/frontend/jobs/components/job/manual_variables_form_spec.js @@ -13,6 +13,8 @@ import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line imp import ManualVariablesForm from '~/jobs/components/job/manual_variables_form.vue'; import getJobQuery from '~/jobs/components/job/graphql/queries/get_job.query.graphql'; import playJobMutation from '~/jobs/components/job/graphql/mutations/job_play_with_variables.mutation.graphql'; +import retryJobMutation from '~/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql'; + import { mockFullPath, mockId, @@ -38,9 +40,32 @@ const defaultProvide = { describe('Manual Variables Form', () => { let wrapper; let mockApollo; - let getJobQueryResponse; + let requestHandlers; + + const getJobQueryResponseHandlerWithVariables = jest.fn().mockResolvedValue(mockJobResponse); + const playJobMutationHandler = jest.fn().mockResolvedValue({}); + const retryJobMutationHandler = jest.fn().mockResolvedValue({}); + + const defaultHandlers = { + getJobQueryResponseHandlerWithVariables, + playJobMutationHandler, + retryJobMutationHandler, + }; + + const createComponent = ({ props = {}, handlers = defaultHandlers } = {}) => { + requestHandlers = handlers; + + mockApollo = createMockApollo([ + [getJobQuery, handlers.getJobQueryResponseHandlerWithVariables], + [playJobMutation, handlers.playJobMutationHandler], + [retryJobMutation, handlers.retryJobMutationHandler], + ]); + + const options = { + localVue, + apolloProvider: mockApollo, + }; - const createComponent = ({ options = {}, props = {} } = {}) => { wrapper = mountExtended(ManualVariablesForm, { propsData: { jobId: mockId, @@ -52,22 +77,6 @@ describe('Manual Variables Form', () => { }, ...options, }); - }; - - const createComponentWithApollo = ({ props = {} } = {}) => { - const requestHandlers = [[getJobQuery, getJobQueryResponse]]; - - mockApollo = createMockApollo(requestHandlers); - - const options = { - localVue, - apolloProvider: mockApollo, - }; - - createComponent({ - props, - options, - }); return waitForPromises(); }; @@ -96,18 +105,13 @@ describe('Manual Variables Form', () => { nextTick(); }; - beforeEach(() => { - getJobQueryResponse = jest.fn(); - }); - afterEach(() => { createAlert.mockClear(); }); describe('when page renders', () => { beforeEach(async () => { - getJobQueryResponse.mockResolvedValue(mockJobResponse); - await createComponentWithApollo(); + await createComponent(); }); it('renders help text with provided link', () => { @@ -120,8 +124,11 @@ describe('Manual Variables Form', () => { describe('when query is unsuccessful', () => { beforeEach(async () => { - getJobQueryResponse.mockRejectedValue({}); - await createComponentWithApollo(); + await createComponent({ + handlers: { + getJobQueryResponseHandlerWithVariables: jest.fn().mockRejectedValue({}), + }, + }); }); it('shows an alert with error', () => { @@ -133,8 +140,13 @@ describe('Manual Variables Form', () => { describe('when job has not been retried', () => { beforeEach(async () => { - getJobQueryResponse.mockResolvedValue(mockJobWithVariablesResponse); - await createComponentWithApollo(); + await createComponent({ + handlers: { + getJobQueryResponseHandlerWithVariables: jest + .fn() + .mockResolvedValue(mockJobWithVariablesResponse), + }, + }); }); it('does not render the cancel button', () => { @@ -145,8 +157,13 @@ describe('Manual Variables Form', () => { describe('when job has variables', () => { beforeEach(async () => { - getJobQueryResponse.mockResolvedValue(mockJobWithVariablesResponse); - await createComponentWithApollo(); + await createComponent({ + handlers: { + getJobQueryResponseHandlerWithVariables: jest + .fn() + .mockResolvedValue(mockJobWithVariablesResponse), + }, + }); }); it('sets manual job variables', () => { @@ -161,8 +178,11 @@ describe('Manual Variables Form', () => { describe('when play mutation fires', () => { beforeEach(async () => { - await createComponentWithApollo(); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockJobPlayMutationData); + await createComponent({ + handlers: { + playJobMutationHandler: jest.fn().mockResolvedValue(mockJobPlayMutationData), + }, + }); }); it('passes variables in correct format', async () => { @@ -172,18 +192,15 @@ describe('Manual Variables Form', () => { await findRunBtn().vm.$emit('click'); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: playJobMutation, - variables: { - id: convertToGraphQLId(TYPENAME_CI_BUILD, mockId), - variables: [ - { - key: 'new key', - value: 'new value', - }, - ], - }, + expect(requestHandlers.playJobMutationHandler).toHaveBeenCalledTimes(1); + expect(requestHandlers.playJobMutationHandler).toHaveBeenCalledWith({ + id: convertToGraphQLId(TYPENAME_CI_BUILD, mockId), + variables: [ + { + key: 'new key', + value: 'new value', + }, + ], }); }); @@ -191,15 +208,18 @@ describe('Manual Variables Form', () => { findRunBtn().vm.$emit('click'); await waitForPromises(); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); + expect(requestHandlers.playJobMutationHandler).toHaveBeenCalledTimes(1); expect(redirectTo).toHaveBeenCalledWith(mockJobPlayMutationData.data.jobPlay.job.webPath); // eslint-disable-line import/no-deprecated }); }); describe('when play mutation is unsuccessful', () => { beforeEach(async () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({}); - await createComponentWithApollo(); + await createComponent({ + handlers: { + playJobMutationHandler: jest.fn().mockRejectedValue({}), + }, + }); }); it('shows an alert with error', async () => { @@ -214,8 +234,12 @@ describe('Manual Variables Form', () => { describe('when job is retryable', () => { beforeEach(async () => { - await createComponentWithApollo({ props: { isRetryable: true } }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockJobRetryMutationData); + await createComponent({ + props: { isRetryable: true }, + handlers: { + retryJobMutationHandler: jest.fn().mockResolvedValue(mockJobRetryMutationData), + }, + }); }); it('renders cancel button', () => { @@ -226,15 +250,19 @@ describe('Manual Variables Form', () => { findRunBtn().vm.$emit('click'); await waitForPromises(); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); + expect(requestHandlers.retryJobMutationHandler).toHaveBeenCalledTimes(1); expect(redirectTo).toHaveBeenCalledWith(mockJobRetryMutationData.data.jobRetry.job.webPath); // eslint-disable-line import/no-deprecated }); }); describe('when retry mutation is unsuccessful', () => { beforeEach(async () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({}); - await createComponentWithApollo({ props: { isRetryable: true } }); + await createComponent({ + props: { isRetryable: true }, + handlers: { + retryJobMutationHandler: jest.fn().mockRejectedValue({}), + }, + }); }); it('shows an alert with error', async () => { @@ -249,8 +277,11 @@ describe('Manual Variables Form', () => { describe('updating variables in UI', () => { beforeEach(async () => { - getJobQueryResponse.mockResolvedValue(mockJobResponse); - await createComponentWithApollo(); + await createComponent({ + handlers: { + getJobQueryResponseHandlerWithVariables: jest.fn().mockResolvedValue(mockJobResponse), + }, + }); }); it('creates a new variable when user enters a new key value', async () => { @@ -305,8 +336,11 @@ describe('Manual Variables Form', () => { describe('variable delete button placeholder', () => { beforeEach(async () => { - getJobQueryResponse.mockResolvedValue(mockJobResponse); - await createComponentWithApollo(); + await createComponent({ + handlers: { + getJobQueryResponseHandlerWithVariables: jest.fn().mockResolvedValue(mockJobResponse), + }, + }); }); it('delete variable button placeholder should only exist when a user cannot remove', () => { diff --git a/spec/frontend/jobs/components/job/stages_dropdown_spec.js b/spec/frontend/jobs/components/job/stages_dropdown_spec.js index 9d01dc50e96..c42edc62183 100644 --- a/spec/frontend/jobs/components/job/stages_dropdown_spec.js +++ b/spec/frontend/jobs/components/job/stages_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlDisclosureDropdown, GlLink, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { Mousetrap } from '~/lib/mousetrap'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -16,8 +16,8 @@ describe('Stages Dropdown', () => { let wrapper; const findStatus = () => wrapper.findComponent(CiIcon); - const findSelectedStageText = () => wrapper.findComponent(GlDropdown).props('text'); - const findStageItem = (index) => wrapper.findAllComponents(GlDropdownItem).at(index); + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findSelectedStageText = () => findDropdown().props('toggleText'); const findPipelineInfoText = () => wrapper.findByTestId('pipeline-info').text(); @@ -50,10 +50,13 @@ describe('Stages Dropdown', () => { }); it('renders dropdown with stages', () => { - expect(findStageItem(0).text()).toBe('build'); + expect(findDropdown().props('items')).toEqual([ + expect.objectContaining({ text: 'build' }), + expect.objectContaining({ text: 'test' }), + ]); }); - it('rendes selected stage', () => { + it('renders selected stage', () => { expect(findSelectedStageText()).toBe('deploy'); }); }); diff --git a/spec/frontend/jobs/components/table/job_table_app_spec.js b/spec/frontend/jobs/components/table/job_table_app_spec.js index 0e59e9ab5b6..032b83ca22b 100644 --- a/spec/frontend/jobs/components/table/job_table_app_spec.js +++ b/spec/frontend/jobs/components/table/job_table_app_spec.js @@ -60,14 +60,8 @@ describe('Job table app', () => { handler = successHandler, countHandler = countSuccessHandler, mountFn = shallowMount, - data = {}, } = {}) => { wrapper = mountFn(JobsTableApp, { - data() { - return { - ...data, - }; - }, provide: { fullPath: projectPath, }, @@ -108,34 +102,28 @@ describe('Job table app', () => { }); it('should refetch jobs query on fetchJobsByStatus event', async () => { - jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn()); - - expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); + expect(successHandler).toHaveBeenCalledTimes(1); await findTabs().vm.$emit('fetchJobsByStatus'); - expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1); + expect(successHandler).toHaveBeenCalledTimes(2); }); it('avoids refetch jobs query when scope has not changed', async () => { - jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn()); - - expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); + expect(successHandler).toHaveBeenCalledTimes(1); await findTabs().vm.$emit('fetchJobsByStatus', null); - expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); + expect(successHandler).toHaveBeenCalledTimes(1); }); it('should refetch jobs count query when the amount jobs and count do not match', async () => { - jest.spyOn(wrapper.vm.$apollo.queries.jobsCount, 'refetch').mockImplementation(jest.fn()); - - expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(0); + expect(countSuccessHandler).toHaveBeenCalledTimes(1); // after applying filter a new count is fetched findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); - expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(1); + expect(countSuccessHandler).toHaveBeenCalledTimes(2); // tab is switched to `finished`, no count await findTabs().vm.$emit('fetchJobsByStatus', ['FAILED', 'SUCCESS', 'CANCELED']); @@ -143,7 +131,7 @@ describe('Job table app', () => { // tab is switched back to `all`, the old filter count has to be overwritten with new count await findTabs().vm.$emit('fetchJobsByStatus', null); - expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(2); + expect(countSuccessHandler).toHaveBeenCalledTimes(3); }); describe('when infinite scrolling is triggered', () => { @@ -261,25 +249,21 @@ describe('Job table app', () => { it('refetches jobs query when filtering', async () => { createComponent(); - jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn()); - - expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); + expect(successHandler).toHaveBeenCalledTimes(1); await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); - expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1); + expect(successHandler).toHaveBeenCalledTimes(2); }); it('refetches jobs count query when filtering', async () => { createComponent(); - jest.spyOn(wrapper.vm.$apollo.queries.jobsCount, 'refetch').mockImplementation(jest.fn()); - - expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(0); + expect(countSuccessHandler).toHaveBeenCalledTimes(1); await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); - expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(1); + expect(countSuccessHandler).toHaveBeenCalledTimes(2); }); it('shows raw text warning when user inputs raw text', async () => { @@ -292,14 +276,14 @@ describe('Job table app', () => { createComponent(); - jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn()); - jest.spyOn(wrapper.vm.$apollo.queries.jobsCount, 'refetch').mockImplementation(jest.fn()); + expect(successHandler).toHaveBeenCalledTimes(1); + expect(countSuccessHandler).toHaveBeenCalledTimes(1); await findFilteredSearch().vm.$emit('filterJobsBySearch', ['raw text']); expect(createAlert).toHaveBeenCalledWith(expectedWarning); - expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); - expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(0); + expect(successHandler).toHaveBeenCalledTimes(1); + expect(countSuccessHandler).toHaveBeenCalledTimes(1); }); it('updates URL query string when filtering jobs by status', async () => { diff --git a/spec/frontend/layout_nav_spec.js b/spec/frontend/layout_nav_spec.js new file mode 100644 index 00000000000..30f4f7fcac1 --- /dev/null +++ b/spec/frontend/layout_nav_spec.js @@ -0,0 +1,39 @@ +import { initScrollingTabs } from '~/layout_nav'; +import { setHTMLFixture } from './__helpers__/fixtures'; + +describe('initScrollingTabs', () => { + const htmlFixture = ` + <button type='button' class='fade-left'></button> + <button type='button' class='fade-right'></button> + <div class='scrolling-tabs'></div> + `; + const findTabs = () => document.querySelector('.scrolling-tabs'); + const findScrollLeftButton = () => document.querySelector('button.fade-left'); + const findScrollRightButton = () => document.querySelector('button.fade-right'); + + beforeEach(() => { + setHTMLFixture(htmlFixture); + }); + + it('scrolls left when clicking on the left button', () => { + initScrollingTabs(); + const tabs = findTabs(); + tabs.scrollBy = jest.fn(); + const fadeLeft = findScrollLeftButton(); + + fadeLeft.click(); + + expect(tabs.scrollBy).toHaveBeenCalledWith({ left: -200, behavior: 'smooth' }); + }); + + it('scrolls right when clicking on the right button', () => { + initScrollingTabs(); + const tabs = findTabs(); + tabs.scrollBy = jest.fn(); + const fadeRight = findScrollRightButton(); + + fadeRight.click(); + + expect(tabs.scrollBy).toHaveBeenCalledWith({ left: 200, behavior: 'smooth' }); + }); +}); diff --git a/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js b/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js index 8d6ace165ab..f9e3c314d02 100644 --- a/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js +++ b/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js @@ -1,5 +1,6 @@ import { getDateWithUTC, + getCurrentUtcDate, newDateAsLocaleTime, nSecondsAfter, nSecondsBefore, @@ -84,3 +85,11 @@ describe('isToday', () => { }); }); }); + +describe('getCurrentUtcDate', () => { + useFakeDate(2022, 11, 5, 10, 10); + + it('returns the date at midnight', () => { + expect(getCurrentUtcDate()).toEqual(new Date('2022-12-05T00:00:00.000Z')); + }); +}); diff --git a/spec/frontend/lib/utils/dom_utils_spec.js b/spec/frontend/lib/utils/dom_utils_spec.js index 172f8972653..a0504458037 100644 --- a/spec/frontend/lib/utils/dom_utils_spec.js +++ b/spec/frontend/lib/utils/dom_utils_spec.js @@ -256,8 +256,12 @@ describe('DOM Utils', () => { resetHTMLFixture(); }); + it('returns the height of default element that exists', () => { + expect(getContentWrapperHeight()).toBe('0px'); + }); + it('returns the height of an element that exists', () => { - expect(getContentWrapperHeight('.content-wrapper')).toBe('0px'); + expect(getContentWrapperHeight('.content')).toBe('0px'); }); it('returns an empty string for a class that does not exist', () => { diff --git a/spec/frontend/lib/utils/listbox_helpers_spec.js b/spec/frontend/lib/utils/listbox_helpers_spec.js new file mode 100644 index 00000000000..189aad41ceb --- /dev/null +++ b/spec/frontend/lib/utils/listbox_helpers_spec.js @@ -0,0 +1,89 @@ +import { getSelectedOptionsText } from '~/lib/utils/listbox_helpers'; + +describe('getSelectedOptionsText', () => { + it('returns an empty string per default when no options are selected', () => { + const options = [ + { id: 1, text: 'first' }, + { id: 2, text: 'second' }, + ]; + const selected = []; + + expect(getSelectedOptionsText({ options, selected })).toBe(''); + }); + + it('returns the provided placeholder when no options are selected', () => { + const options = [ + { id: 1, text: 'first' }, + { id: 2, text: 'second' }, + ]; + const selected = []; + const placeholder = 'placeholder'; + + expect(getSelectedOptionsText({ options, selected, placeholder })).toBe(placeholder); + }); + + describe('maxOptionsShown is not provided', () => { + it('returns the text of the first selected option when only one option is selected', () => { + const options = [{ id: 1, text: 'first' }]; + const selected = [options[0].id]; + + expect(getSelectedOptionsText({ options, selected })).toBe('first'); + }); + + it('should also work with the value property', () => { + const options = [{ value: 1, text: 'first' }]; + const selected = [options[0].value]; + + expect(getSelectedOptionsText({ options, selected })).toBe('first'); + }); + + it.each` + options | expectedText + ${[{ id: 1, text: 'first' }, { id: 2, text: 'second' }]} | ${'first +1 more'} + ${[{ id: 1, text: 'first' }, { id: 2, text: 'second' }, { id: 3, text: 'third' }]} | ${'first +2 more'} + `( + 'returns "$expectedText" when more than one option is selected', + ({ options, expectedText }) => { + const selected = options.map(({ id }) => id); + + expect(getSelectedOptionsText({ options, selected })).toBe(expectedText); + }, + ); + }); + + describe('maxOptionsShown > 1', () => { + const options = [ + { id: 1, text: 'first' }, + { id: 2, text: 'second' }, + { id: 3, text: 'third' }, + { id: 4, text: 'fourth' }, + { id: 5, text: 'fifth' }, + ]; + + it.each` + selected | maxOptionsShown | expectedText + ${[1]} | ${2} | ${'first'} + ${[1, 2]} | ${2} | ${'first, second'} + ${[1, 2, 3]} | ${2} | ${'first, second +1 more'} + ${[1, 2, 3]} | ${3} | ${'first, second, third'} + ${[1, 2, 3, 4]} | ${3} | ${'first, second, third +1 more'} + ${[1, 2, 3, 4, 5]} | ${3} | ${'first, second, third +2 more'} + `( + 'returns "$expectedText" when "$selected.length" options are selected and maxOptionsShown is "$maxOptionsShown"', + ({ selected, maxOptionsShown, expectedText }) => { + expect(getSelectedOptionsText({ options, selected, maxOptionsShown })).toBe(expectedText); + }, + ); + }); + + it('ignores selected options that are not in the options array', () => { + const options = [ + { id: 1, text: 'first' }, + { id: 2, text: 'second' }, + ]; + const invalidOption = { id: 3, text: 'third' }; + const selected = [options[0].id, options[1].id, invalidOption.id]; + + expect(getSelectedOptionsText({ options, selected })).toBe('first +1 more'); + }); +}); diff --git a/spec/frontend/lib/utils/number_utility_spec.js b/spec/frontend/lib/utils/number_utility_spec.js index d2591cd2328..07e3e2f0422 100644 --- a/spec/frontend/lib/utils/number_utility_spec.js +++ b/spec/frontend/lib/utils/number_utility_spec.js @@ -109,8 +109,8 @@ describe('Number Utils', () => { describe('numberToHumanSize', () => { it('should return bytes', () => { - expect(numberToHumanSize(654)).toEqual('654 bytes'); - expect(numberToHumanSize(-654)).toEqual('-654 bytes'); + expect(numberToHumanSize(654)).toEqual('654 B'); + expect(numberToHumanSize(-654)).toEqual('-654 B'); }); it('should return KiB', () => { diff --git a/spec/frontend/lib/utils/secret_detection_spec.js b/spec/frontend/lib/utils/secret_detection_spec.js index 7bde6cc4a8e..3213ecf3fe1 100644 --- a/spec/frontend/lib/utils/secret_detection_spec.js +++ b/spec/frontend/lib/utils/secret_detection_spec.js @@ -26,6 +26,25 @@ describe('containsSensitiveToken', () => { 'token: glpat-cgyKc1k_AsnEpmP-5fRL', 'token: GlPat-abcdefghijklmnopqrstuvwxyz', 'token: feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ', + 'token: feed_token=glft-ABCDEFGHIJKLMNOPQRSTUVWXYZ', + 'token: feed_token=glft-a8cc74ccb0de004d09a968705ba49099229b288b3de43f26c473a9d8d7fb7693-1234', + 'https://example.com/feed?feed_token=123456789_abcdefghij', + 'glpat-1234567890 and feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ', + ]; + + it.each(sensitiveMessages)('returns true for message: %s', (message) => { + expect(containsSensitiveToken(message)).toBe(true); + }); + }); + + describe('when custom pat prefix is set', () => { + beforeEach(() => { + gon.pat_prefix = 'specpat-'; + }); + + const sensitiveMessages = [ + 'token: specpat-mGYFaXBmNLvLmrEb7xdf', + 'token: feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'https://example.com/feed?feed_token=123456789_abcdefghij', 'glpat-1234567890 and feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ', ]; diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js index 71a84d56791..8f1f6899935 100644 --- a/spec/frontend/lib/utils/text_utility_spec.js +++ b/spec/frontend/lib/utils/text_utility_spec.js @@ -430,4 +430,21 @@ describe('text_utility', () => { expect(textUtils.humanizeBranchValidationErrors([])).toEqual(''); }); }); + + describe('stripQuotes', () => { + it.each` + inputValue | outputValue + ${'"Foo Bar"'} | ${'Foo Bar'} + ${"'Foo Bar'"} | ${'Foo Bar'} + ${'FooBar'} | ${'FooBar'} + ${"Foo'Bar"} | ${"Foo'Bar"} + ${'Foo"Bar'} | ${'Foo"Bar'} + ${'Foo Bar'} | ${'Foo Bar'} + `( + 'returns string $outputValue when called with string $inputValue', + ({ inputValue, outputValue }) => { + expect(textUtils.stripQuotes(inputValue)).toBe(outputValue); + }, + ); + }); }); diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index 0799bc87c8c..0f32eaa4ca6 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -1,8 +1,11 @@ +import * as Sentry from '@sentry/browser'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import * as urlUtils from '~/lib/utils/url_utility'; import { safeUrls, unsafeUrls } from './mock_data'; +jest.mock('@sentry/browser'); + const shas = { valid: [ 'ad9be38573f9ee4c4daec22673478c2dd1d81cd8', @@ -397,6 +400,62 @@ describe('URL utility', () => { }); }); + describe('visitUrl', () => { + let originalLocation; + const mockUrl = 'http://example.com/page'; + + beforeAll(() => { + originalLocation = window.location; + + Object.defineProperty(window, 'location', { + writable: true, + value: { + assign: jest.fn(), + protocol: 'http:', + host: TEST_HOST, + }, + }); + }); + + afterAll(() => { + window.location = originalLocation; + }); + + it('does not navigate to unsafe urls', () => { + // eslint-disable-next-line no-script-url + const url = 'javascript:alert(document.domain)'; + urlUtils.visitUrl(url); + + expect(Sentry.captureException).toHaveBeenCalledWith( + new RangeError(`Only http and https protocols are allowed: ${url}`), + ); + }); + + it('navigates to a page', () => { + urlUtils.visitUrl(mockUrl); + + expect(window.location.assign).toHaveBeenCalledWith(mockUrl); + }); + + it('navigates to a new page', () => { + const otherWindow = { + location: { + assign: jest.fn(), + }, + }; + + Object.defineProperty(window, 'open', { + writable: true, + value: jest.fn().mockReturnValue(otherWindow), + }); + + urlUtils.visitUrl(mockUrl, true); + + expect(otherWindow.opener).toBe(null); + expect(otherWindow.location.assign).toHaveBeenCalledWith(mockUrl); + }); + }); + describe('updateHistory', () => { const state = { key: 'prop' }; const title = 'TITLE'; diff --git a/spec/frontend/listbox/index_spec.js b/spec/frontend/listbox/index_spec.js index 39e0332631b..ccbef1247ef 100644 --- a/spec/frontend/listbox/index_spec.js +++ b/spec/frontend/listbox/index_spec.js @@ -2,16 +2,15 @@ import { nextTick } from 'vue'; import { getAllByRole, getByTestId } from '@testing-library/dom'; import { GlCollapsibleListbox } from '@gitlab/ui'; import { createWrapper } from '@vue/test-utils'; +import htmlRedirectListbox from 'test_fixtures/listbox/redirect_listbox.html'; import { initListbox, parseAttributes } from '~/listbox'; -import { getFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; jest.mock('~/lib/utils/url_utility'); -const fixture = getFixture('listbox/redirect_listbox.html'); - const parsedAttributes = (() => { const div = document.createElement('div'); - div.innerHTML = fixture; + div.innerHTML = htmlRedirectListbox; return parseAttributes(div.firstChild); })(); @@ -46,7 +45,7 @@ describe('initListbox', () => { const findSelectedItems = () => getAllByRole(document.body, 'option', { selected: true }); beforeEach(async () => { - setHTMLFixture(fixture); + setHTMLFixture(htmlRedirectListbox); onChangeSpy = jest.fn(); setup(document.querySelector('.js-redirect-listbox'), { onChange: onChangeSpy }); diff --git a/spec/frontend/listbox/redirect_behavior_spec.js b/spec/frontend/listbox/redirect_behavior_spec.js index c2479e71e4a..eb3b6900a25 100644 --- a/spec/frontend/listbox/redirect_behavior_spec.js +++ b/spec/frontend/listbox/redirect_behavior_spec.js @@ -1,22 +1,21 @@ +import htmlRedirectListbox from 'test_fixtures/listbox/redirect_listbox.html'; import { initListbox } from '~/listbox'; import { initRedirectListboxBehavior } from '~/listbox/redirect_behavior'; import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated -import { getFixture, setHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture } from 'helpers/fixtures'; jest.mock('~/lib/utils/url_utility'); jest.mock('~/listbox', () => ({ initListbox: jest.fn().mockReturnValue({ foo: true }), })); -const fixture = getFixture('listbox/redirect_listbox.html'); - describe('initRedirectListboxBehavior', () => { let instances; beforeEach(() => { setHTMLFixture(` - ${fixture} - ${fixture} + ${htmlRedirectListbox} + ${htmlRedirectListbox} `); instances = initRedirectListboxBehavior(); diff --git a/spec/frontend/members/components/action_dropdowns/leave_group_dropdown_item_spec.js b/spec/frontend/members/components/action_dropdowns/leave_group_dropdown_item_spec.js index 679ad7897ed..4fb5a2fb99d 100644 --- a/spec/frontend/members/components/action_dropdowns/leave_group_dropdown_item_spec.js +++ b/spec/frontend/members/components/action_dropdowns/leave_group_dropdown_item_spec.js @@ -1,4 +1,4 @@ -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import LeaveGroupDropdownItem from '~/members/components/action_dropdowns/leave_group_dropdown_item.vue'; @@ -26,7 +26,7 @@ describe('LeaveGroupDropdownItem', () => { }); }; - const findDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem); beforeEach(() => { createComponent(); diff --git a/spec/frontend/members/components/action_dropdowns/remove_member_dropdown_item_spec.js b/spec/frontend/members/components/action_dropdowns/remove_member_dropdown_item_spec.js index 125f1f8fff3..2f0d4b8e655 100644 --- a/spec/frontend/members/components/action_dropdowns/remove_member_dropdown_item_spec.js +++ b/spec/frontend/members/components/action_dropdowns/remove_member_dropdown_item_spec.js @@ -1,4 +1,4 @@ -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; @@ -52,7 +52,7 @@ describe('RemoveMemberDropdownItem', () => { }); }; - const findDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem); beforeEach(() => { createComponent(); @@ -63,7 +63,7 @@ describe('RemoveMemberDropdownItem', () => { }); it('calls Vuex action to show `remove member` modal when clicked', () => { - findDropdownItem().vm.$emit('click'); + findDropdownItem().vm.$emit('action'); expect(actions.showRemoveMemberModal).toHaveBeenCalledWith(expect.any(Object), { ...modalData, diff --git a/spec/frontend/members/components/table/role_dropdown_spec.js b/spec/frontend/members/components/table/role_dropdown_spec.js index 1045e3f9849..1285404fd9f 100644 --- a/spec/frontend/members/components/table/role_dropdown_spec.js +++ b/spec/frontend/members/components/table/role_dropdown_spec.js @@ -1,8 +1,7 @@ -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import * as Sentry from '@sentry/browser'; -import { within } from '@testing-library/dom'; -import { mount, createWrapper } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import waitForPromises from 'helpers/wait_for_promises'; @@ -55,59 +54,50 @@ describe('RoleDropdown', () => { }); }; - const getDropdownMenu = () => within(wrapper.element).getByRole('menu'); - const getByTextInDropdownMenu = (text, options = {}) => - createWrapper(within(getDropdownMenu()).getByText(text, options)); - const getDropdownItemByText = (text) => - createWrapper( - within(getDropdownMenu()) - .getByText(text, { selector: '[role="menuitem"] p' }) - .closest('[role="menuitem"]'), - ); - const getCheckedDropdownItem = () => - wrapper - .findAllComponents(GlDropdownItem) - .wrappers.find((dropdownItemWrapper) => dropdownItemWrapper.props('isChecked')); - - const findDropdownToggle = () => wrapper.find('button[aria-haspopup="menu"]'); - const findDropdown = () => wrapper.findComponent(GlDropdown); + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const findListboxItems = () => wrapper.findAllComponents(GlListboxItem); + const findListboxItemByText = (text) => + findListboxItems().wrappers.find((item) => item.text() === text); beforeEach(() => { gon.features = { showOverageOnRolePromotion: true }; }); - describe('when dropdown is open', () => { + it('has correct header text props', () => { + createComponent(); + expect(findListbox().props('headerText')).toBe('Change role'); + }); + + it('has items prop with all valid roles', () => { + createComponent(); + const roles = findListbox() + .props('items') + .map((item) => item.text); + expect(roles).toEqual(Object.keys(member.validRoles)); + }); + + describe('when listbox is open', () => { beforeEach(async () => { guestOverageConfirmAction.mockReturnValue(true); createComponent(); - await findDropdownToggle().trigger('click'); - }); - - it('renders all valid roles', () => { - Object.keys(member.validRoles).forEach((role) => { - expect(getDropdownItemByText(role).exists()).toBe(true); - }); - }); - - it('renders dropdown header', () => { - expect(getByTextInDropdownMenu('Change role').exists()).toBe(true); + await findListbox().vm.$emit('click'); }); it('sets dropdown toggle and checks selected role', () => { - expect(findDropdownToggle().text()).toBe('Owner'); - expect(getCheckedDropdownItem().text()).toBe('Owner'); + expect(findListbox().props('toggleText')).toBe('Owner'); + expect(findListbox().find('[aria-selected=true]').text()).toBe('Owner'); }); describe('when dropdown item is selected', () => { it('does nothing if the item selected was already selected', async () => { - await getDropdownItemByText('Owner').trigger('click'); + await findListboxItemByText('Owner').trigger('click'); expect(actions.updateMemberRole).not.toHaveBeenCalled(); }); it('calls `updateMemberRole` Vuex action', async () => { - await getDropdownItemByText('Developer').trigger('click'); + await findListboxItemByText('Developer').trigger('click'); expect(actions.updateMemberRole).toHaveBeenCalledWith(expect.any(Object), { memberId: member.id, @@ -117,7 +107,7 @@ describe('RoleDropdown', () => { describe('when updateMemberRole is successful', () => { it('displays toast', async () => { - await getDropdownItemByText('Developer').trigger('click'); + await findListboxItemByText('Developer').trigger('click'); await nextTick(); @@ -125,21 +115,21 @@ describe('RoleDropdown', () => { }); it('puts dropdown in loading state while waiting for `updateMemberRole` to resolve', async () => { - await getDropdownItemByText('Developer').trigger('click'); + await findListboxItemByText('Developer').trigger('click'); - expect(findDropdown().props('loading')).toBe(true); + expect(findListbox().props('loading')).toBe(true); }); it('enables dropdown after `updateMemberRole` resolves', async () => { - await getDropdownItemByText('Developer').trigger('click'); + await findListboxItemByText('Developer').trigger('click'); await waitForPromises(); - expect(findDropdown().props('disabled')).toBe(false); + expect(findListbox().props('disabled')).toBe(false); }); it('does not log error to Sentry', async () => { - await getDropdownItemByText('Developer').trigger('click'); + await findListboxItemByText('Developer').trigger('click'); await waitForPromises(); @@ -155,7 +145,7 @@ describe('RoleDropdown', () => { }); it('does not display toast', async () => { - await getDropdownItemByText('Developer').trigger('click'); + await findListboxItemByText('Developer').trigger('click'); await nextTick(); @@ -163,21 +153,21 @@ describe('RoleDropdown', () => { }); it('puts dropdown in loading state while waiting for `updateMemberRole` to resolve', async () => { - await getDropdownItemByText('Developer').trigger('click'); + await findListboxItemByText('Developer').trigger('click'); - expect(findDropdown().props('loading')).toBe(true); + expect(findListbox().props('loading')).toBe(true); }); it('enables dropdown after `updateMemberRole` resolves', async () => { - await getDropdownItemByText('Developer').trigger('click'); + await findListboxItemByText('Developer').trigger('click'); await waitForPromises(); - expect(findDropdown().props('disabled')).toBe(false); + expect(findListbox().props('disabled')).toBe(false); }); it('logs error to Sentry', async () => { - await getDropdownItemByText('Developer').trigger('click'); + await findListboxItemByText('Developer').trigger('click'); await waitForPromises(); @@ -190,7 +180,7 @@ describe('RoleDropdown', () => { it("sets initial dropdown toggle value to member's role", () => { createComponent(); - expect(findDropdownToggle().text()).toBe('Owner'); + expect(findListbox().props('toggleText')).toBe('Owner'); }); it('sets the dropdown alignment to right on mobile', async () => { @@ -199,7 +189,7 @@ describe('RoleDropdown', () => { await nextTick(); - expect(findDropdown().props('right')).toBe(true); + expect(findListbox().props('placement')).toBe('right'); }); it('sets the dropdown alignment to left on desktop', async () => { @@ -208,7 +198,7 @@ describe('RoleDropdown', () => { await nextTick(); - expect(findDropdown().props('right')).toBe(false); + expect(findListbox().props('placement')).toBe('left'); }); describe('guestOverageConfirmAction', () => { @@ -219,7 +209,7 @@ describe('RoleDropdown', () => { beforeEach(() => { createComponent(); - findDropdownToggle().trigger('click'); + findListbox().vm.$emit('click'); }); afterEach(() => { @@ -230,7 +220,7 @@ describe('RoleDropdown', () => { beforeEach(() => { mockConfirmAction({ confirmed: true }); - getDropdownItemByText('Reporter').trigger('click'); + findListboxItemByText('Reporter').trigger('click'); }); it('calls updateMemberRole', () => { @@ -242,7 +232,7 @@ describe('RoleDropdown', () => { beforeEach(() => { mockConfirmAction({ confirmed: false }); - getDropdownItemByText('Reporter').trigger('click'); + findListboxItemByText('Reporter').trigger('click'); }); it('does not call updateMemberRole', () => { diff --git a/spec/frontend/merge_request_spec.js b/spec/frontend/merge_request_spec.js index 6f80f8e6aab..a119ca8272e 100644 --- a/spec/frontend/merge_request_spec.js +++ b/spec/frontend/merge_request_spec.js @@ -1,7 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import htmlMergeRequestWithTaskList from 'test_fixtures/merge_requests/merge_request_with_task_list.html'; -import htmlMergeRequestOfCurrentUser from 'test_fixtures/merge_requests/merge_request_of_current_user.html'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'spec/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; @@ -110,20 +109,4 @@ describe('MergeRequest', () => { }); }); }); - - describe('hideCloseButton', () => { - describe('merge request of current_user', () => { - beforeEach(() => { - setHTMLFixture(htmlMergeRequestOfCurrentUser); - test.el = document.querySelector('.js-issuable-actions'); - MergeRequest.hideCloseButton(); - }); - - it('hides the close button', () => { - const smallCloseItem = test.el.querySelector('.js-close-item'); - - expect(smallCloseItem).toHaveClass('hidden'); - }); - }); - }); }); diff --git a/spec/frontend/merge_requests/components/compare_dropdown_spec.js b/spec/frontend/merge_requests/components/compare_dropdown_spec.js index ce03b80bdcb..bd8b16c8089 100644 --- a/spec/frontend/merge_requests/components/compare_dropdown_spec.js +++ b/spec/frontend/merge_requests/components/compare_dropdown_spec.js @@ -62,10 +62,10 @@ describe('Merge requests compare dropdown component', () => { wrapper.find('[data-testid="base-dropdown-toggle"]').trigger('click'); await waitForPromises(); - - expect(wrapper.findAll('li').length).toBe(2); - expect(wrapper.findAll('li').at(0).text()).toBe('root/gitlab-test'); - expect(wrapper.findAll('li').at(1).text()).toBe('gitlab-org/gitlab-test'); + const items = wrapper.findAll('[role="option"]'); + expect(items.length).toBe(2); + expect(items.at(0).text()).toBe('root/gitlab-test'); + expect(items.at(1).text()).toBe('gitlab-org/gitlab-test'); }); it('searches projects', async () => { @@ -98,6 +98,6 @@ describe('Merge requests compare dropdown component', () => { await waitForPromises(); - expect(wrapper.findAll('li').length).toBe(1); + expect(wrapper.findAll('[role="option"]').length).toBe(1); }); }); diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js index 8a39c5de2b3..53dbd796d85 100644 --- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js +++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js @@ -1,5 +1,4 @@ import { shallowMount } from '@vue/test-utils'; -import { GlLink } from '@gitlab/ui'; import DetailRow from '~/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue'; describe('CandidateDetailRow', () => { @@ -9,14 +8,14 @@ describe('CandidateDetailRow', () => { let wrapper; - const createWrapper = (href = '') => { + const createWrapper = ({ slots = {} } = {}) => { wrapper = shallowMount(DetailRow, { - propsData: { sectionLabel: 'Section', label: 'Item', text: 'Text', href }, + propsData: { sectionLabel: 'Section', label: 'Item' }, + slots, }); }; const findCellAt = (index) => wrapper.findAll('td').at(index); - const findLink = () => findCellAt(ROW_VALUE_CELL).findComponent(GlLink); beforeEach(() => createWrapper()); @@ -28,22 +27,15 @@ describe('CandidateDetailRow', () => { expect(findCellAt(ROW_LABEL_CELL).text()).toBe('Item'); }); - describe('No href', () => { - it('Renders text', () => { - expect(findCellAt(ROW_VALUE_CELL).text()).toBe('Text'); - }); - - it('Does not render as link', () => { - expect(findLink().exists()).toBe(false); - }); + it('renders nothing on item cell', () => { + expect(findCellAt(ROW_VALUE_CELL).text()).toBe(''); }); - describe('With href', () => { - beforeEach(() => createWrapper('LINK')); + describe('With slot', () => { + beforeEach(() => createWrapper({ slots: { default: 'Some content' } })); - it('Renders link', () => { - expect(findLink().attributes().href).toBe('LINK'); - expect(findLink().text()).toBe('Text'); + it('Renders slot', () => { + expect(findCellAt(ROW_VALUE_CELL).text()).toBe('Some content'); }); }); }); diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js index 9d1c22faa8f..0b3b780cb3f 100644 --- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js +++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { GlAvatarLabeled, GlLink } from '@gitlab/ui'; import MlCandidatesShow from '~/ml/experiment_tracking/routes/candidates/show'; import DetailRow from '~/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue'; import { TITLE_LABEL } from '~/ml/experiment_tracking/routes/candidates/show/translations'; @@ -9,6 +10,7 @@ import { newCandidate } from './mock_data'; describe('MlCandidatesShow', () => { let wrapper; const CANDIDATE = newCandidate(); + const USER_ROW = 6; const createWrapper = (createCandidate = () => CANDIDATE) => { wrapper = shallowMount(MlCandidatesShow, { @@ -19,8 +21,12 @@ describe('MlCandidatesShow', () => { const findDeleteButton = () => wrapper.findComponent(DeleteButton); const findHeader = () => wrapper.findComponent(ModelExperimentsHeader); const findNthDetailRow = (index) => wrapper.findAllComponents(DetailRow).at(index); + const findLinkInNthDetailRow = (index) => findNthDetailRow(index).findComponent(GlLink); const findSectionLabel = (label) => wrapper.find(`[sectionLabel='${label}']`); const findLabel = (label) => wrapper.find(`[label='${label}']`); + const findCiUserDetailRow = () => findNthDetailRow(USER_ROW); + const findCiUserAvatar = () => findCiUserDetailRow().findComponent(GlAvatarLabeled); + const findCiUserAvatarNameLink = () => findCiUserAvatar().findComponent(GlLink); describe('Header', () => { beforeEach(() => createWrapper()); @@ -42,28 +48,64 @@ describe('MlCandidatesShow', () => { describe('All info available', () => { beforeEach(() => createWrapper()); + const mrText = `!${CANDIDATE.info.ci_job.merge_request.iid} ${CANDIDATE.info.ci_job.merge_request.title}`; const expectedTable = [ - ['Info', 'ID', CANDIDATE.info.iid, ''], - ['', 'MLflow run ID', CANDIDATE.info.eid, ''], - ['', 'Status', CANDIDATE.info.status, ''], - ['', 'Experiment', CANDIDATE.info.experiment_name, CANDIDATE.info.path_to_experiment], - ['', 'Artifacts', 'Artifacts', CANDIDATE.info.path_to_artifact], - ['Parameters', CANDIDATE.params[0].name, CANDIDATE.params[0].value, ''], - ['', CANDIDATE.params[1].name, CANDIDATE.params[1].value, ''], - ['Metrics', CANDIDATE.metrics[0].name, CANDIDATE.metrics[0].value, ''], - ['', CANDIDATE.metrics[1].name, CANDIDATE.metrics[1].value, ''], - ['Metadata', CANDIDATE.metadata[0].name, CANDIDATE.metadata[0].value, ''], - ['', CANDIDATE.metadata[1].name, CANDIDATE.metadata[1].value, ''], + ['Info', 'ID', CANDIDATE.info.iid], + ['', 'MLflow run ID', CANDIDATE.info.eid], + ['', 'Status', CANDIDATE.info.status], + ['', 'Experiment', CANDIDATE.info.experiment_name], + ['', 'Artifacts', 'Artifacts'], + ['CI', 'Job', CANDIDATE.info.ci_job.name], + ['', 'Triggered by', 'CI User'], + ['', 'Merge request', mrText], + ['Parameters', CANDIDATE.params[0].name, CANDIDATE.params[0].value], + ['', CANDIDATE.params[1].name, CANDIDATE.params[1].value], + ['Metrics', CANDIDATE.metrics[0].name, CANDIDATE.metrics[0].value], + ['', CANDIDATE.metrics[1].name, CANDIDATE.metrics[1].value], + ['Metadata', CANDIDATE.metadata[0].name, CANDIDATE.metadata[0].value], + ['', CANDIDATE.metadata[1].name, CANDIDATE.metadata[1].value], ].map((row, index) => [index, ...row]); it.each(expectedTable)( 'row %s is created correctly', - (index, sectionLabel, label, text, href) => { - const row = findNthDetailRow(index); + (rowIndex, sectionLabel, label, text) => { + const row = findNthDetailRow(rowIndex); - expect(row.props()).toMatchObject({ sectionLabel, label, text, href }); + expect(row.props()).toMatchObject({ sectionLabel, label }); + expect(row.text()).toBe(text); }, ); + + describe('Table links', () => { + const linkRows = [ + [3, CANDIDATE.info.path_to_experiment], + [4, CANDIDATE.info.path_to_artifact], + [5, CANDIDATE.info.ci_job.path], + [7, CANDIDATE.info.ci_job.merge_request.path], + ]; + + it.each(linkRows)('row %s is created correctly', (rowIndex, href) => { + expect(findLinkInNthDetailRow(rowIndex).attributes().href).toBe(href); + }); + }); + + describe('CI triggerer', () => { + it('renders user row', () => { + const avatar = findCiUserAvatar(); + expect(avatar.props()).toMatchObject({ + label: '', + }); + expect(avatar.attributes().src).toEqual('/img.png'); + }); + + it('renders user name', () => { + const nameLink = findCiUserAvatarNameLink(); + + expect(nameLink.attributes().href).toEqual('path/to/ci/user'); + expect(nameLink.text()).toEqual('CI User'); + }); + }); + it('does not render params', () => { expect(findSectionLabel('Parameters').exists()).toBe(true); }); @@ -75,6 +117,9 @@ describe('MlCandidatesShow', () => { expect(findSectionLabel('Parameters').exists()).toBe(true); expect(findSectionLabel('Metadata').exists()).toBe(true); expect(findSectionLabel('Metrics').exists()).toBe(true); + expect(findSectionLabel('CI').exists()).toBe(true); + expect(findLabel('Merge request').exists()).toBe(true); + expect(findLabel('Triggered by').exists()).toBe(true); }); }); @@ -99,6 +144,7 @@ describe('MlCandidatesShow', () => { delete candidate.params; delete candidate.metrics; delete candidate.metadata; + delete candidate.info.ci_job; return candidate; }), ); @@ -114,6 +160,29 @@ describe('MlCandidatesShow', () => { it('does not render metrics', () => { expect(findSectionLabel('Metrics').exists()).toBe(false); }); + + it('does not render CI info', () => { + expect(findSectionLabel('CI').exists()).toBe(false); + }); + }); + + describe('Has CI, but no user or mr', () => { + beforeEach(() => + createWrapper(() => { + const candidate = newCandidate(); + delete candidate.info.ci_job.user; + delete candidate.info.ci_job.merge_request; + return candidate; + }), + ); + + it('does not render MR info', () => { + expect(findLabel('Merge request').exists()).toBe(false); + }); + + it('does not render CI user info', () => { + expect(findLabel('Triggered by').exists()).toBe(false); + }); }); }); }); diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js index cad2c03fc93..3fbcf122997 100644 --- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js +++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js @@ -19,5 +19,20 @@ export const newCandidate = () => ({ path_to_experiment: 'path/to/experiment', status: 'SUCCESS', path: 'path_to_candidate', + ci_job: { + name: 'test', + path: 'path/to/job', + merge_request: { + path: 'path/to/mr', + iid: 1, + title: 'Some MR', + }, + user: { + path: 'path/to/ci/user', + name: 'CI User', + username: 'ciuser', + avatar: '/img.png', + }, + }, }, }); diff --git a/spec/frontend/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index_spec.js b/spec/frontend/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index_spec.js index 0c83be1822e..c1158fd2ca4 100644 --- a/spec/frontend/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index_spec.js +++ b/spec/frontend/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index_spec.js @@ -46,8 +46,8 @@ describe('MlExperimentsIndex', () => { expect(findPagination().exists()).toBe(false); }); - it('does not render header', () => { - expect(findTitleHeader().exists()).toBe(false); + it('renders header', () => { + expect(findTitleHeader().exists()).toBe(true); }); }); diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index 1f995965003..d7f1d4873bb 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -6,7 +6,6 @@ import { TEST_HOST } from 'helpers/test_constants'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; -import { ESC_KEY } from '~/lib/utils/keys'; import { objectToQuery } from '~/lib/utils/url_utility'; import Dashboard from '~/monitoring/components/dashboard.vue'; import DashboardHeader from '~/monitoring/components/dashboard_header.vue'; @@ -479,8 +478,6 @@ describe('Dashboard', () => { let group; let panel; - const mockKeyup = (key) => window.dispatchEvent(new KeyboardEvent('keyup', { key })); - const MockPanel = { template: `<div><slot name="top-left"/></div>`, }; @@ -531,14 +528,6 @@ describe('Dashboard', () => { undefined, ); }); - - it('restores dashboard from full screen by typing the Escape key', () => { - mockKeyup(ESC_KEY); - expect(store.dispatch).toHaveBeenCalledWith( - `monitoringDashboard/clearExpandedPanel`, - undefined, - ); - }); }); }); diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 70f25afc5ba..6c774a1ecd0 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -19,6 +19,7 @@ import * as constants from '~/notes/constants'; import eventHub from '~/notes/event_hub'; import { COMMENT_FORM } from '~/notes/i18n'; import notesModule from '~/notes/stores/modules'; +import { sprintf } from '~/locale'; import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data'; jest.mock('autosize'); @@ -195,6 +196,35 @@ describe('issue_comment_form component', () => { }, ); + describe('if response contains validation errors', () => { + beforeEach(() => { + store = createStore({ + actions: { + saveNote: jest.fn().mockRejectedValue({ + response: { + status: HTTP_STATUS_UNPROCESSABLE_ENTITY, + data: { errors: 'error 1 and error 2' }, + }, + }), + }, + }); + + mountComponent({ mountFunction: mount, initialData: { note: 'invalid note' } }); + + clickCommentButton(); + }); + + it('renders an error message', () => { + const errorAlerts = findErrorAlerts(); + + expect(errorAlerts.length).toBe(1); + + expect(errorAlerts[0].text()).toBe( + sprintf(COMMENT_FORM.error, { reason: 'error 1 and error 2' }), + ); + }); + }); + it('should remove the correct error from the list when it is dismissed', async () => { const commandErrors = ['1', '2', '3']; store = createStore({ diff --git a/spec/frontend/notes/components/diff_with_note_spec.js b/spec/frontend/notes/components/diff_with_note_spec.js index c352265654b..508f2ced4c4 100644 --- a/spec/frontend/notes/components/diff_with_note_spec.js +++ b/spec/frontend/notes/components/diff_with_note_spec.js @@ -3,6 +3,7 @@ import discussionFixture from 'test_fixtures/merge_requests/diff_discussion.json import imageDiscussionFixture from 'test_fixtures/merge_requests/image_diff_discussion.json'; import { createStore } from '~/mr_notes/stores'; import DiffWithNote from '~/notes/components/diff_with_note.vue'; +import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; describe('diff_with_note', () => { let store; @@ -20,6 +21,8 @@ describe('diff_with_note', () => { }, }; + const findDiffViewer = () => wrapper.findComponent(DiffViewer); + beforeEach(() => { store = createStore(); store.replaceState({ @@ -85,4 +88,43 @@ describe('diff_with_note', () => { expect(selectors.diffTable.exists()).toBe(false); }); }); + + describe('legacy diff note', () => { + const mockCommitId = 'abc123'; + + beforeEach(() => { + const diffDiscussion = { + ...discussionFixture[0], + commit_id: mockCommitId, + diff_file: { + ...discussionFixture[0].diff_file, + diff_refs: null, + viewer: { + ...discussionFixture[0].diff_file.viewer, + name: 'no_preview', + }, + }, + }; + + wrapper = shallowMount(DiffWithNote, { + propsData: { + discussion: diffDiscussion, + }, + store, + }); + }); + + it('shows file diff', () => { + expect(selectors.diffTable.exists()).toBe(false); + }); + + it('uses "no_preview" diff mode', () => { + expect(findDiffViewer().props('diffMode')).toBe('no_preview'); + }); + + it('falls back to discussion.commit_id for baseSha and headSha', () => { + expect(findDiffViewer().props('oldSha')).toBe(mockCommitId); + expect(findDiffViewer().props('newSha')).toBe(mockCommitId); + }); + }); }); diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js index 879bada4aee..fc50afcb01d 100644 --- a/spec/frontend/notes/components/note_actions_spec.js +++ b/spec/frontend/notes/components/note_actions_spec.js @@ -175,11 +175,6 @@ describe('noteActions', () => { const { resolveButton } = wrapper.vm.$refs; expect(resolveButton.$el.getAttribute('title')).toBe(`Resolved by ${complexUnescapedName}`); }); - - it('closes the dropdown', () => { - findReportAbuseButton().vm.$emit('action'); - expect(mockCloseDropdown).toHaveBeenCalled(); - }); }); }); diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js index ac0c037fe36..36f89e479e6 100644 --- a/spec/frontend/notes/components/noteable_discussion_spec.js +++ b/spec/frontend/notes/components/noteable_discussion_spec.js @@ -1,14 +1,24 @@ import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; +import MockAdapter from 'axios-mock-adapter'; import discussionWithTwoUnresolvedNotes from 'test_fixtures/merge_requests/resolved_diff_discussion.json'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { trimText } from 'helpers/text_helper'; +import { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; import { getDiffFileMock } from 'jest/diffs/mock_data/diff_file'; 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'; import NoteForm from '~/notes/components/note_form.vue'; import NoteableDiscussion from '~/notes/components/noteable_discussion.vue'; -import createStore from '~/notes/stores'; +import { COMMENT_FORM } from '~/notes/i18n'; +import notesModule from '~/notes/stores/modules'; +import { sprintf } from '~/locale'; +import { createAlert } from '~/alert'; + import { noteableDataMock, discussionMock, @@ -17,22 +27,46 @@ import { userDataMock, } from '../mock_data'; +Vue.use(Vuex); + jest.mock('~/behaviors/markdown/render_gfm'); +jest.mock('~/alert'); describe('noteable_discussion component', () => { let store; let wrapper; + let axiosMock; - beforeEach(() => { - window.mrTabs = {}; - store = createStore(); + const createStore = ({ saveNoteMock = jest.fn() } = {}) => { + const baseModule = notesModule(); + + return new Vuex.Store({ + ...baseModule, + actions: { + ...baseModule.actions, + saveNote: saveNoteMock, + }, + }); + }; + + const createComponent = ({ storeMock = createStore(), discussion = discussionMock } = {}) => { + store = storeMock; store.dispatch('setNoteableData', noteableDataMock); store.dispatch('setNotesData', notesDataMock); - wrapper = mount(NoteableDiscussion, { + wrapper = mountExtended(NoteableDiscussion, { store, - propsData: { discussion: discussionMock }, + propsData: { discussion }, }); + }; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + createComponent(); + }); + + afterEach(() => { + axiosMock.restore(); }); it('should not render thread header for non diff threads', () => { @@ -126,6 +160,40 @@ describe('noteable_discussion component', () => { false, ); }); + + it('should add `internal-note` class when the discussion is internal', async () => { + const softCopyInternalNotes = [...discussionMock.notes]; + const mockInternalNotes = softCopyInternalNotes.splice(0, 2); + mockInternalNotes[0].internal = true; + + const mockDiscussion = { + ...discussionMock, + notes: [...mockInternalNotes], + }; + wrapper.setProps({ discussion: mockDiscussion }); + await nextTick(); + + const replyWrapper = wrapper.find('[data-testid="reply-wrapper"]'); + expect(replyWrapper.exists()).toBe(true); + expect(replyWrapper.classes('internal-note')).toBe(true); + }); + + it('should add `public-note` class when the discussion is not internal', async () => { + const softCopyInternalNotes = [...discussionMock.notes]; + const mockPublicNotes = softCopyInternalNotes.splice(0, 2); + mockPublicNotes[0].internal = false; + + const mockDiscussion = { + ...discussionMock, + notes: [...mockPublicNotes], + }; + wrapper.setProps({ discussion: mockDiscussion }); + await nextTick(); + + const replyWrapper = wrapper.find('[data-testid="reply-wrapper"]'); + expect(replyWrapper.exists()).toBe(true); + expect(replyWrapper.classes('public-note')).toBe(true); + }); }); describe('for resolved thread', () => { @@ -161,6 +229,39 @@ describe('noteable_discussion component', () => { }); }); + describe('save reply', () => { + describe('if response contains validation errors', () => { + beforeEach(async () => { + const storeMock = createStore({ + saveNoteMock: jest.fn().mockRejectedValue({ + response: { + status: HTTP_STATUS_UNPROCESSABLE_ENTITY, + data: { errors: 'error 1 and error 2' }, + }, + }), + }); + + createComponent({ storeMock }); + + wrapper.findComponent(ReplyPlaceholder).vm.$emit('focus'); + await nextTick(); + + wrapper + .findComponent(NoteForm) + .vm.$emit('handleFormUpdate', 'invalid note', null, () => {}); + + await waitForPromises(); + }); + + it('renders an error message', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: sprintf(COMMENT_FORM.error, { reason: 'error 1 and error 2' }), + parent: wrapper.vm.$el, + }); + }); + }); + }); + describe('signout widget', () => { describe('user is logged in', () => { beforeEach(() => { diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js index 5d81a7a9a0f..d50fb130a69 100644 --- a/spec/frontend/notes/components/noteable_note_spec.js +++ b/spec/frontend/notes/components/noteable_note_spec.js @@ -1,6 +1,7 @@ import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { GlAvatar } from '@gitlab/ui'; +import { clone } from 'lodash'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import DiffsModule from '~/diffs/store/modules'; @@ -10,9 +11,13 @@ import NoteHeader from '~/notes/components/note_header.vue'; import issueNote from '~/notes/components/noteable_note.vue'; import NotesModule from '~/notes/stores/modules'; import { NOTEABLE_TYPE_MAPPING } from '~/notes/constants'; +import { createAlert } from '~/alert'; +import { UPDATE_COMMENT_FORM } from '~/notes/i18n'; +import { sprintf } from '~/locale'; import { noteableDataMock, notesDataMock, note } from '../mock_data'; Vue.use(Vuex); +jest.mock('~/alert'); const singleLineNotePosition = { line_range: { @@ -54,10 +59,13 @@ describe('issue_note', () => { store.dispatch('setNoteableData', noteableDataMock); store.dispatch('setNotesData', notesDataMock); + // the component overwrites the `note` prop with every action, hence create a copy + const noteCopy = clone(props.note || note); + wrapper = mountExtended(issueNote, { store, propsData: { - note, + note: noteCopy, ...props, }, stubs: [ @@ -252,7 +260,7 @@ describe('issue_note', () => { }); it('should render issue body', () => { - expect(findNoteBody().props().note).toBe(note); + expect(findNoteBody().props().note).toMatchObject(note); expect(findNoteBody().props().line).toBe(null); expect(findNoteBody().props().canEdit).toBe(note.current_user.can_edit); expect(findNoteBody().props().isEditing).toBe(false); @@ -297,7 +305,7 @@ describe('issue_note', () => { }); it('does not have internal note class for external notes', () => { - createWrapper({ note }); + createWrapper(); expect(wrapper.classes()).not.toContain('internal-note'); }); @@ -327,7 +335,6 @@ describe('issue_note', () => { }); await nextTick(); - expect(findNoteBody().props().note.note_html).toBe(`<p dir="auto">${updatedText}</p>\n`); findNoteBody().vm.$emit('cancelForm', {}); @@ -340,7 +347,7 @@ describe('issue_note', () => { describe('formUpdateHandler', () => { const updateNote = jest.fn(); const params = { - noteText: '', + noteText: 'updated note text', parentElement: null, callback: jest.fn(), resolveDiscussion: false, @@ -359,28 +366,38 @@ describe('issue_note', () => { }); }; + beforeEach(() => { + createWrapper(); + updateActions(); + }); + afterEach(() => updateNote.mockReset()); it('responds to handleFormUpdate', () => { - createWrapper(); - updateActions(); findNoteBody().vm.$emit('handleFormUpdate', params); + expect(wrapper.emitted('handleUpdateNote')).toHaveLength(1); }); + it('updates note content', async () => { + findNoteBody().vm.$emit('handleFormUpdate', params); + + await nextTick(); + + expect(findNoteBody().props().note.note_html).toBe(`<p dir="auto">${params.noteText}</p>\n`); + expect(findNoteBody().props('isEditing')).toBe(false); + }); + it('should not update note with sensitive token', () => { const sensitiveMessage = 'token: glpat-1234567890abcdefghij'; - - createWrapper(); - updateActions(); findNoteBody().vm.$emit('handleFormUpdate', { ...params, noteText: sensitiveMessage }); + expect(updateNote).not.toHaveBeenCalled(); }); it('does not stringify empty position', () => { - createWrapper(); - updateActions(); findNoteBody().vm.$emit('handleFormUpdate', params); + expect(updateNote.mock.calls[0][1].note.note.position).toBeUndefined(); }); @@ -388,10 +405,35 @@ describe('issue_note', () => { const position = { test: true }; const expectation = JSON.stringify(position); createWrapper({ note: { ...note, position } }); + updateActions(); findNoteBody().vm.$emit('handleFormUpdate', params); + expect(updateNote.mock.calls[0][1].note.note.position).toBe(expectation); }); + + describe('when updateNote returns errors', () => { + beforeEach(() => { + updateNote.mockRejectedValue({ + response: { status: 422, data: { errors: 'error 1 and error 2' } }, + }); + }); + + beforeEach(() => { + findNoteBody().vm.$emit('handleFormUpdate', { ...params, noteText: 'invalid note' }); + }); + + it('renders error message and restores content of updated note', async () => { + await waitForPromises(); + expect(createAlert).toHaveBeenCalledWith({ + message: sprintf(UPDATE_COMMENT_FORM.error, { reason: 'error 1 and error 2' }, false), + parent: wrapper.vm.$el, + }); + + expect(findNoteBody().props('isEditing')).toBe(true); + expect(findNoteBody().props().note.note_html).toBe(note.note_html); + }); + }); }); describe('diffFile', () => { diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js index cdfe8b02b48..0f70b264326 100644 --- a/spec/frontend/notes/components/notes_app_spec.js +++ b/spec/frontend/notes/components/notes_app_spec.js @@ -334,14 +334,12 @@ describe('note_app', () => { }); it('should listen hashchange event', () => { - const notesApp = wrapper.findComponent(NotesApp); const hash = 'some dummy hash'; jest.spyOn(urlUtility, 'getLocationHash').mockReturnValue(hash); - const setTargetNoteHash = jest.spyOn(notesApp.vm, 'setTargetNoteHash'); - + const dispatchMock = jest.spyOn(store, 'dispatch'); window.dispatchEvent(new Event('hashchange'), hash); - expect(setTargetNoteHash).toHaveBeenCalled(); + expect(dispatchMock).toHaveBeenCalledWith('setTargetNoteHash', 'some dummy hash'); }); }); diff --git a/spec/frontend/notes/mixins/discussion_navigation_spec.js b/spec/frontend/notes/mixins/discussion_navigation_spec.js index 81e4ed3ebe7..b6a2b318ec3 100644 --- a/spec/frontend/notes/mixins/discussion_navigation_spec.js +++ b/spec/frontend/notes/mixins/discussion_navigation_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; -import { setHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import createEventHub from '~/helpers/event_hub_factory'; import * as utils from '~/lib/utils/common_utils'; import discussionNavigation from '~/notes/mixins/discussion_navigation'; @@ -10,14 +10,15 @@ import notesModule from '~/notes/stores/modules'; let scrollToFile; const discussion = (id, index) => ({ id, - resolvable: index % 2 === 0, + resolvable: index % 2 === 0, // discussions 'b' and 'd' are not resolvable active: true, notes: [{}], diff_discussion: true, position: { new_line: 1, old_line: 1 }, diff_file: { file_path: 'test.js' }, }); -const createDiscussions = () => [...'abcde'].map(discussion); +const mockDiscussionIds = [...'abcde']; +const createDiscussions = () => mockDiscussionIds.map(discussion); const createComponent = () => ({ mixins: [discussionNavigation], render() { @@ -32,22 +33,25 @@ describe('Discussion navigation mixin', () => { let store; let expandDiscussion; + const findDiscussionEl = (id) => document.querySelector(`div[data-discussion-id="${id}"]`); + beforeEach(() => { setHTMLFixture( `<div class="tab-pane notes"> - ${[...'abcde'] + ${mockDiscussionIds .map( - (id) => + (id, index) => `<ul class="notes" data-discussion-id="${id}"></ul> - <div class="discussion" data-discussion-id="${id}"></div>`, + <div class="discussion" data-discussion-id="${id}" ${ + discussion(id, index).resolvable + ? 'data-discussion-resolvable="true"' + : 'data-discussion-resolved="true"' + }></div>`, ) .join('')} </div>`, ); - jest.spyOn(utils, 'scrollToElementWithContext'); - jest.spyOn(utils, 'scrollToElement'); - expandDiscussion = jest.fn(); scrollToFile = jest.fn(); const { actions, ...notesRest } = notesModule(); @@ -70,8 +74,8 @@ describe('Discussion navigation mixin', () => { }); afterEach(() => { - wrapper.vm.$destroy(); jest.clearAllMocks(); + resetHTMLFixture(); }); describe('jumpToFirstUnresolvedDiscussion method', () => { @@ -105,41 +109,61 @@ describe('Discussion navigation mixin', () => { describe('cycle through discussions', () => { beforeEach(() => { window.mrTabs = { eventHub: createEventHub(), tabShown: jest.fn() }; - }); - describe.each` - fn | args | currentId - ${'jumpToNextDiscussion'} | ${[]} | ${null} - ${'jumpToNextDiscussion'} | ${[]} | ${'a'} - ${'jumpToNextDiscussion'} | ${[]} | ${'e'} - ${'jumpToPreviousDiscussion'} | ${[]} | ${null} - ${'jumpToPreviousDiscussion'} | ${[]} | ${'e'} - ${'jumpToPreviousDiscussion'} | ${[]} | ${'c'} - `('$fn (args = $args, currentId = $currentId)', ({ fn, args, currentId }) => { - beforeEach(() => { - store.state.notes.currentDiscussionId = currentId; + // Since we cannot actually scroll on the window, we have to mock each + // discussion's `getBoundingClientRect` to replicate the scroll position: + // a is at 100, b is at 200, c is at 300, d is at 400, e is at 500. + mockDiscussionIds.forEach((id, index) => { + jest + .spyOn(findDiscussionEl(id), 'getBoundingClientRect') + .mockReturnValue({ y: (index + 1) * 100 }); }); - describe('on `show` active tab', () => { - beforeEach(async () => { - window.mrTabs.currentAction = 'show'; - wrapper.vm[fn](...args); - - await nextTick(); - }); - - it('expands discussion', async () => { - await nextTick(); - - expect(expandDiscussion).toHaveBeenCalled(); - }); - - it('scrolls to element', async () => { - await nextTick(); + jest.spyOn(utils, 'scrollToElement'); + }); - expect(utils.scrollToElement).toHaveBeenCalled(); + describe.each` + fn | currentScrollPosition | expectedId + ${'jumpToNextDiscussion'} | ${null} | ${'a'} + ${'jumpToNextDiscussion'} | ${100} | ${'c'} + ${'jumpToNextDiscussion'} | ${200} | ${'c'} + ${'jumpToNextDiscussion'} | ${500} | ${'a'} + ${'jumpToPreviousDiscussion'} | ${null} | ${'e'} + ${'jumpToPreviousDiscussion'} | ${100} | ${'e'} + ${'jumpToPreviousDiscussion'} | ${200} | ${'a'} + ${'jumpToPreviousDiscussion'} | ${500} | ${'c'} + `( + '$fn (currentScrollPosition = $currentScrollPosition)', + ({ fn, currentScrollPosition, expectedId }) => { + describe('on `show` active tab', () => { + beforeEach(async () => { + window.mrTabs.currentAction = 'show'; + + // Set `document.body.scrollHeight` higher than `window.innerHeight` (which is 768) + // to prevent `hasReachedPageEnd` from always returning true + jest.spyOn(document.body, 'scrollHeight', 'get').mockReturnValue(1000); + // Mock current scroll position + jest.spyOn(utils, 'contentTop').mockReturnValue(currentScrollPosition); + + wrapper.vm[fn](); + + await nextTick(); + }); + + it('expands discussion', () => { + expect(expandDiscussion).toHaveBeenCalledWith(expect.any(Object), { + discussionId: expectedId, + }); + }); + + it(`scrolls to discussion element with id "${expectedId}"`, () => { + expect(utils.scrollToElement).toHaveBeenLastCalledWith( + findDiscussionEl(expectedId), + undefined, + ); + }); }); - }); - }); + }, + ); }); }); diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 97249d232dc..50df63d06af 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -68,6 +68,8 @@ describe('Actions Notes Store', () => { resetStore(store); axiosMock.restore(); resetHTMLFixture(); + + window.gon = {}; }); describe('setNotesData', () => { @@ -872,26 +874,6 @@ describe('Actions Notes Store', () => { }); }); - describe('if response contains errors.base', () => { - const res = { errors: { base: ['something went wrong'] } }; - const error = { message: 'Unprocessable entity', response: { data: res } }; - - it('sets an alert using errors.base message', async () => { - const resp = await actions.saveNote( - { - commit() {}, - dispatch: () => Promise.reject(error), - }, - { ...payload, flashContainer }, - ); - expect(resp.hasAlert).toBe(true); - expect(createAlert).toHaveBeenCalledWith({ - message: 'Your comment could not be submitted because something went wrong', - parent: flashContainer, - }); - }); - }); - describe('if response contains no errors', () => { const res = { valid: true }; @@ -1467,6 +1449,29 @@ describe('Actions Notes Store', () => { ); }); + it('dispatches `fetchDiscussionsBatch` action with notes_filter 0 for merge request', () => { + window.gon = { features: { mrActivityFilters: true } }; + + return testAction( + actions.fetchDiscussions, + { path: 'test-path', filter: 'test-filter', persistFilter: 'test-persist-filter' }, + { noteableType: notesConstants.MERGE_REQUEST_NOTEABLE_TYPE }, + [], + [ + { + type: 'fetchDiscussionsBatch', + payload: { + config: { + params: { notes_filter: 0, persist_filter: false }, + }, + path: 'test-path', + perPage: 20, + }, + }, + ], + ); + }); + it('dispatches `fetchDiscussionsBatch` action if noteable is an Issue', () => { return testAction( actions.fetchDiscussions, diff --git a/spec/frontend/notes/stores/mutation_spec.js b/spec/frontend/notes/stores/mutation_spec.js index 8809a496c52..385aee2c1aa 100644 --- a/spec/frontend/notes/stores/mutation_spec.js +++ b/spec/frontend/notes/stores/mutation_spec.js @@ -114,13 +114,33 @@ describe('Notes Store mutations', () => { }); describe('REMOVE_PLACEHOLDER_NOTES', () => { - it('should remove all placeholder notes in indivudal notes and discussion', () => { + it('should remove all placeholder individual notes', () => { const placeholderNote = { ...individualNote, isPlaceholderNote: true }; const state = { discussions: [placeholderNote] }; + mutations.REMOVE_PLACEHOLDER_NOTES(state); expect(state.discussions).toEqual([]); }); + + it.each` + discussionType | discussion + ${'initial'} | ${individualNote} + ${'continued'} | ${discussionMock} + `('should remove all placeholder notes from $discussionType discussions', ({ discussion }) => { + const lengthBefore = discussion.notes.length; + + const placeholderNote = { ...individualNote, isPlaceholderNote: true }; + discussion.notes.push(placeholderNote); + + const state = { + discussions: [discussion], + }; + + mutations.REMOVE_PLACEHOLDER_NOTES(state); + + expect(state.discussions[0].notes.length).toEqual(lengthBefore); + }); }); describe('SET_NOTES_DATA', () => { diff --git a/spec/frontend/notes/utils_spec.js b/spec/frontend/notes/utils_spec.js new file mode 100644 index 00000000000..0882e0a5759 --- /dev/null +++ b/spec/frontend/notes/utils_spec.js @@ -0,0 +1,46 @@ +import { sprintf } from '~/locale'; +import { getErrorMessages } from '~/notes/utils'; +import { HTTP_STATUS_UNPROCESSABLE_ENTITY, HTTP_STATUS_BAD_REQUEST } from '~/lib/utils/http_status'; +import { COMMENT_FORM } from '~/notes/i18n'; + +describe('getErrorMessages', () => { + describe('when http status is not HTTP_STATUS_UNPROCESSABLE_ENTITY', () => { + it('returns generic error', () => { + const errorMessages = getErrorMessages( + { errors: ['unknown error'] }, + HTTP_STATUS_BAD_REQUEST, + ); + + expect(errorMessages).toStrictEqual([COMMENT_FORM.GENERIC_UNSUBMITTABLE_NETWORK]); + }); + }); + + describe('when http status is HTTP_STATUS_UNPROCESSABLE_ENTITY', () => { + it('returns all errors', () => { + const errorMessages = getErrorMessages( + { errors: 'error 1 and error 2' }, + HTTP_STATUS_UNPROCESSABLE_ENTITY, + ); + + expect(errorMessages).toStrictEqual([ + sprintf(COMMENT_FORM.error, { reason: 'error 1 and error 2' }), + ]); + }); + + describe('when response contains commands_only errors', () => { + it('only returns commands_only errors', () => { + const errorMessages = getErrorMessages( + { + errors: { + commands_only: ['commands_only error 1', 'commands_only error 2'], + base: ['base error 1'], + }, + }, + HTTP_STATUS_UNPROCESSABLE_ENTITY, + ); + + expect(errorMessages).toStrictEqual(['commands_only error 1', 'commands_only error 2']); + }); + }); + }); +}); diff --git a/spec/frontend/operation_settings/components/metrics_settings_spec.js b/spec/frontend/operation_settings/components/metrics_settings_spec.js deleted file mode 100644 index 5bccf4943ae..00000000000 --- a/spec/frontend/operation_settings/components/metrics_settings_spec.js +++ /dev/null @@ -1,214 +0,0 @@ -import { GlButton, GlLink, GlFormGroup, GlFormInput, GlFormSelect } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { TEST_HOST } from 'helpers/test_constants'; -import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import { refreshCurrentPage } from '~/lib/utils/url_utility'; -import { timezones } from '~/monitoring/format_date'; -import DashboardTimezone from '~/operation_settings/components/form_group/dashboard_timezone.vue'; -import ExternalDashboard from '~/operation_settings/components/form_group/external_dashboard.vue'; -import MetricsSettings from '~/operation_settings/components/metrics_settings.vue'; - -import store from '~/operation_settings/store'; - -jest.mock('~/lib/utils/url_utility'); -jest.mock('~/alert'); - -describe('operation settings external dashboard component', () => { - let wrapper; - - const operationsSettingsEndpoint = `${TEST_HOST}/mock/ops/settings/endpoint`; - const helpPage = `${TEST_HOST}/help/metrics/page/path`; - const externalDashboardUrl = `http://mock-external-domain.com/external/dashboard/url`; - const dashboardTimezoneSetting = timezones.LOCAL; - - const mountComponent = (shallow = true) => { - const config = [ - MetricsSettings, - { - store: store({ - operationsSettingsEndpoint, - helpPage, - externalDashboardUrl, - dashboardTimezoneSetting, - }), - stubs: { - ExternalDashboard, - DashboardTimezone, - }, - }, - ]; - wrapper = shallow ? shallowMount(...config) : mount(...config); - }; - - beforeEach(() => { - jest.spyOn(axios, 'patch').mockImplementation(); - }); - - afterEach(() => { - axios.patch.mockReset(); - refreshCurrentPage.mockReset(); - createAlert.mockReset(); - }); - - it('renders header text', () => { - mountComponent(); - expect(wrapper.find('.js-section-header').text()).toBe('Metrics'); - }); - - describe('expand/collapse button', () => { - it('renders as an expand button by default', () => { - mountComponent(); - const button = wrapper.findComponent(GlButton); - - expect(button.text()).toBe('Expand'); - }); - }); - - describe('sub-header', () => { - let subHeader; - - beforeEach(() => { - mountComponent(); - subHeader = wrapper.find('.js-section-sub-header'); - }); - - it('renders descriptive text', () => { - expect(subHeader.text()).toContain('Manage metrics dashboard settings.'); - }); - - it('renders help page link', () => { - const link = subHeader.findComponent(GlLink); - - expect(link.text()).toBe('Learn more.'); - expect(link.attributes().href).toBe(helpPage); - }); - }); - - describe('form', () => { - describe('dashboard timezone', () => { - describe('field label', () => { - let formGroup; - - beforeEach(() => { - mountComponent(false); - formGroup = wrapper.findComponent(DashboardTimezone).findComponent(GlFormGroup); - }); - - it('uses label text', () => { - expect(formGroup.find('label').text()).toBe('Dashboard timezone'); - }); - - it('uses description text', () => { - const description = formGroup.find('small'); - const expectedDescription = - "Choose whether to display dashboard metrics in UTC or the user's local timezone."; - - expect(description.text()).toBe(expectedDescription); - }); - }); - - describe('select field', () => { - let select; - - beforeEach(() => { - mountComponent(); - select = wrapper.findComponent(DashboardTimezone).findComponent(GlFormSelect); - }); - - it('defaults to externalDashboardUrl', () => { - expect(select.attributes('value')).toBe(dashboardTimezoneSetting); - }); - }); - }); - - describe('external dashboard', () => { - describe('input label', () => { - let formGroup; - - beforeEach(() => { - mountComponent(false); - formGroup = wrapper.findComponent(ExternalDashboard).findComponent(GlFormGroup); - }); - - it('uses label text', () => { - expect(formGroup.find('label').text()).toBe('External dashboard URL'); - }); - - it('uses description text', () => { - const description = formGroup.find('small'); - const expectedDescription = - 'Add a button to the metrics dashboard linking directly to your existing external dashboard.'; - - expect(description.text()).toBe(expectedDescription); - }); - }); - - describe('input field', () => { - let input; - - beforeEach(() => { - mountComponent(); - input = wrapper.findComponent(ExternalDashboard).findComponent(GlFormInput); - }); - - it('defaults to externalDashboardUrl', () => { - expect(input.attributes().value).toBe(externalDashboardUrl); - }); - - it('uses a placeholder', () => { - expect(input.attributes().placeholder).toBe('https://my-org.gitlab.io/my-dashboards'); - }); - }); - }); - - describe('submit button', () => { - const findSubmitButton = () => wrapper.find('.settings-content form').findComponent(GlButton); - - const endpointRequest = [ - operationsSettingsEndpoint, - { - project: { - metrics_setting_attributes: { - dashboard_timezone: dashboardTimezoneSetting, - external_dashboard_url: externalDashboardUrl, - }, - }, - }, - ]; - - it('renders button label', () => { - mountComponent(); - const submit = findSubmitButton(); - expect(submit.text()).toBe('Save Changes'); - }); - - it('submits form on click', async () => { - mountComponent(false); - axios.patch.mockResolvedValue(); - findSubmitButton().trigger('click'); - - expect(axios.patch).toHaveBeenCalledWith(...endpointRequest); - - await nextTick(); - expect(refreshCurrentPage).toHaveBeenCalled(); - }); - - it('creates an alert on error', async () => { - mountComponent(false); - const message = 'mockErrorMessage'; - axios.patch.mockRejectedValue({ response: { data: { message } } }); - findSubmitButton().trigger('click'); - - expect(axios.patch).toHaveBeenCalledWith(...endpointRequest); - - await nextTick(); - await jest.runAllTicks(); - expect(createAlert).toHaveBeenCalledWith({ - message: `There was an error saving your changes. ${message}`, - }); - }); - }); - }); -}); diff --git a/spec/frontend/operation_settings/store/mutations_spec.js b/spec/frontend/operation_settings/store/mutations_spec.js deleted file mode 100644 index db6b54b503d..00000000000 --- a/spec/frontend/operation_settings/store/mutations_spec.js +++ /dev/null @@ -1,29 +0,0 @@ -import { timezones } from '~/monitoring/format_date'; -import mutations from '~/operation_settings/store/mutations'; -import createState from '~/operation_settings/store/state'; - -describe('operation settings mutations', () => { - let localState; - - beforeEach(() => { - localState = createState(); - }); - - describe('SET_EXTERNAL_DASHBOARD_URL', () => { - it('sets externalDashboardUrl', () => { - const mockUrl = 'mockUrl'; - mutations.SET_EXTERNAL_DASHBOARD_URL(localState, mockUrl); - - expect(localState.externalDashboard.url).toBe(mockUrl); - }); - }); - - describe('SET_DASHBOARD_TIMEZONE', () => { - it('sets dashboardTimezoneSetting', () => { - mutations.SET_DASHBOARD_TIMEZONE(localState, timezones.LOCAL); - - expect(localState.dashboardTimezone.selected).not.toBeUndefined(); - expect(localState.dashboardTimezone.selected).toBe(timezones.LOCAL); - }); - }); -}); diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_row_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_row_spec.js index 1e9b9b1ce47..d5a87945c16 100644 --- a/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_row_spec.js +++ b/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_row_spec.js @@ -132,7 +132,7 @@ describe('Harbor artifact list row', () => { }, }); - expect(findByTestId('size').text()).toBe('0 bytes'); + expect(findByTestId('size').text()).toBe('0 B'); }); }); }); 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 148e87699f1..7f56d3e216c 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 @@ -51,7 +51,7 @@ describe('PackageTitle', () => { it('correctly calculates the size', async () => { await createComponent(); - expect(packageSize().props('text')).toBe('300 bytes'); + expect(packageSize().props('text')).toBe('300 B'); }); }); 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 c3e0818fc11..ca65d87f86c 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 @@ -1,4 +1,4 @@ -import { GlDropdown, GlButton } from '@gitlab/ui'; +import { GlDisclosureDropdown, GlButton } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue/'; import stubChildren from 'helpers/stub_children'; @@ -19,7 +19,7 @@ describe('Package Files', () => { const findSecondRowCommitLink = () => findSecondRow().find('[data-testid="commit-link"]'); const findFirstRowFileIcon = () => findFirstRow().findComponent(FileIcon); const findFirstRowCreatedAt = () => findFirstRow().findComponent(TimeAgoTooltip); - const findFirstActionMenu = () => findFirstRow().findComponent(GlDropdown); + const findFirstActionMenu = () => findFirstRow().findComponent(GlDisclosureDropdown); const findActionMenuDelete = () => findFirstActionMenu().find('[data-testid="delete-file"]'); const findFirstToggleDetailsButton = () => findFirstRow().findComponent(GlButton); const findFirstRowShaComponent = (id) => wrapper.find(`[data-testid="${id}"]`); @@ -159,7 +159,7 @@ describe('Package Files', () => { it('emits a delete event when clicked', () => { createComponent(); - findActionMenuDelete().vm.$emit('click'); + findActionMenuDelete().vm.$emit('action'); const [[{ id }]] = wrapper.emitted('delete-file'); expect(id).toBe(npmFiles[0].id); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js index 1dcac017ccf..2b60684e60a 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js @@ -1,22 +1,50 @@ -import { GlDropdown, GlButton, GlFormCheckbox } from '@gitlab/ui'; -import { nextTick } from 'vue'; +import { GlAlert, GlDropdown, GlButton, GlFormCheckbox, GlLoadingIcon, GlModal } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import { stubComponent } from 'helpers/stub_component'; import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; -import { packageFiles as packageFilesMock } from 'jest/packages_and_registries/package_registry/mock_data'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import Tracking from '~/tracking'; +import { s__ } from '~/locale'; +import { createAlert } from '~/alert'; +import { + packageFiles as packageFilesMock, + packageFilesQuery, + packageDestroyFilesMutation, + packageDestroyFilesMutationError, +} from 'jest/packages_and_registries/package_registry/mock_data'; +import { + DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION, + DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT, + DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT, + DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, + DELETE_PACKAGE_FILE_ERROR_MESSAGE, + DELETE_PACKAGE_FILES_SUCCESS_MESSAGE, + DELETE_PACKAGE_FILES_ERROR_MESSAGE, +} from '~/packages_and_registries/package_registry/constants'; import PackageFiles from '~/packages_and_registries/package_registry/components/details/package_files.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import getPackageFiles from '~/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql'; +import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql'; + +Vue.use(VueApollo); +jest.mock('~/alert'); + describe('Package Files', () => { let wrapper; + let apolloProvider; const findAllRows = () => wrapper.findAllByTestId('file-row'); const findDeleteSelectedButton = () => wrapper.findByTestId('delete-selected'); + const findDeleteFilesModal = () => wrapper.findByTestId('delete-files-modal'); const findFirstRow = () => extendedWrapper(findAllRows().at(0)); const findSecondRow = () => extendedWrapper(findAllRows().at(1)); + const findPackageFilesAlert = () => wrapper.findComponent(GlAlert); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findFirstRowDownloadLink = () => findFirstRow().findByTestId('download-link'); - const findFirstRowCommitLink = () => findFirstRow().findByTestId('commit-link'); - const findSecondRowCommitLink = () => findSecondRow().findByTestId('commit-link'); const findFirstRowFileIcon = () => findFirstRow().findComponent(FileIcon); const findFirstRowCreatedAt = () => findFirstRow().findComponent(TimeAgoTooltip); const findFirstActionMenu = () => extendedWrapper(findFirstRow().findComponent(GlDropdown)); @@ -29,146 +57,150 @@ describe('Package Files', () => { const files = packageFilesMock(); const [file] = files; + const showMock = jest.fn(); + const eventCategory = 'UI::NpmPackages'; + const createComponent = ({ - packageFiles = [file], - isLoading = false, + packageId = '1', + packageType = 'NPM', + projectPath = 'gitlab-test', canDelete = true, stubs, + resolver = jest.fn().mockResolvedValue(packageFilesQuery({ files: [file] })), + filesDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFilesMutation()), } = {}) => { + const requestHandlers = [ + [getPackageFiles, resolver], + [destroyPackageFilesMutation, filesDeleteMutationResolver], + ]; + apolloProvider = createMockApollo(requestHandlers); + wrapper = mountExtended(PackageFiles, { + apolloProvider, propsData: { canDelete, - isLoading, - packageFiles, + packageId, + packageType, + projectPath, }, stubs: { GlTable: false, + GlModal: stubComponent(GlModal, { + methods: { + show: showMock, + }, + }), ...stubs, }, }); }; describe('rows', () => { - it('renders a single file for an npm package', () => { + it('do not get rendered when query is loading', () => { createComponent(); + expect(findLoadingIcon().exists()).toBe(true); + expect(findDeleteSelectedButton().props('disabled')).toBe(true); + }); + + it('renders a single file for an npm package', async () => { + createComponent(); + await waitForPromises(); + expect(findAllRows()).toHaveLength(1); + expect(findLoadingIcon().exists()).toBe(false); }); - it('renders multiple files for a package that contains more than one file', () => { - createComponent({ packageFiles: files }); + it('renders multiple files for a package that contains more than one file', async () => { + createComponent({ resolver: jest.fn().mockResolvedValue(packageFilesQuery()) }); + await waitForPromises(); expect(findAllRows()).toHaveLength(2); }); + + it('does not render gl-alert', async () => { + createComponent(); + await waitForPromises(); + + expect(findPackageFilesAlert().exists()).toBe(false); + }); + + it('renders gl-alert if load fails', async () => { + createComponent({ resolver: jest.fn().mockRejectedValue() }); + await waitForPromises(); + + expect(findPackageFilesAlert().exists()).toBe(true); + expect(findPackageFilesAlert().text()).toBe( + s__('PackageRegistry|Something went wrong while fetching package assets.'), + ); + }); }); describe('link', () => { - it('exists', () => { + beforeEach(async () => { createComponent(); + await waitForPromises(); + }); + it('exists', () => { expect(findFirstRowDownloadLink().exists()).toBe(true); }); it('has the correct attrs bound', () => { - createComponent(); - expect(findFirstRowDownloadLink().attributes('href')).toBe(file.downloadPath); }); - it('emits "download-file" event on click', () => { - createComponent(); + it('tracks "download-file" event on click', () => { + const eventSpy = jest.spyOn(Tracking, 'event'); findFirstRowDownloadLink().vm.$emit('click'); - expect(wrapper.emitted('download-file')).toEqual([[]]); + expect(eventSpy).toHaveBeenCalledWith( + eventCategory, + DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION, + expect.any(Object), + ); }); }); describe('file-icon', () => { - it('exists', () => { + beforeEach(async () => { createComponent(); + await waitForPromises(); + }); + it('exists', () => { expect(findFirstRowFileIcon().exists()).toBe(true); }); it('has the correct props bound', () => { - createComponent(); - expect(findFirstRowFileIcon().props('fileName')).toBe(file.fileName); }); }); describe('time-ago tooltip', () => { - it('exists', () => { + beforeEach(async () => { createComponent(); + await waitForPromises(); + }); + it('exists', () => { expect(findFirstRowCreatedAt().exists()).toBe(true); }); it('has the correct props bound', () => { - createComponent(); - expect(findFirstRowCreatedAt().props('time')).toBe(file.createdAt); }); }); - describe('commit', () => { - const withPipeline = { - ...file, - pipelines: [ - { - sha: 'sha', - id: 1, - commitPath: 'commitPath', - }, - ], - }; - - describe('when package file has a pipeline associated', () => { - it('exists', () => { - createComponent({ packageFiles: [withPipeline] }); - - expect(findFirstRowCommitLink().exists()).toBe(true); - }); - - it('the link points to the commit path', () => { - createComponent({ packageFiles: [withPipeline] }); - - expect(findFirstRowCommitLink().attributes('href')).toBe( - withPipeline.pipelines[0].commitPath, - ); - }); - - it('the text is the pipeline sha', () => { - createComponent({ packageFiles: [withPipeline] }); - - expect(findFirstRowCommitLink().text()).toBe(withPipeline.pipelines[0].sha); - }); - }); - - describe('when package file has no pipeline associated', () => { - it('does not exist', () => { - createComponent(); - - expect(findFirstRowCommitLink().exists()).toBe(false); - }); - }); - - describe('when only one file lacks an associated pipeline', () => { - it('renders the commit when it exists and not otherwise', () => { - createComponent({ packageFiles: [withPipeline, file] }); - - expect(findFirstRowCommitLink().exists()).toBe(true); - expect(findSecondRowCommitLink().exists()).toBe(false); - }); - }); - }); - describe('action menu', () => { describe('when the user can delete', () => { - it('exists', () => { + beforeEach(async () => { createComponent(); + await waitForPromises(); + }); + it('exists', () => { expect(findFirstActionMenu().exists()).toBe(true); expect(findFirstActionMenu().props('icon')).toBe('ellipsis_v'); expect(findFirstActionMenu().props('textSrOnly')).toBe(true); @@ -178,19 +210,17 @@ describe('Package Files', () => { describe('menu items', () => { describe('delete file', () => { it('exists', () => { - createComponent(); - expect(findActionMenuDelete().exists()).toBe(true); }); - it('emits a delete event when clicked', async () => { - createComponent(); - + it('shows delete file confirmation modal', async () => { await findActionMenuDelete().trigger('click'); - const [[items]] = wrapper.emitted('delete-files'); - const [{ id }] = items; - expect(id).toBe(file.id); + expect(showMock).toHaveBeenCalledTimes(1); + + expect(findDeleteFilesModal().text()).toBe( + 'You are about to delete foo-1.0.1.tgz. This is a destructive action that may render your package unusable. Are you sure?', + ); }); }); }); @@ -199,8 +229,9 @@ describe('Package Files', () => { describe('when the user can not delete', () => { const canDelete = false; - it('does not exist', () => { + it('does not exist', async () => { createComponent({ canDelete }); + await waitForPromises(); expect(findFirstActionMenu().exists()).toBe(false); }); @@ -209,22 +240,18 @@ describe('Package Files', () => { describe('multi select', () => { describe('when user can delete', () => { - it('delete selected button exists & is disabled', () => { + it('delete selected button exists & is disabled', async () => { createComponent(); + await waitForPromises(); expect(findDeleteSelectedButton().exists()).toBe(true); expect(findDeleteSelectedButton().text()).toMatchInterpolatedText('Delete selected'); expect(findDeleteSelectedButton().props('disabled')).toBe(true); }); - it('delete selected button exists & is disabled when isLoading prop is true', () => { - createComponent({ isLoading: true }); - - expect(findDeleteSelectedButton().props('disabled')).toBe(true); - }); - - it('checkboxes to select file are visible', () => { - createComponent({ packageFiles: files }); + it('checkboxes to select file are visible', async () => { + createComponent({ resolver: jest.fn().mockResolvedValue(packageFilesQuery()) }); + await waitForPromises(); expect(findCheckAllCheckbox().exists()).toBe(true); expect(findAllRowCheckboxes()).toHaveLength(2); @@ -232,6 +259,7 @@ describe('Package Files', () => { it('selecting a checkbox enables delete selected button', async () => { createComponent(); + await waitForPromises(); const first = findAllRowCheckboxes().at(0); @@ -244,7 +272,8 @@ describe('Package Files', () => { it('will toggle between selecting all and deselecting all files', async () => { const getChecked = () => findAllRowCheckboxes().filter((x) => x.element.checked === true); - createComponent({ packageFiles: files }); + createComponent({ resolver: jest.fn().mockResolvedValue(packageFilesQuery()) }); + await waitForPromises(); expect(getChecked()).toHaveLength(0); @@ -262,9 +291,10 @@ describe('Package Files', () => { expect(findCheckAllCheckbox().props('indeterminate')).toBe(state); createComponent({ - packageFiles: files, + resolver: jest.fn().mockResolvedValue(packageFilesQuery()), stubs: { GlFormCheckbox: stubComponent(GlFormCheckbox, { props: ['indeterminate'] }) }, }); + await waitForPromises(); expectIndeterminateState(false); @@ -286,8 +316,9 @@ describe('Package Files', () => { }); }); - it('emits a delete event when selected', async () => { + it('shows delete modal with single file confirmation text when delete selected is clicked', async () => { createComponent(); + await waitForPromises(); const first = findAllRowCheckboxes().at(0); @@ -295,34 +326,94 @@ describe('Package Files', () => { await findDeleteSelectedButton().trigger('click'); - const [[items]] = wrapper.emitted('delete-files'); - const [{ id }] = items; - expect(id).toBe(file.id); + expect(showMock).toHaveBeenCalledTimes(1); + + expect(findDeleteFilesModal().text()).toBe( + 'You are about to delete foo-1.0.1.tgz. This is a destructive action that may render your package unusable. Are you sure?', + ); }); - it('emits delete event with both items when all are selected', async () => { - createComponent({ packageFiles: files }); + it('shows delete modal with multiple files confirmation text when delete selected is clicked', async () => { + createComponent({ resolver: jest.fn().mockResolvedValue(packageFilesQuery()) }); + await waitForPromises(); await findCheckAllCheckbox().setChecked(true); await findDeleteSelectedButton().trigger('click'); - const [[items]] = wrapper.emitted('delete-files'); - expect(items).toHaveLength(2); + expect(showMock).toHaveBeenCalledTimes(1); + + expect(findDeleteFilesModal().text()).toMatchInterpolatedText( + 'You are about to delete 2 assets. This operation is irreversible.', + ); + }); + + describe('emits delete-all-files event', () => { + it('with right content for last file in package', async () => { + createComponent({ + resolver: jest.fn().mockResolvedValue( + packageFilesQuery({ + files: [file], + pageInfo: { + hasNextPage: false, + }, + }), + ), + }); + await waitForPromises(); + const first = findAllRowCheckboxes().at(0); + + await first.setChecked(true); + + await findDeleteSelectedButton().trigger('click'); + + expect(showMock).toHaveBeenCalledTimes(0); + + expect(wrapper.emitted('delete-all-files')).toHaveLength(1); + expect(wrapper.emitted('delete-all-files')[0]).toEqual([ + DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT, + ]); + }); + + it('with right content for all files in package', async () => { + createComponent({ + resolver: jest.fn().mockResolvedValue( + packageFilesQuery({ + pageInfo: { + hasNextPage: false, + }, + }), + ), + }); + await waitForPromises(); + + await findCheckAllCheckbox().setChecked(true); + + await findDeleteSelectedButton().trigger('click'); + + expect(showMock).toHaveBeenCalledTimes(0); + + expect(wrapper.emitted('delete-all-files')).toHaveLength(1); + expect(wrapper.emitted('delete-all-files')[0]).toEqual([ + DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT, + ]); + }); }); }); describe('when user cannot delete', () => { const canDelete = false; - it('delete selected button does not exist', () => { + it('delete selected button does not exist', async () => { createComponent({ canDelete }); + await waitForPromises(); expect(findDeleteSelectedButton().exists()).toBe(false); }); - it('checkboxes to select file are not visible', () => { - createComponent({ packageFiles: files, canDelete }); + it('checkboxes to select file are not visible', async () => { + createComponent({ resolver: jest.fn().mockResolvedValue(packageFilesQuery()), canDelete }); + await waitForPromises(); expect(findCheckAllCheckbox().exists()).toBe(false); expect(findAllRowCheckboxes()).toHaveLength(0); @@ -330,26 +421,220 @@ describe('Package Files', () => { }); }); + describe('deleting a file', () => { + const doDeleteFile = async () => { + const first = findAllRowCheckboxes().at(0); + + await first.setChecked(true); + + await findDeleteSelectedButton().trigger('click'); + + findDeleteFilesModal().vm.$emit('primary'); + }; + + it('confirming on the modal sets the loading state', async () => { + createComponent(); + + await waitForPromises(); + + await doDeleteFile(); + + await nextTick(); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('confirming on the modal deletes the file and shows a success message', async () => { + const resolver = jest.fn().mockResolvedValue(packageFilesQuery({ files: [file] })); + const filesDeleteMutationResolver = jest + .fn() + .mockResolvedValue(packageDestroyFilesMutation()); + createComponent({ resolver, filesDeleteMutationResolver }); + + await waitForPromises(); + + await doDeleteFile(); + + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + + expect(createAlert).toHaveBeenCalledWith( + expect.objectContaining({ + message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, + }), + ); + + expect(filesDeleteMutationResolver).toHaveBeenCalledWith({ + ids: [file.id], + projectPath: 'gitlab-test', + }); + + // we are re-fetching the package files, so we expect the resolver to have been called twice + expect(resolver).toHaveBeenCalledTimes(2); + expect(resolver).toHaveBeenCalledWith({ + id: '1', + first: 100, + }); + }); + + describe('errors', () => { + it('shows an error when the mutation request fails', async () => { + createComponent({ filesDeleteMutationResolver: jest.fn().mockRejectedValue() }); + await waitForPromises(); + + await doDeleteFile(); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith( + expect.objectContaining({ + message: DELETE_PACKAGE_FILE_ERROR_MESSAGE, + }), + ); + }); + + it('shows an error when the mutation request returns an error payload', async () => { + createComponent({ + filesDeleteMutationResolver: jest + .fn() + .mockResolvedValue(packageDestroyFilesMutationError()), + }); + await waitForPromises(); + + await doDeleteFile(); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith( + expect.objectContaining({ + message: DELETE_PACKAGE_FILE_ERROR_MESSAGE, + }), + ); + }); + }); + }); + + describe('deleting multiple files', () => { + const doDeleteFiles = async () => { + await findCheckAllCheckbox().setChecked(true); + + await findDeleteSelectedButton().trigger('click'); + + findDeleteFilesModal().vm.$emit('primary'); + }; + + it('confirming on the modal sets the loading state', async () => { + createComponent(); + + await waitForPromises(); + + await doDeleteFiles(); + + await nextTick(); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('confirming on the modal deletes the file and shows a success message', async () => { + const resolver = jest.fn().mockResolvedValue(packageFilesQuery()); + const filesDeleteMutationResolver = jest + .fn() + .mockResolvedValue(packageDestroyFilesMutation()); + createComponent({ resolver, filesDeleteMutationResolver }); + + await waitForPromises(); + + await doDeleteFiles(); + + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + + expect(createAlert).toHaveBeenCalledWith( + expect.objectContaining({ + message: DELETE_PACKAGE_FILES_SUCCESS_MESSAGE, + }), + ); + + expect(filesDeleteMutationResolver).toHaveBeenCalledWith({ + ids: files.map(({ id }) => id), + projectPath: 'gitlab-test', + }); + + // we are re-fetching the package files, so we expect the resolver to have been called twice + expect(resolver).toHaveBeenCalledTimes(2); + expect(resolver).toHaveBeenCalledWith({ + id: '1', + first: 100, + }); + }); + + describe('errors', () => { + it('shows an error when the mutation request fails', async () => { + const resolver = jest.fn().mockResolvedValue(packageFilesQuery()); + createComponent({ filesDeleteMutationResolver: jest.fn().mockRejectedValue(), resolver }); + await waitForPromises(); + + await doDeleteFiles(); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith( + expect.objectContaining({ + message: DELETE_PACKAGE_FILES_ERROR_MESSAGE, + }), + ); + }); + + it('shows an error when the mutation request returns an error payload', async () => { + const resolver = jest.fn().mockResolvedValue(packageFilesQuery()); + createComponent({ + filesDeleteMutationResolver: jest + .fn() + .mockResolvedValue(packageDestroyFilesMutationError()), + resolver, + }); + await waitForPromises(); + + await doDeleteFiles(); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith( + expect.objectContaining({ + message: DELETE_PACKAGE_FILES_ERROR_MESSAGE, + }), + ); + }); + }); + }); + describe('additional details', () => { describe('details toggle button', () => { - it('exists', () => { + it('exists', async () => { createComponent(); + await waitForPromises(); expect(findFirstToggleDetailsButton().exists()).toBe(true); }); - it('is hidden when no details is present', () => { + it('is hidden when no details is present', async () => { const { ...noShaFile } = file; noShaFile.fileSha256 = null; noShaFile.fileMd5 = null; noShaFile.fileSha1 = null; - createComponent({ packageFiles: [noShaFile] }); + createComponent({ + resolver: jest.fn().mockResolvedValue(packageFilesQuery({ files: [noShaFile] })), + }); + await waitForPromises(); expect(findFirstToggleDetailsButton().exists()).toBe(false); }); it('toggles the details row', async () => { createComponent(); + await waitForPromises(); expect(findFirstToggleDetailsButton().props('icon')).toBe('chevron-down'); @@ -380,6 +665,7 @@ describe('Package Files', () => { ${'sha-1'} | ${'SHA-1'} | ${'be93151dc23ac34a82752444556fe79b32c7a1ad'} `('has a $title row', async ({ selector, title, sha }) => { createComponent(); + await waitForPromises(); await showShaFiles(); @@ -393,7 +679,10 @@ describe('Package Files', () => { const { ...missingMd5 } = file; missingMd5.fileMd5 = null; - createComponent({ packageFiles: [missingMd5] }); + createComponent({ + resolver: jest.fn().mockResolvedValue(packageFilesQuery({ files: [missingMd5] })), + }); + await waitForPromises(); await showShaFiles(); 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 fc0ca0e898f..7fe8db1c2f7 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 @@ -46,7 +46,6 @@ describe('PackageTitle', () => { const findTitleArea = () => wrapper.findComponent(TitleArea); const findPackageType = () => wrapper.findByTestId('package-type'); - const findPackageSize = () => wrapper.findByTestId('package-size'); const findPipelineProject = () => wrapper.findByTestId('pipeline-project'); const findPackageRef = () => wrapper.findByTestId('package-ref'); const findPackageLastDownloadedAt = () => wrapper.findByTestId('package-last-downloaded-at'); @@ -147,20 +146,6 @@ describe('PackageTitle', () => { }); }); - describe('calculates the package size', () => { - it('correctly calculates when there is only 1 file', async () => { - await createComponent({ ...packageData(), packageFiles: { nodes: [packageFiles()[0]] } }); - - expect(findPackageSize().props()).toMatchObject({ text: '400.00 KiB', icon: 'disk' }); - }); - - it('correctly calculates when there are multiple files', async () => { - await createComponent(); - - expect(findPackageSize().props('text')).toBe('800.00 KiB'); - }); - }); - describe('package tags', () => { it('displays the package-tags component when the package has tags', async () => { await createComponent(); 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 5fb53566d4e..6995a4cc635 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -253,13 +253,6 @@ export const packageDetailsQuery = ({ nodes: packagePipelines(), __typename: 'PipelineConnection', }, - packageFiles: { - pageInfo: { - hasNextPage: true, - }, - nodes: packageFiles(), - __typename: 'PackageFileConnection', - }, versions: { count: packageVersions().length, }, @@ -285,6 +278,23 @@ export const packagePipelinesQuery = (pipelines = packagePipelines()) => ({ }, }); +export const packageFilesQuery = ({ files = packageFiles(), pageInfo = {} } = {}) => ({ + data: { + package: { + id: 'gid://gitlab/Packages::Package/111', + packageFiles: { + pageInfo: { + hasNextPage: true, + ...pageInfo, + }, + nodes: files, + __typename: 'PackageFileConnection', + }, + __typename: 'PackageDetailsType', + }, + }, +}); + export const emptyPackageDetailsQuery = () => ({ data: { package: { diff --git a/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js index 0962b4fa757..0f91a7aeb50 100644 --- a/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js @@ -21,10 +21,7 @@ import { REQUEST_FORWARDING_HELP_PAGE_PATH, FETCH_PACKAGE_DETAILS_ERROR_MESSAGE, PACKAGE_TYPE_COMPOSER, - DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, - DELETE_PACKAGE_FILE_ERROR_MESSAGE, - DELETE_PACKAGE_FILES_SUCCESS_MESSAGE, - DELETE_PACKAGE_FILES_ERROR_MESSAGE, + DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT, PACKAGE_TYPE_NUGET, PACKAGE_TYPE_MAVEN, PACKAGE_TYPE_CONAN, @@ -32,7 +29,6 @@ import { PACKAGE_TYPE_NPM, } from '~/packages_and_registries/package_registry/constants'; -import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql'; import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql'; import getPackageVersionsQuery from '~/packages_and_registries/package_registry/graphql//queries/get_package_versions.query.graphql'; import { @@ -41,9 +37,6 @@ import { packageVersions, dependencyLinks, emptyPackageDetailsQuery, - packageFiles, - packageDestroyFilesMutation, - packageDestroyFilesMutationError, defaultPackageGroupSettings, } from '../mock_data'; @@ -74,13 +67,9 @@ describe('PackagesApp', () => { function createComponent({ resolver = jest.fn().mockResolvedValue(packageDetailsQuery()), - filesDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFilesMutation()), routeId = '1', } = {}) { - const requestHandlers = [ - [getPackageDetails, resolver], - [destroyPackageFilesMutation, filesDeleteMutationResolver], - ]; + const requestHandlers = [[getPackageDetails, resolver]]; apolloProvider = createMockApollo(requestHandlers); wrapper = shallowMountExtended(PackagesApp, { @@ -117,8 +106,6 @@ describe('PackagesApp', () => { const findDeleteModal = () => wrapper.findByTestId('delete-modal'); const findDeleteButton = () => wrapper.findByTestId('delete-package'); const findPackageFiles = () => wrapper.findComponent(PackageFiles); - const findDeleteFileModal = () => wrapper.findByTestId('delete-file-modal'); - const findDeleteFilesModal = () => wrapper.findByTestId('delete-files-modal'); const findVersionsList = () => wrapper.findComponent(PackageVersionsList); const findVersionsCountBadge = () => wrapper.findByTestId('other-versions-badge'); const findNoVersionsMessage = () => wrapper.findByTestId('no-versions-message'); @@ -328,18 +315,18 @@ describe('PackagesApp', () => { describe('package files', () => { it('renders the package files component and has the right props', async () => { - const expectedFile = { ...packageFiles()[0] }; - // eslint-disable-next-line no-underscore-dangle - delete expectedFile.__typename; createComponent(); await waitForPromises(); expect(findPackageFiles().exists()).toBe(true); - expect(findPackageFiles().props('packageFiles')[0]).toMatchObject(expectedFile); - expect(findPackageFiles().props('canDelete')).toBe(packageData().canDestroy); - expect(findPackageFiles().props('isLoading')).toEqual(false); + expect(findPackageFiles().props()).toMatchObject({ + canDelete: packageData().canDestroy, + packageId: packageData().id, + packageType: packageData().packageType, + projectPath: 'gitlab-test', + }); }); it('does not render the package files table when the package is composer', async () => { @@ -356,250 +343,26 @@ describe('PackagesApp', () => { expect(findPackageFiles().exists()).toBe(false); }); - describe('deleting a file', () => { - const [fileToDelete] = packageFiles(); - - const doDeleteFile = () => { - findPackageFiles().vm.$emit('delete-files', [fileToDelete]); - - findDeleteFileModal().vm.$emit('primary'); - - return waitForPromises(); - }; - - it('opens delete file confirmation modal', async () => { - createComponent(); - - await waitForPromises(); - - findPackageFiles().vm.$emit('delete-files', [fileToDelete]); - - expect(showMock).toHaveBeenCalledTimes(1); - - await waitForPromises(); - - expect(findDeleteFileModal().text()).toBe( - 'You are about to delete foo-1.0.1.tgz. This is a destructive action that may render your package unusable. Are you sure?', - ); - }); - - it('when its the only file opens delete package confirmation modal', async () => { - const [packageFile] = packageFiles(); + describe('emits delete-all-files event', () => { + it('opens the delete package confirmation modal and shows confirmation text', async () => { const resolver = jest.fn().mockResolvedValue( packageDetailsQuery({ - extendPackage: { - packageFiles: { - pageInfo: { - hasNextPage: false, - }, - nodes: [packageFile], - __typename: 'PackageFileConnection', - }, - }, + extendPackage: {}, packageSettings: { ...defaultPackageGroupSettings, npmPackageRequestsForwarding: false, }, }), ); - - createComponent({ - resolver, - }); - - await waitForPromises(); - - findPackageFiles().vm.$emit('delete-files', [fileToDelete]); - - expect(showMock).toHaveBeenCalledTimes(1); - - await waitForPromises(); - - expect(findDeleteModal().text()).toBe( - 'Deleting the last package asset will remove version 1.0.0 of @gitlab-org/package-15. Are you sure?', - ); - }); - - it('confirming on the modal sets the loading state', async () => { - createComponent(); - - await waitForPromises(); - - findPackageFiles().vm.$emit('delete-files', [fileToDelete]); - - findDeleteFileModal().vm.$emit('primary'); - - await nextTick(); - - expect(findPackageFiles().props('isLoading')).toEqual(true); - }); - - it('confirming on the modal deletes the file and shows a success message', async () => { - const resolver = jest.fn().mockResolvedValue(packageDetailsQuery()); createComponent({ resolver }); await waitForPromises(); - await doDeleteFile(); - - expect(createAlert).toHaveBeenCalledWith( - expect.objectContaining({ - message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, - }), - ); - // we are re-fetching the package details, so we expect the resolver to have been called twice - expect(resolver).toHaveBeenCalledTimes(2); - }); - - describe('errors', () => { - it('shows an error when the mutation request fails', async () => { - createComponent({ filesDeleteMutationResolver: jest.fn().mockRejectedValue() }); - await waitForPromises(); - - await doDeleteFile(); - - expect(createAlert).toHaveBeenCalledWith( - expect.objectContaining({ - message: DELETE_PACKAGE_FILE_ERROR_MESSAGE, - }), - ); - }); - - it('shows an error when the mutation request returns an error payload', async () => { - createComponent({ - filesDeleteMutationResolver: jest - .fn() - .mockResolvedValue(packageDestroyFilesMutationError()), - }); - await waitForPromises(); - - await doDeleteFile(); - - expect(createAlert).toHaveBeenCalledWith( - expect.objectContaining({ - message: DELETE_PACKAGE_FILE_ERROR_MESSAGE, - }), - ); - }); - }); - }); - - describe('deleting multiple files', () => { - const doDeleteFiles = () => { - findPackageFiles().vm.$emit('delete-files', packageFiles()); - - findDeleteFilesModal().vm.$emit('primary'); - - return waitForPromises(); - }; - - it('opens delete files confirmation modal', async () => { - createComponent(); - - await waitForPromises(); - - const showDeleteFilesSpy = jest.spyOn(wrapper.vm.$refs.deleteFilesModal, 'show'); - - findPackageFiles().vm.$emit('delete-files', packageFiles()); - - expect(showDeleteFilesSpy).toHaveBeenCalled(); - }); - - it('confirming on the modal sets the loading state', async () => { - createComponent(); - - await waitForPromises(); - - findPackageFiles().vm.$emit('delete-files', packageFiles()); - - findDeleteFilesModal().vm.$emit('primary'); - - await nextTick(); - - expect(findPackageFiles().props('isLoading')).toEqual(true); - }); - - it('confirming on the modal deletes the file and shows a success message', async () => { - const resolver = jest.fn().mockResolvedValue(packageDetailsQuery()); - createComponent({ resolver }); - - await waitForPromises(); - - await doDeleteFiles(); - - expect(resolver).toHaveBeenCalledTimes(2); - - expect(createAlert).toHaveBeenCalledWith( - expect.objectContaining({ - message: DELETE_PACKAGE_FILES_SUCCESS_MESSAGE, - }), - ); - // we are re-fetching the package details, so we expect the resolver to have been called twice - expect(resolver).toHaveBeenCalledTimes(2); - }); - - describe('errors', () => { - it('shows an error when the mutation request fails', async () => { - createComponent({ filesDeleteMutationResolver: jest.fn().mockRejectedValue() }); - await waitForPromises(); - - await doDeleteFiles(); - - expect(createAlert).toHaveBeenCalledWith( - expect.objectContaining({ - message: DELETE_PACKAGE_FILES_ERROR_MESSAGE, - }), - ); - }); - - it('shows an error when the mutation request returns an error payload', async () => { - createComponent({ - filesDeleteMutationResolver: jest - .fn() - .mockResolvedValue(packageDestroyFilesMutationError()), - }); - await waitForPromises(); - - await doDeleteFiles(); - - expect(createAlert).toHaveBeenCalledWith( - expect.objectContaining({ - message: DELETE_PACKAGE_FILES_ERROR_MESSAGE, - }), - ); - }); - }); - }); - - describe('deleting all files', () => { - it('opens the delete package confirmation modal', async () => { - const resolver = jest.fn().mockResolvedValue( - packageDetailsQuery({ - extendPackage: { - packageFiles: { - pageInfo: { - hasNextPage: false, - }, - nodes: packageFiles(), - }, - }, - packageSettings: { - ...defaultPackageGroupSettings, - npmPackageRequestsForwarding: false, - }, - }), - ); - createComponent({ - resolver, - }); - - await waitForPromises(); - - findPackageFiles().vm.$emit('delete-files', packageFiles()); + findPackageFiles().vm.$emit('delete-all-files', DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT); expect(showMock).toHaveBeenCalledTimes(1); - await waitForPromises(); + await nextTick(); expect(findDeleteModal().text()).toBe( 'Deleting all package assets will remove version 1.0.0 of @gitlab-org/package-15. Are you sure?', diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js index a68087f7f57..5c64d4cb697 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js @@ -18,7 +18,7 @@ describe('Container Expiration Policy Settings Form', () => { const defaultProvidedValues = { projectPath: 'path', - projectSettingsPath: 'settings-path', + projectSettingsPath: '/settings-path', }; const { @@ -286,8 +286,8 @@ describe('Container Expiration Policy Settings Form', () => { await submitForm(); - expect(window.location.href.endsWith('settings-path?showSetupSuccessAlert=true')).toBe( - true, + expect(window.location.assign).toHaveBeenCalledWith( + '/settings-path?showSetupSuccessAlert=true', ); }); diff --git a/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js b/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js index dad7308ac0a..71ebf64f43c 100644 --- a/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js +++ b/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js @@ -120,34 +120,28 @@ describe('Job table app', () => { }); it('should refetch jobs query on fetchJobsByStatus event', async () => { - jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn()); - - expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); + expect(successHandler).toHaveBeenCalledTimes(1); await findTabs().vm.$emit('fetchJobsByStatus'); - expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1); + expect(successHandler).toHaveBeenCalledTimes(2); }); it('avoids refetch jobs query when scope has not changed', async () => { - jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn()); - - expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); + expect(successHandler).toHaveBeenCalledTimes(1); await findTabs().vm.$emit('fetchJobsByStatus', null); - expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); + expect(successHandler).toHaveBeenCalledTimes(1); }); it('should refetch jobs count query when the amount jobs and count do not match', async () => { - jest.spyOn(wrapper.vm.$apollo.queries.jobsCount, 'refetch').mockImplementation(jest.fn()); - - expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(0); + expect(countSuccessHandler).toHaveBeenCalledTimes(1); // after applying filter a new count is fetched findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); - expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(1); + expect(successHandler).toHaveBeenCalledTimes(2); // tab is switched to `finished`, no count await findTabs().vm.$emit('fetchJobsByStatus', ['FAILED', 'SUCCESS', 'CANCELED']); @@ -155,7 +149,7 @@ describe('Job table app', () => { // tab is switched back to `all`, the old filter count has to be overwritten with new count await findTabs().vm.$emit('fetchJobsByStatus', null); - expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(2); + expect(successHandler).toHaveBeenCalledTimes(4); }); describe('when infinite scrolling is triggered', () => { @@ -313,25 +307,21 @@ describe('Job table app', () => { it('refetches jobs query when filtering', async () => { createComponent(); - jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn()); - - expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); + expect(successHandler).toHaveBeenCalledTimes(1); await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); - expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1); + expect(successHandler).toHaveBeenCalledTimes(2); }); it('refetches jobs count query when filtering', async () => { createComponent(); - jest.spyOn(wrapper.vm.$apollo.queries.jobsCount, 'refetch').mockImplementation(jest.fn()); - - expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(0); + expect(countSuccessHandler).toHaveBeenCalledTimes(1); await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); - expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(1); + expect(countSuccessHandler).toHaveBeenCalledTimes(2); }); it('shows raw text warning when user inputs raw text', async () => { @@ -342,14 +332,14 @@ describe('Job table app', () => { createComponent(); - jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn()); - jest.spyOn(wrapper.vm.$apollo.queries.jobsCount, 'refetch').mockImplementation(jest.fn()); + expect(successHandler).toHaveBeenCalledTimes(1); + expect(countSuccessHandler).toHaveBeenCalledTimes(1); await findFilteredSearch().vm.$emit('filterJobsBySearch', ['raw text']); expect(createAlert).toHaveBeenCalledWith(expectedWarning); - expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); - expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(0); + expect(successHandler).toHaveBeenCalledTimes(1); + expect(countSuccessHandler).toHaveBeenCalledTimes(1); }); it('updates URL query string when filtering jobs by status', async () => { diff --git a/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js b/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js index b308d6305da..23fa4739645 100644 --- a/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js @@ -113,7 +113,7 @@ describe('ProjectNamespace component', () => { }); it('displays fetched namespaces', () => { - const listItems = wrapper.findAll('li'); + const listItems = wrapper.findAll('[role="option"]'); expect(listItems).toHaveLength(2); expect(listItems.at(0).text()).toBe(data.project.forkTargets.nodes[0].fullPath); expect(listItems.at(1).text()).toBe(data.project.forkTargets.nodes[1].fullPath); diff --git a/spec/frontend/pages/projects/shared/permissions/components/ci_catalog_settings_spec.js b/spec/frontend/pages/projects/shared/permissions/components/ci_catalog_settings_spec.js new file mode 100644 index 00000000000..4ac3a511fa2 --- /dev/null +++ b/spec/frontend/pages/projects/shared/permissions/components/ci_catalog_settings_spec.js @@ -0,0 +1,147 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlBadge, GlLoadingIcon, GlModal, GlSprintf, GlToggle } from '@gitlab/ui'; + +import { createAlert } from '~/alert'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; + +import catalogResourcesCreate from '~/pages/projects/shared/permissions/graphql/mutations/catalog_resources_create.mutation.graphql'; +import getCiCatalogSettingsQuery from '~/pages/projects/shared/permissions/graphql/queries/get_ci_catalog_settings.query.graphql'; +import CiCatalogSettings, { + i18n, +} from '~/pages/projects/shared/permissions/components/ci_catalog_settings.vue'; + +import { mockCiCatalogSettingsResponse } from './mock_data'; + +Vue.use(VueApollo); +jest.mock('~/alert'); + +describe('CiCatalogSettings', () => { + let wrapper; + let ciCatalogSettingsResponse; + let catalogResourcesCreateResponse; + + const fullPath = 'gitlab-org/gitlab'; + + const createComponent = ({ ciCatalogSettingsHandler = ciCatalogSettingsResponse } = {}) => { + const handlers = [ + [getCiCatalogSettingsQuery, ciCatalogSettingsHandler], + [catalogResourcesCreate, catalogResourcesCreateResponse], + ]; + const mockApollo = createMockApollo(handlers); + + wrapper = shallowMountExtended(CiCatalogSettings, { + propsData: { + fullPath, + }, + stubs: { + GlSprintf, + }, + apolloProvider: mockApollo, + }); + + return waitForPromises(); + }; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findBadge = () => wrapper.findComponent(GlBadge); + const findModal = () => wrapper.findComponent(GlModal); + const findToggle = () => wrapper.findComponent(GlToggle); + + const findCiCatalogSettings = () => wrapper.findByTestId('ci-catalog-settings'); + + beforeEach(() => { + ciCatalogSettingsResponse = jest.fn().mockResolvedValue(mockCiCatalogSettingsResponse); + catalogResourcesCreateResponse = jest.fn(); + }); + + describe('when initial queries are loading', () => { + beforeEach(() => { + createComponent(); + }); + + it('shows a loading icon and no CI catalog settings', () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(findCiCatalogSettings().exists()).toBe(false); + }); + }); + + describe('when queries have loaded', () => { + beforeEach(async () => { + await createComponent(); + }); + + it('does not show a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders the CI Catalog settings', () => { + expect(findCiCatalogSettings().exists()).toBe(true); + }); + + it('renders the experiment badge', () => { + expect(findBadge().exists()).toBe(true); + }); + + it('renders the toggle', () => { + expect(findToggle().exists()).toBe(true); + }); + + it('renders the modal', () => { + expect(findModal().exists()).toBe(true); + expect(findModal().attributes('title')).toBe(i18n.modal.title); + }); + + describe('when queries have loaded', () => { + beforeEach(() => { + catalogResourcesCreateResponse.mockResolvedValue(mockCiCatalogSettingsResponse); + }); + + it('shows the modal when the toggle is clicked', async () => { + expect(findModal().props('visible')).toBe(false); + + await findToggle().vm.$emit('change', true); + + expect(findModal().props('visible')).toBe(true); + expect(findModal().props('actionPrimary').text).toBe(i18n.modal.actionPrimary.text); + }); + + it('hides the modal when cancel is clicked', () => { + findToggle().vm.$emit('change', true); + findModal().vm.$emit('canceled'); + + expect(findModal().props('visible')).toBe(false); + expect(catalogResourcesCreateResponse).not.toHaveBeenCalled(); + }); + + it('calls the mutation with the correct input from the modal click', async () => { + expect(catalogResourcesCreateResponse).toHaveBeenCalledTimes(0); + + findToggle().vm.$emit('change', true); + findModal().vm.$emit('primary'); + await waitForPromises(); + + expect(catalogResourcesCreateResponse).toHaveBeenCalledTimes(1); + expect(catalogResourcesCreateResponse).toHaveBeenCalledWith({ + input: { + projectPath: fullPath, + }, + }); + }); + }); + }); + + describe('when the query is unsuccessful', () => { + const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + + it('throws an error', async () => { + await createComponent({ ciCatalogSettingsHandler: failedHandler }); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ message: i18n.catalogResourceQueryError }); + }); + }); +}); diff --git a/spec/frontend/pages/projects/shared/permissions/components/mock_data.js b/spec/frontend/pages/projects/shared/permissions/components/mock_data.js new file mode 100644 index 00000000000..44bbf2a5eb2 --- /dev/null +++ b/spec/frontend/pages/projects/shared/permissions/components/mock_data.js @@ -0,0 +1,7 @@ +export const mockCiCatalogSettingsResponse = { + data: { + catalogResourcesCreate: { + errors: [], + }, + }, +}; diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js index a7a1e649cd0..02e510c9541 100644 --- a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js +++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js @@ -1,6 +1,7 @@ import { GlSprintf, GlToggle } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; import ProjectFeatureSetting from '~/pages/projects/shared/permissions/components/project_feature_setting.vue'; +import CiCatalogSettings from '~/pages/projects/shared/permissions/components/ci_catalog_settings.vue'; import settingsPanel from '~/pages/projects/shared/permissions/components/settings_panel.vue'; import { featureAccessLevel, @@ -24,7 +25,6 @@ const defaultProps = { buildsAccessLevel: 20, wikiAccessLevel: 20, snippetsAccessLevel: 20, - metricsDashboardAccessLevel: 20, pagesAccessLevel: 10, analyticsAccessLevel: 20, containerRegistryAccessLevel: 20, @@ -35,6 +35,7 @@ const defaultProps = { warnAboutPotentiallyUnwantedCharacters: true, }, isGitlabCom: true, + canAddCatalogResource: false, canDisableEmails: true, canChangeVisibilityLevel: true, allowedVisibilityOptions: [0, 10, 20], @@ -119,6 +120,7 @@ describe('Settings Panel', () => { const findPagesSettings = () => wrapper.findComponent({ ref: 'pages-settings' }); const findPagesAccessLevels = () => wrapper.find('[name="project[project_feature_attributes][pages_access_level]"]'); + const findCiCatalogSettings = () => wrapper.findComponent(CiCatalogSettings); const findEmailSettings = () => wrapper.findComponent({ ref: 'email-settings' }); const findShowDefaultAwardEmojis = () => wrapper.find('input[name="project[project_setting_attributes][show_default_award_emojis]"]'); @@ -126,10 +128,6 @@ describe('Settings Panel', () => { wrapper.find( 'input[name="project[project_setting_attributes][warn_about_potentially_unwanted_characters]"]', ); - const findMetricsVisibilitySettings = () => - wrapper.findComponent({ ref: 'metrics-visibility-settings' }); - const findMetricsVisibilityInput = () => - findMetricsVisibilitySettings().findComponent(ProjectFeatureSetting); const findConfirmDangerButton = () => wrapper.findComponent(ConfirmDanger); const findEnvironmentsSettings = () => wrapper.findComponent({ ref: 'environments-settings' }); const findFeatureFlagsSettings = () => wrapper.findComponent({ ref: 'feature-flags-settings' }); @@ -137,8 +135,8 @@ describe('Settings Panel', () => { wrapper.findComponent({ ref: 'infrastructure-settings' }); const findReleasesSettings = () => wrapper.findComponent({ ref: 'environments-settings' }); const findMonitorSettings = () => wrapper.findComponent({ ref: 'monitor-settings' }); - const findMonitorVisibilityInput = () => - findMonitorSettings().findComponent(ProjectFeatureSetting); + const findModelExperimentsSettings = () => + wrapper.findComponent({ ref: 'model-experiments-settings' }); describe('Project Visibility', () => { it('should set the project visibility help path', () => { @@ -652,6 +650,19 @@ describe('Settings Panel', () => { }); }); + describe('CI Catalog Settings', () => { + it('should show the CI Catalog settings if user has permission', () => { + wrapper = mountComponent({ canAddCatalogResource: true }); + + expect(findCiCatalogSettings().exists()).toBe(true); + }); + it('should not show the CI Catalog settings if user does not have permission', () => { + wrapper = mountComponent(); + + expect(findCiCatalogSettings().exists()).toBe(false); + }); + }); + describe('Email notifications', () => { it('should show the disable email notifications input if emails an be disabled', () => { wrapper = mountComponent({ canDisableEmails: true }); @@ -682,69 +693,6 @@ describe('Settings Panel', () => { }); }); - describe('Metrics dashboard', () => { - it('should show the metrics dashboard access select', () => { - wrapper = mountComponent(); - - expect(findMetricsVisibilitySettings().exists()).toBe(true); - }); - - it('should contain help text', () => { - wrapper = mountComponent(); - - expect(findMetricsVisibilitySettings().props('helpText')).toBe( - "Visualize the project's performance metrics.", - ); - }); - - it.each` - before | after - ${featureAccessLevel.NOT_ENABLED} | ${featureAccessLevel.EVERYONE} - ${featureAccessLevel.NOT_ENABLED} | ${featureAccessLevel.PROJECT_MEMBERS} - ${featureAccessLevel.EVERYONE} | ${featureAccessLevel.PROJECT_MEMBERS} - ${featureAccessLevel.EVERYONE} | ${featureAccessLevel.NOT_ENABLED} - ${featureAccessLevel.PROJECT_MEMBERS} | ${featureAccessLevel.NOT_ENABLED} - `( - 'when updating Monitor access level from `$before` to `$after`, Metric Dashboard access is updated to `$after` as well', - async ({ before, after }) => { - wrapper = mountComponent({ - currentSettings: { monitorAccessLevel: before, metricsDashboardAccessLevel: before }, - }); - - await findMonitorVisibilityInput().vm.$emit('change', after); - - expect(findMetricsVisibilityInput().props('value')).toBe(after); - }, - ); - - it('when updating Monitor access level from `10` to `20`, Metric Dashboard access is not increased', async () => { - wrapper = mountComponent({ - currentSettings: { - monitorAccessLevel: featureAccessLevel.PROJECT_MEMBERS, - metricsDashboardAccessLevel: featureAccessLevel.PROJECT_MEMBERS, - }, - }); - - await findMonitorVisibilityInput().vm.$emit('change', featureAccessLevel.EVERYONE); - - expect(findMetricsVisibilityInput().props('value')).toBe(featureAccessLevel.PROJECT_MEMBERS); - }); - - it('should reduce Metrics visibility level when visibility is set to private', async () => { - wrapper = mountComponent({ - currentSettings: { - visibilityLevel: VISIBILITY_LEVEL_PUBLIC_INTEGER, - monitorAccessLevel: featureAccessLevel.EVERYONE, - metricsDashboardAccessLevel: featureAccessLevel.EVERYONE, - }, - }); - - await findProjectVisibilityLevelInput().setValue(VISIBILITY_LEVEL_PRIVATE_INTEGER); - - expect(findMetricsVisibilityInput().props('value')).toBe(featureAccessLevel.PROJECT_MEMBERS); - }); - }); - describe('Analytics', () => { it('should show the analytics toggle', () => { wrapper = mountComponent(); @@ -794,12 +742,12 @@ describe('Settings Panel', () => { expectedAccessLevel, ); }); - it('when monitorAccessLevel is for project members, it is also for everyone', () => { - wrapper = mountComponent({ - currentSettings: { monitorAccessLevel: featureAccessLevel.PROJECT_MEMBERS }, - }); + }); + describe('Model experiments', () => { + it('shows model experiments toggle', () => { + wrapper = mountComponent({}); - expect(findMetricsVisibilityInput().props('value')).toBe(featureAccessLevel.EVERYONE); + expect(findModelExperimentsSettings().exists()).toBe(true); }); }); }); 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 ddaa3df71e8..1a3eb86a00e 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -14,6 +14,7 @@ import { WIKI_FORMAT_LABEL, WIKI_FORMAT_UPDATED_ACTION, } from '~/pages/shared/wikis/constants'; +import { DRAWIO_ORIGIN } from 'spec/test_constants'; jest.mock('~/emoji'); @@ -69,12 +70,12 @@ describe('WikiForm', () => { AsciiDoc: 'asciidoc', Org: 'org', }; - function createWrapper({ mountFn = shallowMount, persisted = false, pageInfo, glFeatures = { wikiSwitchBetweenContentEditorRawMarkdown: false }, + provide = { drawioUrl: null }, } = {}) { wrapper = extendedWrapper( mountFn(WikiForm, { @@ -85,6 +86,7 @@ describe('WikiForm', () => { ...(persisted ? pageInfoPersisted : pageInfoNew), ...pageInfo, }, + ...provide, }, stubs: { GlAlert, @@ -334,4 +336,20 @@ describe('WikiForm', () => { }); }); }); + + describe('when drawioURL is provided', () => { + it('enables drawio editor in the Markdown Editor', () => { + createWrapper({ provide: { drawioUrl: DRAWIO_ORIGIN } }); + + expect(findMarkdownEditor().props().drawioEnabled).toBe(true); + }); + }); + + describe('when drawioURL is empty', () => { + it('disables drawio editor in the Markdown Editor', () => { + createWrapper(); + + expect(findMarkdownEditor().props().drawioEnabled).toBe(false); + }); + }); }); diff --git a/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap deleted file mode 100644 index 724ec7366d3..00000000000 --- a/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap +++ /dev/null @@ -1,471 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`DAG visualization parsing utilities generateColumnsFromLayersList matches the snapshot 1`] = ` -Array [ - Object { - "groups": Array [ - Object { - "__typename": "CiGroup", - "id": "4", - "jobs": Array [ - Object { - "__typename": "CiJob", - "id": "6", - "kind": "BUILD", - "name": "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", - "needs": Array [], - "previousStageJobsOrNeeds": Array [], - "scheduledAt": null, - "status": Object { - "__typename": "DetailedStatus", - "action": Object { - "__typename": "StatusAction", - "buttonTitle": "Retry this job", - "icon": "retry", - "id": "8", - "path": "/root/abcd-dag/-/jobs/1482/retry", - "title": "Retry", - }, - "detailsPath": "/root/abcd-dag/-/jobs/1482", - "group": "success", - "hasDetails": true, - "icon": "status_success", - "id": "7", - "label": "passed", - "tooltip": "passed", - }, - }, - ], - "name": "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", - "size": 1, - "stageName": "build", - "status": Object { - "__typename": "DetailedStatus", - "group": "success", - "icon": "status_success", - "id": "5", - "label": "passed", - }, - }, - Object { - "__typename": "CiGroup", - "id": "9", - "jobs": Array [ - Object { - "__typename": "CiJob", - "id": "11", - "kind": "BUILD", - "name": "build_b", - "needs": Array [], - "previousStageJobsOrNeeds": Array [], - "scheduledAt": null, - "status": Object { - "__typename": "DetailedStatus", - "action": Object { - "__typename": "StatusAction", - "buttonTitle": "Retry this job", - "icon": "retry", - "id": "13", - "path": "/root/abcd-dag/-/jobs/1515/retry", - "title": "Retry", - }, - "detailsPath": "/root/abcd-dag/-/jobs/1515", - "group": "success", - "hasDetails": true, - "icon": "status_success", - "id": "12", - "label": "passed", - "tooltip": "passed", - }, - }, - ], - "name": "build_b", - "size": 1, - "stageName": "build", - "status": Object { - "__typename": "DetailedStatus", - "group": "success", - "icon": "status_success", - "id": "10", - "label": "passed", - }, - }, - Object { - "__typename": "CiGroup", - "id": "14", - "jobs": Array [ - Object { - "__typename": "CiJob", - "id": "16", - "kind": "BUILD", - "name": "build_c", - "needs": Array [], - "previousStageJobsOrNeeds": Array [], - "scheduledAt": null, - "status": Object { - "__typename": "DetailedStatus", - "action": Object { - "__typename": "StatusAction", - "buttonTitle": "Retry this job", - "icon": "retry", - "id": "18", - "path": "/root/abcd-dag/-/jobs/1484/retry", - "title": "Retry", - }, - "detailsPath": "/root/abcd-dag/-/jobs/1484", - "group": "success", - "hasDetails": true, - "icon": "status_success", - "id": "17", - "label": "passed", - "tooltip": "passed", - }, - }, - ], - "name": "build_c", - "size": 1, - "stageName": "build", - "status": Object { - "__typename": "DetailedStatus", - "group": "success", - "icon": "status_success", - "id": "15", - "label": "passed", - }, - }, - Object { - "__typename": "CiGroup", - "id": "19", - "jobs": Array [ - Object { - "__typename": "CiJob", - "id": "21", - "kind": "BUILD", - "name": "build_d 1/3", - "needs": Array [], - "previousStageJobsOrNeeds": Array [], - "scheduledAt": null, - "status": Object { - "__typename": "DetailedStatus", - "action": Object { - "__typename": "StatusAction", - "buttonTitle": "Retry this job", - "icon": "retry", - "id": "23", - "path": "/root/abcd-dag/-/jobs/1485/retry", - "title": "Retry", - }, - "detailsPath": "/root/abcd-dag/-/jobs/1485", - "group": "success", - "hasDetails": true, - "icon": "status_success", - "id": "22", - "label": "passed", - "tooltip": "passed", - }, - }, - Object { - "__typename": "CiJob", - "id": "24", - "kind": "BUILD", - "name": "build_d 2/3", - "needs": Array [], - "previousStageJobsOrNeeds": Array [], - "scheduledAt": null, - "status": Object { - "__typename": "DetailedStatus", - "action": Object { - "__typename": "StatusAction", - "buttonTitle": "Retry this job", - "icon": "retry", - "id": "26", - "path": "/root/abcd-dag/-/jobs/1486/retry", - "title": "Retry", - }, - "detailsPath": "/root/abcd-dag/-/jobs/1486", - "group": "success", - "hasDetails": true, - "icon": "status_success", - "id": "25", - "label": "passed", - "tooltip": "passed", - }, - }, - Object { - "__typename": "CiJob", - "id": "27", - "kind": "BUILD", - "name": "build_d 3/3", - "needs": Array [], - "previousStageJobsOrNeeds": Array [], - "scheduledAt": null, - "status": Object { - "__typename": "DetailedStatus", - "action": Object { - "__typename": "StatusAction", - "buttonTitle": "Retry this job", - "icon": "retry", - "id": "29", - "path": "/root/abcd-dag/-/jobs/1487/retry", - "title": "Retry", - }, - "detailsPath": "/root/abcd-dag/-/jobs/1487", - "group": "success", - "hasDetails": true, - "icon": "status_success", - "id": "28", - "label": "passed", - "tooltip": "passed", - }, - }, - ], - "name": "build_d", - "size": 3, - "stageName": "build", - "status": Object { - "__typename": "DetailedStatus", - "group": "success", - "icon": "status_success", - "id": "20", - "label": "passed", - }, - }, - Object { - "__typename": "CiGroup", - "id": "57", - "jobs": Array [ - Object { - "__typename": "CiJob", - "id": "59", - "kind": "BUILD", - "name": "test_c", - "needs": Array [], - "previousStageJobsOrNeeds": Array [], - "scheduledAt": null, - "status": Object { - "__typename": "DetailedStatus", - "action": null, - "detailsPath": "/root/kinder-pipe/-/pipelines/154", - "group": "success", - "hasDetails": true, - "icon": "status_success", - "id": "60", - "label": null, - "tooltip": null, - }, - }, - ], - "name": "test_c", - "size": 1, - "stageName": "test", - "status": Object { - "__typename": "DetailedStatus", - "group": "success", - "icon": "status_success", - "id": "58", - "label": null, - }, - }, - ], - "id": "layer-0", - "name": "", - "status": Object { - "action": null, - }, - }, - Object { - "groups": Array [ - Object { - "__typename": "CiGroup", - "id": "32", - "jobs": Array [ - Object { - "__typename": "CiJob", - "id": "34", - "kind": "BUILD", - "name": "test_a", - "needs": Array [ - "build_c", - "build_b", - "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", - ], - "previousStageJobsOrNeeds": Array [ - "build_c", - "build_b", - "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", - ], - "scheduledAt": null, - "status": Object { - "__typename": "DetailedStatus", - "action": Object { - "__typename": "StatusAction", - "buttonTitle": "Retry this job", - "icon": "retry", - "id": "36", - "path": "/root/abcd-dag/-/jobs/1514/retry", - "title": "Retry", - }, - "detailsPath": "/root/abcd-dag/-/jobs/1514", - "group": "success", - "hasDetails": true, - "icon": "status_success", - "id": "35", - "label": "passed", - "tooltip": "passed", - }, - }, - ], - "name": "test_a", - "size": 1, - "stageName": "test", - "status": Object { - "__typename": "DetailedStatus", - "group": "success", - "icon": "status_success", - "id": "33", - "label": "passed", - }, - }, - Object { - "__typename": "CiGroup", - "id": "40", - "jobs": Array [ - Object { - "__typename": "CiJob", - "id": "42", - "kind": "BUILD", - "name": "test_b 1/2", - "needs": Array [ - "build_d 3/3", - "build_d 2/3", - "build_d 1/3", - "build_b", - "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", - ], - "previousStageJobsOrNeeds": Array [ - "build_d 3/3", - "build_d 2/3", - "build_d 1/3", - "build_b", - "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", - ], - "scheduledAt": null, - "status": Object { - "__typename": "DetailedStatus", - "action": Object { - "__typename": "StatusAction", - "buttonTitle": "Retry this job", - "icon": "retry", - "id": "44", - "path": "/root/abcd-dag/-/jobs/1489/retry", - "title": "Retry", - }, - "detailsPath": "/root/abcd-dag/-/jobs/1489", - "group": "success", - "hasDetails": true, - "icon": "status_success", - "id": "43", - "label": "passed", - "tooltip": "passed", - }, - }, - Object { - "__typename": "CiJob", - "id": "67", - "kind": "BUILD", - "name": "test_b 2/2", - "needs": Array [ - "build_d 3/3", - "build_d 2/3", - "build_d 1/3", - "build_b", - "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", - ], - "previousStageJobsOrNeeds": Array [ - "build_d 3/3", - "build_d 2/3", - "build_d 1/3", - "build_b", - "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", - ], - "scheduledAt": null, - "status": Object { - "__typename": "DetailedStatus", - "action": Object { - "__typename": "StatusAction", - "buttonTitle": "Retry this job", - "icon": "retry", - "id": "51", - "path": "/root/abcd-dag/-/jobs/1490/retry", - "title": "Retry", - }, - "detailsPath": "/root/abcd-dag/-/jobs/1490", - "group": "success", - "hasDetails": true, - "icon": "status_success", - "id": "50", - "label": "passed", - "tooltip": "passed", - }, - }, - ], - "name": "test_b", - "size": 2, - "stageName": "test", - "status": Object { - "__typename": "DetailedStatus", - "group": "success", - "icon": "status_success", - "id": "41", - "label": "passed", - }, - }, - Object { - "__typename": "CiGroup", - "id": "61", - "jobs": Array [ - Object { - "__typename": "CiJob", - "id": "53", - "kind": "BUILD", - "name": "test_d", - "needs": Array [ - "build_b", - ], - "previousStageJobsOrNeeds": Array [ - "build_b", - ], - "scheduledAt": null, - "status": Object { - "__typename": "DetailedStatus", - "action": null, - "detailsPath": "/root/abcd-dag/-/pipelines/153", - "group": "success", - "hasDetails": true, - "icon": "status_success", - "id": "64", - "label": null, - "tooltip": null, - }, - }, - ], - "name": "test_d", - "size": 1, - "stageName": "test", - "status": Object { - "__typename": "DetailedStatus", - "group": "success", - "icon": "status_success", - "id": "62", - "label": null, - }, - }, - ], - "id": "layer-1", - "name": "", - "status": Object { - "action": null, - }, - }, -] -`; diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph_spec.js new file mode 100644 index 00000000000..69b223461bd --- /dev/null +++ b/spec/frontend/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph_spec.js @@ -0,0 +1,123 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlLoadingIcon } from '@gitlab/ui'; + +import { createAlert } from '~/alert'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; + +import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql'; +import getPipelineStagesQuery from '~/pipelines/graphql/queries/get_pipeline_stages.query.graphql'; +import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; +import GraphqlPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue'; +import * as sharedGraphQlUtils from '~/graphql_shared/utils'; + +import { + linkedPipelinesFetchError, + stagesFetchError, + mockPipelineStagesQueryResponse, + mockUpstreamDownstreamQueryResponse, +} from './mock_data'; + +Vue.use(VueApollo); +jest.mock('~/alert'); + +describe('GraphqlPipelineMiniGraph', () => { + let wrapper; + let linkedPipelinesResponse; + let pipelineStagesResponse; + + const fullPath = 'gitlab-org/gitlab'; + const iid = '315'; + const pipelineEtag = '/api/graphql:pipelines/id/315'; + + const createComponent = ({ + pipelineStagesHandler = pipelineStagesResponse, + linkedPipelinesHandler = linkedPipelinesResponse, + } = {}) => { + const handlers = [ + [getLinkedPipelinesQuery, linkedPipelinesHandler], + [getPipelineStagesQuery, pipelineStagesHandler], + ]; + const mockApollo = createMockApollo(handlers); + + wrapper = shallowMountExtended(GraphqlPipelineMiniGraph, { + propsData: { + fullPath, + iid, + pipelineEtag, + }, + apolloProvider: mockApollo, + }); + + return waitForPromises(); + }; + + const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + beforeEach(() => { + linkedPipelinesResponse = jest.fn().mockResolvedValue(mockUpstreamDownstreamQueryResponse); + pipelineStagesResponse = jest.fn().mockResolvedValue(mockPipelineStagesQueryResponse); + }); + + describe('when initial queries are loading', () => { + beforeEach(() => { + createComponent(); + }); + + it('shows a loading icon and no mini graph', () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(findPipelineMiniGraph().exists()).toBe(false); + }); + }); + + describe('when queries have loaded', () => { + it('does not show a loading icon', async () => { + await createComponent(); + + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders the Pipeline Mini Graph', async () => { + await createComponent(); + + expect(findPipelineMiniGraph().exists()).toBe(true); + }); + + it('fires the queries', async () => { + await createComponent(); + + expect(linkedPipelinesResponse).toHaveBeenCalledWith({ iid, fullPath }); + expect(pipelineStagesResponse).toHaveBeenCalledWith({ iid, fullPath }); + }); + }); + + describe('polling', () => { + it('toggles query polling with visibility check', async () => { + jest.spyOn(sharedGraphQlUtils, 'toggleQueryPollingByVisibility'); + + createComponent(); + + await waitForPromises(); + + expect(sharedGraphQlUtils.toggleQueryPollingByVisibility).toHaveBeenCalledTimes(2); + }); + }); + + describe('when pipeline queries are unsuccessful', () => { + const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + it.each` + query | handlerName | errorMessage + ${'pipeline stages'} | ${'pipelineStagesHandler'} | ${stagesFetchError} + ${'linked pipelines'} | ${'linkedPipelinesHandler'} | ${linkedPipelinesFetchError} + `('throws an error for the $query query', async ({ errorMessage, handlerName }) => { + await createComponent({ [handlerName]: failedHandler }); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ message: errorMessage }); + }); + }); +}); diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/mock_data.js b/spec/frontend/pipelines/components/pipeline_mini_graph/mock_data.js new file mode 100644 index 00000000000..1c13e9eb62b --- /dev/null +++ b/spec/frontend/pipelines/components/pipeline_mini_graph/mock_data.js @@ -0,0 +1,150 @@ +export const mockDownstreamPipelinesGraphql = ({ includeSourceJobRetried = true } = {}) => ({ + nodes: [ + { + id: 'gid://gitlab/Ci::Pipeline/612', + path: '/root/job-log-sections/-/pipelines/612', + project: { + id: 'gid://gitlab/Project/21', + name: 'job-log-sections', + __typename: 'Project', + }, + detailedStatus: { + id: 'success-612-612', + group: 'success', + icon: 'status_success', + label: 'passed', + __typename: 'DetailedStatus', + }, + sourceJob: { + id: 'gid://gitlab/Ci::Bridge/532', + retried: includeSourceJobRetried ? false : null, + }, + __typename: 'Pipeline', + }, + { + id: 'gid://gitlab/Ci::Pipeline/611', + path: '/root/job-log-sections/-/pipelines/611', + project: { + id: 'gid://gitlab/Project/21', + name: 'job-log-sections', + __typename: 'Project', + }, + detailedStatus: { + id: 'success-611-611', + group: 'success', + icon: 'status_success', + label: 'passed', + __typename: 'DetailedStatus', + }, + sourceJob: { + id: 'gid://gitlab/Ci::Bridge/531', + retried: includeSourceJobRetried ? true : null, + }, + __typename: 'Pipeline', + }, + { + id: 'gid://gitlab/Ci::Pipeline/609', + path: '/root/job-log-sections/-/pipelines/609', + project: { + id: 'gid://gitlab/Project/21', + name: 'job-log-sections', + __typename: 'Project', + }, + detailedStatus: { + id: 'success-609-609', + group: 'success', + icon: 'status_success', + label: 'passed', + __typename: 'DetailedStatus', + }, + sourceJob: { + id: 'gid://gitlab/Ci::Bridge/530', + retried: includeSourceJobRetried ? true : null, + }, + __typename: 'Pipeline', + }, + ], + __typename: 'PipelineConnection', +}); + +const upstream = { + id: 'gid://gitlab/Ci::Pipeline/610', + path: '/root/trigger-downstream/-/pipelines/610', + project: { + id: 'gid://gitlab/Project/21', + name: 'trigger-downstream', + __typename: 'Project', + }, + detailedStatus: { + id: 'success-610-610', + group: 'success', + icon: 'status_success', + label: 'passed', + __typename: 'DetailedStatus', + }, + __typename: 'Pipeline', +}; + +export const mockPipelineStagesQueryResponse = { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/320', + stages: { + nodes: [ + { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/409', + name: 'build', + detailedStatus: { + __typename: 'DetailedStatus', + id: 'success-409-409', + icon: 'status_success', + group: 'success', + }, + }, + ], + }, + }, + }, + }, +}; + +export const mockPipelineStatusResponse = { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/320', + detailedStatus: { + id: 'pending-320-320', + detailsPath: '/root/ci-project/-/pipelines/320', + icon: 'status_pending', + group: 'pending', + __typename: 'DetailedStatus', + }, + __typename: 'Pipeline', + }, + __typename: 'Project', + }, + }, +}; + +export const mockUpstreamDownstreamQueryResponse = { + data: { + project: { + id: '1', + pipeline: { + id: 'pipeline-1', + path: '/root/ci-project/-/pipelines/790', + downstream: mockDownstreamPipelinesGraphql(), + upstream, + }, + __typename: 'Project', + }, + }, +}; + +export const linkedPipelinesFetchError = 'There was a problem fetching linked pipelines.'; +export const stagesFetchError = 'There was a problem fetching the pipeline stages.'; diff --git a/spec/frontend/pipelines/components/pipelines_list/failure_widget/mock.js b/spec/frontend/pipelines/components/pipelines_list/failure_widget/mock.js new file mode 100644 index 00000000000..a4c90fa3876 --- /dev/null +++ b/spec/frontend/pipelines/components/pipelines_list/failure_widget/mock.js @@ -0,0 +1,45 @@ +export const job = { + id: 'gid://gitlab/Ci::Build/5241', + allowFailure: false, + detailedStatus: { + id: 'status', + action: { + id: 'action', + path: '/retry', + icon: 'retry', + }, + group: 'running', + icon: 'running-icon', + }, + name: 'job-name', + retried: false, + stage: { + id: '1', + name: 'build', + }, + trace: { + htmlSummary: + '<span>To install the missing version, run `gem install bundler:2.4.13`<br/>\tfrom /System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/rubygems.rb:302:in `activate_bin_path\'<br/>\tfrom /usr/bin/bundle:23:in `<main>\'<br/></span><div class="section-start" data-timestamp="1685044123" data-section="upload-artifacts-on-failure" role="button"></div><span class="term-fg-l-cyan term-bold section section-header js-s-upload-artifacts-on-failure">Uploading artifacts for failed job</span><span class="section section-header js-s-upload-artifacts-on-failure"><br/></span><span class="term-fg-l-green term-bold section line js-s-upload-artifacts-on-failure">Uploading artifacts...</span><span class="section line js-s-upload-artifacts-on-failure"><br/>Runtime platform </span><span class="section line js-s-upload-artifacts-on-failure"> arch</span><span class="section line js-s-upload-artifacts-on-failure">=arm64 os</span><span class="section line js-s-upload-artifacts-on-failure">=darwin pid</span><span class="section line js-s-upload-artifacts-on-failure">=16706 revision</span><span class="section line js-s-upload-artifacts-on-failure">=43b2dc3d version</span><span class="section line js-s-upload-artifacts-on-failure">=15.4.0<br/></span><span class="term-fg-yellow section line js-s-upload-artifacts-on-failure">WARNING: rspec.xml: no matching files. Ensure that the artifact path is relative to the working directory</span><span class="section line js-s-upload-artifacts-on-failure"> <br/></span><span class="term-fg-l-red term-bold section line js-s-upload-artifacts-on-failure">ERROR: No files to upload </span><span class="section line js-s-upload-artifacts-on-failure"> <br/></span><div class="section-end" data-section="upload-artifacts-on-failure"></div><span class="term-fg-l-red term-bold">ERROR: Job failed: exit status 1<br/></span><span><br/></span>', + }, + webPath: '/', +}; + +export const allowedToFailJob = { + ...job, + id: 'gid://gitlab/Ci::Build/5242', + allowFailure: true, +}; + +export const failedJobsMock = { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + id: 'gid://gitlab/Pipeline/20', + jobs: { + nodes: [allowedToFailJob, job], + }, + }, + }, + }, +}; diff --git a/spec/frontend/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js b/spec/frontend/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js new file mode 100644 index 00000000000..df6d114f683 --- /dev/null +++ b/spec/frontend/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js @@ -0,0 +1,144 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; + +import { GlButton, GlIcon, GlLoadingIcon, GlPopover } from '@gitlab/ui'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import PipelineFailedJobsWidget from '~/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue'; +import { createAlert } from '~/alert'; +import WidgetFailedJobRow from '~/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue'; +import * as utils from '~/pipelines/components/pipelines_list/failure_widget/utils'; +import getPipelineFailedJobs from '~/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql'; +import { failedJobsMock } from './mock'; + +Vue.use(VueApollo); +jest.mock('~/alert'); + +describe('PipelineFailedJobsWidget component', () => { + let wrapper; + let mockFailedJobsResponse; + + const defaultProps = { + pipelineIid: 1, + pipelinePath: '/pipelines/1', + }; + + const defaultProvide = { + fullPath: 'namespace/project/', + }; + + const createComponent = ({ props = {}, provide } = {}) => { + const handlers = [[getPipelineFailedJobs, mockFailedJobsResponse]]; + const mockApollo = createMockApollo(handlers); + + wrapper = shallowMountExtended(PipelineFailedJobsWidget, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + ...defaultProvide, + ...provide, + }, + apolloProvider: mockApollo, + }); + }; + + const findAllHeaders = () => wrapper.findAllByTestId('header'); + const findFailedJobsButton = () => wrapper.findComponent(GlButton); + const findFailedJobRows = () => wrapper.findAllComponents(WidgetFailedJobRow); + const findInfoIcon = () => wrapper.findComponent(GlIcon); + const findInfoPopover = () => wrapper.findComponent(GlPopover); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + beforeEach(() => { + mockFailedJobsResponse = jest.fn(); + }); + + describe('ui', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the show failed jobs button', () => { + expect(findFailedJobsButton().exists()).toBe(true); + expect(findFailedJobsButton().text()).toBe('Show failed jobs'); + }); + + it('renders the info icon', () => { + expect(findInfoIcon().exists()).toBe(true); + }); + + it('renders the info popover', () => { + expect(findInfoPopover().exists()).toBe(true); + }); + + it('does not show the list of failed jobs', () => { + expect(findFailedJobRows()).toHaveLength(0); + }); + }); + + describe('when loading failed jobs', () => { + beforeEach(async () => { + mockFailedJobsResponse.mockResolvedValue(failedJobsMock); + createComponent(); + await findFailedJobsButton().vm.$emit('click'); + }); + + it('shows a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('when failed jobs have loaded', () => { + beforeEach(async () => { + mockFailedJobsResponse.mockResolvedValue(failedJobsMock); + jest.spyOn(utils, 'sortJobsByStatus'); + + createComponent(); + + await findFailedJobsButton().vm.$emit('click'); + await waitForPromises(); + }); + it('does not renders a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders table column', () => { + expect(findAllHeaders()).toHaveLength(3); + }); + + it('shows the list of failed jobs', () => { + expect(findFailedJobRows()).toHaveLength( + failedJobsMock.data.project.pipeline.jobs.nodes.length, + ); + }); + + it('calls sortJobsByStatus', () => { + expect(utils.sortJobsByStatus).toHaveBeenCalledWith( + failedJobsMock.data.project.pipeline.jobs.nodes, + ); + }); + }); + + describe('when an error occurs loading jobs', () => { + const errorMessage = "We couldn't fetch jobs for you because you are not qualified"; + + beforeEach(async () => { + mockFailedJobsResponse.mockRejectedValue({ message: errorMessage }); + + createComponent(); + + await findFailedJobsButton().vm.$emit('click'); + await waitForPromises(); + }); + it('does not renders a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('calls create Alert with the error message and danger variant', () => { + expect(createAlert).toHaveBeenCalledWith({ message: errorMessage, variant: 'danger' }); + }); + }); +}); diff --git a/spec/frontend/pipelines/components/pipelines_list/failure_widget/utils_spec.js b/spec/frontend/pipelines/components/pipelines_list/failure_widget/utils_spec.js new file mode 100644 index 00000000000..44f16478151 --- /dev/null +++ b/spec/frontend/pipelines/components/pipelines_list/failure_widget/utils_spec.js @@ -0,0 +1,58 @@ +import { + isFailedJob, + sortJobsByStatus, +} from '~/pipelines/components/pipelines_list/failure_widget/utils'; + +describe('isFailedJob', () => { + describe('when the job argument is undefined', () => { + it('returns false', () => { + expect(isFailedJob()).toBe(false); + }); + }); + + describe('when the job is of status `failed`', () => { + it('returns false', () => { + expect(isFailedJob({ detailedStatus: { group: 'success' } })).toBe(false); + }); + }); + + describe('when the job status is `failed`', () => { + it('returns true', () => { + expect(isFailedJob({ detailedStatus: { group: 'failed' } })).toBe(true); + }); + }); +}); + +describe('sortJobsByStatus', () => { + describe('when the arg is undefined', () => { + it('returns an empty array', () => { + expect(sortJobsByStatus()).toEqual([]); + }); + }); + + describe('when receiving an empty array', () => { + it('returns an empty array', () => { + expect(sortJobsByStatus([])).toEqual([]); + }); + }); + + describe('when reciving a list of jobs', () => { + const jobArr = [ + { detailedStatus: { group: 'failed' } }, + { detailedStatus: { group: 'allowed_to_fail' } }, + { detailedStatus: { group: 'failed' } }, + { detailedStatus: { group: 'success' } }, + ]; + + const expectedResult = [ + { detailedStatus: { group: 'failed' } }, + { detailedStatus: { group: 'failed' } }, + { detailedStatus: { group: 'allowed_to_fail' } }, + { detailedStatus: { group: 'success' } }, + ]; + + it('sorts failed jobs first', () => { + expect(sortJobsByStatus(jobArr)).toEqual(expectedResult); + }); + }); +}); diff --git a/spec/frontend/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row_spec.js b/spec/frontend/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row_spec.js new file mode 100644 index 00000000000..dfc2806840f --- /dev/null +++ b/spec/frontend/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row_spec.js @@ -0,0 +1,140 @@ +import { GlIcon, GlLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import WidgetFailedJobRow from '~/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue'; + +describe('WidgetFailedJobRow component', () => { + let wrapper; + + const defaultProps = { + job: { + id: 'gid://gitlab/Ci::Build/5240', + detailedStatus: { + group: 'running', + icon: 'icon_status_running', + }, + name: 'my-job', + stage: { + name: 'build', + }, + trace: { + htmlSummary: '<h1>job log</h1>', + }, + webpath: '/', + }, + }; + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMountExtended(WidgetFailedJobRow, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + const findArrowIcon = () => wrapper.findComponent(GlIcon); + const findJobCiStatus = () => wrapper.findComponent(CiIcon); + const findJobId = () => wrapper.findComponent(GlLink); + const findHiddenJobLog = () => wrapper.findByTestId('log-is-hidden'); + const findVisibleJobLog = () => wrapper.findByTestId('log-is-visible'); + const findJobName = () => wrapper.findByText(defaultProps.job.name); + const findRow = () => wrapper.findByTestId('widget-row'); + const findStageName = () => wrapper.findByText(defaultProps.job.stage.name); + + describe('ui', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the job name', () => { + expect(findJobName().exists()).toBe(true); + }); + + it('renders the stage name', () => { + expect(findStageName().exists()).toBe(true); + }); + + it('renders the job id as a link', () => { + const jobId = getIdFromGraphQLId(defaultProps.job.id); + + expect(findJobId().exists()).toBe(true); + expect(findJobId().text()).toContain(String(jobId)); + }); + + it('renders the ci status badge', () => { + expect(findJobCiStatus().exists()).toBe(true); + }); + + it('renders the right arrow', () => { + expect(findArrowIcon().props().name).toBe('chevron-right'); + }); + + it('does not renders the job lob', () => { + expect(findHiddenJobLog().exists()).toBe(true); + expect(findVisibleJobLog().exists()).toBe(false); + }); + }); + + describe('Job log', () => { + beforeEach(() => { + createComponent(); + }); + + describe('when clicking on the row', () => { + beforeEach(async () => { + await findRow().trigger('click'); + }); + + describe('while collapsed', () => { + it('expands the job log', () => { + expect(findHiddenJobLog().exists()).toBe(false); + expect(findVisibleJobLog().exists()).toBe(true); + }); + + it('renders the down arrow', () => { + expect(findArrowIcon().props().name).toBe('chevron-down'); + }); + + it('renders the received html', () => { + expect(findVisibleJobLog().html()).toContain(defaultProps.job.trace.htmlSummary); + }); + }); + + describe('while expanded', () => { + it('collapes the job log', async () => { + expect(findHiddenJobLog().exists()).toBe(false); + expect(findVisibleJobLog().exists()).toBe(true); + + await findRow().trigger('click'); + + expect(findHiddenJobLog().exists()).toBe(true); + expect(findVisibleJobLog().exists()).toBe(false); + }); + + it('renders the right arrow', async () => { + expect(findArrowIcon().props().name).toBe('chevron-down'); + + await findRow().trigger('click'); + + expect(findArrowIcon().props().name).toBe('chevron-right'); + }); + }); + }); + + describe('when clicking on a link element within the row', () => { + it('does not expands/collapse the job log', async () => { + expect(findHiddenJobLog().exists()).toBe(true); + expect(findVisibleJobLog().exists()).toBe(false); + expect(findArrowIcon().props().name).toBe('chevron-right'); + + await findJobId().vm.$emit('click'); + + expect(findHiddenJobLog().exists()).toBe(true); + expect(findVisibleJobLog().exists()).toBe(false); + expect(findArrowIcon().props().name).toBe('chevron-right'); + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js index 95207fd59ff..e9bce037800 100644 --- a/spec/frontend/pipelines/graph/graph_component_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { LAYER_VIEW, STAGE_VIEW } from '~/pipelines/components/graph/constants'; import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; @@ -7,11 +8,8 @@ import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; import { calculatePipelineLayersInfo } from '~/pipelines/components/graph/utils'; import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; -import { - generateResponse, - mockPipelineResponse, - pipelineWithUpstreamDownstream, -} from './mock_data'; + +import { generateResponse, pipelineWithUpstreamDownstream } from './mock_data'; describe('graph component', () => { let wrapper; diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js index cc952eac1d7..9599b5e6b7b 100644 --- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -2,6 +2,7 @@ import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle } from '@gitl import MockAdapter from 'axios-mock-adapter'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -26,7 +27,6 @@ import { import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; import PipelineGraphWrapper from '~/pipelines/components/graph/graph_component_wrapper.vue'; import GraphViewSelector from '~/pipelines/components/graph/graph_view_selector.vue'; -import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; import * as Api from '~/pipelines/components/graph_shared/api'; import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; import * as parsingUtils from '~/pipelines/components/parsing_utils'; @@ -34,7 +34,7 @@ import getPipelineHeaderData from '~/pipelines/graphql/queries/get_pipeline_head import * as sentryUtils from '~/pipelines/utils'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { mockRunningPipelineHeaderData } from '../mock_data'; -import { mapCallouts, mockCalloutsResponse, mockPipelineResponse } from './mock_data'; +import { mapCallouts, mockCalloutsResponse } from './mock_data'; const defaultProvide = { graphqlResourceEtag: 'frog/amphibirama/etag/', @@ -55,8 +55,6 @@ describe('Pipeline graph wrapper', () => { const findLinksLayer = () => wrapper.findComponent(LinksLayer); const findGraph = () => wrapper.findComponent(PipelineGraph); const findStageColumnTitle = () => wrapper.findByTestId('stage-column-title'); - const findAllStageColumnGroupsInColumn = () => - wrapper.findComponent(StageColumnComponent).findAll('[data-testid="stage-column-group"]'); const findViewSelector = () => wrapper.findComponent(GraphViewSelector); const findViewSelectorToggle = () => findViewSelector().findComponent(GlToggle); const findViewSelectorTrip = () => findViewSelector().findComponent(GlAlert); @@ -316,12 +314,10 @@ describe('Pipeline graph wrapper', () => { }); it('switches between views', async () => { - const groupsInFirstColumn = - mockPipelineResponse.data.project.pipeline.stages.nodes[0].groups.nodes.length; - expect(findAllStageColumnGroupsInColumn()).toHaveLength(groupsInFirstColumn); - expect(findStageColumnTitle().text()).toBe('build'); + expect(findStageColumnTitle().text()).toBe('deploy'); + await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW); - expect(findAllStageColumnGroupsInColumn()).toHaveLength(groupsInFirstColumn + 1); + expect(findStageColumnTitle().text()).toBe(''); }); @@ -507,9 +503,9 @@ describe('Pipeline graph wrapper', () => { }); describe('with metrics path', () => { - const duration = 875; - const numLinks = 7; - const totalGroups = 8; + const duration = 500; + const numLinks = 3; + const totalGroups = 7; const metricsData = { histograms: [ { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 }, @@ -559,9 +555,6 @@ describe('Pipeline graph wrapper', () => { createComponentWithApollo({ provide: { metricsPath, - glFeatures: { - pipelineGraphLayersView: true, - }, }, data: { currentViewType: LAYER_VIEW, diff --git a/spec/frontend/pipelines/graph/job_item_spec.js b/spec/frontend/pipelines/graph/job_item_spec.js index 2a5dfd7e0ee..8a8b0e9aa63 100644 --- a/spec/frontend/pipelines/graph/job_item_spec.js +++ b/spec/frontend/pipelines/graph/job_item_spec.js @@ -1,5 +1,4 @@ import MockAdapter from 'axios-mock-adapter'; -import { shallowMount, mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import { GlBadge, GlModal, GlToast } from '@gitlab/ui'; import JobItem from '~/pipelines/components/graph/job_item.vue'; @@ -7,7 +6,7 @@ import axios from '~/lib/utils/axios_utils'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import ActionComponent from '~/pipelines/components/jobs_shared/action_component.vue'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { delayedJob, mockJob, @@ -44,23 +43,21 @@ describe('pipeline graph job item', () => { job: mockJob, }; - const createWrapper = ({ props, data, mountFn = mount, mocks = {} } = {}) => { - wrapper = extendedWrapper( - mountFn(JobItem, { - data() { - return { - ...data, - }; - }, - propsData: { - ...defaultProps, - ...props, - }, - mocks: { - ...mocks, - }, - }), - ); + const createWrapper = ({ props, data, mountFn = mountExtended, mocks = {} } = {}) => { + wrapper = mountFn(JobItem, { + data() { + return { + ...data, + }; + }, + propsData: { + ...defaultProps, + ...props, + }, + mocks: { + ...mocks, + }, + }); }; const triggerActiveClass = 'gl-shadow-x0-y0-b3-s1-blue-500'; @@ -219,7 +216,7 @@ describe('pipeline graph job item', () => { }); expect(findJobWithLink().attributes('title')).toBe( - `delayed job - delayed manual action (${wrapper.vm.remainingTime})`, + `delayed job - delayed manual action (00:00:00)`, ); }); }); @@ -249,10 +246,7 @@ describe('pipeline graph job item', () => { beforeEach(async () => { createWrapper({ - mountFn: shallowMount, - data: { - currentSkipModalValue: true, - }, + mountFn: shallowMountExtended, props: { skipRetryModal: true, job: triggerJobWithRetryAction, @@ -264,8 +258,6 @@ describe('pipeline graph job item', () => { }, }); - jest.spyOn(wrapper.vm.$toast, 'show'); - await findActionVueComponent().vm.$emit('pipelineActionRequestComplete'); await nextTick(); }); diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js index 6e4b9498918..bcea140f2dd 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js @@ -1,6 +1,7 @@ import { mount, shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; +import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; @@ -15,11 +16,8 @@ import LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue'; import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; import * as parsingUtils from '~/pipelines/components/parsing_utils'; import { LOAD_FAILURE } from '~/pipelines/constants'; -import { - mockPipelineResponse, - pipelineWithUpstreamDownstream, - wrappedPipelineReturn, -} from './mock_data'; + +import { pipelineWithUpstreamDownstream, wrappedPipelineReturn } from './mock_data'; const processedPipeline = pipelineWithUpstreamDownstream(mockPipelineResponse); diff --git a/spec/frontend/pipelines/graph/mock_data.js b/spec/frontend/pipelines/graph/mock_data.js index 08624cc511d..b012e7f66e1 100644 --- a/spec/frontend/pipelines/graph/mock_data.js +++ b/spec/frontend/pipelines/graph/mock_data.js @@ -5,710 +5,6 @@ import { RETRY_ACTION_TITLE, } from '~/pipelines/components/graph/constants'; -export const mockPipelineResponse = { - data: { - project: { - __typename: 'Project', - id: '1', - pipeline: { - __typename: 'Pipeline', - id: 163, - iid: '22', - complete: true, - usesNeeds: true, - downstream: null, - upstream: null, - userPermissions: { - __typename: 'PipelinePermissions', - updatePipeline: true, - }, - stages: { - __typename: 'CiStageConnection', - nodes: [ - { - __typename: 'CiStage', - id: '2', - name: 'build', - status: { - __typename: 'DetailedStatus', - id: '3', - action: null, - }, - groups: { - __typename: 'CiGroupConnection', - nodes: [ - { - __typename: 'CiGroup', - id: '4', - name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', - size: 1, - status: { - __typename: 'DetailedStatus', - id: '5', - label: 'passed', - group: 'success', - icon: 'status_success', - }, - jobs: { - __typename: 'CiJobConnection', - nodes: [ - { - __typename: 'CiJob', - id: '6', - kind: BUILD_KIND, - name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', - scheduledAt: null, - status: { - __typename: 'DetailedStatus', - id: '7', - icon: 'status_success', - tooltip: 'passed', - label: 'passed', - hasDetails: true, - detailsPath: '/root/abcd-dag/-/jobs/1482', - group: 'success', - action: { - __typename: 'StatusAction', - id: '8', - buttonTitle: 'Retry this job', - icon: 'retry', - path: '/root/abcd-dag/-/jobs/1482/retry', - title: 'Retry', - }, - }, - needs: { - __typename: 'CiBuildNeedConnection', - nodes: [], - }, - previousStageJobsOrNeeds: { - __typename: 'CiJobConnection', - nodes: [], - }, - }, - ], - }, - }, - { - __typename: 'CiGroup', - name: 'build_b', - id: '9', - size: 1, - status: { - __typename: 'DetailedStatus', - id: '10', - label: 'passed', - group: 'success', - icon: 'status_success', - }, - jobs: { - __typename: 'CiJobConnection', - nodes: [ - { - __typename: 'CiJob', - id: '11', - name: 'build_b', - kind: BUILD_KIND, - scheduledAt: null, - status: { - __typename: 'DetailedStatus', - id: '12', - icon: 'status_success', - tooltip: 'passed', - label: 'passed', - hasDetails: true, - detailsPath: '/root/abcd-dag/-/jobs/1515', - group: 'success', - action: { - __typename: 'StatusAction', - id: '13', - buttonTitle: 'Retry this job', - icon: 'retry', - path: '/root/abcd-dag/-/jobs/1515/retry', - title: 'Retry', - }, - }, - needs: { - __typename: 'CiBuildNeedConnection', - nodes: [], - }, - previousStageJobsOrNeeds: { - __typename: 'CiJobConnection', - nodes: [], - }, - }, - ], - }, - }, - { - __typename: 'CiGroup', - id: '14', - name: 'build_c', - size: 1, - status: { - __typename: 'DetailedStatus', - id: '15', - label: 'passed', - group: 'success', - icon: 'status_success', - }, - jobs: { - __typename: 'CiJobConnection', - nodes: [ - { - __typename: 'CiJob', - id: '16', - name: 'build_c', - kind: BUILD_KIND, - scheduledAt: null, - status: { - __typename: 'DetailedStatus', - id: '17', - icon: 'status_success', - tooltip: 'passed', - label: 'passed', - hasDetails: true, - detailsPath: '/root/abcd-dag/-/jobs/1484', - group: 'success', - action: { - __typename: 'StatusAction', - id: '18', - buttonTitle: 'Retry this job', - icon: 'retry', - path: '/root/abcd-dag/-/jobs/1484/retry', - title: 'Retry', - }, - }, - needs: { - __typename: 'CiBuildNeedConnection', - nodes: [], - }, - previousStageJobsOrNeeds: { - __typename: 'CiJobConnection', - nodes: [], - }, - }, - ], - }, - }, - { - __typename: 'CiGroup', - id: '19', - name: 'build_d', - size: 3, - status: { - __typename: 'DetailedStatus', - id: '20', - label: 'passed', - group: 'success', - icon: 'status_success', - }, - jobs: { - __typename: 'CiJobConnection', - nodes: [ - { - __typename: 'CiJob', - id: '21', - kind: BUILD_KIND, - name: 'build_d 1/3', - scheduledAt: null, - status: { - __typename: 'DetailedStatus', - id: '22', - icon: 'status_success', - tooltip: 'passed', - label: 'passed', - hasDetails: true, - detailsPath: '/root/abcd-dag/-/jobs/1485', - group: 'success', - action: { - __typename: 'StatusAction', - id: '23', - buttonTitle: 'Retry this job', - icon: 'retry', - path: '/root/abcd-dag/-/jobs/1485/retry', - title: 'Retry', - }, - }, - needs: { - __typename: 'CiBuildNeedConnection', - nodes: [], - }, - previousStageJobsOrNeeds: { - __typename: 'CiJobConnection', - nodes: [], - }, - }, - { - __typename: 'CiJob', - id: '24', - kind: BUILD_KIND, - name: 'build_d 2/3', - scheduledAt: null, - status: { - __typename: 'DetailedStatus', - id: '25', - icon: 'status_success', - tooltip: 'passed', - label: 'passed', - hasDetails: true, - detailsPath: '/root/abcd-dag/-/jobs/1486', - group: 'success', - action: { - __typename: 'StatusAction', - id: '26', - buttonTitle: 'Retry this job', - icon: 'retry', - path: '/root/abcd-dag/-/jobs/1486/retry', - title: 'Retry', - }, - }, - needs: { - __typename: 'CiBuildNeedConnection', - nodes: [], - }, - previousStageJobsOrNeeds: { - __typename: 'CiJobConnection', - nodes: [], - }, - }, - { - __typename: 'CiJob', - id: '27', - kind: BUILD_KIND, - name: 'build_d 3/3', - scheduledAt: null, - status: { - __typename: 'DetailedStatus', - id: '28', - icon: 'status_success', - tooltip: 'passed', - label: 'passed', - hasDetails: true, - detailsPath: '/root/abcd-dag/-/jobs/1487', - group: 'success', - action: { - __typename: 'StatusAction', - id: '29', - buttonTitle: 'Retry this job', - icon: 'retry', - path: '/root/abcd-dag/-/jobs/1487/retry', - title: 'Retry', - }, - }, - needs: { - __typename: 'CiBuildNeedConnection', - nodes: [], - }, - previousStageJobsOrNeeds: { - __typename: 'CiJobConnection', - nodes: [], - }, - }, - ], - }, - }, - ], - }, - }, - { - __typename: 'CiStage', - id: '30', - name: 'test', - status: { - __typename: 'DetailedStatus', - id: '31', - action: null, - }, - groups: { - __typename: 'CiGroupConnection', - nodes: [ - { - __typename: 'CiGroup', - id: '32', - name: 'test_a', - size: 1, - status: { - __typename: 'DetailedStatus', - id: '33', - label: 'passed', - group: 'success', - icon: 'status_success', - }, - jobs: { - __typename: 'CiJobConnection', - nodes: [ - { - __typename: 'CiJob', - id: '34', - kind: BUILD_KIND, - name: 'test_a', - scheduledAt: null, - status: { - __typename: 'DetailedStatus', - id: '35', - icon: 'status_success', - tooltip: 'passed', - label: 'passed', - hasDetails: true, - detailsPath: '/root/abcd-dag/-/jobs/1514', - group: 'success', - action: { - __typename: 'StatusAction', - id: '36', - buttonTitle: 'Retry this job', - icon: 'retry', - path: '/root/abcd-dag/-/jobs/1514/retry', - title: 'Retry', - }, - }, - needs: { - __typename: 'CiBuildNeedConnection', - nodes: [ - { - __typename: 'CiBuildNeed', - id: '37', - name: 'build_c', - }, - { - __typename: 'CiBuildNeed', - id: '38', - name: 'build_b', - }, - { - __typename: 'CiBuildNeed', - id: '39', - name: - 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', - }, - ], - }, - previousStageJobsOrNeeds: { - __typename: 'CiJobConnection', - nodes: [ - { - __typename: 'CiBuildNeed', - id: '37', - name: 'build_c', - }, - { - __typename: 'CiBuildNeed', - id: '38', - name: 'build_b', - }, - { - __typename: 'CiBuildNeed', - id: '39', - name: - 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', - }, - ], - }, - }, - ], - }, - }, - { - __typename: 'CiGroup', - id: '40', - name: 'test_b', - size: 2, - status: { - __typename: 'DetailedStatus', - id: '41', - label: 'passed', - group: 'success', - icon: 'status_success', - }, - jobs: { - __typename: 'CiJobConnection', - nodes: [ - { - __typename: 'CiJob', - id: '42', - kind: BUILD_KIND, - name: 'test_b 1/2', - scheduledAt: null, - status: { - __typename: 'DetailedStatus', - id: '43', - icon: 'status_success', - tooltip: 'passed', - label: 'passed', - hasDetails: true, - detailsPath: '/root/abcd-dag/-/jobs/1489', - group: 'success', - action: { - __typename: 'StatusAction', - id: '44', - buttonTitle: 'Retry this job', - icon: 'retry', - path: '/root/abcd-dag/-/jobs/1489/retry', - title: 'Retry', - }, - }, - needs: { - __typename: 'CiBuildNeedConnection', - 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', - }, - ], - }, - previousStageJobsOrNeeds: { - __typename: 'CiJobConnection', - 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', - }, - ], - }, - }, - { - __typename: 'CiJob', - id: '67', - kind: BUILD_KIND, - name: 'test_b 2/2', - scheduledAt: null, - status: { - __typename: 'DetailedStatus', - id: '50', - icon: 'status_success', - tooltip: 'passed', - label: 'passed', - hasDetails: true, - detailsPath: '/root/abcd-dag/-/jobs/1490', - group: 'success', - action: { - __typename: 'StatusAction', - id: '51', - buttonTitle: 'Retry this job', - icon: 'retry', - path: '/root/abcd-dag/-/jobs/1490/retry', - title: 'Retry', - }, - }, - needs: { - __typename: 'CiBuildNeedConnection', - 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', - }, - ], - }, - previousStageJobsOrNeeds: { - __typename: 'CiJobConnection', - 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', - }, - ], - }, - }, - ], - }, - }, - { - __typename: 'CiGroup', - name: 'test_c', - id: '57', - size: 1, - status: { - __typename: 'DetailedStatus', - id: '58', - label: null, - group: 'success', - icon: 'status_success', - }, - jobs: { - __typename: 'CiJobConnection', - nodes: [ - { - __typename: 'CiJob', - id: '59', - kind: BUILD_KIND, - name: 'test_c', - scheduledAt: null, - status: { - __typename: 'DetailedStatus', - id: '60', - icon: 'status_success', - tooltip: null, - label: null, - hasDetails: true, - detailsPath: '/root/kinder-pipe/-/pipelines/154', - group: 'success', - action: null, - }, - needs: { - __typename: 'CiBuildNeedConnection', - nodes: [], - }, - previousStageJobsOrNeeds: { - __typename: 'CiJobConnection', - nodes: [], - }, - }, - ], - }, - }, - { - __typename: 'CiGroup', - id: '61', - name: 'test_d', - size: 1, - status: { - id: '62', - __typename: 'DetailedStatus', - label: null, - group: 'success', - icon: 'status_success', - }, - jobs: { - __typename: 'CiJobConnection', - nodes: [ - { - __typename: 'CiJob', - id: '53', - kind: BUILD_KIND, - name: 'test_d', - scheduledAt: null, - status: { - __typename: 'DetailedStatus', - id: '64', - icon: 'status_success', - tooltip: null, - label: null, - hasDetails: true, - detailsPath: '/root/abcd-dag/-/pipelines/153', - group: 'success', - action: null, - }, - needs: { - __typename: 'CiBuildNeedConnection', - nodes: [ - { - __typename: 'CiBuildNeed', - id: '65', - name: 'build_b', - }, - ], - }, - previousStageJobsOrNeeds: { - __typename: 'CiJobConnection', - nodes: [ - { - __typename: 'CiBuildNeed', - id: '65', - name: 'build_b', - }, - ], - }, - }, - ], - }, - }, - ], - }, - }, - ], - }, - }, - }, - }, -}; - export const downstream = { nodes: [ { diff --git a/spec/frontend/pipelines/graph_shared/links_layer_spec.js b/spec/frontend/pipelines/graph_shared/links_layer_spec.js index 9d39c86ed5e..88ba84c395a 100644 --- a/spec/frontend/pipelines/graph_shared/links_layer_spec.js +++ b/spec/frontend/pipelines/graph_shared/links_layer_spec.js @@ -1,7 +1,9 @@ import { shallowMount } from '@vue/test-utils'; +import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; -import { generateResponse, mockPipelineResponse } from '../graph/mock_data'; + +import { generateResponse } from '../graph/mock_data'; describe('links layer component', () => { let wrapper; diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js index a4b8d223a0c..62c0d6e2d91 100644 --- a/spec/frontend/pipelines/mock_data.js +++ b/spec/frontend/pipelines/mock_data.js @@ -1,3 +1,8 @@ +import pipelineHeaderSuccess from 'test_fixtures/graphql/pipelines/pipeline_header_success.json'; +import pipelineHeaderRunning from 'test_fixtures/graphql/pipelines/pipeline_header_running.json'; +import pipelineHeaderRunningWithDuration from 'test_fixtures/graphql/pipelines/pipeline_header_running_with_duration.json'; +import pipelineHeaderFailed from 'test_fixtures/graphql/pipelines/pipeline_header_failed.json'; + const PIPELINE_RUNNING = 'RUNNING'; const PIPELINE_CANCELED = 'CANCELED'; const PIPELINE_FAILED = 'FAILED'; @@ -5,6 +10,37 @@ const PIPELINE_FAILED = 'FAILED'; const threeWeeksAgo = new Date(); threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); +export { + pipelineHeaderSuccess, + pipelineHeaderRunning, + pipelineHeaderRunningWithDuration, + pipelineHeaderFailed, +}; + +export const pipelineRetryMutationResponseSuccess = { + data: { pipelineRetry: { errors: [] } }, +}; + +export const pipelineRetryMutationResponseFailed = { + data: { pipelineRetry: { errors: ['error'] } }, +}; + +export const pipelineCancelMutationResponseSuccess = { + data: { pipelineRetry: { errors: [] } }, +}; + +export const pipelineCancelMutationResponseFailed = { + data: { pipelineRetry: { errors: ['error'] } }, +}; + +export const pipelineDeleteMutationResponseSuccess = { + data: { pipelineRetry: { errors: [] } }, +}; + +export const pipelineDeleteMutationResponseFailed = { + data: { pipelineRetry: { errors: ['error'] } }, +}; + export const mockPipelineHeader = { detailedStatus: {}, id: 123, diff --git a/spec/frontend/pipelines/pipeline_details_header_spec.js b/spec/frontend/pipelines/pipeline_details_header_spec.js new file mode 100644 index 00000000000..deaf5c6f72f --- /dev/null +++ b/spec/frontend/pipelines/pipeline_details_header_spec.js @@ -0,0 +1,440 @@ +import { GlAlert, GlBadge, GlLoadingIcon, GlModal, GlSprintf } from '@gitlab/ui'; +import Vue, { nextTick } 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 { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import PipelineDetailsHeader from '~/pipelines/components/pipeline_details_header.vue'; +import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '~/pipelines/constants'; +import TimeAgo from '~/pipelines/components/pipelines_list/time_ago.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import cancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql'; +import deletePipelineMutation from '~/pipelines/graphql/mutations/delete_pipeline.mutation.graphql'; +import retryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql'; +import getPipelineDetailsQuery from '~/pipelines/graphql/queries/get_pipeline_header_data.query.graphql'; +import { + pipelineHeaderSuccess, + pipelineHeaderRunning, + pipelineHeaderRunningWithDuration, + pipelineHeaderFailed, + pipelineRetryMutationResponseSuccess, + pipelineCancelMutationResponseSuccess, + pipelineDeleteMutationResponseSuccess, + pipelineRetryMutationResponseFailed, + pipelineCancelMutationResponseFailed, + pipelineDeleteMutationResponseFailed, +} from './mock_data'; + +Vue.use(VueApollo); + +describe('Pipeline details header', () => { + let wrapper; + let glModalDirective; + + const successHandler = jest.fn().mockResolvedValue(pipelineHeaderSuccess); + const runningHandler = jest.fn().mockResolvedValue(pipelineHeaderRunning); + const runningHandlerWithDuration = jest.fn().mockResolvedValue(pipelineHeaderRunningWithDuration); + const failedHandler = jest.fn().mockResolvedValue(pipelineHeaderFailed); + + const retryMutationHandlerSuccess = jest + .fn() + .mockResolvedValue(pipelineRetryMutationResponseSuccess); + const cancelMutationHandlerSuccess = jest + .fn() + .mockResolvedValue(pipelineCancelMutationResponseSuccess); + const deleteMutationHandlerSuccess = jest + .fn() + .mockResolvedValue(pipelineDeleteMutationResponseSuccess); + const retryMutationHandlerFailed = jest + .fn() + .mockResolvedValue(pipelineRetryMutationResponseFailed); + const cancelMutationHandlerFailed = jest + .fn() + .mockResolvedValue(pipelineCancelMutationResponseFailed); + const deleteMutationHandlerFailed = jest + .fn() + .mockResolvedValue(pipelineDeleteMutationResponseFailed); + + const findAlert = () => wrapper.findComponent(GlAlert); + const findStatus = () => wrapper.findComponent(CiBadgeLink); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findTimeAgo = () => wrapper.findComponent(TimeAgo); + const findAllBadges = () => wrapper.findAllComponents(GlBadge); + const findPipelineName = () => wrapper.findByTestId('pipeline-name'); + const findCommitTitle = () => wrapper.findByTestId('pipeline-commit-title'); + const findTotalJobs = () => wrapper.findByTestId('total-jobs'); + const findComputeCredits = () => wrapper.findByTestId('compute-credits'); + const findCommitLink = () => wrapper.findByTestId('commit-link'); + const findPipelineRunningText = () => wrapper.findByTestId('pipeline-running-text').text(); + const findPipelineRefText = () => wrapper.findByTestId('pipeline-ref-text').text(); + const findRetryButton = () => wrapper.findByTestId('retry-pipeline'); + const findCancelButton = () => wrapper.findByTestId('cancel-pipeline'); + const findDeleteButton = () => wrapper.findByTestId('delete-pipeline'); + const findDeleteModal = () => wrapper.findComponent(GlModal); + const findPipelineUserLink = () => wrapper.findByTestId('pipeline-user-link'); + const findPipelineDuration = () => wrapper.findByTestId('pipeline-duration-text'); + + const defaultHandlers = [[getPipelineDetailsQuery, successHandler]]; + + const defaultProvideOptions = { + pipelineIid: 1, + paths: { + pipelinesPath: '/namespace/my-project/-/pipelines', + fullProject: '/namespace/my-project', + triggeredByPath: '', + }, + }; + + const defaultProps = { + name: 'Ruby 3.0 master branch pipeline', + totalJobs: '50', + computeCredits: '0.65', + yamlErrors: 'errors', + failureReason: 'pipeline failed', + badges: { + schedule: true, + child: false, + latest: true, + mergeTrainPipeline: false, + invalid: false, + failed: false, + autoDevops: false, + detached: false, + stuck: false, + }, + refText: + 'Related merge request <a class="mr-iid" href="/root/ci-project/-/merge_requests/1">!1</a> to merge <a class="ref-name" href="/root/ci-project/-/commits/test">test</a>', + }; + + const createMockApolloProvider = (handlers) => { + return createMockApollo(handlers); + }; + + const createComponent = (handlers = defaultHandlers, props = defaultProps) => { + glModalDirective = jest.fn(); + + wrapper = shallowMountExtended(PipelineDetailsHeader, { + provide: { + ...defaultProvideOptions, + }, + propsData: { + ...props, + }, + directives: { + glModal: { + bind(_, { value }) { + glModalDirective(value); + }, + }, + }, + stubs: { GlSprintf }, + apolloProvider: createMockApolloProvider(handlers), + }); + }; + + describe('loading state', () => { + it('shows a loading state while graphQL is fetching initial data', () => { + createComponent(); + + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('defaults', () => { + beforeEach(async () => { + createComponent(); + + await waitForPromises(); + }); + + it('does not display loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('displays pipeline status', () => { + expect(findStatus().exists()).toBe(true); + }); + + it('displays pipeline name', () => { + expect(findPipelineName().text()).toBe(defaultProps.name); + }); + + it('displays total jobs', () => { + expect(findTotalJobs().text()).toBe('50 Jobs'); + }); + + it('has link to commit', () => { + const { + data: { + project: { pipeline }, + }, + } = pipelineHeaderSuccess; + + expect(findCommitLink().attributes('href')).toBe(pipeline.commit.webPath); + }); + + it('displays correct badges', () => { + expect(findAllBadges()).toHaveLength(2); + expect(wrapper.findByText('latest').exists()).toBe(true); + expect(wrapper.findByText('Scheduled').exists()).toBe(true); + }); + + it('displays ref text', () => { + expect(findPipelineRefText()).toBe('Related merge request !1 to merge test'); + }); + + it('displays pipeline user link with required user popover attributes', () => { + const { + data: { + project: { + pipeline: { user }, + }, + }, + } = pipelineHeaderSuccess; + + const userId = getIdFromGraphQLId(user.id).toString(); + + expect(findPipelineUserLink().classes()).toContain('js-user-link'); + expect(findPipelineUserLink().attributes('data-user-id')).toBe(userId); + expect(findPipelineUserLink().attributes('data-username')).toBe(user.username); + expect(findPipelineUserLink().attributes('href')).toBe(user.webUrl); + }); + }); + + describe('without pipeline name', () => { + it('displays commit title', async () => { + createComponent(defaultHandlers, { ...defaultProps, name: '' }); + + await waitForPromises(); + + const expectedTitle = pipelineHeaderSuccess.data.project.pipeline.commit.title; + + expect(findPipelineName().exists()).toBe(false); + expect(findCommitTitle().text()).toBe(expectedTitle); + }); + }); + + describe('finished pipeline', () => { + it('displays compute credits when not zero', async () => { + createComponent(); + + await waitForPromises(); + + expect(findComputeCredits().text()).toBe('0.65'); + }); + + it('does not display compute credits when zero', async () => { + createComponent(defaultHandlers, { ...defaultProps, computeCredits: '0.0' }); + + await waitForPromises(); + + expect(findComputeCredits().exists()).toBe(false); + }); + + it('displays time ago', async () => { + createComponent(); + + await waitForPromises(); + + expect(findTimeAgo().exists()).toBe(true); + }); + + it('displays pipeline duartion text', async () => { + createComponent(); + + await waitForPromises(); + + expect(findPipelineDuration().text()).toBe( + '120 minutes 10 seconds, queued for 3,600 seconds', + ); + }); + }); + + describe('running pipeline', () => { + beforeEach(async () => { + createComponent([[getPipelineDetailsQuery, runningHandler]]); + + await waitForPromises(); + }); + + it('does not display compute credits', () => { + expect(findComputeCredits().exists()).toBe(false); + }); + + it('does not display time ago', () => { + expect(findTimeAgo().exists()).toBe(false); + }); + + it('does not display pipeline duration text', () => { + expect(findPipelineDuration().exists()).toBe(false); + }); + + it('displays pipeline running text', () => { + expect(findPipelineRunningText()).toBe('In progress, queued for 3,600 seconds'); + }); + }); + + describe('running pipeline with duration', () => { + beforeEach(async () => { + createComponent([[getPipelineDetailsQuery, runningHandlerWithDuration]]); + + await waitForPromises(); + }); + + it('does not display pipeline duration text', () => { + expect(findPipelineDuration().exists()).toBe(false); + }); + }); + + describe('actions', () => { + describe('retry action', () => { + beforeEach(async () => { + createComponent([ + [getPipelineDetailsQuery, failedHandler], + [retryPipelineMutation, retryMutationHandlerSuccess], + ]); + + await waitForPromises(); + }); + + it('should call retryPipeline Mutation with pipeline id', () => { + findRetryButton().vm.$emit('click'); + + expect(retryMutationHandlerSuccess).toHaveBeenCalledWith({ + id: pipelineHeaderFailed.data.project.pipeline.id, + }); + expect(findAlert().exists()).toBe(false); + }); + + it('should render retry action tooltip', () => { + expect(findRetryButton().attributes('title')).toBe(BUTTON_TOOLTIP_RETRY); + }); + }); + + describe('retry action failed', () => { + beforeEach(async () => { + createComponent([ + [getPipelineDetailsQuery, failedHandler], + [retryPipelineMutation, retryMutationHandlerFailed], + ]); + + await waitForPromises(); + }); + + it('should display error message on failure', async () => { + findRetryButton().vm.$emit('click'); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + }); + + it('retry button loading state should reset on error', async () => { + findRetryButton().vm.$emit('click'); + + await nextTick(); + + expect(findRetryButton().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findRetryButton().props('loading')).toBe(false); + }); + }); + + describe('cancel action', () => { + it('should call cancelPipeline Mutation with pipeline id', async () => { + createComponent([ + [getPipelineDetailsQuery, runningHandler], + [cancelPipelineMutation, cancelMutationHandlerSuccess], + ]); + + await waitForPromises(); + + findCancelButton().vm.$emit('click'); + + expect(cancelMutationHandlerSuccess).toHaveBeenCalledWith({ + id: pipelineHeaderRunning.data.project.pipeline.id, + }); + expect(findAlert().exists()).toBe(false); + }); + + it('should render cancel action tooltip', async () => { + createComponent([ + [getPipelineDetailsQuery, runningHandler], + [cancelPipelineMutation, cancelMutationHandlerSuccess], + ]); + + await waitForPromises(); + + expect(findCancelButton().attributes('title')).toBe(BUTTON_TOOLTIP_CANCEL); + }); + + it('should display error message on failure', async () => { + createComponent([ + [getPipelineDetailsQuery, runningHandler], + [cancelPipelineMutation, cancelMutationHandlerFailed], + ]); + + await waitForPromises(); + + findCancelButton().vm.$emit('click'); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + }); + }); + + describe('delete action', () => { + it('displays delete modal when clicking on delete and does not call the delete action', async () => { + createComponent([ + [getPipelineDetailsQuery, successHandler], + [deletePipelineMutation, deleteMutationHandlerSuccess], + ]); + + await waitForPromises(); + + findDeleteButton().vm.$emit('click'); + + const modalId = 'pipeline-delete-modal'; + + expect(findDeleteModal().props('modalId')).toBe(modalId); + expect(glModalDirective).toHaveBeenCalledWith(modalId); + expect(deleteMutationHandlerSuccess).not.toHaveBeenCalled(); + expect(findAlert().exists()).toBe(false); + }); + + it('should call deletePipeline Mutation with pipeline id when modal is submitted', async () => { + createComponent([ + [getPipelineDetailsQuery, successHandler], + [deletePipelineMutation, deleteMutationHandlerSuccess], + ]); + + await waitForPromises(); + + findDeleteModal().vm.$emit('primary'); + + expect(deleteMutationHandlerSuccess).toHaveBeenCalledWith({ + id: pipelineHeaderSuccess.data.project.pipeline.id, + }); + }); + + it('should display error message on failure', async () => { + createComponent([ + [getPipelineDetailsQuery, successHandler], + [deletePipelineMutation, deleteMutationHandlerFailed], + ]); + + await waitForPromises(); + + findDeleteModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/pipeline_multi_actions_spec.js b/spec/frontend/pipelines/pipeline_multi_actions_spec.js index e3c9983aa52..43336bbc748 100644 --- a/spec/frontend/pipelines/pipeline_multi_actions_spec.js +++ b/spec/frontend/pipelines/pipeline_multi_actions_spec.js @@ -1,9 +1,11 @@ +import { nextTick } from 'vue'; import { GlAlert, GlDropdown, GlSprintf, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { stubComponent } from 'helpers/stub_component'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import PipelineMultiActions, { @@ -14,6 +16,7 @@ import { TRACKING_CATEGORIES } from '~/pipelines/constants'; describe('Pipeline Multi Actions Dropdown', () => { let wrapper; let mockAxios; + const focusInputMock = jest.fn(); const artifacts = [ { @@ -30,7 +33,7 @@ describe('Pipeline Multi Actions Dropdown', () => { const artifactsEndpoint = `endpoint/${artifactsEndpointPlaceholder}/artifacts.json`; const pipelineId = 108; - const createComponent = ({ mockData = {} } = {}) => { + const createComponent = () => { wrapper = extendedWrapper( shallowMount(PipelineMultiActions, { provide: { @@ -40,14 +43,12 @@ describe('Pipeline Multi Actions Dropdown', () => { propsData: { pipelineId, }, - data() { - return { - ...mockData, - }; - }, stubs: { GlSprintf, GlDropdown, + GlSearchBoxByType: stubComponent(GlSearchBoxByType, { + methods: { focusInput: focusInputMock }, + }), }, }), ); @@ -76,70 +77,91 @@ describe('Pipeline Multi Actions Dropdown', () => { }); describe('Artifacts', () => { - it('should fetch artifacts and show search box on dropdown click', async () => { - const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId); - mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts }); - createComponent(); - findDropdown().vm.$emit('show'); - await waitForPromises(); + const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId); - expect(mockAxios.history.get).toHaveLength(1); - expect(wrapper.vm.artifacts).toEqual(artifacts); - expect(findSearchBox().exists()).toBe(true); - }); + describe('while loading artifacts', () => { + beforeEach(() => { + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts }); + }); - it('should focus the search box when opened with artifacts', () => { - createComponent({ mockData: { artifacts } }); - wrapper.vm.$refs.searchInput.focusInput = jest.fn(); + it('should render a loading spinner and no empty message', async () => { + createComponent(); - findDropdown().vm.$emit('shown'); + findDropdown().vm.$emit('show'); + await nextTick(); - expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled(); + expect(findLoadingIcon().exists()).toBe(true); + expect(findEmptyMessage().exists()).toBe(false); + }); }); - it('should render all the provided artifacts when search query is empty', () => { - const searchQuery = ''; - createComponent({ mockData: { searchQuery, artifacts } }); + describe('artifacts loaded successfully', () => { + describe('artifacts exist', () => { + beforeEach(async () => { + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts }); - expect(findAllArtifactItems()).toHaveLength(artifacts.length); - expect(findEmptyMessage().exists()).toBe(false); - }); + createComponent(); - it('should render filtered artifacts when search query is not empty', () => { - const searchQuery = 'job-2'; - createComponent({ mockData: { searchQuery, artifacts } }); + findDropdown().vm.$emit('show'); + await waitForPromises(); + }); - expect(findAllArtifactItems()).toHaveLength(1); - expect(findEmptyMessage().exists()).toBe(false); - }); + it('should fetch artifacts and show search box on dropdown click', () => { + expect(mockAxios.history.get).toHaveLength(1); + expect(findSearchBox().exists()).toBe(true); + }); - it('should render the correct artifact name and path', () => { - createComponent({ mockData: { artifacts } }); + it('should focus the search box when opened with artifacts', () => { + findDropdown().vm.$emit('shown'); - expect(findFirstArtifactItem().attributes('href')).toBe(artifacts[0].path); - expect(findFirstArtifactItem().text()).toBe(artifacts[0].name); - }); + expect(focusInputMock).toHaveBeenCalled(); + }); - it('should render empty message and no search box when no artifacts are found', () => { - createComponent({ mockData: { artifacts: [] } }); + it('should render all the provided artifacts when search query is empty', () => { + findSearchBox().vm.$emit('input', ''); - expect(findEmptyMessage().exists()).toBe(true); - expect(findSearchBox().exists()).toBe(false); - }); + expect(findAllArtifactItems()).toHaveLength(artifacts.length); + expect(findEmptyMessage().exists()).toBe(false); + }); - describe('while loading artifacts', () => { - it('should render a loading spinner and no empty message', () => { - createComponent({ mockData: { isLoading: true, artifacts: [] } }); + it('should render filtered artifacts when search query is not empty', async () => { + findSearchBox().vm.$emit('input', 'job-2'); + await waitForPromises(); - expect(findLoadingIcon().exists()).toBe(true); - expect(findEmptyMessage().exists()).toBe(false); + expect(findAllArtifactItems()).toHaveLength(1); + expect(findEmptyMessage().exists()).toBe(false); + }); + + it('should render the correct artifact name and path', () => { + expect(findFirstArtifactItem().attributes('href')).toBe(artifacts[0].path); + expect(findFirstArtifactItem().text()).toBe(artifacts[0].name); + }); + }); + + describe('artifacts list is empty', () => { + beforeEach(() => { + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts: [] }); + }); + + it('should render empty message and no search box when no artifacts are found', async () => { + createComponent(); + + findDropdown().vm.$emit('show'); + await waitForPromises(); + + expect(findEmptyMessage().exists()).toBe(true); + expect(findSearchBox().exists()).toBe(false); + expect(findLoadingIcon().exists()).toBe(false); + }); }); }); describe('with a failing request', () => { - it('should render an error message', async () => { - const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId); + beforeEach(() => { mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); + }); + + it('should render an error message', async () => { createComponent(); findDropdown().vm.$emit('show'); await waitForPromises(); diff --git a/spec/frontend/pipelines/pipeline_url_spec.js b/spec/frontend/pipelines/pipeline_url_spec.js index f00ee4a6367..797ec676ccc 100644 --- a/spec/frontend/pipelines/pipeline_url_spec.js +++ b/spec/frontend/pipelines/pipeline_url_spec.js @@ -24,7 +24,7 @@ describe('Pipeline Url Component', () => { const findPipelineNameContainer = () => wrapper.findByTestId('pipeline-name-container'); const findCommitTitle = (commitWrapper) => commitWrapper.find('[data-testid="commit-title"]'); - const defaultProps = mockPipeline(projectPath); + const defaultProps = { ...mockPipeline(projectPath), refClass: 'gl-text-black' }; const createComponent = (props) => { wrapper = shallowMountExtended(PipelineUrlComponent, { @@ -69,6 +69,18 @@ describe('Pipeline Url Component', () => { expect(findPipelineNameContainer().exists()).toBe(false); }); + it('should pass the refClass prop to merge request link', () => { + createComponent(); + + expect(findRefName().classes()).toContain(defaultProps.refClass); + }); + + it('should pass the refClass prop to the commit ref name link', () => { + createComponent(mockPipelineBranch()); + + expect(findCommitRefName().classes()).toContain(defaultProps.refClass); + }); + describe('commit user avatar', () => { it('renders when commit author exists', () => { const pipelineBranch = mockPipelineBranch(); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index f0772bce167..5b77d44c5bd 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -1,5 +1,13 @@ import '~/commons'; -import { GlButton, GlEmptyState, GlFilteredSearch, GlLoadingIcon, GlPagination } from '@gitlab/ui'; +import { + GlButton, + GlEmptyState, + GlFilteredSearch, + GlLoadingIcon, + GlPagination, + GlCollapsibleListbox, +} from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { chunk } from 'lodash'; @@ -10,8 +18,10 @@ import { TEST_HOST } from 'helpers/test_constants'; import { mockTracking } from 'helpers/tracking_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; import Api from '~/api'; import { createAlert, VARIANT_WARNING } from '~/alert'; +import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import NavigationControls from '~/pipelines/components/pipelines_list/nav_controls.vue'; @@ -22,9 +32,14 @@ import { RAW_TEXT_WARNING, TRACKING_CATEGORIES } from '~/pipelines/constants'; import Store from '~/pipelines/stores/pipelines_store'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import { + setIdTypePreferenceMutationResponse, + setIdTypePreferenceMutationResponseWithErrors, +} from 'jest/issues/list/mock_data'; import { stageReply, users, mockSearch, branches } from './mock_data'; +jest.mock('@sentry/browser'); jest.mock('~/alert'); const mockProjectPath = 'twitter/flight'; @@ -38,13 +53,14 @@ const mockPipelineWithStages = mockPipelinesResponse.pipelines.find( describe('Pipelines', () => { let wrapper; + let mockApollo; let mock; let trackingSpy; const paths = { emptyStateSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', - noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg', + noPipelinesSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', ciLintPath: '/ci/lint', resetCachePath: `${mockProjectPath}/settings/ci_cd/reset_cache`, newPipelinePath: `${mockProjectPath}/pipelines/new`, @@ -55,7 +71,7 @@ describe('Pipelines', () => { const noPermissions = { emptyStateSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', - noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg', + noPipelinesSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', }; const defaultProps = { @@ -70,6 +86,7 @@ describe('Pipelines', () => { const findNavigationControls = () => wrapper.findComponent(NavigationControls); const findPipelinesTable = () => wrapper.findComponent(PipelinesTableComponent); const findTablePagination = () => wrapper.findComponent(TablePagination); + const findPipelineKeyCollapsibleBoxVue = () => wrapper.findComponent(GlCollapsibleListbox); const findTab = (tab) => wrapper.findByTestId(`pipelines-tab-${tab}`); const findPipelineKeyCollapsibleBox = () => wrapper.findByTestId('pipeline-key-collapsible-box'); @@ -81,6 +98,9 @@ describe('Pipelines', () => { const findPipelineUrlLinks = () => wrapper.findAll('[data-testid="pipeline-url-link"]'); const createComponent = (props = defaultProps) => { + const { mutationMock, ...restProps } = props; + mockApollo = createMockApollo([[setSortPreferenceMutation, mutationMock]]); + wrapper = extendedWrapper( mount(PipelinesComponent, { provide: { @@ -95,8 +115,9 @@ describe('Pipelines', () => { defaultBranchName: mockDefaultBranchName, endpoint: mockPipelinesEndpoint, params: {}, - ...props, + ...restProps, }, + apolloProvider: mockApollo, }), ); }; @@ -115,6 +136,7 @@ describe('Pipelines', () => { afterEach(() => { mock.reset(); + mockApollo = null; window.history.pushState.mockReset(); }); @@ -349,6 +371,45 @@ describe('Pipelines', () => { }); }); + describe('when user changes Show Pipeline ID to Show Pipeline IID', () => { + const mockFilteredPipeline = mockPipelinesResponse.pipelines[0]; + + beforeEach(() => { + gon.current_user_id = 1; + }); + + it('should change the text to Show Pipeline IID', async () => { + expect(findPipelineKeyCollapsibleBox().exists()).toBe(true); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.id}`); + findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); + + await waitForPromises(); + + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.iid}`); + }); + + it('calls mutation to save idType preference', () => { + const mutationMock = jest.fn().mockResolvedValue(setIdTypePreferenceMutationResponse); + createComponent({ ...defaultProps, mutationMock }); + + findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); + + expect(mutationMock).toHaveBeenCalledWith({ input: { visibilityPipelineIdType: 'IID' } }); + }); + + it('captures error when mutation response has errors', async () => { + const mutationMock = jest + .fn() + .mockResolvedValue(setIdTypePreferenceMutationResponseWithErrors); + createComponent({ ...defaultProps, mutationMock }); + + findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); + await waitForPromises(); + + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('oh no!')); + }); + }); + describe('when user triggers a filtered search with raw text', () => { beforeEach(async () => { findFilteredSearch().vm.$emit('submit', ['rawText']); diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js index 8d2a52eb6d0..10752cee841 100644 --- a/spec/frontend/pipelines/pipelines_table_spec.js +++ b/spec/frontend/pipelines/pipelines_table_spec.js @@ -10,6 +10,7 @@ import PipelineTriggerer from '~/pipelines/components/pipelines_list/pipeline_tr import PipelineUrl from '~/pipelines/components/pipelines_list/pipeline_url.vue'; import PipelinesTable from '~/pipelines/components/pipelines_list/pipelines_table.vue'; import PipelinesTimeago from '~/pipelines/components/pipelines_list/time_ago.vue'; +import PipelineFailedJobsWidget from '~/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue'; import { PipelineKeyOptions, BUTTON_TOOLTIP_RETRY, @@ -26,6 +27,18 @@ describe('Pipelines Table', () => { let wrapper; let trackingSpy; + const defaultProvide = { + glFeatures: {}, + withFailedJobsDetails: false, + }; + + const provideWithDetails = { + glFeatures: { + ciJobFailuresInMr: true, + }, + withFailedJobsDetails: true, + }; + const defaultProps = { pipelines: [], viewType: 'root', @@ -38,13 +51,18 @@ describe('Pipelines Table', () => { return pipelines.find((p) => p.user !== null && p.commit !== null); }; - const createComponent = (props = {}) => { + const createComponent = (props = {}, provide = {}) => { wrapper = extendedWrapper( mount(PipelinesTable, { propsData: { ...defaultProps, ...props, }, + provide: { + ...defaultProvide, + ...provide, + }, + stubs: ['PipelineFailedJobsWidget'], }), ); }; @@ -56,6 +74,7 @@ describe('Pipelines Table', () => { const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph); const findTimeAgo = () => wrapper.findComponent(PipelinesTimeago); const findActions = () => wrapper.findComponent(PipelineOperations); + const findPipelineFailedJobsWidget = () => wrapper.findComponent(PipelineFailedJobsWidget); const findTableRows = () => wrapper.findAllByTestId('pipeline-table-row'); const findStatusTh = () => wrapper.findByTestId('status-th'); @@ -163,6 +182,68 @@ describe('Pipelines Table', () => { }); }); + describe('failed jobs details', () => { + describe('row', () => { + describe('when the FF is disabled', () => { + beforeEach(() => { + createComponent({ pipelines: [pipeline] }); + }); + + it('does not render', () => { + expect(findTableRows()).toHaveLength(1); + }); + }); + + describe('when the FF is enabled', () => { + describe('and `withFailedJobsDetails` value is provided', () => { + beforeEach(() => { + createComponent({ pipelines: [pipeline] }, provideWithDetails); + }); + it('renders', () => { + expect(findTableRows()).toHaveLength(2); + }); + }); + + describe('and `withFailedJobsDetails` value is not provided', () => { + beforeEach(() => { + createComponent( + { pipelines: [pipeline] }, + { glFeatures: { ciJobFailuresInMr: true } }, + ); + }); + + it('does not render', () => { + expect(findTableRows()).toHaveLength(1); + }); + }); + }); + }); + + describe('widget', () => { + describe('when there are no failed jobs', () => { + beforeEach(() => { + createComponent( + { pipelines: [{ ...pipeline, failed_builds: [] }] }, + provideWithDetails, + ); + }); + + it('does not renders', () => { + expect(findPipelineFailedJobsWidget().exists()).toBe(false); + }); + }); + + describe('when there are failed jobs', () => { + beforeEach(() => { + createComponent({ pipelines: [pipeline] }, provideWithDetails); + }); + it('renders', () => { + expect(findPipelineFailedJobsWidget().exists()).toBe(true); + }); + }); + }); + }); + describe('tracking', () => { beforeEach(() => { trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); diff --git a/spec/frontend/pipelines/time_ago_spec.js b/spec/frontend/pipelines/time_ago_spec.js index efb1bf09d20..5afe91c4784 100644 --- a/spec/frontend/pipelines/time_ago_spec.js +++ b/spec/frontend/pipelines/time_ago_spec.js @@ -8,7 +8,7 @@ describe('Timeago component', () => { const defaultProps = { duration: 0, finished_at: '' }; - const createComponent = (props = defaultProps, stuck = false) => { + const createComponent = (props = defaultProps, extraProps) => { wrapper = extendedWrapper( shallowMount(TimeAgo, { propsData: { @@ -16,10 +16,8 @@ describe('Timeago component', () => { details: { ...props, }, - flags: { - stuck, - }, }, + ...extraProps, }, data() { return { @@ -32,10 +30,7 @@ describe('Timeago component', () => { const duration = () => wrapper.find('.duration'); const finishedAt = () => wrapper.find('.finished-at'); - const findInProgress = () => wrapper.findByTestId('pipeline-in-progress'); - const findSkipped = () => wrapper.findByTestId('pipeline-skipped'); - const findHourGlassIcon = () => wrapper.findByTestId('hourglass-icon'); - const findWarningIcon = () => wrapper.findByTestId('warning-icon'); + const findCalendarIcon = () => wrapper.findByTestId('calendar-icon'); describe('with duration', () => { beforeEach(() => { @@ -61,68 +56,41 @@ describe('Timeago component', () => { }); describe('with finishedTime', () => { - beforeEach(() => { + it('should render time', () => { createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' }); - }); - it('should render time and calendar icon', () => { - const icon = finishedAt().findComponent(GlIcon); const time = finishedAt().find('time'); expect(finishedAt().exists()).toBe(true); - expect(icon.props('name')).toBe('calendar'); expect(time.exists()).toBe(true); }); - }); - describe('without finishedTime', () => { - beforeEach(() => { - createComponent(); - }); + it('should display calendar icon by default', () => { + createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' }); - it('should not render time and calendar icon', () => { - expect(finishedAt().exists()).toBe(false); + expect(findCalendarIcon().exists()).toBe(true); }); - }); - - describe('in progress', () => { - it.each` - durationTime | finishedAtTime | shouldShow - ${10} | ${'2017-04-26T12:40:23.277Z'} | ${false} - ${10} | ${''} | ${false} - ${0} | ${'2017-04-26T12:40:23.277Z'} | ${false} - ${0} | ${''} | ${true} - `( - 'progress state shown: $shouldShow when pipeline duration is $durationTime and finished_at is $finishedAtTime', - ({ durationTime, finishedAtTime, shouldShow }) => { - createComponent({ - duration: durationTime, - finished_at: finishedAtTime, - }); - - expect(findInProgress().exists()).toBe(shouldShow); - expect(findSkipped().exists()).toBe(false); - }, - ); - it('should show warning icon beside in progress if pipeline is stuck', () => { - const stuck = true; - - createComponent(defaultProps, stuck); + it('should hide calendar icon if correct prop is passed', () => { + createComponent( + { duration: 0, finished_at: '2017-04-26T12:40:23.277Z' }, + { + displayCalendarIcon: false, + }, + ); - expect(findWarningIcon().exists()).toBe(true); - expect(findHourGlassIcon().exists()).toBe(false); + expect(findCalendarIcon().exists()).toBe(false); }); }); - describe('skipped', () => { - it('should show skipped if pipeline was skipped', () => { - createComponent({ - status: { label: 'skipped' }, - }); + describe('without finishedTime', () => { + beforeEach(() => { + createComponent(); + }); - expect(findSkipped().exists()).toBe(true); - expect(findInProgress().exists()).toBe(false); + it('should not render time and calendar icon', () => { + expect(finishedAt().exists()).toBe(false); + expect(findCalendarIcon().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/pipelines/utils_spec.js b/spec/frontend/pipelines/utils_spec.js index 51e0e0705ff..286d79edc6c 100644 --- a/spec/frontend/pipelines/utils_spec.js +++ b/spec/frontend/pipelines/utils_spec.js @@ -1,3 +1,4 @@ +import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; import { createSankey } from '~/pipelines/components/dag/drawing_utils'; import { makeLinksFromNodes, @@ -14,7 +15,7 @@ import { createNodeDict } from '~/pipelines/utils'; import { mockDownstreamPipelinesRest } from '../vue_merge_request_widget/mock_data'; import { mockDownstreamPipelinesGraphql } from '../commit/mock_data'; import { mockParsedGraphQLNodes, missingJob } from './components/dag/mock_data'; -import { generateResponse, mockPipelineResponse } from './graph/mock_data'; +import { generateResponse } from './graph/mock_data'; describe('DAG visualization parsing utilities', () => { const nodeDict = createNodeDict(mockParsedGraphQLNodes); @@ -152,14 +153,6 @@ describe('DAG visualization parsing utilities', () => { }); }); }); - - /* - Just as a fallback in case multiple functions change, so tests pass - but the implementation moves away from case. - */ - it('matches the snapshot', () => { - expect(columns).toMatchSnapshot(); - }); }); }); diff --git a/spec/frontend/profile/components/follow_spec.js b/spec/frontend/profile/components/follow_spec.js new file mode 100644 index 00000000000..2555e41257f --- /dev/null +++ b/spec/frontend/profile/components/follow_spec.js @@ -0,0 +1,99 @@ +import { GlAvatarLabeled, GlAvatarLink, GlLoadingIcon, GlPagination } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; + +import users from 'test_fixtures/api/users/followers/get.json'; +import Follow from '~/profile/components/follow.vue'; +import { DEFAULT_PER_PAGE } from '~/api'; + +jest.mock('~/rest_api'); + +describe('FollowersTab', () => { + let wrapper; + + const defaultPropsData = { + users, + loading: false, + page: 1, + totalItems: 50, + }; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = shallowMount(Follow, { + propsData: { + ...defaultPropsData, + ...propsData, + }, + }); + }; + + const findPagination = () => wrapper.findComponent(GlPagination); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + describe('when `loading` prop is `true`', () => { + it('renders loading icon', () => { + createComponent({ propsData: { loading: true } }); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + }); + + describe('when `loading` prop is `false`', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not render loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders users', () => { + const avatarLinksHref = wrapper + .findAllComponents(GlAvatarLink) + .wrappers.map((avatarLinkWrapper) => avatarLinkWrapper.attributes('href')); + const expectedAvatarLinksHref = users.map((user) => user.web_url); + + const avatarLabeledProps = wrapper + .findAllComponents(GlAvatarLabeled) + .wrappers.map((avatarLabeledWrapper) => ({ + label: avatarLabeledWrapper.props('label'), + subLabel: avatarLabeledWrapper.props('subLabel'), + size: avatarLabeledWrapper.attributes('size'), + entityName: avatarLabeledWrapper.attributes('entity-name'), + entityId: avatarLabeledWrapper.attributes('entity-id'), + src: avatarLabeledWrapper.attributes('src'), + })); + const expectedAvatarLabeledProps = users.map((user) => ({ + src: user.avatar_url, + size: '48', + entityId: user.id.toString(), + entityName: user.name, + label: user.name, + subLabel: user.username, + })); + + expect(avatarLinksHref).toEqual(expectedAvatarLinksHref); + expect(avatarLabeledProps).toEqual(expectedAvatarLabeledProps); + }); + + it('renders `GlPagination` and passes correct props', () => { + expect(wrapper.findComponent(GlPagination).props()).toMatchObject({ + align: 'center', + value: defaultPropsData.page, + totalItems: defaultPropsData.totalItems, + perPage: DEFAULT_PER_PAGE, + prevText: Follow.i18n.prev, + nextText: Follow.i18n.next, + }); + }); + + describe('when `GlPagination` emits `input` event', () => { + it('emits `pagination-input` event', () => { + const nextPage = defaultPropsData.page + 1; + + findPagination().vm.$emit('input', nextPage); + + expect(wrapper.emitted('pagination-input')).toEqual([[nextPage]]); + }); + }); + }); +}); diff --git a/spec/frontend/profile/components/followers_tab_spec.js b/spec/frontend/profile/components/followers_tab_spec.js index 9cc5bdea9be..0370005d0a4 100644 --- a/spec/frontend/profile/components/followers_tab_spec.js +++ b/spec/frontend/profile/components/followers_tab_spec.js @@ -1,32 +1,127 @@ import { GlBadge, GlTab } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import followers from 'test_fixtures/api/users/followers/get.json'; import { s__ } from '~/locale'; import FollowersTab from '~/profile/components/followers_tab.vue'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Follow from '~/profile/components/follow.vue'; +import { getUserFollowers } from '~/rest_api'; +import { createAlert } from '~/alert'; +import waitForPromises from 'helpers/wait_for_promises'; +import { stubComponent } from 'helpers/stub_component'; + +jest.mock('~/rest_api'); +jest.mock('~/alert'); describe('FollowersTab', () => { let wrapper; const createComponent = () => { - wrapper = shallowMountExtended(FollowersTab, { + wrapper = shallowMount(FollowersTab, { provide: { - followers: 2, + followersCount: 2, + userId: 1, + }, + stubs: { + GlTab: stubComponent(GlTab, { + template: ` + <li> + <slot name="title"></slot> + <slot></slot> + </li> + `, + }), }, }); }; - it('renders `GlTab` and sets title', () => { - createComponent(); + const findGlBadge = () => wrapper.findComponent(GlBadge); + const findFollow = () => wrapper.findComponent(Follow); + + describe('when API request is loading', () => { + beforeEach(() => { + getUserFollowers.mockReturnValueOnce(new Promise(() => {})); + createComponent(); + }); + + it('renders `Follow` component and sets `loading` prop to `true`', () => { + expect(findFollow().props('loading')).toBe(true); + }); + }); + + describe('when API request is successful', () => { + beforeEach(async () => { + getUserFollowers.mockResolvedValueOnce({ + data: followers, + headers: { 'X-TOTAL': '6' }, + }); + createComponent(); + + await waitForPromises(); + }); + + it('renders `GlTab` and sets title', () => { + expect(wrapper.findComponent(GlTab).text()).toContain(s__('UserProfile|Followers')); + }); + + it('renders `GlBadge`, sets size and content', () => { + expect(findGlBadge().props('size')).toBe('sm'); + expect(findGlBadge().text()).toBe('2'); + }); + + it('renders `Follow` component and passes correct props', () => { + expect(findFollow().props()).toMatchObject({ + users: followers, + loading: false, + page: 1, + totalItems: 6, + }); + }); + + describe('when `Follow` component emits `pagination-input` event', () => { + it('calls API and updates `users` and `page` props', async () => { + const lastFollower = followers.at(-1); + const paginationFollowers = [ + { + ...lastFollower, + id: lastFollower.id + 1, + name: 'page 2 follower', + }, + ]; + + getUserFollowers.mockResolvedValueOnce({ + data: paginationFollowers, + headers: { 'X-TOTAL': '6' }, + }); - expect(wrapper.findComponent(GlTab).element.textContent).toContain( - s__('UserProfile|Followers'), - ); + findFollow().vm.$emit('pagination-input', 2); + + await waitForPromises(); + + expect(findFollow().props()).toMatchObject({ + users: paginationFollowers, + loading: false, + page: 2, + totalItems: 6, + }); + }); + }); }); - it('renders `GlBadge`, sets size and content', () => { - createComponent(); + describe('when API request is not successful', () => { + beforeEach(async () => { + getUserFollowers.mockRejectedValueOnce(new Error()); + createComponent(); - expect(wrapper.findComponent(GlBadge).attributes('size')).toBe('sm'); - expect(wrapper.findComponent(GlBadge).element.textContent).toBe('2'); + await waitForPromises(); + }); + + it('shows error alert', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: FollowersTab.i18n.errorMessage, + error: new Error(), + captureError: true, + }); + }); }); }); diff --git a/spec/frontend/profile/components/following_tab_spec.js b/spec/frontend/profile/components/following_tab_spec.js index c9d56360c3e..c0583cf4877 100644 --- a/spec/frontend/profile/components/following_tab_spec.js +++ b/spec/frontend/profile/components/following_tab_spec.js @@ -10,7 +10,7 @@ describe('FollowingTab', () => { const createComponent = () => { wrapper = shallowMountExtended(FollowingTab, { provide: { - followees: 3, + followeesCount: 3, }, }); }; diff --git a/spec/frontend/profile/components/overview_tab_spec.js b/spec/frontend/profile/components/overview_tab_spec.js index aeab24cb730..0122735e8a3 100644 --- a/spec/frontend/profile/components/overview_tab_spec.js +++ b/spec/frontend/profile/components/overview_tab_spec.js @@ -1,27 +1,47 @@ import { GlLoadingIcon, GlTab, GlLink } from '@gitlab/ui'; +import AxiosMockAdapter from 'axios-mock-adapter'; import projects from 'test_fixtures/api/users/projects/get.json'; +import events from 'test_fixtures/controller/users/activity.json'; import { s__ } from '~/locale'; import OverviewTab from '~/profile/components/overview_tab.vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ActivityCalendar from '~/profile/components/activity_calendar.vue'; import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import axios from '~/lib/utils/axios_utils'; +import ContributionEvents from '~/contribution_events/components/contribution_events.vue'; +import { createAlert } from '~/alert'; +import waitForPromises from 'helpers/wait_for_promises'; + +jest.mock('~/alert'); describe('OverviewTab', () => { let wrapper; + let axiosMock; const defaultPropsData = { personalProjects: convertObjectPropsToCamelCase(projects, { deep: true }), personalProjectsLoading: false, }; + const defaultProvide = { userActivityPath: '/users/root/activity.json' }; + const createComponent = ({ propsData = {} } = {}) => { wrapper = shallowMountExtended(OverviewTab, { propsData: { ...defaultPropsData, ...propsData }, + provide: defaultProvide, }); }; + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + axiosMock.restore(); + }); + it('renders `GlTab` and sets `title` prop', () => { createComponent(); @@ -70,4 +90,50 @@ describe('OverviewTab', () => { ).toMatchObject(defaultPropsData.personalProjects); }); }); + + describe('when activity API request is loading', () => { + beforeEach(() => { + axiosMock.onGet(defaultProvide.userActivityPath).reply(200, events); + + createComponent(); + }); + + it('shows loading icon', () => { + expect(wrapper.findByTestId('activity-section').findComponent(GlLoadingIcon).exists()).toBe( + true, + ); + }); + }); + + describe('when activity API request is successful', () => { + beforeEach(() => { + axiosMock.onGet(defaultProvide.userActivityPath).reply(200, events); + + createComponent(); + }); + + it('renders `ContributionEvents` component', async () => { + await waitForPromises(); + + expect(wrapper.findComponent(ContributionEvents).props('events')).toEqual(events); + }); + }); + + describe('when activity API request is not successful', () => { + beforeEach(() => { + axiosMock.onGet(defaultProvide.userActivityPath).networkError(); + + createComponent(); + }); + + it('calls `createAlert`', async () => { + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: OverviewTab.i18n.eventsErrorMessage, + error: new Error('Network Error'), + captureError: true, + }); + }); + }); }); diff --git a/spec/frontend/profile/components/profile_tabs_spec.js b/spec/frontend/profile/components/profile_tabs_spec.js index 80a1ff422ab..f3dda2e205f 100644 --- a/spec/frontend/profile/components/profile_tabs_spec.js +++ b/spec/frontend/profile/components/profile_tabs_spec.js @@ -10,7 +10,7 @@ import GroupsTab from '~/profile/components/groups_tab.vue'; import ContributedProjectsTab from '~/profile/components/contributed_projects_tab.vue'; import PersonalProjectsTab from '~/profile/components/personal_projects_tab.vue'; import StarredProjectsTab from '~/profile/components/starred_projects_tab.vue'; -import SnippetsTab from '~/profile/components/snippets_tab.vue'; +import SnippetsTab from '~/profile/components/snippets/snippets_tab.vue'; import FollowersTab from '~/profile/components/followers_tab.vue'; import FollowingTab from '~/profile/components/following_tab.vue'; import waitForPromises from 'helpers/wait_for_promises'; diff --git a/spec/frontend/profile/components/snippets/snippet_row_spec.js b/spec/frontend/profile/components/snippets/snippet_row_spec.js new file mode 100644 index 00000000000..68f06ace226 --- /dev/null +++ b/spec/frontend/profile/components/snippets/snippet_row_spec.js @@ -0,0 +1,146 @@ +import { GlAvatar, GlSprintf, GlIcon } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { + VISIBILITY_LEVEL_PRIVATE_STRING, + VISIBILITY_LEVEL_INTERNAL_STRING, + VISIBILITY_LEVEL_PUBLIC_STRING, +} from '~/visibility_level/constants'; +import { SNIPPET_VISIBILITY } from '~/snippets/constants'; +import SnippetRow from '~/profile/components/snippets/snippet_row.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { MOCK_USER, MOCK_SNIPPET } from 'jest/profile/mock_data'; + +describe('UserProfileSnippetRow', () => { + let wrapper; + + const defaultProps = { + userInfo: MOCK_USER, + snippet: MOCK_SNIPPET, + }; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(SnippetRow, { + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + GlSprintf, + }, + }); + }; + + const findGlAvatar = () => wrapper.findComponent(GlAvatar); + const findSnippetUrl = () => wrapper.findByTestId('snippet-url'); + const findSnippetId = () => wrapper.findByTestId('snippet-id'); + const findSnippetCreatedAt = () => wrapper.findByTestId('snippet-created-at'); + const findSnippetAuthor = () => wrapper.findByTestId('snippet-author'); + const findSnippetBlob = () => wrapper.findByTestId('snippet-blob'); + const findSnippetComments = () => wrapper.findByTestId('snippet-comments'); + const findSnippetVisibility = () => wrapper.findByTestId('snippet-visibility'); + const findSnippetUpdatedAt = () => wrapper.findByTestId('snippet-updated-at'); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders GlAvatar with user avatar', () => { + expect(findGlAvatar().exists()).toBe(true); + expect(findGlAvatar().attributes('src')).toBe(MOCK_USER.avatarUrl); + }); + + it('renders Snippet Url with snippet webUrl', () => { + expect(findSnippetUrl().exists()).toBe(true); + expect(findSnippetUrl().attributes('href')).toBe(MOCK_SNIPPET.webUrl); + }); + + it('renders Snippet ID correctly formatted', () => { + expect(findSnippetId().exists()).toBe(true); + expect(findSnippetId().text()).toBe(`$${getIdFromGraphQLId(MOCK_SNIPPET.id)}`); + }); + + it('renders Snippet Created At with correct date string', () => { + expect(findSnippetCreatedAt().exists()).toBe(true); + expect(findSnippetCreatedAt().attributes('time')).toBe(MOCK_SNIPPET.createdAt.toString()); + }); + + it('renders Snippet Author with profileLink', () => { + expect(findSnippetAuthor().exists()).toBe(true); + expect(findSnippetAuthor().attributes('href')).toBe(`/${MOCK_USER.username}`); + }); + + it('renders Snippet Updated At with correct date string', () => { + expect(findSnippetUpdatedAt().exists()).toBe(true); + expect(findSnippetUpdatedAt().attributes('time')).toBe(MOCK_SNIPPET.updatedAt.toString()); + }); + }); + + describe.each` + nodes | hasOpacity | tooltip + ${[]} | ${true} | ${'0 files'} + ${[{ name: 'file.txt' }]} | ${false} | ${'1 file'} + ${[{ name: 'file.txt' }, { name: 'file2.txt' }]} | ${false} | ${'2 files'} + `('Blob Icon', ({ nodes, hasOpacity, tooltip }) => { + describe(`when blobs length ${nodes.length}`, () => { + beforeEach(() => { + createComponent({ snippet: { ...MOCK_SNIPPET, blobs: { nodes } } }); + }); + + it(`does${hasOpacity ? '' : ' not'} render icon with opacity`, () => { + expect(findSnippetBlob().findComponent(GlIcon).props('name')).toBe('documents'); + expect(findSnippetBlob().classes('gl-opacity-5')).toBe(hasOpacity); + }); + + it('renders text and tooltip correctly', () => { + expect(findSnippetBlob().text()).toBe(nodes.length.toString()); + expect(findSnippetBlob().attributes('title')).toBe(tooltip); + }); + }); + }); + + describe.each` + nodes | hasOpacity + ${[]} | ${true} + ${[{ id: 'note/1' }]} | ${false} + ${[{ id: 'note/1' }, { id: 'note/2' }]} | ${false} + `('Comments Icon', ({ nodes, hasOpacity }) => { + describe(`when comments length ${nodes.length}`, () => { + beforeEach(() => { + createComponent({ snippet: { ...MOCK_SNIPPET, notes: { nodes } } }); + }); + + it(`does${hasOpacity ? '' : ' not'} render icon with opacity`, () => { + expect(findSnippetComments().findComponent(GlIcon).props('name')).toBe('comments'); + expect(findSnippetComments().classes('gl-opacity-5')).toBe(hasOpacity); + }); + + it('renders text correctly', () => { + expect(findSnippetComments().text()).toBe(nodes.length.toString()); + }); + + it('renders link to comments correctly', () => { + expect(findSnippetComments().attributes('href')).toBe(`${MOCK_SNIPPET.webUrl}#notes`); + }); + }); + }); + + describe.each` + visibilityLevel + ${VISIBILITY_LEVEL_PUBLIC_STRING} + ${VISIBILITY_LEVEL_PRIVATE_STRING} + ${VISIBILITY_LEVEL_INTERNAL_STRING} + `('Visibility Icon', ({ visibilityLevel }) => { + describe(`when visibilityLevel is ${visibilityLevel}`, () => { + beforeEach(() => { + createComponent({ snippet: { ...MOCK_SNIPPET, visibilityLevel } }); + }); + + it(`renders the ${SNIPPET_VISIBILITY[visibilityLevel].icon} icon`, () => { + expect(findSnippetVisibility().findComponent(GlIcon).props('name')).toBe( + SNIPPET_VISIBILITY[visibilityLevel].icon, + ); + }); + }); + }); +}); diff --git a/spec/frontend/profile/components/snippets/snippets_tab_spec.js b/spec/frontend/profile/components/snippets/snippets_tab_spec.js new file mode 100644 index 00000000000..47e2fbcf2c0 --- /dev/null +++ b/spec/frontend/profile/components/snippets/snippets_tab_spec.js @@ -0,0 +1,162 @@ +import { GlEmptyState, GlKeysetPagination } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPENAME_USER } from '~/graphql_shared/constants'; +import { SNIPPET_MAX_LIST_COUNT } from '~/profile/constants'; +import SnippetsTab from '~/profile/components/snippets/snippets_tab.vue'; +import SnippetRow from '~/profile/components/snippets/snippet_row.vue'; +import getUserSnippets from '~/profile/components/graphql/get_user_snippets.query.graphql'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { + MOCK_USER, + MOCK_SNIPPETS_EMPTY_STATE, + MOCK_USER_SNIPPETS_RES, + MOCK_USER_SNIPPETS_PAGINATION_RES, + MOCK_USER_SNIPPETS_EMPTY_RES, +} from 'jest/profile/mock_data'; + +Vue.use(VueApollo); + +describe('UserProfileSnippetsTab', () => { + let wrapper; + + let queryHandlerMock = jest.fn().mockResolvedValue(MOCK_USER_SNIPPETS_RES); + + const createComponent = () => { + const apolloProvider = createMockApollo([[getUserSnippets, queryHandlerMock]]); + + wrapper = shallowMountExtended(SnippetsTab, { + apolloProvider, + provide: { + userId: MOCK_USER.id, + snippetsEmptyState: MOCK_SNIPPETS_EMPTY_STATE, + }, + }); + }; + + const findSnippetRows = () => wrapper.findAllComponents(SnippetRow); + const findGlEmptyState = () => wrapper.findComponent(GlEmptyState); + const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination); + + describe('when user has no snippets', () => { + beforeEach(async () => { + queryHandlerMock = jest.fn().mockResolvedValue(MOCK_USER_SNIPPETS_EMPTY_RES); + createComponent(); + + await nextTick(); + }); + + it('does not render snippet row', () => { + expect(findSnippetRows().exists()).toBe(false); + }); + + it('does render empty state with correct svg', () => { + expect(findGlEmptyState().exists()).toBe(true); + expect(findGlEmptyState().attributes('svgpath')).toBe(MOCK_SNIPPETS_EMPTY_STATE); + }); + }); + + describe('when snippets returns an error', () => { + beforeEach(async () => { + queryHandlerMock = jest.fn().mockRejectedValue({ errors: [] }); + createComponent(); + + await nextTick(); + }); + + it('does not render snippet row', () => { + expect(findSnippetRows().exists()).toBe(false); + }); + + it('does render empty state with correct svg', () => { + expect(findGlEmptyState().exists()).toBe(true); + expect(findGlEmptyState().attributes('svgpath')).toBe(MOCK_SNIPPETS_EMPTY_STATE); + }); + }); + + describe('when snippets are returned', () => { + beforeEach(async () => { + queryHandlerMock = jest.fn().mockResolvedValue(MOCK_USER_SNIPPETS_RES); + createComponent(); + + await nextTick(); + }); + + it('renders a snippet row for each snippet', () => { + expect(findSnippetRows().exists()).toBe(true); + expect(findSnippetRows().length).toBe(MOCK_USER_SNIPPETS_RES.data.user.snippets.nodes.length); + }); + + it('does not render empty state', () => { + expect(findGlEmptyState().exists()).toBe(false); + }); + + it('adds bottom border when snippet is not last in list', () => { + expect(findSnippetRows().at(0).classes('gl-border-b')).toBe(true); + }); + + it('does not add bottom border when snippet is last in list', () => { + expect( + findSnippetRows() + .at(MOCK_USER_SNIPPETS_RES.data.user.snippets.nodes.length - 1) + .classes('gl-border-b'), + ).toBe(false); + }); + }); + + describe('Snippet Pagination', () => { + describe('when user has one page of snippets', () => { + beforeEach(async () => { + queryHandlerMock = jest.fn().mockResolvedValue(MOCK_USER_SNIPPETS_RES); + createComponent(); + + await nextTick(); + }); + + it('does not render pagination', () => { + expect(findGlKeysetPagination().exists()).toBe(false); + }); + }); + + describe('when user has multiple pages of snippets', () => { + beforeEach(async () => { + queryHandlerMock = jest.fn().mockResolvedValue(MOCK_USER_SNIPPETS_PAGINATION_RES); + createComponent(); + + await nextTick(); + }); + + it('does render pagination', () => { + expect(findGlKeysetPagination().exists()).toBe(true); + }); + + it('when nextPage is clicked', async () => { + findGlKeysetPagination().vm.$emit('next'); + + await nextTick(); + + expect(queryHandlerMock).toHaveBeenCalledWith({ + id: convertToGraphQLId(TYPENAME_USER, MOCK_USER.id), + first: SNIPPET_MAX_LIST_COUNT, + last: null, + afterToken: MOCK_USER_SNIPPETS_RES.data.user.snippets.pageInfo.endCursor, + }); + }); + + it('when previousPage is clicked', async () => { + findGlKeysetPagination().vm.$emit('prev'); + + await nextTick(); + + expect(queryHandlerMock).toHaveBeenCalledWith({ + id: convertToGraphQLId(TYPENAME_USER, MOCK_USER.id), + first: null, + last: SNIPPET_MAX_LIST_COUNT, + beforeToken: MOCK_USER_SNIPPETS_RES.data.user.snippets.pageInfo.startCursor, + }); + }); + }); + }); +}); diff --git a/spec/frontend/profile/components/snippets_tab_spec.js b/spec/frontend/profile/components/snippets_tab_spec.js deleted file mode 100644 index 1306757314c..00000000000 --- a/spec/frontend/profile/components/snippets_tab_spec.js +++ /dev/null @@ -1,19 +0,0 @@ -import { GlTab } from '@gitlab/ui'; - -import { s__ } from '~/locale'; -import SnippetsTab from '~/profile/components/snippets_tab.vue'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; - -describe('SnippetsTab', () => { - let wrapper; - - const createComponent = () => { - wrapper = shallowMountExtended(SnippetsTab); - }; - - it('renders `GlTab` and sets `title` prop', () => { - createComponent(); - - expect(wrapper.findComponent(GlTab).attributes('title')).toBe(s__('UserProfile|Snippets')); - }); -}); diff --git a/spec/frontend/profile/components/user_achievements_spec.js b/spec/frontend/profile/components/user_achievements_spec.js index ff6f323621a..5743c8575d5 100644 --- a/spec/frontend/profile/components/user_achievements_spec.js +++ b/spec/frontend/profile/components/user_achievements_spec.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { GlBadge } from '@gitlab/ui'; import getUserAchievementsEmptyResponse from 'test_fixtures/graphql/get_user_achievements_empty_response.json'; import getUserAchievementsLongResponse from 'test_fixtures/graphql/get_user_achievements_long_response.json'; import getUserAchievementsResponse from 'test_fixtures/graphql/get_user_achievements_with_avatar_and_description_response.json'; @@ -63,6 +64,14 @@ describe('UserAchievements', () => { expect(wrapper.findAllByTestId('user-achievement').length).toBe(3); }); + it('renders count for achievements awarded more than once', async () => { + createComponent({ queryHandler: jest.fn().mockResolvedValue(getUserAchievementsLongResponse) }); + + await waitForPromises(); + + expect(achievement().findComponent(GlBadge).text()).toBe('2x'); + }); + it('renders correctly if the achievement is from a private namespace', async () => { createComponent({ queryHandler: jest.fn().mockResolvedValue(getUserAchievementsPrivateGroupResponse), diff --git a/spec/frontend/profile/mock_data.js b/spec/frontend/profile/mock_data.js index 7106ea84619..856534aebd3 100644 --- a/spec/frontend/profile/mock_data.js +++ b/spec/frontend/profile/mock_data.js @@ -20,3 +20,79 @@ export const userCalendarResponse = { '2023-02-06': 2, '2023-02-07': 2, }; + +export const MOCK_SNIPPETS_EMPTY_STATE = 'illustrations/empty-state/empty-snippets-md.svg'; + +export const MOCK_USER = { + id: '1', + avatarUrl: 'https://www.gravatar.com/avatar/test', + name: 'Test User', + username: 'test', +}; + +const getMockSnippet = (id) => { + return { + id: `gid://gitlab/PersonalSnippet/${id}`, + title: `Test snippet ${id}`, + visibilityLevel: 'public', + webUrl: `http://gitlab.com/-/snippets/${id}`, + createdAt: new Date(), + updatedAt: new Date(), + blobs: { + nodes: [ + { + name: 'test.txt', + }, + ], + }, + notes: { + nodes: [ + { + id: 'git://gitlab/Note/1', + }, + ], + }, + }; +}; + +const MOCK_PAGE_INFO = { + startCursor: 'asdfqwer', + endCursor: 'reqwfdsa', + __typename: 'PageInfo', +}; + +const getMockSnippetRes = (hasPagination) => { + return { + data: { + user: { + ...MOCK_USER, + snippets: { + pageInfo: { + ...MOCK_PAGE_INFO, + hasNextPage: hasPagination, + hasPreviousPage: hasPagination, + }, + nodes: [getMockSnippet(1), getMockSnippet(2)], + }, + }, + }, + }; +}; + +export const MOCK_SNIPPET = getMockSnippet(1); +export const MOCK_USER_SNIPPETS_RES = getMockSnippetRes(false); +export const MOCK_USER_SNIPPETS_PAGINATION_RES = getMockSnippetRes(true); +export const MOCK_USER_SNIPPETS_EMPTY_RES = { + data: { + user: { + ...MOCK_USER, + snippets: { + pageInfo: { + endCursor: null, + startCursor: null, + }, + nodes: [], + }, + }, + }, +}; diff --git a/spec/frontend/projects/commit/components/commit_options_dropdown_spec.js b/spec/frontend/projects/commit/components/commit_options_dropdown_spec.js index 7df498f597b..8a9c3bfff44 100644 --- a/spec/frontend/projects/commit/components/commit_options_dropdown_spec.js +++ b/spec/frontend/projects/commit/components/commit_options_dropdown_spec.js @@ -1,6 +1,4 @@ -import { GlDropdownDivider, GlDropdownSectionHeader } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import CommitOptionsDropdown from '~/projects/commit/components/commit_options_dropdown.vue'; import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '~/projects/commit/constants'; import eventHub from '~/projects/commit/event_hub'; @@ -14,18 +12,16 @@ describe('BranchesDropdown', () => { }; const createComponent = (props = {}) => { - wrapper = extendedWrapper( - shallowMount(CommitOptionsDropdown, { - provide, - propsData: { - canRevert: true, - canCherryPick: true, - canTag: true, - canEmailPatches: true, - ...props, - }, - }), - ); + wrapper = mountExtended(CommitOptionsDropdown, { + provide, + propsData: { + canRevert: true, + canCherryPick: true, + canTag: true, + canEmailPatches: true, + ...props, + }, + }); }; const findRevertLink = () => wrapper.findByTestId('revert-link'); @@ -33,8 +29,6 @@ describe('BranchesDropdown', () => { const findTagItem = () => wrapper.findByTestId('tag-link'); const findEmailPatchesItem = () => wrapper.findByTestId('email-patches-link'); const findPlainDiffItem = () => wrapper.findByTestId('plain-diff-link'); - const findDivider = () => wrapper.findComponent(GlDropdownDivider); - const findSectionHeader = () => wrapper.findComponent(GlDropdownSectionHeader); describe('Everything enabled', () => { beforeEach(() => { @@ -42,7 +36,7 @@ describe('BranchesDropdown', () => { }); it('has expected dropdown button text', () => { - expect(wrapper.attributes('text')).toBe('Options'); + expect(wrapper.findByTestId('base-dropdown-toggle').text()).toBe('Options'); }); it('has expected items', () => { @@ -51,8 +45,6 @@ describe('BranchesDropdown', () => { findRevertLink().exists(), findCherryPickLink().exists(), findTagItem().exists(), - findDivider().exists(), - findSectionHeader().exists(), findEmailPatchesItem().exists(), findPlainDiffItem().exists(), ].every((exists) => exists), @@ -94,7 +86,6 @@ describe('BranchesDropdown', () => { it('only has the download items', () => { createComponent({ canRevert: false, canCherryPick: false, canTag: false }); - expect(findDivider().exists()).toBe(false); expect(findEmailPatchesItem().exists()).toBe(true); expect(findPlainDiffItem().exists()).toBe(true); }); @@ -109,13 +100,13 @@ describe('BranchesDropdown', () => { }); it('emits openModal for revert', () => { - findRevertLink().vm.$emit('click'); + findRevertLink().trigger('click'); expect(spy).toHaveBeenCalledWith(OPEN_REVERT_MODAL); }); it('emits openModal for cherry-pick', () => { - findCherryPickLink().vm.$emit('click'); + findCherryPickLink().trigger('click'); expect(spy).toHaveBeenCalledWith(OPEN_CHERRY_PICK_MODAL); }); diff --git a/spec/frontend/projects/commit_box/info/load_branches_spec.js b/spec/frontend/projects/commit_box/info/load_branches_spec.js deleted file mode 100644 index b00a6378e07..00000000000 --- a/spec/frontend/projects/commit_box/info/load_branches_spec.js +++ /dev/null @@ -1,86 +0,0 @@ -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; -import { setHTMLFixture } from 'helpers/fixtures'; -import waitForPromises from 'helpers/wait_for_promises'; -import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import { loadBranches } from '~/projects/commit_box/info/load_branches'; -import { initDetailsButton } from '~/projects/commit_box/info/init_details_button'; - -jest.mock('~/projects/commit_box/info/init_details_button'); - -const mockCommitPath = '/commit/abcd/branches'; -const mockBranchesRes = - '<a href="/-/commits/main">main</a><span><a href="/-/commits/my-branch">my-branch</a></span>'; - -describe('~/projects/commit_box/info/load_branches', () => { - let mock; - - const getElInnerHtml = () => document.querySelector('.js-commit-box-info').innerHTML; - - beforeEach(() => { - setHTMLFixture(` - <div class="js-commit-box-info" data-commit-path="${mockCommitPath}"> - <div class="commit-info branches"> - <span class="spinner"/> - </div> - </div>`); - - mock = new MockAdapter(axios); - mock.onGet(mockCommitPath).reply(HTTP_STATUS_OK, mockBranchesRes); - }); - - it('initializes the details button', async () => { - loadBranches(); - await waitForPromises(); - - expect(initDetailsButton).toHaveBeenCalled(); - }); - - it('loads and renders branches info', async () => { - loadBranches(); - await waitForPromises(); - - expect(getElInnerHtml()).toMatchInterpolatedText( - `<div class="commit-info branches">${mockBranchesRes}</div>`, - ); - }); - - it('does not load when no container is provided', async () => { - loadBranches('.js-another-class'); - await waitForPromises(); - - expect(mock.history.get).toHaveLength(0); - }); - - describe('when branches request returns unsafe content', () => { - beforeEach(() => { - mock - .onGet(mockCommitPath) - .reply(HTTP_STATUS_OK, '<a onload="alert(\'xss!\');" href="/-/commits/main">main</a>'); - }); - - it('displays sanitized html', async () => { - loadBranches(); - await waitForPromises(); - - expect(getElInnerHtml()).toMatchInterpolatedText( - '<div class="commit-info branches"><a href="/-/commits/main">main</a></div>', - ); - }); - }); - - describe('when branches request fails', () => { - beforeEach(() => { - mock.onGet(mockCommitPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, 'Error!'); - }); - - it('attempts to load and renders an error', async () => { - loadBranches(); - await waitForPromises(); - - expect(getElInnerHtml()).toMatchInterpolatedText( - '<div class="commit-info branches">Failed to load branches. Please try again.</div>', - ); - }); - }); -}); diff --git a/spec/frontend/projects/compare/components/repo_dropdown_spec.js b/spec/frontend/projects/compare/components/repo_dropdown_spec.js index 0b1085470b8..44aaac21733 100644 --- a/spec/frontend/projects/compare/components/repo_dropdown_spec.js +++ b/spec/frontend/projects/compare/components/repo_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import RepoDropdown from '~/projects/compare/components/repo_dropdown.vue'; @@ -13,10 +13,14 @@ describe('RepoDropdown component', () => { ...defaultProps, ...props, }, + stubs: { + GlCollapsibleListbox, + GlListboxItem, + }, }); }; - const findGlDropdown = () => wrapper.findComponent(GlDropdown); + const findGlCollapsibleListbox = () => wrapper.findComponent(GlCollapsibleListbox); const findHiddenInput = () => wrapper.find('input[type="hidden"]'); describe('Source Revision', () => { @@ -29,8 +33,10 @@ describe('RepoDropdown component', () => { }); it('displays the project name in the disabled dropdown', () => { - expect(findGlDropdown().props('text')).toBe(defaultProps.selectedProject.name); - expect(findGlDropdown().props('disabled')).toBe(true); + expect(findGlCollapsibleListbox().props('toggleText')).toBe( + defaultProps.selectedProject.name, + ); + expect(findGlCollapsibleListbox().props('disabled')).toBe(true); }); it('does not emit `changeTargetProject` event', async () => { @@ -57,18 +63,21 @@ describe('RepoDropdown component', () => { }); it('displays matching project name of the source revision initially in the dropdown', () => { - expect(findGlDropdown().props('text')).toBe(defaultProps.selectedProject.name); + expect(findGlCollapsibleListbox().props('toggleText')).toBe( + defaultProps.selectedProject.name, + ); }); - it('updates the hidden input value when onClick method is triggered', async () => { + it('updates the hidden input value when dropdown item is selected', () => { const repoId = '1'; - wrapper.vm.onClick({ id: repoId }); - await nextTick(); + findGlCollapsibleListbox().vm.$emit('select', repoId); expect(findHiddenInput().attributes('value')).toBe(repoId); }); it('emits `selectProject` event when another target project is selected', async () => { - findGlDropdown().findAllComponents(GlDropdownItem).at(0).vm.$emit('click'); + const repoId = '1'; + findGlCollapsibleListbox().vm.$emit('select', repoId); + await nextTick(); expect(wrapper.emitted('selectProject')[0][0]).toEqual({ diff --git a/spec/frontend/projects/project_new_spec.js b/spec/frontend/projects/project_new_spec.js index 8a1e9904a3f..54d0cfaa8c6 100644 --- a/spec/frontend/projects/project_new_spec.js +++ b/spec/frontend/projects/project_new_spec.js @@ -13,6 +13,8 @@ describe('New Project', () => { const mockKeyup = (el) => el.dispatchEvent(new KeyboardEvent('keyup')); const mockChange = (el) => el.dispatchEvent(new Event('change')); + const mockSubmit = () => + document.getElementById('new_project').dispatchEvent(new Event('submit')); beforeEach(() => { setHTMLFixture(` @@ -311,4 +313,35 @@ describe('New Project', () => { expect($projectName.value).toEqual(dummyProjectName); }); }); + + describe('project path trimming', () => { + beforeEach(() => { + projectNew.bindEvents(); + }); + + describe('when the project path field is filled in', () => { + const dirtyProjectPath = ' my-awesome-project '; + const cleanProjectPath = dirtyProjectPath.trim(); + + beforeEach(() => { + $projectPath.value = dirtyProjectPath; + mockSubmit(); + }); + + it('trims the project path on submit', () => { + expect($projectPath.value).not.toBe(dirtyProjectPath); + expect($projectPath.value).toBe(cleanProjectPath); + }); + }); + + describe('when the project path field is left empty', () => { + beforeEach(() => { + mockSubmit(); + }); + + it('leaves the field empty', () => { + expect($projectPath.value).toBe(''); + }); + }); + }); }); diff --git a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js index f3e536de703..ce696ee321b 100644 --- a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js +++ b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js @@ -99,6 +99,9 @@ describe('Access Level Dropdown', () => { const findDropdownItemWithText = (items, text) => items.filter((item) => item.text().includes(text)).at(0); + const findSelected = (type) => + wrapper.findAllByTestId(`${type}-dropdown-item`).filter((w) => w.props('isChecked')); + describe('data request', () => { it('should make an api call for users, groups && deployKeys when user has a license', () => { createComponent(); @@ -305,9 +308,6 @@ describe('Access Level Dropdown', () => { { id: 122, type: 'deploy_key', deploy_key_id: 12 }, ]; - const findSelected = (type) => - wrapper.findAllByTestId(`${type}-dropdown-item`).filter((w) => w.props('isChecked')); - beforeEach(async () => { createComponent({ preselectedItems }); await waitForPromises(); @@ -339,6 +339,34 @@ describe('Access Level Dropdown', () => { }); }); + describe('handling two-way data binding', () => { + it('emits a formatted update on selection', async () => { + createComponent(); + await waitForPromises(); + const dropdownItems = findAllDropdownItems(); + // select new item from each group + findDropdownItemWithText(dropdownItems, 'role1').trigger('click'); + findDropdownItemWithText(dropdownItems, 'group4').trigger('click'); + findDropdownItemWithText(dropdownItems, 'user7').trigger('click'); + findDropdownItemWithText(dropdownItems, 'key10').trigger('click'); + + await wrapper.setProps({ items: [{ user_id: 7 }] }); + + const selectedUsers = findSelected(LEVEL_TYPES.USER); + expect(selectedUsers).toHaveLength(1); + expect(selectedUsers.at(0).text()).toBe('user7'); + + const selectedRoles = findSelected(LEVEL_TYPES.ROLE); + expect(selectedRoles).toHaveLength(0); + + const selectedGroups = findSelected(LEVEL_TYPES.GROUP); + expect(selectedGroups).toHaveLength(0); + + const selectedDeployKeys = findSelected(LEVEL_TYPES.DEPLOY_KEY); + expect(selectedDeployKeys).toHaveLength(0); + }); + }); + describe('on dropdown open', () => { beforeEach(() => { createComponent(); diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js index 86e4e88e3cf..7f6ecbac748 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js @@ -18,6 +18,7 @@ describe('ServiceDeskRoot', () => { endpoint: '/gitlab-org/gitlab-test/service_desk', initialIncomingEmail: 'servicedeskaddress@example.com', initialIsEnabled: true, + isIssueTrackerEnabled: true, outgoingName: 'GitLab Support Bot', projectKey: 'key', selectedTemplate: 'Bug', @@ -59,6 +60,7 @@ describe('ServiceDeskRoot', () => { initialSelectedTemplate: provideData.selectedTemplate, initialSelectedFileTemplateProjectId: provideData.selectedFileTemplateProjectId, isEnabled: provideData.initialIsEnabled, + isIssueTrackerEnabled: provideData.isIssueTrackerEnabled, isTemplateSaving: false, templates: provideData.templates, }); 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 84eafc3d0f3..5631927cc2f 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,7 +1,8 @@ -import { GlButton, GlDropdown, GlLoadingIcon, GlToggle } from '@gitlab/ui'; +import { GlButton, GlDropdown, GlLoadingIcon, GlToggle, GlAlert } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { helpPagePath } from '~/helpers/help_page_helper'; import ServiceDeskSetting from '~/projects/settings_service_desk/components/service_desk_setting.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -16,17 +17,44 @@ describe('ServiceDeskSetting', () => { const findTemplateDropdown = () => wrapper.findComponent(GlDropdown); const findToggle = () => wrapper.findComponent(GlToggle); const findSuffixFormGroup = () => wrapper.findByTestId('suffix-form-group'); + const findIssueTrackerInfo = () => wrapper.findComponent(GlAlert); + const findIssueHelpLink = () => wrapper.findByTestId('issue-help-page'); const createComponent = ({ props = {} } = {}) => extendedWrapper( mount(ServiceDeskSetting, { propsData: { isEnabled: true, + isIssueTrackerEnabled: true, ...props, }, }), ); + describe('with issue tracker', () => { + it('does not show the info notice when enabled', () => { + wrapper = createComponent(); + + expect(findIssueTrackerInfo().exists()).toBe(false); + }); + + it('shows info notice when disabled with help page link', () => { + wrapper = createComponent({ + props: { + isIssueTrackerEnabled: false, + }, + }); + + expect(findIssueTrackerInfo().exists()).toBe(true); + expect(findIssueHelpLink().text()).toEqual('activate the issue tracker'); + expect(findIssueHelpLink().attributes('href')).toBe( + helpPagePath('user/project/settings/index.md', { + anchor: 'configure-project-visibility-features-and-permissions', + }), + ); + }); + }); + describe('when isEnabled=true', () => { describe('only isEnabled', () => { describe('as project admin', () => { diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js index 7090db5cad7..1a76e7d1ec6 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js @@ -14,6 +14,7 @@ describe('ServiceDeskTemplateDropdown', () => { mount(ServiceDeskTemplateDropdown, { propsData: { isEnabled: true, + isIssueTrackerEnabled: true, ...props, }, }), diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index 7e14d292946..ecd617ca44b 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -16,7 +16,7 @@ import ForkSuggestion from '~/repository/components/fork_suggestion.vue'; import { loadViewer } 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 SourceViewer from '~/vue_shared/components/source_viewer/source_viewer_deprecated.vue'; +import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue'; import blobInfoQuery from 'shared_queries/repository/blob_info.query.graphql'; import projectInfoQuery from '~/repository/queries/project_info.query.graphql'; import userInfoQuery from '~/repository/queries/user_info.query.graphql'; @@ -38,6 +38,7 @@ import { userPermissionsMock, propsMock, refMock, + axiosMockResponse, } from '../mock_data'; jest.mock('~/repository/components/blob_viewers'); @@ -61,6 +62,8 @@ const mockRouter = { push: mockRouterPush, }; +const legacyViewerUrl = 'some_file.js?format=json&viewer=simple'; + const createComponent = async (mockData = {}, mountFn = shallowMount, mockRoute = {}) => { Vue.use(VueApollo); @@ -79,8 +82,12 @@ const createComponent = async (mockData = {}, mountFn = shallowMount, mockRoute const blobInfo = { ...projectMock, repository: { + __typename: 'Repository', empty, - blobs: { nodes: [blob] }, + blobs: { + __typename: 'RepositoryBlobConnection', + nodes: [blob], + }, }, }; @@ -148,10 +155,6 @@ const createComponent = async (mockData = {}, mountFn = shallowMount, mockRoute }), ); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ project: blobInfo, isBinary }); - await waitForPromises(); }; @@ -216,7 +219,6 @@ describe('Blob content viewer component', () => { }); describe('legacy viewers', () => { - const legacyViewerUrl = 'some_file.js?format=json&viewer=simple'; const fileType = 'text'; const highlightJs = false; @@ -437,8 +439,8 @@ describe('Blob content viewer component', () => { }); it('renders WebIdeLink button for binary files', async () => { - await createComponent({ blob: richViewerMock, isBinary: true }, mount); - + mockAxios.onGet(legacyViewerUrl).replyOnce(HTTP_STATUS_OK, axiosMockResponse); + await createComponent({}, mount); expect(findWebIdeLink().props()).toMatchObject({ editUrl: editBlobPath, webIdeUrl: ideEditPath, @@ -448,7 +450,8 @@ describe('Blob content viewer component', () => { describe('blob header binary file', () => { it('passes the correct isBinary value when viewing a binary file', async () => { - await createComponent({ blob: richViewerMock, isBinary: true }); + mockAxios.onGet(legacyViewerUrl).replyOnce(HTTP_STATUS_OK, axiosMockResponse); + await createComponent(); expect(findBlobHeader().props('isBinary')).toBe(true); }); diff --git a/spec/frontend/repository/components/blob_viewers/geo_json/geo_json_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/geo_json/geo_json_viewer_spec.js new file mode 100644 index 00000000000..15918b4d8d5 --- /dev/null +++ b/spec/frontend/repository/components/blob_viewers/geo_json/geo_json_viewer_spec.js @@ -0,0 +1,40 @@ +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import GeoJsonViewer from '~/repository/components/blob_viewers/geo_json/geo_json_viewer.vue'; +import { initLeafletMap } from '~/repository/components/blob_viewers/geo_json/utils'; +import { RENDER_ERROR_MSG } from '~/repository/components/blob_viewers/geo_json/constants'; +import { createAlert } from '~/alert'; + +jest.mock('~/repository/components/blob_viewers/geo_json/utils'); +jest.mock('~/alert'); + +describe('GeoJson Viewer', () => { + let wrapper; + + const GEO_JSON_MOCK_DATA = '{ "type": "FeatureCollection" }'; + + const createComponent = (rawTextBlob = GEO_JSON_MOCK_DATA) => { + wrapper = shallowMountExtended(GeoJsonViewer, { + propsData: { blob: { rawTextBlob } }, + }); + }; + + beforeEach(() => createComponent()); + + const findMapWrapper = () => wrapper.findByTestId('map'); + + it('calls a the initLeafletMap util', () => { + const mapWrapper = findMapWrapper(); + + expect(initLeafletMap).toHaveBeenCalledWith(mapWrapper.element, JSON.parse(GEO_JSON_MOCK_DATA)); + expect(mapWrapper.exists()).toBe(true); + }); + + it('displays an error if invalid json is provided', async () => { + createComponent('invalid JSON'); + await nextTick(); + + expect(createAlert).toHaveBeenCalledWith({ message: RENDER_ERROR_MSG }); + expect(findMapWrapper().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/repository/components/blob_viewers/geo_json/utils_spec.js b/spec/frontend/repository/components/blob_viewers/geo_json/utils_spec.js new file mode 100644 index 00000000000..c80a83c0ca0 --- /dev/null +++ b/spec/frontend/repository/components/blob_viewers/geo_json/utils_spec.js @@ -0,0 +1,68 @@ +import { map, tileLayer, geoJson, featureGroup, Icon } from 'leaflet'; +import * as utils from '~/repository/components/blob_viewers/geo_json/utils'; +import { + OPEN_STREET_TILE_URL, + MAP_ATTRIBUTION, + OPEN_STREET_COPYRIGHT_LINK, + ICON_CONFIG, +} from '~/repository/components/blob_viewers/geo_json/constants'; + +jest.mock('leaflet', () => ({ + featureGroup: () => ({ getBounds: jest.fn() }), + Icon: { Default: { mergeOptions: jest.fn() } }, + tileLayer: jest.fn(), + map: jest.fn().mockReturnValue({ fitBounds: jest.fn() }), + geoJson: jest.fn().mockReturnValue({ addTo: jest.fn() }), +})); + +describe('GeoJson utilities', () => { + const mockWrapper = document.createElement('div'); + const mockData = { test: 'data' }; + + describe('initLeafletMap', () => { + describe('valid params', () => { + beforeEach(() => utils.initLeafletMap(mockWrapper, mockData)); + + it('sets the correct icon', () => { + expect(Icon.Default.mergeOptions).toHaveBeenCalledWith(ICON_CONFIG); + }); + + it('inits the leaflet map', () => { + const attribution = `${MAP_ATTRIBUTION} ${OPEN_STREET_COPYRIGHT_LINK}`; + + expect(tileLayer).toHaveBeenCalledWith(OPEN_STREET_TILE_URL, { attribution }); + expect(map).toHaveBeenCalledWith(mockWrapper, { layers: [] }); + }); + + it('adds geojson data to the leaflet map', () => { + expect(geoJson().addTo).toHaveBeenCalledWith(map()); + }); + + it('fits the map to the correct bounds', () => { + expect(map().fitBounds).toHaveBeenCalledWith(featureGroup().getBounds()); + }); + + it('generates popup content containing the metaData', () => { + const popupContent = utils.popupContent(mockData); + + expect(popupContent).toContain(Object.keys(mockData)[0]); + expect(popupContent).toContain(mockData.test); + }); + }); + + describe('invalid params', () => { + it.each([ + [null, null], + [null, mockData], + [mockWrapper, null], + ])('does nothing (returns early) if any of the params are not provided', (wrapper, data) => { + utils.initLeafletMap(wrapper, data); + expect(Icon.Default.mergeOptions).not.toHaveBeenCalled(); + expect(tileLayer).not.toHaveBeenCalled(); + expect(map).not.toHaveBeenCalled(); + expect(geoJson().addTo).not.toHaveBeenCalled(); + expect(map().fitBounds).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/repository/components/fork_info_spec.js b/spec/frontend/repository/components/fork_info_spec.js index 62a66e59d24..23609c95ca0 100644 --- a/spec/frontend/repository/components/fork_info_spec.js +++ b/spec/frontend/repository/components/fork_info_spec.js @@ -27,7 +27,6 @@ describe('ForkInfo component', () => { const forkInfoError = new Error('Something went wrong'); const projectId = 'gid://gitlab/Project/1'; const showMock = jest.fn(); - const synchronizeFork = true; Vue.use(VueApollo); @@ -72,11 +71,6 @@ describe('ForkInfo component', () => { methods: { show: showMock }, }), }, - provide: { - glFeatures: { - synchronizeFork, - }, - }, }); return waitForPromises(); }; diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js index f7be367887c..a89a107b68f 100644 --- a/spec/frontend/repository/components/table/index_spec.js +++ b/spec/frontend/repository/components/table/index_spec.js @@ -1,11 +1,24 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import { GlSkeletonLoader, GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; import Table from '~/repository/components/table/index.vue'; import TableRow from '~/repository/components/table/row.vue'; +import refQuery from '~/repository/queries/ref.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; -let vm; -let $apollo; +let wrapper; + +const createMockApolloProvider = (ref) => { + Vue.use(VueApollo); + const apolloProver = createMockApollo([]); + apolloProver.clients.defaultClient.cache.writeQuery({ + query: refQuery, + data: { ref, escapedRef: ref }, + }); + + return apolloProver; +}; const MOCK_BLOBS = [ { @@ -70,8 +83,15 @@ const MOCK_COMMITS = [ }, ]; -function factory({ path, isLoading = false, hasMore = true, entries = {}, commits = [] }) { - vm = shallowMount(Table, { +function factory({ + path, + isLoading = false, + hasMore = true, + entries = {}, + commits = [], + ref = 'main', +}) { + wrapper = shallowMount(Table, { propsData: { path, isLoading, @@ -79,13 +99,11 @@ function factory({ path, isLoading = false, hasMore = true, entries = {}, commit hasMore, commits, }, - mocks: { - $apollo, - }, + apolloProvider: createMockApolloProvider(ref), }); } -const findTableRows = () => vm.findAllComponents(TableRow); +const findTableRows = () => wrapper.findAllComponents(TableRow); describe('Repository table component', () => { it.each` @@ -94,14 +112,10 @@ describe('Repository table component', () => { ${'app/assets'} | ${'main'} ${'/'} | ${'test'} `('renders table caption for $ref in $path', async ({ path, ref }) => { - factory({ path }); - - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - vm.setData({ ref }); + factory({ path, ref }); await nextTick(); - expect(vm.find('.table').attributes('aria-label')).toEqual( + expect(wrapper.find('.table').attributes('aria-label')).toEqual( `Files, directories, and submodules in the path ${path} for commit reference ${ref}`, ); }); @@ -109,7 +123,7 @@ describe('Repository table component', () => { it('shows loading icon', () => { factory({ path: '/', isLoading: true }); - expect(vm.findComponent(GlSkeletonLoader).exists()).toBe(true); + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true); }); it('renders table rows', () => { @@ -152,7 +166,7 @@ describe('Repository table component', () => { }); describe('Show more button', () => { - const showMoreButton = () => vm.findComponent(GlButton); + const showMoreButton = () => wrapper.findComponent(GlButton); it.each` hasMore | expectButtonToExist @@ -170,7 +184,7 @@ describe('Repository table component', () => { await nextTick(); - expect(vm.emitted('showMore')).toHaveLength(1); + expect(wrapper.emitted('showMore')).toHaveLength(1); }); }); }); diff --git a/spec/frontend/repository/mock_data.js b/spec/frontend/repository/mock_data.js index 399341d23a0..e20849d1085 100644 --- a/spec/frontend/repository/mock_data.js +++ b/spec/frontend/repository/mock_data.js @@ -198,3 +198,5 @@ export const paginatedTreeResponseFactory = ({ }, }, }); + +export const axiosMockResponse = { html: 'text', binary: true }; diff --git a/spec/frontend/search/mock_data.js b/spec/frontend/search/mock_data.js index f8dd6f6df27..7cf8633d749 100644 --- a/spec/frontend/search/mock_data.js +++ b/spec/frontend/search/mock_data.js @@ -7,6 +7,8 @@ export const MOCK_QUERY = { confidential: null, group_id: 1, language: ['C', 'JavaScript'], + labels: ['60', '37'], + search: '*', }; export const MOCK_GROUP = { @@ -542,3 +544,346 @@ export const MOCK_NAVIGATION_ITEMS = [ items: [], }, ]; + +export const PROCESS_LABELS_DATA = [ + { + key: '60', + count: 14, + title: 'Brist', + color: 'rgb(170, 174, 187)', + type: 'GroupLabel', + parent_full_name: 'Twitter', + }, + { + key: '69', + count: 13, + title: 'Brouneforge', + color: 'rgb(170, 174, 187)', + type: 'GroupLabel', + parent_full_name: 'Twitter', + }, + { + key: '33', + count: 12, + title: 'Brifunc', + color: 'rgb(170, 174, 187)', + type: 'GroupLabel', + parent_full_name: 'Commit451', + }, + { + key: '37', + count: 12, + title: 'Aftersync', + color: 'rgb(170, 174, 187)', + type: 'GroupLabel', + parent_full_name: 'Commit451', + }, +]; + +export const APPLIED_SELECTED_LABELS = [ + { + key: '60', + count: 14, + title: 'Brist', + color: '#aaaebb', + type: 'GroupLabel', + parent_full_name: 'Twitter', + }, + { + key: '37', + count: 12, + title: 'Aftersync', + color: '#79fdbf', + type: 'GroupLabel', + parent_full_name: 'Commit451', + }, +]; + +export const MOCK_LABEL_AGGREGATIONS = { + fetching: false, + error: false, + data: [ + { + name: 'labels', + buckets: [ + { + key: '60', + count: 14, + title: 'Brist', + color: '#aaaebb', + type: 'GroupLabel', + parent_full_name: 'Twitter', + }, + { + key: '37', + count: 12, + title: 'Aftersync', + color: '#79fdbf', + type: 'GroupLabel', + parent_full_name: 'Commit451', + }, + { + key: '6', + count: 12, + title: 'Cosche', + color: '#cea786', + type: 'GroupLabel', + parent_full_name: 'Toolbox', + }, + { + key: '73', + count: 12, + title: 'Accent', + color: '#a5c6fb', + type: 'ProjectLabel', + parent_full_name: 'Toolbox / Gitlab Smoke Tests', + }, + ], + }, + ], +}; + +export const MOCK_LABEL_SEARCH_RESULT = { + key: '37', + count: 12, + title: 'Aftersync', + color: '#79fdbf', + type: 'GroupLabel', + parent_full_name: 'Commit451', +}; + +export const MOCK_FILTERED_UNSELECTED_LABELS = [ + { + key: '6', + count: 12, + title: 'Cosche', + color: '#cea786', + type: 'GroupLabel', + parent_full_name: 'Toolbox', + }, + { + key: '73', + count: 12, + title: 'Accent', + color: '#a5c6fb', + type: 'ProjectLabel', + parent_full_name: 'Toolbox / Gitlab Smoke Tests', + }, +]; + +export const MOCK_FILTERED_APPLIED_SELECTED_LABELS = [ + { + key: '60', + count: 14, + title: 'Brist', + color: '#aaaebb', + type: 'GroupLabel', + parent_full_name: 'Twitter', + }, + { + key: '37', + count: 12, + title: 'Aftersync', + color: '#79fdbf', + type: 'GroupLabel', + parent_full_name: 'Commit451', + }, +]; + +export const MOCK_FILTERED_LABELS = [ + { + key: '60', + count: 14, + title: 'Brist', + color: '#aaaebb', + type: 'GroupLabel', + parent_full_name: 'Twitter', + }, + { + key: '69', + count: 13, + title: 'Brouneforge', + color: '#8a13d3', + type: 'GroupLabel', + parent_full_name: 'Twitter', + }, + { + key: '33', + count: 12, + title: 'Brifunc', + color: '#b76463', + type: 'GroupLabel', + parent_full_name: 'Commit451', + }, + { + key: '37', + count: 12, + title: 'Aftersync', + color: '#79fdbf', + type: 'GroupLabel', + parent_full_name: 'Commit451', + }, + { + key: '6', + count: 12, + title: 'Cosche', + color: '#cea786', + type: 'GroupLabel', + parent_full_name: 'Toolbox', + }, + { + key: '73', + count: 12, + title: 'Accent', + color: '#a5c6fb', + type: 'ProjectLabel', + parent_full_name: 'Toolbox / Gitlab Smoke Tests', + }, + { + key: '9', + count: 12, + title: 'Briph', + color: '#e69182', + type: 'GroupLabel', + parent_full_name: 'Toolbox', + }, + { + key: '91', + count: 12, + title: 'Cobalt', + color: '#9eae75', + type: 'ProjectLabel', + parent_full_name: 'Commit451 / Lab Coat', + }, + { + key: '94', + count: 12, + title: 'Protege', + color: '#777b83', + type: 'ProjectLabel', + parent_full_name: 'Commit451 / Lab Coat', + }, + { + key: '84', + count: 11, + title: 'Avenger', + color: '#5c5161', + type: 'ProjectLabel', + parent_full_name: 'Gitlab Org / Gitlab Shell', + }, + { + key: '99', + count: 11, + title: 'Cobalt', + color: '#9eae75', + type: 'ProjectLabel', + parent_full_name: 'Jashkenas / Underscore', + }, + { + key: '77', + count: 10, + title: 'Avenger', + color: '#5c5161', + type: 'ProjectLabel', + parent_full_name: 'Gitlab Org / Gitlab Test', + }, + { + key: '79', + count: 10, + title: 'Fiero', + color: '#681cd0', + type: 'ProjectLabel', + parent_full_name: 'Gitlab Org / Gitlab Test', + }, + { + key: '98', + count: 9, + title: 'Golf', + color: '#007aaf', + type: 'ProjectLabel', + parent_full_name: 'Jashkenas / Underscore', + }, + { + key: '101', + count: 7, + title: 'Accord', + color: '#a72b3b', + type: 'ProjectLabel', + parent_full_name: 'Flightjs / Flight', + }, + { + key: '53', + count: 7, + title: 'Amsche', + color: '#9964cf', + type: 'GroupLabel', + parent_full_name: 'Flightjs', + }, + { + key: '11', + count: 3, + title: 'Aquasync', + color: '#347e7f', + type: 'GroupLabel', + parent_full_name: 'Gitlab Org', + }, + { + key: '15', + count: 3, + title: 'Lunix', + color: '#aad577', + type: 'GroupLabel', + parent_full_name: 'Gitlab Org', + }, + { + key: '88', + count: 3, + title: 'Aztek', + color: '#59160a', + type: 'ProjectLabel', + parent_full_name: 'Gnuwget / Wget2', + }, + { + key: '89', + count: 3, + title: 'Intrigue', + color: '#5039bd', + type: 'ProjectLabel', + parent_full_name: 'Gnuwget / Wget2', + }, + { + key: '96', + count: 2, + title: 'Trailblazer', + color: '#5a3e93', + type: 'ProjectLabel', + parent_full_name: 'Jashkenas / Underscore', + }, + { + key: '54', + count: 1, + title: 'NB', + color: '#a4a53a', + type: 'GroupLabel', + parent_full_name: 'Flightjs', + }, +]; + +export const MOCK_FILTERED_UNAPPLIED_SELECTED_LABELS = [ + { + key: '6', + count: 12, + title: 'Cosche', + color: '#cea786', + type: 'GroupLabel', + parent_full_name: 'Toolbox', + }, + { + key: '73', + count: 12, + title: 'Accent', + color: '#a5c6fb', + type: 'ProjectLabel', + parent_full_name: 'Toolbox / Gitlab Smoke Tests', + }, +]; diff --git a/spec/frontend/search/sidebar/components/app_spec.js b/spec/frontend/search/sidebar/components/app_spec.js index 963b73aeae5..ba492833ec4 100644 --- a/spec/frontend/search/sidebar/components/app_spec.js +++ b/spec/frontend/search/sidebar/components/app_spec.js @@ -3,8 +3,9 @@ import Vue from 'vue'; import Vuex from 'vuex'; import { MOCK_QUERY } from 'jest/search/mock_data'; import GlobalSearchSidebar from '~/search/sidebar/components/app.vue'; -import ResultsFilters from '~/search/sidebar/components/results_filters.vue'; -import ScopeNavigation from '~/search/sidebar/components/scope_navigation.vue'; +import IssuesFilters from '~/search/sidebar/components/issues_filters.vue'; +import ScopeLegacyNavigation from '~/search/sidebar/components/scope_legacy_navigation.vue'; +import ScopeSidebarNavigation from '~/search/sidebar/components/scope_sidebar_navigation.vue'; import LanguageFilter from '~/search/sidebar/components/language_filter/index.vue'; Vue.use(Vuex); @@ -12,22 +13,16 @@ Vue.use(Vuex); describe('GlobalSearchSidebar', () => { let wrapper; - const actionSpies = { - applyQuery: jest.fn(), - resetQuery: jest.fn(), - }; - const getterSpies = { currentScope: jest.fn(() => 'issues'), }; - const createComponent = (initialState, featureFlags) => { + const createComponent = (initialState = {}, featureFlags = {}) => { const store = new Vuex.Store({ state: { urlQuery: MOCK_QUERY, ...initialState, }, - actions: actionSpies, getters: getterSpies, }); @@ -42,14 +37,15 @@ describe('GlobalSearchSidebar', () => { }; const findSidebarSection = () => wrapper.find('section'); - const findFilters = () => wrapper.findComponent(ResultsFilters); - const findSidebarNavigation = () => wrapper.findComponent(ScopeNavigation); + const findFilters = () => wrapper.findComponent(IssuesFilters); + const findScopeLegacyNavigation = () => wrapper.findComponent(ScopeLegacyNavigation); + const findScopeSidebarNavigation = () => wrapper.findComponent(ScopeSidebarNavigation); const findLanguageAggregation = () => wrapper.findComponent(LanguageFilter); describe('renders properly', () => { describe('always', () => { beforeEach(() => { - createComponent({}); + createComponent(); }); it(`shows section`, () => { expect(findSidebarSection().exists()).toBe(true); @@ -77,12 +73,24 @@ describe('GlobalSearchSidebar', () => { }); }); - describe('renders navigation', () => { + describe.each` + currentScope | sidebarNavShown | legacyNavShown + ${'issues'} | ${false} | ${true} + ${''} | ${false} | ${false} + ${'issues'} | ${true} | ${false} + ${''} | ${true} | ${false} + `('renders navigation', ({ currentScope, sidebarNavShown, legacyNavShown }) => { beforeEach(() => { - createComponent({}); + getterSpies.currentScope = jest.fn(() => currentScope); + createComponent({ useSidebarNavigation: sidebarNavShown }); }); - it('shows the vertical navigation', () => { - expect(findSidebarNavigation().exists()).toBe(true); + + it(`${!legacyNavShown ? 'hides' : 'shows'} the legacy navigation`, () => { + expect(findScopeLegacyNavigation().exists()).toBe(legacyNavShown); + }); + + it(`${!sidebarNavShown ? 'hides' : 'shows'} the sidebar navigation`, () => { + expect(findScopeSidebarNavigation().exists()).toBe(sidebarNavShown); }); }); }); diff --git a/spec/frontend/search/sidebar/components/checkbox_filter_spec.js b/spec/frontend/search/sidebar/components/checkbox_filter_spec.js index 3907e199cae..54fdf6e869e 100644 --- a/spec/frontend/search/sidebar/components/checkbox_filter_spec.js +++ b/spec/frontend/search/sidebar/components/checkbox_filter_spec.js @@ -7,7 +7,7 @@ import { MOCK_QUERY, MOCK_LANGUAGE_AGGREGATIONS_BUCKETS } from 'jest/search/mock import CheckboxFilter, { TRACKING_LABEL_CHECKBOX, TRACKING_LABEL_SET, -} from '~/search/sidebar/components/checkbox_filter.vue'; +} from '~/search/sidebar/components/language_filter/checkbox_filter.vue'; import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; import { convertFiltersData } from '~/search/sidebar/utils'; diff --git a/spec/frontend/search/sidebar/components/filters_spec.js b/spec/frontend/search/sidebar/components/filters_spec.js index d189c695467..a92fafd3508 100644 --- a/spec/frontend/search/sidebar/components/filters_spec.js +++ b/spec/frontend/search/sidebar/components/filters_spec.js @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; import { MOCK_QUERY } from 'jest/search/mock_data'; -import ResultsFilters from '~/search/sidebar/components/results_filters.vue'; +import IssuesFilters from '~/search/sidebar/components/issues_filters.vue'; import ConfidentialityFilter from '~/search/sidebar/components/confidentiality_filter.vue'; import StatusFilter from '~/search/sidebar/components/status_filter.vue'; @@ -31,7 +31,7 @@ describe('GlobalSearchSidebarFilters', () => { getters: defaultGetters, }); - wrapper = shallowMount(ResultsFilters, { + wrapper = shallowMount(IssuesFilters, { store, }); }; diff --git a/spec/frontend/search/sidebar/components/label_dropdown_items_spec.js b/spec/frontend/search/sidebar/components/label_dropdown_items_spec.js new file mode 100644 index 00000000000..135b12956b2 --- /dev/null +++ b/spec/frontend/search/sidebar/components/label_dropdown_items_spec.js @@ -0,0 +1,57 @@ +import { GlFormCheckbox } from '@gitlab/ui'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { shallowMount } from '@vue/test-utils'; +import { PROCESS_LABELS_DATA } from 'jest/search/mock_data'; +import LabelDropdownItems from '~/search/sidebar/components/label_filter/label_dropdown_items.vue'; + +Vue.use(Vuex); + +describe('LabelDropdownItems', () => { + let wrapper; + + const defaultProps = { + labels: PROCESS_LABELS_DATA, + }; + + const createComponent = (Props = defaultProps) => { + wrapper = shallowMount(LabelDropdownItems, { + propsData: { + ...Props, + }, + }); + }; + + const findAllLabelItems = () => wrapper.findAll('.label-filter-menu-item'); + const findFirstLabelCheckbox = () => findAllLabelItems().at(0).findComponent(GlFormCheckbox); + const findFirstLabelTitle = () => findAllLabelItems().at(0).findComponent('.label-title'); + const findFirstLabelColor = () => + findAllLabelItems().at(0).findComponent('[data-testid="label-color-indicator"]'); + + describe('Renders correctly', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders items', () => { + expect(findAllLabelItems().exists()).toBe(true); + expect(findAllLabelItems()).toHaveLength(defaultProps.labels.length); + }); + + it('renders items checkbox', () => { + expect(findFirstLabelCheckbox().exists()).toBe(true); + }); + + it('renders label title', () => { + expect(findFirstLabelTitle().exists()).toBe(true); + expect(findFirstLabelTitle().text()).toBe(defaultProps.labels[0].title); + }); + + it('renders label color', () => { + expect(findFirstLabelColor().exists()).toBe(true); + expect(findFirstLabelColor().attributes('style')).toBe( + `background-color: ${defaultProps.labels[0].color};`, + ); + }); + }); +}); diff --git a/spec/frontend/search/sidebar/components/label_filter_spec.js b/spec/frontend/search/sidebar/components/label_filter_spec.js new file mode 100644 index 00000000000..c5df374d4ef --- /dev/null +++ b/spec/frontend/search/sidebar/components/label_filter_spec.js @@ -0,0 +1,322 @@ +import { + GlAlert, + GlLoadingIcon, + GlSearchBoxByType, + GlLabel, + GlDropdownForm, + GlFormCheckboxGroup, + GlDropdownSectionHeader, + GlDropdownDivider, +} from '@gitlab/ui'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { MOCK_QUERY, MOCK_LABEL_AGGREGATIONS } from 'jest/search/mock_data'; +import LabelFilter from '~/search/sidebar/components/label_filter/index.vue'; +import LabelDropdownItems from '~/search/sidebar/components/label_filter/label_dropdown_items.vue'; + +import * as actions from '~/search/store/actions'; +import * as getters from '~/search/store/getters'; +import mutations from '~/search/store/mutations'; +import createState from '~/search/store/state'; + +import { + TRACKING_LABEL_FILTER, + TRACKING_LABEL_DROPDOWN, + TRACKING_LABEL_CHECKBOX, + TRACKING_ACTION_SELECT, + TRACKING_ACTION_SHOW, +} from '~/search/sidebar/components/label_filter/tracking'; + +import { labelFilterData } from '~/search/sidebar/components/label_filter/data'; + +import { + RECEIVE_AGGREGATIONS_SUCCESS, + REQUEST_AGGREGATIONS, + RECEIVE_AGGREGATIONS_ERROR, +} from '~/search/store/mutation_types'; + +Vue.use(Vuex); + +const actionSpies = { + fetchAllAggregation: jest.fn(), + setQuery: jest.fn(), + closeLabel: jest.fn(), + setLabelFilterSearch: jest.fn(), +}; + +describe('GlobalSearchSidebarLabelFilter', () => { + let wrapper; + let trackingSpy; + let config; + let store; + + const createComponent = (initialState) => { + config = { + actions: { + ...actions, + fetchAllAggregation: actionSpies.fetchAllAggregation, + closeLabel: actionSpies.closeLabel, + setLabelFilterSearch: actionSpies.setLabelFilterSearch, + setQuery: actionSpies.setQuery, + }, + getters, + mutations, + state: createState({ + query: MOCK_QUERY, + aggregations: MOCK_LABEL_AGGREGATIONS, + ...initialState, + }), + }; + + store = new Vuex.Store(config); + + wrapper = mountExtended(LabelFilter, { + store, + provide: { + glFeatures: { + searchIssueLabelAggregation: true, + }, + }, + }); + }; + + const findComponentTitle = () => wrapper.findComponentByTestId('label-filter-title'); + const findAllSelectedLabelsAbove = () => wrapper.findAllComponents(GlLabel); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findDropdownForm = () => wrapper.findComponent(GlDropdownForm); + const findCheckboxGroup = () => wrapper.findComponent(GlFormCheckboxGroup); + const findDropdownSectionHeader = () => wrapper.findComponent(GlDropdownSectionHeader); + const findDivider = () => wrapper.findComponent(GlDropdownDivider); + const findCheckboxFilter = () => wrapper.findAllComponents(LabelDropdownItems); + const findAlert = () => wrapper.findComponent(GlAlert); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + describe('Renders correctly closed', () => { + beforeEach(async () => { + createComponent(); + store.commit(RECEIVE_AGGREGATIONS_SUCCESS, MOCK_LABEL_AGGREGATIONS.data); + + await Vue.nextTick(); + }); + + it('renders component title', () => { + expect(findComponentTitle().exists()).toBe(true); + }); + + it('renders selected labels above search box', () => { + expect(findAllSelectedLabelsAbove().exists()).toBe(true); + expect(findAllSelectedLabelsAbove()).toHaveLength(2); + }); + + it('renders search box', () => { + expect(findSearchBox().exists()).toBe(true); + }); + + it("doesn't render dropdown form", () => { + expect(findDropdownForm().exists()).toBe(false); + }); + + it("doesn't render checkbox group", () => { + expect(findCheckboxGroup().exists()).toBe(false); + }); + + it("doesn't render dropdown section header", () => { + expect(findDropdownSectionHeader().exists()).toBe(false); + }); + + it("doesn't render divider", () => { + expect(findDivider().exists()).toBe(false); + }); + + it("doesn't render checkbox filter", () => { + expect(findCheckboxFilter().exists()).toBe(false); + }); + + it("doesn't render alert", () => { + expect(findAlert().exists()).toBe(false); + }); + + it("doesn't render loading icon", () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('Renders correctly opened', () => { + beforeEach(async () => { + createComponent(); + store.commit(RECEIVE_AGGREGATIONS_SUCCESS, MOCK_LABEL_AGGREGATIONS.data); + + await Vue.nextTick(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + findSearchBox().vm.$emit('focusin'); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('renders component title', () => { + expect(findComponentTitle().exists()).toBe(true); + }); + + it('renders selected labels above search box', () => { + // default data need to provide at least two selected labels + expect(findAllSelectedLabelsAbove().exists()).toBe(true); + expect(findAllSelectedLabelsAbove()).toHaveLength(2); + }); + + it('renders search box', () => { + expect(findSearchBox().exists()).toBe(true); + }); + + it('renders dropdown form', () => { + expect(findDropdownForm().exists()).toBe(true); + }); + + it('renders checkbox group', () => { + expect(findCheckboxGroup().exists()).toBe(true); + }); + + it('renders dropdown section header', () => { + expect(findDropdownSectionHeader().exists()).toBe(true); + }); + + it('renders divider', () => { + expect(findDivider().exists()).toBe(true); + }); + + it('renders checkbox filter', () => { + expect(findCheckboxFilter().exists()).toBe(true); + }); + + it("doesn't render alert", () => { + expect(findAlert().exists()).toBe(false); + }); + + it("doesn't render loading icon", () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('sends tracking information when dropdown is opened', () => { + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_ACTION_SHOW, TRACKING_LABEL_DROPDOWN, { + label: TRACKING_LABEL_DROPDOWN, + }); + }); + }); + + describe('Renders loading state correctly', () => { + beforeEach(async () => { + createComponent(); + store.commit(REQUEST_AGGREGATIONS); + await Vue.nextTick(); + + findSearchBox().vm.$emit('focusin'); + }); + + it('renders checkbox filter', () => { + expect(findCheckboxFilter().exists()).toBe(false); + }); + + it("doesn't render alert", () => { + expect(findAlert().exists()).toBe(false); + }); + + it('renders loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('Renders error state correctly', () => { + beforeEach(async () => { + createComponent(); + store.commit(RECEIVE_AGGREGATIONS_ERROR); + await Vue.nextTick(); + + findSearchBox().vm.$emit('focusin'); + }); + + it("doesn't render checkbox filter", () => { + expect(findCheckboxFilter().exists()).toBe(false); + }); + + it('renders alert', () => { + expect(findAlert().exists()).toBe(true); + }); + + it("doesn't render loading icon", () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('Actions', () => { + describe('dispatch action when component is created', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders checkbox filter', async () => { + await Vue.nextTick(); + expect(actionSpies.fetchAllAggregation).toHaveBeenCalled(); + }); + }); + + describe('Closing label works correctly', () => { + beforeEach(async () => { + createComponent(); + store.commit(RECEIVE_AGGREGATIONS_SUCCESS, MOCK_LABEL_AGGREGATIONS.data); + await Vue.nextTick(); + }); + + it('renders checkbox filter', async () => { + await findAllSelectedLabelsAbove().at(0).find('.btn-reset').trigger('click'); + expect(actionSpies.closeLabel).toHaveBeenCalled(); + }); + }); + + describe('label search input box works properly', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders checkbox filter', () => { + findSearchBox().find('input').setValue('test'); + expect(actionSpies.setLabelFilterSearch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + value: 'test', + }), + ); + }); + }); + + describe('dropdown checkboxes work', () => { + beforeEach(async () => { + createComponent(); + + await findSearchBox().vm.$emit('focusin'); + await Vue.nextTick(); + + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + await findCheckboxGroup().vm.$emit('input', 6); + await Vue.nextTick(); + }); + + it('trigger event', () => { + expect(actionSpies.setQuery).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ key: labelFilterData?.filterParam, value: 6 }), + ); + }); + + it('sends tracking information when checkbox is selected', () => { + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_ACTION_SELECT, TRACKING_LABEL_CHECKBOX, { + label: TRACKING_LABEL_FILTER, + property: 6, + }); + }); + }); + }); +}); diff --git a/spec/frontend/search/sidebar/components/language_filter_spec.js b/spec/frontend/search/sidebar/components/language_filter_spec.js index 9ad9d095aca..817199d7cfe 100644 --- a/spec/frontend/search/sidebar/components/language_filter_spec.js +++ b/spec/frontend/search/sidebar/components/language_filter_spec.js @@ -9,7 +9,7 @@ import { MOCK_LANGUAGE_AGGREGATIONS_BUCKETS, } from 'jest/search/mock_data'; import LanguageFilter from '~/search/sidebar/components/language_filter/index.vue'; -import CheckboxFilter from '~/search/sidebar/components/checkbox_filter.vue'; +import CheckboxFilter from '~/search/sidebar/components/language_filter/checkbox_filter.vue'; import { TRACKING_LABEL_SHOW_MORE, @@ -32,7 +32,7 @@ describe('GlobalSearchSidebarLanguageFilter', () => { let trackingSpy; const actionSpies = { - fetchLanguageAggregation: jest.fn(), + fetchAllAggregation: jest.fn(), applyQuery: jest.fn(), }; @@ -61,10 +61,6 @@ describe('GlobalSearchSidebarLanguageFilter', () => { }); }; - afterEach(() => { - unmockTracking(); - }); - const findForm = () => wrapper.findComponent(GlForm); const findCheckboxFilter = () => wrapper.findComponent(CheckboxFilter); const findApplyButton = () => wrapper.findByTestId('apply-button'); @@ -80,6 +76,10 @@ describe('GlobalSearchSidebarLanguageFilter', () => { trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); + afterEach(() => { + unmockTracking(); + }); + it('renders form', () => { expect(findForm().exists()).toBe(true); }); @@ -108,19 +108,19 @@ describe('GlobalSearchSidebarLanguageFilter', () => { describe('resetButton', () => { describe.each` - description | sidebarDirty | queryFilters | isDisabled - ${'sidebar dirty only'} | ${true} | ${[]} | ${undefined} - ${'query filters only'} | ${false} | ${['JSON', 'C']} | ${undefined} - ${'sidebar dirty and query filters'} | ${true} | ${['JSON', 'C']} | ${undefined} - ${'no sidebar and no query filters'} | ${false} | ${[]} | ${'true'} - `('$description', ({ sidebarDirty, queryFilters, isDisabled }) => { + description | sidebarDirty | queryFilters | exists + ${'sidebar dirty only'} | ${true} | ${[]} | ${false} + ${'query filters only'} | ${false} | ${['JSON', 'C']} | ${false} + ${'sidebar dirty and query filters'} | ${true} | ${['JSON', 'C']} | ${true} + ${'no sidebar and no query filters'} | ${false} | ${[]} | ${false} + `('$description', ({ sidebarDirty, queryFilters, exists }) => { beforeEach(() => { getterSpies.queryLanguageFilters = jest.fn(() => queryFilters); createComponent({ sidebarDirty, query: { ...MOCK_QUERY, language: queryFilters } }); }); - it(`button is ${isDisabled ? 'enabled' : 'disabled'}`, () => { - expect(findResetButton().attributes('disabled')).toBe(isDisabled); + it(`button is ${exists ? 'shown' : 'hidden'}`, () => { + expect(findResetButton().exists()).toBe(exists); }); }); }); @@ -153,6 +153,10 @@ describe('GlobalSearchSidebarLanguageFilter', () => { trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); + afterEach(() => { + unmockTracking(); + }); + it(`renders ${MAX_ITEM_LENGTH} amount of items`, async () => { findShowMoreButton().vm.$emit('click'); @@ -196,13 +200,16 @@ describe('GlobalSearchSidebarLanguageFilter', () => { createComponent({}); trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); + afterEach(() => { + unmockTracking(); + }); it('uses getter languageAggregationBuckets', () => { expect(getterSpies.languageAggregationBuckets).toHaveBeenCalled(); }); - it('uses action fetchLanguageAggregation', () => { - expect(actionSpies.fetchLanguageAggregation).toHaveBeenCalled(); + it('uses action fetchAllAggregation', () => { + expect(actionSpies.fetchAllAggregation).toHaveBeenCalled(); }); it('clicking ApplyButton calls applyQuery', () => { diff --git a/spec/frontend/search/sidebar/components/scope_navigation_spec.js b/spec/frontend/search/sidebar/components/scope_legacy_navigation_spec.js index e8737384f27..6a94da31a1b 100644 --- a/spec/frontend/search/sidebar/components/scope_navigation_spec.js +++ b/spec/frontend/search/sidebar/components/scope_legacy_navigation_spec.js @@ -3,11 +3,11 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; import { MOCK_QUERY, MOCK_NAVIGATION } from 'jest/search/mock_data'; -import ScopeNavigation from '~/search/sidebar/components/scope_navigation.vue'; +import ScopeLegacyNavigation from '~/search/sidebar/components/scope_legacy_navigation.vue'; Vue.use(Vuex); -describe('ScopeNavigation', () => { +describe('ScopeLegacyNavigation', () => { let wrapper; const actionSpies = { @@ -29,7 +29,7 @@ describe('ScopeNavigation', () => { getters: getterSpies, }); - wrapper = shallowMount(ScopeNavigation, { + wrapper = shallowMount(ScopeLegacyNavigation, { store, }); }; diff --git a/spec/frontend/search/sidebar/components/scope_new_navigation_spec.js b/spec/frontend/search/sidebar/components/scope_sidebar_navigation_spec.js index 5207665f883..4b71ff0bedc 100644 --- a/spec/frontend/search/sidebar/components/scope_new_navigation_spec.js +++ b/spec/frontend/search/sidebar/components/scope_sidebar_navigation_spec.js @@ -1,13 +1,13 @@ import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; -import ScopeNewNavigation from '~/search/sidebar/components/scope_new_navigation.vue'; +import ScopeSidebarNavigation from '~/search/sidebar/components/scope_sidebar_navigation.vue'; import NavItem from '~/super_sidebar/components/nav_item.vue'; import { MOCK_QUERY, MOCK_NAVIGATION, MOCK_NAVIGATION_ITEMS } from '../../mock_data'; Vue.use(Vuex); -describe('ScopeNewNavigation', () => { +describe('ScopeSidebarNavigation', () => { let wrapper; const actionSpies = { @@ -30,7 +30,7 @@ describe('ScopeNewNavigation', () => { getters: getterSpies, }); - wrapper = mount(ScopeNewNavigation, { + wrapper = mount(ScopeSidebarNavigation, { store, stubs: { NavItem, @@ -42,7 +42,7 @@ describe('ScopeNewNavigation', () => { const findNavItems = () => wrapper.findAllComponents(NavItem); const findNavItemActive = () => wrapper.find('[aria-current=page]'); const findNavItemActiveLabel = () => - findNavItemActive().find('[class="gl-pr-8 gl-text-gray-900 gl-truncate-end"]'); + findNavItemActive().find('[class="gl-flex-grow-1 gl-text-gray-900 gl-truncate-end"]'); describe('scope navigation', () => { beforeEach(() => { diff --git a/spec/frontend/search/sort/components/app_spec.js b/spec/frontend/search/sort/components/app_spec.js index 322ce1b16ef..09c295e3ea9 100644 --- a/spec/frontend/search/sort/components/app_spec.js +++ b/spec/frontend/search/sort/components/app_spec.js @@ -1,4 +1,4 @@ -import { GlButtonGroup, GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlButtonGroup, GlButton, GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; @@ -35,13 +35,16 @@ describe('GlobalSearchSort', () => { ...defaultProps, ...props, }, + stubs: { + GlCollapsibleListbox, + }, }); }; const findSortButtonGroup = () => wrapper.findComponent(GlButtonGroup); - const findSortDropdown = () => wrapper.findComponent(GlDropdown); + const findSortDropdown = () => wrapper.findComponent(GlCollapsibleListbox); const findSortDirectionButton = () => wrapper.findComponent(GlButton); - const findDropdownItems = () => findSortDropdown().findAllComponents(GlDropdownItem); + const findDropdownItems = () => findSortDropdown().findAllComponents(GlListboxItem); const findDropdownItemsText = () => findDropdownItems().wrappers.map((w) => w.text()); describe('template', () => { @@ -89,7 +92,7 @@ describe('GlobalSearchSort', () => { }); it('is set correctly', () => { - expect(findSortDropdown().attributes('text')).toBe(value); + expect(findSortDropdown().props('toggleText')).toBe(value); }); }); }); @@ -116,14 +119,14 @@ describe('GlobalSearchSort', () => { describe('actions', () => { describe.each` - description | index | value - ${'non-sortable'} | ${0} | ${MOCK_SORT_OPTIONS[0].sortParam} - ${'sortable'} | ${1} | ${MOCK_SORT_OPTIONS[1].sortParam.desc} - `('handleSortChange', ({ description, index, value }) => { - describe(`when clicking a ${description} option`, () => { + description | text | value + ${'non-sortable'} | ${MOCK_SORT_OPTIONS[0].title} | ${MOCK_SORT_OPTIONS[0].sortParam} + ${'sortable'} | ${MOCK_SORT_OPTIONS[1].title} | ${MOCK_SORT_OPTIONS[1].sortParam.desc} + `('handleSortChange', ({ description, text, value }) => { + describe(`when selecting a ${description} option`, () => { beforeEach(() => { createComponent(); - findDropdownItems().at(index).vm.$emit('click'); + findSortDropdown().vm.$emit('select', text); }); it('calls setQuery and applyQuery correctly', () => { diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js index 0884411df0c..2051e731647 100644 --- a/spec/frontend/search/store/actions_spec.js +++ b/spec/frontend/search/store/actions_spec.js @@ -31,6 +31,7 @@ import { MOCK_RECEIVE_AGGREGATIONS_SUCCESS_MUTATION, MOCK_RECEIVE_AGGREGATIONS_ERROR_MUTATION, MOCK_AGGREGATIONS, + MOCK_LABEL_AGGREGATIONS, } from '../mock_data'; jest.mock('~/alert'); @@ -132,7 +133,7 @@ describe('Global Search Store Actions', () => { describe('when groupId is set', () => { it('calls Api.groupProjects with expected parameters', () => { - actions.fetchProjects({ commit: mockCommit, state }, undefined); + actions.fetchProjects({ commit: mockCommit, state }, MOCK_QUERY.search); expect(Api.groupProjects).toHaveBeenCalledWith(state.query.group_id, state.query.search, { order_by: 'similarity', include_subgroups: true, @@ -301,11 +302,11 @@ describe('Global Search Store Actions', () => { }); describe.each` - action | axiosMock | type | expectedMutations | errorLogs - ${actions.fetchLanguageAggregation} | ${{ method: 'onGet', code: HTTP_STATUS_OK }} | ${'success'} | ${MOCK_RECEIVE_AGGREGATIONS_SUCCESS_MUTATION} | ${0} - ${actions.fetchLanguageAggregation} | ${{ method: 'onPut', code: 0 }} | ${'error'} | ${MOCK_RECEIVE_AGGREGATIONS_ERROR_MUTATION} | ${1} - ${actions.fetchLanguageAggregation} | ${{ method: 'onGet', code: HTTP_STATUS_INTERNAL_SERVER_ERROR }} | ${'error'} | ${MOCK_RECEIVE_AGGREGATIONS_ERROR_MUTATION} | ${1} - `('fetchLanguageAggregation', ({ action, axiosMock, type, expectedMutations, errorLogs }) => { + action | axiosMock | type | expectedMutations | errorLogs + ${actions.fetchAllAggregation} | ${{ method: 'onGet', code: HTTP_STATUS_OK }} | ${'success'} | ${MOCK_RECEIVE_AGGREGATIONS_SUCCESS_MUTATION} | ${0} + ${actions.fetchAllAggregation} | ${{ method: 'onPut', code: 0 }} | ${'error'} | ${MOCK_RECEIVE_AGGREGATIONS_ERROR_MUTATION} | ${1} + ${actions.fetchAllAggregation} | ${{ method: 'onGet', code: HTTP_STATUS_INTERNAL_SERVER_ERROR }} | ${'error'} | ${MOCK_RECEIVE_AGGREGATIONS_ERROR_MUTATION} | ${1} + `('fetchAllAggregation', ({ action, axiosMock, type, expectedMutations, errorLogs }) => { describe(`on ${type}`, () => { beforeEach(() => { if (axiosMock.method) { @@ -347,4 +348,49 @@ describe('Global Search Store Actions', () => { ); }); }); + + describe('closeLabel', () => { + beforeEach(() => { + state = createState({ + query: MOCK_QUERY, + aggregations: MOCK_LABEL_AGGREGATIONS, + }); + }); + + it('removes correct labels from query and sets sidebar dirty', () => { + const expectedResult = [ + { + payload: { + key: 'labels', + value: ['37'], + }, + type: 'SET_QUERY', + }, + { + payload: true, + type: 'SET_SIDEBAR_DIRTY', + }, + ]; + return testAction(actions.closeLabel, { key: '60' }, state, expectedResult, []); + }); + }); + + describe('setLabelFilterSearch', () => { + beforeEach(() => { + state = createState({ + query: MOCK_QUERY, + aggregations: MOCK_LABEL_AGGREGATIONS, + }); + }); + + it('sets search string', () => { + const expectedResult = [ + { + payload: 'test', + type: 'SET_LABEL_SEARCH_STRING', + }, + ]; + return testAction(actions.setLabelFilterSearch, { value: 'test' }, state, expectedResult, []); + }); + }); }); diff --git a/spec/frontend/search/store/getters_spec.js b/spec/frontend/search/store/getters_spec.js index e3b8e7575a4..772acb39a57 100644 --- a/spec/frontend/search/store/getters_spec.js +++ b/spec/frontend/search/store/getters_spec.js @@ -1,3 +1,4 @@ +import { cloneDeep } from 'lodash'; import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from '~/search/store/constants'; import * as getters from '~/search/store/getters'; import createState from '~/search/store/state'; @@ -11,13 +12,24 @@ import { TEST_FILTER_DATA, MOCK_NAVIGATION, MOCK_NAVIGATION_ITEMS, + MOCK_LABEL_AGGREGATIONS, + SMALL_MOCK_AGGREGATIONS, + MOCK_LABEL_SEARCH_RESULT, + MOCK_FILTERED_APPLIED_SELECTED_LABELS, + MOCK_FILTERED_UNSELECTED_LABELS, + MOCK_FILTERED_UNAPPLIED_SELECTED_LABELS, } from '../mock_data'; describe('Global Search Store Getters', () => { let state; + const defaultState = createState({ query: MOCK_QUERY }); + + defaultState.aggregations = MOCK_LABEL_AGGREGATIONS; + defaultState.aggregations.data.push(SMALL_MOCK_AGGREGATIONS[0]); beforeEach(() => { - state = createState({ query: MOCK_QUERY }); + state = cloneDeep(defaultState); + useMockLocationHelper(); }); @@ -76,4 +88,82 @@ describe('Global Search Store Getters', () => { expect(getters.navigationItems(state)).toStrictEqual(MOCK_NAVIGATION_ITEMS); }); }); + + describe('labelAggregationBuckets', () => { + it('strips labels buckets from all aggregations', () => { + expect(getters.labelAggregationBuckets(state)).toStrictEqual( + MOCK_LABEL_AGGREGATIONS.data[0].buckets, + ); + }); + }); + + describe('filteredLabels', () => { + it('gets all labels if no string is set', () => { + state.searchLabelString = ''; + expect(getters.filteredLabels(state)).toStrictEqual(MOCK_LABEL_AGGREGATIONS.data[0].buckets); + }); + + it('get correct labels if string is set', () => { + state.searchLabelString = 'SYNC'; + expect(getters.filteredLabels(state)).toStrictEqual([MOCK_LABEL_SEARCH_RESULT]); + }); + }); + + describe('filteredAppliedSelectedLabels', () => { + it('returns all labels that are selected (part of URL)', () => { + expect(getters.filteredAppliedSelectedLabels(state)).toStrictEqual( + MOCK_FILTERED_APPLIED_SELECTED_LABELS, + ); + }); + + it('returns labels that are selected (part of URL) and result of search', () => { + state.searchLabelString = 'SYNC'; + expect(getters.filteredAppliedSelectedLabels(state)).toStrictEqual([ + MOCK_FILTERED_APPLIED_SELECTED_LABELS[1], + ]); + }); + }); + + describe('appliedSelectedLabels', () => { + it('returns all labels that are selected (part of URL) no search', () => { + state.searchLabelString = 'SYNC'; + expect(getters.appliedSelectedLabels(state)).toStrictEqual( + MOCK_FILTERED_APPLIED_SELECTED_LABELS, + ); + }); + }); + + describe('filteredUnappliedSelectedLabels', () => { + beforeEach(() => { + state.query.labels = ['6', '73']; + }); + + it('returns all labels that are selected (part of URL) no search', () => { + expect(getters.filteredUnappliedSelectedLabels(state)).toStrictEqual( + MOCK_FILTERED_UNAPPLIED_SELECTED_LABELS, + ); + }); + + it('returns labels that are selected (part of URL) and result of search', () => { + state.searchLabelString = 'ACC'; + expect(getters.filteredUnappliedSelectedLabels(state)).toStrictEqual([ + MOCK_FILTERED_UNAPPLIED_SELECTED_LABELS[1], + ]); + }); + }); + + describe('filteredUnselectedLabels', () => { + it('returns all labels that are selected (part of URL) no search', () => { + expect(getters.filteredUnselectedLabels(state)).toStrictEqual( + MOCK_FILTERED_UNSELECTED_LABELS, + ); + }); + + it('returns labels that are selected (part of URL) and result of search', () => { + state.searchLabelString = 'ACC'; + expect(getters.filteredUnselectedLabels(state)).toStrictEqual([ + MOCK_FILTERED_UNSELECTED_LABELS[1], + ]); + }); + }); }); diff --git a/spec/frontend/search/store/mutations_spec.js b/spec/frontend/search/store/mutations_spec.js index d604cf38f8f..a517932b0eb 100644 --- a/spec/frontend/search/store/mutations_spec.js +++ b/spec/frontend/search/store/mutations_spec.js @@ -122,4 +122,12 @@ describe('Global Search Store Mutations', () => { expect(state.aggregations).toStrictEqual(result); }); }); + + describe('SET_LABEL_SEARCH_STRING', () => { + it('sets the search string to the given data', () => { + mutations[types.SET_LABEL_SEARCH_STRING](state, 'test'); + + expect(state.searchLabelString).toBe('test'); + }); + }); }); diff --git a/spec/frontend/sentry/index_spec.js b/spec/frontend/sentry/index_spec.js index aa19bb03cda..3130e01cc9e 100644 --- a/spec/frontend/sentry/index_spec.js +++ b/spec/frontend/sentry/index_spec.js @@ -4,6 +4,7 @@ import LegacySentryConfig from '~/sentry/legacy_sentry_config'; import SentryConfig from '~/sentry/sentry_config'; describe('Sentry init', () => { + const version = '1.0.0'; const dsn = 'https://123@sentry.gitlab.test/123'; const environment = 'test'; const currentUserId = '1'; @@ -13,6 +14,7 @@ describe('Sentry init', () => { beforeEach(() => { window.gon = { + version, sentry_dsn: dsn, sentry_environment: environment, current_user_id: currentUserId, @@ -42,7 +44,7 @@ describe('Sentry init', () => { currentUserId, allowUrls: [gitlabUrl, 'webpack-internal://'], environment, - release: revision, + release: version, tags: { revision, feature_category: featureCategory, diff --git a/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js index 25a19b5808b..00fa0a8ae56 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js @@ -16,7 +16,12 @@ describe('Sidebar participant component', () => { const findAvatar = () => wrapper.findComponent(GlAvatarLabeled); const findIcon = () => wrapper.findComponent(GlIcon); - const createComponent = ({ status = null, issuableType = TYPE_ISSUE, canMerge = false } = {}) => { + const createComponent = ({ + status = null, + issuableType = TYPE_ISSUE, + canMerge = false, + selected = false, + } = {}) => { wrapper = shallowMount(SidebarParticipant, { propsData: { user: { @@ -25,6 +30,7 @@ describe('Sidebar participant component', () => { status, }, issuableType, + selected, }, stubs: { GlAvatarLabeled, @@ -52,13 +58,27 @@ describe('Sidebar participant component', () => { }); describe('when on merge request sidebar', () => { - it('when project member cannot merge', () => { - createComponent({ issuableType: TYPE_MERGE_REQUEST }); + describe('when project member cannot merge', () => { + it('renders a `cannot-merge` icon', () => { + createComponent({ issuableType: TYPE_MERGE_REQUEST }); - expect(findIcon().exists()).toBe(true); + expect(findIcon().exists()).toBe(true); + }); + + it('does not apply `gl-left-6!` class to an icon if participant is not selected', () => { + createComponent({ issuableType: TYPE_MERGE_REQUEST, canMerge: false }); + + expect(findIcon().classes('gl-left-6!')).toBe(false); + }); + + it('applies `gl-left-6!` class to an icon if participant is selected', () => { + createComponent({ issuableType: TYPE_MERGE_REQUEST, canMerge: false, selected: true }); + + expect(findIcon().classes('gl-left-6!')).toBe(true); + }); }); - it('when project member can merge', () => { + it('does not render an icon when project member can merge', () => { createComponent({ issuableType: TYPE_MERGE_REQUEST, canMerge: true }); expect(findIcon().exists()).toBe(false); diff --git a/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js b/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js index 5e766e9a41c..47f68e1fe83 100644 --- a/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js +++ b/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js @@ -7,6 +7,7 @@ import createStore from '~/notes/stores'; import EditForm from '~/sidebar/components/lock/edit_form.vue'; import IssuableLockForm from '~/sidebar/components/lock/issuable_lock_form.vue'; import toast from '~/vue_shared/plugins/global_toast'; +import waitForPromises from 'helpers/wait_for_promises'; import { ISSUABLE_TYPE_ISSUE, ISSUABLE_TYPE_MR } from './constants'; jest.mock('~/vue_shared/plugins/global_toast'); @@ -27,6 +28,7 @@ describe('IssuableLockForm', () => { const findLockStatus = () => wrapper.find('[data-testid="lock-status"]'); const findEditLink = () => wrapper.find('[data-testid="edit-link"]'); const findEditForm = () => wrapper.findComponent(EditForm); + const findLockButton = () => wrapper.find('[data-testid="issuable-lock"]'); const findSidebarLockStatusTooltip = () => getBinding(findSidebarCollapseIcon().element, 'gl-tooltip'); const findIssuableLockClickable = () => wrapper.find('[data-testid="issuable-lock"]'); @@ -172,7 +174,9 @@ describe('IssuableLockForm', () => { createComponent({ movedMrSidebar: true }); - await wrapper.find('.dropdown-item').trigger('click'); + await findLockButton().trigger('click'); + + await waitForPromises(); expect(toast).toHaveBeenCalledWith(message); }); @@ -187,7 +191,7 @@ describe('IssuableLockForm', () => { }); describe('when the flag is on', () => { - it('does not show the non editable lock status', () => { + it('shows the non editable lock status', () => { createComponent({ movedMrSidebar: true }); expect(findIssuableLockClickable().exists()).toBe(true); }); diff --git a/spec/frontend/sidebar/components/status/status_dropdown_spec.js b/spec/frontend/sidebar/components/status/status_dropdown_spec.js index 229b51ea568..923b171e763 100644 --- a/spec/frontend/sidebar/components/status/status_dropdown_spec.js +++ b/spec/frontend/sidebar/components/status/status_dropdown_spec.js @@ -1,17 +1,23 @@ -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import StatusDropdown from '~/sidebar/components/status/status_dropdown.vue'; import { statusDropdownOptions } from '~/sidebar/constants'; describe('SubscriptionsDropdown component', () => { let wrapper; - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); + const findAllDropdownItems = () => wrapper.findAllComponents(GlListboxItem); const findHiddenInput = () => wrapper.find('input'); function createComponent() { - wrapper = shallowMount(StatusDropdown); + wrapper = shallowMount(StatusDropdown, { + stubs: { + GlCollapsibleListbox, + GlListboxItem, + }, + }); } describe('with no value selected', () => { @@ -20,52 +26,55 @@ describe('SubscriptionsDropdown component', () => { }); it('renders default text', () => { - expect(findDropdown().props('text')).toBe('Select status'); + expect(findDropdown().props('toggleText')).toBe('Select status'); }); - it('renders dropdown items with `is-checked` prop set to `false`', () => { + it('renders dropdown items with `isSelected` prop set to `false`', () => { const dropdownItems = findAllDropdownItems(); - expect(dropdownItems.at(0).props('isChecked')).toBe(false); - expect(dropdownItems.at(1).props('isChecked')).toBe(false); + expect(dropdownItems.at(0).props('isSelected')).toBe(false); + expect(dropdownItems.at(1).props('isSelected')).toBe(false); }); }); describe('when selecting a value', () => { - const selectItemAtIndex = 0; + const optionToSelect = statusDropdownOptions[0]; - beforeEach(async () => { + beforeEach(() => { createComponent(); - await findAllDropdownItems().at(selectItemAtIndex).vm.$emit('click'); + findDropdown().vm.$emit('select', optionToSelect.value); }); it('updates value of the hidden input', () => { - expect(findHiddenInput().attributes('value')).toBe( - statusDropdownOptions[selectItemAtIndex].value, - ); + expect(findHiddenInput().attributes('value')).toBe(optionToSelect.value); }); it('updates the dropdown text prop', () => { - expect(findDropdown().props('text')).toBe(statusDropdownOptions[selectItemAtIndex].text); + expect(findDropdown().props('toggleText')).toBe(optionToSelect.text); }); - it('sets dropdown item `is-checked` prop to `true`', () => { + it('sets dropdown item `isSelected` prop to `true`', () => { const dropdownItems = findAllDropdownItems(); - expect(dropdownItems.at(0).props('isChecked')).toBe(true); - expect(dropdownItems.at(1).props('isChecked')).toBe(false); + expect(dropdownItems.at(0).props('isSelected')).toBe(true); + expect(dropdownItems.at(1).props('isSelected')).toBe(false); }); + }); - describe('when selecting the value that is already selected', () => { - it('clears dropdown selection', async () => { - await findAllDropdownItems().at(selectItemAtIndex).vm.$emit('click'); + describe('when reset is triggered', () => { + beforeEach(() => { + createComponent(); + findDropdown().vm.$emit('select', statusDropdownOptions[0].value); + }); - const dropdownItems = findAllDropdownItems(); + it('clears dropdown selection', async () => { + findDropdown().vm.$emit('reset'); + await nextTick(); + const dropdownItems = findAllDropdownItems(); - expect(dropdownItems.at(0).props('isChecked')).toBe(false); - expect(dropdownItems.at(1).props('isChecked')).toBe(false); - expect(findDropdown().props('text')).toBe('Select status'); - }); + expect(dropdownItems.at(0).props('isSelected')).toBe(false); + expect(dropdownItems.at(1).props('isSelected')).toBe(false); + expect(findDropdown().props('toggleText')).toBe('Select status'); }); }); }); diff --git a/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js b/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js index 7275557e7f2..39b80c1d886 100644 --- a/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js +++ b/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js @@ -1,4 +1,4 @@ -import { GlIcon, GlToggle } from '@gitlab/ui'; +import { GlDisclosureDropdownItem, GlIcon, GlToggle } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; @@ -28,6 +28,7 @@ describe('Sidebar Subscriptions Widget', () => { const findEditableItem = () => wrapper.findComponent(SidebarEditableItem); const findToggle = () => wrapper.findComponent(GlToggle); const findIcon = () => wrapper.findComponent(GlIcon); + const findDropdownToggleItem = () => wrapper.findComponent(GlDisclosureDropdownItem); const createComponent = ({ subscriptionsQueryHandler = jest.fn().mockResolvedValue(issueSubscriptionsResponse()), @@ -155,7 +156,7 @@ describe('Sidebar Subscriptions Widget', () => { }); await waitForPromises(); - await wrapper.find('[data-testid="notifications-toggle"]').vm.$emit('change'); + await findDropdownToggleItem().vm.$emit('action'); await waitForPromises(); diff --git a/spec/frontend/sidebar/components/subscriptions/subscriptions_dropdown_spec.js b/spec/frontend/sidebar/components/subscriptions/subscriptions_dropdown_spec.js index eaf7bc13d20..052e6ec9553 100644 --- a/spec/frontend/sidebar/components/subscriptions/subscriptions_dropdown_spec.js +++ b/spec/frontend/sidebar/components/subscriptions/subscriptions_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import SubscriptionsDropdown from '~/sidebar/components/subscriptions/subscriptions_dropdown.vue'; @@ -7,12 +7,17 @@ import { subscriptionsDropdownOptions } from '~/sidebar/constants'; describe('SubscriptionsDropdown component', () => { let wrapper; - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); + const findAllDropdownItems = () => wrapper.findAllComponents(GlListboxItem); const findHiddenInput = () => wrapper.find('input'); function createComponent() { - wrapper = shallowMount(SubscriptionsDropdown); + wrapper = shallowMount(SubscriptionsDropdown, { + stubs: { + GlCollapsibleListbox, + GlListboxItem, + }, + }); } describe('with no value selected', () => { @@ -25,48 +30,59 @@ describe('SubscriptionsDropdown component', () => { }); it('renders default text', () => { - expect(findDropdown().props('text')).toBe(SubscriptionsDropdown.i18n.defaultDropdownText); + expect(findDropdown().props('toggleText')).toBe( + SubscriptionsDropdown.i18n.defaultDropdownText, + ); }); - it('renders dropdown items with `is-checked` prop set to `false`', () => { + it('renders dropdown items with `isSelected` prop set to `false`', () => { const dropdownItems = findAllDropdownItems(); - expect(dropdownItems.at(0).props('isChecked')).toBe(false); - expect(dropdownItems.at(1).props('isChecked')).toBe(false); + expect(dropdownItems.at(0).props('isSelected')).toBe(false); + expect(dropdownItems.at(1).props('isSelected')).toBe(false); }); }); describe('when selecting a value', () => { + const optionToSelect = subscriptionsDropdownOptions[0]; + beforeEach(() => { createComponent(); - findAllDropdownItems().at(0).vm.$emit('click'); + findDropdown().vm.$emit('select', optionToSelect.value); }); it('updates value of the hidden input', () => { - expect(findHiddenInput().attributes('value')).toBe(subscriptionsDropdownOptions[0].value); + expect(findHiddenInput().attributes('value')).toBe(optionToSelect.value); }); it('updates the dropdown text prop', () => { - expect(findDropdown().props('text')).toBe(subscriptionsDropdownOptions[0].text); + expect(findDropdown().props('toggleText')).toBe(optionToSelect.text); }); - it('sets dropdown item `is-checked` prop to `true`', () => { + it('sets dropdown item `isSelected` prop to `true`', () => { const dropdownItems = findAllDropdownItems(); - expect(dropdownItems.at(0).props('isChecked')).toBe(true); - expect(dropdownItems.at(1).props('isChecked')).toBe(false); + expect(dropdownItems.at(0).props('isSelected')).toBe(true); + expect(dropdownItems.at(1).props('isSelected')).toBe(false); + }); + }); + + describe('when reset is triggered', () => { + beforeEach(() => { + createComponent(); + findDropdown().vm.$emit('select', subscriptionsDropdownOptions[0].value); }); - describe('when selecting the value that is already selected', () => { - it('clears dropdown selection', async () => { - findAllDropdownItems().at(0).vm.$emit('click'); - await nextTick(); - const dropdownItems = findAllDropdownItems(); + it('clears dropdown selection', async () => { + findDropdown().vm.$emit('reset'); + await nextTick(); + const dropdownItems = findAllDropdownItems(); - expect(dropdownItems.at(0).props('isChecked')).toBe(false); - expect(dropdownItems.at(1).props('isChecked')).toBe(false); - expect(findDropdown().props('text')).toBe(SubscriptionsDropdown.i18n.defaultDropdownText); - }); + expect(dropdownItems.at(0).props('isSelected')).toBe(false); + expect(dropdownItems.at(1).props('isSelected')).toBe(false); + expect(findDropdown().props('toggleText')).toBe( + SubscriptionsDropdown.i18n.defaultDropdownText, + ); }); }); }); diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index d17e20ac227..17862953920 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -113,7 +113,7 @@ describe('Snippet Edit app', () => { const triggerBlobActions = (actions) => findBlobActions().vm.$emit('actions', actions); const setUploadFilesHtml = (paths) => { - wrapper.vm.$el.innerHTML = paths + wrapper.element.innerHTML = paths .map((path) => `<input name="files[]" value="${path}">`) .join(''); }; diff --git a/spec/frontend/snippets/components/show_spec.js b/spec/frontend/snippets/components/show_spec.js index 45a7c7b0b4a..5973768c337 100644 --- a/spec/frontend/snippets/components/show_spec.js +++ b/spec/frontend/snippets/components/show_spec.js @@ -11,7 +11,7 @@ import { VISIBILITY_LEVEL_PRIVATE_STRING, VISIBILITY_LEVEL_PUBLIC_STRING, } from '~/visibility_level/constants'; -import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue'; +import CloneDropdownButton from '~/vue_shared/components/clone_dropdown/clone_dropdown.vue'; import { stubPerformanceWebAPI } from 'helpers/performance'; describe('Snippet view app', () => { @@ -89,22 +89,32 @@ describe('Snippet view app', () => { describe('Embed dropdown rendering', () => { it.each` - visibilityLevel | condition | isRendered - ${VISIBILITY_LEVEL_INTERNAL_STRING} | ${'not render'} | ${false} - ${VISIBILITY_LEVEL_PRIVATE_STRING} | ${'not render'} | ${false} - ${'foo'} | ${'not render'} | ${false} - ${VISIBILITY_LEVEL_PUBLIC_STRING} | ${'render'} | ${true} - `('does $condition embed-dropdown by default', ({ visibilityLevel, isRendered }) => { - createComponent({ - data: { - snippet: { - visibilityLevel, - webUrl, + snippetVisibility | projectVisibility | condition | isRendered + ${VISIBILITY_LEVEL_INTERNAL_STRING} | ${VISIBILITY_LEVEL_PUBLIC_STRING} | ${'not render'} | ${false} + ${VISIBILITY_LEVEL_PRIVATE_STRING} | ${VISIBILITY_LEVEL_PUBLIC_STRING} | ${'not render'} | ${false} + ${VISIBILITY_LEVEL_PUBLIC_STRING} | ${undefined} | ${'render'} | ${true} + ${VISIBILITY_LEVEL_PUBLIC_STRING} | ${VISIBILITY_LEVEL_PUBLIC_STRING} | ${'render'} | ${true} + ${VISIBILITY_LEVEL_INTERNAL_STRING} | ${VISIBILITY_LEVEL_PUBLIC_STRING} | ${'not render'} | ${false} + ${VISIBILITY_LEVEL_PRIVATE_STRING} | ${undefined} | ${'not render'} | ${false} + ${'foo'} | ${undefined} | ${'not render'} | ${false} + ${VISIBILITY_LEVEL_PUBLIC_STRING} | ${VISIBILITY_LEVEL_PRIVATE_STRING} | ${'not render'} | ${false} + `( + 'does $condition embed-dropdown by default', + ({ snippetVisibility, projectVisibility, isRendered }) => { + createComponent({ + data: { + snippet: { + visibilityLevel: snippetVisibility, + webUrl, + project: { + visibility: projectVisibility, + }, + }, }, - }, - }); - expect(findEmbedDropdown().exists()).toBe(isRendered); - }); + }); + expect(findEmbedDropdown().exists()).toBe(isRendered); + }, + ); }); describe('hasUnretrievableBlobs alert rendering', () => { diff --git a/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js index 58f47e8b0dc..cb11e98cd35 100644 --- a/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js @@ -8,8 +8,9 @@ import { SNIPPET_MAX_BLOBS, SNIPPET_BLOB_ACTION_CREATE, SNIPPET_BLOB_ACTION_MOVE, + SNIPPET_LIMITATIONS, } from '~/snippets/constants'; -import { s__ } from '~/locale'; +import { s__, sprintf } from '~/locale'; import { testEntries, createBlobFromTestEntry } from '../test_utils'; const TEST_BLOBS = [ @@ -40,6 +41,7 @@ describe('snippets/components/snippet_blob_actions_edit', () => { })); const findFirstBlobEdit = () => findBlobEdits().at(0); const findAddButton = () => wrapper.find('[data-testid="add_button"]'); + const findLimitationsText = () => wrapper.find('[data-testid="limitations_text"]'); const getLastActions = () => { const events = wrapper.emitted().actions; @@ -97,6 +99,10 @@ describe('snippets/components/snippet_blob_actions_edit', () => { expect(button.props('disabled')).toBe(false); }); + it('do not show limitations text', () => { + expect(findLimitationsText().exists()).toBe(false); + }); + describe('when add is clicked', () => { beforeEach(() => { findAddButton().vm.$emit('click'); @@ -276,6 +282,12 @@ describe('snippets/components/snippet_blob_actions_edit', () => { it('should disable add button', () => { expect(findAddButton().props('disabled')).toBe(true); }); + + it('shows limitations text', () => { + expect(findLimitationsText().text()).toBe( + sprintf(SNIPPET_LIMITATIONS, { total: SNIPPET_MAX_BLOBS }), + ); + }); }); describe('isValid prop', () => { diff --git a/spec/frontend/snippets/test_utils.js b/spec/frontend/snippets/test_utils.js index dcef8fc9a8b..76b03c0aa0d 100644 --- a/spec/frontend/snippets/test_utils.js +++ b/spec/frontend/snippets/test_utils.js @@ -30,6 +30,7 @@ export const createGQLSnippet = () => ({ id: 'project-1', fullPath: 'group/project', webUrl: `${TEST_HOST}/group/project`, + visibility: 'public', }, author: { __typename: 'User', diff --git a/spec/frontend/streaming/handle_streamed_relative_timestamps_spec.js b/spec/frontend/streaming/handle_streamed_relative_timestamps_spec.js new file mode 100644 index 00000000000..12bd27488b1 --- /dev/null +++ b/spec/frontend/streaming/handle_streamed_relative_timestamps_spec.js @@ -0,0 +1,94 @@ +import { resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures'; +import waitForPromises from 'helpers/wait_for_promises'; +import { handleStreamedRelativeTimestamps } from '~/streaming/handle_streamed_relative_timestamps'; +import { localTimeAgo } from '~/lib/utils/datetime_utility'; +import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; + +jest.mock('~/lib/utils/datetime_utility'); + +const TIMESTAMP_MOCK = `<div class="js-timeago">Oct 2, 2019</div>`; + +describe('handleStreamedRelativeTimestamps', () => { + const findRoot = () => document.querySelector('#root'); + const findStreamingElement = () => document.querySelector('streaming-element'); + const findTimestamp = () => document.querySelector('.js-timeago'); + + afterEach(() => { + resetHTMLFixture(); + }); + + describe('when element is present', () => { + beforeEach(() => { + setHTMLFixture(`<div id="root">${TIMESTAMP_MOCK}</div>`); + handleStreamedRelativeTimestamps(findRoot()); + }); + + it('does nothing', async () => { + await waitForPromises(); + expect(localTimeAgo).not.toHaveBeenCalled(); + }); + }); + + describe('when element is streamed', () => { + let relativeTimestampsHandler; + const { trigger: triggerIntersection } = useMockIntersectionObserver(); + + const insertStreamingElement = () => + findRoot().insertAdjacentHTML('afterbegin', `<streaming-element></streaming-element>`); + + beforeEach(() => { + setHTMLFixture('<div id="root"></div>'); + relativeTimestampsHandler = handleStreamedRelativeTimestamps(findRoot()); + }); + + it('formats and unobserved the timestamp when inserted and intersecting', async () => { + insertStreamingElement(); + await waitForPromises(); + findStreamingElement().insertAdjacentHTML('afterbegin', TIMESTAMP_MOCK); + await waitForPromises(); + + const timestamp = findTimestamp(); + const unobserveMock = jest.fn(); + + triggerIntersection(findTimestamp(), { + entry: { isIntersecting: true }, + observer: { unobserve: unobserveMock }, + }); + + expect(unobserveMock).toHaveBeenCalled(); + expect(localTimeAgo).toHaveBeenCalledWith([timestamp]); + }); + + it('does not format the timestamp when inserted but not intersecting', async () => { + insertStreamingElement(); + await waitForPromises(); + findStreamingElement().insertAdjacentHTML('afterbegin', TIMESTAMP_MOCK); + await waitForPromises(); + + const unobserveMock = jest.fn(); + + triggerIntersection(findTimestamp(), { + entry: { isIntersecting: false }, + observer: { unobserve: unobserveMock }, + }); + + expect(unobserveMock).not.toHaveBeenCalled(); + expect(localTimeAgo).not.toHaveBeenCalled(); + }); + + it('does not format the time when destroyed', async () => { + insertStreamingElement(); + + const stop = await relativeTimestampsHandler; + stop(); + + await waitForPromises(); + findStreamingElement().insertAdjacentHTML('afterbegin', TIMESTAMP_MOCK); + await waitForPromises(); + + triggerIntersection(findTimestamp(), { entry: { isIntersecting: true } }); + + expect(localTimeAgo).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/brand_logo_spec.js b/spec/frontend/super_sidebar/components/brand_logo_spec.js new file mode 100644 index 00000000000..63c4bb9668b --- /dev/null +++ b/spec/frontend/super_sidebar/components/brand_logo_spec.js @@ -0,0 +1,42 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { createMockDirective } from 'helpers/vue_mock_directive'; +import BrandLogo from 'jh_else_ce/super_sidebar/components/brand_logo.vue'; + +describe('Brand Logo component', () => { + let wrapper; + + const defaultPropsData = { + logoUrl: 'path/to/logo', + }; + + const findBrandLogo = () => wrapper.findByTestId('brand-header-custom-logo'); + const findDefaultLogo = () => wrapper.findByTestId('brand-header-default-logo'); + + const createWrapper = (props = {}) => { + wrapper = shallowMountExtended(BrandLogo, { + provide: { + rootPath: '/', + }, + propsData: { + ...defaultPropsData, + ...props, + }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + }); + }; + + it('renders it', () => { + createWrapper(); + expect(findBrandLogo().exists()).toBe(true); + expect(findBrandLogo().attributes('src')).toBe(defaultPropsData.logoUrl); + }); + + it('when logoUrl given empty', () => { + createWrapper({ logoUrl: '' }); + + expect(findBrandLogo().exists()).toBe(false); + expect(findDefaultLogo().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/super_sidebar/components/context_switcher_spec.js b/spec/frontend/super_sidebar/components/context_switcher_spec.js index 7928ee6400c..4317f451377 100644 --- a/spec/frontend/super_sidebar/components/context_switcher_spec.js +++ b/spec/frontend/super_sidebar/components/context_switcher_spec.js @@ -158,12 +158,6 @@ describe('ContextSwitcher component', () => { expect(findContextSwitcherToggle().props('expanded')).toEqual(false); }); - it("passes Popper.js' options to the disclosure dropdown", () => { - expect(findDisclosureDropdown().props('popperOptions')).toMatchObject({ - modifiers: expect.any(Array), - }); - }); - it('does not emit the `toggle` event initially', () => { expect(wrapper.emitted('toggle')).toBe(undefined); }); diff --git a/spec/frontend/super_sidebar/components/create_menu_spec.js b/spec/frontend/super_sidebar/components/create_menu_spec.js index 456085e23da..fe2fd17ae4d 100644 --- a/spec/frontend/super_sidebar/components/create_menu_spec.js +++ b/spec/frontend/super_sidebar/components/create_menu_spec.js @@ -6,7 +6,6 @@ import { GlDisclosureDropdownItem, } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { stubComponent } from 'helpers/stub_component'; import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; import { __ } from '~/locale'; import CreateMenu from '~/super_sidebar/components/create_menu.vue'; @@ -21,8 +20,6 @@ describe('CreateMenu component', () => { const findInviteMembersTrigger = () => wrapper.findComponent(InviteMembersTrigger); const findGlTooltip = () => wrapper.findComponent(GlTooltip); - const closeAndFocusMock = jest.fn(); - const createWrapper = () => { wrapper = shallowMountExtended(CreateMenu, { propsData: { @@ -30,9 +27,7 @@ describe('CreateMenu component', () => { }, stubs: { InviteMembersTrigger, - GlDisclosureDropdown: stubComponent(GlDisclosureDropdown, { - methods: { closeAndFocus: closeAndFocusMock }, - }), + GlDisclosureDropdown, }, }); }; @@ -42,11 +37,12 @@ describe('CreateMenu component', () => { createWrapper(); }); - it('passes popper options to the dropdown', () => { + it('passes custom offset to the dropdown', () => { createWrapper(); - expect(findGlDisclosureDropdown().props('popperOptions')).toEqual({ - modifiers: [{ name: 'offset', options: { offset: [-147, 4] } }], + expect(findGlDisclosureDropdown().props('dropdownOffset')).toEqual({ + crossAxis: -147, + mainAxis: 4, }); }); @@ -93,10 +89,5 @@ describe('CreateMenu component', () => { expect(findGlTooltip().exists()).toBe(true); }); - - it('closes the dropdown when invite members modal is opened', () => { - findInviteMembersTrigger().vm.$emit('modal-opened'); - expect(closeAndFocusMock).toHaveBeenCalled(); - }); }); }); diff --git a/spec/frontend/super_sidebar/components/frequent_items_list_spec.js b/spec/frontend/super_sidebar/components/frequent_items_list_spec.js index 5329a8f5da3..63dd941974a 100644 --- a/spec/frontend/super_sidebar/components/frequent_items_list_spec.js +++ b/spec/frontend/super_sidebar/components/frequent_items_list_spec.js @@ -1,4 +1,4 @@ -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import { s__ } from '~/locale'; import FrequentItemsList from '~/super_sidebar/components//frequent_items_list.vue'; import ItemsList from '~/super_sidebar/components/items_list.vue'; @@ -18,18 +18,20 @@ describe('FrequentItemsList component', () => { const findListTitle = () => wrapper.findByTestId('list-title'); const findItemsList = () => wrapper.findComponent(ItemsList); const findEmptyText = () => wrapper.findByTestId('empty-text'); + const findRemoveItemButton = () => wrapper.findByTestId('item-remove'); - const createWrapper = ({ props = {} } = {}) => { - wrapper = shallowMountExtended(FrequentItemsList, { + const createWrapperFactory = (mountFn = shallowMountExtended) => () => { + wrapper = mountFn(FrequentItemsList, { propsData: { title, pristineText, storageKey, maxItems, - ...props, }, }); }; + const createWrapper = createWrapperFactory(); + const createFullWrapper = createWrapperFactory(mountExtended); describe('default', () => { beforeEach(() => { @@ -64,16 +66,20 @@ describe('FrequentItemsList component', () => { it('does not render the empty text slot', () => { expect(findEmptyText().exists()).toBe(false); }); + }); - describe('items editing', () => { - it('remove-item event emission from items-list causes list item to be removed', async () => { - const localStorageProjects = findItemsList().props('items'); + describe('items editing', () => { + beforeEach(() => { + window.localStorage.setItem(storageKey, cachedFrequentProjects); + createFullWrapper(); + }); - await findItemsList().vm.$emit('remove-item', localStorageProjects[0]); + it('remove-item event emission from items-list causes list item to be removed', async () => { + const localStorageProjects = findItemsList().props('items'); + await findRemoveItemButton().trigger('click'); - expect(findItemsList().props('items')).toHaveLength(maxItems - 1); - expect(findItemsList().props('items')).not.toContain(localStorageProjects[0]); - }); + expect(findItemsList().props('items')).toHaveLength(maxItems - 1); + expect(findItemsList().props('items')).not.toContain(localStorageProjects[0]); }); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/command_palette/__snapshots__/search_item_spec.js.snap b/spec/frontend/super_sidebar/components/global_search/command_palette/__snapshots__/search_item_spec.js.snap new file mode 100644 index 00000000000..d16d137db2f --- /dev/null +++ b/spec/frontend/super_sidebar/components/global_search/command_palette/__snapshots__/search_item_spec.js.snap @@ -0,0 +1,122 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SearchItem should render the item 1`] = ` +<div + class="gl-display-flex gl-align-items-center" +> + <gl-avatar-stub + alt="avatar" + aria-hidden="true" + class="gl-mr-3" + entityid="37" + entityname="" + shape="rect" + size="16" + src="https://www.gravatar.com/avatar/a9638f4ec70148d51e56bf05ad41e993?s=80&d=identicon" + /> + + <!----> + + <span + class="gl-display-flex gl-flex-direction-column" + > + <span + class="gl-text-gray-900" + /> + + <!----> + </span> +</div> +`; + +exports[`SearchItem should render the item 2`] = ` +<div + class="gl-display-flex gl-align-items-center" +> + <!----> + + <gl-icon-stub + class="gl-mr-3" + name="users" + size="16" + /> + + <span + class="gl-display-flex gl-flex-direction-column" + > + <span + class="gl-text-gray-900" + > + Manage > Activity + </span> + + <!----> + </span> +</div> +`; + +exports[`SearchItem should render the item 3`] = ` +<div + class="gl-display-flex gl-align-items-center" +> + <gl-avatar-stub + alt="avatar" + aria-hidden="true" + class="gl-mr-3" + entityid="1" + entityname="MockProject1" + shape="rect" + size="32" + src="/project/avatar/1/avatar.png" + /> + + <!----> + + <span + class="gl-display-flex gl-flex-direction-column" + > + <span + class="gl-text-gray-900" + > + MockProject1 + </span> + + <span + class="gl-font-sm gl-text-gray-500" + > + Gitlab Org / MockProject1 + </span> + </span> +</div> +`; + +exports[`SearchItem should render the item 4`] = ` +<div + class="gl-display-flex gl-align-items-center" +> + <gl-avatar-stub + alt="avatar" + aria-hidden="true" + class="gl-mr-3" + entityid="7" + entityname="Flight" + shape="rect" + size="16" + src="" + /> + + <!----> + + <span + class="gl-display-flex gl-flex-direction-column" + > + <span + class="gl-text-gray-900" + > + Dismiss Cipher with no integrity + </span> + + <!----> + </span> +</div> +`; diff --git a/spec/frontend/super_sidebar/components/global_search/command_palette/command_palette_items_spec.js b/spec/frontend/super_sidebar/components/global_search/command_palette/command_palette_items_spec.js new file mode 100644 index 00000000000..21d085dc0fb --- /dev/null +++ b/spec/frontend/super_sidebar/components/global_search/command_palette/command_palette_items_spec.js @@ -0,0 +1,143 @@ +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import CommandPaletteItems from '~/super_sidebar/components/global_search/command_palette/command_palette_items.vue'; +import { + COMMAND_HANDLE, + USERS_GROUP_TITLE, + USER_HANDLE, + SEARCH_SCOPE, +} from '~/super_sidebar/components/global_search/command_palette/constants'; +import { + commandMapper, + linksReducer, +} from '~/super_sidebar/components/global_search/command_palette/utils'; +import { getFormattedItem } from '~/super_sidebar/components/global_search/utils'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import waitForPromises from 'helpers/wait_for_promises'; +import { COMMANDS, LINKS, USERS } from './mock_data'; + +const links = LINKS.reduce(linksReducer, []); + +describe('CommandPaletteItems', () => { + let wrapper; + const autocompletePath = '/autocomplete'; + const searchContext = { project: { id: 1 }, group: { id: 2 } }; + + const createComponent = (props) => { + wrapper = shallowMount(CommandPaletteItems, { + propsData: { + handle: COMMAND_HANDLE, + searchQuery: '', + ...props, + }, + stubs: { + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, + }, + provide: { + commandPaletteCommands: COMMANDS, + commandPaletteLinks: LINKS, + autocompletePath, + searchContext, + }, + }); + }; + + const findItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); + const findGroups = () => wrapper.findAllComponents(GlDisclosureDropdownGroup); + const findLoader = () => wrapper.findComponent(GlLoadingIcon); + + describe('COMMANDS & LINKS', () => { + it('renders all commands initially', () => { + createComponent(); + const commandGroup = COMMANDS.map(commandMapper)[0]; + expect(findItems()).toHaveLength(commandGroup.items.length); + expect(findGroups().at(0).props('group')).toEqual({ + name: commandGroup.name, + items: commandGroup.items, + }); + }); + + describe('with search query', () => { + it('should filter commands and links by the search query', async () => { + jest.spyOn(fuzzaldrinPlus, 'filter'); + createComponent({ searchQuery: 'mr' }); + const searchQuery = 'todo'; + await wrapper.setProps({ searchQuery }); + const commandGroup = COMMANDS.map(commandMapper)[0]; + expect(fuzzaldrinPlus.filter).toHaveBeenCalledWith( + commandGroup.items, + searchQuery, + expect.objectContaining({ key: 'text' }), + ); + expect(fuzzaldrinPlus.filter).toHaveBeenCalledWith( + links, + searchQuery, + expect.objectContaining({ key: 'keywords' }), + ); + }); + + it('should display no results message when no command matched the search query', async () => { + jest.spyOn(fuzzaldrinPlus, 'filter').mockReturnValue([]); + createComponent({ searchQuery: 'mr' }); + const searchQuery = 'todo'; + await wrapper.setProps({ searchQuery }); + expect(wrapper.text()).toBe('No results found'); + }); + }); + }); + + describe('USERS, ISSUES, PROJECTS', () => { + let mockAxios; + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + }); + + it('should NOT start search by the search query which is less than 3 chars', () => { + jest.spyOn(axios, 'get'); + const searchQuery = 'us'; + createComponent({ handle: USER_HANDLE, searchQuery }); + + expect(axios.get).not.toHaveBeenCalled(); + + expect(findLoader().exists()).toBe(false); + }); + + it('should start scoped search with 3+ chars and display a loader', () => { + jest.spyOn(axios, 'get'); + const searchQuery = 'user'; + createComponent({ handle: USER_HANDLE, searchQuery }); + + expect(axios.get).toHaveBeenCalledWith( + `${autocompletePath}?term=${searchQuery}&project_id=${searchContext.project.id}&filter=search&scope=${SEARCH_SCOPE[USER_HANDLE]}`, + ); + expect(findLoader().exists()).toBe(true); + }); + + it('should render returned items', async () => { + mockAxios.onGet().replyOnce(HTTP_STATUS_OK, USERS); + + const searchQuery = 'user'; + createComponent({ handle: USER_HANDLE, searchQuery }); + + await waitForPromises(); + expect(findItems()).toHaveLength(USERS.length); + expect(findGroups().at(0).props('group')).toMatchObject({ + name: USERS_GROUP_TITLE, + items: USERS.map(getFormattedItem), + }); + }); + + it('should display no results message when no users matched the search query', async () => { + mockAxios.onGet().replyOnce(HTTP_STATUS_OK, []); + const searchQuery = 'user'; + createComponent({ handle: USER_HANDLE, searchQuery }); + await waitForPromises(); + expect(wrapper.text()).toBe('No results found'); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/global_search/command_palette/fake_search_input_spec.js b/spec/frontend/super_sidebar/components/global_search/command_palette/fake_search_input_spec.js new file mode 100644 index 00000000000..a8e91395303 --- /dev/null +++ b/spec/frontend/super_sidebar/components/global_search/command_palette/fake_search_input_spec.js @@ -0,0 +1,44 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import FakeSearchInput from '~/super_sidebar/components/global_search/command_palette/fake_search_input.vue'; +import { + SEARCH_SCOPE_PLACEHOLDER, + COMMON_HANDLES, + COMMAND_HANDLE, +} from '~/super_sidebar/components/global_search/command_palette/constants'; + +describe('FakeSearchInput', () => { + let wrapper; + + const createComponent = (props) => { + wrapper = shallowMountExtended(FakeSearchInput, { + propsData: { + scope: COMMAND_HANDLE, + userInput: '', + ...props, + }, + }); + }; + + const findSearchScope = () => wrapper.findByTestId('search-scope'); + const findSearchScopePlaceholder = () => wrapper.findByTestId('search-scope-placeholder'); + + it('should render the search scope', () => { + createComponent(); + expect(findSearchScope().text()).toBe(COMMAND_HANDLE); + }); + + describe('placeholder', () => { + it.each(COMMON_HANDLES)( + 'should render the placeholder for the %s scope when there is no user input', + (scope) => { + createComponent({ scope }); + expect(findSearchScopePlaceholder().text()).toBe(SEARCH_SCOPE_PLACEHOLDER[scope]); + }, + ); + + it('should NOT render the placeholder when there is user input', () => { + createComponent({ userInput: 'todo' }); + expect(findSearchScopePlaceholder().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/global_search/command_palette/mock_data.js b/spec/frontend/super_sidebar/components/global_search/command_palette/mock_data.js new file mode 100644 index 00000000000..ec65a43d549 --- /dev/null +++ b/spec/frontend/super_sidebar/components/global_search/command_palette/mock_data.js @@ -0,0 +1,133 @@ +export const COMMANDS = [ + { + name: 'Global', + items: [ + { + text: 'New project/repository', + href: '/projects/new', + }, + { + text: 'New group', + href: '/groups/new', + }, + { + text: 'New snippet', + href: '/-/snippets/new', + }, + { + text: 'Invite members', + href: '/-/snippets/new', + component: 'invite_members', + }, + ], + }, +]; + +export const LINKS = [ + { + title: 'Manage', + icon: 'users', + link: '/flightjs/Flight/activity', + is_active: false, + pill_count: null, + items: [ + { + id: 'activity', + title: 'Activity', + icon: null, + link: '/flightjs/Flight/activity', + pill_count: null, + link_classes: 'shortcuts-project-activity', + is_active: false, + }, + { + id: 'members', + title: 'Members', + icon: null, + link: '/flightjs/Flight/-/project_members', + pill_count: null, + link_classes: null, + is_active: false, + }, + { + id: 'labels', + title: 'Labels', + icon: null, + link: '/flightjs/Flight/-/labels', + pill_count: null, + link_classes: null, + is_active: false, + }, + ], + separated: false, + }, +]; + +export const TRANSFORMED_LINKS = [ + { + href: '/flightjs/Flight/activity', + icon: 'users', + keywords: 'Manage', + text: 'Manage', + }, + { + href: '/flightjs/Flight/activity', + icon: 'users', + keywords: 'Activity', + text: 'Manage > Activity', + }, + { + href: '/flightjs/Flight/-/project_members', + icon: 'users', + keywords: 'Members', + text: 'Manage > Members', + }, + { + href: '/flightjs/Flight/-/labels', + icon: 'users', + keywords: 'Labels', + text: 'Manage > Labels', + }, +]; + +export const USERS = [ + { + id: 37, + username: 'reported_user_14', + name: 'Cole Dickinson', + web_url: 'http://127.0.0.1:3000/reported_user_14', + avatar_url: + 'https://www.gravatar.com/avatar/a9638f4ec70148d51e56bf05ad41e993?s=80\u0026d=identicon', + }, + { + id: 47, + username: 'sharlatenok', + name: 'Olena Horal-Koretska', + web_url: 'http://127.0.0.1:3000/sharlatenok', + }, + { + id: 30, + username: 'reported_user_7', + name: 'Violeta Feeney', + web_url: 'http://127.0.0.1:3000/reported_user_7', + }, +]; + +export const PROJECT = { + category: 'Projects', + id: 1, + label: 'Gitlab Org / MockProject1', + value: 'MockProject1', + url: 'project/1', + avatar_url: '/project/avatar/1/avatar.png', +}; + +export const ISSUE = { + avatar_url: '', + category: 'Recent issues', + id: 516, + label: 'Dismiss Cipher with no integrity', + project_id: 7, + project_name: 'Flight', + url: '/flightjs/Flight/-/issues/37', +}; diff --git a/spec/frontend/super_sidebar/components/global_search/command_palette/search_item_spec.js b/spec/frontend/super_sidebar/components/global_search/command_palette/search_item_spec.js new file mode 100644 index 00000000000..c7e49310588 --- /dev/null +++ b/spec/frontend/super_sidebar/components/global_search/command_palette/search_item_spec.js @@ -0,0 +1,33 @@ +import { shallowMount } from '@vue/test-utils'; +import SearchItem from '~/super_sidebar/components/global_search/command_palette/search_item.vue'; +import { getFormattedItem } from '~/super_sidebar/components/global_search/utils'; +import { linksReducer } from '~/super_sidebar/components/global_search/command_palette/utils'; +import { USERS, LINKS, PROJECT, ISSUE } from './mock_data'; + +jest.mock('~/lib/utils/highlight', () => ({ + __esModule: true, + default: (text) => text, +})); +const mockUser = getFormattedItem(USERS[0]); +const mockCommand = LINKS.reduce(linksReducer, [])[1]; +const mockProject = getFormattedItem(PROJECT); +const mockIssue = getFormattedItem(ISSUE); + +describe('SearchItem', () => { + let wrapper; + + const createComponent = (item) => { + wrapper = shallowMount(SearchItem, { + propsData: { + item, + searchQuery: 'root', + }, + }); + }; + + it.each([mockUser, mockCommand, mockProject, mockIssue])('should render the item', (item) => { + createComponent(item); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/super_sidebar/components/global_search/command_palette/utils_spec.js b/spec/frontend/super_sidebar/components/global_search/command_palette/utils_spec.js new file mode 100644 index 00000000000..0b75787723e --- /dev/null +++ b/spec/frontend/super_sidebar/components/global_search/command_palette/utils_spec.js @@ -0,0 +1,18 @@ +import { + commandMapper, + linksReducer, +} from '~/super_sidebar/components/global_search/command_palette/utils'; +import { COMMANDS, LINKS, TRANSFORMED_LINKS } from './mock_data'; + +describe('linksReducer', () => { + it('should transform links', () => { + expect(LINKS.reduce(linksReducer, [])).toEqual(TRANSFORMED_LINKS); + }); +}); + +describe('commandMapper', () => { + it('should temporarily remove the `invite_members` item', () => { + const initialCommandsLength = COMMANDS[0].items.length; + expect(COMMANDS.map(commandMapper)[0].items).toHaveLength(initialCommandsLength - 1); + }); +}); diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js index f78e141afad..9b7b9e288df 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js @@ -7,6 +7,12 @@ import GlobalSearchModal from '~/super_sidebar/components/global_search/componen import GlobalSearchAutocompleteItems from '~/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue'; import GlobalSearchDefaultItems from '~/super_sidebar/components/global_search/components/global_search_default_items.vue'; import GlobalSearchScopedItems from '~/super_sidebar/components/global_search/components/global_search_scoped_items.vue'; +import FakeSearchInput from '~/super_sidebar/components/global_search/command_palette/fake_search_input.vue'; +import CommandPaletteItems from '~/super_sidebar/components/global_search/command_palette/command_palette_items.vue'; +import { + SEARCH_OR_COMMAND_MODE_PLACEHOLDER, + COMMON_HANDLES, +} from '~/super_sidebar/components/global_search/command_palette/constants'; import { SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, @@ -17,6 +23,7 @@ import { IS_SEARCHING, SEARCH_SHORTCUTS_MIN_CHARACTERS, } from '~/super_sidebar/components/global_search/constants'; +import { SEARCH_GITLAB } from '~/vue_shared/global_search/constants'; import { truncate } from '~/lib/utils/text_utility'; import { visitUrl } from '~/lib/utils/url_utility'; import { ENTER_KEY } from '~/lib/utils/keys'; @@ -53,7 +60,18 @@ describe('GlobalSearchModal', () => { }, }; - const createComponent = (initialState, mockGetters, stubs) => { + const defaultMockGetters = { + searchQuery: () => MOCK_SEARCH_QUERY, + searchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS, + scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS, + }; + + const createComponent = ( + initialState = deafaultMockState, + mockGetters = defaultMockGetters, + stubs, + glFeatures = { commandPalette: false }, + ) => { const store = new Vuex.Store({ state: { ...deafaultMockState, @@ -71,6 +89,7 @@ describe('GlobalSearchModal', () => { wrapper = shallowMountExtended(GlobalSearchModal, { store, stubs, + provide: { glFeatures }, }); }; @@ -98,6 +117,8 @@ describe('GlobalSearchModal', () => { wrapper.findComponent(GlobalSearchAutocompleteItems); const findSearchInputDescription = () => wrapper.find(`#${SEARCH_INPUT_DESCRIPTION}`); const findSearchResultsDescription = () => wrapper.findByTestId(SEARCH_RESULTS_DESCRIPTION); + const findCommandPaletteItems = () => wrapper.findComponent(CommandPaletteItems); + const findFakeSearchInput = () => wrapper.findComponent(FakeSearchInput); describe('template', () => { describe('always renders', () => { @@ -281,6 +302,45 @@ describe('GlobalSearchModal', () => { ).toBe(iconName); }); }); + + describe('Command palette', () => { + describe('when FF `command_palette` is disabled', () => { + beforeEach(() => { + createComponent(); + }); + + it('should not render command mode components', () => { + expect(findCommandPaletteItems().exists()).toBe(false); + expect(findFakeSearchInput().exists()).toBe(false); + }); + + it('should provide default placeholder to the search input', () => { + expect(findGlobalSearchInput().attributes('placeholder')).toBe(SEARCH_GITLAB); + }); + }); + + describe.each(COMMON_HANDLES)( + 'when FF `command_palette` is enabled and search handle is %s', + (handle) => { + beforeEach(() => { + createComponent({ search: handle }, undefined, undefined, { + commandPalette: true, + }); + }); + + it('should render command mode components', () => { + expect(findCommandPaletteItems().exists()).toBe(true); + expect(findFakeSearchInput().exists()).toBe(true); + }); + + it('should provide an alternative placeholder to the search input', () => { + expect(findGlobalSearchInput().attributes('placeholder')).toBe( + SEARCH_OR_COMMAND_MODE_PLACEHOLDER, + ); + }); + }, + ); + }); }); describe('events', () => { diff --git a/spec/frontend/super_sidebar/components/help_center_spec.js b/spec/frontend/super_sidebar/components/help_center_spec.js index 808c30436a3..6af1172e4d8 100644 --- a/spec/frontend/super_sidebar/components/help_center_spec.js +++ b/spec/frontend/super_sidebar/components/help_center_spec.js @@ -4,7 +4,7 @@ import toggleWhatsNewDrawer from '~/whats_new'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import HelpCenter from '~/super_sidebar/components/help_center.vue'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { DOMAIN, PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; +import { DOCS_URL, FORUM_URL, PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { STORAGE_KEY } from '~/whats_new/utils/notification'; import { helpCenterState } from '~/super_sidebar/constants'; @@ -25,6 +25,7 @@ describe('HelpCenter component', () => { }; const withinComponent = () => within(wrapper.element); const findButton = (name) => withinComponent().getByRole('button', { name }); + const findNotificationDot = () => wrapper.findByTestId('notification-dot'); // eslint-disable-next-line no-shadow const createWrapper = (sidebarData) => { @@ -52,7 +53,7 @@ describe('HelpCenter component', () => { }, { text: HelpCenter.i18n.docs, - href: `https://docs.${DOMAIN}`, + href: DOCS_URL, extraAttrs: trackingAttrs('gitlab_documentation'), }, { @@ -62,7 +63,7 @@ describe('HelpCenter component', () => { }, { text: HelpCenter.i18n.forum, - href: `https://forum.${DOMAIN}/`, + href: FORUM_URL, extraAttrs: trackingAttrs('community_forum'), }, { @@ -91,22 +92,22 @@ describe('HelpCenter component', () => { ]); }); - it('passes popper options to the dropdown', () => { - expect(findDropdown().props('popperOptions')).toEqual({ - modifiers: [{ name: 'offset', options: { offset: [-4, 4] } }], + it('passes custom offset to the dropdown', () => { + expect(findDropdown().props('dropdownOffset')).toEqual({ + crossAxis: -4, + mainAxis: 4, }); }); describe('with show_tanuki_bot true', () => { beforeEach(() => { createWrapper({ ...sidebarData, show_tanuki_bot: true }); - jest.spyOn(wrapper.vm.$refs.dropdown, 'close'); }); it('shows Ask GitLab Chat with the help items', () => { expect(findDropdownGroup(0).props('group').items).toEqual([ expect.objectContaining({ - icon: 'tanuki', + icon: 'tanuki-ai', text: HelpCenter.i18n.chat, extraAttrs: trackingAttrs('tanuki_bot_help_dropdown'), }), @@ -119,10 +120,6 @@ describe('HelpCenter component', () => { findButton('Ask GitLab Chat').click(); }); - it('closes the dropdown', () => { - expect(wrapper.vm.$refs.dropdown.close).toHaveBeenCalled(); - }); - it('sets helpCenterState.showTanukiBotChatDrawer to true', () => { expect(helpCenterState.showTanukiBotChatDrawer).toBe(true); }); @@ -150,16 +147,9 @@ describe('HelpCenter component', () => { let button; beforeEach(() => { - jest.spyOn(wrapper.vm.$refs.dropdown, 'close'); - button = findButton('Keyboard shortcuts ?'); }); - it('closes the dropdown', () => { - button.click(); - expect(wrapper.vm.$refs.dropdown.close).toHaveBeenCalled(); - }); - it('shows the keyboard shortcuts modal', () => { // This relies on the event delegation set up by the Shortcuts class in // ~/behaviors/shortcuts/shortcuts.js. @@ -179,17 +169,12 @@ describe('HelpCenter component', () => { describe('showWhatsNew', () => { beforeEach(() => { - jest.spyOn(wrapper.vm.$refs.dropdown, 'close'); beforeEach(() => { createWrapper({ ...sidebarData, show_version_check: true }); }); findButton("What's new 5").click(); }); - it('closes the dropdown', () => { - expect(wrapper.vm.$refs.dropdown.close).toHaveBeenCalled(); - }); - it('shows the "What\'s new" slideout', () => { expect(toggleWhatsNewDrawer).toHaveBeenCalledWith(expect.any(Object)); }); @@ -219,8 +204,8 @@ describe('HelpCenter component', () => { createWrapper({ ...sidebarData, display_whats_new: false }); }); - it('is false', () => { - expect(wrapper.vm.showWhatsNewNotification).toBe(false); + it('does not render notification dot', () => { + expect(findNotificationDot().exists()).toBe(false); }); }); @@ -231,8 +216,8 @@ describe('HelpCenter component', () => { createWrapper({ ...sidebarData, display_whats_new: true }); }); - it('is true', () => { - expect(wrapper.vm.showWhatsNewNotification).toBe(true); + it('renders notification dot', () => { + expect(findNotificationDot().exists()).toBe(true); }); describe('when "What\'s new" drawer got opened', () => { @@ -240,8 +225,8 @@ describe('HelpCenter component', () => { findButton("What's new 5").click(); }); - it('is false', () => { - expect(wrapper.vm.showWhatsNewNotification).toBe(false); + it('does not render notification dot', () => { + expect(findNotificationDot().exists()).toBe(false); }); }); @@ -251,8 +236,8 @@ describe('HelpCenter component', () => { createWrapper({ ...sidebarData, display_whats_new: true }); }); - it('is false', () => { - expect(wrapper.vm.showWhatsNewNotification).toBe(false); + it('does not render notification dot', () => { + expect(findNotificationDot().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/super_sidebar/components/items_list_spec.js b/spec/frontend/super_sidebar/components/items_list_spec.js index d5e8043cce9..8e00984f500 100644 --- a/spec/frontend/super_sidebar/components/items_list_spec.js +++ b/spec/frontend/super_sidebar/components/items_list_spec.js @@ -1,5 +1,4 @@ -import { GlIcon } from '@gitlab/ui'; -import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ItemsList from '~/super_sidebar/components/items_list.vue'; import NavItem from '~/super_sidebar/components/nav_item.vue'; import { cachedFrequentProjects } from '../mock_data'; @@ -12,8 +11,8 @@ describe('ItemsList component', () => { const findNavItems = () => wrapper.findAllComponents(NavItem); - const createWrapper = ({ props = {}, slots = {}, mountFn = shallowMountExtended } = {}) => { - wrapper = mountFn(ItemsList, { + const createWrapper = ({ props = {}, slots = {} } = {}) => { + wrapper = shallowMountExtended(ItemsList, { propsData: { ...props, }, @@ -61,41 +60,4 @@ describe('ItemsList component', () => { expect(wrapper.findByTestId(testId).exists()).toBe(true); }); - - describe('item removal', () => { - const findRemoveButton = () => wrapper.findByTestId('item-remove'); - const mockProject = { - ...firstMockedProject, - title: firstMockedProject.name, - }; - - beforeEach(() => { - createWrapper({ - props: { - items: [mockProject], - }, - mountFn: mountExtended, - }); - }); - - it('renders the remove button', () => { - const itemRemoveButton = findRemoveButton(); - - expect(itemRemoveButton.exists()).toBe(true); - expect(itemRemoveButton.attributes('title')).toBe('Remove'); - expect(itemRemoveButton.findComponent(GlIcon).props('name')).toBe('dash'); - }); - - it('emits `remove-item` event with item param when remove button is clicked', () => { - const itemRemoveButton = findRemoveButton(); - - itemRemoveButton.vm.$emit( - 'click', - { stopPropagation: jest.fn(), preventDefault: jest.fn() }, - mockProject, - ); - - expect(wrapper.emitted('remove-item')).toEqual([[mockProject]]); - }); - }); }); diff --git a/spec/frontend/super_sidebar/components/sidebar_menu_spec.js b/spec/frontend/super_sidebar/components/sidebar_menu_spec.js index 9b726b620dd..21e5220edd9 100644 --- a/spec/frontend/super_sidebar/components/sidebar_menu_spec.js +++ b/spec/frontend/super_sidebar/components/sidebar_menu_spec.js @@ -1,6 +1,8 @@ -import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SidebarMenu from '~/super_sidebar/components/sidebar_menu.vue'; import PinnedSection from '~/super_sidebar/components/pinned_section.vue'; +import NavItem from '~/super_sidebar/components/nav_item.vue'; +import MenuSection from '~/super_sidebar/components/menu_section.vue'; import { PANELS_WITH_PINS } from '~/super_sidebar/constants'; import { sidebarData } from '../mock_data'; @@ -11,174 +13,142 @@ const menuItems = [ { id: 4, title: 'Also with subitems', items: [{ id: 41, title: 'Subitem' }] }, ]; -describe('SidebarMenu component', () => { +describe('Sidebar Menu', () => { let wrapper; - const createWrapper = (mockData) => { - wrapper = mountExtended(SidebarMenu, { + const createWrapper = (extraProps = {}) => { + wrapper = shallowMountExtended(SidebarMenu, { propsData: { - items: mockData.current_menu_items, - pinnedItemIds: mockData.pinned_items, - panelType: mockData.panel_type, - updatePinsUrl: mockData.update_pins_url, + items: sidebarData.current_menu_items, + pinnedItemIds: sidebarData.pinned_items, + panelType: sidebarData.panel_type, + updatePinsUrl: sidebarData.update_pins_url, + ...extraProps, }, }); }; + const findStaticItemsSection = () => wrapper.findByTestId('static-items-section'); + const findStaticItems = () => findStaticItemsSection().findAllComponents(NavItem); const findPinnedSection = () => wrapper.findComponent(PinnedSection); const findMainMenuSeparator = () => wrapper.findByTestId('main-menu-separator'); - - describe('computed', () => { - describe('supportsPins', () => { - it('is true for the project sidebar', () => { - createWrapper({ ...sidebarData, panel_type: 'project' }); - expect(wrapper.vm.supportsPins).toBe(true); - }); - - it('is true for the group sidebar', () => { - createWrapper({ ...sidebarData, panel_type: 'group' }); - expect(wrapper.vm.supportsPins).toBe(true); - }); - - it('is false for any other sidebar', () => { - createWrapper({ ...sidebarData, panel_type: 'your_work' }); - expect(wrapper.vm.supportsPins).toEqual(false); + const findNonStaticItemsSection = () => wrapper.findByTestId('non-static-items-section'); + const findNonStaticItems = () => findNonStaticItemsSection().findAllComponents(NavItem); + const findNonStaticSectionItems = () => + findNonStaticItemsSection().findAllComponents(MenuSection); + + describe('Static section', () => { + describe('when the sidebar supports pins', () => { + beforeEach(() => { + createWrapper({ + items: menuItems, + panelType: PANELS_WITH_PINS[0], + }); }); - }); - describe('flatPinnableItems', () => { - it('returns all subitems in a flat array', () => { - createWrapper({ ...sidebarData, current_menu_items: menuItems }); - expect(wrapper.vm.flatPinnableItems).toEqual([ - { id: 21, title: 'Pinned subitem' }, - { id: 41, title: 'Subitem' }, + it('renders static items section', () => { + expect(findStaticItemsSection().exists()).toBe(true); + expect(findStaticItems().wrappers.map((w) => w.props('item').title)).toEqual([ + 'No subitems', + 'Empty subitems array', ]); }); }); - describe('staticItems', () => { - describe('when the sidebar supports pins', () => { - beforeEach(() => { - createWrapper({ - ...sidebarData, - current_menu_items: menuItems, - panel_type: PANELS_WITH_PINS[0], - }); + describe('when the sidebar does not support pins', () => { + beforeEach(() => { + createWrapper({ + items: menuItems, + panelType: 'explore', }); + }); - it('makes everything that has no subitems a static item', () => { - expect(wrapper.vm.staticItems.map((i) => i.title)).toEqual([ - 'No subitems', - 'Empty subitems array', - ]); - }); + it('does not render static items section', () => { + expect(findStaticItemsSection().exists()).toBe(false); }); + }); + }); - describe('when the sidebar does not support pins', () => { - beforeEach(() => { - createWrapper({ - ...sidebarData, - current_menu_items: menuItems, - panel_type: 'explore', - }); - }); + describe('Pinned section', () => { + it('is rendered in a project sidebar', () => { + createWrapper({ panelType: 'project' }); + expect(findPinnedSection().exists()).toBe(true); + }); - it('returns an empty array', () => { - expect(wrapper.vm.staticItems.map((i) => i.title)).toEqual([]); - }); - }); + it('is rendered in a group sidebar', () => { + createWrapper({ panelType: 'group' }); + expect(findPinnedSection().exists()).toBe(true); }); - describe('nonStaticItems', () => { - describe('when the sidebar supports pins', () => { - beforeEach(() => { - createWrapper({ - ...sidebarData, - current_menu_items: menuItems, - panel_type: PANELS_WITH_PINS[0], - }); - }); + it('is not rendered in other sidebars', () => { + createWrapper({ panelType: 'your_work' }); + expect(findPinnedSection().exists()).toBe(false); + }); + }); - it('keeps items that have subitems (aka "sections") as non-static', () => { - expect(wrapper.vm.nonStaticItems.map((i) => i.title)).toEqual([ - 'With subitems', - 'Also with subitems', - ]); + describe('Non static items section', () => { + describe('when the sidebar supports pins', () => { + beforeEach(() => { + createWrapper({ + items: menuItems, + panelType: PANELS_WITH_PINS[0], }); }); - describe('when the sidebar does not support pins', () => { - beforeEach(() => { - createWrapper({ - ...sidebarData, - current_menu_items: menuItems, - panel_type: 'explore', - }); - }); - - it('keeps all items as non-static', () => { - expect(wrapper.vm.nonStaticItems).toEqual(menuItems); - }); + it('keeps items that have subitems (aka "sections") as non-static', () => { + expect(findNonStaticSectionItems().wrappers.map((w) => w.props('item').title)).toEqual([ + 'With subitems', + 'Also with subitems', + ]); }); }); - describe('pinnedItems', () => { - describe('when user has no pinned item ids stored', () => { - beforeEach(() => { - createWrapper({ - ...sidebarData, - current_menu_items: menuItems, - pinned_items: [], - }); - }); - - it('returns an empty array', () => { - expect(wrapper.vm.pinnedItems).toEqual([]); + describe('when the sidebar does not support pins', () => { + beforeEach(() => { + createWrapper({ + items: menuItems, + panelType: 'explore', }); }); - describe('when user has some pinned item ids stored', () => { - beforeEach(() => { - createWrapper({ - ...sidebarData, - current_menu_items: menuItems, - pinned_items: [21], - }); - }); - - it('returns the items matching the pinned ids', () => { - expect(wrapper.vm.pinnedItems).toEqual([{ id: 21, title: 'Pinned subitem' }]); - }); + it('keeps all items as non-static', () => { + expect(findNonStaticSectionItems().length + findNonStaticItems().length).toBe( + menuItems.length, + ); }); }); }); - describe('Menu separators', () => { + describe('Separators', () => { it('should add the separator above pinned section', () => { createWrapper({ - ...sidebarData, - current_menu_items: menuItems, - panel_type: 'project', + items: menuItems, + panelType: 'project', }); expect(findPinnedSection().props('separated')).toBe(true); }); it('should add the separator above main menu items when there is a pinned section', () => { createWrapper({ - ...sidebarData, - current_menu_items: menuItems, - panel_type: PANELS_WITH_PINS[0], + items: menuItems, + panelType: PANELS_WITH_PINS[0], }); expect(findMainMenuSeparator().exists()).toBe(true); }); it('should NOT add the separator above main menu items when there is no pinned section', () => { createWrapper({ - ...sidebarData, - current_menu_items: menuItems, - panel_type: 'explore', + items: menuItems, + panelType: 'explore', }); expect(findMainMenuSeparator().exists()).toBe(false); }); }); + + describe('ARIA attributes', () => { + it('adds aria-label attribute to nav element', () => { + createWrapper(); + expect(wrapper.find('nav').attributes('aria-label')).toBe('Main navigation'); + }); + }); }); diff --git a/spec/frontend/super_sidebar/components/user_bar_spec.js b/spec/frontend/super_sidebar/components/user_bar_spec.js index 6878e724c65..ae48c0f2a75 100644 --- a/spec/frontend/super_sidebar/components/user_bar_spec.js +++ b/spec/frontend/super_sidebar/components/user_bar_spec.js @@ -5,6 +5,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { __ } from '~/locale'; import CreateMenu from '~/super_sidebar/components/create_menu.vue'; import SearchModal from '~/super_sidebar/components/global_search/components/global_search.vue'; +import BrandLogo from 'jh_else_ce/super_sidebar/components/brand_logo.vue'; import MergeRequestMenu from '~/super_sidebar/components/merge_request_menu.vue'; import Counter from '~/super_sidebar/components/counter.vue'; import UserBar from '~/super_sidebar/components/user_bar.vue'; @@ -23,7 +24,7 @@ describe('UserBar component', () => { const findMRsCounter = () => findCounter(1); const findTodosCounter = () => findCounter(2); const findMergeRequestMenu = () => wrapper.findComponent(MergeRequestMenu); - const findBrandLogo = () => wrapper.findByTestId('brand-header-custom-logo'); + const findBrandLogo = () => wrapper.findComponent(BrandLogo); const findCollapseButton = () => wrapper.findByTestId('super-sidebar-collapse-button'); const findSearchButton = () => wrapper.findByTestId('super-sidebar-search-button'); const findSearchModal = () => wrapper.findComponent(SearchModal); @@ -47,7 +48,6 @@ describe('UserBar component', () => { sidebarData: { ...sidebarData, ...extraSidebarData }, }, provide: { - rootPath: '/', toggleNewNavEndpoint: '/-/profile/preferences', isImpersonating: false, ...provideOverrides, @@ -116,7 +116,7 @@ describe('UserBar component', () => { it('renders branding logo', () => { expect(findBrandLogo().exists()).toBe(true); - expect(findBrandLogo().attributes('src')).toBe(sidebarData.logo_url); + expect(findBrandLogo().props('logoUrl')).toBe(sidebarData.logo_url); }); it('does not render the "Stop impersonating" button', () => { diff --git a/spec/frontend/super_sidebar/components/user_menu_spec.js b/spec/frontend/super_sidebar/components/user_menu_spec.js index cf8f650ec8f..f0f18ca9185 100644 --- a/spec/frontend/super_sidebar/components/user_menu_spec.js +++ b/spec/frontend/super_sidebar/components/user_menu_spec.js @@ -1,5 +1,6 @@ import { GlAvatar, GlDisclosureDropdown } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; import UserMenu from '~/super_sidebar/components/user_menu.vue'; import UserNameGroup from '~/super_sidebar/components/user_name_group.vue'; import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; @@ -17,7 +18,9 @@ describe('UserMenu component', () => { const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); const showDropdown = () => findDropdown().vm.$emit('shown'); - const createWrapper = (userDataChanges = {}) => { + const closeDropdownSpy = jest.fn(); + + const createWrapper = (userDataChanges = {}, stubs = {}) => { wrapper = mountExtended(UserMenu, { propsData: { data: { @@ -28,6 +31,7 @@ describe('UserMenu component', () => { stubs: { GlEmoji, GlAvatar: true, + ...stubs, }, provide: { toggleNewNavEndpoint, @@ -37,11 +41,12 @@ describe('UserMenu component', () => { trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }; - it('passes popper options to the dropdown', () => { + it('passes custom offset to the dropdown', () => { createWrapper(); - expect(findDropdown().props('popperOptions')).toEqual({ - modifiers: [{ name: 'offset', options: { offset: [-211, 4] } }], + expect(findDropdown().props('dropdownOffset')).toEqual({ + crossAxis: -211, + mainAxis: 4, }); }); @@ -79,8 +84,8 @@ describe('UserMenu component', () => { describe('User status item', () => { let item; - const setItem = ({ can_update, busy, customized } = {}) => { - createWrapper({ status: { ...userMenuMockStatus, can_update, busy, customized } }); + const setItem = ({ can_update, busy, customized, stubs } = {}) => { + createWrapper({ status: { ...userMenuMockStatus, can_update, busy, customized } }, stubs); item = wrapper.findByTestId('status-item'); }; @@ -103,11 +108,19 @@ describe('UserMenu component', () => { }); it('should close the dropdown when status modal opened', () => { - setItem({ can_update: true }); - wrapper.vm.$refs.userDropdown.close = jest.fn(); - expect(wrapper.vm.$refs.userDropdown.close).not.toHaveBeenCalled(); + setItem({ + can_update: true, + stubs: { + GlDisclosureDropdown: stubComponent(GlDisclosureDropdown, { + methods: { + close: closeDropdownSpy, + }, + }), + }, + }); + expect(closeDropdownSpy).not.toHaveBeenCalled(); item.vm.$emit('action'); - expect(wrapper.vm.$refs.userDropdown.close).toHaveBeenCalled(); + expect(closeDropdownSpy).toHaveBeenCalled(); }); describe('renders correct label', () => { diff --git a/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js b/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js index 909f4249e28..771d1f07fea 100644 --- a/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js +++ b/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js @@ -42,22 +42,19 @@ describe('Super Sidebar Collapsed State Manager', () => { describe('toggleSuperSidebarCollapsed', () => { it.each` - collapsed | saveCookie | windowWidth | hasClass | superSidebarPeek | isPeekable - ${true} | ${true} | ${xl} | ${true} | ${false} | ${false} - ${true} | ${true} | ${xl} | ${true} | ${true} | ${true} - ${true} | ${false} | ${xl} | ${true} | ${false} | ${false} - ${true} | ${true} | ${sm} | ${true} | ${false} | ${false} - ${true} | ${false} | ${sm} | ${true} | ${false} | ${false} - ${false} | ${true} | ${xl} | ${false} | ${false} | ${false} - ${false} | ${true} | ${xl} | ${false} | ${true} | ${false} - ${false} | ${false} | ${xl} | ${false} | ${false} | ${false} - ${false} | ${true} | ${sm} | ${false} | ${false} | ${false} - ${false} | ${false} | ${sm} | ${false} | ${false} | ${false} + collapsed | saveCookie | windowWidth | hasClass | isPeekable + ${true} | ${true} | ${xl} | ${true} | ${true} + ${true} | ${false} | ${xl} | ${true} | ${true} + ${true} | ${true} | ${sm} | ${true} | ${true} + ${true} | ${false} | ${sm} | ${true} | ${true} + ${false} | ${true} | ${xl} | ${false} | ${false} + ${false} | ${false} | ${xl} | ${false} | ${false} + ${false} | ${true} | ${sm} | ${false} | ${false} + ${false} | ${false} | ${sm} | ${false} | ${false} `( 'when collapsed is $collapsed, saveCookie is $saveCookie, and windowWidth is $windowWidth then page class contains `page-with-super-sidebar-collapsed` is $hasClass', - ({ collapsed, saveCookie, windowWidth, hasClass, superSidebarPeek, isPeekable }) => { + ({ collapsed, saveCookie, windowWidth, hasClass, isPeekable }) => { jest.spyOn(bp, 'windowWidth').mockReturnValue(windowWidth); - gon.features = { superSidebarPeek }; toggleSuperSidebarCollapsed(collapsed, saveCookie); diff --git a/spec/frontend/tabs/index_spec.js b/spec/frontend/tabs/index_spec.js index 1d61d38a488..7c127fd7124 100644 --- a/spec/frontend/tabs/index_spec.js +++ b/spec/frontend/tabs/index_spec.js @@ -1,12 +1,11 @@ +import htmlTabs from 'test_fixtures/tabs/tabs.html'; import { GlTabsBehavior, TAB_SHOWN_EVENT, HISTORY_TYPE_HASH } from '~/tabs'; import { ACTIVE_PANEL_CLASS, ACTIVE_TAB_CLASSES } from '~/tabs/constants'; import { getLocationHash } from '~/lib/utils/url_utility'; import { NO_SCROLL_TO_HASH_CLASS } from '~/lib/utils/common_utils'; -import { getFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import setWindowLocation from 'helpers/set_window_location_helper'; -const tabsFixture = getFixture('tabs/tabs.html'); - global.CSS = { escape: (val) => val, }; @@ -107,7 +106,7 @@ describe('GlTabsBehavior', () => { }); beforeEach(() => { - setHTMLFixture(tabsFixture); + setHTMLFixture(htmlTabs); const tabsEl = findByTestId('tabs'); tabShownEventSpy = jest.fn(); @@ -247,7 +246,7 @@ describe('GlTabsBehavior', () => { describe('using aria-controls instead of href to link tabs to panels', () => { beforeEach(() => { - setHTMLFixture(tabsFixture); + setHTMLFixture(htmlTabs); const tabsEl = findByTestId('tabs'); ['foo', 'bar', 'qux'].forEach((name) => { @@ -279,7 +278,7 @@ describe('GlTabsBehavior', () => { let tabsEl; beforeEach(() => { - setHTMLFixture(tabsFixture); + setHTMLFixture(htmlTabs); tabsEl = findByTestId('tabs'); }); diff --git a/spec/frontend/tags/components/sort_dropdown_spec.js b/spec/frontend/tags/components/sort_dropdown_spec.js index e0ff370d313..ebf79c93f9b 100644 --- a/spec/frontend/tags/components/sort_dropdown_spec.js +++ b/spec/frontend/tags/components/sort_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui'; +import { GlListboxItem, GlSearchBoxByClick } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import * as urlUtils from '~/lib/utils/url_utility'; @@ -39,9 +39,9 @@ describe('Tags sort dropdown', () => { }); it('should have a sort order dropdown', () => { - const branchesDropdown = findTagsDropdown(); + const tagsDropdown = findTagsDropdown(); - expect(branchesDropdown.exists()).toBe(true); + expect(tagsDropdown.exists()).toBe(true); }); }); @@ -63,9 +63,9 @@ describe('Tags sort dropdown', () => { }); it('should send a sort parameter', () => { - const sortDropdownItems = findTagsDropdown().findAllComponents(GlDropdownItem).at(0); + const sortDropdownItem = findTagsDropdown().findAllComponents(GlListboxItem).at(0); - sortDropdownItems.vm.$emit('click'); + sortDropdownItem.trigger('click'); expect(urlUtils.visitUrl).toHaveBeenCalledWith( '/root/ci-cd-project-demo/-/tags?sort=name_asc', diff --git a/spec/frontend/usage_quotas/components/sectioned_percentage_bar_spec.js b/spec/frontend/usage_quotas/components/sectioned_percentage_bar_spec.js new file mode 100644 index 00000000000..6b022172d46 --- /dev/null +++ b/spec/frontend/usage_quotas/components/sectioned_percentage_bar_spec.js @@ -0,0 +1,101 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import SectionedPercentageBar from '~/usage_quotas/components/sectioned_percentage_bar.vue'; + +describe('SectionedPercentageBar', () => { + let wrapper; + + const PERCENTAGE_BAR_SECTION_TESTID_PREFIX = 'percentage-bar-section-'; + const PERCENTAGE_BAR_LEGEND_SECTION_TESTID_PREFIX = 'percentage-bar-legend-section-'; + const LEGEND_SECTION_COLOR_TESTID = 'legend-section-color'; + const SECTION_1 = 'section1'; + const SECTION_2 = 'section2'; + const SECTION_3 = 'section3'; + const SECTION_4 = 'section4'; + + const defaultPropsData = { + sections: [ + { + id: SECTION_1, + label: 'Section 1', + value: 2000, + formattedValue: '1.95 KiB', + }, + { + id: SECTION_2, + label: 'Section 2', + value: 4000, + formattedValue: '3.90 KiB', + }, + { + id: SECTION_3, + label: 'Section 3', + value: 3000, + formattedValue: '2.93 KiB', + }, + { + id: SECTION_4, + label: 'Section 4', + value: 5000, + formattedValue: '4.88 KiB', + }, + ], + }; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = shallowMountExtended(SectionedPercentageBar, { + propsData: { ...defaultPropsData, ...propsData }, + }); + }; + + it('displays sectioned percentage bar', () => { + createComponent(); + + const section1 = wrapper.findByTestId(PERCENTAGE_BAR_SECTION_TESTID_PREFIX + SECTION_1); + const section2 = wrapper.findByTestId(PERCENTAGE_BAR_SECTION_TESTID_PREFIX + SECTION_2); + const section3 = wrapper.findByTestId(PERCENTAGE_BAR_SECTION_TESTID_PREFIX + SECTION_3); + const section4 = wrapper.findByTestId(PERCENTAGE_BAR_SECTION_TESTID_PREFIX + SECTION_4); + + expect(section1.attributes('style')).toBe( + 'background-color: rgb(97, 122, 226); width: 14.2857%;', + ); + expect(section2.attributes('style')).toBe( + 'background-color: rgb(177, 79, 24); width: 28.5714%;', + ); + expect(section3.attributes('style')).toBe( + 'background-color: rgb(0, 144, 177); width: 21.4286%;', + ); + expect(section4.attributes('style')).toBe( + 'background-color: rgb(78, 127, 14); width: 35.7143%;', + ); + expect(section1.text()).toMatchInterpolatedText('Section 1 14.3%'); + expect(section2.text()).toMatchInterpolatedText('Section 2 28.6%'); + expect(section3.text()).toMatchInterpolatedText('Section 3 21.4%'); + expect(section4.text()).toMatchInterpolatedText('Section 4 35.7%'); + }); + + it('displays sectioned percentage bar legend', () => { + createComponent(); + + const section1 = wrapper.findByTestId(PERCENTAGE_BAR_LEGEND_SECTION_TESTID_PREFIX + SECTION_1); + const section2 = wrapper.findByTestId(PERCENTAGE_BAR_LEGEND_SECTION_TESTID_PREFIX + SECTION_2); + const section3 = wrapper.findByTestId(PERCENTAGE_BAR_LEGEND_SECTION_TESTID_PREFIX + SECTION_3); + const section4 = wrapper.findByTestId(PERCENTAGE_BAR_LEGEND_SECTION_TESTID_PREFIX + SECTION_4); + + expect(section1.text()).toMatchInterpolatedText('Section 1 1.95 KiB'); + expect(section2.text()).toMatchInterpolatedText('Section 2 3.90 KiB'); + expect(section3.text()).toMatchInterpolatedText('Section 3 2.93 KiB'); + expect(section4.text()).toMatchInterpolatedText('Section 4 4.88 KiB'); + expect( + section1.find(`[data-testid="${LEGEND_SECTION_COLOR_TESTID}"]`).attributes('style'), + ).toBe('background-color: rgb(97, 122, 226);'); + expect( + section2.find(`[data-testid="${LEGEND_SECTION_COLOR_TESTID}"]`).attributes('style'), + ).toBe('background-color: rgb(177, 79, 24);'); + expect( + section3.find(`[data-testid="${LEGEND_SECTION_COLOR_TESTID}"]`).attributes('style'), + ).toBe('background-color: rgb(0, 144, 177);'); + expect( + section4.find(`[data-testid="${LEGEND_SECTION_COLOR_TESTID}"]`).attributes('style'), + ).toBe('background-color: rgb(78, 127, 14);'); + }); +}); diff --git a/spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js b/spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js index 15758c94436..37fc9602315 100644 --- a/spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js +++ b/spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js @@ -26,7 +26,7 @@ describe('ProjectStorageDetail', () => { ); }; - const generateStorageType = (id = 'buildArtifactsSize') => { + const generateStorageType = (id = 'buildArtifacts') => { return { storageType: { id, @@ -56,7 +56,7 @@ describe('ProjectStorageDetail', () => { 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( - projectHelpLinks[id.replace(`Size`, ``)], + projectHelpLinks[id], ); }, ); @@ -74,6 +74,14 @@ describe('ProjectStorageDetail', () => { }); }); + describe('with details links', () => { + it.each(storageTypes)('each $storageType.id', (item) => { + const shouldExist = Boolean(item.storageType.detailsPath && item.value); + const detailsLink = wrapper.findByTestId(`${item.storageType.id}-details-link`); + expect(detailsLink.exists()).toBe(shouldExist); + }); + }); + describe('without storage types', () => { beforeEach(() => { createComponent({ storageTypes: [] }); diff --git a/spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js b/spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js index ebe4c4b7f4e..92c24400e76 100644 --- a/spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js +++ b/spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js @@ -18,11 +18,11 @@ describe('StorageTypeIcon', () => { describe('rendering icon', () => { it.each` expected | provided - ${'doc-image'} | ${'lfsObjectsSize'} - ${'snippet'} | ${'snippetsSize'} - ${'infrastructure-registry'} | ${'repositorySize'} - ${'package'} | ${'packagesSize'} - ${'disk'} | ${'wikiSize'} + ${'doc-image'} | ${'lfsObjects'} + ${'snippet'} | ${'snippets'} + ${'infrastructure-registry'} | ${'repository'} + ${'package'} | ${'packages'} + ${'disk'} | ${'wiki'} ${'disk'} | ${'anything-else'} `( 'renders icon with name of $expected when name prop is $provided', diff --git a/spec/frontend/usage_quotas/storage/mock_data.js b/spec/frontend/usage_quotas/storage/mock_data.js index b4b02f77b52..8a7f941151b 100644 --- a/spec/frontend/usage_quotas/storage/mock_data.js +++ b/spec/frontend/usage_quotas/storage/mock_data.js @@ -9,25 +9,27 @@ export const projectData = { storageTypes: [ { storageType: { - id: 'containerRegistrySize', + id: 'containerRegistry', name: 'Container Registry', description: 'Gitlab-integrated Docker Container Registry for storing Docker Images.', helpPath: '/container_registry', + detailsPath: 'http://localhost/frontend-fixtures/builds-project/container_registry', }, - value: 3_900_000, + value: 3900000, }, { storageType: { - id: 'buildArtifactsSize', + id: 'buildArtifacts', name: 'Job artifacts', description: 'Job artifacts created by CI/CD.', helpPath: '/build-artifacts', + detailsPath: 'http://localhost/frontend-fixtures/builds-project/-/artifacts', }, value: 400000, }, { storageType: { - id: 'pipelineArtifactsSize', + id: 'pipelineArtifacts', name: 'Pipeline artifacts', description: 'Pipeline artifacts created by CI/CD.', helpPath: '/pipeline-artifacts', @@ -36,7 +38,7 @@ export const projectData = { }, { storageType: { - id: 'lfsObjectsSize', + id: 'lfsObjects', name: 'LFS', description: 'Audio samples, videos, datasets, and graphics.', helpPath: '/lsf-objects', @@ -45,37 +47,41 @@ export const projectData = { }, { storageType: { - id: 'packagesSize', + id: 'packages', name: 'Packages', description: 'Code packages and container images.', helpPath: '/packages', + detailsPath: 'http://localhost/frontend-fixtures/builds-project/-/packages', }, value: 3800000, }, { storageType: { - id: 'repositorySize', + id: 'repository', name: 'Repository', description: 'Git repository.', helpPath: '/repository', + detailsPath: 'http://localhost/frontend-fixtures/builds-project/-/tree/master', }, value: 3900000, }, { storageType: { - id: 'snippetsSize', + id: 'snippets', name: 'Snippets', description: 'Shared bits of code and text.', helpPath: '/snippets', + detailsPath: 'http://localhost/frontend-fixtures/builds-project/-/snippets', }, value: 0, }, { storageType: { - id: 'wikiSize', + id: 'wiki', name: 'Wiki', description: 'Wiki content.', helpPath: '/wiki', + detailsPath: 'http://localhost/frontend-fixtures/builds-project/-/wikis/pages', }, value: 300000, }, diff --git a/spec/frontend/usage_quotas/storage/utils_spec.js b/spec/frontend/usage_quotas/storage/utils_spec.js index 8fdd307c008..e3a271adc57 100644 --- a/spec/frontend/usage_quotas/storage/utils_spec.js +++ b/spec/frontend/usage_quotas/storage/utils_spec.js @@ -12,7 +12,10 @@ import { } from './mock_data'; describe('getStorageTypesFromProjectStatistics', () => { - const projectStatistics = mockGetProjectStorageStatisticsGraphQLResponse.data.project.statistics; + const { + statistics: projectStatistics, + statisticsDetailsPaths, + } = mockGetProjectStorageStatisticsGraphQLResponse.data.project; describe('matches project statistics value with matching storage type', () => { const typesWithStats = getStorageTypesFromProjectStatistics(projectStatistics); @@ -22,29 +25,39 @@ describe('getStorageTypesFromProjectStatistics', () => { storageType: expect.objectContaining({ id, }), - value: projectStatistics[id], + value: projectStatistics[`${id}Size`], }); }); }); it('adds helpPath to a relevant type', () => { - const trimTypeId = (id) => id.replace('Size', ''); const helpLinks = PROJECT_STORAGE_TYPES.reduce((acc, { id }) => { - const key = trimTypeId(id); return { ...acc, - [key]: `url://${id}`, + [id]: `url://${id}`, }; }, {}); const typesWithStats = getStorageTypesFromProjectStatistics(projectStatistics, helpLinks); typesWithStats.forEach((type) => { - const key = trimTypeId(type.storageType.id); + const key = type.storageType.id; expect(type.storageType.helpPath).toBe(helpLinks[key]); }); }); + + it('adds details page path', () => { + const typesWithStats = getStorageTypesFromProjectStatistics( + projectStatistics, + {}, + statisticsDetailsPaths, + ); + typesWithStats.forEach((type) => { + expect(type.storageType.detailsPath).toBe(statisticsDetailsPaths[type.storageType.id]); + }); + }); }); + describe('parseGetProjectStorageResults', () => { it('parses project statistics correctly', () => { expect( diff --git a/spec/frontend/user_popovers_spec.js b/spec/frontend/user_popovers_spec.js index 3346735055d..6f39eb9a118 100644 --- a/spec/frontend/user_popovers_spec.js +++ b/spec/frontend/user_popovers_spec.js @@ -121,6 +121,8 @@ describe('User Popovers', () => { expect(findPopovers().length).toBe(0); }); + // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/18442 + // Remove as @all is deprecated. it('does not initialize the popovers for @all references', () => { const [projectLink] = Array.from(document.querySelectorAll('.js-user-link[data-project]')); diff --git a/spec/frontend/users_select/index_spec.js b/spec/frontend/users_select/index_spec.js index 3757e63c4f9..dc6918ee543 100644 --- a/spec/frontend/users_select/index_spec.js +++ b/spec/frontend/users_select/index_spec.js @@ -1,4 +1,5 @@ import { escape } from 'lodash'; +import htmlCeMrSingleAssignees from 'test_fixtures/merge_requests/merge_request_with_single_assignee_feature.html'; import UsersSelect from '~/users_select/index'; import { createInputsModelExpectation, @@ -15,9 +16,7 @@ import { } from './test_helper'; describe('~/users_select/index', () => { - const context = createTestContext({ - fixturePath: 'merge_requests/merge_request_with_single_assignee_feature.html', - }); + const context = createTestContext({ fixture: htmlCeMrSingleAssignees }); beforeEach(() => { context.setup(); diff --git a/spec/frontend/users_select/test_helper.js b/spec/frontend/users_select/test_helper.js index 6fb3436100f..b38400446a9 100644 --- a/spec/frontend/users_select/test_helper.js +++ b/spec/frontend/users_select/test_helper.js @@ -1,18 +1,16 @@ import MockAdapter from 'axios-mock-adapter'; import { memoize, cloneDeep } from 'lodash'; import usersFixture from 'test_fixtures/autocomplete/users.json'; -import { getFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import UsersSelect from '~/users_select'; // fixtures ------------------------------------------------------------------- -const getUserSearchHTML = memoize((fixturePath) => { - const html = getFixture(fixturePath); +const getUserSearchHTML = memoize((fixture) => { const parser = new DOMParser(); - const el = parser.parseFromString(html, 'text/html').querySelector('.assignee'); + const el = parser.parseFromString(fixture, 'text/html').querySelector('.assignee'); return el.outerHTML; }); @@ -22,13 +20,13 @@ const getUsersFixture = () => usersFixture; export const getUsersFixtureAt = (idx) => getUsersFixture()[idx]; // test context --------------------------------------------------------------- -export const createTestContext = ({ fixturePath }) => { +export const createTestContext = ({ fixture }) => { let mock = null; let subject = null; const setup = () => { const rootEl = document.createElement('div'); - rootEl.innerHTML = getUserSearchHTML(fixturePath); + rootEl.innerHTML = getUserSearchHTML(fixture); document.body.appendChild(rootEl); mock = new MockAdapter(axios); diff --git a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js index a07a60438fb..2aed037be6f 100644 --- a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js @@ -57,13 +57,10 @@ describe('MRWidget approvals', () => { const apolloProvider = createMockApollo(requestHandlers); const provide = { ...options.provide, - glFeatures: { - realtimeApprovals: options.provide?.glFeatures?.realtimeApprovals || false, - }, }; - subscriptionHandlers.forEach(([document, stream]) => { - apolloProvider.defaultClient.setRequestHandler(document, stream); + subscriptionHandlers.forEach(([query, stream]) => { + apolloProvider.defaultClient.setRequestHandler(query, stream); }); wrapper = shallowMount(Approvals, { @@ -246,10 +243,6 @@ describe('MRWidget approvals', () => { it('calls service approve', () => { expect(service.approveMergeRequest).toHaveBeenCalled(); }); - - it('emits to eventHub', () => { - expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); - }); }); describe('and error', () => { @@ -300,10 +293,6 @@ describe('MRWidget approvals', () => { it('calls service unapprove', () => { expect(service.unapproveMergeRequest).toHaveBeenCalled(); }); - - it('emits to eventHub', () => { - expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); - }); }); describe('and error', () => { @@ -386,42 +375,21 @@ describe('MRWidget approvals', () => { }); describe('realtime approvals update', () => { - describe('realtime_approvals feature disabled', () => { - beforeEach(() => { - jest.spyOn(console, 'warn').mockImplementation(); - createComponent(); - }); + const subscriptionApproval = { approved: true }; + const subscriptionResponse = { + data: { mergeRequestApprovalStateUpdated: subscriptionApproval }, + }; - it('does not subscribe to the approvals update socket', () => { - expect(mr.setApprovals).not.toHaveBeenCalled(); - mockedSubscription.next({}); - // eslint-disable-next-line no-console - expect(console.warn).toHaveBeenCalledWith( - expect.stringMatching('Mock subscription has no observer, this will have no effect'), - ); - expect(mr.setApprovals).not.toHaveBeenCalled(); - }); + beforeEach(() => { + createComponent(); }); - describe('realtime_approvals feature enabled', () => { - const subscriptionApproval = { approved: true }; - const subscriptionResponse = { - data: { mergeRequestApprovalStateUpdated: subscriptionApproval }, - }; - - beforeEach(() => { - createComponent({ - provide: { glFeatures: { realtimeApprovals: true } }, - }); - }); - - it('updates approvals when the subscription data is streamed to the Apollo client', () => { - expect(mr.setApprovals).not.toHaveBeenCalled(); + it('updates approvals when the subscription data is streamed to the Apollo client', () => { + expect(mr.setApprovals).not.toHaveBeenCalled(); - mockedSubscription.next(subscriptionResponse); + mockedSubscription.next(subscriptionResponse); - expect(mr.setApprovals).toHaveBeenCalledWith(subscriptionApproval); - }); + expect(mr.setApprovals).toHaveBeenCalledWith(subscriptionApproval); }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge_spec.js index c8fa1399dcb..016eac05727 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge_spec.js @@ -4,26 +4,15 @@ import NothingToMerge from '~/vue_merge_request_widget/components/states/nothing describe('NothingToMerge', () => { let wrapper; - const newBlobPath = '/foo'; - const defaultProps = { - mr: { - newBlobPath, - }, - }; - - const createComponent = (props = defaultProps) => { + const createComponent = () => { wrapper = shallowMountExtended(NothingToMerge, { - propsData: { - ...props, - }, stubs: { GlSprintf, }, }); }; - const findCreateButton = () => wrapper.findByTestId('createFileButton'); const findNothingToMergeTextBody = () => wrapper.findByTestId('nothing-to-merge-body'); describe('With Blob link', () => { @@ -32,27 +21,10 @@ describe('NothingToMerge', () => { }); it('shows the component with the correct text and highlights', () => { - expect(wrapper.text()).toContain('This merge request contains no changes.'); + expect(wrapper.text()).toContain('Merge request contains no changes'); expect(findNothingToMergeTextBody().text()).toContain( - 'Use merge requests to propose changes to your project and discuss them with your team. To make changes, push a commit or edit this merge request to use a different branch.', + 'Use merge requests to propose changes to your project and discuss them with your team. To make changes, use the Code dropdown list above, then test them with CI/CD before merging.', ); }); - - it('shows the Create file button with the correct attributes', () => { - const createButton = findCreateButton(); - - expect(createButton.exists()).toBe(true); - expect(createButton.attributes('href')).toBe(newBlobPath); - }); - }); - - describe('Without Blob link', () => { - beforeEach(() => { - createComponent({ mr: { newBlobPath: '' } }); - }); - - it('does not show the Create file button', () => { - expect(findCreateButton().exists()).toBe(false); - }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_preparing_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_preparing_spec.js new file mode 100644 index 00000000000..a54591cdb16 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_preparing_spec.js @@ -0,0 +1,29 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; + +import Preparing from '~/vue_merge_request_widget/components/states/mr_widget_preparing.vue'; +import { MR_WIDGET_PREPARING_ASYNCHRONOUSLY } from '~/vue_merge_request_widget/i18n'; + +function createComponent() { + return shallowMount(Preparing); +} + +function findSpinnerIcon(wrapper) { + return wrapper.findComponent(GlLoadingIcon); +} + +describe('Preparing', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + it('should render a spinner', () => { + expect(findSpinnerIcon(wrapper).exists()).toBe(true); + }); + + it('should render the correct text', () => { + expect(wrapper.text()).toBe(MR_WIDGET_PREPARING_ASYNCHRONOUSLY); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js index 19825318a4f..d36ad4983c6 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js @@ -4,19 +4,12 @@ import { removeBreakLine } from 'helpers/text_helper'; import notesEventHub from '~/notes/event_hub'; import UnresolvedDiscussions from '~/vue_merge_request_widget/components/states/unresolved_discussions.vue'; -function createComponent({ path = '', propsData = {}, provide = {} } = {}) { +function createComponent({ path = '' } = {}) { return mount(UnresolvedDiscussions, { propsData: { mr: { createIssueToResolveDiscussionsPath: path, }, - ...propsData, - }, - provide: { - glFeatures: { - hideCreateIssueResolveAll: false, - }, - ...provide, }, }); } @@ -46,11 +39,7 @@ describe('UnresolvedDiscussions', () => { expect(text).toContain('Merge blocked:'); expect(text).toContain('all threads must be resolved.'); - expect(wrapper.element.innerText).toContain('Resolve all with new issue'); expect(wrapper.element.innerText).toContain('Go to first unresolved thread'); - expect(wrapper.element.querySelector('.js-create-issue').getAttribute('href')).toEqual( - TEST_HOST, - ); }); }); @@ -60,26 +49,7 @@ describe('UnresolvedDiscussions', () => { expect(text).toContain('Merge blocked:'); expect(text).toContain('all threads must be resolved.'); - expect(wrapper.element.innerText).not.toContain('Resolve all with new issue'); expect(wrapper.element.innerText).toContain('Go to first unresolved thread'); - expect(wrapper.element.querySelector('.js-create-issue')).toEqual(null); - }); - }); - - describe('when `hideCreateIssueResolveAll` is enabled', () => { - beforeEach(() => { - wrapper = createComponent({ - path: TEST_HOST, - provide: { - glFeatures: { - hideCreateIssueResolveAll: true, - }, - }, - }); - }); - - it('do not show jump to first button', () => { - expect(wrapper.text()).not.toContain('Create issue to resolve all threads'); }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/widget/app_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/app_spec.js index 8dbee9b370c..bf318cd6b88 100644 --- a/spec/frontend/vue_merge_request_widget/components/widget/app_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/widget/app_spec.js @@ -12,8 +12,8 @@ describe('MR Widget App', () => { }); }; - it('does not mount if widgets array is empty', () => { + it('renders widget container', () => { createComponent(); - expect(wrapper.findByTestId('mr-widget-app').exists()).toBe(false); + expect(wrapper.findByTestId('mr-widget-app').exists()).toBe(true); }); }); diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js index 785515ae846..2aa4e7c4841 100644 --- a/spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js +++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js @@ -5,6 +5,7 @@ import { RUNNING, DEPLOYING, REDEPLOYING, + WILL_DEPLOY, } from '~/vue_merge_request_widget/components/deployment/constants'; import DeploymentActionButton from '~/vue_merge_request_widget/components/deployment/deployment_action_button.vue'; import { actionButtonMocks } from './deployment_mock_data'; @@ -118,4 +119,20 @@ describe('Deployment action button', () => { expect(wrapper.findComponent(GlButton).props('disabled')).toBe(false); }); }); + + describe('when the deployment status is will_deploy', () => { + beforeEach(() => { + factory({ + propsData: { + ...baseProps, + actionInProgress: actionButtonMocks[REDEPLOYING].actionName, + computedDeploymentStatus: WILL_DEPLOY, + }, + }); + }); + it('is disabled and shows the loading icon', () => { + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlButton).props('disabled')).toBe(true); + }); + }); }); diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js index f2b78dedf3a..b901b80e8bf 100644 --- a/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js +++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js @@ -9,6 +9,7 @@ import { FAILED, DEPLOYING, REDEPLOYING, + SUCCESS, STOPPING, } from '~/vue_merge_request_widget/components/deployment/constants'; import eventHub from '~/vue_merge_request_widget/event_hub'; @@ -35,7 +36,8 @@ describe('DeploymentAction component', () => { const findStopButton = () => wrapper.find('.js-stop-env'); const findDeployButton = () => wrapper.find('.js-manual-deploy-action'); - const findRedeployButton = () => wrapper.find('.js-manual-redeploy-action'); + const findManualRedeployButton = () => wrapper.find('.js-manual-redeploy-action'); + const findRedeployButton = () => wrapper.find('.js-redeploy-action'); beforeEach(() => { executeActionSpy = jest.spyOn(MRWidgetService, 'executeInlineAction'); @@ -79,17 +81,17 @@ describe('DeploymentAction component', () => { describe('when there is no retry_path in details', () => { it('the manual redeploy button does not appear', () => { - expect(findRedeployButton().exists()).toBe(false); + expect(findManualRedeployButton().exists()).toBe(false); }); }); }); describe('when conditions are met', () => { describe.each` - configConst | computedDeploymentStatus | displayConditionChanges | finderFn | endpoint - ${STOPPING} | ${CREATED} | ${{}} | ${findStopButton} | ${deploymentMockData.stop_url} - ${DEPLOYING} | ${MANUAL_DEPLOY} | ${playDetails} | ${findDeployButton} | ${playDetails.playable_build.play_path} - ${REDEPLOYING} | ${FAILED} | ${retryDetails} | ${findRedeployButton} | ${retryDetails.playable_build.retry_path} + configConst | computedDeploymentStatus | displayConditionChanges | finderFn | endpoint + ${STOPPING} | ${CREATED} | ${{}} | ${findStopButton} | ${deploymentMockData.stop_url} + ${DEPLOYING} | ${MANUAL_DEPLOY} | ${playDetails} | ${findDeployButton} | ${playDetails.playable_build.play_path} + ${REDEPLOYING} | ${FAILED} | ${retryDetails} | ${findManualRedeployButton} | ${retryDetails.playable_build.retry_path} `( '$configConst action', ({ configConst, computedDeploymentStatus, displayConditionChanges, finderFn, endpoint }) => { @@ -231,4 +233,141 @@ describe('DeploymentAction component', () => { }, ); }); + + describe('with the reviewAppsRedeployMrWidget feature flag turned on', () => { + beforeEach(() => { + factory({ + propsData: { + computedDeploymentStatus: SUCCESS, + deployment: { + ...deploymentMockData, + details: undefined, + retry_url: retryDetails.playable_build.retry_path, + environment_available: false, + }, + }, + provide: { + glFeatures: { + reviewAppsRedeployMrWidget: true, + }, + }, + }); + }); + + it('should display the redeploy button', () => { + expect(findRedeployButton().exists()).toBe(true); + }); + + describe('when the redeploy button is clicked', () => { + describe('should show a confirm dialog but not call executeInlineAction when declined', () => { + beforeEach(() => { + executeActionSpy.mockResolvedValueOnce(); + confirmAction.mockResolvedValueOnce(false); + findRedeployButton().trigger('click'); + }); + + it('should show the confirm dialog', () => { + expect(confirmAction).toHaveBeenCalled(); + expect(confirmAction).toHaveBeenCalledWith( + actionButtonMocks[REDEPLOYING].confirmMessage, + { + primaryBtnVariant: actionButtonMocks[REDEPLOYING].buttonVariant, + primaryBtnText: actionButtonMocks[REDEPLOYING].buttonText, + }, + ); + }); + + it('should not execute the action', () => { + expect(MRWidgetService.executeInlineAction).not.toHaveBeenCalled(); + }); + }); + + describe('should show a confirm dialog and call executeInlineAction when accepted', () => { + beforeEach(() => { + executeActionSpy.mockResolvedValueOnce(); + confirmAction.mockResolvedValueOnce(true); + findRedeployButton().trigger('click'); + }); + + it('should show the confirm dialog', () => { + expect(confirmAction).toHaveBeenCalled(); + expect(confirmAction).toHaveBeenCalledWith( + actionButtonMocks[REDEPLOYING].confirmMessage, + { + primaryBtnVariant: actionButtonMocks[REDEPLOYING].buttonVariant, + primaryBtnText: actionButtonMocks[REDEPLOYING].buttonText, + }, + ); + }); + + it('should not throw an error', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); + + describe('response includes redirect_url', () => { + const url = '/root/example'; + beforeEach(async () => { + executeActionSpy.mockResolvedValueOnce({ + data: { redirect_url: url }, + }); + + await waitForPromises(); + + confirmAction.mockResolvedValueOnce(true); + findRedeployButton().trigger('click'); + }); + + it('does not call visit url', () => { + expect(visitUrl).not.toHaveBeenCalled(); + }); + }); + + describe('it should call the executeAction method', () => { + beforeEach(async () => { + jest.spyOn(wrapper.vm, 'executeAction').mockImplementation(); + jest.spyOn(eventHub, '$emit'); + + await waitForPromises(); + + confirmAction.mockResolvedValueOnce(true); + findRedeployButton().trigger('click'); + }); + + it('calls with the expected arguments', () => { + expect(wrapper.vm.executeAction).toHaveBeenCalled(); + expect(wrapper.vm.executeAction).toHaveBeenCalledWith( + retryDetails.playable_build.retry_path, + actionButtonMocks[REDEPLOYING], + ); + }); + + it('emits the FetchDeployments event', () => { + expect(eventHub.$emit).toHaveBeenCalledWith('FetchDeployments'); + }); + }); + + describe('when executeInlineAction errors', () => { + beforeEach(async () => { + executeActionSpy.mockRejectedValueOnce(); + jest.spyOn(eventHub, '$emit'); + + await waitForPromises(); + + confirmAction.mockResolvedValueOnce(true); + findRedeployButton().trigger('click'); + }); + + it('should call createAlert with error message', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: actionButtonMocks[REDEPLOYING].errorMessage, + }); + }); + + it('emits the FetchDeployments event', () => { + expect(eventHub.$emit).toHaveBeenCalledWith('FetchDeployments'); + }); + }); + }); + }); + }); }); diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_mock_data.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_mock_data.js index e98b1160ae4..374fe4e1b95 100644 --- a/spec/frontend/vue_merge_request_widget/deployment/deployment_mock_data.js +++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_mock_data.js @@ -43,6 +43,7 @@ const deploymentMockData = { external_url_formatted: 'gitlab', deployed_at: '2017-03-22T22:44:42.258Z', deployed_at_formatted: 'Mar 22, 2017 10:44pm', + environment_available: true, details: {}, status: SUCCESS, changes: [ diff --git a/spec/frontend/vue_merge_request_widget/mock_data.js b/spec/frontend/vue_merge_request_widget/mock_data.js index 46e1919b0ea..47143bb2bb8 100644 --- a/spec/frontend/vue_merge_request_widget/mock_data.js +++ b/spec/frontend/vue_merge_request_widget/mock_data.js @@ -427,6 +427,7 @@ export const mockStore = { external_url: 'https://fake.com', external_url_formatted: 'https://fake.com', status: SUCCESS, + environment_available: true, }, { id: 1, @@ -434,6 +435,7 @@ export const mockStore = { external_url: 'https://fake.com', external_url_formatted: 'https://fake.com', status: SUCCESS, + environment_available: true, }, ], postMergeDeployments: [ diff --git a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js index 64fb2806447..0533471bece 100644 --- a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js @@ -3,13 +3,13 @@ import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; +import { createMockSubscription as createMockApolloSubscription } from 'mock-apollo-client'; import * as Sentry from '@sentry/browser'; import approvedByCurrentUser from 'test_fixtures/graphql/merge_requests/approvals/approvals.query.graphql.json'; import getStateQueryResponse from 'test_fixtures/graphql/merge_requests/get_state.query.graphql.json'; import readyToMergeResponse from 'test_fixtures/graphql/merge_requests/states/ready_to_merge.query.graphql.json'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { securityReportMergeRequestDownloadPathsQueryResponse } from 'jest/vue_shared/security_reports/mock_data'; import api from '~/api'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK, HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; @@ -25,12 +25,16 @@ import { STATE_QUERY_POLLING_INTERVAL_BACKOFF } from '~/vue_merge_request_widget import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants'; import eventHub from '~/vue_merge_request_widget/event_hub'; import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue'; +import Approvals from '~/vue_merge_request_widget/components/approvals/approvals.vue'; +import Preparing from '~/vue_merge_request_widget/components/states/mr_widget_preparing.vue'; import WidgetContainer from '~/vue_merge_request_widget/components/widget/app.vue'; 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 getStateQuery from '~/vue_merge_request_widget/queries/get_state.query.graphql'; +import getStateSubscription from '~/vue_merge_request_widget/queries/get_state.subscription.graphql'; +import readyToMergeSubscription from '~/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql'; import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql'; import approvalsQuery from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approvals.query.graphql'; +import approvedBySubscription from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approvals.subscription.graphql'; import userPermissionsQuery from '~/vue_merge_request_widget/queries/permissions.query.graphql'; import conflictsStateQuery from '~/vue_merge_request_widget/queries/states/conflicts.query.graphql'; import { faviconDataUrl, overlayDataUrl } from '../lib/utils/mock_data'; @@ -67,13 +71,11 @@ describe('MrWidgetOptions', () => { let queryResponse; let wrapper; let mock; + let stateSubscription; const COLLABORATION_MESSAGE = 'Members who can merge are allowed to add commits'; - const findWidgetContainer = () => wrapper.findComponent(WidgetContainer); - const findExtensionToggleButton = () => - wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]'); - const findExtensionLink = (linkHref) => - wrapper.find(`[data-testid="widget-extension"] [href="${linkHref}"]`); + const findApprovalsWidget = () => wrapper.findComponent(Approvals); + const findPreparingWidget = () => wrapper.findComponent(Preparing); beforeEach(() => { gl.mrWidgetData = { ...mockData }; @@ -94,8 +96,7 @@ describe('MrWidgetOptions', () => { }); const createComponent = (mrData = mockData, options = {}, data = {}, fullMount = true) => { - const mounting = fullMount ? mount : shallowMount; - + const mockedApprovalsSubscription = createMockApolloSubscription(); queryResponse = { data: { project: { @@ -103,11 +104,45 @@ describe('MrWidgetOptions', () => { mergeRequest: { ...getStateQueryResponse.data.project.mergeRequest, mergeError: mrData.mergeError || null, + detailedMergeStatus: + mrData.detailedMergeStatus || + getStateQueryResponse.data.project.mergeRequest.detailedMergeStatus, }, }, }, }; stateQueryHandler = jest.fn().mockResolvedValue(queryResponse); + stateSubscription = createMockApolloSubscription(); + + const mounting = fullMount ? mount : shallowMount; + const queryHandlers = [ + [approvalsQuery, jest.fn().mockResolvedValue(approvedByCurrentUser)], + [getStateQuery, stateQueryHandler], + [readyToMergeQuery, jest.fn().mockResolvedValue(readyToMergeResponse)], + [ + userPermissionsQuery, + jest.fn().mockResolvedValue({ + data: { project: { mergeRequest: { userPermissions: {} } } }, + }), + ], + [ + conflictsStateQuery, + jest.fn().mockResolvedValue({ data: { project: { mergeRequest: {} } } }), + ], + ...(options.apolloMock || []), + ]; + const subscriptionHandlers = [ + [approvedBySubscription, () => mockedApprovalsSubscription], + [getStateSubscription, () => stateSubscription], + [readyToMergeSubscription, () => createMockApolloSubscription()], + ...(options.apolloSubscriptions || []), + ]; + const apolloProvider = createMockApollo(queryHandlers); + + subscriptionHandlers.forEach(([query, stream]) => { + apolloProvider.defaultClient.setRequestHandler(query, stream); + }); + wrapper = mounting(MrWidgetOptions, { propsData: { mrData: { ...mrData }, @@ -120,30 +155,19 @@ describe('MrWidgetOptions', () => { }, ...options, - apolloProvider: createMockApollo([ - [approvalsQuery, jest.fn().mockResolvedValue(approvedByCurrentUser)], - [getStateQuery, stateQueryHandler], - [readyToMergeQuery, jest.fn().mockResolvedValue(readyToMergeResponse)], - [ - userPermissionsQuery, - jest.fn().mockResolvedValue({ - data: { project: { mergeRequest: { userPermissions: {} } } }, - }), - ], - [ - conflictsStateQuery, - jest.fn().mockResolvedValue({ data: { project: { mergeRequest: {} } } }), - ], - ...(options.apolloMock || []), - ]), + apolloProvider, }); return axios.waitForAll(); }; + const findExtensionToggleButton = () => + wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]'); + const findExtensionLink = (linkHref) => + wrapper.find(`[data-testid="widget-extension"] [href="${linkHref}"]`); const findSuggestPipeline = () => wrapper.find('[data-testid="mr-suggest-pipeline"]'); const findSuggestPipelineButton = () => findSuggestPipeline().find('button'); - const findSecurityMrWidget = () => wrapper.find('[data-testid="security-mr-widget"]'); + const findWidgetContainer = () => wrapper.findComponent(WidgetContainer); describe('default', () => { beforeEach(() => { @@ -626,6 +650,7 @@ describe('MrWidgetOptions', () => { deployed_at_formatted: 'Mar 22, 2017 10:44pm', changes, status: SUCCESS, + environment_available: true, }; beforeEach(() => { @@ -847,47 +872,6 @@ describe('MrWidgetOptions', () => { }); }); - describe('security widget', () => { - const setup = (hasPipeline) => { - const mrData = { - ...mockData, - ...(hasPipeline ? {} : { pipeline: null }), - }; - - // Override top-level mocked requests, which always use a fresh copy of - // mockData, which always includes the full pipeline object. - mock.onGet(mockData.merge_request_widget_path).reply(() => [HTTP_STATUS_OK, mrData]); - mock.onGet(mockData.merge_request_cached_widget_path).reply(() => [HTTP_STATUS_OK, mrData]); - - return createComponent(mrData, { - apolloMock: [ - [ - securityReportMergeRequestDownloadPathsQuery, - jest - .fn() - .mockResolvedValue({ data: securityReportMergeRequestDownloadPathsQueryResponse }), - ], - ], - }); - }; - - describe('with a pipeline', () => { - it('renders the security widget', async () => { - await setup(true); - - expect(findSecurityMrWidget().exists()).toBe(true); - }); - }); - - describe('with no pipeline', () => { - it('does not render the security widget', async () => { - await setup(false); - - expect(findSecurityMrWidget().exists()).toBe(false); - }); - }); - }); - describe('suggestPipeline', () => { beforeEach(() => { mock.onAny().reply(HTTP_STATUS_OK); @@ -1156,7 +1140,7 @@ describe('MrWidgetOptions', () => { await nextTick(); await waitForPromises(); - expect(Sentry.captureException).toHaveBeenCalledTimes(2); + expect(Sentry.captureException).toHaveBeenCalledTimes(1); expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Fetch error')); expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed'); }); @@ -1248,17 +1232,86 @@ describe('MrWidgetOptions', () => { expect(api.trackRedisCounterEvent).not.toHaveBeenCalled(); }); }); + }); - describe('widget container', () => { - it('should not be displayed when the refactor_security_extension feature flag is turned off', () => { - createComponent(); - expect(findWidgetContainer().exists()).toBe(false); + describe('widget container', () => { + it('renders the widget container when there is MR data', async () => { + await createComponent(mockData); + expect(findWidgetContainer().props('mr')).not.toBeUndefined(); + }); + }); + + describe('async preparation for a newly opened MR', () => { + beforeEach(() => { + mock + .onGet(mockData.merge_request_widget_path) + .reply(() => [HTTP_STATUS_OK, { ...mockData, state: 'opened' }]); + }); + + it('does not render the Preparing state component by default', async () => { + await createComponent(); + + expect(findApprovalsWidget().exists()).toBe(true); + expect(findPreparingWidget().exists()).toBe(false); + }); + + it('renders the Preparing state component when the MR state is initially "preparing"', async () => { + await createComponent({ + ...mockData, + state: 'opened', + detailedMergeStatus: 'PREPARING', }); - it('should be displayed when the refactor_security_extension feature flag is turned on', () => { - window.gon.features.refactorSecurityExtension = true; - createComponent(); - expect(findWidgetContainer().exists()).toBe(true); + expect(findApprovalsWidget().exists()).toBe(false); + expect(findPreparingWidget().exists()).toBe(true); + }); + + describe('when the MR is updated by observing its status', () => { + beforeEach(() => { + window.gon.features.realtimeMrStatusChange = true; + }); + + it("shows the Preparing widget when the MR reports it's not ready yet", async () => { + await createComponent( + { + ...mockData, + state: 'opened', + detailedMergeStatus: 'PREPARING', + }, + {}, + {}, + false, + ); + + expect(wrapper.html()).toContain('mr-widget-preparing-stub'); + }); + + it('removes the Preparing widget when the MR indicates it has been prepared', async () => { + await createComponent( + { + ...mockData, + state: 'opened', + detailedMergeStatus: 'PREPARING', + }, + {}, + {}, + false, + ); + + expect(wrapper.html()).toContain('mr-widget-preparing-stub'); + + stateSubscription.next({ + data: { + mergeRequestMergeStatusUpdated: { + preparedAt: 'non-null value', + }, + }, + }); + + // Wait for batched DOM updates + await nextTick(); + + expect(wrapper.html()).not.toContain('mr-widget-preparing-stub'); }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js b/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js index a6288b9c725..ca5c9084a62 100644 --- a/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js +++ b/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js @@ -16,10 +16,14 @@ describe('getStateKey', () => { commitsCount: 2, hasConflicts: false, draft: false, - detailedMergeStatus: null, + detailedMergeStatus: 'PREPARING', }; const bound = getStateKey.bind(context); + expect(bound()).toEqual('preparing'); + + context.detailedMergeStatus = null; + expect(bound()).toEqual('checking'); context.detailedMergeStatus = 'MERGEABLE'; diff --git a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap deleted file mode 100644 index 30e15595193..00000000000 --- a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap +++ /dev/null @@ -1,103 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` -<gl-dropdown-stub - category="primary" - clearalltext="Clear all" - clearalltextclass="gl-px-5" - headertext="" - hideheaderborder="true" - highlighteditemstitle="Selected" - highlighteditemstitleclass="gl-px-5" - right="true" - size="medium" - text="Clone" - variant="confirm" -> - <div - class="pb-2 mx-1" - > - <gl-dropdown-section-header-stub> - Clone with SSH - </gl-dropdown-section-header-stub> - - <div - class="mx-3" - > - <b-input-group-stub - readonly="" - tag="div" - > - <!----> - - <b-form-input-stub - class="gl-form-input" - debounce="0" - formatter="[Function]" - readonly="true" - type="text" - value="ssh://foo.bar" - /> - - <b-input-group-append-stub - tag="div" - > - <gl-button-stub - aria-label="Copy URL" - buttontextclasses="" - category="primary" - class="d-inline-flex" - data-clipboard-text="ssh://foo.bar" - data-qa-selector="copy_ssh_url_button" - icon="copy-to-clipboard" - size="medium" - title="Copy URL" - variant="default" - /> - </b-input-group-append-stub> - </b-input-group-stub> - </div> - - <gl-dropdown-section-header-stub> - Clone with HTTP - </gl-dropdown-section-header-stub> - - <div - class="mx-3" - > - <b-input-group-stub - readonly="" - tag="div" - > - <!----> - - <b-form-input-stub - class="gl-form-input" - debounce="0" - formatter="[Function]" - readonly="true" - type="text" - value="http://foo.bar" - /> - - <b-input-group-append-stub - tag="div" - > - <gl-button-stub - aria-label="Copy URL" - buttontextclasses="" - category="primary" - class="d-inline-flex" - data-clipboard-text="http://foo.bar" - data-qa-selector="copy_http_url_button" - icon="copy-to-clipboard" - size="medium" - title="Copy URL" - variant="default" - /> - </b-input-group-append-stub> - </b-input-group-stub> - </div> - </div> -</gl-dropdown-stub> -`; diff --git a/spec/frontend/vue_shared/components/actions_button_spec.js b/spec/frontend/vue_shared/components/actions_button_spec.js index 8c2f2b52f8e..e7663e2adb2 100644 --- a/spec/frontend/vue_shared/components/actions_button_spec.js +++ b/spec/frontend/vue_shared/components/actions_button_spec.js @@ -1,12 +1,15 @@ -import { GlDropdown, GlDropdownDivider, GlButton, GlTooltip } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { + GlDisclosureDropdown, + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, +} from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ActionsButton from '~/vue_shared/components/actions_button.vue'; const TEST_ACTION = { key: 'action1', text: 'Sample', secondaryText: 'Lorem ipsum.', - tooltip: '', href: '/sample', attrs: { 'data-test': '123', @@ -14,191 +17,75 @@ const TEST_ACTION = { href: '/sample', variant: 'default', }, + handle: jest.fn(), }; const TEST_ACTION_2 = { key: 'action2', text: 'Sample 2', secondaryText: 'Dolar sit amit.', - tooltip: 'Dolar sit amit.', href: '#', attrs: { 'data-test': '456' }, + handle: jest.fn(), }; -const TEST_TOOLTIP = 'Lorem ipsum dolar sit'; -describe('Actions button component', () => { +describe('vue_shared/components/actions_button', () => { let wrapper; function createComponent(props) { - wrapper = shallowMount(ActionsButton, { - propsData: { ...props }, + wrapper = shallowMountExtended(ActionsButton, { + propsData: { actions: [TEST_ACTION, TEST_ACTION_2], toggleText: 'Edit', ...props }, + stubs: { + GlDisclosureDropdownItem, + }, }); } + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); - const findButton = () => wrapper.findComponent(GlButton); - const findTooltip = () => wrapper.findComponent(GlTooltip); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const parseDropdownItems = () => - findDropdown() - .findAll('gl-dropdown-item-stub,gl-dropdown-divider-stub') - .wrappers.map((x) => { - if (x.is(GlDropdownDivider)) { - return { type: 'divider' }; - } - - const { isCheckItem, isChecked, secondaryText } = x.props(); - - return { - type: 'item', - isCheckItem, - isChecked, - secondaryText, - text: x.text(), - }; - }); - const clickOn = (child, evt = new Event('click')) => child.vm.$emit('click', evt); - const clickLink = (...args) => clickOn(findButton(), ...args); - const clickDropdown = (...args) => clickOn(findDropdown(), ...args); - - describe('with 1 action', () => { - beforeEach(() => { - createComponent({ actions: [TEST_ACTION] }); - }); - - it('should not render dropdown', () => { - expect(findDropdown().exists()).toBe(false); - }); - - it('should render single button', () => { - expect(findButton().attributes()).toMatchObject({ - href: TEST_ACTION.href, - ...TEST_ACTION.attrs, - }); - expect(findButton().text()).toBe(TEST_ACTION.text); - }); - - it('should not have tooltip', () => { - expect(findTooltip().exists()).toBe(false); - }); + it('dropdown toggle displays provided toggleLabel', () => { + createComponent(); - it('should have attrs', () => { - expect(findButton().attributes()).toMatchObject(TEST_ACTION.attrs); - }); - - it('can click', () => { - expect(clickLink).not.toThrow(); - }); + expect(findDropdown().props().toggleText).toBe('Edit'); }); - describe('with 1 action with tooltip', () => { - it('should have tooltip', () => { - createComponent({ actions: [{ ...TEST_ACTION, tooltip: TEST_TOOLTIP }] }); + it('allows customizing variant and category', () => { + const variant = 'confirm'; + const category = 'secondary'; - expect(findTooltip().text()).toBe(TEST_TOOLTIP); - }); + createComponent({ variant, category }); + + expect(findDropdown().props()).toMatchObject({ category, variant }); }); - describe('when showActionTooltip is false', () => { - it('should not have tooltip', () => { - createComponent({ - actions: [{ ...TEST_ACTION, tooltip: TEST_TOOLTIP }], - showActionTooltip: false, - }); + it('displays a single dropdown group', () => { + createComponent(); - expect(findTooltip().exists()).toBe(false); - }); + expect(wrapper.findAllComponents(GlDisclosureDropdownGroup)).toHaveLength(1); }); - describe('with 1 action with handle', () => { - it('can click and trigger handle', () => { - const handleClick = jest.fn(); - createComponent({ actions: [{ ...TEST_ACTION, handle: handleClick }] }); + it('create dropdown items for every action', () => { + createComponent(); - const event = new Event('click'); - clickLink(event); + [TEST_ACTION, TEST_ACTION_2].forEach((action, index) => { + const dropdownItem = wrapper.findAllComponents(GlDisclosureDropdownItem).at(index); - expect(handleClick).toHaveBeenCalledWith(event); + expect(dropdownItem.props().item).toBe(action); + expect(dropdownItem.attributes()).toMatchObject(action.attrs); + expect(dropdownItem.text()).toContain(action.text); + expect(dropdownItem.text()).toContain(action.secondaryText); }); }); - describe('with multiple actions', () => { - let handleAction; + describe('when clicking a dropdown item', () => { + it("invokes the action's handle method", () => { + createComponent(); - beforeEach(() => { - handleAction = jest.fn(); + [TEST_ACTION, TEST_ACTION_2].forEach((action, index) => { + const dropdownItem = wrapper.findAllComponents(GlDisclosureDropdownItem).at(index); - createComponent({ actions: [{ ...TEST_ACTION, handle: handleAction }, TEST_ACTION_2] }); - }); + dropdownItem.vm.$emit('action'); - it('should default to selecting first action', () => { - expect(findDropdown().attributes()).toMatchObject({ - text: TEST_ACTION.text, - 'split-href': TEST_ACTION.href, + expect(action.handle).toHaveBeenCalled(); }); }); - - it('should handle first action click', () => { - const event = new Event('click'); - - clickDropdown(event); - - expect(handleAction).toHaveBeenCalledWith(event); - }); - - it('should render dropdown items', () => { - expect(parseDropdownItems()).toEqual([ - { - type: 'item', - isCheckItem: true, - isChecked: true, - secondaryText: TEST_ACTION.secondaryText, - text: TEST_ACTION.text, - }, - { type: 'divider' }, - { - type: 'item', - isCheckItem: true, - isChecked: false, - secondaryText: TEST_ACTION_2.secondaryText, - text: TEST_ACTION_2.text, - }, - ]); - }); - - it('should select action 2 when clicked', () => { - expect(wrapper.emitted('select')).toBeUndefined(); - - const action2 = wrapper.find(`[data-testid="action_${TEST_ACTION_2.key}"]`); - action2.vm.$emit('click'); - - expect(wrapper.emitted('select')).toEqual([[TEST_ACTION_2.key]]); - }); - - it('should not have tooltip value', () => { - expect(findTooltip().exists()).toBe(false); - }); - }); - - describe('with multiple actions and selectedKey', () => { - beforeEach(() => { - createComponent({ actions: [TEST_ACTION, TEST_ACTION_2], selectedKey: TEST_ACTION_2.key }); - }); - - it('should show action 2 as selected', () => { - expect(parseDropdownItems()).toEqual([ - expect.objectContaining({ - type: 'item', - isChecked: false, - }), - { type: 'divider' }, - expect.objectContaining({ - type: 'item', - isChecked: true, - }), - ]); - }); - - it('should have tooltip value', () => { - expect(findTooltip().text()).toBe(TEST_ACTION_2.tooltip); - }); }); }); diff --git a/spec/frontend/vue_shared/components/chronic_duration_input_spec.js b/spec/frontend/vue_shared/components/chronic_duration_input_spec.js index 2a40511affb..374babe3a97 100644 --- a/spec/frontend/vue_shared/components/chronic_duration_input_spec.js +++ b/spec/frontend/vue_shared/components/chronic_duration_input_spec.js @@ -310,12 +310,11 @@ describe('vue_shared/components/chronic_duration_input', () => { }); it('passes updated prop via v-model', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ value: MOCK_VALUE }); + textElement.value = '2hr20min'; + textElement.dispatchEvent(new Event('input')); await nextTick(); - expect(textElement.value).toBe('2 hrs 20 mins'); + expect(textElement.value).toBe('2hr20min'); expect(hiddenElement.value).toBe(MOCK_VALUE.toString()); }); }); diff --git a/spec/frontend/vue_shared/components/ci_badge_link_spec.js b/spec/frontend/vue_shared/components/ci_badge_link_spec.js index afb509b9fe6..8c860c9b06f 100644 --- a/spec/frontend/vue_shared/components/ci_badge_link_spec.js +++ b/spec/frontend/vue_shared/components/ci_badge_link_spec.js @@ -1,4 +1,4 @@ -import { GlLink } from '@gitlab/ui'; +import { GlBadge } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; @@ -46,6 +46,13 @@ describe('CI Badge Link Component', () => { icon: 'status_pending', details_path: 'status/pending', }, + preparing: { + text: 'preparing', + label: 'preparing', + group: 'preparing', + icon: 'status_preparing', + details_path: 'status/preparing', + }, running: { text: 'running', label: 'running', @@ -53,6 +60,13 @@ describe('CI Badge Link Component', () => { icon: 'status_running', details_path: 'status/running', }, + scheduled: { + text: 'scheduled', + label: 'scheduled', + group: 'scheduled', + icon: 'status_scheduled', + details_path: 'status/scheduled', + }, skipped: { text: 'skipped', label: 'skipped', @@ -61,8 +75,8 @@ describe('CI Badge Link Component', () => { details_path: 'status/skipped', }, success_warining: { - text: 'passed', - label: 'passed', + text: 'warning', + label: 'passed with warnings', group: 'success-with-warnings', icon: 'status_warning', details_path: 'status/warning', @@ -77,6 +91,8 @@ describe('CI Badge Link Component', () => { }; const findIcon = () => wrapper.findComponent(CiIcon); + const findBadge = () => wrapper.findComponent(GlBadge); + const findBadgeText = () => wrapper.find('[data-testid="ci-badge-text"'); const createComponent = (propsData) => { wrapper = shallowMount(CiBadgeLink, { propsData }); @@ -87,22 +103,50 @@ describe('CI Badge Link Component', () => { expect(wrapper.attributes('href')).toBe(statuses[status].details_path); expect(wrapper.text()).toBe(statuses[status].text); - expect(wrapper.classes()).toContain('ci-status'); - expect(wrapper.classes()).toContain(`ci-${statuses[status].group}`); + expect(findBadge().props('size')).toBe('md'); expect(findIcon().exists()).toBe(true); }); + it.each` + status | textColor | variant + ${statuses.success} | ${'gl-text-green-700'} | ${'success'} + ${statuses.success_warining} | ${'gl-text-orange-700'} | ${'warning'} + ${statuses.failed} | ${'gl-text-red-700'} | ${'danger'} + ${statuses.running} | ${'gl-text-blue-700'} | ${'info'} + ${statuses.pending} | ${'gl-text-orange-700'} | ${'warning'} + ${statuses.preparing} | ${'gl-text-gray-600'} | ${'muted'} + ${statuses.canceled} | ${'gl-text-gray-700'} | ${'neutral'} + ${statuses.scheduled} | ${'gl-text-gray-600'} | ${'muted'} + ${statuses.skipped} | ${'gl-text-gray-600'} | ${'muted'} + ${statuses.manual} | ${'gl-text-gray-700'} | ${'neutral'} + ${statuses.created} | ${'gl-text-gray-600'} | ${'muted'} + `( + 'should contain correct badge class and variant for status: $status.text', + ({ status, textColor, variant }) => { + createComponent({ status }); + + expect(findBadgeText().classes()).toContain(textColor); + expect(findBadge().props('variant')).toBe(variant); + }, + ); + it('should not render label', () => { createComponent({ status: statuses.canceled, showText: false }); expect(wrapper.text()).toBe(''); }); - it('should emit ciStatusBadgeClick event', async () => { + it('should emit ciStatusBadgeClick event', () => { createComponent({ status: statuses.success }); - await wrapper.findComponent(GlLink).vm.$emit('click'); + findBadge().vm.$emit('click'); expect(wrapper.emitted('ciStatusBadgeClick')).toEqual([[]]); }); + + it('should render dynamic badge size', () => { + createComponent({ status: statuses.success, badgeSize: 'lg' }); + + expect(findBadge().props('size')).toBe('lg'); + }); }); diff --git a/spec/frontend/vue_shared/components/clone_dropdown/clone_dropdown_item_spec.js b/spec/frontend/vue_shared/components/clone_dropdown/clone_dropdown_item_spec.js new file mode 100644 index 00000000000..e0dfa084f3e --- /dev/null +++ b/spec/frontend/vue_shared/components/clone_dropdown/clone_dropdown_item_spec.js @@ -0,0 +1,52 @@ +import { GlButton, GlFormGroup, GlFormInputGroup } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import CloneDropdownItem from '~/vue_shared/components/clone_dropdown/clone_dropdown_item.vue'; + +describe('Clone Dropdown Button', () => { + let wrapper; + const link = 'ssh://foo.bar'; + const label = 'SSH'; + const qaSelector = 'some-selector'; + const defaultPropsData = { + link, + label, + qaSelector, + }; + + const findCopyButton = () => wrapper.findComponent(GlButton); + + const createComponent = (propsData = defaultPropsData) => { + wrapper = shallowMount(CloneDropdownItem, { + propsData, + stubs: { + GlFormInputGroup, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + describe('default', () => { + it('sets form group label', () => { + expect(wrapper.findComponent(GlFormGroup).attributes('label')).toBe(label); + }); + + it('sets form input group link', () => { + expect(wrapper.findComponent(GlFormInputGroup).props('value')).toBe(link); + }); + + it('sets the copy tooltip text', () => { + expect(findCopyButton().attributes('title')).toBe('Copy URL'); + }); + + it('sets the copy tooltip link', () => { + expect(findCopyButton().attributes('data-clipboard-text')).toBe(link); + }); + + it('sets the qa selector', () => { + expect(findCopyButton().attributes('data-qa-selector')).toBe(qaSelector); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/clone_dropdown_spec.js b/spec/frontend/vue_shared/components/clone_dropdown/clone_dropdown_spec.js index 584e29d94c4..48c158d6fa2 100644 --- a/spec/frontend/vue_shared/components/clone_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/clone_dropdown/clone_dropdown_spec.js @@ -1,6 +1,7 @@ -import { GlFormInputGroup, GlDropdownSectionHeader } from '@gitlab/ui'; +import { GlFormInputGroup } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import CloneDropdown from '~/vue_shared/components/clone_dropdown.vue'; +import CloneDropdown from '~/vue_shared/components/clone_dropdown/clone_dropdown.vue'; +import CloneDropdownItem from '~/vue_shared/components/clone_dropdown/clone_dropdown_item.vue'; describe('Clone Dropdown Button', () => { let wrapper; @@ -12,30 +13,28 @@ describe('Clone Dropdown Button', () => { httpLink, }; + const findCloneDropdownItems = () => wrapper.findAllComponents(CloneDropdownItem); + const findCloneDropdownItemAtIndex = (index) => findCloneDropdownItems().at(index); + const createComponent = (propsData = defaultPropsData) => { wrapper = shallowMount(CloneDropdown, { propsData, stubs: { - 'gl-form-input-group': GlFormInputGroup, + GlFormInputGroup, }, }); }; describe('rendering', () => { - it('matches the snapshot', () => { - createComponent(); - expect(wrapper.element).toMatchSnapshot(); - }); - it.each` - name | index | value + name | index | link ${'SSH'} | ${0} | ${sshLink} ${'HTTP'} | ${1} | ${httpLink} - `('renders correct link and a copy-button for $name', ({ index, value }) => { + `('renders correct link and a copy-button for $name', ({ index, link }) => { createComponent(); - const group = wrapper.findAllComponents(GlFormInputGroup).at(index); - expect(group.props('value')).toBe(value); - expect(group.findComponent(GlFormInputGroup).exists()).toBe(true); + + const group = findCloneDropdownItemAtIndex(index); + expect(group.props('link')).toBe(link); }); it.each` @@ -45,8 +44,7 @@ describe('Clone Dropdown Button', () => { `('does not fail if only $name is set', ({ name, value }) => { createComponent({ [name]: value }); - expect(wrapper.findComponent(GlFormInputGroup).props('value')).toBe(value); - expect(wrapper.findAllComponents(GlDropdownSectionHeader).length).toBe(1); + expect(findCloneDropdownItemAtIndex(0).props('link')).toBe(value); }); }); @@ -58,12 +56,13 @@ describe('Clone Dropdown Button', () => { `('allows null values for the props', ({ name, value }) => { createComponent({ ...defaultPropsData, [name]: value }); - expect(wrapper.findAllComponents(GlDropdownSectionHeader).length).toBe(1); + expect(findCloneDropdownItems().length).toBe(1); }); it('correctly calculates httpLabel for HTTPS protocol', () => { createComponent({ httpLink: httpsLink }); - expect(wrapper.findComponent(GlDropdownSectionHeader).text()).toContain('HTTPS'); + + expect(findCloneDropdownItemAtIndex(0).attributes('label')).toContain('HTTPS'); }); }); }); diff --git a/spec/frontend/vue_shared/components/confirm_fork_modal_spec.js b/spec/frontend/vue_shared/components/confirm_fork_modal_spec.js index fbfef5cbe46..97c48a4db74 100644 --- a/spec/frontend/vue_shared/components/confirm_fork_modal_spec.js +++ b/spec/frontend/vue_shared/components/confirm_fork_modal_spec.js @@ -1,8 +1,17 @@ -import { GlModal } from '@gitlab/ui'; +import { GlLoadingIcon, GlModal } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import getNoWritableForksResponse from 'test_fixtures/graphql/vue_shared/components/web_ide/get_writable_forks.query.graphql_none.json'; +import getSomeWritableForksResponse from 'test_fixtures/graphql/vue_shared/components/web_ide/get_writable_forks.query.graphql_some.json'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import ConfirmForkModal, { i18n } from '~/vue_shared/components/confirm_fork_modal.vue'; +import ConfirmForkModal, { i18n } from '~/vue_shared/components/web_ide/confirm_fork_modal.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import getWritableForksQuery from '~/vue_shared/components/web_ide/get_writable_forks.query.graphql'; +import waitForPromises from 'helpers/wait_for_promises'; describe('vue_shared/components/confirm_fork_modal', () => { + Vue.use(VueApollo); + let wrapper = null; const forkPath = '/fake/fork/path'; @@ -13,13 +22,18 @@ describe('vue_shared/components/confirm_fork_modal', () => { const findModalProp = (prop) => findModal().props(prop); const findModalActionProps = () => findModalProp('actionPrimary'); - const createComponent = (props = {}) => - shallowMountExtended(ConfirmForkModal, { + const createComponent = (props = {}, getWritableForksResponse = getNoWritableForksResponse) => { + const fakeApollo = createMockApollo([ + [getWritableForksQuery, jest.fn().mockResolvedValue(getWritableForksResponse)], + ]); + return shallowMountExtended(ConfirmForkModal, { propsData: { ...defaultProps, ...props, }, + apolloProvider: fakeApollo, }); + }; describe('visible = false', () => { beforeEach(() => { @@ -73,4 +87,45 @@ describe('vue_shared/components/confirm_fork_modal', () => { expect(wrapper.emitted('change')).toEqual([[false]]); }); }); + + describe('writable forks', () => { + describe('when loading', () => { + it('shows loading spinner', () => { + wrapper = createComponent(); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + }); + + describe('with no writable forks', () => { + it('contains `newForkMessage`', async () => { + wrapper = createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toContain(i18n.newForkMessage); + }); + }); + + describe('with writable forks', () => { + it('contains `existingForksMessage`', async () => { + wrapper = createComponent(null, getSomeWritableForksResponse); + + await waitForPromises(); + + expect(wrapper.text()).toContain(i18n.existingForksMessage); + }); + + it('renders links to the forks', async () => { + wrapper = createComponent(null, getSomeWritableForksResponse); + + await waitForPromises(); + + const forks = getSomeWritableForksResponse.data.project.visibleForks.nodes; + + expect(wrapper.findByText(forks[0].fullPath).attributes('href')).toBe(forks[0].webUrl); + expect(wrapper.findByText(forks[1].fullPath).attributes('href')).toBe(forks[1].webUrl); + }); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js index f576121fc18..c0cb17f0d16 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js @@ -36,9 +36,7 @@ import { jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({ uniqueTokens: jest.fn().mockImplementation((tokens) => tokens), - stripQuotes: jest.requireActual( - '~/vue_shared/components/filtered_search_bar/filtered_search_utils', - ).stripQuotes, + stripQuotes: jest.requireActual('~/lib/utils/text_utility').stripQuotes, filterEmptySearchTerm: jest.requireActual( '~/vue_shared/components/filtered_search_bar/filtered_search_utils', ).filterEmptySearchTerm, diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js index d85b6e6d115..21a1303ccf3 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js @@ -5,7 +5,6 @@ import AccessorUtilities from '~/lib/utils/accessor'; import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import { - stripQuotes, uniqueTokens, prepareTokens, processFilters, @@ -29,23 +28,6 @@ function setLocalStorageAvailability(isAvailable) { } describe('Filtered Search Utils', () => { - describe('stripQuotes', () => { - it.each` - inputValue | outputValue - ${'"Foo Bar"'} | ${'Foo Bar'} - ${"'Foo Bar'"} | ${'Foo Bar'} - ${'FooBar'} | ${'FooBar'} - ${"Foo'Bar"} | ${"Foo'Bar"} - ${'Foo"Bar'} | ${'Foo"Bar'} - ${'Foo Bar'} | ${'Foo Bar'} - `( - 'returns string $outputValue when called with string $inputValue', - ({ inputValue, outputValue }) => { - expect(stripQuotes(inputValue)).toBe(outputValue); - }, - ); - }); - describe('uniqueTokens', () => { it('returns tokens array with duplicates removed', () => { expect( 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 d87aa3194d2..63eacaabd0c 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 @@ -31,9 +31,7 @@ import { mockLabelToken } from '../mock_data'; 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, + stripQuotes: jest.requireActual('~/lib/utils/text_utility').stripQuotes, })); const mockStorageKey = 'recent-tokens-label_name'; @@ -71,8 +69,9 @@ const defaultScopedSlots = { 'suggestions-list': `<div data-testid="${mockSuggestionListTestId}" :data-suggestions="JSON.stringify(props.suggestions)"></div>`, }; +const mockConfig = { ...mockLabelToken, recentSuggestionsStorageKey: mockStorageKey }; const mockProps = { - config: { ...mockLabelToken, recentSuggestionsStorageKey: mockStorageKey }, + config: mockConfig, value: { data: '' }, active: false, suggestions: [], @@ -221,6 +220,20 @@ describe('BaseToken', () => { }); }, ); + + it('limits the length of the rendered list using config.maxSuggestions', () => { + mockSuggestions = ['a', 'b', 'c', 'd'].map((id) => ({ id })); + + const maxSuggestions = 2; + const config = { ...mockConfig, maxSuggestions }; + const props = { defaultSuggestions: [], suggestions: mockSuggestions, config }; + + getRecentlyUsedSuggestions.mockReturnValue([]); + wrapper = createComponent({ props, mountFn: shallowMountExtended, stubs: {} }); + + expect(findMockSuggestionList().exists()).toBe(true); + expect(getMockSuggestionListSuggestions().length).toEqual(maxSuggestions); + }); }); describe('with preloaded suggestions', () => { diff --git a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js index 26a74036b10..e54e261b8e4 100644 --- a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js +++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js @@ -120,17 +120,26 @@ describe('vue_shared/component/markdown/markdown_editor', () => { }); }); - it.each` - desc | supportsQuickActions - ${'passes render_quick_actions param to renderMarkdownPath if quick actions are enabled'} | ${true} - ${'does not pass render_quick_actions param to renderMarkdownPath if quick actions are disabled'} | ${false} - `('$desc', async ({ supportsQuickActions }) => { - buildWrapper({ propsData: { supportsQuickActions } }); + // quarantine flaky spec:https://gitlab.com/gitlab-org/gitlab/-/issues/412618 + // eslint-disable-next-line jest/no-disabled-tests + it.skip('passes render_quick_actions param to renderMarkdownPath if quick actions are enabled', async () => { + buildWrapper({ propsData: { supportsQuickActions: true } }); + + await enableContentEditor(); + + expect(mock.history.post).toHaveLength(1); + expect(mock.history.post[0].url).toContain(`render_quick_actions=true`); + }); + + // quarantine flaky spec: https://gitlab.com/gitlab-org/gitlab/-/issues/411565 + // eslint-disable-next-line jest/no-disabled-tests + it.skip('does not pass render_quick_actions param to renderMarkdownPath if quick actions are disabled', async () => { + buildWrapper({ propsData: { supportsQuickActions: false } }); await enableContentEditor(); expect(mock.history.post).toHaveLength(1); - expect(mock.history.post[0].url).toContain(`render_quick_actions=${supportsQuickActions}`); + expect(mock.history.post[0].url).toContain(`render_quick_actions=false`); }); it('enables content editor switcher when contentEditorEnabled prop is true', () => { @@ -165,6 +174,20 @@ describe('vue_shared/component/markdown/markdown_editor', () => { }); }); + describe('when attachments are disabled', () => { + beforeEach(() => { + buildWrapper({ propsData: { disableAttachments: true } }); + }); + + it('disables canAttachFile', () => { + expect(findMarkdownField().props().canAttachFile).toBe(false); + }); + + it('passes `attach-file` to restrictedToolBarItems', () => { + expect(findMarkdownField().props().restrictedToolBarItems).toContain('attach-file'); + }); + }); + describe('disabled', () => { it('disables markdown field when disabled prop is true', () => { buildWrapper({ propsData: { disabled: true } }); @@ -178,7 +201,9 @@ describe('vue_shared/component/markdown/markdown_editor', () => { expect(findMarkdownField().find('textarea').attributes('disabled')).toBe(undefined); }); - it('disables content editor when disabled prop is true', async () => { + // quarantine flaky spec: https://gitlab.com/gitlab-org/gitlab/-/issues/404734 + // eslint-disable-next-line jest/no-disabled-tests + it.skip('disables content editor when disabled prop is true', async () => { buildWrapper({ propsData: { disabled: true } }); await enableContentEditor(); diff --git a/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js b/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js index 6f4902e3f96..e916336f21a 100644 --- a/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js +++ b/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js @@ -4,6 +4,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import MarkdownDrawer, { cache } from '~/vue_shared/components/markdown_drawer/markdown_drawer.vue'; import { getRenderedMarkdown } from '~/vue_shared/components/markdown_drawer/utils/fetch'; import { contentTop } from '~/lib/utils/common_utils'; +import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; jest.mock('~/vue_shared/components/markdown_drawer/utils/fetch', () => ({ getRenderedMarkdown: jest.fn().mockReturnValue({ @@ -55,6 +56,10 @@ describe('MarkdownDrawer', () => { expect(findDrawerTitle().text()).toBe('test title test'); expect(findDrawerBody().text()).toBe('test body'); }); + + it(`has proper z-index set for the drawer component`, () => { + expect(findDrawer().attributes('zindex')).toBe(DRAWER_Z_INDEX.toString()); + }); }); describe.each` diff --git a/spec/frontend/vue_shared/components/mr_more_dropdown_spec.js b/spec/frontend/vue_shared/components/mr_more_dropdown_spec.js new file mode 100644 index 00000000000..41639725f66 --- /dev/null +++ b/spec/frontend/vue_shared/components/mr_more_dropdown_spec.js @@ -0,0 +1,137 @@ +import { shallowMount } from '@vue/test-utils'; +import MRMoreActionsDropdown from '~/vue_shared/components/mr_more_dropdown.vue'; + +describe('MR More actions sidebar', () => { + let wrapper; + + const findNotificationToggle = () => wrapper.find('[data-testid="notification-toggle"]'); + const findEditMergeRequestOption = () => wrapper.find('[data-testid="edit-merge-request"]'); + const findMarkAsReadyAndDraftOption = () => + wrapper.find('[data-testid="ready-and-draft-action"]'); + const findCopyReferenceButton = () => wrapper.find('[data-testid="copy-reference"]'); + const findReopenMergeRequestOption = () => wrapper.find('[data-testid="reopen-merge-request"]'); + const findReportAbuseOption = () => wrapper.find('[data-testid="report-abuse-option"]'); + + const createComponent = ({ + movedMrSidebarFlag = false, + isCurrentUser = true, + isLoggedIn = true, + open = false, + canUpdateMergeRequest = false, + } = {}) => { + wrapper = shallowMount(MRMoreActionsDropdown, { + propsData: { + mr: { + iid: 1, + }, + isCurrentUser, + isLoggedIn, + open, + canUpdateMergeRequest, + }, + provide: { + glFeatures: { movedMrSidebar: movedMrSidebarFlag }, + }, + }); + }; + + describe('Notifications toggle', () => { + it.each` + movedMrSidebarFlag | isLoggedIn | showNotificationToggle + ${false} | ${false} | ${false} + ${false} | ${true} | ${false} + ${true} | ${false} | ${false} + ${true} | ${true} | ${true} + `( + "when the movedMrSidebar flag is '$movedMrSidebarFlag' and is isLoggedIn as '$isLoggedIn'", + ({ movedMrSidebarFlag, isLoggedIn, showNotificationToggle }) => { + createComponent({ + isLoggedIn, + movedMrSidebarFlag, + }); + + expect(findNotificationToggle().exists()).toBe(showNotificationToggle); + }, + ); + }); + + describe('Edit/Draft/Reopen MR', () => { + it('should not have the edit option when `canUpdateMergeRequest` is false', () => { + createComponent(); + + expect(findEditMergeRequestOption().exists()).toBe(false); + }); + + it('should have the edit option when `canUpdateMergeRequest` is true', () => { + createComponent({ + canUpdateMergeRequest: true, + }); + + expect(findEditMergeRequestOption().exists()).toBe(true); + }); + + it('should not have the ready and draft option when the the MR is open and `canUpdateMergeRequest` is false', () => { + createComponent({ + open: true, + canUpdateMergeRequest: false, + }); + + expect(findMarkAsReadyAndDraftOption().exists()).toBe(false); + }); + + it('should have the ready and draft option when the the MR is open and `canUpdateMergeRequest` is true', () => { + createComponent({ + open: true, + canUpdateMergeRequest: true, + }); + + expect(findMarkAsReadyAndDraftOption().exists()).toBe(true); + }); + + it('should have the reopen option when the the MR is closed and `canUpdateMergeRequest` is true', () => { + createComponent({ + open: false, + canUpdateMergeRequest: true, + }); + + expect(findReopenMergeRequestOption().exists()).toBe(true); + }); + + it('should not have the reopen option when the the MR is closed and `canUpdateMergeRequest` is false', () => { + createComponent({ + open: false, + canUpdateMergeRequest: false, + }); + + expect(findReopenMergeRequestOption().exists()).toBe(false); + }); + }); + + describe('Copy reference', () => { + it('should not be visible by default', () => { + createComponent(); + + expect(findCopyReferenceButton().exists()).toBe(false); + }); + + it('should be visible when the movedMrSidebarFlag is on', () => { + createComponent({ movedMrSidebarFlag: true }); + + expect(findCopyReferenceButton().exists()).toBe(true); + }); + }); + + describe('Report abuse action', () => { + it('should not have the option by default', () => { + createComponent(); + + expect(findReportAbuseOption().exists()).toBe(false); + }); + + it('should have the option when not the current user', () => { + createComponent({ isCurrentUser: false }); + + expect(findReportAbuseOption().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js index 9b6f5ae3e38..a27877e7ba8 100644 --- a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js +++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js @@ -94,16 +94,15 @@ describe('AlertManagementEmptyState', () => { const ItemsTable = () => wrapper.find('.gl-table'); const ErrorAlert = () => wrapper.findComponent(GlAlert); const Pagination = () => wrapper.findComponent(GlPagination); - const Tabs = () => wrapper.findComponent(GlTabs); const ActionButton = () => wrapper.find('.header-actions > button'); - const Filters = () => wrapper.findComponent(FilteredSearchBar); + const findFilteredSearchBar = () => wrapper.findComponent(FilteredSearchBar); const findPagination = () => wrapper.findComponent(GlPagination); const findStatusFilterTabs = () => wrapper.findAllComponents(GlTab); const findStatusTabs = () => wrapper.findComponent(GlTabs); const findStatusFilterBadge = () => wrapper.findAllComponents(GlBadge); const handleFilterItems = (filters) => { - Filters().vm.$emit('onFilter', filters); + findFilteredSearchBar().vm.$emit('onFilter', filters); return nextTick(); }; @@ -140,7 +139,7 @@ describe('AlertManagementEmptyState', () => { }, }); - expect(Tabs().exists()).toBe(true); + expect(findStatusTabs().exists()).toBe(true); }); it('renders the header action buttons if present', () => { @@ -176,7 +175,7 @@ describe('AlertManagementEmptyState', () => { props: { filterSearchTokens: [TOKEN_TYPE_ASSIGNEE] }, }); - expect(Filters().exists()).toBe(true); + expect(findFilteredSearchBar().exists()).toBe(true); }); }); @@ -291,8 +290,9 @@ describe('AlertManagementEmptyState', () => { }); it('renders the search component for incidents', () => { - expect(Filters().props('searchInputPlaceholder')).toBe('Search or filter results…'); - expect(Filters().props('tokens')).toEqual([ + const filteredSearchBar = findFilteredSearchBar(); + expect(filteredSearchBar.props('searchInputPlaceholder')).toBe('Search or filter results…'); + expect(filteredSearchBar.props('tokens')).toEqual([ { type: TOKEN_TYPE_AUTHOR, icon: 'user', @@ -316,14 +316,14 @@ describe('AlertManagementEmptyState', () => { fetchUsers: expect.any(Function), }, ]); - expect(Filters().props('recentSearchesStorageKey')).toBe('items'); + expect(filteredSearchBar.props('recentSearchesStorageKey')).toBe('items'); }); it('returns correctly applied filter search values', async () => { const searchTerm = 'foo'; await handleFilterItems([{ type: 'filtered-search-term', value: { data: searchTerm } }]); await nextTick(); - expect(Filters().props('initialFilterValue')).toEqual([searchTerm]); + expect(findFilteredSearchBar().props('initialFilterValue')).toEqual([searchTerm]); }); it('updates props tied to getIncidents GraphQL query', async () => { @@ -337,7 +337,7 @@ describe('AlertManagementEmptyState', () => { value: { data: assigneeUsername }, }, searchTerm, - ] = Filters().props('initialFilterValue'); + ] = findFilteredSearchBar().props('initialFilterValue'); expect(authorUsername).toBe('root'); expect(assigneeUsername).toEqual('root2'); @@ -346,7 +346,7 @@ describe('AlertManagementEmptyState', () => { it('updates props `searchTerm` and `authorUsername` with empty values when passed filters param is empty', async () => { await handleFilterItems([]); - expect(Filters().props('initialFilterValue')).toEqual([]); + expect(findFilteredSearchBar().props('initialFilterValue')).toEqual([]); }); }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/components/__snapshots__/chunk_spec.js.snap b/spec/frontend/vue_shared/components/source_viewer/components/__snapshots__/chunk_new_spec.js.snap index 26c9a6f8d5a..26c9a6f8d5a 100644 --- a/spec/frontend/vue_shared/components/source_viewer/components/__snapshots__/chunk_spec.js.snap +++ b/spec/frontend/vue_shared/components/source_viewer/components/__snapshots__/chunk_new_spec.js.snap diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_deprecated_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_deprecated_spec.js deleted file mode 100644 index 395ba92d4c6..00000000000 --- a/spec/frontend/vue_shared/components/source_viewer/components/chunk_deprecated_spec.js +++ /dev/null @@ -1,121 +0,0 @@ -import { nextTick } from 'vue'; -import { GlIntersectionObserver } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import Chunk from '~/vue_shared/components/source_viewer/components/chunk_deprecated.vue'; -import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue'; -import LineHighlighter from '~/blob/line_highlighter'; - -const lineHighlighter = new LineHighlighter(); -jest.mock('~/blob/line_highlighter', () => - jest.fn().mockReturnValue({ - highlightHash: jest.fn(), - }), -); - -const DEFAULT_PROPS = { - chunkIndex: 2, - isHighlighted: false, - content: '// Line 1 content \n // Line 2 content', - startingFrom: 140, - totalLines: 50, - language: 'javascript', - blamePath: 'blame/file.js', -}; - -const hash = '#L142'; - -describe('Chunk component', () => { - let wrapper; - let idleCallbackSpy; - - const createComponent = (props = {}) => { - wrapper = shallowMountExtended(Chunk, { - mocks: { $route: { hash } }, - propsData: { ...DEFAULT_PROPS, ...props }, - }); - }; - - const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); - const findChunkLines = () => wrapper.findAllComponents(ChunkLine); - const findLineNumbers = () => wrapper.findAllByTestId('line-number'); - const findContent = () => wrapper.findByTestId('content'); - - beforeEach(() => { - idleCallbackSpy = jest.spyOn(window, 'requestIdleCallback').mockImplementation((fn) => fn()); - createComponent(); - }); - - describe('Intersection observer', () => { - it('renders an Intersection observer component', () => { - expect(findIntersectionObserver().exists()).toBe(true); - }); - - it('emits an appear event when intersection-observer appears', () => { - findIntersectionObserver().vm.$emit('appear'); - - expect(wrapper.emitted('appear')).toEqual([[DEFAULT_PROPS.chunkIndex]]); - }); - - it('does not emit an appear event is isHighlighted is true', () => { - createComponent({ isHighlighted: true }); - findIntersectionObserver().vm.$emit('appear'); - - expect(wrapper.emitted('appear')).toEqual(undefined); - }); - }); - - describe('rendering', () => { - it('does not register window.requestIdleCallback if isFirstChunk prop is true, renders lines immediately', () => { - jest.clearAllMocks(); - createComponent({ isFirstChunk: true }); - - expect(window.requestIdleCallback).not.toHaveBeenCalled(); - expect(findContent().exists()).toBe(true); - }); - - it('does not render a Chunk Line component if isHighlighted is false', () => { - expect(findChunkLines().length).toBe(0); - }); - - it('does not render simplified line numbers and content if browser is not in idle state', () => { - idleCallbackSpy.mockRestore(); - createComponent(); - - expect(findLineNumbers()).toHaveLength(0); - expect(findContent().exists()).toBe(false); - }); - - it('renders simplified line numbers and content if isHighlighted is false', () => { - expect(findLineNumbers().length).toBe(DEFAULT_PROPS.totalLines); - - expect(findLineNumbers().at(0).attributes('id')).toBe(`L${DEFAULT_PROPS.startingFrom + 1}`); - - expect(findContent().text()).toBe(DEFAULT_PROPS.content); - }); - - it('renders Chunk Line components if isHighlighted is true', () => { - const splitContent = DEFAULT_PROPS.content.split('\n'); - createComponent({ isHighlighted: true }); - - expect(findChunkLines().length).toBe(splitContent.length); - - expect(findChunkLines().at(0).props()).toMatchObject({ - number: DEFAULT_PROPS.startingFrom + 1, - content: splitContent[0], - language: DEFAULT_PROPS.language, - blamePath: DEFAULT_PROPS.blamePath, - }); - }); - - it('does not scroll to route hash if last chunk is not loaded', () => { - expect(LineHighlighter).not.toHaveBeenCalled(); - }); - - it('scrolls to route hash if last chunk is loaded', async () => { - createComponent({ totalChunks: DEFAULT_PROPS.chunkIndex + 1 }); - await nextTick(); - expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' }); - expect(lineHighlighter.highlightHash).toHaveBeenCalledWith(hash); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_new_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_new_spec.js new file mode 100644 index 00000000000..919abc26e05 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_new_spec.js @@ -0,0 +1,84 @@ +import { nextTick } from 'vue'; +import { GlIntersectionObserver } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Chunk from '~/vue_shared/components/source_viewer/components/chunk_new.vue'; +import { CHUNK_1, CHUNK_2 } from '../mock_data'; + +describe('Chunk component', () => { + let wrapper; + let idleCallbackSpy; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(Chunk, { + propsData: { ...CHUNK_1, ...props }, + }); + }; + + const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); + const findLineNumbers = () => wrapper.findAllByTestId('line-numbers'); + const findContent = () => wrapper.findByTestId('content'); + + beforeEach(() => { + idleCallbackSpy = jest.spyOn(window, 'requestIdleCallback').mockImplementation((fn) => fn()); + createComponent(); + }); + + describe('Intersection observer', () => { + it('renders an Intersection observer component', () => { + expect(findIntersectionObserver().exists()).toBe(true); + }); + + it('renders highlighted content if appear event is emitted', async () => { + createComponent({ chunkIndex: 1, isHighlighted: false }); + findIntersectionObserver().vm.$emit('appear'); + + await nextTick(); + + expect(findContent().exists()).toBe(true); + }); + }); + + describe('rendering', () => { + it('does not register window.requestIdleCallback for the first chunk, renders content immediately', () => { + jest.clearAllMocks(); + + expect(window.requestIdleCallback).not.toHaveBeenCalled(); + expect(findContent().text()).toBe(CHUNK_1.highlightedContent); + }); + + it('does not render content if browser is not in idle state', () => { + idleCallbackSpy.mockRestore(); + createComponent({ chunkIndex: 1, ...CHUNK_2 }); + + expect(findLineNumbers()).toHaveLength(0); + expect(findContent().exists()).toBe(false); + }); + + describe('isHighlighted is false', () => { + beforeEach(() => createComponent(CHUNK_2)); + + it('does not render line numbers', () => { + expect(findLineNumbers()).toHaveLength(0); + }); + + it('renders raw content', () => { + expect(findContent().text()).toBe(CHUNK_2.rawContent); + }); + }); + + describe('isHighlighted is true', () => { + beforeEach(() => createComponent({ ...CHUNK_2, isHighlighted: true })); + + it('renders line numbers', () => { + expect(findLineNumbers()).toHaveLength(CHUNK_2.totalLines); + + // Opted for a snapshot test here since the output is simple and verifies native HTML elements + expect(findLineNumbers().at(0).element).toMatchSnapshot(); + }); + + it('renders highlighted content', () => { + expect(findContent().text()).toBe(CHUNK_2.highlightedContent); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js index ff50326917f..9e43aa1d707 100644 --- a/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js @@ -2,7 +2,27 @@ import { nextTick } from 'vue'; import { GlIntersectionObserver } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue'; -import { CHUNK_1, CHUNK_2 } from '../mock_data'; +import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue'; +import LineHighlighter from '~/blob/line_highlighter'; + +const lineHighlighter = new LineHighlighter(); +jest.mock('~/blob/line_highlighter', () => + jest.fn().mockReturnValue({ + highlightHash: jest.fn(), + }), +); + +const DEFAULT_PROPS = { + chunkIndex: 2, + isHighlighted: false, + content: '// Line 1 content \n // Line 2 content', + startingFrom: 140, + totalLines: 50, + language: 'javascript', + blamePath: 'blame/file.js', +}; + +const hash = '#L142'; describe('Chunk component', () => { let wrapper; @@ -10,12 +30,14 @@ describe('Chunk component', () => { const createComponent = (props = {}) => { wrapper = shallowMountExtended(Chunk, { - propsData: { ...CHUNK_1, ...props }, + mocks: { $route: { hash } }, + propsData: { ...DEFAULT_PROPS, ...props }, }); }; const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); - const findLineNumbers = () => wrapper.findAllByTestId('line-numbers'); + const findChunkLines = () => wrapper.findAllComponents(ChunkLine); + const findLineNumbers = () => wrapper.findAllByTestId('line-number'); const findContent = () => wrapper.findByTestId('content'); beforeEach(() => { @@ -28,57 +50,72 @@ describe('Chunk component', () => { expect(findIntersectionObserver().exists()).toBe(true); }); - it('renders highlighted content if appear event is emitted', async () => { - createComponent({ chunkIndex: 1, isHighlighted: false }); + it('emits an appear event when intersection-observer appears', () => { findIntersectionObserver().vm.$emit('appear'); - await nextTick(); + expect(wrapper.emitted('appear')).toEqual([[DEFAULT_PROPS.chunkIndex]]); + }); - expect(findContent().exists()).toBe(true); + it('does not emit an appear event is isHighlighted is true', () => { + createComponent({ isHighlighted: true }); + findIntersectionObserver().vm.$emit('appear'); + + expect(wrapper.emitted('appear')).toEqual(undefined); }); }); describe('rendering', () => { - it('does not register window.requestIdleCallback for the first chunk, renders content immediately', () => { + it('does not register window.requestIdleCallback if isFirstChunk prop is true, renders lines immediately', () => { jest.clearAllMocks(); + createComponent({ isFirstChunk: true }); expect(window.requestIdleCallback).not.toHaveBeenCalled(); - expect(findContent().text()).toBe(CHUNK_1.highlightedContent); + expect(findContent().exists()).toBe(true); + }); + + it('does not render a Chunk Line component if isHighlighted is false', () => { + expect(findChunkLines().length).toBe(0); }); - it('does not render content if browser is not in idle state', () => { + it('does not render simplified line numbers and content if browser is not in idle state', () => { idleCallbackSpy.mockRestore(); - createComponent({ chunkIndex: 1, ...CHUNK_2 }); + createComponent(); expect(findLineNumbers()).toHaveLength(0); expect(findContent().exists()).toBe(false); }); - describe('isHighlighted is false', () => { - beforeEach(() => createComponent(CHUNK_2)); + it('renders simplified line numbers and content if isHighlighted is false', () => { + expect(findLineNumbers().length).toBe(DEFAULT_PROPS.totalLines); - it('does not render line numbers', () => { - expect(findLineNumbers()).toHaveLength(0); - }); + expect(findLineNumbers().at(0).attributes('id')).toBe(`L${DEFAULT_PROPS.startingFrom + 1}`); - it('renders raw content', () => { - expect(findContent().text()).toBe(CHUNK_2.rawContent); - }); + expect(findContent().text()).toBe(DEFAULT_PROPS.content); }); - describe('isHighlighted is true', () => { - beforeEach(() => createComponent({ ...CHUNK_2, isHighlighted: true })); + it('renders Chunk Line components if isHighlighted is true', () => { + const splitContent = DEFAULT_PROPS.content.split('\n'); + createComponent({ isHighlighted: true }); - it('renders line numbers', () => { - expect(findLineNumbers()).toHaveLength(CHUNK_2.totalLines); + expect(findChunkLines().length).toBe(splitContent.length); - // Opted for a snapshot test here since the output is simple and verifies native HTML elements - expect(findLineNumbers().at(0).element).toMatchSnapshot(); + expect(findChunkLines().at(0).props()).toMatchObject({ + number: DEFAULT_PROPS.startingFrom + 1, + content: splitContent[0], + language: DEFAULT_PROPS.language, + blamePath: DEFAULT_PROPS.blamePath, }); + }); - it('renders highlighted content', () => { - expect(findContent().text()).toBe(CHUNK_2.highlightedContent); - }); + it('does not scroll to route hash if last chunk is not loaded', () => { + expect(LineHighlighter).not.toHaveBeenCalled(); + }); + + it('scrolls to route hash if last chunk is loaded', async () => { + createComponent({ totalChunks: DEFAULT_PROPS.chunkIndex + 1 }); + await nextTick(); + expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' }); + expect(lineHighlighter.highlightHash).toHaveBeenCalledWith(hash); }); }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js index 8d072c8c8de..9d2bf002d73 100644 --- a/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js @@ -6,9 +6,9 @@ describe('Highlight.js plugin for wrapping _emitter nodes', () => { _emitter: { rootNode: { children: [ - { kind: 'string', children: ['Text 1'] }, - { kind: 'string', children: ['Text 2', { kind: 'comment', children: ['Text 3'] }] }, - { kind: undefined, sublanguage: true, children: ['Text 3 (sublanguage)'] }, + { scope: 'string', children: ['Text 1'] }, + { scope: 'string', children: ['Text 2', { scope: 'comment', children: ['Text 3'] }] }, + { scope: undefined, sublanguage: true, children: ['Text 3 (sublanguage)'] }, 'Text4\nText5', ], }, diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_deprecated_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_deprecated_spec.js deleted file mode 100644 index 8419a0c5ddf..00000000000 --- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_deprecated_spec.js +++ /dev/null @@ -1,178 +0,0 @@ -import hljs from 'highlight.js/lib/core'; -import Vue from 'vue'; -import VueRouter from 'vue-router'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer_deprecated.vue'; -import { registerPlugins } from '~/vue_shared/components/source_viewer/plugins/index'; -import Chunk from '~/vue_shared/components/source_viewer/components/chunk_deprecated.vue'; -import { - EVENT_ACTION, - EVENT_LABEL_VIEWER, - EVENT_LABEL_FALLBACK, - ROUGE_TO_HLJS_LANGUAGE_MAP, - LINES_PER_CHUNK, - LEGACY_FALLBACKS, -} from '~/vue_shared/components/source_viewer/constants'; -import waitForPromises from 'helpers/wait_for_promises'; -import LineHighlighter from '~/blob/line_highlighter'; -import eventHub from '~/notes/event_hub'; -import Tracking from '~/tracking'; - -jest.mock('~/blob/line_highlighter'); -jest.mock('highlight.js/lib/core'); -jest.mock('~/vue_shared/components/source_viewer/plugins/index'); -Vue.use(VueRouter); -const router = new VueRouter(); - -const generateContent = (content, totalLines = 1, delimiter = '\n') => { - let generatedContent = ''; - for (let i = 0; i < totalLines; i += 1) { - generatedContent += `Line: ${i + 1} = ${content}${delimiter}`; - } - return generatedContent; -}; - -const execImmediately = (callback) => callback(); - -describe('Source Viewer component', () => { - let wrapper; - const language = 'docker'; - const mappedLanguage = ROUGE_TO_HLJS_LANGUAGE_MAP[language]; - const chunk1 = generateContent('// Some source code 1', 70); - const chunk2 = generateContent('// Some source code 2', 70); - const chunk3 = generateContent('// Some source code 3', 70, '\r\n'); - const chunk3Result = generateContent('// Some source code 3', 70, '\n'); - const content = chunk1 + chunk2 + chunk3; - const path = 'some/path.js'; - const blamePath = 'some/blame/path.js'; - const fileType = 'javascript'; - const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path, blamePath, fileType }; - const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`; - - const createComponent = async (blob = {}) => { - wrapper = shallowMountExtended(SourceViewer, { - router, - propsData: { blob: { ...DEFAULT_BLOB_DATA, ...blob } }, - }); - await waitForPromises(); - }; - - const findChunks = () => wrapper.findAllComponents(Chunk); - - beforeEach(() => { - hljs.highlight.mockImplementation(() => ({ value: highlightedContent })); - hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent })); - jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); - jest.spyOn(eventHub, '$emit'); - jest.spyOn(Tracking, 'event'); - - return createComponent(); - }); - - describe('event tracking', () => { - it('fires a tracking event when the component is created', () => { - const eventData = { label: EVENT_LABEL_VIEWER, property: language }; - expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); - }); - - it('does not emit an error event when the language is supported', () => { - expect(wrapper.emitted('error')).toBeUndefined(); - }); - - it('fires a tracking event and emits an error when the language is not supported', () => { - const unsupportedLanguage = 'apex'; - const eventData = { label: EVENT_LABEL_FALLBACK, property: unsupportedLanguage }; - createComponent({ language: unsupportedLanguage }); - - expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); - expect(wrapper.emitted('error')).toHaveLength(1); - }); - }); - - describe('legacy fallbacks', () => { - it.each(LEGACY_FALLBACKS)( - 'tracks a fallback event and emits an error when viewing %s files', - (fallbackLanguage) => { - const eventData = { label: EVENT_LABEL_FALLBACK, property: fallbackLanguage }; - createComponent({ language: fallbackLanguage }); - - expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); - expect(wrapper.emitted('error')).toHaveLength(1); - }, - ); - }); - - describe('highlight.js', () => { - beforeEach(() => createComponent({ language: mappedLanguage })); - - it('registers our plugins for Highlight.js', () => { - expect(registerPlugins).toHaveBeenCalledWith(hljs, fileType, content); - }); - - it('registers the language definition', async () => { - const languageDefinition = await import(`highlight.js/lib/languages/${mappedLanguage}`); - - expect(hljs.registerLanguage).toHaveBeenCalledWith( - mappedLanguage, - languageDefinition.default, - ); - }); - - it('registers json language definition if fileType is package_json', async () => { - await createComponent({ language: 'json', fileType: 'package_json' }); - const languageDefinition = await import(`highlight.js/lib/languages/json`); - - expect(hljs.registerLanguage).toHaveBeenCalledWith('json', languageDefinition.default); - }); - - it('correctly maps languages starting with uppercase', async () => { - await createComponent({ language: 'Ruby' }); - const languageDefinition = await import(`highlight.js/lib/languages/ruby`); - - expect(hljs.registerLanguage).toHaveBeenCalledWith('ruby', languageDefinition.default); - }); - - it('highlights the first chunk', () => { - expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage }); - expect(findChunks().at(0).props('isFirstChunk')).toBe(true); - }); - - describe('auto-detects if a language cannot be loaded', () => { - beforeEach(() => createComponent({ language: 'some_unknown_language' })); - - it('highlights the content with auto-detection', () => { - expect(hljs.highlightAuto).toHaveBeenCalledWith(chunk1.trim()); - }); - }); - }); - - describe('rendering', () => { - it.each` - chunkIndex | chunkContent | totalChunks - ${0} | ${chunk1} | ${0} - ${1} | ${chunk2} | ${3} - ${2} | ${chunk3Result} | ${3} - `('renders chunk $chunkIndex', ({ chunkIndex, chunkContent, totalChunks }) => { - const chunk = findChunks().at(chunkIndex); - - expect(chunk.props('content')).toContain(chunkContent.trim()); - - expect(chunk.props()).toMatchObject({ - totalLines: LINES_PER_CHUNK, - startingFrom: LINES_PER_CHUNK * chunkIndex, - totalChunks, - }); - }); - - it('emits showBlobInteractionZones on the eventHub when chunk appears', () => { - findChunks().at(0).vm.$emit('appear'); - expect(eventHub.$emit).toHaveBeenCalledWith('showBlobInteractionZones', path); - }); - }); - - describe('LineHighlighter', () => { - it('instantiates the lineHighlighter class', () => { - expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js new file mode 100644 index 00000000000..715234e56fd --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js @@ -0,0 +1,45 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer_new.vue'; +import Chunk from '~/vue_shared/components/source_viewer/components/chunk_new.vue'; +import { EVENT_ACTION, EVENT_LABEL_VIEWER } from '~/vue_shared/components/source_viewer/constants'; +import Tracking from '~/tracking'; +import addBlobLinksTracking from '~/blob/blob_links_tracking'; +import { BLOB_DATA_MOCK, CHUNK_1, CHUNK_2, LANGUAGE_MOCK } from './mock_data'; + +jest.mock('~/blob/blob_links_tracking'); + +describe('Source Viewer component', () => { + let wrapper; + const CHUNKS_MOCK = [CHUNK_1, CHUNK_2]; + + const createComponent = () => { + wrapper = shallowMountExtended(SourceViewer, { + propsData: { blob: BLOB_DATA_MOCK, chunks: CHUNKS_MOCK }, + }); + }; + + const findChunks = () => wrapper.findAllComponents(Chunk); + + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + return createComponent(); + }); + + describe('event tracking', () => { + it('fires a tracking event when the component is created', () => { + const eventData = { label: EVENT_LABEL_VIEWER, property: LANGUAGE_MOCK }; + expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); + }); + + it('adds blob links tracking', () => { + expect(addBlobLinksTracking).toHaveBeenCalled(); + }); + }); + + describe('rendering', () => { + it('renders a Chunk component for each chunk', () => { + expect(findChunks().at(0).props()).toMatchObject(CHUNK_1); + expect(findChunks().at(1).props()).toMatchObject(CHUNK_2); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js index 46b582c3668..6b1d65c5a6a 100644 --- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js @@ -1,45 +1,192 @@ +import hljs from 'highlight.js/lib/core'; +import Vue from 'vue'; +import VueRouter from 'vue-router'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue'; +import { registerPlugins } from '~/vue_shared/components/source_viewer/plugins/index'; import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue'; -import { EVENT_ACTION, EVENT_LABEL_VIEWER } from '~/vue_shared/components/source_viewer/constants'; +import { + EVENT_ACTION, + EVENT_LABEL_VIEWER, + EVENT_LABEL_FALLBACK, + ROUGE_TO_HLJS_LANGUAGE_MAP, + LINES_PER_CHUNK, + LEGACY_FALLBACKS, + CODEOWNERS_FILE_NAME, + CODEOWNERS_LANGUAGE, +} from '~/vue_shared/components/source_viewer/constants'; +import waitForPromises from 'helpers/wait_for_promises'; +import LineHighlighter from '~/blob/line_highlighter'; +import eventHub from '~/notes/event_hub'; import Tracking from '~/tracking'; -import addBlobLinksTracking from '~/blob/blob_links_tracking'; -import { BLOB_DATA_MOCK, CHUNK_1, CHUNK_2, LANGUAGE_MOCK } from './mock_data'; -jest.mock('~/blob/blob_links_tracking'); +jest.mock('~/blob/line_highlighter'); +jest.mock('highlight.js/lib/core'); +jest.mock('~/vue_shared/components/source_viewer/plugins/index'); +Vue.use(VueRouter); +const router = new VueRouter(); + +const generateContent = (content, totalLines = 1, delimiter = '\n') => { + let generatedContent = ''; + for (let i = 0; i < totalLines; i += 1) { + generatedContent += `Line: ${i + 1} = ${content}${delimiter}`; + } + return generatedContent; +}; + +const execImmediately = (callback) => callback(); describe('Source Viewer component', () => { let wrapper; - const CHUNKS_MOCK = [CHUNK_1, CHUNK_2]; + const language = 'docker'; + const mappedLanguage = ROUGE_TO_HLJS_LANGUAGE_MAP[language]; + const chunk1 = generateContent('// Some source code 1', 70); + const chunk2 = generateContent('// Some source code 2', 70); + const chunk3 = generateContent('// Some source code 3', 70, '\r\n'); + const chunk3Result = generateContent('// Some source code 3', 70, '\n'); + const content = chunk1 + chunk2 + chunk3; + const path = 'some/path.js'; + const blamePath = 'some/blame/path.js'; + const fileType = 'javascript'; + const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path, blamePath, fileType }; + const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`; - const createComponent = () => { + const createComponent = async (blob = {}) => { wrapper = shallowMountExtended(SourceViewer, { - propsData: { blob: BLOB_DATA_MOCK, chunks: CHUNKS_MOCK }, + router, + propsData: { blob: { ...DEFAULT_BLOB_DATA, ...blob } }, }); + await waitForPromises(); }; const findChunks = () => wrapper.findAllComponents(Chunk); beforeEach(() => { + hljs.highlight.mockImplementation(() => ({ value: highlightedContent })); + hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent })); + jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); + jest.spyOn(eventHub, '$emit'); jest.spyOn(Tracking, 'event'); + return createComponent(); }); describe('event tracking', () => { it('fires a tracking event when the component is created', () => { - const eventData = { label: EVENT_LABEL_VIEWER, property: LANGUAGE_MOCK }; + const eventData = { label: EVENT_LABEL_VIEWER, property: language }; + expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); + }); + + it('does not emit an error event when the language is supported', () => { + expect(wrapper.emitted('error')).toBeUndefined(); + }); + + it('fires a tracking event and emits an error when the language is not supported', () => { + const unsupportedLanguage = 'apex'; + const eventData = { label: EVENT_LABEL_FALLBACK, property: unsupportedLanguage }; + createComponent({ language: unsupportedLanguage }); + expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); + expect(wrapper.emitted('error')).toHaveLength(1); + }); + }); + + describe('legacy fallbacks', () => { + it.each(LEGACY_FALLBACKS)( + 'tracks a fallback event and emits an error when viewing %s files', + (fallbackLanguage) => { + const eventData = { label: EVENT_LABEL_FALLBACK, property: fallbackLanguage }; + createComponent({ language: fallbackLanguage }); + + expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); + expect(wrapper.emitted('error')).toHaveLength(1); + }, + ); + }); + + describe('highlight.js', () => { + beforeEach(() => createComponent({ language: mappedLanguage })); + + it('registers our plugins for Highlight.js', () => { + expect(registerPlugins).toHaveBeenCalledWith(hljs, fileType, content); + }); + + it('registers the language definition', async () => { + const languageDefinition = await import(`highlight.js/lib/languages/${mappedLanguage}`); + + expect(hljs.registerLanguage).toHaveBeenCalledWith( + mappedLanguage, + languageDefinition.default, + ); }); - it('adds blob links tracking', () => { - expect(addBlobLinksTracking).toHaveBeenCalled(); + it('registers json language definition if fileType is package_json', async () => { + await createComponent({ language: 'json', fileType: 'package_json' }); + const languageDefinition = await import(`highlight.js/lib/languages/json`); + + expect(hljs.registerLanguage).toHaveBeenCalledWith('json', languageDefinition.default); + }); + + it('correctly maps languages starting with uppercase', async () => { + await createComponent({ language: 'Ruby' }); + const languageDefinition = await import(`highlight.js/lib/languages/ruby`); + + expect(hljs.registerLanguage).toHaveBeenCalledWith('ruby', languageDefinition.default); + }); + + it('registers codeowners language definition if file name is CODEOWNERS', async () => { + await createComponent({ name: CODEOWNERS_FILE_NAME }); + const languageDefinition = await import( + '~/vue_shared/components/source_viewer/languages/codeowners' + ); + + expect(hljs.registerLanguage).toHaveBeenCalledWith( + CODEOWNERS_LANGUAGE, + languageDefinition.default, + ); + }); + + it('highlights the first chunk', () => { + expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage }); + expect(findChunks().at(0).props('isFirstChunk')).toBe(true); + }); + + describe('auto-detects if a language cannot be loaded', () => { + beforeEach(() => createComponent({ language: 'some_unknown_language' })); + + it('highlights the content with auto-detection', () => { + expect(hljs.highlightAuto).toHaveBeenCalledWith(chunk1.trim()); + }); }); }); describe('rendering', () => { - it('renders a Chunk component for each chunk', () => { - expect(findChunks().at(0).props()).toMatchObject(CHUNK_1); - expect(findChunks().at(1).props()).toMatchObject(CHUNK_2); + it.each` + chunkIndex | chunkContent | totalChunks + ${0} | ${chunk1} | ${0} + ${1} | ${chunk2} | ${3} + ${2} | ${chunk3Result} | ${3} + `('renders chunk $chunkIndex', ({ chunkIndex, chunkContent, totalChunks }) => { + const chunk = findChunks().at(chunkIndex); + + expect(chunk.props('content')).toContain(chunkContent.trim()); + + expect(chunk.props()).toMatchObject({ + totalLines: LINES_PER_CHUNK, + startingFrom: LINES_PER_CHUNK * chunkIndex, + totalChunks, + }); + }); + + it('emits showBlobInteractionZones on the eventHub when chunk appears', () => { + findChunks().at(0).vm.$emit('appear'); + expect(eventHub.$emit).toHaveBeenCalledWith('showBlobInteractionZones', path); + }); + }); + + describe('LineHighlighter', () => { + it('instantiates the lineHighlighter class', () => { + expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' }); }); }); }); diff --git a/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js b/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js index d8dedd8240b..ecf6a776a4b 100644 --- a/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlDropdownItem, GlDropdown, GlSearchBoxByType } from '@gitlab/ui'; +import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue'; @@ -9,7 +9,8 @@ describe('Deploy freeze timezone dropdown', () => { let wrapper; let store; - const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); + const findSearchBox = () => wrapper.findByTestId('listbox-search-input'); const createComponent = async (searchTerm, selectedTimezone) => { wrapper = shallowMountExtended(TimezoneDropdown, { @@ -19,15 +20,18 @@ describe('Deploy freeze timezone dropdown', () => { timezoneData: timezoneDataFixture, name: 'user[timezone]', }, + stubs: { + GlCollapsibleListbox, + }, }); findSearchBox().vm.$emit('input', searchTerm); await nextTick(); }; - const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findDropdownItemByIndex = (index) => wrapper.findAllComponents(GlDropdownItem).at(index); - const findEmptyResultsItem = () => wrapper.findByTestId('noMatchingResults'); + const findAllDropdownItems = () => wrapper.findAllComponents(GlListboxItem); + const findDropdownItemByIndex = (index) => findAllDropdownItems().at(index); + const findEmptyResultsItem = () => wrapper.findByTestId('listbox-no-results-text'); const findHiddenInput = () => wrapper.find('input'); describe('No time zones found', () => { @@ -36,7 +40,8 @@ describe('Deploy freeze timezone dropdown', () => { }); it('renders empty results message', () => { - expect(findDropdownItemByIndex(0).text()).toBe('No matching results'); + expect(findEmptyResultsItem().exists()).toBe(true); + expect(findEmptyResultsItem().text()).toBe('No matching results'); }); }); @@ -69,11 +74,13 @@ describe('Deploy freeze timezone dropdown', () => { const selectedTz = findTzByName('Alaska'); it('should emit input if a time zone is clicked', () => { - findDropdownItemByIndex(0).vm.$emit('click'); + const payload = formatTimezone(selectedTz); + + findDropdown().vm.$emit('select', payload); expect(wrapper.emitted('input')).toEqual([ [ { - formattedTimezone: formatTimezone(selectedTz), + formattedTimezone: payload, identifier: selectedTz.identifier, }, ], @@ -88,7 +95,7 @@ describe('Deploy freeze timezone dropdown', () => { }); it('renders empty selections', () => { - expect(wrapper.findComponent(GlDropdown).props().text).toBe('Select timezone'); + expect(findDropdown().props('toggleText')).toBe('Select timezone'); }); it('preserves initial value in the associated input', () => { @@ -102,14 +109,14 @@ describe('Deploy freeze timezone dropdown', () => { }); it('renders selected time zone as dropdown label', () => { - expect(wrapper.findComponent(GlDropdown).props().text).toBe('[UTC+2] Berlin'); + expect(findDropdown().props('toggleText')).toBe('[UTC+2] Berlin'); }); it('adds a checkmark to the selected option', async () => { - const selectedTZOption = findAllDropdownItems().at(0); - selectedTZOption.vm.$emit('click'); + findDropdown().vm.$emit('select', formatTimezone(findTzByName('Abu Dhabi'))); await nextTick(); - expect(selectedTZOption.attributes('ischecked')).toBe('true'); + + expect(findDropdownItemByIndex(0).props('isSelected')).toBe(true); }); }); }); diff --git a/spec/frontend/vue_shared/components/truncated_text/truncated_text_spec.js b/spec/frontend/vue_shared/components/truncated_text/truncated_text_spec.js deleted file mode 100644 index 76467c185db..00000000000 --- a/spec/frontend/vue_shared/components/truncated_text/truncated_text_spec.js +++ /dev/null @@ -1,113 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { __ } from '~/locale'; -import TruncatedText from '~/vue_shared/components/truncated_text/truncated_text.vue'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; - -describe('TruncatedText', () => { - let wrapper; - - const findContent = () => wrapper.findComponent({ ref: 'content' }).element; - const findButton = () => wrapper.findComponent(GlButton); - - const createComponent = (propsData = {}) => { - wrapper = shallowMount(TruncatedText, { - propsData, - directives: { - GlResizeObserver: createMockDirective('gl-resize-observer'), - }, - stubs: { - GlButton, - }, - }); - }; - - beforeEach(() => { - createComponent(); - }); - - describe('when mounted', () => { - it('the content has class `gl-truncate-text-by-line`', () => { - expect(findContent().classList).toContain('gl-truncate-text-by-line'); - }); - - it('the content has style variables for `lines` and `mobile-lines` with the correct values', () => { - const { style } = findContent(); - - expect(style).toContain('--lines'); - expect(style.getPropertyValue('--lines')).toBe('3'); - expect(style).toContain('--mobile-lines'); - expect(style.getPropertyValue('--mobile-lines')).toBe('10'); - }); - - it('the button is not visible', () => { - expect(findButton().exists()).toBe(false); - }); - }); - - describe('when mounted with a value for the lines property', () => { - const lines = 4; - - beforeEach(() => { - createComponent({ lines }); - }); - - it('the lines variable has the value of the passed property', () => { - expect(findContent().style.getPropertyValue('--lines')).toBe(lines.toString()); - }); - }); - - describe('when mounted with a value for the mobileLines property', () => { - const mobileLines = 4; - - beforeEach(() => { - createComponent({ mobileLines }); - }); - - it('the lines variable has the value of the passed property', () => { - expect(findContent().style.getPropertyValue('--mobile-lines')).toBe(mobileLines.toString()); - }); - }); - - describe('when resizing and the scroll height is smaller than the offset height', () => { - beforeEach(() => { - getBinding(findContent(), 'gl-resize-observer').value({ - target: { scrollHeight: 10, offsetHeight: 20 }, - }); - }); - - it('the button remains invisible', () => { - expect(findButton().exists()).toBe(false); - }); - }); - - describe('when resizing and the scroll height is greater than the offset height', () => { - beforeEach(() => { - getBinding(findContent(), 'gl-resize-observer').value({ - target: { scrollHeight: 20, offsetHeight: 10 }, - }); - }); - - it('the button becomes visible', () => { - expect(findButton().exists()).toBe(true); - }); - - it('the button text says "show more"', () => { - expect(findButton().text()).toBe(__('Show more')); - }); - - describe('clicking the button', () => { - beforeEach(() => { - findButton().trigger('click'); - }); - - it('removes the `gl-truncate-text-by-line` class on the content', () => { - expect(findContent().classList).not.toContain('gl-truncate-text-by-line'); - }); - - it('toggles the button text to "Show less"', () => { - expect(findButton().text()).toBe(__('Show less')); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js index d888abc19ef..e54de25dc0d 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -1,21 +1,18 @@ -import { GlButton, GlModal } from '@gitlab/ui'; -import { nextTick } from 'vue'; +import { GlModal } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import getWritableForksResponse from 'test_fixtures/graphql/vue_shared/components/web_ide/get_writable_forks.query.graphql_none.json'; import ActionsButton from '~/vue_shared/components/actions_button.vue'; -import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import WebIdeLink, { - i18n, - PREFERRED_EDITOR_RESET_KEY, - PREFERRED_EDITOR_KEY, -} from '~/vue_shared/components/web_ide_link.vue'; -import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue'; -import { KEY_WEB_IDE } from '~/vue_shared/components/constants'; +import WebIdeLink, { i18n } from '~/vue_shared/components/web_ide_link.vue'; +import ConfirmForkModal from '~/vue_shared/components/web_ide/confirm_fork_modal.vue'; import { stubComponent } from 'helpers/stub_component'; import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; -import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { visitUrl } from '~/lib/utils/url_utility'; +import getWritableForksQuery from '~/vue_shared/components/web_ide/get_writable_forks.query.graphql'; jest.mock('~/lib/utils/url_utility'); @@ -30,9 +27,8 @@ const forkPath = '/some/fork/path'; const ACTION_EDIT = { href: TEST_EDIT_URL, key: 'edit', - text: 'Edit', + text: 'Edit single file', secondaryText: 'Edit this file only.', - tooltip: '', attrs: { 'data-qa-selector': 'edit_button', 'data-track-action': 'click_consolidated_edit', @@ -45,10 +41,8 @@ const ACTION_EDIT_CONFIRM_FORK = { handle: expect.any(Function), }; const ACTION_WEB_IDE = { - href: TEST_WEB_IDE_URL, key: 'webide', secondaryText: i18n.webIdeText, - tooltip: i18n.webIdeTooltip, text: 'Web IDE', attrs: { 'data-qa-selector': 'web_ide_button', @@ -59,7 +53,6 @@ const ACTION_WEB_IDE = { }; const ACTION_WEB_IDE_CONFIRM_FORK = { ...ACTION_WEB_IDE, - href: '#modal-confirm-fork-webide', handle: expect.any(Function), }; const ACTION_WEB_IDE_EDIT_FORK = { ...ACTION_WEB_IDE, text: 'Edit fork in Web IDE' }; @@ -67,7 +60,6 @@ const ACTION_GITPOD = { href: TEST_GITPOD_URL, key: 'gitpod', secondaryText: 'Launch a ready-to-code development environment for your project.', - tooltip: 'Launch a ready-to-code development environment for your project.', text: 'Gitpod', attrs: { 'data-qa-selector': 'gitpod_button', @@ -82,19 +74,21 @@ const ACTION_PIPELINE_EDITOR = { href: TEST_PIPELINE_EDITOR_URL, key: 'pipeline_editor', secondaryText: 'Edit, lint, and visualize your pipeline.', - tooltip: 'Edit, lint, and visualize your pipeline.', text: 'Edit in pipeline editor', attrs: { 'data-qa-selector': 'pipeline_editor_button', }, }; -describe('Web IDE link component', () => { - useLocalStorageSpy(); +describe('vue_shared/components/web_ide_link', () => { + Vue.use(VueApollo); let wrapper; function createComponent(props, { mountFn = shallowMountExtended, glFeatures = {} } = {}) { + const fakeApollo = createMockApollo([ + [getWritableForksQuery, jest.fn().mockResolvedValue(getWritableForksResponse)], + ]); wrapper = mountFn(WebIdeLink, { propsData: { editUrl: TEST_EDIT_URL, @@ -117,15 +111,11 @@ describe('Web IDE link component', () => { </div>`, }), }, + apolloProvider: fakeApollo, }); } - beforeEach(() => { - localStorage.setItem(PREFERRED_EDITOR_RESET_KEY, 'true'); - }); - const findActionsButton = () => wrapper.findComponent(ActionsButton); - const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); const findModal = () => wrapper.findComponent(GlModal); const findForkConfirmModal = () => wrapper.findComponent(ConfirmForkModal); @@ -238,64 +228,16 @@ describe('Web IDE link component', () => { }); }); - it('selected Pipeline Editor by default', () => { + it('displays Pipeline Editor as the first action', () => { expect(findActionsButton().props()).toMatchObject({ actions: [ACTION_PIPELINE_EDITOR, ACTION_WEB_IDE, ACTION_GITPOD], - selectedKey: ACTION_PIPELINE_EDITOR.key, }); }); it('when web ide button is clicked it opens in a new tab', async () => { - findActionsButton().props('actions')[1].handle({ - preventDefault: jest.fn(), - }); + findActionsButton().props('actions')[1].handle(); await nextTick(); - expect(visitUrl).toHaveBeenCalledWith(ACTION_WEB_IDE.href, true); - }); - }); - - describe('with multiple actions', () => { - beforeEach(() => { - createComponent({ - showEditButton: false, - showWebIdeButton: true, - showGitpodButton: true, - showPipelineEditorButton: false, - userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH, - userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, - gitpodEnabled: true, - }); - }); - - it('selected Web IDE by default', () => { - expect(findActionsButton().props()).toMatchObject({ - actions: [ACTION_WEB_IDE, ACTION_GITPOD], - selectedKey: ACTION_WEB_IDE.key, - }); - }); - - it('should set selection with local storage value', async () => { - expect(findActionsButton().props('selectedKey')).toBe(ACTION_WEB_IDE.key); - - findLocalStorageSync().vm.$emit('input', ACTION_GITPOD.key); - - await nextTick(); - - expect(findActionsButton().props('selectedKey')).toBe(ACTION_GITPOD.key); - }); - - it('should update local storage when selection changes', async () => { - expect(findLocalStorageSync().props()).toMatchObject({ - asString: true, - value: ACTION_WEB_IDE.key, - }); - - findActionsButton().vm.$emit('select', ACTION_GITPOD.key); - - await nextTick(); - - expect(findActionsButton().props('selectedKey')).toBe(ACTION_GITPOD.key); - expect(findLocalStorageSync().props('value')).toBe(ACTION_GITPOD.key); + expect(visitUrl).toHaveBeenCalledWith(TEST_WEB_IDE_URL, true); }); }); @@ -348,7 +290,10 @@ describe('Web IDE link component', () => { it.each(testActions)('opens the modal when the button is clicked', async ({ props }) => { createComponent({ ...props, needsToFork: true }, { mountFn: mountExtended }); - await findActionsButton().findComponent(GlButton).trigger('click'); + wrapper.findComponent(ActionsButton).props().actions[0].handle(); + + await nextTick(); + await wrapper.findByRole('button', { name: /Web IDE|Edit/im }).trigger('click'); expect(findForkConfirmModal().props()).toEqual({ visible: true, @@ -404,10 +349,8 @@ describe('Web IDE link component', () => { { mountFn: mountExtended }, ); - findLocalStorageSync().vm.$emit('input', ACTION_GITPOD.key); - await nextTick(); - await wrapper.findByRole('button', { name: gitpodText }).trigger('click'); + await wrapper.findByRole('button', { name: new RegExp(gitpodText, 'm') }).trigger('click'); expect(findModal().props('visible')).toBe(true); }); @@ -425,58 +368,4 @@ describe('Web IDE link component', () => { expect(findModal().exists()).toBe(false); }); }); - - describe('when vscode_web_ide feature flag is enabled', () => { - describe('when is not showing edit button', () => { - describe(`when ${PREFERRED_EDITOR_RESET_KEY} is unset`, () => { - beforeEach(() => { - localStorage.setItem.mockReset(); - localStorage.getItem.mockReturnValueOnce(null); - createComponent({ showEditButton: false }, { glFeatures: { vscodeWebIde: true } }); - }); - - it(`sets ${PREFERRED_EDITOR_KEY} local storage key to ${KEY_WEB_IDE}`, () => { - expect(localStorage.getItem).toHaveBeenCalledWith(PREFERRED_EDITOR_RESET_KEY); - expect(localStorage.setItem).toHaveBeenCalledWith(PREFERRED_EDITOR_KEY, KEY_WEB_IDE); - }); - - it(`sets ${PREFERRED_EDITOR_RESET_KEY} local storage key to true`, () => { - expect(localStorage.setItem).toHaveBeenCalledWith(PREFERRED_EDITOR_RESET_KEY, true); - }); - - it(`selects ${KEY_WEB_IDE} as the preferred editor`, () => { - expect(findActionsButton().props().selectedKey).toBe(KEY_WEB_IDE); - }); - }); - - describe(`when ${PREFERRED_EDITOR_RESET_KEY} is set to true`, () => { - beforeEach(() => { - localStorage.setItem.mockReset(); - localStorage.getItem.mockReturnValueOnce('true'); - createComponent({ showEditButton: false }, { glFeatures: { vscodeWebIde: true } }); - }); - - it(`does not update the persisted preferred editor`, () => { - expect(localStorage.getItem).toHaveBeenCalledWith(PREFERRED_EDITOR_RESET_KEY); - expect(localStorage.setItem).not.toHaveBeenCalledWith(PREFERRED_EDITOR_RESET_KEY); - }); - }); - }); - - describe('when is showing the edit button', () => { - it(`does not try to reset the ${PREFERRED_EDITOR_KEY}`, () => { - createComponent({ showEditButton: true }, { glFeatures: { vscodeWebIde: true } }); - - expect(localStorage.getItem).not.toHaveBeenCalledWith(PREFERRED_EDITOR_RESET_KEY); - }); - }); - }); - - describe('when vscode_web_ide feature flag is disabled', () => { - it(`does not try to reset the ${PREFERRED_EDITOR_KEY}`, () => { - createComponent({}, { glFeatures: { vscodeWebIde: false } }); - - expect(localStorage.getItem).not.toHaveBeenCalledWith(PREFERRED_EDITOR_RESET_KEY); - }); - }); }); diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js index ec975dfdcb5..68904603f40 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js @@ -7,6 +7,7 @@ import { TEST_HOST } from 'helpers/test_constants'; import IssuableItem from '~/vue_shared/issuable/list/components/issuable_item.vue'; import IssuableListRoot from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; +import issuableGrid from '~/vue_shared/issuable/list/components/issuable_grid.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 PageSizeSelector from '~/vue_shared/components/page_size_selector.vue'; @@ -43,6 +44,7 @@ describe('IssuableListRoot', () => { const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination); const findGlPagination = () => wrapper.findComponent(GlPagination); const findIssuableItem = () => wrapper.findComponent(IssuableItem); + const findIssuableGrid = () => wrapper.findComponent(issuableGrid); const findIssuableTabs = () => wrapper.findComponent(IssuableTabs); const findVueDraggable = () => wrapper.findComponent(VueDraggable); const findPageSizeSelector = () => wrapper.findComponent(PageSizeSelector); @@ -514,4 +516,18 @@ describe('IssuableListRoot', () => { expect(wrapper.emitted('page-size-change')).toEqual([[pageSize]]); }); }); + + describe('grid view issue', () => { + beforeEach(() => { + wrapper = createComponent({ + props: { + isGridView: true, + }, + }); + }); + + it('renders issuableGrid', () => { + expect(findIssuableGrid().exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js b/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js index b87ae8a232f..abc69da7a58 100644 --- a/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js +++ b/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js @@ -4,6 +4,7 @@ import { nextTick } from 'vue'; import LegacyContainer from '~/vue_shared/new_namespace/components/legacy_container.vue'; import WelcomePage from '~/vue_shared/new_namespace/components/welcome.vue'; import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'; +import NewTopLevelGroupAlert from '~/groups/components/new_top_level_group_alert.vue'; import SuperSidebarToggle from '~/super_sidebar/components/super_sidebar_toggle.vue'; import { sidebarState } from '~/super_sidebar/constants'; @@ -14,6 +15,7 @@ describe('Experimental new namespace creation app', () => { const findWelcomePage = () => wrapper.findComponent(WelcomePage); const findLegacyContainer = () => wrapper.findComponent(LegacyContainer); const findBreadcrumb = () => wrapper.findComponent(GlBreadcrumb); + const findNewTopLevelGroupAlert = () => wrapper.findComponent(NewTopLevelGroupAlert); const findSuperSidebarToggle = () => wrapper.findComponent(SuperSidebarToggle); const DEFAULT_PROPS = { @@ -125,4 +127,39 @@ describe('Experimental new namespace creation app', () => { expect(findSuperSidebarToggle().exists()).toBe(isToggleVisible); }); }); + + describe('top level group alert', () => { + beforeEach(() => { + window.location.hash = `#${DEFAULT_PROPS.panels[0].name}`; + }); + + describe('when self-managed', () => { + it('does not render alert', () => { + createComponent(); + + expect(findNewTopLevelGroupAlert().exists()).toBe(false); + }); + }); + + describe('when on .com', () => { + it('does not render alert', () => { + createComponent({ propsData: { isSaas: true } }); + + expect(findNewTopLevelGroupAlert().exists()).toBe(false); + }); + + describe('when empty parent group name', () => { + it('renders alert', () => { + createComponent({ + propsData: { + isSaas: true, + panels: [{ ...DEFAULT_PROPS.panels[0], detailProps: { parentGroupName: '' } }], + }, + }); + + expect(findNewTopLevelGroupAlert().exists()).toBe(true); + }); + }); + }); + }); }); diff --git a/spec/frontend/whats_new/components/app_spec.js b/spec/frontend/whats_new/components/app_spec.js index 000b07f4dfd..b74473b5494 100644 --- a/spec/frontend/whats_new/components/app_spec.js +++ b/spec/frontend/whats_new/components/app_spec.js @@ -5,6 +5,7 @@ import Vuex from 'vuex'; import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import App from '~/whats_new/components/app.vue'; +import SkeletonLoader from '~/whats_new/components/skeleton_loader.vue'; import { getDrawerBodyHeight } from '~/whats_new/utils/get_drawer_body_height'; const MOCK_DRAWER_BODY_HEIGHT = 42; @@ -38,6 +39,7 @@ describe('App', () => { open: true, features: [], drawerBodyHeight: null, + fetching: false, }; store = new Vuex.Store({ @@ -55,18 +57,18 @@ describe('App', () => { }; const findInfiniteScroll = () => wrapper.findComponent(GlInfiniteScroll); + const findSkeletonLoader = () => wrapper.findComponent(SkeletonLoader); - const setup = async () => { + const setup = async (features, fetching) => { document.body.dataset.page = 'test-page'; document.body.dataset.namespaceId = 'namespace-840'; trackingSpy = mockTracking('_category_', null, jest.spyOn); buildWrapper(); - wrapper.vm.$store.state.features = [ - { name: 'Whats New Drawer', documentation_link: 'www.url.com', release: 3.11 }, - ]; - wrapper.vm.$store.state.drawerBodyHeight = MOCK_DRAWER_BODY_HEIGHT; + store.state.features = features; + store.state.fetching = fetching; + store.state.drawerBodyHeight = MOCK_DRAWER_BODY_HEIGHT; await nextTick(); }; @@ -75,110 +77,144 @@ describe('App', () => { }); describe('gitlab.com', () => { - beforeEach(() => { - setup(); - }); + describe('with features', () => { + beforeEach(() => { + setup( + [{ name: 'Whats New Drawer', documentation_link: 'www.url.com', release: 3.11 }], + false, + ); + }); - const getDrawer = () => wrapper.findComponent(GlDrawer); - const getBackdrop = () => wrapper.find('.whats-new-modal-backdrop'); + const getDrawer = () => wrapper.findComponent(GlDrawer); + const getBackdrop = () => wrapper.find('.whats-new-modal-backdrop'); - it('contains a drawer', () => { - expect(getDrawer().exists()).toBe(true); - }); + it('contains a drawer', () => { + expect(getDrawer().exists()).toBe(true); + }); - it('dispatches openDrawer and tracking calls when mounted', () => { - expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'version-digest'); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', { - label: 'namespace_id', - property: 'navigation_top', - value: 'namespace-840', + it('dispatches openDrawer and tracking calls when mounted', () => { + expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'version-digest'); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', { + label: 'namespace_id', + property: 'navigation_top', + value: 'namespace-840', + }); }); - }); - it('dispatches closeDrawer when clicking close', () => { - getDrawer().vm.$emit('close'); - expect(actions.closeDrawer).toHaveBeenCalled(); - }); + it('dispatches closeDrawer when clicking close', () => { + getDrawer().vm.$emit('close'); + expect(actions.closeDrawer).toHaveBeenCalled(); + }); - it('dispatches closeDrawer when clicking the backdrop', () => { - getBackdrop().trigger('click'); - expect(actions.closeDrawer).toHaveBeenCalled(); - }); + it('dispatches closeDrawer when clicking the backdrop', () => { + getBackdrop().trigger('click'); + expect(actions.closeDrawer).toHaveBeenCalled(); + }); - it.each([true, false])('passes open property', async (openState) => { - wrapper.vm.$store.state.open = openState; + it.each([true, false])('passes open property', async (openState) => { + store.state.open = openState; - await nextTick(); + await nextTick(); - expect(getDrawer().props('open')).toBe(openState); - }); + expect(getDrawer().props('open')).toBe(openState); + }); - it('renders features when provided via ajax', () => { - expect(actions.fetchItems).toHaveBeenCalled(); - expect(wrapper.find('[data-test-id="feature-name"]').text()).toBe('Whats New Drawer'); - }); + it('renders features when provided via ajax', () => { + expect(actions.fetchItems).toHaveBeenCalled(); + expect(wrapper.find('[data-test-id="feature-name"]').text()).toBe('Whats New Drawer'); + }); - it('send an event when feature item is clicked', () => { - trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); + it('send an event when feature item is clicked', () => { + trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); - const link = wrapper.find('.whats-new-item-title-link'); - triggerEvent(link.element); + const link = wrapper.find('.whats-new-item-title-link'); + triggerEvent(link.element); - expect(trackingSpy.mock.calls[1]).toMatchObject([ - '_category_', - 'click_whats_new_item', - { - label: 'Whats New Drawer', - property: 'www.url.com', - }, - ]); - }); + expect(trackingSpy.mock.calls[1]).toMatchObject([ + '_category_', + 'click_whats_new_item', + { + label: 'Whats New Drawer', + property: 'www.url.com', + }, + ]); + }); - it('renders infinite scroll', () => { - const scroll = findInfiniteScroll(); + it('renders infinite scroll', () => { + const scroll = findInfiniteScroll(); + const skeletonLoader = findSkeletonLoader(); - expect(scroll.props()).toMatchObject({ - fetchedItems: wrapper.vm.$store.state.features.length, - maxListHeight: MOCK_DRAWER_BODY_HEIGHT, + expect(skeletonLoader.exists()).toBe(false); + + expect(scroll.props()).toMatchObject({ + fetchedItems: store.state.features.length, + maxListHeight: MOCK_DRAWER_BODY_HEIGHT, + }); }); - }); - describe('bottomReached', () => { - const emitBottomReached = () => findInfiniteScroll().vm.$emit('bottomReached'); + describe('bottomReached', () => { + const emitBottomReached = () => findInfiniteScroll().vm.$emit('bottomReached'); - beforeEach(() => { - actions.fetchItems.mockClear(); - }); + beforeEach(() => { + actions.fetchItems.mockClear(); + }); + + it('when nextPage exists it calls fetchItems', () => { + store.state.pageInfo = { nextPage: 840 }; + emitBottomReached(); + + expect(actions.fetchItems).toHaveBeenCalledWith(expect.anything(), { + page: 840, + versionDigest: 'version-digest', + }); + }); - it('when nextPage exists it calls fetchItems', () => { - wrapper.vm.$store.state.pageInfo = { nextPage: 840 }; - emitBottomReached(); + it('when nextPage does not exist it does not call fetchItems', () => { + store.state.pageInfo = { nextPage: null }; + emitBottomReached(); - expect(actions.fetchItems).toHaveBeenCalledWith(expect.anything(), { - page: 840, - versionDigest: 'version-digest', + expect(actions.fetchItems).not.toHaveBeenCalled(); }); }); - it('when nextPage does not exist it does not call fetchItems', () => { - wrapper.vm.$store.state.pageInfo = { nextPage: null }; - emitBottomReached(); + it('calls getDrawerBodyHeight and setDrawerBodyHeight when resize directive is triggered', () => { + const { value } = getBinding(getDrawer().element, 'gl-resize-observer'); - expect(actions.fetchItems).not.toHaveBeenCalled(); + value(); + + expect(getDrawerBodyHeight).toHaveBeenCalledWith(wrapper.findComponent(GlDrawer).element); + + expect(actions.setDrawerBodyHeight).toHaveBeenCalledWith( + expect.any(Object), + MOCK_DRAWER_BODY_HEIGHT, + ); }); }); - it('calls getDrawerBodyHeight and setDrawerBodyHeight when resize directive is triggered', () => { - const { value } = getBinding(getDrawer().element, 'gl-resize-observer'); + describe('without features', () => { + it('renders skeleton loader when fetching', async () => { + setup([], true); + + await nextTick(); + + const scroll = findInfiniteScroll(); + const skeletonLoader = findSkeletonLoader(); - value(); + expect(scroll.exists()).toBe(false); + expect(skeletonLoader.exists()).toBe(true); + }); + + it('renders infinite scroll loader when NOT fetching', async () => { + setup([], false); - expect(getDrawerBodyHeight).toHaveBeenCalledWith(wrapper.findComponent(GlDrawer).element); + await nextTick(); - expect(actions.setDrawerBodyHeight).toHaveBeenCalledWith( - expect.any(Object), - MOCK_DRAWER_BODY_HEIGHT, - ); + const scroll = findInfiniteScroll(); + const skeletonLoader = findSkeletonLoader(); + + expect(scroll.exists()).toBe(true); + expect(skeletonLoader.exists()).toBe(false); + }); }); }); }); diff --git a/spec/frontend/whats_new/utils/notification_spec.js b/spec/frontend/whats_new/utils/notification_spec.js index 8b5663ee764..020d833c578 100644 --- a/spec/frontend/whats_new/utils/notification_spec.js +++ b/spec/frontend/whats_new/utils/notification_spec.js @@ -38,6 +38,7 @@ describe('~/whats_new/utils/notification', () => { it('removes class and count element when storage key has current digest', () => { const notificationEl = findNotificationEl(); + notificationEl.classList.add('with-notifications'); localStorage.setItem('display-whats-new-notification', 'version-digest'); @@ -48,6 +49,20 @@ describe('~/whats_new/utils/notification', () => { expect(findNotificationCountEl()).toBe(null); expect(notificationEl.classList).not.toContain('with-notifications'); }); + + it('removes class and count element when no records and digest undefined', () => { + const notificationEl = findNotificationEl(); + + notificationEl.classList.add('with-notifications'); + localStorage.setItem('display-whats-new-notification', 'version-digest'); + + expect(findNotificationCountEl()).not.toBe(null); + + setNotification(wrapper.querySelector('[data-testid="without-digest"]')); + + expect(findNotificationCountEl()).toBe(null); + expect(notificationEl.classList).not.toContain('with-notifications'); + }); }); describe('getVersionDigest', () => { diff --git a/spec/frontend/work_items/components/notes/system_note_spec.js b/spec/frontend/work_items/components/notes/system_note_spec.js index fd5f373d076..03f1aa356ad 100644 --- a/spec/frontend/work_items/components/notes/system_note_spec.js +++ b/spec/frontend/work_items/components/notes/system_note_spec.js @@ -1,54 +1,32 @@ import { GlIcon } from '@gitlab/ui'; -import MockAdapter from 'axios-mock-adapter'; import { shallowMount } from '@vue/test-utils'; -import waitForPromises from 'helpers/wait_for_promises'; -import { renderGFM } from '~/behaviors/markdown/render_gfm'; +import MockAdapter from 'axios-mock-adapter'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import WorkItemSystemNote from '~/work_items/components/notes/system_note.vue'; -import NoteHeader from '~/notes/components/note_header.vue'; +import { workItemSystemNoteWithMetadata } from 'jest/work_items/mock_data'; import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; jest.mock('~/behaviors/markdown/render_gfm'); -describe('system note component', () => { +describe('Work Items system note component', () => { let wrapper; - let props; let mock; - const findTimelineIcon = () => wrapper.findComponent(GlIcon); - const findSystemNoteMessage = () => wrapper.findComponent(NoteHeader); - const findOutdatedLineButton = () => - wrapper.findComponent('[data-testid="outdated-lines-change-btn"]'); - const findOutdatedLines = () => wrapper.findComponent('[data-testid="outdated-lines"]'); + const createComponent = ({ note = workItemSystemNoteWithMetadata } = {}) => { + mock = new MockAdapter(axios); - const createComponent = (propsData = {}) => { wrapper = shallowMount(WorkItemSystemNote, { - propsData, - slots: { - 'extra-controls': - '<gl-button data-testid="outdated-lines-change-btn">Compare with last version</gl-button>', + propsData: { + note, }, }); }; - beforeEach(() => { - props = { - note: { - id: '1424', - author: { - id: 1, - name: 'Root', - username: 'root', - state: 'active', - avatarUrl: 'path', - path: '/root', - }, - bodyHtml: '<p dir="auto">closed</p>', - systemNoteIconName: 'status_closed', - createdAt: '2017-08-02T10:51:58.559Z', - }, - }; + const findTimelineIcon = () => wrapper.findComponent(GlIcon); + const findComparePreviousVersionButton = () => wrapper.find('[data-testid="compare-btn"]'); + beforeEach(() => { + createComponent(); mock = new MockAdapter(axios); }); @@ -57,56 +35,16 @@ describe('system note component', () => { }); it('should render a list item with correct id', () => { - createComponent(props); - - expect(wrapper.attributes('id')).toBe(`note_${props.note.id}`); - }); - - // Note: The test case below is to handle a use case related to vuex store but since this does not - // have a vuex store , disabling it now will be fixing it in the next iteration - // eslint-disable-next-line jest/no-disabled-tests - it.skip('should render target class is note is target note', () => { - createComponent(props); - - expect(wrapper.classes()).toContain('target'); + expect(wrapper.attributes('id')).toBe( + `note_${getIdFromGraphQLId(workItemSystemNoteWithMetadata.id)}`, + ); }); it('should render svg icon', () => { - createComponent(props); - expect(findTimelineIcon().exists()).toBe(true); }); - // Redcarpet Markdown renderer wraps text in `<p>` tags - // we need to strip them because they break layout of commit lists in system notes: - // https://gitlab.com/gitlab-org/gitlab-foss/uploads/b07a10670919254f0220d3ff5c1aa110/jqzI.png - it('removes wrapping paragraph from note HTML', () => { - createComponent(props); - - expect(findSystemNoteMessage().html()).toContain('<span>closed</span>'); - }); - - it('should renderGFM onMount', () => { - createComponent(props); - - expect(renderGFM).toHaveBeenCalled(); - }); - - // eslint-disable-next-line jest/no-disabled-tests - it.skip('renders outdated code lines', async () => { - mock - .onGet('/outdated_line_change_path') - .reply(HTTP_STATUS_OK, [ - { rich_text: 'console.log', type: 'new', line_code: '123', old_line: null, new_line: 1 }, - ]); - - createComponent({ - note: { ...props.note, outdated_line_change_path: '/outdated_line_change_path' }, - }); - - await findOutdatedLineButton().vm.$emit('click'); - await waitForPromises(); - - expect(findOutdatedLines().exists()).toBe(true); + it('should not show compare previous version for FOSS', () => { + expect(findComparePreviousVersionButton().exists()).toBe(false); }); }); diff --git a/spec/frontend/work_items/components/notes/work_item_add_note_spec.js b/spec/frontend/work_items/components/notes/work_item_add_note_spec.js index 739340f4936..e6d20dcb0d9 100644 --- a/spec/frontend/work_items/components/notes/work_item_add_note_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_add_note_spec.js @@ -32,15 +32,18 @@ describe('Work item add note', () => { const findCommentForm = () => wrapper.findComponent(WorkItemCommentForm); const findTextarea = () => wrapper.findByTestId('note-reply-textarea'); + const findWorkItemLockedComponent = () => wrapper.findComponent(WorkItemCommentLocked); const createComponent = async ({ mutationHandler = mutationSuccessHandler, canUpdate = true, + canCreateNote = true, workItemIid = '1', - workItemResponse = workItemByIidResponseFactory({ canUpdate }), + workItemResponse = workItemByIidResponseFactory({ canUpdate, canCreateNote }), signedIn = true, isEditing = true, workItemType = 'Task', + isInternalThread = false, } = {}) => { workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse); if (signedIn) { @@ -65,6 +68,7 @@ describe('Work item add note', () => { workItemType, markdownPreviewPath: '/group/project/preview_markdown?target_type=WorkItem', autocompleteDataSources: {}, + isInternalThread, }, stubs: { WorkItemCommentLocked, @@ -79,142 +83,170 @@ describe('Work item add note', () => { }; describe('adding a comment', () => { - it('calls update widgets mutation', async () => { - const noteText = 'updated desc'; - - await createComponent({ - isEditing: true, - signedIn: true, + describe.each` + isInternalComment + ${false} + ${true} + `('when internal comment is $isInternalComment', ({ isInternalComment }) => { + it('calls update widgets mutation', async () => { + const noteText = 'updated desc'; + + await createComponent({ + isEditing: true, + signedIn: true, + }); + + findCommentForm().vm.$emit('submitForm', { + commentText: noteText, + isNoteInternal: isInternalComment, + }); + + await waitForPromises(); + + expect(mutationSuccessHandler).toHaveBeenCalledWith({ + input: { + noteableId: workItemId, + body: noteText, + discussionId: null, + internal: isInternalComment, + }, + }); }); - findCommentForm().vm.$emit('submitForm', noteText); + it('tracks adding comment', async () => { + await createComponent(); + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - await waitForPromises(); + findCommentForm().vm.$emit('submitForm', { + commentText: 'test', + isNoteInternal: isInternalComment, + }); - expect(mutationSuccessHandler).toHaveBeenCalledWith({ - input: { - noteableId: workItemId, - body: noteText, - discussionId: null, - }, - }); - }); + await waitForPromises(); - it('tracks adding comment', async () => { - await createComponent(); - const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'add_work_item_comment', { + category: TRACKING_CATEGORY_SHOW, + label: 'item_comment', + property: 'type_Task', + }); + }); - findCommentForm().vm.$emit('submitForm', 'test'); + it('emits `replied` event and hides form after successful mutation', async () => { + await createComponent({ isEditing: true, signedIn: true }); - await waitForPromises(); + findCommentForm().vm.$emit('submitForm', { + commentText: 'some text', + isNoteInternal: isInternalComment, + }); + await waitForPromises(); - expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'add_work_item_comment', { - category: TRACKING_CATEGORY_SHOW, - label: 'item_comment', - property: 'type_Task', + expect(wrapper.emitted('replied')).toEqual([[]]); }); - }); - - it('emits `replied` event and hides form after successful mutation', async () => { - await createComponent({ isEditing: true, signedIn: true }); - findCommentForm().vm.$emit('submitForm', 'some text'); - await waitForPromises(); + it('clears a draft after successful mutation', async () => { + await createComponent({ + isEditing: true, + signedIn: true, + }); - expect(wrapper.emitted('replied')).toEqual([[]]); - }); + findCommentForm().vm.$emit('submitForm', { + commentText: 'some text', + isNoteInternal: isInternalComment, + }); + await waitForPromises(); - it('clears a draft after successful mutation', async () => { - await createComponent({ - isEditing: true, - signedIn: true, + expect(clearDraft).toHaveBeenCalledWith('gid://gitlab/WorkItem/1-comment'); }); - findCommentForm().vm.$emit('submitForm', 'some text'); - await waitForPromises(); - - expect(clearDraft).toHaveBeenCalledWith('gid://gitlab/WorkItem/1-comment'); - }); + it('emits error when mutation returns error', async () => { + const error = 'eror'; - it('emits error when mutation returns error', async () => { - const error = 'eror'; - - await createComponent({ - isEditing: true, - mutationHandler: jest.fn().mockResolvedValue({ - data: { - createNote: { - note: { - id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122', - discussion: { + await createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockResolvedValue({ + data: { + createNote: { + note: { id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122', - notes: { - nodes: [], - __typename: 'NoteConnection', + discussion: { + id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122', + notes: { + nodes: [], + __typename: 'NoteConnection', + }, + __typename: 'Discussion', }, - __typename: 'Discussion', + __typename: 'Note', }, - __typename: 'Note', + __typename: 'CreateNotePayload', + errors: [error], }, - __typename: 'CreateNotePayload', - errors: [error], }, - }, - }), - }); + }), + }); - findCommentForm().vm.$emit('submitForm', 'updated desc'); + findCommentForm().vm.$emit('submitForm', { + commentText: 'updated desc', + isNoteInternal: isInternalComment, + }); - await waitForPromises(); + await waitForPromises(); - expect(wrapper.emitted('error')).toEqual([[error]]); - }); + expect(wrapper.emitted('error')).toEqual([[error]]); + }); - it('emits error when mutation fails', async () => { - const error = 'eror'; + it('emits error when mutation fails', async () => { + const error = 'eror'; - await createComponent({ - isEditing: true, - mutationHandler: jest.fn().mockRejectedValue(new Error(error)), - }); + await createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockRejectedValue(new Error(error)), + }); - findCommentForm().vm.$emit('submitForm', 'updated desc'); + findCommentForm().vm.$emit('submitForm', { + commentText: 'updated desc', + isNoteInternal: isInternalComment, + }); - await waitForPromises(); + await waitForPromises(); - expect(wrapper.emitted('error')).toEqual([[error]]); - }); + expect(wrapper.emitted('error')).toEqual([[error]]); + }); - it('ignores errors when mutation returns additional information as errors for quick actions', async () => { - await createComponent({ - isEditing: true, - mutationHandler: jest.fn().mockResolvedValue({ - data: { - createNote: { - note: { - id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122', - discussion: { + it('ignores errors when mutation returns additional information as errors for quick actions', async () => { + await createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockResolvedValue({ + data: { + createNote: { + note: { id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122', - notes: { - nodes: [], - __typename: 'NoteConnection', + discussion: { + id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122', + notes: { + nodes: [], + __typename: 'NoteConnection', + }, + __typename: 'Discussion', }, - __typename: 'Discussion', + __typename: 'Note', }, - __typename: 'Note', + __typename: 'CreateNotePayload', + errors: ['Commands only Removed assignee @foobar.', 'Command names ["unassign"]'], }, - __typename: 'CreateNotePayload', - errors: ['Commands only Removed assignee @foobar.', 'Command names ["unassign"]'], }, - }, - }), - }); + }), + }); - findCommentForm().vm.$emit('submitForm', 'updated desc'); + findCommentForm().vm.$emit('submitForm', { + commentText: 'updated desc', + isNoteInternal: isInternalComment, + }); - await waitForPromises(); + await waitForPromises(); - expect(clearDraft).toHaveBeenCalledWith('gid://gitlab/WorkItem/1-comment'); + expect(clearDraft).toHaveBeenCalledWith('gid://gitlab/WorkItem/1-comment'); + }); }); }); @@ -225,8 +257,23 @@ describe('Work item add note', () => { }); it('skips calling the work item query when missing workItemIid', async () => { - await createComponent({ workItemIid: null, isEditing: false }); + await createComponent({ workItemIid: '', isEditing: false }); expect(workItemResponseHandler).not.toHaveBeenCalled(); }); + + it('wrapper adds `internal-note` class when internal thread', async () => { + await createComponent({ isInternalThread: true }); + + expect(wrapper.attributes('class')).toContain('internal-note'); + }); + + describe('when work item`createNote` permission false', () => { + it('cannot add comment', async () => { + await createComponent({ isEditing: false, canCreateNote: false }); + + expect(findWorkItemLockedComponent().exists()).toBe(true); + expect(findCommentForm().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js b/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js index 147f2904761..6c00d52aac5 100644 --- a/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js @@ -1,6 +1,8 @@ +import { GlFormCheckbox, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; +import { createMockDirective } from 'helpers/vue_mock_directive'; import waitForPromises from 'helpers/wait_for_promises'; import * as autosave from '~/lib/utils/autosave'; import { ESC_KEY, ENTER_KEY } from '~/lib/utils/keys'; @@ -40,6 +42,8 @@ describe('Work item comment form component', () => { const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor); const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]'); const findConfirmButton = () => wrapper.find('[data-testid="confirm-button"]'); + const findInternalNoteCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findInternalNoteTooltipIcon = () => wrapper.findComponent(GlIcon); const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); @@ -68,6 +72,9 @@ describe('Work item comment form component', () => { provide: { fullPath: 'test-project-path', }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, }); }; @@ -168,7 +175,9 @@ describe('Work item comment form component', () => { createComponent(); findConfirmButton().vm.$emit('click'); - expect(wrapper.emitted('submitForm')).toEqual([[draftComment]]); + expect(wrapper.emitted('submitForm')).toEqual([ + [{ commentText: draftComment, isNoteInternal: false }], + ]); }); it('emits `submitForm` event on pressing enter with meta key on markdown editor', () => { @@ -178,7 +187,9 @@ describe('Work item comment form component', () => { new KeyboardEvent('keydown', { key: ENTER_KEY, metaKey: true }), ); - expect(wrapper.emitted('submitForm')).toEqual([[draftComment]]); + expect(wrapper.emitted('submitForm')).toEqual([ + [{ commentText: draftComment, isNoteInternal: false }], + ]); }); it('emits `submitForm` event on pressing ctrl+enter on markdown editor', () => { @@ -188,7 +199,9 @@ describe('Work item comment form component', () => { new KeyboardEvent('keydown', { key: ENTER_KEY, ctrlKey: true }), ); - expect(wrapper.emitted('submitForm')).toEqual([[draftComment]]); + expect(wrapper.emitted('submitForm')).toEqual([ + [{ commentText: draftComment, isNoteInternal: false }], + ]); }); describe('when used as a top level/is a new discussion', () => { @@ -249,4 +262,36 @@ describe('Work item comment form component', () => { }); }); }); + + describe('internal note', () => { + it('internal note checkbox should not be visible by default', () => { + createComponent(); + + expect(findInternalNoteCheckbox().exists()).toBe(false); + }); + + describe('when used as a new discussion', () => { + beforeEach(() => { + createComponent({ isNewDiscussion: true }); + }); + + it('should have the add as internal note capability', () => { + expect(findInternalNoteCheckbox().exists()).toBe(true); + }); + + it('should have the tooltip explaining the internal note capabilities', () => { + expect(findInternalNoteTooltipIcon().exists()).toBe(true); + expect(findInternalNoteTooltipIcon().attributes('title')).toBe( + WorkItemCommentForm.i18n.internalVisibility, + ); + }); + + it('should change the submit button text on change of value', async () => { + findInternalNoteCheckbox().vm.$emit('input', true); + await nextTick(); + + expect(findConfirmButton().text()).toBe(WorkItemCommentForm.i18n.addInternalNote); + }); + }); + }); }); diff --git a/spec/frontend/work_items/components/notes/work_item_discussion_spec.js b/spec/frontend/work_items/components/notes/work_item_discussion_spec.js index fac5011b6af..9d22a64f2cb 100644 --- a/spec/frontend/work_items/components/notes/work_item_discussion_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_discussion_spec.js @@ -90,6 +90,16 @@ describe('Work Item Discussion', () => { expect(findWorkItemAddNote().exists()).toBe(true); expect(findWorkItemAddNote().props('autofocus')).toBe(true); }); + + it('should send the correct props is when the main comment is internal', async () => { + const mainComment = findThreadAtIndex(0); + + mainComment.vm.$emit('startReplying'); + await nextTick(); + expect(findWorkItemAddNote().props('isInternalThread')).toBe( + mockWorkItemNotesWidgetResponseWithComments.discussions.nodes[0].notes.nodes[0].internal, + ); + }); }); describe('When replying to any comment', () => { @@ -115,6 +125,13 @@ describe('Work Item Discussion', () => { expect(findToggleRepliesWidget().exists()).toBe(true); expect(findToggleRepliesWidget().props('collapsed')).toBe(false); }); + + it('should pass `is-internal-note` props to make sure the correct background is set', () => { + expect(findWorkItemNoteReplying().exists()).toBe(true); + expect(findWorkItemNoteReplying().props('isInternalNote')).toBe( + mockWorkItemNotesWidgetResponseWithComments.discussions.nodes[0].notes.nodes[0].internal, + ); + }); }); it('emits `deleteNote` event with correct parameter when child note component emits `deleteNote` event', () => { diff --git a/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js b/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js index 99bf391e261..2e901783e07 100644 --- a/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js @@ -1,8 +1,9 @@ -import { GlDropdown } from '@gitlab/ui'; +import { GlDisclosureDropdown } 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 { createMockDirective } from 'helpers/vue_mock_directive'; import EmojiPicker from '~/emoji/components/picker.vue'; import waitForPromises from 'helpers/wait_for_promises'; import ReplyButton from '~/notes/components/note_actions/reply_button.vue'; @@ -18,11 +19,14 @@ describe('Work Item Note Actions', () => { const findReplyButton = () => wrapper.findComponent(ReplyButton); const findEditButton = () => wrapper.find('[data-testid="edit-work-item-note"]'); const findEmojiButton = () => wrapper.find('[data-testid="note-emoji-button"]'); - const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); const findDeleteNoteButton = () => wrapper.find('[data-testid="delete-note-action"]'); const findCopyLinkButton = () => wrapper.find('[data-testid="copy-link-action"]'); const findAssignUnassignButton = () => wrapper.find('[data-testid="assign-note-action"]'); const findReportAbuseToAdminButton = () => wrapper.find('[data-testid="abuse-note-action"]'); + const findAuthorBadge = () => wrapper.find('[data-testid="author-badge"]'); + const findMaxAccessLevelBadge = () => wrapper.find('[data-testid="max-access-level-badge"]'); + const findContributorBadge = () => wrapper.find('[data-testid="contributor-badge"]'); const addEmojiMutationResolver = jest.fn().mockResolvedValue({ data: { @@ -41,6 +45,11 @@ describe('Work Item Note Actions', () => { showAwardEmoji = true, showAssignUnassign = false, canReportAbuse = false, + workItemType = 'Task', + isWorkItemAuthor = false, + isAuthorContributor = false, + maxAccessLevelOfAuthor = '', + projectName = 'Project name', } = {}) => { wrapper = shallowMount(WorkItemNoteActions, { propsData: { @@ -50,6 +59,11 @@ describe('Work Item Note Actions', () => { showAwardEmoji, showAssignUnassign, canReportAbuse, + workItemType, + isWorkItemAuthor, + isAuthorContributor, + maxAccessLevelOfAuthor, + projectName, }, provide: { glFeatures: { @@ -60,7 +74,11 @@ describe('Work Item Note Actions', () => { EmojiPicker: EmojiPickerStub, }, apolloProvider: createMockApollo([[addAwardEmojiMutation, addEmojiMutationResolver]]), + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, }); + wrapper.vm.$refs.dropdown.close = jest.fn(); }; describe('reply button', () => { @@ -152,7 +170,7 @@ describe('Work Item Note Actions', () => { showEdit: true, }); - findDeleteNoteButton().vm.$emit('click'); + findDeleteNoteButton().vm.$emit('action'); expect(wrapper.emitted('deleteNote')).toEqual([[]]); }); @@ -167,7 +185,7 @@ describe('Work Item Note Actions', () => { }); it('should emit `notifyCopyDone` event when copy link note action is clicked', () => { - findCopyLinkButton().vm.$emit('click'); + findCopyLinkButton().vm.$emit('action'); expect(wrapper.emitted('notifyCopyDone')).toEqual([[]]); }); @@ -193,7 +211,7 @@ describe('Work Item Note Actions', () => { showAssignUnassign: true, }); - findAssignUnassignButton().vm.$emit('click'); + findAssignUnassignButton().vm.$emit('action'); expect(wrapper.emitted('assignUser')).toEqual([[]]); }); @@ -219,9 +237,63 @@ describe('Work Item Note Actions', () => { canReportAbuse: true, }); - findReportAbuseToAdminButton().vm.$emit('click'); + findReportAbuseToAdminButton().vm.$emit('action'); expect(wrapper.emitted('reportAbuse')).toEqual([[]]); }); }); + + describe('user role badges', () => { + describe('author badge', () => { + it('does not show the author badge by default', () => { + createComponent(); + + expect(findAuthorBadge().exists()).toBe(false); + }); + + it('shows the author badge when the work item is author by the current User', () => { + createComponent({ isWorkItemAuthor: true }); + + expect(findAuthorBadge().exists()).toBe(true); + expect(findAuthorBadge().text()).toBe('Author'); + expect(findAuthorBadge().attributes('title')).toBe('This user is the author of this task.'); + }); + }); + + describe('Max access level badge', () => { + it('does not show the access level badge by default', () => { + createComponent(); + + expect(findMaxAccessLevelBadge().exists()).toBe(false); + }); + + it('shows the access badge when we have a valid value', () => { + createComponent({ maxAccessLevelOfAuthor: 'Owner' }); + + expect(findMaxAccessLevelBadge().exists()).toBe(true); + expect(findMaxAccessLevelBadge().text()).toBe('Owner'); + expect(findMaxAccessLevelBadge().attributes('title')).toBe( + 'This user has the owner role in the Project name project.', + ); + }); + }); + + describe('Contributor badge', () => { + it('does not show the contributor badge by default', () => { + createComponent(); + + expect(findContributorBadge().exists()).toBe(false); + }); + + it('shows the contributor badge the note author is a contributor', () => { + createComponent({ isAuthorContributor: true }); + + expect(findContributorBadge().exists()).toBe(true); + expect(findContributorBadge().text()).toBe('Contributor'); + expect(findContributorBadge().attributes('title')).toBe( + 'This user has previously committed to the Project name project.', + ); + }); + }); + }); }); diff --git a/spec/frontend/work_items/components/notes/work_item_note_replying_spec.js b/spec/frontend/work_items/components/notes/work_item_note_replying_spec.js index 225cc3bacaf..5a6894400b6 100644 --- a/spec/frontend/work_items/components/notes/work_item_note_replying_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_note_replying_spec.js @@ -10,10 +10,11 @@ describe('Work Item Note Replying', () => { const findTimelineEntry = () => wrapper.findComponent(TimelineEntryItem); const findNoteHeader = () => wrapper.findComponent(NoteHeader); - const createComponent = ({ body = mockNoteBody } = {}) => { + const createComponent = ({ body = mockNoteBody, isInternalNote = false } = {}) => { wrapper = shallowMount(WorkItemNoteReplying, { propsData: { body, + isInternalNote, }, }); @@ -31,4 +32,9 @@ describe('Work Item Note Replying', () => { expect(findTimelineEntry().exists()).toBe(true); expect(findNoteHeader().html()).toMatchSnapshot(); }); + + it('should have the correct class when internal note', () => { + createComponent({ isInternalNote: true }); + expect(findTimelineEntry().classes()).toContain('internal-note'); + }); }); diff --git a/spec/frontend/work_items/components/notes/work_item_note_spec.js b/spec/frontend/work_items/components/notes/work_item_note_spec.js index f2cf5171cc1..8dbd2818fc5 100644 --- a/spec/frontend/work_items/components/notes/work_item_note_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_note_spec.js @@ -20,6 +20,8 @@ import { updateWorkItemMutationResponse, workItemByIidResponseFactory, workItemQueryResponse, + mockWorkItemCommentNoteByContributor, + mockWorkItemCommentByMaintainer, } from 'jest/work_items/mock_data'; import { i18n, TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import { mockTracking } from 'helpers/tracking_helper'; @@ -33,6 +35,23 @@ describe('Work Item Note', () => { const updatedNoteBody = '<h1 data-sourcepos="1:1-1:12" dir="auto">Some title</h1>'; const mockWorkItemId = workItemQueryResponse.data.workItem.id; + const mockWorkItemByDifferentUser = { + data: { + workItem: { + ...workItemQueryResponse.data.workItem, + author: { + avatarUrl: + 'http://127.0.0.1:3000/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 'gid://gitlab/User/2', + name: 'User 1', + username: 'user1', + webUrl: 'http://127.0.0.1:3000/user1', + __typename: 'UserCore', + }, + }, + }, + }; + const successHandler = jest.fn().mockResolvedValue({ data: { updateNote: { @@ -47,6 +66,9 @@ describe('Work Item Note', () => { }); const workItemResponseHandler = jest.fn().mockResolvedValue(workItemByIidResponseFactory()); + const workItemByAuthoredByDifferentUser = jest + .fn() + .mockResolvedValue(mockWorkItemByDifferentUser); const updateWorkItemMutationSuccessHandler = jest .fn() @@ -69,6 +91,7 @@ describe('Work Item Note', () => { workItemId = mockWorkItemId, updateWorkItemMutationHandler = updateWorkItemMutationSuccessHandler, assignees = mockAssignees, + workItemByIidResponseHandler = workItemResponseHandler, } = {}) => { wrapper = shallowMount(WorkItemNote, { provide: { @@ -85,7 +108,7 @@ describe('Work Item Note', () => { assignees, }, apolloProvider: mockApollo([ - [workItemByIidQuery, workItemResponseHandler], + [workItemByIidQuery, workItemByIidResponseHandler], [updateWorkItemNoteMutation, updateNoteMutationHandler], [updateWorkItemMutation, updateWorkItemMutationHandler], ]), @@ -133,7 +156,7 @@ describe('Work Item Note', () => { findNoteActions().vm.$emit('startEditing'); await nextTick(); - findCommentForm().vm.$emit('submitForm', updatedNoteText); + findCommentForm().vm.$emit('submitForm', { commentText: updatedNoteText }); expect(successHandler).toHaveBeenCalledWith({ input: { @@ -148,7 +171,7 @@ describe('Work Item Note', () => { findNoteActions().vm.$emit('startEditing'); await nextTick(); - findCommentForm().vm.$emit('submitForm', updatedNoteText); + findCommentForm().vm.$emit('submitForm', { commentText: updatedNoteText }); await waitForPromises(); expect(findCommentForm().exists()).toBe(false); @@ -161,7 +184,7 @@ describe('Work Item Note', () => { findNoteActions().vm.$emit('startEditing'); await nextTick(); - findCommentForm().vm.$emit('submitForm', updatedNoteText); + findCommentForm().vm.$emit('submitForm', { commentText: updatedNoteText }); await waitForPromises(); }); @@ -215,8 +238,9 @@ describe('Work Item Note', () => { }); describe('main comment', () => { - beforeEach(() => { + beforeEach(async () => { createComponent({ isFirstNote: true }); + await waitForPromises(); }); it('should have the note header, actions and body', () => { @@ -229,6 +253,10 @@ describe('Work Item Note', () => { it('should have the reply button props', () => { expect(findNoteActions().props('showReply')).toBe(true); }); + + it('should have the project name', () => { + expect(findNoteActions().props('projectName')).toBe('Project name'); + }); }); describe('comment threads', () => { @@ -318,5 +346,63 @@ describe('Work Item Note', () => { }, ); }); + + describe('internal note', () => { + it('does not have the internal note class set by default', () => { + createComponent(); + expect(findTimelineEntryItem().classes()).not.toContain('internal-note'); + }); + + it('timeline entry item and note header has the class for internal notes', () => { + createComponent({ + note: { + ...mockWorkItemCommentNote, + internal: true, + }, + }); + expect(findTimelineEntryItem().classes()).toContain('internal-note'); + expect(findNoteHeader().props('isInternalNote')).toBe(true); + }); + }); + + describe('author and user role badges', () => { + describe('author badge props', () => { + it.each` + isWorkItemAuthor | sameAsCurrentUser | workItemByIidResponseHandler + ${true} | ${'same as'} | ${workItemResponseHandler} + ${false} | ${'not same as'} | ${workItemByAuthoredByDifferentUser} + `( + 'should pass correct isWorkItemAuthor `$isWorkItemAuthor` to note actions when author is $sameAsCurrentUser as current note', + async ({ isWorkItemAuthor, workItemByIidResponseHandler }) => { + createComponent({ workItemByIidResponseHandler }); + await waitForPromises(); + + expect(findNoteActions().props('isWorkItemAuthor')).toBe(isWorkItemAuthor); + }, + ); + }); + + describe('Max access level badge', () => { + it('should pass the max access badge props', async () => { + createComponent({ note: mockWorkItemCommentByMaintainer }); + await waitForPromises(); + + expect(findNoteActions().props('maxAccessLevelOfAuthor')).toBe( + mockWorkItemCommentByMaintainer.maxAccessLevelOfAuthor, + ); + }); + }); + + describe('Contributor badge', () => { + it('should pass the contributor props', async () => { + createComponent({ note: mockWorkItemCommentNoteByContributor }); + await waitForPromises(); + + expect(findNoteActions().props('isAuthorContributor')).toBe( + mockWorkItemCommentNoteByContributor.authorIsContributor, + ); + }); + }); + }); }); }); diff --git a/spec/frontend/work_items/components/work_item_actions_spec.js b/spec/frontend/work_items/components/work_item_actions_spec.js index 0045abe50d0..e03c6a7e28d 100644 --- a/spec/frontend/work_items/components/work_item_actions_spec.js +++ b/spec/frontend/work_items/components/work_item_actions_spec.js @@ -1,9 +1,12 @@ import { GlDropdownDivider, GlModal, GlToggle } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; + import createMockApollo from 'helpers/mock_apollo_helper'; +import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + import { isLoggedIn } from '~/lib/utils/common_utils'; import toast from '~/vue_shared/plugins/global_toast'; import WorkItemActions from '~/work_items/components/work_item_actions.vue'; @@ -13,6 +16,8 @@ import { TEST_ID_NOTIFICATIONS_TOGGLE_FORM, TEST_ID_DELETE_ACTION, TEST_ID_PROMOTE_ACTION, + TEST_ID_COPY_REFERENCE_ACTION, + TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION, } from '~/work_items/constants'; import updateWorkItemNotificationsMutation from '~/work_items/graphql/update_work_item_notifications.mutation.graphql'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; @@ -31,8 +36,10 @@ describe('WorkItemActions component', () => { Vue.use(VueApollo); let wrapper; - let glModalDirective; let mockApollo; + const mockWorkItemReference = 'gitlab-org/gitlab-test#1'; + const mockWorkItemCreateNoteEmail = + 'gitlab-incoming+gitlab-org-gitlab-test-2-ddpzuq0zd2wefzofcpcdr3dg7-issue-1@gmail.com'; const findModal = () => wrapper.findComponent(GlModal); const findConfidentialityToggleButton = () => @@ -41,6 +48,9 @@ describe('WorkItemActions component', () => { wrapper.findByTestId(TEST_ID_NOTIFICATIONS_TOGGLE_ACTION); const findDeleteButton = () => wrapper.findByTestId(TEST_ID_DELETE_ACTION); const findPromoteButton = () => wrapper.findByTestId(TEST_ID_PROMOTE_ACTION); + const findCopyReferenceButton = () => wrapper.findByTestId(TEST_ID_COPY_REFERENCE_ACTION); + const findCopyCreateNoteEmailButton = () => + wrapper.findByTestId(TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION); const findDropdownItems = () => wrapper.findAll('[data-testid="work-item-actions-dropdown"] > *'); const findDropdownItemsActual = () => findDropdownItems().wrappers.map((x) => { @@ -55,6 +65,7 @@ describe('WorkItemActions component', () => { }); const findNotificationsToggle = () => wrapper.findComponent(GlToggle); + const modalShowSpy = jest.fn(); const $toast = { show: jest.fn(), hide: jest.fn(), @@ -77,9 +88,10 @@ describe('WorkItemActions component', () => { notificationsMock = [updateWorkItemNotificationsMutation, jest.fn()], convertWorkItemMutationHandler = convertWorkItemMutationSuccessHandler, workItemType = 'Task', + workItemReference = mockWorkItemReference, + workItemCreateNoteEmail = mockWorkItemCreateNoteEmail, } = {}) => { const handlers = [notificationsMock]; - glModalDirective = jest.fn(); mockApollo = createMockApollo([ ...handlers, [convertWorkItemMutation, convertWorkItemMutationHandler], @@ -96,13 +108,8 @@ describe('WorkItemActions component', () => { subscribed, isParentConfidential, workItemType, - }, - directives: { - glModal: { - bind(_, { value }) { - glModalDirective(value); - }, - }, + workItemReference, + workItemCreateNoteEmail, }, provide: { fullPath: 'gitlab-org/gitlab', @@ -111,6 +118,13 @@ describe('WorkItemActions component', () => { mocks: { $toast, }, + stubs: { + GlModal: stubComponent(GlModal, { + methods: { + show: modalShowSpy, + }, + }), + }, }); }; @@ -141,6 +155,14 @@ describe('WorkItemActions component', () => { text: 'Turn on confidentiality', }, { + testId: TEST_ID_COPY_REFERENCE_ACTION, + text: 'Copy reference', + }, + { + testId: TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION, + text: 'Copy task email address', + }, + { divider: true, }, { @@ -189,7 +211,7 @@ describe('WorkItemActions component', () => { findDeleteButton().vm.$emit('click'); - expect(glModalDirective).toHaveBeenCalled(); + expect(modalShowSpy).toHaveBeenCalled(); }); it('emits event when clicking OK button', () => { @@ -359,4 +381,37 @@ describe('WorkItemActions component', () => { ]); }); }); + + describe('copy reference action', () => { + it('shows toast when user clicks on the action', () => { + createComponent(); + + expect(findCopyReferenceButton().exists()).toBe(true); + findCopyReferenceButton().vm.$emit('click'); + + expect(toast).toHaveBeenCalledWith('Reference copied'); + }); + }); + + describe('copy email address action', () => { + it.each(['key result', 'objective'])( + 'renders correct button name when work item is %s', + (workItemType) => { + createComponent({ workItemType }); + + expect(findCopyCreateNoteEmailButton().text()).toEqual( + `Copy ${workItemType} email address`, + ); + }, + ); + + it('shows toast when user clicks on the action', () => { + createComponent(); + + expect(findCopyCreateNoteEmailButton().exists()).toBe(true); + findCopyCreateNoteEmailButton().vm.$emit('click'); + + expect(toast).toHaveBeenCalledWith('Email address copied'); + }); + }); }); diff --git a/spec/frontend/work_items/components/work_item_assignees_spec.js b/spec/frontend/work_items/components/work_item_assignees_spec.js index 25b0b74c217..94d47bfb3be 100644 --- a/spec/frontend/work_items/components/work_item_assignees_spec.js +++ b/spec/frontend/work_items/components/work_item_assignees_spec.js @@ -26,6 +26,7 @@ import { updateWorkItemMutationResponse, projectMembersResponseWithCurrentUserWithNextPage, projectMembersResponseWithNoMatchingUsers, + projectMembersResponseWithDuplicates, } from '../mock_data'; Vue.use(VueApollo); @@ -529,4 +530,14 @@ describe('WorkItemAssignees component', () => { }); }); }); + + it('filters out the users with the same ID from the list of project members', async () => { + createComponent({ + searchQueryHandler: jest.fn().mockResolvedValue(projectMembersResponseWithDuplicates), + }); + findTokenSelector().vm.$emit('focus'); + await waitForPromises(); + + expect(findTokenSelector().props('dropdownItems')).toHaveLength(2); + }); }); diff --git a/spec/frontend/work_items/components/work_item_award_emoji_spec.js b/spec/frontend/work_items/components/work_item_award_emoji_spec.js index f87c0e3f357..82be6d990e4 100644 --- a/spec/frontend/work_items/components/work_item_award_emoji_spec.js +++ b/spec/frontend/work_items/components/work_item_award_emoji_spec.js @@ -8,19 +8,15 @@ import waitForPromises from 'helpers/wait_for_promises'; import { isLoggedIn } from '~/lib/utils/common_utils'; import AwardList from '~/vue_shared/components/awards_list.vue'; import WorkItemAwardEmoji from '~/work_items/components/work_item_award_emoji.vue'; -import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; -import { - EMOJI_ACTION_REMOVE, - EMOJI_ACTION_ADD, - EMOJI_THUMBSUP, - EMOJI_THUMBSDOWN, -} from '~/work_items/constants'; +import updateAwardEmojiMutation from '~/work_items/graphql/update_award_emoji.mutation.graphql'; +import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; +import { EMOJI_THUMBSUP, EMOJI_THUMBSDOWN } from '~/work_items/constants'; import { workItemByIidResponseFactory, mockAwardsWidget, - updateWorkItemMutationResponseFactory, mockAwardEmojiThumbsUp, + getAwardEmojiResponse, } from '../mock_data'; jest.mock('~/lib/utils/common_utils'); @@ -28,43 +24,61 @@ Vue.use(VueApollo); describe('WorkItemAwardEmoji component', () => { let wrapper; + let mockApolloProvider; const errorMessage = 'Failed to update the award'; - const workItemQueryResponse = workItemByIidResponseFactory(); - const workItemSuccessHandler = jest - .fn() - .mockResolvedValue(updateWorkItemMutationResponseFactory()); - const awardEmojiAddSuccessHandler = jest.fn().mockResolvedValue( - updateWorkItemMutationResponseFactory({ - awardEmoji: { - ...mockAwardsWidget, - nodes: [mockAwardEmojiThumbsUp], - }, - }), - ); - const awardEmojiRemoveSuccessHandler = jest.fn().mockResolvedValue( - updateWorkItemMutationResponseFactory({ - awardEmoji: { - ...mockAwardsWidget, - nodes: [], - }, - }), - ); - const workItemUpdateFailureHandler = jest.fn().mockRejectedValue(new Error(errorMessage)); + const workItemQueryAddAwardEmojiResponse = workItemByIidResponseFactory({ + awardEmoji: { ...mockAwardsWidget, nodes: [mockAwardEmojiThumbsUp] }, + }); + const workItemQueryRemoveAwardEmojiResponse = workItemByIidResponseFactory({ + awardEmoji: { ...mockAwardsWidget, nodes: [] }, + }); + const awardEmojiAddSuccessHandler = jest.fn().mockResolvedValue(getAwardEmojiResponse(true)); + const awardEmojiRemoveSuccessHandler = jest.fn().mockResolvedValue(getAwardEmojiResponse(false)); + const awardEmojiUpdateFailureHandler = jest.fn().mockRejectedValue(new Error(errorMessage)); const mockWorkItem = workItemQueryResponse.data.workspace.workItems.nodes[0]; + const mockAwardEmojiDifferentUserThumbsUp = { + name: 'thumbsup', + __typename: 'AwardEmoji', + user: { + id: 'gid://gitlab/User/1', + name: 'John Doe', + __typename: 'UserCore', + }, + }; const createComponent = ({ - mockWorkItemUpdateMutationHandler = [updateWorkItemMutation, workItemSuccessHandler], + awardMutationHandler = awardEmojiAddSuccessHandler, workItem = mockWorkItem, + workItemIid = '1', awardEmoji = { ...mockAwardsWidget, nodes: [] }, } = {}) => { + mockApolloProvider = createMockApollo([[updateAwardEmojiMutation, awardMutationHandler]]); + + mockApolloProvider.clients.defaultClient.writeQuery({ + query: workItemByIidQuery, + variables: { fullPath: workItem.project.fullPath, iid: workItemIid }, + data: { + ...workItemQueryResponse.data, + workspace: { + __typename: 'Project', + id: 'gid://gitlab/Project/1', + workItems: { + nodes: [workItem], + }, + }, + }, + }); + wrapper = shallowMount(WorkItemAwardEmoji, { isLoggedIn: isLoggedIn(), - apolloProvider: createMockApollo([mockWorkItemUpdateMutationHandler]), + apolloProvider: mockApolloProvider, propsData: { - workItem, + workItemId: workItem.id, + workItemFullpath: workItem.project.fullPath, awardEmoji, + workItemIid, }, }); }; @@ -74,7 +88,8 @@ describe('WorkItemAwardEmoji component', () => { beforeEach(() => { isLoggedIn.mockReturnValue(true); window.gon = { - current_user_id: 1, + current_user_id: 5, + current_user_fullname: 'Dave Smith', }; createComponent(); @@ -85,7 +100,7 @@ describe('WorkItemAwardEmoji component', () => { expect(findAwardsList().props()).toEqual({ boundary: '', canAwardEmoji: true, - currentUserId: 1, + currentUserId: 5, defaultAwards: [EMOJI_THUMBSUP, EMOJI_THUMBSDOWN], selectedClass: 'selected', awards: [], @@ -97,48 +112,70 @@ describe('WorkItemAwardEmoji component', () => { expect(findAwardsList().props('awards')).toEqual([ { - id: 1, name: EMOJI_THUMBSUP, user: { id: 5, + name: 'Dave Smith', }, }, { - id: 2, name: EMOJI_THUMBSDOWN, user: { id: 5, + name: 'Dave Smith', + }, + }, + ]); + }); + + it('renders awards list given by multiple users', () => { + createComponent({ + awardEmoji: { + ...mockAwardsWidget, + nodes: [mockAwardEmojiThumbsUp, mockAwardEmojiDifferentUserThumbsUp], + }, + }); + + expect(findAwardsList().props('awards')).toEqual([ + { + name: EMOJI_THUMBSUP, + user: { + id: 5, + name: 'Dave Smith', + }, + }, + { + name: EMOJI_THUMBSUP, + user: { + id: 1, + name: 'John Doe', }, }, ]); }); it.each` - expectedAssertion | action | successHandler | mockAwardEmojiNodes - ${'added'} | ${EMOJI_ACTION_ADD} | ${awardEmojiAddSuccessHandler} | ${[]} - ${'removed'} | ${EMOJI_ACTION_REMOVE} | ${awardEmojiRemoveSuccessHandler} | ${[mockAwardEmojiThumbsUp]} + expectedAssertion | awardEmojiMutationHandler | mockAwardEmojiNodes | workItem + ${'added'} | ${awardEmojiAddSuccessHandler} | ${[]} | ${workItemQueryRemoveAwardEmojiResponse.data.workspace.workItems.nodes[0]} + ${'removed'} | ${awardEmojiRemoveSuccessHandler} | ${[mockAwardEmojiThumbsUp]} | ${workItemQueryAddAwardEmojiResponse.data.workspace.workItems.nodes[0]} `( 'calls mutation when an award emoji is $expectedAssertion', - async ({ action, successHandler, mockAwardEmojiNodes }) => { + ({ awardEmojiMutationHandler, mockAwardEmojiNodes, workItem }) => { createComponent({ - mockWorkItemUpdateMutationHandler: [updateWorkItemMutation, successHandler], + awardMutationHandler: awardEmojiMutationHandler, awardEmoji: { ...mockAwardsWidget, nodes: mockAwardEmojiNodes, }, + workItem, }); findAwardsList().vm.$emit('award', EMOJI_THUMBSUP); - await waitForPromises(); - - expect(successHandler).toHaveBeenCalledWith({ + expect(awardEmojiMutationHandler).toHaveBeenCalledWith({ input: { - id: mockWorkItem.id, - awardEmojiWidget: { - action, - name: EMOJI_THUMBSUP, - }, + awardableId: mockWorkItem.id, + name: EMOJI_THUMBSUP, }, }); }, @@ -146,7 +183,7 @@ describe('WorkItemAwardEmoji component', () => { it('emits error when the update mutation fails', async () => { createComponent({ - mockWorkItemUpdateMutationHandler: [updateWorkItemMutation, workItemUpdateFailureHandler], + awardMutationHandler: awardEmojiUpdateFailureHandler, }); findAwardsList().vm.$emit('award', EMOJI_THUMBSUP); @@ -167,4 +204,32 @@ describe('WorkItemAwardEmoji component', () => { expect(findAwardsList().props('canAwardEmoji')).toBe(false); }); }); + + describe('when a different users awards same emoji', () => { + beforeEach(() => { + window.gon = { + current_user_id: 1, + current_user_fullname: 'John Doe', + }; + }); + + it('calls mutation succesfully and adds the award emoji with proper user details', () => { + createComponent({ + awardMutationHandler: awardEmojiAddSuccessHandler, + awardEmoji: { + ...mockAwardsWidget, + nodes: [mockAwardEmojiThumbsUp], + }, + }); + + findAwardsList().vm.$emit('award', EMOJI_THUMBSUP); + + expect(awardEmojiAddSuccessHandler).toHaveBeenCalledWith({ + input: { + awardableId: mockWorkItem.id, + name: EMOJI_THUMBSUP, + }, + }); + }); + }); }); diff --git a/spec/frontend/work_items/components/work_item_description_spec.js b/spec/frontend/work_items/components/work_item_description_spec.js index 62cbb1bacb6..b910e9854f8 100644 --- a/spec/frontend/work_items/components/work_item_description_spec.js +++ b/spec/frontend/work_items/components/work_item_description_spec.js @@ -1,3 +1,4 @@ +import { GlForm } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; @@ -7,7 +8,6 @@ import waitForPromises from 'helpers/wait_for_promises'; import EditedAt from '~/issues/show/components/edited.vue'; import { updateDraft } from '~/lib/utils/autosave'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; -import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import WorkItemDescription from '~/work_items/components/work_item_description.vue'; import WorkItemDescriptionRendered from '~/work_items/components/work_item_description_rendered.vue'; @@ -36,22 +36,18 @@ describe('WorkItemDescription', () => { const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); const subscriptionHandler = jest.fn().mockResolvedValue(workItemDescriptionSubscriptionResponse); let workItemResponseHandler; - let workItemsMvc; - const findMarkdownField = () => wrapper.findComponent(MarkdownField); + const findForm = () => wrapper.findComponent(GlForm); const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor); const findRenderedDescription = () => wrapper.findComponent(WorkItemDescriptionRendered); const findEditedAt = () => wrapper.findComponent(EditedAt); - const editDescription = (newText) => { - if (workItemsMvc) { - return findMarkdownEditor().vm.$emit('input', newText); - } - return wrapper.find('textarea').setValue(newText); - }; + const editDescription = (newText) => findMarkdownEditor().vm.$emit('input', newText); - const clickCancel = () => wrapper.find('[data-testid="cancel"]').vm.$emit('click'); - const clickSave = () => wrapper.find('[data-testid="save-description"]').vm.$emit('click', {}); + const findCancelButton = () => wrapper.find('[data-testid="cancel"]'); + const findSubmitButton = () => wrapper.find('[data-testid="save-description"]'); + const clickCancel = () => findForm().vm.$emit('reset', new Event('reset')); + const clickSave = () => findForm().vm.$emit('submit', new Event('submit')); const createComponent = async ({ mutationHandler = mutationSuccessHandler, @@ -75,12 +71,6 @@ describe('WorkItemDescription', () => { }, provide: { fullPath: 'test-project-path', - glFeatures: { - workItemsMvc, - }, - }, - stubs: { - MarkdownField, }, }); @@ -93,11 +83,15 @@ describe('WorkItemDescription', () => { } }; - describe('editing description with workItemsMvc FF enabled', () => { - beforeEach(() => { - workItemsMvc = true; + it('has a subscription', async () => { + await createComponent(); + + expect(subscriptionHandler).toHaveBeenCalledWith({ + issuableId: workItemQueryResponse.data.workItem.id, }); + }); + describe('editing description', () => { it('passes correct autocompletion data and preview markdown sources and enables quick actions', async () => { const { iid, @@ -113,196 +107,162 @@ describe('WorkItemDescription', () => { autocompleteDataSources: autocompleteDataSources(fullPath, iid), }); }); - }); - - describe('editing description with workItemsMvc FF disabled', () => { - beforeEach(() => { - workItemsMvc = false; - }); - - it('passes correct autocompletion data and preview markdown sources', async () => { - const { - iid, - project: { fullPath }, - } = workItemQueryResponse.data.workItem; - - await createComponent({ isEditing: true }); + it('shows edited by text', async () => { + const lastEditedAt = '2022-09-21T06:18:42Z'; + const lastEditedBy = { + name: 'Administrator', + webPath: '/root', + }; + + await createComponent({ + workItemResponse: workItemByIidResponseFactory({ lastEditedAt, lastEditedBy }), + }); - expect(findMarkdownField().props()).toMatchObject({ - autocompleteDataSources: autocompleteDataSources(fullPath, iid), - markdownPreviewPath: markdownPreviewPath(fullPath, iid), - quickActionsDocsPath: wrapper.vm.$options.quickActionsDocsPath, + expect(findEditedAt().props()).toMatchObject({ + updatedAt: lastEditedAt, + updatedByName: lastEditedBy.name, + updatedByPath: lastEditedBy.webPath, }); }); - }); - describe.each([true, false])( - 'editing description with workItemsMvc %workItemsMvcEnabled', - (workItemsMvcEnabled) => { - beforeEach(() => { - beforeEach(() => { - workItemsMvc = workItemsMvcEnabled; - }); - }); + it('does not show edited by text', async () => { + await createComponent(); - it('has a subscription', async () => { - await createComponent(); + expect(findEditedAt().exists()).toBe(false); + }); - expect(subscriptionHandler).toHaveBeenCalledWith({ - issuableId: workItemQueryResponse.data.workItem.id, - }); + it('cancels when clicking cancel', async () => { + await createComponent({ + isEditing: true, }); - describe('editing description', () => { - it('shows edited by text', async () => { - const lastEditedAt = '2022-09-21T06:18:42Z'; - const lastEditedBy = { - name: 'Administrator', - webPath: '/root', - }; + clickCancel(); - await createComponent({ - workItemResponse: workItemByIidResponseFactory({ lastEditedAt, lastEditedBy }), - }); + await nextTick(); - expect(findEditedAt().props()).toMatchObject({ - updatedAt: lastEditedAt, - updatedByName: lastEditedBy.name, - updatedByPath: lastEditedBy.webPath, - }); - }); + expect(confirmAction).not.toHaveBeenCalled(); + expect(findMarkdownEditor().exists()).toBe(false); + }); - it('does not show edited by text', async () => { - await createComponent(); + it('prompts for confirmation when clicking cancel after changes', async () => { + await createComponent({ + isEditing: true, + }); - expect(findEditedAt().exists()).toBe(false); - }); + editDescription('updated desc'); - it('cancels when clicking cancel', async () => { - await createComponent({ - isEditing: true, - }); + clickCancel(); - clickCancel(); + await nextTick(); - await nextTick(); + expect(confirmAction).toHaveBeenCalled(); + }); - expect(confirmAction).not.toHaveBeenCalled(); - expect(findMarkdownField().exists()).toBe(false); - }); + it('calls update widgets mutation', async () => { + const updatedDesc = 'updated desc'; - it('prompts for confirmation when clicking cancel after changes', async () => { - await createComponent({ - isEditing: true, - }); + await createComponent({ + isEditing: true, + }); - editDescription('updated desc'); + editDescription(updatedDesc); - clickCancel(); + clickSave(); - await nextTick(); + await waitForPromises(); - expect(confirmAction).toHaveBeenCalled(); - }); + expect(mutationSuccessHandler).toHaveBeenCalledWith({ + input: { + id: workItemId, + descriptionWidget: { + description: updatedDesc, + }, + }, + }); + }); - it('calls update widgets mutation', async () => { - const updatedDesc = 'updated desc'; + it('tracks editing description', async () => { + await createComponent({ + isEditing: true, + markdownPreviewPath: '/preview', + }); + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - await createComponent({ - isEditing: true, - }); + clickSave(); - editDescription(updatedDesc); + await waitForPromises(); - clickSave(); + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_description', { + category: TRACKING_CATEGORY_SHOW, + label: 'item_description', + property: 'type_Task', + }); + }); - await waitForPromises(); + it('emits error when mutation returns error', async () => { + const error = 'eror'; - expect(mutationSuccessHandler).toHaveBeenCalledWith({ - input: { - id: workItemId, - descriptionWidget: { - description: updatedDesc, - }, + await createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockResolvedValue({ + data: { + workItemUpdate: { + workItem: {}, + errors: [error], }, - }); - }); - - it('tracks editing description', async () => { - await createComponent({ - isEditing: true, - markdownPreviewPath: '/preview', - }); - const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - - clickSave(); - - await waitForPromises(); - - expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_description', { - category: TRACKING_CATEGORY_SHOW, - label: 'item_description', - property: 'type_Task', - }); - }); - - it('emits error when mutation returns error', async () => { - const error = 'eror'; + }, + }), + }); - await createComponent({ - isEditing: true, - mutationHandler: jest.fn().mockResolvedValue({ - data: { - workItemUpdate: { - workItem: {}, - errors: [error], - }, - }, - }), - }); + editDescription('updated desc'); - editDescription('updated desc'); + clickSave(); - clickSave(); + await waitForPromises(); - await waitForPromises(); + expect(wrapper.emitted('error')).toEqual([[error]]); + }); - expect(wrapper.emitted('error')).toEqual([[error]]); - }); + it('emits error when mutation fails', async () => { + const error = 'eror'; - it('emits error when mutation fails', async () => { - const error = 'eror'; + await createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockRejectedValue(new Error(error)), + }); - await createComponent({ - isEditing: true, - mutationHandler: jest.fn().mockRejectedValue(new Error(error)), - }); + editDescription('updated desc'); - editDescription('updated desc'); + clickSave(); - clickSave(); + await waitForPromises(); - await waitForPromises(); + expect(wrapper.emitted('error')).toEqual([[error]]); + }); - expect(wrapper.emitted('error')).toEqual([[error]]); - }); + it('autosaves description', async () => { + await createComponent({ + isEditing: true, + }); - it('autosaves description', async () => { - await createComponent({ - isEditing: true, - }); + editDescription('updated desc'); - editDescription('updated desc'); + expect(updateDraft).toHaveBeenCalled(); + }); - expect(updateDraft).toHaveBeenCalled(); - }); + it('maps submit and cancel buttons to form actions', async () => { + await createComponent({ + isEditing: true, }); - it('calls the work item query', async () => { - await createComponent(); + expect(findCancelButton().attributes('type')).toBe('reset'); + expect(findSubmitButton().attributes('type')).toBe('submit'); + }); + }); + + it('calls the work item query', async () => { + await createComponent(); - expect(workItemResponseHandler).toHaveBeenCalled(); - }); - }, - ); + expect(workItemResponseHandler).toHaveBeenCalled(); + }); }); diff --git a/spec/frontend/work_items/components/work_item_detail_modal_spec.js b/spec/frontend/work_items/components/work_item_detail_modal_spec.js index e305cc310bd..6fa3a70c3eb 100644 --- a/spec/frontend/work_items/components/work_item_detail_modal_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_modal_spec.js @@ -33,7 +33,6 @@ describe('WorkItemDetailModal component', () => { const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail); const createComponent = ({ - error = false, deleteWorkItemMutationHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse), } = {}) => { const apolloProvider = createMockApollo([ @@ -46,19 +45,12 @@ describe('WorkItemDetailModal component', () => { workItemId, workItemIid: '1', }, - data() { - return { - error, - }; - }, provide: { fullPath: 'group/project', }, stubs: { GlModal, - WorkItemDetail: stubComponent(WorkItemDetail, { - apollo: {}, - }), + WorkItemDetail: stubComponent(WorkItemDetail), }, }); }; @@ -68,14 +60,18 @@ describe('WorkItemDetailModal component', () => { expect(findWorkItemDetail().props()).toEqual({ isModal: true, - workItemId, workItemIid: '1', workItemParentId: null, }); }); - it('renders alert if there is an error', () => { - createComponent({ error: true }); + it('renders alert if there is an error', async () => { + createComponent({ + deleteWorkItemMutationHandler: jest.fn().mockRejectedValue({ message: 'message' }), + }); + + findWorkItemDetail().vm.$emit('deleteWorkItem'); + await waitForPromises(); expect(findAlert().exists()).toBe(true); }); @@ -87,7 +83,13 @@ describe('WorkItemDetailModal component', () => { }); it('dismisses the alert on `dismiss` emitted event', async () => { - createComponent({ error: true }); + createComponent({ + deleteWorkItemMutationHandler: jest.fn().mockRejectedValue({ message: 'message' }), + }); + + findWorkItemDetail().vm.$emit('deleteWorkItem'); + await waitForPromises(); + findAlert().vm.$emit('dismiss'); await nextTick(); @@ -103,24 +105,19 @@ describe('WorkItemDetailModal component', () => { it('hides the modal when WorkItemDetail emits `close` event', () => { createComponent(); - const closeSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide'); findWorkItemDetail().vm.$emit('close'); - expect(closeSpy).toHaveBeenCalled(); + expect(hideModal).toHaveBeenCalled(); }); it('updates the work item when WorkItemDetail emits `update-modal` event', async () => { createComponent(); - findWorkItemDetail().vm.$emit('update-modal', undefined, { - id: 'updatedId', - iid: 'updatedIid', - }); - await waitForPromises(); + findWorkItemDetail().vm.$emit('update-modal', undefined, { iid: 'updatedIid' }); + await nextTick(); - expect(findWorkItemDetail().props().workItemId).toEqual('updatedId'); - expect(findWorkItemDetail().props().workItemIid).toEqual('updatedIid'); + expect(findWorkItemDetail().props('workItemIid')).toBe('updatedIid'); }); describe('delete work item', () => { diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js index 557ae07969e..d8ba8ea74f2 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -100,7 +100,6 @@ describe('WorkItemDetail component', () => { const createComponent = ({ isModal = false, updateInProgress = false, - workItemId = id, workItemIid = '1', handler = successHandler, subscriptionHandler = titleSubscriptionHandler, @@ -120,7 +119,10 @@ describe('WorkItemDetail component', () => { wrapper = shallowMount(WorkItemDetail, { apolloProvider: createMockApollo(handlers), isLoggedIn: isLoggedIn(), - propsData: { isModal, workItemId, workItemIid }, + propsData: { + isModal, + workItemIid, + }, data() { return { updateInProgress, @@ -160,9 +162,9 @@ describe('WorkItemDetail component', () => { setWindowLocation(''); }); - describe('when there is no `workItemId` and no `workItemIid` prop', () => { + describe('when there is no `workItemIid` prop', () => { beforeEach(() => { - createComponent({ workItemId: null, workItemIid: null }); + createComponent({ workItemIid: null }); }); it('skips the work item query', () => { @@ -437,7 +439,7 @@ describe('WorkItemDetail component', () => { }); it('sets the parent breadcrumb URL pointing to issue page when parent type is `Issue`', () => { - expect(findParentButton().attributes().href).toBe('../../issues/5'); + expect(findParentButton().attributes().href).toBe('../../-/issues/5'); }); it('sets the parent breadcrumb URL based on parent webUrl when parent type is not `Issue`', async () => { diff --git a/spec/frontend/work_items/components/work_item_due_date_spec.js b/spec/frontend/work_items/components/work_item_due_date_spec.js index b4811db8bed..5e8c34d90ee 100644 --- a/spec/frontend/work_items/components/work_item_due_date_spec.js +++ b/spec/frontend/work_items/components/work_item_due_date_spec.js @@ -3,6 +3,7 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mockTracking } from 'helpers/tracking_helper'; +import { stubComponent } from 'helpers/stub_component'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import WorkItemDueDate from '~/work_items/components/work_item_due_date.vue'; @@ -33,6 +34,7 @@ describe('WorkItemDueDate component', () => { dueDate = null, startDate = null, mutationHandler = updateWorkItemMutationHandler, + stubs = {}, } = {}) => { wrapper = mountExtended(WorkItemDueDate, { apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), @@ -43,6 +45,7 @@ describe('WorkItemDueDate component', () => { workItemId, workItemType: 'Task', }, + stubs, }); }; @@ -132,11 +135,21 @@ describe('WorkItemDueDate component', () => { describe('when the start date is later than the due date', () => { const startDate = new Date('2030-01-01T00:00:00.000Z'); - let datePickerOpenSpy; + const datePickerOpenSpy = jest.fn(); beforeEach(() => { - createComponent({ canUpdate: true, dueDate: '2022-12-31', startDate: '2022-12-31' }); - datePickerOpenSpy = jest.spyOn(wrapper.vm.$refs.dueDatePicker, 'show'); + createComponent({ + canUpdate: true, + dueDate: '2022-12-31', + startDate: '2022-12-31', + stubs: { + GlDatepicker: stubComponent(GlDatepicker, { + methods: { + show: datePickerOpenSpy, + }, + }), + }, + }); findStartDatePicker().vm.$emit('input', startDate); findStartDatePicker().vm.$emit('close'); }); diff --git a/spec/frontend/work_items/components/work_item_labels_spec.js b/spec/frontend/work_items/components/work_item_labels_spec.js index 554c9a4f7b8..6894aa236e3 100644 --- a/spec/frontend/work_items/components/work_item_labels_spec.js +++ b/spec/frontend/work_items/components/work_item_labels_spec.js @@ -266,7 +266,7 @@ describe('WorkItemLabels component', () => { }); it('skips calling the work item query when missing workItemIid', async () => { - createComponent({ workItemIid: null }); + createComponent({ workItemIid: '' }); await waitForPromises(); expect(workItemQuerySuccess).not.toHaveBeenCalled(); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js index b06be6c8083..cd077fbf705 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js @@ -6,16 +6,28 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import WorkItemChildrenWrapper from '~/work_items/components/work_item_links/work_item_children_wrapper.vue'; import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { childrenWorkItems, workItemByIidResponseFactory } from '../../mock_data'; +import { + changeWorkItemParentMutationResponse, + childrenWorkItems, + updateWorkItemMutationErrorResponse, + workItemByIidResponseFactory, +} from '../../mock_data'; describe('WorkItemChildrenWrapper', () => { let wrapper; + const $toast = { + show: jest.fn(), + }; const getWorkItemQueryHandler = jest.fn().mockResolvedValue(workItemByIidResponseFactory()); + const updateWorkItemMutationHandler = jest + .fn() + .mockResolvedValue(changeWorkItemParentMutationResponse); const findWorkItemLinkChildItems = () => wrapper.findAllComponents(WorkItemLinkChild); @@ -25,18 +37,33 @@ describe('WorkItemChildrenWrapper', () => { workItemType = 'Objective', confidential = false, children = childrenWorkItems, + mutationHandler = updateWorkItemMutationHandler, } = {}) => { + const mockApollo = createMockApollo([ + [workItemByIidQuery, getWorkItemQueryHandler], + [updateWorkItemMutation, mutationHandler], + ]); + + mockApollo.clients.defaultClient.cache.writeQuery({ + query: workItemByIidQuery, + variables: { fullPath: 'test/project', iid: '1' }, + data: workItemByIidResponseFactory().data, + }); + wrapper = shallowMountExtended(WorkItemChildrenWrapper, { - apolloProvider: createMockApollo([[workItemByIidQuery, getWorkItemQueryHandler]]), + apolloProvider: mockApollo, provide: { fullPath: 'test/project', }, propsData: { workItemType, workItemId: 'gid://gitlab/WorkItem/515', + workItemIid: '1', confidential, children, - fetchByIid: true, + }, + mocks: { + $toast, }, }); }; @@ -51,16 +78,6 @@ describe('WorkItemChildrenWrapper', () => { ); }); - it('remove event on child triggers `removeChild` event', () => { - createComponent(); - const workItem = { id: 'gid://gitlab/WorkItem/2' }; - const firstChild = findWorkItemLinkChildItems().at(0); - - firstChild.vm.$emit('removeChild', workItem); - - expect(wrapper.emitted('removeChild')).toEqual([[workItem]]); - }); - it('emits `show-modal` on `click` event', () => { createComponent(); const firstChild = findWorkItemLinkChildItems().at(0); @@ -95,4 +112,47 @@ describe('WorkItemChildrenWrapper', () => { } }, ); + + describe('when removing child work item', () => { + const workItem = { id: 'gid://gitlab/WorkItem/2' }; + + describe('when successful', () => { + beforeEach(async () => { + createComponent(); + findWorkItemLinkChildItems().at(0).vm.$emit('removeChild', workItem); + await waitForPromises(); + }); + + it('calls a mutation to update the work item', () => { + expect(updateWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { + id: workItem.id, + hierarchyWidget: { + parentId: null, + }, + }, + }); + }); + + it('shows a toast', () => { + expect($toast.show).toHaveBeenCalledWith('Child removed', { + action: { onClick: expect.anything(), text: 'Undo' }, + }); + }); + }); + + describe('when not successful', () => { + beforeEach(async () => { + createComponent({ + mutationHandler: jest.fn().mockResolvedValue(updateWorkItemMutationErrorResponse), + }); + findWorkItemLinkChildItems().at(0).vm.$emit('removeChild', workItem); + await waitForPromises(); + }); + + it('emits an error message', () => { + expect(wrapper.emitted('error')).toEqual([['Something went wrong while removing child.']]); + }); + }); + }); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js index 786f8604039..dd46505bd65 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js @@ -4,7 +4,7 @@ import { shallowMountExtended } 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 { stubComponent } from 'helpers/stub_component'; +import { RENDER_ALL_SLOTS_TEMPLATE, stubComponent } from 'helpers/stub_component'; import issueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql'; import { resolvers } from '~/graphql_shared/issuable_client'; import WidgetWrapper from '~/work_items/components/widget_wrapper.vue'; @@ -13,19 +13,14 @@ import WorkItemChildrenWrapper from '~/work_items/components/work_item_links/wor import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; import { FORM_TYPES } from '~/work_items/constants'; -import changeWorkItemParentMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; -import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import { getIssueDetailsResponse, workItemHierarchyResponse, workItemHierarchyEmptyResponse, workItemHierarchyNoUpdatePermissionResponse, - changeWorkItemParentMutationResponse, workItemByIidResponseFactory, - workItemQueryResponse, mockWorkItemCommentNote, - childrenWorkItems, } from '../../mock_data'; Vue.use(VueApollo); @@ -36,66 +31,48 @@ describe('WorkItemLinks', () => { let wrapper; let mockApollo; - const WORK_ITEM_ID = 'gid://gitlab/WorkItem/2'; - - const $toast = { - show: jest.fn(), - }; - - const mutationChangeParentHandler = jest - .fn() - .mockResolvedValue(changeWorkItemParentMutationResponse); - const childWorkItemByIidHandler = jest.fn().mockResolvedValue(workItemByIidResponseFactory()); const responseWithAddChildPermission = jest.fn().mockResolvedValue(workItemHierarchyResponse); const responseWithoutAddChildPermission = jest .fn() .mockResolvedValue(workItemByIidResponseFactory({ adminParentLink: false })); const createComponent = async ({ - data = {}, fetchHandler = responseWithAddChildPermission, - mutationHandler = mutationChangeParentHandler, issueDetailsQueryHandler = jest.fn().mockResolvedValue(getIssueDetailsResponse()), hasIterationsFeature = false, } = {}) => { mockApollo = createMockApollo( [ - [workItemQuery, fetchHandler], - [changeWorkItemParentMutation, mutationHandler], + [workItemByIidQuery, fetchHandler], [issueDetailsQuery, issueDetailsQueryHandler], - [workItemByIidQuery, childWorkItemByIidHandler], ], resolvers, { addTypename: true }, ); wrapper = shallowMountExtended(WorkItemLinks, { - data() { - return { - ...data, - }; - }, provide: { fullPath: 'project/path', hasIterationsFeature, reportAbusePath: '/report/abuse/path', }, - propsData: { issuableId: 1 }, - apolloProvider: mockApollo, - mocks: { - $toast, + propsData: { + issuableId: 1, + issuableIid: 1, }, + apolloProvider: mockApollo, stubs: { WorkItemDetailModal: stubComponent(WorkItemDetailModal, { methods: { show: showModal, }, }), + WidgetWrapper: stubComponent(WidgetWrapper, { + template: RENDER_ALL_SLOTS_TEMPLATE, + }), }, }); - wrapper.vm.$refs.wrapper.show = jest.fn(); - await waitForPromises(); }; @@ -122,8 +99,7 @@ describe('WorkItemLinks', () => { `( '$expectedAssertion "Add" button in hierarchy widget header when "userPermissions.adminParentLink" is $value', async ({ workItemFetchHandler, value }) => { - createComponent({ fetchHandler: workItemFetchHandler }); - await waitForPromises(); + await createComponent({ fetchHandler: workItemFetchHandler }); expect(findToggleFormDropdown().exists()).toBe(value); }, @@ -159,24 +135,6 @@ describe('WorkItemLinks', () => { expect(findAddLinksForm().exists()).toBe(false); }); - - it('adds work item child from the form', async () => { - const workItem = { - ...workItemQueryResponse.data.workItem, - id: 'gid://gitlab/WorkItem/11', - }; - await createComponent(); - findToggleFormDropdown().vm.$emit('click'); - findToggleCreateFormButton().vm.$emit('click'); - await nextTick(); - - expect(findWorkItemLinkChildrenWrapper().props().children).toHaveLength(4); - - findAddLinksForm().vm.$emit('addWorkItemChild', workItem); - await waitForPromises(); - - expect(findWorkItemLinkChildrenWrapper().props().children).toHaveLength(5); - }); }); describe('when no child links', () => { @@ -230,50 +188,6 @@ describe('WorkItemLinks', () => { }); }); - describe('remove child', () => { - let firstChild; - - beforeEach(async () => { - await createComponent({ mutationHandler: mutationChangeParentHandler }); - - [firstChild] = childrenWorkItems; - }); - - it('calls correct mutation with correct variables', async () => { - findWorkItemLinkChildrenWrapper().vm.$emit('removeChild', firstChild); - - await waitForPromises(); - - expect(mutationChangeParentHandler).toHaveBeenCalledWith({ - input: { - id: WORK_ITEM_ID, - hierarchyWidget: { - parentId: null, - }, - }, - }); - }); - - it('shows toast when mutation succeeds', async () => { - findWorkItemLinkChildrenWrapper().vm.$emit('removeChild', firstChild); - - await waitForPromises(); - - expect($toast.show).toHaveBeenCalledWith('Child removed', { - action: { onClick: expect.anything(), text: 'Undo' }, - }); - }); - - it('renders correct number of children after removal', async () => { - expect(findWorkItemLinkChildrenWrapper().props().children).toHaveLength(4); - - findWorkItemLinkChildrenWrapper().vm.$emit('removeChild', firstChild); - await waitForPromises(); - - expect(findWorkItemLinkChildrenWrapper().props().children).toHaveLength(3); - }); - }); - describe('when parent item is confidential', () => { it('passes correct confidentiality status to form', async () => { await createComponent({ @@ -289,16 +203,6 @@ describe('WorkItemLinks', () => { }); }); - it('starts prefetching work item by iid if URL contains work_item_iid query parameter', async () => { - setWindowLocation('?work_item_iid=5'); - await createComponent(); - - expect(childWorkItemByIidHandler).toHaveBeenCalledWith({ - iid: '5', - fullPath: 'project/path', - }); - }); - it('does not open the modal if work item iid URL parameter is not found in child items', async () => { setWindowLocation('?work_item_iid=555'); await createComponent(); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js index 06716584879..f3aa347f389 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js @@ -1,6 +1,7 @@ import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import WidgetWrapper from '~/work_items/components/widget_wrapper.vue'; import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue'; import WorkItemChildrenWrapper from '~/work_items/components/work_item_links/work_item_children_wrapper.vue'; import WorkItemLinksForm from '~/work_items/components/work_item_links/work_item_links_form.vue'; @@ -19,6 +20,7 @@ describe('WorkItemTree', () => { const findEmptyState = () => wrapper.findByTestId('tree-empty'); const findToggleFormSplitButton = () => wrapper.findComponent(OkrActionsSplitButton); const findForm = () => wrapper.findComponent(WorkItemLinksForm); + const findWidgetWrapper = () => wrapper.findComponent(WidgetWrapper); const findWorkItemLinkChildrenWrapper = () => wrapper.findComponent(WorkItemChildrenWrapper); const createComponent = ({ @@ -70,6 +72,16 @@ describe('WorkItemTree', () => { expect(findForm().exists()).toBe(false); }); + it('shows an error message on error', async () => { + const errorMessage = 'Some error'; + createComponent(); + + findWorkItemLinkChildrenWrapper().vm.$emit('error', errorMessage); + await nextTick(); + + expect(findWidgetWrapper().props('error')).toBe(errorMessage); + }); + it.each` option | event | formType | childType ${'New objective'} | ${'showCreateObjectiveForm'} | ${FORM_TYPES.create} | ${WORK_ITEM_TYPE_ENUM_OBJECTIVE} diff --git a/spec/frontend/work_items/graphql/cache_utils_spec.js b/spec/frontend/work_items/graphql/cache_utils_spec.js new file mode 100644 index 00000000000..6d0083790d1 --- /dev/null +++ b/spec/frontend/work_items/graphql/cache_utils_spec.js @@ -0,0 +1,153 @@ +import { WIDGET_TYPE_HIERARCHY } from '~/work_items/constants'; +import { addHierarchyChild, removeHierarchyChild } from '~/work_items/graphql/cache_utils'; +import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; + +describe('work items graphql cache utils', () => { + const fullPath = 'full/path'; + const iid = '10'; + const mockCacheData = { + workspace: { + workItems: { + nodes: [ + { + id: 'gid://gitlab/WorkItem/10', + title: 'Work item', + widgets: [ + { + type: WIDGET_TYPE_HIERARCHY, + children: { + nodes: [ + { + id: 'gid://gitlab/WorkItem/20', + title: 'Child', + }, + ], + }, + }, + ], + }, + ], + }, + }, + }; + + describe('addHierarchyChild', () => { + it('updates the work item with a new child', () => { + const mockCache = { + readQuery: () => mockCacheData, + writeQuery: jest.fn(), + }; + + const child = { + id: 'gid://gitlab/WorkItem/30', + title: 'New child', + }; + + addHierarchyChild(mockCache, fullPath, iid, child); + + expect(mockCache.writeQuery).toHaveBeenCalledWith({ + query: workItemByIidQuery, + variables: { fullPath, iid }, + data: { + workspace: { + workItems: { + nodes: [ + { + id: 'gid://gitlab/WorkItem/10', + title: 'Work item', + widgets: [ + { + type: WIDGET_TYPE_HIERARCHY, + children: { + nodes: [ + { + id: 'gid://gitlab/WorkItem/20', + title: 'Child', + }, + child, + ], + }, + }, + ], + }, + ], + }, + }, + }, + }); + }); + + it('does not update the work item when there is no cache data', () => { + const mockCache = { + readQuery: () => {}, + writeQuery: jest.fn(), + }; + + const child = { + id: 'gid://gitlab/WorkItem/30', + title: 'New child', + }; + + addHierarchyChild(mockCache, fullPath, iid, child); + + expect(mockCache.writeQuery).not.toHaveBeenCalled(); + }); + }); + + describe('removeHierarchyChild', () => { + it('updates the work item with a new child', () => { + const mockCache = { + readQuery: () => mockCacheData, + writeQuery: jest.fn(), + }; + + const childToRemove = { + id: 'gid://gitlab/WorkItem/20', + title: 'Child', + }; + + removeHierarchyChild(mockCache, fullPath, iid, childToRemove); + + expect(mockCache.writeQuery).toHaveBeenCalledWith({ + query: workItemByIidQuery, + variables: { fullPath, iid }, + data: { + workspace: { + workItems: { + nodes: [ + { + id: 'gid://gitlab/WorkItem/10', + title: 'Work item', + widgets: [ + { + type: WIDGET_TYPE_HIERARCHY, + children: { + nodes: [], + }, + }, + ], + }, + ], + }, + }, + }, + }); + }); + + it('does not update the work item when there is no cache data', () => { + const mockCache = { + readQuery: () => {}, + writeQuery: jest.fn(), + }; + + const childToRemove = { + id: 'gid://gitlab/WorkItem/20', + title: 'Child', + }; + + removeHierarchyChild(mockCache, fullPath, iid, childToRemove); + + expect(mockCache.writeQuery).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 05c6a21bb38..a873462ea63 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -51,6 +51,7 @@ export const mockAwardEmojiThumbsUp = { __typename: 'AwardEmoji', user: { id: 'gid://gitlab/User/5', + name: 'Dave Smith', __typename: 'UserCore', }, }; @@ -60,6 +61,7 @@ export const mockAwardEmojiThumbsDown = { __typename: 'AwardEmoji', user: { id: 'gid://gitlab/User/5', + name: 'Dave Smith', __typename: 'UserCore', }, }; @@ -95,6 +97,7 @@ export const workItemQueryResponse = { id: '1', fullPath: 'test-project-path', archived: false, + name: 'Project name', }, workItemType: { __typename: 'WorkItemType', @@ -107,6 +110,7 @@ export const workItemQueryResponse = { updateWorkItem: false, setWorkItemMetadata: false, adminParentLink: false, + createNote: false, __typename: 'WorkItemPermissions', }, widgets: [ @@ -198,6 +202,7 @@ export const updateWorkItemMutationResponse = { id: '1', fullPath: 'test-project-path', archived: false, + name: 'Project name', }, workItemType: { __typename: 'WorkItemType', @@ -210,8 +215,12 @@ export const updateWorkItemMutationResponse = { updateWorkItem: false, setWorkItemMetadata: false, adminParentLink: false, + createNote: false, __typename: 'WorkItemPermissions', }, + reference: 'test-project-path#1', + createNoteEmail: + 'gitlab-incoming+test-project-path-13fp7g6i9agekcv71s0jx9p58-issue-1@gmail.com', widgets: [ { type: 'HIERARCHY', @@ -302,6 +311,7 @@ export const convertWorkItemMutationResponse = { id: '1', fullPath: 'test-project-path', archived: false, + name: 'Project name', }, workItemType: { __typename: 'WorkItemType', @@ -314,8 +324,12 @@ export const convertWorkItemMutationResponse = { updateWorkItem: false, setWorkItemMetadata: false, adminParentLink: false, + createNote: false, __typename: 'WorkItemPermissions', }, + reference: 'gitlab-org/gitlab-test#1', + createNoteEmail: + 'gitlab-incoming+gitlab-org-gitlab-test-2-ddpzuq0zd2wefzofcpcdr3dg7-issue-1@gmail.com', widgets: [ { type: 'HIERARCHY', @@ -407,6 +421,7 @@ export const objectiveType = { export const workItemResponseFactory = ({ canUpdate = false, canDelete = false, + canCreateNote = false, adminParentLink = false, notificationsWidgetPresent = true, currentUserTodosWidgetPresent = true, @@ -454,6 +469,7 @@ export const workItemResponseFactory = ({ id: '1', fullPath: 'test-project-path', archived: false, + name: 'Project name', }, workItemType, userPermissions: { @@ -461,8 +477,12 @@ export const workItemResponseFactory = ({ updateWorkItem: canUpdate, setWorkItemMetadata: canUpdate, adminParentLink, + createNote: canCreateNote, __typename: 'WorkItemPermissions', }, + reference: 'test-project-path#1', + createNoteEmail: + 'gitlab-incoming+test-project-path-13fp7g6i9agekcv71s0jx9p58-issue-1@gmail.com', widgets: [ { __typename: 'WorkItemWidgetDescription', @@ -723,6 +743,7 @@ export const createWorkItemMutationResponse = { id: '1', fullPath: 'test-project-path', archived: false, + name: 'Project name', }, workItemType: { __typename: 'WorkItemType', @@ -735,8 +756,12 @@ export const createWorkItemMutationResponse = { updateWorkItem: false, setWorkItemMetadata: false, adminParentLink: false, + createNote: false, __typename: 'WorkItemPermissions', }, + reference: 'test-project-path#1', + createNoteEmail: + 'gitlab-incoming+test-project-path-13fp7g6i9agekcv71s0jx9p58-issue-1@gmail.com', widgets: [], }, errors: [], @@ -928,49 +953,62 @@ export const workItemMilestoneSubscriptionResponse = { export const workItemHierarchyEmptyResponse = { data: { - workItem: { - id: 'gid://gitlab/WorkItem/1', - iid: '1', - state: 'OPEN', - workItemType: { - id: 'gid://gitlab/WorkItems::Type/1', - name: 'Issue', - iconName: 'issue-type-issue', - __typename: 'WorkItemType', - }, - title: 'New title', - description: '', - createdAt: '2022-08-03T12:41:54Z', - updatedAt: null, - closedAt: null, - author: mockAssignees[0], - project: { - __typename: 'Project', - id: '1', - fullPath: 'test-project-path', - archived: false, - }, - userPermissions: { - deleteWorkItem: false, - updateWorkItem: false, - setWorkItemMetadata: false, - adminParentLink: false, - __typename: 'WorkItemPermissions', - }, - confidential: false, - widgets: [ - { - type: 'HIERARCHY', - parent: null, - hasChildren: false, - children: { - nodes: [], - __typename: 'WorkItemConnection', + workspace: { + __typename: 'Project', + id: 'gid://gitlab/Project/2', + workItems: { + nodes: [ + { + id: 'gid://gitlab/WorkItem/1', + iid: '1', + state: 'OPEN', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/1', + name: 'Issue', + iconName: 'issue-type-issue', + __typename: 'WorkItemType', + }, + title: 'New title', + description: '', + createdAt: '2022-08-03T12:41:54Z', + updatedAt: null, + closedAt: null, + author: mockAssignees[0], + project: { + __typename: 'Project', + id: '1', + fullPath: 'test-project-path', + archived: false, + name: 'Project name', + }, + userPermissions: { + deleteWorkItem: false, + updateWorkItem: false, + setWorkItemMetadata: false, + adminParentLink: false, + createNote: false, + __typename: 'WorkItemPermissions', + }, + confidential: false, + reference: 'test-project-path#1', + createNoteEmail: + 'gitlab-incoming+test-project-path-13fp7g6i9agekcv71s0jx9p58-issue-1@gmail.com', + widgets: [ + { + type: 'HIERARCHY', + parent: null, + hasChildren: false, + children: { + nodes: [], + __typename: 'WorkItemConnection', + }, + __typename: 'WorkItemWidgetHierarchy', + }, + ], + __typename: 'WorkItem', }, - __typename: 'WorkItemWidgetHierarchy', - }, - ], - __typename: 'WorkItem', + ], + }, }, }, }; @@ -998,6 +1036,7 @@ export const workItemHierarchyNoUpdatePermissionResponse = { updateWorkItem: false, setWorkItemMetadata: false, adminParentLink: false, + createNote: false, __typename: 'WorkItemPermissions', }, project: { @@ -1005,6 +1044,7 @@ export const workItemHierarchyNoUpdatePermissionResponse = { id: '1', fullPath: 'test-project-path', archived: false, + name: 'Project name', }, confidential: false, widgets: [ @@ -1126,51 +1166,64 @@ export const childrenWorkItems = [ export const workItemHierarchyResponse = { data: { - workItem: { - id: 'gid://gitlab/WorkItem/1', - iid: '1', - workItemType: { - id: 'gid://gitlab/WorkItems::Type/1', - name: 'Issue', - iconName: 'issue-type-issue', - __typename: 'WorkItemType', - }, - title: 'New title', - userPermissions: { - deleteWorkItem: true, - updateWorkItem: true, - setWorkItemMetadata: true, - adminParentLink: true, - __typename: 'WorkItemPermissions', - }, - author: { - ...mockAssignees[0], - }, - confidential: false, - project: { - __typename: 'Project', - id: '1', - fullPath: 'test-project-path', - archived: false, - }, - description: 'Issue description', - state: 'OPEN', - createdAt: '2022-08-03T12:41:54Z', - updatedAt: null, - closedAt: null, - widgets: [ - { - type: 'HIERARCHY', - parent: null, - hasChildren: true, - children: { - nodes: childrenWorkItems, - __typename: 'WorkItemConnection', + workspace: { + __typename: 'Project', + id: 'gid://gitlab/Project/2', + workItems: { + nodes: [ + { + id: 'gid://gitlab/WorkItem/1', + iid: '1', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/1', + name: 'Issue', + iconName: 'issue-type-issue', + __typename: 'WorkItemType', + }, + title: 'New title', + userPermissions: { + deleteWorkItem: true, + updateWorkItem: true, + setWorkItemMetadata: true, + adminParentLink: true, + createNote: true, + __typename: 'WorkItemPermissions', + }, + author: { + ...mockAssignees[0], + }, + confidential: false, + project: { + __typename: 'Project', + id: '1', + fullPath: 'test-project-path', + archived: false, + name: 'Project name', + }, + description: 'Issue description', + state: 'OPEN', + createdAt: '2022-08-03T12:41:54Z', + updatedAt: null, + closedAt: null, + reference: 'test-project-path#1', + createNoteEmail: + 'gitlab-incoming+test-project-path-13fp7g6i9agekcv71s0jx9p58-issue-1@gmail.com', + widgets: [ + { + type: 'HIERARCHY', + parent: null, + hasChildren: true, + children: { + nodes: childrenWorkItems, + __typename: 'WorkItemConnection', + }, + __typename: 'WorkItemWidgetHierarchy', + }, + ], + __typename: 'WorkItem', }, - __typename: 'WorkItemWidgetHierarchy', - }, - ], - __typename: 'WorkItem', + ], + }, }, }, }; @@ -1226,12 +1279,14 @@ export const workItemObjectiveWithChild = { id: '1', fullPath: 'test-project-path', archived: false, + name: 'Project name', }, userPermissions: { deleteWorkItem: true, updateWorkItem: true, setWorkItemMetadata: true, adminParentLink: true, + createNote: true, __typename: 'WorkItemPermissions', }, author: { @@ -1301,6 +1356,7 @@ export const workItemHierarchyTreeResponse = { updateWorkItem: true, setWorkItemMetadata: true, adminParentLink: true, + createNote: true, __typename: 'WorkItemPermissions', }, confidential: false, @@ -1309,6 +1365,7 @@ export const workItemHierarchyTreeResponse = { id: '1', fullPath: 'test-project-path', archived: false, + name: 'Project name', }, widgets: [ { @@ -1380,6 +1437,7 @@ export const changeIndirectWorkItemParentMutationResponse = { updateWorkItem: true, setWorkItemMetadata: true, adminParentLink: true, + createNote: true, __typename: 'WorkItemPermissions', }, description: null, @@ -1399,7 +1457,11 @@ export const changeIndirectWorkItemParentMutationResponse = { id: '1', fullPath: 'test-project-path', archived: false, + name: 'Project name', }, + reference: 'test-project-path#13', + createNoteEmail: + 'gitlab-incoming+test-project-path-13fp7g6i9agekcv71s0jx9p58-issue-13@gmail.com', widgets: [ { __typename: 'WorkItemWidgetHierarchy', @@ -1443,6 +1505,7 @@ export const changeWorkItemParentMutationResponse = { updateWorkItem: true, setWorkItemMetadata: true, adminParentLink: true, + createNote: true, __typename: 'WorkItemPermissions', }, description: null, @@ -1462,7 +1525,11 @@ export const changeWorkItemParentMutationResponse = { id: '1', fullPath: 'test-project-path', archived: false, + name: 'Project name', }, + reference: 'test-project-path#2', + createNoteEmail: + 'gitlab-incoming+test-project-path-13fp7g6i9agekcv71s0jx9p58-issue-2@gmail.com', widgets: [ { __typename: 'WorkItemWidgetHierarchy', @@ -1561,6 +1628,74 @@ export const projectMembersResponseWithCurrentUser = { }, }; +export const projectMembersResponseWithDuplicates = { + data: { + workspace: { + id: '1', + __typename: 'Project', + users: { + nodes: [ + { + id: 'user-2', + user: { + __typename: 'UserCore', + id: 'gid://gitlab/User/5', + avatarUrl: '/avatar2', + name: 'rookie', + username: 'rookie', + webUrl: 'rookie', + status: null, + }, + }, + { + id: 'user-4', + user: { + __typename: 'UserCore', + id: 'gid://gitlab/User/5', + avatarUrl: '/avatar2', + name: 'rookie', + username: 'rookie', + webUrl: 'rookie', + status: null, + }, + }, + { + id: 'user-1', + user: { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: '/root', + status: null, + }, + }, + { + id: 'user-3', + user: { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: '/root', + status: null, + }, + }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + startCursor: null, + }, + }, + }, + }, +}; + export const projectMembersResponseWithCurrentUserWithNextPage = { data: { workspace: { @@ -1867,6 +2002,8 @@ export const mockWorkItemNotesResponse = { lastEditedBy: null, system: true, internal: false, + maxAccessLevelOfAuthor: 'Owner', + authorIsContributor: false, discussion: { id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723561234', }, @@ -1879,6 +2016,10 @@ export const mockWorkItemNotesResponse = { repositionNote: true, __typename: 'NotePermissions', }, + systemNoteMetadata: { + id: 'gid://gitlab/SystemNoteMetadata/36', + descriptionVersion: null, + }, author: { avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', @@ -1912,6 +2053,8 @@ export const mockWorkItemNotesResponse = { lastEditedBy: null, system: true, internal: false, + maxAccessLevelOfAuthor: 'Owner', + authorIsContributor: false, discussion: { id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723565678', }, @@ -1924,6 +2067,10 @@ export const mockWorkItemNotesResponse = { repositionNote: true, __typename: 'NotePermissions', }, + systemNoteMetadata: { + id: 'gid://gitlab/SystemNoteMetadata/76', + descriptionVersion: null, + }, author: { avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', @@ -1956,6 +2103,8 @@ export const mockWorkItemNotesResponse = { lastEditedBy: null, system: true, internal: false, + maxAccessLevelOfAuthor: 'Owner', + authorIsContributor: false, discussion: { id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723560987', }, @@ -1968,6 +2117,10 @@ export const mockWorkItemNotesResponse = { repositionNote: true, __typename: 'NotePermissions', }, + systemNoteMetadata: { + id: 'gid://gitlab/SystemNoteMetadata/71', + descriptionVersion: null, + }, author: { avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', @@ -2060,6 +2213,8 @@ export const mockWorkItemNotesByIidResponse = { lastEditedBy: null, system: true, internal: false, + maxAccessLevelOfAuthor: null, + authorIsContributor: false, discussion: { id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723561234', @@ -2073,6 +2228,10 @@ export const mockWorkItemNotesByIidResponse = { repositionNote: true, __typename: 'NotePermissions', }, + systemNoteMetadata: { + id: 'gid://gitlab/SystemNoteMetadata/72', + descriptionVersion: null, + }, author: { id: 'gid://gitlab/User/1', avatarUrl: @@ -2107,6 +2266,8 @@ export const mockWorkItemNotesByIidResponse = { lastEditedBy: null, system: true, internal: false, + maxAccessLevelOfAuthor: null, + authorIsContributor: false, discussion: { id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723568765', @@ -2120,6 +2281,10 @@ export const mockWorkItemNotesByIidResponse = { repositionNote: true, __typename: 'NotePermissions', }, + systemNoteMetadata: { + id: 'gid://gitlab/SystemNoteMetadata/76', + descriptionVersion: null, + }, author: { id: 'gid://gitlab/User/1', avatarUrl: @@ -2155,6 +2320,8 @@ export const mockWorkItemNotesByIidResponse = { lastEditedBy: null, system: true, internal: false, + maxAccessLevelOfAuthor: null, + authorIsContributor: false, discussion: { id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723569876', @@ -2168,6 +2335,10 @@ export const mockWorkItemNotesByIidResponse = { repositionNote: true, __typename: 'NotePermissions', }, + systemNoteMetadata: { + id: 'gid://gitlab/SystemNoteMetadata/22', + descriptionVersion: null, + }, author: { id: 'gid://gitlab/User/1', avatarUrl: @@ -2261,6 +2432,8 @@ export const mockMoreWorkItemNotesResponse = { lastEditedBy: null, system: true, internal: false, + maxAccessLevelOfAuthor: 'Owner', + authorIsContributor: false, discussion: { id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da1112356a59e', @@ -2274,6 +2447,10 @@ export const mockMoreWorkItemNotesResponse = { repositionNote: true, __typename: 'NotePermissions', }, + systemNoteMetadata: { + id: 'gid://gitlab/SystemNoteMetadata/16', + descriptionVersion: null, + }, author: { avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', @@ -2308,6 +2485,8 @@ export const mockMoreWorkItemNotesResponse = { lastEditedBy: null, system: true, internal: false, + maxAccessLevelOfAuthor: 'Owner', + authorIsContributor: false, discussion: { id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da1272356a59e', @@ -2321,6 +2500,10 @@ export const mockMoreWorkItemNotesResponse = { repositionNote: true, __typename: 'NotePermissions', }, + systemNoteMetadata: { + id: 'gid://gitlab/SystemNoteMetadata/96', + descriptionVersion: null, + }, author: { avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', @@ -2353,6 +2536,8 @@ export const mockMoreWorkItemNotesResponse = { lastEditedBy: null, system: true, internal: false, + maxAccessLevelOfAuthor: 'Owner', + authorIsContributor: false, discussion: { id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723569876', @@ -2366,6 +2551,10 @@ export const mockMoreWorkItemNotesResponse = { repositionNote: true, __typename: 'NotePermissions', }, + systemNoteMetadata: { + id: 'gid://gitlab/SystemNoteMetadata/56', + descriptionVersion: null, + }, author: { avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', @@ -2417,6 +2606,8 @@ export const createWorkItemNoteResponse = { lastEditedAt: null, url: 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_191', lastEditedBy: null, + maxAccessLevelOfAuthor: 'Owner', + authorIsContributor: false, discussion: { id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122', __typename: 'Discussion', @@ -2430,6 +2621,7 @@ export const createWorkItemNoteResponse = { webUrl: 'http://127.0.0.1:3000/root', __typename: 'UserCore', }, + systemNoteMetadata: null, userPermissions: { adminNote: true, awardEmoji: true, @@ -2467,6 +2659,8 @@ export const mockWorkItemCommentNote = { lastEditedBy: null, system: false, internal: false, + maxAccessLevelOfAuthor: 'Owner', + authorIsContributor: false, discussion: { id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723569876', }, @@ -2479,6 +2673,7 @@ export const mockWorkItemCommentNote = { repositionNote: true, __typename: 'NotePermissions', }, + systemNoteMetadata: null, author: { avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', id: 'gid://gitlab/User/1', @@ -2489,6 +2684,16 @@ export const mockWorkItemCommentNote = { }, }; +export const mockWorkItemCommentNoteByContributor = { + ...mockWorkItemCommentNote, + authorIsContributor: true, +}; + +export const mockWorkItemCommentByMaintainer = { + ...mockWorkItemCommentNote, + maxAccessLevelOfAuthor: 'Maintainer', +}; + export const mockWorkItemNotesResponseWithComments = { data: { workspace: { @@ -2550,6 +2755,8 @@ export const mockWorkItemNotesResponseWithComments = { url: 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_191', lastEditedBy: null, + maxAccessLevelOfAuthor: 'Owner', + authorIsContributor: false, discussion: { id: 'gid://gitlab/Discussion/2bb1162fd0d39297d1a68fdd7d4083d3780af0f3', @@ -2564,6 +2771,7 @@ export const mockWorkItemNotesResponseWithComments = { webUrl: 'http://127.0.0.1:3000/root', __typename: 'UserCore', }, + systemNoteMetadata: null, userPermissions: { adminNote: true, awardEmoji: true, @@ -2587,6 +2795,8 @@ export const mockWorkItemNotesResponseWithComments = { url: 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_191', lastEditedBy: null, + maxAccessLevelOfAuthor: 'Owner', + authorIsContributor: false, discussion: { id: 'gid://gitlab/Discussion/2bb1162fd0d39297d1a68fdd7d4083d3780af0f3', @@ -2601,6 +2811,7 @@ export const mockWorkItemNotesResponseWithComments = { webUrl: 'http://127.0.0.1:3000/root', __typename: 'UserCore', }, + systemNoteMetadata: null, userPermissions: { adminNote: true, awardEmoji: true, @@ -2633,6 +2844,8 @@ export const mockWorkItemNotesResponseWithComments = { lastEditedBy: null, system: false, internal: false, + maxAccessLevelOfAuthor: 'Owner', + authorIsContributor: false, discussion: { id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723560987', @@ -2646,6 +2859,7 @@ export const mockWorkItemNotesResponseWithComments = { repositionNote: true, __typename: 'NotePermissions', }, + systemNoteMetadata: null, author: { avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', @@ -2704,6 +2918,8 @@ export const workItemNotesCreateSubscriptionResponse = { lastEditedBy: null, system: true, internal: false, + maxAccessLevelOfAuthor: 'Owner', + authorIsContributor: false, discussion: { id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723560987', }, @@ -2716,6 +2932,10 @@ export const workItemNotesCreateSubscriptionResponse = { repositionNote: true, __typename: 'NotePermissions', }, + systemNoteMetadata: { + id: 'gid://gitlab/SystemNoteMetadata/65', + descriptionVersion: null, + }, author: { avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', @@ -2739,6 +2959,10 @@ export const workItemNotesCreateSubscriptionResponse = { repositionNote: true, __typename: 'NotePermissions', }, + systemNoteMetadata: { + id: 'gid://gitlab/SystemNoteMetadata/26', + descriptionVersion: null, + }, author: { avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', @@ -2766,6 +2990,8 @@ export const workItemNotesUpdateSubscriptionResponse = { lastEditedBy: null, system: true, internal: false, + maxAccessLevelOfAuthor: 'Owner', + authorIsContributor: false, discussion: { id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723560987', }, @@ -2778,6 +3004,10 @@ export const workItemNotesUpdateSubscriptionResponse = { repositionNote: true, __typename: 'NotePermissions', }, + systemNoteMetadata: { + id: 'gid://gitlab/SystemNoteMetadata/46', + descriptionVersion: null, + }, author: { avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', @@ -2801,3 +3031,322 @@ export const workItemNotesDeleteSubscriptionResponse = { }, }, }; + +export const workItemSystemNoteWithMetadata = { + id: 'gid://gitlab/Note/1651', + body: 'changed the description', + bodyHtml: '<p data-sourcepos="1:1-1:23" dir="auto">changed the description</p>', + system: true, + internal: false, + systemNoteIconName: 'pencil', + createdAt: '2023-05-05T07:19:37Z', + lastEditedAt: '2023-05-05T07:19:37Z', + url: 'https://gdk.test:3443/flightjs/Flight/-/work_items/46#note_1651', + lastEditedBy: null, + maxAccessLevelOfAuthor: 'Owner', + authorIsContributor: false, + discussion: { + id: 'gid://gitlab/Discussion/7d4a46ea0525e2eeed451f7b718b0ebe73205374', + __typename: 'Discussion', + }, + author: { + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://secure.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + name: 'Administrator', + username: 'root', + webUrl: 'https://gdk.test:3443/root', + __typename: 'UserCore', + }, + userPermissions: { + adminNote: false, + awardEmoji: true, + readNote: true, + createNote: true, + resolveNote: true, + repositionNote: false, + __typename: 'NotePermissions', + }, + systemNoteMetadata: { + id: 'gid://gitlab/SystemNoteMetadata/670', + descriptionVersion: { + id: 'gid://gitlab/DescriptionVersion/167', + description: '5th May 90 987', + diff: '<span class="idiff">5th May 90</span><span class="idiff addition"> 987</span>', + diffPath: '/flightjs/Flight/-/issues/46/descriptions/167/diff', + deletePath: '/flightjs/Flight/-/issues/46/descriptions/167', + canDelete: true, + deleted: false, + startVersionId: '', + __typename: 'DescriptionVersion', + }, + __typename: 'SystemNoteMetadata', + }, + __typename: 'Note', +}; + +export const workItemNotesWithSystemNotesWithChangedDescription = { + data: { + workspace: { + id: 'gid://gitlab/Project/4', + workItems: { + nodes: [ + { + id: 'gid://gitlab/WorkItem/733', + iid: '79', + widgets: [ + { + __typename: 'WorkItemWidgetAssignees', + }, + { + __typename: 'WorkItemWidgetLabels', + }, + { + __typename: 'WorkItemWidgetDescription', + }, + { + __typename: 'WorkItemWidgetHierarchy', + }, + { + __typename: 'WorkItemWidgetMilestone', + }, + { + type: 'NOTES', + discussions: { + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + __typename: 'PageInfo', + }, + nodes: [ + { + id: 'gid://gitlab/Discussion/aa72f4c2f3eef66afa6d79a805178801ce4bd89f', + notes: { + nodes: [ + { + id: 'gid://gitlab/Note/1687', + body: 'changed the description', + bodyHtml: + '<p data-sourcepos="1:1-1:23" dir="auto">changed the description</p>', + system: true, + internal: false, + systemNoteIconName: 'pencil', + createdAt: '2023-05-10T05:21:01Z', + lastEditedAt: '2023-05-10T05:21:01Z', + url: 'https://gdk.test:3443/gnuwget/Wget2/-/work_items/79#note_1687', + lastEditedBy: null, + maxAccessLevelOfAuthor: 'Owner', + authorIsContributor: false, + discussion: { + id: + 'gid://gitlab/Discussion/aa72f4c2f3eef66afa6d79a805178801ce4bd89f', + __typename: 'Discussion', + }, + author: { + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://secure.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + name: 'Administrator', + username: 'root', + webUrl: 'https://gdk.test:3443/root', + __typename: 'UserCore', + }, + userPermissions: { + adminNote: false, + awardEmoji: true, + readNote: true, + createNote: true, + resolveNote: true, + repositionNote: false, + __typename: 'NotePermissions', + }, + systemNoteMetadata: { + id: 'gid://gitlab/SystemNoteMetadata/703', + descriptionVersion: { + id: 'gid://gitlab/DescriptionVersion/198', + description: 'Desc1', + diff: '<span class="idiff addition">Desc1</span>', + diffPath: '/gnuwget/Wget2/-/issues/79/descriptions/198/diff', + deletePath: '/gnuwget/Wget2/-/issues/79/descriptions/198', + canDelete: true, + deleted: false, + __typename: 'DescriptionVersion', + }, + __typename: 'SystemNoteMetadata', + }, + __typename: 'Note', + }, + ], + __typename: 'NoteConnection', + }, + __typename: 'Discussion', + }, + { + id: 'gid://gitlab/Discussion/a7d3cf7bd72f7a98f802845f538af65cb11a02cc', + notes: { + nodes: [ + { + id: 'gid://gitlab/Note/1688', + body: 'changed the description', + bodyHtml: + '<p data-sourcepos="1:1-1:23" dir="auto">changed the description</p>', + system: true, + internal: false, + systemNoteIconName: 'pencil', + createdAt: '2023-05-10T05:21:05Z', + lastEditedAt: '2023-05-10T05:21:05Z', + url: 'https://gdk.test:3443/gnuwget/Wget2/-/work_items/79#note_1688', + lastEditedBy: null, + maxAccessLevelOfAuthor: 'Owner', + authorIsContributor: false, + discussion: { + id: + 'gid://gitlab/Discussion/a7d3cf7bd72f7a98f802845f538af65cb11a02cc', + __typename: 'Discussion', + }, + author: { + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://secure.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + name: 'Administrator', + username: 'root', + webUrl: 'https://gdk.test:3443/root', + __typename: 'UserCore', + }, + userPermissions: { + adminNote: false, + awardEmoji: true, + readNote: true, + createNote: true, + resolveNote: true, + repositionNote: false, + __typename: 'NotePermissions', + }, + systemNoteMetadata: { + id: 'gid://gitlab/SystemNoteMetadata/704', + descriptionVersion: { + id: 'gid://gitlab/DescriptionVersion/199', + description: 'Desc2', + diff: + '<span class="idiff">Desc</span><span class="idiff deletion">1</span><span class="idiff addition">2</span>', + diffPath: '/gnuwget/Wget2/-/issues/79/descriptions/199/diff', + deletePath: '/gnuwget/Wget2/-/issues/79/descriptions/199', + canDelete: true, + deleted: false, + __typename: 'DescriptionVersion', + }, + __typename: 'SystemNoteMetadata', + }, + __typename: 'Note', + }, + ], + __typename: 'NoteConnection', + }, + __typename: 'Discussion', + }, + { + id: 'gid://gitlab/Discussion/391eed1ee0a258cc966a51dde900424f3b51b95d', + notes: { + nodes: [ + { + id: 'gid://gitlab/Note/1689', + body: 'changed the description', + bodyHtml: + '<p data-sourcepos="1:1-1:23" dir="auto">changed the description</p>', + system: true, + internal: false, + systemNoteIconName: 'pencil', + createdAt: '2023-05-10T05:21:08Z', + lastEditedAt: '2023-05-10T05:21:08Z', + url: 'https://gdk.test:3443/gnuwget/Wget2/-/work_items/79#note_1689', + lastEditedBy: null, + maxAccessLevelOfAuthor: 'Owner', + authorIsContributor: false, + discussion: { + id: + 'gid://gitlab/Discussion/391eed1ee0a258cc966a51dde900424f3b51b95d', + __typename: 'Discussion', + }, + author: { + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://secure.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + name: 'Administrator', + username: 'root', + webUrl: 'https://gdk.test:3443/root', + __typename: 'UserCore', + }, + userPermissions: { + adminNote: false, + awardEmoji: true, + readNote: true, + createNote: true, + resolveNote: true, + repositionNote: false, + __typename: 'NotePermissions', + }, + systemNoteMetadata: { + id: 'gid://gitlab/SystemNoteMetadata/705', + descriptionVersion: { + id: 'gid://gitlab/DescriptionVersion/200', + description: 'Desc3', + diff: + '<span class="idiff">Desc</span><span class="idiff deletion">2</span><span class="idiff addition">3</span>', + diffPath: '/gnuwget/Wget2/-/issues/79/descriptions/200/diff', + deletePath: '/gnuwget/Wget2/-/issues/79/descriptions/200', + canDelete: true, + deleted: false, + __typename: 'DescriptionVersion', + }, + __typename: 'SystemNoteMetadata', + }, + __typename: 'Note', + }, + ], + __typename: 'NoteConnection', + }, + __typename: 'Discussion', + }, + ], + __typename: 'DiscussionConnection', + }, + __typename: 'WorkItemWidgetNotes', + }, + { + __typename: 'WorkItemWidgetHealthStatus', + }, + { + __typename: 'WorkItemWidgetProgress', + }, + { + __typename: 'WorkItemWidgetNotifications', + }, + { + __typename: 'WorkItemWidgetCurrentUserTodos', + }, + { + __typename: 'WorkItemWidgetAwardEmoji', + }, + ], + __typename: 'WorkItem', + }, + ], + __typename: 'WorkItemConnection', + }, + __typename: 'Project', + }, + }, +}; + +export const getAwardEmojiResponse = (toggledOn) => { + return { + data: { + awardEmojiToggle: { + errors: [], + toggledOn, + }, + }, + }; +}; diff --git a/spec/frontend/work_items/notes/collapse_utils_spec.js b/spec/frontend/work_items/notes/collapse_utils_spec.js new file mode 100644 index 00000000000..c26ef891e9f --- /dev/null +++ b/spec/frontend/work_items/notes/collapse_utils_spec.js @@ -0,0 +1,29 @@ +import { + isDescriptionSystemNote, + getTimeDifferenceInMinutes, +} from '~/work_items/notes/collapse_utils'; +import { workItemSystemNoteWithMetadata } from '../mock_data'; + +describe('Work items collapse utils', () => { + it('checks if a system note is of a description type', () => { + expect(isDescriptionSystemNote(workItemSystemNoteWithMetadata)).toEqual(true); + }); + + it('returns false when a system note is not a description type', () => { + expect(isDescriptionSystemNote({ ...workItemSystemNoteWithMetadata, system: false })).toEqual( + false, + ); + }); + + it('gets the time difference between two notes', () => { + const anotherSystemNote = { + ...workItemSystemNoteWithMetadata, + createdAt: '2023-05-06T07:19:37Z', + }; + + // kept the dates 24 hours apart so 24 * 60 mins = 1440 + expect(getTimeDifferenceInMinutes(workItemSystemNoteWithMetadata, anotherSystemNote)).toEqual( + 1440, + ); + }); +}); 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 c480affe484..84b10f30418 100644 --- a/spec/frontend/work_items/pages/work_item_root_spec.js +++ b/spec/frontend/work_items/pages/work_item_root_spec.js @@ -34,7 +34,7 @@ describe('Work items root component', () => { issuesListPath, }, propsData: { - id: '1', + iid: '1', }, mocks: { $toast: { @@ -49,7 +49,6 @@ describe('Work items root component', () => { expect(findWorkItemDetail().props()).toEqual({ isModal: false, - workItemId: 'gid://gitlab/WorkItem/1', workItemParentId: null, workItemIid: '1', }); |