diff options
Diffstat (limited to 'spec/frontend')
497 files changed, 17272 insertions, 13276 deletions
diff --git a/spec/frontend/__helpers__/mock_observability_client.js b/spec/frontend/__helpers__/mock_observability_client.js index 82425aa2842..a65b5233b73 100644 --- a/spec/frontend/__helpers__/mock_observability_client.js +++ b/spec/frontend/__helpers__/mock_observability_client.js @@ -7,6 +7,7 @@ export function createMockClient() { servicesUrl: 'services-url', operationsUrl: 'operations-url', metricsUrl: 'metrics-url', + metricsSearchUrl: 'metrics-search-url', }); Object.getOwnPropertyNames(mockClient) diff --git a/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap b/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap index 2bd2b17a12d..7785693ff2a 100644 --- a/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap +++ b/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap @@ -11,7 +11,7 @@ exports[`~/access_tokens/components/expires_at_field should render datepicker wi arialabel="" autocomplete="" container="" - data-qa-selector="expiry_date_field" + data-testid="expiry-date-field" defaultdate="Wed Aug 05 2020 00:00:00 GMT+0000 (Greenwich Mean Time)" displayfield="true" firstday="0" diff --git a/spec/frontend/access_tokens/components/access_token_table_app_spec.js b/spec/frontend/access_tokens/components/access_token_table_app_spec.js index ae767f8b3f5..dd3fc3a9d98 100644 --- a/spec/frontend/access_tokens/components/access_token_table_app_spec.js +++ b/spec/frontend/access_tokens/components/access_token_table_app_spec.js @@ -25,7 +25,7 @@ describe('~/access_tokens/components/access_token_table_app', () => { expires_soon: true, expires_at: null, revoked: false, - revoke_path: '/-/profile/personal_access_tokens/1/revoke', + revoke_path: '/-/user_settings/personal_access_tokens/1/revoke', role: 'Maintainer', }, { @@ -37,7 +37,7 @@ describe('~/access_tokens/components/access_token_table_app', () => { expires_soon: false, expires_at: new Date().toISOString(), revoked: false, - revoke_path: '/-/profile/personal_access_tokens/2/revoke', + revoke_path: '/-/user_settings/personal_access_tokens/2/revoke', role: 'Maintainer', }, ]; @@ -153,8 +153,8 @@ describe('~/access_tokens/components/access_token_table_app', () => { let button = cells.at(6).findComponent(GlButton); expect(button.attributes()).toMatchObject({ 'aria-label': __('Revoke'), - 'data-qa-selector': __('revoke_button'), - href: '/-/profile/personal_access_tokens/1/revoke', + 'data-testid': 'revoke-button', + href: '/-/user_settings/personal_access_tokens/1/revoke', 'data-confirm': sprintf( __( 'Are you sure you want to revoke the %{accessTokenType} "%{tokenName}"? This action cannot be undone.', @@ -172,7 +172,7 @@ describe('~/access_tokens/components/access_token_table_app', () => { expect(cells.at(11).text()).toBe(__('Expired')); expect(cells.at(12).text()).toBe('Maintainer'); button = cells.at(13).findComponent(GlButton); - expect(button.attributes('href')).toBe('/-/profile/personal_access_tokens/2/revoke'); + expect(button.attributes('href')).toBe('/-/user_settings/personal_access_tokens/2/revoke'); expect(button.props('category')).toBe('tertiary'); }); diff --git a/spec/frontend/access_tokens/components/new_access_token_app_spec.js b/spec/frontend/access_tokens/components/new_access_token_app_spec.js index d51ac638f0e..966a69fa60a 100644 --- a/spec/frontend/access_tokens/components/new_access_token_app_spec.js +++ b/spec/frontend/access_tokens/components/new_access_token_app_spec.js @@ -81,20 +81,6 @@ describe('~/access_tokens/components/new_access_token_app', () => { ); }); - it('input field should contain QA-related selectors', async () => { - const newToken = '12345'; - await triggerSuccess(newToken); - - expect(findGlAlertError().exists()).toBe(false); - - const inputAttributes = wrapper - .findByLabelText(sprintf(__('Your new %{accessTokenType}'), { accessTokenType })) - .attributes(); - expect(inputAttributes).toMatchObject({ - 'data-qa-selector': 'created_access_token_field', - }); - }); - it('should render an info alert', async () => { await triggerSuccess(); diff --git a/spec/frontend/admin/abuse_report/components/abuse_report_notes_spec.js b/spec/frontend/admin/abuse_report/components/abuse_report_notes_spec.js index 166c735ffbd..a1993e2bde3 100644 --- a/spec/frontend/admin/abuse_report/components/abuse_report_notes_spec.js +++ b/spec/frontend/admin/abuse_report/components/abuse_report_notes_spec.js @@ -8,6 +8,7 @@ import SkeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_not import abuseReportNotesQuery from '~/admin/abuse_report/graphql/notes/abuse_report_notes.query.graphql'; import AbuseReportNotes from '~/admin/abuse_report/components/abuse_report_notes.vue'; import AbuseReportDiscussion from '~/admin/abuse_report/components/notes/abuse_report_discussion.vue'; +import AbuseReportAddNote from '~/admin/abuse_report/components/notes/abuse_report_add_note.vue'; import { mockAbuseReport, mockNotesByIdResponse } from '../mock_data'; @@ -24,6 +25,7 @@ describe('Abuse Report Notes', () => { const findSkeletonLoaders = () => wrapper.findAllComponents(SkeletonLoadingContainer); const findAbuseReportDiscussions = () => wrapper.findAllComponents(AbuseReportDiscussion); + const findAbuseReportAddNote = () => wrapper.findComponent(AbuseReportAddNote); const createComponent = ({ queryHandler = notesQueryHandler, @@ -78,6 +80,16 @@ describe('Abuse Report Notes', () => { discussion: discussions[1].notes.nodes, }); }); + + it('should show the comment form', () => { + expect(findAbuseReportAddNote().exists()).toBe(true); + + expect(findAbuseReportAddNote().props()).toMatchObject({ + abuseReportId: mockAbuseReportId, + discussionId: '', + isNewDiscussion: true, + }); + }); }); describe('When there is an error fetching the notes', () => { diff --git a/spec/frontend/admin/abuse_report/components/notes/abuse_report_add_note_spec.js b/spec/frontend/admin/abuse_report/components/notes/abuse_report_add_note_spec.js new file mode 100644 index 00000000000..959b52beaef --- /dev/null +++ b/spec/frontend/admin/abuse_report/components/notes/abuse_report_add_note_spec.js @@ -0,0 +1,227 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { createAlert } from '~/alert'; +import { clearDraft } from '~/lib/utils/autosave'; +import waitForPromises from 'helpers/wait_for_promises'; +import createNoteMutation from '~/admin/abuse_report/graphql/notes/create_abuse_report_note.mutation.graphql'; +import AbuseReportAddNote from '~/admin/abuse_report/components/notes/abuse_report_add_note.vue'; +import AbuseReportCommentForm from '~/admin/abuse_report/components/notes/abuse_report_comment_form.vue'; + +import { mockAbuseReport, createAbuseReportNoteResponse } from '../../mock_data'; + +jest.mock('~/alert'); +jest.mock('~/lib/utils/autosave'); +Vue.use(VueApollo); + +describe('Abuse Report Add Note', () => { + let wrapper; + + const mockAbuseReportId = mockAbuseReport.report.globalId; + const mockDiscussionId = 'gid://gitlab/Discussion/9c7228e06fb0339a3d1440fcda960acfd8baa43a'; + + const mutationSuccessHandler = jest.fn().mockResolvedValue(createAbuseReportNoteResponse); + + const findTimelineEntry = () => wrapper.findByTestId('abuse-report-note-timeline-entry'); + const findTimelineEntryInner = () => + wrapper.findByTestId('abuse-report-note-timeline-entry-inner'); + const findCommentFormWrapper = () => wrapper.findByTestId('abuse-report-comment-form-wrapper'); + + const findAbuseReportCommentForm = () => wrapper.findComponent(AbuseReportCommentForm); + const findReplyTextarea = () => wrapper.findByTestId('abuse-report-note-reply-textarea'); + + const createComponent = ({ + mutationHandler = mutationSuccessHandler, + abuseReportId = mockAbuseReportId, + discussionId = '', + isNewDiscussion = true, + showCommentForm = false, + } = {}) => { + wrapper = shallowMountExtended(AbuseReportAddNote, { + apolloProvider: createMockApollo([[createNoteMutation, mutationHandler]]), + propsData: { + abuseReportId, + discussionId, + isNewDiscussion, + showCommentForm, + }, + }); + }; + + describe('Default', () => { + beforeEach(() => { + createComponent(); + }); + + it('should show the comment form', () => { + expect(findAbuseReportCommentForm().exists()).toBe(true); + expect(findAbuseReportCommentForm().props()).toMatchObject({ + abuseReportId: mockAbuseReportId, + isSubmitting: false, + autosaveKey: `${mockAbuseReportId}-comment`, + commentButtonText: 'Comment', + initialValue: '', + }); + }); + + it('should not show the reply textarea', () => { + expect(findReplyTextarea().exists()).toBe(false); + }); + + it('should add the correct classList to timeline-entry', () => { + expect(findTimelineEntry().classes()).toEqual( + expect.arrayContaining(['timeline-entry', 'note-form']), + ); + + expect(findTimelineEntryInner().classes()).toEqual(['timeline-entry-inner']); + }); + }); + + describe('When the main comments has replies', () => { + beforeEach(() => { + createComponent({ + discussionId: 'gid://gitlab/Discussion/9c7228e06fb0339a3d1440fcda960acfd8baa43a', + isNewDiscussion: false, + }); + }); + + it('should add the correct classLists', () => { + expect(findTimelineEntry().classes()).toEqual( + expect.arrayContaining([ + 'note', + 'note-wrapper', + 'note-comment', + 'discussion-reply-holder', + 'gl-border-t-0!', + 'clearfix', + ]), + ); + + expect(findTimelineEntryInner().classes()).toEqual([]); + + expect(findCommentFormWrapper().classes()).toEqual( + expect.arrayContaining([ + 'gl-relative', + 'gl-display-flex', + 'gl-align-items-flex-start', + 'gl-flex-nowrap', + ]), + ); + }); + + it('should show not the comment form', () => { + expect(findAbuseReportCommentForm().exists()).toBe(false); + }); + + it('should show the reply textarea', () => { + expect(findReplyTextarea().exists()).toBe(true); + expect(findReplyTextarea().attributes()).toMatchObject({ + rows: '1', + placeholder: 'Reply…', + 'aria-label': 'Reply to comment', + }); + }); + }); + + describe('Adding a comment', () => { + const noteText = 'mock note'; + + beforeEach(() => { + createComponent(); + + findAbuseReportCommentForm().vm.$emit('submitForm', { + commentText: noteText, + }); + }); + + it('should call the mutation with provided noteText', async () => { + expect(findAbuseReportCommentForm().props('isSubmitting')).toBe(true); + + expect(mutationSuccessHandler).toHaveBeenCalledWith({ + input: { + noteableId: mockAbuseReportId, + body: noteText, + discussionId: null, + }, + }); + + await waitForPromises(); + + expect(findAbuseReportCommentForm().props('isSubmitting')).toBe(false); + }); + + it('should add the correct classList to comment-form wrapper', () => { + expect(findCommentFormWrapper().classes()).toEqual([]); + }); + + it('should clear draft from local storage', async () => { + await waitForPromises(); + + expect(clearDraft).toHaveBeenCalledWith(`${mockAbuseReportId}-comment`); + }); + + it('should emit `cancelEditing` event', async () => { + await waitForPromises(); + + expect(wrapper.emitted('cancelEditing')).toHaveLength(1); + }); + + it.each` + description | errorResponse + ${'with an error response'} | ${new Error('The discussion could not be found')} + ${'without an error ressponse'} | ${null} + `('should show an error when mutation fails $description', async ({ errorResponse }) => { + createComponent({ + mutationHandler: jest.fn().mockRejectedValue(errorResponse), + }); + + findAbuseReportCommentForm().vm.$emit('submitForm', { + commentText: noteText, + }); + + await waitForPromises(); + + const errorMessage = errorResponse + ? 'Your comment could not be submitted because the discussion could not be found.' + : 'Your comment could not be submitted! Please check your network connection and try again.'; + + expect(createAlert).toHaveBeenCalledWith({ + message: errorMessage, + captureError: true, + parent: expect.anything(), + }); + }); + }); + + describe('Replying to a comment', () => { + beforeEach(() => { + createComponent({ + discussionId: mockDiscussionId, + isNewDiscussion: false, + showCommentForm: false, + }); + }); + + it('should not show the comment form', () => { + expect(findAbuseReportCommentForm().exists()).toBe(false); + }); + + it('should show comment form when reply textarea is clicked on', async () => { + await findReplyTextarea().trigger('click'); + + expect(findAbuseReportCommentForm().exists()).toBe(true); + expect(findAbuseReportCommentForm().props('commentButtonText')).toBe('Reply'); + }); + + it('should show comment form if `showCommentForm` is true', () => { + createComponent({ + discussionId: mockDiscussionId, + isNewDiscussion: false, + showCommentForm: true, + }); + + expect(findAbuseReportCommentForm().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/admin/abuse_report/components/notes/abuse_report_comment_form_spec.js b/spec/frontend/admin/abuse_report/components/notes/abuse_report_comment_form_spec.js new file mode 100644 index 00000000000..2265ef7d441 --- /dev/null +++ b/spec/frontend/admin/abuse_report/components/notes/abuse_report_comment_form_spec.js @@ -0,0 +1,214 @@ +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import { ESC_KEY, ENTER_KEY } from '~/lib/utils/keys'; +import * as autosave from '~/lib/utils/autosave'; +import * as confirmViaGlModal from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; + +import AbuseReportCommentForm from '~/admin/abuse_report/components/notes/abuse_report_comment_form.vue'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; + +import { mockAbuseReport } from '../../mock_data'; + +jest.mock('~/lib/utils/autosave', () => ({ + updateDraft: jest.fn(), + clearDraft: jest.fn(), + getDraft: jest.fn().mockReturnValue(''), +})); + +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal', () => ({ + confirmAction: jest.fn().mockResolvedValue(true), +})); + +describe('Abuse Report Comment Form', () => { + let wrapper; + + const mockAbuseReportId = mockAbuseReport.report.globalId; + const mockAutosaveKey = `${mockAbuseReportId}-comment`; + const mockInitialValue = 'note text'; + + const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor); + const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]'); + const findCommentButton = () => wrapper.find('[data-testid="comment-button"]'); + + const createComponent = ({ + abuseReportId = mockAbuseReportId, + isSubmitting = false, + initialValue = mockInitialValue, + autosaveKey = mockAutosaveKey, + commentButtonText = 'Comment', + } = {}) => { + wrapper = shallowMount(AbuseReportCommentForm, { + propsData: { + abuseReportId, + isSubmitting, + initialValue, + autosaveKey, + commentButtonText, + }, + provide: { + uploadNoteAttachmentPath: 'test-upload-path', + }, + }); + }; + + describe('Markdown editor', () => { + it('should show markdown editor', () => { + createComponent(); + + expect(findMarkdownEditor().exists()).toBe(true); + + expect(findMarkdownEditor().props()).toMatchObject({ + value: mockInitialValue, + renderMarkdownPath: '', + uploadsPath: 'test-upload-path', + enableContentEditor: false, + formFieldProps: { + 'aria-label': 'Add a reply', + placeholder: 'Write a comment or drag your files here…', + id: 'abuse-report-add-or-edit-comment', + name: 'abuse-report-add-or-edit-comment', + }, + markdownDocsPath: '/help/user/markdown', + }); + }); + + it('should pass the draft from local storage if it exists', () => { + jest.spyOn(autosave, 'getDraft').mockImplementation(() => 'draft comment'); + createComponent(); + + expect(findMarkdownEditor().props('value')).toBe('draft comment'); + }); + + it('should pass an empty string if both draft and initialValue are empty', () => { + jest.spyOn(autosave, 'getDraft').mockImplementation(() => ''); + createComponent({ initialValue: '' }); + + expect(findMarkdownEditor().props('value')).toBe(''); + }); + }); + + describe('Markdown Editor input', () => { + beforeEach(() => { + createComponent(); + }); + + it('should set the correct comment text value', async () => { + findMarkdownEditor().vm.$emit('input', 'new comment'); + await nextTick(); + + expect(findMarkdownEditor().props('value')).toBe('new comment'); + }); + + it('should call `updateDraft` with correct parameters', () => { + findMarkdownEditor().vm.$emit('input', 'new comment'); + + expect(autosave.updateDraft).toHaveBeenCalledWith(mockAutosaveKey, 'new comment'); + }); + }); + + describe('Submitting a comment', () => { + beforeEach(() => { + jest.spyOn(autosave, 'getDraft').mockImplementation(() => 'draft comment'); + createComponent(); + }); + + it('should show comment button', () => { + expect(findCommentButton().exists()).toBe(true); + expect(findCommentButton().text()).toBe('Comment'); + }); + + it('should show `Reply` button if its not a new discussion', () => { + createComponent({ commentButtonText: 'Reply' }); + expect(findCommentButton().text()).toBe('Reply'); + }); + + describe('when enter with meta key is pressed', () => { + beforeEach(() => { + findMarkdownEditor().vm.$emit( + 'keydown', + new KeyboardEvent('keydown', { key: ENTER_KEY, metaKey: true }), + ); + }); + + it('should emit `submitForm` event with correct parameters', () => { + expect(wrapper.emitted('submitForm')).toEqual([[{ commentText: 'draft comment' }]]); + }); + }); + + describe('when ctrl+enter is pressed', () => { + beforeEach(() => { + findMarkdownEditor().vm.$emit( + 'keydown', + new KeyboardEvent('keydown', { key: ENTER_KEY, ctrlKey: true }), + ); + }); + + it('should emit `submitForm` event with correct parameters', () => { + expect(wrapper.emitted('submitForm')).toEqual([[{ commentText: 'draft comment' }]]); + }); + }); + + describe('when comment button is clicked', () => { + beforeEach(() => { + findCommentButton().vm.$emit('click'); + }); + + it('should emit `submitForm` event with correct parameters', () => { + expect(wrapper.emitted('submitForm')).toEqual([[{ commentText: 'draft comment' }]]); + }); + }); + }); + + describe('Cancel editing', () => { + beforeEach(() => { + jest.spyOn(autosave, 'getDraft').mockImplementation(() => 'draft comment'); + createComponent(); + }); + + it('should show cancel button', () => { + expect(findCancelButton().exists()).toBe(true); + expect(findCancelButton().text()).toBe('Cancel'); + }); + + describe('when escape key is pressed', () => { + beforeEach(() => { + findMarkdownEditor().vm.$emit('keydown', new KeyboardEvent('keydown', { key: ESC_KEY })); + + return waitForPromises(); + }); + + it('should confirm a user action if comment text is not empty', () => { + expect(confirmViaGlModal.confirmAction).toHaveBeenCalled(); + }); + + it('should clear draft from local storage', () => { + expect(autosave.clearDraft).toHaveBeenCalledWith(mockAutosaveKey); + }); + + it('should emit `cancelEditing` event', () => { + expect(wrapper.emitted('cancelEditing')).toHaveLength(1); + }); + }); + + describe('when cancel button is clicked', () => { + beforeEach(() => { + findCancelButton().vm.$emit('click'); + + return waitForPromises(); + }); + + it('should confirm a user action if comment text is not empty', () => { + expect(confirmViaGlModal.confirmAction).toHaveBeenCalled(); + }); + + it('should clear draft from local storage', () => { + expect(autosave.clearDraft).toHaveBeenCalledWith(mockAutosaveKey); + }); + + it('should emit `cancelEditing` event', () => { + expect(wrapper.emitted('cancelEditing')).toHaveLength(1); + }); + }); + }); +}); diff --git a/spec/frontend/admin/abuse_report/components/notes/abuse_report_discussion_spec.js b/spec/frontend/admin/abuse_report/components/notes/abuse_report_discussion_spec.js index 86f0939a938..fdc049725a4 100644 --- a/spec/frontend/admin/abuse_report/components/notes/abuse_report_discussion_spec.js +++ b/spec/frontend/admin/abuse_report/components/notes/abuse_report_discussion_spec.js @@ -4,6 +4,7 @@ import ToggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import AbuseReportDiscussion from '~/admin/abuse_report/components/notes/abuse_report_discussion.vue'; import AbuseReportNote from '~/admin/abuse_report/components/notes/abuse_report_note.vue'; +import AbuseReportAddNote from '~/admin/abuse_report/components/notes/abuse_report_add_note.vue'; import { mockAbuseReport, @@ -19,6 +20,7 @@ describe('Abuse Report Discussion', () => { const findAbuseReportNotes = () => wrapper.findAllComponents(AbuseReportNote); const findTimelineEntryItem = () => wrapper.findComponent(TimelineEntryItem); const findToggleRepliesWidget = () => wrapper.findComponent(ToggleRepliesWidget); + const findAbuseReportAddNote = () => wrapper.findComponent(AbuseReportAddNote); const createComponent = ({ discussion = mockDiscussionWithNoReplies, @@ -43,6 +45,7 @@ describe('Abuse Report Discussion', () => { expect(findAbuseReportNote().props()).toMatchObject({ abuseReportId: mockAbuseReportId, note: mockDiscussionWithNoReplies[0], + showReplyButton: true, }); }); @@ -50,9 +53,13 @@ describe('Abuse Report Discussion', () => { expect(findTimelineEntryItem().exists()).toBe(false); }); - it('should not show the the toggle replies widget wrapper when no replies', () => { + it('should not show the toggle replies widget wrapper when there are no replies', () => { expect(findToggleRepliesWidget().exists()).toBe(false); }); + + it('should not show the comment form there are no replies', () => { + expect(findAbuseReportAddNote().exists()).toBe(false); + }); }); describe('When the main comments has replies', () => { @@ -75,5 +82,68 @@ describe('Abuse Report Discussion', () => { await nextTick(); expect(findAbuseReportNotes()).toHaveLength(1); }); + + it('should show the comment form', () => { + expect(findAbuseReportAddNote().exists()).toBe(true); + + expect(findAbuseReportAddNote().props()).toMatchObject({ + abuseReportId: mockAbuseReportId, + discussionId: mockDiscussionWithReplies[0].discussion.id, + isNewDiscussion: false, + }); + }); + + it('should show the reply button only for the main comment', () => { + expect(findAbuseReportNotes().at(0).props('showReplyButton')).toBe(true); + + expect(findAbuseReportNotes().at(1).props('showReplyButton')).toBe(false); + expect(findAbuseReportNotes().at(2).props('showReplyButton')).toBe(false); + }); + }); + + describe('Replying to a comment when it has no replies', () => { + beforeEach(() => { + createComponent(); + }); + + it('should show comment form when `startReplying` is emitted', async () => { + expect(findAbuseReportAddNote().exists()).toBe(false); + + findAbuseReportNote().vm.$emit('startReplying'); + await nextTick(); + + expect(findAbuseReportAddNote().exists()).toBe(true); + expect(findAbuseReportAddNote().props('showCommentForm')).toBe(true); + }); + + it('should hide the comment form when `cancelEditing` is emitted', async () => { + findAbuseReportNote().vm.$emit('startReplying'); + await nextTick(); + + findAbuseReportAddNote().vm.$emit('cancelEditing'); + await nextTick(); + + expect(findAbuseReportAddNote().exists()).toBe(false); + }); + }); + + describe('Replying to a comment with replies', () => { + beforeEach(() => { + createComponent({ + discussion: mockDiscussionWithReplies, + }); + }); + + it('should show reply textarea, but not comment form', () => { + expect(findAbuseReportAddNote().exists()).toBe(true); + expect(findAbuseReportAddNote().props('showCommentForm')).toBe(false); + }); + + it('should show comment form when reply button on main comment is clicked', async () => { + findAbuseReportNotes().at(0).vm.$emit('startReplying'); + await nextTick(); + + expect(findAbuseReportAddNote().props('showCommentForm')).toBe(true); + }); }); }); diff --git a/spec/frontend/admin/abuse_report/components/notes/abuse_report_edit_note_spec.js b/spec/frontend/admin/abuse_report/components/notes/abuse_report_edit_note_spec.js new file mode 100644 index 00000000000..88f243b2501 --- /dev/null +++ b/spec/frontend/admin/abuse_report/components/notes/abuse_report_edit_note_spec.js @@ -0,0 +1,129 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { createAlert } from '~/alert'; +import { clearDraft } from '~/lib/utils/autosave'; +import waitForPromises from 'helpers/wait_for_promises'; +import updateNoteMutation from '~/admin/abuse_report/graphql/notes/update_abuse_report_note.mutation.graphql'; +import AbuseReportEditNote from '~/admin/abuse_report/components/notes/abuse_report_edit_note.vue'; +import AbuseReportCommentForm from '~/admin/abuse_report/components/notes/abuse_report_comment_form.vue'; + +import { + mockAbuseReport, + mockDiscussionWithNoReplies, + editAbuseReportNoteResponse, +} from '../../mock_data'; + +jest.mock('~/alert'); +jest.mock('~/lib/utils/autosave'); +Vue.use(VueApollo); + +describe('Abuse Report Edit Note', () => { + let wrapper; + + const mockAbuseReportId = mockAbuseReport.report.globalId; + const mockNote = mockDiscussionWithNoReplies[0]; + + const mutationSuccessHandler = jest.fn().mockResolvedValue(editAbuseReportNoteResponse); + + const findAbuseReportCommentForm = () => wrapper.findComponent(AbuseReportCommentForm); + + const createComponent = ({ + mutationHandler = mutationSuccessHandler, + abuseReportId = mockAbuseReportId, + discussionId = '', + note = mockNote, + } = {}) => { + wrapper = shallowMountExtended(AbuseReportEditNote, { + apolloProvider: createMockApollo([[updateNoteMutation, mutationHandler]]), + propsData: { + abuseReportId, + discussionId, + note, + }, + }); + }; + + describe('Default', () => { + beforeEach(() => { + createComponent(); + }); + + it('should show the comment form', () => { + expect(findAbuseReportCommentForm().exists()).toBe(true); + expect(findAbuseReportCommentForm().props()).toMatchObject({ + abuseReportId: mockAbuseReportId, + isSubmitting: false, + autosaveKey: `${mockNote.id}-comment`, + commentButtonText: 'Save comment', + initialValue: mockNote.body, + }); + }); + }); + + describe('Editing a comment', () => { + const noteText = 'Updated comment'; + + beforeEach(() => { + createComponent(); + + findAbuseReportCommentForm().vm.$emit('submitForm', { + commentText: noteText, + }); + }); + + it('should call the mutation with provided noteText', async () => { + expect(findAbuseReportCommentForm().props('isSubmitting')).toBe(true); + + expect(mutationSuccessHandler).toHaveBeenCalledWith({ + input: { + id: mockNote.id, + body: noteText, + }, + }); + + await waitForPromises(); + + expect(findAbuseReportCommentForm().props('isSubmitting')).toBe(false); + }); + + it('should clear draft from local storage', async () => { + await waitForPromises(); + + expect(clearDraft).toHaveBeenCalledWith(`${mockNote.id}-comment`); + }); + + it('should emit `cancelEditing` event', async () => { + await waitForPromises(); + + expect(wrapper.emitted('cancelEditing')).toHaveLength(1); + }); + + it.each` + description | errorResponse + ${'with an error response'} | ${new Error('The note could not be found')} + ${'without an error ressponse'} | ${null} + `('should show an error when mutation fails $description', async ({ errorResponse }) => { + createComponent({ + mutationHandler: jest.fn().mockRejectedValue(errorResponse), + }); + + findAbuseReportCommentForm().vm.$emit('submitForm', { + commentText: noteText, + }); + + await waitForPromises(); + + const errorMessage = errorResponse + ? 'Your comment could not be updated because the note could not be found.' + : 'Something went wrong while editing your comment. Please try again.'; + + expect(createAlert).toHaveBeenCalledWith({ + message: errorMessage, + captureError: true, + parent: wrapper.vm.$el, + }); + }); + }); +}); diff --git a/spec/frontend/admin/abuse_report/components/notes/abuse_report_note_actions_spec.js b/spec/frontend/admin/abuse_report/components/notes/abuse_report_note_actions_spec.js new file mode 100644 index 00000000000..1ddfb6145fc --- /dev/null +++ b/spec/frontend/admin/abuse_report/components/notes/abuse_report_note_actions_spec.js @@ -0,0 +1,79 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { createMockDirective } from 'helpers/vue_mock_directive'; +import ReplyButton from '~/notes/components/note_actions/reply_button.vue'; +import AbuseReportNoteActions from '~/admin/abuse_report/components/notes/abuse_report_note_actions.vue'; + +describe('Abuse Report Note Actions', () => { + let wrapper; + + const findReplyButton = () => wrapper.findComponent(ReplyButton); + const findEditButton = () => wrapper.findComponent(GlButton); + + const createComponent = ({ showReplyButton = true, showEditButton = true } = {}) => { + wrapper = shallowMount(AbuseReportNoteActions, { + propsData: { + showReplyButton, + showEditButton, + }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + }); + }; + + describe('Default', () => { + beforeEach(() => { + createComponent(); + }); + + it('should show reply button', () => { + expect(findReplyButton().exists()).toBe(true); + }); + + it('should emit `startReplying` when reply button is clicked', () => { + findReplyButton().vm.$emit('startReplying'); + + expect(wrapper.emitted('startReplying')).toHaveLength(1); + }); + + it('should show edit button', () => { + expect(findEditButton().exists()).toBe(true); + expect(findEditButton().attributes()).toMatchObject({ + icon: 'pencil', + title: 'Edit comment', + 'aria-label': 'Edit comment', + }); + }); + + it('should emit `startEditing` when edit button is clicked', () => { + findEditButton().vm.$emit('click'); + + expect(wrapper.emitted('startEditing')).toHaveLength(1); + }); + }); + + describe('When `showReplyButton` is false', () => { + beforeEach(() => { + createComponent({ + showReplyButton: false, + }); + }); + + it('should not show reply button', () => { + expect(findReplyButton().exists()).toBe(false); + }); + }); + + describe('When `showEditButton` is false', () => { + beforeEach(() => { + createComponent({ + showEditButton: false, + }); + }); + + it('should not show edit button', () => { + expect(findEditButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/admin/abuse_report/components/notes/abuse_report_note_spec.js b/spec/frontend/admin/abuse_report/components/notes/abuse_report_note_spec.js index b6908853e46..bc7aa8ef5de 100644 --- a/spec/frontend/admin/abuse_report/components/notes/abuse_report_note_spec.js +++ b/spec/frontend/admin/abuse_report/components/notes/abuse_report_note_spec.js @@ -2,7 +2,10 @@ import { shallowMount } from '@vue/test-utils'; import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; import AbuseReportNote from '~/admin/abuse_report/components/notes/abuse_report_note.vue'; import NoteHeader from '~/notes/components/note_header.vue'; -import NoteBody from '~/admin/abuse_report/components/notes/abuse_report_note_body.vue'; +import EditedAt from '~/issues/show/components/edited.vue'; +import AbuseReportNoteBody from '~/admin/abuse_report/components/notes/abuse_report_note_body.vue'; +import AbuseReportEditNote from '~/admin/abuse_report/components/notes/abuse_report_edit_note.vue'; +import AbuseReportNoteActions from '~/admin/abuse_report/components/notes/abuse_report_note_actions.vue'; import { mockAbuseReport, mockDiscussionWithNoReplies } from '../../mock_data'; @@ -10,18 +13,29 @@ describe('Abuse Report Note', () => { let wrapper; const mockAbuseReportId = mockAbuseReport.report.globalId; const mockNote = mockDiscussionWithNoReplies[0]; + const mockShowReplyButton = true; const findAvatar = () => wrapper.findComponent(GlAvatar); const findAvatarLink = () => wrapper.findComponent(GlAvatarLink); const findNoteHeader = () => wrapper.findComponent(NoteHeader); - const findNoteBody = () => wrapper.findComponent(NoteBody); + const findNoteBody = () => wrapper.findComponent(AbuseReportNoteBody); - const createComponent = ({ note = mockNote, abuseReportId = mockAbuseReportId } = {}) => { + const findEditNote = () => wrapper.findComponent(AbuseReportEditNote); + const findEditedAt = () => wrapper.findComponent(EditedAt); + + const findNoteActions = () => wrapper.findComponent(AbuseReportNoteActions); + + const createComponent = ({ + note = mockNote, + abuseReportId = mockAbuseReportId, + showReplyButton = mockShowReplyButton, + } = {}) => { wrapper = shallowMount(AbuseReportNote, { propsData: { note, abuseReportId, + showReplyButton, }, }); }; @@ -77,4 +91,110 @@ describe('Abuse Report Note', () => { }); }); }); + + describe('Editing', () => { + it('should show edit button when resolveNote is true', () => { + createComponent({ + note: { ...mockNote, userPermissions: { resolveNote: true } }, + }); + + expect(findNoteActions().props()).toMatchObject({ + showEditButton: true, + }); + }); + + it('should not show edit button when resolveNote is false', () => { + createComponent({ + note: { ...mockNote, userPermissions: { resolveNote: false } }, + }); + + expect(findNoteActions().props()).toMatchObject({ + showEditButton: false, + }); + }); + + it('should not be in edit mode by default', () => { + expect(findEditNote().exists()).toBe(false); + }); + + it('should trigger edit mode when `startEditing` event is emitted', async () => { + await findNoteActions().vm.$emit('startEditing'); + + expect(findEditNote().exists()).toBe(true); + expect(findEditNote().props()).toMatchObject({ + abuseReportId: mockAbuseReportId, + note: mockNote, + }); + + expect(findNoteHeader().exists()).toBe(false); + expect(findNoteBody().exists()).toBe(false); + }); + + it('should hide edit mode when `cancelEditing` event is emitted', async () => { + await findNoteActions().vm.$emit('startEditing'); + await findEditNote().vm.$emit('cancelEditing'); + + expect(findEditNote().exists()).toBe(false); + + expect(findNoteHeader().exists()).toBe(true); + expect(findNoteBody().exists()).toBe(true); + }); + }); + + describe('Edited At', () => { + it('should not show edited-at if lastEditedBy is null', () => { + expect(findEditedAt().exists()).toBe(false); + }); + + it('should show edited-at if lastEditedBy is not null', () => { + createComponent({ + note: { + ...mockNote, + lastEditedBy: { name: 'user', webPath: '/user' }, + lastEditedAt: '2023-10-20T02:46:50Z', + }, + }); + + expect(findEditedAt().exists()).toBe(true); + + expect(findEditedAt().props()).toMatchObject({ + updatedAt: '2023-10-20T02:46:50Z', + updatedByName: 'user', + updatedByPath: '/user', + }); + + expect(findEditedAt().classes()).toEqual( + expect.arrayContaining(['gl-text-secondary', 'gl-pl-3']), + ); + }); + + it('should add the correct classList when showReplyButton is false', () => { + createComponent({ + note: { + ...mockNote, + lastEditedBy: { name: 'user', webPath: '/user' }, + lastEditedAt: '2023-10-20T02:46:50Z', + }, + showReplyButton: false, + }); + + expect(findEditedAt().classes()).toEqual( + expect.arrayContaining(['gl-text-secondary', 'gl-pl-8']), + ); + }); + }); + + describe('Replying', () => { + it('should show reply button', () => { + expect(findNoteActions().props()).toMatchObject({ + showReplyButton: true, + }); + }); + + it('should bubble up `startReplying` event', () => { + findNoteActions().vm.$emit('startReplying'); + + expect(wrapper.emitted('startReplying')).toHaveLength(1); + }); + }); }); diff --git a/spec/frontend/admin/abuse_report/mock_data.js b/spec/frontend/admin/abuse_report/mock_data.js index 44c8cbdad7f..9790b44c976 100644 --- a/spec/frontend/admin/abuse_report/mock_data.js +++ b/spec/frontend/admin/abuse_report/mock_data.js @@ -139,7 +139,7 @@ export const mockDiscussionWithNoReplies = [ body: 'Comment 1', bodyHtml: '\u003cp data-sourcepos="1:1-1:9" dir="auto"\u003eComment 1\u003c/p\u003e', createdAt: '2023-10-19T06:11:13Z', - lastEditedAt: '2023-10-20T02:46:50Z', + lastEditedAt: null, url: 'http://127.0.0.1:3000/admin/abuse_reports/1#note_1', resolved: false, author: { @@ -153,7 +153,7 @@ export const mockDiscussionWithNoReplies = [ }, lastEditedBy: null, userPermissions: { - adminNote: true, + resolveNote: true, __typename: 'NotePermissions', }, discussion: { @@ -192,7 +192,7 @@ export const mockDiscussionWithReplies = [ }, lastEditedBy: null, userPermissions: { - adminNote: true, + resolveNote: true, __typename: 'NotePermissions', }, discussion: { @@ -237,7 +237,7 @@ export const mockDiscussionWithReplies = [ }, lastEditedBy: null, userPermissions: { - adminNote: true, + resolveNote: true, __typename: 'NotePermissions', }, discussion: { @@ -282,7 +282,7 @@ export const mockDiscussionWithReplies = [ }, lastEditedBy: null, userPermissions: { - adminNote: true, + resolveNote: true, __typename: 'NotePermissions', }, discussion: { @@ -340,3 +340,83 @@ export const mockNotesByIdResponse = { }, }, }; + +export const createAbuseReportNoteResponse = { + data: { + createNote: { + note: { + id: 'gid://gitlab/Note/6', + discussion: { + id: 'gid://gitlab/Discussion/90ca230051611e6e1676c50ba7178e0baeabd98d', + notes: { + nodes: [ + { + id: 'gid://gitlab/Note/6', + body: 'Another comment', + bodyHtml: '<p data-sourcepos="1:1-1:15" dir="auto">Another comment</p>', + createdAt: '2023-11-02T02:45:46Z', + lastEditedAt: null, + url: 'http://127.0.0.1:3000/admin/abuse_reports/20#note_6', + resolved: false, + author: { + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + name: 'Administrator', + username: 'root', + webUrl: 'http://127.0.0.1:3000/root', + }, + lastEditedBy: null, + userPermissions: { + resolveNote: true, + }, + discussion: { + id: 'gid://gitlab/Discussion/90ca230051611e6e1676c50ba7178e0baeabd98d', + notes: { + nodes: [ + { + id: 'gid://gitlab/Note/6', + }, + ], + }, + }, + }, + ], + }, + }, + }, + errors: [], + }, + }, +}; + +export const editAbuseReportNoteResponse = { + data: { + updateNote: { + errors: [], + note: { + id: 'gid://gitlab/Note/1', + body: 'Updated comment', + bodyHtml: '<p data-sourcepos="1:1-1:15" dir="auto">Updated comment</p>', + createdAt: '2023-10-20T07:47:42Z', + lastEditedAt: '2023-10-20T07:47:42Z', + url: 'http://127.0.0.1:3000/admin/abuse_reports/1#note_1', + resolved: false, + author: { + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: 'http://127.0.0.1:3000/root', + __typename: 'UserCore', + }, + lastEditedBy: 'root', + userPermissions: { + resolveNote: true, + __typename: 'NotePermissions', + }, + }, + }, + }, +}; diff --git a/spec/frontend/admin/signup_restrictions/components/signup_checkbox_spec.js b/spec/frontend/admin/signup_restrictions/components/signup_checkbox_spec.js index 9e55716cc30..463455573ee 100644 --- a/spec/frontend/admin/signup_restrictions/components/signup_checkbox_spec.js +++ b/spec/frontend/admin/signup_restrictions/components/signup_checkbox_spec.js @@ -10,7 +10,7 @@ describe('Signup Form', () => { helpText: 'some help text', label: 'a label', value: true, - dataQaSelector: 'qa_selector', + dataTestId: 'test-id', }; const mountComponent = () => { @@ -55,7 +55,7 @@ describe('Signup Form', () => { }); it('gets passed data qa selector', () => { - expect(findCheckbox().attributes('data-qa-selector')).toBe(props.dataQaSelector); + expect(findCheckbox().attributes('data-testid')).toBe(props.dataTestId); }); }); }); diff --git a/spec/frontend/analytics/cycle_analytics/mock_data.js b/spec/frontend/analytics/cycle_analytics/mock_data.js index e0b6f4aa8c4..73387606433 100644 --- a/spec/frontend/analytics/cycle_analytics/mock_data.js +++ b/spec/frontend/analytics/cycle_analytics/mock_data.js @@ -159,12 +159,12 @@ export const stageMedians = { }; export const formattedStageMedians = { - issue: '2d', - plan: '1d', - review: '1w', - code: '1d', - test: '3d', - staging: '4d', + issue: '2 days', + plan: '1 day', + review: '1 week', + code: '1 day', + test: '3 days', + staging: '4 days', }; export const allowedStages = [issueStage, planStage, codeStage]; diff --git a/spec/frontend/analytics/cycle_analytics/store/actions_spec.js b/spec/frontend/analytics/cycle_analytics/store/actions_spec.js index c3551d3da6f..897d75573f0 100644 --- a/spec/frontend/analytics/cycle_analytics/store/actions_spec.js +++ b/spec/frontend/analytics/cycle_analytics/store/actions_spec.js @@ -141,7 +141,7 @@ describe('Project Value Stream Analytics actions', () => { describe('without a selected stage', () => { it('will select the first stage from the value stream', () => { const [firstStage] = allowedStages; - testAction({ + return testAction({ action: actions.setInitialStage, state, payload: null, @@ -154,7 +154,7 @@ describe('Project Value Stream Analytics actions', () => { describe('with no value stream stages available', () => { it('will return SET_NO_ACCESS_ERROR', () => { state = { ...state, stages: [] }; - testAction({ + return testAction({ action: actions.setInitialStage, state, payload: null, @@ -299,25 +299,23 @@ describe('Project Value Stream Analytics actions', () => { name: 'mock default', }; const mockValueStreams = [mockValueStream, selectedValueStream]; - it('with data, will set the first value stream', () => { + it('with data, will set the first value stream', () => testAction({ action: actions.receiveValueStreamsSuccess, state, payload: mockValueStreams, expectedMutations: [{ type: 'RECEIVE_VALUE_STREAMS_SUCCESS', payload: mockValueStreams }], expectedActions: [{ type: 'setSelectedValueStream', payload: mockValueStream }], - }); - }); + })); - it('without data, will set the default value stream', () => { + it('without data, will set the default value stream', () => testAction({ action: actions.receiveValueStreamsSuccess, state, payload: [], expectedMutations: [{ type: 'RECEIVE_VALUE_STREAMS_SUCCESS', payload: [] }], expectedActions: [{ type: 'setSelectedValueStream', payload: selectedValueStream }], - }); - }); + })); }); describe('fetchValueStreamStages', () => { diff --git a/spec/frontend/analytics/cycle_analytics/utils_spec.js b/spec/frontend/analytics/cycle_analytics/utils_spec.js index ab5d78bde51..5d2fcf97a76 100644 --- a/spec/frontend/analytics/cycle_analytics/utils_spec.js +++ b/spec/frontend/analytics/cycle_analytics/utils_spec.js @@ -45,13 +45,13 @@ describe('Value stream analytics utils', () => { describe('medianTimeToParsedSeconds', () => { it.each` value | result - ${1036800} | ${'1w'} - ${259200} | ${'3d'} - ${172800} | ${'2d'} - ${86400} | ${'1d'} - ${1000} | ${'16m'} - ${61} | ${'1m'} - ${59} | ${'<1m'} + ${1036800} | ${'1 week'} + ${259200} | ${'3 days'} + ${172800} | ${'2 days'} + ${86400} | ${'1 day'} + ${1000} | ${'16 minutes'} + ${61} | ${'1 minute'} + ${59} | ${'<1 minute'} ${0} | ${'-'} `('will correctly parse $value seconds into $result', ({ value, result }) => { expect(medianTimeToParsedSeconds(value)).toBe(result); diff --git a/spec/frontend/api/user_api_spec.js b/spec/frontend/api/user_api_spec.js index a6e08e1cf4b..aeddf6b9ae1 100644 --- a/spec/frontend/api/user_api_spec.js +++ b/spec/frontend/api/user_api_spec.js @@ -4,6 +4,8 @@ import projects from 'test_fixtures/api/users/projects/get.json'; import followers from 'test_fixtures/api/users/followers/get.json'; import following from 'test_fixtures/api/users/following/get.json'; import { + getUsers, + getGroupUsers, followUser, unfollowUser, associationsCount, @@ -36,6 +38,32 @@ describe('~/api/user_api', () => { axiosMock.resetHistory(); }); + describe('getUsers', () => { + it('calls correct URL with expected query parameters', async () => { + const expectedUrl = '/api/v4/users.json'; + axiosMock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK); + + await getUsers('den', { without_project_bots: true }); + + const { url, params } = axiosMock.history.get[0]; + expect(url).toBe(expectedUrl); + expect(params).toMatchObject({ search: 'den', without_project_bots: true }); + }); + }); + + describe('getSAMLUsers', () => { + it('calls correct URL with expected query parameters', async () => { + const expectedUrl = '/api/v4/groups/34/users.json'; + axiosMock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK); + + await getGroupUsers('den', '34', { include_service_accounts: true }); + + const { url, params } = axiosMock.history.get[0]; + expect(url).toBe(expectedUrl); + expect(params).toMatchObject({ search: 'den', include_service_accounts: true }); + }); + }); + describe('followUser', () => { it('calls correct URL and returns expected response', async () => { const expectedUrl = '/api/v4/users/1/follow'; diff --git a/spec/frontend/authentication/password/components/password_input_spec.js b/spec/frontend/authentication/password/components/password_input_spec.js index 5b2a9da993b..62438e824cf 100644 --- a/spec/frontend/authentication/password/components/password_input_spec.js +++ b/spec/frontend/authentication/password/components/password_input_spec.js @@ -9,7 +9,6 @@ describe('PasswordInput', () => { title: 'This field is required', id: 'new_user_password', minimumPasswordLength: '8', - qaSelector: 'new_user_password_field', testid: 'new_user_password', autocomplete: 'new-password', name: 'new_user', diff --git a/spec/frontend/badges/store/actions_spec.js b/spec/frontend/badges/store/actions_spec.js index 5ca199357f9..1900ebc1e08 100644 --- a/spec/frontend/badges/store/actions_spec.js +++ b/spec/frontend/badges/store/actions_spec.js @@ -258,7 +258,7 @@ describe('Badges store actions', () => { it('dispatches requestLoadBadges and receiveLoadBadges for successful response', async () => { const dummyData = 'this is just some data'; - const dummyReponse = [ + const dummyResponse = [ createDummyBadgeResponse(), createDummyBadgeResponse(), createDummyBadgeResponse(), @@ -266,11 +266,11 @@ describe('Badges store actions', () => { endpointMock.replyOnce(() => { expect(dispatch.mock.calls).toEqual([['requestLoadBadges', dummyData]]); dispatch.mockClear(); - return [HTTP_STATUS_OK, dummyReponse]; + return [HTTP_STATUS_OK, dummyResponse]; }); await actions.loadBadges({ state, dispatch }, dummyData); - const badges = dummyReponse.map(transformBackendBadge); + const badges = dummyResponse.map(transformBackendBadge); expect(dispatch.mock.calls).toEqual([['receiveLoadBadges', badges]]); }); @@ -377,15 +377,15 @@ describe('Badges store actions', () => { }); it('dispatches requestRenderedBadge and receiveRenderedBadge for successful response', async () => { - const dummyReponse = createDummyBadgeResponse(); + const dummyResponse = createDummyBadgeResponse(); endpointMock.replyOnce(() => { expect(dispatch.mock.calls).toEqual([['requestRenderedBadge']]); dispatch.mockClear(); - return [HTTP_STATUS_OK, dummyReponse]; + return [HTTP_STATUS_OK, dummyResponse]; }); await actions.renderBadge({ state, dispatch }); - const renderedBadge = transformBackendBadge(dummyReponse); + const renderedBadge = transformBackendBadge(dummyResponse); expect(dispatch.mock.calls).toEqual([['receiveRenderedBadge', renderedBadge]]); }); diff --git a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js index ae7f5416c0c..6db99e796d6 100644 --- a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js +++ b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js @@ -30,14 +30,11 @@ describe('ShortcutsIssuable', () => { </div>`, ); document.querySelector('.js-new-note-form').classList.add('js-main-target-form'); - - window.shortcut = new ShortcutsIssuable(true); }); afterEach(() => { $(FORM_SELECTOR).remove(); - delete window.shortcut; resetHTMLFixture(); }); @@ -55,6 +52,15 @@ describe('ShortcutsIssuable', () => { }); }; + it('sets up commands on instantiation', () => { + const mockShortcutsInstance = { addAll: jest.fn() }; + + // eslint-disable-next-line no-new + new ShortcutsIssuable(mockShortcutsInstance); + + expect(mockShortcutsInstance.addAll).toHaveBeenCalled(); + }); + describe('with empty selection', () => { it('does not return an error', () => { ShortcutsIssuable.replyWithSelectedText(true); diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/behaviors/shortcuts/shortcuts_spec.js index ca72426cb44..5f71eb24758 100644 --- a/spec/frontend/shortcuts_spec.js +++ b/spec/frontend/behaviors/shortcuts/shortcuts_spec.js @@ -37,6 +37,16 @@ describe('Shortcuts', () => { resetHTMLFixture(); }); + it('does not allow subclassing', () => { + const createSubclass = () => { + class Subclass extends Shortcuts {} + + return new Subclass(); + }; + + expect(createSubclass).toThrow(/cannot be subclassed/); + }); + describe('markdown shortcuts', () => { let shortcutElements; @@ -106,7 +116,6 @@ describe('Shortcuts', () => { let event; beforeEach(() => { - window.gon.use_new_navigation = true; event = new KeyboardEvent('keydown', { cancelable: true }); Shortcuts.focusSearch(event); }); @@ -122,12 +131,12 @@ describe('Shortcuts', () => { }); }); - describe('bindCommand(s)', () => { - it('bindCommand calls Mousetrap.bind correctly', () => { + describe('adding shortcuts', () => { + it('add calls Mousetrap.bind correctly', () => { const mockCommand = { defaultKeys: ['m'] }; const mockCallback = () => {}; - shortcuts.bindCommand(mockCommand, mockCallback); + shortcuts.add(mockCommand, mockCallback); expect(Mousetrap.prototype.bind).toHaveBeenCalledTimes(1); const [callArguments] = Mousetrap.prototype.bind.mock.calls; @@ -135,13 +144,13 @@ describe('Shortcuts', () => { expect(callArguments[1]).toBe(mockCallback); }); - it('bindCommands calls Mousetrap.bind correctly', () => { + it('addAll calls Mousetrap.bind correctly', () => { const mockCommandsAndCallbacks = [ [{ defaultKeys: ['1'] }, () => {}], [{ defaultKeys: ['2'] }, () => {}], ]; - shortcuts.bindCommands(mockCommandsAndCallbacks); + shortcuts.addAll(mockCommandsAndCallbacks); expect(Mousetrap.prototype.bind).toHaveBeenCalledTimes(mockCommandsAndCallbacks.length); const { calls } = Mousetrap.prototype.bind.mock; @@ -152,4 +161,107 @@ describe('Shortcuts', () => { }); }); }); + + describe('addExtension', () => { + it('instantiates the given extension', () => { + const MockExtension = jest.fn(); + + const returnValue = shortcuts.addExtension(MockExtension, ['foo']); + + expect(MockExtension).toHaveBeenCalledTimes(1); + expect(MockExtension).toHaveBeenCalledWith(shortcuts, 'foo'); + expect(returnValue).toBe(MockExtension.mock.instances[0]); + }); + + it('instantiates declared dependencies', () => { + const MockDependency = jest.fn(); + const MockExtension = jest.fn(); + + MockExtension.dependencies = [MockDependency]; + + const returnValue = shortcuts.addExtension(MockExtension, ['foo']); + + expect(MockDependency).toHaveBeenCalledTimes(1); + expect(MockDependency.mock.instances).toHaveLength(1); + expect(MockDependency).toHaveBeenCalledWith(shortcuts); + + expect(returnValue).toBe(MockExtension.mock.instances[0]); + }); + + it('does not instantiate an extension more than once', () => { + const MockExtension = jest.fn(); + + const returnValue = shortcuts.addExtension(MockExtension, ['foo']); + const secondReturnValue = shortcuts.addExtension(MockExtension, ['bar']); + + expect(MockExtension).toHaveBeenCalledTimes(1); + expect(MockExtension).toHaveBeenCalledWith(shortcuts, 'foo'); + expect(returnValue).toBe(MockExtension.mock.instances[0]); + expect(secondReturnValue).toBe(MockExtension.mock.instances[0]); + }); + + it('allows extensions to redundantly depend on Shortcuts', () => { + const MockExtension = jest.fn(); + MockExtension.dependencies = [Shortcuts]; + + shortcuts.addExtension(MockExtension); + + expect(MockExtension).toHaveBeenCalledTimes(1); + expect(MockExtension).toHaveBeenCalledWith(shortcuts); + + // Ensure it wasn't instantiated + expect(shortcuts.extensions.has(Shortcuts)).toBe(false); + }); + + it('allows extensions to incorrectly depend on themselves', () => { + const A = jest.fn(); + A.dependencies = [A]; + shortcuts.addExtension(A); + expect(A).toHaveBeenCalledTimes(1); + expect(A).toHaveBeenCalledWith(shortcuts); + }); + + it('handles extensions with circular dependencies', () => { + const A = jest.fn(); + const B = jest.fn(); + const C = jest.fn(); + + A.dependencies = [B]; + B.dependencies = [C]; + C.dependencies = [A]; + + shortcuts.addExtension(A); + + expect(A).toHaveBeenCalledTimes(1); + expect(B).toHaveBeenCalledTimes(1); + expect(C).toHaveBeenCalledTimes(1); + }); + + it('handles complex (diamond) dependency graphs', () => { + const X = jest.fn(); + const A = jest.fn(); + const C = jest.fn(); + const D = jest.fn(); + const E = jest.fn(); + + // Form this dependency graph: + // + // X ───► A ───► C + // │ ▲ + // └────► D ─────┘ + // │ + // └────► E + X.dependencies = [A, D]; + A.dependencies = [C]; + D.dependencies = [C, E]; + + shortcuts.addExtension(X); + + expect(X).toHaveBeenCalledTimes(1); + expect(A).toHaveBeenCalledTimes(1); + expect(C).toHaveBeenCalledTimes(1); + expect(D).toHaveBeenCalledTimes(1); + expect(E).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/spec/frontend/blob_edit/edit_blob_spec.js b/spec/frontend/blob_edit/edit_blob_spec.js index e58ad4040a9..31be1a86de4 100644 --- a/spec/frontend/blob_edit/edit_blob_spec.js +++ b/spec/frontend/blob_edit/edit_blob_spec.js @@ -6,6 +6,7 @@ import EditBlob from '~/blob_edit/edit_blob'; import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext'; import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; +import { SecurityPolicySchemaExtension } from '~/editor/extensions/source_editor_security_policy_schema_ext'; import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext'; import { ToolbarExtension } from '~/editor/extensions/source_editor_toolbar_ext'; import SourceEditor from '~/editor/source_editor'; @@ -17,6 +18,7 @@ jest.mock('~/editor/extensions/source_editor_file_template_ext'); jest.mock('~/editor/extensions/source_editor_markdown_ext'); jest.mock('~/editor/extensions/source_editor_markdown_livepreview_ext'); jest.mock('~/editor/extensions/source_editor_toolbar_ext'); +jest.mock('~/editor/extensions/source_editor_security_policy_schema_ext'); const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown'; const defaultExtensions = [ @@ -67,16 +69,18 @@ describe('Blob Editing', () => { resetHTMLFixture(); }); - const editorInst = (isMarkdown) => { + const editorInst = ({ isMarkdown = false, isSecurityPolicy = false }) => { blobInstance = new EditBlob({ isMarkdown, previewMarkdownPath: PREVIEW_MARKDOWN_PATH, + filePath: isSecurityPolicy ? '.gitlab/security-policies/policy.yml' : '', + projectPath: 'path/to/project', }); return blobInstance; }; - const initEditor = async (isMarkdown = false) => { - editorInst(isMarkdown); + const initEditor = async ({ isMarkdown = false, isSecurityPolicy = false } = {}) => { + editorInst({ isMarkdown, isSecurityPolicy }); await waitForPromises(); }; @@ -93,13 +97,13 @@ describe('Blob Editing', () => { }); it('loads MarkdownExtension only for the markdown files', async () => { - await initEditor(true); + await initEditor({ isMarkdown: true }); expect(useMock).toHaveBeenCalledTimes(2); expect(useMock.mock.calls[1]).toEqual([markdownExtensions]); }); it('correctly handles switching from markdown and un-uses markdown extensions', async () => { - await initEditor(true); + await initEditor({ isMarkdown: true }); expect(unuseMock).not.toHaveBeenCalled(); await emitter.fire({ newLanguage: 'plaintext', oldLanguage: 'markdown' }); expect(unuseMock).toHaveBeenCalledWith(markdownExtensions); @@ -115,6 +119,19 @@ describe('Blob Editing', () => { }); }); + describe('Security Policy Yaml', () => { + it('does not load SecurityPolicySchemaExtension by default', async () => { + await initEditor(); + expect(SecurityPolicySchemaExtension).not.toHaveBeenCalled(); + }); + + it('loads SecurityPolicySchemaExtension only for the security policies yml', async () => { + await initEditor({ isSecurityPolicy: true }); + expect(useMock).toHaveBeenCalledTimes(2); + expect(useMock.mock.calls[1]).toEqual([[{ definition: SecurityPolicySchemaExtension }]]); + }); + }); + describe('correctly handles toggling the live-preview panel for different file types', () => { it.each` desc | isMarkdown | isPreviewOpened | tabToClick | shouldOpenPreview | shouldClosePreview | expectedDesc @@ -142,7 +159,7 @@ describe('Blob Editing', () => { }, }, }); - await initEditor(isMarkdown); + await initEditor({ isMarkdown }); blobInstance.markdownLivePreviewOpened = isPreviewOpened; const elToClick = document.querySelector(`a[href='${tabToClick}']`); elToClick.dispatchEvent(new Event('click')); diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js index c70e461da83..8f2752b6bd8 100644 --- a/spec/frontend/boards/board_card_inner_spec.js +++ b/spec/frontend/boards/board_card_inner_spec.js @@ -2,8 +2,6 @@ import { GlLabel, GlLoadingIcon } from '@gitlab/ui'; import { range } from 'lodash'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; @@ -13,15 +11,13 @@ import BoardCardInner from '~/boards/components/board_card_inner.vue'; import isShowingLabelsQuery from '~/graphql_shared/client/is_showing_labels.query.graphql'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import eventHub from '~/boards/eventhub'; -import defaultStore from '~/boards/stores'; import { TYPE_ISSUE } from '~/issues/constants'; import { updateHistory } from '~/lib/utils/url_utility'; -import { mockLabelList, mockIssue, mockIssueFullPath } from './mock_data'; +import { mockLabelList, mockIssue, mockIssueFullPath, mockIssueDirectNamespace } from './mock_data'; jest.mock('~/lib/utils/url_utility'); jest.mock('~/boards/eventhub'); -Vue.use(Vuex); Vue.use(VueApollo); describe('Board card component', () => { @@ -43,24 +39,12 @@ describe('Board card component', () => { let wrapper; let issue; let list; - let store; const findIssuableBlockedIcon = () => wrapper.findComponent(IssuableBlockedIcon); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findHiddenIssueIcon = () => wrapper.findByTestId('hidden-icon'); const findWorkItemIcon = () => wrapper.findComponent(WorkItemTypeIcon); - const performSearchMock = jest.fn(); - - const createStore = () => { - store = new Vuex.Store({ - actions: { - performSearch: performSearchMock, - }, - state: defaultStore.state, - }); - }; - const mockApollo = createMockApollo(); const createWrapper = ({ props = {}, isGroupBoard = true } = {}) => { @@ -72,7 +56,6 @@ describe('Board card component', () => { }); wrapper = mountExtended(BoardCardInner, { - store, apolloProvider: mockApollo, propsData: { list, @@ -94,7 +77,6 @@ describe('Board card component', () => { allowSubEpics: false, issuableType: TYPE_ISSUE, isGroupBoard, - isApolloBoard: false, }, }); }; @@ -108,14 +90,9 @@ describe('Board card component', () => { weight: 1, }; - createStore(); createWrapper({ props: { item: issue, list } }); }); - afterEach(() => { - store = null; - }); - it('renders issue title', () => { expect(wrapper.find('.board-card-title').text()).toContain(issue.title); }); @@ -159,14 +136,15 @@ describe('Board card component', () => { }); it('does not render item reference path', () => { - createStore(); createWrapper({ isGroupBoard: false }); - expect(wrapper.find('.board-card-number').text()).not.toContain(mockIssueFullPath); + expect(wrapper.find('.board-card-number').text()).not.toContain(mockIssueDirectNamespace); + expect(wrapper.find('.board-item-path').exists()).toBe(false); }); - it('renders item reference path', () => { - expect(wrapper.find('.board-card-number').text()).toContain(mockIssueFullPath); + it('renders item direct namespace path with full reference path in a tooltip', () => { + expect(wrapper.find('.board-item-path').text()).toBe(mockIssueDirectNamespace); + expect(wrapper.find('.board-item-path').attributes('title')).toBe(mockIssueFullPath); }); describe('blocked', () => { @@ -458,10 +436,6 @@ describe('Board card component', () => { expect(updateHistory).toHaveBeenCalledTimes(1); }); - it('dispatches performSearch vuex action', () => { - expect(performSearchMock).toHaveBeenCalledTimes(1); - }); - it('emits updateTokens event', () => { expect(eventHub.$emit).toHaveBeenCalledTimes(1); expect(eventHub.$emit).toHaveBeenCalledWith('updateTokens'); @@ -478,10 +452,6 @@ describe('Board card component', () => { expect(updateHistory).not.toHaveBeenCalled(); }); - it('does not dispatch performSearch vuex action', () => { - expect(performSearchMock).not.toHaveBeenCalled(); - }); - it('does not emit updateTokens event', () => { expect(eventHub.$emit).not.toHaveBeenCalled(); }); diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js index 5bafd9a8d0e..e3afd2dec2f 100644 --- a/spec/frontend/boards/board_list_helper.js +++ b/spec/frontend/boards/board_list_helper.js @@ -1,34 +1,22 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; import BoardCard from '~/boards/components/board_card.vue'; import BoardList from '~/boards/components/board_list.vue'; import BoardNewIssue from '~/boards/components/board_new_issue.vue'; import BoardNewItem from '~/boards/components/board_new_item.vue'; -import defaultState from '~/boards/stores/state'; + import createMockApollo from 'helpers/mock_apollo_helper'; import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; -import { - mockList, - mockIssuesByListId, - issues, - mockGroupProjects, - boardListQueryResponse, -} from './mock_data'; +import { mockList, boardListQueryResponse } from './mock_data'; export default function createComponent({ - listIssueProps = {}, componentProps = {}, listProps = {}, apolloQueryHandlers = [], - actions = {}, - getters = {}, provide = {}, data = {}, - state = defaultState, stubs = { BoardNewIssue, BoardNewItem, @@ -37,60 +25,25 @@ export default function createComponent({ issuesCount, } = {}) { Vue.use(VueApollo); - Vue.use(Vuex); const fakeApollo = createMockApollo([ [listQuery, jest.fn().mockResolvedValue(boardListQueryResponse({ issuesCount }))], ...apolloQueryHandlers, ]); - const store = new Vuex.Store({ - state: { - selectedProject: mockGroupProjects[0], - boardItemsByListId: mockIssuesByListId, - boardItems: issues, - pageInfoByListId: { - 'gid://gitlab/List/1': { hasNextPage: true }, - 'gid://gitlab/List/2': {}, - }, - listsFlags: { - 'gid://gitlab/List/1': {}, - 'gid://gitlab/List/2': {}, - }, - selectedBoardItems: [], - ...state, - }, - getters: { - isEpicBoard: () => false, - ...getters, - }, - actions, - }); - const list = { ...mockList, ...listProps, }; - const issue = { - title: 'Testing', - id: 1, - iid: 1, - confidential: false, - referencePath: 'gitlab-org/test-subgroup/gitlab-test#1', - labels: [], - assignees: [], - ...listIssueProps, - }; + if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesCount')) { list.issuesCount = 1; } const component = shallowMount(BoardList, { apolloProvider: fakeApollo, - store, propsData: { list, - boardItems: [issue], canAdminList: true, boardId: 'gid://gitlab/Board/1', filterParams: {}, @@ -106,12 +59,12 @@ export default function createComponent({ canAdminList: true, isIssueBoard: true, isEpicBoard: false, - isGroupBoard: false, - isProjectBoard: true, + isGroupBoard: true, + isProjectBoard: false, disabled: false, boardType: 'group', issuableType: 'issue', - isApolloBoard: false, + isApolloBoard: true, ...provide, }, stubs, @@ -122,7 +75,5 @@ export default function createComponent({ }, }); - jest.spyOn(store, 'dispatch').mockImplementation(() => {}); - return component; } diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index 30bb4fba4e3..8d59cb2692e 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -8,8 +8,9 @@ import createComponent from 'jest/boards/board_list_helper'; import BoardCard from '~/boards/components/board_card.vue'; import eventHub from '~/boards/eventhub'; import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue'; +import listIssuesQuery from '~/boards/graphql/lists_issues.query.graphql'; -import { mockIssues, mockList, mockIssuesMore } from './mock_data'; +import { mockIssues, mockList, mockIssuesMore, mockGroupIssuesResponse } from './mock_data'; describe('Board list component', () => { let wrapper; @@ -41,8 +42,13 @@ describe('Board list component', () => { useFakeRequestAnimationFrame(); describe('When Expanded', () => { - beforeEach(() => { - wrapper = createComponent({ issuesCount: 1 }); + beforeEach(async () => { + wrapper = createComponent({ + apolloQueryHandlers: [ + [listIssuesQuery, jest.fn().mockResolvedValue(mockGroupIssuesResponse())], + ], + }); + await waitForPromises(); }); it('renders component', () => { @@ -62,7 +68,7 @@ describe('Board list component', () => { }); it('sets data attribute with issue id', () => { - expect(wrapper.find('.board-card').attributes('data-item-id')).toBe('1'); + expect(wrapper.find('.board-card').attributes('data-item-id')).toBe('gid://gitlab/Issue/436'); }); it('shows new issue form after eventhub event', async () => { @@ -107,19 +113,18 @@ describe('Board list component', () => { describe('load more issues', () => { describe('when loading is not in progress', () => { - beforeEach(() => { + beforeEach(async () => { wrapper = createComponent({ - listProps: { - id: 'gid://gitlab/List/1', - }, - componentProps: { - boardItems: mockIssuesMore, - }, - actions: { - fetchItemsForList: jest.fn(), - }, - state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: false } } }, + apolloQueryHandlers: [ + [ + listIssuesQuery, + jest + .fn() + .mockResolvedValue(mockGroupIssuesResponse('gid://gitlab/List/1', mockIssuesMore)), + ], + ], }); + await waitForPromises(); }); it('has intersection observer when the number of board list items are more than 5', () => { 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 1a847d35900..768a93f6970 100644 --- a/spec/frontend/boards/components/board_add_new_column_spec.js +++ b/spec/frontend/boards/components/board_add_new_column_spec.js @@ -1,14 +1,11 @@ import { GlCollapsibleListbox } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; 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 createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; import boardLabelsQuery from '~/boards/graphql/board_labels.query.graphql'; import * as cacheUpdates from '~/boards/graphql/cache_updates'; @@ -19,7 +16,6 @@ import { boardListsQueryResponse, } from '../mock_data'; -Vue.use(Vuex); Vue.use(VueApollo); describe('BoardAddNewColumn', () => { @@ -39,22 +35,8 @@ describe('BoardAddNewColumn', () => { findDropdown().vm.$emit('select', id); }; - const createStore = ({ actions = {}, getters = {}, state = {} } = {}) => { - return new Vuex.Store({ - state: { - ...defaultState, - ...state, - }, - actions, - getters, - }); - }; - const mountComponent = ({ selectedId, - labels = [], - getListByLabelId = jest.fn(), - actions = {}, provide = {}, lists = {}, labelsHandler = labelsQueryHandler, @@ -83,26 +65,12 @@ describe('BoardAddNewColumn', () => { selectedId, }; }, - store: createStore({ - actions: { - fetchLabels: jest.fn(), - ...actions, - }, - getters: { - getListByLabelId: () => getListByLabelId, - }, - state: { - labels, - labelsLoading: false, - }, - }), provide: { scopedLabelsAvailable: true, isEpicBoard: false, issuableType: 'issue', fullPath: 'gitlab-org/gitlab', boardType: 'project', - isApolloBoard: false, ...provide, }, stubs: { @@ -126,149 +94,94 @@ describe('BoardAddNewColumn', () => { cacheUpdates.setError = jest.fn(); }); - describe('Add list button', () => { - it('calls addList', async () => { - const getListByLabelId = jest.fn().mockReturnValue(null); - const highlightList = jest.fn(); - const createList = jest.fn(); + describe('when list is new', () => { + beforeEach(() => { + mountComponent({ selectedId: mockLabelList.label.id }); + }); - mountComponent({ - labels: [mockLabelList.label], - selectedId: mockLabelList.label.id, - getListByLabelId, - actions: { - createList, - highlightList, - }, - }); + 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(highlightList).not.toHaveBeenCalled(); - expect(createList).toHaveBeenCalledWith(expect.anything(), { + expect(wrapper.emitted('highlight-list')).toBeUndefined(); + expect(createBoardListQueryHandler).toHaveBeenCalledWith({ labelId: mockLabelList.label.id, + boardId: 'gid://gitlab/Board/1', }); }); + }); - it('highlights existing list if trying to re-add', async () => { - const getListByLabelId = jest.fn().mockReturnValue(mockLabelList); - const highlightList = jest.fn(); - const createList = jest.fn(); - + describe('when list already exists in board', () => { + beforeEach(() => { mountComponent({ - labels: [mockLabelList.label], - selectedId: mockLabelList.label.id, - getListByLabelId, - actions: { - createList, - highlightList, + lists: { + [mockLabelList.id]: mockLabelList, }, + selectedId: mockLabelList.label.id, }); - - findAddNewColumnForm().vm.$emit('add-list'); - - await nextTick(); - - expect(highlightList).toHaveBeenCalledWith(expect.anything(), mockLabelList.id); - 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'); + it('highlights existing list if trying to re-add', async () => { + findDropdown().vm.$emit('show'); - await nextTick(); - expect(labelsQueryHandler).toHaveBeenCalled(); + await nextTick(); + expect(labelsQueryHandler).toHaveBeenCalled(); - selectLabel(mockLabelList.label.id); + selectLabel(mockLabelList.label.id); - findAddNewColumnForm().vm.$emit('add-list'); + findAddNewColumnForm().vm.$emit('add-list'); - await nextTick(); + await waitForPromises(); - expect(wrapper.emitted('highlight-list')).toBeUndefined(); - expect(createBoardListQueryHandler).toHaveBeenCalledWith({ - labelId: mockLabelList.label.id, - boardId: 'gid://gitlab/Board/1', - }); - }); + expect(wrapper.emitted('highlight-list')).toEqual([[mockLabelList.id]]); + expect(createBoardListQueryHandler).not.toHaveBeenCalledWith(); }); + }); - 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 waitForPromises(); - - expect(wrapper.emitted('highlight-list')).toEqual([[mockLabelList.id]]); - expect(createBoardListQueryHandler).not.toHaveBeenCalledWith(); + describe('when fetch labels query fails', () => { + beforeEach(() => { + mountComponent({ + labelsHandler: labelsQueryHandlerFailure, }); }); - describe('when fetch labels query fails', () => { - beforeEach(() => { - mountComponent({ - provide: { isApolloBoard: true }, - labelsHandler: labelsQueryHandlerFailure, - }); - }); + it('sets error', async () => { + findDropdown().vm.$emit('show'); - it('sets error', async () => { - findDropdown().vm.$emit('show'); - - await waitForPromises(); - expect(cacheUpdates.setError).toHaveBeenCalled(); - }); + await waitForPromises(); + expect(cacheUpdates.setError).toHaveBeenCalled(); }); + }); - describe('when create list mutation fails', () => { - beforeEach(() => { - mountComponent({ - selectedId: mockLabelList.label.id, - provide: { isApolloBoard: true }, - createHandler: createBoardListQueryHandlerFailure, - }); + describe('when create list mutation fails', () => { + beforeEach(() => { + mountComponent({ + selectedId: mockLabelList.label.id, + createHandler: createBoardListQueryHandlerFailure, }); + }); - it('sets error', async () => { - findDropdown().vm.$emit('show'); + it('sets error', async () => { + findDropdown().vm.$emit('show'); - await nextTick(); - expect(labelsQueryHandler).toHaveBeenCalled(); + await nextTick(); + expect(labelsQueryHandler).toHaveBeenCalled(); - selectLabel(mockLabelList.label.id); + selectLabel(mockLabelList.label.id); - findAddNewColumnForm().vm.$emit('add-list'); + findAddNewColumnForm().vm.$emit('add-list'); - await waitForPromises(); + await waitForPromises(); - expect(cacheUpdates.setError).toHaveBeenCalled(); - }); + expect(cacheUpdates.setError).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/boards/components/board_app_spec.js b/spec/frontend/boards/components/board_app_spec.js index b16f9b26f40..157c76b4fff 100644 --- a/spec/frontend/boards/components/board_app_spec.js +++ b/spec/frontend/boards/components/board_app_spec.js @@ -1,8 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -15,34 +13,15 @@ import { rawIssue, boardListsQueryResponse } from '../mock_data'; describe('BoardApp', () => { let wrapper; - let store; let mockApollo; const errorMessage = 'Failed to fetch lists'; const boardListQueryHandler = jest.fn().mockResolvedValue(boardListsQueryResponse); const boardListQueryHandlerFailure = jest.fn().mockRejectedValue(new Error(errorMessage)); - Vue.use(Vuex); Vue.use(VueApollo); - const createStore = ({ mockGetters = {} } = {}) => { - store = new Vuex.Store({ - state: {}, - actions: { - performSearch: jest.fn(), - }, - getters: { - isSidebarOpen: () => true, - ...mockGetters, - }, - }); - }; - - const createComponent = ({ - isApolloBoard = false, - issue = rawIssue, - handler = boardListQueryHandler, - } = {}) => { + const createComponent = ({ issue = rawIssue, handler = boardListQueryHandler } = {}) => { mockApollo = createMockApollo([[boardListsQuery, handler]]); mockApollo.clients.defaultClient.cache.writeQuery({ query: activeBoardItemQuery, @@ -53,7 +32,6 @@ describe('BoardApp', () => { wrapper = shallowMount(BoardApp, { apolloProvider: mockApollo, - store, provide: { fullPath: 'gitlab-org', initialBoardId: 'gid://gitlab/Board/1', @@ -62,69 +40,46 @@ describe('BoardApp', () => { boardType: 'group', isIssueBoard: true, isGroupBoard: true, - isApolloBoard, }, }); }; - beforeEach(() => { + beforeEach(async () => { cacheUpdates.setError = jest.fn(); - }); - afterEach(() => { - store = null; + createComponent({ isApolloBoard: true }); + await nextTick(); }); - it("should have 'is-compact' class when sidebar is open", () => { - createStore(); - createComponent(); + it('fetches lists', () => { + expect(boardListQueryHandler).toHaveBeenCalled(); + }); + it('should have is-compact class when a card is selected', () => { expect(wrapper.classes()).toContain('is-compact'); }); - it("should not have 'is-compact' class when sidebar is closed", () => { - createStore({ mockGetters: { isSidebarOpen: () => false } }); - createComponent(); + it('should not have is-compact class when no card is selected', async () => { + createComponent({ isApolloBoard: true, issue: {} }); + await nextTick(); expect(wrapper.classes()).not.toContain('is-compact'); }); - describe('Apollo boards', () => { - beforeEach(async () => { - createComponent({ isApolloBoard: true }); - await nextTick(); - }); + it('refetches lists when updateBoard event is received', async () => { + jest.spyOn(eventHub, '$on').mockImplementation(() => {}); - it('fetches lists', () => { - expect(boardListQueryHandler).toHaveBeenCalled(); - }); + createComponent({ isApolloBoard: true }); + await waitForPromises(); - it('should have is-compact class when a card is selected', () => { - expect(wrapper.classes()).toContain('is-compact'); - }); - - it('should not have is-compact class when no card is selected', async () => { - createComponent({ isApolloBoard: true, issue: {} }); - await nextTick(); - - expect(wrapper.classes()).not.toContain('is-compact'); - }); - - it('refetches lists when updateBoard event is received', async () => { - jest.spyOn(eventHub, '$on').mockImplementation(() => {}); - - createComponent({ isApolloBoard: true }); - await waitForPromises(); - - expect(eventHub.$on).toHaveBeenCalledWith('updateBoard', wrapper.vm.refetchLists); - }); + expect(eventHub.$on).toHaveBeenCalledWith('updateBoard', wrapper.vm.refetchLists); + }); - it('sets error on fetch lists failure', async () => { - createComponent({ isApolloBoard: true, handler: boardListQueryHandlerFailure }); + it('sets error on fetch lists failure', async () => { + createComponent({ isApolloBoard: true, handler: boardListQueryHandlerFailure }); - await waitForPromises(); + await waitForPromises(); - expect(cacheUpdates.setError).toHaveBeenCalled(); - }); + expect(cacheUpdates.setError).toHaveBeenCalled(); }); }); 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 20beaf2e9bd..d3c43a4e054 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 @@ -8,7 +8,7 @@ import { BOARD_CARD_MOVE_TO_POSITIONS_END_OPTION, } from '~/boards/constants'; import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue'; -import { mockList, mockIssue2, mockIssue, mockIssue3, mockIssue4 } from 'jest/boards/mock_data'; +import { mockList, mockIssue2 } from 'jest/boards/mock_data'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; Vue.use(Vuex); @@ -28,30 +28,8 @@ describe('Board Card Move to position', () => { let wrapper; let trackingSpy; let store; - let dispatch; const itemIndex = 1; - const createStoreOptions = () => { - const state = { - pageInfoByListId: { - 'gid://gitlab/List/1': {}, - 'gid://gitlab/List/2': { hasNextPage: true }, - }, - }; - const getters = { - getBoardItemsByList: () => () => [mockIssue, mockIssue2, mockIssue3, mockIssue4], - }; - const actions = { - moveItem: jest.fn(), - }; - - return { - state, - getters, - actions, - }; - }; - const createComponent = (propsData, isApolloBoard = false) => { wrapper = shallowMount(BoardCardMoveToPosition, { store, @@ -73,7 +51,6 @@ describe('Board Card Move to position', () => { }; beforeEach(() => { - store = new Vuex.Store(createStoreOptions()); createComponent(); }); @@ -97,50 +74,6 @@ describe('Board Card Move to position', () => { describe('Dropdown options', () => { beforeEach(() => { - createComponent({ index: itemIndex }); - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - dispatch = jest.spyOn(store, 'dispatch').mockImplementation(() => {}); - }); - - 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 should call moveItem action 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(dispatch).toHaveBeenCalledWith('moveItem', { - fromListId: mockList.id, - itemId: mockIssue2.id, - itemIid: mockIssue2.iid, - itemPath: mockIssue2.referencePath, - positionInList, - toListId: mockList.id, - allItemsLoadedInList: true, - atIndex: itemIndex, - }); - }, - ); - }); - - describe('Apollo boards', () => { - beforeEach(() => { createComponent({ index: itemIndex }, true); trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js index 11f9a4f6ff2..dae0db27104 100644 --- a/spec/frontend/boards/components/board_card_spec.js +++ b/spec/frontend/boards/components/board_card_spec.js @@ -1,7 +1,5 @@ import { GlLabel } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; import VueApollo from 'vue-apollo'; import waitForPromises from 'helpers/wait_for_promises'; @@ -9,17 +7,14 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import BoardCard from '~/boards/components/board_card.vue'; import BoardCardInner from '~/boards/components/board_card_inner.vue'; -import { inactiveId } from '~/boards/constants'; import selectedBoardItemsQuery from '~/boards/graphql/client/selected_board_items.query.graphql'; +import activeBoardItemQuery from '~/boards/graphql/client/active_board_item.query.graphql'; import isShowingLabelsQuery from '~/graphql_shared/client/is_showing_labels.query.graphql'; import { mockLabelList, mockIssue, DEFAULT_COLOR } from '../mock_data'; describe('Board card', () => { let wrapper; - let store; - let mockActions; - Vue.use(Vuex); Vue.use(VueApollo); const mockSetActiveBoardItemResolver = jest.fn(); @@ -31,23 +26,6 @@ describe('Board card', () => { }, }); - const createStore = ({ initialState = {} } = {}) => { - mockActions = { - toggleBoardItem: jest.fn(), - toggleBoardItemMultiSelection: jest.fn(), - performSearch: jest.fn(), - }; - - store = new Vuex.Store({ - state: { - activeId: inactiveId, - selectedBoardItems: [], - ...initialState, - }, - actions: mockActions, - }); - }; - // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized const mountComponent = ({ propsData = {}, @@ -55,6 +33,7 @@ describe('Board card', () => { stubs = { BoardCardInner }, item = mockIssue, selectedBoardItems = [], + activeBoardItem = {}, } = {}) => { mockApollo.clients.defaultClient.cache.writeQuery({ query: isShowingLabelsQuery, @@ -68,6 +47,12 @@ describe('Board card', () => { selectedBoardItems, }, }); + mockApollo.clients.defaultClient.cache.writeQuery({ + query: activeBoardItemQuery, + data: { + activeBoardItem, + }, + }); wrapper = shallowMountExtended(BoardCard, { apolloProvider: mockApollo, @@ -75,7 +60,6 @@ describe('Board card', () => { ...stubs, BoardCardInner, }, - store, propsData: { list: mockLabelList, item, @@ -92,7 +76,7 @@ describe('Board card', () => { isGroupBoard: true, disabled: false, allowSubEpics: false, - isApolloBoard: false, + isApolloBoard: true, ...provide, }, }); @@ -112,47 +96,32 @@ describe('Board card', () => { window.gon = { features: {} }; }); - afterEach(() => { - store = null; - }); - describe('when GlLabel is clicked in BoardCardInner', () => { - it('doesnt call toggleBoardItem', () => { - createStore(); + it("doesn't call setSelectedBoardItemsMutation", () => { mountComponent(); wrapper.findComponent(GlLabel).trigger('mouseup'); - expect(mockActions.toggleBoardItem).toHaveBeenCalledTimes(0); + expect(mockSetSelectedBoardItemsResolver).toHaveBeenCalledTimes(0); }); }); it('should not highlight the card by default', () => { - createStore(); mountComponent(); expect(wrapper.classes()).not.toContain('is-active'); expect(wrapper.classes()).not.toContain('multi-select'); }); - it('should highlight the card with a correct style when selected', () => { - createStore({ - initialState: { - activeId: mockIssue.id, - }, - }); - mountComponent(); + it('should highlight the card with a correct style when selected', async () => { + mountComponent({ activeBoardItem: mockIssue }); + await waitForPromises(); expect(wrapper.classes()).toContain('is-active'); expect(wrapper.classes()).not.toContain('multi-select'); }); it('should highlight the card with a correct style when multi-selected', () => { - createStore({ - initialState: { - activeId: inactiveId, - }, - }); mountComponent({ selectedBoardItems: [mockIssue.id] }); expect(wrapper.classes()).toContain('multi-select'); @@ -161,18 +130,22 @@ describe('Board card', () => { describe('when mouseup event is called on the card', () => { beforeEach(() => { - createStore(); mountComponent(); }); describe('when not using multi-select', () => { - it('should call vuex action "toggleBoardItem" with correct parameters', async () => { + it('set active board item on client when clicking on card', async () => { await selectCard(); + await waitForPromises(); - expect(mockActions.toggleBoardItem).toHaveBeenCalledTimes(1); - expect(mockActions.toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), { - boardItem: mockIssue, - }); + expect(mockSetActiveBoardItemResolver).toHaveBeenCalledWith( + {}, + { + boardItem: mockIssue, + }, + expect.anything(), + expect.anything(), + ); }); }); @@ -199,7 +172,6 @@ describe('Board card', () => { describe('when card is loading', () => { it('card is disabled and user cannot drag', () => { - createStore(); mountComponent({ item: { ...mockIssue, isLoading: true } }); expect(wrapper.classes()).toContain('is-disabled'); @@ -209,7 +181,6 @@ describe('Board card', () => { describe('when card is not loading', () => { it('user can drag', () => { - createStore(); mountComponent(); expect(wrapper.classes()).not.toContain('is-disabled'); @@ -220,7 +191,6 @@ describe('Board card', () => { describe('when Epic colors are enabled', () => { it('applies the correct color', () => { window.gon.features = { epicColorHighlight: true }; - createStore(); mountComponent({ item: { ...mockIssue, @@ -238,7 +208,6 @@ describe('Board card', () => { describe('when Epic colors are not enabled', () => { it('applies the correct color', () => { window.gon.features = { epicColorHighlight: false }; - createStore(); mountComponent({ item: { ...mockIssue, @@ -252,26 +221,4 @@ describe('Board card', () => { expect(wrapper.attributes('style')).toBeUndefined(); }); }); - - describe('Apollo boards', () => { - beforeEach(async () => { - createStore(); - mountComponent({ provide: { isApolloBoard: true } }); - await nextTick(); - }); - - it('set active board item on client when clicking on card', async () => { - await selectCard(); - await waitForPromises(); - - expect(mockSetActiveBoardItemResolver).toHaveBeenCalledWith( - {}, - { - boardItem: mockIssue, - }, - expect.anything(), - expect.anything(), - ); - }); - }); }); diff --git a/spec/frontend/boards/components/board_column_spec.js b/spec/frontend/boards/components/board_column_spec.js index 5717031be20..61c53c27187 100644 --- a/spec/frontend/boards/components/board_column_spec.js +++ b/spec/frontend/boards/components/board_column_spec.js @@ -4,17 +4,15 @@ import { nextTick } from 'vue'; import { listObj } from 'jest/boards/mock_data'; import BoardColumn from '~/boards/components/board_column.vue'; import { ListType } from '~/boards/constants'; -import { createStore } from '~/boards/stores'; describe('Board Column Component', () => { let wrapper; - let store; - const initStore = () => { - store = createStore(); - }; - - const createComponent = ({ listType = ListType.backlog, collapsed = false } = {}) => { + const createComponent = ({ + listType = ListType.backlog, + collapsed = false, + highlightedLists = [], + } = {}) => { const listMock = { ...listObj, listType, @@ -27,14 +25,11 @@ describe('Board Column Component', () => { } wrapper = shallowMount(BoardColumn, { - store, propsData: { list: listMock, boardId: 'gid://gitlab/Board/1', filters: {}, - }, - provide: { - isApolloBoard: false, + highlightedLists, }, }); }; @@ -43,10 +38,6 @@ describe('Board Column Component', () => { const isCollapsed = () => wrapper.classes('is-collapsed'); describe('Given different list types', () => { - beforeEach(() => { - initStore(); - }); - it('is expandable when List Type is `backlog`', () => { createComponent({ listType: ListType.backlog }); @@ -70,40 +61,11 @@ describe('Board Column Component', () => { describe('highlighting', () => { it('scrolls to column when highlighted', async () => { - createComponent(); - - store.state.highlightedLists.push(listObj.id); + createComponent({ highlightedLists: [listObj.id] }); await nextTick(); expect(wrapper.element.scrollIntoView).toHaveBeenCalled(); }); }); - - describe('on mount', () => { - beforeEach(() => { - initStore(); - jest.spyOn(store, 'dispatch').mockImplementation(); - }); - - describe('when list is collapsed', () => { - it('does not call fetchItemsForList when', async () => { - createComponent({ collapsed: true }); - - await nextTick(); - - expect(store.dispatch).toHaveBeenCalledTimes(0); - }); - }); - - describe('when the list is not collapsed', () => { - it('calls fetchItemsForList when', async () => { - createComponent({ collapsed: false }); - - await nextTick(); - - expect(store.dispatch).toHaveBeenCalledWith('fetchItemsForList', { listId: 300 }); - }); - }); - }); }); diff --git a/spec/frontend/boards/components/board_content_sidebar_spec.js b/spec/frontend/boards/components/board_content_sidebar_spec.js index 01eea12bf0a..5fffd4d0c23 100644 --- a/spec/frontend/boards/components/board_content_sidebar_spec.js +++ b/spec/frontend/boards/components/board_content_sidebar_spec.js @@ -2,8 +2,6 @@ import { GlDrawer } from '@gitlab/ui'; import { MountingPortal } from 'portal-vue'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue'; import createMockApollo from 'helpers/mock_apollo_helper'; import { stubComponent } from 'helpers/stub_component'; @@ -13,20 +11,17 @@ import waitForPromises from 'helpers/wait_for_promises'; import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; -import { ISSUABLE } from '~/boards/constants'; import { TYPE_ISSUE } from '~/issues/constants'; import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; import SidebarSeverityWidget from '~/sidebar/components/severity/sidebar_severity_widget.vue'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import SidebarLabelsWidget from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue'; -import { mockActiveIssue, mockIssue, rawIssue } from '../mock_data'; +import { rawIssue } from '../mock_data'; -Vue.use(Vuex); Vue.use(VueApollo); describe('BoardContentSidebar', () => { let wrapper; - let store; const mockSetActiveBoardItemResolver = jest.fn(); const mockApollo = createMockApollo([], { @@ -35,28 +30,11 @@ describe('BoardContentSidebar', () => { }, }); - const createStore = ({ mockGetters = {}, mockActions = {} } = {}) => { - store = new Vuex.Store({ - state: { - sidebarType: ISSUABLE, - issues: { [mockIssue.id]: { ...mockIssue, epic: null } }, - activeId: mockIssue.id, - }, - getters: { - activeBoardItem: () => { - return { ...mockActiveIssue, epic: null }; - }, - ...mockGetters, - }, - actions: mockActions, - }); - }; - - const createComponent = ({ isApolloBoard = false } = {}) => { + const createComponent = ({ issuable = rawIssue } = {}) => { mockApollo.clients.defaultClient.cache.writeQuery({ query: activeBoardItemQuery, data: { - activeBoardItem: rawIssue, + activeBoardItem: issuable, }, }); @@ -68,9 +46,7 @@ describe('BoardContentSidebar', () => { groupId: 1, issuableType: TYPE_ISSUE, isGroupBoard: false, - isApolloBoard, }, - store, stubs: { GlDrawer: stubComponent(GlDrawer, { template: '<div><slot name="header"></slot><slot></slot></div>', @@ -80,7 +56,6 @@ describe('BoardContentSidebar', () => { }; beforeEach(() => { - createStore(); createComponent(); }); @@ -97,8 +72,7 @@ describe('BoardContentSidebar', () => { }); it('does not render GlDrawer when no active item is set', async () => { - createStore({ mockGetters: { activeBoardItem: () => ({ id: '', iid: '' }) } }); - createComponent(); + createComponent({ issuable: {} }); await nextTick(); @@ -155,45 +129,10 @@ describe('BoardContentSidebar', () => { }); describe('when we emit close', () => { - let toggleBoardItem; - - beforeEach(() => { - toggleBoardItem = jest.fn(); - createStore({ mockActions: { toggleBoardItem } }); - createComponent(); - }); - - it('calls toggleBoardItem with correct parameters', () => { - wrapper.findComponent(GlDrawer).vm.$emit('close'); - - expect(toggleBoardItem).toHaveBeenCalledTimes(1); - expect(toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), { - boardItem: { ...mockActiveIssue, epic: null }, - sidebarType: ISSUABLE, - }); - }); - }); - - describe('incident sidebar', () => { beforeEach(() => { - createStore({ - mockGetters: { activeBoardItem: () => ({ ...mockIssue, epic: null, type: 'INCIDENT' }) }, - }); createComponent(); }); - it('renders SidebarSeverityWidget', () => { - expect(wrapper.findComponent(SidebarSeverityWidget).exists()).toBe(true); - }); - }); - - describe('Apollo boards', () => { - beforeEach(async () => { - createStore(); - createComponent({ isApolloBoard: true }); - await nextTick(); - }); - it('calls setActiveBoardItemMutation on close', async () => { wrapper.findComponent(GlDrawer).vm.$emit('close'); @@ -209,4 +148,14 @@ describe('BoardContentSidebar', () => { ); }); }); + + describe('incident sidebar', () => { + beforeEach(() => { + createComponent({ issuable: { ...rawIssue, epic: null, type: 'INCIDENT' } }); + }); + + it('renders SidebarSeverityWidget', () => { + expect(wrapper.findComponent(SidebarSeverityWidget).exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js index 675b79a8b1a..706f84ad319 100644 --- a/spec/frontend/boards/components/board_content_spec.js +++ b/spec/frontend/boards/components/board_content_spec.js @@ -3,14 +3,11 @@ import { shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import Vue, { nextTick } from 'vue'; import Draggable from 'vuedraggable'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; 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'; -import getters from 'ee_else_ce/boards/stores/getters'; import * as cacheUpdates from '~/boards/graphql/cache_updates'; import BoardColumn from '~/boards/components/board_column.vue'; import BoardContent from '~/boards/components/board_content.vue'; @@ -27,11 +24,6 @@ import { } from '../mock_data'; Vue.use(VueApollo); -Vue.use(Vuex); - -const actions = { - moveList: jest.fn(), -}; describe('BoardContent', () => { let wrapper; @@ -41,26 +33,9 @@ describe('BoardContent', () => { const errorMessage = 'Failed to update list'; const updateListHandlerFailure = jest.fn().mockRejectedValue(new Error(errorMessage)); - const defaultState = { - isShowingEpicsSwimlanes: false, - boardLists: mockListsById, - error: undefined, - issuableType: 'issue', - }; - - const createStore = (state = defaultState) => { - return new Vuex.Store({ - actions, - getters, - state, - }); - }; - const createComponent = ({ - state, props = {}, canAdminList = true, - isApolloBoard = false, issuableType = 'issue', isIssueBoard = true, isEpicBoard = false, @@ -75,17 +50,13 @@ describe('BoardContent', () => { data: boardListsQueryResponse.data, }); - const store = createStore({ - ...defaultState, - ...state, - }); wrapper = shallowMount(BoardContent, { apolloProvider: mockApollo, propsData: { boardId: 'gid://gitlab/Board/1', filterParams: {}, isSwimlanesOn: false, - boardListsApollo: mockListsById, + boardLists: mockListsById, listQueryVariables, addColumnFormVisible: false, ...props, @@ -98,9 +69,7 @@ describe('BoardContent', () => { isEpicBoard, isGroupBoard: true, disabled: false, - isApolloBoard, }, - store, stubs: { BoardContentSidebar: stubComponent(BoardContentSidebar, { template: '<div></div>', @@ -114,13 +83,26 @@ describe('BoardContent', () => { const findDraggable = () => wrapper.findComponent(Draggable); const findError = () => wrapper.findComponent(GlAlert); + const moveList = () => { + 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 } })), + }, + }); + }; + beforeEach(() => { cacheUpdates.setError = jest.fn(); }); describe('default', () => { - beforeEach(() => { + beforeEach(async () => { createComponent(); + await waitForPromises(); }); it('renders a BoardColumn component per list', () => { @@ -146,63 +128,6 @@ describe('BoardContent', () => { it('does not show the "add column" form', () => { expect(findBoardAddNewColumn().exists()).toBe(false); }); - }); - - describe('when issuableType is not issue', () => { - beforeEach(() => { - createComponent({ issuableType: 'foo', isIssueBoard: false }); - }); - - it('does not render BoardContentSidebar', () => { - expect(wrapper.findComponent(BoardContentSidebar).exists()).toBe(false); - }); - }); - - describe('can admin list', () => { - beforeEach(() => { - createComponent({ canAdminList: true }); - }); - - it('renders draggable component', () => { - expect(findDraggable().exists()).toBe(true); - }); - }); - - describe('can not admin list', () => { - beforeEach(() => { - createComponent({ canAdminList: false }); - }); - - it('does not render draggable component', () => { - expect(findDraggable().exists()).toBe(false); - }); - }); - - describe('when Apollo boards FF is on', () => { - const moveList = () => { - 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 } })), - }, - }); - }; - - beforeEach(async () => { - createComponent({ isApolloBoard: true }); - await waitForPromises(); - }); - - it('renders a BoardColumn component per list', () => { - expect(wrapper.findAllComponents(BoardColumn)).toHaveLength(mockLists.length); - }); - - it('renders BoardContentSidebar', () => { - expect(wrapper.findComponent(BoardContentSidebar).exists()).toBe(true); - }); it('reorders lists', async () => { moveList(); @@ -212,7 +137,7 @@ describe('BoardContent', () => { }); it('sets error on reorder lists failure', async () => { - createComponent({ isApolloBoard: true, handler: updateListHandlerFailure }); + createComponent({ handler: updateListHandlerFailure }); moveList(); await waitForPromises(); @@ -222,7 +147,7 @@ describe('BoardContent', () => { describe('when error is passed', () => { beforeEach(async () => { - createComponent({ isApolloBoard: true, props: { apolloError: 'Error' } }); + createComponent({ props: { apolloError: 'Error' } }); await waitForPromises(); }); @@ -239,6 +164,36 @@ describe('BoardContent', () => { }); }); + describe('when issuableType is not issue', () => { + beforeEach(() => { + createComponent({ issuableType: 'foo', isIssueBoard: false }); + }); + + it('does not render BoardContentSidebar', () => { + expect(wrapper.findComponent(BoardContentSidebar).exists()).toBe(false); + }); + }); + + describe('can admin list', () => { + beforeEach(() => { + createComponent({ canAdminList: true }); + }); + + it('renders draggable component', () => { + expect(findDraggable().exists()).toBe(true); + }); + }); + + describe('can not admin list', () => { + beforeEach(() => { + createComponent({ canAdminList: false }); + }); + + it('does not render draggable component', () => { + expect(findDraggable().exists()).toBe(false); + }); + }); + describe('when "add column" form is visible', () => { beforeEach(() => { createComponent({ props: { addColumnFormVisible: true } }); diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js index 0bd936c9abd..5e96b508f37 100644 --- a/spec/frontend/boards/components/board_filtered_search_spec.js +++ b/spec/frontend/boards/components/board_filtered_search_spec.js @@ -1,7 +1,4 @@ import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue'; import { updateHistory } from '~/lib/utils/url_utility'; import { @@ -20,9 +17,6 @@ import { import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; -import { createStore } from '~/boards/stores'; - -Vue.use(Vuex); jest.mock('~/lib/utils/url_utility', () => ({ updateHistory: jest.fn(), @@ -32,7 +26,6 @@ jest.mock('~/lib/utils/url_utility', () => ({ describe('BoardFilteredSearch', () => { let wrapper; - let store; const tokens = [ { icon: 'labels', @@ -63,10 +56,12 @@ describe('BoardFilteredSearch', () => { ]; const createComponent = ({ initialFilterParams = {}, props = {}, provide = {} } = {}) => { - store = createStore(); wrapper = shallowMount(BoardFilteredSearch, { - provide: { initialFilterParams, fullPath: '', isApolloBoard: false, ...provide }, - store, + provide: { + initialFilterParams, + fullPath: '', + ...provide, + }, propsData: { ...props, tokens, @@ -79,8 +74,6 @@ describe('BoardFilteredSearch', () => { describe('default', () => { beforeEach(() => { createComponent(); - - jest.spyOn(store, 'dispatch').mockImplementation(); }); it('passes the correct tokens to FilteredSearch', () => { @@ -88,12 +81,6 @@ describe('BoardFilteredSearch', () => { }); describe('when onFilter is emitted', () => { - it('calls performSearch', () => { - findFilteredSearch().vm.$emit('onFilter', [{ value: { data: '' } }]); - - expect(store.dispatch).toHaveBeenCalledWith('performSearch'); - }); - it('calls historyPushState', () => { findFilteredSearch().vm.$emit('onFilter', [{ value: { data: 'searchQuery' } }]); @@ -104,10 +91,22 @@ describe('BoardFilteredSearch', () => { }); }); }); + + it('emits setFilters and updates URL when onFilter is emitted', () => { + findFilteredSearch().vm.$emit('onFilter', [{ value: { data: '' } }]); + + expect(updateHistory).toHaveBeenCalledWith({ + title: '', + replace: true, + url: 'http://test.host/', + }); + + expect(wrapper.emitted('setFilters')).toHaveLength(1); + }); }); describe('when eeFilters is not empty', () => { - it('passes the correct initialFilterValue to FitleredSearchBarRoot', () => { + it('passes the correct initialFilterValue to FilteredSearchBarRoot', () => { createComponent({ props: { eeFilters: { labelName: ['label'] } } }); expect(findFilteredSearch().props('initialFilterValue')).toEqual([ @@ -125,8 +124,6 @@ describe('BoardFilteredSearch', () => { describe('when searching', () => { beforeEach(() => { createComponent(); - - jest.spyOn(store, 'dispatch').mockImplementation(); }); it('sets the url params to the correct results', () => { @@ -146,7 +143,6 @@ describe('BoardFilteredSearch', () => { findFilteredSearch().vm.$emit('onFilter', mockFilters); - expect(store.dispatch).toHaveBeenCalledWith('performSearch'); expect(updateHistory).toHaveBeenCalledWith({ title: '', replace: true, @@ -193,21 +189,42 @@ describe('BoardFilteredSearch', () => { }); }); - describe('when Apollo boards FF is on', () => { + describe('when iteration is passed a wildcard value with a cadence id', () => { + const url = (arg) => `http://test.host/?iteration_id=${arg}&iteration_cadence_id=1349`; + beforeEach(() => { - createComponent({ provide: { isApolloBoard: true } }); + createComponent(); }); - it('emits setFilters and updates URL when onFilter is emitted', () => { - findFilteredSearch().vm.$emit('onFilter', [{ value: { data: '' } }]); + it.each([ + ['Current&1349', url('Current'), 'Current'], + ['Any&1349', url('Any'), 'Any'], + ])('sets the url param %s', (iterationParam, expected, wildCardId) => { + Object.defineProperty(window, 'location', { + writable: true, + value: new URL(expected), + }); + + const mockFilters = [ + { type: TOKEN_TYPE_ITERATION, value: { data: iterationParam, operator: '=' } }, + ]; + + findFilteredSearch().vm.$emit('onFilter', mockFilters); expect(updateHistory).toHaveBeenCalledWith({ title: '', replace: true, - url: 'http://test.host/', + url: expected, }); - expect(wrapper.emitted('setFilters')).toHaveLength(1); + expect(wrapper.emitted('setFilters')).toStrictEqual([ + [ + { + iterationCadenceId: '1349', + iterationId: wildCardId, + }, + ], + ]); }); }); }); diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index a0dacf085e2..16947a0512d 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -1,7 +1,5 @@ import { GlModal } from '@gitlab/ui'; import Vue from 'vue'; -// eslint-disable-next-line no-restricted-imports -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'; @@ -23,8 +21,6 @@ jest.mock('~/lib/utils/url_utility', () => ({ })); jest.mock('~/boards/eventhub'); -Vue.use(Vuex); - const currentBoard = { id: 'gid://gitlab/Board/1', name: 'test', @@ -55,14 +51,6 @@ describe('BoardForm', () => { const findDeleteConfirmation = () => wrapper.findByTestId('delete-confirmation-message'); const findInput = () => wrapper.find('#board-new-name'); - const setBoardMock = jest.fn(); - - const store = new Vuex.Store({ - actions: { - setBoard: setBoardMock, - }, - }); - const defaultHandlers = { createBoardMutationHandler: jest.fn().mockResolvedValue({ data: { @@ -107,7 +95,6 @@ describe('BoardForm', () => { isProjectBoard: false, ...provide, }, - store, attachTo: document.body, }); }; @@ -220,7 +207,7 @@ describe('BoardForm', () => { }); await waitForPromises(); - expect(setBoardMock).toHaveBeenCalledTimes(1); + expect(wrapper.emitted('addBoard')).toHaveLength(1); }); it('sets error in state if GraphQL mutation fails', async () => { @@ -239,31 +226,8 @@ describe('BoardForm', () => { expect(requestHandlers.createBoardMutationHandler).toHaveBeenCalled(); await waitForPromises(); - expect(setBoardMock).not.toHaveBeenCalled(); expect(cacheUpdates.setError).toHaveBeenCalled(); }); - - describe('when Apollo boards FF is on', () => { - it('calls a correct GraphQL mutation and emits addBoard event when creating a board', async () => { - createComponent({ - props: { canAdminBoard: true, currentPage: formType.new }, - provide: { isApolloBoard: true }, - }); - - fillForm(); - - await waitForPromises(); - - expect(requestHandlers.createBoardMutationHandler).toHaveBeenCalledWith({ - input: expect.objectContaining({ - name: 'test', - }), - }); - - await waitForPromises(); - expect(wrapper.emitted('addBoard')).toHaveLength(1); - }); - }); }); }); @@ -314,8 +278,12 @@ describe('BoardForm', () => { }); await waitForPromises(); - expect(setBoardMock).toHaveBeenCalledTimes(1); expect(global.window.location.href).not.toContain('?group_by=epic'); + expect(eventHub.$emit).toHaveBeenCalledTimes(1); + expect(eventHub.$emit).toHaveBeenCalledWith('updateBoard', { + id: 'gid://gitlab/Board/321', + webPath: 'test-path', + }); }); it('calls GraphQL mutation with correct parameters when issues are grouped by epic', async () => { @@ -335,7 +303,6 @@ describe('BoardForm', () => { }); await waitForPromises(); - expect(setBoardMock).toHaveBeenCalledTimes(1); expect(global.window.location.href).toContain('?group_by=epic'); }); @@ -355,36 +322,8 @@ describe('BoardForm', () => { expect(requestHandlers.updateBoardMutationHandler).toHaveBeenCalled(); await waitForPromises(); - expect(setBoardMock).not.toHaveBeenCalled(); expect(cacheUpdates.setError).toHaveBeenCalled(); }); - - describe('when Apollo boards FF is on', () => { - it('calls a correct GraphQL mutation and emits updateBoard event when updating a board', async () => { - setWindowLocation('https://test/boards/1'); - - createComponent({ - props: { canAdminBoard: true, currentPage: formType.edit }, - provide: { isApolloBoard: true }, - }); - findInput().trigger('keyup.enter', { metaKey: true }); - - await waitForPromises(); - - expect(requestHandlers.updateBoardMutationHandler).toHaveBeenCalledWith({ - input: expect.objectContaining({ - id: currentBoard.id, - }), - }); - - await waitForPromises(); - expect(eventHub.$emit).toHaveBeenCalledTimes(1); - expect(eventHub.$emit).toHaveBeenCalledWith('updateBoard', { - id: 'gid://gitlab/Board/321', - webPath: 'test-path', - }); - }); - }); }); describe('when deleting a board', () => { @@ -427,7 +366,6 @@ describe('BoardForm', () => { destroyBoardMutationHandler: jest.fn().mockRejectedValue('Houston, we have a problem'), }, }); - jest.spyOn(store, 'dispatch').mockImplementation(() => {}); findModal().vm.$emit('primary'); diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js index 76e969f1725..b59ed8b6abb 100644 --- a/spec/frontend/boards/components/board_list_header_spec.js +++ b/spec/frontend/boards/components/board_list_header_spec.js @@ -1,8 +1,6 @@ import { GlButtonGroup } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -18,15 +16,11 @@ import * as cacheUpdates from '~/boards/graphql/cache_updates'; import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; Vue.use(VueApollo); -Vue.use(Vuex); describe('Board List Header Component', () => { let wrapper; - let store; let fakeApollo; - const updateListSpy = jest.fn(); - const toggleListCollapsedSpy = jest.fn(); const mockClientToggleListCollapsedResolver = jest.fn(); const updateListHandlerSuccess = jest.fn().mockResolvedValue(updateBoardListResponse); @@ -69,10 +63,6 @@ describe('Board List Header Component', () => { ); } - store = new Vuex.Store({ - state: {}, - actions: { updateList: updateListSpy, toggleListCollapsed: toggleListCollapsedSpy }, - }); fakeApollo = createMockApollo( [ [listQuery, listQueryHandler], @@ -87,7 +77,6 @@ describe('Board List Header Component', () => { wrapper = shallowMountExtended(BoardListHeader, { apolloProvider: fakeApollo, - store, propsData: { list: listMock, filterParams: {}, @@ -198,26 +187,34 @@ describe('Board List Header Component', () => { expect(icon.props('icon')).toBe('chevron-lg-right'); }); - it('should dispatch toggleListCollapse when clicking the collapse icon', async () => { - createComponent(); + it('set active board item on client when clicking on card', async () => { + createComponent({ listType: ListType.label }); + await nextTick(); findCaret().vm.$emit('click'); - await nextTick(); - expect(toggleListCollapsedSpy).toHaveBeenCalledTimes(1); + + expect(mockClientToggleListCollapsedResolver).toHaveBeenCalledWith( + {}, + { + list: mockLabelList, + collapsed: true, + }, + expect.anything(), + expect.anything(), + ); }); - it("when logged in it calls list update and doesn't set localStorage", async () => { + it("when logged in it doesn't set localStorage", async () => { createComponent({ withLocalStorage: false, currentUserId: 1 }); findCaret().vm.$emit('click'); await nextTick(); - expect(updateListSpy).toHaveBeenCalledTimes(1); expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.collapsed`)).toBe(null); }); - it("when logged out it doesn't call list update and sets localStorage", async () => { + it('when logged out it sets localStorage', async () => { createComponent({ currentUserId: null, }); @@ -225,7 +222,6 @@ describe('Board List Header Component', () => { findCaret().vm.$emit('click'); await nextTick(); - expect(updateListSpy).not.toHaveBeenCalled(); expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.collapsed`)).toBe( String(!isCollapsed()), ); @@ -252,86 +248,67 @@ describe('Board List Header Component', () => { }); }); - describe('Apollo boards', () => { - beforeEach(async () => { - createComponent({ listType: ListType.label, injectedProps: { isApolloBoard: true } }); - await nextTick(); - }); - - it('set active board item on client when clicking on card', async () => { - findCaret().vm.$emit('click'); - await nextTick(); - - expect(mockClientToggleListCollapsedResolver).toHaveBeenCalledWith( - {}, - { - list: mockLabelList, - collapsed: true, - }, - expect.anything(), - expect.anything(), - ); - }); + beforeEach(async () => { + createComponent({ listType: ListType.label }); + await nextTick(); + }); - it('does not call update list mutation when user is not logged in', async () => { - createComponent({ currentUserId: null, injectedProps: { isApolloBoard: true } }); + it('does not call update list mutation when user is not logged in', async () => { + createComponent({ currentUserId: null }); - findCaret().vm.$emit('click'); - await nextTick(); + findCaret().vm.$emit('click'); + await nextTick(); - expect(updateListHandlerSuccess).not.toHaveBeenCalled(); - }); + expect(updateListHandlerSuccess).not.toHaveBeenCalled(); + }); - it('calls update list mutation when user is logged in', async () => { - createComponent({ currentUserId: 1, injectedProps: { isApolloBoard: true } }); + it('calls update list mutation when user is logged in', async () => { + createComponent({ currentUserId: 1 }); - findCaret().vm.$emit('click'); - await nextTick(); + findCaret().vm.$emit('click'); + await nextTick(); - expect(updateListHandlerSuccess).toHaveBeenCalledWith({ - listId: mockLabelList.id, - collapsed: true, - }); + expect(updateListHandlerSuccess).toHaveBeenCalledWith({ + listId: mockLabelList.id, + collapsed: true, }); + }); - describe('when fetch list query fails', () => { - const errorMessage = 'Failed to fetch list'; - const listQueryHandlerFailure = jest.fn().mockRejectedValue(new Error(errorMessage)); + describe('when fetch list query fails', () => { + const errorMessage = 'Failed to fetch list'; + const listQueryHandlerFailure = jest.fn().mockRejectedValue(new Error(errorMessage)); - beforeEach(() => { - createComponent({ - listQueryHandler: listQueryHandlerFailure, - injectedProps: { isApolloBoard: true }, - }); + beforeEach(() => { + createComponent({ + listQueryHandler: listQueryHandlerFailure, }); + }); - it('sets error', async () => { - await waitForPromises(); + it('sets error', async () => { + await waitForPromises(); - expect(cacheUpdates.setError).toHaveBeenCalled(); - }); + expect(cacheUpdates.setError).toHaveBeenCalled(); }); + }); - describe('when update list mutation fails', () => { - const errorMessage = 'Failed to update list'; - const updateListHandlerFailure = jest.fn().mockRejectedValue(new Error(errorMessage)); + describe('when update list mutation fails', () => { + const errorMessage = 'Failed to update list'; + const updateListHandlerFailure = jest.fn().mockRejectedValue(new Error(errorMessage)); - beforeEach(() => { - createComponent({ - currentUserId: 1, - updateListHandler: updateListHandlerFailure, - injectedProps: { isApolloBoard: true }, - }); + beforeEach(() => { + createComponent({ + currentUserId: 1, + updateListHandler: updateListHandlerFailure, }); + }); - it('sets error', async () => { - await waitForPromises(); + it('sets error', async () => { + await waitForPromises(); - findCaret().vm.$emit('click'); - await waitForPromises(); + findCaret().vm.$emit('click'); + await waitForPromises(); - expect(cacheUpdates.setError).toHaveBeenCalled(); - }); + expect(cacheUpdates.setError).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/boards/components/board_new_issue_spec.js b/spec/frontend/boards/components/board_new_issue_spec.js index bf2608d0594..dad0d148449 100644 --- a/spec/frontend/boards/components/board_new_issue_spec.js +++ b/spec/frontend/boards/components/board_new_issue_spec.js @@ -1,7 +1,5 @@ import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import BoardNewIssue from '~/boards/components/board_new_issue.vue'; @@ -15,18 +13,12 @@ import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; import { mockList, mockGroupProjects, - mockIssue, - mockIssue2, mockProjectBoardResponse, mockGroupBoardResponse, } from '../mock_data'; -Vue.use(Vuex); Vue.use(VueApollo); -const addListNewIssuesSpy = jest.fn().mockResolvedValue(); -const mockActions = { addListNewIssue: addListNewIssuesSpy }; - const projectBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockProjectBoardResponse); const groupBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockGroupBoardResponse); @@ -36,20 +28,12 @@ const mockApollo = createMockApollo([ ]); const createComponent = ({ - state = {}, - actions = mockActions, - getters = { getBoardItemsByList: () => () => [] }, isGroupBoard = true, data = { selectedProject: mockGroupProjects[0] }, provide = {}, } = {}) => shallowMount(BoardNewIssue, { apolloProvider: mockApollo, - store: new Vuex.Store({ - state, - actions, - getters, - }), propsData: { list: mockList, boardId: 'gid://gitlab/Board/1', @@ -63,7 +47,6 @@ const createComponent = ({ isGroupBoard, boardType: 'group', isEpicBoard: false, - isApolloBoard: false, ...provide, }, stubs: { @@ -82,6 +65,32 @@ describe('Issue boards new issue form', () => { await nextTick(); }); + it.each` + boardType | queryHandler | notCalledHandler + ${WORKSPACE_GROUP} | ${groupBoardQueryHandlerSuccess} | ${projectBoardQueryHandlerSuccess} + ${WORKSPACE_PROJECT} | ${projectBoardQueryHandlerSuccess} | ${groupBoardQueryHandlerSuccess} + `( + 'fetches $boardType board and emits addNewIssue event', + async ({ boardType, queryHandler, notCalledHandler }) => { + wrapper = createComponent({ + provide: { + boardType, + isProjectBoard: boardType === WORKSPACE_PROJECT, + isGroupBoard: boardType === WORKSPACE_GROUP, + }, + }); + + await nextTick(); + findBoardNewItem().vm.$emit('form-submit', { title: 'Foo' }); + + await nextTick(); + + expect(queryHandler).toHaveBeenCalled(); + expect(notCalledHandler).not.toHaveBeenCalled(); + expect(wrapper.emitted('addNewIssue')[0][0]).toMatchObject({ title: 'Foo' }); + }, + ); + it('renders board-new-item component', () => { const boardNewItem = findBoardNewItem(); expect(boardNewItem.exists()).toBe(true); @@ -93,51 +102,6 @@ describe('Issue boards new issue form', () => { }); }); - it('calls addListNewIssue action when `board-new-item` emits form-submit event', async () => { - findBoardNewItem().vm.$emit('form-submit', { title: 'Foo' }); - - await nextTick(); - expect(addListNewIssuesSpy).toHaveBeenCalledWith(expect.any(Object), { - list: mockList, - issueInput: { - title: 'Foo', - labelIds: [], - assigneeIds: [], - milestoneId: undefined, - projectPath: mockGroupProjects[0].fullPath, - moveAfterId: undefined, - }, - }); - }); - - describe('when list has an existing issues', () => { - beforeEach(() => { - wrapper = createComponent({ - getters: { - getBoardItemsByList: () => () => [mockIssue, mockIssue2], - }, - isGroupBoard: true, - }); - }); - - it('uses the first issue ID as moveAfterId', async () => { - findBoardNewItem().vm.$emit('form-submit', { title: 'Foo' }); - - await nextTick(); - expect(addListNewIssuesSpy).toHaveBeenCalledWith(expect.any(Object), { - list: mockList, - issueInput: { - title: 'Foo', - labelIds: [], - assigneeIds: [], - milestoneId: undefined, - projectPath: mockGroupProjects[0].fullPath, - moveAfterId: mockIssue.id, - }, - }); - }); - }); - it('emits event `toggle-issue-form` with current list Id suffix on eventHub when `board-new-item` emits form-cancel event', async () => { jest.spyOn(eventHub, '$emit').mockImplementation(); findBoardNewItem().vm.$emit('form-cancel'); @@ -168,33 +132,4 @@ describe('Issue boards new issue form', () => { expect(projectSelect.exists()).toBe(false); }); }); - - describe('Apollo boards', () => { - it.each` - boardType | queryHandler | notCalledHandler - ${WORKSPACE_GROUP} | ${groupBoardQueryHandlerSuccess} | ${projectBoardQueryHandlerSuccess} - ${WORKSPACE_PROJECT} | ${projectBoardQueryHandlerSuccess} | ${groupBoardQueryHandlerSuccess} - `( - 'fetches $boardType board and emits addNewIssue event', - async ({ boardType, queryHandler, notCalledHandler }) => { - wrapper = createComponent({ - provide: { - boardType, - isProjectBoard: boardType === WORKSPACE_PROJECT, - isGroupBoard: boardType === WORKSPACE_GROUP, - isApolloBoard: true, - }, - }); - - await nextTick(); - findBoardNewItem().vm.$emit('form-submit', { title: 'Foo' }); - - await nextTick(); - - expect(queryHandler).toHaveBeenCalled(); - expect(notCalledHandler).not.toHaveBeenCalled(); - expect(wrapper.emitted('addNewIssue')[0][0]).toMatchObject({ title: 'Foo' }); - }, - ); - }); }); diff --git a/spec/frontend/boards/components/board_settings_sidebar_spec.js b/spec/frontend/boards/components/board_settings_sidebar_spec.js index f6ed483dfc5..71c886351b6 100644 --- a/spec/frontend/boards/components/board_settings_sidebar_spec.js +++ b/spec/frontend/boards/components/board_settings_sidebar_spec.js @@ -3,32 +3,23 @@ import { shallowMount } from '@vue/test-utils'; import { MountingPortal } from 'portal-vue'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; import createMockApollo from 'helpers/mock_apollo_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import waitForPromises from 'helpers/wait_for_promises'; import { stubComponent } from 'helpers/stub_component'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue'; -import { inactiveId, LIST } from '~/boards/constants'; import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql'; import * as cacheUpdates from '~/boards/graphql/cache_updates'; -import actions from '~/boards/stores/actions'; -import getters from '~/boards/stores/getters'; -import mutations from '~/boards/stores/mutations'; -import sidebarEventHub from '~/sidebar/event_hub'; import { mockLabelList, destroyBoardListMutationResponse } from '../mock_data'; Vue.use(VueApollo); -Vue.use(Vuex); describe('BoardSettingsSidebar', () => { let wrapper; let mockApollo; const labelTitle = mockLabelList.label.title; const labelColor = mockLabelList.label.color; - const listId = mockLabelList.id; const modalID = 'board-settings-sidebar-modal'; const destroyBoardListMutationHandlerSuccess = jest @@ -42,26 +33,12 @@ describe('BoardSettingsSidebar', () => { const createComponent = ({ canAdminList = false, list = {}, - sidebarType = LIST, - activeId = inactiveId, destroyBoardListMutationHandler = destroyBoardListMutationHandlerSuccess, - isApolloBoard = false, } = {}) => { - const boardLists = { - [listId]: list, - }; - const store = new Vuex.Store({ - state: { sidebarType, activeId, boardLists }, - getters, - mutations, - actions, - }); - mockApollo = createMockApollo([[destroyBoardListMutation, destroyBoardListMutationHandler]]); wrapper = extendedWrapper( shallowMount(BoardSettingsSidebar, { - store, apolloProvider: mockApollo, provide: { canAdminList, @@ -69,7 +46,6 @@ describe('BoardSettingsSidebar', () => { isIssueBoard: true, boardType: 'group', issuableType: 'issue', - isApolloBoard, }, propsData: { listId: list.id || '', @@ -100,90 +76,50 @@ describe('BoardSettingsSidebar', () => { cacheUpdates.setError = jest.fn(); }); - it('finds a MountingPortal component', () => { - createComponent(); - - expect(wrapper.findComponent(MountingPortal).props()).toMatchObject({ - mountTo: '#js-right-sidebar-portal', - append: true, - name: 'board-settings-sidebar', - }); - }); - - describe('when sidebarType is "list"', () => { - it('finds a GlDrawer component', () => { + describe('default', () => { + beforeEach(() => { createComponent(); + }); + it('renders a MountingPortal component', () => { + expect(wrapper.findComponent(MountingPortal).props()).toMatchObject({ + mountTo: '#js-right-sidebar-portal', + append: true, + name: 'board-settings-sidebar', + }); + }); + it('renders a GlDrawer component', () => { expect(findDrawer().exists()).toBe(true); }); describe('on close', () => { it('closes the sidebar', async () => { - createComponent(); - findDrawer().vm.$emit('close'); await nextTick(); expect(wrapper.findComponent(GlDrawer).props('open')).toBe(false); }); - - it('closes the sidebar when emitting the correct event', async () => { - createComponent(); - - sidebarEventHub.$emit('sidebar.closeAll'); - - await nextTick(); - - expect(wrapper.findComponent(GlDrawer).props('open')).toBe(false); - }); }); - describe('when activeId is zero', () => { + describe('when there is no active list', () => { it('renders GlDrawer with open false', () => { createComponent(); expect(findDrawer().props('open')).toBe(false); + expect(findLabel().exists()).toBe(false); }); }); - describe('when activeId is greater than zero', () => { - it('renders GlDrawer with open true', () => { - createComponent({ list: mockLabelList, activeId: listId }); + describe('when there is an active list', () => { + it('renders GlDrawer with list title and label', () => { + createComponent({ list: mockLabelList }); expect(findDrawer().props('open')).toBe(true); - }); - }); - - describe('when activeId is in state', () => { - it('renders label title', () => { - createComponent({ list: mockLabelList, activeId: listId }); - expect(findLabel().props('title')).toBe(labelTitle); - }); - - it('renders label background color', () => { - createComponent({ list: mockLabelList, activeId: listId }); - expect(findLabel().props('backgroundColor')).toBe(labelColor); }); }); - - describe('when activeId is not in state', () => { - it('does not render GlLabel', () => { - createComponent({ list: mockLabelList }); - - expect(findLabel().exists()).toBe(false); - }); - }); - }); - - describe('when sidebarType is not List', () => { - it('does not render GlDrawer', () => { - createComponent({ sidebarType: '' }); - - expect(findDrawer().props('open')).toBe(false); - }); }); it('does not render "Remove list" when user cannot admin the boards list', () => { @@ -193,20 +129,15 @@ describe('BoardSettingsSidebar', () => { }); describe('when user can admin the boards list', () => { - it('renders "Remove list" button', () => { - createComponent({ canAdminList: true, activeId: listId, list: mockLabelList }); + beforeEach(() => { + createComponent({ canAdminList: true, list: mockLabelList }); + }); + it('renders "Remove list" button', () => { expect(findRemoveButton().exists()).toBe(true); }); it('removes the list', () => { - createComponent({ - canAdminList: true, - activeId: listId, - list: mockLabelList, - isApolloBoard: true, - }); - findRemoveButton().vm.$emit('click'); wrapper.findComponent(GlModal).vm.$emit('primary'); @@ -215,23 +146,19 @@ describe('BoardSettingsSidebar', () => { }); it('has the correct ID on the button', () => { - createComponent({ canAdminList: true, activeId: listId, list: mockLabelList }); const binding = getBinding(findRemoveButton().element, 'gl-modal'); expect(binding.value).toBe(modalID); }); it('has the correct ID on the modal', () => { - createComponent({ canAdminList: true, activeId: listId, list: mockLabelList }); expect(findModal().props('modalId')).toBe(modalID); }); it('sets error when destroy list mutation fails', async () => { createComponent({ canAdminList: true, - activeId: listId, list: mockLabelList, destroyBoardListMutationHandler: destroyBoardListMutationHandlerFailure, - isApolloBoard: true, }); findRemoveButton().vm.$emit('click'); diff --git a/spec/frontend/boards/components/board_top_bar_spec.js b/spec/frontend/boards/components/board_top_bar_spec.js index 87abe630688..03526600114 100644 --- a/spec/frontend/boards/components/board_top_bar_spec.js +++ b/spec/frontend/boards/components/board_top_bar_spec.js @@ -1,8 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -21,18 +19,11 @@ import projectBoardQuery from '~/boards/graphql/project_board.query.graphql'; import { mockProjectBoardResponse, mockGroupBoardResponse } from '../mock_data'; Vue.use(VueApollo); -Vue.use(Vuex); describe('BoardTopBar', () => { let wrapper; let mockApollo; - const createStore = () => { - return new Vuex.Store({ - state: {}, - }); - }; - const projectBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockProjectBoardResponse); const groupBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockGroupBoardResponse); const errorMessage = 'Failed to fetch board'; @@ -43,14 +34,12 @@ describe('BoardTopBar', () => { projectBoardQueryHandler = projectBoardQueryHandlerSuccess, groupBoardQueryHandler = groupBoardQueryHandlerSuccess, } = {}) => { - const store = createStore(); mockApollo = createMockApollo([ [projectBoardQuery, projectBoardQueryHandler], [groupBoardQuery, groupBoardQueryHandler], ]); wrapper = shallowMount(BoardTopBar, { - store, apolloProvider: mockApollo, propsData: { boardId: 'gid://gitlab/Board/1', @@ -67,7 +56,7 @@ describe('BoardTopBar', () => { isIssueBoard: true, isEpicBoard: false, isGroupBoard: true, - isApolloBoard: false, + // isApolloBoard: false, ...provide, }, stubs: { IssueBoardFilteredSearch }, @@ -127,45 +116,41 @@ describe('BoardTopBar', () => { }); }); - describe('Apollo boards', () => { - it.each` - boardType | queryHandler | notCalledHandler - ${WORKSPACE_GROUP} | ${groupBoardQueryHandlerSuccess} | ${projectBoardQueryHandlerSuccess} - ${WORKSPACE_PROJECT} | ${projectBoardQueryHandlerSuccess} | ${groupBoardQueryHandlerSuccess} - `('fetches $boardType boards', async ({ boardType, queryHandler, notCalledHandler }) => { - createComponent({ - provide: { - boardType, - isProjectBoard: boardType === WORKSPACE_PROJECT, - isGroupBoard: boardType === WORKSPACE_GROUP, - isApolloBoard: true, - }, - }); - - await nextTick(); - - expect(queryHandler).toHaveBeenCalled(); - expect(notCalledHandler).not.toHaveBeenCalled(); + it.each` + boardType | queryHandler | notCalledHandler + ${WORKSPACE_GROUP} | ${groupBoardQueryHandlerSuccess} | ${projectBoardQueryHandlerSuccess} + ${WORKSPACE_PROJECT} | ${projectBoardQueryHandlerSuccess} | ${groupBoardQueryHandlerSuccess} + `('fetches $boardType boards', async ({ boardType, queryHandler, notCalledHandler }) => { + createComponent({ + provide: { + boardType, + isProjectBoard: boardType === WORKSPACE_PROJECT, + isGroupBoard: boardType === WORKSPACE_GROUP, + }, }); - it.each` - boardType - ${WORKSPACE_GROUP} - ${WORKSPACE_PROJECT} - `('sets error when $boardType board query fails', async ({ boardType }) => { - createComponent({ - provide: { - boardType, - isProjectBoard: boardType === WORKSPACE_PROJECT, - isGroupBoard: boardType === WORKSPACE_GROUP, - isApolloBoard: true, - }, - groupBoardQueryHandler: boardQueryHandlerFailure, - projectBoardQueryHandler: boardQueryHandlerFailure, - }); - - await waitForPromises(); - expect(cacheUpdates.setError).toHaveBeenCalled(); + await nextTick(); + + expect(queryHandler).toHaveBeenCalled(); + expect(notCalledHandler).not.toHaveBeenCalled(); + }); + + it.each` + boardType + ${WORKSPACE_GROUP} + ${WORKSPACE_PROJECT} + `('sets error when $boardType board query fails', async ({ boardType }) => { + createComponent({ + provide: { + boardType, + isProjectBoard: boardType === WORKSPACE_PROJECT, + isGroupBoard: boardType === WORKSPACE_GROUP, + }, + groupBoardQueryHandler: boardQueryHandlerFailure, + projectBoardQueryHandler: boardQueryHandlerFailure, }); + + await waitForPromises(); + expect(cacheUpdates.setError).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js index 0a628af9939..8766b1c25f2 100644 --- a/spec/frontend/boards/components/boards_selector_spec.js +++ b/spec/frontend/boards/components/boards_selector_spec.js @@ -1,8 +1,6 @@ import { GlCollapsibleListbox } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'spec/test_constants'; import BoardsSelector from '~/boards/components/boards_selector.vue'; @@ -29,23 +27,10 @@ import { const throttleDuration = 1; Vue.use(VueApollo); -Vue.use(Vuex); describe('BoardsSelector', () => { let wrapper; let fakeApollo; - let store; - - const createStore = () => { - store = new Vuex.Store({ - actions: { - setBoardConfig: jest.fn(), - }, - state: { - board: mockBoard, - }, - }); - }; const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); @@ -91,10 +76,10 @@ describe('BoardsSelector', () => { ]); wrapper = shallowMountExtended(BoardsSelector, { - store, apolloProvider: fakeApollo, propsData: { throttleDuration, + board: mockBoard, ...props, }, attachTo: document.body, @@ -109,7 +94,7 @@ describe('BoardsSelector', () => { boardType: isGroupBoard ? 'group' : 'project', isGroupBoard, isProjectBoard, - isApolloBoard: false, + // isApolloBoard: false, ...provide, }, }); @@ -125,7 +110,6 @@ describe('BoardsSelector', () => { describe('template', () => { beforeEach(() => { - createStore(); createComponent({ isProjectBoard: true }); }); @@ -137,9 +121,6 @@ describe('BoardsSelector', () => { it('shows loading spinner', async () => { createComponent({ - provide: { - isApolloBoard: true, - }, props: { isCurrentBoardLoading: true, }, @@ -243,7 +224,6 @@ describe('BoardsSelector', () => { ${WORKSPACE_GROUP} | ${groupBoardsQueryHandlerSuccess} | ${projectBoardsQueryHandlerSuccess} ${WORKSPACE_PROJECT} | ${projectBoardsQueryHandlerSuccess} | ${groupBoardsQueryHandlerSuccess} `('fetches $boardType boards', async ({ boardType, queryHandler, notCalledHandler }) => { - createStore(); createComponent({ isGroupBoard: boardType === WORKSPACE_GROUP, isProjectBoard: boardType === WORKSPACE_PROJECT, @@ -265,7 +245,6 @@ describe('BoardsSelector', () => { ${WORKSPACE_GROUP} ${WORKSPACE_PROJECT} `('sets error when fetching $boardType boards fails', async ({ boardType }) => { - createStore(); createComponent({ isGroupBoard: boardType === WORKSPACE_GROUP, isProjectBoard: boardType === WORKSPACE_PROJECT, @@ -287,7 +266,6 @@ describe('BoardsSelector', () => { describe('dropdown visibility', () => { describe('when multipleIssueBoardsAvailable is enabled', () => { it('show dropdown', () => { - createStore(); createComponent({ provide: { multipleIssueBoardsAvailable: true } }); expect(findDropdown().exists()).toBe(true); expect(findDropdown().props('toggleText')).toBe('Select board'); @@ -296,7 +274,6 @@ describe('BoardsSelector', () => { describe('when multipleIssueBoardsAvailable is disabled but it hasMissingBoards', () => { it('show dropdown', () => { - createStore(); createComponent({ provide: { multipleIssueBoardsAvailable: false, hasMissingBoards: true }, }); @@ -307,7 +284,6 @@ describe('BoardsSelector', () => { describe("when multipleIssueBoardsAvailable is disabled and it dosn't hasMissingBoards", () => { it('hide dropdown', () => { - createStore(); createComponent({ provide: { multipleIssueBoardsAvailable: false, hasMissingBoards: false }, }); @@ -320,7 +296,6 @@ describe('BoardsSelector', () => { it('displays loading state of dropdown while current board is being fetched', () => { createComponent({ props: { isCurrentBoardLoading: true }, - provide: { isApolloBoard: true }, }); expect(findDropdown().props('loading')).toBe(true); expect(findDropdown().props('toggleText')).toBe('Select board'); diff --git a/spec/frontend/boards/components/issue_board_filtered_search_spec.js b/spec/frontend/boards/components/issue_board_filtered_search_spec.js index 1edb6812af0..39cdde295aa 100644 --- a/spec/frontend/boards/components/issue_board_filtered_search_spec.js +++ b/spec/frontend/boards/components/issue_board_filtered_search_spec.js @@ -23,6 +23,9 @@ describe('IssueBoardFilter', () => { fullPath: 'gitlab-org', isGroupBoard: true, }, + mocks: { + $apollo: {}, + }, }); }; diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js index f354067e226..77b557e7ccd 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js @@ -6,7 +6,6 @@ import waitForPromises from 'helpers/wait_for_promises'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; -import { createStore } from '~/boards/stores'; import issueSetTitleMutation from '~/boards/graphql/issue_set_title.mutation.graphql'; import * as cacheUpdates from '~/boards/graphql/cache_updates'; import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql'; @@ -32,11 +31,10 @@ const TEST_ISSUE_B = { describe('BoardSidebarTitle', () => { let wrapper; - let store; - let storeDispatch; let mockApollo; const issueSetTitleMutationHandlerSuccess = jest.fn().mockResolvedValue(updateIssueTitleResponse); + const issueSetTitleMutationHandlerFailure = jest.fn().mockRejectedValue(new Error('error')); const updateEpicTitleMutationHandlerSuccess = jest .fn() .mockResolvedValue(updateEpicTitleResponse); @@ -47,28 +45,25 @@ describe('BoardSidebarTitle', () => { afterEach(() => { localStorage.clear(); - store = null; }); - const createWrapper = ({ item = TEST_ISSUE_A, provide = {} } = {}) => { - store = createStore(); - store.state.boardItems = { [item.id]: { ...item } }; - store.dispatch('setActiveId', { id: item.id }); + const createWrapper = ({ + item = TEST_ISSUE_A, + provide = {}, + issueSetTitleMutationHandler = issueSetTitleMutationHandlerSuccess, + } = {}) => { mockApollo = createMockApollo([ - [issueSetTitleMutation, issueSetTitleMutationHandlerSuccess], + [issueSetTitleMutation, issueSetTitleMutationHandler], [updateEpicTitleMutation, updateEpicTitleMutationHandlerSuccess], ]); - storeDispatch = jest.spyOn(store, 'dispatch'); wrapper = shallowMountExtended(BoardSidebarTitle, { - store, apolloProvider: mockApollo, provide: { canUpdate: true, fullPath: 'gitlab-org', issuableType: 'issue', isEpicBoard: false, - isApolloBoard: false, ...provide, }, propsData: { @@ -122,13 +117,6 @@ describe('BoardSidebarTitle', () => { expect(findCollapsed().isVisible()).toBe(true); }); - it('commits change to the server', () => { - expect(storeDispatch).toHaveBeenCalledWith('setActiveItemTitle', { - projectPath: 'h/b', - title: 'New item title', - }); - }); - it('renders correct title', async () => { createWrapper({ item: { ...TEST_ISSUE_A, title: TEST_TITLE } }); await waitForPromises(); @@ -137,6 +125,31 @@ describe('BoardSidebarTitle', () => { }); }); + it.each` + issuableType | isEpicBoard | queryHandler | notCalledHandler + ${'issue'} | ${false} | ${issueSetTitleMutationHandlerSuccess} | ${updateEpicTitleMutationHandlerSuccess} + ${'epic'} | ${true} | ${updateEpicTitleMutationHandlerSuccess} | ${issueSetTitleMutationHandlerSuccess} + `( + 'updates $issuableType title', + async ({ issuableType, isEpicBoard, queryHandler, notCalledHandler }) => { + createWrapper({ + provide: { + issuableType, + isEpicBoard, + }, + }); + + await nextTick(); + + findFormInput().vm.$emit('input', TEST_TITLE); + findForm().vm.$emit('submit', { preventDefault: () => {} }); + await nextTick(); + + expect(queryHandler).toHaveBeenCalled(); + expect(notCalledHandler).not.toHaveBeenCalled(); + }, + ); + describe('when submitting and invalid title', () => { beforeEach(async () => { createWrapper(); @@ -146,8 +159,8 @@ describe('BoardSidebarTitle', () => { await nextTick(); }); - it('commits change to the server', () => { - expect(storeDispatch).not.toHaveBeenCalled(); + it('does not update title', () => { + expect(issueSetTitleMutationHandlerSuccess).not.toHaveBeenCalled(); }); }); @@ -194,7 +207,7 @@ describe('BoardSidebarTitle', () => { }); it('collapses sidebar and render former title', () => { - expect(storeDispatch).not.toHaveBeenCalled(); + expect(issueSetTitleMutationHandlerSuccess).not.toHaveBeenCalled(); expect(findCollapsed().isVisible()).toBe(true); expect(findTitle().text()).toBe(TEST_ISSUE_B.title); }); @@ -202,47 +215,23 @@ describe('BoardSidebarTitle', () => { describe('when the mutation fails', () => { beforeEach(async () => { - createWrapper({ item: TEST_ISSUE_B }); + createWrapper({ + item: TEST_ISSUE_B, + issueSetTitleMutationHandler: issueSetTitleMutationHandlerFailure, + }); findFormInput().vm.$emit('input', 'Invalid title'); findForm().vm.$emit('submit', { preventDefault: () => {} }); await nextTick(); }); - it('collapses sidebar and renders former item title', () => { + it('collapses sidebar and renders former item title', async () => { expect(findCollapsed().isVisible()).toBe(true); expect(findTitle().text()).toContain(TEST_ISSUE_B.title); + await waitForPromises(); expect(cacheUpdates.setError).toHaveBeenCalledWith( expect.objectContaining({ message: 'An error occurred when updating the title' }), ); }); }); - - describe('Apollo boards', () => { - it.each` - issuableType | isEpicBoard | queryHandler | notCalledHandler - ${'issue'} | ${false} | ${issueSetTitleMutationHandlerSuccess} | ${updateEpicTitleMutationHandlerSuccess} - ${'epic'} | ${true} | ${updateEpicTitleMutationHandlerSuccess} | ${issueSetTitleMutationHandlerSuccess} - `( - 'updates $issuableType title', - async ({ issuableType, isEpicBoard, queryHandler, notCalledHandler }) => { - createWrapper({ - provide: { - issuableType, - isEpicBoard, - isApolloBoard: true, - }, - }); - - await nextTick(); - - findFormInput().vm.$emit('input', TEST_TITLE); - findForm().vm.$emit('submit', { preventDefault: () => {} }); - await nextTick(); - - expect(queryHandler).toHaveBeenCalled(); - expect(notCalledHandler).not.toHaveBeenCalled(); - }, - ); - }); }); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 0be17db9450..3a5e108ac07 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -275,6 +275,7 @@ export const labels = [ ]; export const mockIssueFullPath = 'gitlab-org/test-subgroup/gitlab-test'; +export const mockIssueDirectNamespace = 'gitlab-test'; export const mockEpicFullPath = 'gitlab-org/test-subgroup'; export const rawIssue = { @@ -331,15 +332,17 @@ export const mockIssue = { confidential: false, referencePath: `${mockIssueFullPath}#27`, path: `/${mockIssueFullPath}/-/issues/27`, - assignees, - labels: [ - { - id: 1, - title: 'test', - color: '#F0AD4E', - description: 'testing', - }, - ], + assignees: { nodes: assignees }, + labels: { + nodes: [ + { + id: 1, + title: 'test', + color: '#F0AD4E', + description: 'testing', + }, + ], + }, epic: { id: 'gid://gitlab/Epic/41', }, @@ -411,6 +414,7 @@ export const mockActiveIssue = { }; export const mockIssue2 = { + ...rawIssue, id: 'gid://gitlab/Issue/437', iid: 28, title: 'Issue 2', @@ -420,14 +424,13 @@ export const mockIssue2 = { confidential: false, referencePath: 'gitlab-org/test-subgroup/gitlab-test#28', path: '/gitlab-org/test-subgroup/gitlab-test/-/issues/28', - assignees, - labels, epic: { id: 'gid://gitlab/Epic/40', }, }; export const mockIssue3 = { + ...rawIssue, id: 'gid://gitlab/Issue/438', iid: 29, title: 'Issue 3', @@ -436,12 +439,11 @@ export const mockIssue3 = { timeEstimate: 0, confidential: false, path: '/gitlab-org/gitlab-test/-/issues/28', - assignees, - labels, epic: null, }; export const mockIssue4 = { + ...rawIssue, id: 'gid://gitlab/Issue/439', iid: 30, title: 'Issue 4', @@ -450,12 +452,11 @@ export const mockIssue4 = { timeEstimate: 0, confidential: false, path: '/gitlab-org/gitlab-test/-/issues/28', - assignees, - labels, epic: null, }; export const mockIssue5 = { + ...rawIssue, id: 'gid://gitlab/Issue/440', iid: 40, title: 'Issue 5', @@ -464,12 +465,11 @@ export const mockIssue5 = { timeEstimate: 0, confidential: false, path: '/gitlab-org/gitlab-test/-/issues/40', - assignees, - labels, epic: null, }; export const mockIssue6 = { + ...rawIssue, id: 'gid://gitlab/Issue/441', iid: 41, title: 'Issue 6', @@ -478,12 +478,11 @@ export const mockIssue6 = { timeEstimate: 0, confidential: false, path: '/gitlab-org/gitlab-test/-/issues/41', - assignees, - labels, epic: null, }; export const mockIssue7 = { + ...rawIssue, id: 'gid://gitlab/Issue/442', iid: 42, title: 'Issue 6', @@ -492,8 +491,6 @@ export const mockIssue7 = { timeEstimate: 0, confidential: false, path: '/gitlab-org/gitlab-test/-/issues/42', - assignees, - labels, epic: null, }; @@ -1085,4 +1082,36 @@ export const mockGroupProjectsResponse = (projects = mockProjects) => ({ }, }); +export const mockGroupIssuesResponse = ( + listId = 'gid://gitlab/List/1', + rawIssues = [rawIssue], +) => ({ + data: { + group: { + id: 'gid://gitlab/Group/1', + board: { + __typename: 'Board', + id: 'gid://gitlab/Board/1', + lists: { + nodes: [ + { + id: listId, + listType: 'backlog', + issues: { + nodes: rawIssues, + pageInfo: { + endCursor: null, + hasNextPage: true, + }, + }, + __typename: 'BoardList', + }, + ], + }, + }, + __typename: 'Group', + }, + }, +}); + export const DEFAULT_COLOR = '#1068bf'; diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 358cb340802..616bb083211 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -165,7 +165,7 @@ describe('setFilters', () => { issuableType: TYPE_ISSUE, }; - testAction( + return testAction( actions.setFilters, filters, state, @@ -441,7 +441,7 @@ describe('fetchMilestones', () => { describe('createList', () => { it('should dispatch createIssueList action', () => { - testAction({ + return testAction({ action: actions.createList, payload: { backlog: true }, expectedActions: [{ type: 'createIssueList', payload: { backlog: true } }], @@ -560,7 +560,7 @@ describe('addList', () => { }; it('should commit RECEIVE_ADD_LIST_SUCCESS mutation and dispatch fetchItemsForList action', () => { - testAction({ + return testAction({ action: actions.addList, payload: mockLists[1], state: { ...getters }, @@ -1007,7 +1007,7 @@ describe('moveItem', () => { it('should dispatch moveIssue action with payload', () => { const payload = { mock: 'payload' }; - testAction({ + return testAction({ action: actions.moveItem, payload, expectedActions: [{ type: 'moveIssue', payload }], @@ -1017,7 +1017,7 @@ describe('moveItem', () => { describe('moveIssue', () => { it('should dispatch a correct set of actions', () => { - testAction({ + return testAction({ action: actions.moveIssue, payload: mockMoveIssueParams, state: mockMoveState, @@ -1092,7 +1092,7 @@ describe('moveIssueCard and undoMoveIssueCard', () => { }); it('moveIssueCard commits a correct set of actions', () => { - testAction({ + return testAction({ action: actions.moveIssueCard, state, payload: getMoveData(state, params), @@ -1101,7 +1101,7 @@ describe('moveIssueCard and undoMoveIssueCard', () => { }); it('undoMoveIssueCard commits a correct set of actions', () => { - testAction({ + return testAction({ action: actions.undoMoveIssueCard, state, payload: getMoveData(state, params), @@ -1169,7 +1169,7 @@ describe('moveIssueCard and undoMoveIssueCard', () => { }); it('moveIssueCard commits a correct set of actions', () => { - testAction({ + return testAction({ action: actions.moveIssueCard, state, payload: getMoveData(state, params), @@ -1178,7 +1178,7 @@ describe('moveIssueCard and undoMoveIssueCard', () => { }); it('undoMoveIssueCard commits a correct set of actions', () => { - testAction({ + return testAction({ action: actions.undoMoveIssueCard, state, payload: getMoveData(state, params), @@ -1244,7 +1244,7 @@ describe('moveIssueCard and undoMoveIssueCard', () => { }); it('moveIssueCard commits a correct set of actions', () => { - testAction({ + return testAction({ action: actions.moveIssueCard, state, payload: getMoveData(state, params), @@ -1253,7 +1253,7 @@ describe('moveIssueCard and undoMoveIssueCard', () => { }); it('undoMoveIssueCard commits a correct set of actions', () => { - testAction({ + return testAction({ action: actions.undoMoveIssueCard, state, payload: getMoveData(state, params), @@ -1298,7 +1298,7 @@ describe('updateMovedIssueCard', () => { ])( 'should commit UPDATE_BOARD_ITEM with a correctly updated issue data when %s', (_, { state, moveData, updatedIssue }) => { - testAction({ + return testAction({ action: actions.updateMovedIssue, payload: moveData, state, @@ -1363,7 +1363,7 @@ describe('updateIssueOrder', () => { }, }); - testAction( + return testAction( actions.updateIssueOrder, { moveData }, state, @@ -1395,7 +1395,7 @@ describe('updateIssueOrder', () => { }, }); - testAction( + return testAction( actions.updateIssueOrder, { moveData }, state, @@ -1448,7 +1448,7 @@ describe('addListItem', () => { inProgress: true, }; - testAction( + return testAction( actions.addListItem, payload, {}, @@ -1475,7 +1475,7 @@ describe('addListItem', () => { position: 0, }; - testAction( + return testAction( actions.addListItem, payload, {}, @@ -1503,7 +1503,7 @@ describe('removeListItem', () => { itemId: mockIssue.id, }; - testAction(actions.removeListItem, payload, {}, [ + return testAction(actions.removeListItem, payload, {}, [ { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload }, { type: types.REMOVE_BOARD_ITEM, payload: mockIssue.id }, ]); @@ -1608,7 +1608,7 @@ describe('addListNewIssue', () => { }, }); - testAction({ + return testAction({ action: actions.addListNewIssue, payload: { issueInput: mockIssue, @@ -1651,7 +1651,7 @@ describe('addListNewIssue', () => { }, }); - testAction({ + return testAction({ action: actions.addListNewIssue, payload: { issueInput: mockIssue, @@ -1700,7 +1700,7 @@ describe('setActiveIssueLabels', () => { value: labels, }; - testAction( + return testAction( actions.setActiveIssueLabels, input, { ...state, ...getters }, @@ -1721,7 +1721,7 @@ describe('setActiveIssueLabels', () => { value: [labels[1]], }; - testAction( + return testAction( actions.setActiveIssueLabels, { ...input, removeLabelIds: [getIdFromGraphQLId(labels[0].id)] }, { ...state, ...getters }, @@ -1962,7 +1962,7 @@ describe('toggleBoardItemMultiSelection', () => { const boardItem2 = mockIssue2; it('should commit mutation ADD_BOARD_ITEM_TO_SELECTION if item is not on selection state', () => { - testAction( + return testAction( actions.toggleBoardItemMultiSelection, boardItem, { selectedBoardItems: [] }, @@ -1977,7 +1977,7 @@ describe('toggleBoardItemMultiSelection', () => { }); it('should commit mutation REMOVE_BOARD_ITEM_FROM_SELECTION if item is on selection state', () => { - testAction( + return testAction( actions.toggleBoardItemMultiSelection, boardItem, { selectedBoardItems: [mockIssue] }, @@ -1992,7 +1992,7 @@ describe('toggleBoardItemMultiSelection', () => { }); it('should additionally commit mutation ADD_BOARD_ITEM_TO_SELECTION for active issue and dispatch unsetActiveId', () => { - testAction( + return testAction( actions.toggleBoardItemMultiSelection, boardItem2, { activeId: mockActiveIssue.id, activeBoardItem: mockActiveIssue, selectedBoardItems: [] }, @@ -2013,7 +2013,7 @@ describe('toggleBoardItemMultiSelection', () => { describe('resetBoardItemMultiSelection', () => { it('should commit mutation RESET_BOARD_ITEM_SELECTION', () => { - testAction({ + return testAction({ action: actions.resetBoardItemMultiSelection, state: { selectedBoardItems: [mockIssue] }, expectedMutations: [ @@ -2027,7 +2027,7 @@ describe('resetBoardItemMultiSelection', () => { describe('toggleBoardItem', () => { it('should dispatch resetBoardItemMultiSelection and unsetActiveId when boardItem is the active item', () => { - testAction({ + return testAction({ action: actions.toggleBoardItem, payload: { boardItem: mockIssue }, state: { @@ -2038,7 +2038,7 @@ describe('toggleBoardItem', () => { }); it('should dispatch resetBoardItemMultiSelection and setActiveId when boardItem is not the active item', () => { - testAction({ + return testAction({ action: actions.toggleBoardItem, payload: { boardItem: mockIssue }, state: { @@ -2054,7 +2054,7 @@ describe('toggleBoardItem', () => { describe('setError', () => { it('should commit mutation SET_ERROR', () => { - testAction({ + return testAction({ action: actions.setError, payload: { message: 'mayday' }, expectedMutations: [ @@ -2085,7 +2085,7 @@ describe('setError', () => { describe('unsetError', () => { it('should commit mutation SET_ERROR with undefined as payload', () => { - testAction({ + return testAction({ action: actions.unsetError, expectedMutations: [ { diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js deleted file mode 100644 index 2d68c070b83..00000000000 --- a/spec/frontend/boards/stores/mutations_spec.js +++ /dev/null @@ -1,672 +0,0 @@ -import { cloneDeep } from 'lodash'; -import * as types from '~/boards/stores/mutation_types'; -import mutations from '~/boards/stores/mutations'; -import defaultState from '~/boards/stores/state'; -import { TYPE_ISSUE } from '~/issues/constants'; -import { - mockBoard, - mockLists, - rawIssue, - mockIssue, - mockIssue2, - mockGroupProjects, - labels, - mockList, -} from '../mock_data'; - -describe('Board Store Mutations', () => { - let state; - - const initialBoardListsState = { - 'gid://gitlab/List/1': mockLists[0], - 'gid://gitlab/List/2': mockLists[1], - }; - - const setBoardsListsState = () => { - state = cloneDeep({ - ...state, - boardItemsByListId: { 'gid://gitlab/List/1': [mockIssue.id] }, - boardLists: { 'gid://gitlab/List/1': mockList }, - }); - }; - - beforeEach(() => { - state = defaultState(); - }); - - describe('REQUEST_CURRENT_BOARD', () => { - it('Should set isBoardLoading state to true', () => { - mutations[types.REQUEST_CURRENT_BOARD](state); - - expect(state.isBoardLoading).toBe(true); - }); - }); - - describe('RECEIVE_BOARD_SUCCESS', () => { - it('Should set board to state', () => { - mutations[types.RECEIVE_BOARD_SUCCESS](state, mockBoard); - - expect(state.board).toEqual({ - ...mockBoard, - labels: mockBoard.labels.nodes, - }); - }); - }); - - describe('RECEIVE_BOARD_FAILURE', () => { - it('Should set error in state', () => { - mutations[types.RECEIVE_BOARD_FAILURE](state); - - expect(state.error).toEqual( - 'An error occurred while fetching the board. Please reload the page.', - ); - }); - }); - - describe('SET_INITIAL_BOARD_DATA', () => { - it('Should set initial Boards data to state', () => { - const allowSubEpics = true; - const boardId = 1; - const fullPath = 'gitlab-org'; - const boardType = 'group'; - const disabled = false; - const issuableType = TYPE_ISSUE; - - mutations[types.SET_INITIAL_BOARD_DATA](state, { - allowSubEpics, - boardId, - fullPath, - boardType, - disabled, - issuableType, - }); - - expect(state.allowSubEpics).toBe(allowSubEpics); - expect(state.boardId).toEqual(boardId); - expect(state.fullPath).toEqual(fullPath); - expect(state.boardType).toEqual(boardType); - expect(state.disabled).toEqual(disabled); - expect(state.issuableType).toEqual(issuableType); - }); - }); - - describe('SET_BOARD_CONFIG', () => { - it('Should set board config data o state', () => { - const boardConfig = { - milestoneId: 1, - milestoneTitle: 'Milestone 1', - }; - - mutations[types.SET_BOARD_CONFIG](state, boardConfig); - - expect(state.boardConfig).toEqual(boardConfig); - }); - }); - - describe('RECEIVE_BOARD_LISTS_SUCCESS', () => { - it('Should set boardLists to state', () => { - mutations[types.RECEIVE_BOARD_LISTS_SUCCESS](state, initialBoardListsState); - - expect(state.boardLists).toEqual(initialBoardListsState); - }); - }); - - describe('RECEIVE_BOARD_LISTS_FAILURE', () => { - it('Should set error in state', () => { - mutations[types.RECEIVE_BOARD_LISTS_FAILURE](state); - - expect(state.error).toEqual( - 'An error occurred while fetching the board lists. Please reload the page.', - ); - }); - }); - - describe('SET_ACTIVE_ID', () => { - const expected = { id: 1, sidebarType: '' }; - - beforeEach(() => { - mutations.SET_ACTIVE_ID(state, expected); - }); - - it('updates activeListId to be the value that is passed', () => { - expect(state.activeId).toBe(expected.id); - }); - - it('updates sidebarType to be the value that is passed', () => { - expect(state.sidebarType).toBe(expected.sidebarType); - }); - }); - - describe('SET_FILTERS', () => { - it('updates filterParams to be the value that is passed', () => { - const filterParams = { labelName: 'label' }; - - mutations.SET_FILTERS(state, filterParams); - - expect(state.filterParams).toBe(filterParams); - }); - }); - - describe('CREATE_LIST_FAILURE', () => { - it('sets error message', () => { - mutations.CREATE_LIST_FAILURE(state); - - expect(state.error).toEqual('An error occurred while creating the list. Please try again.'); - }); - }); - - describe('RECEIVE_LABELS_REQUEST', () => { - it('sets labelsLoading on state', () => { - mutations.RECEIVE_LABELS_REQUEST(state); - - expect(state.labelsLoading).toEqual(true); - }); - }); - - describe('RECEIVE_LABELS_SUCCESS', () => { - it('sets labels on state', () => { - mutations.RECEIVE_LABELS_SUCCESS(state, labels); - - expect(state.labels).toEqual(labels); - expect(state.labelsLoading).toEqual(false); - }); - }); - - describe('RECEIVE_LABELS_FAILURE', () => { - it('sets error message', () => { - mutations.RECEIVE_LABELS_FAILURE(state); - - expect(state.error).toEqual( - 'An error occurred while fetching labels. Please reload the page.', - ); - expect(state.labelsLoading).toEqual(false); - }); - }); - - describe('GENERATE_DEFAULT_LISTS_FAILURE', () => { - it('sets error message', () => { - mutations.GENERATE_DEFAULT_LISTS_FAILURE(state); - - expect(state.error).toEqual( - 'An error occurred while generating lists. Please reload the page.', - ); - }); - }); - - describe('RECEIVE_ADD_LIST_SUCCESS', () => { - it('adds list to boardLists state', () => { - mutations.RECEIVE_ADD_LIST_SUCCESS(state, mockLists[0]); - - expect(state.boardLists).toEqual({ - [mockLists[0].id]: mockLists[0], - }); - }); - }); - - describe('MOVE_LISTS', () => { - it('updates the positions of board lists', () => { - state = { - ...state, - boardLists: initialBoardListsState, - }; - - mutations.MOVE_LISTS(state, [ - { - listId: mockLists[0].id, - position: 1, - }, - { - listId: mockLists[1].id, - position: 0, - }, - ]); - - expect(state.boardLists[mockLists[0].id].position).toBe(1); - expect(state.boardLists[mockLists[1].id].position).toBe(0); - }); - }); - - describe('TOGGLE_LIST_COLLAPSED', () => { - it('updates collapsed attribute of list in boardLists state', () => { - const listId = 'gid://gitlab/List/1'; - state = { - ...state, - boardLists: { - [listId]: mockLists[0], - }, - }; - - expect(state.boardLists[listId].collapsed).toEqual(false); - - mutations.TOGGLE_LIST_COLLAPSED(state, { listId, collapsed: true }); - - expect(state.boardLists[listId].collapsed).toEqual(true); - }); - }); - - describe('REMOVE_LIST', () => { - it('removes list from boardLists', () => { - const [list, secondList] = mockLists; - const expected = { - [secondList.id]: secondList, - }; - state = { - ...state, - boardLists: { ...initialBoardListsState }, - }; - - mutations[types.REMOVE_LIST](state, list.id); - - expect(state.boardLists).toEqual(expected); - }); - }); - - describe('REMOVE_LIST_FAILURE', () => { - it('restores lists from backup', () => { - const backupLists = { ...initialBoardListsState }; - - mutations[types.REMOVE_LIST_FAILURE](state, backupLists); - - expect(state.boardLists).toEqual(backupLists); - }); - - it('sets error state', () => { - const backupLists = { ...initialBoardListsState }; - state = { - ...state, - error: undefined, - }; - - mutations[types.REMOVE_LIST_FAILURE](state, backupLists); - - expect(state.error).toEqual('An error occurred while removing the list. Please try again.'); - }); - }); - - describe('RESET_ISSUES', () => { - it('should remove issues from boardItemsByListId state', () => { - const boardItemsByListId = { - 'gid://gitlab/List/1': [mockIssue.id], - }; - - state = { - ...state, - boardItemsByListId, - }; - - mutations[types.RESET_ISSUES](state); - - expect(state.boardItemsByListId).toEqual({ 'gid://gitlab/List/1': [] }); - }); - }); - - describe('REQUEST_ITEMS_FOR_LIST', () => { - const listId = 'gid://gitlab/List/1'; - const boardItemsByListId = { - [listId]: [mockIssue.id], - }; - - it.each` - fetchNext | isLoading | isLoadingMore - ${true} | ${undefined} | ${true} - ${false} | ${true} | ${undefined} - `( - 'sets isLoading to $isLoading and isLoadingMore to $isLoadingMore when fetchNext is $fetchNext', - ({ fetchNext, isLoading, isLoadingMore }) => { - state = { - ...state, - boardItemsByListId, - listsFlags: { - [listId]: {}, - }, - }; - - mutations[types.REQUEST_ITEMS_FOR_LIST](state, { listId, fetchNext }); - - expect(state.listsFlags[listId].isLoading).toBe(isLoading); - expect(state.listsFlags[listId].isLoadingMore).toBe(isLoadingMore); - }, - ); - }); - - describe('RECEIVE_ITEMS_FOR_LIST_SUCCESS', () => { - it('updates boardItemsByListId and issues on state', () => { - const listIssues = { - 'gid://gitlab/List/1': [mockIssue.id], - }; - const issues = { - 1: mockIssue, - }; - - state = { - ...state, - boardItemsByListId: { - 'gid://gitlab/List/1': [], - }, - boardItems: {}, - boardLists: initialBoardListsState, - }; - - const listPageInfo = { - 'gid://gitlab/List/1': { - endCursor: '', - hasNextPage: false, - }, - }; - - mutations.RECEIVE_ITEMS_FOR_LIST_SUCCESS(state, { - listItems: { listData: listIssues, boardItems: issues }, - listPageInfo, - listId: 'gid://gitlab/List/1', - }); - - expect(state.boardItemsByListId).toEqual(listIssues); - expect(state.boardItems).toEqual(issues); - }); - }); - - describe('RECEIVE_ITEMS_FOR_LIST_FAILURE', () => { - it('sets error message', () => { - state = { - ...state, - boardLists: initialBoardListsState, - error: undefined, - }; - - const listId = 'gid://gitlab/List/1'; - - mutations.RECEIVE_ITEMS_FOR_LIST_FAILURE(state, listId); - - expect(state.error).toEqual( - 'An error occurred while fetching the board issues. Please reload the page.', - ); - }); - }); - - describe('UPDATE_BOARD_ITEM_BY_ID', () => { - const issueId = '1'; - const prop = 'id'; - const value = '2'; - const issue = { [issueId]: { id: 1, title: 'Issue' } }; - - beforeEach(() => { - state = { - ...state, - error: undefined, - boardItems: { - ...issue, - }, - }; - }); - - describe('when the issue is in state', () => { - it('updates the property of the correct issue', () => { - mutations.UPDATE_BOARD_ITEM_BY_ID(state, { - itemId: issueId, - prop, - value, - }); - - expect(state.boardItems[issueId]).toEqual({ ...issue[issueId], id: '2' }); - }); - }); - - describe('when the issue is not in state', () => { - it('throws an error', () => { - expect(() => { - mutations.UPDATE_BOARD_ITEM_BY_ID(state, { - itemId: '3', - prop, - value, - }); - }).toThrow(new Error('No issue found.')); - }); - }); - }); - - describe('MUTATE_ISSUE_SUCCESS', () => { - it('updates issue in issues state', () => { - const issues = { - [rawIssue.id]: { id: rawIssue.id }, - }; - - state = { - ...state, - boardItems: issues, - }; - - mutations.MUTATE_ISSUE_SUCCESS(state, { - issue: rawIssue, - }); - - expect(state.boardItems).toEqual({ [mockIssue.id]: mockIssue }); - }); - }); - - describe('UPDATE_BOARD_ITEM', () => { - it('updates the given issue in state.boardItems', () => { - const updatedIssue = { id: 'some_gid', foo: 'bar' }; - state = { boardItems: { some_gid: { id: 'some_gid' } } }; - - mutations.UPDATE_BOARD_ITEM(state, updatedIssue); - - expect(state.boardItems.some_gid).toEqual(updatedIssue); - }); - }); - - describe('REMOVE_BOARD_ITEM', () => { - it('removes the given issue from state.boardItems', () => { - state = { boardItems: { some_gid: {}, some_gid2: {} } }; - - mutations.REMOVE_BOARD_ITEM(state, 'some_gid'); - - expect(state.boardItems).toEqual({ some_gid2: {} }); - }); - }); - - describe('ADD_BOARD_ITEM_TO_LIST', () => { - beforeEach(() => { - setBoardsListsState(); - }); - - it.each([ - [ - 'at position 0 by default', - { - payload: { - itemId: mockIssue2.id, - listId: mockList.id, - }, - listState: [mockIssue2.id, mockIssue.id], - }, - ], - [ - 'at a given position', - { - payload: { - itemId: mockIssue2.id, - listId: mockList.id, - atIndex: 1, - }, - listState: [mockIssue.id, mockIssue2.id], - }, - ], - [ - "below the issue with id of 'moveBeforeId'", - { - payload: { - itemId: mockIssue2.id, - listId: mockList.id, - moveBeforeId: mockIssue.id, - }, - listState: [mockIssue.id, mockIssue2.id], - }, - ], - [ - "above the issue with id of 'moveAfterId'", - { - payload: { - itemId: mockIssue2.id, - listId: mockList.id, - moveAfterId: mockIssue.id, - }, - listState: [mockIssue2.id, mockIssue.id], - }, - ], - [ - 'to the top of the list', - { - payload: { - itemId: mockIssue2.id, - listId: mockList.id, - positionInList: 0, - atIndex: 1, - }, - listState: [mockIssue2.id, mockIssue.id], - }, - ], - [ - 'to the bottom of the list when the list is fully loaded', - { - payload: { - itemId: mockIssue2.id, - listId: mockList.id, - positionInList: -1, - atIndex: 0, - allItemsLoadedInList: true, - }, - listState: [mockIssue.id, mockIssue2.id], - }, - ], - ])(`inserts an item into a list %s`, (_, { payload, listState }) => { - mutations.ADD_BOARD_ITEM_TO_LIST(state, payload); - - expect(state.boardItemsByListId[payload.listId]).toEqual(listState); - }); - }); - - describe('REMOVE_BOARD_ITEM_FROM_LIST', () => { - beforeEach(() => { - setBoardsListsState(); - }); - - it('removes an item from a list', () => { - expect(state.boardItemsByListId['gid://gitlab/List/1']).toContain(mockIssue.id); - - mutations.REMOVE_BOARD_ITEM_FROM_LIST(state, { - itemId: mockIssue.id, - listId: mockList.id, - }); - - expect(state.boardItemsByListId['gid://gitlab/List/1']).not.toContain(mockIssue.id); - }); - }); - - describe('SET_ASSIGNEE_LOADING', () => { - it('sets isSettingAssignees to the value passed', () => { - mutations.SET_ASSIGNEE_LOADING(state, true); - - expect(state.isSettingAssignees).toBe(true); - }); - }); - - describe('REQUEST_GROUP_PROJECTS', () => { - it('Should set isLoading in groupProjectsFlags to true in state when fetchNext is false', () => { - mutations[types.REQUEST_GROUP_PROJECTS](state, false); - - expect(state.groupProjectsFlags.isLoading).toBe(true); - }); - - it('Should set isLoadingMore in groupProjectsFlags to true in state when fetchNext is true', () => { - mutations[types.REQUEST_GROUP_PROJECTS](state, true); - - expect(state.groupProjectsFlags.isLoadingMore).toBe(true); - }); - }); - - describe('RECEIVE_GROUP_PROJECTS_SUCCESS', () => { - it('Should set groupProjects and pageInfo to state and isLoading in groupProjectsFlags to false', () => { - mutations[types.RECEIVE_GROUP_PROJECTS_SUCCESS](state, { - projects: mockGroupProjects, - pageInfo: { hasNextPage: false }, - }); - - expect(state.groupProjects).toEqual(mockGroupProjects); - expect(state.groupProjectsFlags.isLoading).toBe(false); - expect(state.groupProjectsFlags.pageInfo).toEqual({ hasNextPage: false }); - }); - - it('Should merge projects in groupProjects in state when fetchNext is true', () => { - state = { - ...state, - groupProjects: [mockGroupProjects[0]], - }; - - mutations[types.RECEIVE_GROUP_PROJECTS_SUCCESS](state, { - projects: [mockGroupProjects[1]], - fetchNext: true, - }); - - expect(state.groupProjects).toEqual(mockGroupProjects); - }); - }); - - describe('RECEIVE_GROUP_PROJECTS_FAILURE', () => { - it('Should set error in state and isLoading in groupProjectsFlags to false', () => { - mutations[types.RECEIVE_GROUP_PROJECTS_FAILURE](state); - - expect(state.error).toEqual( - 'An error occurred while fetching group projects. Please try again.', - ); - expect(state.groupProjectsFlags.isLoading).toBe(false); - }); - }); - - describe('SET_SELECTED_PROJECT', () => { - it('Should set selectedProject to state', () => { - mutations[types.SET_SELECTED_PROJECT](state, mockGroupProjects[0]); - - expect(state.selectedProject).toEqual(mockGroupProjects[0]); - }); - }); - - describe('ADD_BOARD_ITEM_TO_SELECTION', () => { - it('Should add boardItem to selectedBoardItems state', () => { - expect(state.selectedBoardItems).toEqual([]); - - mutations[types.ADD_BOARD_ITEM_TO_SELECTION](state, mockIssue); - - expect(state.selectedBoardItems).toEqual([mockIssue]); - }); - }); - - describe('REMOVE_BOARD_ITEM_FROM_SELECTION', () => { - it('Should remove boardItem to selectedBoardItems state', () => { - state.selectedBoardItems = [mockIssue]; - - mutations[types.REMOVE_BOARD_ITEM_FROM_SELECTION](state, mockIssue); - - expect(state.selectedBoardItems).toEqual([]); - }); - }); - - describe('RESET_BOARD_ITEM_SELECTION', () => { - it('Should reset selectedBoardItems state', () => { - state.selectedBoardItems = [mockIssue]; - - mutations[types.RESET_BOARD_ITEM_SELECTION](state, mockIssue); - - expect(state.selectedBoardItems).toEqual([]); - }); - }); - - describe('SET_ERROR', () => { - it('Should set error state', () => { - state.error = undefined; - - mutations[types.SET_ERROR](state, 'mayday'); - - expect(state.error).toBe('mayday'); - }); - }); -}); 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 ba77d90f4e2..36f27d1781e 100644 --- a/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js +++ b/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js @@ -2,7 +2,7 @@ import { GlLoadingIcon, GlTable, GlLink, GlPagination, GlModal, GlFormCheckbox } import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import getJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql.json'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import waitForPromises from 'helpers/wait_for_promises'; import JobArtifactsTable from '~/ci/artifacts/components/job_artifacts_table.vue'; import ArtifactsTableRowDetails from '~/ci/artifacts/components/artifacts_table_row_details.vue'; @@ -22,11 +22,12 @@ import { I18N_FETCH_ERROR, INITIAL_CURRENT_PAGE, I18N_BULK_DELETE_ERROR, - SELECTED_ARTIFACTS_MAX_COUNT, } from '~/ci/artifacts/constants'; import { totalArtifactsSizeForJob } from '~/ci/artifacts/utils'; import { createAlert } from '~/alert'; +const jobArtifactsCountLimit = 100; + jest.mock('~/alert'); Vue.use(VueApollo); @@ -127,10 +128,10 @@ describe('JobArtifactsTable component', () => { .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 maxSelectedArtifacts = new Array(jobArtifactsCountLimit).fill('artifact-id'); const maxSelectedArtifactsIncludingCurrentPage = [ ...allArtifacts, - ...new Array(SELECTED_ARTIFACTS_MAX_COUNT - allArtifacts.length).fill('artifact-id'), + ...new Array(jobArtifactsCountLimit - allArtifacts.length).fill('artifact-id'), ]; const createComponent = ({ @@ -151,6 +152,7 @@ describe('JobArtifactsTable component', () => { projectPath: 'project/path', projectId, canDestroyArtifacts, + jobArtifactsCountLimit, }, mocks: { $toast: { @@ -665,7 +667,7 @@ describe('JobArtifactsTable component', () => { 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; + const selectedArtifactsLength = jobArtifactsCountLimit - 1; beforeEach(async () => { createComponent({ @@ -687,9 +689,7 @@ describe('JobArtifactsTable component', () => { await nextTick(); expect(findSelectAllCheckboxChecked()).toBe(true); - expect(findBulkDelete().props('selectedArtifacts')).toHaveLength( - SELECTED_ARTIFACTS_MAX_COUNT, - ); + expect(findBulkDelete().props('selectedArtifacts')).toHaveLength(jobArtifactsCountLimit); expect(findBulkDelete().props('selectedArtifacts')).not.toContain( allArtifacts[allArtifacts.length - 1], ); @@ -748,7 +748,7 @@ describe('JobArtifactsTable component', () => { it('deselects all artifacts when toggled', async () => { expect(findBulkDelete().props('selectedArtifacts')).toHaveLength( - SELECTED_ARTIFACTS_MAX_COUNT, + jobArtifactsCountLimit, ); toggleSelectAllCheckbox(); @@ -757,7 +757,7 @@ describe('JobArtifactsTable component', () => { expect(findSelectAllCheckboxChecked()).toBe(false); expect(findBulkDelete().props('selectedArtifacts')).toHaveLength( - SELECTED_ARTIFACTS_MAX_COUNT - allArtifacts.length, + jobArtifactsCountLimit - allArtifacts.length, ); }); }); diff --git a/spec/frontend/ci/catalog/components/details/ci_resource_components_spec.js b/spec/frontend/ci/catalog/components/details/ci_resource_components_spec.js index 382f8e46203..330163e9f39 100644 --- a/spec/frontend/ci/catalog/components/details/ci_resource_components_spec.js +++ b/spec/frontend/ci/catalog/components/details/ci_resource_components_spec.js @@ -2,7 +2,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import { resolvers } from '~/ci/catalog/graphql/settings'; import CiResourceComponents from '~/ci/catalog/components/details/ci_resource_components.vue'; import getCiCatalogcomponentComponents from '~/ci/catalog/graphql/queries/get_ci_catalog_resource_components.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -17,15 +16,15 @@ describe('CiResourceComponents', () => { let wrapper; let mockComponentsResponse; - const components = mockComponents.data.ciCatalogResource.components.nodes; + const components = mockComponents.data.ciCatalogResource.latestVersion.components.nodes; - const resourceId = 'gid://gitlab/Ci::Catalog::Resource/1'; + const resourcePath = 'twitter/project-1'; - const defaultProps = { resourceId }; + const defaultProps = { resourcePath }; const createComponent = async () => { const handlers = [[getCiCatalogcomponentComponents, mockComponentsResponse]]; - const mockApollo = createMockApollo(handlers, resolvers); + const mockApollo = createMockApollo(handlers); wrapper = mountExtended(CiResourceComponents, { propsData: { @@ -113,10 +112,9 @@ describe('CiResourceComponents', () => { expect(findComponents()).toHaveLength(components.length); }); - it('renders the component name, description and snippet', () => { + it('renders the component name and snippet', () => { components.forEach((component) => { expect(wrapper.text()).toContain(component.name); - expect(wrapper.text()).toContain(component.description); expect(wrapper.text()).toContain(component.path); }); }); @@ -134,9 +132,9 @@ describe('CiResourceComponents', () => { it('renders the component parameter attributes', () => { const [firstComponent] = components; - firstComponent.inputs.nodes.forEach((input) => { + firstComponent.inputs.forEach((input) => { expect(findComponents().at(0).text()).toContain(input.name); - expect(findComponents().at(0).text()).toContain(input.defaultValue); + expect(findComponents().at(0).text()).toContain(input.default); expect(findComponents().at(0).text()).toContain('Yes'); }); }); diff --git a/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js b/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js index 1f7dcf9d4e5..e4b6c1cd046 100644 --- a/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js +++ b/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js @@ -8,7 +8,7 @@ describe('CiResourceDetails', () => { let wrapper; const defaultProps = { - resourceId: 'gid://gitlab/Ci::Catalog::Resource/1', + resourcePath: 'twitter/project-1', }; const defaultProvide = { glFeatures: { ciCatalogComponentsTab: true }, diff --git a/spec/frontend/ci/catalog/components/details/ci_resource_header_spec.js b/spec/frontend/ci/catalog/components/details/ci_resource_header_spec.js index c061332ba13..6af9daabea0 100644 --- a/spec/frontend/ci/catalog/components/details/ci_resource_header_spec.js +++ b/spec/frontend/ci/catalog/components/details/ci_resource_header_spec.js @@ -3,7 +3,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import CiResourceHeader from '~/ci/catalog/components/details/ci_resource_header.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import CiResourceAbout from '~/ci/catalog/components/details/ci_resource_about.vue'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import { catalogSharedDataMock, catalogAdditionalDetailsMock } from '../../mock'; describe('CiResourceHeader', () => { @@ -45,9 +45,9 @@ describe('CiResourceHeader', () => { expect(wrapper.html()).toContain(resource.description); }); - it('renders the namespace and project path', () => { - expect(wrapper.html()).toContain(resource.rootNamespace.fullPath); - expect(wrapper.html()).toContain(resource.rootNamespace.name); + it('renders the project path and name', () => { + expect(wrapper.html()).toContain(resource.webPath); + expect(wrapper.html()).toContain(resource.name); }); it('renders the avatar', () => { diff --git a/spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js b/spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js index 0dadac236a8..ad76b47db57 100644 --- a/spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js +++ b/spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js @@ -23,12 +23,13 @@ describe('CiResourceReadme', () => { data: { ciCatalogResource: { id: resourceId, + webPath: 'twitter/project-1', readmeHtml, }, }, }; - const defaultProps = { resourceId }; + const defaultProps = { resourcePath: readmeMockData.data.ciCatalogResource.webPath }; const createComponent = ({ props = {} } = {}) => { const handlers = [[getCiCatalogResourceReadme, mockReadmeResponse]]; diff --git a/spec/frontend/ci/catalog/components/list/catalog_header_spec.js b/spec/frontend/ci/catalog/components/list/catalog_header_spec.js index 2a5c24d0515..e9d2e68c1a3 100644 --- a/spec/frontend/ci/catalog/components/list/catalog_header_spec.js +++ b/spec/frontend/ci/catalog/components/list/catalog_header_spec.js @@ -1,6 +1,7 @@ import { GlBanner, GlButton } from '@gitlab/ui'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import BetaBadge from '~/vue_shared/components/badges/beta_badge.vue'; import CatalogHeader from '~/ci/catalog/components/list/catalog_header.vue'; import { CATALOG_FEEDBACK_DISMISSED_KEY } from '~/ci/catalog/constants'; @@ -16,9 +17,10 @@ describe('CatalogHeader', () => { }; const findBanner = () => wrapper.findComponent(GlBanner); + const findBetaBadge = () => wrapper.findComponent(BetaBadge); const findFeedbackButton = () => findBanner().findComponent(GlButton); const findTitle = () => wrapper.find('h1'); - const findDescription = () => wrapper.findByTestId('description'); + const findDescription = () => wrapper.findByTestId('page-description'); const createComponent = ({ props = {}, provide = {}, stubs = {} } = {}) => { wrapper = shallowMountExtended(CatalogHeader, { @@ -33,6 +35,16 @@ describe('CatalogHeader', () => { }); }; + describe('Default view', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders a Beta Badge', () => { + expect(findBetaBadge().exists()).toBe(true); + }); + }); + describe('title and description', () => { describe('when there are no values provided', () => { beforeEach(() => { @@ -42,10 +54,11 @@ describe('CatalogHeader', () => { it('renders the default values', () => { expect(findTitle().text()).toBe('CI/CD Catalog'); expect(findDescription().text()).toBe( - 'Discover CI configuration resources for a seamless CI/CD experience.', + 'Discover CI/CD components that can improve your pipeline with additional functionality.', ); }); }); + describe('when custom values are provided', () => { beforeEach(() => { createComponent({ provide: customProvide }); @@ -57,6 +70,7 @@ describe('CatalogHeader', () => { }); }); }); + describe('Feedback banner', () => { describe('when user has never dismissed', () => { beforeEach(() => { diff --git a/spec/frontend/ci/catalog/components/list/catalog_search_spec.js b/spec/frontend/ci/catalog/components/list/catalog_search_spec.js new file mode 100644 index 00000000000..c6f8498f2fd --- /dev/null +++ b/spec/frontend/ci/catalog/components/list/catalog_search_spec.js @@ -0,0 +1,103 @@ +import { GlSearchBoxByClick, GlSorting, GlSortingItem } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CatalogSearch from '~/ci/catalog/components/list/catalog_search.vue'; +import { SORT_ASC, SORT_DESC, SORT_OPTION_CREATED } from '~/ci/catalog/constants'; + +describe('CatalogSearch', () => { + let wrapper; + + const findSearchBar = () => wrapper.findComponent(GlSearchBoxByClick); + const findSorting = () => wrapper.findComponent(GlSorting); + const findAllSortingItems = () => wrapper.findAllComponents(GlSortingItem); + + const createComponent = () => { + wrapper = shallowMountExtended(CatalogSearch, {}); + }; + + beforeEach(() => { + createComponent(); + }); + + describe('default UI', () => { + it('renders the search bar', () => { + expect(findSearchBar().exists()).toBe(true); + }); + + it('renders the sorting options', () => { + expect(findSorting().exists()).toBe(true); + expect(findAllSortingItems()).toHaveLength(1); + }); + + it('renders the `Created at` option as the default', () => { + expect(findAllSortingItems().at(0).text()).toBe('Created at'); + }); + }); + + describe('search', () => { + it('passes down the search value to the search component', async () => { + const newSearchTerm = 'cat'; + + expect(findSearchBar().props().value).toBe(''); + + await findSearchBar().vm.$emit('input', newSearchTerm); + + expect(findSearchBar().props().value).toBe(newSearchTerm); + }); + + it('does not submit only when typing', async () => { + expect(wrapper.emitted('update-search-term')).toBeUndefined(); + + await findSearchBar().vm.$emit('input', 'new'); + + expect(wrapper.emitted('update-search-term')).toBeUndefined(); + }); + + describe('when submitting the search', () => { + const newSearchTerm = 'dog'; + + beforeEach(async () => { + await findSearchBar().vm.$emit('input', newSearchTerm); + await findSearchBar().vm.$emit('submit'); + }); + + it('emits the event up with the new payload', () => { + expect(wrapper.emitted('update-search-term')).toEqual([[newSearchTerm]]); + }); + }); + + describe('when clearing the search', () => { + beforeEach(async () => { + await findSearchBar().vm.$emit('input', 'new'); + await findSearchBar().vm.$emit('clear'); + }); + + it('emits an update event with an empty string payload', () => { + expect(wrapper.emitted('update-search-term')).toEqual([['']]); + }); + }); + }); + + describe('sort', () => { + describe('when changing sort order', () => { + it('changes the `isAscending` prop to the sorting component', async () => { + expect(findSorting().props().isAscending).toBe(false); + + await findSorting().vm.$emit('sortDirectionChange'); + + expect(findSorting().props().isAscending).toBe(true); + }); + + it('emits an `update-sorting` event with the new direction', async () => { + expect(wrapper.emitted('update-sorting')).toBeUndefined(); + + await findSorting().vm.$emit('sortDirectionChange'); + await findSorting().vm.$emit('sortDirectionChange'); + + expect(wrapper.emitted('update-sorting')).toEqual([ + [`${SORT_OPTION_CREATED}_${SORT_ASC}`], + [`${SORT_OPTION_CREATED}_${SORT_DESC}`], + ]); + }); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/list/ci_resources_list_item_spec.js b/spec/frontend/ci/catalog/components/list/ci_resources_list_item_spec.js index 3862195d8c7..d74b133f386 100644 --- a/spec/frontend/ci/catalog/components/list/ci_resources_list_item_spec.js +++ b/spec/frontend/ci/catalog/components/list/ci_resources_list_item_spec.js @@ -1,21 +1,22 @@ import Vue from 'vue'; import VueRouter from 'vue-router'; -import { GlAvatar, GlBadge, GlButton, GlSprintf } from '@gitlab/ui'; +import { GlAvatar, GlBadge, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { cleanLeadingSeparator } from '~/lib/utils/url_utility'; import { createRouter } from '~/ci/catalog/router/index'; import CiResourcesListItem from '~/ci/catalog/components/list/ci_resources_list_item.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { CI_RESOURCE_DETAILS_PAGE_NAME } from '~/ci/catalog/router/constants'; import { catalogSinglePageResponse } from '../../mock'; Vue.use(VueRouter); -let router; -let routerPush; +const defaultEvent = { preventDefault: jest.fn, ctrlKey: false, metaKey: false }; describe('CiResourcesListItem', () => { let wrapper; + let routerPush; + const router = createRouter(); const resource = catalogSinglePageResponse.data.ciCatalogResources.nodes[0]; const release = { author: { name: 'author', webUrl: '/user/1' }, @@ -35,22 +36,19 @@ describe('CiResourcesListItem', () => { }, stubs: { GlSprintf, - RouterLink: true, - RouterView: true, }, }); }; const findAvatar = () => wrapper.findComponent(GlAvatar); const findBadge = () => wrapper.findComponent(GlBadge); - const findResourceName = () => wrapper.findComponent(GlButton); + const findResourceName = () => wrapper.findByTestId('ci-resource-link'); const findResourceDescription = () => wrapper.findByText(defaultProps.resource.description); const findUserLink = () => wrapper.findByTestId('user-link'); const findTimeAgoMessage = () => wrapper.findComponent(GlSprintf); const findFavorites = () => wrapper.findByTestId('stats-favorites'); beforeEach(() => { - router = createRouter(); routerPush = jest.spyOn(router, 'push').mockImplementation(() => {}); }); @@ -70,8 +68,9 @@ describe('CiResourcesListItem', () => { }); }); - it('renders the resource name button', () => { + it('renders the resource name and link', () => { expect(findResourceName().exists()).toBe(true); + expect(findResourceName().attributes().href).toBe(defaultProps.resource.webPath); }); it('renders the resource version badge', () => { @@ -81,58 +80,69 @@ describe('CiResourcesListItem', () => { it('renders the resource description', () => { expect(findResourceDescription().exists()).toBe(true); }); + }); - describe('release time', () => { - describe('when there is no release data', () => { - beforeEach(() => { - createComponent({ props: { resource: { ...resource, latestVersion: null } } }); - }); + describe('release time', () => { + describe('when there is no release data', () => { + beforeEach(() => { + createComponent({ props: { resource: { ...resource, latestVersion: null } } }); + }); - it('does not render the release', () => { - expect(findTimeAgoMessage().exists()).toBe(false); - }); + it('does not render the release', () => { + expect(findTimeAgoMessage().exists()).toBe(false); + }); - it('renders the generic `unreleased` badge', () => { - expect(findBadge().exists()).toBe(true); - expect(findBadge().text()).toBe('Unreleased'); - }); + it('renders the generic `unreleased` badge', () => { + expect(findBadge().exists()).toBe(true); + expect(findBadge().text()).toBe('Unreleased'); }); + }); - describe('when there is release data', () => { - beforeEach(() => { - createComponent({ props: { resource: { ...resource, latestVersion: { ...release } } } }); - }); + describe('when there is release data', () => { + beforeEach(() => { + createComponent({ props: { resource: { ...resource, latestVersion: { ...release } } } }); + }); - it('renders the user link', () => { - expect(findUserLink().exists()).toBe(true); - expect(findUserLink().attributes('href')).toBe(release.author.webUrl); - }); + it('renders the user link', () => { + expect(findUserLink().exists()).toBe(true); + expect(findUserLink().attributes('href')).toBe(release.author.webUrl); + }); - it('renders the time since the resource was released', () => { - expect(findTimeAgoMessage().exists()).toBe(true); - }); + it('renders the time since the resource was released', () => { + expect(findTimeAgoMessage().exists()).toBe(true); + }); - it('renders the version badge', () => { - expect(findBadge().exists()).toBe(true); - expect(findBadge().text()).toBe(release.tagName); - }); + it('renders the version badge', () => { + expect(findBadge().exists()).toBe(true); + expect(findBadge().text()).toBe(release.tagName); }); }); }); describe('when clicking on an item title', () => { - beforeEach(async () => { - createComponent(); + describe('without holding down a modifier key', () => { + it('navigates to the details page in the same tab', async () => { + createComponent(); + await findResourceName().vm.$emit('click', defaultEvent); - await findResourceName().vm.$emit('click'); + expect(routerPush).toHaveBeenCalledWith({ + path: cleanLeadingSeparator(resource.webPath), + }); + }); }); - it('navigates to the details page', () => { - expect(routerPush).toHaveBeenCalledWith({ - name: CI_RESOURCE_DETAILS_PAGE_NAME, - params: { - id: getIdFromGraphQLId(resource.id), - }, + describe.each` + keyName + ${'ctrlKey'} + ${'metaKey'} + `('when $keyName is being held down', ({ keyName }) => { + beforeEach(async () => { + createComponent(); + await findResourceName().vm.$emit('click', { ...defaultEvent, [keyName]: true }); + }); + + it('does not call VueRouter push', () => { + expect(routerPush).not.toHaveBeenCalled(); }); }); }); @@ -141,43 +151,35 @@ describe('CiResourcesListItem', () => { beforeEach(async () => { createComponent(); - await findAvatar().vm.$emit('click'); + await findAvatar().vm.$emit('click', defaultEvent); }); it('navigates to the details page', () => { - expect(routerPush).toHaveBeenCalledWith({ - name: CI_RESOURCE_DETAILS_PAGE_NAME, - params: { - id: getIdFromGraphQLId(resource.id), - }, - }); + expect(routerPush).toHaveBeenCalledWith({ path: cleanLeadingSeparator(resource.webPath) }); }); }); describe('statistics', () => { describe('when there are no statistics', () => { - beforeEach(() => { + it('render favorites as 0', () => { createComponent({ props: { resource: { + ...resource, starCount: 0, }, }, }); - }); - it('render favorites as 0', () => { expect(findFavorites().exists()).toBe(true); expect(findFavorites().text()).toBe('0'); }); }); describe('where there are statistics', () => { - beforeEach(() => { + it('render favorites', () => { createComponent(); - }); - it('render favorites', () => { expect(findFavorites().exists()).toBe(true); expect(findFavorites().text()).toBe(String(defaultProps.resource.starCount)); }); diff --git a/spec/frontend/ci/catalog/components/list/empty_state_spec.js b/spec/frontend/ci/catalog/components/list/empty_state_spec.js index f589ad96a9d..5db0c61371d 100644 --- a/spec/frontend/ci/catalog/components/list/empty_state_spec.js +++ b/spec/frontend/ci/catalog/components/list/empty_state_spec.js @@ -1,27 +1,83 @@ -import { GlEmptyState } from '@gitlab/ui'; +import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import EmptyState from '~/ci/catalog/components/list/empty_state.vue'; +import { COMPONENTS_DOCS_URL } from '~/ci/catalog/constants'; describe('EmptyState', () => { let wrapper; const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findComponentsDocLink = () => wrapper.findComponent(GlLink); const createComponent = ({ props = {} } = {}) => { wrapper = shallowMountExtended(EmptyState, { propsData: { ...props, }, + stubs: { + GlEmptyState, + GlSprintf, + }, }); }; - describe('when mounted', () => { + describe('default', () => { beforeEach(() => { createComponent(); }); - it('renders the empty state', () => { - expect(findEmptyState().exists()).toBe(true); + it('renders the default empty state', () => { + const emptyState = findEmptyState(); + + expect(emptyState.exists()).toBe(true); + expect(emptyState.props().title).toBe('Get started with the CI/CD Catalog'); + expect(emptyState.props().description).toBe( + 'Create a pipeline component repository and make reusing pipeline configurations faster and easier.', + ); + }); + }); + + describe('when there is a search query', () => { + beforeEach(() => { + createComponent({ + props: { searchTerm: 'a' }, + }); + }); + + it('renders the search description', () => { + expect(findEmptyState().text()).toContain( + 'Edit your search and try again. Or learn to create a component repository.', + ); + }); + + it('renders the link to the components documentation', () => { + const docsLink = findComponentsDocLink(); + expect(docsLink.exists()).toBe(true); + expect(docsLink.attributes().href).toBe(COMPONENTS_DOCS_URL); + }); + + describe('and it is less than 3 characters', () => { + beforeEach(() => { + createComponent({ + props: { searchTerm: 'a' }, + }); + }); + + it('render the too few chars empty state title', () => { + expect(findEmptyState().props().title).toBe('Search must be at least 3 characters'); + }); + }); + + describe('and it has more than 3 characters', () => { + beforeEach(() => { + createComponent({ + props: { searchTerm: 'my component' }, + }); + }); + + it('renders the search empty state title', () => { + expect(findEmptyState().props().title).toBe('No result found'); + }); }); }); }); diff --git a/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js b/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js index 40f243ed891..015c6504fa5 100644 --- a/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js +++ b/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js @@ -5,7 +5,8 @@ import { GlEmptyState } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { CI_CATALOG_RESOURCE_TYPE, cacheConfig } from '~/ci/catalog/graphql/settings'; +import { cacheConfig } from '~/ci/catalog/graphql/settings'; +import { cleanLeadingSeparator } from '~/lib/utils/url_utility'; import getCiCatalogResourceSharedData from '~/ci/catalog/graphql/queries/get_ci_catalog_resource_shared_data.query.graphql'; import getCiCatalogResourceDetails from '~/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql'; @@ -17,7 +18,6 @@ import CiResourceHeaderSkeletonLoader from '~/ci/catalog/components/details/ci_r import { createRouter } from '~/ci/catalog/router/index'; import { CI_RESOURCE_DETAILS_PAGE_NAME } from '~/ci/catalog/router/constants'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; import { catalogSharedDataMock, catalogAdditionalDetailsMock } from '../../mock'; Vue.use(VueApollo); @@ -75,7 +75,7 @@ describe('CiResourceDetailsPage', () => { router = createRouter(); await router.push({ name: CI_RESOURCE_DETAILS_PAGE_NAME, - params: { id: defaultSharedData.id }, + params: { id: defaultSharedData.webPath }, }); }); @@ -178,7 +178,7 @@ describe('CiResourceDetailsPage', () => { it('passes expected props', () => { expect(findDetailsComponent().props()).toEqual({ - resourceId: convertToGraphQLId(CI_CATALOG_RESOURCE_TYPE, defaultAdditionalData.id), + resourcePath: cleanLeadingSeparator(defaultSharedData.webPath), }); }); }); diff --git a/spec/frontend/ci/catalog/components/pages/ci_resources_page_spec.js b/spec/frontend/ci/catalog/components/pages/ci_resources_page_spec.js index e18b418b155..e6fbd63f307 100644 --- a/spec/frontend/ci/catalog/components/pages/ci_resources_page_spec.js +++ b/spec/frontend/ci/catalog/components/pages/ci_resources_page_spec.js @@ -7,10 +7,12 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import { createAlert } from '~/alert'; import CatalogHeader from '~/ci/catalog/components/list/catalog_header.vue'; +import CatalogSearch from '~/ci/catalog/components/list/catalog_search.vue'; import CiResourcesList from '~/ci/catalog/components/list/ci_resources_list.vue'; import CatalogListSkeletonLoader from '~/ci/catalog/components/list/catalog_list_skeleton_loader.vue'; import EmptyState from '~/ci/catalog/components/list/empty_state.vue'; -import { cacheConfig } from '~/ci/catalog/graphql/settings'; +import { cacheConfig, resolvers } from '~/ci/catalog/graphql/settings'; +import typeDefs from '~/ci/catalog/graphql/typedefs.graphql'; import ciResourcesPage from '~/ci/catalog/components/pages/ci_resources_page.vue'; import getCatalogResources from '~/ci/catalog/graphql/queries/get_ci_catalog_resources.query.graphql'; @@ -24,9 +26,11 @@ describe('CiResourcesPage', () => { let wrapper; let catalogResourcesResponse; + const defaultQueryVariables = { first: 20 }; + const createComponent = () => { const handlers = [[getCatalogResources, catalogResourcesResponse]]; - const mockApollo = createMockApollo(handlers, {}, cacheConfig); + const mockApollo = createMockApollo(handlers, resolvers, { cacheConfig, typeDefs }); wrapper = shallowMountExtended(ciResourcesPage, { apolloProvider: mockApollo, @@ -36,6 +40,7 @@ describe('CiResourcesPage', () => { }; const findCatalogHeader = () => wrapper.findComponent(CatalogHeader); + const findCatalogSearch = () => wrapper.findComponent(CatalogSearch); const findCiResourcesList = () => wrapper.findComponent(CiResourcesList); const findLoadingState = () => wrapper.findComponent(CatalogListSkeletonLoader); const findEmptyState = () => wrapper.findComponent(EmptyState); @@ -71,8 +76,14 @@ describe('CiResourcesPage', () => { }); it('renders the empty state', () => { - expect(findLoadingState().exists()).toBe(false); expect(findEmptyState().exists()).toBe(true); + }); + + it('renders the search', () => { + expect(findCatalogSearch().exists()).toBe(true); + }); + + it('does not render the list', () => { expect(findCiResourcesList().exists()).toBe(false); }); }); @@ -99,6 +110,10 @@ describe('CiResourcesPage', () => { totalCount: count, }); }); + + it('renders the search and sort', () => { + expect(findCatalogSearch().exists()).toBe(true); + }); }); }); @@ -121,11 +136,12 @@ describe('CiResourcesPage', () => { if (eventName === 'onNextPage') { expect(catalogResourcesResponse.mock.calls[1][0]).toEqual({ + ...defaultQueryVariables, after: pageInfo.endCursor, - first: 20, }); } else { expect(catalogResourcesResponse.mock.calls[1][0]).toEqual({ + ...defaultQueryVariables, before: pageInfo.startCursor, last: 20, first: null, @@ -134,8 +150,75 @@ describe('CiResourcesPage', () => { }); }); + describe('search and sort', () => { + describe('on initial load', () => { + beforeEach(async () => { + catalogResourcesResponse.mockResolvedValue(catalogResponseBody); + await createComponent(); + }); + + it('calls the query without search or sort', () => { + expect(catalogResourcesResponse).toHaveBeenCalledTimes(1); + expect(catalogResourcesResponse.mock.calls[0][0]).toEqual({ + ...defaultQueryVariables, + }); + }); + }); + + describe('when sorting changes', () => { + const newSort = 'MOST_AWESOME_ASC'; + + beforeEach(async () => { + catalogResourcesResponse.mockResolvedValue(catalogResponseBody); + await createComponent(); + await findCatalogSearch().vm.$emit('update-sorting', newSort); + }); + + it('passes it to the graphql query', () => { + expect(catalogResourcesResponse).toHaveBeenCalledTimes(2); + expect(catalogResourcesResponse.mock.calls[1][0]).toEqual({ + ...defaultQueryVariables, + sortValue: newSort, + }); + }); + }); + + describe('when search component emits a new search term', () => { + const newSearch = 'sloths'; + + describe('and there are no results', () => { + beforeEach(async () => { + catalogResourcesResponse.mockResolvedValue(emptyCatalogResponseBody); + await createComponent(); + await findCatalogSearch().vm.$emit('update-search-term', newSearch); + }); + + it('renders the empty state and passes down the search query', () => { + expect(findEmptyState().exists()).toBe(true); + expect(findEmptyState().props().searchTerm).toBe(newSearch); + }); + }); + + describe('and there are results', () => { + beforeEach(async () => { + catalogResourcesResponse.mockResolvedValue(catalogResponseBody); + await createComponent(); + await findCatalogSearch().vm.$emit('update-search-term', newSearch); + }); + + it('passes it to the graphql query', () => { + expect(catalogResourcesResponse).toHaveBeenCalledTimes(2); + expect(catalogResourcesResponse.mock.calls[1][0]).toEqual({ + ...defaultQueryVariables, + searchTerm: newSearch, + }); + }); + }); + }); + }); + describe('pages count', () => { - describe('when the fetchMore call suceeds', () => { + describe('when the fetchMore call succeeds', () => { beforeEach(async () => { catalogResourcesResponse.mockResolvedValue(catalogResponseBody); @@ -157,6 +240,31 @@ describe('CiResourcesPage', () => { }); }); + describe.each` + event | payload + ${'update-search-term'} | ${'cat'} + ${'update-sorting'} | ${'CREATED_ASC'} + `('when $event event is emitted', ({ event, payload }) => { + beforeEach(async () => { + catalogResourcesResponse.mockResolvedValue(catalogResponseBody); + await createComponent(); + }); + + it('resets the page count', async () => { + expect(findCiResourcesList().props().currentPage).toBe(1); + + findCiResourcesList().vm.$emit('onNextPage'); + await waitForPromises(); + + expect(findCiResourcesList().props().currentPage).toBe(2); + + await findCatalogSearch().vm.$emit(event, payload); + await waitForPromises(); + + expect(findCiResourcesList().props().currentPage).toBe(1); + }); + }); + describe('when the fetchMore call fails', () => { const errorMessage = 'there was an error'; diff --git a/spec/frontend/ci/catalog/mock.js b/spec/frontend/ci/catalog/mock.js index 125f003224c..e370ac5054f 100644 --- a/spec/frontend/ci/catalog/mock.js +++ b/spec/frontend/ci/catalog/mock.js @@ -1,5 +1,3 @@ -import { componentsMockData } from '~/ci/catalog/constants'; - export const emptyCatalogResponseBody = { data: { ciCatalogResources: { @@ -39,12 +37,6 @@ export const catalogResponseBody = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-42', __typename: 'CiCatalogResource', }, @@ -55,12 +47,6 @@ export const catalogResponseBody = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-41', __typename: 'CiCatalogResource', }, @@ -71,12 +57,6 @@ export const catalogResponseBody = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-40', __typename: 'CiCatalogResource', }, @@ -87,12 +67,6 @@ export const catalogResponseBody = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-39', __typename: 'CiCatalogResource', }, @@ -103,12 +77,6 @@ export const catalogResponseBody = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-38', __typename: 'CiCatalogResource', }, @@ -119,12 +87,6 @@ export const catalogResponseBody = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-37', __typename: 'CiCatalogResource', }, @@ -135,12 +97,6 @@ export const catalogResponseBody = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-36', __typename: 'CiCatalogResource', }, @@ -151,12 +107,6 @@ export const catalogResponseBody = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-35', __typename: 'CiCatalogResource', }, @@ -167,12 +117,6 @@ export const catalogResponseBody = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-34', __typename: 'CiCatalogResource', }, @@ -183,12 +127,6 @@ export const catalogResponseBody = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-33', __typename: 'CiCatalogResource', }, @@ -199,12 +137,6 @@ export const catalogResponseBody = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-32', __typename: 'CiCatalogResource', }, @@ -215,12 +147,6 @@ export const catalogResponseBody = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-31', __typename: 'CiCatalogResource', }, @@ -231,12 +157,6 @@ export const catalogResponseBody = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-30', __typename: 'CiCatalogResource', }, @@ -247,12 +167,6 @@ export const catalogResponseBody = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-29', __typename: 'CiCatalogResource', }, @@ -263,12 +177,6 @@ export const catalogResponseBody = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-28', __typename: 'CiCatalogResource', }, @@ -279,12 +187,6 @@ export const catalogResponseBody = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-27', __typename: 'CiCatalogResource', }, @@ -295,12 +197,6 @@ export const catalogResponseBody = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-26', __typename: 'CiCatalogResource', }, @@ -311,12 +207,6 @@ export const catalogResponseBody = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-25', __typename: 'CiCatalogResource', }, @@ -327,12 +217,6 @@ export const catalogResponseBody = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-24', __typename: 'CiCatalogResource', }, @@ -343,12 +227,6 @@ export const catalogResponseBody = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-23', __typename: 'CiCatalogResource', }, @@ -379,12 +257,6 @@ export const catalogSinglePageResponse = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-45', __typename: 'CiCatalogResource', }, @@ -395,12 +267,6 @@ export const catalogSinglePageResponse = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-44', __typename: 'CiCatalogResource', }, @@ -411,12 +277,6 @@ export const catalogSinglePageResponse = { description: 'A simple component', starCount: 0, latestVersion: null, - rootNamespace: { - id: 'gid://gitlab/Group/185', - fullPath: 'frontend-fixtures', - name: 'frontend-fixtures', - __typename: 'Namespace', - }, webPath: '/frontend-fixtures/project-43', __typename: 'CiCatalogResource', }, @@ -434,7 +294,6 @@ export const catalogSharedDataMock = { icon: null, description: 'This is the description of the repo', name: 'Ruby', - rootNamespace: { id: 1, fullPath: '/group/project', name: 'my-dumb-project' }, starCount: 1, latestVersion: { __typename: 'Release', @@ -444,7 +303,7 @@ export const catalogSharedDataMock = { releasedAt: Date.now(), author: { id: 1, webUrl: 'profile/1', name: 'username' }, }, - webPath: 'path/to/project', + webPath: '/path/to/project', }, }, }; @@ -454,6 +313,7 @@ export const catalogAdditionalDetailsMock = { ciCatalogResource: { __typename: 'CiCatalogResource', id: `gid://gitlab/CiCatalogResource/1`, + webPath: '/twitter/project', openIssuesCount: 4, openMergeRequestsCount: 10, readmeHtml: '<h1>Hello world</h1>', @@ -502,12 +362,6 @@ const generateResourcesNodes = (count = 20, startId = 0) => { description: `This is a component that does a bunch of stuff and is really just a number: ${i}`, icon: 'my-icon', name: `My component #${i}`, - rootNamespace: { - id: 1, - __typename: 'Namespace', - name: 'namespaceName', - path: 'namespacePath', - }, starCount: 10, latestVersion: { __typename: 'Release', @@ -526,13 +380,47 @@ const generateResourcesNodes = (count = 20, startId = 0) => { export const mockCatalogResourceItem = generateResourcesNodes(1)[0]; +const componentsMockData = { + __typename: 'CiComponentConnection', + nodes: [ + { + id: 'gid://gitlab/Ci::Component/1', + name: 'Ruby gal', + description: 'This is a pretty amazing component that does EVERYTHING ruby.', + path: 'gitlab.com/gitlab-org/ruby-gal@~latest', + inputs: [{ name: 'version', default: '1.0.0', required: true }], + }, + { + id: 'gid://gitlab/Ci::Component/2', + name: 'Javascript madness', + description: 'Adds some spice to your life.', + path: 'gitlab.com/gitlab-org/javascript-madness@~latest', + inputs: [ + { name: 'isFun', default: 'true', required: true }, + { name: 'RandomNumber', default: '10', required: false }, + ], + }, + { + id: 'gid://gitlab/Ci::Component/3', + name: 'Go go go', + description: 'When you write Go, you gotta go go go.', + path: 'gitlab.com/gitlab-org/go-go-go@~latest', + inputs: [{ name: 'version', default: '1.0.0', required: true }], + }, + ], +}; + export const mockComponents = { data: { ciCatalogResource: { __typename: 'CiCatalogResource', id: `gid://gitlab/CiCatalogResource/1`, - components: { - ...componentsMockData, + webPath: '/twitter/project-1', + latestVersion: { + id: 'gid://gitlab/Version/1', + components: { + ...componentsMockData, + }, }, }, }, @@ -543,7 +431,11 @@ export const mockComponentsEmpty = { ciCatalogResource: { __typename: 'CiCatalogResource', id: `gid://gitlab/CiCatalogResource/1`, - components: [], + webPath: '/twitter/project-1', + latestVersion: { + id: 'gid://gitlab/Version/1', + components: [], + }, }, }, }; diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js index 610aae3946f..721e2b831fc 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js @@ -1,6 +1,16 @@ import { nextTick } from 'vue'; -import { GlDrawer, GlFormCombobox, GlFormInput, GlFormSelect, GlModal } from '@gitlab/ui'; +import { + GlDrawer, + GlFormCombobox, + GlFormGroup, + GlFormInput, + GlFormSelect, + GlLink, + GlModal, + GlSprintf, +} from '@gitlab/ui'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { helpPagePath } from '~/helpers/help_page_helper'; import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue'; import CiVariableDrawer from '~/ci/ci_variable_list/components/ci_variable_drawer.vue'; import { awsTokenList } from '~/ci/ci_variable_list/components/ci_variable_autocomplete_tokens'; @@ -20,6 +30,8 @@ describe('CI Variable Drawer', () => { let wrapper; let trackingSpy; + const itif = (condition) => (condition ? it : it.skip); + const mockProjectVariable = mockVariablesWithScopes(projectString)[0]; const mockProjectVariableFileType = mockVariablesWithScopes(projectString)[1]; const mockEnvScope = 'staging'; @@ -74,6 +86,7 @@ describe('CI Variable Drawer', () => { const findDrawer = () => wrapper.findComponent(GlDrawer); const findEnvironmentScopeDropdown = () => wrapper.findComponent(CiEnvironmentsDropdown); const findExpandedCheckbox = () => wrapper.findByTestId('ci-variable-expanded-checkbox'); + const findFlagsDocsLink = () => wrapper.findByTestId('ci-variable-flags-docs-link'); const findKeyField = () => wrapper.findComponent(GlFormCombobox); const findMaskedCheckbox = () => wrapper.findByTestId('ci-variable-masked-checkbox'); const findProtectedCheckbox = () => wrapper.findByTestId('ci-variable-protected-checkbox'); @@ -81,6 +94,26 @@ describe('CI Variable Drawer', () => { const findValueLabel = () => wrapper.findByTestId('ci-variable-value-label'); const findTitle = () => findDrawer().find('h2'); const findTypeDropdown = () => wrapper.findComponent(GlFormSelect); + const findVariablesPrecedenceDocsLink = () => + wrapper.findByTestId('ci-variable-precedence-docs-link'); + + describe('template', () => { + beforeEach(() => { + createComponent({ stubs: { GlFormGroup, GlLink, GlSprintf } }); + }); + + it('renders docs link for variables precendece', () => { + expect(findVariablesPrecedenceDocsLink().attributes('href')).toBe( + helpPagePath('ci/variables/index', { anchor: 'cicd-variable-precedence' }), + ); + }); + + it('renders docs link for flags', () => { + expect(findFlagsDocsLink().attributes('href')).toBe( + helpPagePath('ci/variables/index', { anchor: 'define-a-cicd-variable-in-the-ui' }), + ); + }); + }); describe('validations', () => { describe('type dropdown', () => { @@ -263,12 +296,22 @@ describe('CI Variable Drawer', () => { expect(findKeyField().props('tokenList')).toBe(awsTokenList); }); - it('cannot submit with empty key', async () => { - expect(findConfirmBtn().attributes('disabled')).toBeDefined(); - - await findKeyField().vm.$emit('input', 'NEW_VARIABLE'); - - expect(findConfirmBtn().attributes('disabled')).toBeUndefined(); + const keyFeedbackMessage = "A variable key can only contain letters, numbers, and '_'."; + describe.each` + key | feedbackMessage | submitButtonDisabledState + ${'validKey123'} | ${''} | ${undefined} + ${'VALID_KEY'} | ${''} | ${undefined} + ${''} | ${''} | ${'true'} + ${'invalid!!key'} | ${keyFeedbackMessage} | ${'true'} + ${'key with whitespace'} | ${keyFeedbackMessage} | ${'true'} + ${'multiline\nkey'} | ${keyFeedbackMessage} | ${'true'} + `('key validation', ({ key, feedbackMessage, submitButtonDisabledState }) => { + it(`validates key ${key} correctly`, async () => { + await findKeyField().vm.$emit('input', key); + + expect(findConfirmBtn().attributes('disabled')).toBe(submitButtonDisabledState); + expect(wrapper.text()).toContain(feedbackMessage); + }); }); }); @@ -284,52 +327,106 @@ describe('CI Variable Drawer', () => { expect(findConfirmBtn().attributes('disabled')).toBeUndefined(); }); - describe.each` - value | canSubmit | trackingErrorProperty - ${'secretValue'} | ${true} | ${null} - ${'~v@lid:symbols.'} | ${true} | ${null} - ${'short'} | ${false} | ${null} - ${'multiline\nvalue'} | ${false} | ${'\n'} - ${'dollar$ign'} | ${false} | ${'$'} - ${'unsupported|char'} | ${false} | ${'|'} - `('masking requirements', ({ value, canSubmit, trackingErrorProperty }) => { - beforeEach(async () => { - createComponent(); - - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - await findKeyField().vm.$emit('input', 'NEW_VARIABLE'); - await findValueField().vm.$emit('input', value); - await findMaskedCheckbox().vm.$emit('input', true); - }); + const invalidValues = { + short: 'short', + multiLine: 'multiline\nvalue', + unsupportedChar: 'unsupported|char', + twoUnsupportedChars: 'unsupported|chars!', + threeUnsupportedChars: '%unsupported|chars!', + shortAndMultiLine: 'sho\nrt', + shortAndUnsupportedChar: 'short!', + shortAndMultiLineAndUnsupportedChar: 'short\n!', + multiLineAndUnsupportedChar: 'multiline\nvalue!', + }; + const maskedValidationIssuesText = { + short: 'The value must have at least 8 characters.', + multiLine: + 'This value cannot be masked because it contains the following characters: whitespace characters.', + unsupportedChar: + 'This value cannot be masked because it contains the following characters: |.', + unsupportedDollarChar: + 'This value cannot be masked because it contains the following characters: $.', + twoUnsupportedChars: + 'This value cannot be masked because it contains the following characters: |, !.', + threeUnsupportedChars: + 'This value cannot be masked because it contains the following characters: %, |, !.', + shortAndMultiLine: + 'This value cannot be masked because it contains the following characters: whitespace characters. The value must have at least 8 characters.', + shortAndUnsupportedChar: + 'This value cannot be masked because it contains the following characters: !. The value must have at least 8 characters.', + shortAndMultiLineAndUnsupportedChar: + 'This value cannot be masked because it contains the following characters: ! and whitespace characters. The value must have at least 8 characters.', + multiLineAndUnsupportedChar: + 'This value cannot be masked because it contains the following characters: ! and whitespace characters.', + }; - it(`${ - canSubmit ? 'can submit' : 'shows validation errors and disables submit button' - } when value is '${value}'`, () => { - if (canSubmit) { + describe.each` + value | canSubmit | trackingErrorProperty | validationIssueKey + ${'secretValue'} | ${true} | ${null} | ${''} + ${'~v@lid:symbols.'} | ${true} | ${null} | ${''} + ${invalidValues.short} | ${false} | ${null} | ${'short'} + ${invalidValues.multiLine} | ${false} | ${'\n'} | ${'multiLine'} + ${'dollar$ign'} | ${false} | ${'$'} | ${'unsupportedDollarChar'} + ${invalidValues.unsupportedChar} | ${false} | ${'|'} | ${'unsupportedChar'} + ${invalidValues.twoUnsupportedChars} | ${false} | ${'|!'} | ${'twoUnsupportedChars'} + ${invalidValues.threeUnsupportedChars} | ${false} | ${'%|!'} | ${'threeUnsupportedChars'} + ${invalidValues.shortAndMultiLine} | ${false} | ${'\n'} | ${'shortAndMultiLine'} + ${invalidValues.shortAndUnsupportedChar} | ${false} | ${'!'} | ${'shortAndUnsupportedChar'} + ${invalidValues.shortAndMultiLineAndUnsupportedChar} | ${false} | ${'\n!'} | ${'shortAndMultiLineAndUnsupportedChar'} + ${invalidValues.multiLineAndUnsupportedChar} | ${false} | ${'\n!'} | ${'multiLineAndUnsupportedChar'} + `( + 'masking requirements', + ({ value, canSubmit, trackingErrorProperty, validationIssueKey }) => { + beforeEach(() => { + createComponent(); + + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + findKeyField().vm.$emit('input', 'NEW_VARIABLE'); + findValueField().vm.$emit('input', value); + findMaskedCheckbox().vm.$emit('input', true); + }); + + itif(canSubmit)(`can submit when value is ${value}`, () => { + /* eslint-disable jest/no-standalone-expect */ expect(findValueLabel().attributes('invalid-feedback')).toBe(''); expect(findConfirmBtn().attributes('disabled')).toBeUndefined(); - } else { - expect(findValueLabel().attributes('invalid-feedback')).toBe( - 'This variable value does not meet the masking requirements.', - ); - expect(findConfirmBtn().attributes('disabled')).toBeDefined(); - } - }); + /* eslint-enable jest/no-standalone-expect */ + }); + + itif(!canSubmit)( + `shows validation errors and disables submit button when value is ${value}`, + () => { + const validationIssueText = maskedValidationIssuesText[validationIssueKey] || ''; + + /* eslint-disable jest/no-standalone-expect */ + expect(findValueLabel().attributes('invalid-feedback')).toBe(validationIssueText); + expect(findConfirmBtn().attributes('disabled')).toBeDefined(); + /* eslint-enable jest/no-standalone-expect */ + }, + ); + + itif(trackingErrorProperty)( + `sends the correct variable validation tracking event when value is ${value}`, + () => { + /* eslint-disable jest/no-standalone-expect */ + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTION, { + label: DRAWER_EVENT_LABEL, + property: trackingErrorProperty, + }); + /* eslint-enable jest/no-standalone-expect */ + }, + ); - it(`${ - trackingErrorProperty ? 'sends the correct' : 'does not send the' - } variable validation tracking event when value is '${value}'`, () => { - const trackingEventSent = trackingErrorProperty ? 1 : 0; - expect(trackingSpy).toHaveBeenCalledTimes(trackingEventSent); - - if (trackingErrorProperty) { - expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTION, { - label: DRAWER_EVENT_LABEL, - property: trackingErrorProperty, - }); - } - }); - }); + itif(!trackingErrorProperty)( + `does not send the the correct variable validation tracking event when value is ${value}`, + () => { + // eslint-disable-next-line jest/no-standalone-expect + expect(trackingSpy).toHaveBeenCalledTimes(0); + }, + ); + }, + ); it('only sends the tracking event once', async () => { trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); diff --git a/spec/frontend/ci/common/pipelines_table_spec.js b/spec/frontend/ci/common/pipelines_table_spec.js index f6d3121109f..ca07e0ab8c8 100644 --- a/spec/frontend/ci/common/pipelines_table_spec.js +++ b/spec/frontend/ci/common/pipelines_table_spec.js @@ -16,7 +16,7 @@ import { TRACKING_CATEGORIES, } from '~/ci/constants'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; describe('Pipelines Table', () => { let wrapper; diff --git a/spec/frontend/ci/job_details/components/job_header_spec.js b/spec/frontend/ci/job_details/components/job_header_spec.js index d12267807ac..0b98d5fa935 100644 --- a/spec/frontend/ci/job_details/components/job_header_spec.js +++ b/spec/frontend/ci/job_details/components/job_header_spec.js @@ -1,7 +1,7 @@ import { GlButton, GlAvatarLink, GlTooltip } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import JobHeader from '~/ci/job_details/components/job_header.vue'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; diff --git a/spec/frontend/ci/job_details/components/job_log_controllers_spec.js b/spec/frontend/ci/job_details/components/job_log_controllers_spec.js index 84c664aca34..078ad4aee34 100644 --- a/spec/frontend/ci/job_details/components/job_log_controllers_spec.js +++ b/spec/frontend/ci/job_details/components/job_log_controllers_spec.js @@ -30,17 +30,12 @@ describe('Job log controllers', () => { jobLog: mockJobLog, }; - const createWrapper = (props, { jobLogJumpToFailures = false } = {}) => { + const createWrapper = (props) => { wrapper = mount(JobLogControllers, { propsData: { ...defaultProps, ...props, }, - provide: { - glFeatures: { - jobLogJumpToFailures, - }, - }, data() { return { searchTerm: '82', @@ -62,6 +57,10 @@ describe('Job log controllers', () => { const findJobLogSearch = () => wrapper.findComponent(GlSearchBoxByClick); const findSearchHelp = () => wrapper.findComponent(HelpPopover); const findScrollFailure = () => wrapper.find('[data-testid="job-controller-scroll-to-failure"]'); + const findShowFullScreenButton = () => + wrapper.find('[data-testid="job-controller-enter-fullscreen"]'); + const findExitFullScreenButton = () => + wrapper.find('[data-testid="job-controller-exit-fullscreen"]'); describe('Truncate information', () => { describe('with isJobLogSizeVisible', () => { @@ -199,14 +198,6 @@ describe('Job log controllers', () => { }); describe('scroll to failure button', () => { - describe('with feature flag disabled', () => { - it('does not display button', () => { - createWrapper(); - - expect(findScrollFailure().exists()).toBe(false); - }); - }); - describe('with red text failures on the page', () => { let firstFailure; let secondFailure; @@ -214,7 +205,7 @@ describe('Job log controllers', () => { beforeEach(() => { jest.spyOn(document, 'querySelectorAll').mockReturnValueOnce(['mock-element']); - createWrapper({}, { jobLogJumpToFailures: true }); + createWrapper(); firstFailure = document.createElement('div'); firstFailure.className = 'term-fg-l-red'; @@ -262,7 +253,7 @@ describe('Job log controllers', () => { beforeEach(() => { jest.spyOn(document, 'querySelectorAll').mockReturnValueOnce([]); - createWrapper({}, { jobLogJumpToFailures: true }); + createWrapper(); }); it('is disabled', () => { @@ -274,7 +265,7 @@ describe('Job log controllers', () => { beforeEach(() => { jest.spyOn(document, 'querySelectorAll').mockReturnValueOnce(['mock-element']); - createWrapper({ isComplete: false }, { jobLogJumpToFailures: true }); + createWrapper(); }); it('is enabled', () => { @@ -286,7 +277,7 @@ describe('Job log controllers', () => { beforeEach(() => { jest.spyOn(commonUtils, 'backOff').mockRejectedValueOnce(); - createWrapper({}, { jobLogJumpToFailures: true }); + createWrapper(); }); it('stays disabled', () => { @@ -318,4 +309,53 @@ describe('Job log controllers', () => { expect(wrapper.emitted('searchResults')).toEqual([[[]]]); }); }); + + describe('Fullscreen controls', () => { + it('displays a disabled "Show fullscreen" button', () => { + createWrapper(); + + expect(findShowFullScreenButton().exists()).toBe(true); + expect(findShowFullScreenButton().attributes('disabled')).toBe('disabled'); + }); + + it('displays a enabled "Show fullscreen" button', () => { + createWrapper({ + fullScreenModeAvailable: true, + }); + + expect(findShowFullScreenButton().exists()).toBe(true); + expect(findShowFullScreenButton().attributes('disabled')).toBeUndefined(); + }); + + it('emits a enterFullscreen event when the show fullscreen is clicked', async () => { + createWrapper({ + fullScreenModeAvailable: true, + }); + + await findShowFullScreenButton().trigger('click'); + + expect(wrapper.emitted('enterFullscreen')).toHaveLength(1); + }); + + it('displays a enabled "Exit fullscreen" button', () => { + createWrapper({ + fullScreenModeAvailable: true, + fullScreenEnabled: true, + }); + + expect(findExitFullScreenButton().exists()).toBe(true); + expect(findExitFullScreenButton().attributes('disabled')).toBeUndefined(); + }); + + it('emits a exitFullscreen event when the exit fullscreen is clicked', async () => { + createWrapper({ + fullScreenModeAvailable: true, + fullScreenEnabled: true, + }); + + await findExitFullScreenButton().trigger('click'); + + expect(wrapper.emitted('exitFullscreen')).toHaveLength(1); + }); + }); }); diff --git a/spec/frontend/ci/job_details/components/log/collapsible_section_spec.js b/spec/frontend/ci/job_details/components/log/collapsible_section_spec.js deleted file mode 100644 index 5abf2a5ce53..00000000000 --- a/spec/frontend/ci/job_details/components/log/collapsible_section_spec.js +++ /dev/null @@ -1,103 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import CollapsibleSection from '~/ci/job_details/components/log/collapsible_section.vue'; -import LogLine from '~/ci/job_details/components/log/line.vue'; -import LogLineHeader from '~/ci/job_details/components/log/line_header.vue'; -import { collapsibleSectionClosed, collapsibleSectionOpened } from './mock_data'; - -describe('Job Log Collapsible Section', () => { - let wrapper; - - const jobLogEndpoint = 'jobs/335'; - - const findLogLineHeader = () => wrapper.findComponent(LogLineHeader); - const findLogLineHeaderSvg = () => findLogLineHeader().find('svg'); - const findLogLines = () => wrapper.findAllComponents(LogLine); - - const createComponent = (props = {}) => { - wrapper = mount(CollapsibleSection, { - propsData: { - ...props, - }, - }); - }; - - describe('with closed section', () => { - beforeEach(() => { - createComponent({ - section: collapsibleSectionClosed, - jobLogEndpoint, - }); - }); - - it('renders clickable header line', () => { - expect(findLogLineHeader().text()).toBe('1 foo'); - expect(findLogLineHeader().attributes('role')).toBe('button'); - }); - - it('renders an icon with a closed state', () => { - expect(findLogLineHeaderSvg().attributes('data-testid')).toBe('chevron-lg-right-icon'); - }); - - it('does not render collapsed lines', () => { - expect(findLogLines()).toHaveLength(0); - }); - }); - - describe('with opened section', () => { - beforeEach(() => { - createComponent({ - section: collapsibleSectionOpened, - jobLogEndpoint, - }); - }); - - it('renders clickable header line', () => { - expect(findLogLineHeader().text()).toContain('foo'); - expect(findLogLineHeader().attributes('role')).toBe('button'); - }); - - it('renders an icon with the open state', () => { - expect(findLogLineHeaderSvg().attributes('data-testid')).toBe('chevron-lg-down-icon'); - }); - - it('renders collapsible lines', () => { - expect(findLogLines().at(0).text()).toContain('this is a collapsible nested section'); - expect(findLogLines()).toHaveLength(collapsibleSectionOpened.lines.length); - }); - }); - - it('emits onClickCollapsibleLine on click', async () => { - createComponent({ - section: collapsibleSectionOpened, - jobLogEndpoint, - }); - - findLogLineHeader().trigger('click'); - - await nextTick(); - expect(wrapper.emitted('onClickCollapsibleLine').length).toBe(1); - }); - - describe('with search results', () => { - it('passes isHighlighted prop correctly', () => { - const mockSearchResults = [ - { - content: [{ text: 'foo' }], - lineNumber: 1, - offset: 5, - section: 'prepare-script', - section_header: true, - }, - ]; - - createComponent({ - section: collapsibleSectionOpened, - jobLogEndpoint, - searchResults: mockSearchResults, - }); - - expect(findLogLineHeader().props('isHighlighted')).toBe(true); - }); - }); -}); diff --git a/spec/frontend/ci/job_details/components/log/line_header_spec.js b/spec/frontend/ci/job_details/components/log/line_header_spec.js index c75f5fa30d5..0ac33f5aa5a 100644 --- a/spec/frontend/ci/job_details/components/log/line_header_spec.js +++ b/spec/frontend/ci/job_details/components/log/line_header_spec.js @@ -95,12 +95,14 @@ describe('Job Log Header Line', () => { }); describe('with duration', () => { - beforeEach(() => { + it('renders the duration badge', () => { createComponent({ ...defaultProps, duration: '00:10' }); + expect(wrapper.findComponent(DurationBadge).exists()).toBe(true); }); - it('renders the duration badge', () => { - expect(wrapper.findComponent(DurationBadge).exists()).toBe(true); + it('does not render the duration badge with hidden duration', () => { + createComponent({ ...defaultProps, hideDuration: true, duration: '00:10' }); + expect(wrapper.findComponent(DurationBadge).exists()).toBe(false); }); }); diff --git a/spec/frontend/ci/job_details/components/log/log_spec.js b/spec/frontend/ci/job_details/components/log/log_spec.js index 1931d5046dc..de02c7aad6d 100644 --- a/spec/frontend/ci/job_details/components/log/log_spec.js +++ b/spec/frontend/ci/job_details/components/log/log_spec.js @@ -6,9 +6,12 @@ import waitForPromises from 'helpers/wait_for_promises'; import { scrollToElement } from '~/lib/utils/common_utils'; import Log from '~/ci/job_details/components/log/log.vue'; import LogLineHeader from '~/ci/job_details/components/log/line_header.vue'; +import LineNumber from '~/ci/job_details/components/log/line_number.vue'; import { logLinesParser } from '~/ci/job_details/store/utils'; import { mockJobLog, mockJobLogLineCount } from './mock_data'; +const mockPagePath = 'project/-/jobs/99'; + jest.mock('~/lib/utils/common_utils', () => ({ ...jest.requireActual('~/lib/utils/common_utils'), scrollToElement: jest.fn(), @@ -24,7 +27,12 @@ describe('Job Log', () => { Vue.use(Vuex); const createComponent = (props) => { + store = new Vuex.Store({ actions, state }); + wrapper = mount(Log, { + provide: { + pagePath: mockPagePath, + }, propsData: { ...props, }, @@ -36,39 +44,34 @@ describe('Job Log', () => { toggleCollapsibleLineMock = jest.fn(); actions = { toggleCollapsibleLine: toggleCollapsibleLineMock, + setupFullScreenListeners: jest.fn(), }; + const { lines, sections } = logLinesParser(mockJobLog); + state = { - jobLog: logLinesParser(mockJobLog), - jobLogEndpoint: 'jobs/id', + jobLog: lines, + jobLogSections: sections, }; - - store = new Vuex.Store({ - actions, - state, - }); }); - const findCollapsibleLine = () => wrapper.findComponent(LogLineHeader); - const findAllCollapsibleLines = () => wrapper.findAllComponents(LogLineHeader); + const findLineNumbers = () => wrapper.findAllComponents(LineNumber); + const findLineHeader = () => wrapper.findComponent(LogLineHeader); + const findLineHeaders = () => wrapper.findAllComponents(LogLineHeader); describe('line numbers', () => { beforeEach(() => { createComponent(); }); - it.each([...Array(mockJobLogLineCount).keys()])( - 'renders a line number for each line %d', - (index) => { - const lineNumber = wrapper - .findAll('.js-log-line') - .at(index) - .find(`#L${index + 1}`); + it('renders a line number for each line %d with an href', () => { + for (let i = 0; i < mockJobLogLineCount; i += 1) { + const w = findLineNumbers().at(i); - expect(lineNumber.text()).toBe(`${index + 1}`); - expect(lineNumber.attributes('href')).toBe(`${state.jobLogEndpoint}#L${index + 1}`); - }, - ); + expect(w.text()).toBe(`${i + 1}`); + expect(w.attributes('href')).toBe(`${mockPagePath}#L${i + 1}`); + } + }); }); describe('collapsible sections', () => { @@ -77,22 +80,54 @@ describe('Job Log', () => { }); it('renders a clickable header section', () => { - expect(findCollapsibleLine().attributes('role')).toBe('button'); + expect(findLineHeader().attributes('role')).toBe('button'); }); it('renders an icon with the open state', () => { - expect(findCollapsibleLine().find('[data-testid="chevron-lg-down-icon"]').exists()).toBe( - true, - ); + expect(findLineHeader().find('[data-testid="chevron-lg-down-icon"]').exists()).toBe(true); }); describe('on click header section', () => { it('calls toggleCollapsibleLine', () => { - findCollapsibleLine().trigger('click'); + findLineHeader().trigger('click'); expect(toggleCollapsibleLineMock).toHaveBeenCalled(); }); }); + + describe('duration', () => { + it('shows duration', () => { + expect(findLineHeader().props('duration')).toBe('00:00'); + expect(findLineHeader().props('hideDuration')).toBe(false); + }); + + it('hides duration', () => { + state.jobLogSections['resolve-secrets'].hideDuration = true; + createComponent(); + + expect(findLineHeader().props('duration')).toBe('00:00'); + expect(findLineHeader().props('hideDuration')).toBe(true); + }); + }); + + describe('when a section is collapsed', () => { + beforeEach(() => { + state.jobLogSections['prepare-executor'].isClosed = true; + + createComponent(); + }); + + it('hides lines in section', () => { + expect(findLineNumbers().wrappers.map((w) => w.text())).toEqual([ + '1', + '2', + '3', + '4', + // closed section not shown + '7', + ]); + }); + }); }); describe('anchor scrolling', () => { @@ -119,19 +154,19 @@ describe('Job Log', () => { it('scrolls to line number', async () => { createComponent(); - state.jobLog = logLinesParser(mockJobLog, [], '#L6'); + state.jobLog = logLinesParser(mockJobLog, [], '#L6').lines; await waitForPromises(); expect(scrollToElement).toHaveBeenCalledTimes(1); - state.jobLog = logLinesParser(mockJobLog, [], '#L7'); + state.jobLog = logLinesParser(mockJobLog, [], '#L6').lines; await waitForPromises(); expect(scrollToElement).toHaveBeenCalledTimes(1); }); it('line number within collapsed section is visible', () => { - state.jobLog = logLinesParser(mockJobLog, [], '#L6'); + state.jobLog = logLinesParser(mockJobLog, [], '#L6').lines; createComponent(); @@ -150,15 +185,14 @@ describe('Job Log', () => { }, ], section: 'prepare-executor', - section_header: true, lineNumber: 3, }, ]; createComponent({ searchResults: mockSearchResults }); - expect(findAllCollapsibleLines().at(0).props('isHighlighted')).toBe(true); - expect(findAllCollapsibleLines().at(1).props('isHighlighted')).toBe(false); + expect(findLineHeaders().at(0).props('isHighlighted')).toBe(true); + expect(findLineHeaders().at(1).props('isHighlighted')).toBe(false); }); }); }); diff --git a/spec/frontend/ci/job_details/components/log/mock_data.js b/spec/frontend/ci/job_details/components/log/mock_data.js index d9b1354f475..066f783586b 100644 --- a/spec/frontend/ci/job_details/components/log/mock_data.js +++ b/spec/frontend/ci/job_details/components/log/mock_data.js @@ -65,141 +65,182 @@ export const mockContentSection = [ }, ]; -export const mockJobLog = [...mockJobLines, ...mockEmptySection, ...mockContentSection]; - -export const mockJobLogLineCount = 6; // `text` entries in mockJobLog - -export const originalTrace = [ +export const mockJobLogEnd = [ { - offset: 1, - content: [ - { - text: 'Downloading', - }, - ], + offset: 1008, + content: [{ text: 'Job succeeded' }], }, ]; -export const regularIncremental = [ - { - offset: 2, - content: [ - { - text: 'log line', - }, - ], - }, +export const mockJobLog = [ + ...mockJobLines, + ...mockEmptySection, + ...mockContentSection, + ...mockJobLogEnd, ]; -export const regularIncrementalRepeated = [ +export const mockJobLogLineCount = 7; // `text` entries in mockJobLog + +export const mockContentSectionClosed = [ { - offset: 1, + offset: 0, content: [ { - text: 'log line', + text: 'Using Docker executor with image dev.gitlab.org3', }, ], + section: 'mock-closed-section', + section_header: true, + section_options: { collapsed: true }, + }, + { + offset: 1003, + content: [{ text: 'Docker executor with image registry.gitlab.com ...' }], + section: 'mock-closed-section', + }, + { + offset: 1004, + content: [{ text: 'Starting service ...', style: 'term-fg-l-green' }], + section: 'mock-closed-section', + }, + { + offset: 1005, + content: [], + section: 'mock-closed-section', + section_footer: true, + section_duration: '00:09', }, ]; -export const headerTrace = [ +export const mockContentSectionHiddenDuration = [ { - offset: 1, + offset: 0, + content: [{ text: 'Line 1' }], + section: 'mock-hidden-duration-section', section_header: true, - content: [ - { - text: 'log line', - }, - ], - section: 'section', + section_options: { hide_duration: 'true' }, + }, + { + offset: 1001, + content: [{ text: 'Line 2' }], + section: 'mock-hidden-duration-section', + }, + { + offset: 1002, + content: [], + section: 'mock-hidden-duration-section', + section_footer: true, + section_duration: '00:09', }, ]; -export const headerTraceIncremental = [ +export const mockContentSubsection = [ { - offset: 1, + offset: 0, + content: [{ text: 'Line 1' }], + section: 'mock-section', section_header: true, - content: [ - { - text: 'updated log line', - }, - ], - section: 'section', }, -]; - -export const collapsibleTrace = [ { - offset: 1, + offset: 1002, + content: [{ text: 'Line 2 - section content' }], + section: 'mock-section', + }, + { + offset: 1003, + content: [{ text: 'Line 3 - sub section header' }], + section: 'sub-section', section_header: true, - content: [ - { - text: 'log line', - }, - ], - section: 'section', }, { - offset: 2, - content: [ - { - text: 'log line', - }, - ], - section: 'section', + offset: 1004, + content: [{ text: 'Line 4 - sub section content' }], + section: 'sub-section', + }, + { + offset: 1005, + content: [{ text: 'Line 5 - sub sub section header with no content' }], + section: 'sub-sub-section', + section_header: true, + }, + { + offset: 1006, + content: [], + section: 'sub-sub-section', + section_footer: true, + section_duration: '00:00', + }, + + { + offset: 1007, + content: [{ text: 'Line 6 - sub section content 2' }], + section: 'sub-section', + }, + { + offset: 1008, + content: [], + section: 'sub-section', + section_footer: true, + section_duration: '00:29', + }, + { + offset: 1009, + content: [{ text: 'Line 7 - section content' }], + section: 'mock-section', + }, + { + offset: 1010, + content: [], + section: 'mock-section', + section_footer: true, + section_duration: '00:59', + }, + { + offset: 1011, + content: [{ text: 'Job succeeded' }], }, ]; -export const collapsibleTraceIncremental = [ +export const mockTruncatedBottomSection = [ + // only the top of a section is obtained, such as when a job gets cancelled { - offset: 2, + offset: 1004, content: [ { - text: 'updated log line', + text: 'Starting job', }, ], - section: 'section', + section: 'mock-section', + section_header: true, + }, + { + offset: 1005, + content: [{ text: 'Job interrupted' }], + section: 'mock-section', }, ]; -export const collapsibleSectionClosed = { - offset: 5, - section_header: true, - isHeader: true, - isClosed: true, - line: { - content: [{ text: 'foo' }], - section: 'prepare-script', - lineNumber: 1, - }, - section_duration: '00:03', - lines: [ - { - offset: 80, - content: [{ text: 'this is a collapsible nested section' }], - section: 'prepare-script', - lineNumber: 2, - }, - ], -}; - -export const collapsibleSectionOpened = { - offset: 5, - section_header: true, - isHeader: true, - isClosed: false, - line: { - content: [{ text: 'foo' }], - section: 'prepare-script', - lineNumber: 1, - }, - section_duration: '00:03', - lines: [ - { - offset: 80, - content: [{ text: 'this is a collapsible nested section' }], - section: 'prepare-script', - lineNumber: 2, - }, - ], -}; +export const mockTruncatedTopSection = [ + // only the bottom half of a section is obtained, such as when jobs are cut off due to large sizes + { + offset: 1008, + content: [{ text: 'Line N - incomplete section content' }], + section: 'mock-section', + }, + { + offset: 1009, + content: [{ text: 'Line N+1 - incomplete section content' }], + section: 'mock-section', + }, + { + offset: 1010, + content: [], + section: 'mock-section', + section_footer: true, + section_duration: '00:59', + }, + { + offset: 1011, + content: [{ text: 'Job succeeded' }], + }, +]; diff --git a/spec/frontend/ci/job_details/components/manual_variables_form_spec.js b/spec/frontend/ci/job_details/components/manual_variables_form_spec.js index 3391cafb4fc..4961b605ee3 100644 --- a/spec/frontend/ci/job_details/components/manual_variables_form_spec.js +++ b/spec/frontend/ci/job_details/components/manual_variables_form_spec.js @@ -1,7 +1,6 @@ import { GlSprintf, GlLink } from '@gitlab/ui'; -import { createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; import { createAlert } from '~/alert'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -24,9 +23,8 @@ import { mockJobRetryMutationData, } from '../mock_data'; -const localVue = createLocalVue(); jest.mock('~/alert'); -localVue.use(VueApollo); +Vue.use(VueApollo); jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), @@ -62,7 +60,6 @@ describe('Manual Variables Form', () => { ]); const options = { - localVue, apolloProvider: mockApollo, }; @@ -180,6 +177,9 @@ describe('Manual Variables Form', () => { beforeEach(async () => { await createComponent({ handlers: { + getJobQueryResponseHandlerWithVariables: jest + .fn() + .mockResolvedValue(mockJobWithVariablesResponse), playJobMutationHandler: jest.fn().mockResolvedValue(mockJobPlayMutationData), }, }); @@ -211,6 +211,15 @@ describe('Manual Variables Form', () => { expect(requestHandlers.playJobMutationHandler).toHaveBeenCalledTimes(1); expect(redirectTo).toHaveBeenCalledWith(mockJobPlayMutationData.data.jobPlay.job.webPath); // eslint-disable-line import/no-deprecated }); + + it('does not refetch variables after job is run', async () => { + expect(requestHandlers.getJobQueryResponseHandlerWithVariables).toHaveBeenCalledTimes(1); + + findRunBtn().vm.$emit('click'); + await waitForPromises(); + + expect(requestHandlers.getJobQueryResponseHandlerWithVariables).toHaveBeenCalledTimes(1); + }); }); describe('when play mutation is unsuccessful', () => { @@ -237,6 +246,9 @@ describe('Manual Variables Form', () => { await createComponent({ props: { isRetryable: true }, handlers: { + getJobQueryResponseHandlerWithVariables: jest + .fn() + .mockResolvedValue(mockJobWithVariablesResponse), retryJobMutationHandler: jest.fn().mockResolvedValue(mockJobRetryMutationData), }, }); @@ -253,6 +265,15 @@ describe('Manual Variables Form', () => { expect(requestHandlers.retryJobMutationHandler).toHaveBeenCalledTimes(1); expect(redirectTo).toHaveBeenCalledWith(mockJobRetryMutationData.data.jobRetry.job.webPath); // eslint-disable-line import/no-deprecated }); + + it('does not refetch variables after job is rerun', async () => { + expect(requestHandlers.getJobQueryResponseHandlerWithVariables).toHaveBeenCalledTimes(1); + + findRunBtn().vm.$emit('click'); + await waitForPromises(); + + expect(requestHandlers.getJobQueryResponseHandlerWithVariables).toHaveBeenCalledTimes(1); + }); }); describe('when retry mutation is unsuccessful', () => { diff --git a/spec/frontend/ci/job_details/components/sidebar/job_container_item_spec.js b/spec/frontend/ci/job_details/components/sidebar/job_container_item_spec.js index 0eabaefd5de..697235dbe54 100644 --- a/spec/frontend/ci/job_details/components/sidebar/job_container_item_spec.js +++ b/spec/frontend/ci/job_details/components/sidebar/job_container_item_spec.js @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import delayedJobFixture from 'test_fixtures/jobs/delayed.json'; import JobContainerItem from '~/ci/job_details/components/sidebar/job_container_item.vue'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import job from 'jest/ci/jobs_mock_data'; describe('JobContainerItem', () => { diff --git a/spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js b/spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js index 37a2ca75df0..3b6cc85472b 100644 --- a/spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js +++ b/spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js @@ -3,7 +3,7 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import DetailRow from '~/ci/job_details/components/sidebar/sidebar_detail_row.vue'; import SidebarJobDetailsContainer from '~/ci/job_details/components/sidebar/sidebar_job_details_container.vue'; import createStore from '~/ci/job_details/store'; -import job from 'jest/ci/jobs_mock_data'; +import job, { testSummaryData, testSummaryDataWithFailures } from 'jest/ci/jobs_mock_data'; describe('Job Sidebar Details Container', () => { let store; @@ -12,6 +12,7 @@ describe('Job Sidebar Details Container', () => { const findJobTimeout = () => wrapper.findByTestId('job-timeout'); const findJobTags = () => wrapper.findByTestId('job-tags'); const findAllDetailsRow = () => wrapper.findAllComponents(DetailRow); + const findTestSummary = () => wrapper.findByTestId('test-summary'); const createWrapper = ({ props = {} } = {}) => { store = createStore(); @@ -22,6 +23,9 @@ describe('Job Sidebar Details Container', () => { stubs: { DetailRow, }, + provide: { + pipelineTestReportUrl: '/root/test-unit-test-reports/-/pipelines/512/test_report', + }, }), ); }; @@ -90,6 +94,37 @@ describe('Job Sidebar Details Container', () => { }); }); + describe('Test summary details', () => { + it('displays the test summary section', async () => { + createWrapper(); + + await store.dispatch('receiveJobSuccess', job); + await store.dispatch('receiveTestSummarySuccess', testSummaryData); + + expect(findTestSummary().exists()).toBe(true); + expect(findTestSummary().text()).toContain('Test summary'); + expect(findTestSummary().text()).toContain('1'); + }); + + it('does not display the test summary section', async () => { + createWrapper(); + + await store.dispatch('receiveJobSuccess', job); + + expect(findTestSummary().exists()).toBe(false); + }); + + it('displays the failure count message', async () => { + createWrapper(); + + await store.dispatch('receiveJobSuccess', job); + await store.dispatch('receiveTestSummarySuccess', testSummaryDataWithFailures); + + expect(findTestSummary().text()).toContain('Test summary'); + expect(findTestSummary().text()).toContain('1 of 2 failed'); + }); + }); + describe('timeout', () => { const { metadata: { timeout_human_readable, timeout_source }, diff --git a/spec/frontend/ci/job_details/components/sidebar/stages_dropdown_spec.js b/spec/frontend/ci/job_details/components/sidebar/stages_dropdown_spec.js index 54c5a73f757..a629c1c185a 100644 --- a/spec/frontend/ci/job_details/components/sidebar/stages_dropdown_spec.js +++ b/spec/frontend/ci/job_details/components/sidebar/stages_dropdown_spec.js @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import { Mousetrap } from '~/lib/mousetrap'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import StagesDropdown from '~/ci/job_details/components/sidebar/stages_dropdown.vue'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import * as copyToClipboard from '~/behaviors/copy_to_clipboard'; import { mockPipelineWithoutRef, diff --git a/spec/frontend/ci/job_details/job_app_spec.js b/spec/frontend/ci/job_details/job_app_spec.js index 2bd0429ef56..8601850a403 100644 --- a/spec/frontend/ci/job_details/job_app_spec.js +++ b/spec/frontend/ci/job_details/job_app_spec.js @@ -4,7 +4,6 @@ import Vuex from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { TEST_HOST } from 'helpers/test_constants'; import EmptyState from '~/ci/job_details/components/empty_state.vue'; import EnvironmentsBlock from '~/ci/job_details/components/environments_block.vue'; import ErasedBlock from '~/ci/job_details/components/erased_block.vue'; @@ -29,8 +28,9 @@ describe('Job App', () => { let mock; const initSettings = { - endpoint: `${TEST_HOST}jobs/123.json`, - pagePath: `${TEST_HOST}jobs/123`, + jobEndpoint: '/group1/project1/-/jobs/99.json', + logEndpoint: '/group1/project1/-/jobs/99/trace', + testReportSummaryUrl: '/group1/project1/-/jobs/99/test_report_summary.json', }; const props = { @@ -50,8 +50,8 @@ describe('Job App', () => { }; const setupAndMount = async ({ jobData = {}, jobLogData = {} } = {}) => { - mock.onGet(initSettings.endpoint).replyOnce(HTTP_STATUS_OK, { ...job, ...jobData }); - mock.onGet(`${initSettings.pagePath}/trace.json`).reply(HTTP_STATUS_OK, jobLogData); + mock.onGet(initSettings.jobEndpoint).replyOnce(HTTP_STATUS_OK, { ...job, ...jobData }); + mock.onGet(initSettings.logEndpoint).reply(HTTP_STATUS_OK, jobLogData); const asyncInit = store.dispatch('init', initSettings); diff --git a/spec/frontend/ci/job_details/store/actions_spec.js b/spec/frontend/ci/job_details/store/actions_spec.js index 849f55ac444..9c4b241b6eb 100644 --- a/spec/frontend/ci/job_details/store/actions_spec.js +++ b/spec/frontend/ci/job_details/store/actions_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; import { - setJobLogOptions, + init, clearEtagPoll, stopPolling, requestJob, @@ -15,7 +15,6 @@ import { fetchJobLog, startPollingJobLog, stopPollingJobLog, - receiveJobLogSuccess, receiveJobLogError, toggleCollapsibleLine, requestJobsForStage, @@ -25,11 +24,24 @@ import { hideSidebar, showSidebar, toggleSidebar, + receiveTestSummarySuccess, + requestTestSummary, + enterFullscreenSuccess, + exitFullscreenSuccess, + fullScreenContainerSetUpResult, } from '~/ci/job_details/store/actions'; +import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; + import * as types from '~/ci/job_details/store/mutation_types'; import state from '~/ci/job_details/store/state'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { testSummaryData } from 'jest/ci/jobs_mock_data'; + +jest.mock('~/lib/utils/scroll_utils'); + +const mockJobEndpoint = '/group1/project1/-/jobs/99.json'; +const mockLogEndpoint = '/group1/project1/-/jobs/99/trace'; describe('Job State actions', () => { let mockedState; @@ -38,22 +50,28 @@ describe('Job State actions', () => { mockedState = state(); }); - describe('setJobLogOptions', () => { + describe('init', () => { it('should commit SET_JOB_LOG_OPTIONS mutation', () => { return testAction( - setJobLogOptions, - { endpoint: '/group1/project1/-/jobs/99.json', pagePath: '/group1/project1/-/jobs/99' }, + init, + { + jobEndpoint: mockJobEndpoint, + logEndpoint: mockLogEndpoint, + testReportSummaryUrl: '/group1/project1/-/jobs/99/test_report_summary.json', + }, mockedState, [ { type: types.SET_JOB_LOG_OPTIONS, payload: { - endpoint: '/group1/project1/-/jobs/99.json', - pagePath: '/group1/project1/-/jobs/99', + fullScreenAPIAvailable: false, + jobEndpoint: mockJobEndpoint, + logEndpoint: mockLogEndpoint, + testReportSummaryUrl: '/group1/project1/-/jobs/99/test_report_summary.json', }, }, ], - [], + [{ type: 'fetchJob' }], ); }); }); @@ -96,7 +114,7 @@ describe('Job State actions', () => { let mock; beforeEach(() => { - mockedState.jobEndpoint = `${TEST_HOST}/endpoint.json`; + mockedState.jobEndpoint = mockJobEndpoint; mock = new MockAdapter(axios); }); @@ -108,9 +126,7 @@ describe('Job State actions', () => { describe('success', () => { it('dispatches requestJob and receiveJobSuccess', () => { - mock - .onGet(`${TEST_HOST}/endpoint.json`) - .replyOnce(HTTP_STATUS_OK, { id: 121212, name: 'karma' }); + mock.onGet(mockJobEndpoint).replyOnce(HTTP_STATUS_OK, { id: 121212, name: 'karma' }); return testAction( fetchJob, @@ -200,7 +216,7 @@ describe('Job State actions', () => { let mock; beforeEach(() => { - mockedState.jobLogEndpoint = `${TEST_HOST}/endpoint`; + mockedState.logEndpoint = mockLogEndpoint; mock = new MockAdapter(axios); }); @@ -211,46 +227,46 @@ describe('Job State actions', () => { }); describe('success', () => { - it('dispatches requestJobLog, receiveJobLogSuccess and stopPollingJobLog when job is complete', () => { - mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(HTTP_STATUS_OK, { - html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', - complete: true, + let jobLogPayload; + + beforeEach(() => { + isScrolledToBottom.mockReturnValue(false); + }); + + describe('when job is complete', () => { + beforeEach(() => { + jobLogPayload = { + html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', + complete: true, + }; + + mock.onGet(mockLogEndpoint).replyOnce(HTTP_STATUS_OK, jobLogPayload); }); - return testAction( - fetchJobLog, - null, - mockedState, - [], - [ - { - type: 'toggleScrollisInBottom', - payload: true, - }, - { - payload: { - html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', - complete: true, + it('commits RECEIVE_JOB_LOG_SUCCESS, dispatches stopPollingJobLog and requestTestSummary', () => { + return testAction( + fetchJobLog, + null, + mockedState, + [ + { + type: types.RECEIVE_JOB_LOG_SUCCESS, + payload: jobLogPayload, }, - type: 'receiveJobLogSuccess', - }, - { - type: 'stopPollingJobLog', - }, - ], - ); + ], + [{ type: 'stopPollingJobLog' }, { type: 'requestTestSummary' }], + ); + }); }); describe('when job is incomplete', () => { - let jobLogPayload; - beforeEach(() => { jobLogPayload = { html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', complete: false, }; - mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(HTTP_STATUS_OK, jobLogPayload); + mock.onGet(mockLogEndpoint).replyOnce(HTTP_STATUS_OK, jobLogPayload); }); it('dispatches startPollingJobLog', () => { @@ -258,12 +274,13 @@ describe('Job State actions', () => { fetchJobLog, null, mockedState, - [], [ - { type: 'toggleScrollisInBottom', payload: true }, - { type: 'receiveJobLogSuccess', payload: jobLogPayload }, - { type: 'startPollingJobLog' }, + { + type: types.RECEIVE_JOB_LOG_SUCCESS, + payload: jobLogPayload, + }, ], + [{ type: 'startPollingJobLog' }], ); }); @@ -274,10 +291,44 @@ describe('Job State actions', () => { fetchJobLog, null, mockedState, + [ + { + type: types.RECEIVE_JOB_LOG_SUCCESS, + payload: jobLogPayload, + }, + ], [], + ); + }); + }); + + describe('when user scrolled to the bottom', () => { + beforeEach(() => { + isScrolledToBottom.mockReturnValue(true); + + jobLogPayload = { + html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', + complete: true, + }; + + mock.onGet(mockLogEndpoint).replyOnce(HTTP_STATUS_OK, jobLogPayload); + }); + + it('should auto scroll to bottom by dispatching scrollBottom', () => { + return testAction( + fetchJobLog, + null, + mockedState, + [ + { + type: types.RECEIVE_JOB_LOG_SUCCESS, + payload: jobLogPayload, + }, + ], [ - { type: 'toggleScrollisInBottom', payload: true }, - { type: 'receiveJobLogSuccess', payload: jobLogPayload }, + { type: 'stopPollingJobLog' }, + { type: 'requestTestSummary' }, + { type: 'scrollBottom' }, ], ); }); @@ -286,7 +337,7 @@ describe('Job State actions', () => { describe('server error', () => { beforeEach(() => { - mock.onGet(`${TEST_HOST}/endpoint/trace.json`).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + mock.onGet(mockLogEndpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); }); it('dispatches requestJobLog and receiveJobLogError', () => { @@ -306,7 +357,7 @@ describe('Job State actions', () => { describe('unexpected error', () => { beforeEach(() => { - mock.onGet(`${TEST_HOST}/endpoint/trace.json`).reply(() => { + mock.onGet(mockLogEndpoint).reply(() => { throw new Error('an error'); }); }); @@ -389,18 +440,6 @@ describe('Job State actions', () => { }); }); - describe('receiveJobLogSuccess', () => { - it('should commit RECEIVE_JOB_LOG_SUCCESS mutation', () => { - return testAction( - receiveJobLogSuccess, - 'hello world', - mockedState, - [{ type: types.RECEIVE_JOB_LOG_SUCCESS, payload: 'hello world' }], - [], - ); - }); - }); - describe('receiveJobLogError', () => { it('should commit stop polling job log', () => { return testAction(receiveJobLogError, null, mockedState, [], [{ type: 'stopPollingJobLog' }]); @@ -516,4 +555,95 @@ describe('Job State actions', () => { ); }); }); + + describe('requestTestSummarySuccess', () => { + it('should commit RECEIVE_TEST_SUMMARY_SUCCESS mutation', () => { + return testAction( + receiveTestSummarySuccess, + { total: {}, test_suites: [] }, + mockedState, + [{ type: types.RECEIVE_TEST_SUMMARY_SUCCESS, payload: { total: {}, test_suites: [] } }], + [], + ); + }); + }); + + describe('requestTestSummary', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + stopPolling(); + clearEtagPoll(); + }); + + describe('success', () => { + it('dispatches receiveTestSummarySuccess', () => { + mockedState.testReportSummaryUrl = `${TEST_HOST}/test_report_summary.json`; + + mock + .onGet(`${TEST_HOST}/test_report_summary.json`) + .replyOnce(HTTP_STATUS_OK, testSummaryData); + + return testAction( + requestTestSummary, + null, + mockedState, + [{ type: types.RECEIVE_TEST_SUMMARY_COMPLETE }], + [ + { + payload: testSummaryData, + type: 'receiveTestSummarySuccess', + }, + ], + ); + }); + }); + + describe('without testReportSummaryUrl', () => { + it('does not dispatch any actions or mutations', () => { + return testAction(requestTestSummary, null, mockedState, [], []); + }); + }); + }); + + describe('enterFullscreenSuccess', () => { + it('should commit ENTER_FULLSCREEN_SUCCESS mutation', () => { + return testAction( + enterFullscreenSuccess, + {}, + mockedState, + [{ type: types.ENTER_FULLSCREEN_SUCCESS }], + [], + ); + }); + }); + + describe('exitFullscreenSuccess', () => { + it('should commit EXIT_FULLSCREEN_SUCCESS mutation', () => { + return testAction( + exitFullscreenSuccess, + {}, + mockedState, + [{ type: types.EXIT_FULLSCREEN_SUCCESS }], + [], + ); + }); + }); + + describe('fullScreenContainerSetUpResult', () => { + it('should commit FULL_SCREEN_CONTAINER_SET_UP mutation', () => { + return testAction( + fullScreenContainerSetUpResult, + {}, + mockedState, + [{ type: types.FULL_SCREEN_CONTAINER_SET_UP, payload: {} }], + [], + ); + }); + }); }); diff --git a/spec/frontend/ci/job_details/store/mutations_spec.js b/spec/frontend/ci/job_details/store/mutations_spec.js index 601dff47584..d42e4c40107 100644 --- a/spec/frontend/ci/job_details/store/mutations_spec.js +++ b/spec/frontend/ci/job_details/store/mutations_spec.js @@ -16,13 +16,15 @@ describe('Jobs Store Mutations', () => { describe('SET_JOB_LOG_OPTIONS', () => { it('should set jobEndpoint', () => { mutations[types.SET_JOB_LOG_OPTIONS](stateCopy, { - endpoint: '/group1/project1/-/jobs/99.json', - pagePath: '/group1/project1/-/jobs/99', + jobEndpoint: '/group1/project1/-/jobs/99.json', + logEndpoint: '/group1/project1/-/jobs/99/trace', + testReportSummaryUrl: '/group1/project1/-/jobs/99/test_report_summary.json', }); expect(stateCopy).toMatchObject({ - jobLogEndpoint: '/group1/project1/-/jobs/99', jobEndpoint: '/group1/project1/-/jobs/99.json', + logEndpoint: '/group1/project1/-/jobs/99/trace', + testReportSummaryUrl: '/group1/project1/-/jobs/99/test_report_summary.json', }); }); }); @@ -113,7 +115,7 @@ describe('Jobs Store Mutations', () => { it('sets the parsed log', () => { mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, mockLog); - expect(utils.logLinesParser).toHaveBeenCalledWith(mockLog.lines, [], ''); + expect(utils.logLinesParser).toHaveBeenCalledWith(mockLog.lines, {}, ''); expect(stateCopy.jobLog).toEqual([ { @@ -133,7 +135,7 @@ describe('Jobs Store Mutations', () => { it('sets the parsed log', () => { mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, mockLog); - expect(utils.logLinesParser).toHaveBeenCalledWith(mockLog.lines, [], '#L1'); + expect(utils.logLinesParser).toHaveBeenCalledWith(mockLog.lines, {}, '#L1'); expect(stateCopy.jobLog).toEqual([ { @@ -214,9 +216,17 @@ describe('Jobs Store Mutations', () => { describe('TOGGLE_COLLAPSIBLE_LINE', () => { it('toggles the `isClosed` property of the provided object', () => { - const section = { isClosed: true }; - mutations[types.TOGGLE_COLLAPSIBLE_LINE](stateCopy, section); - expect(section.isClosed).toEqual(false); + stateCopy.jobLogSections = { + 'step-script': { isClosed: true }, + }; + + mutations[types.TOGGLE_COLLAPSIBLE_LINE](stateCopy, 'step-script'); + + expect(stateCopy.jobLogSections['step-script'].isClosed).toEqual(false); + + mutations[types.TOGGLE_COLLAPSIBLE_LINE](stateCopy, 'step-script'); + + expect(stateCopy.jobLogSections['step-script'].isClosed).toEqual(true); }); }); @@ -314,4 +324,34 @@ describe('Jobs Store Mutations', () => { expect(stateCopy.jobs).toEqual([]); }); }); + + describe('ENTER_FULLSCREEN_SUCCESS', () => { + beforeEach(() => { + mutations[types.ENTER_FULLSCREEN_SUCCESS](stateCopy); + }); + + it('sets fullScreenEnabled to true', () => { + expect(stateCopy.fullScreenEnabled).toEqual(true); + }); + }); + + describe('EXIT_FULLSCREEN_SUCCESS', () => { + beforeEach(() => { + mutations[types.EXIT_FULLSCREEN_SUCCESS](stateCopy); + }); + + it('sets fullScreenEnabled to false', () => { + expect(stateCopy.fullScreenEnabled).toEqual(false); + }); + }); + + describe('FULL_SCREEN_CONTAINER_SET_UP', () => { + beforeEach(() => { + mutations[types.FULL_SCREEN_CONTAINER_SET_UP](stateCopy, true); + }); + + it('sets fullScreenEnabled to true', () => { + expect(stateCopy.fullScreenContainerSetUp).toEqual(true); + }); + }); }); diff --git a/spec/frontend/ci/job_details/store/utils_spec.js b/spec/frontend/ci/job_details/store/utils_spec.js index 8fc4eeb0ca8..6105c53a306 100644 --- a/spec/frontend/ci/job_details/store/utils_spec.js +++ b/spec/frontend/ci/job_details/store/utils_spec.js @@ -1,524 +1,305 @@ +import { logLinesParser } from '~/ci/job_details/store/utils'; + import { - logLinesParser, - updateIncrementalJobLog, - parseHeaderLine, - parseLine, - addDurationToHeader, - isCollapsibleSection, - findOffsetAndRemove, - getNextLineNumber, -} from '~/ci/job_details/store/utils'; -import { - mockJobLog, - originalTrace, - regularIncremental, - regularIncrementalRepeated, - headerTrace, - headerTraceIncremental, - collapsibleTrace, - collapsibleTraceIncremental, + mockJobLines, + mockEmptySection, + mockContentSection, + mockContentSectionClosed, + mockContentSectionHiddenDuration, + mockContentSubsection, + mockTruncatedBottomSection, + mockTruncatedTopSection, } from '../components/log/mock_data'; describe('Jobs Store Utils', () => { - describe('parseHeaderLine', () => { - it('returns a new object with the header keys and the provided line parsed', () => { - const headerLine = { content: [{ text: 'foo' }] }; - const parsedHeaderLine = parseHeaderLine(headerLine, 2); + describe('logLinesParser', () => { + it('parses plain lines', () => { + const result = logLinesParser(mockJobLines); - expect(parsedHeaderLine).toEqual({ - isClosed: false, - isHeader: true, - line: { - ...headerLine, - lineNumber: 2, - }, - lines: [], + expect(result).toEqual({ + lines: [ + { + offset: 0, + content: [ + { + text: 'Running with gitlab-runner 12.1.0 (de7731dd)', + style: 'term-fg-l-cyan term-bold', + }, + ], + lineNumber: 1, + }, + { + offset: 1001, + content: [{ text: ' on docker-auto-scale-com 8a6210b8' }], + lineNumber: 2, + }, + ], + sections: {}, }); }); - it('pre-closes a section when specified in options', () => { - const headerLine = { content: [{ text: 'foo' }], section_options: { collapsed: 'true' } }; - - const parsedHeaderLine = parseHeaderLine(headerLine, 2); - - expect(parsedHeaderLine.isClosed).toBe(true); - }); - - it('expands all pre-closed sections if hash is present', () => { - const headerLine = { content: [{ text: 'foo' }], section_options: { collapsed: 'true' } }; - - const parsedHeaderLine = parseHeaderLine(headerLine, 2, '#L33'); - - expect(parsedHeaderLine.isClosed).toBe(false); - }); - }); - - describe('parseLine', () => { - it('returns a new object with the lineNumber key added to the provided line object', () => { - const line = { content: [{ text: 'foo' }] }; - const parsed = parseLine(line, 1); - expect(parsed.content).toEqual(line.content); - expect(parsed.lineNumber).toEqual(1); - }); - }); + it('parses an empty section', () => { + const result = logLinesParser(mockEmptySection); - describe('addDurationToHeader', () => { - const duration = { - offset: 106, - content: [], - section: 'prepare-script', - section_duration: '00:03', - }; - - it('adds the section duration to the correct header', () => { - const parsed = [ - { - isClosed: false, - isHeader: true, - line: { - section: 'prepare-script', - content: [{ text: 'foo' }], + expect(result).toEqual({ + lines: [ + { + offset: 1002, + content: [ + { + text: 'Resolving secrets', + style: 'term-fg-l-cyan term-bold', + }, + ], + lineNumber: 1, + section: 'resolve-secrets', + isHeader: true, }, - lines: [], - }, - { - isClosed: false, - isHeader: true, - line: { - section: 'foo-bar', - content: [{ text: 'foo' }], + ], + sections: { + 'resolve-secrets': { + startLineNumber: 1, + endLineNumber: 1, + duration: '00:00', + isClosed: false, }, - lines: [], }, - ]; - - addDurationToHeader(parsed, duration); - - expect(parsed[0].line.section_duration).toEqual(duration.section_duration); - expect(parsed[1].line.section_duration).toEqual(undefined); + }); }); - it('does not add the section duration when the headers do not match', () => { - const parsed = [ - { - isClosed: false, - isHeader: true, - line: { - section: 'bar-foo', - content: [{ text: 'foo' }], + it('parses a section with content', () => { + const result = logLinesParser(mockContentSection); + + expect(result).toEqual({ + lines: [ + { + content: [{ text: 'Using Docker executor with image dev.gitlab.org3' }], + isHeader: true, + lineNumber: 1, + offset: 1004, + section: 'prepare-executor', }, - lines: [], - }, - { - isClosed: false, - isHeader: true, - line: { - section: 'foo-bar', - content: [{ text: 'foo' }], + { + content: [{ text: 'Docker executor with image registry.gitlab.com ...' }], + lineNumber: 2, + offset: 1005, + section: 'prepare-executor', + }, + { + content: [{ style: 'term-fg-l-green', text: 'Starting service ...' }], + lineNumber: 3, + offset: 1006, + section: 'prepare-executor', + }, + ], + sections: { + 'prepare-executor': { + startLineNumber: 1, + endLineNumber: 3, + duration: '00:09', + isClosed: false, }, - lines: [], - }, - ]; - addDurationToHeader(parsed, duration); - - expect(parsed[0].line.section_duration).toEqual(undefined); - expect(parsed[1].line.section_duration).toEqual(undefined); - }); - - it('does not add when content has no headers', () => { - const parsed = [ - { - section: 'bar-foo', - content: [{ text: 'foo' }], - lineNumber: 1, - }, - { - section: 'foo-bar', - content: [{ text: 'foo' }], - lineNumber: 2, }, - ]; - - addDurationToHeader(parsed, duration); - - expect(parsed[0].line).toEqual(undefined); - expect(parsed[1].line).toEqual(undefined); - }); - }); - - describe('isCollapsibleSection', () => { - const header = { - isHeader: true, - line: { - section: 'foo', - }, - }; - const line = { - lineNumber: 1, - section: 'foo', - content: [], - }; - - it('returns true when line belongs to the last section', () => { - expect(isCollapsibleSection([header], header, { section: 'foo', content: [] })).toEqual(true); - }); - - it('returns false when last line was not an header', () => { - expect(isCollapsibleSection([line], line, { section: 'bar' })).toEqual(false); - }); - - it('returns false when accumulator is empty', () => { - expect(isCollapsibleSection([], { isHeader: true }, { section: 'bar' })).toEqual(false); - }); - - it('returns false when section_duration is defined', () => { - expect(isCollapsibleSection([header], header, { section_duration: '10:00' })).toEqual(false); - }); - - it('returns false when `section` is not a match', () => { - expect(isCollapsibleSection([header], header, { section: 'bar' })).toEqual(false); - }); - - it('returns false when no parameters are provided', () => { - expect(isCollapsibleSection()).toEqual(false); - }); - }); - describe('logLinesParser', () => { - let result; - - beforeEach(() => { - result = logLinesParser(mockJobLog); - }); - - describe('regular line', () => { - it('adds a lineNumber property with correct index', () => { - expect(result[0].lineNumber).toEqual(1); - expect(result[1].lineNumber).toEqual(2); - expect(result[2].line.lineNumber).toEqual(3); - expect(result[3].line.lineNumber).toEqual(4); - expect(result[3].lines[0].lineNumber).toEqual(5); - expect(result[3].lines[1].lineNumber).toEqual(6); }); }); - describe('collapsible section', () => { - it('adds a `isClosed` property', () => { - expect(result[2].isClosed).toEqual(false); - expect(result[3].isClosed).toEqual(false); - }); - - it('adds a `isHeader` property', () => { - expect(result[2].isHeader).toEqual(true); - expect(result[3].isHeader).toEqual(true); - }); + it('parses a closed section with content', () => { + const result = logLinesParser(mockContentSectionClosed); - it('creates a lines array property with the content of the collapsible section', () => { - expect(result[3].lines.length).toEqual(2); - expect(result[3].lines[0].content).toEqual(mockJobLog[5].content); - expect(result[3].lines[1].content).toEqual(mockJobLog[6].content); + expect(result.sections['mock-closed-section']).toMatchObject({ + isClosed: true, }); }); - describe('section duration', () => { - it('adds the section information to the header section', () => { - expect(result[2].line.section_duration).toEqual(mockJobLog[3].section_duration); - expect(result[3].line.section_duration).toEqual(mockJobLog[7].section_duration); - }); - - it('does not add section duration as a line', () => { - expect(result[2].lines.includes(mockJobLog[5])).toEqual(false); - expect(result[3].lines.includes(mockJobLog[9])).toEqual(false); - }); - }); - }); - - describe('findOffsetAndRemove', () => { - describe('when last item is header', () => { - const existingLog = [ - { - isHeader: true, - isClosed: false, - line: { content: [{ text: 'bar' }], offset: 10, lineNumber: 1 }, - }, - ]; - - describe('and matches the offset', () => { - it('returns an array with the item removed', () => { - const newData = [{ offset: 10, content: [{ text: 'foobar' }] }]; - const result = findOffsetAndRemove(newData, existingLog); - - expect(result).toEqual([]); - }); - }); + it('parses a closed section as open when hash is present', () => { + const result = logLinesParser(mockContentSectionClosed, {}, '#L1'); - describe('and does not match the offset', () => { - it('returns the provided existing log', () => { - const newData = [{ offset: 110, content: [{ text: 'foobar' }] }]; - const result = findOffsetAndRemove(newData, existingLog); - - expect(result).toEqual(existingLog); - }); - }); - }); - - describe('when last item is a regular line', () => { - const existingLog = [{ content: [{ text: 'bar' }], offset: 10, lineNumber: 1 }]; - - describe('and matches the offset', () => { - it('returns an array with the item removed', () => { - const newData = [{ offset: 10, content: [{ text: 'foobar' }] }]; - const result = findOffsetAndRemove(newData, existingLog); - - expect(result).toEqual([]); - }); - }); - - describe('and does not match the fofset', () => { - it('returns the provided old log', () => { - const newData = [{ offset: 101, content: [{ text: 'foobar' }] }]; - const result = findOffsetAndRemove(newData, existingLog); - - expect(result).toEqual(existingLog); - }); + expect(result.sections['mock-closed-section']).toMatchObject({ + isClosed: false, }); }); - describe('when last item is nested', () => { - const existingLog = [ - { - isHeader: true, - isClosed: false, - lines: [{ offset: 101, content: [{ text: 'foobar' }], lineNumber: 2 }], - line: { - offset: 10, - lineNumber: 1, - section_duration: '10:00', - }, - }, - ]; - - describe('and matches the offset', () => { - it('returns an array with the last nested line item removed', () => { - const newData = [{ offset: 101, content: [{ text: 'foobar' }] }]; + it('parses a section with a hidden duration', () => { + const result = logLinesParser(mockContentSectionHiddenDuration); - const result = findOffsetAndRemove(newData, existingLog); - expect(result[0].lines).toEqual([]); - }); - }); - - describe('and does not match the offset', () => { - it('returns the provided old log', () => { - const newData = [{ offset: 120, content: [{ text: 'foobar' }] }]; - - const result = findOffsetAndRemove(newData, existingLog); - expect(result).toEqual(existingLog); - }); + expect(result.sections['mock-hidden-duration-section']).toMatchObject({ + hideDuration: true, + duration: '00:09', }); }); - describe('when no data is provided', () => { - it('returns an empty array', () => { - const result = findOffsetAndRemove(); - expect(result).toEqual([]); - }); - }); - }); - - describe('getNextLineNumber', () => { - describe('when there is no previous log', () => { - it('returns 1', () => { - expect(getNextLineNumber([])).toEqual(1); - expect(getNextLineNumber(undefined)).toEqual(1); - }); - }); + it('parses a section with a sub section', () => { + const result = logLinesParser(mockContentSubsection); - describe('when last line is 1', () => { - it('returns 1', () => { - const log = [ + expect(result).toEqual({ + lines: [ { - content: [], + offset: 0, + content: [{ text: 'Line 1' }], lineNumber: 1, + section: 'mock-section', + isHeader: true, }, - ]; - - expect(getNextLineNumber(log)).toEqual(2); - }); - }); - - describe('with unnested line', () => { - it('returns the lineNumber of the last item in the array', () => { - const log = [ { - content: [], - lineNumber: 10, + offset: 1002, + content: [{ text: 'Line 2 - section content' }], + lineNumber: 2, + section: 'mock-section', }, { - content: [], - lineNumber: 101, + offset: 1003, + content: [{ text: 'Line 3 - sub section header' }], + lineNumber: 3, + section: 'sub-section', + isHeader: true, }, - ]; - - expect(getNextLineNumber(log)).toEqual(102); - }); - }); - - describe('when last line is the header section', () => { - it('returns the lineNumber of the last item in the array', () => { - const log = [ { - content: [], - lineNumber: 10, + offset: 1004, + content: [{ text: 'Line 4 - sub section content' }], + lineNumber: 4, + section: 'sub-section', }, { + offset: 1005, + content: [{ text: 'Line 5 - sub sub section header with no content' }], + lineNumber: 5, + section: 'sub-sub-section', isHeader: true, - line: { - lineNumber: 101, - content: [], - }, - lines: [], }, - ]; - - expect(getNextLineNumber(log)).toEqual(102); - }); - }); - - describe('when last line is a nested line', () => { - it('returns the lineNumber of the last item in the nested array', () => { - const log = [ { - content: [], - lineNumber: 10, + offset: 1007, + content: [{ text: 'Line 6 - sub section content 2' }], + lineNumber: 6, + section: 'sub-section', }, { - isHeader: true, - line: { - lineNumber: 101, - content: [], - }, - lines: [ - { - lineNumber: 102, - content: [], - }, - { lineNumber: 103, content: [] }, - ], + offset: 1009, + content: [{ text: 'Line 7 - section content' }], + lineNumber: 7, + section: 'mock-section', + }, + { + offset: 1011, + content: [{ text: 'Job succeeded' }], + lineNumber: 8, + }, + ], + sections: { + 'mock-section': { + startLineNumber: 1, + endLineNumber: 7, + duration: '00:59', + isClosed: false, }, - ]; + 'sub-section': { + startLineNumber: 3, + endLineNumber: 6, + duration: '00:29', + isClosed: false, + }, + 'sub-sub-section': { + startLineNumber: 5, + endLineNumber: 5, + duration: '00:00', + isClosed: false, + }, + }, + }); + }); - expect(getNextLineNumber(log)).toEqual(104); + it('parsing repeated lines returns the same result', () => { + const result1 = logLinesParser(mockJobLines); + const result2 = logLinesParser(mockJobLines, { + currentLines: result1.lines, + currentSections: result1.sections, }); + + // `toBe` is used to ensure objects do not change and trigger Vue reactivity + expect(result1.lines).toBe(result2.lines); + expect(result1.sections).toBe(result2.sections); }); - }); - describe('updateIncrementalJobLog', () => { - describe('without repeated section', () => { - it('concats and parses both arrays', () => { - const oldLog = logLinesParser(originalTrace); - const result = updateIncrementalJobLog(regularIncremental, oldLog); + it('discards repeated lines and adds new ones', () => { + const result1 = logLinesParser(mockContentSection); + const result2 = logLinesParser( + [ + ...mockContentSection, + { + content: [{ text: 'offset is too low, is ignored' }], + offset: 500, + }, + { + content: [{ text: 'one new line' }], + offset: 1007, + }, + ], + { + currentLines: result1.lines, + currentSections: result1.sections, + }, + ); - expect(result).toEqual([ + expect(result2).toEqual({ + lines: [ { - offset: 1, - content: [ - { - text: 'Downloading', - }, - ], + content: [{ text: 'Using Docker executor with image dev.gitlab.org3' }], + isHeader: true, lineNumber: 1, + offset: 1004, + section: 'prepare-executor', }, { - offset: 2, - content: [ - { - text: 'log line', - }, - ], + content: [{ text: 'Docker executor with image registry.gitlab.com ...' }], lineNumber: 2, + offset: 1005, + section: 'prepare-executor', }, - ]); - }); - }); - - describe('with regular line repeated offset', () => { - it('updates the last line and formats with the incremental part', () => { - const oldLog = logLinesParser(originalTrace); - const result = updateIncrementalJobLog(regularIncrementalRepeated, oldLog); - - expect(result).toEqual([ { - offset: 1, - content: [ - { - text: 'log line', - }, - ], - lineNumber: 1, + content: [{ style: 'term-fg-l-green', text: 'Starting service ...' }], + lineNumber: 3, + offset: 1006, + section: 'prepare-executor', + }, + { + content: [{ text: 'one new line' }], + lineNumber: 4, + offset: 1007, }, - ]); + ], + sections: { + 'prepare-executor': { + startLineNumber: 1, + endLineNumber: 3, + duration: '00:09', + isClosed: false, + }, + }, }); }); - describe('with header line repeated', () => { - it('updates the header line and formats with the incremental part', () => { - const oldLog = logLinesParser(headerTrace); - const result = updateIncrementalJobLog(headerTraceIncremental, oldLog); + it('parses an interrupted job', () => { + const result = logLinesParser(mockTruncatedBottomSection); - expect(result).toEqual([ - { - isClosed: false, - isHeader: true, - line: { - offset: 1, - section_header: true, - content: [ - { - text: 'updated log line', - }, - ], - section: 'section', - lineNumber: 1, - }, - lines: [], - }, - ]); + expect(result.sections).toEqual({ + 'mock-section': { + startLineNumber: 1, + endLineNumber: Infinity, + duration: null, + isClosed: false, + }, }); }); - describe('with collapsible line repeated', () => { - it('updates the collapsible line and formats with the incremental part', () => { - const oldLog = logLinesParser(collapsibleTrace); - const result = updateIncrementalJobLog(collapsibleTraceIncremental, oldLog); + it('parses the ending of an incomplete section', () => { + const result = logLinesParser(mockTruncatedTopSection); - expect(result).toEqual([ - { - isClosed: false, - isHeader: true, - line: { - offset: 1, - section_header: true, - content: [ - { - text: 'log line', - }, - ], - section: 'section', - lineNumber: 1, - }, - lines: [ - { - offset: 2, - content: [ - { - text: 'updated log line', - }, - ], - section: 'section', - lineNumber: 2, - }, - ], - }, - ]); + expect(result.sections).toEqual({ + 'mock-section': { + startLineNumber: 0, + endLineNumber: 2, + duration: '00:59', + isClosed: false, + }, }); }); }); diff --git a/spec/frontend/ci/jobs_mock_data.js b/spec/frontend/ci/jobs_mock_data.js index c428de3b9d8..12833524fd9 100644 --- a/spec/frontend/ci/jobs_mock_data.js +++ b/spec/frontend/ci/jobs_mock_data.js @@ -1627,3 +1627,53 @@ export const mockJobLog = [ lineNumber: 23, }, ]; + +export const testSummaryData = { + total: { + time: 0.001, + count: 1, + success: 1, + failed: 0, + skipped: 0, + error: 0, + suite_error: null, + }, + test_suites: [ + { + name: 'javascript', + total_time: 0.001, + total_count: 1, + success_count: 1, + failed_count: 0, + skipped_count: 0, + error_count: 0, + build_ids: [3633], + suite_error: null, + }, + ], +}; + +export const testSummaryDataWithFailures = { + total: { + time: 0.001, + count: 2, + success: 1, + failed: 1, + skipped: 0, + error: 0, + suite_error: null, + }, + test_suites: [ + { + name: 'javascript', + total_time: 0.001, + total_count: 2, + success_count: 1, + failed_count: 1, + skipped_count: 0, + error_count: 0, + build_ids: [3633], + suite_error: null, + }, + ], +}; diff --git a/spec/frontend/ci/jobs_page/components/job_cells/actions_cell_spec.js b/spec/frontend/ci/jobs_page/components/job_cells/actions_cell_spec.js index 1ffd680118e..7af333543b8 100644 --- a/spec/frontend/ci/jobs_page/components/job_cells/actions_cell_spec.js +++ b/spec/frontend/ci/jobs_page/components/job_cells/actions_cell_spec.js @@ -43,6 +43,7 @@ describe('Job actions cell', () => { const cannotPlayJob = findMockJob('playable', mockJobsNodesAsGuest); const cannotRetryJob = findMockJob('retryable', mockJobsNodesAsGuest); const cannotPlayScheduledJob = findMockJob('scheduled', mockJobsNodesAsGuest); + const cannotCancelJob = findMockJob('cancelable', mockJobsNodesAsGuest); const findRetryButton = () => wrapper.findByTestId('retry'); const findPlayButton = () => wrapper.findByTestId('play'); @@ -99,6 +100,7 @@ describe('Job actions cell', () => { ${findPlayButton} | ${'play'} | ${cannotPlayJob} ${findRetryButton} | ${'retry'} | ${cannotRetryJob} ${findPlayScheduledJobButton} | ${'play scheduled'} | ${cannotPlayScheduledJob} + ${findCancelButton} | ${'cancel'} | ${cannotCancelJob} `('does not display the $action button if user cannot update build', ({ button, jobType }) => { createComponent(jobType); diff --git a/spec/frontend/ci/jobs_page/components/jobs_table_spec.js b/spec/frontend/ci/jobs_page/components/jobs_table_spec.js index d14afe7dd3e..a865b7a0c0c 100644 --- a/spec/frontend/ci/jobs_page/components/jobs_table_spec.js +++ b/spec/frontend/ci/jobs_page/components/jobs_table_spec.js @@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import JobsTable from '~/ci/jobs_page/components/jobs_table.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import { DEFAULT_FIELDS_ADMIN } from '~/ci/admin/jobs_table/constants'; import ProjectCell from '~/ci/admin/jobs_table/components/cells/project_cell.vue'; import RunnerCell from '~/ci/admin/jobs_table/components/cells/runner_cell.vue'; diff --git a/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js b/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js index 10db7f398fe..432775d469c 100644 --- a/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js +++ b/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js @@ -5,7 +5,7 @@ import JobItem from '~/ci/pipeline_details/graph/components/job_item.vue'; import axios from '~/lib/utils/axios_utils'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import ActionComponent from '~/ci/common/private/job_action_component.vue'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { diff --git a/spec/frontend/ci/pipeline_details/graph/components/job_name_component_spec.js b/spec/frontend/ci/pipeline_details/graph/components/job_name_component_spec.js index 1da85ad9f78..b84ca77081a 100644 --- a/spec/frontend/ci/pipeline_details/graph/components/job_name_component_spec.js +++ b/spec/frontend/ci/pipeline_details/graph/components/job_name_component_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; import jobNameComponent from '~/ci/common/private/job_name_component.vue'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; describe('job name component', () => { let wrapper; diff --git a/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js b/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js index 72be51575d7..e6f89910a97 100644 --- a/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js +++ b/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js @@ -10,7 +10,7 @@ import { ACTION_FAILURE, UPSTREAM, DOWNSTREAM } from '~/ci/pipeline_details/grap import LinkedPipelineComponent from '~/ci/pipeline_details/graph/components/linked_pipeline.vue'; import CancelPipelineMutation from '~/ci/pipeline_details/graphql/mutations/cancel_pipeline.mutation.graphql'; import RetryPipelineMutation from '~/ci/pipeline_details/graphql/mutations/retry_pipeline.mutation.graphql'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import mockPipeline from './linked_pipelines_mock_data'; describe('Linked pipeline', () => { diff --git a/spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js b/spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js index e8e178ed148..86b8c416a07 100644 --- a/spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js +++ b/spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js @@ -7,7 +7,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import PipelineDetailsHeader from '~/ci/pipeline_details/header/pipeline_details_header.vue'; import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '~/ci/constants'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import cancelPipelineMutation from '~/ci/pipeline_details/graphql/mutations/cancel_pipeline.mutation.graphql'; import deletePipelineMutation from '~/ci/pipeline_details/graphql/mutations/delete_pipeline.mutation.graphql'; import retryPipelineMutation from '~/ci/pipeline_details/graphql/mutations/retry_pipeline.mutation.graphql'; @@ -15,6 +15,7 @@ import getPipelineDetailsQuery from '~/ci/pipeline_details/header/graphql/querie import { pipelineHeaderSuccess, pipelineHeaderRunning, + pipelineHeaderRunningNoPermissions, pipelineHeaderRunningWithDuration, pipelineHeaderFailed, pipelineRetryMutationResponseSuccess, @@ -33,6 +34,9 @@ describe('Pipeline details header', () => { const successHandler = jest.fn().mockResolvedValue(pipelineHeaderSuccess); const runningHandler = jest.fn().mockResolvedValue(pipelineHeaderRunning); + const runningHandlerNoPermissions = jest + .fn() + .mockResolvedValue(pipelineHeaderRunningNoPermissions); const runningHandlerWithDuration = jest.fn().mockResolvedValue(pipelineHeaderRunningWithDuration); const failedHandler = jest.fn().mockResolvedValue(pipelineHeaderFailed); @@ -65,7 +69,6 @@ describe('Pipeline details header', () => { const findPipelineName = () => wrapper.findByTestId('pipeline-name'); const findCommitTitle = () => wrapper.findByTestId('pipeline-commit-title'); const findTotalJobs = () => wrapper.findByTestId('total-jobs'); - const findComputeMinutes = () => wrapper.findByTestId('compute-minutes'); const findCommitLink = () => wrapper.findByTestId('commit-link'); const findPipelineRunningText = () => wrapper.findByTestId('pipeline-running-text').text(); const findPipelineRefText = () => wrapper.findByTestId('pipeline-ref-text').text(); @@ -82,31 +85,12 @@ describe('Pipeline details header', () => { paths: { pipelinesPath: '/namespace/my-project/-/pipelines', fullProject: '/namespace/my-project', - triggeredByPath: '', }, }; const defaultProps = { - name: 'Ruby 3.0 master branch pipeline', - totalJobs: '50', - computeMinutes: '0.65', - yamlErrors: 'errors', - failureReason: 'pipeline failed', - badges: { - schedule: true, - trigger: false, - child: false, - latest: true, - mergeTrainPipeline: false, - mergedResultsPipeline: 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>', + yamlErrors: '', + trigger: false, }; const createMockApolloProvider = (handlers) => { @@ -159,11 +143,11 @@ describe('Pipeline details header', () => { }); it('displays pipeline name', () => { - expect(findPipelineName().text()).toBe(defaultProps.name); + expect(findPipelineName().text()).toBe('Build pipeline'); }); it('displays total jobs', () => { - expect(findTotalJobs().text()).toBe('50 Jobs'); + expect(findTotalJobs().text()).toBe('3 Jobs'); }); it('has link to commit', () => { @@ -178,13 +162,13 @@ describe('Pipeline details header', () => { it('displays correct badges', () => { expect(findAllBadges()).toHaveLength(2); - expect(wrapper.findByText('latest').exists()).toBe(true); + expect(wrapper.findByText('merged results').exists()).toBe(true); expect(wrapper.findByText('Scheduled').exists()).toBe(true); expect(wrapper.findByText('trigger token').exists()).toBe(false); }); it('displays ref text', () => { - expect(findPipelineRefText()).toBe('Related merge request !1 to merge test'); + expect(findPipelineRefText()).toBe('Related merge request !1 to merge master into feature'); }); it('displays pipeline user link with required user popover attributes', () => { @@ -209,7 +193,7 @@ describe('Pipeline details header', () => { beforeEach(async () => { createComponent(defaultHandlers, { ...defaultProps, - badges: { ...defaultProps.badges, trigger: true }, + trigger: true, }); await waitForPromises(); @@ -222,7 +206,7 @@ describe('Pipeline details header', () => { describe('without pipeline name', () => { it('displays commit title', async () => { - createComponent(defaultHandlers, { ...defaultProps, name: '' }); + createComponent([[getPipelineDetailsQuery, runningHandler]]); await waitForPromises(); @@ -234,22 +218,6 @@ describe('Pipeline details header', () => { }); describe('finished pipeline', () => { - it('displays compute minutes when not zero', async () => { - createComponent(); - - await waitForPromises(); - - expect(findComputeMinutes().text()).toBe('0.65'); - }); - - it('does not display compute minutes when zero', async () => { - createComponent(defaultHandlers, { ...defaultProps, computeMinutes: '0.0' }); - - await waitForPromises(); - - expect(findComputeMinutes().exists()).toBe(false); - }); - it('does not display created time ago', async () => { createComponent(); @@ -284,10 +252,6 @@ describe('Pipeline details header', () => { await waitForPromises(); }); - it('does not display compute minutes', () => { - expect(findComputeMinutes().exists()).toBe(false); - }); - it('does not display finished time ago', () => { expect(findFinishedTimeAgo().exists()).toBe(false); }); @@ -374,46 +338,58 @@ describe('Pipeline details header', () => { }); describe('cancel action', () => { - it('should call cancelPipeline Mutation with pipeline id', async () => { - createComponent([ - [getPipelineDetailsQuery, runningHandler], - [cancelPipelineMutation, cancelMutationHandlerSuccess], - ]); + describe('with permissions', () => { + it('should call cancelPipeline Mutation with pipeline id', async () => { + createComponent([ + [getPipelineDetailsQuery, runningHandler], + [cancelPipelineMutation, cancelMutationHandlerSuccess], + ]); - await waitForPromises(); + await waitForPromises(); - findCancelButton().vm.$emit('click'); + findCancelButton().vm.$emit('click'); - expect(cancelMutationHandlerSuccess).toHaveBeenCalledWith({ - id: pipelineHeaderRunning.data.project.pipeline.id, + expect(cancelMutationHandlerSuccess).toHaveBeenCalledWith({ + id: pipelineHeaderRunning.data.project.pipeline.id, + }); + expect(findAlert().exists()).toBe(false); }); - expect(findAlert().exists()).toBe(false); - }); - it('should render cancel action tooltip', async () => { - createComponent([ - [getPipelineDetailsQuery, runningHandler], - [cancelPipelineMutation, cancelMutationHandlerSuccess], - ]); + it('should render cancel action tooltip', async () => { + createComponent([ + [getPipelineDetailsQuery, runningHandler], + [cancelPipelineMutation, cancelMutationHandlerSuccess], + ]); - await waitForPromises(); + await waitForPromises(); - expect(findCancelButton().attributes('title')).toBe(BUTTON_TOOLTIP_CANCEL); - }); + expect(findCancelButton().attributes('title')).toBe(BUTTON_TOOLTIP_CANCEL); + }); - it('should display error message on failure', async () => { - createComponent([ - [getPipelineDetailsQuery, runningHandler], - [cancelPipelineMutation, cancelMutationHandlerFailed], - ]); + it('should display error message on failure', async () => { + createComponent([ + [getPipelineDetailsQuery, runningHandler], + [cancelPipelineMutation, cancelMutationHandlerFailed], + ]); - await waitForPromises(); + await waitForPromises(); - findCancelButton().vm.$emit('click'); + findCancelButton().vm.$emit('click'); - await waitForPromises(); + await waitForPromises(); - expect(findAlert().exists()).toBe(true); + expect(findAlert().exists()).toBe(true); + }); + }); + + describe('without permissions', () => { + it('should not display cancel pipeline button', async () => { + createComponent([[getPipelineDetailsQuery, runningHandlerNoPermissions]]); + + await waitForPromises(); + + expect(findCancelButton().exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/ci/pipeline_details/mock_data.js b/spec/frontend/ci/pipeline_details/mock_data.js index 56365622544..48570b2515f 100644 --- a/spec/frontend/ci/pipeline_details/mock_data.js +++ b/spec/frontend/ci/pipeline_details/mock_data.js @@ -1,5 +1,7 @@ +// pipeline header fixtures located in spec/frontend/fixtures/pipeline_header.rb import pipelineHeaderSuccess from 'test_fixtures/graphql/pipelines/pipeline_header_success.json'; import pipelineHeaderRunning from 'test_fixtures/graphql/pipelines/pipeline_header_running.json'; +import pipelineHeaderRunningNoPermissions from 'test_fixtures/graphql/pipelines/pipeline_header_running_no_permissions.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'; @@ -13,6 +15,7 @@ threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); export { pipelineHeaderSuccess, pipelineHeaderRunning, + pipelineHeaderRunningNoPermissions, pipelineHeaderRunningWithDuration, pipelineHeaderFailed, }; diff --git a/spec/frontend/ci/pipeline_details/test_reports/stores/utils_spec.js b/spec/frontend/ci/pipeline_details/test_reports/stores/utils_spec.js index c0ffc2b34fb..ecc61ab43c0 100644 --- a/spec/frontend/ci/pipeline_details/test_reports/stores/utils_spec.js +++ b/spec/frontend/ci/pipeline_details/test_reports/stores/utils_spec.js @@ -36,5 +36,33 @@ describe('Test reports utils', () => { expect(result).toBe('4.82s'); }); }); + + describe('when time is greater than a minute', () => { + it('should return time in minutes', () => { + const result = formattedTime(99); + expect(result).toBe('1m 39s'); + }); + }); + + describe('when time is greater than a hour', () => { + it('should return time in hours', () => { + const result = formattedTime(3606); + expect(result).toBe('1h 6s'); + }); + }); + + describe('when time is exact a hour', () => { + it('should return time as one hour', () => { + const result = formattedTime(3600); + expect(result).toBe('1h'); + }); + }); + + describe('when time is greater than a hour with some minutes', () => { + it('should return time in hours', () => { + const result = formattedTime(3662); + expect(result).toBe('1h 1m 2s'); + }); + }); }); }); diff --git a/spec/frontend/ci/pipeline_details/test_reports/test_reports_spec.js b/spec/frontend/ci/pipeline_details/test_reports/test_reports_spec.js index 8ff060026da..d318aa36bcf 100644 --- a/spec/frontend/ci/pipeline_details/test_reports/test_reports_spec.js +++ b/spec/frontend/ci/pipeline_details/test_reports/test_reports_spec.js @@ -5,6 +5,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import testReports from 'test_fixtures/pipelines/test_report.json'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { getParameterValues } from '~/lib/utils/url_utility'; import EmptyState from '~/ci/pipeline_details/test_reports/empty_state.vue'; import TestReports from '~/ci/pipeline_details/test_reports/test_reports.vue'; import TestSummary from '~/ci/pipeline_details/test_reports/test_summary.vue'; @@ -13,6 +14,11 @@ import * as getters from '~/ci/pipeline_details/stores/test_reports/getters'; Vue.use(Vuex); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + getParameterValues: jest.fn().mockReturnValue([]), +})); + describe('Test reports app', () => { let wrapper; let store; @@ -100,6 +106,22 @@ describe('Test reports app', () => { }); }); + describe('when a job name is provided as a query parameter', () => { + beforeEach(() => { + getParameterValues.mockReturnValue(['javascript']); + createComponent(); + }); + + it('shows tests details', () => { + expect(testsDetail().exists()).toBe(true); + }); + + it('should call setSelectedSuiteIndex and fetchTestSuite', () => { + expect(actionSpies.setSelectedSuiteIndex).toHaveBeenCalled(); + expect(actionSpies.fetchTestSuite).toHaveBeenCalled(); + }); + }); + describe('when a suite is clicked', () => { beforeEach(() => { createComponent({ state: { hasFullReport: true } }); 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 f6247fb4a19..46ef8a0d771 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 @@ -16,14 +16,15 @@ describe('CI Editor Header', () => { const createComponent = ({ showHelpDrawer = false, showJobAssistantDrawer = false, - showAiAssistantDrawer = false, aiChatAvailable = false, aiCiConfigGenerator = false, + ciCatalogPath = '/explore/catalog', } = {}) => { wrapper = extendedWrapper( shallowMount(CiEditorHeader, { provide: { aiChatAvailable, + ciCatalogPath, glFeatures: { aiCiConfigGenerator, }, @@ -31,7 +32,6 @@ describe('CI Editor Header', () => { propsData: { showHelpDrawer, showJobAssistantDrawer, - showAiAssistantDrawer, }, }), ); @@ -39,7 +39,7 @@ describe('CI Editor Header', () => { const findLinkBtn = () => wrapper.findByTestId('template-repo-link'); const findHelpBtn = () => wrapper.findByTestId('drawer-toggle'); - const findAiAssistnantBtn = () => wrapper.findByTestId('ai-assistant-drawer-toggle'); + const findCatalogRepoLinkButton = () => wrapper.findByTestId('catalog-repo-link'); afterEach(() => { unmockTracking(); @@ -55,29 +55,32 @@ describe('CI Editor Header', () => { label, }); }; - describe('Ai Assistant toggle button', () => { - describe('when feature is unavailable', () => { - it('should not show ai button when feature toggle is off', () => { - createComponent({ aiChatAvailable: true }); - mockTracking(undefined, wrapper.element, jest.spyOn); - expect(findAiAssistnantBtn().exists()).toBe(false); - }); - it('should not show ai button when feature is unavailable', () => { - createComponent({ aiCiConfigGenerator: true }); - mockTracking(undefined, wrapper.element, jest.spyOn); - expect(findAiAssistnantBtn().exists()).toBe(false); - }); + describe('component repo link button', () => { + beforeEach(() => { + createComponent(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); - describe('when feature is available', () => { - it('should show ai button', () => { - createComponent({ aiCiConfigGenerator: true, aiChatAvailable: true }); - mockTracking(undefined, wrapper.element, jest.spyOn); - expect(findAiAssistnantBtn().exists()).toBe(true); - }); + afterEach(() => { + unmockTracking(); + }); + + it('finds the CI/CD Catalog button', () => { + expect(findCatalogRepoLinkButton().exists()).toBe(true); + }); + + it('has the external-link icon', () => { + expect(findCatalogRepoLinkButton().props('icon')).toBe('external-link'); + }); + + it('tracks the click on the Catalog button', () => { + const { browseCatalog } = pipelineEditorTrackingOptions.actions; + + testTracker(findCatalogRepoLinkButton(), browseCatalog); }); }); + describe('link button', () => { beforeEach(() => { createComponent(); 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 69e91f11309..43620a58572 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 @@ -65,6 +65,7 @@ describe('Pipeline editor tabs component', () => { }, provide: { aiChatAvailable: false, + ciCatalogPath: '/explore/catalog', ciConfigPath: '/path/to/ci-config', ciLintPath: mockCiLintPath, currentBranch: 'main', 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 f2818277c59..b66b44e5f06 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,5 +1,12 @@ import Vue from 'vue'; -import { GlAlert, GlDisclosureDropdown, GlIcon, GlLoadingIcon, GlPopover } from '@gitlab/ui'; +import { + GlAlert, + GlDisclosureDropdown, + GlEmptyState, + GlIcon, + GlLoadingIcon, + GlPopover, +} from '@gitlab/ui'; import VueApollo from 'vue-apollo'; import MockAdapter from 'axios-mock-adapter'; @@ -70,7 +77,7 @@ describe('Pipeline Editor Validate Tab', () => { const findCta = () => wrapper.findByTestId('simulate-pipeline-button'); const findDisabledCtaTooltip = () => wrapper.findByTestId('cta-tooltip'); const findHelpIcon = () => wrapper.findComponent(GlIcon); - const findIllustration = () => wrapper.findByRole('img'); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findPipelineSource = () => wrapper.findComponent(GlDisclosureDropdown); const findPopover = () => wrapper.findComponent(GlPopover); @@ -283,7 +290,7 @@ describe('Pipeline Editor Validate Tab', () => { it('returns to init state', async () => { // init state - expect(findIllustration().exists()).toBe(true); + expect(findEmptyState().exists()).toBe(true); expect(findCiLintResults().exists()).toBe(false); // mutations should have successful results @@ -294,7 +301,7 @@ describe('Pipeline Editor Validate Tab', () => { await findCancelBtn().vm.$emit('click'); // should still render init state - expect(findIllustration().exists()).toBe(true); + expect(findEmptyState().exists()).toBe(true); expect(findCiLintResults().exists()).toBe(false); }); }); diff --git a/spec/frontend/ci/pipeline_editor/mock_data.js b/spec/frontend/ci/pipeline_editor/mock_data.js index e08c35f1555..e700411ec57 100644 --- a/spec/frontend/ci/pipeline_editor/mock_data.js +++ b/spec/frontend/ci/pipeline_editor/mock_data.js @@ -22,18 +22,17 @@ export const commonOptions = { usesExternalConfig: 'false', validateTabIllustrationPath: 'illustrations/tab', ymlHelpPagePath: 'help/ci/yml', - aiChatAvailable: 'true', }; export const editorDatasetOptions = { initialBranchName: 'production', pipelineEtag: 'pipelineEtag', + ciCatalogPath: '/explore/catalog', ...commonOptions, }; export const expectedInjectValues = { ...commonOptions, - aiChatAvailable: true, usesExternalConfig: false, totalBranches: 10, }; diff --git a/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js index ca5f80f331c..fd0d17ee05b 100644 --- a/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js +++ b/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js @@ -1,5 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import { GlButton, GlModal } from '@gitlab/ui'; +import { GlModal } from '@gitlab/ui'; +import { nextTick } from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import CommitSection from '~/ci/pipeline_editor/components/commit/commit_section.vue'; @@ -60,7 +61,7 @@ describe('Pipeline editor home wrapper', () => { const findPipelineEditorFileTree = () => wrapper.findComponent(PipelineEditorFileTree); const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorHeader); const findPipelineEditorTabs = () => wrapper.findComponent(PipelineEditorTabs); - const findFileTreeBtn = () => wrapper.findByTestId('file-tree-toggle'); + const findPipelineEditorFileNav = () => wrapper.findComponent(PipelineEditorFileNav); const clickHelpBtn = async () => { await findPipelineEditorDrawer().vm.$emit('switch-drawer', EDITOR_APP_DRAWER_HELP); @@ -279,24 +280,16 @@ describe('Pipeline editor home wrapper', () => { describe('file tree', () => { const toggleFileTree = async () => { - await findFileTreeBtn().vm.$emit('click'); + findPipelineEditorFileNav().vm.$emit('toggle-file-tree'); + await nextTick(); }; - describe('button toggle', () => { + describe('file navigation', () => { beforeEach(() => { - createComponent({ - stubs: { - GlButton, - PipelineEditorFileNav, - }, - }); - }); - - it('shows button toggle', () => { - expect(findFileTreeBtn().exists()).toBe(true); + createComponent({}); }); - it('toggles the drawer on button click', async () => { + it('toggles the drawer on `toggle-file-tree` event', async () => { await toggleFileTree(); expect(findPipelineEditorFileTree().exists()).toBe(true); diff --git a/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js b/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js index 87df7676bf1..95fa82adc9e 100644 --- a/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js +++ b/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js @@ -2,7 +2,7 @@ import { GlDropdown } from '@gitlab/ui'; import { nextTick } from 'vue'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import LegacyPipelineStage from '~/ci/pipeline_mini_graph/legacy_pipeline_stage.vue'; diff --git a/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js b/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js index 55ce3c79039..4f0bf3767cd 100644 --- a/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js +++ b/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import LinkedPipelinesMiniList from '~/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue'; import mockData from './linked_pipelines_mock_data'; diff --git a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js index b79e7c6e251..b79662e7a89 100644 --- a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js @@ -1,5 +1,5 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import PipelineScheduleLastPipeline from '~/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue'; import { mockPipelineScheduleNodes } from '../../../mock_data'; diff --git a/spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js b/spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js index fbef4aa08eb..f824dab9ae1 100644 --- a/spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js +++ b/spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js @@ -1,4 +1,5 @@ import '~/commons'; +import { GlButton } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import PipelinesCiTemplates from '~/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue'; @@ -14,12 +15,15 @@ describe('Pipelines CI Templates', () => { return shallowMountExtended(PipelinesCiTemplates, { provide: { pipelineEditorPath, + showJenkinsCiPrompt: false, ...propsData, }, stubs, }); }; + const findMigrateFromJenkinsPrompt = () => wrapper.findByTestId('migrate-from-jenkins-prompt'); + const findMigrationPlanBtn = () => findMigrateFromJenkinsPrompt().findComponent(GlButton); const findTestTemplateLink = () => wrapper.findByTestId('test-template-link'); const findCiTemplates = () => wrapper.findComponent(CiTemplates); @@ -34,6 +38,27 @@ describe('Pipelines CI Templates', () => { ); expect(findCiTemplates().exists()).toBe(true); }); + + it('does not show migrate from jenkins prompt', () => { + expect(findMigrateFromJenkinsPrompt().exists()).toBe(false); + }); + + describe('when Jenkinsfile is detected', () => { + beforeEach(() => { + wrapper = createWrapper({ showJenkinsCiPrompt: true }); + }); + + it('shows migrate from jenkins prompt', () => { + expect(findMigrateFromJenkinsPrompt().exists()).toBe(true); + }); + + it('opens correct link in new tab after clicking migration plan CTA', () => { + expect(findMigrationPlanBtn().attributes('href')).toBe( + '/help/ci/migration/plan_a_migration', + ); + expect(findMigrationPlanBtn().attributes('target')).toBe('_blank'); + }); + }); }); describe('tracking', () => { @@ -54,5 +79,27 @@ describe('Pipelines CI Templates', () => { label: 'Getting-Started', }); }); + + describe('when Jenkinsfile detected', () => { + beforeEach(() => { + wrapper = createWrapper({ showJenkinsCiPrompt: true }); + }); + + it('creates render event on page load', () => { + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'render', { + label: 'migrate_from_jenkins_prompt', + }); + }); + + it('sends an event when migration plan is clicked', () => { + findMigrationPlanBtn().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledTimes(2); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { + label: 'migrate_from_jenkins_prompt', + }); + }); + }); }); }); diff --git a/spec/frontend/ci/pipelines_page/pipelines_spec.js b/spec/frontend/ci/pipelines_page/pipelines_spec.js index 97192058ff6..f3c28b17339 100644 --- a/spec/frontend/ci/pipelines_page/pipelines_spec.js +++ b/spec/frontend/ci/pipelines_page/pipelines_spec.js @@ -110,6 +110,7 @@ describe('Pipelines', () => { suggestedCiTemplates: [], ciRunnerSettingsPath: defaultProps.ciRunnerSettingsPath, anyRunnersAvailable: true, + showJenkinsCiPrompt: false, }, propsData: { ...defaultProps, diff --git a/spec/frontend/ci/reports/codequality_report/store/actions_spec.js b/spec/frontend/ci/reports/codequality_report/store/actions_spec.js deleted file mode 100644 index a606bce3d78..00000000000 --- a/spec/frontend/ci/reports/codequality_report/store/actions_spec.js +++ /dev/null @@ -1,190 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import testAction from 'helpers/vuex_action_helper'; -import { TEST_HOST } from 'spec/test_constants'; -import axios from '~/lib/utils/axios_utils'; -import { - HTTP_STATUS_INTERNAL_SERVER_ERROR, - HTTP_STATUS_NO_CONTENT, - HTTP_STATUS_OK, -} from '~/lib/utils/http_status'; -import createStore from '~/ci/reports/codequality_report/store'; -import * as actions from '~/ci/reports/codequality_report/store/actions'; -import * as types from '~/ci/reports/codequality_report/store/mutation_types'; -import { STATUS_NOT_FOUND } from '~/ci/reports/constants'; -import { reportIssues, parsedReportIssues } from '../mock_data'; - -const pollInterval = 123; -const pollIntervalHeader = { - 'Poll-Interval': pollInterval, -}; - -describe('Codequality Reports actions', () => { - let localState; - let localStore; - - beforeEach(() => { - localStore = createStore(); - localState = localStore.state; - }); - - describe('setPaths', () => { - it('should commit SET_PATHS mutation', () => { - const paths = { - baseBlobPath: 'baseBlobPath', - headBlobPath: 'headBlobPath', - reportsPath: 'reportsPath', - }; - - return testAction( - actions.setPaths, - paths, - localState, - [{ type: types.SET_PATHS, payload: paths }], - [], - ); - }); - }); - - describe('fetchReports', () => { - const endpoint = `${TEST_HOST}/codequality_reports.json`; - let mock; - - beforeEach(() => { - localState.reportsPath = endpoint; - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - describe('on success', () => { - it('commits REQUEST_REPORTS and dispatches receiveReportsSuccess', () => { - mock.onGet(endpoint).reply(HTTP_STATUS_OK, reportIssues); - - return testAction( - actions.fetchReports, - null, - localState, - [{ type: types.REQUEST_REPORTS }], - [ - { - payload: parsedReportIssues, - type: 'receiveReportsSuccess', - }, - ], - ); - }); - }); - - describe('on error', () => { - it('commits REQUEST_REPORTS and dispatches receiveReportsError', () => { - mock.onGet(endpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - - return testAction( - actions.fetchReports, - null, - localState, - [{ type: types.REQUEST_REPORTS }], - [{ type: 'receiveReportsError', payload: expect.any(Error) }], - ); - }); - }); - - describe('when base report is not found', () => { - it('commits REQUEST_REPORTS and dispatches receiveReportsError', () => { - const data = { status: STATUS_NOT_FOUND }; - mock.onGet(`${TEST_HOST}/codequality_reports.json`).reply(HTTP_STATUS_OK, data); - - return testAction( - actions.fetchReports, - null, - localState, - [{ type: types.REQUEST_REPORTS }], - [{ type: 'receiveReportsError', payload: data }], - ); - }); - }); - - describe('while waiting for report results', () => { - it('continues polling until it receives data', () => { - mock - .onGet(endpoint) - .replyOnce(HTTP_STATUS_NO_CONTENT, undefined, pollIntervalHeader) - .onGet(endpoint) - .reply(HTTP_STATUS_OK, reportIssues); - - return Promise.all([ - testAction( - actions.fetchReports, - null, - localState, - [{ type: types.REQUEST_REPORTS }], - [ - { - payload: parsedReportIssues, - type: 'receiveReportsSuccess', - }, - ], - ), - axios - // wait for initial NO_CONTENT response to be fulfilled - .waitForAll() - .then(() => { - jest.advanceTimersByTime(pollInterval); - }), - ]); - }); - - it('continues polling until it receives an error', () => { - mock - .onGet(endpoint) - .replyOnce(HTTP_STATUS_NO_CONTENT, undefined, pollIntervalHeader) - .onGet(endpoint) - .reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - - return Promise.all([ - testAction( - actions.fetchReports, - null, - localState, - [{ type: types.REQUEST_REPORTS }], - [{ type: 'receiveReportsError', payload: expect.any(Error) }], - ), - axios - // wait for initial NO_CONTENT response to be fulfilled - .waitForAll() - .then(() => { - jest.advanceTimersByTime(pollInterval); - }), - ]); - }); - }); - }); - - describe('receiveReportsSuccess', () => { - it('commits RECEIVE_REPORTS_SUCCESS', () => { - const data = { issues: [] }; - - return testAction( - actions.receiveReportsSuccess, - data, - localState, - [{ type: types.RECEIVE_REPORTS_SUCCESS, payload: data }], - [], - ); - }); - }); - - describe('receiveReportsError', () => { - it('commits RECEIVE_REPORTS_ERROR', () => { - return testAction( - actions.receiveReportsError, - null, - localState, - [{ type: types.RECEIVE_REPORTS_ERROR, payload: null }], - [], - ); - }); - }); -}); diff --git a/spec/frontend/ci/reports/codequality_report/store/getters_spec.js b/spec/frontend/ci/reports/codequality_report/store/getters_spec.js deleted file mode 100644 index f4505204f67..00000000000 --- a/spec/frontend/ci/reports/codequality_report/store/getters_spec.js +++ /dev/null @@ -1,94 +0,0 @@ -import createStore from '~/ci/reports/codequality_report/store'; -import * as getters from '~/ci/reports/codequality_report/store/getters'; -import { LOADING, ERROR, SUCCESS, STATUS_NOT_FOUND } from '~/ci/reports/constants'; - -describe('Codequality reports store getters', () => { - let localState; - let localStore; - - beforeEach(() => { - localStore = createStore(); - localState = localStore.state; - }); - - describe('hasCodequalityIssues', () => { - describe('when there are issues', () => { - it('returns true', () => { - localState.newIssues = [{ reason: 'repetitive code' }]; - localState.resolvedIssues = []; - - expect(getters.hasCodequalityIssues(localState)).toEqual(true); - - localState.newIssues = []; - localState.resolvedIssues = [{ reason: 'repetitive code' }]; - - expect(getters.hasCodequalityIssues(localState)).toEqual(true); - }); - }); - - describe('when there are no issues', () => { - it('returns false when there are no issues', () => { - expect(getters.hasCodequalityIssues(localState)).toEqual(false); - }); - }); - }); - - describe('codequalityStatus', () => { - describe('when loading', () => { - it('returns loading status', () => { - localState.isLoading = true; - - expect(getters.codequalityStatus(localState)).toEqual(LOADING); - }); - }); - - describe('on error', () => { - it('returns error status', () => { - localState.hasError = true; - - expect(getters.codequalityStatus(localState)).toEqual(ERROR); - }); - }); - - describe('when successfully loaded', () => { - it('returns error status', () => { - expect(getters.codequalityStatus(localState)).toEqual(SUCCESS); - }); - }); - }); - - describe('codequalityText', () => { - it.each` - resolvedIssues | newIssues | expectedText - ${0} | ${0} | ${'No changes to code quality'} - ${0} | ${1} | ${'Code quality degraded due to 1 new issue'} - ${2} | ${0} | ${'Code quality improved due to 2 resolved issues'} - ${1} | ${2} | ${'Code quality scanning detected 3 changes in merged results'} - `( - 'returns a summary containing $resolvedIssues resolved issues and $newIssues new issues', - ({ newIssues, resolvedIssues, expectedText }) => { - localState.newIssues = new Array(newIssues).fill({ reason: 'Repetitive code' }); - localState.resolvedIssues = new Array(resolvedIssues).fill({ reason: 'Repetitive code' }); - - expect(getters.codequalityText(localState)).toEqual(expectedText); - }, - ); - }); - - describe('codequalityPopover', () => { - describe('when base report is not available', () => { - it('returns a popover with a documentation link', () => { - localState.status = STATUS_NOT_FOUND; - localState.helpPath = 'codequality_help.html'; - - expect(getters.codequalityPopover(localState).title).toEqual( - 'Base pipeline codequality artifact not found', - ); - expect(getters.codequalityPopover(localState).content).toContain( - 'Learn more about codequality reports', - 'href="codequality_help.html"', - ); - }); - }); - }); -}); diff --git a/spec/frontend/ci/reports/codequality_report/store/mutations_spec.js b/spec/frontend/ci/reports/codequality_report/store/mutations_spec.js deleted file mode 100644 index 22ff86b1040..00000000000 --- a/spec/frontend/ci/reports/codequality_report/store/mutations_spec.js +++ /dev/null @@ -1,100 +0,0 @@ -import createStore from '~/ci/reports/codequality_report/store'; -import mutations from '~/ci/reports/codequality_report/store/mutations'; -import { STATUS_NOT_FOUND } from '~/ci/reports/constants'; - -describe('Codequality Reports mutations', () => { - let localState; - let localStore; - - beforeEach(() => { - localStore = createStore(); - localState = localStore.state; - }); - - describe('SET_PATHS', () => { - it('sets paths to given values', () => { - const baseBlobPath = 'base/blob/path/'; - const headBlobPath = 'head/blob/path/'; - const reportsPath = 'reports.json'; - const helpPath = 'help.html'; - - mutations.SET_PATHS(localState, { - baseBlobPath, - headBlobPath, - reportsPath, - helpPath, - }); - - expect(localState.baseBlobPath).toEqual(baseBlobPath); - expect(localState.headBlobPath).toEqual(headBlobPath); - expect(localState.reportsPath).toEqual(reportsPath); - expect(localState.helpPath).toEqual(helpPath); - }); - }); - - describe('REQUEST_REPORTS', () => { - it('sets isLoading to true', () => { - mutations.REQUEST_REPORTS(localState); - - expect(localState.isLoading).toEqual(true); - }); - }); - - describe('RECEIVE_REPORTS_SUCCESS', () => { - it('sets isLoading to false', () => { - mutations.RECEIVE_REPORTS_SUCCESS(localState, {}); - - expect(localState.isLoading).toEqual(false); - }); - - it('sets hasError to false', () => { - mutations.RECEIVE_REPORTS_SUCCESS(localState, {}); - - expect(localState.hasError).toEqual(false); - }); - - it('clears status and statusReason', () => { - mutations.RECEIVE_REPORTS_SUCCESS(localState, {}); - - expect(localState.status).toEqual(''); - expect(localState.statusReason).toEqual(''); - }); - - it('sets newIssues and resolvedIssues from response data', () => { - const data = { newIssues: [{ id: 1 }], resolvedIssues: [{ id: 2 }] }; - mutations.RECEIVE_REPORTS_SUCCESS(localState, data); - - expect(localState.newIssues).toEqual(data.newIssues); - expect(localState.resolvedIssues).toEqual(data.resolvedIssues); - }); - }); - - describe('RECEIVE_REPORTS_ERROR', () => { - it('sets isLoading to false', () => { - mutations.RECEIVE_REPORTS_ERROR(localState); - - expect(localState.isLoading).toEqual(false); - }); - - it('sets hasError to true', () => { - mutations.RECEIVE_REPORTS_ERROR(localState); - - expect(localState.hasError).toEqual(true); - }); - - it('sets status based on error object', () => { - const error = { status: STATUS_NOT_FOUND }; - mutations.RECEIVE_REPORTS_ERROR(localState, error); - - expect(localState.status).toEqual(error.status); - }); - - it('sets statusReason to string from error response data', () => { - const data = { status_reason: 'This merge request does not have codequality reports' }; - const error = { response: { data } }; - mutations.RECEIVE_REPORTS_ERROR(localState, error); - - expect(localState.statusReason).toEqual(data.status_reason); - }); - }); -}); diff --git a/spec/frontend/ci/reports/codequality_report/store/utils/codequality_parser_spec.js b/spec/frontend/ci/reports/codequality_report/utils/codequality_parser_spec.js index f7d82d2b662..953e6173662 100644 --- a/spec/frontend/ci/reports/codequality_report/store/utils/codequality_parser_spec.js +++ b/spec/frontend/ci/reports/codequality_report/utils/codequality_parser_spec.js @@ -1,5 +1,5 @@ import { reportIssues, parsedReportIssues } from 'jest/ci/reports/codequality_report/mock_data'; -import { parseCodeclimateMetrics } from '~/ci/reports/codequality_report/store/utils/codequality_parser'; +import { parseCodeclimateMetrics } from '~/ci/reports/codequality_report/utils/codequality_parser'; describe('Codequality report store utils', () => { let result; 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 4f5f9c43cb4..798cef252c9 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 @@ -50,11 +50,13 @@ import { } from '~/ci/runner/constants'; import allRunnersQuery from 'ee_else_ce/ci/runner/graphql/list/all_runners.query.graphql'; import allRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/all_runners_count.query.graphql'; +import runnerJobCountQuery from '~/ci/runner/graphql/list/runner_job_count.query.graphql'; import { captureException } from '~/ci/runner/sentry_utils'; import { allRunnersData, runnersCountData, + runnerJobCountData, allRunnersDataPaginated, onlineContactTimeoutSecs, staleTimeoutSecs, @@ -68,6 +70,7 @@ const mockRunnersCount = runnersCountData.data.runners.count; const mockRunnersHandler = jest.fn(); const mockRunnersCountHandler = jest.fn(); +const mockRunnerJobCountHandler = jest.fn(); jest.mock('~/alert'); jest.mock('~/ci/runner/sentry_utils'); @@ -108,6 +111,7 @@ describe('AdminRunnersApp', () => { const handlers = [ [allRunnersQuery, mockRunnersHandler], [allRunnersCountQuery, mockRunnersCountHandler], + [runnerJobCountQuery, mockRunnerJobCountHandler], ]; wrapper = mountFn(AdminRunnersApp, { @@ -137,11 +141,13 @@ describe('AdminRunnersApp', () => { beforeEach(() => { mockRunnersHandler.mockResolvedValue(allRunnersData); mockRunnersCountHandler.mockResolvedValue(runnersCountData); + mockRunnerJobCountHandler.mockResolvedValue(runnerJobCountData); }); afterEach(() => { mockRunnersHandler.mockReset(); mockRunnersCountHandler.mockReset(); + mockRunnerJobCountHandler.mockReset(); showToast.mockReset(); }); 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 27fb288c462..2504458efff 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 @@ -4,6 +4,7 @@ import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_help import RunnerSummaryCell from '~/ci/runner/components/cells/runner_summary_cell.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import RunnerCreatedAt from '~/ci/runner/components/runner_created_at.vue'; +import RunnerJobCount from '~/ci/runner/components/runner_job_count.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'; @@ -157,23 +158,9 @@ describe('RunnerTypeCell', () => { }); it('Displays job count', () => { - expect(findRunnerSummaryField('pipeline').text()).toContain(`${mockRunner.jobCount}`); - }); - - it('Formats large job counts', () => { - createComponent({ - runner: { jobCount: 1000 }, - }); - - expect(findRunnerSummaryField('pipeline').text()).toContain('1,000'); - }); - - it('Formats large job counts with a plus symbol', () => { - createComponent({ - runner: { jobCount: 1001 }, - }); - - expect(findRunnerSummaryField('pipeline').text()).toContain('1,000+'); + expect( + findRunnerSummaryField('pipeline').findComponent(RunnerJobCount).props('runner'), + ).toEqual(mockRunner); }); it('Displays creation info', () => { diff --git a/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js index ffc19d66cac..62ab40b2ebb 100644 --- a/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js +++ b/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js @@ -1,4 +1,4 @@ -import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlFilteredSearch, GlSorting } from '@gitlab/ui'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { assertProps } from 'helpers/assert_props'; @@ -32,7 +32,12 @@ describe('RunnerList', () => { const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); - const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem); + const findGlSorting = () => wrapper.findComponent(GlSorting); + const getSortOptions = () => findGlSorting().props('sortOptions'); + const getSelectedSortOption = () => { + const sortBy = findGlSorting().props('sortBy'); + return getSortOptions().find(({ value }) => sortBy === value)?.text; + }; const mockOtherSort = CONTACTED_DESC; const mockFilters = [ @@ -56,8 +61,6 @@ describe('RunnerList', () => { stubs: { FilteredSearch, GlFilteredSearch, - GlDropdown, - GlDropdownItem, }, ...options, }); @@ -74,9 +77,10 @@ describe('RunnerList', () => { it('sets sorting options', () => { const SORT_OPTIONS_COUNT = 2; - expect(findSortOptions()).toHaveLength(SORT_OPTIONS_COUNT); - expect(findSortOptions().at(0).text()).toBe('Created date'); - expect(findSortOptions().at(1).text()).toBe('Last contact'); + const sortOptionsProp = getSortOptions(); + expect(sortOptionsProp).toHaveLength(SORT_OPTIONS_COUNT); + expect(sortOptionsProp[0].text).toBe('Created date'); + expect(sortOptionsProp[1].text).toBe('Last contact'); }); it('sets tokens to the filtered search', () => { @@ -141,12 +145,7 @@ describe('RunnerList', () => { }); it('sort option is selected', () => { - expect( - findSortOptions() - .filter((w) => w.props('isChecked')) - .at(0) - .text(), - ).toEqual('Last contact'); + expect(getSelectedSortOption()).toBe('Last contact'); }); it('when the user sets a filter, the "search" preserves the other filters', async () => { @@ -181,7 +180,7 @@ describe('RunnerList', () => { }); it('when the user sets a sorting method, the "search" is emitted with the sort', () => { - findSortOptions().at(1).vm.$emit('click'); + findGlSorting().vm.$emit('sortByChange', 2); expectToHaveLastEmittedInput({ runnerType: null, diff --git a/spec/frontend/ci/runner/components/runner_job_count_spec.js b/spec/frontend/ci/runner/components/runner_job_count_spec.js new file mode 100644 index 00000000000..01b5ca5332e --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_job_count_spec.js @@ -0,0 +1,74 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import runnerJobCountQuery from '~/ci/runner/graphql/list/runner_job_count.query.graphql'; + +import RunnerJobCount from '~/ci/runner/components/runner_job_count.vue'; + +import { runnerJobCountData } from '../mock_data'; + +const mockRunner = runnerJobCountData.data.runner; + +Vue.use(VueApollo); + +describe('RunnerJobCount', () => { + let wrapper; + let runnerJobCountHandler; + + const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMountExtended) => { + wrapper = mountFn(RunnerJobCount, { + apolloProvider: createMockApollo([[runnerJobCountQuery, runnerJobCountHandler]]), + propsData: { + runner: mockRunner, + ...props, + }, + ...options, + }); + }; + + beforeEach(() => { + runnerJobCountHandler = jest.fn().mockReturnValue(new Promise(() => {})); + }); + + it('Loads data while it displays empty content', () => { + createComponent(); + + expect(runnerJobCountHandler).toHaveBeenCalledWith({ id: mockRunner.id }); + expect(wrapper.text()).toBe('-'); + }); + + it('Sets a batch key for the "jobCount" query', () => { + createComponent(); + + expect(wrapper.vm.$apollo.queries.jobCount.options.context.batchKey).toBe('RunnerJobCount'); + }); + + it('Displays job count', async () => { + runnerJobCountHandler.mockResolvedValue(runnerJobCountData); + + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toBe('999'); + }); + + it('Displays formatted job count', async () => { + runnerJobCountHandler.mockResolvedValue({ + data: { + runner: { + ...mockRunner, + jobCount: 1001, + }, + }, + }); + + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toBe('1,000+'); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_managers_detail_spec.js b/spec/frontend/ci/runner/components/runner_managers_detail_spec.js index 3435292394f..6db9bb1d091 100644 --- a/spec/frontend/ci/runner/components/runner_managers_detail_spec.js +++ b/spec/frontend/ci/runner/components/runner_managers_detail_spec.js @@ -85,7 +85,7 @@ describe('RunnerJobs', () => { }); it('is collapsed', () => { - expect(findCollapse().attributes('visible')).toBeUndefined(); + expect(findCollapse().props('visible')).toBe(false); }); describe('when expanded', () => { @@ -99,7 +99,7 @@ describe('RunnerJobs', () => { }); it('shows loading state', () => { - expect(findCollapse().attributes('visible')).toBe('true'); + expect(findCollapse().props('visible')).toBe(true); expect(findSkeletonLoader().exists()).toBe(true); }); @@ -156,14 +156,14 @@ describe('RunnerJobs', () => { }); it('shows rows', () => { - expect(findCollapse().attributes('visible')).toBe('true'); + expect(findCollapse().props('visible')).toBe(true); expect(findRunnerManagersTable().props('items')).toEqual(mockRunnerManagers); }); it('collapses when clicked', async () => { await findHideDetails().trigger('click'); - expect(findCollapse().attributes('visible')).toBeUndefined(); + expect(findCollapse().props('visible')).toBe(false); }); }); }); 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 f3d7ae85e0d..3e4cdecb07b 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 @@ -50,12 +50,14 @@ import { } from '~/ci/runner/constants'; import groupRunnersQuery from 'ee_else_ce/ci/runner/graphql/list/group_runners.query.graphql'; import groupRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/group_runners_count.query.graphql'; +import runnerJobCountQuery from '~/ci/runner/graphql/list/runner_job_count.query.graphql'; import GroupRunnersApp from '~/ci/runner/group_runners/group_runners_app.vue'; import { captureException } from '~/ci/runner/sentry_utils'; import { groupRunnersData, groupRunnersDataPaginated, groupRunnersCountData, + runnerJobCountData, onlineContactTimeoutSecs, staleTimeoutSecs, mockRegistrationToken, @@ -72,6 +74,7 @@ const mockGroupRunnersCount = mockGroupRunnersEdges.length; const mockGroupRunnersHandler = jest.fn(); const mockGroupRunnersCountHandler = jest.fn(); +const mockRunnerJobCountHandler = jest.fn(); jest.mock('~/alert'); jest.mock('~/ci/runner/sentry_utils'); @@ -108,6 +111,7 @@ describe('GroupRunnersApp', () => { const handlers = [ [groupRunnersQuery, mockGroupRunnersHandler], [groupRunnersCountQuery, mockGroupRunnersCountHandler], + [runnerJobCountQuery, mockRunnerJobCountHandler], ]; wrapper = mountFn(GroupRunnersApp, { @@ -138,11 +142,13 @@ describe('GroupRunnersApp', () => { beforeEach(() => { mockGroupRunnersHandler.mockResolvedValue(groupRunnersData); mockGroupRunnersCountHandler.mockResolvedValue(groupRunnersCountData); + mockRunnerJobCountHandler.mockResolvedValue(runnerJobCountData); }); afterEach(() => { mockGroupRunnersHandler.mockReset(); mockGroupRunnersCountHandler.mockReset(); + mockRunnerJobCountHandler.mockReset(); }); it('shows the runner tabs with a runner count for each type', async () => { diff --git a/spec/frontend/ci/runner/mock_data.js b/spec/frontend/ci/runner/mock_data.js index 51556650c32..58d8e0ee74a 100644 --- a/spec/frontend/ci/runner/mock_data.js +++ b/spec/frontend/ci/runner/mock_data.js @@ -43,6 +43,15 @@ const emptyPageInfo = { endCursor: '', }; +const runnerJobCountData = { + data: { + runner: { + id: 'gid://gitlab/Ci::Runner/99', + jobCount: 999, + }, + }, +}; + // Other mock data // Mock searches and their corresponding urls @@ -348,6 +357,7 @@ export { groupRunnersCountData, emptyPageInfo, runnerData, + runnerJobCountData, runnerWithGroupData, runnerProjectsData, runnerJobsData, diff --git a/spec/frontend/clusters/agents/components/integration_status_spec.js b/spec/frontend/clusters/agents/components/integration_status_spec.js index 28a59391578..0f3da3e02be 100644 --- a/spec/frontend/clusters/agents/components/integration_status_spec.js +++ b/spec/frontend/clusters/agents/components/integration_status_spec.js @@ -58,7 +58,7 @@ describe('IntegrationStatus', () => { }); it('sets collapse component as invisible by default', () => { - expect(findCollapse().props('visible')).toBeUndefined(); + expect(findCollapse().props('visible')).toBe(false); }); }); @@ -73,7 +73,7 @@ describe('IntegrationStatus', () => { }); it('sets collapse component as visible', () => { - expect(findCollapse().attributes('visible')).toBe('true'); + expect(findCollapse().props('visible')).toBe(true); }); }); 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 24b2677f497..97b8e1f7fc8 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 @@ -22,7 +22,7 @@ exports[`Comment templates list item component renders list item 1`] = ` <button aria-controls="reference-1" aria-labelledby="reference-0" - class="btn btn-default btn-default-tertiary btn-md gl-button gl-new-dropdown-icon-only gl-new-dropdown-toggle gl-new-dropdown-toggle-no-caret" + class="btn btn-default btn-default-tertiary btn-icon btn-md gl-button gl-new-dropdown-icon-only gl-new-dropdown-toggle gl-new-dropdown-toggle-no-caret" data-testid="base-dropdown-toggle" id="reference-0" type="button" diff --git a/spec/frontend/commit/commit_pipeline_status_spec.js b/spec/frontend/commit/commit_pipeline_status_spec.js index 08a7ec17785..6d407ed886a 100644 --- a/spec/frontend/commit/commit_pipeline_status_spec.js +++ b/spec/frontend/commit/commit_pipeline_status_spec.js @@ -6,7 +6,7 @@ import fixture from 'test_fixtures/pipelines/pipelines.json'; import { createAlert } from '~/alert'; import Poll from '~/lib/utils/poll'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status.vue'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; jest.mock('~/lib/utils/poll'); jest.mock('visibilityjs'); diff --git a/spec/frontend/commit/components/commit_box_pipeline_status_spec.js b/spec/frontend/commit/components/commit_box_pipeline_status_spec.js index 008a1b2c068..37ce234c61c 100644 --- a/spec/frontend/commit/components/commit_box_pipeline_status_spec.js +++ b/spec/frontend/commit/components/commit_box_pipeline_status_spec.js @@ -5,7 +5,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import CommitBoxPipelineStatus from '~/projects/commit_box/info/components/commit_box_pipeline_status.vue'; import { COMMIT_BOX_POLL_INTERVAL, diff --git a/spec/frontend/commons/nav/user_merge_requests_spec.js b/spec/frontend/commons/nav/user_merge_requests_spec.js deleted file mode 100644 index 114cbbf812c..00000000000 --- a/spec/frontend/commons/nav/user_merge_requests_spec.js +++ /dev/null @@ -1,154 +0,0 @@ -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import * as UserApi from '~/api/user_api'; -import { - openUserCountsBroadcast, - closeUserCountsBroadcast, - refreshUserMergeRequestCounts, -} from '~/commons/nav/user_merge_requests'; - -jest.mock('~/api'); - -const TEST_COUNT = 1000; -const MR_COUNT_CLASS = 'js-merge-requests-count'; - -describe('User Merge Requests', () => { - let channelMock; - let newBroadcastChannelMock; - - beforeEach(() => { - jest.spyOn(document, 'dispatchEvent').mockReturnValue(false); - - global.gon.current_user_id = 123; - global.gon.use_new_navigation = false; - - channelMock = { - postMessage: jest.fn(), - close: jest.fn(), - }; - newBroadcastChannelMock = jest.fn().mockImplementation(() => channelMock); - - global.BroadcastChannel = newBroadcastChannelMock; - setHTMLFixture( - `<div><div class="${MR_COUNT_CLASS}">0</div><div class="js-assigned-mr-count"></div><div class="js-reviewer-mr-count"></div></div>`, - ); - }); - - afterEach(() => { - resetHTMLFixture(); - }); - - const findMRCountText = () => document.body.querySelector(`.${MR_COUNT_CLASS}`).textContent; - - describe('refreshUserMergeRequestCounts', () => { - beforeEach(() => { - jest.spyOn(UserApi, 'getUserCounts').mockResolvedValue({ - data: { - assigned_merge_requests: TEST_COUNT, - review_requested_merge_requests: TEST_COUNT, - }, - }); - }); - - describe('with open broadcast channel', () => { - beforeEach(() => { - openUserCountsBroadcast(); - - return refreshUserMergeRequestCounts(); - }); - - it('updates the top count of merge requests', () => { - expect(findMRCountText()).toEqual(Number(TEST_COUNT + TEST_COUNT).toLocaleString()); - }); - - it('calls the API', () => { - expect(UserApi.getUserCounts).toHaveBeenCalled(); - }); - - it('posts count to BroadcastChannel', () => { - expect(channelMock.postMessage).toHaveBeenCalledWith(TEST_COUNT + TEST_COUNT); - }); - }); - - describe('without open broadcast channel', () => { - beforeEach(() => refreshUserMergeRequestCounts()); - - it('does not post anything', () => { - expect(channelMock.postMessage).not.toHaveBeenCalled(); - }); - }); - - it('does not emit event to refetch counts', () => { - expect(document.dispatchEvent).not.toHaveBeenCalled(); - }); - }); - - describe('openUserCountsBroadcast', () => { - beforeEach(() => { - openUserCountsBroadcast(); - }); - - it('creates BroadcastChannel that updates DOM on message received', () => { - expect(findMRCountText()).toEqual('0'); - - channelMock.onmessage({ data: TEST_COUNT }); - - expect(newBroadcastChannelMock).toHaveBeenCalled(); - expect(findMRCountText()).toEqual(TEST_COUNT.toLocaleString()); - }); - - it('closes if called while already open', () => { - expect(channelMock.close).not.toHaveBeenCalled(); - - openUserCountsBroadcast(); - - expect(newBroadcastChannelMock).toHaveBeenCalled(); - expect(channelMock.close).toHaveBeenCalled(); - }); - }); - - describe('closeUserCountsBroadcast', () => { - describe('when not opened', () => { - it('does nothing', () => { - expect(channelMock.close).not.toHaveBeenCalled(); - }); - }); - - describe('when opened', () => { - beforeEach(() => { - openUserCountsBroadcast(); - }); - - it('closes', () => { - expect(channelMock.close).not.toHaveBeenCalled(); - - closeUserCountsBroadcast(); - - expect(channelMock.close).toHaveBeenCalled(); - }); - }); - }); - - describe('if new navigation is enabled', () => { - beforeEach(() => { - global.gon.use_new_navigation = true; - jest.spyOn(UserApi, 'getUserCounts'); - }); - - it('openUserCountsBroadcast is a noop', () => { - openUserCountsBroadcast(); - expect(newBroadcastChannelMock).not.toHaveBeenCalled(); - }); - - describe('refreshUserMergeRequestCounts', () => { - it('does not call api', async () => { - await refreshUserMergeRequestCounts(); - expect(UserApi.getUserCounts).not.toHaveBeenCalled(); - }); - - it('emits event to refetch counts', async () => { - await refreshUserMergeRequestCounts(); - expect(document.dispatchEvent).toHaveBeenCalledWith(new CustomEvent('todo:toggle')); - }); - }); - }); -}); diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap index a708f7d5f47..0fafd42095b 100644 --- a/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap +++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap @@ -3,7 +3,7 @@ exports[`content_editor/components/toolbar_button displays tertiary, medium button with a provided label and icon 1`] = ` <b-button-stub aria-label="Bold" - class="btn-default-tertiary btn-icon gl-button gl-mr-3" + class="btn-default-tertiary btn-icon gl-button gl-mr-2" size="sm" tag="button" title="Bold" diff --git a/spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js index 2a6ab75227c..6e8a6092667 100644 --- a/spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js @@ -80,6 +80,7 @@ describe('content_editor/components/bubble_menus/code_block_bubble_menu', () => await emitEditorEvent({ event: 'transaction', tiptapEditor }); expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Plain text'); + expect(wrapper.findComponent(GlDropdown).attributes('contenteditable')).toBe(String(false)); }); it('selects appropriate language based on the code block', async () => { diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index 816c9458201..bbc0203344c 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -1,6 +1,8 @@ import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; import { EditorContent, Editor } from '@tiptap/vue-2'; import { nextTick } from 'vue'; +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ContentEditor from '~/content_editor/components/content_editor.vue'; import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue'; @@ -16,11 +18,10 @@ import waitForPromises from 'helpers/wait_for_promises'; import { KEYDOWN_EVENT } from '~/content_editor/constants'; import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue'; -jest.mock('~/emoji'); - describe('ContentEditor', () => { let wrapper; let renderMarkdown; + let mock; const uploadsPath = '/uploads'; const findEditorElement = () => wrapper.findByTestId('content-editor'); @@ -32,6 +33,7 @@ describe('ContentEditor', () => { wrapper = shallowMountExtended(ContentEditor, { propsData: { renderMarkdown, + markdownDocsPath: '/docs/markdown', uploadsPath, markdown, autofocus, @@ -49,9 +51,17 @@ describe('ContentEditor', () => { }; beforeEach(() => { + mock = new MockAdapter(axios); + // ignore /-/emojis requests + mock.onGet().reply(200, []); + renderMarkdown = jest.fn(); }); + afterEach(() => { + mock.restore(); + }); + it('triggers initialized event', () => { createWrapper(); diff --git a/spec/frontend/content_editor/components/suggestions_dropdown_spec.js b/spec/frontend/content_editor/components/suggestions_dropdown_spec.js index ee3ad59bf9a..b17a1b5fc11 100644 --- a/spec/frontend/content_editor/components/suggestions_dropdown_spec.js +++ b/spec/frontend/content_editor/components/suggestions_dropdown_spec.js @@ -1,5 +1,6 @@ -import { GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui'; +import { GlAvatar, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import SuggestionsDropdown from '~/content_editor/components/suggestions_dropdown.vue'; @@ -14,11 +15,17 @@ describe('~/content_editor/components/suggestions_dropdown', () => { command: jest.fn(), ...propsData, }, + stubs: ['gl-emoji'], }), ); }; - const exampleUser = { username: 'root', avatar_url: 'root_avatar.png', type: 'User' }; + const exampleUser = { + username: 'root', + avatar_url: 'root_avatar.png', + type: 'User', + name: 'Administrator', + }; const exampleIssue = { iid: 123, title: 'Test Issue' }; const exampleMergeRequest = { iid: 224, title: 'Test MR' }; const exampleMilestone1 = { iid: 21, title: '13' }; @@ -61,11 +68,14 @@ describe('~/content_editor/components/suggestions_dropdown', () => { title: 'Project creation QueryRecorder logs', }; const exampleEmoji = { - c: 'people', - e: '😃', - d: 'smiling face with open mouth', - u: '6.0', - name: 'smiley', + emoji: { + c: 'people', + e: '😃', + d: 'smiling face with open mouth', + u: '6.0', + name: 'smiley', + }, + fieldValue: 'smiley', }; const insertedEmojiProps = { @@ -95,6 +105,68 @@ describe('~/content_editor/components/suggestions_dropdown', () => { expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(loading); }); + it('selects first item if query is not empty and items are available', async () => { + buildWrapper({ + propsData: { + char: '@', + nodeType: 'reference', + nodeProps: { + referenceType: 'member', + }, + items: [exampleUser], + query: 'ro', + }, + }); + + await nextTick(); + + expect( + wrapper.findByTestId('content-editor-suggestions-dropdown').find('li').classes(), + ).toContain('focused'); + }); + + describe('when query is defined', () => { + it.each` + nodeType | referenceType | reference | query | expectedHTML + ${'reference'} | ${'user'} | ${exampleUser} | ${'r'} | ${'<strong class="gl-text-body!">r</strong>oot'} + ${'reference'} | ${'user'} | ${exampleUser} | ${'r'} | ${'Administ<strong class="gl-text-body!">r</strong>ator'} + ${'reference'} | ${'issue'} | ${exampleIssue} | ${'test'} | ${'<strong class="gl-text-body!">Test</strong> Issue'} + ${'reference'} | ${'issue'} | ${exampleIssue} | ${'12'} | ${'<strong class="gl-text-body!">12</strong>3'} + ${'reference'} | ${'merge_request'} | ${exampleMergeRequest} | ${'test'} | ${'<strong class="gl-text-body!">Test</strong> MR'} + ${'reference'} | ${'merge_request'} | ${exampleMergeRequest} | ${'22'} | ${'<strong class="gl-text-body!">22</strong>4'} + ${'reference'} | ${'epic'} | ${exampleEpic} | ${'rem'} | ${'❓ <strong class="gl-text-body!">Rem</strong>ote Development | Solution validation'} + ${'reference'} | ${'epic'} | ${exampleEpic} | ${'88'} | ${'gitlab-org&<strong class="gl-text-body!">88</strong>84'} + ${'reference'} | ${'milestone'} | ${exampleMilestone1} | ${'1'} | ${'<strong class="gl-text-body!">1</strong>3'} + ${'reference'} | ${'command'} | ${exampleCommand} | ${'due'} | ${'<strong class="gl-text-body!">due</strong>'} + ${'reference'} | ${'command'} | ${exampleCommand} | ${'due'} | ${'Set <strong class="gl-text-body!">due</strong> date'} + ${'reference'} | ${'label'} | ${exampleLabel1} | ${'c'} | ${'<strong class="gl-text-body!">C</strong>reate'} + ${'reference'} | ${'vulnerability'} | ${exampleVulnerability} | ${'network'} | ${'System procs <strong class="gl-text-body!">network</strong> activity'} + ${'reference'} | ${'vulnerability'} | ${exampleVulnerability} | ${'85'} | ${'60<strong class="gl-text-body!">85</strong>0147'} + ${'reference'} | ${'snippet'} | ${exampleSnippet} | ${'project'} | ${'<strong class="gl-text-body!">Project</strong> creation QueryRecorder logs'} + ${'reference'} | ${'snippet'} | ${exampleSnippet} | ${'242'} | ${'<strong class="gl-text-body!">242</strong>0859'} + ${'emoji'} | ${'emoji'} | ${exampleEmoji} | ${'sm'} | ${'<strong class="gl-text-body!">sm</strong>iley'} + `( + 'highlights query as bolded in $referenceType text', + ({ nodeType, referenceType, reference, query, expectedHTML }) => { + buildWrapper({ + propsData: { + char: '@', + nodeType, + nodeProps: { + referenceType, + }, + items: [reference], + query, + }, + }); + + expect(wrapper.findByTestId('content-editor-suggestions-dropdown').html()).toContain( + expectedHTML, + ); + }, + ); + }); + describe('on item select', () => { it.each` nodeType | referenceType | char | reference | insertedText | insertedProps @@ -146,7 +218,7 @@ describe('~/content_editor/components/suggestions_dropdown', () => { }); describe('rendering user references', () => { - it('displays avatar labeled component', () => { + it('displays avatar component', () => { buildWrapper({ propsData: { char: '@', @@ -157,13 +229,11 @@ describe('~/content_editor/components/suggestions_dropdown', () => { }, }); - expect(wrapper.findComponent(GlAvatarLabeled).attributes()).toEqual( - expect.objectContaining({ - label: exampleUser.username, - shape: 'circle', - src: exampleUser.avatar_url, - }), - ); + expect(wrapper.findComponent(GlAvatar).attributes()).toMatchObject({ + entityname: exampleUser.username, + shape: 'circle', + src: exampleUser.avatar_url, + }); }); describe.each` @@ -273,20 +343,46 @@ describe('~/content_editor/components/suggestions_dropdown', () => { it('displays emoji', () => { const testEmojis = [ { - c: 'people', - e: '😄', - d: 'smiling face with open mouth and smiling eyes', - u: '6.0', - name: 'smile', + emoji: { + c: 'people', + e: '😄', + d: 'smiling face with open mouth and smiling eyes', + u: '6.0', + name: 'smile', + }, + fieldValue: 'smile', + }, + { + emoji: { + c: 'people', + e: '😸', + d: 'grinning cat face with smiling eyes', + u: '6.0', + name: 'smile_cat', + }, + fieldValue: 'smile_cat', + }, + { + emoji: { + c: 'people', + e: '😃', + d: 'smiling face with open mouth', + u: '6.0', + name: 'smiley', + }, + fieldValue: 'smiley', }, { - c: 'people', - e: '😸', - d: 'grinning cat face with smiling eyes', - u: '6.0', - name: 'smile_cat', + emoji: { + c: 'custom', + e: null, + d: 'party-parrot', + u: 'custom', + name: 'party-parrot', + src: 'https://cultofthepartyparrot.com/parrots/hd/parrot.gif', + }, + fieldValue: 'party-parrot', }, - { c: 'people', e: '😃', d: 'smiling face with open mouth', u: '6.0', name: 'smiley' }, ]; buildWrapper({ @@ -298,11 +394,41 @@ describe('~/content_editor/components/suggestions_dropdown', () => { }, }); - testEmojis.forEach((testEmoji) => { - expect(wrapper.text()).toContain(testEmoji.e); - expect(wrapper.text()).toContain(testEmoji.d); - expect(wrapper.text()).toContain(testEmoji.name); - }); + expect(wrapper.findAllComponents('gl-emoji-stub').at(0).html()).toMatchInlineSnapshot(` + <gl-emoji-stub + data-name="smile" + data-unicode-version="6.0" + title="smiling face with open mouth and smiling eyes" + > + 😄 + </gl-emoji-stub> + `); + expect(wrapper.findAllComponents('gl-emoji-stub').at(1).html()).toMatchInlineSnapshot(` + <gl-emoji-stub + data-name="smile_cat" + data-unicode-version="6.0" + title="grinning cat face with smiling eyes" + > + 😸 + </gl-emoji-stub> + `); + expect(wrapper.findAllComponents('gl-emoji-stub').at(2).html()).toMatchInlineSnapshot(` + <gl-emoji-stub + data-name="smiley" + data-unicode-version="6.0" + title="smiling face with open mouth" + > + 😃 + </gl-emoji-stub> + `); + expect(wrapper.findAllComponents('gl-emoji-stub').at(3).html()).toMatchInlineSnapshot(` + <gl-emoji-stub + data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" + data-name="party-parrot" + data-unicode-version="custom" + title="party-parrot" + /> + `); }); }); }); diff --git a/spec/frontend/content_editor/components/wrappers/code_block_spec.js b/spec/frontend/content_editor/components/wrappers/code_block_spec.js index 0093393eceb..1f15dc17f7f 100644 --- a/spec/frontend/content_editor/components/wrappers/code_block_spec.js +++ b/spec/frontend/content_editor/components/wrappers/code_block_spec.js @@ -97,6 +97,7 @@ describe('content/components/wrappers/code_block', () => { const label = wrapper.findByTestId('frontmatter-label'); expect(label.text()).toEqual('frontmatter:yaml'); + expect(label.attributes('contenteditable')).toBe('false'); expect(label.classes()).toEqual(['gl-absolute', 'gl-top-0', 'gl-right-3']); }); @@ -128,6 +129,9 @@ describe('content/components/wrappers/code_block', () => { await nextTick(); expect(wrapper.find('img').attributes('src')).toBe('url/to/some/diagram'); + expect(wrapper.findByTestId('sandbox-preview').attributes('contenteditable')).toBe( + String(false), + ); jest.spyOn(tiptapEditor, 'isActive').mockReturnValue(false); @@ -214,6 +218,9 @@ describe('content/components/wrappers/code_block', () => { }); it('shows a code suggestion block', () => { + expect(wrapper.findByTestId('code-suggestion-box').attributes('contenteditable')).toBe( + 'false', + ); expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 5 to 5'); expect(findCodeDeleted()).toMatchInlineSnapshot(` <code 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 275f48ea857..94628f2b2c5 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 @@ -165,6 +165,9 @@ describe('content/components/wrappers/table_cell_base', () => { it('does not allow adding a row before the header', () => { expect(findDropdown().text()).not.toContain('Insert row before'); + expect(wrapper.findByTestId('actions-dropdown').attributes('contenteditable')).toBe( + 'false', + ); }); it('does not allow removing the header row', async () => { diff --git a/spec/frontend/content_editor/extensions/copy_paste_spec.js b/spec/frontend/content_editor/extensions/copy_paste_spec.js index e290b4e5137..6969f4985a1 100644 --- a/spec/frontend/content_editor/extensions/copy_paste_spec.js +++ b/spec/frontend/content_editor/extensions/copy_paste_spec.js @@ -20,12 +20,6 @@ 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 CODE_SUGGESTION_HTML = - '<pre data-lang-params="-0+0" class="js-syntax-highlight language-suggestion" lang="suggestion">Suggested code</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 dir="auto">Some text with <strong>bold</strong> and <em>italic</em> text.</p>'; @@ -123,19 +117,6 @@ describe('content_editor/extensions/copy_paste', () => { expect(await triggerPasteEventHandler(buildClipboardEvent({ types, data }))).toBe(true); }); - it.each` - nodeType | html | handled | desc - ${'codeBlock'} | ${CODE_BLOCK_HTML} | ${false} | ${'does not handle'} - ${'codeSuggestion'} | ${CODE_SUGGESTION_HTML} | ${false} | ${'does not handle'} - ${'diagram'} | ${DIAGRAM_HTML} | ${false} | ${'does not handle'} - ${'frontmatter'} | ${FRONTMATTER_HTML} | ${false} | ${'does not handle'} - ${'paragraph'} | ${PARAGRAPH_HTML} | ${true} | ${'handles'} - `('$desc paste if currently a `$nodeType` is in focus', async ({ html, handled }) => { - tiptapEditor.commands.insertContent(html); - - expect(await triggerPasteEventHandler(buildClipboardEvent())).toBe(handled); - }); - describe.each` eventName | expectedDoc ${'cut'} | ${() => doc(p())} diff --git a/spec/frontend/content_editor/extensions/reference_spec.js b/spec/frontend/content_editor/extensions/reference_spec.js index c25c7c41d75..d4b07d5127e 100644 --- a/spec/frontend/content_editor/extensions/reference_spec.js +++ b/spec/frontend/content_editor/extensions/reference_spec.js @@ -1,9 +1,15 @@ import Reference from '~/content_editor/extensions/reference'; +import ReferenceLabel from '~/content_editor/extensions/reference_label'; import AssetResolver from '~/content_editor/services/asset_resolver'; import { RESOLVED_ISSUE_HTML, RESOLVED_MERGE_REQUEST_HTML, RESOLVED_EPIC_HTML, + RESOLVED_LABEL_HTML, + RESOLVED_SNIPPET_HTML, + RESOLVED_MILESTONE_HTML, + RESOLVED_USER_HTML, + RESOLVED_VULNERABILITY_HTML, } from '../test_constants'; import { createTestEditor, @@ -17,6 +23,7 @@ describe('content_editor/extensions/reference', () => { let doc; let p; let reference; + let referenceLabel; let renderMarkdown; let assetResolver; @@ -25,33 +32,54 @@ describe('content_editor/extensions/reference', () => { assetResolver = new AssetResolver({ renderMarkdown }); tiptapEditor = createTestEditor({ - extensions: [Reference.configure({ assetResolver })], + extensions: [Reference.configure({ assetResolver }), ReferenceLabel], }); ({ - builders: { doc, p, reference }, + builders: { doc, p, reference, referenceLabel }, } = createDocBuilder({ tiptapEditor, names: { reference: { nodeType: Reference.name }, + referenceLabel: { nodeType: ReferenceLabel.name }, }, })); }); describe('when typing a valid reference input rule', () => { - const buildExpectedDoc = (href, originalText, referenceType, text) => + const buildExpectedDoc = (href, originalText, referenceType, text = originalText) => doc(p(reference({ className: null, href, originalText, referenceType, text }), ' ')); + const buildExpectedDocForLabel = (href, originalText, text, color) => + doc( + p( + referenceLabel({ + className: null, + referenceType: 'label', + href, + originalText, + text, + color, + }), + ' ', + ), + ); + 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)')} + 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)')} + ${'@root'} | ${RESOLVED_USER_HTML} | ${() => buildExpectedDoc('/root', '@root', 'user')} + ${'~Aquanix'} | ${RESOLVED_LABEL_HTML} | ${() => buildExpectedDocForLabel('/gitlab-org/gitlab-shell/-/issues?label_name=Aquanix', '~Aquanix', 'Aquanix', 'rgb(230, 84, 49)')} + ${'%v4.0'} | ${RESOLVED_MILESTONE_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab-shell/-/milestones/5', '%v4.0', 'milestone')} + ${'$25'} | ${RESOLVED_SNIPPET_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab-shell/-/snippets/25', '$25', 'snippet')} + ${'[vulnerability:1]'} | ${RESOLVED_VULNERABILITY_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab-shell/-/security/vulnerabilities/1', '[vulnerability:1]', 'vulnerability')} `( 'replaces the input rule ($inputRuleText) with a reference node', async ({ inputRuleText, mockReferenceHtml, expectedDoc }) => { @@ -61,8 +89,8 @@ describe('content_editor/extensions/reference', () => { action() { renderMarkdown.mockResolvedValueOnce(mockReferenceHtml); - tiptapEditor.commands.insertContent({ type: 'text', text: inputRuleText }); - triggerNodeInputRule({ tiptapEditor, inputRuleText }); + tiptapEditor.commands.insertContent({ type: 'text', text: `${inputRuleText} ` }); + triggerNodeInputRule({ tiptapEditor, inputRuleText: `${inputRuleText} ` }); }, }); diff --git a/spec/frontend/content_editor/services/__snapshots__/data_source_factory_spec.js.snap b/spec/frontend/content_editor/services/__snapshots__/data_source_factory_spec.js.snap new file mode 100644 index 00000000000..2d16c6b1a2f --- /dev/null +++ b/spec/frontend/content_editor/services/__snapshots__/data_source_factory_spec.js.snap @@ -0,0 +1,256 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DataSourceFactory filters items based on command "/assign" for reference type "user" and command 1`] = ` +Array [ + "florida.schoen", + "root", + "all", + "lakeesha.batz", + "laurene_blick", + "myrtis", + "patty", + "Commit451", + "flightjs", + "gitlab-instance-ade037f9", + "gitlab-org", + "gnuwget", + "h5bp", + "jashkenas", + "twitter", +] +`; + +exports[`DataSourceFactory filters items based on command "/assign_reviewer" for reference type "user" and command 1`] = ` +Array [ + "florida.schoen", + "root", + "all", + "errol", + "evelynn_olson", + "Commit451", + "flightjs", + "gitlab-instance-ade037f9", + "gitlab-org", + "gnuwget", + "h5bp", + "jashkenas", + "twitter", +] +`; + +exports[`DataSourceFactory filters items based on command "/label" for reference type "label" and command 1`] = ` +Array [ + "Bronce", + "Contour", + "Corolla", + "Cygsync", + "Frontier", + "Grand Am", + "Onesync", + "Phone", + "Pynefunc", + "Trinix", + "Trounswood", + "group::knowledge", + "scoped label", + "type::one", + "type::two", +] +`; + +exports[`DataSourceFactory filters items based on command "/reassign" for reference type "user" and command 1`] = ` +Array [ + "florida.schoen", + "root", + "all", + "errol", + "evelynn_olson", + "lakeesha.batz", + "laurene_blick", + "myrtis", + "patty", + "Commit451", + "flightjs", + "gitlab-instance-ade037f9", + "gitlab-org", + "gnuwget", + "h5bp", +] +`; + +exports[`DataSourceFactory filters items based on command "/reassign_reviewer" for reference type "user" and command 1`] = ` +Array [ + "florida.schoen", + "root", + "all", + "errol", + "evelynn_olson", + "lakeesha.batz", + "laurene_blick", + "myrtis", + "patty", + "Commit451", + "flightjs", + "gitlab-instance-ade037f9", + "gitlab-org", + "gnuwget", + "h5bp", +] +`; + +exports[`DataSourceFactory filters items based on command "/relabel" for reference type "label" and command 1`] = ` +Array [ + "Amsche", + "Brioffe", + "Bronce", + "Bryncefunc", + "Contour", + "Corolla", + "Cygsync", + "Frontier", + "Ghost", + "Grand Am", + "Onesync", + "Phone", + "Pynefunc", + "Trinix", + "Trounswood", +] +`; + +exports[`DataSourceFactory filters items based on command "/unassign" for reference type "user" and command 1`] = ` +Array [ + "errol", + "evelynn_olson", +] +`; + +exports[`DataSourceFactory filters items based on command "/unassign_reviewer" for reference type "user" and command 1`] = ` +Array [ + "lakeesha.batz", + "laurene_blick", + "myrtis", + "patty", +] +`; + +exports[`DataSourceFactory filters items based on command "/unlabel" for reference type "label" and command 1`] = ` +Array [ + "Amsche", + "Brioffe", + "Bryncefunc", + "Ghost", +] +`; + +exports[`DataSourceFactory for reference type "command", searches for "re" correctly 1`] = ` +Array [ + "relabel", + "remove_milestone", + "remove_estimate", + "remove_time_spent", + "relate", + "remove_epic", + "reassign", + "create_merge_request", +] +`; + +exports[`DataSourceFactory for reference type "epic", searches for "n" correctly 1`] = ` +Array [ + "Nobis quidem aspernatur reprehenderit sunt ut ipsum tempora sapiente sed iste.", + "Minus eius ut omnis quos sunt dicta ex ipsum.", + "Quae nostrum possimus rerum aliquam pariatur a eos aut id.", + "Dicta incidunt vel dignissimos sint sit esse est quibusdam quidem consequatur.", + "Doloremque a quisquam qui culpa numquam doloribus similique iure enim.", +] +`; + +exports[`DataSourceFactory for reference type "issue", searches for "q" correctly 1`] = ` +Array [ + "Quasi id et et nihil sint autem.", + "Eaque omnis eius quas necessitatibus hic ut et corrupti.", + "Aut quisquam magnam eos distinctio incidunt perferendis fugit.", + "Dolorem quisquam cupiditate consequatur perspiciatis sequi eligendi ullam.", + "Nesciunt quia molestiae in aliquam amet et dolorem.", + "Porro tempore qui qui culpa saepe et nam quos.", + "Sed sint a est consequatur quae quasi autem debitis alias.", + "Molestiae minima maxime optio nihil quam eveniet dolor.", + "Et laboriosam aut ratione voluptatem quasi recusandae.", + "Et molestiae delectus voluptates velit vero illo aut rerum quo et.", +] +`; + +exports[`DataSourceFactory for reference type "label", searches for "c" correctly 1`] = ` +Array [ + "Contour", + "Corolla", + "Cygsync", + "scoped label", + "Amsche", + "Bronce", + "Bryncefunc", + "Onesync", + "Pynefunc", +] +`; + +exports[`DataSourceFactory for reference type "merge_request", searches for "n" correctly 1`] = ` +Array [ + "Blanditiis maxime voluptatem ut pariatur vel autem vero non quod libero.", + "Optio nemo qui dolorem sit ipsum qui saepe.", + "Draft: Alunny/publish lib", + "Draft: Fix event current target", + "Draft: Resolve \\"hgvbbvnnb\\"", + "Autem eaque et sed provident enim corrupti molestiae.", + "Always call registry's trigger method from withRegistration", +] +`; + +exports[`DataSourceFactory for reference type "milestone", searches for "16" correctly 1`] = ` +Array [ + "16.7", + "16.8", + "16.9", + "16.10", + "16.11", + "16.0 (expired)", + "16.1 (expired)", + "16.2 (expired)", + "16.3 (expired)", + "16.4 (expired)", + "16.5 (expired)", + "16.6 (expired)", +] +`; + +exports[`DataSourceFactory for reference type "snippet", searches for "s" correctly 1`] = ` +Array [ + "ss", + "test snippet", + "another test snippet", +] +`; + +exports[`DataSourceFactory for reference type "user", searches for "r" correctly 1`] = ` +Array [ + "root", + "errol", + "lakeesha.batz", + "myrtis", + "florida.schoen", + "laurene_blick", + "all", + "twitter", + "gitlab-org", + "evelynn_olson", +] +`; + +exports[`DataSourceFactory for reference type "vulnerability", searches for "cross" correctly 1`] = ` +Array [ + "Cross Site Scripting (Persistent)", + "Cross Site Scripting (Persistent)", + "Cross Site Scripting (Persistent)", +] +`; diff --git a/spec/frontend/content_editor/services/asset_resolver_spec.js b/spec/frontend/content_editor/services/asset_resolver_spec.js index 292eec6db77..b0135a6bc9f 100644 --- a/spec/frontend/content_editor/services/asset_resolver_spec.js +++ b/spec/frontend/content_editor/services/asset_resolver_spec.js @@ -3,6 +3,11 @@ import { RESOLVED_ISSUE_HTML, RESOLVED_MERGE_REQUEST_HTML, RESOLVED_EPIC_HTML, + RESOLVED_LABEL_HTML, + RESOLVED_SNIPPET_HTML, + RESOLVED_MILESTONE_HTML, + RESOLVED_USER_HTML, + RESOLVED_VULNERABILITY_HTML, } from '../test_constants'; describe('content_editor/services/asset_resolver', () => { @@ -48,6 +53,32 @@ describe('content_editor/services/asset_resolver', () => { text: '!1 (merged)', }; + const resolvedLabel = { + backgroundColor: 'rgb(230, 84, 49)', + href: '/gitlab-org/gitlab-shell/-/issues?label_name=Aquanix', + text: 'Aquanix', + }; + + const resolvedSnippet = { + href: '/gitlab-org/gitlab-shell/-/snippets/25', + text: '$25', + }; + + const resolvedMilestone = { + href: '/gitlab-org/gitlab-shell/-/milestones/5', + text: '%v4.0', + }; + + const resolvedUser = { + href: '/root', + text: '@root', + }; + + const resolvedVulnerability = { + href: '/gitlab-org/gitlab-shell/-/security/vulnerabilities/1', + text: '[vulnerability:1]', + }; + describe.each` referenceType | referenceId | sentMarkdown | returnedHtml | resolvedReference ${'issue'} | ${'#1'} | ${'#1 #1+ #1+s'} | ${RESOLVED_ISSUE_HTML} | ${resolvedIssue} @@ -59,7 +90,9 @@ describe('content_editor/services/asset_resolver', () => { it(`resolves ${referenceType} reference to href, text, title and summary`, async () => { renderMarkdown.mockResolvedValue(returnedHtml); - expect(await assetResolver.resolveReference(referenceId)).toEqual(resolvedReference); + expect(await assetResolver.resolveReference(referenceId)).toMatchObject( + resolvedReference, + ); }); it.each` @@ -74,6 +107,26 @@ describe('content_editor/services/asset_resolver', () => { }, ); + describe.each` + referenceType | referenceId | returnedHtml | resolvedReference + ${'label'} | ${'~Aquanix'} | ${RESOLVED_LABEL_HTML} | ${resolvedLabel} + ${'snippet'} | ${'$25'} | ${RESOLVED_SNIPPET_HTML} | ${resolvedSnippet} + ${'milestone'} | ${'%v4.0'} | ${RESOLVED_MILESTONE_HTML} | ${resolvedMilestone} + ${'user'} | ${'@root'} | ${RESOLVED_USER_HTML} | ${resolvedUser} + ${'vulnerability'} | ${'[vulnerability:1]'} | ${RESOLVED_VULNERABILITY_HTML} | ${resolvedVulnerability} + `( + 'for reference type $referenceType', + ({ referenceType, referenceId, returnedHtml, resolvedReference }) => { + it(`resolves ${referenceType} reference to href, text and additional props (if any)`, async () => { + renderMarkdown.mockResolvedValue(returnedHtml); + + expect(await assetResolver.resolveReference(referenceId)).toMatchObject( + resolvedReference, + ); + }); + }, + ); + it.each` case | sentMarkdown | returnedHtml ${'no html is returned'} | ${''} | ${''} diff --git a/spec/frontend/content_editor/services/autocomplete_mock_data.js b/spec/frontend/content_editor/services/autocomplete_mock_data.js new file mode 100644 index 00000000000..c1bf2a6ae5b --- /dev/null +++ b/spec/frontend/content_editor/services/autocomplete_mock_data.js @@ -0,0 +1,967 @@ +export const MOCK_MEMBERS = [ + { + type: 'User', + username: 'florida.schoen', + name: 'Anglea Durgan', + avatar_url: + 'https://www.gravatar.com/avatar/ac82b5615d3308ecbcacedad361af8e7?s=80\u0026d=identicon', + availability: null, + }, + { + type: 'User', + username: 'root', + name: 'Administrator', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + availability: null, + }, + { + username: 'all', + name: 'All Project and Group Members', + count: 8, + }, + { + type: 'User', + username: 'errol', + name: "Linnie O'Connell", + avatar_url: + 'https://www.gravatar.com/avatar/d3d9a468a9884eb217fad5ca5b2b9bd7?s=80\u0026d=identicon', + availability: null, + }, + { + type: 'User', + username: 'evelynn_olson', + name: 'Dimple Dare', + avatar_url: + 'https://www.gravatar.com/avatar/bc1e51ee3512c2b4442f51732d655107?s=80\u0026d=identicon', + availability: null, + }, + { + type: 'User', + username: 'lakeesha.batz', + name: 'Larae Veum', + avatar_url: + 'https://www.gravatar.com/avatar/e5605cb9bbb1a28640d65f25f256e541?s=80\u0026d=identicon', + availability: null, + }, + { + type: 'User', + username: 'laurene_blick', + name: 'Evelina Murray', + avatar_url: + 'https://www.gravatar.com/avatar/389768eef61b7b2d125c64ee01c240fb?s=80\u0026d=identicon', + availability: null, + }, + { + type: 'User', + username: 'myrtis', + name: 'Fernanda Adams', + avatar_url: + 'https://www.gravatar.com/avatar/719d5569bd31d4a70e350b4205fa2cb5?s=80\u0026d=identicon', + availability: null, + }, + { + type: 'User', + username: 'patty', + name: 'Emily Toy', + avatar_url: + 'https://www.gravatar.com/avatar/dca2077b662338808459dc11e70d6688?s=80\u0026d=identicon', + availability: null, + }, + { + type: 'Group', + username: 'Commit451', + name: 'Commit451', + avatar_url: null, + count: 5, + mentionsDisabled: null, + }, + { + type: 'Group', + username: 'flightjs', + name: 'Flightjs', + avatar_url: null, + count: 5, + mentionsDisabled: null, + }, + { + type: 'Group', + username: 'gitlab-instance-ade037f9', + name: 'GitLab Instance', + avatar_url: null, + count: 1, + mentionsDisabled: null, + }, + { + type: 'Group', + username: 'gitlab-org', + name: 'Gitlab Org', + avatar_url: null, + count: 5, + mentionsDisabled: null, + }, + { + type: 'Group', + username: 'gnuwget', + name: 'Gnuwget', + avatar_url: null, + count: 5, + mentionsDisabled: null, + }, + { + type: 'Group', + username: 'h5bp', + name: 'H5bp', + avatar_url: null, + count: 4, + mentionsDisabled: null, + }, + { + type: 'Group', + username: 'jashkenas', + name: 'Jashkenas', + avatar_url: null, + count: 5, + mentionsDisabled: null, + }, + { + type: 'Group', + username: 'twitter', + name: 'Twitter', + avatar_url: null, + count: 5, + mentionsDisabled: null, + }, +]; + +export const MOCK_ASSIGNEES = MOCK_MEMBERS.filter( + ({ username }) => username === 'errol' || username === 'evelynn_olson', +); + +export const MOCK_REVIEWERS = MOCK_MEMBERS.filter( + ({ username }) => + username === 'lakeesha.batz' || + username === 'laurene_blick' || + username === 'myrtis' || + username === 'patty', +); + +export const MOCK_ISSUES = [ + { + iid: 31, + title: 'rdfhdfj', + id: null, + }, + { + iid: 30, + title: 'incident1', + id: null, + }, + { + iid: 29, + title: 'example feature rollout', + id: null, + }, + { + iid: 28, + title: 'sagasg', + id: null, + }, + { + iid: 26, + title: 'Quasi id et et nihil sint autem.', + id: null, + }, + { + iid: 25, + title: 'Dolorem quisquam cupiditate consequatur perspiciatis sequi eligendi ullam.', + id: null, + }, + { + iid: 24, + title: 'Et molestiae delectus voluptates velit vero illo aut rerum quo et.', + id: null, + }, + { + iid: 23, + title: 'Nesciunt quia molestiae in aliquam amet et dolorem.', + id: null, + }, + { + iid: 22, + title: 'Sint asperiores unde vel autem delectus ullam dolor nihil et.', + id: null, + }, + { + iid: 21, + title: 'Eaque omnis eius quas necessitatibus hic ut et corrupti.', + id: null, + }, + { + iid: 20, + title: 'Porro tempore qui qui culpa saepe et nam quos.', + id: null, + }, + { + iid: 19, + title: 'Molestiae minima maxime optio nihil quam eveniet dolor.', + id: null, + }, + { + iid: 18, + title: 'Sed sint a est consequatur quae quasi autem debitis alias.', + id: null, + }, + { + iid: 6, + title: 'Et laboriosam aut ratione voluptatem quasi recusandae.', + id: null, + }, + { + iid: 2, + title: 'Aut quisquam magnam eos distinctio incidunt perferendis fugit.', + id: null, + }, +]; + +export const MOCK_EPICS = [ + { + iid: 6, + title: 'sgs', + reference: 'flightjs\u00266', + }, + { + iid: 5, + title: 'Doloremque a quisquam qui culpa numquam doloribus similique iure enim.', + reference: 'flightjs\u00265', + }, + { + iid: 4, + title: 'Minus eius ut omnis quos sunt dicta ex ipsum.', + reference: 'flightjs\u00264', + }, + { + iid: 3, + title: 'Quae nostrum possimus rerum aliquam pariatur a eos aut id.', + reference: 'flightjs\u00263', + }, + { + iid: 2, + title: 'Nobis quidem aspernatur reprehenderit sunt ut ipsum tempora sapiente sed iste.', + reference: 'flightjs\u00262', + }, + { + iid: 1, + title: 'Dicta incidunt vel dignissimos sint sit esse est quibusdam quidem consequatur.', + reference: 'flightjs\u00261', + }, +]; + +export const MOCK_MERGE_REQUESTS = [ + { + iid: 12, + title: "Always call registry's trigger method from withRegistration", + id: null, + }, + { + iid: 11, + title: 'Draft: Alunny/publish lib', + id: null, + }, + { + iid: 10, + title: 'Draft: Resolve "hgvbbvnnb"', + id: null, + }, + { + iid: 9, + title: 'Draft: Fix event current target', + id: null, + }, + { + iid: 3, + title: 'Autem eaque et sed provident enim corrupti molestiae.', + id: null, + }, + { + iid: 2, + title: 'Blanditiis maxime voluptatem ut pariatur vel autem vero non quod libero.', + id: null, + }, + { + iid: 1, + title: 'Optio nemo qui dolorem sit ipsum qui saepe.', + id: null, + }, +]; + +export const MOCK_SNIPPETS = [ + { + id: 24, + title: 'ss', + }, + { + id: 22, + title: 'another test snippet', + }, + { + id: 21, + title: 'test snippet', + }, +]; + +export const MOCK_LABELS = [ + { + title: 'Amsche', + color: '#9964cf', + type: 'GroupLabel', + textColor: '#FFFFFF', + set: true, + }, + { + title: 'Brioffe', + color: '#203e13', + type: 'GroupLabel', + textColor: '#FFFFFF', + set: true, + }, + { + title: 'Bronce', + color: '#c0b7f2', + type: 'GroupLabel', + textColor: '#1F1E24', + }, + { + title: 'Bryncefunc', + color: '#8baa5e', + type: 'GroupLabel', + textColor: '#FFFFFF', + set: true, + }, + { + title: 'Contour', + color: '#8cf3a3', + type: 'ProjectLabel', + textColor: '#1F1E24', + }, + { + title: 'Corolla', + color: '#0384f3', + type: 'ProjectLabel', + textColor: '#FFFFFF', + }, + { + title: 'Cygsync', + color: '#1308c3', + type: 'GroupLabel', + textColor: '#FFFFFF', + }, + { + title: 'Frontier', + color: '#85db43', + type: 'ProjectLabel', + textColor: '#1F1E24', + }, + { + title: 'Ghost', + color: '#df1bc4', + type: 'ProjectLabel', + textColor: '#FFFFFF', + set: true, + }, + { + title: 'Grand Am', + color: '#a1d7ee', + type: 'ProjectLabel', + textColor: '#1F1E24', + }, + { + title: 'Onesync', + color: '#a73ba0', + type: 'GroupLabel', + textColor: '#FFFFFF', + }, + { + title: 'Phone', + color: '#63dceb', + type: 'GroupLabel', + textColor: '#1F1E24', + }, + { + title: 'Pynefunc', + color: '#974b19', + type: 'GroupLabel', + textColor: '#FFFFFF', + }, + { + title: 'Trinix', + color: '#2c894f', + type: 'GroupLabel', + textColor: '#FFFFFF', + }, + { + title: 'Trounswood', + color: '#ad0370', + type: 'GroupLabel', + textColor: '#FFFFFF', + }, + { + title: 'group::knowledge', + color: '#8fbc8f', + type: 'ProjectLabel', + textColor: '#1F1E24', + }, + { + title: 'scoped label', + color: '#6699cc', + type: 'GroupLabel', + textColor: '#FFFFFF', + }, + { + title: 'type::one', + color: '#9400d3', + type: 'ProjectLabel', + textColor: '#FFFFFF', + }, + { + title: 'type::two', + color: '#013220', + type: 'ProjectLabel', + textColor: '#FFFFFF', + }, +]; + +export const MOCK_MILESTONES = [ + { + iid: 65, + title: '15.0', + due_date: '2022-05-17', + id: null, + }, + { + iid: 73, + title: '15.1', + due_date: '2022-06-17', + id: null, + }, + { + iid: 74, + title: '15.2', + due_date: '2022-07-17', + id: null, + }, + { + iid: 75, + title: '15.3', + due_date: '2022-08-17', + id: null, + }, + { + iid: 76, + title: '15.4', + due_date: '2022-09-17', + id: null, + }, + { + iid: 77, + title: '15.5', + due_date: '2022-10-17', + id: null, + }, + { + iid: 81, + title: '15.6', + due_date: '2022-11-17', + id: null, + }, + { + iid: 82, + title: '15.7', + due_date: '2022-12-17', + id: null, + }, + { + iid: 83, + title: '15.8', + due_date: '2023-01-17', + id: null, + }, + { + iid: 84, + title: '15.9', + due_date: '2023-02-17', + id: null, + }, + { + iid: 85, + title: '15.10', + due_date: '2023-03-17', + id: null, + }, + { + iid: 86, + title: '15.11', + due_date: '2023-04-17', + id: null, + }, + { + iid: 80, + title: '16.0', + due_date: '2023-05-17', + id: null, + }, + { + iid: 88, + title: '16.1', + due_date: '2023-06-17', + id: null, + }, + { + iid: 89, + title: '16.2', + due_date: '2023-07-17', + id: null, + }, + { + iid: 90, + title: '16.3', + due_date: '2023-08-17', + id: null, + }, + { + iid: 91, + title: '16.4', + due_date: '2023-09-17', + id: null, + }, + { + iid: 92, + title: '16.5', + due_date: '2023-10-17', + id: null, + }, + { + iid: 93, + title: '16.6', + due_date: '2023-11-10', + id: null, + }, + { + iid: 95, + title: '16.7', + due_date: '2023-12-15', + id: null, + }, + { + iid: 94, + title: '16.8', + due_date: '2024-01-12', + id: null, + }, + { + iid: 96, + title: '16.9', + due_date: '2024-02-09', + id: null, + }, + { + iid: 97, + title: '16.10', + due_date: '2024-03-15', + id: null, + }, + { + iid: 98, + title: '16.11', + due_date: '2024-04-12', + id: null, + }, + { + iid: 87, + title: '17.0', + due_date: '2024-05-10', + id: null, + }, + { + iid: 48, + title: 'Next 1-3 releases', + due_date: null, + id: null, + }, + { + iid: 24, + title: 'Awaiting further demand', + due_date: null, + id: null, + }, + { + iid: 14, + title: 'Backlog', + due_date: null, + id: null, + }, + { + iid: 11, + title: 'Next 4-7 releases', + due_date: null, + id: null, + }, + { + iid: 10, + title: 'Next 3-4 releases', + due_date: null, + id: null, + }, + { + iid: 6, + title: 'Next 7-13 releases', + due_date: null, + id: null, + }, +]; + +export const MOCK_VULNERABILITIES = [ + { + id: 99499903, + title: 'Cross Site Scripting (Persistent)', + }, + { + id: 99495085, + title: 'Possible SQL injection', + }, + { + id: 99490610, + title: 'GitLab Runner Authentication Token', + }, + { + id: 99288920, + title: 'Cross Site Scripting (Persistent)', + }, + { + id: 99258720, + title: 'Cross Site Scripting (Persistent)', + }, +]; + +export const MOCK_COMMANDS = [ + { + name: 'due', + aliases: [], + description: 'Set due date', + warning: '', + icon: '', + params: ['\u003cin 2 days | this Friday | December 31st\u003e'], + }, + { + name: 'duplicate', + aliases: [], + description: 'Mark this issue as a duplicate of another issue', + warning: '', + icon: '', + params: ['#issue'], + }, + { + name: 'clone', + aliases: [], + description: 'Clone this issue', + warning: '', + icon: '', + params: ['path/to/project [--with_notes]'], + }, + { + name: 'move', + aliases: [], + description: 'Move this issue to another project.', + warning: '', + icon: '', + params: ['path/to/project'], + }, + { + name: 'create_merge_request', + aliases: [], + description: 'Create a merge request', + warning: '', + icon: '', + params: ['\u003cbranch name\u003e'], + }, + { + name: 'zoom', + aliases: [], + description: 'Add Zoom meeting', + warning: '', + icon: '', + params: ['\u003cZoom URL\u003e'], + }, + { + name: 'promote_to_incident', + aliases: [], + description: 'Promote issue to incident', + warning: '', + icon: '', + params: [], + }, + { + name: 'close', + aliases: [], + description: 'Close this issue', + warning: '', + icon: '', + params: [], + }, + { + name: 'title', + aliases: [], + description: 'Change title', + warning: '', + icon: '', + params: ['\u003cNew title\u003e'], + }, + { + name: 'label', + aliases: ['labels'], + description: 'Add labels', + warning: '', + icon: '', + params: ['~label1 ~"label 2"'], + }, + { + name: 'unlabel', + aliases: ['remove_label'], + description: 'Remove all or specific labels', + warning: '', + icon: '', + params: ['~label1 ~"label 2"'], + }, + { + name: 'relabel', + aliases: [], + description: 'Replace all labels', + warning: '', + icon: '', + params: ['~label1 ~"label 2"'], + }, + { + name: 'todo', + aliases: [], + description: 'Add a to do', + warning: '', + icon: '', + params: [], + }, + { + name: 'unsubscribe', + aliases: [], + description: 'Unsubscribe', + warning: '', + icon: '', + params: [], + }, + { + name: 'award', + aliases: [], + description: 'Toggle emoji award', + warning: '', + icon: '', + params: [':emoji:'], + }, + { + name: 'shrug', + aliases: [], + description: 'Append the comment with ¯\\_(ツ)_/¯', + warning: '', + icon: '', + params: ['\u003cComment\u003e'], + }, + { + name: 'tableflip', + aliases: [], + description: 'Append the comment with (╯°□°)╯︵ ┻━┻', + warning: '', + icon: '', + params: ['\u003cComment\u003e'], + }, + { + name: 'confidential', + aliases: [], + description: 'Make issue confidential', + warning: '', + icon: '', + params: [], + }, + { + name: 'assign', + aliases: [], + description: 'Assign', + warning: '', + icon: '', + params: ['@user1 @user2'], + }, + { + name: 'unassign', + aliases: [], + description: 'Remove all or specific assignees', + warning: '', + icon: '', + params: ['@user1 @user2'], + }, + { + name: 'milestone', + aliases: [], + description: 'Set milestone', + warning: '', + icon: '', + params: ['%"milestone"'], + }, + { + name: 'remove_milestone', + aliases: [], + description: 'Remove milestone', + warning: '', + icon: '', + params: [], + }, + { + name: 'copy_metadata', + aliases: [], + description: 'Copy labels and milestone from other issue or merge request in this project', + warning: '', + icon: '', + params: ['#issue | !merge_request'], + }, + { + name: 'estimate', + aliases: ['estimate_time'], + description: 'Set time estimate', + warning: '', + icon: '', + params: ['\u003c1w 3d 2h 14m\u003e'], + }, + { + name: 'spend', + aliases: ['spent', 'spend_time'], + description: 'Add or subtract spent time', + warning: '', + icon: '', + params: ['\u003ctime(1h30m | -1h30m)\u003e \u003cdate(YYYY-MM-DD)\u003e'], + }, + { + name: 'remove_estimate', + aliases: ['remove_time_estimate'], + description: 'Remove time estimate', + warning: '', + icon: '', + params: [], + }, + { + name: 'remove_time_spent', + aliases: [], + description: 'Remove spent time', + warning: '', + icon: '', + params: [], + }, + { + name: 'lock', + aliases: [], + description: 'Lock the discussion', + warning: '', + icon: '', + params: [], + }, + { + name: 'cc', + aliases: [], + description: 'CC', + warning: '', + icon: '', + params: ['@user'], + }, + { + name: 'relate', + aliases: [], + description: 'Mark this issue as related to another issue', + warning: '', + icon: '', + params: ['\u003c#issue | group/project#issue | issue URL\u003e'], + }, + { + name: 'unlink', + aliases: [], + description: 'Remove link with another issue', + warning: '', + icon: '', + params: ['\u003c#issue | group/project#issue | issue URL\u003e'], + }, + { + name: 'epic', + aliases: [], + description: 'Add to epic', + warning: '', + icon: '', + params: ['\u003c\u0026epic | group\u0026epic | Epic URL\u003e'], + }, + { + name: 'remove_epic', + aliases: [], + description: 'Remove from epic', + warning: '', + icon: '', + params: [], + }, + { + name: 'promote', + aliases: [], + description: 'Promote issue to an epic', + warning: '', + icon: 'confidential', + params: [], + }, + { + name: 'iteration', + aliases: [], + description: 'Set iteration', + warning: '', + icon: '', + params: ['*iteration:"iteration name" | *iteration:\u003cID\u003e'], + }, + { + name: 'health_status', + aliases: [], + description: 'Set health status', + warning: '', + icon: '', + params: ['\u003con_track|needs_attention|at_risk\u003e'], + }, + { + name: 'reassign', + aliases: [], + description: 'Change assignees', + warning: '', + icon: '', + params: ['@user1 @user2'], + }, + { + name: 'weight', + aliases: [], + description: 'Set weight', + warning: '', + icon: '', + params: ['0, 1, 2, …'], + }, + { + name: 'blocks', + aliases: [], + description: 'Specifies that this issue blocks other issues', + warning: '', + icon: '', + params: ['\u003c#issue | group/project#issue | issue URL\u003e'], + }, + { + name: 'blocked_by', + aliases: [], + description: 'Mark this issue as blocked by other issues', + warning: '', + icon: '', + params: ['\u003c#issue | group/project#issue | issue URL\u003e'], + }, +]; diff --git a/spec/frontend/content_editor/services/data_source_factory_spec.js b/spec/frontend/content_editor/services/data_source_factory_spec.js new file mode 100644 index 00000000000..d540f11711d --- /dev/null +++ b/spec/frontend/content_editor/services/data_source_factory_spec.js @@ -0,0 +1,202 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import DataSourceFactory, { + defaultSorter, + customSorter, + createDataSource, +} from '~/content_editor/services/data_source_factory'; +import { + MOCK_MEMBERS, + MOCK_COMMANDS, + MOCK_EPICS, + MOCK_ISSUES, + MOCK_LABELS, + MOCK_MILESTONES, + MOCK_SNIPPETS, + MOCK_VULNERABILITIES, + MOCK_MERGE_REQUESTS, + MOCK_ASSIGNEES, + MOCK_REVIEWERS, +} from './autocomplete_mock_data'; + +jest.mock('~/emoji'); + +describe('defaultSorter', () => { + it('returns items as is if query is empty', () => { + const items = [{ name: 'abc' }, { name: 'bcd' }, { name: 'cde' }]; + const sorter = defaultSorter(['name']); + expect(sorter(items, '')).toEqual(items); + }); + + it('sorts items based on query match', () => { + const items = [{ name: 'abc' }, { name: 'bcd' }, { name: 'cde' }]; + const sorter = defaultSorter(['name']); + expect(sorter(items, 'b')).toEqual([{ name: 'bcd' }, { name: 'abc' }, { name: 'cde' }]); + }); + + it('sorts items based on query match in multiple fields', () => { + const items = [ + { name: 'wabc', description: 'xyz' }, + { name: 'bcd', description: 'wxy' }, + { name: 'cde', description: 'vwx' }, + ]; + const sorter = defaultSorter(['name', 'description']); + expect(sorter(items, 'w')).toEqual([ + { name: 'wabc', description: 'xyz' }, + { name: 'bcd', description: 'wxy' }, + { name: 'cde', description: 'vwx' }, + ]); + }); +}); + +describe('customSorter', () => { + it('sorts items based on custom sorter function', () => { + const items = [3, 1, 2]; + const sorter = customSorter((a, b) => a - b); + expect(sorter(items)).toEqual([1, 2, 3]); + }); +}); + +describe('createDataSource', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + it('fetches data from source and filters based on query', async () => { + const data = [ + { name: 'abc', description: 'xyz' }, + { name: 'bcd', description: 'wxy' }, + { name: 'cde', description: 'vwx' }, + ]; + mock.onGet('/source').reply(200, data); + + const dataSource = createDataSource({ + source: '/source', + searchFields: ['name', 'description'], + }); + + const results = await dataSource.search('b'); + expect(results).toEqual([ + { name: 'bcd', description: 'wxy' }, + { name: 'abc', description: 'xyz' }, + ]); + }); + + it('handles source fetch errors', async () => { + mock.onGet('/source').reply(500); + + const dataSource = createDataSource({ + source: '/source', + searchFields: ['name', 'description'], + sorter: (items) => items, + }); + + const results = await dataSource.search('b'); + expect(results).toEqual([]); + }); +}); + +describe('DataSourceFactory', () => { + let mock; + let autocompleteHelper; + let dateNowOld; + + beforeEach(() => { + mock = new MockAdapter(axios); + const dataSourceUrls = { + members: '/members', + issues: '/issues', + snippets: '/snippets', + labels: '/labels', + epics: '/epics', + milestones: '/milestones', + mergeRequests: '/mergeRequests', + vulnerabilities: '/vulnerabilities', + commands: '/commands', + }; + + mock.onGet('/members').reply(200, MOCK_MEMBERS); + mock.onGet('/issues').reply(200, MOCK_ISSUES); + mock.onGet('/snippets').reply(200, MOCK_SNIPPETS); + mock.onGet('/labels').reply(200, MOCK_LABELS); + mock.onGet('/epics').reply(200, MOCK_EPICS); + mock.onGet('/milestones').reply(200, MOCK_MILESTONES); + mock.onGet('/mergeRequests').reply(200, MOCK_MERGE_REQUESTS); + mock.onGet('/vulnerabilities').reply(200, MOCK_VULNERABILITIES); + mock.onGet('/commands').reply(200, MOCK_COMMANDS); + + const sidebarMediator = { + store: { + assignees: MOCK_ASSIGNEES, + reviewers: MOCK_REVIEWERS, + }, + }; + + autocompleteHelper = new DataSourceFactory({ + dataSourceUrls, + sidebarMediator, + }); + + dateNowOld = Date.now(); + + jest.spyOn(Date, 'now').mockImplementation(() => new Date('2023-11-14').getTime()); + }); + + afterEach(() => { + mock.restore(); + + jest.spyOn(Date, 'now').mockImplementation(() => dateNowOld); + }); + + it.each` + referenceType | query + ${'user'} | ${'r'} + ${'issue'} | ${'q'} + ${'snippet'} | ${'s'} + ${'label'} | ${'c'} + ${'epic'} | ${'n'} + ${'milestone'} | ${'16'} + ${'merge_request'} | ${'n'} + ${'vulnerability'} | ${'cross'} + ${'command'} | ${'re'} + `( + 'for reference type "$referenceType", searches for "$query" correctly', + async ({ referenceType, query }) => { + const dataSource = autocompleteHelper.getDataSource(referenceType); + const results = await dataSource.search(query); + + expect( + results.map(({ title, name, username }) => username || name || title), + ).toMatchSnapshot(); + }, + ); + + it.each` + referenceType | command + ${'label'} | ${'/label'} + ${'label'} | ${'/unlabel'} + ${'label'} | ${'/relabel'} + ${'user'} | ${'/assign'} + ${'user'} | ${'/reassign'} + ${'user'} | ${'/unassign'} + ${'user'} | ${'/assign_reviewer'} + ${'user'} | ${'/unassign_reviewer'} + ${'user'} | ${'/reassign_reviewer'} + `( + 'filters items based on command "$command" for reference type "$referenceType" and command', + async ({ referenceType, command }) => { + const dataSource = autocompleteHelper.getDataSource(referenceType, { command }); + const results = await dataSource.search(); + + expect( + results.map(({ username, name, title }) => username || name || title), + ).toMatchSnapshot(); + }, + ); +}); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 548c6030ed7..c329a12bcc4 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -152,19 +152,26 @@ describe('markdownSerializer', () => { expect(serialize(paragraph(italic('italics')))).toBe('_italics_'); }); - it('correctly serializes code blocks wrapped by italics and bold marks', () => { - 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.each` + input | output + ${'code'} | ${'`code`'} + ${'code `with` backticks'} | ${'``code `with` backticks``'} + ${'this is `inline-code`'} | ${'`` this is `inline-code` ``'} + ${'`inline-code` in markdown'} | ${'`` `inline-code` in markdown ``'} + ${'```js'} | ${'`` ```js ``'} + `('correctly serializes inline code ("$input")', ({ input, output }) => { + expect(serialize(paragraph(code(input)))).toBe(output); + }); + + it('correctly serializes inline code wrapped by italics and bold marks', () => { + const content = 'code'; + + expect(serialize(paragraph(italic(code(content))))).toBe(`_\`${content}\`_`); + expect(serialize(paragraph(code(italic(content))))).toBe(`_\`${content}\`_`); + expect(serialize(paragraph(bold(code(content))))).toBe(`**\`${content}\`**`); + expect(serialize(paragraph(code(bold(content))))).toBe(`**\`${content}\`**`); + expect(serialize(paragraph(strike(code(content))))).toBe(`~~\`${content}\`~~`); + expect(serialize(paragraph(code(strike(content))))).toBe(`~~\`${content}\`~~`); }); it('correctly serializes inline diff', () => { @@ -461,6 +468,52 @@ this is not really json:table but just trying out whether this case works or not ); }); + it('correctly serializes a markdown code block containing a nested code block', () => { + expect( + serialize( + codeBlock( + { language: 'markdown' }, + 'markdown code block **bold** _italic_ `code`\n\n```js\nvar a = 0;\n```\n\nend markdown code block', + ), + ), + ).toBe( + ` +\`\`\`\`markdown +markdown code block **bold** _italic_ \`code\` + +\`\`\`js +var a = 0; +\`\`\` + +end markdown code block +\`\`\`\` + `.trim(), + ); + }); + + it('correctly serializes a markdown code block containing a markdown code block containing another code block', () => { + expect( + serialize( + codeBlock( + { language: 'markdown' }, + '````md\na nested code block\n\n```js\nvar a = 0;\n```\n````', + ), + ), + ).toBe( + ` +\`\`\`\`\`markdown +\`\`\`\`md +a nested code block + +\`\`\`js +var a = 0; +\`\`\` +\`\`\`\` +\`\`\`\`\` + `.trim(), + ); + }); + it('correctly serializes emoji', () => { expect(serialize(paragraph(emoji({ name: 'dog' })))).toBe(':dog:'); }); @@ -607,6 +660,34 @@ this is not really json:table but just trying out whether this case works or not ); }); + it('correctly serializes bullet task list with different bullet styles', () => { + expect( + serialize( + taskList( + { bullet: '+' }, + taskItem({ checked: true }, paragraph('list item 1')), + taskItem(paragraph('list item 2')), + taskItem( + paragraph('list item 3'), + taskList( + { bullet: '-' }, + taskItem({ checked: true }, paragraph('sub-list item 1')), + taskItem(paragraph('sub-list item 2')), + ), + ), + ), + ), + ).toBe( + ` ++ [x] list item 1 ++ [ ] list item 2 ++ [ ] list item 3 + - [x] sub-list item 1 + - [ ] sub-list item 2 + `.trim(), + ); + }); + it('correctly serializes a numeric list', () => { expect( serialize( diff --git a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js index 2efc73ddef8..4428fa682e7 100644 --- a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js +++ b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js @@ -1,6 +1,8 @@ import { Extension } from '@tiptap/core'; import BulletList from '~/content_editor/extensions/bullet_list'; import ListItem from '~/content_editor/extensions/list_item'; +import TaskList from '~/content_editor/extensions/task_list'; +import TaskItem from '~/content_editor/extensions/task_item'; import Paragraph from '~/content_editor/extensions/paragraph'; import markdownDeserializer from '~/content_editor/services/gl_api_markdown_deserializer'; import { getMarkdownSource, getFullSource } from '~/content_editor/services/markdown_sourcemap'; @@ -18,6 +20,20 @@ const BULLET_LIST_HTML = `<ul data-sourcepos="1:1-3:24" dir="auto"> </li> </ul>`; +const BULLET_TASK_LIST_MARKDOWN = `- [ ] list item 1 ++ [x] checked list item 2 + + [ ] embedded list item 1 + - [x] checked embedded list item 2`; +const BULLET_TASK_LIST_HTML = `<ul data-sourcepos="1:1-4:36" class="task-list" dir="auto"> + <li data-sourcepos="1:1-1:17" class="task-list-item"><input type="checkbox" class="task-list-item-checkbox"> list item 1</li> + <li data-sourcepos="2:1-4:36" class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" checked> checked list item 2 + <ul data-sourcepos="3:3-4:36" class="task-list"> + <li data-sourcepos="3:3-3:28" class="task-list-item"><input type="checkbox" class="task-list-item-checkbox"> embedded list item 1</li> + <li data-sourcepos="4:3-4:36" class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" checked> checked embedded list item 2</li> + </ul> + </li> +</ul>`; + const SourcemapExtension = Extension.create({ // lets add `source` attribute to every element using `getMarkdownSource` addGlobalAttributes() { @@ -38,19 +54,68 @@ const SourcemapExtension = Extension.create({ }); const tiptapEditor = createTestEditor({ - extensions: [BulletList, ListItem, SourcemapExtension], + extensions: [BulletList, ListItem, TaskList, TaskItem, SourcemapExtension], }); const { - builders: { doc, bulletList, listItem, paragraph }, + builders: { doc, bulletList, listItem, taskList, taskItem, paragraph }, } = createDocBuilder({ tiptapEditor, names: { bulletList: { nodeType: BulletList.name }, listItem: { nodeType: ListItem.name }, + taskList: { nodeType: TaskList.name }, + taskItem: { nodeType: TaskItem.name }, }, }); +const bulletListDoc = () => + doc( + bulletList( + { bullet: '+', source: '+ list item 1\n+ list item 2\n - embedded list item 3' }, + listItem({ source: '+ list item 1' }, paragraph('list item 1')), + listItem( + { source: '+ list item 2\n - embedded list item 3' }, + paragraph('list item 2'), + bulletList( + { bullet: '-', source: '- embedded list item 3' }, + listItem({ source: '- embedded list item 3' }, paragraph('embedded list item 3')), + ), + ), + ), + ); + +const bulletTaskListDoc = () => + doc( + taskList( + { + bullet: '-', + source: + '- [ ] list item 1\n+ [x] checked list item 2\n + [ ] embedded list item 1\n - [x] checked embedded list item 2', + }, + taskItem({ source: '- [ ] list item 1' }, paragraph('list item 1')), + taskItem( + { + source: + '+ [x] checked list item 2\n + [ ] embedded list item 1\n - [x] checked embedded list item 2', + checked: true, + }, + paragraph('checked list item 2'), + taskList( + { + bullet: '+', + source: '+ [ ] embedded list item 1\n - [x] checked embedded list item 2', + }, + taskItem({ source: '+ [ ] embedded list item 1' }, paragraph('embedded list item 1')), + taskItem( + { source: '- [x] checked embedded list item 2', checked: true }, + paragraph('checked embedded list item 2'), + ), + ), + ), + ), + ); + describe('content_editor/services/markdown_sourcemap', () => { describe('getFullSource', () => { it.each` @@ -72,29 +137,21 @@ describe('content_editor/services/markdown_sourcemap', () => { }); }); - it('gets markdown source for a rendered HTML element', async () => { - const { document } = await markdownDeserializer({ - render: () => BULLET_LIST_HTML, - }).deserialize({ - schema: tiptapEditor.schema, - markdown: BULLET_LIST_MARKDOWN, - }); - - const expected = doc( - bulletList( - { bullet: '+', source: '+ list item 1\n+ list item 2' }, - listItem({ source: '+ list item 1' }, paragraph('list item 1')), - listItem( - { source: '+ list item 2' }, - paragraph('list item 2'), - bulletList( - { bullet: '-', source: '- embedded list item 3' }, - listItem({ source: '- embedded list item 3' }, paragraph('embedded list item 3')), - ), - ), - ), - ); + it.each` + description | sourceMarkdown | sourceHTML | expectedDoc + ${'bullet list'} | ${BULLET_LIST_MARKDOWN} | ${BULLET_LIST_HTML} | ${bulletListDoc} + ${'bullet task list'} | ${BULLET_TASK_LIST_MARKDOWN} | ${BULLET_TASK_LIST_HTML} | ${bulletTaskListDoc} + `( + 'gets markdown source for a rendered $description', + async ({ sourceMarkdown, sourceHTML, expectedDoc }) => { + const { document } = await markdownDeserializer({ + render: () => sourceHTML, + }).deserialize({ + schema: tiptapEditor.schema, + markdown: sourceMarkdown, + }); - expect(document.toJSON()).toEqual(expected.toJSON()); - }); + expect(document.toJSON()).toEqual(expectedDoc().toJSON()); + }, + ); }); diff --git a/spec/frontend/content_editor/test_constants.js b/spec/frontend/content_editor/test_constants.js index cbd4f555e97..255a7104eaf 100644 --- a/spec/frontend/content_editor/test_constants.js +++ b/spec/frontend/content_editor/test_constants.js @@ -44,3 +44,18 @@ export const RESOLVED_MERGE_REQUEST_HTML = 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>'; + +export const RESOLVED_LABEL_HTML = + '<p data-sourcepos="1:1-1:29" dir="auto"><span class="gl-label gl-label-sm"><a href="/gitlab-org/gitlab-shell/-/issues?label_name=Aquanix" data-reference-type="label" data-original="~Aquanix" data-link="false" data-link-reference="false" data-project="2" data-label="5" data-container="body" data-placement="top" title="" class="gfm gfm-label has-tooltip gl-link gl-label-link"><span class="gl-label-text gl-label-text-light" data-container="body" data-html="true" style="background-color: #e65431">Aquanix</span></a></span> <span class="gl-label gl-label-sm"><a href="/gitlab-org/gitlab-shell/-/issues?label_name=Aquanix" data-reference-type="label" data-original="~Aquanix" data-link="false" data-link-reference="false" data-project="2" data-label="5" data-container="body" data-placement="top" title="" class="gfm gfm-label has-tooltip gl-link gl-label-link"><span class="gl-label-text gl-label-text-light" data-container="body" data-html="true" style="background-color: #e65431">Aquanix</span></a></span>+ <span class="gl-label gl-label-sm"><a href="/gitlab-org/gitlab-shell/-/issues?label_name=Aquanix" data-reference-type="label" data-original="~Aquanix" data-link="false" data-link-reference="false" data-project="2" data-label="5" data-container="body" data-placement="top" title="" class="gfm gfm-label has-tooltip gl-link gl-label-link"><span class="gl-label-text gl-label-text-light" data-container="body" data-html="true" style="background-color: #e65431">Aquanix</span></a></span>+s</p>'; + +export const RESOLVED_SNIPPET_HTML = + '<p data-sourcepos="1:1-1:14" dir="auto"><a href="/gitlab-org/gitlab-shell/-/snippets/25" data-reference-type="snippet" data-original="$25" data-link="false" data-link-reference="false" data-project="2" data-snippet="25" data-container="body" data-placement="top" title="test" class="gfm gfm-snippet has-tooltip">$25</a> <a href="/gitlab-org/gitlab-shell/-/snippets/25" data-reference-type="snippet" data-original="$25" data-link="false" data-link-reference="false" data-project="2" data-snippet="25" data-container="body" data-placement="top" title="test" class="gfm gfm-snippet has-tooltip">$25</a>+ <a href="/gitlab-org/gitlab-shell/-/snippets/25" data-reference-type="snippet" data-original="$25" data-link="false" data-link-reference="false" data-project="2" data-snippet="25" data-container="body" data-placement="top" title="test" class="gfm gfm-snippet has-tooltip">$25</a>+s</p>'; + +export const RESOLVED_MILESTONE_HTML = + '<p data-sourcepos="1:1-1:20" dir="auto"><a href="/gitlab-org/gitlab-shell/-/milestones/5" data-reference-type="milestone" data-original="%v4.0" data-link="false" data-link-reference="false" data-project="2" data-milestone="10" data-container="body" data-placement="top" title="" class="gfm gfm-milestone has-tooltip">%v4.0</a> <a href="/gitlab-org/gitlab-shell/-/milestones/5" data-reference-type="milestone" data-original="%v4.0" data-link="false" data-link-reference="false" data-project="2" data-milestone="10" data-container="body" data-placement="top" title="" class="gfm gfm-milestone has-tooltip">%v4.0</a>+ %v4.0+s</p>'; + +export const RESOLVED_USER_HTML = + '<p data-sourcepos="1:1-1:20" dir="auto"><a href="/root" data-reference-type="user" data-user="1" data-container="body" data-placement="top" class="gfm gfm-project_member js-user-link" title="Administrator">@root</a> <a href="/root" data-reference-type="user" data-user="1" data-container="body" data-placement="top" class="gfm gfm-project_member js-user-link" title="Administrator">@root</a>+ <a href="/root" data-reference-type="user" data-user="1" data-container="body" data-placement="top" class="gfm gfm-project_member js-user-link" title="Administrator">@root</a>+s</p>'; + +export const RESOLVED_VULNERABILITY_HTML = + '<p data-sourcepos="1:1-1:56" dir="auto"><a href="/gitlab-org/gitlab-shell/-/security/vulnerabilities/1" data-reference-type="vulnerability" data-original="[vulnerability:1]" data-link="false" data-link-reference="false" data-project="2" data-vulnerability="1" data-container="body" data-placement="top" title="oh no!" class="gfm gfm-vulnerability has-tooltip">[vulnerability:1]</a> <a href="/gitlab-org/gitlab-shell/-/security/vulnerabilities/1" data-reference-type="vulnerability" data-original="[vulnerability:1]" data-link="false" data-link-reference="false" data-project="2" data-vulnerability="1" data-container="body" data-placement="top" title="oh no!" class="gfm gfm-vulnerability has-tooltip">[vulnerability:1]</a>+ <a href="/gitlab-org/gitlab-shell/-/security/vulnerabilities/1" data-reference-type="vulnerability" data-original="[vulnerability:1]" data-link="false" data-link-reference="false" data-project="2" data-vulnerability="1" data-container="body" data-placement="top" title="oh no!" class="gfm gfm-vulnerability has-tooltip">[vulnerability:1]</a>+s</p>'; diff --git a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap index 8b76a627c1e..50a4a21ef1f 100644 --- a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap +++ b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap @@ -45,22 +45,32 @@ exports[`Contributors charts should render charts and a RefSelector when loading Excluding merge commits. Limited to 6,000 commits. </span> <glareachart-stub - annotations="" class="gl-mb-5" data="[object Object]" + format-tooltip-text="function () { [native code] }" height="264" - includelegendavgmax="true" - legendaveragetext="Avg" - legendcurrenttext="Current" - legendlayout="inline" - legendmaxtext="Max" - legendmintext="Min" - legendseriesinfo="" option="[object Object]" responsive="" - thresholds="" width="auto" - /> + > + <div + data-testid="tooltip-title" + /> + <div + class="gl-display-flex gl-gap-6 gl-justify-content-space-between" + > + <span + data-testid="tooltip-label" + > + Number of commits + </span> + <span + data-testid="tooltip-value" + > + [] + </span> + </div> + </glareachart-stub> <div class="row" > @@ -78,21 +88,31 @@ exports[`Contributors charts should render charts and a RefSelector when loading 2 commits (jawnnypoo@gmail.com) </p> <glareachart-stub - annotations="" data="[object Object]" + format-tooltip-text="function () { [native code] }" height="216" - includelegendavgmax="true" - legendaveragetext="Avg" - legendcurrenttext="Current" - legendlayout="inline" - legendmaxtext="Max" - legendmintext="Min" - legendseriesinfo="" option="[object Object]" responsive="" - thresholds="" width="auto" - /> + > + <div + data-testid="tooltip-title" + /> + <div + class="gl-display-flex gl-gap-6 gl-justify-content-space-between" + > + <span + data-testid="tooltip-label" + > + Commits + </span> + <span + data-testid="tooltip-value" + > + [] + </span> + </div> + </glareachart-stub> </div> </div> </div> diff --git a/spec/frontend/contributors/component/contributor_area_chart_spec.js b/spec/frontend/contributors/component/contributor_area_chart_spec.js new file mode 100644 index 00000000000..262c3e8afee --- /dev/null +++ b/spec/frontend/contributors/component/contributor_area_chart_spec.js @@ -0,0 +1,92 @@ +import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ContributorAreaChart from '~/contributors/components/contributor_area_chart.vue'; + +describe('Contributor area chart', () => { + let wrapper; + + const defaultProps = { + data: [ + { + name: 'Commits', + data: [ + ['2015-01-01', 1], + ['2015-01-02', 2], + ['2015-01-03', 3], + ], + }, + ], + height: 100, + option: { + xAxis: { name: '', type: 'time' }, + yAxis: { name: 'Number of commits' }, + grid: { + top: 10, + bottom: 10, + left: 10, + right: 10, + }, + }, + }; + + const createWrapper = (props = {}) => { + wrapper = shallowMountExtended(ContributorAreaChart, { + propsData: { ...defaultProps, ...props }, + }); + }; + + const findAreaChart = () => wrapper.findComponent(GlAreaChart); + const findTooltipTitle = () => wrapper.findByTestId('tooltip-title').text(); + const findTooltipLabel = () => wrapper.findByTestId('tooltip-label').text(); + const findTooltipValue = () => wrapper.findByTestId('tooltip-value').text(); + + const setTooltipData = async (title, value) => { + findAreaChart().vm.formatTooltipText({ seriesData: [{ data: [title, value] }] }); + await nextTick(); + }; + + describe('default inputs', () => { + beforeEach(() => { + createWrapper(); + }); + + it('renders the area chart', () => { + expect(findAreaChart().exists()).toBe(true); + expect(findAreaChart().props()).toMatchObject(defaultProps); + }); + + it('emits the area chart created event', () => { + const payload = 'test'; + findAreaChart().vm.$emit('created', payload); + + expect(wrapper.emitted('created')).toHaveLength(1); + expect(wrapper.emitted('created')[0]).toEqual([payload]); + }); + + it('shows the tooltip with the formatted chart data', async () => { + await setTooltipData('01-01-2000', 10); + + expect(findTooltipTitle()).toEqual('Jan 01, 2000'); + expect(findTooltipLabel()).toEqual(defaultProps.option.yAxis.name); + expect(findTooltipValue()).toEqual('10'); + }); + }); + + describe('Y axis has no name', () => { + beforeEach(() => { + createWrapper({ + option: { + ...defaultProps.option, + yAxis: {}, + }, + }); + }); + + it('shows a default tooltip label if the Y axis name is missing', async () => { + await setTooltipData('01-01-2000', 10); + + expect(findTooltipLabel()).toEqual('Value'); + }); + }); +}); diff --git a/spec/frontend/contributors/component/contributors_spec.js b/spec/frontend/contributors/component/contributors_spec.js index 7d863a8eb78..6235d2610a9 100644 --- a/spec/frontend/contributors/component/contributors_spec.js +++ b/spec/frontend/contributors/component/contributors_spec.js @@ -12,6 +12,8 @@ import { SET_CHART_DATA, SET_LOADING_STATE } from '~/contributors/stores/mutatio jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn(), + joinPaths: jest.fn(), + setUrlFragment: jest.fn(), })); let wrapper; diff --git a/spec/frontend/deploy_freeze/store/actions_spec.js b/spec/frontend/deploy_freeze/store/actions_spec.js index d39577baa59..86b72c673bc 100644 --- a/spec/frontend/deploy_freeze/store/actions_spec.js +++ b/spec/frontend/deploy_freeze/store/actions_spec.js @@ -36,7 +36,7 @@ describe('deploy freeze store actions', () => { describe('setSelectedFreezePeriod', () => { it('commits SET_SELECTED_TIMEZONE mutation', () => { - testAction( + return testAction( actions.setFreezePeriod, { id: 3, @@ -69,7 +69,7 @@ describe('deploy freeze store actions', () => { describe('setSelectedTimezone', () => { it('commits SET_SELECTED_TIMEZONE mutation', () => { - testAction(actions.setSelectedTimezone, {}, {}, [ + return testAction(actions.setSelectedTimezone, {}, {}, [ { payload: {}, type: types.SET_SELECTED_TIMEZONE, @@ -80,7 +80,7 @@ describe('deploy freeze store actions', () => { describe('setFreezeStartCron', () => { it('commits SET_FREEZE_START_CRON mutation', () => { - testAction(actions.setFreezeStartCron, {}, {}, [ + return testAction(actions.setFreezeStartCron, {}, {}, [ { type: types.SET_FREEZE_START_CRON, }, @@ -90,7 +90,7 @@ describe('deploy freeze store actions', () => { describe('setFreezeEndCron', () => { it('commits SET_FREEZE_END_CRON mutation', () => { - testAction(actions.setFreezeEndCron, {}, {}, [ + return testAction(actions.setFreezeEndCron, {}, {}, [ { type: types.SET_FREEZE_END_CRON, }, diff --git a/spec/frontend/deploy_keys/components/key_spec.js b/spec/frontend/deploy_keys/components/key_spec.js index 3c4fa2a6de6..e57da4df150 100644 --- a/spec/frontend/deploy_keys/components/key_spec.js +++ b/spec/frontend/deploy_keys/components/key_spec.js @@ -4,7 +4,7 @@ import data from 'test_fixtures/deploy_keys/keys.json'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import key from '~/deploy_keys/components/key.vue'; import DeployKeysStore from '~/deploy_keys/store'; -import { getTimeago, formatDate } from '~/lib/utils/datetime_utility'; +import { getTimeago, localeDateFormat } from '~/lib/utils/datetime_utility'; describe('Deploy keys key', () => { let wrapper; @@ -64,7 +64,9 @@ describe('Deploy keys key', () => { const expiryComponent = wrapper.find('[data-testid="expires-at-tooltip"]'); const tooltip = getBinding(expiryComponent.element, 'gl-tooltip'); expect(tooltip).toBeDefined(); - expect(expiryComponent.attributes('title')).toBe(`${formatDate(expiresAt)}`); + expect(expiryComponent.attributes('title')).toBe( + `${localeDateFormat.asDateTimeFull.format(expiresAt)}`, + ); }); it('renders never when no expiration date', () => { createComponent({ diff --git a/spec/frontend/deploy_keys/graphql/resolvers_spec.js b/spec/frontend/deploy_keys/graphql/resolvers_spec.js new file mode 100644 index 00000000000..458232697cb --- /dev/null +++ b/spec/frontend/deploy_keys/graphql/resolvers_spec.js @@ -0,0 +1,249 @@ +import MockAdapter from 'axios-mock-adapter'; +import { HTTP_STATUS_OK, HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status'; +import axios from '~/lib/utils/axios_utils'; +import pageInfoQuery from '~/graphql_shared/client/page_info.query.graphql'; +import currentPageQuery from '~/deploy_keys/graphql/queries/current_page.query.graphql'; +import currentScopeQuery from '~/deploy_keys/graphql/queries/current_scope.query.graphql'; +import confirmRemoveKeyQuery from '~/deploy_keys/graphql/queries/confirm_remove_key.query.graphql'; +import { resolvers } from '~/deploy_keys/graphql/resolvers'; + +const ENDPOINTS = { + enabledKeysEndpoint: '/enabled_keys', + availableProjectKeysEndpoint: '/available_project_keys', + availablePublicKeysEndpoint: '/available_public_keys', +}; + +describe('~/deploy_keys/graphql/resolvers', () => { + let mockResolvers; + let mock; + let client; + + beforeEach(() => { + mockResolvers = resolvers(ENDPOINTS); + mock = new MockAdapter(axios); + client = { + writeQuery: jest.fn(), + readQuery: jest.fn(), + readFragment: jest.fn(), + cache: { evict: jest.fn(), gc: jest.fn() }, + }; + }); + + afterEach(() => { + mock.reset(); + }); + + describe('deployKeys', () => { + const key = { id: 1, title: 'hello', edit_path: '/edit' }; + + it.each(['enabledKeys', 'availableProjectKeys', 'availablePublicKeys'])( + 'should request the endpoint for the %s scope', + async (scope) => { + mock.onGet(ENDPOINTS[`${scope}Endpoint`]).reply(HTTP_STATUS_OK, { keys: [key] }); + + const keys = await mockResolvers.Project.deployKeys(null, { scope, page: 1 }, { client }); + + expect(keys).toEqual([ + { id: 1, title: 'hello', editPath: '/edit', __typename: 'LocalDeployKey' }, + ]); + }, + ); + + it('should default to enabled keys if a bad scope is given', async () => { + const scope = 'bad'; + mock.onGet(ENDPOINTS.enabledKeysEndpoint).reply(HTTP_STATUS_OK, { keys: [key] }); + + const keys = await mockResolvers.Project.deployKeys(null, { scope, page: 1 }, { client }); + + expect(keys).toEqual([ + { id: 1, title: 'hello', editPath: '/edit', __typename: 'LocalDeployKey' }, + ]); + }); + + it('should request the given page', async () => { + const scope = 'enabledKeys'; + const page = 2; + mock + .onGet(ENDPOINTS.enabledKeysEndpoint, { params: { page } }) + .reply(HTTP_STATUS_OK, { keys: [key] }); + + const keys = await mockResolvers.Project.deployKeys(null, { scope, page }, { client }); + + expect(keys).toEqual([ + { id: 1, title: 'hello', editPath: '/edit', __typename: 'LocalDeployKey' }, + ]); + }); + + it('should write pagination info to the cache', async () => { + const scope = 'enabledKeys'; + const page = 1; + + mock.onGet(ENDPOINTS.enabledKeysEndpoint).reply( + HTTP_STATUS_OK, + { keys: [key] }, + { + 'x-next-page': '2', + 'x-page': '1', + 'X-Per-Page': '2', + 'X-Prev-Page': '', + 'X-TOTAL': '37', + 'X-Total-Pages': '5', + }, + ); + + await mockResolvers.Project.deployKeys(null, { scope, page }, { client }); + + expect(client.writeQuery).toHaveBeenCalledWith({ + query: pageInfoQuery, + variables: { input: { scope, page } }, + data: { + pageInfo: { + total: 37, + perPage: 2, + previousPage: NaN, + totalPages: 5, + nextPage: 2, + page: 1, + __typename: 'LocalPageInfo', + }, + }, + }); + }); + + it('should not write page info if the request fails', async () => { + const scope = 'enabledKeys'; + const page = 1; + + mock.onGet(ENDPOINTS.enabledKeysEndpoint).reply(HTTP_STATUS_NOT_FOUND); + + try { + await mockResolvers.Project.deployKeys(null, { scope, page }, { client }); + } catch { + expect(client.writeQuery).not.toHaveBeenCalled(); + } + }); + }); + + describe('currentPage', () => { + it('sets the current page', () => { + const page = 5; + mockResolvers.Mutation.currentPage(null, { page }, { client }); + + expect(client.writeQuery).toHaveBeenCalledWith({ + query: currentPageQuery, + data: { currentPage: page }, + }); + }); + }); + + describe('currentScope', () => { + let scope; + + beforeEach(() => { + scope = 'enabledKeys'; + mockResolvers.Mutation.currentScope(null, { scope }, { client }); + }); + + it('sets the current scope', () => { + expect(client.writeQuery).toHaveBeenCalledWith({ + query: currentScopeQuery, + data: { currentScope: scope }, + }); + }); + + it('resets the page to 1', () => { + expect(client.writeQuery).toHaveBeenCalledWith({ + query: currentPageQuery, + data: { currentPage: 1 }, + }); + }); + }); + + describe('disableKey', () => { + it('disables the key that is pending confirmation', async () => { + const key = { id: 1, title: 'hello', disablePath: '/disable', __typename: 'LocalDeployKey' }; + client.readQuery.mockReturnValue({ deployKeyToRemove: key }); + client.readFragment.mockReturnValue(key); + mock.onPut(key.disablePath).reply(HTTP_STATUS_OK); + await mockResolvers.Mutation.disableKey(null, null, { client }); + + expect(client.readQuery).toHaveBeenCalledWith({ query: confirmRemoveKeyQuery }); + expect(client.readFragment).toHaveBeenCalledWith( + expect.objectContaining({ id: `LocalDeployKey:${key.id}` }), + ); + expect(client.cache.evict).toHaveBeenCalledWith({ fieldName: 'deployKeyToRemove' }); + expect(client.cache.evict).toHaveBeenCalledWith({ id: `LocalDeployKey:${key.id}` }); + expect(client.cache.gc).toHaveBeenCalled(); + }); + + it('does not remove the key from the cache on fail', async () => { + const key = { id: 1, title: 'hello', disablePath: '/disable', __typename: 'LocalDeployKey' }; + client.readQuery.mockReturnValue({ deployKeyToRemove: key }); + client.readFragment.mockReturnValue(key); + mock.onPut(key.disablePath).reply(HTTP_STATUS_NOT_FOUND); + + try { + await mockResolvers.Mutation.disableKey(null, null, { client }); + } catch { + expect(client.readQuery).toHaveBeenCalledWith({ query: confirmRemoveKeyQuery }); + expect(client.readFragment).toHaveBeenCalledWith( + expect.objectContaining({ id: `LocalDeployKey:${key.id}` }), + ); + expect(client.cache.evict).not.toHaveBeenCalled(); + expect(client.cache.gc).not.toHaveBeenCalled(); + } + }); + }); + + describe('enableKey', () => { + it("calls the key's enable path", async () => { + const key = { id: 1, title: 'hello', enablePath: '/enable', __typename: 'LocalDeployKey' }; + client.readQuery.mockReturnValue({ deployKeyToRemove: key }); + client.readFragment.mockReturnValue(key); + mock.onPut(key.enablePath).reply(HTTP_STATUS_OK); + await mockResolvers.Mutation.enableKey(null, key, { client }); + + expect(client.readFragment).toHaveBeenCalledWith( + expect.objectContaining({ id: `LocalDeployKey:${key.id}` }), + ); + expect(client.cache.evict).toHaveBeenCalledWith({ id: `LocalDeployKey:${key.id}` }); + expect(client.cache.gc).toHaveBeenCalled(); + }); + + it('does not remove the key from the cache on failure', async () => { + const key = { id: 1, title: 'hello', enablePath: '/enable', __typename: 'LocalDeployKey' }; + client.readQuery.mockReturnValue({ deployKeyToRemove: key }); + client.readFragment.mockReturnValue(key); + mock.onPut(key.enablePath).reply(HTTP_STATUS_NOT_FOUND); + try { + await mockResolvers.Mutation.enableKey(null, key, { client }); + } catch { + expect(client.readFragment).toHaveBeenCalledWith( + expect.objectContaining({ id: `LocalDeployKey:${key.id}` }), + ); + expect(client.cache.evict).not.toHaveBeenCalled(); + expect(client.cache.gc).not.toHaveBeenCalled(); + } + }); + }); + + describe('confirmDisable', () => { + it('sets the key to disable', () => { + const key = { id: 1, title: 'hello', enablePath: '/enable', __typename: 'LocalDeployKey' }; + mockResolvers.Mutation.confirmDisable(null, key, { client }); + + expect(client.writeQuery).toHaveBeenCalledWith({ + query: confirmRemoveKeyQuery, + data: { deployKeyToRemove: { id: key.id, __type: 'LocalDeployKey' } }, + }); + }); + it('clears the value when null id is passed', () => { + mockResolvers.Mutation.confirmDisable(null, { id: null }, { client }); + + expect(client.writeQuery).toHaveBeenCalledWith({ + query: confirmRemoveKeyQuery, + data: { deployKeyToRemove: null }, + }); + }); + }); +}); 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 a05b3baecd3..6624c90a146 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 @@ -48,7 +48,7 @@ exports[`Design management list item component with notes renders item with mult Updated <timeago-stub cssclass="" - datetimeformat="DATE_WITH_TIME_FORMAT" + datetimeformat="asDateTime" time="01-01-2019" tooltipplacement="bottom" /> @@ -113,7 +113,7 @@ exports[`Design management list item component with notes renders item with sing Updated <timeago-stub cssclass="" - datetimeformat="DATE_WITH_TIME_FORMAT" + datetimeformat="asDateTime" time="01-01-2019" tooltipplacement="bottom" /> diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js index 34af3d72b04..a9fbf4632ac 100644 --- a/spec/frontend/diffs/components/diff_file_spec.js +++ b/spec/frontend/diffs/components/diff_file_spec.js @@ -23,7 +23,7 @@ import eventHub from '~/diffs/event_hub'; import { diffViewerModes, diffViewerErrors } from '~/ide/constants'; import axios from '~/lib/utils/axios_utils'; -import { scrollToElement } from '~/lib/utils/common_utils'; +import { scrollToElement, isElementStuck } from '~/lib/utils/common_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import createNotesStore from '~/notes/stores/modules'; import diffsModule from '~/diffs/store/modules'; @@ -399,6 +399,27 @@ describe('DiffFile', () => { }); }); + describe('automatically collapsed generated file', () => { + beforeEach(() => { + makeFileAutomaticallyCollapsed(store); + const file = store.state.diffs.diffFiles[0]; + Object.assign(store.state.diffs.diffFiles[0], { + ...file, + viewer: { + ...file.viewer, + generated: true, + }, + }); + }); + + it('should show the generated file warning with expansion button', () => { + expect(findDiffContentArea(wrapper).html()).toContain( + 'Generated files are collapsed by default. This behavior can be overriden via .gitattributes file if required.', + ); + expect(findToggleButton(wrapper).exists()).toBe(true); + }); + }); + describe('not collapsed', () => { beforeEach(() => { makeFileOpenByDefault(store); @@ -429,6 +450,7 @@ describe('DiffFile', () => { describe('scoll-to-top of file after collapse', () => { beforeEach(() => { jest.spyOn(wrapper.vm.$store, 'dispatch').mockImplementation(() => {}); + isElementStuck.mockReturnValueOnce(true); }); it("scrolls to the top when the file is open, the users initiates the collapse, and there's a content block to scroll to", async () => { diff --git a/spec/frontend/diffs/components/diff_row_spec.js b/spec/frontend/diffs/components/diff_row_spec.js index 30510958704..e9fbde11211 100644 --- a/spec/frontend/diffs/components/diff_row_spec.js +++ b/spec/frontend/diffs/components/diff_row_spec.js @@ -112,6 +112,8 @@ describe('DiffRow', () => { }); const getCommentButton = (side) => wrapper.find(`[data-testid="${side}-comment-button"]`); + const findRightCommentButton = () => wrapper.find('[data-testid="right-comment-button"]'); + const findLeftCommentButton = () => wrapper.find('[data-testid="left-comment-button"]'); describe.each` side @@ -135,6 +137,10 @@ describe('DiffRow', () => { it('renders', () => { wrapper = createWrapper({ props: { line, inline: false } }); + expect(findRightCommentButton().attributes('draggable')).toBe('true'); + expect(findLeftCommentButton().attributes('draggable')).toBe( + side === 'left' ? 'true' : 'false', + ); expect(getCommentButton(side).exists()).toBe(true); }); diff --git a/spec/frontend/diffs/components/merge_conflict_warning_spec.js b/spec/frontend/diffs/components/merge_conflict_warning_spec.js deleted file mode 100644 index 715912b361f..00000000000 --- a/spec/frontend/diffs/components/merge_conflict_warning_spec.js +++ /dev/null @@ -1,58 +0,0 @@ -import { shallowMount, mount } from '@vue/test-utils'; -import MergeConflictWarning from '~/diffs/components/merge_conflict_warning.vue'; - -const propsData = { - limited: true, - mergeable: true, - resolutionPath: 'a-path', -}; - -function findResolveButton(wrapper) { - return wrapper.find('.gl-alert-actions a.gl-button:first-child'); -} -function findLocalMergeButton(wrapper) { - return wrapper.find('.gl-alert-actions button.gl-button:last-child'); -} - -describe('MergeConflictWarning', () => { - let wrapper; - - const createComponent = (props = {}, { full } = { full: false }) => { - const mounter = full ? mount : shallowMount; - - wrapper = mounter(MergeConflictWarning, { - propsData: { ...propsData, ...props }, - }); - }; - - it.each` - present | resolutionPath - ${false} | ${''} - ${true} | ${'some-path'} - `( - 'toggles the resolve conflicts button based on the provided resolutionPath "$resolutionPath"', - ({ present, resolutionPath }) => { - createComponent({ resolutionPath }, { full: true }); - const resolveButton = findResolveButton(wrapper); - - expect(resolveButton.exists()).toBe(present); - if (present) { - expect(resolveButton.attributes('href')).toBe(resolutionPath); - } - }, - ); - - it.each` - present | mergeable - ${false} | ${false} - ${true} | ${true} - `( - 'toggles the local merge button based on the provided mergeable property "$mergable"', - ({ present, mergeable }) => { - createComponent({ mergeable }, { full: true }); - const localMerge = findLocalMergeButton(wrapper); - - expect(localMerge.exists()).toBe(present); - }, - ); -}); diff --git a/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap b/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap index cfc34bd2f25..33a268c06cc 100644 --- a/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap +++ b/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`FindingsDrawer matches the snapshot 1`] = ` +exports[`FindingsDrawer General Rendering matches the snapshot with detected badge 1`] = ` <transition-stub class="findings-drawer" name="gl-drawer" @@ -16,7 +16,7 @@ exports[`FindingsDrawer matches the snapshot 1`] = ` class="gl-drawer-title" > <h2 - class="drawer-heading gl-font-base gl-mb-0 gl-mt-0" + class="drawer-heading gl-font-base gl-mb-0 gl-mt-0 gl-w-28" > <svg aria-hidden="true" @@ -61,6 +61,227 @@ exports[`FindingsDrawer matches the snapshot 1`] = ` > <li class="gl-mb-4" + data-testid="findings-drawer-title" + > + <p + class="gl-line-height-20" + > + <span + class="gl-display-block gl-font-weight-bold gl-mb-1" + data-testid="findings-drawer-item-description" + > + Name + </span> + <span + data-testid="findings-drawer-item-value-prop" + > + mockedtitle + </span> + </p> + </li> + <li + class="gl-mb-4" + > + <p + class="gl-line-height-20" + > + <span + class="gl-display-block gl-font-weight-bold gl-mb-1" + data-testid="findings-drawer-item-description" + > + Status + </span> + <span + class="badge badge-pill badge-warning gl-badge md text-capitalize" + > + detected + </span> + </p> + </li> + <li + class="gl-mb-4" + > + <p + class="gl-line-height-20" + > + <span + class="gl-display-block gl-font-weight-bold gl-mb-1" + data-testid="findings-drawer-item-description" + > + Description + </span> + <span + data-testid="findings-drawer-item-value-prop" + > + fakedesc + </span> + </p> + </li> + <li + class="gl-mb-4" + > + <p + class="gl-line-height-20" + > + <span + class="gl-display-block gl-font-weight-bold gl-mb-1" + data-testid="findings-drawer-item-description" + > + Project + </span> + <a + class="gl-link" + href="/testpath" + > + testname + </a> + </p> + </li> + <li + class="gl-mb-4" + > + <p + class="gl-line-height-20" + > + <span + class="gl-display-block gl-font-weight-bold gl-mb-1" + data-testid="findings-drawer-item-description" + > + File + </span> + <span + data-testid="findings-drawer-item-value-prop" + /> + </p> + </li> + <li + class="gl-mb-4" + > + <p + class="gl-line-height-20" + > + <span + class="gl-display-block gl-font-weight-bold gl-mb-1" + data-testid="findings-drawer-item-description" + > + Identifiers + </span> + <span> + <a + class="gl-link" + href="https://semgrep.dev/r/gitlab.eslint.detect-disable-mustache-escape" + > + eslint.detect-disable-mustache-escape + </a> + </span> + </p> + </li> + <li + class="gl-mb-4" + > + <p + class="gl-line-height-20" + > + <span + class="gl-display-block gl-font-weight-bold gl-mb-1" + data-testid="findings-drawer-item-description" + > + Tool + </span> + <span + data-testid="findings-drawer-item-value-prop" + > + SAST + </span> + </p> + </li> + <li + class="gl-mb-4" + > + <p + class="gl-line-height-20" + > + <span + class="gl-display-block gl-font-weight-bold gl-mb-1" + data-testid="findings-drawer-item-description" + > + Engine + </span> + <span + data-testid="findings-drawer-item-value-prop" + > + testengine name + </span> + </p> + </li> + </ul> + </div> + </aside> +</transition-stub> +`; + +exports[`FindingsDrawer General Rendering matches the snapshot with dismissed badge 1`] = ` +<transition-stub + class="findings-drawer" + name="gl-drawer" +> + <aside + class="gl-drawer gl-drawer-default" + style="top: 0px; z-index: 252;" + > + <div + class="gl-drawer-header" + > + <div + class="gl-drawer-title" + > + <h2 + class="drawer-heading gl-font-base gl-mb-0 gl-mt-0 gl-w-28" + > + <svg + aria-hidden="true" + class="gl-icon gl-text-orange-300 gl-vertical-align-baseline! inline-findings-severity-icon s12" + data-testid="severity-low-icon" + role="img" + > + <use + href="file-mock#severity-low" + /> + </svg> + <span + class="drawer-heading-severity" + > + low + </span> + SAST Finding + </h2> + <button + aria-label="Close drawer" + class="btn btn-default btn-default-tertiary btn-icon btn-sm gl-button gl-drawer-close-button" + type="button" + > + <svg + aria-hidden="true" + class="gl-button-icon gl-icon s16" + data-testid="close-icon" + role="img" + > + <use + href="file-mock#close" + /> + </svg> + </button> + </div> + </div> + <div + class="gl-drawer-body gl-drawer-body-scrim" + > + <ul + class="gl-border-b-initial gl-list-style-none gl-mb-0 gl-pb-0!" + > + <li + class="gl-mb-4" + data-testid="findings-drawer-title" > <p class="gl-line-height-20" diff --git a/spec/frontend/diffs/components/shared/findings_drawer_spec.js b/spec/frontend/diffs/components/shared/findings_drawer_spec.js index 62d875ed9b7..00b4ca262be 100644 --- a/spec/frontend/diffs/components/shared/findings_drawer_spec.js +++ b/spec/frontend/diffs/components/shared/findings_drawer_spec.js @@ -1,36 +1,106 @@ +import { nextTick } from 'vue'; import { GlDrawer } from '@gitlab/ui'; import FindingsDrawer from '~/diffs/components/shared/findings_drawer.vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import { mockFinding, mockProject } from '../../mock_data/findings_drawer'; - -let wrapper; -const getDrawer = () => wrapper.findComponent(GlDrawer); -const closeEvent = 'close'; - -const createWrapper = () => { - return mountExtended(FindingsDrawer, { - propsData: { - drawer: mockFinding, - project: mockProject, - }, - }); -}; +import { + mockFindingDismissed, + mockFindingDetected, + mockProject, + mockFindingsMultiple, +} from '../../mock_data/findings_drawer'; describe('FindingsDrawer', () => { - it('renders without errors', () => { - wrapper = createWrapper(); - expect(wrapper.exists()).toBe(true); + let wrapper; + + const findPreviousButton = () => wrapper.findByTestId('findings-drawer-prev-button'); + const findNextButton = () => wrapper.findByTestId('findings-drawer-next-button'); + const findTitle = () => wrapper.findByTestId('findings-drawer-title'); + const createWrapper = ( + drawer = { findings: [mockFindingDetected], index: 0 }, + project = mockProject, + ) => { + return mountExtended(FindingsDrawer, { + propsData: { + drawer, + project, + }, + }); + }; + + describe('General Rendering', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + it('renders without errors', () => { + expect(wrapper.exists()).toBe(true); + }); + + it('emits close event when gl-drawer emits close event', () => { + wrapper.findComponent(GlDrawer).vm.$emit('close'); + expect(wrapper.emitted('close')).toHaveLength(1); + }); + + it('matches the snapshot with dismissed badge', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('matches the snapshot with detected badge', () => { + expect(wrapper.element).toMatchSnapshot(); + }); }); - it('emits close event when gl-drawer emits close event', () => { - wrapper = createWrapper(); + describe('Prev/Next Buttons with Multiple Items', () => { + it('renders prev/next buttons when there are multiple items', () => { + wrapper = createWrapper({ findings: mockFindingsMultiple, index: 0 }); + expect(findPreviousButton().exists()).toBe(true); + expect(findNextButton().exists()).toBe(true); + }); + + it('does not render prev/next buttons when there is only one item', () => { + wrapper = createWrapper({ findings: [mockFindingDismissed], index: 0 }); + expect(findPreviousButton().exists()).toBe(false); + expect(findNextButton().exists()).toBe(false); + }); - getDrawer().vm.$emit(closeEvent); - expect(wrapper.emitted(closeEvent)).toHaveLength(1); + it('calls prev method on prev button click and loops correct activeIndex', async () => { + wrapper = createWrapper({ findings: mockFindingsMultiple, index: 0 }); + expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[0].title}`); + + await findPreviousButton().trigger('click'); + await nextTick(); + expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[2].title}`); + + await findPreviousButton().trigger('click'); + await nextTick(); + expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[1].title}`); + }); + + it('calls next method on next button click', async () => { + wrapper = createWrapper({ findings: mockFindingsMultiple, index: 0 }); + expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[0].title}`); + + await findNextButton().trigger('click'); + await nextTick(); + expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[1].title}`); + + await findNextButton().trigger('click'); + await nextTick(); + expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[2].title}`); + + await findNextButton().trigger('click'); + await nextTick(); + expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[0].title}`); + }); }); - it('matches the snapshot', () => { - wrapper = createWrapper(); - expect(wrapper.element).toMatchSnapshot(); + describe('Active Index Handling', () => { + it('watcher sets active index on drawer prop change', async () => { + wrapper = createWrapper(); + const newFinding = { findings: mockFindingsMultiple, index: 2 }; + + await wrapper.setProps({ drawer: newFinding }); + await nextTick(); + expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[2].title}`); + }); }); }); diff --git a/spec/frontend/diffs/mock_data/findings_drawer.js b/spec/frontend/diffs/mock_data/findings_drawer.js index 4823a18b267..257a3b3e499 100644 --- a/spec/frontend/diffs/mock_data/findings_drawer.js +++ b/spec/frontend/diffs/mock_data/findings_drawer.js @@ -1,6 +1,6 @@ -export const mockFinding = { +export const mockFindingDismissed = { title: 'mockedtitle', - state: 'detected', + state: 'dismissed', scale: 'sast', line: 7, description: 'fakedesc', @@ -22,7 +22,54 @@ export const mockFinding = { ], }; +export const mockFindingDetected = { + ...mockFindingDismissed, + state: 'detected', +}; + export const mockProject = { nameWithNamespace: 'testname', fullPath: 'testpath', }; + +export const mockFindingsMultiple = [ + { + ...mockFindingDismissed, + title: 'Finding 1', + severity: 'critical', + engineName: 'Engine 1', + identifiers: [ + { + ...mockFindingDismissed.identifiers[0], + name: 'identifier 1', + url: 'https://example.com/identifier1', + }, + ], + }, + { + ...mockFindingDetected, + title: 'Finding 2', + severity: 'medium', + engineName: 'Engine 2', + identifiers: [ + { + ...mockFindingDetected.identifiers[0], + name: 'identifier 2', + url: 'https://example.com/identifier2', + }, + ], + }, + { + ...mockFindingDetected, + title: 'Finding 3', + severity: 'medium', + engineName: 'Engine 3', + identifiers: [ + { + ...mockFindingDetected.identifiers[0], + name: 'identifier 3', + url: 'https://example.com/identifier3', + }, + ], + }, +]; diff --git a/spec/frontend/diffs/mock_data/inline_findings.js b/spec/frontend/diffs/mock_data/inline_findings.js index ae1ae909238..6307c2c7343 100644 --- a/spec/frontend/diffs/mock_data/inline_findings.js +++ b/spec/frontend/diffs/mock_data/inline_findings.js @@ -45,36 +45,43 @@ export const multipleFindingsArrSastScale = [ line: 2, scale: 'sast', text: 'mocked low Issue', + state: 'detected', }, { severity: 'medium', description: 'mocked medium Issue', line: 3, scale: 'sast', + text: 'mocked medium Issue', + state: 'dismissed', }, { severity: 'info', description: 'mocked info Issue', line: 3, scale: 'sast', + state: 'detected', }, { severity: 'high', description: 'mocked high Issue', line: 3, scale: 'sast', + state: 'dismissed', }, { severity: 'critical', description: 'mocked critical Issue', line: 3, scale: 'sast', + state: 'detected', }, { severity: 'unknown', description: 'mocked unknown Issue', line: 3, scale: 'sast', + state: 'dismissed', }, ]; @@ -114,6 +121,9 @@ export const diffCodeQuality = { export const singularCodeQualityFinding = [multipleFindingsArrCodeQualityScale[0]]; export const singularSastFinding = [multipleFindingsArrSastScale[0]]; +export const singularSastFindingDetected = [multipleFindingsArrSastScale[0]]; +export const singularSastFindingDismissed = [multipleFindingsArrSastScale[1]]; + export const twoSastFindings = multipleFindingsArrSastScale.slice(0, 2); export const fiveCodeQualityFindings = multipleFindingsArrCodeQualityScale.slice(0, 5); export const threeCodeQualityFindings = multipleFindingsArrCodeQualityScale.slice(0, 3); diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index 8cf376b13e3..be3b30e8e7a 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -631,7 +631,7 @@ describe('DiffsStoreActions', () => { describe('prefetchFileNeighbors', () => { it('dispatches two requests to prefetch the next/previous files', () => { - testAction( + return testAction( diffActions.prefetchFileNeighbors, {}, { @@ -1327,8 +1327,13 @@ describe('DiffsStoreActions', () => { await waitForPromises(); expect(dispatch).toHaveBeenCalledWith('fetchFileByFile'); - expect(dispatch).toHaveBeenCalledWith('scrollToFile', file); - expect(dispatch).toHaveBeenCalledTimes(2); + expect(commonUtils.historyPushState).toHaveBeenCalledWith(new URL(`${TEST_HOST}/#test`), { + skipScrolling: true, + }); + expect(commonUtils.scrollToElement).toHaveBeenCalledWith('.diff-files-holder', { + duration: 0, + }); + expect(dispatch).toHaveBeenCalledTimes(1); }); it('shows an alert when there was an error fetching the file', async () => { @@ -2057,11 +2062,48 @@ describe('DiffsStoreActions', () => { describe('toggleFileCommentForm', () => { it('commits TOGGLE_FILE_COMMENT_FORM', () => { + const file = getDiffFileMock(); return testAction( diffActions.toggleFileCommentForm, - 'path', - {}, - [{ type: types.TOGGLE_FILE_COMMENT_FORM, payload: 'path' }], + file.file_path, + { + diffFiles: [file], + }, + [ + { type: types.TOGGLE_FILE_COMMENT_FORM, payload: file.file_path }, + { + type: types.SET_FILE_COLLAPSED, + payload: { filePath: file.file_path, collapsed: false }, + }, + ], + [], + ); + }); + + it('always opens if file is collapsed', () => { + const file = { + ...getDiffFileMock(), + viewer: { + ...getDiffFileMock().viewer, + manuallyCollapsed: true, + }, + }; + return testAction( + diffActions.toggleFileCommentForm, + file.file_path, + { + diffFiles: [file], + }, + [ + { + type: types.SET_FILE_COMMENT_FORM, + payload: { filePath: file.file_path, expanded: true }, + }, + { + type: types.SET_FILE_COLLAPSED, + payload: { filePath: file.file_path, collapsed: false }, + }, + ], [], ); }); diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js index fdcf7c3eeab..a5be41aa69f 100644 --- a/spec/frontend/diffs/store/mutations_spec.js +++ b/spec/frontend/diffs/store/mutations_spec.js @@ -1045,6 +1045,17 @@ describe('DiffsStoreMutations', () => { }); }); + describe('SET_FILE_COMMENT_FORM', () => { + it('toggles diff files hasCommentForm', () => { + const state = { diffFiles: [{ file_path: 'path', hasCommentForm: false }] }; + const expanded = true; + + mutations[types.SET_FILE_COMMENT_FORM](state, { filePath: 'path', expanded }); + + expect(state.diffFiles[0].hasCommentForm).toEqual(expanded); + }); + }); + describe('ADD_DRAFT_TO_FILE', () => { it('adds draft to diff file', () => { const state = { diffFiles: [{ file_path: 'path', drafts: [] }] }; diff --git a/spec/frontend/dropzone_input_spec.js b/spec/frontend/dropzone_input_spec.js index ba4d838e44b..bde84d3b603 100644 --- a/spec/frontend/dropzone_input_spec.js +++ b/spec/frontend/dropzone_input_spec.js @@ -21,6 +21,10 @@ const TEMPLATE = `<form class="gfm-form" data-uploads-path="${TEST_UPLOAD_PATH}" </form>`; describe('dropzone_input', () => { + afterEach(() => { + resetHTMLFixture(); + }); + it('returns null when failed to initialize', () => { const dropzone = dropzoneInput($('<form class="gfm-form"></form>')); @@ -58,8 +62,6 @@ describe('dropzone_input', () => { afterEach(() => { form = null; - - resetHTMLFixture(); }); it('pastes Markdown tables', () => { @@ -154,8 +156,6 @@ describe('dropzone_input', () => { mock.teardown(); }); - beforeEach(() => {}); - it.each` responseType | responseBody ${'application/json'} | ${JSON.stringify({ message: TEST_ERROR_MESSAGE })} @@ -174,4 +174,36 @@ describe('dropzone_input', () => { }); }); }); + + describe('clickable element', () => { + let form; + + beforeEach(() => { + jest.spyOn($.fn, 'dropzone'); + setHTMLFixture(TEMPLATE); + form = $('form'); + }); + + describe('if attach file button exists', () => { + let attachFileButton; + + beforeEach(() => { + attachFileButton = document.createElement('button'); + attachFileButton.dataset.buttonType = 'attach-file'; + document.body.querySelector('form').appendChild(attachFileButton); + }); + + it('passes attach file button as `clickable` to dropzone', () => { + dropzoneInput(form); + expect($.fn.dropzone.mock.calls[0][0]).toMatchObject({ clickable: attachFileButton }); + }); + }); + + describe('if attach file button does not exist', () => { + it('passes attach file button as `clickable`, if it exists', () => { + dropzoneInput(form); + expect($.fn.dropzone.mock.calls[0][0]).toMatchObject({ clickable: true }); + }); + }); + }); }); diff --git a/spec/frontend/editor/schema/ci/ci_schema_spec.js b/spec/frontend/editor/schema/ci/ci_schema_spec.js index 0f380f13679..7986509074e 100644 --- a/spec/frontend/editor/schema/ci/ci_schema_spec.js +++ b/spec/frontend/editor/schema/ci/ci_schema_spec.js @@ -23,6 +23,7 @@ import RetryUnknownWhenJson from './json_tests/negative_tests/retry_unknown_when // YAML POSITIVE TEST import ArtifactsYaml from './yaml_tests/positive_tests/artifacts.yml'; +import ImageYaml from './yaml_tests/positive_tests/image.yml'; import CacheYaml from './yaml_tests/positive_tests/cache.yml'; import FilterYaml from './yaml_tests/positive_tests/filter.yml'; import IncludeYaml from './yaml_tests/positive_tests/include.yml'; @@ -37,9 +38,12 @@ import SecretsYaml from './yaml_tests/positive_tests/secrets.yml'; import ServicesYaml from './yaml_tests/positive_tests/services.yml'; import NeedsParallelMatrixYaml from './yaml_tests/positive_tests/needs_parallel_matrix.yml'; import ScriptYaml from './yaml_tests/positive_tests/script.yml'; +import AutoCancelPipelineOnJobFailureAllYaml from './yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/all.yml'; +import AutoCancelPipelineOnJobFailureNoneYaml from './yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/none.yml'; // YAML NEGATIVE TEST import ArtifactsNegativeYaml from './yaml_tests/negative_tests/artifacts.yml'; +import ImageNegativeYaml from './yaml_tests/negative_tests/image.yml'; import CacheKeyNeative from './yaml_tests/negative_tests/cache.yml'; import IncludeNegativeYaml from './yaml_tests/negative_tests/include.yml'; import JobWhenNegativeYaml from './yaml_tests/negative_tests/job_when.yml'; @@ -62,6 +66,7 @@ import NeedsParallelMatrixNumericYaml from './yaml_tests/negative_tests/needs/pa import NeedsParallelMatrixWrongParallelValueYaml from './yaml_tests/negative_tests/needs/parallel_matrix/wrong_parallel_value.yml'; import NeedsParallelMatrixWrongMatrixValueYaml from './yaml_tests/negative_tests/needs/parallel_matrix/wrong_matrix_value.yml'; import ScriptNegativeYaml from './yaml_tests/negative_tests/script.yml'; +import AutoCancelPipelineNegativeYaml from './yaml_tests/negative_tests/auto_cancel_pipeline.yml'; const ajv = new Ajv({ strictTypes: false, @@ -90,6 +95,7 @@ describe('positive tests', () => { // YAML ArtifactsYaml, + ImageYaml, CacheYaml, FilterYaml, IncludeYaml, @@ -104,6 +110,8 @@ describe('positive tests', () => { SecretsYaml, NeedsParallelMatrixYaml, ScriptYaml, + AutoCancelPipelineOnJobFailureAllYaml, + AutoCancelPipelineOnJobFailureNoneYaml, }), )('schema validates %s', (_, input) => { // We construct a new "JSON" from each main key that is inside a @@ -126,6 +134,7 @@ describe('negative tests', () => { // YAML ArtifactsNegativeYaml, + ImageNegativeYaml, CacheKeyNeative, HooksNegative, IdTokensNegativeYaml, @@ -148,6 +157,7 @@ describe('negative tests', () => { NeedsParallelMatrixWrongParallelValueYaml, NeedsParallelMatrixWrongMatrixValueYaml, ScriptNegativeYaml, + AutoCancelPipelineNegativeYaml, }), )('schema validates %s', (_, input) => { // We construct a new "JSON" from each main key that is inside a diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/auto_cancel_pipeline.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/auto_cancel_pipeline.yml new file mode 100644 index 00000000000..0ba3e5632e3 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/auto_cancel_pipeline.yml @@ -0,0 +1,4 @@ +# invalid workflow:auto-cancel:on-job-failure +workflow: + auto_cancel: + on_job_failure: unexpected_value diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/image.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/image.yml new file mode 100644 index 00000000000..ad37cd6c3ba --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/image.yml @@ -0,0 +1,38 @@ +empty_image: + image: + +multi_image_array: + image: + - alpine:latest + - ubuntu:latest + +image_without_name: + image: + entrypoint: ["/bin/sh", "-c"] + +image_with_invalid_entrypoint: + image: + name: my-postgres:11.7 + entrypoint: "/usr/local/bin/db-postgres" # must be array + +image_with_empty_pull_policy: + image: + name: postgres:11.6 + pull_policy: [] + +invalid_image_platform: + image: + name: alpine:latest + docker: + platform: ["arm64"] # The expected value is a string, not an array + +invalid_image_executor_opts: + image: + name: alpine:latest + docker: + unknown_key: test + +image_with_empty_executor_opts: + image: + name: alpine:latest + docker: diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/services.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/services.yml index 6761a603a0a..e14ac9ca86e 100644 --- a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/services.yml +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/services.yml @@ -36,3 +36,17 @@ empty_pull_policy: services: - name: postgres:11.6 pull_policy: [] + +invalid_service_executor_opts: + script: echo "Specifying platform." + services: + - name: mysql:5.7 + docker: + unknown_key: test + +invalid_service_platform: + script: echo "Specifying platform." + services: + - name: mysql:5.7 + docker: + platform: ["arm64"] # The expected value is a string, not an array diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/all.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/all.yml new file mode 100644 index 00000000000..bf84ff16f42 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/all.yml @@ -0,0 +1,4 @@ +# valid workflow:auto-cancel:on-job-failure +workflow: + auto_cancel: + on_job_failure: all diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/none.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/none.yml new file mode 100644 index 00000000000..b99eb50e962 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/none.yml @@ -0,0 +1,4 @@ +# valid workflow:auto-cancel:on-job-failure +workflow: + auto_cancel: + on_job_failure: none diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/image.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/image.yml new file mode 100644 index 00000000000..4c2559d0800 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/image.yml @@ -0,0 +1,41 @@ +valid_image: + image: alpine:latest + +valid_image_basic: + image: + name: alpine:latest + +valid_image_with_entrypoint: + image: + name: alpine:latest + entrypoint: + - /bin/sh + - -c + +valid_image_with_pull_policy: + image: + name: alpine:latest + pull_policy: always + +valid_image_with_pull_policies: + image: + name: alpine:latest + pull_policy: + - always + - if-not-present + +valid_image_with_docker: + image: + name: alpine:latest + docker: + platform: linux/amd64 + +valid_image_full: + image: + name: alpine:latest + entrypoint: + - /bin/sh + - -c + docker: + platform: linux/amd64 + pull_policy: if-not-present diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/services.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/services.yml index 8a0f59d1dfd..1d19ee52cc3 100644 --- a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/services.yml +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/services.yml @@ -29,3 +29,10 @@ pull_policy_array: services: - name: postgres:11.6 pull_policy: [always, if-not-present] + +services_platform_string: + script: echo "Specifying platform." + services: + - name: mysql:5.7 + docker: + platform: arm64 diff --git a/spec/frontend/editor/source_editor_security_policy_schema_ext_spec.js b/spec/frontend/editor/source_editor_security_policy_schema_ext_spec.js new file mode 100644 index 00000000000..96c876b27c9 --- /dev/null +++ b/spec/frontend/editor/source_editor_security_policy_schema_ext_spec.js @@ -0,0 +1,181 @@ +import MockAdapter from 'axios-mock-adapter'; +import { registerSchema } from '~/ide/utils'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { TEST_HOST } from 'helpers/test_constants'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { + getSecurityPolicyListUrl, + getSecurityPolicySchemaUrl, + getSinglePolicySchema, + SecurityPolicySchemaExtension, +} from '~/editor/extensions/source_editor_security_policy_schema_ext'; +import SourceEditor from '~/editor/source_editor'; + +jest.mock('~/ide/utils'); + +const mockNamespacePath = 'mock-namespace'; + +const mockSchema = { + $id: 1, + title: 'mockSchema', + description: 'mockDescriptions', + type: 'Object', + properties: { + scan_execution_policy: { items: { properties: { foo: 'bar' } } }, + scan_result_policy: { items: { properties: { fizz: 'buzz' } } }, + }, +}; + +const createMockOutput = (policyType) => ({ + $id: mockSchema.$id, + title: mockSchema.title, + description: mockSchema.description, + type: mockSchema.type, + properties: { + type: { + type: 'string', + description: 'Specifies the type of policy to be enforced.', + enum: policyType, + }, + ...mockSchema.properties[policyType].items.properties, + }, +}); + +describe('getSecurityPolicyListUrl', () => { + it.each` + input | output + ${{ namespacePath: '' }} | ${`${TEST_HOST}/groups/-/security/policies`} + ${{ namespacePath: 'test', namespaceType: 'group' }} | ${`${TEST_HOST}/groups/test/-/security/policies`} + ${{ namespacePath: '', namespaceType: 'project' }} | ${`${TEST_HOST}/-/security/policies`} + ${{ namespacePath: 'test', namespaceType: 'project' }} | ${`${TEST_HOST}/test/-/security/policies`} + ${{ namespacePath: undefined, namespaceType: 'project' }} | ${`${TEST_HOST}/-/security/policies`} + ${{ namespacePath: undefined, namespaceType: 'group' }} | ${`${TEST_HOST}/groups/-/security/policies`} + ${{ namespacePath: null, namespaceType: 'project' }} | ${`${TEST_HOST}/-/security/policies`} + ${{ namespacePath: null, namespaceType: 'group' }} | ${`${TEST_HOST}/groups/-/security/policies`} + `('returns `$output` when passed `$input`', ({ input, output }) => { + expect(getSecurityPolicyListUrl(input)).toBe(output); + }); +}); + +describe('getSecurityPolicySchemaUrl', () => { + it.each` + namespacePath | namespaceType | output + ${'test'} | ${'project'} | ${`${TEST_HOST}/test/-/security/policies/schema`} + ${'test'} | ${'group'} | ${`${TEST_HOST}/groups/test/-/security/policies/schema`} + `( + 'returns $output when passed $namespacePath and $namespaceType', + ({ namespacePath, namespaceType, output }) => { + expect(getSecurityPolicySchemaUrl({ namespacePath, namespaceType })).toBe(output); + }, + ); +}); + +describe('getSinglePolicySchema', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + it.each` + policyType + ${'scan_execution_policy'} + ${'scan_result_policy'} + `('returns the appropriate schema on request success for $policyType', async ({ policyType }) => { + mock.onGet().reply(HTTP_STATUS_OK, mockSchema); + + await expect( + getSinglePolicySchema({ + namespacePath: mockNamespacePath, + namespaceType: 'project', + policyType, + }), + ).resolves.toStrictEqual(createMockOutput(policyType)); + }); + + it('returns an empty schema on request failure', async () => { + await expect( + getSinglePolicySchema({ + namespacePath: mockNamespacePath, + namespaceType: 'project', + policyType: 'scan_execution_policy', + }), + ).resolves.toStrictEqual({}); + }); + + it('returns an empty schema on non-existing policy type', async () => { + await expect( + getSinglePolicySchema({ + namespacePath: mockNamespacePath, + namespaceType: 'project', + policyType: 'non_existent_policy', + }), + ).resolves.toStrictEqual({}); + }); +}); + +describe('SecurityPolicySchemaExtension', () => { + let mock; + let editor; + let instance; + let editorEl; + + const createMockEditor = ({ blobPath = '.gitlab/security-policies/policy.yml' } = {}) => { + setHTMLFixture('<div id="editor"></div>'); + editorEl = document.getElementById('editor'); + editor = new SourceEditor(); + instance = editor.createInstance({ el: editorEl, blobPath, blobContent: '' }); + instance.use({ definition: SecurityPolicySchemaExtension }); + }; + + beforeEach(() => { + createMockEditor(); + mock = new MockAdapter(axios); + mock.onGet().reply(HTTP_STATUS_OK, mockSchema); + }); + + afterEach(() => { + instance.dispose(); + editorEl.remove(); + resetHTMLFixture(); + mock.restore(); + }); + + describe('registerSecurityPolicyEditorSchema', () => { + describe('register validations options with monaco for yaml language', () => { + it('registers the schema', async () => { + const policyType = 'scan_execution_policy'; + await instance.registerSecurityPolicyEditorSchema({ + namespacePath: mockNamespacePath, + namespaceType: 'project', + policyType, + }); + + expect(registerSchema).toHaveBeenCalledTimes(1); + expect(registerSchema).toHaveBeenCalledWith({ + uri: `${TEST_HOST}/${mockNamespacePath}/-/security/policies/schema`, + schema: createMockOutput(policyType), + fileMatch: ['policy.yml'], + }); + }); + }); + }); + + describe('registerSecurityPolicySchema', () => { + describe('register validations options with monaco for yaml language', () => { + it('registers the schema', async () => { + await instance.registerSecurityPolicySchema(mockNamespacePath); + expect(registerSchema).toHaveBeenCalledTimes(1); + expect(registerSchema).toHaveBeenCalledWith({ + uri: `${TEST_HOST}/${mockNamespacePath}/-/security/policies/schema`, + fileMatch: ['policy.yml'], + }); + }); + }); + }); +}); diff --git a/spec/frontend/emoji/components/emoji_group_spec.js b/spec/frontend/emoji/components/emoji_group_spec.js index 75397ce25ff..a2a46bedd7b 100644 --- a/spec/frontend/emoji/components/emoji_group_spec.js +++ b/spec/frontend/emoji/components/emoji_group_spec.js @@ -1,5 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; +import { GlButton } from '@gitlab/ui'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import EmojiGroup from '~/emoji/components/emoji_group.vue'; @@ -10,6 +11,9 @@ function factory(propsData = {}) { wrapper = extendedWrapper( shallowMount(EmojiGroup, { propsData, + stubs: { + GlButton, + }, }), ); } @@ -19,7 +23,6 @@ describe('Emoji group component', () => { factory({ emojis: [], renderGroup: false, - clickEmoji: jest.fn(), }); expect(wrapper.findByTestId('emoji-button').exists()).toBe(false); @@ -29,24 +32,20 @@ describe('Emoji group component', () => { factory({ emojis: ['thumbsup', 'thumbsdown'], renderGroup: true, - clickEmoji: jest.fn(), }); expect(wrapper.findAllByTestId('emoji-button').exists()).toBe(true); expect(wrapper.findAllByTestId('emoji-button').length).toBe(2); }); - it('calls clickEmoji', () => { - const clickEmoji = jest.fn(); - + it('emits emoji-click', () => { factory({ emojis: ['thumbsup', 'thumbsdown'], renderGroup: true, - clickEmoji, }); - wrapper.findByTestId('emoji-button').trigger('click'); + wrapper.findComponent(GlButton).vm.$emit('click'); - expect(clickEmoji).toHaveBeenCalledWith('thumbsup'); + expect(wrapper.emitted('emoji-click')).toStrictEqual([['thumbsup']]); }); }); diff --git a/spec/frontend/emoji/index_spec.js b/spec/frontend/emoji/index_spec.js index 7d6a45fbf30..577b7bc726e 100644 --- a/spec/frontend/emoji/index_spec.js +++ b/spec/frontend/emoji/index_spec.js @@ -925,7 +925,7 @@ describe('emoji', () => { window.gon = {}; }); - it('returns empty object', async () => { + it('returns empty emoji data', async () => { const result = await loadCustomEmojiWithNames(); expect(result).toEqual({ emojis: {}, names: [] }); @@ -937,7 +937,28 @@ describe('emoji', () => { delete document.body.dataset.groupFullPath; }); - it('returns empty object', async () => { + it('returns empty emoji data', async () => { + const result = await loadCustomEmojiWithNames(); + + expect(result).toEqual({ emojis: {}, names: [] }); + }); + }); + + describe('when GraphQL request returns null data', () => { + beforeEach(() => { + mockClient = createMockClient([ + [ + customEmojiQuery, + jest.fn().mockResolvedValue({ + data: { + group: null, + }, + }), + ], + ]); + }); + + it('returns empty emoji data', async () => { const result = await loadCustomEmojiWithNames(); expect(result).toEqual({ emojis: {}, names: [] }); @@ -945,7 +966,7 @@ describe('emoji', () => { }); describe('when in a group with flag enabled', () => { - it('returns empty object', async () => { + it('returns emoji data', async () => { const result = await loadCustomEmojiWithNames(); expect(result).toEqual({ diff --git a/spec/frontend/environments/deploy_board_wrapper_spec.js b/spec/frontend/environments/deploy_board_wrapper_spec.js index 49eed68fa11..fec5032e31b 100644 --- a/spec/frontend/environments/deploy_board_wrapper_spec.js +++ b/spec/frontend/environments/deploy_board_wrapper_spec.js @@ -56,7 +56,7 @@ describe('~/environments/components/deploy_board_wrapper.vue', () => { }); it('is collapsed by default', () => { - expect(collapse.attributes('visible')).toBeUndefined(); + expect(collapse.props('visible')).toBe(false); expect(icon.props('name')).toBe('chevron-lg-right'); }); @@ -64,7 +64,7 @@ describe('~/environments/components/deploy_board_wrapper.vue', () => { const button = await expandCollapsedSection(); expect(button.attributes('aria-label')).toBe(__('Collapse')); - expect(collapse.attributes('visible')).toBe('visible'); + expect(collapse.props('visible')).toBe(true); expect(icon.props('name')).toBe('chevron-lg-down'); const deployBoard = findDeployBoard(); diff --git a/spec/frontend/environments/deployment_spec.js b/spec/frontend/environments/deployment_spec.js index 4cbbb60b74c..bc0f1c58e7d 100644 --- a/spec/frontend/environments/deployment_spec.js +++ b/spec/frontend/environments/deployment_spec.js @@ -4,7 +4,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { useFakeDate } from 'helpers/fake_date'; import { stubTransition } from 'helpers/stub_transition'; -import { formatDate } from '~/lib/utils/datetime_utility'; +import { localeDateFormat } from '~/lib/utils/datetime_utility'; import { __, s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import Deployment from '~/environments/components/deployment.vue'; @@ -158,7 +158,9 @@ describe('~/environments/components/deployment.vue', () => { describe('is present', () => { it('shows the timestamp the deployment was deployed at', () => { wrapper = createWrapper(); - const date = wrapper.findByTitle(formatDate(deployment.createdAt)); + const date = wrapper.findByTitle( + localeDateFormat.asDateTimeFull.format(deployment.createdAt), + ); expect(date.text()).toBe('1 day ago'); }); @@ -166,7 +168,9 @@ describe('~/environments/components/deployment.vue', () => { describe('is not present', () => { it('does not show the timestamp', () => { wrapper = createWrapper({ propsData: { deployment: { ...deployment, createdAt: null } } }); - const date = wrapper.findByTitle(formatDate(deployment.createdAt)); + const date = wrapper.findByTitle( + localeDateFormat.asDateTimeFull.format(deployment.createdAt), + ); expect(date.exists()).toBe(false); }); diff --git a/spec/frontend/environments/environment_flux_resource_selector_spec.js b/spec/frontend/environments/environment_flux_resource_selector_spec.js index ba3375c731f..8dab8fdd96a 100644 --- a/spec/frontend/environments/environment_flux_resource_selector_spec.js +++ b/spec/frontend/environments/environment_flux_resource_selector_spec.js @@ -25,7 +25,7 @@ const DEFAULT_PROPS = { fluxResourcePath: '', }; -describe('~/environments/components/form.vue', () => { +describe('~/environments/components/flux_resource_selector.vue', () => { let wrapper; const kustomizationItem = { diff --git a/spec/frontend/environments/environment_folder_spec.js b/spec/frontend/environments/environment_folder_spec.js index 1973613897d..e21e0f280ec 100644 --- a/spec/frontend/environments/environment_folder_spec.js +++ b/spec/frontend/environments/environment_folder_spec.js @@ -79,7 +79,7 @@ describe('~/environments/components/environments_folder.vue', () => { it('is collapsed by default', () => { const link = findLink(); - expect(collapse.attributes('visible')).toBeUndefined(); + expect(collapse.props('visible')).toBe(false); const iconNames = icons.wrappers.map((i) => i.props('name')).slice(0, 2); expect(iconNames).toEqual(['chevron-lg-right', 'folder-o']); expect(folderName.classes('gl-font-weight-bold')).toBe(false); @@ -96,7 +96,7 @@ describe('~/environments/components/environments_folder.vue', () => { const link = findLink(); expect(button.attributes('aria-label')).toBe(__('Collapse')); - expect(collapse.attributes('visible')).toBe('visible'); + expect(collapse.props('visible')).toBe(true); const iconNames = icons.wrappers.map((i) => i.props('name')).slice(0, 2); expect(iconNames).toEqual(['chevron-lg-down', 'folder-open']); expect(folderName.classes('gl-font-weight-bold')).toBe(true); diff --git a/spec/frontend/environments/environment_form_spec.js b/spec/frontend/environments/environment_form_spec.js index 478ac8d6e0e..f3dfc7a72f2 100644 --- a/spec/frontend/environments/environment_form_spec.js +++ b/spec/frontend/environments/environment_form_spec.js @@ -1,11 +1,12 @@ -import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; -import Vue from 'vue'; +import { GlLoadingIcon } from '@gitlab/ui'; +import Vue, { nextTick } 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 EnvironmentFluxResourceSelector from '~/environments/components/environment_flux_resource_selector.vue'; +import EnvironmentNamespaceSelector from '~/environments/components/environment_namespace_selector.vue'; import createMockApollo from '../__helpers__/mock_apollo_helper'; import { mockKasTunnelUrl } from './mock_data'; @@ -36,13 +37,16 @@ const configuration = { credentials: 'include', }; +const environmentWithAgentAndNamespace = { + ...DEFAULT_PROPS.environment, + clusterAgent: { id: '12', name: 'agent-2' }, + clusterAgentId: '2', + kubernetesNamespace: 'agent', +}; + describe('~/environments/components/form.vue', () => { let wrapper; - const getNamespacesQueryResult = jest - .fn() - .mockReturnValue([{ metadata: { name: 'default' } }, { metadata: { name: 'agent' } }]); - const createWrapper = (propsData = {}, options = {}) => mountExtended(EnvironmentForm, { provide: PROVIDE, @@ -53,7 +57,7 @@ describe('~/environments/components/form.vue', () => { }, }); - const createWrapperWithApollo = ({ propsData = {}, queryResult = null } = {}) => { + const createWrapperWithApollo = (propsData = {}) => { Vue.use(VueApollo); const requestHandlers = [ @@ -70,12 +74,6 @@ describe('~/environments/components/form.vue', () => { ], ]; - const mockResolvers = { - Query: { - k8sNamespaces: queryResult || getNamespacesQueryResult, - }, - }; - return mountExtended(EnvironmentForm, { provide: { ...PROVIDE, @@ -84,13 +82,12 @@ describe('~/environments/components/form.vue', () => { ...DEFAULT_PROPS, ...propsData, }, - apolloProvider: createMockApollo(requestHandlers, mockResolvers), + apolloProvider: createMockApollo(requestHandlers, []), }); }; const findAgentSelector = () => wrapper.findByTestId('agent-selector'); - const findNamespaceSelector = () => wrapper.findByTestId('namespace-selector'); - const findAlert = () => wrapper.findComponent(GlAlert); + const findNamespaceSelector = () => wrapper.findComponent(EnvironmentNamespaceSelector); const findFluxResourceSelector = () => wrapper.findComponent(EnvironmentFluxResourceSelector); const selectAgent = async () => { @@ -326,91 +323,15 @@ describe('~/environments/components/form.vue', () => { expect(findNamespaceSelector().exists()).toBe(true); }); - it('requests the kubernetes namespaces with the correct configuration', async () => { - await waitForPromises(); - - expect(getNamespacesQueryResult).toHaveBeenCalledWith( - {}, - { configuration }, - expect.anything(), - expect.anything(), - ); - }); - - it('sets the loading prop while fetching the list', async () => { - expect(findNamespaceSelector().props('loading')).toBe(true); - - await waitForPromises(); - - expect(findNamespaceSelector().props('loading')).toBe(false); - }); - - it('renders a list of available namespaces', async () => { - await waitForPromises(); - - expect(findNamespaceSelector().props('items')).toEqual([ - { text: 'default', value: 'default' }, - { text: 'agent', value: 'agent' }, - ]); - }); - - it('filters the namespaces list on user search', async () => { - await waitForPromises(); - await findNamespaceSelector().vm.$emit('search', 'default'); - - expect(findNamespaceSelector().props('items')).toEqual([ - { value: 'default', text: 'default' }, - ]); - }); - - it('updates namespace selector field with the name of selected namespace', async () => { - await waitForPromises(); - await findNamespaceSelector().vm.$emit('select', 'agent'); - - expect(findNamespaceSelector().props('toggleText')).toBe('agent'); - }); - it('emits changes to the kubernetesNamespace', async () => { await waitForPromises(); - await findNamespaceSelector().vm.$emit('select', 'agent'); + findNamespaceSelector().vm.$emit('change', 'agent'); + await nextTick(); expect(wrapper.emitted('change')[1]).toEqual([ { name: '', externalUrl: '', kubernetesNamespace: 'agent', fluxResourcePath: null }, ]); }); - - it('clears namespace selector when another agent was selected', async () => { - await waitForPromises(); - await findNamespaceSelector().vm.$emit('select', 'agent'); - - expect(findNamespaceSelector().props('toggleText')).toBe('agent'); - - await findAgentSelector().vm.$emit('select', '1'); - expect(findNamespaceSelector().props('toggleText')).toBe( - EnvironmentForm.i18n.namespaceHelpText, - ); - }); - }); - - describe('when cannot connect to the cluster', () => { - const error = new Error('Error from the cluster_client API'); - - beforeEach(async () => { - wrapper = createWrapperWithApollo({ - queryResult: jest.fn().mockRejectedValueOnce(error), - }); - - await selectAgent(); - await waitForPromises(); - }); - - it("doesn't render the namespace selector", () => { - expect(findNamespaceSelector().exists()).toBe(false); - }); - - it('renders an alert', () => { - expect(findAlert().text()).toBe('Error from the cluster_client API'); - }); }); }); @@ -431,16 +352,6 @@ describe('~/environments/components/form.vue', () => { it("doesn't render flux resource selector", () => { expect(findFluxResourceSelector().exists()).toBe(false); }); - - it('renders the flux resource selector when the namespace is selected', async () => { - await findNamespaceSelector().vm.$emit('select', 'agent'); - - expect(findFluxResourceSelector().props()).toEqual({ - namespace: 'agent', - fluxResourcePath: '', - configuration, - }); - }); }); }); @@ -451,9 +362,7 @@ describe('~/environments/components/form.vue', () => { clusterAgentId: '1', }; beforeEach(() => { - wrapper = createWrapperWithApollo({ - propsData: { environment: environmentWithAgent }, - }); + wrapper = createWrapperWithApollo({ environment: environmentWithAgent }); }); it('updates agent selector field with the name of the associated agent', () => { @@ -468,45 +377,46 @@ describe('~/environments/components/form.vue', () => { it('renders a list of available namespaces', async () => { await waitForPromises(); - expect(findNamespaceSelector().props('items')).toEqual([ - { text: 'default', value: 'default' }, - { text: 'agent', value: 'agent' }, - ]); + expect(findNamespaceSelector().exists()).toBe(true); }); }); describe('when environment has an associated kubernetes namespace', () => { - const environmentWithAgentAndNamespace = { - ...DEFAULT_PROPS.environment, - clusterAgent: { id: '1', name: 'agent-1' }, - clusterAgentId: '1', - kubernetesNamespace: 'default', - }; beforeEach(() => { - wrapper = createWrapperWithApollo({ - propsData: { environment: environmentWithAgentAndNamespace }, - }); + wrapper = createWrapperWithApollo({ environment: environmentWithAgentAndNamespace }); }); it('updates namespace selector with the name of the associated namespace', async () => { await waitForPromises(); - expect(findNamespaceSelector().props('toggleText')).toBe('default'); + expect(findNamespaceSelector().props('namespace')).toBe('agent'); + }); + + it('clears namespace selector when another agent was selected', async () => { + expect(findNamespaceSelector().props('namespace')).toBe('agent'); + + findAgentSelector().vm.$emit('select', '1'); + await nextTick(); + + expect(findNamespaceSelector().props('namespace')).toBe(null); + }); + + it('renders the flux resource selector when the namespace is selected', () => { + expect(findFluxResourceSelector().props()).toEqual({ + namespace: 'agent', + fluxResourcePath: '', + configuration, + }); }); }); describe('when environment has an associated flux resource', () => { const fluxResourcePath = 'path/to/flux/resource'; - const environmentWithAgentAndNamespace = { - ...DEFAULT_PROPS.environment, - clusterAgent: { id: '1', name: 'agent-1' }, - clusterAgentId: '1', - kubernetesNamespace: 'default', + const environmentWithFluxResource = { + ...environmentWithAgentAndNamespace, fluxResourcePath, }; beforeEach(() => { - wrapper = createWrapperWithApollo({ - propsData: { environment: environmentWithAgentAndNamespace }, - }); + wrapper = createWrapperWithApollo({ environment: environmentWithFluxResource }); }); it('provides flux resource path to the flux resource selector component', () => { diff --git a/spec/frontend/environments/environment_namespace_selector_spec.js b/spec/frontend/environments/environment_namespace_selector_spec.js new file mode 100644 index 00000000000..53e4f807751 --- /dev/null +++ b/spec/frontend/environments/environment_namespace_selector_spec.js @@ -0,0 +1,217 @@ +import { GlAlert, GlCollapsibleListbox, GlButton } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import EnvironmentNamespaceSelector from '~/environments/components/environment_namespace_selector.vue'; +import { stubComponent } from 'helpers/stub_component'; +import createMockApollo from '../__helpers__/mock_apollo_helper'; +import { mockKasTunnelUrl } from './mock_data'; + +const configuration = { + basePath: mockKasTunnelUrl.replace(/\/$/, ''), + headers: { + 'GitLab-Agent-Id': 2, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + credentials: 'include', +}; + +const DEFAULT_PROPS = { + namespace: '', + configuration, +}; + +describe('~/environments/components/namespace_selector.vue', () => { + let wrapper; + + const getNamespacesQueryResult = jest + .fn() + .mockReturnValue([ + { metadata: { name: 'default' } }, + { metadata: { name: 'agent' } }, + { metadata: { name: 'test-agent' } }, + ]); + + const closeMock = jest.fn(); + + const createWrapper = ({ propsData = {}, queryResult = null } = {}) => { + Vue.use(VueApollo); + + const mockResolvers = { + Query: { + k8sNamespaces: queryResult || getNamespacesQueryResult, + }, + }; + + return shallowMount(EnvironmentNamespaceSelector, { + propsData: { + ...DEFAULT_PROPS, + ...propsData, + }, + stubs: { + GlCollapsibleListbox: stubComponent(GlCollapsibleListbox, { + template: `<div><slot name="footer"></slot></div>`, + methods: { + close: closeMock, + }, + }), + }, + apolloProvider: createMockApollo([], mockResolvers), + }); + }; + + const findNamespaceSelector = () => wrapper.findComponent(GlCollapsibleListbox); + const findAlert = () => wrapper.findComponent(GlAlert); + const findSelectButton = () => wrapper.findComponent(GlButton); + + const searchNamespace = async (searchTerm = 'test') => { + findNamespaceSelector().vm.$emit('search', searchTerm); + await nextTick(); + }; + + describe('default', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('renders namespace selector', () => { + expect(findNamespaceSelector().exists()).toBe(true); + }); + + it('requests the namespaces', async () => { + await waitForPromises(); + + expect(getNamespacesQueryResult).toHaveBeenCalled(); + }); + + it('sets the loading prop while fetching the list', async () => { + expect(findNamespaceSelector().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findNamespaceSelector().props('loading')).toBe(false); + }); + + it('renders a list of available namespaces', async () => { + await waitForPromises(); + + expect(findNamespaceSelector().props('items')).toMatchObject([ + { + text: 'default', + value: 'default', + }, + { + text: 'agent', + value: 'agent', + }, + { + text: 'test-agent', + value: 'test-agent', + }, + ]); + }); + + it('filters the namespaces list on user search', async () => { + await waitForPromises(); + await searchNamespace('agent'); + + expect(findNamespaceSelector().props('items')).toMatchObject([ + { + text: 'agent', + value: 'agent', + }, + { + text: 'test-agent', + value: 'test-agent', + }, + ]); + }); + + it('emits changes to the namespace', () => { + findNamespaceSelector().vm.$emit('select', 'agent'); + + expect(wrapper.emitted('change')).toEqual([['agent']]); + }); + }); + + describe('custom select button', () => { + beforeEach(async () => { + wrapper = createWrapper(); + await waitForPromises(); + }); + + it("doesn't render custom select button before searching", () => { + expect(findSelectButton().exists()).toBe(false); + }); + + it("doesn't render custom select button when the search is found in the namespaces list", async () => { + await searchNamespace('test-agent'); + expect(findSelectButton().exists()).toBe(false); + }); + + it('renders custom select button when the namespace searched for is not found in the namespaces list', async () => { + await searchNamespace(); + expect(findSelectButton().exists()).toBe(true); + }); + + it('emits custom filled namespace name to the `change` event', async () => { + await searchNamespace(); + findSelectButton().vm.$emit('click'); + + expect(wrapper.emitted('change')).toEqual([['test']]); + }); + + it('closes the listbox after the custom value for the namespace was selected', async () => { + await searchNamespace(); + findSelectButton().vm.$emit('click'); + + expect(closeMock).toHaveBeenCalled(); + }); + }); + + describe('when environment has an associated namespace', () => { + beforeEach(() => { + wrapper = createWrapper({ + propsData: { namespace: 'existing-namespace' }, + }); + }); + + it('updates namespace selector with the name of the associated namespace', () => { + expect(findNamespaceSelector().props('toggleText')).toBe('existing-namespace'); + }); + }); + + describe('on error', () => { + const error = new Error('Error from the cluster_client API'); + + beforeEach(async () => { + wrapper = createWrapper({ + queryResult: jest.fn().mockRejectedValueOnce(error), + }); + await waitForPromises(); + }); + + it('renders an alert with the error text', () => { + expect(findAlert().text()).toContain(error.message); + }); + + it('renders an empty namespace selector', () => { + expect(findNamespaceSelector().props('items')).toMatchObject([]); + }); + + it('renders custom select button when the user performs search', async () => { + await searchNamespace(); + + expect(findSelectButton().exists()).toBe(true); + }); + + it('emits custom filled namespace name to the `change` event', async () => { + await searchNamespace(); + findSelectButton().vm.$emit('click'); + + expect(wrapper.emitted('change')).toEqual([['test']]); + }); + }); +}); diff --git a/spec/frontend/environments/folder/environments_folder_app_spec.js b/spec/frontend/environments/folder/environments_folder_app_spec.js new file mode 100644 index 00000000000..0b76a74e3a0 --- /dev/null +++ b/spec/frontend/environments/folder/environments_folder_app_spec.js @@ -0,0 +1,131 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlSkeletonLoader, GlTab, GlPagination } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import EnvironmentsFolderAppComponent from '~/environments/folder/environments_folder_app.vue'; +import EnvironmentItem from '~/environments/components/new_environment_item.vue'; +import StopEnvironmentModal from '~/environments/components/stop_environment_modal.vue'; +import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue'; +import DeleteEnvironmentModal from '~/environments/components/delete_environment_modal.vue'; +import CanaryUpdateModal from '~/environments/components/canary_update_modal.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { + resolvedFolder, + resolvedEnvironment, + resolvedEnvironmentToDelete, + resolvedEnvironmentToRollback, +} from '../graphql/mock_data'; + +Vue.use(VueApollo); + +describe('EnvironmentsFolderAppComponent', () => { + let wrapper; + const mockFolderName = 'folders'; + + let environmentFolderMock; + + const createApolloProvider = () => { + const mockResolvers = { + Query: { + folder: environmentFolderMock, + environmentToDelete: jest.fn().mockReturnValue(resolvedEnvironmentToDelete), + environmentToRollback: jest.fn().mockReturnValue(resolvedEnvironment), + environmentToChangeCanary: jest.fn().mockReturnValue(resolvedEnvironment), + environmentToStop: jest.fn().mockReturnValue(resolvedEnvironment), + weight: jest.fn().mockReturnValue(1), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + beforeEach(() => { + environmentFolderMock = jest.fn(); + }); + + const emptyFolderData = { + environments: [], + activeCount: 0, + stoppedCount: 0, + __typename: 'LocalEnvironmentFolder', + }; + + const createWrapper = ({ folderData } = {}) => { + environmentFolderMock.mockReturnValue(folderData || emptyFolderData); + + const apolloProvider = createApolloProvider(); + + wrapper = shallowMountExtended(EnvironmentsFolderAppComponent, { + apolloProvider, + propsData: { + folderName: mockFolderName, + folderPath: '/gitlab-org/test-project/-/environments/folder/dev', + scope: 'active', + page: 1, + }, + }); + }; + + const findHeader = () => wrapper.findByTestId('folder-name'); + const findEnvironmentItems = () => wrapper.findAllComponents(EnvironmentItem); + const findSkeletonLoaders = () => wrapper.findAllComponents(GlSkeletonLoader); + const findTabs = () => wrapper.findAllComponents(GlTab); + + it('should render a header with the folder name', () => { + createWrapper(); + + expect(findHeader().text()).toMatchInterpolatedText(`Environments / ${mockFolderName}`); + }); + + it('should show skeletons while loading', () => { + createWrapper(); + expect(findSkeletonLoaders().length).toBe(3); + }); + + describe('when environments are loaded', () => { + beforeEach(async () => { + createWrapper({ folderData: resolvedFolder }); + await waitForPromises(); + }); + + it('should list environmnets in folder', () => { + const items = findEnvironmentItems(); + expect(items.length).toBe(resolvedFolder.environments.length); + }); + + it('should render active and stopped tabs', () => { + const tabs = findTabs(); + expect(tabs.length).toBe(2); + }); + + [ + [StopEnvironmentModal, resolvedEnvironment], + [DeleteEnvironmentModal, resolvedEnvironmentToDelete], + [ConfirmRollbackModal, resolvedEnvironmentToRollback], + ].forEach(([Component, expectedEnvironment]) => + it(`should render ${Component.name} component`, () => { + const modal = wrapper.findComponent(Component); + + expect(modal.exists()).toBe(true); + expect(modal.props().environment).toEqual(expectedEnvironment); + expect(modal.props().graphql).toBe(true); + }), + ); + + it(`should render CanaryUpdateModal component`, () => { + const modal = wrapper.findComponent(CanaryUpdateModal); + + expect(modal.exists()).toBe(true); + expect(modal.props().environment).toEqual(resolvedEnvironment); + expect(modal.props().weight).toBe(1); + }); + + it('should render pagination component', () => { + const pagination = wrapper.findComponent(GlPagination); + + expect(pagination.props().perPage).toBe(20); + expect(pagination.props().totalItems).toBe(2); + }); + }); +}); diff --git a/spec/frontend/environments/folder/environments_folder_view_spec.js b/spec/frontend/environments/folder/environments_folder_view_spec.js index 6a40c68397b..34eef1e89ab 100644 --- a/spec/frontend/environments/folder/environments_folder_view_spec.js +++ b/spec/frontend/environments/folder/environments_folder_view_spec.js @@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { removeBreakLine, removeWhitespace } from 'helpers/text_helper'; import EnvironmentTable from '~/environments/components/environments_table.vue'; +import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue'; import EnvironmentsFolderViewComponent from '~/environments/folder/environments_folder_view.vue'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; @@ -91,6 +92,10 @@ describe('Environments Folder View', () => { ).toContain('Environments / review'); }); + it('should render the confirm rollback modal', () => { + expect(wrapper.findComponent(ConfirmRollbackModal).exists()).toBe(true); + }); + describe('pagination', () => { it('should render pagination', () => { expect(wrapper.findComponent(GlPagination).exists()).toBe(true); diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js index 7d354566761..efc63a80e89 100644 --- a/spec/frontend/environments/graphql/mock_data.js +++ b/spec/frontend/environments/graphql/mock_data.js @@ -886,6 +886,11 @@ const failedDeployment = { ], }, }; +const pendingDeployment = { + status: { + conditions: [], + }, +}; const readyDaemonSet = { status: { numberReady: 1, desiredNumberScheduled: 1, numberMisscheduled: 0 }, }; @@ -904,7 +909,7 @@ const suspendedCronJob = { spec: { suspend: 1 }, status: { active: 0, lastSchedu const failedCronJob = { spec: { suspend: 0 }, status: { active: 2, lastScheduleTime: '' } }; export const k8sWorkloadsMock = { - DeploymentList: [readyDeployment, failedDeployment], + DeploymentList: [readyDeployment, failedDeployment, pendingDeployment], DaemonSetList: [readyDaemonSet, failedDaemonSet, failedDaemonSet], StatefulSetList: [readySet, readySet, failedSet], ReplicaSetList: [readySet, failedSet], @@ -925,3 +930,153 @@ export const fluxKustomizationsMock = [ ]; export const fluxResourcePathMock = 'path/to/flux/resource'; + +export const resolvedEnvironmentToDelete = { + __typename: 'LocalEnvironment', + id: 41, + name: 'review/hello', + deletePath: '/api/v4/projects/8/environments/41', +}; + +export const resolvedEnvironmentToRollback = { + __typename: 'LocalEnvironment', + id: 41, + name: 'review/hello', + lastDeployment: { + id: 78, + iid: 24, + sha: 'f3ba6dd84f8f891373e9b869135622b954852db1', + ref: { name: 'main', refPath: '/h5bp/html5-boilerplate/-/tree/main' }, + status: 'success', + createdAt: '2022-01-07T15:47:27.415Z', + deployedAt: '2022-01-07T15:47:32.450Z', + tierInYaml: 'staging', + tag: false, + isLast: true, + user: { + id: 1, + username: 'root', + name: 'Administrator', + state: 'active', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + webUrl: 'http://gck.test:3000/root', + showStatus: false, + path: '/root', + }, + deployable: { + id: 1014, + name: 'deploy-prod', + started: '2022-01-07T15:47:31.037Z', + complete: true, + archived: false, + buildPath: '/h5bp/html5-boilerplate/-/jobs/1014', + retryPath: '/h5bp/html5-boilerplate/-/jobs/1014/retry', + playable: false, + scheduled: false, + createdAt: '2022-01-07T15:47:27.404Z', + updatedAt: '2022-01-07T15:47:32.341Z', + status: { + action: { + buttonTitle: 'Retry this job', + icon: 'retry', + method: 'post', + path: '/h5bp/html5-boilerplate/-/jobs/1014/retry', + title: 'Retry', + }, + detailsPath: '/h5bp/html5-boilerplate/-/jobs/1014', + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + group: 'success', + hasDetails: true, + icon: 'status_success', + illustration: { + image: + '/assets/illustrations/skipped-job_empty-29a8a37d8a61d1b6f68cf3484f9024e53cd6eb95e28eae3554f8011a1146bf27.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + label: 'passed', + text: 'passed', + tooltip: 'passed', + }, + }, + commit: { + id: 'f3ba6dd84f8f891373e9b869135622b954852db1', + shortId: 'f3ba6dd8', + createdAt: '2022-01-07T15:47:26.000+00:00', + parentIds: ['3213b6ac17afab99be37d5d38f38c6c8407387cc'], + title: 'Update .gitlab-ci.yml file', + message: 'Update .gitlab-ci.yml file', + authorName: 'Administrator', + authorEmail: 'admin@example.com', + authoredDate: '2022-01-07T15:47:26.000+00:00', + committerName: 'Administrator', + committerEmail: 'admin@example.com', + committedDate: '2022-01-07T15:47:26.000+00:00', + trailers: {}, + webUrl: + 'http://gck.test:3000/h5bp/html5-boilerplate/-/commit/f3ba6dd84f8f891373e9b869135622b954852db1', + author: { + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 1, + name: 'Administrator', + path: '/root', + showStatus: false, + state: 'active', + username: 'root', + webUrl: 'http://gck.test:3000/root', + }, + authorGravatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + commitUrl: + 'http://gck.test:3000/h5bp/html5-boilerplate/-/commit/f3ba6dd84f8f891373e9b869135622b954852db1', + commitPath: '/h5bp/html5-boilerplate/-/commit/f3ba6dd84f8f891373e9b869135622b954852db1', + }, + manualActions: [ + { + id: 1015, + name: 'deploy-staging', + started: null, + complete: false, + archived: false, + buildPath: '/h5bp/html5-boilerplate/-/jobs/1015', + playPath: '/h5bp/html5-boilerplate/-/jobs/1015/play', + playable: true, + scheduled: false, + createdAt: '2022-01-07T15:47:27.422Z', + updatedAt: '2022-01-07T15:47:28.557Z', + status: { + icon: 'status_manual', + text: 'manual', + label: 'manual play action', + group: 'manual', + tooltip: 'manual action', + hasDetails: true, + detailsPath: '/h5bp/html5-boilerplate/-/jobs/1015', + illustration: { + image: + '/assets/illustrations/manual_action-c55aee2c5f9ebe9f72751480af8bb307be1a6f35552f344cc6d1bf979d3422f6.svg', + size: 'svg-394', + title: 'This job requires a manual action', + content: + 'This job requires manual intervention to start. Before starting this job, you can add variables below for last-minute configuration changes.', + }, + favicon: + '/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', + action: { + icon: 'play', + title: 'Play', + path: '/h5bp/html5-boilerplate/-/jobs/1015/play', + method: 'post', + buttonTitle: 'Run job', + }, + }, + }, + ], + scheduledActions: [], + cluster: null, + }, + retryUrl: '/h5bp/html5-boilerplate/-/jobs/1014/retry', +}; diff --git a/spec/frontend/environments/graphql/resolvers/base_spec.js b/spec/frontend/environments/graphql/resolvers/base_spec.js index e01cf18c40d..939ccc0780c 100644 --- a/spec/frontend/environments/graphql/resolvers/base_spec.js +++ b/spec/frontend/environments/graphql/resolvers/base_spec.js @@ -9,7 +9,7 @@ import environmentToStopQuery from '~/environments/graphql/queries/environment_t import createMockApollo from 'helpers/mock_apollo_helper'; import pollIntervalQuery from '~/environments/graphql/queries/poll_interval.query.graphql'; import isEnvironmentStoppingQuery from '~/environments/graphql/queries/is_environment_stopping.query.graphql'; -import pageInfoQuery from '~/environments/graphql/queries/page_info.query.graphql'; +import pageInfoQuery from '~/graphql_shared/client/page_info.query.graphql'; import { TEST_HOST } from 'helpers/test_constants'; import { environmentsApp, @@ -131,13 +131,14 @@ describe('~/frontend/environments/graphql/resolvers', () => { describe('folder', () => { it('should fetch the folder url passed to it', async () => { mock - .onGet(ENDPOINT, { params: { per_page: 3, scope: 'available', search: '' } }) + .onGet(ENDPOINT, { params: { per_page: 3, scope: 'available', search: '', page: 1 } }) .reply(HTTP_STATUS_OK, folder); const environmentFolder = await mockResolvers.Query.folder(null, { environment: { folderPath: ENDPOINT }, scope: 'available', search: '', + page: 1, }); expect(environmentFolder).toEqual(resolvedFolder); @@ -147,10 +148,10 @@ describe('~/frontend/environments/graphql/resolvers', () => { describe('stopEnvironmentREST', () => { it('should post to the stop environment path', async () => { mock.onPost(ENDPOINT).reply(HTTP_STATUS_OK); - + const cache = { evict: jest.fn() }; const client = { writeQuery: jest.fn() }; const environment = { stopPath: ENDPOINT }; - await mockResolvers.Mutation.stopEnvironmentREST(null, { environment }, { client }); + await mockResolvers.Mutation.stopEnvironmentREST(null, { environment }, { client, cache }); expect(mock.history.post).toContainEqual( expect.objectContaining({ url: ENDPOINT, method: 'post' }), @@ -161,6 +162,7 @@ describe('~/frontend/environments/graphql/resolvers', () => { variables: { environment }, data: { isEnvironmentStopping: true }, }); + expect(cache.evict).toHaveBeenCalledWith({ fieldName: 'folder' }); }); it('should set is stopping to false if stop fails', async () => { mock.onPost(ENDPOINT).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); @@ -183,27 +185,39 @@ describe('~/frontend/environments/graphql/resolvers', () => { describe('rollbackEnvironment', () => { it('should post to the retry environment path', async () => { mock.onPost(ENDPOINT).reply(HTTP_STATUS_OK); + const cache = { evict: jest.fn() }; - await mockResolvers.Mutation.rollbackEnvironment(null, { - environment: { retryUrl: ENDPOINT }, - }); + await mockResolvers.Mutation.rollbackEnvironment( + null, + { + environment: { retryUrl: ENDPOINT }, + }, + { cache }, + ); expect(mock.history.post).toContainEqual( expect.objectContaining({ url: ENDPOINT, method: 'post' }), ); + expect(cache.evict).toHaveBeenCalledWith({ fieldName: 'folder' }); }); }); describe('deleteEnvironment', () => { it('should DELETE to the delete environment path', async () => { mock.onDelete(ENDPOINT).reply(HTTP_STATUS_OK); + const cache = { evict: jest.fn() }; - await mockResolvers.Mutation.deleteEnvironment(null, { - environment: { deletePath: ENDPOINT }, - }); + await mockResolvers.Mutation.deleteEnvironment( + null, + { + environment: { deletePath: ENDPOINT }, + }, + { cache }, + ); expect(mock.history.delete).toContainEqual( expect.objectContaining({ url: ENDPOINT, method: 'delete' }), ); + expect(cache.evict).toHaveBeenCalledWith({ fieldName: 'folder' }); }); }); describe('cancelAutoStop', () => { diff --git a/spec/frontend/environments/graphql/resolvers/kubernetes_spec.js b/spec/frontend/environments/graphql/resolvers/kubernetes_spec.js index f244ddb01b5..4f3295442b5 100644 --- a/spec/frontend/environments/graphql/resolvers/kubernetes_spec.js +++ b/spec/frontend/environments/graphql/resolvers/kubernetes_spec.js @@ -4,6 +4,8 @@ import axios from '~/lib/utils/axios_utils'; import { resolvers } from '~/environments/graphql/resolvers'; import { CLUSTER_AGENT_ERROR_MESSAGES } from '~/environments/constants'; import k8sPodsQuery from '~/environments/graphql/queries/k8s_pods.query.graphql'; +import k8sWorkloadsQuery from '~/environments/graphql/queries/k8s_workloads.query.graphql'; +import k8sServicesQuery from '~/environments/graphql/queries/k8s_services.query.graphql'; import { k8sPodsMock, k8sServicesMock, k8sNamespacesMock } from '../mock_data'; describe('~/frontend/environments/graphql/resolvers', () => { @@ -157,6 +159,7 @@ describe('~/frontend/environments/graphql/resolvers', () => { }); }); describe('k8sServices', () => { + const client = { writeQuery: jest.fn() }; const mockServicesListFn = jest.fn().mockImplementation(() => { return Promise.resolve({ items: k8sServicesMock, @@ -166,49 +169,130 @@ describe('~/frontend/environments/graphql/resolvers', () => { const mockNamespacedServicesListFn = jest.fn().mockImplementation(mockServicesListFn); const mockAllServicesListFn = jest.fn().mockImplementation(mockServicesListFn); - beforeEach(() => { - jest - .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces') - .mockImplementation(mockServicesListFn); + describe('when k8sWatchApi feature is disabled', () => { + beforeEach(() => { + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1NamespacedService') + .mockImplementation(mockNamespacedServicesListFn); + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces') + .mockImplementation(mockAllServicesListFn); + }); - jest - .spyOn(CoreV1Api.prototype, 'listCoreV1NamespacedService') - .mockImplementation(mockNamespacedServicesListFn); - jest - .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces') - .mockImplementation(mockAllServicesListFn); - }); + it('should request namespaced services from the cluster_client library if namespace is specified', async () => { + const services = await mockResolvers.Query.k8sServices( + null, + { configuration, namespace }, + { client }, + ); - it('should request namespaced services from the cluster_client library if namespace is specified', async () => { - const services = await mockResolvers.Query.k8sServices(null, { configuration, namespace }); + expect(mockNamespacedServicesListFn).toHaveBeenCalledWith({ namespace }); + expect(mockAllServicesListFn).not.toHaveBeenCalled(); - expect(mockNamespacedServicesListFn).toHaveBeenCalledWith({ namespace }); - expect(mockAllServicesListFn).not.toHaveBeenCalled(); + expect(services).toEqual(k8sServicesMock); + }); + it('should request all services from the cluster_client library if namespace is not specified', async () => { + const services = await mockResolvers.Query.k8sServices( + null, + { + configuration, + namespace: '', + }, + { client }, + ); + + expect(mockServicesListFn).toHaveBeenCalled(); + expect(mockNamespacedServicesListFn).not.toHaveBeenCalled(); + + expect(services).toEqual(k8sServicesMock); + }); + it('should throw an error if the API call fails', async () => { + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces') + .mockRejectedValue(new Error('API error')); - expect(services).toEqual(k8sServicesMock); + await expect( + mockResolvers.Query.k8sServices(null, { configuration }, { client }), + ).rejects.toThrow('API error'); + }); }); - it('should request all services from the cluster_client library if namespace is not specified', async () => { - const services = await mockResolvers.Query.k8sServices(null, { - configuration, - namespace: '', + + describe('when k8sWatchApi feature is enabled', () => { + const mockWatcher = WatchApi.prototype; + const mockServicesListWatcherFn = jest.fn().mockImplementation(() => { + return Promise.resolve(mockWatcher); + }); + + const mockOnDataFn = jest.fn().mockImplementation((eventName, callback) => { + if (eventName === 'data') { + callback([]); + } }); - expect(mockServicesListFn).toHaveBeenCalled(); - expect(mockNamespacedServicesListFn).not.toHaveBeenCalled(); + describe('when the services data is present', () => { + beforeEach(() => { + gon.features = { k8sWatchApi: true }; - expect(services).toEqual(k8sServicesMock); - }); - it('should throw an error if the API call fails', async () => { - jest - .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces') - .mockRejectedValue(new Error('API error')); + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1NamespacedService') + .mockImplementation(mockNamespacedServicesListFn); + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces') + .mockImplementation(mockAllServicesListFn); + jest + .spyOn(mockWatcher, 'subscribeToStream') + .mockImplementation(mockServicesListWatcherFn); + jest.spyOn(mockWatcher, 'on').mockImplementation(mockOnDataFn); + }); + + it('should request namespaced services from the cluster_client library if namespace is specified', async () => { + await mockResolvers.Query.k8sServices(null, { configuration, namespace }, { client }); + + expect(mockServicesListWatcherFn).toHaveBeenCalledWith( + `/api/v1/namespaces/${namespace}/services`, + { + watch: true, + }, + ); + }); + it('should request all services from the cluster_client library if namespace is not specified', async () => { + await mockResolvers.Query.k8sServices(null, { configuration, namespace: '' }, { client }); + + expect(mockServicesListWatcherFn).toHaveBeenCalledWith(`/api/v1/services`, { + watch: true, + }); + }); + it('should update cache with the new data when received from the library', async () => { + await mockResolvers.Query.k8sServices(null, { configuration, namespace: '' }, { client }); + + expect(client.writeQuery).toHaveBeenCalledWith({ + query: k8sServicesQuery, + variables: { configuration, namespace: '' }, + data: { k8sServices: [] }, + }); + }); + }); + + it('should not watch pods from the cluster_client library when the services data is not present', async () => { + jest.spyOn(CoreV1Api.prototype, 'listCoreV1NamespacedService').mockImplementation( + jest.fn().mockImplementation(() => { + return Promise.resolve({ + items: [], + }); + }), + ); - await expect(mockResolvers.Query.k8sServices(null, { configuration })).rejects.toThrow( - 'API error', - ); + await mockResolvers.Query.k8sServices(null, { configuration, namespace }, { client }); + + expect(mockServicesListWatcherFn).not.toHaveBeenCalled(); + }); }); }); describe('k8sWorkloads', () => { + const client = { + readQuery: jest.fn(() => ({ k8sWorkloads: {} })), + writeQuery: jest.fn(), + }; const emptyImplementation = jest.fn().mockImplementation(() => { return Promise.resolve({ data: { @@ -250,48 +334,137 @@ describe('~/frontend/environments/graphql/resolvers', () => { { method: 'listBatchV1CronJobForAllNamespaces', api: BatchV1Api, spy: mockAllCronJob }, ]; - beforeEach(() => { - [...namespacedMocks, ...allMocks].forEach((workloadMock) => { - jest - .spyOn(workloadMock.api.prototype, workloadMock.method) - .mockImplementation(workloadMock.spy); + describe('when k8sWatchApi feature is disabled', () => { + beforeEach(() => { + [...namespacedMocks, ...allMocks].forEach((workloadMock) => { + jest + .spyOn(workloadMock.api.prototype, workloadMock.method) + .mockImplementation(workloadMock.spy); + }); }); - }); - it('should request namespaced workload types from the cluster_client library if namespace is specified', async () => { - await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace }); + it('should request namespaced workload types from the cluster_client library if namespace is specified', async () => { + await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace }, { client }); - namespacedMocks.forEach((workloadMock) => { - expect(workloadMock.spy).toHaveBeenCalledWith({ namespace }); + namespacedMocks.forEach((workloadMock) => { + expect(workloadMock.spy).toHaveBeenCalledWith({ namespace }); + }); }); - }); - it('should request all workload types from the cluster_client library if namespace is not specified', async () => { - await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace: '' }); + it('should request all workload types from the cluster_client library if namespace is not specified', async () => { + await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace: '' }, { client }); - allMocks.forEach((workloadMock) => { - expect(workloadMock.spy).toHaveBeenCalled(); + allMocks.forEach((workloadMock) => { + expect(workloadMock.spy).toHaveBeenCalled(); + }); }); - }); - it('should pass fulfilled calls data if one of the API calls fail', async () => { - jest - .spyOn(AppsV1Api.prototype, 'listAppsV1DeploymentForAllNamespaces') - .mockRejectedValue(new Error('API error')); - - await expect( - mockResolvers.Query.k8sWorkloads(null, { configuration }), - ).resolves.toBeDefined(); - }); - it('should throw an error if all the API calls fail', async () => { - [...allMocks].forEach((workloadMock) => { + it('should pass fulfilled calls data if one of the API calls fail', async () => { jest - .spyOn(workloadMock.api.prototype, workloadMock.method) + .spyOn(AppsV1Api.prototype, 'listAppsV1DeploymentForAllNamespaces') .mockRejectedValue(new Error('API error')); + + await expect( + mockResolvers.Query.k8sWorkloads(null, { configuration }, { client }), + ).resolves.toBeDefined(); + }); + it('should throw an error if all the API calls fail', async () => { + [...allMocks].forEach((workloadMock) => { + jest + .spyOn(workloadMock.api.prototype, workloadMock.method) + .mockRejectedValue(new Error('API error')); + }); + + await expect( + mockResolvers.Query.k8sWorkloads(null, { configuration }, { client }), + ).rejects.toThrow('API error'); + }); + }); + describe('when k8sWatchApi feature is enabled', () => { + const mockDeployment = jest.fn().mockImplementation(() => { + return Promise.resolve({ + kind: 'DeploymentList', + apiVersion: 'apps/v1', + items: [ + { + status: { + conditions: [], + }, + }, + ], + }); + }); + const mockWatcher = WatchApi.prototype; + const mockDeploymentsListWatcherFn = jest.fn().mockImplementation(() => { + return Promise.resolve(mockWatcher); + }); + + const mockOnDataFn = jest.fn().mockImplementation((eventName, callback) => { + if (eventName === 'data') { + callback([]); + } }); - await expect(mockResolvers.Query.k8sWorkloads(null, { configuration })).rejects.toThrow( - 'API error', - ); + describe('when the deployments data is present', () => { + beforeEach(() => { + gon.features = { k8sWatchApi: true }; + + jest + .spyOn(AppsV1Api.prototype, 'listAppsV1NamespacedDeployment') + .mockImplementation(mockDeployment); + jest + .spyOn(AppsV1Api.prototype, 'listAppsV1DeploymentForAllNamespaces') + .mockImplementation(mockDeployment); + jest + .spyOn(mockWatcher, 'subscribeToStream') + .mockImplementation(mockDeploymentsListWatcherFn); + jest.spyOn(mockWatcher, 'on').mockImplementation(mockOnDataFn); + }); + + it('should request namespaced deployments from the cluster_client library if namespace is specified', async () => { + await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace }, { client }); + + expect(mockDeploymentsListWatcherFn).toHaveBeenCalledWith( + `/apis/apps/v1/namespaces/${namespace}/deployments`, + { + watch: true, + }, + ); + }); + it('should request all deployments from the cluster_client library if namespace is not specified', async () => { + await mockResolvers.Query.k8sWorkloads( + null, + { configuration, namespace: '' }, + { client }, + ); + + expect(mockDeploymentsListWatcherFn).toHaveBeenCalledWith(`/apis/apps/v1/deployments`, { + watch: true, + }); + }); + it('should update cache with the new data when received from the library', async () => { + await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace }, { client }); + + expect(client.writeQuery).toHaveBeenCalledWith({ + query: k8sWorkloadsQuery, + variables: { configuration, namespace }, + data: { k8sWorkloads: { DeploymentList: [] } }, + }); + }); + }); + + it('should not watch deployments from the cluster_client library when the deployments data is not present', async () => { + jest.spyOn(AppsV1Api.prototype, 'listAppsV1NamespacedDeployment').mockImplementation( + jest.fn().mockImplementation(() => { + return Promise.resolve({ + items: [], + }); + }), + ); + + await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace }, { client }); + + expect(mockDeploymentsListWatcherFn).not.toHaveBeenCalled(); + }); }); }); describe('k8sNamespaces', () => { diff --git a/spec/frontend/environments/helpers/k8s_integration_helper_spec.js b/spec/frontend/environments/helpers/k8s_integration_helper_spec.js new file mode 100644 index 00000000000..97100557ef3 --- /dev/null +++ b/spec/frontend/environments/helpers/k8s_integration_helper_spec.js @@ -0,0 +1,225 @@ +import { + generateServicePortsString, + getDeploymentsStatuses, + getDaemonSetStatuses, + getStatefulSetStatuses, + getReplicaSetStatuses, + getJobsStatuses, + getCronJobsStatuses, + humanizeClusterErrors, +} from '~/environments/helpers/k8s_integration_helper'; + +import { CLUSTER_AGENT_ERROR_MESSAGES } from '~/environments/constants'; + +describe('k8s_integration_helper', () => { + describe('generateServicePortsString', () => { + const port = '8080'; + const protocol = 'TCP'; + const nodePort = '31732'; + + it('returns empty string if no ports provided', () => { + expect(generateServicePortsString([])).toBe(''); + }); + + it('returns port and protocol when provided', () => { + expect(generateServicePortsString([{ port, protocol }])).toBe(`${port}/${protocol}`); + }); + + it('returns port, protocol and nodePort when provided', () => { + expect(generateServicePortsString([{ port, protocol, nodePort }])).toBe( + `${port}:${nodePort}/${protocol}`, + ); + }); + + it('returns joined strings of ports if multiple are provided', () => { + expect( + generateServicePortsString([ + { port, protocol }, + { port, protocol, nodePort }, + ]), + ).toBe(`${port}/${protocol}, ${port}:${nodePort}/${protocol}`); + }); + }); + + describe('getDeploymentsStatuses', () => { + const pending = { + status: { + conditions: [ + { type: 'Available', status: 'False' }, + { type: 'Progressing', status: 'True' }, + ], + }, + }; + const ready = { + status: { + conditions: [ + { type: 'Available', status: 'True' }, + { type: 'Progressing', status: 'False' }, + ], + }, + }; + const failed = { + status: { + conditions: [ + { type: 'Available', status: 'False' }, + { type: 'Progressing', status: 'False' }, + ], + }, + }; + + it.each` + condition | items | expected + ${'there are only pending items'} | ${[pending]} | ${{ pending: [pending] }} + ${'there are pending and ready items'} | ${[pending, ready]} | ${{ pending: [pending], ready: [ready] }} + ${'there are all kind of items'} | ${[failed, ready, ready, pending]} | ${{ pending: [pending], failed: [failed], ready: [ready, ready] }} + `('returns correct object of statuses when $condition', ({ items, expected }) => { + expect(getDeploymentsStatuses(items)).toEqual(expected); + }); + }); + + describe('getDaemonSetStatuses', () => { + const ready = { + status: { + numberMisscheduled: 0, + numberReady: 1, + desiredNumberScheduled: 1, + }, + }; + const failed = { + status: { + numberReady: 0, + desiredNumberScheduled: 1, + }, + }; + const anotherFailed = { + status: { + numberReady: 0, + desiredNumberScheduled: 0, + numberMisscheduled: 1, + }, + }; + + it.each` + condition | items | expected + ${'there are only failed items'} | ${[failed, anotherFailed]} | ${{ failed: [failed, anotherFailed] }} + ${'there are only ready items'} | ${[ready]} | ${{ ready: [ready] }} + ${'there are all kind of items'} | ${[failed, ready, anotherFailed]} | ${{ failed: [failed, anotherFailed], ready: [ready] }} + `('returns correct object of statuses when $condition', ({ items, expected }) => { + expect(getDaemonSetStatuses(items)).toEqual(expected); + }); + }); + + describe('getStatefulSetStatuses', () => { + const ready = { + status: { + readyReplicas: 1, + }, + spec: { replicas: 1 }, + }; + const failed = { + status: { + readyReplicas: 1, + }, + spec: { replicas: 3 }, + }; + + it.each` + condition | items | expected + ${'there are only failed items'} | ${[failed, failed]} | ${{ failed: [failed, failed] }} + ${'there are only ready items'} | ${[ready]} | ${{ ready: [ready] }} + ${'there are all kind of items'} | ${[failed, failed, ready]} | ${{ failed: [failed, failed], ready: [ready] }} + `('returns correct object of statuses when $condition', ({ items, expected }) => { + expect(getStatefulSetStatuses(items)).toEqual(expected); + }); + }); + + describe('getReplicaSetStatuses', () => { + const ready = { + status: { + readyReplicas: 1, + }, + spec: { replicas: 1 }, + }; + const failed = { + status: { + readyReplicas: 1, + }, + spec: { replicas: 3 }, + }; + + it.each` + condition | items | expected + ${'there are only failed items'} | ${[failed, failed]} | ${{ failed: [failed, failed] }} + ${'there are only ready items'} | ${[ready]} | ${{ ready: [ready] }} + ${'there are all kind of items'} | ${[failed, failed, ready]} | ${{ failed: [failed, failed], ready: [ready] }} + `('returns correct object of statuses when $condition', ({ items, expected }) => { + expect(getReplicaSetStatuses(items)).toEqual(expected); + }); + }); + + describe('getJobsStatuses', () => { + const completed = { + status: { + succeeded: 1, + }, + spec: { completions: 1 }, + }; + const failed = { + status: { + failed: 1, + }, + spec: { completions: 2 }, + }; + + const anotherFailed = { + status: { + succeeded: 1, + }, + spec: { completions: 2 }, + }; + + it.each` + condition | items | expected + ${'there are only failed items'} | ${[failed, anotherFailed]} | ${{ failed: [failed, anotherFailed] }} + ${'there are only completed items'} | ${[completed]} | ${{ completed: [completed] }} + ${'there are all kind of items'} | ${[failed, completed, anotherFailed]} | ${{ failed: [failed, anotherFailed], completed: [completed] }} + `('returns correct object of statuses when $condition', ({ items, expected }) => { + expect(getJobsStatuses(items)).toEqual(expected); + }); + }); + + describe('getCronJobsStatuses', () => { + const suspended = { + spec: { suspend: true }, + }; + const ready = { + status: { + active: 2, + lastScheduleTime: new Date(), + }, + }; + const failed = { + status: { + active: 2, + }, + }; + + it.each` + condition | items | expected + ${'there are only suspended items'} | ${[suspended]} | ${{ suspended: [suspended] }} + ${'there are suspended and ready items'} | ${[suspended, ready]} | ${{ suspended: [suspended], ready: [ready] }} + ${'there are all kind of items'} | ${[failed, ready, ready, suspended]} | ${{ suspended: [suspended], failed: [failed], ready: [ready, ready] }} + `('returns correct object of statuses when $condition', ({ items, expected }) => { + expect(getCronJobsStatuses(items)).toEqual(expected); + }); + }); + + describe('humanizeClusterErrors', () => { + it.each(['unauthorized', 'forbidden', 'not found', 'other'])( + 'returns correct object of statuses when error reason is %s', + (reason) => { + expect(humanizeClusterErrors(reason)).toEqual(CLUSTER_AGENT_ERROR_MESSAGES[reason]); + }, + ); + }); +}); diff --git a/spec/frontend/environments/kubernetes_overview_spec.js b/spec/frontend/environments/kubernetes_overview_spec.js index 12689df586f..e00cabd1066 100644 --- a/spec/frontend/environments/kubernetes_overview_spec.js +++ b/spec/frontend/environments/kubernetes_overview_spec.js @@ -74,7 +74,7 @@ describe('~/environments/components/kubernetes_overview.vue', () => { }); it('is collapsed by default', () => { - expect(findCollapse().props('visible')).toBeUndefined(); + expect(findCollapse().props('visible')).toBe(false); expect(findCollapseButton().attributes('aria-label')).toBe(KubernetesOverview.i18n.expand); expect(findCollapseButton().props('icon')).toBe('chevron-right'); }); @@ -88,7 +88,7 @@ describe('~/environments/components/kubernetes_overview.vue', () => { findCollapseButton().vm.$emit('click'); await nextTick(); - expect(findCollapse().attributes('visible')).toBe('true'); + expect(findCollapse().props('visible')).toBe(true); expect(findCollapseButton().attributes('aria-label')).toBe(KubernetesOverview.i18n.collapse); expect(findCollapseButton().props('icon')).toBe('chevron-down'); }); @@ -149,14 +149,14 @@ describe('~/environments/components/kubernetes_overview.vue', () => { }); it('sets `clusterHealthStatus` as error when pods emitted a failure', async () => { - findKubernetesPods().vm.$emit('failed'); + findKubernetesPods().vm.$emit('update-failed-state', { pods: true }); await nextTick(); expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('error'); }); it('sets `clusterHealthStatus` as error when workload types emitted a failure', async () => { - findKubernetesTabs().vm.$emit('failed'); + findKubernetesTabs().vm.$emit('update-failed-state', { summary: true }); await nextTick(); expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('error'); @@ -165,6 +165,21 @@ describe('~/environments/components/kubernetes_overview.vue', () => { it('sets `clusterHealthStatus` as success when data is loaded and no failures where emitted', () => { expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('success'); }); + + it('sets `clusterHealthStatus` as success after state update if there are no failures', async () => { + findKubernetesTabs().vm.$emit('update-failed-state', { summary: true }); + findKubernetesTabs().vm.$emit('update-failed-state', { pods: true }); + await nextTick(); + expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('error'); + + findKubernetesTabs().vm.$emit('update-failed-state', { summary: false }); + await nextTick(); + expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('error'); + + findKubernetesTabs().vm.$emit('update-failed-state', { pods: false }); + await nextTick(); + 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 a51c85468b4..6c3e49e4d8a 100644 --- a/spec/frontend/environments/kubernetes_pods_spec.js +++ b/spec/frontend/environments/kubernetes_pods_spec.js @@ -2,10 +2,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMount } from '@vue/test-utils'; import { GlLoadingIcon } from '@gitlab/ui'; -import { GlSingleStat } from '@gitlab/ui/dist/charts'; import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; import KubernetesPods from '~/environments/components/kubernetes_pods.vue'; +import WorkloadStats from '~/kubernetes_dashboard/components/workload_stats.vue'; import { mockKasTunnelUrl } from './mock_data'; import { k8sPodsMock } from './graphql/mock_data'; @@ -23,8 +23,7 @@ describe('~/environments/components/kubernetes_pods.vue', () => { }; const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findAllStats = () => wrapper.findAllComponents(GlSingleStat); - const findSingleStat = (at) => findAllStats().at(at); + const findWorkloadStats = () => wrapper.findComponent(WorkloadStats); const createApolloProvider = () => { const mockResolvers = { @@ -67,37 +66,41 @@ describe('~/environments/components/kubernetes_pods.vue', () => { }); describe('when gets pods data', () => { - it('renders stats', async () => { + it('renders workload stats with the correct data', async () => { createWrapper(); await waitForPromises(); - expect(findAllStats()).toHaveLength(4); + expect(findWorkloadStats().props('stats')).toEqual([ + { + value: 2, + title: 'Running', + }, + { + value: 1, + title: 'Pending', + }, + { + value: 1, + title: 'Succeeded', + }, + { + value: 2, + title: 'Failed', + }, + ]); }); - it.each` - count | title | index - ${2} | ${KubernetesPods.i18n.runningPods} | ${0} - ${1} | ${KubernetesPods.i18n.pendingPods} | ${1} - ${1} | ${KubernetesPods.i18n.succeededPods} | ${2} - ${2} | ${KubernetesPods.i18n.failedPods} | ${3} - `( - 'renders stat with title "$title" and count "$count" at index $index', - async ({ count, title, index }) => { - createWrapper(); - await waitForPromises(); - - expect(findSingleStat(index).props()).toMatchObject({ - value: count, - title, - }); - }, - ); - - it('emits a failed event when there are failed pods', async () => { + it('emits a update-failed-state event for each pod', async () => { createWrapper(); await waitForPromises(); - expect(wrapper.emitted('failed')).toHaveLength(1); + expect(wrapper.emitted('update-failed-state')).toHaveLength(4); + expect(wrapper.emitted('update-failed-state')).toEqual([ + [{ pods: false }], + [{ pods: false }], + [{ pods: false }], + [{ pods: true }], + ]); }); }); @@ -119,7 +122,7 @@ describe('~/environments/components/kubernetes_pods.vue', () => { }); it("doesn't show pods stats", () => { - expect(findAllStats()).toHaveLength(0); + expect(findWorkloadStats().exists()).toBe(false); }); it('emits an error message', () => { diff --git a/spec/frontend/environments/kubernetes_summary_spec.js b/spec/frontend/environments/kubernetes_summary_spec.js index 457d1a37c1d..0d448d0b6af 100644 --- a/spec/frontend/environments/kubernetes_summary_spec.js +++ b/spec/frontend/environments/kubernetes_summary_spec.js @@ -80,16 +80,16 @@ describe('~/environments/components/kubernetes_summary.vue', () => { }); it.each` - type | successText | successCount | failedCount | suspendedCount | index - ${'Deployments'} | ${'ready'} | ${1} | ${1} | ${0} | ${0} - ${'DaemonSets'} | ${'ready'} | ${1} | ${2} | ${0} | ${1} - ${'StatefulSets'} | ${'ready'} | ${2} | ${1} | ${0} | ${2} - ${'ReplicaSets'} | ${'ready'} | ${1} | ${1} | ${0} | ${3} - ${'Jobs'} | ${'completed'} | ${2} | ${1} | ${0} | ${4} - ${'CronJobs'} | ${'ready'} | ${1} | ${1} | ${1} | ${5} + type | successText | successCount | failedCount | suspendedCount | pendingCount | index + ${'Deployments'} | ${'ready'} | ${1} | ${1} | ${0} | ${1} | ${0} + ${'DaemonSets'} | ${'ready'} | ${1} | ${2} | ${0} | ${0} | ${1} + ${'StatefulSets'} | ${'ready'} | ${2} | ${1} | ${0} | ${0} | ${2} + ${'ReplicaSets'} | ${'ready'} | ${1} | ${1} | ${0} | ${0} | ${3} + ${'Jobs'} | ${'completed'} | ${2} | ${1} | ${0} | ${0} | ${4} + ${'CronJobs'} | ${'ready'} | ${1} | ${1} | ${1} | ${0} | ${5} `( 'populates view with the correct badges for workload type $type', - ({ type, successText, successCount, failedCount, suspendedCount, index }) => { + ({ type, successText, successCount, failedCount, suspendedCount, pendingCount, index }) => { const findAllBadges = () => findSummaryListItem(index).findAllComponents(GlBadge); const findBadgeByVariant = (variant) => findAllBadges().wrappers.find((badge) => badge.props('variant') === variant); @@ -100,12 +100,15 @@ describe('~/environments/components/kubernetes_summary.vue', () => { if (suspendedCount > 0) { expect(findBadgeByVariant('neutral').text()).toBe(`${suspendedCount} suspended`); } + if (pendingCount > 0) { + expect(findBadgeByVariant('info').text()).toBe(`${pendingCount} pending`); + } }, ); }); - it('emits a failed event when there are failed workload types', () => { - expect(wrapper.emitted('failed')).toHaveLength(1); + it('emits a update-failed-state event when there are failed workload types', () => { + expect(wrapper.emitted('update-failed-state')).toEqual([[{ summary: true }]]); }); it('emits an error message when gets an error from the cluster_client API', async () => { diff --git a/spec/frontend/environments/kubernetes_tabs_spec.js b/spec/frontend/environments/kubernetes_tabs_spec.js index fecd6d2a8ee..bf029ad6a81 100644 --- a/spec/frontend/environments/kubernetes_tabs_spec.js +++ b/spec/frontend/environments/kubernetes_tabs_spec.js @@ -179,9 +179,10 @@ describe('~/environments/components/kubernetes_tabs.vue', () => { 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); + it('emits a state update event when gets it from the component', () => { + const eventData = { summary: true }; + findKubernetesSummary().vm.$emit('update-failed-state', eventData); + expect(wrapper.emitted('update-failed-state')).toEqual([[eventData]]); }); }); }); diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js index 7ee31bf2c62..552c44fe197 100644 --- a/spec/frontend/environments/new_environment_item_spec.js +++ b/spec/frontend/environments/new_environment_item_spec.js @@ -5,7 +5,7 @@ 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 { getTimeago, localeDateFormat } from '~/lib/utils/datetime_utility'; import { __, s__, sprintf } from '~/locale'; import EnvironmentItem from '~/environments/components/new_environment_item.vue'; import EnvironmentActions from '~/environments/components/environment_actions.vue'; @@ -253,7 +253,9 @@ describe('~/environments/components/new_environment_item.vue', () => { }); it('shows when the environment auto stops', () => { - const autoStop = wrapper.findByTitle(formatDate(environment.autoStopAt)); + const autoStop = wrapper.findByTitle( + localeDateFormat.asDateTimeFull.format(environment.autoStopAt), + ); expect(autoStop.text()).toBe('in 1 minute'); }); @@ -380,7 +382,7 @@ describe('~/environments/components/new_environment_item.vue', () => { }); it('is collapsed by default', () => { - expect(collapse.attributes('visible')).toBeUndefined(); + expect(collapse.props('visible')).toBe(false); expect(icon.props('name')).toBe('chevron-lg-right'); expect(environmentName.classes('gl-font-weight-bold')).toBe(false); }); @@ -392,7 +394,7 @@ describe('~/environments/components/new_environment_item.vue', () => { expect(button.attributes('aria-label')).toBe(__('Collapse')); expect(button.props('category')).toBe('secondary'); - expect(collapse.attributes('visible')).toBe('visible'); + expect(collapse.props('visible')).toBe(true); expect(icon.props('name')).toBe('chevron-lg-down'); expect(environmentName.classes('gl-font-weight-bold')).toBe(true); expect(findDeployment().isVisible()).toBe(true); diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js index 977e0a55a99..f43d6a2b025 100644 --- a/spec/frontend/error_tracking/components/error_details_spec.js +++ b/spec/frontend/error_tracking/components/error_details_spec.js @@ -463,7 +463,7 @@ describe('ErrorDetails', () => { const gitlabIssuePath = 'https://gitlab.example.com/issues/1'; const findGitLabLink = () => wrapper.find(`[href="${gitlabIssuePath}"]`); const findCreateIssueButton = () => wrapper.find('[data-testid="create-issue-button"]'); - const findViewIssueButton = () => wrapper.find('[data-qa-selector="view_issue_button"]'); + const findViewIssueButton = () => wrapper.find('[data-testid="view-issue-button"]'); describe('is present', () => { beforeEach(() => { 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 a9cd407f758..823f7132fdd 100644 --- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js +++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js @@ -43,6 +43,8 @@ describe('ErrorTrackingList', () => { userCanEnableErrorTracking = true, showIntegratedTrackingDisabledAlert = false, integratedErrorTrackingEnabled = false, + listPath = '/error_tracking', + stubs = {}, } = {}) { wrapper = extendedWrapper( @@ -50,7 +52,7 @@ describe('ErrorTrackingList', () => { store, propsData: { indexPath: '/path', - listPath: '/error_tracking', + listPath, projectPath: 'project/test', enableErrorTrackingLink: '/link', userCanEnableErrorTracking, @@ -144,13 +146,27 @@ describe('ErrorTrackingList', () => { expect(findErrorListRows().length).toEqual(store.state.list.errors.length); }); - it('each error in a list should have a link to the error page', () => { - const errorTitle = wrapper.findAll('tbody tr a'); + describe.each([ + ['/test-project/-/error_tracking'], + ['/test-project/-/error_tracking/'], // handles leading '/' https://gitlab.com/gitlab-org/gitlab/-/issues/430211 + ])('details link', (url) => { + beforeEach(() => { + mountComponent({ + listPath: url, + stubs: { + GlTable: false, + GlLink: false, + }, + }); + }); + it('each error in a list should have a link to the error page', () => { + const errorTitle = wrapper.findAll('tbody tr a'); - errorTitle.wrappers.forEach((_, index) => { - expect(errorTitle.at(index).attributes('href')).toEqual( - expect.stringMatching(/error_tracking\/\d+\/details$/), - ); + errorTitle.wrappers.forEach((_, index) => { + expect(errorTitle.at(index).attributes('href')).toEqual( + `/test-project/-/error_tracking/${errorsList[index].id}/details`, + ); + }); }); }); diff --git a/spec/frontend/error_tracking/store/list/actions_spec.js b/spec/frontend/error_tracking/store/list/actions_spec.js index 24a26476455..622195defa1 100644 --- a/spec/frontend/error_tracking/store/list/actions_spec.js +++ b/spec/frontend/error_tracking/store/list/actions_spec.js @@ -57,7 +57,7 @@ describe('error tracking actions', () => { describe('restartPolling', () => { it('should restart polling', () => { - testAction( + return testAction( actions.restartPolling, {}, {}, @@ -74,7 +74,7 @@ describe('error tracking actions', () => { it('should search by query', () => { const query = 'search'; - testAction( + return testAction( actions.searchByQuery, query, {}, @@ -92,7 +92,7 @@ describe('error tracking actions', () => { it('should search errors by status', () => { const status = 'ignored'; - testAction( + return testAction( actions.filterByStatus, status, {}, @@ -106,7 +106,7 @@ describe('error tracking actions', () => { it('should search by query', () => { const field = 'frequency'; - testAction( + return testAction( actions.sortByField, field, {}, @@ -123,7 +123,7 @@ describe('error tracking actions', () => { it('should set search endpoint', () => { const endpoint = 'https://sentry.io'; - testAction( + return testAction( actions.setEndpoint, { endpoint }, {}, @@ -136,7 +136,7 @@ describe('error tracking actions', () => { describe('fetchPaginatedResults', () => { it('should start polling the selected page cursor', () => { const cursor = '1576637570000:1:1'; - testAction( + return testAction( actions.fetchPaginatedResults, cursor, {}, diff --git a/spec/frontend/feature_flags/mock_data.js b/spec/frontend/feature_flags/mock_data.js index 4c40c2acf01..61e96057017 100644 --- a/spec/frontend/feature_flags/mock_data.js +++ b/spec/frontend/feature_flags/mock_data.js @@ -56,7 +56,7 @@ export const userList = { iid: 2, project_id: 1, created_at: '2020-02-04T08:13:10.507Z', - updated_at: '2020-02-04T08:13:10.507Z', + updated_at: '2020-02-05T08:14:10.507Z', path: '/path/to/user/list', edit_path: '/path/to/user/list/edit', }; diff --git a/spec/frontend/feature_highlight/feature_highlight_helper_spec.js b/spec/frontend/feature_highlight/feature_highlight_helper_spec.js deleted file mode 100644 index 4609bfc23d7..00000000000 --- a/spec/frontend/feature_highlight/feature_highlight_helper_spec.js +++ /dev/null @@ -1,42 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import { dismiss } from '~/feature_highlight/feature_highlight_helper'; -import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_CREATED, HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status'; - -jest.mock('~/alert'); - -describe('feature highlight helper', () => { - describe('dismiss', () => { - let mockAxios; - const endpoint = '/-/callouts/dismiss'; - const highlightId = '123'; - - beforeEach(() => { - mockAxios = new MockAdapter(axios); - }); - - afterEach(() => { - mockAxios.reset(); - }); - - it('calls persistent dismissal endpoint with highlightId', async () => { - mockAxios.onPost(endpoint, { feature_name: highlightId }).replyOnce(HTTP_STATUS_CREATED); - - await expect(dismiss(endpoint, highlightId)).resolves.toEqual(expect.anything()); - }); - - it('triggers an alert when dismiss request fails', async () => { - mockAxios - .onPost(endpoint, { feature_name: highlightId }) - .replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); - - await dismiss(endpoint, highlightId); - - expect(createAlert).toHaveBeenCalledWith({ - message: - 'An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again.', - }); - }); - }); -}); diff --git a/spec/frontend/feature_highlight/feature_highlight_popover_spec.js b/spec/frontend/feature_highlight/feature_highlight_popover_spec.js deleted file mode 100644 index 66ea22cece3..00000000000 --- a/spec/frontend/feature_highlight/feature_highlight_popover_spec.js +++ /dev/null @@ -1,75 +0,0 @@ -import { GlPopover, GlLink, GlButton } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { POPOVER_TARGET_ID } from '~/feature_highlight/constants'; -import { dismiss } from '~/feature_highlight/feature_highlight_helper'; -import FeatureHighlightPopover from '~/feature_highlight/feature_highlight_popover.vue'; - -jest.mock('~/feature_highlight/feature_highlight_helper'); - -describe('feature_highlight/feature_highlight_popover', () => { - let wrapper; - const props = { - autoDevopsHelpPath: '/help/autodevops', - highlightId: '123', - dismissEndpoint: '/api/dismiss', - }; - - const buildWrapper = (propsData = props) => { - wrapper = mount(FeatureHighlightPopover, { - propsData, - }); - }; - const findPopoverTarget = () => wrapper.find(`#${POPOVER_TARGET_ID}`); - const findPopover = () => wrapper.findComponent(GlPopover); - const findAutoDevopsHelpLink = () => wrapper.findComponent(GlLink); - const findDismissButton = () => wrapper.findComponent(GlButton); - - beforeEach(() => { - buildWrapper(); - }); - - it('renders popover target', () => { - expect(findPopoverTarget().exists()).toBe(true); - }); - - it('renders popover', () => { - expect(findPopover().props()).toMatchObject({ - target: POPOVER_TARGET_ID, - cssClasses: ['feature-highlight-popover'], - container: 'body', - placement: 'right', - boundary: 'viewport', - }); - }); - - it('renders link that points to the autodevops help page', () => { - expect(findAutoDevopsHelpLink().attributes().href).toBe(props.autoDevopsHelpPath); - expect(findAutoDevopsHelpLink().text()).toBe('Auto DevOps'); - }); - - it('renders dismiss button', () => { - expect(findDismissButton().props()).toMatchObject({ - size: 'small', - icon: 'thumb-up', - variant: 'confirm', - }); - }); - - it('dismisses popover when dismiss button is clicked', async () => { - await findDismissButton().trigger('click'); - - expect(findPopover().emitted('close')).toHaveLength(1); - expect(dismiss).toHaveBeenCalledWith(props.dismissEndpoint, props.highlightId); - }); - - describe('when popover is dismissed and hidden', () => { - it('hides the popover target', async () => { - await findDismissButton().trigger('click'); - findPopover().vm.$emit('hidden'); - await nextTick(); - - expect(findPopoverTarget().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/filtered_search/add_extra_tokens_for_merge_requests_spec.js b/spec/frontend/filtered_search/add_extra_tokens_for_merge_requests_spec.js new file mode 100644 index 00000000000..6b3490122c3 --- /dev/null +++ b/spec/frontend/filtered_search/add_extra_tokens_for_merge_requests_spec.js @@ -0,0 +1,30 @@ +import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; +import { createFilteredSearchTokenKeys } from '~/filtered_search/issuable_filtered_search_token_keys'; + +describe('app/assets/javascripts/pages/dashboard/merge_requests/index.js', () => { + let IssuableFilteredSearchTokenKeys; + + beforeEach(() => { + IssuableFilteredSearchTokenKeys = createFilteredSearchTokenKeys(); + window.gon = { + ...window.gon, + features: { + mrApprovedFilter: true, + }, + }; + }); + + describe.each(['Branch', 'Environment'])('when $filter is disabled', (filter) => { + beforeEach(() => { + addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys, { + [`disable${filter}Filter`]: true, + }); + }); + + it('excludes the filter', () => { + expect(IssuableFilteredSearchTokenKeys.tokenKeys).not.toContainEqual( + expect.objectContaining({ tag: filter.toLowerCase() }), + ); + }); + }); +}); diff --git a/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js b/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js index 2041bc3d959..35fdb02e208 100644 --- a/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js +++ b/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js @@ -1,4 +1,6 @@ -import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import IssuableFilteredSearchTokenKeys, { + createFilteredSearchTokenKeys, +} from '~/filtered_search/issuable_filtered_search_token_keys'; describe('Issues Filtered Search Token Keys', () => { describe('get', () => { @@ -167,3 +169,21 @@ describe('Issues Filtered Search Token Keys', () => { }); }); }); + +describe('createFilteredSearchTokenKeys', () => { + describe.each(['Release'])('when $filter is disabled', (filter) => { + let tokens; + + beforeEach(() => { + tokens = createFilteredSearchTokenKeys({ + [`disable${filter}Filter`]: true, + }); + }); + + it('excludes the filter', () => { + expect(tokens.tokenKeys).not.toContainEqual( + expect.objectContaining({ tag: filter.toLowerCase() }), + ); + }); + }); +}); diff --git a/spec/frontend/fixtures/deploy_keys.rb b/spec/frontend/fixtures/deploy_keys.rb index 05fca368fd5..8c371827594 100644 --- a/spec/frontend/fixtures/deploy_keys.rb +++ b/spec/frontend/fixtures/deploy_keys.rb @@ -12,12 +12,19 @@ RSpec.describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :c let(:project2) { create(:project, :internal) } let(:project3) { create(:project, :internal) } let(:project4) { create(:project, :internal) } + let(:project_key) { create(:deploy_key) } + let(:internal_key) { create(:deploy_key) } before do # Using an admin for these fixtures because they are used for verifying a frontend # component that would normally get its data from `Admin::DeployKeysController` sign_in(admin) enable_admin_mode!(admin) + create(:rsa_deploy_key_5120, public: true) + create(:deploy_keys_project, project: project, deploy_key: project_key) + create(:deploy_keys_project, project: project2, deploy_key: internal_key) + create(:deploy_keys_project, project: project3, deploy_key: project_key) + create(:deploy_keys_project, project: project4, deploy_key: project_key) end after do @@ -27,14 +34,6 @@ RSpec.describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :c render_views it 'deploy_keys/keys.json' do - create(:rsa_deploy_key_5120, public: true) - project_key = create(:deploy_key) - internal_key = create(:deploy_key) - create(:deploy_keys_project, project: project, deploy_key: project_key) - create(:deploy_keys_project, project: project2, deploy_key: internal_key) - create(:deploy_keys_project, project: project3, deploy_key: project_key) - create(:deploy_keys_project, project: project4, deploy_key: project_key) - get :index, params: { namespace_id: project.namespace.to_param, project_id: project @@ -42,4 +41,31 @@ RSpec.describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :c expect(response).to be_successful end + + it 'deploy_keys/enabled_keys.json' do + get :enabled_keys, params: { + namespace_id: project.namespace.to_param, + project_id: project + }, format: :json + + expect(response).to be_successful + end + + it 'deploy_keys/available_project_keys.json' do + get :available_project_keys, params: { + namespace_id: project.namespace.to_param, + project_id: project + }, format: :json + + expect(response).to be_successful + end + + it 'deploy_keys/available_public_keys.json' do + get :available_public_keys, params: { + namespace_id: project.namespace.to_param, + project_id: project + }, format: :json + + expect(response).to be_successful + end end diff --git a/spec/frontend/fixtures/pipeline_header.rb b/spec/frontend/fixtures/pipeline_header.rb index 744df18a403..77d626100ad 100644 --- a/spec/frontend/fixtures/pipeline_header.rb +++ b/spec/frontend/fixtures/pipeline_header.rb @@ -18,18 +18,23 @@ RSpec.describe "GraphQL Pipeline Header", '(JavaScript fixtures)', type: :reques let_it_be(:pipeline) do create( :ci_pipeline, + :merged_result_pipeline, project: project, sha: commit.id, ref: 'master', user: user, + name: 'Build pipeline', status: :success, duration: 7210, created_at: 2.hours.ago, started_at: 1.hour.ago, - finished_at: Time.current + finished_at: Time.current, + source: :schedule ) end + let_it_be(:builds) { create_list(:ci_build, 3, :success, pipeline: pipeline, ref: 'master') } + it "graphql/pipelines/pipeline_header_success.json" do query = get_graphql_query_as_string(query_path) @@ -64,6 +69,34 @@ RSpec.describe "GraphQL Pipeline Header", '(JavaScript fixtures)', type: :reques end end + context 'with running pipeline and no permissions' 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_no_permissions.json" do + guest = create(:user) + project.add_guest(guest) + + query = get_graphql_query_as_string(query_path) + + post_graphql(query, current_user: guest, 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( diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb index a73a0dcbdd1..3b03a03cb96 100644 --- a/spec/frontend/fixtures/runner.rb +++ b/spec/frontend/fixtures/runner.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Runner (JavaScript fixtures)', feature_category: :runner_fleet do +RSpec.describe 'Runner (JavaScript fixtures)', feature_category: :fleet_visibility do include AdminModeHelper include ApiHelpers include JavaScriptFixturesHelpers diff --git a/spec/frontend/fixtures/static/whats_new_notification.html b/spec/frontend/fixtures/static/whats_new_notification.html deleted file mode 100644 index bc8a27c779f..00000000000 --- a/spec/frontend/fixtures/static/whats_new_notification.html +++ /dev/null @@ -1,7 +0,0 @@ -<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> -</div> diff --git a/spec/frontend/frequent_items/components/app_spec.js b/spec/frontend/frequent_items/components/app_spec.js deleted file mode 100644 index 122155a5d3f..00000000000 --- a/spec/frontend/frequent_items/components/app_spec.js +++ /dev/null @@ -1,286 +0,0 @@ -import { GlButton, GlIcon } from '@gitlab/ui'; -import MockAdapter from 'axios-mock-adapter'; -import Vue, { nextTick } from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import { useLocalStorageSpy } from 'helpers/local_storage_helper'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import App from '~/frequent_items/components/app.vue'; -import FrequentItemsList from '~/frequent_items/components/frequent_items_list.vue'; -import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/frequent_items/constants'; -import eventHub from '~/frequent_items/event_hub'; -import { createStore } from '~/frequent_items/store'; -import { getTopFrequentItems } from '~/frequent_items/utils'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import { currentSession, mockFrequentProjects, mockSearchedProjects } from '../mock_data'; - -Vue.use(Vuex); - -useLocalStorageSpy(); - -const TEST_NAMESPACE = 'projects'; -const TEST_VUEX_MODULE = 'frequentProjects'; -const TEST_PROJECT = currentSession[TEST_NAMESPACE].project; -const TEST_STORAGE_KEY = currentSession[TEST_NAMESPACE].storageKey; -const TEST_SEARCH_CLASS = 'test-search-class'; - -describe('Frequent Items App Component', () => { - let wrapper; - let mock; - let store; - - const createComponent = (props = {}) => { - const session = currentSession[TEST_NAMESPACE]; - gon.api_version = session.apiVersion; - - wrapper = mountExtended(App, { - store, - propsData: { - namespace: TEST_NAMESPACE, - currentUserName: session.username, - currentItem: session.project, - ...props, - }, - provide: { - vuexModule: TEST_VUEX_MODULE, - }, - }); - }; - - const triggerDropdownOpen = () => eventHub.$emit(`${TEST_NAMESPACE}-dropdownOpen`); - const getStoredProjects = () => JSON.parse(localStorage.getItem(TEST_STORAGE_KEY)); - const findSearchInput = () => wrapper.findByTestId('frequent-items-search-input'); - const findLoading = () => wrapper.findByTestId('loading'); - const findSectionHeader = () => wrapper.findByTestId('header'); - const findFrequentItemsList = () => wrapper.findComponent(FrequentItemsList); - const findFrequentItems = () => findFrequentItemsList().findAll('li'); - const setSearch = (search) => { - const searchInput = wrapper.find('input'); - - searchInput.setValue(search); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - store = createStore(); - }); - - afterEach(() => { - mock.restore(); - }); - - describe('default', () => { - beforeEach(() => { - jest.spyOn(store, 'dispatch'); - - createComponent(); - }); - - it('should fetch frequent items', () => { - triggerDropdownOpen(); - - expect(store.dispatch).toHaveBeenCalledWith(`${TEST_VUEX_MODULE}/fetchFrequentItems`); - }); - - it('should not fetch frequent items if detroyed', () => { - wrapper.destroy(); - triggerDropdownOpen(); - - expect(store.dispatch).not.toHaveBeenCalledWith(`${TEST_VUEX_MODULE}/fetchFrequentItems`); - }); - - it('should render search input', () => { - expect(findSearchInput().classes()).toEqual(['search-input-container']); - }); - - it('should render loading animation', async () => { - triggerDropdownOpen(); - store.state[TEST_VUEX_MODULE].isLoadingItems = true; - - await nextTick(); - - const loading = findLoading(); - - expect(loading.exists()).toBe(true); - expect(loading.find('[aria-label="Loading projects"]').exists()).toBe(true); - expect(findSectionHeader().exists()).toBe(false); - }); - - it('should render frequent projects list header', () => { - const sectionHeader = findSectionHeader(); - - expect(sectionHeader.exists()).toBe(true); - expect(sectionHeader.text()).toBe('Frequently visited'); - }); - - it('should render searched projects list', async () => { - mock - .onGet(/\/api\/v4\/projects.json(.*)$/) - .replyOnce(HTTP_STATUS_OK, mockSearchedProjects.data); - - setSearch('gitlab'); - await nextTick(); - - expect(findLoading().exists()).toBe(true); - - await waitForPromises(); - - expect(findFrequentItems().length).toBe(mockSearchedProjects.data.length); - expect(findFrequentItemsList().props()).toEqual( - expect.objectContaining({ - items: mockSearchedProjects.data.map( - ({ - avatar_url: avatarUrl, - web_url: webUrl, - name_with_namespace: namespace, - ...item - }) => ({ - ...item, - avatarUrl, - webUrl, - namespace, - }), - ), - namespace: TEST_NAMESPACE, - hasSearchQuery: true, - isFetchFailed: false, - matcher: 'gitlab', - }), - ); - }); - - describe('with frequent items list', () => { - const expectedResult = getTopFrequentItems(mockFrequentProjects); - - beforeEach(async () => { - localStorage.setItem(TEST_STORAGE_KEY, JSON.stringify(mockFrequentProjects)); - triggerDropdownOpen(); - await nextTick(); - }); - - it('should render edit button within header', () => { - const itemEditButton = findSectionHeader().findComponent(GlButton); - - expect(itemEditButton.exists()).toBe(true); - expect(itemEditButton.attributes('title')).toBe('Toggle edit mode'); - expect(itemEditButton.findComponent(GlIcon).props('name')).toBe('pencil'); - }); - - it('should render frequent projects list', () => { - expect(findFrequentItems().length).toBe(expectedResult.length); - expect(findFrequentItemsList().props()).toEqual({ - items: expectedResult, - namespace: TEST_NAMESPACE, - hasSearchQuery: false, - isFetchFailed: false, - isItemRemovalFailed: false, - matcher: '', - }); - }); - - it('dispatches action `toggleItemsListEditablity` when edit button is clicked', async () => { - const itemEditButton = findSectionHeader().findComponent(GlButton); - itemEditButton.vm.$emit('click'); - - await nextTick(); - - expect(store.dispatch).toHaveBeenCalledWith( - `${TEST_VUEX_MODULE}/toggleItemsListEditablity`, - ); - }); - }); - }); - - describe('with searchClass', () => { - beforeEach(() => { - createComponent({ searchClass: TEST_SEARCH_CLASS }); - }); - - it('should render search input with searchClass', () => { - expect(findSearchInput().classes()).toEqual(['search-input-container', TEST_SEARCH_CLASS]); - }); - }); - - describe('logging', () => { - it('when created, it should create a project storage entry and adds a project', () => { - createComponent(); - - expect(getStoredProjects()).toEqual([ - expect.objectContaining({ - frequency: 1, - lastAccessedOn: Date.now(), - }), - ]); - }); - - describe('when created multiple times', () => { - beforeEach(() => { - createComponent(); - wrapper.destroy(); - createComponent(); - wrapper.destroy(); - }); - - it('should only log once', () => { - expect(getStoredProjects()).toEqual([ - expect.objectContaining({ - lastAccessedOn: Date.now(), - frequency: 1, - }), - ]); - }); - - it('should increase frequency, when created 15 minutes later', () => { - const fifteenMinutesLater = Date.now() + FIFTEEN_MINUTES_IN_MS + 1; - - jest.spyOn(Date, 'now').mockReturnValue(fifteenMinutesLater); - createComponent({ currentItem: { ...TEST_PROJECT, lastAccessedOn: fifteenMinutesLater } }); - - expect(getStoredProjects()).toEqual([ - expect.objectContaining({ - lastAccessedOn: fifteenMinutesLater, - frequency: 2, - }), - ]); - }); - }); - - it('should always update project metadata', () => { - const oldProject = { - ...TEST_PROJECT, - }; - - const newProject = { - ...oldProject, - name: 'New Name', - avatarUrl: 'new/avatar.png', - namespace: 'New / Namespace', - webUrl: 'http://localhost/new/web/url', - }; - - createComponent({ currentItem: oldProject }); - wrapper.destroy(); - expect(getStoredProjects()).toEqual([expect.objectContaining(oldProject)]); - - createComponent({ currentItem: newProject }); - wrapper.destroy(); - - expect(getStoredProjects()).toEqual([expect.objectContaining(newProject)]); - }); - - it('should not add more than 20 projects in store', () => { - for (let id = 0; id < FREQUENT_ITEMS.MAX_COUNT + 10; id += 1) { - const project = { - ...TEST_PROJECT, - id, - }; - createComponent({ currentItem: project }); - wrapper.destroy(); - } - - expect(getStoredProjects().length).toBe(FREQUENT_ITEMS.MAX_COUNT); - }); - }); -}); diff --git a/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js b/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js deleted file mode 100644 index 55d20ad603c..00000000000 --- a/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js +++ /dev/null @@ -1,161 +0,0 @@ -import { GlIcon } from '@gitlab/ui'; -import Vue, { nextTick } from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { trimText } from 'helpers/text_helper'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue'; -import { createStore } from '~/frequent_items/store'; -import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; -import { mockProject } from '../mock_data'; - -Vue.use(Vuex); - -describe('FrequentItemsListItemComponent', () => { - const TEST_VUEX_MODULE = 'frequentProjects'; - let wrapper; - let trackingSpy; - let store; - - const findTitle = () => wrapper.findByTestId('frequent-items-item-title'); - const findAvatar = () => wrapper.findComponent(ProjectAvatar); - const findAllTitles = () => wrapper.findAllByTestId('frequent-items-item-title'); - const findNamespace = () => wrapper.findByTestId('frequent-items-item-namespace'); - const findAllFrequentItems = () => wrapper.findAllByTestId('frequent-item-link'); - const findAllNamespace = () => wrapper.findAllByTestId('frequent-items-item-namespace'); - const findAllAvatars = () => wrapper.findAllComponents(ProjectAvatar); - const findAllMetadataContainers = () => - wrapper.findAllByTestId('frequent-items-item-metadata-container'); - const findRemoveButton = () => wrapper.findByTestId('item-remove'); - - const toggleItemsListEditablity = async () => { - store.dispatch(`${TEST_VUEX_MODULE}/toggleItemsListEditablity`); - - await nextTick(); - }; - - const createComponent = (props = {}) => { - wrapper = shallowMountExtended(frequentItemsListItemComponent, { - store, - propsData: { - itemId: mockProject.id, - itemName: mockProject.name, - namespace: mockProject.namespace, - webUrl: mockProject.webUrl, - avatarUrl: mockProject.avatarUrl, - ...props, - }, - provide: { - vuexModule: TEST_VUEX_MODULE, - }, - }); - }; - - beforeEach(() => { - store = createStore(); - trackingSpy = mockTracking('_category_', document, jest.spyOn); - trackingSpy.mockImplementation(() => {}); - }); - - afterEach(() => { - unmockTracking(); - }); - - describe('computed', () => { - describe('highlightedItemName', () => { - it('should enclose part of project name in <b> & </b> which matches with `matcher` prop', () => { - createComponent({ matcher: 'lab' }); - - expect(findTitle().element.innerHTML).toContain('<b>L</b><b>a</b><b>b</b>'); - }); - - it('should return project name as it is if `matcher` is not available', () => { - createComponent({ matcher: null }); - - expect(trimText(findTitle().text())).toBe(mockProject.name); - }); - }); - - describe('truncatedNamespace', () => { - it('should truncate project name from namespace string', () => { - createComponent({ namespace: 'platform / nokia-3310' }); - - expect(trimText(findNamespace().text())).toBe('platform'); - }); - - it('should truncate namespace string from the middle if it includes more than two groups in path', () => { - createComponent({ - namespace: 'platform / hardware / broadcom / Wifi Group / Mobile Chipset / nokia-3310', - }); - - expect(trimText(findNamespace().text())).toBe('platform / ... / Mobile Chipset'); - }); - }); - }); - - describe('template', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders avatar', () => { - expect(findAvatar().exists()).toBe(true); - }); - - it('renders root element with the right classes', () => { - expect(wrapper.classes('frequent-items-list-item-container')).toBe(true); - }); - - it.each` - name | selector | expected - ${'list item'} | ${findAllFrequentItems} | ${1} - ${'avatar container'} | ${findAllAvatars} | ${1} - ${'metadata container'} | ${findAllMetadataContainers} | ${1} - ${'title'} | ${findAllTitles} | ${1} - ${'namespace'} | ${findAllNamespace} | ${1} - `('should render $expected $name', ({ selector, expected }) => { - expect(selector()).toHaveLength(expected); - }); - - it('renders remove button within item when `isItemsListEditable` is true', async () => { - await toggleItemsListEditablity(); - - const removeButton = findRemoveButton(); - expect(removeButton.exists()).toBe(true); - expect(removeButton.attributes('title')).toBe('Remove'); - expect(removeButton.findComponent(GlIcon).props('name')).toBe('close'); - }); - - it('dispatches action `removeFrequentItem` when remove button is clicked', async () => { - await toggleItemsListEditablity(); - - jest.spyOn(store, 'dispatch'); - - const removeButton = findRemoveButton(); - removeButton.vm.$emit( - 'click', - { stopPropagation: jest.fn(), preventDefault: jest.fn() }, - mockProject.id, - ); - - await nextTick(); - - expect(store.dispatch).toHaveBeenCalledWith( - `${TEST_VUEX_MODULE}/removeFrequentItem`, - mockProject.id, - ); - }); - - it('tracks when item link is clicked', () => { - const link = wrapper.findByTestId('frequent-item-link'); - - link.vm.$emit('click'); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_link', { - label: 'projects_dropdown_frequent_items_list_item', - property: 'navigation_top', - }); - }); - }); -}); diff --git a/spec/frontend/frequent_items/components/frequent_items_list_spec.js b/spec/frontend/frequent_items/components/frequent_items_list_spec.js deleted file mode 100644 index 8055b7a9c13..00000000000 --- a/spec/frontend/frequent_items/components/frequent_items_list_spec.js +++ /dev/null @@ -1,121 +0,0 @@ -import Vue, { nextTick } from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import frequentItemsListComponent from '~/frequent_items/components/frequent_items_list.vue'; -import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue'; -import { createStore } from '~/frequent_items/store'; -import { mockFrequentProjects } from '../mock_data'; - -Vue.use(Vuex); - -describe('FrequentItemsListComponent', () => { - let wrapper; - - const createComponent = (props = {}) => { - wrapper = mountExtended(frequentItemsListComponent, { - store: createStore(), - propsData: { - namespace: 'projects', - items: mockFrequentProjects, - isFetchFailed: false, - isItemRemovalFailed: false, - hasSearchQuery: false, - matcher: 'lab', - ...props, - }, - provide: { - vuexModule: 'frequentProjects', - }, - }); - }; - - describe('computed', () => { - describe('isListEmpty', () => { - it('should return `true` or `false` representing whether if `items` is empty or not with projects', async () => { - createComponent({ - items: [], - }); - - expect(wrapper.vm.isListEmpty).toBe(true); - - wrapper.setProps({ - items: mockFrequentProjects, - }); - await nextTick(); - - expect(wrapper.vm.isListEmpty).toBe(false); - }); - }); - - describe('fetched item messages', () => { - it('should show default empty list message', () => { - createComponent({ - items: [], - }); - - expect(wrapper.findByTestId('frequent-items-list-empty').text()).toContain( - 'Projects you visit often will appear here', - ); - }); - - it.each` - isFetchFailed | isItemRemovalFailed - ${true} | ${false} - ${false} | ${true} - `( - 'should show failure message when `isFetchFailed` is $isFetchFailed or `isItemRemovalFailed` is $isItemRemovalFailed', - ({ isFetchFailed, isItemRemovalFailed }) => { - createComponent({ - items: [], - isFetchFailed, - isItemRemovalFailed, - }); - - expect(wrapper.findByTestId('frequent-items-list-empty').text()).toContain( - 'This feature requires browser localStorage support', - ); - }, - ); - }); - - describe('searched item messages', () => { - it('should return appropriate empty list message based on value of `searchFailed` prop with projects', async () => { - createComponent({ - hasSearchQuery: true, - isFetchFailed: true, - }); - - expect(wrapper.vm.listEmptyMessage).toBe('Something went wrong on our end.'); - - wrapper.setProps({ - isFetchFailed: false, - }); - await nextTick(); - - expect(wrapper.vm.listEmptyMessage).toBe('Sorry, no projects matched your search'); - }); - }); - }); - - describe('template', () => { - it('should render component element with list of projects', async () => { - createComponent(); - - await nextTick(); - expect(wrapper.classes('frequent-items-list-container')).toBe(true); - expect(wrapper.findAllByTestId('frequent-items-list')).toHaveLength(1); - expect(wrapper.findAllComponents(frequentItemsListItemComponent)).toHaveLength(5); - }); - - it('should render component element with empty message', async () => { - createComponent({ - items: [], - }); - - await nextTick(); - expect(wrapper.vm.$el.querySelectorAll('li.section-empty')).toHaveLength(1); - expect(wrapper.findAllComponents(frequentItemsListItemComponent)).toHaveLength(0); - }); - }); -}); diff --git a/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js b/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js deleted file mode 100644 index d6aa0f4e221..00000000000 --- a/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js +++ /dev/null @@ -1,74 +0,0 @@ -import { GlSearchBoxByType } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import searchComponent from '~/frequent_items/components/frequent_items_search_input.vue'; -import { createStore } from '~/frequent_items/store'; - -Vue.use(Vuex); - -describe('FrequentItemsSearchInputComponent', () => { - let wrapper; - let trackingSpy; - let vm; - let store; - - const createComponent = (namespace = 'projects') => - shallowMount(searchComponent, { - store, - propsData: { namespace }, - provide: { - vuexModule: 'frequentProjects', - }, - }); - - const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType); - - beforeEach(() => { - store = createStore(); - jest.spyOn(store, 'dispatch').mockImplementation(() => {}); - - trackingSpy = mockTracking('_category_', document, jest.spyOn); - trackingSpy.mockImplementation(() => {}); - - wrapper = createComponent(); - - ({ vm } = wrapper); - }); - - afterEach(() => { - unmockTracking(); - vm.$destroy(); - }); - - describe('template', () => { - it('should render component element', () => { - expect(wrapper.classes()).toContain('search-input-container'); - expect(findSearchBoxByType().exists()).toBe(true); - expect(findSearchBoxByType().attributes()).toMatchObject({ - placeholder: 'Search your projects', - }); - }); - }); - - describe('tracking', () => { - it('tracks when search query is entered', async () => { - expect(trackingSpy).not.toHaveBeenCalled(); - expect(store.dispatch).not.toHaveBeenCalled(); - - const value = 'my project'; - - findSearchBoxByType().vm.$emit('input', value); - - await nextTick(); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'type_search_query', { - label: 'projects_dropdown_frequent_items_search_input', - property: 'navigation_top', - }); - expect(store.dispatch).toHaveBeenCalledWith('frequentProjects/setSearchQuery', value); - }); - }); -}); diff --git a/spec/frontend/frequent_items/mock_data.js b/spec/frontend/frequent_items/mock_data.js deleted file mode 100644 index 6563daee6c3..00000000000 --- a/spec/frontend/frequent_items/mock_data.js +++ /dev/null @@ -1,169 +0,0 @@ -import { TEST_HOST } from 'helpers/test_constants'; - -export const currentSession = { - groups: { - username: 'root', - storageKey: 'root/frequent-groups', - apiVersion: 'v4', - group: { - id: 1, - name: 'dummy-group', - full_name: 'dummy-parent-group', - webUrl: `${TEST_HOST}/dummy-group`, - avatarUrl: null, - lastAccessedOn: Date.now(), - }, - }, - projects: { - username: 'root', - storageKey: 'root/frequent-projects', - apiVersion: 'v4', - project: { - id: 1, - name: 'dummy-project', - namespace: 'SampleGroup / Dummy-Project', - webUrl: `${TEST_HOST}/samplegroup/dummy-project`, - avatarUrl: null, - lastAccessedOn: Date.now(), - }, - }, -}; - -export const mockNamespace = 'projects'; -export const mockStorageKey = 'test-user/frequent-projects'; - -export const mockGroup = { - id: 1, - name: 'Sub451', - namespace: 'Commit451 / Sub451', - webUrl: `${TEST_HOST}/Commit451/Sub451`, - avatarUrl: null, -}; - -export const mockRawGroup = { - id: 1, - name: 'Sub451', - full_name: 'Commit451 / Sub451', - web_url: `${TEST_HOST}/Commit451/Sub451`, - avatar_url: null, -}; - -export const mockFrequentGroups = [ - { - id: 3, - name: 'Subgroup451', - full_name: 'Commit451 / Subgroup451', - webUrl: '/Commit451/Subgroup451', - avatarUrl: null, - frequency: 7, - lastAccessedOn: 1497979281815, - }, - { - id: 1, - name: 'Commit451', - full_name: 'Commit451', - webUrl: '/Commit451', - avatarUrl: null, - frequency: 3, - lastAccessedOn: 1497979281815, - }, -]; - -export const mockSearchedGroups = { data: [mockRawGroup] }; -export const mockProcessedSearchedGroups = [mockGroup]; - -export const mockProject = { - id: 1, - name: 'GitLab Community Edition', - namespace: 'gitlab-org / gitlab-ce', - webUrl: `${TEST_HOST}/gitlab-org/gitlab-foss`, - avatarUrl: null, -}; - -export const mockRawProject = { - id: 1, - name: 'GitLab Community Edition', - name_with_namespace: 'gitlab-org / gitlab-ce', - web_url: `${TEST_HOST}/gitlab-org/gitlab-foss`, - avatar_url: null, -}; - -export const mockFrequentProjects = [ - { - id: 1, - name: 'GitLab Community Edition', - namespace: 'gitlab-org / gitlab-ce', - webUrl: `${TEST_HOST}/gitlab-org/gitlab-foss`, - avatarUrl: null, - frequency: 1, - lastAccessedOn: Date.now(), - }, - { - id: 2, - name: 'GitLab CI', - namespace: 'gitlab-org / gitlab-ci', - webUrl: `${TEST_HOST}/gitlab-org/gitlab-ci`, - avatarUrl: null, - frequency: 9, - lastAccessedOn: Date.now(), - }, - { - id: 3, - name: 'Typeahead.Js', - namespace: 'twitter / typeahead-js', - webUrl: `${TEST_HOST}/twitter/typeahead-js`, - avatarUrl: '/uploads/-/system/project/avatar/7/TWBS.png', - frequency: 2, - lastAccessedOn: Date.now(), - }, - { - id: 4, - name: 'Intel', - namespace: 'platform / hardware / bsp / intel', - webUrl: `${TEST_HOST}/platform/hardware/bsp/intel`, - avatarUrl: null, - frequency: 3, - lastAccessedOn: Date.now(), - }, - { - id: 5, - name: 'v4.4', - namespace: 'platform / hardware / bsp / kernel / common / v4.4', - webUrl: `${TEST_HOST}/platform/hardware/bsp/kernel/common/v4.4`, - avatarUrl: null, - frequency: 8, - lastAccessedOn: Date.now(), - }, -]; - -export const mockSearchedProjects = { data: [mockRawProject] }; -export const mockProcessedSearchedProjects = [mockProject]; - -export const unsortedFrequentItems = [ - { id: 1, frequency: 12, lastAccessedOn: 1491400843391 }, - { id: 2, frequency: 14, lastAccessedOn: 1488240890738 }, - { id: 3, frequency: 44, lastAccessedOn: 1497675908472 }, - { id: 4, frequency: 8, lastAccessedOn: 1497979281815 }, - { id: 5, frequency: 34, lastAccessedOn: 1488089211943 }, - { id: 6, frequency: 14, lastAccessedOn: 1493517292488 }, - { id: 7, frequency: 42, lastAccessedOn: 1486815299875 }, - { id: 8, frequency: 33, lastAccessedOn: 1500762279114 }, - { id: 10, frequency: 46, lastAccessedOn: 1483251641543 }, -]; - -/** - * This const has a specific order which tests authenticity - * of `getTopFrequentItems` method so - * DO NOT change order of items in this const. - */ -export const sortedFrequentItems = [ - { id: 10, frequency: 46, lastAccessedOn: 1483251641543 }, - { id: 3, frequency: 44, lastAccessedOn: 1497675908472 }, - { id: 7, frequency: 42, lastAccessedOn: 1486815299875 }, - { id: 5, frequency: 34, lastAccessedOn: 1488089211943 }, - { id: 8, frequency: 33, lastAccessedOn: 1500762279114 }, - { id: 6, frequency: 14, lastAccessedOn: 1493517292488 }, - { id: 2, frequency: 14, lastAccessedOn: 1488240890738 }, - { id: 1, frequency: 12, lastAccessedOn: 1491400843391 }, - { id: 4, frequency: 8, lastAccessedOn: 1497979281815 }, -]; diff --git a/spec/frontend/frequent_items/store/actions_spec.js b/spec/frontend/frequent_items/store/actions_spec.js deleted file mode 100644 index 2feb488da2c..00000000000 --- a/spec/frontend/frequent_items/store/actions_spec.js +++ /dev/null @@ -1,304 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import testAction from 'helpers/vuex_action_helper'; -import * as actions from '~/frequent_items/store/actions'; -import * as types from '~/frequent_items/store/mutation_types'; -import state from '~/frequent_items/store/state'; -import AccessorUtilities from '~/lib/utils/accessor'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import { useLocalStorageSpy } from 'helpers/local_storage_helper'; -import { - mockNamespace, - mockStorageKey, - mockFrequentProjects, - mockSearchedProjects, -} from '../mock_data'; - -describe('Frequent Items Dropdown Store Actions', () => { - useLocalStorageSpy(); - let mockedState; - let mock; - - beforeEach(() => { - mockedState = state(); - mock = new MockAdapter(axios); - - mockedState.namespace = mockNamespace; - mockedState.storageKey = mockStorageKey; - }); - - afterEach(() => { - mock.restore(); - }); - - describe('setNamespace', () => { - it('should set namespace', () => { - return testAction( - actions.setNamespace, - mockNamespace, - mockedState, - [{ type: types.SET_NAMESPACE, payload: mockNamespace }], - [], - ); - }); - }); - - describe('setStorageKey', () => { - it('should set storage key', () => { - return testAction( - actions.setStorageKey, - mockStorageKey, - mockedState, - [{ type: types.SET_STORAGE_KEY, payload: mockStorageKey }], - [], - ); - }); - }); - - describe('toggleItemsListEditablity', () => { - it('should toggle items list editablity', () => { - return testAction( - actions.toggleItemsListEditablity, - null, - mockedState, - [{ type: types.TOGGLE_ITEMS_LIST_EDITABILITY }], - [], - ); - }); - }); - - describe('requestFrequentItems', () => { - it('should request frequent items', () => { - return testAction( - actions.requestFrequentItems, - null, - mockedState, - [{ type: types.REQUEST_FREQUENT_ITEMS }], - [], - ); - }); - }); - - describe('receiveFrequentItemsSuccess', () => { - it('should set frequent items', () => { - return testAction( - actions.receiveFrequentItemsSuccess, - mockFrequentProjects, - mockedState, - [{ type: types.RECEIVE_FREQUENT_ITEMS_SUCCESS, payload: mockFrequentProjects }], - [], - ); - }); - }); - - describe('receiveFrequentItemsError', () => { - it('should set frequent items error state', () => { - return testAction( - actions.receiveFrequentItemsError, - null, - mockedState, - [{ type: types.RECEIVE_FREQUENT_ITEMS_ERROR }], - [], - ); - }); - }); - - describe('fetchFrequentItems', () => { - it('should dispatch `receiveFrequentItemsSuccess`', () => { - mockedState.namespace = mockNamespace; - mockedState.storageKey = mockStorageKey; - - return testAction( - actions.fetchFrequentItems, - null, - mockedState, - [], - [{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsSuccess', payload: [] }], - ); - }); - - it('should dispatch `receiveFrequentItemsError`', () => { - jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false); - mockedState.namespace = mockNamespace; - mockedState.storageKey = mockStorageKey; - - return testAction( - actions.fetchFrequentItems, - null, - mockedState, - [], - [{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsError' }], - ); - }); - }); - - describe('requestSearchedItems', () => { - it('should request searched items', () => { - return testAction( - actions.requestSearchedItems, - null, - mockedState, - [{ type: types.REQUEST_SEARCHED_ITEMS }], - [], - ); - }); - }); - - describe('receiveSearchedItemsSuccess', () => { - it('should set searched items', () => { - return testAction( - actions.receiveSearchedItemsSuccess, - mockSearchedProjects, - mockedState, - [{ type: types.RECEIVE_SEARCHED_ITEMS_SUCCESS, payload: mockSearchedProjects }], - [], - ); - }); - }); - - describe('receiveSearchedItemsError', () => { - it('should set searched items error state', () => { - return testAction( - actions.receiveSearchedItemsError, - null, - mockedState, - [{ type: types.RECEIVE_SEARCHED_ITEMS_ERROR }], - [], - ); - }); - }); - - describe('fetchSearchedItems', () => { - beforeEach(() => { - gon.api_version = 'v4'; - }); - - it('should dispatch `receiveSearchedItemsSuccess`', () => { - mock - .onGet(/\/api\/v4\/projects.json(.*)$/) - .replyOnce(HTTP_STATUS_OK, mockSearchedProjects, {}); - - return testAction( - actions.fetchSearchedItems, - null, - mockedState, - [], - [ - { type: 'requestSearchedItems' }, - { - type: 'receiveSearchedItemsSuccess', - payload: { data: mockSearchedProjects, headers: {} }, - }, - ], - ); - }); - - it('should dispatch `receiveSearchedItemsError`', () => { - gon.api_version = 'v4'; - mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); - - return testAction( - actions.fetchSearchedItems, - null, - mockedState, - [], - [{ type: 'requestSearchedItems' }, { type: 'receiveSearchedItemsError' }], - ); - }); - }); - - describe('setSearchQuery', () => { - it('should commit query and dispatch `fetchSearchedItems` when query is present', () => { - return testAction( - actions.setSearchQuery, - { query: 'test' }, - mockedState, - [{ type: types.SET_SEARCH_QUERY, payload: { query: 'test' } }], - [{ type: 'fetchSearchedItems', payload: { query: 'test' } }], - ); - }); - - it('should commit query and dispatch `fetchFrequentItems` when query is empty', () => { - return testAction( - actions.setSearchQuery, - null, - mockedState, - [{ type: types.SET_SEARCH_QUERY, payload: null }], - [{ type: 'fetchFrequentItems' }], - ); - }); - }); - - describe('removeFrequentItemSuccess', () => { - it('should remove frequent item on success', () => { - return testAction( - actions.removeFrequentItemSuccess, - { itemId: 1 }, - mockedState, - [ - { - type: types.RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS, - payload: { itemId: 1 }, - }, - ], - [], - ); - }); - }); - - describe('removeFrequentItemError', () => { - it('should should not remove frequent item on failure', () => { - return testAction( - actions.removeFrequentItemError, - null, - mockedState, - [{ type: types.RECEIVE_REMOVE_FREQUENT_ITEM_ERROR }], - [], - ); - }); - }); - - describe('removeFrequentItem', () => { - beforeEach(() => { - mockedState.items = [...mockFrequentProjects]; - window.localStorage.setItem(mockStorageKey, JSON.stringify(mockFrequentProjects)); - }); - - it('should remove provided itemId from localStorage', () => { - jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true); - - actions.removeFrequentItem( - { commit: jest.fn(), dispatch: jest.fn(), state: mockedState }, - mockFrequentProjects[0].id, - ); - - expect(window.localStorage.getItem(mockStorageKey)).toBe( - JSON.stringify(mockFrequentProjects.slice(1)), // First item was removed - ); - }); - - it('should dispatch `removeFrequentItemSuccess` on localStorage update success', () => { - jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true); - - return testAction( - actions.removeFrequentItem, - mockFrequentProjects[0].id, - mockedState, - [], - [{ type: 'removeFrequentItemSuccess', payload: mockFrequentProjects[0].id }], - ); - }); - - it('should dispatch `removeFrequentItemError` on localStorage update failure', () => { - jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false); - - return testAction( - actions.removeFrequentItem, - mockFrequentProjects[0].id, - mockedState, - [], - [{ type: 'removeFrequentItemError' }], - ); - }); - }); -}); diff --git a/spec/frontend/frequent_items/store/getters_spec.js b/spec/frontend/frequent_items/store/getters_spec.js deleted file mode 100644 index 97732cd95fc..00000000000 --- a/spec/frontend/frequent_items/store/getters_spec.js +++ /dev/null @@ -1,24 +0,0 @@ -import * as getters from '~/frequent_items/store/getters'; -import state from '~/frequent_items/store/state'; - -describe('Frequent Items Dropdown Store Getters', () => { - let mockedState; - - beforeEach(() => { - mockedState = state(); - }); - - describe('hasSearchQuery', () => { - it('should return `true` when search query is present', () => { - mockedState.searchQuery = 'test'; - - expect(getters.hasSearchQuery(mockedState)).toBe(true); - }); - - it('should return `false` when search query is empty', () => { - mockedState.searchQuery = ''; - - expect(getters.hasSearchQuery(mockedState)).toBe(false); - }); - }); -}); diff --git a/spec/frontend/frequent_items/store/mutations_spec.js b/spec/frontend/frequent_items/store/mutations_spec.js deleted file mode 100644 index 1e1878c3377..00000000000 --- a/spec/frontend/frequent_items/store/mutations_spec.js +++ /dev/null @@ -1,152 +0,0 @@ -import * as types from '~/frequent_items/store/mutation_types'; -import mutations from '~/frequent_items/store/mutations'; -import state from '~/frequent_items/store/state'; -import { - mockNamespace, - mockStorageKey, - mockFrequentProjects, - mockSearchedProjects, - mockProcessedSearchedProjects, - mockSearchedGroups, - mockProcessedSearchedGroups, -} from '../mock_data'; - -describe('Frequent Items dropdown mutations', () => { - let stateCopy; - - beforeEach(() => { - stateCopy = state(); - }); - - describe('SET_NAMESPACE', () => { - it('should set namespace', () => { - mutations[types.SET_NAMESPACE](stateCopy, mockNamespace); - - expect(stateCopy.namespace).toEqual(mockNamespace); - }); - }); - - describe('SET_STORAGE_KEY', () => { - it('should set storage key', () => { - mutations[types.SET_STORAGE_KEY](stateCopy, mockStorageKey); - - expect(stateCopy.storageKey).toEqual(mockStorageKey); - }); - }); - - describe('SET_SEARCH_QUERY', () => { - it('should set search query', () => { - const searchQuery = 'gitlab-ce'; - - mutations[types.SET_SEARCH_QUERY](stateCopy, searchQuery); - - expect(stateCopy.searchQuery).toEqual(searchQuery); - }); - }); - - describe('TOGGLE_ITEMS_LIST_EDITABILITY', () => { - it('should toggle items list editablity', () => { - mutations[types.TOGGLE_ITEMS_LIST_EDITABILITY](stateCopy); - - expect(stateCopy.isItemsListEditable).toEqual(true); - - mutations[types.TOGGLE_ITEMS_LIST_EDITABILITY](stateCopy); - - expect(stateCopy.isItemsListEditable).toEqual(false); - }); - }); - - describe('REQUEST_FREQUENT_ITEMS', () => { - it('should set view states when requesting frequent items', () => { - mutations[types.REQUEST_FREQUENT_ITEMS](stateCopy); - - expect(stateCopy.isLoadingItems).toEqual(true); - expect(stateCopy.hasSearchQuery).toEqual(false); - }); - }); - - describe('RECEIVE_FREQUENT_ITEMS_SUCCESS', () => { - it('should set view states when receiving frequent items', () => { - mutations[types.RECEIVE_FREQUENT_ITEMS_SUCCESS](stateCopy, mockFrequentProjects); - - expect(stateCopy.items).toEqual(mockFrequentProjects); - expect(stateCopy.isLoadingItems).toEqual(false); - expect(stateCopy.hasSearchQuery).toEqual(false); - expect(stateCopy.isFetchFailed).toEqual(false); - }); - }); - - describe('RECEIVE_FREQUENT_ITEMS_ERROR', () => { - it('should set items and view states when error occurs retrieving frequent items', () => { - mutations[types.RECEIVE_FREQUENT_ITEMS_ERROR](stateCopy); - - expect(stateCopy.items).toEqual([]); - expect(stateCopy.isLoadingItems).toEqual(false); - expect(stateCopy.hasSearchQuery).toEqual(false); - expect(stateCopy.isFetchFailed).toEqual(true); - }); - }); - - describe('REQUEST_SEARCHED_ITEMS', () => { - it('should set view states when requesting searched items', () => { - mutations[types.REQUEST_SEARCHED_ITEMS](stateCopy); - - expect(stateCopy.isLoadingItems).toEqual(true); - expect(stateCopy.hasSearchQuery).toEqual(true); - }); - }); - - describe('RECEIVE_SEARCHED_ITEMS_SUCCESS', () => { - it('should set items and view states when receiving searched items', () => { - mutations[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](stateCopy, mockSearchedProjects); - - expect(stateCopy.items).toEqual(mockProcessedSearchedProjects); - expect(stateCopy.isLoadingItems).toEqual(false); - expect(stateCopy.hasSearchQuery).toEqual(true); - expect(stateCopy.isFetchFailed).toEqual(false); - }); - - it('should also handle the different `full_name` key for namespace in groups payload', () => { - mutations[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](stateCopy, mockSearchedGroups); - - expect(stateCopy.items).toEqual(mockProcessedSearchedGroups); - expect(stateCopy.isLoadingItems).toEqual(false); - expect(stateCopy.hasSearchQuery).toEqual(true); - expect(stateCopy.isFetchFailed).toEqual(false); - }); - }); - - describe('RECEIVE_SEARCHED_ITEMS_ERROR', () => { - it('should set view states when error occurs retrieving searched items', () => { - mutations[types.RECEIVE_SEARCHED_ITEMS_ERROR](stateCopy); - - expect(stateCopy.items).toEqual([]); - expect(stateCopy.isLoadingItems).toEqual(false); - expect(stateCopy.hasSearchQuery).toEqual(true); - expect(stateCopy.isFetchFailed).toEqual(true); - }); - }); - - describe('RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS', () => { - it('should remove item with provided itemId from the items', () => { - stateCopy.isItemRemovalFailed = true; - stateCopy.items = mockFrequentProjects; - - mutations[types.RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS](stateCopy, mockFrequentProjects[0].id); - - expect(stateCopy.items).toHaveLength(mockFrequentProjects.length - 1); - expect(stateCopy.items).toEqual([...mockFrequentProjects.slice(1)]); - expect(stateCopy.isItemRemovalFailed).toBe(false); - }); - }); - - describe('RECEIVE_REMOVE_FREQUENT_ITEM_ERROR', () => { - it('should remove item with provided itemId from the items', () => { - stateCopy.isItemRemovalFailed = false; - - mutations[types.RECEIVE_REMOVE_FREQUENT_ITEM_ERROR](stateCopy); - - expect(stateCopy.isItemRemovalFailed).toBe(true); - }); - }); -}); diff --git a/spec/frontend/frequent_items/utils_spec.js b/spec/frontend/frequent_items/utils_spec.js deleted file mode 100644 index 8d4c89bd48f..00000000000 --- a/spec/frontend/frequent_items/utils_spec.js +++ /dev/null @@ -1,131 +0,0 @@ -import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import { FIFTEEN_MINUTES_IN_MS, FREQUENT_ITEMS } from '~/frequent_items/constants'; -import { - isMobile, - getTopFrequentItems, - updateExistingFrequentItem, - sanitizeItem, -} from '~/frequent_items/utils'; -import { mockProject, unsortedFrequentItems, sortedFrequentItems } from './mock_data'; - -describe('Frequent Items utils spec', () => { - describe('isMobile', () => { - it('returns true when the screen is medium', () => { - jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('md'); - - expect(isMobile()).toBe(true); - }); - - it('returns true when the screen is small', () => { - jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('sm'); - - expect(isMobile()).toBe(true); - }); - - it('returns true when the screen is extra-small', () => { - jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xs'); - - expect(isMobile()).toBe(true); - }); - - it('returns false when the screen is larger than medium', () => { - jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('lg'); - - expect(isMobile()).toBe(false); - }); - }); - - describe('getTopFrequentItems', () => { - it('returns empty array if no items provided', () => { - const result = getTopFrequentItems(); - - expect(result.length).toBe(0); - }); - - it('returns correct amount of items for mobile', () => { - jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('md'); - const result = getTopFrequentItems(unsortedFrequentItems); - - expect(result.length).toBe(FREQUENT_ITEMS.LIST_COUNT_MOBILE); - }); - - it('returns correct amount of items for desktop', () => { - jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xl'); - const result = getTopFrequentItems(unsortedFrequentItems); - - expect(result.length).toBe(FREQUENT_ITEMS.LIST_COUNT_DESKTOP); - }); - - it('sorts frequent items in order of frequency and lastAccessedOn', () => { - jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xl'); - const result = getTopFrequentItems(unsortedFrequentItems); - const expectedResult = sortedFrequentItems.slice(0, FREQUENT_ITEMS.LIST_COUNT_DESKTOP); - - expect(result).toEqual(expectedResult); - }); - }); - - describe('updateExistingFrequentItem', () => { - const LAST_ACCESSED = 1497979281815; - const WITHIN_FIFTEEN_MINUTES = LAST_ACCESSED + FIFTEEN_MINUTES_IN_MS; - const OVER_FIFTEEN_MINUTES = WITHIN_FIFTEEN_MINUTES + 1; - const EXISTING_ITEM = Object.freeze({ - ...mockProject, - frequency: 1, - lastAccessedOn: 1497979281815, - }); - - it.each` - desc | existingProps | newProps | expected - ${'updates item if accessed over 15 minutes ago'} | ${{}} | ${{ lastAccessedOn: OVER_FIFTEEN_MINUTES }} | ${{ lastAccessedOn: Date.now(), frequency: 2 }} - ${'does not update is accessed with 15 minutes'} | ${{}} | ${{ lastAccessedOn: WITHIN_FIFTEEN_MINUTES }} | ${{ lastAccessedOn: EXISTING_ITEM.lastAccessedOn, frequency: 1 }} - ${'updates if lastAccessedOn not found'} | ${{ lastAccessedOn: undefined }} | ${{ lastAccessedOn: WITHIN_FIFTEEN_MINUTES }} | ${{ lastAccessedOn: Date.now(), frequency: 2 }} - `('$desc', ({ existingProps, newProps, expected }) => { - const newItem = { - ...EXISTING_ITEM, - ...newProps, - }; - const existingItem = { - ...EXISTING_ITEM, - ...existingProps, - }; - - const result = updateExistingFrequentItem(existingItem, newItem); - - expect(result).toEqual({ - ...newItem, - ...expected, - }); - }); - }); - - describe('sanitizeItem', () => { - it('strips HTML tags for name and namespace', () => { - const input = { - name: '<br><b>test</b>', - namespace: '<br>test', - id: 1, - }; - - expect(sanitizeItem(input)).toEqual({ name: 'test', namespace: 'test', id: 1 }); - }); - - it("skips `name` key if it doesn't exist on the item", () => { - const input = { - namespace: '<br>test', - id: 1, - }; - - expect(sanitizeItem(input)).toEqual({ namespace: 'test', id: 1 }); - }); - - it("skips `namespace` key if it doesn't exist on the item", () => { - const input = { - name: '<br><b>test</b>', - id: 1, - }; - - expect(sanitizeItem(input)).toEqual({ name: 'test', id: 1 }); - }); - }); -}); diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index da465552db3..2d7841771a1 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -45,6 +45,16 @@ describe('GfmAutoComplete', () => { let sorterValue; let filterValue; + const triggerDropdown = ($textarea, text) => { + $textarea + .trigger('focus') + .val($textarea.val() + text) + .caret('pos', -1); + $textarea.trigger('keyup'); + + jest.runOnlyPendingTimers(); + }; + describe('DefaultOptions.filter', () => { let items; @@ -537,7 +547,7 @@ describe('GfmAutoComplete', () => { expect(membersBeforeSave([{ ...mockGroup, avatar_url: null }])).toEqual([ { username: 'my-group', - avatarTag: '<div class="avatar rect-avatar center avatar-inline s26">M</div>', + avatarTag: '<div class="avatar rect-avatar avatar-inline s24 gl-mr-2">M</div>', title: 'My Group (2)', search: 'MyGroup my-group', icon: '', @@ -550,7 +560,7 @@ describe('GfmAutoComplete', () => { { username: 'my-group', avatarTag: - '<img src="./group.jpg" alt="my-group" class="avatar rect-avatar avatar-inline center s26"/>', + '<img src="./group.jpg" alt="my-group" class="avatar rect-avatar avatar-inline s24 gl-mr-2"/>', title: 'My Group (2)', search: 'MyGroup my-group', icon: '', @@ -563,7 +573,7 @@ describe('GfmAutoComplete', () => { { username: 'my-group', avatarTag: - '<img src="./group.jpg" alt="my-group" class="avatar rect-avatar avatar-inline center s26"/>', + '<img src="./group.jpg" alt="my-group" class="avatar rect-avatar avatar-inline s24 gl-mr-2"/>', title: 'My Group', search: 'MyGroup my-group', icon: @@ -581,7 +591,7 @@ describe('GfmAutoComplete', () => { { username: 'my-user', avatarTag: - '<img src="./users.jpg" alt="my-user" class="avatar avatar-inline center s26"/>', + '<img src="./users.jpg" alt="my-user" class="avatar avatar-inline s24 gl-mr-2"/>', title: 'My User', search: 'MyUser my-user', icon: '', @@ -786,13 +796,6 @@ describe('GfmAutoComplete', () => { resetHTMLFixture(); }); - const triggerDropdown = (text) => { - $textarea.trigger('focus').val(text).caret('pos', -1); - $textarea.trigger('keyup'); - - jest.runOnlyPendingTimers(); - }; - const getDropdownItems = () => { const dropdown = document.getElementById('at-view-labels'); const items = dropdown.getElementsByTagName('li'); @@ -800,7 +803,7 @@ describe('GfmAutoComplete', () => { }; const expectLabels = ({ input, output }) => { - triggerDropdown(input); + triggerDropdown($textarea, input); expect(getDropdownItems()).toEqual(output.map((label) => label.title)); }; @@ -860,6 +863,50 @@ describe('GfmAutoComplete', () => { }); }); + describe('submit_review', () => { + let autocomplete; + let $textarea; + + const getDropdownItems = () => { + const dropdown = document.getElementById('at-view-submit_review'); + + return dropdown.getElementsByTagName('li'); + }; + + beforeEach(() => { + jest + .spyOn(AjaxCache, 'retrieve') + .mockReturnValue(Promise.resolve([{ name: 'submit_review' }])); + + window.gon = { features: { mrRequestChanges: true } }; + + setHTMLFixture('<textarea data-supports-quick-actions="true"></textarea>'); + autocomplete = new GfmAutoComplete({ + commands: `${TEST_HOST}/autocomplete_sources/commands`, + }); + $textarea = $('textarea'); + autocomplete.setup($textarea, {}); + }); + + afterEach(() => { + autocomplete.destroy(); + resetHTMLFixture(); + }); + + it('renders submit review options', async () => { + triggerDropdown($textarea, '/'); + + await waitForPromises(); + + triggerDropdown($textarea, 'submit_review '); + + expect(getDropdownItems()).toHaveLength(3); + expect(getDropdownItems()[0].textContent).toContain('Comment'); + expect(getDropdownItems()[1].textContent).toContain('Approve'); + expect(getDropdownItems()[2].textContent).toContain('Request changes'); + }); + }); + describe('emoji', () => { const mockItem = { 'atwho-at': ':', @@ -951,13 +998,6 @@ describe('GfmAutoComplete', () => { resetHTMLFixture(); }); - const triggerDropdown = (text) => { - $textarea.trigger('focus').val(text).caret('pos', -1); - $textarea.trigger('keyup'); - - jest.runOnlyPendingTimers(); - }; - const getDropdownItems = () => { const dropdown = document.getElementById('at-view-contacts'); const items = dropdown.getElementsByTagName('li'); @@ -965,7 +1005,7 @@ describe('GfmAutoComplete', () => { }; const expectContacts = ({ input, output }) => { - triggerDropdown(input); + triggerDropdown($textarea, input); expect(getDropdownItems()).toEqual( output.map((contact) => `${contact.first_name} ${contact.last_name} ${contact.email}`), diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js index e32c50db8bf..8ac410c87b1 100644 --- a/spec/frontend/groups/components/app_spec.js +++ b/spec/frontend/groups/components/app_spec.js @@ -1,12 +1,10 @@ import { GlModal, GlLoadingIcon } from '@gitlab/ui'; import AxiosMockAdapter from 'axios-mock-adapter'; -import Vue, { nextTick } from 'vue'; +import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; 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 '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'; @@ -67,8 +65,6 @@ describe('AppComponent', () => { beforeEach(async () => { mock = new AxiosMockAdapter(axios); mock.onGet('/dashboard/groups.json').reply(HTTP_STATUS_OK, mockGroups); - Vue.component('GroupFolder', groupFolderComponent); - Vue.component('GroupItem', groupItemComponent); setWindowLocation('?filter=foobar'); document.body.innerHTML = ` diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js index 3cdbd3e38be..33fd2681766 100644 --- a/spec/frontend/groups/components/groups_spec.js +++ b/spec/frontend/groups/components/groups_spec.js @@ -1,9 +1,7 @@ import Vue from 'vue'; import { GlEmptyState } from '@gitlab/ui'; - -import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { shallowMount } from '@vue/test-utils'; import GroupFolderComponent from '~/groups/components/group_folder.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'; @@ -19,7 +17,7 @@ describe('GroupsComponent', () => { }; const createComponent = ({ propsData } = {}) => { - wrapper = mountExtended(GroupsComponent, { + wrapper = shallowMount(GroupsComponent, { propsData: { ...defaultPropsData, ...propsData, @@ -32,11 +30,6 @@ describe('GroupsComponent', () => { const findPaginationLinks = () => wrapper.findComponent(PaginationLinks); - beforeEach(() => { - Vue.component('GroupFolder', GroupFolderComponent); - Vue.component('GroupItem', GroupItemComponent); - }); - describe('methods', () => { describe('change', () => { it('should emit `fetchPage` event when page is changed via pagination', () => { @@ -57,6 +50,8 @@ describe('GroupsComponent', () => { }); describe('template', () => { + Vue.component('GroupFolder', GroupFolderComponent); + it('should render component template correctly', () => { createComponent(); diff --git a/spec/frontend/groups/service/archived_projects_service_spec.js b/spec/frontend/groups/service/archived_projects_service_spec.js index 6bc46e4799c..988fb5553ba 100644 --- a/spec/frontend/groups/service/archived_projects_service_spec.js +++ b/spec/frontend/groups/service/archived_projects_service_spec.js @@ -30,7 +30,7 @@ describe('ArchivedProjectsService', () => { markdown_description: project.description_html, visibility: project.visibility, avatar_url: project.avatar_url, - relative_path: `/${project.path_with_namespace}`, + relative_path: `${gon.relative_url_root}/${project.path_with_namespace}`, edit_path: null, leave_path: null, can_edit: false, diff --git a/spec/frontend/groups_projects/components/more_actions_dropdown_spec.js b/spec/frontend/groups_projects/components/more_actions_dropdown_spec.js new file mode 100644 index 00000000000..1bcff8a44be --- /dev/null +++ b/spec/frontend/groups_projects/components/more_actions_dropdown_spec.js @@ -0,0 +1,173 @@ +import { GlDisclosureDropdownItem, GlDisclosureDropdown } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import moreActionsDropdown from '~/groups_projects/components/more_actions_dropdown.vue'; + +describe('moreActionsDropdown', () => { + let wrapper; + + const createComponent = ({ provideData = {}, propsData = {} } = {}) => { + wrapper = shallowMountExtended(moreActionsDropdown, { + provide: { + isGroup: false, + id: 1, + leavePath: '', + leaveConfirmMessage: '', + withdrawPath: '', + withdrawConfirmMessage: '', + requestAccessPath: '', + ...provideData, + }, + propsData, + stubs: { + GlDisclosureDropdownItem, + }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const showDropdown = () => { + findDropdown().vm.$emit('show'); + }; + + describe('copy id', () => { + describe('project namespace type', () => { + beforeEach(async () => { + createComponent({ + provideData: { + id: 22, + }, + }); + await showDropdown(); + }); + + it('has correct test id `copy-project-id`', () => { + expect(wrapper.findByTestId('copy-project-id').exists()).toBe(true); + expect(wrapper.findByTestId('copy-group-id').exists()).toBe(false); + }); + + it('renders copy project id with correct id', () => { + expect(wrapper.findByTestId('copy-project-id').text()).toBe('Copy project ID: 22'); + }); + }); + + describe('group namespace type', () => { + beforeEach(async () => { + createComponent({ + provideData: { + isGroup: true, + id: 11, + }, + }); + await showDropdown(); + }); + + it('has correct test id `copy-group-id`', () => { + expect(wrapper.findByTestId('copy-project-id').exists()).toBe(false); + expect(wrapper.findByTestId('copy-group-id').exists()).toBe(true); + }); + + it('renders copy group id with correct id', () => { + expect(wrapper.findByTestId('copy-group-id').text()).toBe('Copy group ID: 11'); + }); + }); + }); + + describe('request access', () => { + it('does not render request access link', async () => { + createComponent(); + await showDropdown(); + + expect(wrapper.findByTestId('request-access-link').exists()).toBe(false); + }); + + it('renders request access link', async () => { + createComponent({ + provideData: { + requestAccessPath: 'http://request.path/path', + }, + }); + await showDropdown(); + + expect(wrapper.findByTestId('request-access-link').text()).toBe('Request Access'); + expect(wrapper.findByTestId('request-access-link').attributes('href')).toBe( + 'http://request.path/path', + ); + }); + }); + + describe('withdraw access', () => { + it('does not render withdraw access link', async () => { + createComponent(); + await showDropdown(); + + expect(wrapper.findByTestId('withdraw-access-link').exists()).toBe(false); + }); + + it('renders withdraw access link', async () => { + createComponent({ + provideData: { + withdrawPath: 'http://withdraw.path/path', + }, + }); + await showDropdown(); + + expect(wrapper.findByTestId('withdraw-access-link').text()).toBe('Withdraw Access Request'); + expect(wrapper.findByTestId('withdraw-access-link').attributes('href')).toBe( + 'http://withdraw.path/path', + ); + }); + }); + + describe('leave access', () => { + it('does not render leave link', async () => { + createComponent(); + await showDropdown(); + + expect(wrapper.findByTestId('leave-project-link').exists()).toBe(false); + }); + + it('renders leave link', async () => { + createComponent({ + provideData: { + leavePath: 'http://leave.path/path', + }, + }); + await showDropdown(); + + expect(wrapper.findByTestId('leave-project-link').exists()).toBe(true); + expect(wrapper.findByTestId('leave-project-link').text()).toBe('Leave project'); + expect(wrapper.findByTestId('leave-project-link').attributes('href')).toBe( + 'http://leave.path/path', + ); + }); + + describe('when `isGroup` is set to `false`', () => { + it('use testid `leave-project-link`', async () => { + createComponent({ + provideData: { + leavePath: 'http://leave.path/path', + }, + }); + await showDropdown(); + + expect(wrapper.findByTestId('leave-project-link').exists()).toBe(true); + expect(wrapper.findByTestId('leave-group-link').exists()).toBe(false); + }); + }); + + describe('when `isGroup` is set to `true`', () => { + it('use testid `leave-group-link`', async () => { + createComponent({ + provideData: { + isGroup: true, + leavePath: 'http://leave.path/path', + }, + }); + await showDropdown(); + + expect(wrapper.findByTestId('leave-project-link').exists()).toBe(false); + expect(wrapper.findByTestId('leave-group-link').exists()).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js deleted file mode 100644 index 0d0b6628bdf..00000000000 --- a/spec/frontend/header_search/components/app_spec.js +++ /dev/null @@ -1,517 +0,0 @@ -import { GlSearchBoxByType, GlToken, GlIcon } from '@gitlab/ui'; -import Vue, { nextTick } from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { mockTracking } from 'helpers/tracking_helper'; -import { s__, sprintf } from '~/locale'; -import HeaderSearchApp from '~/header_search/components/app.vue'; -import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue'; -import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue'; -import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue'; -import { - SEARCH_INPUT_DESCRIPTION, - SEARCH_RESULTS_DESCRIPTION, - SEARCH_BOX_INDEX, - ICON_PROJECT, - ICON_GROUP, - ICON_SUBGROUP, - SCOPE_TOKEN_MAX_LENGTH, - IS_SEARCHING, - IS_NOT_FOCUSED, - IS_FOCUSED, - SEARCH_SHORTCUTS_MIN_CHARACTERS, - DROPDOWN_CLOSE_TIMEOUT, -} from '~/header_search/constants'; -import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; -import { ENTER_KEY } from '~/lib/utils/keys'; -import { visitUrl } from '~/lib/utils/url_utility'; -import { truncate } from '~/lib/utils/text_utility'; -import { - MOCK_SEARCH, - MOCK_SEARCH_QUERY, - MOCK_USERNAME, - MOCK_DEFAULT_SEARCH_OPTIONS, - MOCK_SCOPED_SEARCH_OPTIONS, - MOCK_SEARCH_CONTEXT_FULL, -} from '../mock_data'; - -Vue.use(Vuex); - -jest.mock('~/lib/utils/url_utility', () => ({ - visitUrl: jest.fn(), -})); - -describe('HeaderSearchApp', () => { - let wrapper; - - jest.useFakeTimers(); - jest.spyOn(global, 'setTimeout'); - - const actionSpies = { - setSearch: jest.fn(), - fetchAutocompleteOptions: jest.fn(), - clearAutocomplete: jest.fn(), - }; - - const createComponent = (initialState, mockGetters) => { - const store = new Vuex.Store({ - state: { - ...initialState, - }, - actions: actionSpies, - getters: { - searchQuery: () => MOCK_SEARCH_QUERY, - searchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS, - ...mockGetters, - }, - }); - - wrapper = shallowMountExtended(HeaderSearchApp, { - store, - }); - }; - - const formatScopeName = (scopeName) => { - if (!scopeName) { - return false; - } - const searchResultsScope = s__('GlobalSearch|in %{scope}'); - return truncate( - sprintf(searchResultsScope, { - scope: scopeName, - }), - SCOPE_TOKEN_MAX_LENGTH, - ); - }; - - const findHeaderSearchForm = () => wrapper.findByTestId('header-search-form'); - const findHeaderSearchInput = () => wrapper.findComponent(GlSearchBoxByType); - const findScopeToken = () => wrapper.findComponent(GlToken); - const findHeaderSearchInputKBD = () => wrapper.find('.keyboard-shortcut-helper'); - const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu'); - const findHeaderSearchDefaultItems = () => wrapper.findComponent(HeaderSearchDefaultItems); - const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems); - const findHeaderSearchAutocompleteItems = () => - wrapper.findComponent(HeaderSearchAutocompleteItems); - const findDropdownKeyboardNavigation = () => wrapper.findComponent(DropdownKeyboardNavigation); - const findSearchInputDescription = () => wrapper.find(`#${SEARCH_INPUT_DESCRIPTION}`); - const findSearchResultsDescription = () => wrapper.findByTestId(SEARCH_RESULTS_DESCRIPTION); - - describe('template', () => { - describe('always renders', () => { - beforeEach(() => { - createComponent(); - }); - - it('Header Search Input', () => { - expect(findHeaderSearchInput().exists()).toBe(true); - }); - - it('Header Search Input KBD hint', () => { - expect(findHeaderSearchInputKBD().exists()).toBe(true); - expect(findHeaderSearchInputKBD().text()).toContain('/'); - expect(findHeaderSearchInputKBD().attributes('title')).toContain( - 'Use the shortcut key <kbd>/</kbd> to start a search', - ); - }); - - it('Search Input Description', () => { - expect(findSearchInputDescription().exists()).toBe(true); - }); - - it('Search Results Description', () => { - expect(findSearchResultsDescription().exists()).toBe(true); - }); - }); - - describe.each` - showDropdown | username | showSearchDropdown - ${false} | ${null} | ${false} - ${false} | ${MOCK_USERNAME} | ${false} - ${true} | ${null} | ${false} - ${true} | ${MOCK_USERNAME} | ${true} - `('Header Search Dropdown', ({ showDropdown, username, showSearchDropdown }) => { - describe(`when showDropdown is ${showDropdown} and current_username is ${username}`, () => { - beforeEach(() => { - window.gon.current_username = username; - createComponent(); - findHeaderSearchInput().vm.$emit(showDropdown ? 'focusin' : ''); - }); - - it(`should${showSearchDropdown ? '' : ' not'} render`, () => { - expect(findHeaderSearchDropdown().exists()).toBe(showSearchDropdown); - }); - }); - }); - - describe.each` - search | showDefault | showScoped | showAutocomplete - ${null} | ${true} | ${false} | ${false} - ${''} | ${true} | ${false} | ${false} - ${'t'} | ${false} | ${false} | ${true} - ${'te'} | ${false} | ${false} | ${true} - ${'tes'} | ${false} | ${true} | ${true} - ${MOCK_SEARCH} | ${false} | ${true} | ${true} - `('Header Search Dropdown Items', ({ search, showDefault, showScoped, showAutocomplete }) => { - describe(`when search is ${search}`, () => { - beforeEach(() => { - window.gon.current_username = MOCK_USERNAME; - createComponent({ search }, {}); - findHeaderSearchInput().vm.$emit('focusin'); - }); - - it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => { - expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault); - }); - - it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => { - expect(findHeaderSearchScopedItems().exists()).toBe(showScoped); - }); - - it(`should${showAutocomplete ? '' : ' not'} render the Autocomplete Dropdown Items`, () => { - expect(findHeaderSearchAutocompleteItems().exists()).toBe(showAutocomplete); - }); - - it(`should render the Dropdown Navigation Component`, () => { - expect(findDropdownKeyboardNavigation().exists()).toBe(true); - }); - - it(`should close the dropdown when press escape key`, async () => { - findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: 27 })); - jest.runAllTimers(); - await nextTick(); - expect(findHeaderSearchDropdown().exists()).toBe(false); - expect(wrapper.emitted().expandSearchBar.length).toBe(1); - }); - }); - }); - - describe.each` - username | showDropdown | expectedDesc - ${null} | ${false} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN} - ${null} | ${true} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN} - ${MOCK_USERNAME} | ${false} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN} - ${MOCK_USERNAME} | ${true} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN} - `('Search Input Description', ({ username, showDropdown, expectedDesc }) => { - describe(`current_username is ${username} and showDropdown is ${showDropdown}`, () => { - beforeEach(() => { - window.gon.current_username = username; - createComponent(); - findHeaderSearchInput().vm.$emit(showDropdown ? 'focusin' : ''); - }); - - it(`sets description to ${expectedDesc}`, () => { - expect(findSearchInputDescription().text()).toBe(expectedDesc); - }); - }); - }); - - describe.each` - username | showDropdown | search | loading | searchOptions | expectedDesc - ${null} | ${true} | ${''} | ${false} | ${[]} | ${''} - ${MOCK_USERNAME} | ${false} | ${''} | ${false} | ${[]} | ${''} - ${MOCK_USERNAME} | ${true} | ${''} | ${false} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`} - ${MOCK_USERNAME} | ${true} | ${''} | ${true} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`} - ${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${false} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${`Results updated. ${MOCK_SCOPED_SEARCH_OPTIONS.length} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.`} - ${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${true} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${HeaderSearchApp.i18n.SEARCH_RESULTS_LOADING} - `( - 'Search Results Description', - ({ username, showDropdown, search, loading, searchOptions, expectedDesc }) => { - describe(`search is "${search}", loading is ${loading}, and showSearchDropdown is ${showDropdown}`, () => { - beforeEach(() => { - window.gon.current_username = username; - createComponent( - { - search, - loading, - }, - { - searchOptions: () => searchOptions, - }, - ); - findHeaderSearchInput().vm.$emit(showDropdown ? 'focusin' : ''); - }); - - it(`sets description to ${expectedDesc}`, () => { - expect(findSearchResultsDescription().text()).toBe(expectedDesc); - }); - }); - }, - ); - - describe('input box', () => { - describe.each` - search | searchOptions | hasToken - ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[0]]} | ${true} - ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[1]]} | ${true} - ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[2]]} | ${true} - ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[3]]} | ${true} - ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[4]]} | ${true} - ${'te'} | ${[MOCK_SCOPED_SEARCH_OPTIONS[5]]} | ${false} - ${'x'} | ${[]} | ${false} - `('token', ({ search, searchOptions, hasToken }) => { - beforeEach(() => { - window.gon.current_username = MOCK_USERNAME; - createComponent( - { search }, - { - searchOptions: () => searchOptions, - }, - ); - findHeaderSearchInput().vm.$emit('focusin'); - }); - - it(`${hasToken ? 'is' : 'is NOT'} rendered when data set has type "${ - searchOptions[0]?.html_id - }"`, () => { - expect(findScopeToken().exists()).toBe(hasToken); - }); - - it(`text ${hasToken ? 'is correctly' : 'is NOT'} rendered when text is "${ - searchOptions[0]?.scope || searchOptions[0]?.description - }"`, () => { - expect(findScopeToken().exists() && findScopeToken().text()).toBe( - formatScopeName(searchOptions[0]?.scope || searchOptions[0]?.description), - ); - }); - }); - }); - - describe('form', () => { - describe.each` - searchContext | search | searchOptions | isFocused - ${MOCK_SEARCH_CONTEXT_FULL} | ${null} | ${[]} | ${true} - ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${[]} | ${true} - ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${true} - ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${false} - ${null} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${true} - ${null} | ${null} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${true} - ${null} | ${null} | ${[]} | ${true} - `('wrapper', ({ searchContext, search, searchOptions, isFocused }) => { - beforeEach(() => { - window.gon.current_username = MOCK_USERNAME; - createComponent({ search, searchContext }, { searchOptions: () => searchOptions }); - if (isFocused) { - findHeaderSearchInput().vm.$emit('focusin'); - } - }); - - const isSearching = search?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS; - - it(`classes ${isSearching ? 'contain' : 'do not contain'} "${IS_SEARCHING}"`, () => { - if (isSearching) { - expect(findHeaderSearchForm().classes()).toContain(IS_SEARCHING); - return; - } - if (!isSearching) { - expect(findHeaderSearchForm().classes()).not.toContain(IS_SEARCHING); - } - }); - - it(`classes ${isSearching ? 'contain' : 'do not contain'} "${ - isFocused ? IS_FOCUSED : IS_NOT_FOCUSED - }"`, () => { - expect(findHeaderSearchForm().classes()).toContain( - isFocused ? IS_FOCUSED : IS_NOT_FOCUSED, - ); - }); - }); - }); - - describe.each` - search | searchOptions | hasIcon | iconName - ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[0]]} | ${true} | ${ICON_PROJECT} - ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[2]]} | ${true} | ${ICON_GROUP} - ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[3]]} | ${true} | ${ICON_SUBGROUP} - ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[4]]} | ${false} | ${false} - `('token', ({ search, searchOptions, hasIcon, iconName }) => { - beforeEach(() => { - window.gon.current_username = MOCK_USERNAME; - createComponent( - { search }, - { - searchOptions: () => searchOptions, - }, - ); - findHeaderSearchInput().vm.$emit('focusin'); - }); - - it(`icon for data set type "${searchOptions[0]?.html_id}" ${ - hasIcon ? 'is' : 'is NOT' - } rendered`, () => { - expect(findScopeToken().findComponent(GlIcon).exists()).toBe(hasIcon); - }); - - it(`render ${iconName ? `"${iconName}"` : 'NO'} icon for data set type "${ - searchOptions[0]?.html_id - }"`, () => { - expect( - findScopeToken().findComponent(GlIcon).exists() && - findScopeToken().findComponent(GlIcon).attributes('name'), - ).toBe(iconName); - }); - }); - }); - - describe('events', () => { - describe('Header Search Input', () => { - beforeEach(() => { - window.gon.current_username = MOCK_USERNAME; - createComponent(); - }); - - describe('when dropdown is closed', () => { - let trackingSpy; - - beforeEach(() => { - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - }); - - it('onFocusin opens dropdown and triggers snowplow event', async () => { - expect(findHeaderSearchDropdown().exists()).toBe(false); - findHeaderSearchInput().vm.$emit('focusin'); - - await nextTick(); - - expect(findHeaderSearchDropdown().exists()).toBe(true); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'focus_input', { - label: 'global_search', - property: 'navigation_top', - }); - }); - - it('onFocusout closes dropdown and triggers snowplow event', async () => { - expect(findHeaderSearchDropdown().exists()).toBe(false); - - findHeaderSearchInput().vm.$emit('focusout'); - jest.runAllTimers(); - await nextTick(); - - expect(findHeaderSearchDropdown().exists()).toBe(false); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'blur_input', { - label: 'global_search', - property: 'navigation_top', - }); - }); - }); - - describe('onInput', () => { - describe('when search has text', () => { - beforeEach(() => { - findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH); - }); - - it('calls setSearch with search term', () => { - expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), MOCK_SEARCH); - }); - - it('calls fetchAutocompleteOptions', () => { - expect(actionSpies.fetchAutocompleteOptions).toHaveBeenCalled(); - }); - - it('does not call clearAutocomplete', () => { - expect(actionSpies.clearAutocomplete).not.toHaveBeenCalled(); - }); - }); - - describe('when search is emptied', () => { - beforeEach(() => { - findHeaderSearchInput().vm.$emit('input', ''); - }); - - it('calls setSearch with empty term', () => { - expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), ''); - }); - - it('does not call fetchAutocompleteOptions', () => { - expect(actionSpies.fetchAutocompleteOptions).not.toHaveBeenCalled(); - }); - - it('calls clearAutocomplete', () => { - expect(actionSpies.clearAutocomplete).toHaveBeenCalled(); - }); - }); - }); - }); - - describe('onFocusout dropdown', () => { - beforeEach(() => { - window.gon.current_username = MOCK_USERNAME; - createComponent({ search: 'tes' }, {}); - findHeaderSearchInput().vm.$emit('focusin'); - }); - - it('closes with timeout so click event gets emited', () => { - findHeaderSearchInput().vm.$emit('focusout'); - - expect(setTimeout).toHaveBeenCalledTimes(1); - expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), DROPDOWN_CLOSE_TIMEOUT); - }); - }); - }); - - describe('computed', () => { - describe.each` - MOCK_INDEX | search - ${1} | ${null} - ${SEARCH_BOX_INDEX} | ${'test'} - ${2} | ${'test1'} - `('currentFocusedOption', ({ MOCK_INDEX, search }) => { - beforeEach(() => { - window.gon.current_username = MOCK_USERNAME; - createComponent({ search }); - findHeaderSearchInput().vm.$emit('focusin'); - }); - - it(`when currentFocusIndex changes to ${MOCK_INDEX} updates the data to searchOptions[${MOCK_INDEX}]`, () => { - findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX); - expect(wrapper.vm.currentFocusedOption).toBe(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX]); - }); - }); - }); - - describe('Submitting a search', () => { - describe('with no currentFocusedOption', () => { - beforeEach(() => { - createComponent(); - }); - - it('onKey-enter submits a search', () => { - findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); - - expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY); - }); - }); - - describe('with less than min characters and no dropdown results', () => { - beforeEach(() => { - createComponent({ search: 'x' }); - }); - - it('onKey-enter will NOT submit a search', () => { - findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); - - expect(visitUrl).not.toHaveBeenCalledWith(MOCK_SEARCH_QUERY); - }); - }); - - describe('with currentFocusedOption', () => { - const MOCK_INDEX = 1; - - beforeEach(() => { - window.gon.current_username = MOCK_USERNAME; - createComponent(); - findHeaderSearchInput().vm.$emit('focusin'); - }); - - it('onKey-enter clicks the selected dropdown item rather than submitting a search', async () => { - await nextTick(); - findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX); - - findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); - expect(visitUrl).toHaveBeenCalledWith(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX].url); - }); - }); - }); -}); diff --git a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js deleted file mode 100644 index 868edb3e651..00000000000 --- a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js +++ /dev/null @@ -1,236 +0,0 @@ -import { GlDropdownItem, GlLoadingIcon, GlAvatar, GlAlert, GlDropdownDivider } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue'; -import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '~/header_search/constants'; -import { - PROJECTS_CATEGORY, - GROUPS_CATEGORY, - ISSUES_CATEGORY, - MERGE_REQUEST_CATEGORY, - RECENT_EPICS_CATEGORY, -} from '~/vue_shared/global_search/constants'; -import { - MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, - MOCK_SORTED_AUTOCOMPLETE_OPTIONS, - MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP, - MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP, - MOCK_SEARCH, - MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2, -} from '../mock_data'; - -Vue.use(Vuex); - -describe('HeaderSearchAutocompleteItems', () => { - let wrapper; - - const createComponent = (initialState, mockGetters, props) => { - const store = new Vuex.Store({ - state: { - loading: false, - ...initialState, - }, - getters: { - autocompleteGroupedSearchOptions: () => MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, - ...mockGetters, - }, - }); - - wrapper = shallowMount(HeaderSearchAutocompleteItems, { - store, - propsData: { - ...props, - }, - }); - }; - - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findGlDropdownDividers = () => wrapper.findAllComponents(GlDropdownDivider); - const findFirstDropdownItem = () => findDropdownItems().at(0); - const findDropdownItemTitles = () => - findDropdownItems().wrappers.map((w) => w.findAll('span').at(1).text()); - const findDropdownItemSubTitles = () => - findDropdownItems() - .wrappers.filter((w) => w.findAll('span').length > 2) - .map((w) => w.findAll('span').at(2).text()); - const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); - const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findGlAvatar = () => wrapper.findComponent(GlAvatar); - const findGlAlert = () => wrapper.findComponent(GlAlert); - - describe('template', () => { - describe('when loading is true', () => { - beforeEach(() => { - createComponent({ loading: true }); - }); - - it('renders GlLoadingIcon', () => { - expect(findGlLoadingIcon().exists()).toBe(true); - }); - - it('does not render autocomplete options', () => { - expect(findDropdownItems()).toHaveLength(0); - }); - }); - - describe('when api returns error', () => { - beforeEach(() => { - createComponent({ autocompleteError: true }); - }); - - it('renders Alert', () => { - expect(findGlAlert().exists()).toBe(true); - }); - }); - describe('when loading is false', () => { - beforeEach(() => { - createComponent({ loading: false }); - }); - - it('does not render GlLoadingIcon', () => { - expect(findGlLoadingIcon().exists()).toBe(false); - }); - - describe('Dropdown items', () => { - it('renders item for each option in autocomplete option', () => { - expect(findDropdownItems()).toHaveLength(MOCK_SORTED_AUTOCOMPLETE_OPTIONS.length); - }); - - it('renders titles correctly', () => { - const expectedTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.value || o.label); - expect(findDropdownItemTitles()).toStrictEqual(expectedTitles); - }); - - it('renders sub-titles correctly', () => { - const expectedSubTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.filter((o) => o.value).map( - (o) => o.label, - ); - expect(findDropdownItemSubTitles()).toStrictEqual(expectedSubTitles); - }); - - it('renders links correctly', () => { - const expectedLinks = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.url); - expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); - }); - }); - - describe.each` - item | showAvatar | avatarSize | searchContext | entityId | entityName - ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 29 } }} | ${'29'} | ${''} - ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: '/123' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 12 } }} | ${'12'} | ${''} - ${{ data: [{ category: 'Help', avatar_url: '' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'0'} | ${''} - ${{ data: [{ category: 'Settings' }] }} | ${false} | ${false} | ${null} | ${false} | ${false} - ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 1, name: 'test1' } }} | ${'1'} | ${'test1'} - ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 2, name: 'test2' } }} | ${'2'} | ${'test2'} - ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 3, name: 'test3' } }} | ${'3'} | ${'test3'} - ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 4, name: 'test4' } }} | ${'4'} | ${'test4'} - ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ group: { id: 5, name: 'test5' } }} | ${'5'} | ${'test5'} - ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null, group_id: 6, group_name: 'test6' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${null} | ${'6'} | ${'test6'} - ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null, project_id: 7, project_name: 'test7' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${null} | ${'7'} | ${'test7'} - ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null, project_id: 8, project_name: 'test8' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'8'} | ${'test8'} - ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null, project_id: 9, project_name: 'test9' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'9'} | ${'test9'} - ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null, group_id: 10, group_name: 'test10' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'10'} | ${'test10'} - ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null, group_id: 11, group_name: 'test11' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 1, name: 'test1' } }} | ${'11'} | ${'test11'} - ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null, project_id: 12, project_name: 'test12' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 2, name: 'test2' } }} | ${'12'} | ${'test12'} - ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null, project_id: 13, project_name: 'test13' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 3, name: 'test3' } }} | ${'13'} | ${'test13'} - ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null, project_id: 14, project_name: 'test14' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 4, name: 'test4' } }} | ${'14'} | ${'test14'} - ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null, group_id: 15, group_name: 'test15' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ group: { id: 5, name: 'test5' } }} | ${'15'} | ${'test15'} - `('GlAvatar', ({ item, showAvatar, avatarSize, searchContext, entityId, entityName }) => { - describe(`when category is ${item.data[0].category} and avatar_url is ${item.data[0].avatar_url}`, () => { - beforeEach(() => { - createComponent({ searchContext }, { autocompleteGroupedSearchOptions: () => [item] }); - }); - - it(`should${showAvatar ? '' : ' not'} render`, () => { - expect(findGlAvatar().exists()).toBe(showAvatar); - }); - - it(`should set avatarSize to ${avatarSize}`, () => { - expect(findGlAvatar().exists() && findGlAvatar().attributes('size')).toBe(avatarSize); - }); - - it(`should set avatar entityId to ${entityId}`, () => { - expect(findGlAvatar().exists() && findGlAvatar().attributes('entityid')).toBe(entityId); - }); - - it(`should set avatar entityName to ${entityName}`, () => { - expect(findGlAvatar().exists() && findGlAvatar().attributes('entityname')).toBe( - entityName, - ); - }); - }); - }); - }); - - describe.each` - currentFocusedOption | isFocused | ariaSelected - ${null} | ${false} | ${undefined} - ${{ html_id: 'not-a-match' }} | ${false} | ${undefined} - ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS[0]} | ${true} | ${'true'} - `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => { - describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => { - beforeEach(() => { - createComponent({}, {}, { currentFocusedOption }); - }); - - it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => { - expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused); - }); - - it(`sets "aria-selected to ${ariaSelected}`, () => { - expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected); - }); - }); - }); - - describe.each` - search | items | dividerCount - ${null} | ${[]} | ${0} - ${''} | ${[]} | ${0} - ${'1'} | ${[]} | ${0} - ${')'} | ${[]} | ${0} - ${'t'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP} | ${1} - ${'te'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP} | ${0} - ${'tes'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2} | ${1} - ${MOCK_SEARCH} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2} | ${1} - `('Header Search Dropdown Dividers', ({ search, items, dividerCount }) => { - describe(`when search is ${search}`, () => { - beforeEach(() => { - createComponent( - { search }, - { - autocompleteGroupedSearchOptions: () => items, - }, - {}, - ); - }); - - it(`component should have ${dividerCount} dividers`, () => { - expect(findGlDropdownDividers()).toHaveLength(dividerCount); - }); - }); - }); - }); - - describe('watchers', () => { - describe('currentFocusedOption', () => { - beforeEach(() => { - createComponent(); - }); - - it('when focused changes to existing element calls scroll into view on the newly focused element', async () => { - const focusedElement = findFirstDropdownItem().element; - const scrollSpy = jest.spyOn(focusedElement, 'scrollIntoView'); - - wrapper.setProps({ currentFocusedOption: MOCK_SORTED_AUTOCOMPLETE_OPTIONS[0] }); - - await nextTick(); - - expect(scrollSpy).toHaveBeenCalledWith(false); - scrollSpy.mockRestore(); - }); - }); - }); -}); diff --git a/spec/frontend/header_search/components/header_search_default_items_spec.js b/spec/frontend/header_search/components/header_search_default_items_spec.js deleted file mode 100644 index acaad251bec..00000000000 --- a/spec/frontend/header_search/components/header_search_default_items_spec.js +++ /dev/null @@ -1,103 +0,0 @@ -import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue'; -import { MOCK_SEARCH_CONTEXT, MOCK_DEFAULT_SEARCH_OPTIONS } from '../mock_data'; - -Vue.use(Vuex); - -describe('HeaderSearchDefaultItems', () => { - let wrapper; - - const createComponent = (initialState, props) => { - const store = new Vuex.Store({ - state: { - searchContext: MOCK_SEARCH_CONTEXT, - ...initialState, - }, - getters: { - defaultSearchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS, - }, - }); - - wrapper = shallowMount(HeaderSearchDefaultItems, { - store, - propsData: { - ...props, - }, - }); - }; - - const findDropdownHeader = () => wrapper.findComponent(GlDropdownSectionHeader); - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findFirstDropdownItem = () => findDropdownItems().at(0); - const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => w.text()); - const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); - - describe('template', () => { - describe('Dropdown items', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders item for each option in defaultSearchOptions', () => { - expect(findDropdownItems()).toHaveLength(MOCK_DEFAULT_SEARCH_OPTIONS.length); - }); - - it('renders titles correctly', () => { - const expectedTitles = MOCK_DEFAULT_SEARCH_OPTIONS.map((o) => o.title); - expect(findDropdownItemTitles()).toStrictEqual(expectedTitles); - }); - - it('renders links correctly', () => { - const expectedLinks = MOCK_DEFAULT_SEARCH_OPTIONS.map((o) => o.url); - expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); - }); - }); - - describe.each` - group | project | dropdownTitle - ${null} | ${null} | ${'All GitLab'} - ${{ name: 'Test Group' }} | ${null} | ${'Test Group'} - ${{ name: 'Test Group' }} | ${{ name: 'Test Project' }} | ${'Test Project'} - `('Dropdown Header', ({ group, project, dropdownTitle }) => { - describe(`when group is ${group?.name} and project is ${project?.name}`, () => { - beforeEach(() => { - createComponent({ - searchContext: { - group, - project, - }, - }); - }); - - it(`should render as ${dropdownTitle}`, () => { - expect(findDropdownHeader().text()).toBe(dropdownTitle); - }); - }); - }); - - describe.each` - currentFocusedOption | isFocused | ariaSelected - ${null} | ${false} | ${undefined} - ${{ html_id: 'not-a-match' }} | ${false} | ${undefined} - ${MOCK_DEFAULT_SEARCH_OPTIONS[0]} | ${true} | ${'true'} - `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => { - describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => { - beforeEach(() => { - createComponent({}, { currentFocusedOption }); - }); - - it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => { - expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused); - }); - - it(`sets "aria-selected to ${ariaSelected}`, () => { - expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected); - }); - }); - }); - }); -}); diff --git a/spec/frontend/header_search/components/header_search_scoped_items_spec.js b/spec/frontend/header_search/components/header_search_scoped_items_spec.js deleted file mode 100644 index 78ea148caac..00000000000 --- a/spec/frontend/header_search/components/header_search_scoped_items_spec.js +++ /dev/null @@ -1,121 +0,0 @@ -import { GlDropdownItem, GlToken, GlIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import { trimText } from 'helpers/text_helper'; -import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue'; -import { truncate } from '~/lib/utils/text_utility'; -import { SCOPE_TOKEN_MAX_LENGTH } from '~/header_search/constants'; -import { MSG_IN_ALL_GITLAB } from '~/vue_shared/global_search/constants'; -import { - MOCK_SEARCH, - MOCK_SCOPED_SEARCH_OPTIONS, - MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, -} from '../mock_data'; - -Vue.use(Vuex); - -describe('HeaderSearchScopedItems', () => { - let wrapper; - - const createComponent = (initialState, mockGetters, props) => { - const store = new Vuex.Store({ - state: { - search: MOCK_SEARCH, - ...initialState, - }, - getters: { - scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS, - autocompleteGroupedSearchOptions: () => MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, - ...mockGetters, - }, - }); - - wrapper = shallowMount(HeaderSearchScopedItems, { - store, - propsData: { - ...props, - }, - }); - }; - - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findFirstDropdownItem = () => findDropdownItems().at(0); - const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text())); - const findScopeTokens = () => wrapper.findAllComponents(GlToken); - const findScopeTokensText = () => findScopeTokens().wrappers.map((w) => trimText(w.text())); - const findScopeTokensIcons = () => - findScopeTokens().wrappers.map((w) => w.findAllComponents(GlIcon)); - const findDropdownItemAriaLabels = () => - findDropdownItems().wrappers.map((w) => trimText(w.attributes('aria-label'))); - const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); - - describe('template', () => { - describe('Dropdown items', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders item for each option in scopedSearchOptions', () => { - expect(findDropdownItems()).toHaveLength(MOCK_SCOPED_SEARCH_OPTIONS.length); - }); - - it('renders titles correctly', () => { - findDropdownItemTitles().forEach((title) => expect(title).toContain(MOCK_SEARCH)); - }); - - it('renders scope names correctly', () => { - const expectedTitles = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => - truncate(trimText(`in ${o.description || o.scope}`), SCOPE_TOKEN_MAX_LENGTH), - ); - - expect(findScopeTokensText()).toStrictEqual(expectedTitles); - }); - - it('renders scope icons correctly', () => { - findScopeTokensIcons().forEach((icon, i) => { - const w = icon.wrappers[0]; - expect(w?.attributes('name')).toBe(MOCK_SCOPED_SEARCH_OPTIONS[i].icon); - }); - }); - - it(`renders scope ${MSG_IN_ALL_GITLAB} correctly`, () => { - expect(findScopeTokens().at(-1).findComponent(GlIcon).exists()).toBe(false); - }); - - it('renders aria-labels correctly', () => { - const expectedLabels = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => - trimText(`${MOCK_SEARCH} ${o.description || o.icon} ${o.scope || ''}`), - ); - expect(findDropdownItemAriaLabels()).toStrictEqual(expectedLabels); - }); - - it('renders links correctly', () => { - const expectedLinks = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => o.url); - expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); - }); - }); - - describe.each` - currentFocusedOption | isFocused | ariaSelected - ${null} | ${false} | ${undefined} - ${{ html_id: 'not-a-match' }} | ${false} | ${undefined} - ${MOCK_SCOPED_SEARCH_OPTIONS[0]} | ${true} | ${'true'} - `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => { - describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => { - beforeEach(() => { - createComponent({}, {}, { currentFocusedOption }); - }); - - it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => { - expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused); - }); - - it(`sets "aria-selected to ${ariaSelected}`, () => { - expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected); - }); - }); - }); - }); -}); diff --git a/spec/frontend/header_search/init_spec.js b/spec/frontend/header_search/init_spec.js deleted file mode 100644 index 459ca33ee66..00000000000 --- a/spec/frontend/header_search/init_spec.js +++ /dev/null @@ -1,54 +0,0 @@ -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; - -import initHeaderSearch, { eventHandler, cleanEventListeners } from '~/header_search/init'; - -describe('Header Search EventListener', () => { - beforeEach(() => { - jest.resetModules(); - setHTMLFixture(` - <div class="js-header-content"> - <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>`); - }); - - afterEach(() => { - resetHTMLFixture(); - }); - - it('attached event listener', () => { - const searchInputBox = document?.querySelector('#search'); - const addEventListenerSpy = jest.spyOn(searchInputBox, 'addEventListener'); - initHeaderSearch(); - - expect(addEventListenerSpy).toHaveBeenCalledTimes(2); - }); - - it('removes event listener', async () => { - const searchInputBox = document?.querySelector('#search'); - const removeEventListenerSpy = jest.spyOn(searchInputBox, 'removeEventListener'); - jest.mock('~/header_search', () => ({ initHeaderSearchApp: jest.fn() })); - await eventHandler.apply( - { - searchInputBox: document.querySelector('#search'), - }, - [cleanEventListeners], - ); - - expect(removeEventListenerSpy).toHaveBeenCalledTimes(2); - }); - - it('attaches new vue dropdown when feature flag is enabled', async () => { - const mockVueApp = jest.fn(); - jest.mock('~/header_search', () => ({ initHeaderSearchApp: mockVueApp })); - await eventHandler.apply( - { - searchInputBox: document.querySelector('#search'), - }, - () => {}, - ); - - expect(mockVueApp).toHaveBeenCalled(); - }); -}); diff --git a/spec/frontend/header_search/mock_data.js b/spec/frontend/header_search/mock_data.js deleted file mode 100644 index 2218c81efc3..00000000000 --- a/spec/frontend/header_search/mock_data.js +++ /dev/null @@ -1,400 +0,0 @@ -import { ICON_PROJECT, ICON_GROUP, ICON_SUBGROUP } from '~/header_search/constants'; -import { - PROJECTS_CATEGORY, - GROUPS_CATEGORY, - MSG_ISSUES_ASSIGNED_TO_ME, - MSG_ISSUES_IVE_CREATED, - MSG_MR_ASSIGNED_TO_ME, - MSG_MR_IM_REVIEWER, - MSG_MR_IVE_CREATED, - MSG_IN_ALL_GITLAB, -} from '~/vue_shared/global_search/constants'; - -export const MOCK_USERNAME = 'anyone'; - -export const MOCK_SEARCH_PATH = '/search'; - -export const MOCK_ISSUE_PATH = '/dashboard/issues'; - -export const MOCK_MR_PATH = '/dashboard/merge_requests'; - -export const MOCK_ALL_PATH = '/'; - -export const MOCK_AUTOCOMPLETE_PATH = '/autocomplete'; - -export const MOCK_PROJECT = { - id: 123, - name: 'MockProject', - path: '/mock-project', -}; - -export const MOCK_PROJECT_LONG = { - id: 124, - name: 'Mock Project Name That Is Ridiculously Long And It Goes Forever', - path: '/mock-project-name-that-is-ridiculously-long-and-it-goes-forever', -}; - -export const MOCK_GROUP = { - id: 321, - name: 'MockGroup', - path: '/mock-group', -}; - -export const MOCK_SUBGROUP = { - id: 322, - name: 'MockSubGroup', - path: `${MOCK_GROUP}/mock-subgroup`, -}; - -export const MOCK_SEARCH_QUERY = 'http://gitlab.com/search?search=test'; - -export const MOCK_SEARCH = 'test'; - -export const MOCK_SEARCH_CONTEXT = { - project: null, - project_metadata: {}, - group: null, - group_metadata: {}, -}; - -export const MOCK_SEARCH_CONTEXT_FULL = { - group: { - id: 31, - name: 'testGroup', - full_name: 'testGroup', - }, - group_metadata: { - group_path: 'testGroup', - name: 'testGroup', - issues_path: '/groups/testGroup/-/issues', - mr_path: '/groups/testGroup/-/merge_requests', - }, -}; - -export const MOCK_DEFAULT_SEARCH_OPTIONS = [ - { - html_id: 'default-issues-assigned', - title: MSG_ISSUES_ASSIGNED_TO_ME, - url: `${MOCK_ISSUE_PATH}/?assignee_username=${MOCK_USERNAME}`, - }, - { - html_id: 'default-issues-created', - title: MSG_ISSUES_IVE_CREATED, - url: `${MOCK_ISSUE_PATH}/?author_username=${MOCK_USERNAME}`, - }, - { - html_id: 'default-mrs-assigned', - title: MSG_MR_ASSIGNED_TO_ME, - url: `${MOCK_MR_PATH}/?assignee_username=${MOCK_USERNAME}`, - }, - { - html_id: 'default-mrs-reviewer', - title: MSG_MR_IM_REVIEWER, - url: `${MOCK_MR_PATH}/?reviewer_username=${MOCK_USERNAME}`, - }, - { - html_id: 'default-mrs-created', - title: MSG_MR_IVE_CREATED, - url: `${MOCK_MR_PATH}/?author_username=${MOCK_USERNAME}`, - }, -]; - -export const MOCK_SCOPED_SEARCH_OPTIONS = [ - { - html_id: 'scoped-in-project', - scope: MOCK_PROJECT.name, - scopeCategory: PROJECTS_CATEGORY, - icon: ICON_PROJECT, - url: MOCK_PROJECT.path, - }, - { - html_id: 'scoped-in-project-long', - scope: MOCK_PROJECT_LONG.name, - scopeCategory: PROJECTS_CATEGORY, - icon: ICON_PROJECT, - url: MOCK_PROJECT_LONG.path, - }, - { - html_id: 'scoped-in-group', - scope: MOCK_GROUP.name, - scopeCategory: GROUPS_CATEGORY, - icon: ICON_GROUP, - url: MOCK_GROUP.path, - }, - { - html_id: 'scoped-in-subgroup', - scope: MOCK_SUBGROUP.name, - scopeCategory: GROUPS_CATEGORY, - icon: ICON_SUBGROUP, - url: MOCK_SUBGROUP.path, - }, - { - html_id: 'scoped-in-all', - description: MSG_IN_ALL_GITLAB, - url: MOCK_ALL_PATH, - }, -]; - -export const MOCK_SCOPED_SEARCH_OPTIONS_DEF = [ - { - html_id: 'scoped-in-project', - scope: MOCK_PROJECT.name, - scopeCategory: PROJECTS_CATEGORY, - icon: ICON_PROJECT, - url: MOCK_PROJECT.path, - }, - { - html_id: 'scoped-in-group', - scope: MOCK_GROUP.name, - scopeCategory: GROUPS_CATEGORY, - icon: ICON_GROUP, - url: MOCK_GROUP.path, - }, - { - html_id: 'scoped-in-all', - description: MSG_IN_ALL_GITLAB, - url: MOCK_ALL_PATH, - }, -]; - -export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [ - { - category: 'Projects', - id: 1, - label: 'Gitlab Org / MockProject1', - value: 'MockProject1', - url: 'project/1', - }, - { - category: 'Groups', - id: 1, - label: 'Gitlab Org / MockGroup1', - value: 'MockGroup1', - url: 'group/1', - }, - { - category: 'Projects', - id: 2, - label: 'Gitlab Org / MockProject2', - value: 'MockProject2', - url: 'project/2', - }, - { - category: 'Help', - label: 'GitLab Help', - url: 'help/gitlab', - }, -]; - -export const MOCK_AUTOCOMPLETE_OPTIONS = [ - { - category: 'Projects', - html_id: 'autocomplete-Projects-0', - id: 1, - label: 'Gitlab Org / MockProject1', - value: 'MockProject1', - url: 'project/1', - }, - { - category: 'Groups', - html_id: 'autocomplete-Groups-1', - id: 1, - label: 'Gitlab Org / MockGroup1', - value: 'MockGroup1', - url: 'group/1', - }, - { - category: 'Projects', - html_id: 'autocomplete-Projects-2', - id: 2, - label: 'Gitlab Org / MockProject2', - value: 'MockProject2', - url: 'project/2', - }, - { - category: 'Help', - html_id: 'autocomplete-Help-3', - label: 'GitLab Help', - url: 'help/gitlab', - }, -]; - -export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ - { - category: 'Groups', - data: [ - { - category: 'Groups', - html_id: 'autocomplete-Groups-1', - - id: 1, - label: 'Gitlab Org / MockGroup1', - value: 'MockGroup1', - url: 'group/1', - }, - ], - }, - { - category: 'Projects', - data: [ - { - category: 'Projects', - html_id: 'autocomplete-Projects-0', - - id: 1, - label: 'Gitlab Org / MockProject1', - value: 'MockProject1', - url: 'project/1', - }, - { - category: 'Projects', - html_id: 'autocomplete-Projects-2', - - id: 2, - label: 'Gitlab Org / MockProject2', - value: 'MockProject2', - url: 'project/2', - }, - ], - }, - { - category: 'Help', - data: [ - { - category: 'Help', - html_id: 'autocomplete-Help-3', - - label: 'GitLab Help', - url: 'help/gitlab', - }, - ], - }, -]; - -export const MOCK_SORTED_AUTOCOMPLETE_OPTIONS = [ - { - category: 'Groups', - html_id: 'autocomplete-Groups-1', - id: 1, - label: 'Gitlab Org / MockGroup1', - value: 'MockGroup1', - url: 'group/1', - }, - { - category: 'Projects', - html_id: 'autocomplete-Projects-0', - id: 1, - label: 'Gitlab Org / MockProject1', - value: 'MockProject1', - url: 'project/1', - }, - { - category: 'Projects', - html_id: 'autocomplete-Projects-2', - id: 2, - label: 'Gitlab Org / MockProject2', - value: 'MockProject2', - url: 'project/2', - }, - { - category: 'Help', - html_id: 'autocomplete-Help-3', - label: 'GitLab Help', - url: 'help/gitlab', - }, -]; - -export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP = [ - { - category: 'Help', - data: [ - { - html_id: 'autocomplete-Help-1', - category: 'Help', - label: 'Rake Tasks Help', - url: '/help/raketasks/index', - }, - { - html_id: 'autocomplete-Help-2', - category: 'Help', - label: 'System Hooks Help', - url: '/help/system_hooks/system_hooks', - }, - ], - }, -]; - -export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP = [ - { - category: 'Settings', - data: [ - { - html_id: 'autocomplete-Settings-0', - category: 'Settings', - label: 'User settings', - url: '/-/profile', - }, - { - html_id: 'autocomplete-Settings-3', - category: 'Settings', - label: 'Admin Section', - url: '/admin', - }, - ], - }, - { - category: 'Help', - data: [ - { - html_id: 'autocomplete-Help-1', - category: 'Help', - label: 'Rake Tasks Help', - url: '/help/raketasks/index', - }, - { - html_id: 'autocomplete-Help-2', - category: 'Help', - label: 'System Hooks Help', - url: '/help/system_hooks/system_hooks', - }, - ], - }, -]; - -export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2 = [ - { - category: 'Groups', - data: [ - { - html_id: 'autocomplete-Groups-0', - category: 'Groups', - id: 148, - label: 'Jashkenas / Test Subgroup / test-subgroup', - url: '/jashkenas/test-subgroup/test-subgroup', - avatar_url: '', - }, - { - html_id: 'autocomplete-Groups-1', - category: 'Groups', - id: 147, - label: 'Jashkenas / Test Subgroup', - url: '/jashkenas/test-subgroup', - avatar_url: '', - }, - ], - }, - { - category: 'Projects', - data: [ - { - html_id: 'autocomplete-Projects-2', - category: 'Projects', - id: 1, - value: 'Gitlab Test', - label: 'Gitlab Org / Gitlab Test', - url: '/gitlab-org/gitlab-test', - avatar_url: '/uploads/-/system/project/avatar/1/icons8-gitlab-512.png', - }, - ], - }, -]; diff --git a/spec/frontend/header_search/store/actions_spec.js b/spec/frontend/header_search/store/actions_spec.js deleted file mode 100644 index 95a619ebeca..00000000000 --- a/spec/frontend/header_search/store/actions_spec.js +++ /dev/null @@ -1,113 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import testAction from 'helpers/vuex_action_helper'; -import * as actions from '~/header_search/store/actions'; -import * as types from '~/header_search/store/mutation_types'; -import initState from '~/header_search/store/state'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import { - MOCK_SEARCH, - MOCK_AUTOCOMPLETE_OPTIONS_RES, - MOCK_AUTOCOMPLETE_PATH, - MOCK_PROJECT, - MOCK_SEARCH_CONTEXT, - MOCK_SEARCH_PATH, - MOCK_MR_PATH, - MOCK_ISSUE_PATH, -} from '../mock_data'; - -jest.mock('~/alert'); - -describe('Header Search Store Actions', () => { - let state; - let mock; - - const createState = (initialState) => - initState({ - searchPath: MOCK_SEARCH_PATH, - issuesPath: MOCK_ISSUE_PATH, - mrPath: MOCK_MR_PATH, - autocompletePath: MOCK_AUTOCOMPLETE_PATH, - searchContext: MOCK_SEARCH_CONTEXT, - ...initialState, - }); - - afterEach(() => { - state = null; - mock.restore(); - }); - - describe.each` - axiosMock | type | expectedMutations - ${{ method: 'onGet', code: HTTP_STATUS_OK, res: MOCK_AUTOCOMPLETE_OPTIONS_RES }} | ${'success'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_SUCCESS, payload: MOCK_AUTOCOMPLETE_OPTIONS_RES }, { type: types.RECEIVE_AUTOCOMPLETE_SUCCESS, payload: MOCK_AUTOCOMPLETE_OPTIONS_RES }]} - ${{ method: 'onGet', code: HTTP_STATUS_INTERNAL_SERVER_ERROR, res: null }} | ${'error'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_ERROR }, { type: types.RECEIVE_AUTOCOMPLETE_ERROR }]} - `('fetchAutocompleteOptions', ({ axiosMock, type, expectedMutations }) => { - describe(`on ${type}`, () => { - beforeEach(() => { - state = createState({}); - mock = new MockAdapter(axios); - mock[axiosMock.method]().reply(axiosMock.code, axiosMock.res); - }); - it(`should dispatch the correct mutations`, () => { - return testAction({ - action: actions.fetchAutocompleteOptions, - state, - expectedMutations, - }); - }); - }); - }); - - describe.each` - project | ref | fetchType | expectedPath - ${null} | ${null} | ${null} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}`} - ${MOCK_PROJECT} | ${null} | ${'generic'} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=${MOCK_PROJECT.id}&filter=generic`} - ${null} | ${MOCK_PROJECT.id} | ${'generic'} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_ref=${MOCK_PROJECT.id}&filter=generic`} - ${MOCK_PROJECT} | ${MOCK_PROJECT.id} | ${'search'} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=${MOCK_PROJECT.id}&project_ref=${MOCK_PROJECT.id}&filter=search`} - `('autocompleteQuery', ({ project, ref, fetchType, expectedPath }) => { - describe(`when project is ${project?.name} and project ref is ${ref}`, () => { - beforeEach(() => { - state = createState({ - search: MOCK_SEARCH, - searchContext: { - project, - ref, - }, - }); - }); - - it(`should return ${expectedPath}`, () => { - expect(actions.autocompleteQuery({ state, fetchType })).toBe(expectedPath); - }); - }); - }); - - describe('clearAutocomplete', () => { - beforeEach(() => { - state = createState({}); - }); - - it('calls the CLEAR_AUTOCOMPLETE mutation', () => { - return testAction({ - action: actions.clearAutocomplete, - state, - expectedMutations: [{ type: types.CLEAR_AUTOCOMPLETE }], - }); - }); - }); - - describe('setSearch', () => { - beforeEach(() => { - state = createState({}); - }); - - it('calls the SET_SEARCH mutation', () => { - return testAction({ - action: actions.setSearch, - payload: MOCK_SEARCH, - state, - expectedMutations: [{ type: types.SET_SEARCH, payload: MOCK_SEARCH }], - }); - }); - }); -}); diff --git a/spec/frontend/header_search/store/getters_spec.js b/spec/frontend/header_search/store/getters_spec.js deleted file mode 100644 index 7a7a00178f1..00000000000 --- a/spec/frontend/header_search/store/getters_spec.js +++ /dev/null @@ -1,333 +0,0 @@ -import * as getters from '~/header_search/store/getters'; -import initState from '~/header_search/store/state'; -import { - MOCK_USERNAME, - MOCK_SEARCH_PATH, - MOCK_ISSUE_PATH, - MOCK_MR_PATH, - MOCK_AUTOCOMPLETE_PATH, - MOCK_SEARCH_CONTEXT, - MOCK_DEFAULT_SEARCH_OPTIONS, - MOCK_SCOPED_SEARCH_OPTIONS, - MOCK_SCOPED_SEARCH_OPTIONS_DEF, - MOCK_PROJECT, - MOCK_GROUP, - MOCK_ALL_PATH, - MOCK_SEARCH, - MOCK_AUTOCOMPLETE_OPTIONS, - MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, - MOCK_SORTED_AUTOCOMPLETE_OPTIONS, -} from '../mock_data'; - -describe('Header Search Store Getters', () => { - let state; - - const createState = (initialState) => { - state = initState({ - searchPath: MOCK_SEARCH_PATH, - issuesPath: MOCK_ISSUE_PATH, - mrPath: MOCK_MR_PATH, - autocompletePath: MOCK_AUTOCOMPLETE_PATH, - searchContext: MOCK_SEARCH_CONTEXT, - ...initialState, - }); - }; - - afterEach(() => { - state = null; - }); - - describe.each` - group | project | scope | forSnippets | codeSearch | ref | expectedPath - ${null} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`} - ${null} | ${null} | ${null} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&snippets=true`} - ${null} | ${null} | ${null} | ${false} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&search_code=true`} - ${null} | ${null} | ${null} | ${false} | ${false} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&repository_ref=test-branch`} - ${MOCK_GROUP} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`} - ${null} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true&repository_ref=test-branch`} - `('searchQuery', ({ group, project, scope, forSnippets, codeSearch, ref, expectedPath }) => { - describe(`when group is ${group?.name}, project is ${project?.name}, scope is ${scope}, for_snippets is ${forSnippets}, code_search is ${codeSearch}, and ref is ${ref}`, () => { - beforeEach(() => { - createState({ - searchContext: { - group, - project, - scope, - for_snippets: forSnippets, - code_search: codeSearch, - ref, - }, - }); - state.search = MOCK_SEARCH; - }); - - it(`should return ${expectedPath}`, () => { - expect(getters.searchQuery(state)).toBe(expectedPath); - }); - }); - }); - - describe.each` - group | group_metadata | project | project_metadata | expectedPath - ${null} | ${null} | ${null} | ${null} | ${MOCK_ISSUE_PATH} - ${{ name: 'Test Group' }} | ${{ issues_path: 'group/path' }} | ${null} | ${null} | ${'group/path'} - ${{ name: 'Test Group' }} | ${{ issues_path: 'group/path' }} | ${{ name: 'Test Project' }} | ${{ issues_path: 'project/path' }} | ${'project/path'} - `('scopedIssuesPath', ({ group, group_metadata, project, project_metadata, expectedPath }) => { - describe(`when group is ${group?.name} and project is ${project?.name}`, () => { - beforeEach(() => { - createState({ - searchContext: { - group, - group_metadata, - project, - project_metadata, - }, - }); - }); - - it(`should return ${expectedPath}`, () => { - expect(getters.scopedIssuesPath(state)).toBe(expectedPath); - }); - }); - }); - - describe.each` - group | group_metadata | project | project_metadata | expectedPath - ${null} | ${null} | ${null} | ${null} | ${MOCK_MR_PATH} - ${{ name: 'Test Group' }} | ${{ mr_path: 'group/path' }} | ${null} | ${null} | ${'group/path'} - ${{ name: 'Test Group' }} | ${{ mr_path: 'group/path' }} | ${{ name: 'Test Project' }} | ${{ mr_path: 'project/path' }} | ${'project/path'} - `('scopedMRPath', ({ group, group_metadata, project, project_metadata, expectedPath }) => { - describe(`when group is ${group?.name} and project is ${project?.name}`, () => { - beforeEach(() => { - createState({ - searchContext: { - group, - group_metadata, - project, - project_metadata, - }, - }); - }); - - it(`should return ${expectedPath}`, () => { - expect(getters.scopedMRPath(state)).toBe(expectedPath); - }); - }); - }); - - describe.each` - group | project | scope | forSnippets | codeSearch | ref | expectedPath - ${null} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`} - ${null} | ${null} | ${null} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&snippets=true`} - ${null} | ${null} | ${null} | ${false} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&search_code=true`} - ${null} | ${null} | ${null} | ${false} | ${false} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&repository_ref=test-branch`} - ${MOCK_GROUP} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`} - ${null} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true&repository_ref=test-branch`} - `('projectUrl', ({ group, project, scope, forSnippets, codeSearch, ref, expectedPath }) => { - describe(`when group is ${group?.name}, project is ${project?.name}, scope is ${scope}, for_snippets is ${forSnippets}, code_search is ${codeSearch}, and ref is ${ref}`, () => { - beforeEach(() => { - createState({ - searchContext: { - group, - project, - scope, - for_snippets: forSnippets, - code_search: codeSearch, - ref, - }, - }); - state.search = MOCK_SEARCH; - }); - - it(`should return ${expectedPath}`, () => { - expect(getters.projectUrl(state)).toBe(expectedPath); - }); - }); - }); - - describe.each` - group | project | scope | forSnippets | codeSearch | ref | expectedPath - ${null} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`} - ${null} | ${null} | ${null} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&snippets=true`} - ${null} | ${null} | ${null} | ${false} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&search_code=true`} - ${null} | ${null} | ${null} | ${false} | ${false} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&repository_ref=test-branch`} - ${MOCK_GROUP} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`} - ${null} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true&repository_ref=test-branch`} - `('groupUrl', ({ group, project, scope, forSnippets, codeSearch, ref, expectedPath }) => { - describe(`when group is ${group?.name}, project is ${project?.name}, scope is ${scope}, for_snippets is ${forSnippets}, code_search is ${codeSearch}, and ref is ${ref}`, () => { - beforeEach(() => { - createState({ - searchContext: { - group, - project, - scope, - for_snippets: forSnippets, - code_search: codeSearch, - ref, - }, - }); - state.search = MOCK_SEARCH; - }); - - it(`should return ${expectedPath}`, () => { - expect(getters.groupUrl(state)).toBe(expectedPath); - }); - }); - }); - - describe.each` - group | project | scope | forSnippets | codeSearch | ref | expectedPath - ${null} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`} - ${null} | ${null} | ${null} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&snippets=true`} - ${null} | ${null} | ${null} | ${false} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&search_code=true`} - ${null} | ${null} | ${null} | ${false} | ${false} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&repository_ref=test-branch`} - ${MOCK_GROUP} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`} - ${null} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues&snippets=true`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues&snippets=true&search_code=true`} - ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues&snippets=true&search_code=true&repository_ref=test-branch`} - `('allUrl', ({ group, project, scope, forSnippets, codeSearch, ref, expectedPath }) => { - describe(`when group is ${group?.name}, project is ${project?.name}, scope is ${scope}, for_snippets is ${forSnippets}, code_search is ${codeSearch}, and ref is ${ref}`, () => { - beforeEach(() => { - createState({ - searchContext: { - group, - project, - scope, - for_snippets: forSnippets, - code_search: codeSearch, - ref, - }, - }); - state.search = MOCK_SEARCH; - }); - - it(`should return ${expectedPath}`, () => { - expect(getters.allUrl(state)).toBe(expectedPath); - }); - }); - }); - - describe('defaultSearchOptions', () => { - const mockGetters = { - scopedIssuesPath: MOCK_ISSUE_PATH, - scopedMRPath: MOCK_MR_PATH, - }; - - beforeEach(() => { - createState(); - window.gon.current_username = MOCK_USERNAME; - }); - - it('returns the correct array', () => { - expect(getters.defaultSearchOptions(state, mockGetters)).toStrictEqual( - MOCK_DEFAULT_SEARCH_OPTIONS, - ); - }); - - it('returns the correct array if issues path is false', () => { - mockGetters.scopedIssuesPath = undefined; - expect(getters.defaultSearchOptions(state, mockGetters)).toStrictEqual( - MOCK_DEFAULT_SEARCH_OPTIONS.slice(2, MOCK_DEFAULT_SEARCH_OPTIONS.length), - ); - }); - }); - - describe('scopedSearchOptions', () => { - const mockGetters = { - projectUrl: MOCK_PROJECT.path, - groupUrl: MOCK_GROUP.path, - allUrl: MOCK_ALL_PATH, - }; - - beforeEach(() => { - createState({ - searchContext: { - project: MOCK_PROJECT, - group: MOCK_GROUP, - }, - }); - }); - - it('returns the correct array', () => { - expect(getters.scopedSearchOptions(state, mockGetters)).toStrictEqual( - MOCK_SCOPED_SEARCH_OPTIONS_DEF, - ); - }); - }); - - describe('autocompleteGroupedSearchOptions', () => { - beforeEach(() => { - createState(); - state.autocompleteOptions = MOCK_AUTOCOMPLETE_OPTIONS; - }); - - it('returns the correct grouped array', () => { - expect(getters.autocompleteGroupedSearchOptions(state)).toStrictEqual( - MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, - ); - }); - }); - - describe.each` - search | defaultSearchOptions | scopedSearchOptions | autocompleteGroupedSearchOptions | expectedArray - ${null} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_DEFAULT_SEARCH_OPTIONS} - ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${[]} | ${MOCK_SCOPED_SEARCH_OPTIONS} - ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS} - ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS.concat(MOCK_SORTED_AUTOCOMPLETE_OPTIONS)} - ${1} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${[]} | ${[]} - ${'('} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${[]} | ${[]} - ${'t'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS} - ${'te'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS} - ${'tes'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS.concat(MOCK_SORTED_AUTOCOMPLETE_OPTIONS)} - `( - 'searchOptions', - ({ - search, - defaultSearchOptions, - scopedSearchOptions, - autocompleteGroupedSearchOptions, - expectedArray, - }) => { - describe(`when search is ${search} and the defaultSearchOptions${ - defaultSearchOptions.length ? '' : ' do not' - } exist, scopedSearchOptions${ - scopedSearchOptions.length ? '' : ' do not' - } exist, and autocompleteGroupedSearchOptions${ - autocompleteGroupedSearchOptions.length ? '' : ' do not' - } exist`, () => { - const mockGetters = { - defaultSearchOptions, - scopedSearchOptions, - autocompleteGroupedSearchOptions, - }; - - beforeEach(() => { - createState(); - state.search = search; - }); - - it(`should return the correct combined array`, () => { - expect(getters.searchOptions(state, mockGetters)).toStrictEqual(expectedArray); - }); - }); - }, - ); -}); diff --git a/spec/frontend/header_search/store/mutations_spec.js b/spec/frontend/header_search/store/mutations_spec.js deleted file mode 100644 index e3c15ded948..00000000000 --- a/spec/frontend/header_search/store/mutations_spec.js +++ /dev/null @@ -1,63 +0,0 @@ -import * as types from '~/header_search/store/mutation_types'; -import mutations from '~/header_search/store/mutations'; -import createState from '~/header_search/store/state'; -import { - MOCK_SEARCH, - MOCK_AUTOCOMPLETE_OPTIONS_RES, - MOCK_AUTOCOMPLETE_OPTIONS, -} from '../mock_data'; - -describe('Header Search Store Mutations', () => { - let state; - - beforeEach(() => { - state = createState({}); - }); - - describe('REQUEST_AUTOCOMPLETE', () => { - it('sets loading to true and empties autocompleteOptions array', () => { - mutations[types.REQUEST_AUTOCOMPLETE](state); - - expect(state.loading).toBe(true); - expect(state.autocompleteOptions).toStrictEqual([]); - expect(state.autocompleteError).toBe(false); - }); - }); - - describe('RECEIVE_AUTOCOMPLETE_SUCCESS', () => { - it('sets loading to false and then formats and sets the autocompleteOptions array', () => { - mutations[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, MOCK_AUTOCOMPLETE_OPTIONS_RES); - - expect(state.loading).toBe(false); - expect(state.autocompleteOptions).toStrictEqual(MOCK_AUTOCOMPLETE_OPTIONS); - expect(state.autocompleteError).toBe(false); - }); - }); - - describe('RECEIVE_AUTOCOMPLETE_ERROR', () => { - it('sets loading to false and empties autocompleteOptions array', () => { - mutations[types.RECEIVE_AUTOCOMPLETE_ERROR](state); - - expect(state.loading).toBe(false); - expect(state.autocompleteOptions).toStrictEqual([]); - expect(state.autocompleteError).toBe(true); - }); - }); - - describe('CLEAR_AUTOCOMPLETE', () => { - it('empties autocompleteOptions array', () => { - mutations[types.CLEAR_AUTOCOMPLETE](state); - - expect(state.autocompleteOptions).toStrictEqual([]); - expect(state.autocompleteError).toBe(false); - }); - }); - - describe('SET_SEARCH', () => { - it('sets search to value', () => { - mutations[types.SET_SEARCH](state, MOCK_SEARCH); - - expect(state.search).toBe(MOCK_SEARCH); - }); - }); -}); diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js deleted file mode 100644 index 13c11863443..00000000000 --- a/spec/frontend/header_spec.js +++ /dev/null @@ -1,107 +0,0 @@ -import htmlOpenIssue from 'test_fixtures/issues/open-issue.html'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import initTodoToggle, { initNavUserDropdownTracking } from '~/header'; -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; - -// TODO: Remove this with the removal of the old navigation. -// See https://gitlab.com/groups/gitlab-org/-/epics/11875. -// -// This and ~/header will be removed. These tests no longer work due to the -// corresponding fixtures changing for -// https://gitlab.com/gitlab-org/gitlab/-/issues/420121. -// eslint-disable-next-line jest/no-disabled-tests -describe.skip('Header', () => { - describe('Todos notification', () => { - const todosPendingCount = '.js-todos-count'; - - function isTodosCountHidden() { - return document.querySelector(todosPendingCount).classList.contains('hidden'); - } - - function triggerToggle(newCount) { - const event = new CustomEvent('todo:toggle', { - detail: { - count: newCount, - }, - }); - - document.dispatchEvent(event); - } - - beforeEach(() => { - initTodoToggle(); - setHTMLFixture(htmlOpenIssue); - }); - - afterEach(() => { - resetHTMLFixture(); - }); - - it('should update todos-count after receiving the todo:toggle event', () => { - triggerToggle(5); - - expect(document.querySelector(todosPendingCount).textContent).toEqual('5'); - }); - - it('should hide todos-count when it is 0', () => { - triggerToggle(0); - - expect(isTodosCountHidden()).toEqual(true); - }); - - it('should show todos-count when it is more than 0', () => { - triggerToggle(10); - - expect(isTodosCountHidden()).toEqual(false); - }); - - describe('when todos-count is 1000', () => { - beforeEach(() => { - triggerToggle(1000); - }); - - it('should show todos-count', () => { - expect(isTodosCountHidden()).toEqual(false); - }); - - it('should show 99+ for todos-count', () => { - expect(document.querySelector(todosPendingCount).textContent).toEqual('99+'); - }); - }); - }); - - describe('Track user dropdown open', () => { - let trackingSpy; - - beforeEach(() => { - setHTMLFixture(` - <li class="js-nav-user-dropdown"> - <a class="js-buy-pipeline-minutes-link" data-track-action="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy Pipeline minutes</a> - </li>`); - - trackingSpy = mockTracking( - '_category_', - document.querySelector('.js-nav-user-dropdown').element, - jest.spyOn, - ); - document.body.dataset.page = 'some:page'; - - initNavUserDropdownTracking(); - }); - - afterEach(() => { - unmockTracking(); - resetHTMLFixture(); - }); - - it('sends a tracking event when the dropdown is opened and contains Buy Pipeline minutes link', () => { - const event = new CustomEvent('shown.bs.dropdown'); - document.querySelector('.js-nav-user-dropdown').dispatchEvent(event); - - expect(trackingSpy).toHaveBeenCalledWith('some:page', 'show_buy_ci_minutes', { - label: 'free', - property: 'user_dropdown', - }); - }); - }); -}); diff --git a/spec/frontend/ide/components/ide_sidebar_nav_spec.js b/spec/frontend/ide/components/ide_sidebar_nav_spec.js index 4ee24f63f76..d89891bdd41 100644 --- a/spec/frontend/ide/components/ide_sidebar_nav_spec.js +++ b/spec/frontend/ide/components/ide_sidebar_nav_spec.js @@ -45,7 +45,6 @@ describe('ide/components/ide_sidebar_nav', () => { title: button.attributes('title'), ariaLabel: button.attributes('aria-label'), classes: button.classes(), - qaSelector: button.attributes('data-qa-selector'), icon: button.findComponent(GlIcon).props('name'), tooltip: getBinding(button.element, 'tooltip').value, }; @@ -75,7 +74,6 @@ describe('ide/components/ide_sidebar_nav', () => { title: tab.title, ariaLabel: tab.title, classes: ['ide-sidebar-link', ...classes, ...(classesObj[index] || [])], - qaSelector: `${tab.title.toLowerCase()}_tab_button`, icon: tab.icon, tooltip: { container: 'body', diff --git a/spec/frontend/ide/components/ide_status_bar_spec.js b/spec/frontend/ide/components/ide_status_bar_spec.js index fe392a64013..eb51faaaa16 100644 --- a/spec/frontend/ide/components/ide_status_bar_spec.js +++ b/spec/frontend/ide/components/ide_status_bar_spec.js @@ -1,4 +1,4 @@ -import _ from 'lodash'; +import { clone } from 'lodash'; import { TEST_HOST } from 'helpers/test_constants'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import IdeStatusBar from '~/ide/components/ide_status_bar.vue'; @@ -28,7 +28,7 @@ describe('IdeStatusBar component', () => { currentProjectId: TEST_PROJECT_ID, projects: { ...store.state.projects, - [TEST_PROJECT_ID]: _.clone(projectData), + [TEST_PROJECT_ID]: clone(projectData), }, ...state, }); @@ -100,7 +100,7 @@ describe('IdeStatusBar component', () => { currentMergeRequestId: TEST_MERGE_REQUEST_ID, projects: { [TEST_PROJECT_ID]: { - ..._.clone(projectData), + ...clone(projectData), mergeRequests: { [TEST_MERGE_REQUEST_ID]: { web_url: TEST_MERGE_REQUEST_URL, diff --git a/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js b/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js index 56e62829971..174e62550d5 100644 --- a/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js +++ b/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; // eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import IdeSidebarNav from '~/ide/components/ide_sidebar_nav.vue'; @@ -37,31 +37,26 @@ describe('ide/components/panes/collapsible_sidebar.vue', () => { }); describe('with a tab', () => { - let fakeView; - let extensionTabs; - - beforeEach(() => { - const FakeComponent = Vue.component(fakeComponentName, { - render: () => null, - }); - - fakeView = { - name: fakeComponentName, - keepAlive: true, - component: FakeComponent, - }; - - extensionTabs = [ - { - show: true, - title: fakeComponentName, - views: [fakeView], - icon: 'text-description', - buttonClasses: ['button-class-1', 'button-class-2'], - }, - ]; + const FakeComponent = Vue.component(fakeComponentName, { + render: () => null, }); + const fakeView = { + name: fakeComponentName, + keepAlive: true, + component: FakeComponent, + }; + + const extensionTabs = [ + { + show: true, + title: fakeComponentName, + views: [fakeView], + icon: 'text-description', + buttonClasses: ['button-class-1', 'button-class-2'], + }, + ]; + describe.each` side ${'left'} @@ -79,10 +74,6 @@ describe('ide/components/panes/collapsible_sidebar.vue', () => { expect(findSidebarNav().props('side')).toBe(side); }); - it('nothing is dispatched', () => { - expect(store.dispatch).not.toHaveBeenCalled(); - }); - it('when sidebar emits open, dispatch open', () => { const view = 'lorem-view'; @@ -98,6 +89,13 @@ describe('ide/components/panes/collapsible_sidebar.vue', () => { }); }); + describe('when side bar is rendered initially', () => { + it('nothing is dispatched', () => { + createComponent({ extensionTabs }); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); + describe.each` isOpen ${true} @@ -125,25 +123,15 @@ describe('ide/components/panes/collapsible_sidebar.vue', () => { }); describe('with initOpenView that does not exist', () => { - beforeEach(async () => { - createComponent({ extensionTabs, initOpenView: 'does-not-exist' }); - - await nextTick(); - }); - it('nothing is dispatched', () => { + createComponent({ extensionTabs, initOpenView: 'does-not-exist' }); expect(store.dispatch).not.toHaveBeenCalled(); }); }); describe('with initOpenView that does exist', () => { - beforeEach(async () => { - createComponent({ extensionTabs, initOpenView: fakeView.name }); - - await nextTick(); - }); - it('dispatches open with view on create', () => { + createComponent({ extensionTabs, initOpenView: fakeView.name }); expect(store.dispatch).toHaveBeenCalledWith('rightPane/open', fakeView); }); }); diff --git a/spec/frontend/ide/components/pipelines/list_spec.js b/spec/frontend/ide/components/pipelines/list_spec.js index 9c11ae9334b..9d8b3b1d32a 100644 --- a/spec/frontend/ide/components/pipelines/list_spec.js +++ b/spec/frontend/ide/components/pipelines/list_spec.js @@ -8,7 +8,7 @@ import JobsList from '~/ide/components/jobs/list.vue'; import List from '~/ide/components/pipelines/list.vue'; import EmptyState from '~/ide/components/pipelines/empty_state.vue'; import IDEServices from '~/ide/services'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; Vue.use(Vuex); diff --git a/spec/frontend/ide/components/repo_commit_section_spec.js b/spec/frontend/ide/components/repo_commit_section_spec.js index ead609421b7..3dd9ae1285d 100644 --- a/spec/frontend/ide/components/repo_commit_section_spec.js +++ b/spec/frontend/ide/components/repo_commit_section_spec.js @@ -71,11 +71,8 @@ describe('RepoCommitSection', () => { createComponent(); }); - it('renders no changes text', () => { - expect(wrapper.findComponent(EmptyState).text().trim()).toContain('No changes'); - expect(wrapper.findComponent(EmptyState).find('img').attributes('src')).toBe( - TEST_NO_CHANGES_SVG, - ); + it('renders empty state component', () => { + expect(wrapper.findComponent(EmptyState).exists()).toBe(true); }); }); diff --git a/spec/frontend/ide/init_gitlab_web_ide_spec.js b/spec/frontend/ide/init_gitlab_web_ide_spec.js index 6a5bedb0bbb..d7a16bec1c3 100644 --- a/spec/frontend/ide/init_gitlab_web_ide_spec.js +++ b/spec/frontend/ide/init_gitlab_web_ide_spec.js @@ -40,6 +40,9 @@ const TEST_EDITOR_FONT_SRC_URL = 'http://gitlab.test/assets/gitlab-mono/GitLabMo const TEST_EDITOR_FONT_FORMAT = 'woff2'; const TEST_EDITOR_FONT_FAMILY = 'GitLab Mono'; +const TEST_OAUTH_CLIENT_ID = 'oauth-client-id-123abc'; +const TEST_OAUTH_CALLBACK_URL = 'https://example.com/oauth_callback'; + describe('ide/init_gitlab_web_ide', () => { let resolveConfirm; @@ -231,4 +234,29 @@ describe('ide/init_gitlab_web_ide', () => { ); }); }); + + describe('when oauth info is in dataset', () => { + beforeEach(() => { + findRootElement().dataset.clientId = TEST_OAUTH_CLIENT_ID; + findRootElement().dataset.callbackUrl = TEST_OAUTH_CALLBACK_URL; + + createSubject(); + }); + + it('calls start with element', () => { + expect(start).toHaveBeenCalledTimes(1); + expect(start).toHaveBeenCalledWith( + findRootElement(), + expect.objectContaining({ + auth: { + type: 'oauth', + clientId: TEST_OAUTH_CLIENT_ID, + callbackUrl: TEST_OAUTH_CALLBACK_URL, + protectRefreshToken: true, + }, + httpHeaders: undefined, + }), + ); + }); + }); }); diff --git a/spec/frontend/ide/lib/gitlab_web_ide/get_oauth_config_spec.js b/spec/frontend/ide/lib/gitlab_web_ide/get_oauth_config_spec.js new file mode 100644 index 00000000000..3431068937f --- /dev/null +++ b/spec/frontend/ide/lib/gitlab_web_ide/get_oauth_config_spec.js @@ -0,0 +1,16 @@ +import { getOAuthConfig } from '~/ide/lib/gitlab_web_ide/get_oauth_config'; + +describe('~/ide/lib/gitlab_web_ide/get_oauth_config', () => { + it('returns undefined if no clientId found', () => { + expect(getOAuthConfig({})).toBeUndefined(); + }); + + it('returns auth config from dataset', () => { + expect(getOAuthConfig({ clientId: 'test-clientId', callbackUrl: 'test-callbackUrl' })).toEqual({ + type: 'oauth', + clientId: 'test-clientId', + callbackUrl: 'test-callbackUrl', + protectRefreshToken: true, + }); + }); +}); diff --git a/spec/frontend/ide/mock_data.js b/spec/frontend/ide/mock_data.js index b1f192e1d98..722f15db87d 100644 --- a/spec/frontend/ide/mock_data.js +++ b/spec/frontend/ide/mock_data.js @@ -14,6 +14,7 @@ export const projectData = { commit: { id: '123', short_id: 'abc123de', + committed_date: '2019-09-13T15:37:30+0300', }, }, }, diff --git a/spec/frontend/ide/mount_oauth_callback_spec.js b/spec/frontend/ide/mount_oauth_callback_spec.js new file mode 100644 index 00000000000..6ac0b4e4615 --- /dev/null +++ b/spec/frontend/ide/mount_oauth_callback_spec.js @@ -0,0 +1,53 @@ +import { oauthCallback } from '@gitlab/web-ide'; +import { TEST_HOST } from 'helpers/test_constants'; +import { mountOAuthCallback } from '~/ide/mount_oauth_callback'; + +jest.mock('@gitlab/web-ide'); + +const TEST_USERNAME = 'gandalf.the.grey'; +const TEST_GITLAB_WEB_IDE_PUBLIC_PATH = 'test/webpack/assets/gitlab-web-ide/public/path'; + +const TEST_OAUTH_CLIENT_ID = 'oauth-client-id-123abc'; +const TEST_OAUTH_CALLBACK_URL = 'https://example.com/oauth_callback'; + +describe('~/ide/mount_oauth_callback', () => { + const createRootElement = () => { + const el = document.createElement('div'); + + el.id = 'ide'; + el.dataset.clientId = TEST_OAUTH_CLIENT_ID; + el.dataset.callbackUrl = TEST_OAUTH_CALLBACK_URL; + + document.body.append(el); + }; + + beforeEach(() => { + gon.current_username = TEST_USERNAME; + process.env.GITLAB_WEB_IDE_PUBLIC_PATH = TEST_GITLAB_WEB_IDE_PUBLIC_PATH; + + createRootElement(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('calls oauthCallback', () => { + expect(oauthCallback).not.toHaveBeenCalled(); + + mountOAuthCallback(); + + expect(oauthCallback).toHaveBeenCalledTimes(1); + expect(oauthCallback).toHaveBeenCalledWith({ + auth: { + type: 'oauth', + callbackUrl: TEST_OAUTH_CALLBACK_URL, + clientId: TEST_OAUTH_CLIENT_ID, + protectRefreshToken: true, + }, + gitlabUrl: TEST_HOST, + baseUrl: `${TEST_HOST}/${TEST_GITLAB_WEB_IDE_PUBLIC_PATH}`, + username: TEST_USERNAME, + }); + }); +}); diff --git a/spec/frontend/ide/stores/modules/editor/actions_spec.js b/spec/frontend/ide/stores/modules/editor/actions_spec.js index f006018364b..e24d54ef6da 100644 --- a/spec/frontend/ide/stores/modules/editor/actions_spec.js +++ b/spec/frontend/ide/stores/modules/editor/actions_spec.js @@ -8,7 +8,7 @@ describe('~/ide/stores/modules/editor/actions', () => { it('commits with payload', () => { const payload = {}; - testAction(actions.updateFileEditor, payload, {}, [ + return testAction(actions.updateFileEditor, payload, {}, [ { type: types.UPDATE_FILE_EDITOR, payload }, ]); }); @@ -18,7 +18,7 @@ describe('~/ide/stores/modules/editor/actions', () => { it('commits with payload', () => { const payload = 'path/to/file.txt'; - testAction(actions.removeFileEditor, payload, {}, [ + return testAction(actions.removeFileEditor, payload, {}, [ { type: types.REMOVE_FILE_EDITOR, payload }, ]); }); @@ -28,7 +28,7 @@ describe('~/ide/stores/modules/editor/actions', () => { it('commits with payload', () => { const payload = createTriggerRenamePayload('test', 'test123'); - testAction(actions.renameFileEditor, payload, {}, [ + return testAction(actions.renameFileEditor, payload, {}, [ { type: types.RENAME_FILE_EDITOR, payload }, ]); }); diff --git a/spec/frontend/import/details/components/bulk_import_details_app_spec.js b/spec/frontend/import/details/components/bulk_import_details_app_spec.js index d32afb7ddcb..18b03ed9802 100644 --- a/spec/frontend/import/details/components/bulk_import_details_app_spec.js +++ b/spec/frontend/import/details/components/bulk_import_details_app_spec.js @@ -1,18 +1,30 @@ import { shallowMount } from '@vue/test-utils'; +import { getParameterValues } from '~/lib/utils/url_utility'; + import BulkImportDetailsApp from '~/import/details/components/bulk_import_details_app.vue'; +jest.mock('~/lib/utils/url_utility'); + describe('Bulk import details app', () => { let wrapper; + const mockId = 151; + const createComponent = () => { wrapper = shallowMount(BulkImportDetailsApp); }; + beforeEach(() => { + getParameterValues.mockReturnValueOnce([mockId]); + }); + describe('template', () => { it('renders heading', () => { createComponent(); - expect(wrapper.find('h1').text()).toBe('GitLab Migration details'); + const headingText = wrapper.find('h1').text(); + + expect(headingText).toBe(`Items that failed to be imported for ${mockId}`); }); }); }); diff --git a/spec/frontend/import_entities/import_groups/components/import_history_link_spec.js b/spec/frontend/import_entities/import_groups/components/import_history_link_spec.js new file mode 100644 index 00000000000..5f530f2c3be --- /dev/null +++ b/spec/frontend/import_entities/import_groups/components/import_history_link_spec.js @@ -0,0 +1,34 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink } from '@gitlab/ui'; + +import ImportHistoryLink from '~/import_entities/import_groups/components/import_history_link.vue'; + +describe('import history link', () => { + let wrapper; + + const mockHistoryPath = '/import/history'; + + const createComponent = ({ props } = {}) => { + wrapper = shallowMount(ImportHistoryLink, { + propsData: { + historyPath: mockHistoryPath, + ...props, + }, + }); + }; + + const findGlLink = () => wrapper.findComponent(GlLink); + + it('renders link with href', () => { + const mockId = 174; + + createComponent({ + props: { + id: mockId, + }, + }); + + expect(findGlLink().text()).toBe('View details'); + expect(findGlLink().attributes('href')).toBe('/import/history?bulk_import_id=174'); + }); +}); diff --git a/spec/frontend/import_entities/import_groups/components/import_table_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_spec.js index 4fab22e316a..84f149b4dd5 100644 --- a/spec/frontend/import_entities/import_groups/components/import_table_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_table_spec.js @@ -9,9 +9,12 @@ import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { createAlert } from '~/alert'; import { HTTP_STATUS_OK, HTTP_STATUS_TOO_MANY_REQUESTS } from '~/lib/utils/http_status'; import axios from '~/lib/utils/axios_utils'; + import { STATUSES } from '~/import_entities/constants'; -import { i18n, ROOT_NAMESPACE } from '~/import_entities/import_groups/constants'; +import { ROOT_NAMESPACE } from '~/import_entities/import_groups/constants'; import ImportTable from '~/import_entities/import_groups/components/import_table.vue'; +import ImportStatus from '~/import_entities/import_groups/components/import_status.vue'; +import ImportHistoryLink from '~/import_entities/import_groups/components//import_history_link.vue'; import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql'; import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; @@ -39,6 +42,7 @@ describe('import table', () => { generateFakeEntry({ id: 1, status: STATUSES.NONE }), generateFakeEntry({ id: 2, status: STATUSES.FINISHED }), generateFakeEntry({ id: 3, status: STATUSES.NONE }), + generateFakeEntry({ id: 4, status: STATUSES.FINISHED, hasFailures: true }), ]; const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 }; @@ -64,6 +68,7 @@ describe('import table', () => { const findSelectionCount = () => wrapper.find('[data-test-id="selection-count"]'); const findNewPathCol = () => wrapper.find('[data-test-id="new-path-col"]'); const findUnavailableFeaturesWarning = () => wrapper.findByTestId('unavailable-features-alert'); + const findAllImportStatuses = () => wrapper.findAllComponents(ImportStatus); const triggerSelectAllCheckbox = (checked = true) => wrapper.find('thead input[type=checkbox]').setChecked(checked); @@ -144,7 +149,7 @@ describe('import table', () => { }); await waitForPromises(); - expect(wrapper.findComponent(GlEmptyState).props().title).toBe(i18n.NO_GROUPS_FOUND); + expect(wrapper.findComponent(GlEmptyState).props().title).toBe('No groups found'); }); }); @@ -161,6 +166,38 @@ describe('import table', () => { expect(wrapper.findAll('tbody tr')).toHaveLength(FAKE_GROUPS.length); }); + it('renders correct import status for each group', async () => { + const expectedStatuses = ['Not started', 'Complete', 'Not started', 'Partially completed']; + + createComponent({ + bulkImportSourceGroups: () => ({ + nodes: FAKE_GROUPS, + pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, + }), + }); + await waitForPromises(); + + expect(findAllImportStatuses().wrappers.map((w) => w.text())).toEqual(expectedStatuses); + }); + + it('renders import history link for imports with id', async () => { + createComponent({ + bulkImportSourceGroups: () => ({ + nodes: FAKE_GROUPS, + pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, + }), + }); + await waitForPromises(); + + const importHistoryLinks = wrapper.findAllComponents(ImportHistoryLink); + + expect(importHistoryLinks).toHaveLength(2); + expect(importHistoryLinks.at(0).props('id')).toBe(FAKE_GROUPS[1].id); + expect(importHistoryLinks.at(1).props('id')).toBe(FAKE_GROUPS[3].id); + }); + it('correctly maintains root namespace as last import target', async () => { createComponent({ bulkImportSourceGroups: () => ({ @@ -260,6 +297,42 @@ describe('import table', () => { }); }); + describe('when importGroup query is using stale data from LocalStorageCache', () => { + it('displays error', async () => { + const mockMutationWithProgressInvalid = jest.fn().mockResolvedValue({ + __typename: 'ClientBulkImportSourceGroup', + id: 1, + lastImportTarget: { id: 1, targetNamespace: 'root', newName: 'group1' }, + progress: { + __typename: 'ClientBulkImportProgress', + id: null, + status: 'failed', + message: '', + }, + }); + + createComponent({ + bulkImportSourceGroups: () => ({ + nodes: [FAKE_GROUP], + pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, + }), + importGroups: mockMutationWithProgressInvalid, + }); + + await waitForPromises(); + await findRowImportDropdownAtIndex(0).trigger('click'); + await waitForPromises(); + + expect(mockMutationWithProgressInvalid).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledWith({ + message: 'Importing the group failed.', + captureError: true, + error: expect.any(Error), + }); + }); + }); + it('displays error if importing group fails', async () => { createComponent({ bulkImportSourceGroups: () => ({ @@ -276,11 +349,11 @@ describe('import table', () => { await findRowImportDropdownAtIndex(0).trigger('click'); await waitForPromises(); - expect(createAlert).toHaveBeenCalledWith( - expect.objectContaining({ - message: i18n.ERROR_IMPORT, - }), - ); + expect(createAlert).toHaveBeenCalledWith({ + message: 'Importing the group failed.', + captureError: true, + error: expect.any(Error), + }); }); it('displays inline error if importing group reports rate limit', async () => { @@ -302,7 +375,9 @@ describe('import table', () => { await waitForPromises(); expect(createAlert).not.toHaveBeenCalled(); - expect(wrapper.find('tbody tr').text()).toContain(i18n.ERROR_TOO_MANY_REQUESTS); + expect(wrapper.find('tbody tr').text()).toContain( + 'Over six imports in one minute were attempted. Wait at least one minute and try again.', + ); }); it('displays inline error if backend returns validation error', async () => { @@ -316,6 +391,7 @@ describe('import table', () => { __typename: 'ClientBulkImportProgress', id: null, status: 'failed', + hasFailures: true, message: mockValidationError, }, }); diff --git a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js index 540c42a2854..0976a3294c2 100644 --- a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js +++ b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js @@ -72,6 +72,7 @@ describe('Bulk import resolvers', () => { progress: { id: 'DEMO', status: 'cached', + hasFailures: true, }, }; localStorageCache.get.mockReturnValueOnce(CACHED_DATA); @@ -234,7 +235,7 @@ describe('Bulk import resolvers', () => { data: { updateImportStatus: statusInResponse }, } = await client.mutate({ mutation: updateImportStatusMutation, - variables: { id, status: NEW_STATUS }, + variables: { id, status: NEW_STATUS, hasFailures: true }, }); expect(statusInResponse).toStrictEqual({ @@ -242,6 +243,7 @@ describe('Bulk import resolvers', () => { id, message: null, status: NEW_STATUS, + hasFailures: true, }); }); }); diff --git a/spec/frontend/import_entities/import_groups/graphql/fixtures.js b/spec/frontend/import_entities/import_groups/graphql/fixtures.js index 7530e9fc348..edc2d1a2381 100644 --- a/spec/frontend/import_entities/import_groups/graphql/fixtures.js +++ b/spec/frontend/import_entities/import_groups/graphql/fixtures.js @@ -1,7 +1,7 @@ import { STATUSES } from '~/import_entities/constants'; import { clientTypenames } from '~/import_entities/import_groups/graphql/client_factory'; -export const generateFakeEntry = ({ id, status, message, ...rest }) => ({ +export const generateFakeEntry = ({ id, status, hasFailures = false, message, ...rest }) => ({ __typename: clientTypenames.BulkImportSourceGroup, webUrl: `https://fake.host/${id}`, fullPath: `fake_group_${id}`, @@ -19,6 +19,7 @@ export const generateFakeEntry = ({ id, status, message, ...rest }) => ({ __typename: clientTypenames.BulkImportProgress, id, status, + hasFailures, message: message || '', }, ...rest, diff --git a/spec/frontend/import_entities/import_groups/graphql/services/local_storage_cache_spec.js b/spec/frontend/import_entities/import_groups/graphql/services/local_storage_cache_spec.js index b44a2767ad8..d1ecd47b498 100644 --- a/spec/frontend/import_entities/import_groups/graphql/services/local_storage_cache_spec.js +++ b/spec/frontend/import_entities/import_groups/graphql/services/local_storage_cache_spec.js @@ -40,10 +40,11 @@ describe('Local storage cache', () => { progress: { id: JOB_ID, status: 'original', + hasFailures: false, }, }); - cache.updateStatusByJobId(JOB_ID, CHANGED_STATUS); + cache.updateStatusByJobId(JOB_ID, CHANGED_STATUS, true); expect(storage.setItem).toHaveBeenCalledWith( KEY, @@ -52,6 +53,7 @@ describe('Local storage cache', () => { progress: { id: JOB_ID, status: CHANGED_STATUS, + hasFailures: true, }, }, }), diff --git a/spec/frontend/import_entities/import_groups/utils_spec.js b/spec/frontend/import_entities/import_groups/utils_spec.js index 2892c5c217b..3db57170ed3 100644 --- a/spec/frontend/import_entities/import_groups/utils_spec.js +++ b/spec/frontend/import_entities/import_groups/utils_spec.js @@ -5,7 +5,7 @@ const FINISHED_STATUSES = [STATUSES.FINISHED, STATUSES.FAILED, STATUSES.TIMEOUT] const OTHER_STATUSES = Object.values(STATUSES).filter( (status) => !FINISHED_STATUSES.includes(status), ); -describe('gitlab migration status utils', () => { +describe('Direct transfer status utils', () => { describe('isFinished', () => { it.each(FINISHED_STATUSES.map((s) => [s]))( 'reports group as finished when import status is %s', diff --git a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js index 92d064846bd..056155a560f 100644 --- a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js +++ b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js @@ -36,6 +36,7 @@ describe('ImportProjectsTable', () => { .filter((w) => w.props().variant === 'confirm') .at(0); const findImportAllModal = () => wrapper.findComponent({ ref: 'importAllModal' }); + const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); const importAllFn = jest.fn(); const importAllModalShowFn = jest.fn(); @@ -203,13 +204,13 @@ describe('ImportProjectsTable', () => { describe('when paginatable is set to true', () => { const initState = { namespaces: [{ fullPath: 'path' }], - pageInfo: { page: 1, hasNextPage: true }, + pageInfo: { page: 1, hasNextPage: false }, repositories: [ { importSource: { id: 1 }, importedProject: null, importStatus: STATUSES.NONE }, ], }; - describe('with hasNextPage true', () => { + describe('with hasNextPage false', () => { beforeEach(() => { createComponent({ state: initState, @@ -217,26 +218,14 @@ describe('ImportProjectsTable', () => { }); }); - it('does not call fetchRepos on mount', () => { - expect(fetchReposFn).not.toHaveBeenCalled(); - }); - - it('renders intersection observer component', () => { - expect(wrapper.findComponent(GlIntersectionObserver).exists()).toBe(true); - }); - - it('calls fetchRepos when intersection observer appears', async () => { - wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear'); - - await nextTick(); - - expect(fetchReposFn).toHaveBeenCalled(); + it('does not render intersection observer component', () => { + expect(findIntersectionObserver().exists()).toBe(false); }); }); - describe('with hasNextPage false', () => { + describe('with hasNextPage true', () => { beforeEach(() => { - initState.pageInfo.hasNextPage = false; + initState.pageInfo.hasNextPage = true; createComponent({ state: initState, @@ -244,8 +233,16 @@ describe('ImportProjectsTable', () => { }); }); - it('does not render intersection observer component', () => { - expect(wrapper.findComponent(GlIntersectionObserver).exists()).toBe(false); + it('renders intersection observer component', () => { + expect(findIntersectionObserver().exists()).toBe(true); + }); + + it('calls fetchRepos again when intersection observer appears', async () => { + findIntersectionObserver().vm.$emit('appear'); + + await nextTick(); + + expect(fetchReposFn).toHaveBeenCalledTimes(2); }); }); }); diff --git a/spec/frontend/import_entities/import_projects/store/actions_spec.js b/spec/frontend/import_entities/import_projects/store/actions_spec.js index 3b94db37801..918821dfa59 100644 --- a/spec/frontend/import_entities/import_projects/store/actions_spec.js +++ b/spec/frontend/import_entities/import_projects/store/actions_spec.js @@ -17,6 +17,7 @@ import { SET_PAGE, SET_FILTER, SET_PAGE_CURSORS, + SET_HAS_NEXT_PAGE, } from '~/import_entities/import_projects/store/mutation_types'; import state from '~/import_entities/import_projects/store/state'; import axios from '~/lib/utils/axios_utils'; @@ -143,6 +144,44 @@ describe('import_projects store actions', () => { ); }); }); + + describe('when provider is BITBUCKET_SERVER', () => { + beforeEach(() => { + localState.provider = PROVIDERS.BITBUCKET_SERVER; + }); + + describe.each` + reposLength | expectedHasNextPage + ${0} | ${false} + ${12} | ${false} + ${20} | ${false} + ${25} | ${true} + `('when reposLength is $reposLength', ({ reposLength, expectedHasNextPage }) => { + beforeEach(() => { + payload.provider_repos = Array(reposLength).fill({}); + + mock.onGet(MOCK_ENDPOINT).reply(HTTP_STATUS_OK, payload); + }); + + it('commits SET_HAS_NEXT_PAGE', () => { + return testAction( + fetchRepos, + null, + localState, + [ + { type: REQUEST_REPOS }, + { type: SET_PAGE, payload: 1 }, + { type: SET_HAS_NEXT_PAGE, payload: expectedHasNextPage }, + { + type: RECEIVE_REPOS_SUCCESS, + payload: convertObjectPropsToCamelCase(payload, { deep: true }), + }, + ], + [], + ); + }); + }); + }); }); it('commits REQUEST_REPOS, RECEIVE_REPOS_ERROR mutations on an unsuccessful request', () => { diff --git a/spec/frontend/import_entities/import_projects/store/mutations_spec.js b/spec/frontend/import_entities/import_projects/store/mutations_spec.js index 07d247630cc..90053f79bdf 100644 --- a/spec/frontend/import_entities/import_projects/store/mutations_spec.js +++ b/spec/frontend/import_entities/import_projects/store/mutations_spec.js @@ -332,6 +332,16 @@ describe('import_projects store mutations', () => { }); }); + describe(`${types.SET_HAS_NEXT_PAGE}`, () => { + it('sets hasNextPage in pageInfo', () => { + const NEW_HAS_NEXT_PAGE = true; + state = { pageInfo: { hasNextPage: false } }; + + mutations[types.SET_HAS_NEXT_PAGE](state, NEW_HAS_NEXT_PAGE); + expect(state.pageInfo.hasNextPage).toBe(NEW_HAS_NEXT_PAGE); + }); + }); + describe(`${types.CANCEL_IMPORT_SUCCESS}`, () => { const payload = { repoId: 1 }; diff --git a/spec/frontend/integrations/edit/components/dynamic_field_spec.js b/spec/frontend/integrations/edit/components/dynamic_field_spec.js index 95d15eb2c00..bf9a77074f4 100644 --- a/spec/frontend/integrations/edit/components/dynamic_field_spec.js +++ b/spec/frontend/integrations/edit/components/dynamic_field_spec.js @@ -169,7 +169,7 @@ describe('DynamicField', () => { expect(findGlFormInput().exists()).toBe(true); expect(findGlFormInput().attributes()).toMatchObject({ type: 'text', - id: 'service_project_url', + id: 'service-project_url', name: 'service[project_url]', placeholder: mockField.placeholder, required: expect.any(String), diff --git a/spec/frontend/integrations/edit/components/integration_form_actions_spec.js b/spec/frontend/integrations/edit/components/integration_form_actions_spec.js index e95e30a1899..d7ee31cc857 100644 --- a/spec/frontend/integrations/edit/components/integration_form_actions_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_actions_spec.js @@ -28,7 +28,7 @@ describe('IntegrationFormActions', () => { const findConfirmationModal = () => wrapper.findComponent(ConfirmationModal); const findResetConfirmationModal = () => wrapper.findComponent(ResetConfirmationModal); const findResetButton = () => wrapper.findByTestId('reset-button'); - const findSaveButton = () => wrapper.findByTestId('save-button'); + const findSaveButton = () => wrapper.findByTestId('save-changes-button'); const findTestButton = () => wrapper.findByTestId('test-button'); const findCancelButton = () => wrapper.findByTestId('cancel-button'); diff --git a/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js index a038b63d28c..08f758c1382 100644 --- a/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js @@ -1,11 +1,15 @@ import { GlFormCheckbox } from '@gitlab/ui'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +// eslint-disable-next-line no-restricted-imports +import Vuex from 'vuex'; import { mountExtended } from 'helpers/vue_test_utils_helper'; - import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue'; +Vue.use(Vuex); + describe('JiraTriggerFields', () => { let wrapper; + let store; const defaultProps = { initialTriggerCommit: false, @@ -14,12 +18,16 @@ describe('JiraTriggerFields', () => { }; const createComponent = (props, isInheriting = false) => { - wrapper = mountExtended(JiraTriggerFields, { - propsData: { ...defaultProps, ...props }, - computed: { + store = new Vuex.Store({ + getters: { isInheriting: () => isInheriting, }, }); + + wrapper = mountExtended(JiraTriggerFields, { + propsData: { ...defaultProps, ...props }, + store, + }); }; const findCommentSettings = () => wrapper.findByTestId('comment-settings'); diff --git a/spec/frontend/integrations/edit/components/trigger_field_spec.js b/spec/frontend/integrations/edit/components/trigger_field_spec.js index b3d6784959f..1dad3b27618 100644 --- a/spec/frontend/integrations/edit/components/trigger_field_spec.js +++ b/spec/frontend/integrations/edit/components/trigger_field_spec.js @@ -1,12 +1,17 @@ -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +// eslint-disable-next-line no-restricted-imports +import Vuex from 'vuex'; import { shallowMount } from '@vue/test-utils'; import { GlFormCheckbox, GlFormInput } from '@gitlab/ui'; import TriggerField from '~/integrations/edit/components/trigger_field.vue'; import { integrationTriggerEventTitles } from '~/integrations/constants'; +Vue.use(Vuex); + describe('TriggerField', () => { let wrapper; + let store; const defaultProps = { event: { name: 'push_events' }, @@ -15,12 +20,16 @@ describe('TriggerField', () => { const mockField = { name: 'push_channel' }; const createComponent = ({ props = {}, isInheriting = false } = {}) => { - wrapper = shallowMount(TriggerField, { - propsData: { ...defaultProps, ...props }, - computed: { + store = new Vuex.Store({ + getters: { isInheriting: () => isInheriting, }, }); + + wrapper = shallowMount(TriggerField, { + propsData: { ...defaultProps, ...props }, + store, + }); }; const findGlFormCheckbox = () => wrapper.findComponent(GlFormCheckbox); diff --git a/spec/frontend/integrations/edit/components/trigger_fields_spec.js b/spec/frontend/integrations/edit/components/trigger_fields_spec.js index defa02aefd2..97ac01e2f26 100644 --- a/spec/frontend/integrations/edit/components/trigger_fields_spec.js +++ b/spec/frontend/integrations/edit/components/trigger_fields_spec.js @@ -1,23 +1,32 @@ import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui'; +import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports +import Vuex from 'vuex'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { placeholderForType } from 'jh_else_ce/integrations/constants'; - import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; +Vue.use(Vuex); + describe('TriggerFields', () => { let wrapper; + let store; const defaultProps = { type: 'slack', }; const createComponent = (props, isInheriting = false) => { - wrapper = mountExtended(TriggerFields, { - propsData: { ...defaultProps, ...props }, - computed: { + store = new Vuex.Store({ + getters: { isInheriting: () => isInheriting, }, }); + + wrapper = mountExtended(TriggerFields, { + propsData: { ...defaultProps, ...props }, + store, + }); }; const findTriggerLabel = () => wrapper.findByTestId('trigger-fields-group').find('label'); diff --git a/spec/frontend/invite_members/components/invite_groups_modal_spec.js b/spec/frontend/invite_members/components/invite_groups_modal_spec.js index 4136de75545..358d70d8117 100644 --- a/spec/frontend/invite_members/components/invite_groups_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_groups_modal_spec.js @@ -77,6 +77,16 @@ describe('InviteGroupsModal', () => { const clickInviteButton = emitClickFromModal('invite-modal-submit'); const clickCancelButton = emitClickFromModal('invite-modal-cancel'); + describe('passes correct props to InviteModalBase', () => { + it('set accessLevel', () => { + createInviteGroupToProjectWrapper(); + + expect(findBase().props('accessLevels')).toMatchObject({ + validRoles: propsData.accessLevels, + }); + }); + }); + describe('displaying the correct introText and form group description', () => { describe('when inviting to a project', () => { it('includes the correct type, and formatted intro text', () => { 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 19b7fad5fc8..ad3174b8946 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -128,6 +128,7 @@ describe('InviteMembersModal', () => { }); const findModal = () => wrapper.findComponent(GlModal); + const findBase = () => wrapper.findComponent(InviteModalBase); const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text(); const findEmptyInvitesAlert = () => wrapper.findByTestId('empty-invites-alert'); const findMemberErrorAlert = () => wrapper.findByTestId('alert-member-error'); @@ -168,6 +169,22 @@ describe('InviteMembersModal', () => { await nextTick(); }; + describe('passes correct props to InviteModalBase', () => { + it('set defaultMemberRoleId', () => { + createInviteMembersToProjectWrapper(); + + expect(findBase().props('defaultMemberRoleId')).toBeNull(); + }); + + it('set accessLevel', () => { + createInviteMembersToProjectWrapper(); + + expect(findBase().props('accessLevels')).toMatchObject({ + validRoles: propsData.accessLevels, + }); + }); + }); + describe('rendering with tracking considerations', () => { describe('when inviting to a project', () => { describe('when inviting members', () => { diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js index 58c40a49b3c..f14d24538d8 100644 --- a/spec/frontend/invite_members/components/invite_members_trigger_spec.js +++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js @@ -4,7 +4,6 @@ import InviteMembersTrigger from '~/invite_members/components/invite_members_tri import eventHub from '~/invite_members/event_hub'; import { TRIGGER_ELEMENT_BUTTON, - TRIGGER_DEFAULT_QA_SELECTOR, TRIGGER_ELEMENT_WITH_EMOJI, TRIGGER_ELEMENT_DROPDOWN_WITH_EMOJI, TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN, @@ -66,18 +65,6 @@ describe.each(triggerItems)('with triggerElement as %s', (triggerItem) => { expect(findButton().text()).toBe(displayText); }); - - it('uses the default qa selector value', () => { - createComponent(); - - expect(findButton().attributes('data-qa-selector')).toBe(TRIGGER_DEFAULT_QA_SELECTOR); - }); - - it('sets the qa selector value', () => { - createComponent({ qaSelector: '_qaSelector_' }); - - expect(findButton().attributes('data-qa-selector')).toBe('_qaSelector_'); - }); }); describe('clicking the link', () => { diff --git a/spec/frontend/invite_members/components/invite_modal_base_spec.js b/spec/frontend/invite_members/components/invite_modal_base_spec.js index e70c83a424e..c26d1d921a5 100644 --- a/spec/frontend/invite_members/components/invite_modal_base_spec.js +++ b/spec/frontend/invite_members/components/invite_modal_base_spec.js @@ -1,5 +1,5 @@ import { - GlFormSelect, + GlCollapsibleListbox, GlDatepicker, GlFormGroup, GlLink, @@ -7,9 +7,14 @@ import { GlModal, GlIcon, } from '@gitlab/ui'; +import { nextTick } from 'vue'; import { stubComponent } from 'helpers/stub_component'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + mountExtended, + shallowMountExtended, + extendedWrapper, +} from 'helpers/vue_test_utils_helper'; import InviteModalBase from '~/invite_members/components/invite_modal_base.vue'; import ContentTransition from '~/vue_shared/components/content_transition.vue'; @@ -31,7 +36,7 @@ describe('InviteModalBase', () => { ? {} : { ContentTransition, - GlFormSelect: true, + GlCollapsibleListbox: true, GlSprintf, GlFormGroup: stubComponent(GlFormGroup, { props: ['state', 'invalidFeedback'], @@ -41,6 +46,7 @@ describe('InviteModalBase', () => { wrapper = mountFn(InviteModalBase, { propsData: { ...propsData, + accessLevels: { validRoles: propsData.accessLevels }, ...props, }, stubs: { @@ -54,8 +60,8 @@ describe('InviteModalBase', () => { }); }; - const findFormSelect = () => wrapper.findComponent(GlFormSelect); - const findFormSelectOptions = () => findFormSelect().findAllComponents('option'); + const findCollapsibleListbox = () => extendedWrapper(wrapper.findComponent(GlCollapsibleListbox)); + const findCollapsibleListboxOptions = () => findCollapsibleListbox().findAllByRole('option'); const findDatepicker = () => wrapper.findComponent(GlDatepicker); const findLink = () => wrapper.findComponent(GlLink); const findIcon = () => wrapper.findComponent(GlIcon); @@ -91,7 +97,6 @@ describe('InviteModalBase', () => { const actionButton = findActionButton(); expect(actionButton.text()).toBe(INVITE_BUTTON_TEXT); - expect(actionButton.attributes('data-qa-selector')).toBe('invite_button'); expect(actionButton.props()).toMatchObject({ variant: 'confirm', @@ -103,17 +108,47 @@ describe('InviteModalBase', () => { describe('rendering the access levels dropdown', () => { beforeEach(() => { createComponent({ + props: { isLoadingRoles: true }, mountFn: mountExtended, }); }); + it('passes `isLoadingRoles` prop to the dropdown', () => { + expect(findCollapsibleListbox().props('loading')).toBe(true); + }); + it('sets the default dropdown text to the default access level name', () => { - expect(findFormSelect().exists()).toBe(true); - expect(findFormSelect().element.value).toBe('10'); + expect(findCollapsibleListbox().exists()).toBe(true); + const option = findCollapsibleListbox().find('[aria-selected]'); + expect(option.text()).toBe('Reporter'); + }); + + it('updates the selection base on changes in the dropdown', async () => { + wrapper.setProps({ accessLevels: { validRoles: [] } }); + expect(findCollapsibleListbox().props('selected')).not.toHaveLength(0); + await nextTick(); + + expect(findCollapsibleListboxOptions()).toHaveLength(0); + expect(findCollapsibleListbox().props('selected')).toHaveLength(0); + }); + + it('reset the dropdown to the default option', async () => { + const developerOption = findCollapsibleListboxOptions().at(2); + await developerOption.trigger('click'); + + let option; + option = findCollapsibleListbox().find('[aria-selected]'); + expect(option.text()).toBe('Developer'); + + // Reset the dropdown by clicking cancel button + await findCancelButton().trigger('click'); + + option = findCollapsibleListbox().find('[aria-selected]'); + expect(option.text()).toBe('Reporter'); }); it('renders dropdown items for each accessLevel', () => { - expect(findFormSelectOptions()).toHaveLength(5); + expect(findCollapsibleListboxOptions()).toHaveLength(5); }); }); @@ -211,7 +246,7 @@ describe('InviteModalBase', () => { it('renders correct blocks', () => { expect(findIcon().exists()).toBe(false); expect(findDisabledInput().exists()).toBe(false); - expect(findFormSelect().exists()).toBe(true); + expect(findCollapsibleListbox().exists()).toBe(true); expect(findDatepicker().exists()).toBe(true); expect(wrapper.findComponent(GlModal).text()).toMatch(textRegex); }); 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 a4b8a8b0197..a2b21367388 100644 --- a/spec/frontend/invite_members/components/members_token_select_spec.js +++ b/spec/frontend/invite_members/components/members_token_select_spec.js @@ -6,23 +6,32 @@ import waitForPromises from 'helpers/wait_for_promises'; import * as UserApi from '~/api/user_api'; import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; import { VALID_TOKEN_BACKGROUND, INVALID_TOKEN_BACKGROUND } from '~/invite_members/constants'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; const label = 'testgroup'; const placeholder = 'Search for a member'; +const rootGroupId = '31'; const user1 = { id: 1, name: 'John Smith', username: 'one_1', avatar_url: '' }; const user2 = { id: 2, name: 'Jane Doe', username: 'two_2', avatar_url: '' }; const allUsers = [user1, user2]; +const handleEnterSpy = jest.fn(); -const createComponent = (props) => { +const createComponent = (props = {}, glFeatures = {}) => { return shallowMount(MembersTokenSelect, { propsData: { ariaLabelledby: label, invalidMembers: {}, placeholder, + rootGroupId, ...props, }, + provide: { glFeatures }, stubs: { - GlTokenSelector: stubComponent(GlTokenSelector), + GlTokenSelector: stubComponent(GlTokenSelector, { + methods: { + handleEnter: handleEnterSpy, + }, + }), }, }); }; @@ -84,23 +93,11 @@ describe('MembersTokenSelect', () => { wrapper = createComponent(); }); - describe('when input is focused for the first time (modal auto-focus)', () => { - it('does not call the API', async () => { - findTokenSelector().vm.$emit('focus'); - - await waitForPromises(); - - expect(UserApi.getUsers).not.toHaveBeenCalled(); - }); - }); - describe('when input is manually focused', () => { it('calls the API and sets dropdown items as request result', async () => { const tokenSelector = findTokenSelector(); tokenSelector.vm.$emit('focus'); - tokenSelector.vm.$emit('blur'); - tokenSelector.vm.$emit('focus'); await waitForPromises(); @@ -173,6 +170,29 @@ describe('MembersTokenSelect', () => { }); }); }); + + describe('when API search fails', () => { + beforeEach(() => { + jest.spyOn(Sentry, 'captureException'); + jest.spyOn(UserApi, 'getUsers').mockRejectedValue('error'); + }); + + it('reports to sentry', async () => { + tokenSelector.vm.$emit('text-input', 'Den'); + + await waitForPromises(); + + expect(Sentry.captureException).toHaveBeenCalledWith('error'); + }); + }); + + it('allows tab to function as enter', () => { + tokenSelector.vm.$emit('text-input', 'username'); + + tokenSelector.vm.$emit('keydown', new KeyboardEvent('keydown', { key: 'Tab' })); + + expect(handleEnterSpy).toHaveBeenCalled(); + }); }); describe('when user is selected', () => { @@ -215,31 +235,45 @@ describe('MembersTokenSelect', () => { }); }); - describe('when component is mounted for a group using a saml provider', () => { + describe('when component is mounted for a group using a SAML provider', () => { const searchParam = 'name'; - const samlProviderId = 123; - let resolveApiRequest; beforeEach(() => { - jest.spyOn(UserApi, 'getUsers').mockImplementation( - () => - new Promise((resolve) => { - resolveApiRequest = resolve; - }), - ); + jest.spyOn(UserApi, 'getGroupUsers').mockResolvedValue({ data: allUsers }); - wrapper = createComponent({ filterId: samlProviderId, usersFilter: 'saml_provider_id' }); + wrapper = createComponent({ usersFilter: 'saml_provider_id' }, { groupUserSaml: true }); findTokenSelector().vm.$emit('text-input', searchParam); }); - it('calls the API with the saml provider ID param', () => { - resolveApiRequest({ data: allUsers }); - - expect(UserApi.getUsers).toHaveBeenCalledWith(searchParam, { + it('calls the group API with correct parameters', () => { + expect(UserApi.getGroupUsers).toHaveBeenCalledWith(searchParam, rootGroupId, { active: true, - without_project_bots: true, - saml_provider_id: samlProviderId, + include_saml_users: true, + include_service_accounts: true, + }); + }); + }); + + describe('when group_user_saml feature flag is disabled', () => { + describe('when component is mounted for a group using a SAML provider', () => { + const searchParam = 'name'; + const samlProviderId = 123; + + beforeEach(() => { + jest.spyOn(UserApi, 'getUsers').mockResolvedValue({ data: allUsers }); + + wrapper = createComponent({ filterId: samlProviderId, usersFilter: 'saml_provider_id' }); + + findTokenSelector().vm.$emit('text-input', searchParam); + }); + + it('calls the API with the saml provider ID param', () => { + expect(UserApi.getUsers).toHaveBeenCalledWith(searchParam, { + active: true, + without_project_bots: true, + saml_provider_id: samlProviderId, + }); }); }); }); diff --git a/spec/frontend/invite_members/mock_data/member_modal.js b/spec/frontend/invite_members/mock_data/member_modal.js index 8cde13bf69c..0c0e669b894 100644 --- a/spec/frontend/invite_members/mock_data/member_modal.js +++ b/spec/frontend/invite_members/mock_data/member_modal.js @@ -40,6 +40,7 @@ export const user6 = { export const postData = { user_id: `${user1.id},${user2.id}`, access_level: propsData.defaultAccessLevel, + member_role_id: null, expires_at: undefined, invite_source: inviteSource, format: 'json', @@ -47,6 +48,7 @@ export const postData = { export const emailPostData = { access_level: propsData.defaultAccessLevel, + member_role_id: null, expires_at: undefined, email: `${user3.name}`, invite_source: inviteSource, @@ -55,6 +57,7 @@ export const emailPostData = { export const singleUserPostData = { access_level: propsData.defaultAccessLevel, + member_role_id: null, expires_at: undefined, user_id: `${user1.id}`, email: `${user3.name}`, diff --git a/spec/frontend/invite_members/mock_data/modal_base.js b/spec/frontend/invite_members/mock_data/modal_base.js index 565e8d4df1e..c44e890da3d 100644 --- a/spec/frontend/invite_members/mock_data/modal_base.js +++ b/spec/frontend/invite_members/mock_data/modal_base.js @@ -3,7 +3,7 @@ export const propsData = { modalId: '_modal_id_', name: '_name_', accessLevels: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }, - defaultAccessLevel: 10, + defaultAccessLevel: 20, helpLink: 'https://example.com', labelIntroText: '_label_intro_text_', labelSearchField: '_label_search_field_', diff --git a/spec/frontend/issuable/popover/components/mr_popover_spec.js b/spec/frontend/issuable/popover/components/mr_popover_spec.js index 4ed783da853..80b04c05524 100644 --- a/spec/frontend/issuable/popover/components/mr_popover_spec.js +++ b/spec/frontend/issuable/popover/components/mr_popover_spec.js @@ -6,7 +6,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import MRPopover from '~/issuable/popover/components/mr_popover.vue'; import mergeRequestQuery from '~/issuable/popover/queries/merge_request.query.graphql'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; describe('MR Popover', () => { let wrapper; diff --git a/spec/frontend/issues/dashboard/components/index_spec.js b/spec/frontend/issues/dashboard/components/index_spec.js new file mode 100644 index 00000000000..51cb5c0acf6 --- /dev/null +++ b/spec/frontend/issues/dashboard/components/index_spec.js @@ -0,0 +1,18 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { mountIssuesDashboardApp } from '~/issues/dashboard'; + +describe('IssueDashboardRoot', () => { + beforeEach(() => { + setHTMLFixture( + '<div class="js-issues-dashboard" data-has-issue-date-filter-feature="true"></div>', + ); + }); + + afterEach(() => { + resetHTMLFixture(); + }); + + it('mounts without errors and vue warnings', async () => { + await expect(mountIssuesDashboardApp()).resolves.toBeTruthy(); + }); +}); 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 6bd952cd215..b432a29ee5c 100644 --- a/spec/frontend/issues/list/components/issues_list_app_spec.js +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -19,6 +19,7 @@ import { getIssuesCountsQueryResponse, getIssuesQueryEmptyResponse, getIssuesQueryResponse, + groupedFilteredTokens, locationSearch, setSortPreferenceMutationResponse, setSortPreferenceMutationResponseWithErrors, @@ -507,6 +508,13 @@ describe('CE IssuesListApp component', () => { }); describe('filter tokens', () => { + it('groups url params of assignee and author', () => { + setWindowLocation(locationSearch); + wrapper = mountComponent({ provide: { glFeatures: { groupMultiSelectTokens: true } } }); + + expect(findIssuableList().props('initialFilterValue')).toEqual(groupedFilteredTokens); + }); + it('is set from the url params', () => { setWindowLocation(locationSearch); wrapper = mountComponent(); diff --git a/spec/frontend/issues/list/mock_data.js b/spec/frontend/issues/list/mock_data.js index b9a8bc171db..e387c924418 100644 --- a/spec/frontend/issues/list/mock_data.js +++ b/spec/frontend/issues/list/mock_data.js @@ -231,19 +231,33 @@ export const locationSearchWithSpecialValues = [ 'health_status=None', ].join('&'); -export const filteredTokens = [ +const makeFilteredTokens = ({ grouped }) => [ { type: FILTERED_SEARCH_TERM, value: { data: 'find issues', operator: 'undefined' } }, { type: TOKEN_TYPE_AUTHOR, value: { data: 'homer', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_AUTHOR, value: { data: 'marge', operator: OPERATOR_NOT } }, - { type: TOKEN_TYPE_AUTHOR, value: { data: 'burns', operator: OPERATOR_OR } }, - { type: TOKEN_TYPE_AUTHOR, value: { data: 'smithers', operator: OPERATOR_OR } }, + ...(grouped + ? [ + { type: TOKEN_TYPE_AUTHOR, value: { data: ['marge'], operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_AUTHOR, value: { data: ['burns', 'smithers'], operator: OPERATOR_OR } }, + ] + : [ + { type: TOKEN_TYPE_AUTHOR, value: { data: 'marge', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_AUTHOR, value: { data: 'burns', operator: OPERATOR_OR } }, + { type: TOKEN_TYPE_AUTHOR, value: { data: 'smithers', operator: OPERATOR_OR } }, + ]), { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'bart', operator: OPERATOR_IS } }, { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'lisa', operator: OPERATOR_IS } }, { type: TOKEN_TYPE_ASSIGNEE, value: { data: '5', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'patty', operator: OPERATOR_NOT } }, - { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'selma', operator: OPERATOR_NOT } }, - { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'carl', operator: OPERATOR_OR } }, - { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'lenny', operator: OPERATOR_OR } }, + ...(grouped + ? [ + { type: TOKEN_TYPE_ASSIGNEE, value: { data: ['patty', 'selma'], operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_ASSIGNEE, value: { data: ['carl', 'lenny'], operator: OPERATOR_OR } }, + ] + : [ + { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'patty', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'selma', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'carl', operator: OPERATOR_OR } }, + { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'lenny', operator: OPERATOR_OR } }, + ]), { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 3', operator: OPERATOR_IS } }, { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 4', operator: OPERATOR_IS } }, { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 20', operator: OPERATOR_NOT } }, @@ -279,6 +293,9 @@ export const filteredTokens = [ { type: TOKEN_TYPE_HEALTH, value: { data: 'onTrack', operator: OPERATOR_NOT } }, ]; +export const filteredTokens = makeFilteredTokens({ grouped: false }); +export const groupedFilteredTokens = makeFilteredTokens({ grouped: true }); + export const filteredTokensWithSpecialValues = [ { type: TOKEN_TYPE_ASSIGNEE, value: { data: '123', operator: OPERATOR_IS } }, { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'bart', operator: OPERATOR_IS } }, diff --git a/spec/frontend/issues/list/utils_spec.js b/spec/frontend/issues/list/utils_spec.js index c14dcf96c98..e13a69b7444 100644 --- a/spec/frontend/issues/list/utils_spec.js +++ b/spec/frontend/issues/list/utils_spec.js @@ -5,6 +5,7 @@ import { apiParamsWithSpecialValues, filteredTokens, filteredTokensWithSpecialValues, + groupedFilteredTokens, locationSearch, locationSearchWithSpecialValues, urlParams, @@ -19,6 +20,7 @@ import { getInitialPageParams, getSortKey, getSortOptions, + groupMultiSelectFilterTokens, isSortKey, } from '~/issues/list/utils'; import { DEFAULT_PAGE_SIZE } from '~/vue_shared/issuable/list/constants'; @@ -163,3 +165,14 @@ describe('convertToSearchQuery', () => { expect(convertToSearchQuery(filteredTokens)).toBe('find issues'); }); }); + +describe('groupMultiSelectFilterTokens', () => { + it('groups multiSelect filter tokens with || and != operators', () => { + expect( + groupMultiSelectFilterTokens(filteredTokens, [ + { type: 'assignee', multiSelect: true }, + { type: 'author', multiSelect: true }, + ]), + ).toEqual(groupedFilteredTokens); + }); +}); diff --git a/spec/frontend/issues/show/components/app_spec.js b/spec/frontend/issues/show/components/app_spec.js index 8999952c54c..f9ce7c20ad6 100644 --- a/spec/frontend/issues/show/components/app_spec.js +++ b/spec/frontend/issues/show/components/app_spec.js @@ -94,6 +94,10 @@ describe('Issuable output', () => { axiosMock.onPut().reply(HTTP_STATUS_OK, putRequest); }); + afterEach(() => { + document.body.classList?.remove('issuable-sticky-header-visible'); + }); + describe('update', () => { beforeEach(async () => { await createComponent(); @@ -334,6 +338,29 @@ describe('Issuable output', () => { }); }, ); + + describe('document body class', () => { + beforeEach(async () => { + await createComponent({ props: { canUpdate: false } }); + }); + + it('adds the css class to the document body', () => { + wrapper.findComponent(StickyHeader).vm.$emit('show'); + expect(document.body.classList?.contains('issuable-sticky-header-visible')).toBe(true); + }); + + it('removes the css class from the document body', () => { + wrapper.findComponent(StickyHeader).vm.$emit('show'); + wrapper.findComponent(StickyHeader).vm.$emit('hide'); + expect(document.body.classList?.contains('issuable-sticky-header-visible')).toBe(false); + }); + + it('removes the css class from the document body when unmounting', () => { + wrapper.findComponent(StickyHeader).vm.$emit('show'); + wrapper.vm.$destroy(); + expect(document.body.classList?.contains('issuable-sticky-header-visible')).toBe(false); + }); + }); }); describe('Composable description component', () => { diff --git a/spec/frontend/issues/show/components/header_actions_spec.js b/spec/frontend/issues/show/components/header_actions_spec.js index d0c2a1a5f1b..33fd9d39feb 100644 --- a/spec/frontend/issues/show/components/header_actions_spec.js +++ b/spec/frontend/issues/show/components/header_actions_spec.js @@ -6,13 +6,13 @@ import { GlModal, GlButton, } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; // eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import VueApollo from 'vue-apollo'; import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import { mockTracking } from 'helpers/tracking_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createAlert, VARIANT_SUCCESS } from '~/alert'; import { STATUS_CLOSED, @@ -132,11 +132,11 @@ describe('HeaderActions component', () => { const findDesktopDropdownItems = () => findDesktopDropdown().findAllComponents(GlDisclosureDropdownItem); const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector); - const findReportAbuseButton = () => wrapper.find(`[data-testid="report-abuse-item"]`); - const findNotificationWidget = () => wrapper.find(`[data-testid="notification-toggle"]`); - const findLockIssueWidget = () => wrapper.find(`[data-testid="lock-issue-toggle"]`); - const findCopyRefenceDropdownItem = () => wrapper.find(`[data-testid="copy-reference"]`); - const findCopyEmailItem = () => wrapper.find(`[data-testid="copy-email"]`); + const findReportAbuseButton = () => wrapper.findByTestId('report-abuse-item'); + const findNotificationWidget = () => wrapper.findByTestId('notification-toggle'); + const findLockIssueWidget = () => wrapper.findByTestId('lock-issue-toggle'); + const findCopyRefenceDropdownItem = () => wrapper.findByTestId('copy-reference'); + const findCopyEmailItem = () => wrapper.findByTestId('copy-email'); const findModal = () => wrapper.findComponent(GlModal); @@ -176,7 +176,7 @@ describe('HeaderActions component', () => { window.gon.current_user_id = 1; } - return shallowMount(HeaderActions, { + return shallowMountExtended(HeaderActions, { apolloProvider: createMockApollo(handlers), store, provide: { @@ -625,6 +625,10 @@ describe('HeaderActions component', () => { expect(toast).toHaveBeenCalledWith('Reference copied'); }); + + it('contains copy reference class', () => { + expect(findCopyRefenceDropdownItem().classes()).toContain('js-copy-reference'); + }); }); }); diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions_spec.js index efe89100e90..74c998bfc51 100644 --- a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions_spec.js +++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions_spec.js @@ -6,6 +6,7 @@ import { PREREQUISITES_DOC_LINK, OAUTH_SELF_MANAGED_DOC_LINK, SET_UP_INSTANCE_DOC_LINK, + JIRA_USER_REQUIREMENTS_DOC_LINK, } from '~/jira_connect/subscriptions/constants'; import SetupInstructions from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue'; @@ -15,6 +16,7 @@ describe('SetupInstructions', () => { const findPrerequisitesGlLink = () => wrapper.findAllComponents(GlLink).at(0); const findOAuthGlLink = () => wrapper.findAllComponents(GlLink).at(1); const findSetUpInstanceGlLink = () => wrapper.findAllComponents(GlLink).at(2); + const findJiraUserRequirementsGlLink = () => wrapper.findAllComponents(GlLink).at(3); const findBackButton = () => wrapper.findAllComponents(GlButton).at(0); const findNextButton = () => wrapper.findAllComponents(GlButton).at(1); const findCheckboxAtIndex = (index) => wrapper.findAllComponents(GlFormCheckbox).at(index); @@ -40,6 +42,12 @@ describe('SetupInstructions', () => { expect(findSetUpInstanceGlLink().attributes('href')).toBe(SET_UP_INSTANCE_DOC_LINK); }); + it('renders "Jira user requirements" link to documentation', () => { + expect(findJiraUserRequirementsGlLink().attributes('href')).toBe( + JIRA_USER_REQUIREMENTS_DOC_LINK, + ); + }); + describe('NextButton', () => { it('emits next event when clicked and all steps checked', async () => { createComponent(); @@ -47,6 +55,7 @@ describe('SetupInstructions', () => { findCheckboxAtIndex(0).vm.$emit('input', true); findCheckboxAtIndex(1).vm.$emit('input', true); findCheckboxAtIndex(2).vm.$emit('input', true); + findCheckboxAtIndex(3).vm.$emit('input', true); await nextTick(); diff --git a/spec/frontend/kubernetes_dashboard/components/page_title_spec.js b/spec/frontend/kubernetes_dashboard/components/page_title_spec.js new file mode 100644 index 00000000000..ee2ac44d6a3 --- /dev/null +++ b/spec/frontend/kubernetes_dashboard/components/page_title_spec.js @@ -0,0 +1,35 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon, GlSprintf } from '@gitlab/ui'; +import PageTitle from '~/kubernetes_dashboard/components/page_title.vue'; + +const agent = { + name: 'my-agent', + id: '123', +}; + +let wrapper; + +const createWrapper = () => { + wrapper = shallowMount(PageTitle, { + provide: { + agent, + }, + stubs: { GlSprintf }, + }); +}; + +const findIcon = () => wrapper.findComponent(GlIcon); + +describe('Page title component', () => { + it('renders Kubernetes agent icon', () => { + createWrapper(); + + expect(findIcon().props('name')).toBe('kubernetes-agent'); + }); + + it('renders agent information', () => { + createWrapper(); + + expect(wrapper.text()).toMatchInterpolatedText('Agent my-agent ID #123'); + }); +}); diff --git a/spec/frontend/kubernetes_dashboard/components/workload_details_item_spec.js b/spec/frontend/kubernetes_dashboard/components/workload_details_item_spec.js new file mode 100644 index 00000000000..72af25e72e5 --- /dev/null +++ b/spec/frontend/kubernetes_dashboard/components/workload_details_item_spec.js @@ -0,0 +1,34 @@ +import { shallowMount } from '@vue/test-utils'; +import WorkloadDetailsItem from '~/kubernetes_dashboard/components/workload_details_item.vue'; + +let wrapper; + +const propsData = { + label: 'name', +}; +const slots = { + default: '<b>slot value</b>', +}; + +const createWrapper = () => { + wrapper = shallowMount(WorkloadDetailsItem, { + propsData, + slots, + }); +}; + +const findLabel = () => wrapper.findComponent('label'); + +describe('Workload details item component', () => { + beforeEach(() => { + createWrapper(); + }); + + it('renders the correct label', () => { + expect(findLabel().text()).toBe(propsData.label); + }); + + it('renders slot content', () => { + expect(wrapper.html()).toContain(slots.default); + }); +}); diff --git a/spec/frontend/kubernetes_dashboard/components/workload_details_spec.js b/spec/frontend/kubernetes_dashboard/components/workload_details_spec.js new file mode 100644 index 00000000000..fc47c658ebe --- /dev/null +++ b/spec/frontend/kubernetes_dashboard/components/workload_details_spec.js @@ -0,0 +1,53 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlBadge, GlTruncate } from '@gitlab/ui'; +import WorkloadDetails from '~/kubernetes_dashboard/components/workload_details.vue'; +import WorkloadDetailsItem from '~/kubernetes_dashboard/components/workload_details_item.vue'; +import { WORKLOAD_STATUS_BADGE_VARIANTS } from '~/kubernetes_dashboard/constants'; +import { mockPodsTableItems } from '../graphql/mock_data'; + +let wrapper; + +const defaultItem = mockPodsTableItems[0]; + +const createWrapper = (item = defaultItem) => { + wrapper = shallowMount(WorkloadDetails, { + propsData: { + item, + }, + stubs: { GlTruncate }, + }); +}; + +const findAllWorkloadDetailsItems = () => wrapper.findAllComponents(WorkloadDetailsItem); +const findWorkloadDetailsItem = (at) => findAllWorkloadDetailsItems().at(at); +const findAllBadges = () => wrapper.findAllComponents(GlBadge); +const findBadge = (at) => findAllBadges().at(at); + +describe('Workload details component', () => { + beforeEach(() => { + createWrapper(); + }); + + it.each` + label | data | index + ${'Name'} | ${defaultItem.name} | ${0} + ${'Kind'} | ${defaultItem.kind} | ${1} + ${'Labels'} | ${'key=value'} | ${2} + ${'Status'} | ${defaultItem.status} | ${3} + ${'Annotations'} | ${'annotation: text another: text'} | ${4} + `('renders a list item for each not empty value', ({ label, data, index }) => { + expect(findWorkloadDetailsItem(index).props('label')).toBe(label); + expect(findWorkloadDetailsItem(index).text()).toMatchInterpolatedText(data); + }); + + it('renders a badge for each of the labels', () => { + const label = 'key=value'; + expect(findBadge(0).text()).toBe(label); + }); + + it('renders a badge for the status value', () => { + const { status } = defaultItem; + expect(findBadge(1).text()).toBe(status); + expect(findBadge(1).props('variant')).toBe(WORKLOAD_STATUS_BADGE_VARIANTS[status]); + }); +}); diff --git a/spec/frontend/kubernetes_dashboard/components/workload_layout_spec.js b/spec/frontend/kubernetes_dashboard/components/workload_layout_spec.js new file mode 100644 index 00000000000..1dc5bd4f165 --- /dev/null +++ b/spec/frontend/kubernetes_dashboard/components/workload_layout_spec.js @@ -0,0 +1,141 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon, GlAlert, GlDrawer } from '@gitlab/ui'; +import WorkloadLayout from '~/kubernetes_dashboard/components/workload_layout.vue'; +import WorkloadStats from '~/kubernetes_dashboard/components/workload_stats.vue'; +import WorkloadTable from '~/kubernetes_dashboard/components/workload_table.vue'; +import WorkloadDetails from '~/kubernetes_dashboard/components/workload_details.vue'; +import { mockPodStats, mockPodsTableItems } from '../graphql/mock_data'; + +let wrapper; + +const defaultProps = { + stats: mockPodStats, + items: mockPodsTableItems, +}; + +const createWrapper = (propsData = {}) => { + wrapper = shallowMount(WorkloadLayout, { + propsData: { + ...defaultProps, + ...propsData, + }, + stubs: { GlDrawer }, + }); +}; + +const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); +const findErrorAlert = () => wrapper.findComponent(GlAlert); +const findDrawer = () => wrapper.findComponent(GlDrawer); +const findWorkloadStats = () => wrapper.findComponent(WorkloadStats); +const findWorkloadTable = () => wrapper.findComponent(WorkloadTable); +const findWorkloadDetails = () => wrapper.findComponent(WorkloadDetails); + +describe('Workload layout component', () => { + describe('when loading', () => { + beforeEach(() => { + createWrapper({ loading: true, errorMessage: 'error' }); + }); + + it('renders a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + + it("doesn't render an error message", () => { + expect(findErrorAlert().exists()).toBe(false); + }); + + it("doesn't render workload stats", () => { + expect(findWorkloadStats().exists()).toBe(false); + }); + + it("doesn't render workload table", () => { + expect(findWorkloadTable().exists()).toBe(false); + }); + + it("doesn't render details drawer", () => { + expect(findDrawer().exists()).toBe(false); + }); + }); + + describe('when received an error', () => { + beforeEach(() => { + createWrapper({ errorMessage: 'error' }); + }); + + it("doesn't render a loading icon", () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders an error alert with the correct message and props', () => { + expect(findErrorAlert().text()).toBe('error'); + expect(findErrorAlert().props()).toMatchObject({ variant: 'danger', dismissible: false }); + }); + + it("doesn't render workload stats", () => { + expect(findWorkloadStats().exists()).toBe(false); + }); + + it("doesn't render workload table", () => { + expect(findWorkloadTable().exists()).toBe(false); + }); + + it("doesn't render details drawer", () => { + expect(findDrawer().exists()).toBe(false); + }); + }); + + describe('when received the data', () => { + beforeEach(() => { + createWrapper(); + }); + + it("doesn't render a loading icon", () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it("doesn't render an error message", () => { + expect(findErrorAlert().exists()).toBe(false); + }); + + it('renders workload-stats component with the correct props', () => { + expect(findWorkloadStats().props('stats')).toBe(mockPodStats); + }); + + it('renders workload-table component with the correct props', () => { + expect(findWorkloadTable().props('items')).toBe(mockPodsTableItems); + }); + + it('renders a drawer', () => { + expect(findDrawer().exists()).toBe(true); + }); + + describe('drawer', () => { + it('is closed by default', () => { + expect(findDrawer().props('open')).toBe(false); + }); + + it('is opened when an item was selected', async () => { + await findWorkloadTable().vm.$emit('select-item', mockPodsTableItems[0]); + expect(findDrawer().props('open')).toBe(true); + }); + + it('is closed when clicked on a cross button', async () => { + await findWorkloadTable().vm.$emit('select-item', mockPodsTableItems[0]); + expect(findDrawer().props('open')).toBe(true); + + await findDrawer().vm.$emit('close'); + expect(findDrawer().props('open')).toBe(false); + }); + + it('renders a title with the selected item name', async () => { + await findWorkloadTable().vm.$emit('select-item', mockPodsTableItems[0]); + expect(findDrawer().text()).toContain(mockPodsTableItems[0].name); + }); + + it('renders WorkloadDetails with the correct props', async () => { + await findWorkloadTable().vm.$emit('select-item', mockPodsTableItems[0]); + expect(findWorkloadDetails().props('item')).toBe(mockPodsTableItems[0]); + }); + }); + }); +}); diff --git a/spec/frontend/kubernetes_dashboard/components/workload_stats_spec.js b/spec/frontend/kubernetes_dashboard/components/workload_stats_spec.js new file mode 100644 index 00000000000..d1bee0c0a16 --- /dev/null +++ b/spec/frontend/kubernetes_dashboard/components/workload_stats_spec.js @@ -0,0 +1,43 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import WorkloadStats from '~/kubernetes_dashboard/components/workload_stats.vue'; +import { mockPodStats } from '../graphql/mock_data'; + +let wrapper; + +const createWrapper = () => { + wrapper = shallowMount(WorkloadStats, { + propsData: { + stats: mockPodStats, + }, + }); +}; + +const findAllStats = () => wrapper.findAllComponents(GlSingleStat); +const findSingleStat = (at) => findAllStats().at(at); + +describe('Workload stats component', () => { + it('renders GlSingleStat component for each stat', () => { + createWrapper(); + + expect(findAllStats()).toHaveLength(4); + }); + + it.each` + count | title | index + ${2} | ${'Running'} | ${0} + ${1} | ${'Pending'} | ${1} + ${1} | ${'Succeeded'} | ${2} + ${2} | ${'Failed'} | ${3} + `( + 'renders stat with title "$title" and count "$count" at index $index', + ({ count, title, index }) => { + createWrapper(); + + expect(findSingleStat(index).props()).toMatchObject({ + value: count, + title, + }); + }, + ); +}); diff --git a/spec/frontend/kubernetes_dashboard/components/workload_table_spec.js b/spec/frontend/kubernetes_dashboard/components/workload_table_spec.js new file mode 100644 index 00000000000..369b8f32c2d --- /dev/null +++ b/spec/frontend/kubernetes_dashboard/components/workload_table_spec.js @@ -0,0 +1,128 @@ +import { mount } from '@vue/test-utils'; +import { GlTable, GlBadge, GlPagination } from '@gitlab/ui'; +import WorkloadTable from '~/kubernetes_dashboard/components/workload_table.vue'; +import { TABLE_HEADING_CLASSES, PAGE_SIZE } from '~/kubernetes_dashboard/constants'; +import { mockPodsTableItems } from '../graphql/mock_data'; + +let wrapper; + +const createWrapper = (propsData = {}) => { + wrapper = mount(WorkloadTable, { + propsData, + }); +}; + +const findTable = () => wrapper.findComponent(GlTable); +const findAllRows = () => findTable().find('tbody').findAll('tr'); +const findRow = (at) => findAllRows().at(at); +const findAllBadges = () => wrapper.findAllComponents(GlBadge); +const findBadge = (at) => findAllBadges().at(at); +const findPagination = () => wrapper.findComponent(GlPagination); + +describe('Workload table component', () => { + it('renders GlTable component with the default fields if no fields specified in props', () => { + createWrapper({ items: mockPodsTableItems }); + const defaultFields = [ + { + key: 'name', + label: 'Name', + thClass: TABLE_HEADING_CLASSES, + sortable: true, + }, + { + key: 'status', + label: 'Status', + thClass: TABLE_HEADING_CLASSES, + sortable: true, + }, + { + key: 'namespace', + label: 'Namespace', + thClass: TABLE_HEADING_CLASSES, + sortable: true, + }, + { + key: 'age', + label: 'Age', + thClass: TABLE_HEADING_CLASSES, + sortable: true, + }, + ]; + + expect(findTable().props('fields')).toEqual(defaultFields); + }); + + it('renders GlTable component fields specified in props', () => { + const customFields = [ + { + key: 'field-1', + label: 'Field-1', + thClass: TABLE_HEADING_CLASSES, + sortable: true, + }, + { + key: 'field-2', + label: 'Field-2', + thClass: TABLE_HEADING_CLASSES, + sortable: true, + }, + ]; + createWrapper({ items: mockPodsTableItems, fields: customFields }); + + expect(findTable().props('fields')).toEqual(customFields); + }); + + describe('table rows', () => { + beforeEach(() => { + createWrapper({ items: mockPodsTableItems }); + }); + + it('displays the correct number of rows', () => { + expect(findAllRows()).toHaveLength(mockPodsTableItems.length); + }); + + it('emits an event on row click', () => { + mockPodsTableItems.forEach((data, index) => { + findRow(index).trigger('click'); + + expect(wrapper.emitted('select-item')[index]).toEqual([data]); + }); + }); + + it('renders correct data for each row', () => { + mockPodsTableItems.forEach((data, index) => { + expect(findRow(index).text()).toContain(data.name); + expect(findRow(index).text()).toContain(data.namespace); + expect(findRow(index).text()).toContain(data.status); + expect(findRow(index).text()).toContain(data.age); + }); + }); + + it('renders a badge for the status', () => { + expect(findAllBadges()).toHaveLength(mockPodsTableItems.length); + }); + + it.each` + status | variant | index + ${'Running'} | ${'info'} | ${0} + ${'Running'} | ${'info'} | ${1} + ${'Pending'} | ${'warning'} | ${2} + ${'Succeeded'} | ${'success'} | ${3} + ${'Failed'} | ${'danger'} | ${4} + ${'Failed'} | ${'danger'} | ${5} + `( + 'renders "$variant" badge for status "$status" at index "$index"', + ({ status, variant, index }) => { + expect(findBadge(index).text()).toBe(status); + expect(findBadge(index).props('variant')).toBe(variant); + }, + ); + + it('renders pagination', () => { + expect(findPagination().props()).toMatchObject({ + totalItems: mockPodsTableItems.length, + perPage: PAGE_SIZE, + }); + }); + }); +}); diff --git a/spec/frontend/kubernetes_dashboard/graphql/mock_data.js b/spec/frontend/kubernetes_dashboard/graphql/mock_data.js new file mode 100644 index 00000000000..674425a5bc9 --- /dev/null +++ b/spec/frontend/kubernetes_dashboard/graphql/mock_data.js @@ -0,0 +1,353 @@ +const runningPod = { + status: { phase: 'Running' }, + metadata: { + name: 'pod-1', + namespace: 'default', + creationTimestamp: '2023-07-31T11:50:17Z', + labels: { key: 'value' }, + annotations: { annotation: 'text', another: 'text' }, + }, +}; +const pendingPod = { + status: { phase: 'Pending' }, + metadata: { + name: 'pod-2', + namespace: 'new-namespace', + creationTimestamp: '2023-11-21T11:50:59Z', + labels: {}, + annotations: {}, + }, +}; +const succeededPod = { + status: { phase: 'Succeeded' }, + metadata: { + name: 'pod-3', + namespace: 'default', + creationTimestamp: '2023-07-31T11:50:17Z', + labels: {}, + annotations: {}, + }, +}; +const failedPod = { + status: { phase: 'Failed' }, + metadata: { + name: 'pod-4', + namespace: 'default', + creationTimestamp: '2023-11-21T11:50:59Z', + labels: {}, + annotations: {}, + }, +}; + +export const k8sPodsMock = [runningPod, runningPod, pendingPod, succeededPod, failedPod, failedPod]; + +export const mockPodStats = [ + { + title: 'Running', + value: 2, + }, + { + title: 'Pending', + value: 1, + }, + { + title: 'Succeeded', + value: 1, + }, + { + title: 'Failed', + value: 2, + }, +]; + +export const mockPodsTableItems = [ + { + name: 'pod-1', + namespace: 'default', + status: 'Running', + age: '114d', + labels: { key: 'value' }, + annotations: { annotation: 'text', another: 'text' }, + kind: 'Pod', + }, + { + name: 'pod-1', + namespace: 'default', + status: 'Running', + age: '114d', + labels: {}, + annotations: {}, + kind: 'Pod', + }, + { + name: 'pod-2', + namespace: 'new-namespace', + status: 'Pending', + age: '1d', + labels: {}, + annotations: {}, + kind: 'Pod', + }, + { + name: 'pod-3', + namespace: 'default', + status: 'Succeeded', + age: '114d', + labels: {}, + annotations: {}, + kind: 'Pod', + }, + { + name: 'pod-4', + namespace: 'default', + status: 'Failed', + age: '1d', + labels: {}, + annotations: {}, + kind: 'Pod', + }, + { + name: 'pod-4', + namespace: 'default', + status: 'Failed', + age: '1d', + labels: {}, + annotations: {}, + kind: 'Pod', + }, +]; + +const pendingDeployment = { + status: { + conditions: [ + { type: 'Available', status: 'False' }, + { type: 'Progressing', status: 'True' }, + ], + }, + metadata: { + name: 'deployment-1', + namespace: 'new-namespace', + creationTimestamp: '2023-11-21T11:50:59Z', + labels: {}, + annotations: {}, + }, +}; +const readyDeployment = { + status: { + conditions: [ + { type: 'Available', status: 'True' }, + { type: 'Progressing', status: 'False' }, + ], + }, + metadata: { + name: 'deployment-2', + namespace: 'default', + creationTimestamp: '2023-07-31T11:50:17Z', + labels: {}, + annotations: {}, + }, +}; +const failedDeployment = { + status: { + conditions: [ + { type: 'Available', status: 'False' }, + { type: 'Progressing', status: 'False' }, + ], + }, + metadata: { + name: 'deployment-3', + namespace: 'default', + creationTimestamp: '2023-11-21T11:50:59Z', + labels: {}, + annotations: {}, + }, +}; + +export const k8sDeploymentsMock = [ + pendingDeployment, + readyDeployment, + readyDeployment, + failedDeployment, +]; + +export const mockDeploymentsStats = [ + { + title: 'Ready', + value: 2, + }, + { + title: 'Failed', + value: 1, + }, + { + title: 'Pending', + value: 1, + }, +]; + +export const mockDeploymentsTableItems = [ + { + name: 'deployment-1', + namespace: 'new-namespace', + status: 'Pending', + age: '1d', + labels: {}, + annotations: {}, + kind: 'Deployment', + }, + { + name: 'deployment-2', + namespace: 'default', + status: 'Ready', + age: '114d', + labels: {}, + annotations: {}, + kind: 'Deployment', + }, + { + name: 'deployment-2', + namespace: 'default', + status: 'Ready', + age: '114d', + labels: {}, + annotations: {}, + kind: 'Deployment', + }, + { + name: 'deployment-3', + namespace: 'default', + status: 'Failed', + age: '1d', + labels: {}, + annotations: {}, + kind: 'Deployment', + }, +]; + +const readyStatefulSet = { + status: { readyReplicas: 2 }, + spec: { replicas: 2 }, + metadata: { + name: 'statefulSet-2', + namespace: 'default', + creationTimestamp: '2023-07-31T11:50:17Z', + labels: {}, + annotations: {}, + }, +}; +const failedStatefulSet = { + status: { readyReplicas: 1 }, + spec: { replicas: 2 }, + metadata: { + name: 'statefulSet-3', + namespace: 'default', + creationTimestamp: '2023-11-21T11:50:59Z', + labels: {}, + annotations: {}, + }, +}; + +export const k8sStatefulSetsMock = [readyStatefulSet, readyStatefulSet, failedStatefulSet]; + +export const mockStatefulSetsStats = [ + { + title: 'Ready', + value: 2, + }, + { + title: 'Failed', + value: 1, + }, +]; + +export const mockStatefulSetsTableItems = [ + { + name: 'statefulSet-2', + namespace: 'default', + status: 'Ready', + age: '114d', + labels: {}, + annotations: {}, + kind: 'StatefulSet', + }, + { + name: 'statefulSet-2', + namespace: 'default', + status: 'Ready', + age: '114d', + labels: {}, + annotations: {}, + kind: 'StatefulSet', + }, + { + name: 'statefulSet-3', + namespace: 'default', + status: 'Failed', + age: '1d', + labels: {}, + annotations: {}, + kind: 'StatefulSet', + }, +]; + +export const k8sReplicaSetsMock = [readyStatefulSet, readyStatefulSet, failedStatefulSet]; + +export const mockReplicaSetsTableItems = mockStatefulSetsTableItems.map((item) => { + return { ...item, kind: 'ReplicaSet' }; +}); + +const readyDaemonSet = { + status: { numberMisscheduled: 0, numberReady: 2, desiredNumberScheduled: 2 }, + metadata: { + name: 'daemonSet-1', + namespace: 'default', + creationTimestamp: '2023-07-31T11:50:17Z', + labels: {}, + annotations: {}, + }, +}; + +const failedDaemonSet = { + status: { numberMisscheduled: 1, numberReady: 1, desiredNumberScheduled: 2 }, + metadata: { + name: 'daemonSet-2', + namespace: 'default', + creationTimestamp: '2023-11-21T11:50:59Z', + labels: {}, + annotations: {}, + }, +}; + +export const mockDaemonSetsStats = [ + { + title: 'Ready', + value: 1, + }, + { + title: 'Failed', + value: 1, + }, +]; + +export const mockDaemonSetsTableItems = [ + { + name: 'daemonSet-1', + namespace: 'default', + status: 'Ready', + age: '114d', + labels: {}, + annotations: {}, + kind: 'DaemonSet', + }, + { + name: 'daemonSet-2', + namespace: 'default', + status: 'Failed', + age: '1d', + labels: {}, + annotations: {}, + kind: 'DaemonSet', + }, +]; + +export const k8sDaemonSetsMock = [readyDaemonSet, failedDaemonSet]; diff --git a/spec/frontend/kubernetes_dashboard/graphql/resolvers/kubernetes_spec.js b/spec/frontend/kubernetes_dashboard/graphql/resolvers/kubernetes_spec.js new file mode 100644 index 00000000000..516d91af947 --- /dev/null +++ b/spec/frontend/kubernetes_dashboard/graphql/resolvers/kubernetes_spec.js @@ -0,0 +1,459 @@ +import { CoreV1Api, WatchApi, AppsV1Api } from '@gitlab/cluster-client'; +import { resolvers } from '~/kubernetes_dashboard/graphql/resolvers'; +import k8sDashboardPodsQuery from '~/kubernetes_dashboard/graphql/queries/k8s_dashboard_pods.query.graphql'; +import k8sDashboardDeploymentsQuery from '~/kubernetes_dashboard/graphql/queries/k8s_dashboard_deployments.query.graphql'; +import k8sDashboardStatefulSetsQuery from '~/kubernetes_dashboard/graphql/queries/k8s_dashboard_stateful_sets.query.graphql'; +import k8sDashboardReplicaSetsQuery from '~/kubernetes_dashboard/graphql/queries/k8s_dashboard_replica_sets.query.graphql'; +import k8sDashboardDaemonSetsQuery from '~/kubernetes_dashboard/graphql/queries/k8s_dashboard_daemon_sets.query.graphql'; +import { + k8sPodsMock, + k8sDeploymentsMock, + k8sStatefulSetsMock, + k8sReplicaSetsMock, + k8sDaemonSetsMock, +} from '../mock_data'; + +describe('~/frontend/environments/graphql/resolvers', () => { + let mockResolvers; + + const configuration = { + basePath: 'kas-proxy/', + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + }, + }; + + beforeEach(() => { + mockResolvers = resolvers; + }); + + describe('k8sPods', () => { + const client = { writeQuery: jest.fn() }; + + const mockWatcher = WatchApi.prototype; + const mockPodsListWatcherFn = jest.fn().mockImplementation(() => { + return Promise.resolve(mockWatcher); + }); + + const mockOnDataFn = jest.fn().mockImplementation((eventName, callback) => { + if (eventName === 'data') { + callback([]); + } + }); + + const mockPodsListFn = jest.fn().mockImplementation(() => { + return Promise.resolve({ + items: k8sPodsMock, + }); + }); + + const mockAllPodsListFn = jest.fn().mockImplementation(mockPodsListFn); + + describe('when the pods data is present', () => { + beforeEach(() => { + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1PodForAllNamespaces') + .mockImplementation(mockAllPodsListFn); + jest.spyOn(mockWatcher, 'subscribeToStream').mockImplementation(mockPodsListWatcherFn); + jest.spyOn(mockWatcher, 'on').mockImplementation(mockOnDataFn); + }); + + it('should request all pods from the cluster_client library and watch the events', async () => { + const pods = await mockResolvers.Query.k8sPods( + null, + { + configuration, + }, + { client }, + ); + + expect(mockAllPodsListFn).toHaveBeenCalled(); + expect(mockPodsListWatcherFn).toHaveBeenCalled(); + + expect(pods).toEqual(k8sPodsMock); + }); + + it('should update cache with the new data when received from the library', async () => { + await mockResolvers.Query.k8sPods(null, { configuration, namespace: '' }, { client }); + + expect(client.writeQuery).toHaveBeenCalledWith({ + query: k8sDashboardPodsQuery, + variables: { configuration, namespace: '' }, + data: { k8sPods: [] }, + }); + }); + }); + + it('should not watch pods from the cluster_client library when the pods data is not present', async () => { + jest.spyOn(CoreV1Api.prototype, 'listCoreV1PodForAllNamespaces').mockImplementation( + jest.fn().mockImplementation(() => { + return Promise.resolve({ + items: [], + }); + }), + ); + + await mockResolvers.Query.k8sPods(null, { configuration }, { client }); + + expect(mockPodsListWatcherFn).not.toHaveBeenCalled(); + }); + + it('should throw an error if the API call fails', async () => { + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1PodForAllNamespaces') + .mockRejectedValue(new Error('API error')); + + await expect( + mockResolvers.Query.k8sPods(null, { configuration }, { client }), + ).rejects.toThrow('API error'); + }); + }); + + describe('k8sDeployments', () => { + const client = { writeQuery: jest.fn() }; + + const mockWatcher = WatchApi.prototype; + const mockDeploymentsListWatcherFn = jest.fn().mockImplementation(() => { + return Promise.resolve(mockWatcher); + }); + + const mockOnDataFn = jest.fn().mockImplementation((eventName, callback) => { + if (eventName === 'data') { + callback([]); + } + }); + + const mockDeploymentsListFn = jest.fn().mockImplementation(() => { + return Promise.resolve({ + items: k8sDeploymentsMock, + }); + }); + + const mockAllDeploymentsListFn = jest.fn().mockImplementation(mockDeploymentsListFn); + + describe('when the deployments data is present', () => { + beforeEach(() => { + jest + .spyOn(AppsV1Api.prototype, 'listAppsV1DeploymentForAllNamespaces') + .mockImplementation(mockAllDeploymentsListFn); + jest + .spyOn(mockWatcher, 'subscribeToStream') + .mockImplementation(mockDeploymentsListWatcherFn); + jest.spyOn(mockWatcher, 'on').mockImplementation(mockOnDataFn); + }); + + it('should request all deployments from the cluster_client library and watch the events', async () => { + const deployments = await mockResolvers.Query.k8sDeployments( + null, + { + configuration, + }, + { client }, + ); + + expect(mockAllDeploymentsListFn).toHaveBeenCalled(); + expect(mockDeploymentsListWatcherFn).toHaveBeenCalled(); + + expect(deployments).toEqual(k8sDeploymentsMock); + }); + + it('should update cache with the new data when received from the library', async () => { + await mockResolvers.Query.k8sDeployments( + null, + { configuration, namespace: '' }, + { client }, + ); + + expect(client.writeQuery).toHaveBeenCalledWith({ + query: k8sDashboardDeploymentsQuery, + variables: { configuration, namespace: '' }, + data: { k8sDeployments: [] }, + }); + }); + }); + + it('should not watch deployments from the cluster_client library when the deployments data is not present', async () => { + jest.spyOn(AppsV1Api.prototype, 'listAppsV1DeploymentForAllNamespaces').mockImplementation( + jest.fn().mockImplementation(() => { + return Promise.resolve({ + items: [], + }); + }), + ); + + await mockResolvers.Query.k8sDeployments(null, { configuration }, { client }); + + expect(mockDeploymentsListWatcherFn).not.toHaveBeenCalled(); + }); + + it('should throw an error if the API call fails', async () => { + jest + .spyOn(AppsV1Api.prototype, 'listAppsV1DeploymentForAllNamespaces') + .mockRejectedValue(new Error('API error')); + + await expect( + mockResolvers.Query.k8sDeployments(null, { configuration }, { client }), + ).rejects.toThrow('API error'); + }); + }); + + describe('k8sStatefulSets', () => { + const client = { writeQuery: jest.fn() }; + + const mockWatcher = WatchApi.prototype; + const mockStatefulSetsListWatcherFn = jest.fn().mockImplementation(() => { + return Promise.resolve(mockWatcher); + }); + + const mockOnDataFn = jest.fn().mockImplementation((eventName, callback) => { + if (eventName === 'data') { + callback([]); + } + }); + + const mockStatefulSetsListFn = jest.fn().mockImplementation(() => { + return Promise.resolve({ + items: k8sStatefulSetsMock, + }); + }); + + const mockAllStatefulSetsListFn = jest.fn().mockImplementation(mockStatefulSetsListFn); + + describe('when the StatefulSets data is present', () => { + beforeEach(() => { + jest + .spyOn(AppsV1Api.prototype, 'listAppsV1StatefulSetForAllNamespaces') + .mockImplementation(mockAllStatefulSetsListFn); + jest + .spyOn(mockWatcher, 'subscribeToStream') + .mockImplementation(mockStatefulSetsListWatcherFn); + jest.spyOn(mockWatcher, 'on').mockImplementation(mockOnDataFn); + }); + + it('should request all StatefulSets from the cluster_client library and watch the events', async () => { + const StatefulSets = await mockResolvers.Query.k8sStatefulSets( + null, + { + configuration, + }, + { client }, + ); + + expect(mockAllStatefulSetsListFn).toHaveBeenCalled(); + expect(mockStatefulSetsListWatcherFn).toHaveBeenCalled(); + + expect(StatefulSets).toEqual(k8sStatefulSetsMock); + }); + + it('should update cache with the new data when received from the library', async () => { + await mockResolvers.Query.k8sStatefulSets( + null, + { configuration, namespace: '' }, + { client }, + ); + + expect(client.writeQuery).toHaveBeenCalledWith({ + query: k8sDashboardStatefulSetsQuery, + variables: { configuration, namespace: '' }, + data: { k8sStatefulSets: [] }, + }); + }); + }); + + it('should not watch StatefulSets from the cluster_client library when the StatefulSets data is not present', async () => { + jest.spyOn(AppsV1Api.prototype, 'listAppsV1StatefulSetForAllNamespaces').mockImplementation( + jest.fn().mockImplementation(() => { + return Promise.resolve({ + items: [], + }); + }), + ); + + await mockResolvers.Query.k8sStatefulSets(null, { configuration }, { client }); + + expect(mockStatefulSetsListWatcherFn).not.toHaveBeenCalled(); + }); + + it('should throw an error if the API call fails', async () => { + jest + .spyOn(AppsV1Api.prototype, 'listAppsV1StatefulSetForAllNamespaces') + .mockRejectedValue(new Error('API error')); + + await expect( + mockResolvers.Query.k8sStatefulSets(null, { configuration }, { client }), + ).rejects.toThrow('API error'); + }); + }); + + describe('k8sReplicaSets', () => { + const client = { writeQuery: jest.fn() }; + + const mockWatcher = WatchApi.prototype; + const mockReplicaSetsListWatcherFn = jest.fn().mockImplementation(() => { + return Promise.resolve(mockWatcher); + }); + + const mockOnDataFn = jest.fn().mockImplementation((eventName, callback) => { + if (eventName === 'data') { + callback([]); + } + }); + + const mockReplicaSetsListFn = jest.fn().mockImplementation(() => { + return Promise.resolve({ + items: k8sReplicaSetsMock, + }); + }); + + const mockAllReplicaSetsListFn = jest.fn().mockImplementation(mockReplicaSetsListFn); + + describe('when the ReplicaSets data is present', () => { + beforeEach(() => { + jest + .spyOn(AppsV1Api.prototype, 'listAppsV1ReplicaSetForAllNamespaces') + .mockImplementation(mockAllReplicaSetsListFn); + jest + .spyOn(mockWatcher, 'subscribeToStream') + .mockImplementation(mockReplicaSetsListWatcherFn); + jest.spyOn(mockWatcher, 'on').mockImplementation(mockOnDataFn); + }); + + it('should request all ReplicaSets from the cluster_client library and watch the events', async () => { + const ReplicaSets = await mockResolvers.Query.k8sReplicaSets( + null, + { + configuration, + }, + { client }, + ); + + expect(mockAllReplicaSetsListFn).toHaveBeenCalled(); + expect(mockReplicaSetsListWatcherFn).toHaveBeenCalled(); + + expect(ReplicaSets).toEqual(k8sReplicaSetsMock); + }); + + it('should update cache with the new data when received from the library', async () => { + await mockResolvers.Query.k8sReplicaSets( + null, + { configuration, namespace: '' }, + { client }, + ); + + expect(client.writeQuery).toHaveBeenCalledWith({ + query: k8sDashboardReplicaSetsQuery, + variables: { configuration, namespace: '' }, + data: { k8sReplicaSets: [] }, + }); + }); + }); + + it('should not watch ReplicaSets from the cluster_client library when the ReplicaSets data is not present', async () => { + jest.spyOn(AppsV1Api.prototype, 'listAppsV1ReplicaSetForAllNamespaces').mockImplementation( + jest.fn().mockImplementation(() => { + return Promise.resolve({ + items: [], + }); + }), + ); + + await mockResolvers.Query.k8sReplicaSets(null, { configuration }, { client }); + + expect(mockReplicaSetsListWatcherFn).not.toHaveBeenCalled(); + }); + + it('should throw an error if the API call fails', async () => { + jest + .spyOn(AppsV1Api.prototype, 'listAppsV1ReplicaSetForAllNamespaces') + .mockRejectedValue(new Error('API error')); + + await expect( + mockResolvers.Query.k8sReplicaSets(null, { configuration }, { client }), + ).rejects.toThrow('API error'); + }); + }); + + describe('k8sDaemonSets', () => { + const client = { writeQuery: jest.fn() }; + + const mockWatcher = WatchApi.prototype; + const mockDaemonSetsListWatcherFn = jest.fn().mockImplementation(() => { + return Promise.resolve(mockWatcher); + }); + + const mockOnDataFn = jest.fn().mockImplementation((eventName, callback) => { + if (eventName === 'data') { + callback([]); + } + }); + + const mockDaemonSetsListFn = jest.fn().mockImplementation(() => { + return Promise.resolve({ + items: k8sDaemonSetsMock, + }); + }); + + const mockAllDaemonSetsListFn = jest.fn().mockImplementation(mockDaemonSetsListFn); + + describe('when the DaemonSets data is present', () => { + beforeEach(() => { + jest + .spyOn(AppsV1Api.prototype, 'listAppsV1DaemonSetForAllNamespaces') + .mockImplementation(mockAllDaemonSetsListFn); + jest + .spyOn(mockWatcher, 'subscribeToStream') + .mockImplementation(mockDaemonSetsListWatcherFn); + jest.spyOn(mockWatcher, 'on').mockImplementation(mockOnDataFn); + }); + + it('should request all DaemonSets from the cluster_client library and watch the events', async () => { + const DaemonSets = await mockResolvers.Query.k8sDaemonSets( + null, + { + configuration, + }, + { client }, + ); + + expect(mockAllDaemonSetsListFn).toHaveBeenCalled(); + expect(mockDaemonSetsListWatcherFn).toHaveBeenCalled(); + + expect(DaemonSets).toEqual(k8sDaemonSetsMock); + }); + + it('should update cache with the new data when received from the library', async () => { + await mockResolvers.Query.k8sDaemonSets(null, { configuration, namespace: '' }, { client }); + + expect(client.writeQuery).toHaveBeenCalledWith({ + query: k8sDashboardDaemonSetsQuery, + variables: { configuration, namespace: '' }, + data: { k8sDaemonSets: [] }, + }); + }); + }); + + it('should not watch DaemonSets from the cluster_client library when the DaemonSets data is not present', async () => { + jest.spyOn(AppsV1Api.prototype, 'listAppsV1DaemonSetForAllNamespaces').mockImplementation( + jest.fn().mockImplementation(() => { + return Promise.resolve({ + items: [], + }); + }), + ); + + await mockResolvers.Query.k8sDaemonSets(null, { configuration }, { client }); + + expect(mockDaemonSetsListWatcherFn).not.toHaveBeenCalled(); + }); + + it('should throw an error if the API call fails', async () => { + jest + .spyOn(AppsV1Api.prototype, 'listAppsV1DaemonSetForAllNamespaces') + .mockRejectedValue(new Error('API error')); + + await expect( + mockResolvers.Query.k8sDaemonSets(null, { configuration }, { client }), + ).rejects.toThrow('API error'); + }); + }); +}); diff --git a/spec/frontend/kubernetes_dashboard/helpers/k8s_integration_helper_spec.js b/spec/frontend/kubernetes_dashboard/helpers/k8s_integration_helper_spec.js new file mode 100644 index 00000000000..2892d657aea --- /dev/null +++ b/spec/frontend/kubernetes_dashboard/helpers/k8s_integration_helper_spec.js @@ -0,0 +1,93 @@ +import { + getAge, + calculateDeploymentStatus, + calculateStatefulSetStatus, + calculateDaemonSetStatus, +} from '~/kubernetes_dashboard/helpers/k8s_integration_helper'; +import { useFakeDate } from 'helpers/fake_date'; + +describe('k8s_integration_helper', () => { + describe('getAge', () => { + useFakeDate(2023, 10, 23, 10, 10); + + it.each` + condition | measures | timestamp | expected + ${'timestamp > 1 day'} | ${'days'} | ${'2023-07-31T11:50:59Z'} | ${'114d'} + ${'timestamp = 1 day'} | ${'days'} | ${'2023-11-21T11:50:59Z'} | ${'1d'} + ${'1 day > timestamp > 1 hour'} | ${'hours'} | ${'2023-11-22T11:50:59Z'} | ${'22h'} + ${'timestamp = 1 hour'} | ${'hours'} | ${'2023-11-23T08:50:59Z'} | ${'1h'} + ${'1 hour > timestamp >1 minute'} | ${'minutes'} | ${'2023-11-23T09:50:59Z'} | ${'19m'} + ${'timestamp = 1 minute'} | ${'minutes'} | ${'2023-11-23T10:08:59Z'} | ${'1m'} + ${'1 minute > timestamp'} | ${'seconds'} | ${'2023-11-23T10:09:17Z'} | ${'43s'} + ${'timestamp = 1 second'} | ${'seconds'} | ${'2023-11-23T10:09:59Z'} | ${'1s'} + `('returns age in $measures when $condition', ({ timestamp, expected }) => { + expect(getAge(timestamp)).toBe(expected); + }); + }); + + describe('calculateDeploymentStatus', () => { + const pending = { + conditions: [ + { type: 'Available', status: 'False' }, + { type: 'Progressing', status: 'True' }, + ], + }; + const ready = { + conditions: [ + { type: 'Available', status: 'True' }, + { type: 'Progressing', status: 'False' }, + ], + }; + const failed = { + conditions: [ + { type: 'Available', status: 'False' }, + { type: 'Progressing', status: 'False' }, + ], + }; + + it.each` + condition | status | expected + ${'Available is false and Progressing is true'} | ${pending} | ${'Pending'} + ${'Available is true and Progressing is false'} | ${ready} | ${'Ready'} + ${'Available is false and Progressing is false'} | ${failed} | ${'Failed'} + `('returns status as $expected when $condition', ({ status, expected }) => { + expect(calculateDeploymentStatus({ status })).toBe(expected); + }); + }); + + describe('calculateStatefulSetStatus', () => { + const ready = { + status: { readyReplicas: 2 }, + spec: { replicas: 2 }, + }; + const failed = { + status: { readyReplicas: 1 }, + spec: { replicas: 2 }, + }; + + it.each` + condition | item | expected + ${'there are less readyReplicas than replicas in spec'} | ${failed} | ${'Failed'} + ${'there are the same amount of readyReplicas as in spec'} | ${ready} | ${'Ready'} + `('returns status as $expected when $condition', ({ item, expected }) => { + expect(calculateStatefulSetStatus(item)).toBe(expected); + }); + }); + + describe('calculateDaemonSetStatus', () => { + const ready = { + status: { numberMisscheduled: 0, numberReady: 2, desiredNumberScheduled: 2 }, + }; + const failed = { + status: { numberMisscheduled: 1, numberReady: 1, desiredNumberScheduled: 2 }, + }; + + it.each` + condition | item | expected + ${'there are less numberReady than desiredNumberScheduled or the numberMisscheduled is present'} | ${failed} | ${'Failed'} + ${'there are the same amount of numberReady and desiredNumberScheduled'} | ${ready} | ${'Ready'} + `('returns status as $expected when $condition', ({ item, expected }) => { + expect(calculateDaemonSetStatus(item)).toBe(expected); + }); + }); +}); diff --git a/spec/frontend/kubernetes_dashboard/pages/app_spec.js b/spec/frontend/kubernetes_dashboard/pages/app_spec.js new file mode 100644 index 00000000000..7d3b9cd2ee6 --- /dev/null +++ b/spec/frontend/kubernetes_dashboard/pages/app_spec.js @@ -0,0 +1,40 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import { shallowMount } from '@vue/test-utils'; +import createRouter from '~/kubernetes_dashboard/router/index'; +import { PODS_ROUTE_PATH } from '~/kubernetes_dashboard/router/constants'; +import App from '~/kubernetes_dashboard/pages/app.vue'; +import PageTitle from '~/kubernetes_dashboard/components/page_title.vue'; + +Vue.use(VueRouter); + +let wrapper; +let router; +const base = 'base/path'; + +const mountApp = async (route = PODS_ROUTE_PATH) => { + await router.push(route); + + wrapper = shallowMount(App, { + router, + provide: { + agent: {}, + }, + }); +}; + +const findPageTitle = () => wrapper.findComponent(PageTitle); + +describe('Kubernetes dashboard app component', () => { + beforeEach(() => { + router = createRouter({ + base, + }); + }); + + it(`sets the correct title for '${PODS_ROUTE_PATH}' path`, async () => { + await mountApp(); + + expect(findPageTitle().text()).toBe('Pods'); + }); +}); diff --git a/spec/frontend/kubernetes_dashboard/pages/daemon_sets_page_spec.js b/spec/frontend/kubernetes_dashboard/pages/daemon_sets_page_spec.js new file mode 100644 index 00000000000..a987f46fd78 --- /dev/null +++ b/spec/frontend/kubernetes_dashboard/pages/daemon_sets_page_spec.js @@ -0,0 +1,106 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import DaemonSetsPage from '~/kubernetes_dashboard/pages/daemon_sets_page.vue'; +import WorkloadLayout from '~/kubernetes_dashboard/components/workload_layout.vue'; +import { useFakeDate } from 'helpers/fake_date'; +import { + k8sDaemonSetsMock, + mockDaemonSetsStats, + mockDaemonSetsTableItems, +} from '../graphql/mock_data'; + +Vue.use(VueApollo); + +describe('Kubernetes dashboard daemonSets page', () => { + let wrapper; + + const configuration = { + basePath: 'kas/tunnel/url', + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + }, + }; + + const findWorkloadLayout = () => wrapper.findComponent(WorkloadLayout); + + const createApolloProvider = () => { + const mockResolvers = { + Query: { + k8sDaemonSets: jest.fn().mockReturnValue(k8sDaemonSetsMock), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + const createWrapper = (apolloProvider = createApolloProvider()) => { + wrapper = shallowMount(DaemonSetsPage, { + provide: { configuration }, + apolloProvider, + }); + }; + + describe('mounted', () => { + it('renders WorkloadLayout component', () => { + createWrapper(); + + expect(findWorkloadLayout().exists()).toBe(true); + }); + + it('sets loading prop for the WorkloadLayout', () => { + createWrapper(); + + expect(findWorkloadLayout().props('loading')).toBe(true); + }); + + it('removes loading prop from the WorkloadLayout when the list of pods loaded', async () => { + createWrapper(); + await waitForPromises(); + + expect(findWorkloadLayout().props('loading')).toBe(false); + }); + }); + + describe('when gets pods data', () => { + useFakeDate(2023, 10, 23, 10, 10); + + it('sets correct stats object for the WorkloadLayout', async () => { + createWrapper(); + await waitForPromises(); + + expect(findWorkloadLayout().props('stats')).toEqual(mockDaemonSetsStats); + }); + + it('sets correct table items object for the WorkloadLayout', async () => { + createWrapper(); + await waitForPromises(); + + expect(findWorkloadLayout().props('items')).toMatchObject(mockDaemonSetsTableItems); + }); + }); + + describe('when gets an error from the cluster_client API', () => { + const error = new Error('Error from the cluster_client API'); + const createErroredApolloProvider = () => { + const mockResolvers = { + Query: { + k8sDaemonSets: jest.fn().mockRejectedValueOnce(error), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + beforeEach(async () => { + createWrapper(createErroredApolloProvider()); + await waitForPromises(); + }); + + it('sets errorMessage prop for the WorkloadLayout', () => { + expect(findWorkloadLayout().props('errorMessage')).toBe(error.message); + }); + }); +}); diff --git a/spec/frontend/kubernetes_dashboard/pages/deployments_page_spec.js b/spec/frontend/kubernetes_dashboard/pages/deployments_page_spec.js new file mode 100644 index 00000000000..371116f0495 --- /dev/null +++ b/spec/frontend/kubernetes_dashboard/pages/deployments_page_spec.js @@ -0,0 +1,106 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import DeploymentsPage from '~/kubernetes_dashboard/pages/deployments_page.vue'; +import WorkloadLayout from '~/kubernetes_dashboard/components/workload_layout.vue'; +import { useFakeDate } from 'helpers/fake_date'; +import { + k8sDeploymentsMock, + mockDeploymentsStats, + mockDeploymentsTableItems, +} from '../graphql/mock_data'; + +Vue.use(VueApollo); + +describe('Kubernetes dashboard deployments page', () => { + let wrapper; + + const configuration = { + basePath: 'kas/tunnel/url', + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + }, + }; + + const findWorkloadLayout = () => wrapper.findComponent(WorkloadLayout); + + const createApolloProvider = () => { + const mockResolvers = { + Query: { + k8sDeployments: jest.fn().mockReturnValue(k8sDeploymentsMock), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + const createWrapper = (apolloProvider = createApolloProvider()) => { + wrapper = shallowMount(DeploymentsPage, { + provide: { configuration }, + apolloProvider, + }); + }; + + describe('mounted', () => { + it('renders WorkloadLayout component', () => { + createWrapper(); + + expect(findWorkloadLayout().exists()).toBe(true); + }); + + it('sets loading prop for the WorkloadLayout', () => { + createWrapper(); + + expect(findWorkloadLayout().props('loading')).toBe(true); + }); + + it('removes loading prop from the WorkloadLayout when the list of pods loaded', async () => { + createWrapper(); + await waitForPromises(); + + expect(findWorkloadLayout().props('loading')).toBe(false); + }); + }); + + describe('when gets pods data', () => { + useFakeDate(2023, 10, 23, 10, 10); + + it('sets correct stats object for the WorkloadLayout', async () => { + createWrapper(); + await waitForPromises(); + + expect(findWorkloadLayout().props('stats')).toEqual(mockDeploymentsStats); + }); + + it('sets correct table items object for the WorkloadLayout', async () => { + createWrapper(); + await waitForPromises(); + + expect(findWorkloadLayout().props('items')).toMatchObject(mockDeploymentsTableItems); + }); + }); + + describe('when gets an error from the cluster_client API', () => { + const error = new Error('Error from the cluster_client API'); + const createErroredApolloProvider = () => { + const mockResolvers = { + Query: { + k8sDeployments: jest.fn().mockRejectedValueOnce(error), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + beforeEach(async () => { + createWrapper(createErroredApolloProvider()); + await waitForPromises(); + }); + + it('sets errorMessage prop for the WorkloadLayout', () => { + expect(findWorkloadLayout().props('errorMessage')).toBe(error.message); + }); + }); +}); diff --git a/spec/frontend/kubernetes_dashboard/pages/pods_page_spec.js b/spec/frontend/kubernetes_dashboard/pages/pods_page_spec.js new file mode 100644 index 00000000000..28a98bad211 --- /dev/null +++ b/spec/frontend/kubernetes_dashboard/pages/pods_page_spec.js @@ -0,0 +1,102 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import PodsPage from '~/kubernetes_dashboard/pages/pods_page.vue'; +import WorkloadLayout from '~/kubernetes_dashboard/components/workload_layout.vue'; +import { useFakeDate } from 'helpers/fake_date'; +import { k8sPodsMock, mockPodStats, mockPodsTableItems } from '../graphql/mock_data'; + +Vue.use(VueApollo); + +describe('Kubernetes dashboard pods page', () => { + let wrapper; + + const configuration = { + basePath: 'kas/tunnel/url', + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + }, + }; + + const findWorkloadLayout = () => wrapper.findComponent(WorkloadLayout); + + const createApolloProvider = () => { + const mockResolvers = { + Query: { + k8sPods: jest.fn().mockReturnValue(k8sPodsMock), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + const createWrapper = (apolloProvider = createApolloProvider()) => { + wrapper = shallowMount(PodsPage, { + provide: { configuration }, + apolloProvider, + }); + }; + + describe('mounted', () => { + it('renders WorkloadLayout component', () => { + createWrapper(); + + expect(findWorkloadLayout().exists()).toBe(true); + }); + + it('sets loading prop for the WorkloadLayout', () => { + createWrapper(); + + expect(findWorkloadLayout().props('loading')).toBe(true); + }); + + it('removes loading prop from the WorkloadLayout when the list of pods loaded', async () => { + createWrapper(); + await waitForPromises(); + + expect(findWorkloadLayout().props('loading')).toBe(false); + }); + }); + + describe('when gets pods data', () => { + useFakeDate(2023, 10, 23, 10, 10); + + it('sets correct stats object for the WorkloadLayout', async () => { + createWrapper(); + await waitForPromises(); + + expect(findWorkloadLayout().props('stats')).toEqual(mockPodStats); + }); + + it('sets correct table items object for the WorkloadLayout', async () => { + createWrapper(); + await waitForPromises(); + + expect(findWorkloadLayout().props('items')).toMatchObject(mockPodsTableItems); + }); + }); + + describe('when gets an error from the cluster_client API', () => { + const error = new Error('Error from the cluster_client API'); + const createErroredApolloProvider = () => { + const mockResolvers = { + Query: { + k8sPods: jest.fn().mockRejectedValueOnce(error), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + beforeEach(async () => { + createWrapper(createErroredApolloProvider()); + await waitForPromises(); + }); + + it('sets errorMessage prop for the WorkloadLayout', () => { + expect(findWorkloadLayout().props('errorMessage')).toBe(error.message); + }); + }); +}); diff --git a/spec/frontend/kubernetes_dashboard/pages/replica_sets_page_spec.js b/spec/frontend/kubernetes_dashboard/pages/replica_sets_page_spec.js new file mode 100644 index 00000000000..0e442ec8328 --- /dev/null +++ b/spec/frontend/kubernetes_dashboard/pages/replica_sets_page_spec.js @@ -0,0 +1,106 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import ReplicaSetsPage from '~/kubernetes_dashboard/pages/replica_sets_page.vue'; +import WorkloadLayout from '~/kubernetes_dashboard/components/workload_layout.vue'; +import { useFakeDate } from 'helpers/fake_date'; +import { + k8sReplicaSetsMock, + mockStatefulSetsStats, + mockReplicaSetsTableItems, +} from '../graphql/mock_data'; + +Vue.use(VueApollo); + +describe('Kubernetes dashboard replicaSets page', () => { + let wrapper; + + const configuration = { + basePath: 'kas/tunnel/url', + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + }, + }; + + const findWorkloadLayout = () => wrapper.findComponent(WorkloadLayout); + + const createApolloProvider = () => { + const mockResolvers = { + Query: { + k8sReplicaSets: jest.fn().mockReturnValue(k8sReplicaSetsMock), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + const createWrapper = (apolloProvider = createApolloProvider()) => { + wrapper = shallowMount(ReplicaSetsPage, { + provide: { configuration }, + apolloProvider, + }); + }; + + describe('mounted', () => { + it('renders WorkloadLayout component', () => { + createWrapper(); + + expect(findWorkloadLayout().exists()).toBe(true); + }); + + it('sets loading prop for the WorkloadLayout', () => { + createWrapper(); + + expect(findWorkloadLayout().props('loading')).toBe(true); + }); + + it('removes loading prop from the WorkloadLayout when the list of pods loaded', async () => { + createWrapper(); + await waitForPromises(); + + expect(findWorkloadLayout().props('loading')).toBe(false); + }); + }); + + describe('when gets pods data', () => { + useFakeDate(2023, 10, 23, 10, 10); + + it('sets correct stats object for the WorkloadLayout', async () => { + createWrapper(); + await waitForPromises(); + + expect(findWorkloadLayout().props('stats')).toEqual(mockStatefulSetsStats); + }); + + it('sets correct table items object for the WorkloadLayout', async () => { + createWrapper(); + await waitForPromises(); + + expect(findWorkloadLayout().props('items')).toMatchObject(mockReplicaSetsTableItems); + }); + }); + + describe('when gets an error from the cluster_client API', () => { + const error = new Error('Error from the cluster_client API'); + const createErroredApolloProvider = () => { + const mockResolvers = { + Query: { + k8sReplicaSets: jest.fn().mockRejectedValueOnce(error), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + beforeEach(async () => { + createWrapper(createErroredApolloProvider()); + await waitForPromises(); + }); + + it('sets errorMessage prop for the WorkloadLayout', () => { + expect(findWorkloadLayout().props('errorMessage')).toBe(error.message); + }); + }); +}); diff --git a/spec/frontend/kubernetes_dashboard/pages/stateful_sets_page_spec.js b/spec/frontend/kubernetes_dashboard/pages/stateful_sets_page_spec.js new file mode 100644 index 00000000000..3e9bd9a42de --- /dev/null +++ b/spec/frontend/kubernetes_dashboard/pages/stateful_sets_page_spec.js @@ -0,0 +1,106 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import StatefulSetsPage from '~/kubernetes_dashboard/pages/stateful_sets_page.vue'; +import WorkloadLayout from '~/kubernetes_dashboard/components/workload_layout.vue'; +import { useFakeDate } from 'helpers/fake_date'; +import { + k8sStatefulSetsMock, + mockStatefulSetsStats, + mockStatefulSetsTableItems, +} from '../graphql/mock_data'; + +Vue.use(VueApollo); + +describe('Kubernetes dashboard statefulSets page', () => { + let wrapper; + + const configuration = { + basePath: 'kas/tunnel/url', + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + }, + }; + + const findWorkloadLayout = () => wrapper.findComponent(WorkloadLayout); + + const createApolloProvider = () => { + const mockResolvers = { + Query: { + k8sStatefulSets: jest.fn().mockReturnValue(k8sStatefulSetsMock), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + const createWrapper = (apolloProvider = createApolloProvider()) => { + wrapper = shallowMount(StatefulSetsPage, { + provide: { configuration }, + apolloProvider, + }); + }; + + describe('mounted', () => { + it('renders WorkloadLayout component', () => { + createWrapper(); + + expect(findWorkloadLayout().exists()).toBe(true); + }); + + it('sets loading prop for the WorkloadLayout', () => { + createWrapper(); + + expect(findWorkloadLayout().props('loading')).toBe(true); + }); + + it('removes loading prop from the WorkloadLayout when the list of pods loaded', async () => { + createWrapper(); + await waitForPromises(); + + expect(findWorkloadLayout().props('loading')).toBe(false); + }); + }); + + describe('when gets pods data', () => { + useFakeDate(2023, 10, 23, 10, 10); + + it('sets correct stats object for the WorkloadLayout', async () => { + createWrapper(); + await waitForPromises(); + + expect(findWorkloadLayout().props('stats')).toEqual(mockStatefulSetsStats); + }); + + it('sets correct table items object for the WorkloadLayout', async () => { + createWrapper(); + await waitForPromises(); + + expect(findWorkloadLayout().props('items')).toMatchObject(mockStatefulSetsTableItems); + }); + }); + + describe('when gets an error from the cluster_client API', () => { + const error = new Error('Error from the cluster_client API'); + const createErroredApolloProvider = () => { + const mockResolvers = { + Query: { + k8sStatefulSets: jest.fn().mockRejectedValueOnce(error), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + beforeEach(async () => { + createWrapper(createErroredApolloProvider()); + await waitForPromises(); + }); + + it('sets errorMessage prop for the WorkloadLayout', () => { + expect(findWorkloadLayout().props('errorMessage')).toBe(error.message); + }); + }); +}); diff --git a/spec/frontend/lib/utils/breadcrumbs_spec.js b/spec/frontend/lib/utils/breadcrumbs_spec.js index 3c29e3723d3..481e3db521c 100644 --- a/spec/frontend/lib/utils/breadcrumbs_spec.js +++ b/spec/frontend/lib/utils/breadcrumbs_spec.js @@ -26,24 +26,20 @@ describe('Breadcrumbs utils', () => { `; const mockRouter = jest.fn(); - let MockComponent; - let mockApolloProvider; - beforeEach(() => { - MockComponent = Vue.component('MockComponent', { - render: (createElement) => - createElement('span', { - attrs: { - 'data-testid': 'mock-component', - }, - }), - }); - mockApolloProvider = createMockApollo(); + const MockComponent = Vue.component('MockComponent', { + render: (createElement) => + createElement('span', { + attrs: { + 'data-testid': 'mock-component', + }, + }), }); + const mockApolloProvider = createMockApollo(); + afterEach(() => { resetHTMLFixture(); - MockComponent = null; }); describe('injectVueAppBreadcrumbs', () => { diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js index 6295914b127..5c2bcd48f3e 100644 --- a/spec/frontend/lib/utils/common_utils_spec.js +++ b/spec/frontend/lib/utils/common_utils_spec.js @@ -151,7 +151,7 @@ describe('common_utils', () => { jest.spyOn(window, 'scrollBy'); document.body.innerHTML += ` <div id="parent"> - <div class="navbar-gitlab" style="position: fixed; top: 0; height: 50px;"></div> + <div class="header-logged-out" style="position: fixed; top: 0; height: 50px;"></div> <div style="height: 2000px; margin-top: 50px;"></div> <div id="user-content-test" style="height: 2000px;"></div> </div> diff --git a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js index 65018fe1625..79b09654f00 100644 --- a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js +++ b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js @@ -122,12 +122,12 @@ describe('date_format_utility.js', () => { describe('formatTimeAsSummary', () => { it.each` unit | value | result - ${'months'} | ${1.5} | ${'1.5M'} - ${'weeks'} | ${1.25} | ${'1.5w'} - ${'days'} | ${2} | ${'2d'} - ${'hours'} | ${10} | ${'10h'} - ${'minutes'} | ${20} | ${'20m'} - ${'seconds'} | ${10} | ${'<1m'} + ${'months'} | ${1.5} | ${'1.5 months'} + ${'weeks'} | ${1.25} | ${'1.5 weeks'} + ${'days'} | ${2} | ${'2 days'} + ${'hours'} | ${10} | ${'10 hours'} + ${'minutes'} | ${20} | ${'20 minutes'} + ${'seconds'} | ${10} | ${'<1 minute'} ${'seconds'} | ${0} | ${'-'} `('will format $value $unit to $result', ({ unit, value, result }) => { expect(utils.formatTimeAsSummary({ [unit]: value })).toBe(result); diff --git a/spec/frontend/lib/utils/datetime/locale_dateformat_spec.js b/spec/frontend/lib/utils/datetime/locale_dateformat_spec.js new file mode 100644 index 00000000000..3200f0cc7d7 --- /dev/null +++ b/spec/frontend/lib/utils/datetime/locale_dateformat_spec.js @@ -0,0 +1,177 @@ +import { DATE_TIME_FORMATS, localeDateFormat } from '~/lib/utils/datetime/locale_dateformat'; +import { setLanguage } from 'jest/__helpers__/locale_helper'; +import * as localeFns from '~/locale'; + +describe('localeDateFormat (en-US)', () => { + const date = new Date('1983-07-09T14:15:23.123Z'); + const sameDay = new Date('1983-07-09T18:27:09.198Z'); + const sameMonth = new Date('1983-07-12T12:36:02.654Z'); + const nextYear = new Date('1984-01-10T07:47:54.947Z'); + + beforeEach(() => { + setLanguage('en-US'); + localeDateFormat.reset(); + }); + + /* + Depending on the ICU/Intl version, formatted strings might contain + characters which aren't a normal space, e.g. U+2009 THIN SPACE in formatRange or + U+202F NARROW NO-BREAK SPACE between time and AM/PM. + + In order for the specs to be more portable and easier to read, as git/gitlab aren't + great at rendering these other spaces, we replace them U+0020 SPACE + */ + function expectDateString(str) { + // eslint-disable-next-line jest/valid-expect + return expect(str.replace(/[\s\u2009]+/g, ' ')); + } + + describe('#asDateTime', () => { + it('exposes a working date formatter', () => { + expectDateString(localeDateFormat.asDateTime.format(date)).toBe('Jul 9, 1983, 2:15 PM'); + expectDateString(localeDateFormat.asDateTime.format(nextYear)).toBe('Jan 10, 1984, 7:47 AM'); + }); + + it('exposes a working date range formatter', () => { + expectDateString(localeDateFormat.asDateTime.formatRange(date, nextYear)).toBe( + 'Jul 9, 1983, 2:15 PM – Jan 10, 1984, 7:47 AM', + ); + expectDateString(localeDateFormat.asDateTime.formatRange(date, sameMonth)).toBe( + 'Jul 9, 1983, 2:15 PM – Jul 12, 1983, 12:36 PM', + ); + expectDateString(localeDateFormat.asDateTime.formatRange(date, sameDay)).toBe( + 'Jul 9, 1983, 2:15 – 6:27 PM', + ); + }); + + it.each([ + ['automatic', 0, '2:15 PM'], + ['h12 preference', 1, '2:15 PM'], + ['h24 preference', 2, '14:15'], + ])("respects user's hourCycle preference: %s", (_, timeDisplayFormat, result) => { + window.gon.time_display_format = timeDisplayFormat; + expectDateString(localeDateFormat.asDateTime.format(date)).toContain(result); + expectDateString(localeDateFormat.asDateTime.formatRange(date, nextYear)).toContain(result); + }); + }); + + describe('#asDateTimeFull', () => { + it('exposes a working date formatter', () => { + expectDateString(localeDateFormat.asDateTimeFull.format(date)).toBe( + 'July 9, 1983 at 2:15:23 PM GMT', + ); + expectDateString(localeDateFormat.asDateTimeFull.format(nextYear)).toBe( + 'January 10, 1984 at 7:47:54 AM GMT', + ); + }); + + it('exposes a working date range formatter', () => { + expectDateString(localeDateFormat.asDateTimeFull.formatRange(date, nextYear)).toBe( + 'July 9, 1983 at 2:15:23 PM GMT – January 10, 1984 at 7:47:54 AM GMT', + ); + expectDateString(localeDateFormat.asDateTimeFull.formatRange(date, sameMonth)).toBe( + 'July 9, 1983 at 2:15:23 PM GMT – July 12, 1983 at 12:36:02 PM GMT', + ); + expectDateString(localeDateFormat.asDateTimeFull.formatRange(date, sameDay)).toBe( + 'July 9, 1983, 2:15:23 PM GMT – 6:27:09 PM GMT', + ); + }); + + it.each([ + ['automatic', 0, '2:15:23 PM'], + ['h12 preference', 1, '2:15:23 PM'], + ['h24 preference', 2, '14:15:23'], + ])("respects user's hourCycle preference: %s", (_, timeDisplayFormat, result) => { + window.gon.time_display_format = timeDisplayFormat; + expectDateString(localeDateFormat.asDateTimeFull.format(date)).toContain(result); + expectDateString(localeDateFormat.asDateTimeFull.formatRange(date, nextYear)).toContain( + result, + ); + }); + }); + + describe('#asDate', () => { + it('exposes a working date formatter', () => { + expectDateString(localeDateFormat.asDate.format(date)).toBe('Jul 9, 1983'); + expectDateString(localeDateFormat.asDate.format(nextYear)).toBe('Jan 10, 1984'); + }); + + it('exposes a working date range formatter', () => { + expectDateString(localeDateFormat.asDate.formatRange(date, nextYear)).toBe( + 'Jul 9, 1983 – Jan 10, 1984', + ); + expectDateString(localeDateFormat.asDate.formatRange(date, sameMonth)).toBe( + 'Jul 9 – 12, 1983', + ); + expectDateString(localeDateFormat.asDate.formatRange(date, sameDay)).toBe('Jul 9, 1983'); + }); + }); + + describe('#asTime', () => { + it('exposes a working date formatter', () => { + expectDateString(localeDateFormat.asTime.format(date)).toBe('2:15 PM'); + expectDateString(localeDateFormat.asTime.format(nextYear)).toBe('7:47 AM'); + }); + + it('exposes a working date range formatter', () => { + expectDateString(localeDateFormat.asTime.formatRange(date, nextYear)).toBe( + '7/9/1983, 2:15 PM – 1/10/1984, 7:47 AM', + ); + expectDateString(localeDateFormat.asTime.formatRange(date, sameMonth)).toBe( + '7/9/1983, 2:15 PM – 7/12/1983, 12:36 PM', + ); + expectDateString(localeDateFormat.asTime.formatRange(date, sameDay)).toBe('2:15 – 6:27 PM'); + }); + + it.each([ + ['automatic', 0, '2:15 PM'], + ['h12 preference', 1, '2:15 PM'], + ['h24 preference', 2, '14:15'], + ])("respects user's hourCycle preference: %s", (_, timeDisplayFormat, result) => { + window.gon.time_display_format = timeDisplayFormat; + expectDateString(localeDateFormat.asTime.format(date)).toContain(result); + expectDateString(localeDateFormat.asTime.formatRange(date, nextYear)).toContain(result); + }); + }); + + describe('#reset', () => { + it('removes the cached formatters', () => { + const spy = jest.spyOn(localeFns, 'createDateTimeFormat'); + + localeDateFormat.asDate.format(date); + localeDateFormat.asDate.format(date); + expect(spy).toHaveBeenCalledTimes(1); + + localeDateFormat.reset(); + + localeDateFormat.asDate.format(date); + localeDateFormat.asDate.format(date); + expect(spy).toHaveBeenCalledTimes(2); + }); + }); + + describe.each(DATE_TIME_FORMATS)('formatter for %p', (format) => { + it('is defined', () => { + expect(localeDateFormat[format]).toBeDefined(); + expect(localeDateFormat[format].format(date)).toBeDefined(); + expect(localeDateFormat[format].formatRange(date, nextYear)).toBeDefined(); + }); + + it('getting the formatter multiple times, just calls the Intl API once', () => { + const spy = jest.spyOn(localeFns, 'createDateTimeFormat'); + + localeDateFormat[format].format(date); + localeDateFormat[format].format(date); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('getting the formatter memoized the correct formatter', () => { + const spy = jest.spyOn(localeFns, 'createDateTimeFormat'); + + expect(localeDateFormat[format].format(date)).toBe(localeDateFormat[format].format(date)); + + expect(spy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/spec/frontend/lib/utils/datetime/timeago_utility_spec.js b/spec/frontend/lib/utils/datetime/timeago_utility_spec.js index 44db4cf88a2..53ed524116e 100644 --- a/spec/frontend/lib/utils/datetime/timeago_utility_spec.js +++ b/spec/frontend/lib/utils/datetime/timeago_utility_spec.js @@ -1,5 +1,6 @@ -import { DATE_ONLY_FORMAT } from '~/lib/utils/datetime/constants'; import { getTimeago, localTimeAgo, timeFor, duration } from '~/lib/utils/datetime/timeago_utility'; +import { DATE_ONLY_FORMAT, localeDateFormat } from '~/lib/utils/datetime/locale_dateformat'; + import { s__ } from '~/locale'; import '~/commons/bootstrap'; @@ -143,7 +144,7 @@ describe('TimeAgo utils', () => { it.each` updateTooltip | title ${false} | ${'some time'} - ${true} | ${'Feb 18, 2020 10:22pm UTC'} + ${true} | ${'February 18, 2020 at 10:22:32 PM GMT'} `( `has content: '${text}' and tooltip: '$title' with updateTooltip = $updateTooltip`, ({ updateTooltip, title }) => { @@ -168,6 +169,7 @@ describe('TimeAgo utils', () => { ${1} | ${'12-hour'} | ${'Feb 18, 2020, 10:22 PM'} ${2} | ${'24-hour'} | ${'Feb 18, 2020, 22:22'} `(`'$display' renders as '$text'`, ({ timeDisplayFormat, text }) => { + localeDateFormat.reset(); gon.time_display_relative = false; gon.time_display_format = timeDisplayFormat; diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js index 330bfca7029..73a4af2c85d 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -800,6 +800,21 @@ describe('date addition/subtraction methods', () => { ); }); + describe('nYearsBefore', () => { + it.each` + date | numberOfYears | expected + ${'2020-07-06'} | ${4} | ${'2016-07-06'} + ${'2020-07-06'} | ${1} | ${'2019-07-06'} + `( + 'returns $expected for "$numberOfYears year(s) before $date"', + ({ date, numberOfYears, expected }) => { + expect(datetimeUtility.nYearsBefore(new Date(date), numberOfYears)).toEqual( + new Date(expected), + ); + }, + ); + }); + describe('nMonthsBefore', () => { // The previous month (February) has 28 days const march2019 = '2019-03-15T00:00:00.000Z'; diff --git a/spec/frontend/lib/utils/secret_detection_spec.js b/spec/frontend/lib/utils/secret_detection_spec.js index 761062f0340..a8da6e8969f 100644 --- a/spec/frontend/lib/utils/secret_detection_spec.js +++ b/spec/frontend/lib/utils/secret_detection_spec.js @@ -31,6 +31,7 @@ describe('containsSensitiveToken', () => { 'token: gloas-a8cc74ccb0de004d09a968705ba49099229b288b3de43f26c473a9d8d7fb7693', 'https://example.com/feed?feed_token=123456789_abcdefghij', 'glpat-1234567890 and feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ', + 'token: gldt-cgyKc1k_AsnEpmP-5fRL', ]; it.each(sensitiveMessages)('returns true for message: %s', (message) => { diff --git a/spec/frontend/lib/utils/vuex_module_mappers_spec.js b/spec/frontend/lib/utils/vuex_module_mappers_spec.js deleted file mode 100644 index 9070903728b..00000000000 --- a/spec/frontend/lib/utils/vuex_module_mappers_spec.js +++ /dev/null @@ -1,133 +0,0 @@ -import { mount } from '@vue/test-utils'; -import Vue from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import { - mapVuexModuleActions, - mapVuexModuleGetters, - mapVuexModuleState, - REQUIRE_STRING_ERROR_MESSAGE, -} from '~/lib/utils/vuex_module_mappers'; - -const TEST_MODULE_NAME = 'testModuleName'; - -Vue.use(Vuex); - -// setup test component and store ---------------------------------------------- -// -// These are used to indirectly test `vuex_module_mappers`. -const TestComponent = { - props: { - vuexModule: { - type: String, - required: true, - }, - }, - computed: { - ...mapVuexModuleState((vm) => vm.vuexModule, { name: 'name', value: 'count' }), - ...mapVuexModuleGetters((vm) => vm.vuexModule, ['hasValue', 'hasName']), - stateJson() { - return JSON.stringify({ - name: this.name, - value: this.value, - }); - }, - gettersJson() { - return JSON.stringify({ - hasValue: this.hasValue, - hasName: this.hasName, - }); - }, - }, - methods: { - ...mapVuexModuleActions((vm) => vm.vuexModule, ['increment']), - }, - template: ` -<div> - <pre data-testid="state">{{ stateJson }}</pre> - <pre data-testid="getters">{{ gettersJson }}</pre> -</div>`, -}; - -const createTestStore = () => { - return new Vuex.Store({ - modules: { - [TEST_MODULE_NAME]: { - namespaced: true, - state: { - name: 'Lorem', - count: 0, - }, - mutations: { - INCREMENT: (state, amount) => { - state.count += amount; - }, - }, - actions: { - increment({ commit }, amount) { - commit('INCREMENT', amount); - }, - }, - getters: { - hasValue: (state) => state.count > 0, - hasName: (state) => Boolean(state.name.length), - }, - }, - }, - }); -}; - -describe('~/lib/utils/vuex_module_mappers', () => { - let store; - let wrapper; - - const getJsonInTemplate = (testId) => - JSON.parse(wrapper.find(`[data-testid="${testId}"]`).text()); - const getMappedState = () => getJsonInTemplate('state'); - const getMappedGetters = () => getJsonInTemplate('getters'); - - beforeEach(() => { - store = createTestStore(); - - wrapper = mount(TestComponent, { - propsData: { - vuexModule: TEST_MODULE_NAME, - }, - store, - }); - }); - - describe('from module defined by prop', () => { - it('maps state', () => { - expect(getMappedState()).toEqual({ - name: store.state[TEST_MODULE_NAME].name, - value: store.state[TEST_MODULE_NAME].count, - }); - }); - - it('maps getters', () => { - expect(getMappedGetters()).toEqual({ - hasName: true, - hasValue: false, - }); - }); - - it('maps action', () => { - jest.spyOn(store, 'dispatch'); - - expect(store.dispatch).not.toHaveBeenCalled(); - - wrapper.vm.increment(10); - - expect(store.dispatch).toHaveBeenCalledWith(`${TEST_MODULE_NAME}/increment`, 10); - }); - }); - - describe('with non-string object value', () => { - it('throws helpful error', () => { - expect(() => mapVuexModuleActions((vm) => vm.bogus, { foo: () => {} })).toThrow( - REQUIRE_STRING_ERROR_MESSAGE, - ); - }); - }); -}); diff --git a/spec/frontend/loading_icon_for_legacy_js_spec.js b/spec/frontend/loading_icon_for_legacy_js_spec.js index 46deee555ba..1e4acffdfd0 100644 --- a/spec/frontend/loading_icon_for_legacy_js_spec.js +++ b/spec/frontend/loading_icon_for_legacy_js_spec.js @@ -8,7 +8,7 @@ describe('loadingIconForLegacyJS', () => { expect(el.className).toBe('gl-spinner-container'); expect(el.querySelector('.gl-spinner-sm')).toEqual(expect.any(HTMLElement)); expect(el.querySelector('.gl-spinner-dark')).toEqual(expect.any(HTMLElement)); - expect(el.querySelector('[aria-label="Loading"]')).toEqual(expect.any(HTMLElement)); + expect(el.getAttribute('aria-label')).toEqual('Loading'); expect(el.getAttribute('role')).toBe('status'); }); @@ -31,7 +31,7 @@ describe('loadingIconForLegacyJS', () => { it('can render a different aria-label', () => { const el = loadingIconForLegacyJS({ label: 'Foo' }); - expect(el.querySelector('[aria-label="Foo"]')).toEqual(expect.any(HTMLElement)); + expect(el.getAttribute('aria-label')).toEqual('Foo'); }); it('can render additional classes', () => { diff --git a/spec/frontend/logo_spec.js b/spec/frontend/logo_spec.js new file mode 100644 index 00000000000..8e39e75bd3b --- /dev/null +++ b/spec/frontend/logo_spec.js @@ -0,0 +1,55 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { initPortraitLogoDetection } from '~/logo'; + +describe('initPortraitLogoDetection', () => { + let img; + + const loadImage = () => { + const loadEvent = new Event('load'); + img.dispatchEvent(loadEvent); + }; + + beforeEach(() => { + setHTMLFixture('<img class="gl-visibility-hidden gl-h-9 js-portrait-logo-detection" />'); + initPortraitLogoDetection(); + img = document.querySelector('img'); + }); + + afterEach(() => { + resetHTMLFixture(); + }); + + describe('when logo does not have portrait format', () => { + beforeEach(() => { + img.height = 10; + img.width = 10; + }); + + it('removes gl-visibility-hidden', () => { + expect(img.classList).toContain('gl-visibility-hidden'); + expect(img.classList).toContain('gl-h-9'); + + loadImage(); + + expect(img.classList).not.toContain('gl-visibility-hidden'); + expect(img.classList).toContain('gl-h-9'); + }); + }); + + describe('when logo has portrait format', () => { + beforeEach(() => { + img.height = 11; + img.width = 10; + }); + + it('removes gl-visibility-hidden', () => { + expect(img.classList).toContain('gl-visibility-hidden'); + expect(img.classList).toContain('gl-h-9'); + + loadImage(); + + expect(img.classList).not.toContain('gl-visibility-hidden'); + expect(img.classList).toContain('gl-w-10'); + }); + }); +}); diff --git a/spec/frontend/members/components/table/role_dropdown_spec.js b/spec/frontend/members/components/table/max_role_spec.js index 62275a05dc5..75e1e05afb1 100644 --- a/spec/frontend/members/components/table/role_dropdown_spec.js +++ b/spec/frontend/members/components/table/max_role_spec.js @@ -1,4 +1,4 @@ -import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; +import { GlBadge, GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; @@ -6,16 +6,19 @@ import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; import waitForPromises from 'helpers/wait_for_promises'; -import RoleDropdown from '~/members/components/table/role_dropdown.vue'; +import MaxRole from '~/members/components/table/max_role.vue'; import { MEMBER_TYPES } from '~/members/constants'; import { guestOverageConfirmAction } from 'ee_else_ce/members/guest_overage_confirm_action'; import { member } from '../../mock_data'; Vue.use(Vuex); + jest.mock('ee_else_ce/members/guest_overage_confirm_action'); jest.mock('~/sentry/sentry_browser_wrapper'); -describe('RoleDropdown', () => { +guestOverageConfirmAction.mockReturnValue(true); + +describe('MaxRole', () => { let wrapper; let actions; const $toast = { @@ -35,7 +38,7 @@ describe('RoleDropdown', () => { }; const createComponent = (propsData = {}, store = createStore()) => { - wrapper = mount(RoleDropdown, { + wrapper = mount(MaxRole, { provide: { namespace: MEMBER_TYPES.user, group: { @@ -45,7 +48,9 @@ describe('RoleDropdown', () => { }, propsData: { member, - permissions: {}, + permissions: { + canUpdate: true, + }, ...propsData, }, store, @@ -55,6 +60,7 @@ describe('RoleDropdown', () => { }); }; + const findBadge = () => wrapper.findComponent(GlBadge); const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); const findListboxItems = () => wrapper.findAllComponents(GlListboxItem); const findListboxItemByText = (text) => @@ -64,6 +70,18 @@ describe('RoleDropdown', () => { gon.features = { showOverageOnRolePromotion: true }; }); + describe('when member can not be updated', () => { + it('renders a badge instead of a collapsible listbox', () => { + createComponent({ + permissions: { + canUpdate: false, + }, + }); + + expect(findBadge().text()).toBe('Owner'); + }); + }); + it('has correct header text props', () => { createComponent(); expect(findListbox().props('headerText')).toBe('Change role'); @@ -77,14 +95,12 @@ describe('RoleDropdown', () => { describe('when listbox is open', () => { beforeEach(async () => { - guestOverageConfirmAction.mockReturnValue(true); createComponent(); await findListbox().vm.$emit('click'); }); it('sets dropdown toggle and checks selected role', () => { - expect(findListbox().props('toggleText')).toBe('Owner'); expect(findListbox().find('[aria-selected=true]').text()).toBe('Owner'); }); @@ -100,7 +116,8 @@ describe('RoleDropdown', () => { expect(actions.updateMemberRole).toHaveBeenCalledWith(expect.any(Object), { memberId: member.id, - accessLevel: { integerValue: 30, memberRoleId: null }, + accessLevel: 30, + memberRoleId: null, }); }); @@ -108,7 +125,7 @@ describe('RoleDropdown', () => { it('displays toast', async () => { await findListboxItemByText('Developer').trigger('click'); - await nextTick(); + await waitForPromises(); expect($toast.show).toHaveBeenCalledWith('Role updated successfully.'); }); @@ -146,7 +163,7 @@ describe('RoleDropdown', () => { it('does not display toast', async () => { await findListboxItemByText('Developer').trigger('click'); - await nextTick(); + await waitForPromises(); expect($toast.show).not.toHaveBeenCalled(); }); @@ -176,12 +193,6 @@ describe('RoleDropdown', () => { }); }); - it("sets initial dropdown toggle value to member's role", () => { - createComponent(); - - expect(findListbox().props('toggleText')).toBe('Owner'); - }); - it('sets the dropdown alignment to right on mobile', async () => { jest.spyOn(bp, 'isDesktop').mockReturnValue(false); createComponent(); @@ -199,54 +210,4 @@ describe('RoleDropdown', () => { expect(findListbox().props('placement')).toBe('left'); }); - - describe('guestOverageConfirmAction', () => { - const mockConfirmAction = ({ confirmed }) => { - guestOverageConfirmAction.mockResolvedValueOnce(confirmed); - }; - - beforeEach(() => { - createComponent(); - - findListbox().vm.$emit('click'); - }); - - afterEach(() => { - guestOverageConfirmAction.mockReset(); - }); - - describe('when guestOverageConfirmAction returns true', () => { - beforeEach(() => { - mockConfirmAction({ confirmed: true }); - - findListboxItemByText('Reporter').trigger('click'); - }); - - it('calls updateMemberRole', () => { - expect(actions.updateMemberRole).toHaveBeenCalled(); - }); - }); - - describe('when guestOverageConfirmAction returns false', () => { - beforeEach(() => { - mockConfirmAction({ confirmed: false }); - - findListboxItemByText('Reporter').trigger('click'); - }); - - it('does not call updateMemberRole', () => { - expect(actions.updateMemberRole).not.toHaveBeenCalled(); - }); - - it('re-enables dropdown', async () => { - await waitForPromises(); - - expect(findListbox().props('disabled')).toBe(false); - }); - - it('resets selected dropdown item', () => { - expect(findListbox().props('selected')).toMatch(/role-static-\d+/); - }); - }); - }); }); diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js index 791155fcd1b..c2400fbc142 100644 --- a/spec/frontend/members/components/table/members_table_spec.js +++ b/spec/frontend/members/components/table/members_table_spec.js @@ -1,4 +1,4 @@ -import { GlBadge, GlPagination, GlTable } from '@gitlab/ui'; +import { GlPagination, GlTable } from '@gitlab/ui'; import Vue from 'vue'; // eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; @@ -11,7 +11,7 @@ import MemberAvatar from '~/members/components/table/member_avatar.vue'; import MemberSource from '~/members/components/table/member_source.vue'; import MemberActivity from '~/members/components/table/member_activity.vue'; import MembersTable from '~/members/components/table/members_table.vue'; -import RoleDropdown from '~/members/components/table/role_dropdown.vue'; +import MaxRole from '~/members/components/table/max_role.vue'; import { MEMBER_TYPES, MEMBER_STATE_CREATED, @@ -74,7 +74,7 @@ describe('MembersTable', () => { 'member-source', 'created-at', 'member-actions', - 'role-dropdown', + 'max-role', 'remove-group-link-modal', 'remove-member-modal', 'expiration-datepicker', @@ -110,7 +110,7 @@ describe('MembersTable', () => { ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource} ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt} ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt} - ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown} + ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${MaxRole} ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker} ${'activity'} | ${'Activity'} | ${memberMock} | ${MemberActivity} `('renders the $label field', ({ field, label, member, expectedComponent }) => { @@ -274,16 +274,6 @@ describe('MembersTable', () => { }); }); - describe('when member can not be updated', () => { - it('renders badge in "Max role" field', () => { - createComponent({ members: [memberMock], tableFields: ['maxRole'] }); - - expect( - wrapper.find(`[data-label="Max role"][role="cell"]`).findComponent(GlBadge).text(), - ).toBe(memberMock.accessLevel.stringValue); - }); - }); - it('adds QA selector to table', () => { createComponent(); diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js index e0dc765b9e4..f550039bfdc 100644 --- a/spec/frontend/members/mock_data.js +++ b/spec/frontend/members/mock_data.js @@ -51,6 +51,7 @@ export const member = { 'Minimal access': 5, }, customRoles: [], + customPermissions: [], }; export const group = { diff --git a/spec/frontend/members/store/actions_spec.js b/spec/frontend/members/store/actions_spec.js index 3df3d85c4f1..30ea83abf22 100644 --- a/spec/frontend/members/store/actions_spec.js +++ b/spec/frontend/members/store/actions_spec.js @@ -37,28 +37,21 @@ describe('Vuex members actions', () => { describe('updateMemberRole', () => { const memberId = members[0].id; - const accessLevel = { integerValue: 30, memberRoleId: 90 }; + const accessLevel = 30; + const memberRoleId = 90; - const payload = { - memberId, - accessLevel, - }; + const payload = { memberId, accessLevel, memberRoleId }; describe('successful request', () => { - it(`commits ${types.RECEIVE_MEMBER_ROLE_SUCCESS} mutation`, async () => { + it(`updates member role`, async () => { mock.onPut().replyOnce(HTTP_STATUS_OK); - await testAction(updateMemberRole, payload, state, [ - { - type: types.RECEIVE_MEMBER_ROLE_SUCCESS, - payload, - }, - ]); + await testAction(updateMemberRole, payload, state, []); expect(mock.history.put[0].url).toBe('/groups/foo-bar/-/group_members/238'); expect(mockedRequestFormatter).toHaveBeenCalledWith({ - accessLevel: accessLevel.integerValue, - memberRoleId: accessLevel.memberRoleId, + accessLevel, + memberRoleId, }); }); }); @@ -142,7 +135,7 @@ describe('Vuex members actions', () => { describe('showRemoveGroupLinkModal', () => { it(`commits ${types.SHOW_REMOVE_GROUP_LINK_MODAL} mutation`, () => { - testAction(showRemoveGroupLinkModal, group, state, [ + return testAction(showRemoveGroupLinkModal, group, state, [ { type: types.SHOW_REMOVE_GROUP_LINK_MODAL, payload: group, @@ -153,7 +146,7 @@ describe('Vuex members actions', () => { describe('hideRemoveGroupLinkModal', () => { it(`commits ${types.HIDE_REMOVE_GROUP_LINK_MODAL} mutation`, () => { - testAction(hideRemoveGroupLinkModal, group, state, [ + return testAction(hideRemoveGroupLinkModal, group, state, [ { type: types.HIDE_REMOVE_GROUP_LINK_MODAL, }, @@ -170,7 +163,7 @@ describe('Vuex members actions', () => { describe('showRemoveMemberModal', () => { it(`commits ${types.SHOW_REMOVE_MEMBER_MODAL} mutation`, () => { - testAction(showRemoveMemberModal, modalData, state, [ + return testAction(showRemoveMemberModal, modalData, state, [ { type: types.SHOW_REMOVE_MEMBER_MODAL, payload: modalData, @@ -181,7 +174,7 @@ describe('Vuex members actions', () => { describe('hideRemoveMemberModal', () => { it(`commits ${types.HIDE_REMOVE_MEMBER_MODAL} mutation`, () => { - testAction(hideRemoveMemberModal, undefined, state, [ + return testAction(hideRemoveMemberModal, undefined, state, [ { type: types.HIDE_REMOVE_MEMBER_MODAL, }, diff --git a/spec/frontend/members/store/mutations_spec.js b/spec/frontend/members/store/mutations_spec.js index 8160cc373d8..240a14b2836 100644 --- a/spec/frontend/members/store/mutations_spec.js +++ b/spec/frontend/members/store/mutations_spec.js @@ -14,19 +14,6 @@ describe('Vuex members mutations', () => { }; }); - describe(types.RECEIVE_MEMBER_ROLE_SUCCESS, () => { - it('updates member', () => { - const accessLevel = { integerValue: 30, stringValue: 'Developer' }; - - mutations[types.RECEIVE_MEMBER_ROLE_SUCCESS](state, { - memberId: members[0].id, - accessLevel, - }); - - expect(state.members[0].accessLevel).toEqual(accessLevel); - }); - }); - describe(types.RECEIVE_MEMBER_ROLE_ERROR, () => { describe('when error does not have a message', () => { it('shows default error message', () => { diff --git a/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js b/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js index edd18c57f43..1a45ada98f9 100644 --- a/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js +++ b/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js @@ -6,6 +6,7 @@ import { shallowMountExtended, extendedWrapper } from 'helpers/vue_test_utils_he import InlineConflictLines from '~/merge_conflicts/components/inline_conflict_lines.vue'; import ParallelConflictLines from '~/merge_conflicts/components/parallel_conflict_lines.vue'; import component from '~/merge_conflicts/merge_conflict_resolver_app.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { createStore } from '~/merge_conflicts/store'; import { decorateFiles } from '~/merge_conflicts/utils'; import { conflictsMock } from '../mock_data'; @@ -49,6 +50,7 @@ describe('Merge Conflict Resolver App', () => { const findInlineConflictLines = (w = wrapper) => w.findComponent(InlineConflictLines); const findParallelConflictLines = (w = wrapper) => w.findComponent(ParallelConflictLines); const findCommitMessageTextarea = () => wrapper.findByTestId('commit-message'); + const findClipboardButton = (w = wrapper) => w.findComponent(ClipboardButton); it('shows the amount of conflicts', () => { mountComponent(); @@ -131,6 +133,21 @@ describe('Merge Conflict Resolver App', () => { expect(parallelConflictLinesComponent.props('file')).toEqual(decoratedMockFiles[0]); }); }); + + describe('clipboard button', () => { + it('exists', () => { + mountComponent(); + expect(findClipboardButton().exists()).toBe(true); + }); + + it('has the correct props', () => { + mountComponent(); + expect(findClipboardButton().attributes()).toMatchObject({ + text: decoratedMockFiles[0].filePath, + title: 'Copy file path', + }); + }); + }); }); describe('submit form', () => { diff --git a/spec/frontend/merge_conflicts/store/actions_spec.js b/spec/frontend/merge_conflicts/store/actions_spec.js index d2c4c8b796c..6fbd17af5af 100644 --- a/spec/frontend/merge_conflicts/store/actions_spec.js +++ b/spec/frontend/merge_conflicts/store/actions_spec.js @@ -134,7 +134,7 @@ describe('merge conflicts actions', () => { describe('setLoadingState', () => { it('commits the right mutation', () => { - testAction( + return testAction( actions.setLoadingState, true, {}, @@ -151,7 +151,7 @@ describe('merge conflicts actions', () => { describe('setErrorState', () => { it('commits the right mutation', () => { - testAction( + return testAction( actions.setErrorState, true, {}, @@ -168,7 +168,7 @@ describe('merge conflicts actions', () => { describe('setFailedRequest', () => { it('commits the right mutation', () => { - testAction( + return testAction( actions.setFailedRequest, 'errors in the request', {}, @@ -207,7 +207,7 @@ describe('merge conflicts actions', () => { describe('setSubmitState', () => { it('commits the right mutation', () => { - testAction( + return testAction( actions.setSubmitState, true, {}, @@ -224,7 +224,7 @@ describe('merge conflicts actions', () => { describe('updateCommitMessage', () => { it('commits the right mutation', () => { - testAction( + return testAction( actions.updateCommitMessage, 'some message', {}, diff --git a/spec/frontend/milestones/stores/actions_spec.js b/spec/frontend/milestones/stores/actions_spec.js index 4355ea71fb2..4be12f17f9e 100644 --- a/spec/frontend/milestones/stores/actions_spec.js +++ b/spec/frontend/milestones/stores/actions_spec.js @@ -28,7 +28,7 @@ describe('Milestone combobox Vuex store actions', () => { describe('setProjectId', () => { it(`commits ${types.SET_PROJECT_ID} with the new project ID`, () => { const projectId = '4'; - testAction(actions.setProjectId, projectId, state, [ + return testAction(actions.setProjectId, projectId, state, [ { type: types.SET_PROJECT_ID, payload: projectId }, ]); }); @@ -37,7 +37,7 @@ describe('Milestone combobox Vuex store actions', () => { describe('setGroupId', () => { it(`commits ${types.SET_GROUP_ID} with the new group ID`, () => { const groupId = '123'; - testAction(actions.setGroupId, groupId, state, [ + return testAction(actions.setGroupId, groupId, state, [ { type: types.SET_GROUP_ID, payload: groupId }, ]); }); @@ -46,16 +46,19 @@ describe('Milestone combobox Vuex store actions', () => { describe('setGroupMilestonesAvailable', () => { it(`commits ${types.SET_GROUP_MILESTONES_AVAILABLE} with the boolean indicating if group milestones are available (Premium)`, () => { state.groupMilestonesAvailable = true; - testAction(actions.setGroupMilestonesAvailable, state.groupMilestonesAvailable, state, [ - { type: types.SET_GROUP_MILESTONES_AVAILABLE, payload: state.groupMilestonesAvailable }, - ]); + return testAction( + actions.setGroupMilestonesAvailable, + state.groupMilestonesAvailable, + state, + [{ type: types.SET_GROUP_MILESTONES_AVAILABLE, payload: state.groupMilestonesAvailable }], + ); }); }); describe('setSelectedMilestones', () => { it(`commits ${types.SET_SELECTED_MILESTONES} with the new selected milestones name`, () => { const selectedMilestones = ['v1.2.3']; - testAction(actions.setSelectedMilestones, selectedMilestones, state, [ + return testAction(actions.setSelectedMilestones, selectedMilestones, state, [ { type: types.SET_SELECTED_MILESTONES, payload: selectedMilestones }, ]); }); @@ -63,7 +66,7 @@ describe('Milestone combobox Vuex store actions', () => { describe('clearSelectedMilestones', () => { it(`commits ${types.CLEAR_SELECTED_MILESTONES} with the new selected milestones name`, () => { - testAction(actions.clearSelectedMilestones, null, state, [ + return testAction(actions.clearSelectedMilestones, null, state, [ { type: types.CLEAR_SELECTED_MILESTONES }, ]); }); @@ -72,14 +75,14 @@ describe('Milestone combobox Vuex store actions', () => { describe('toggleMilestones', () => { const selectedMilestone = 'v1.2.3'; it(`commits ${types.ADD_SELECTED_MILESTONE} with the new selected milestone name`, () => { - testAction(actions.toggleMilestones, selectedMilestone, state, [ + return testAction(actions.toggleMilestones, selectedMilestone, state, [ { type: types.ADD_SELECTED_MILESTONE, payload: selectedMilestone }, ]); }); it(`commits ${types.REMOVE_SELECTED_MILESTONE} with the new selected milestone name`, () => { state.selectedMilestones = [selectedMilestone]; - testAction(actions.toggleMilestones, selectedMilestone, state, [ + return testAction(actions.toggleMilestones, selectedMilestone, state, [ { type: types.REMOVE_SELECTED_MILESTONE, payload: selectedMilestone }, ]); }); @@ -93,7 +96,7 @@ describe('Milestone combobox Vuex store actions', () => { }; const searchQuery = 'v1.0'; - testAction( + return testAction( actions.search, searchQuery, { ...state, ...getters }, @@ -106,7 +109,7 @@ describe('Milestone combobox Vuex store actions', () => { describe('when project does not have license to add group milestones', () => { it(`commits ${types.SET_SEARCH_QUERY} with the new search query to search for project milestones`, () => { const searchQuery = 'v1.0'; - testAction( + return testAction( actions.search, searchQuery, state, @@ -192,7 +195,7 @@ describe('Milestone combobox Vuex store actions', () => { groupMilestonesEnabled: () => true, }; - testAction( + return testAction( actions.fetchMilestones, undefined, { ...state, ...getters }, @@ -204,7 +207,7 @@ describe('Milestone combobox Vuex store actions', () => { describe('when project does not have license to add group milestones', () => { it(`dispatchs fetchProjectMilestones`, () => { - testAction( + return testAction( actions.fetchMilestones, undefined, state, 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 296728af46a..3999e906cec 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,206 +1,39 @@ -import { GlAvatarLabeled, GlLink, GlTableLite } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { shallowMount } from '@vue/test-utils'; 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, - NO_PARAMETERS_MESSAGE, - NO_METRICS_MESSAGE, - NO_METADATA_MESSAGE, - NO_CI_MESSAGE, -} from '~/ml/experiment_tracking/routes/candidates/show/translations'; import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; +import CandidateDetail from '~/ml/model_registry/components/candidate_detail.vue'; import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue'; -import { stubComponent } from 'helpers/stub_component'; -import { newCandidate } from './mock_data'; +import { newCandidate } from 'jest/ml/model_registry/mock_data'; describe('MlCandidatesShow', () => { let wrapper; const CANDIDATE = newCandidate(); - const USER_ROW = 1; - const INFO_SECTION = 0; - const CI_SECTION = 1; - const PARAMETER_SECTION = 2; - const METADATA_SECTION = 3; - - const createWrapper = (createCandidate = () => CANDIDATE) => { - wrapper = shallowMountExtended(MlCandidatesShow, { - propsData: { candidate: createCandidate() }, - stubs: { - GlTableLite: { ...stubComponent(GlTableLite), props: ['items', 'fields'] }, - }, + const createWrapper = () => { + wrapper = shallowMount(MlCandidatesShow, { + propsData: { candidate: CANDIDATE }, }); }; const findDeleteButton = () => wrapper.findComponent(DeleteButton); const findHeader = () => wrapper.findComponent(ModelExperimentsHeader); - const findSection = (section) => wrapper.findAll('section').at(section); - const findRowInSection = (section, row) => - findSection(section).findAllComponents(DetailRow).at(row); - const findLinkAtRow = (section, rowIndex) => - findRowInSection(section, rowIndex).findComponent(GlLink); - const findNoDataMessage = (label) => wrapper.findByText(label); - const findLabel = (label) => wrapper.find(`[label='${label}']`); - const findCiUserDetailRow = () => findRowInSection(CI_SECTION, USER_ROW); - const findCiUserAvatar = () => findCiUserDetailRow().findComponent(GlAvatarLabeled); - const findCiUserAvatarNameLink = () => findCiUserAvatar().findComponent(GlLink); - const findMetricsTable = () => wrapper.findComponent(GlTableLite); - - describe('Header', () => { - beforeEach(() => createWrapper()); - - it('shows delete button', () => { - expect(findDeleteButton().exists()).toBe(true); - }); + const findCandidateDetail = () => wrapper.findComponent(CandidateDetail); - it('passes the delete path to delete button', () => { - expect(findDeleteButton().props('deletePath')).toBe('path_to_candidate'); - }); + beforeEach(() => createWrapper()); - it('passes the right title', () => { - expect(findHeader().props('pageTitle')).toBe(TITLE_LABEL); - }); + it('shows delete button', () => { + expect(findDeleteButton().exists()).toBe(true); }); - describe('Detail Table', () => { - 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_SECTION, 0, 'ID', CANDIDATE.info.iid], - [INFO_SECTION, 1, 'MLflow run ID', CANDIDATE.info.eid], - [INFO_SECTION, 2, 'Status', CANDIDATE.info.status], - [INFO_SECTION, 3, 'Experiment', CANDIDATE.info.experiment_name], - [INFO_SECTION, 4, 'Artifacts', 'Artifacts'], - [CI_SECTION, 0, 'Job', CANDIDATE.info.ci_job.name], - [CI_SECTION, 1, 'Triggered by', 'CI User'], - [CI_SECTION, 2, 'Merge request', mrText], - [PARAMETER_SECTION, 0, CANDIDATE.params[0].name, CANDIDATE.params[0].value], - [PARAMETER_SECTION, 1, CANDIDATE.params[1].name, CANDIDATE.params[1].value], - [METADATA_SECTION, 0, CANDIDATE.metadata[0].name, CANDIDATE.metadata[0].value], - [METADATA_SECTION, 1, CANDIDATE.metadata[1].name, CANDIDATE.metadata[1].value], - ]; - - it.each(expectedTable)('row %s is created correctly', (section, rowIndex, label, text) => { - const row = findRowInSection(section, rowIndex); - - expect(row.props()).toMatchObject({ label }); - expect(row.text()).toBe(text); - }); - - describe('Table links', () => { - const linkRows = [ - [INFO_SECTION, 3, CANDIDATE.info.path_to_experiment], - [INFO_SECTION, 4, CANDIDATE.info.path_to_artifact], - [CI_SECTION, 0, CANDIDATE.info.ci_job.path], - [CI_SECTION, 2, CANDIDATE.info.ci_job.merge_request.path], - ]; - - it.each(linkRows)('row %s is created correctly', (section, rowIndex, href) => { - expect(findLinkAtRow(section, rowIndex).attributes().href).toBe(href); - }); - }); - - describe('Metrics table', () => { - it('computes metrics table items correctly', () => { - expect(findMetricsTable().props('items')).toEqual([ - { name: 'AUC', 0: '.55' }, - { name: 'Accuracy', 1: '.99', 2: '.98', 3: '.97' }, - { name: 'F1', 3: '.1' }, - ]); - }); - - it('computes metrics table fields correctly', () => { - expect(findMetricsTable().props('fields')).toEqual([ - expect.objectContaining({ key: 'name', label: 'Metric' }), - expect.objectContaining({ key: '0', label: 'Step 0' }), - expect.objectContaining({ key: '1', label: 'Step 1' }), - expect.objectContaining({ key: '2', label: 'Step 2' }), - expect.objectContaining({ key: '3', label: 'Step 3' }), - ]); - }); - }); - - 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'); - }); - }); - }); - - describe('No artifact path', () => { - beforeEach(() => - createWrapper(() => { - const candidate = newCandidate(); - delete candidate.info.path_to_artifact; - return candidate; - }), - ); - - it('does not render artifact row', () => { - expect(findLabel('Artifacts').exists()).toBe(false); - }); - }); - - describe('No params, metrics, ci or metadata available', () => { - beforeEach(() => - createWrapper(() => { - const candidate = newCandidate(); - delete candidate.params; - delete candidate.metrics; - delete candidate.metadata; - delete candidate.info.ci_job; - return candidate; - }), - ); - - it('does not render params', () => { - expect(findNoDataMessage(NO_PARAMETERS_MESSAGE).exists()).toBe(true); - }); - - it('does not render metadata', () => { - expect(findNoDataMessage(NO_METADATA_MESSAGE).exists()).toBe(true); - }); - - it('does not render metrics', () => { - expect(findNoDataMessage(NO_METRICS_MESSAGE).exists()).toBe(true); - }); - - it('does not render CI info', () => { - expect(findNoDataMessage(NO_CI_MESSAGE).exists()).toBe(true); - }); - }); - - 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('passes the delete path to delete button', () => { + expect(findDeleteButton().props('deletePath')).toBe('path_to_candidate'); + }); - it('does not render MR info', () => { - expect(findLabel('Merge request').exists()).toBe(false); - }); + it('passes the right title', () => { + expect(findHeader().props('pageTitle')).toBe('Model candidate details'); + }); - it('does not render CI user info', () => { - expect(findLabel('Triggered by').exists()).toBe(false); - }); - }); + it('creates the candidate detail section', () => { + expect(findCandidateDetail().props('candidate')).toBe(CANDIDATE); }); }); 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 deleted file mode 100644 index 4ea23ed2513..00000000000 --- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js +++ /dev/null @@ -1,41 +0,0 @@ -export const newCandidate = () => ({ - params: [ - { name: 'Algorithm', value: 'Decision Tree' }, - { name: 'MaxDepth', value: '3' }, - ], - metrics: [ - { name: 'AUC', value: '.55', step: 0 }, - { name: 'Accuracy', value: '.99', step: 1 }, - { name: 'Accuracy', value: '.98', step: 2 }, - { name: 'Accuracy', value: '.97', step: 3 }, - { name: 'F1', value: '.1', step: 3 }, - ], - metadata: [ - { name: 'FileName', value: 'test.py' }, - { name: 'ExecutionTime', value: '.0856' }, - ], - info: { - iid: 'candidate_iid', - eid: 'abcdefg', - path_to_artifact: 'path_to_artifact', - experiment_name: 'The Experiment', - 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/model_registry/apps/index_ml_models_spec.js b/spec/frontend/ml/model_registry/apps/index_ml_models_spec.js index 6e0ab2ebe2d..66a447e73d3 100644 --- a/spec/frontend/ml/model_registry/apps/index_ml_models_spec.js +++ b/spec/frontend/ml/model_registry/apps/index_ml_models_spec.js @@ -1,12 +1,13 @@ +import { GlBadge } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { IndexMlModels } from '~/ml/model_registry/apps'; import ModelRow from '~/ml/model_registry/components/model_row.vue'; -import { TITLE_LABEL, NO_MODELS_LABEL } from '~/ml/model_registry/translations'; import Pagination from '~/vue_shared/components/incubation/pagination.vue'; import SearchBar from '~/ml/model_registry/components/search_bar.vue'; -import { BASE_SORT_FIELDS } from '~/ml/model_registry/constants'; +import { BASE_SORT_FIELDS, MODEL_ENTITIES } from '~/ml/model_registry/constants'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; +import EmptyState from '~/ml/model_registry/components/empty_state.vue'; import { mockModels, startCursor, defaultPageInfo } from '../mock_data'; let wrapper; @@ -18,17 +19,18 @@ const createWrapper = ( const findModelRow = (index) => wrapper.findAllComponents(ModelRow).at(index); const findPagination = () => wrapper.findComponent(Pagination); -const findEmptyLabel = () => wrapper.findByText(NO_MODELS_LABEL); +const findEmptyState = () => wrapper.findComponent(EmptyState); const findSearchBar = () => wrapper.findComponent(SearchBar); const findTitleArea = () => wrapper.findComponent(TitleArea); const findModelCountMetadataItem = () => findTitleArea().findComponent(MetadataItem); +const findBadge = () => wrapper.findComponent(GlBadge); describe('MlModelsIndex', () => { describe('empty state', () => { beforeEach(() => createWrapper({ models: [], pageInfo: defaultPageInfo })); - it('displays empty state when no experiment', () => { - expect(findEmptyLabel().exists()).toBe(true); + it('shows empty state', () => { + expect(findEmptyState().props('entityType')).toBe(MODEL_ENTITIES.model); }); it('does not show pagination', () => { @@ -46,12 +48,16 @@ describe('MlModelsIndex', () => { }); it('does not show empty state', () => { - expect(findEmptyLabel().exists()).toBe(false); + expect(findEmptyState().exists()).toBe(false); }); describe('header', () => { it('displays the title', () => { - expect(findTitleArea().props('title')).toBe(TITLE_LABEL); + expect(findTitleArea().text()).toContain('Model registry'); + }); + + it('displays the experiment badge', () => { + expect(findBadge().attributes().href).toBe('/help/user/project/ml/model_registry/index.md'); }); it('sets model metadata item to model count', () => { diff --git a/spec/frontend/ml/model_registry/apps/show_ml_model_spec.js b/spec/frontend/ml/model_registry/apps/show_ml_model_spec.js index bc4770976a9..1fe0f5f88b3 100644 --- a/spec/frontend/ml/model_registry/apps/show_ml_model_spec.js +++ b/spec/frontend/ml/model_registry/apps/show_ml_model_spec.js @@ -1,22 +1,41 @@ import { GlBadge, GlTab } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { ShowMlModel } from '~/ml/model_registry/apps'; +import ModelVersionList from '~/ml/model_registry/components/model_version_list.vue'; +import CandidateList from '~/ml/model_registry/components/candidate_list.vue'; +import ModelVersionDetail from '~/ml/model_registry/components/model_version_detail.vue'; +import EmptyState from '~/ml/model_registry/components/empty_state.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; -import { NO_VERSIONS_LABEL } from '~/ml/model_registry/translations'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { MODEL_ENTITIES } from '~/ml/model_registry/constants'; import { MODEL, makeModel } from '../mock_data'; +const apolloProvider = createMockApollo([]); let wrapper; + +Vue.use(VueApollo); + const createWrapper = (model = MODEL) => { - wrapper = shallowMount(ShowMlModel, { propsData: { model } }); + wrapper = shallowMount(ShowMlModel, { + apolloProvider, + propsData: { model }, + stubs: { GlTab }, + }); }; const findDetailTab = () => wrapper.findAllComponents(GlTab).at(0); const findVersionsTab = () => wrapper.findAllComponents(GlTab).at(1); const findVersionsCountBadge = () => findVersionsTab().findComponent(GlBadge); +const findModelVersionList = () => findVersionsTab().findComponent(ModelVersionList); +const findModelVersionDetail = () => findDetailTab().findComponent(ModelVersionDetail); const findCandidateTab = () => wrapper.findAllComponents(GlTab).at(2); +const findCandidateList = () => findCandidateTab().findComponent(CandidateList); const findCandidatesCountBadge = () => findCandidateTab().findComponent(GlBadge); const findTitleArea = () => wrapper.findComponent(TitleArea); +const findEmptyState = () => wrapper.findComponent(EmptyState); const findVersionCountMetadataItem = () => findTitleArea().findComponent(MetadataItem); describe('ShowMlModel', () => { @@ -45,7 +64,11 @@ describe('ShowMlModel', () => { describe('when it has latest version', () => { it('displays the version', () => { - expect(findDetailTab().text()).toContain(MODEL.latestVersion.version); + expect(findModelVersionDetail().props('modelVersion')).toBe(MODEL.latestVersion); + }); + + it('displays the title', () => { + expect(findDetailTab().text()).toContain('Latest version: 1.2.3'); }); }); @@ -54,8 +77,12 @@ describe('ShowMlModel', () => { createWrapper(makeModel({ latestVersion: null })); }); - it('shows no version message', () => { - expect(findDetailTab().text()).toContain(NO_VERSIONS_LABEL); + it('shows empty state', () => { + expect(findEmptyState().props('entityType')).toBe(MODEL_ENTITIES.modelVersion); + }); + + it('does not render model version detail', () => { + expect(findModelVersionDetail().exists()).toBe(false); }); }); }); @@ -66,6 +93,10 @@ describe('ShowMlModel', () => { it('shows the number of versions in the tab', () => { expect(findVersionsCountBadge().text()).toBe(MODEL.versionCount.toString()); }); + + it('shows a list of model versions', () => { + expect(findModelVersionList().props('modelId')).toBe(MODEL.id); + }); }); describe('Candidates tab', () => { @@ -74,5 +105,9 @@ describe('ShowMlModel', () => { it('shows the number of candidates in the tab', () => { expect(findCandidatesCountBadge().text()).toBe(MODEL.candidateCount.toString()); }); + + it('shows a list of candidates', () => { + expect(findCandidateList().props('modelId')).toBe(MODEL.id); + }); }); }); diff --git a/spec/frontend/ml/model_registry/apps/show_ml_model_version_spec.js b/spec/frontend/ml/model_registry/apps/show_ml_model_version_spec.js index 77fca53c00e..2605a75d961 100644 --- a/spec/frontend/ml/model_registry/apps/show_ml_model_version_spec.js +++ b/spec/frontend/ml/model_registry/apps/show_ml_model_version_spec.js @@ -1,5 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import { ShowMlModelVersion } from '~/ml/model_registry/apps'; +import ModelVersionDetail from '~/ml/model_registry/components/model_version_detail.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import { MODEL_VERSION } from '../mock_data'; let wrapper; @@ -7,9 +9,17 @@ const createWrapper = () => { wrapper = shallowMount(ShowMlModelVersion, { propsData: { modelVersion: MODEL_VERSION } }); }; -describe('ShowMlModelVersion', () => { +const findTitleArea = () => wrapper.findComponent(TitleArea); +const findModelVersionDetail = () => wrapper.findComponent(ModelVersionDetail); + +describe('ml/model_registry/apps/show_model_version.vue', () => { beforeEach(() => createWrapper()); - it('renders the app', () => { - expect(wrapper.text()).toContain(`${MODEL_VERSION.model.name} - ${MODEL_VERSION.version}`); + + it('renders the title', () => { + expect(findTitleArea().props('title')).toBe('blah / 1.2.3'); + }); + + it('renders the model version detail', () => { + expect(findModelVersionDetail().props('modelVersion')).toBe(MODEL_VERSION); }); }); diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js b/spec/frontend/ml/model_registry/components/candidate_detail_row_spec.js index cd252560590..24b18b6b42d 100644 --- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js +++ b/spec/frontend/ml/model_registry/components/candidate_detail_row_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import DetailRow from '~/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue'; +import DetailRow from '~/ml/model_registry/components/candidate_detail_row.vue'; describe('CandidateDetailRow', () => { const ROW_LABEL_CELL = 0; diff --git a/spec/frontend/ml/model_registry/components/candidate_detail_spec.js b/spec/frontend/ml/model_registry/components/candidate_detail_spec.js new file mode 100644 index 00000000000..94aa65a1690 --- /dev/null +++ b/spec/frontend/ml/model_registry/components/candidate_detail_spec.js @@ -0,0 +1,191 @@ +import { GlAvatarLabeled, GlLink, GlTableLite } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CandidateDetail from '~/ml/model_registry/components/candidate_detail.vue'; +import DetailRow from '~/ml/model_registry/components/candidate_detail_row.vue'; +import { + NO_PARAMETERS_MESSAGE, + NO_METRICS_MESSAGE, + NO_METADATA_MESSAGE, + NO_CI_MESSAGE, +} from '~/ml/model_registry/translations'; +import { stubComponent } from 'helpers/stub_component'; +import { newCandidate } from '../mock_data'; + +describe('ml/model_registry/components/candidate_detail.vue', () => { + let wrapper; + const CANDIDATE = newCandidate(); + const USER_ROW = 1; + + const INFO_SECTION = 0; + const CI_SECTION = 1; + const PARAMETER_SECTION = 2; + const METADATA_SECTION = 3; + + const createWrapper = (createCandidate = () => CANDIDATE, showInfoSection = true) => { + wrapper = shallowMountExtended(CandidateDetail, { + propsData: { candidate: createCandidate(), showInfoSection }, + stubs: { + GlTableLite: { ...stubComponent(GlTableLite), props: ['items', 'fields'] }, + }, + }); + }; + + const findSection = (section) => wrapper.findAll('section').at(section); + const findRowInSection = (section, row) => + findSection(section).findAllComponents(DetailRow).at(row); + const findLinkAtRow = (section, rowIndex) => + findRowInSection(section, rowIndex).findComponent(GlLink); + const findNoDataMessage = (label) => wrapper.findByText(label); + const findLabel = (label) => wrapper.find(`[label='${label}']`); + const findCiUserDetailRow = () => findRowInSection(CI_SECTION, USER_ROW); + const findCiUserAvatar = () => findCiUserDetailRow().findComponent(GlAvatarLabeled); + const findCiUserAvatarNameLink = () => findCiUserAvatar().findComponent(GlLink); + const findMetricsTable = () => wrapper.findComponent(GlTableLite); + + describe('All info available', () => { + beforeEach(() => createWrapper()); + + const mrText = `!${CANDIDATE.info.ciJob.mergeRequest.iid} ${CANDIDATE.info.ciJob.mergeRequest.title}`; + const expectedTable = [ + [INFO_SECTION, 0, 'ID', CANDIDATE.info.iid], + [INFO_SECTION, 1, 'MLflow run ID', CANDIDATE.info.eid], + [INFO_SECTION, 2, 'Status', CANDIDATE.info.status], + [INFO_SECTION, 3, 'Experiment', CANDIDATE.info.experimentName], + [INFO_SECTION, 4, 'Artifacts', 'Artifacts'], + [CI_SECTION, 0, 'Job', CANDIDATE.info.ciJob.name], + [CI_SECTION, 1, 'Triggered by', 'CI User'], + [CI_SECTION, 2, 'Merge request', mrText], + [PARAMETER_SECTION, 0, CANDIDATE.params[0].name, CANDIDATE.params[0].value], + [PARAMETER_SECTION, 1, CANDIDATE.params[1].name, CANDIDATE.params[1].value], + [METADATA_SECTION, 0, CANDIDATE.metadata[0].name, CANDIDATE.metadata[0].value], + [METADATA_SECTION, 1, CANDIDATE.metadata[1].name, CANDIDATE.metadata[1].value], + ]; + + it.each(expectedTable)('row %s is created correctly', (section, rowIndex, label, text) => { + const row = findRowInSection(section, rowIndex); + + expect(row.props()).toMatchObject({ label }); + expect(row.text()).toBe(text); + }); + + describe('Table links', () => { + const linkRows = [ + [INFO_SECTION, 3, CANDIDATE.info.pathToExperiment], + [INFO_SECTION, 4, CANDIDATE.info.pathToArtifact], + [CI_SECTION, 0, CANDIDATE.info.ciJob.path], + [CI_SECTION, 2, CANDIDATE.info.ciJob.mergeRequest.path], + ]; + + it.each(linkRows)('row %s is created correctly', (section, rowIndex, href) => { + expect(findLinkAtRow(section, rowIndex).attributes().href).toBe(href); + }); + }); + + describe('Metrics table', () => { + it('computes metrics table items correctly', () => { + expect(findMetricsTable().props('items')).toEqual([ + { name: 'AUC', 0: '.55' }, + { name: 'Accuracy', 1: '.99', 2: '.98', 3: '.97' }, + { name: 'F1', 3: '.1' }, + ]); + }); + + it('computes metrics table fields correctly', () => { + expect(findMetricsTable().props('fields')).toEqual([ + expect.objectContaining({ key: 'name', label: 'Metric' }), + expect.objectContaining({ key: '0', label: 'Step 0' }), + expect.objectContaining({ key: '1', label: 'Step 1' }), + expect.objectContaining({ key: '2', label: 'Step 2' }), + expect.objectContaining({ key: '3', label: 'Step 3' }), + ]); + }); + }); + + 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'); + }); + }); + }); + + describe('No artifact path', () => { + beforeEach(() => + createWrapper(() => { + const candidate = newCandidate(); + delete candidate.info.pathToArtifact; + return candidate; + }), + ); + + it('does not render artifact row', () => { + expect(findLabel('Artifacts').exists()).toBe(false); + }); + }); + + describe('No params, metrics, ci or metadata available', () => { + beforeEach(() => + createWrapper(() => { + const candidate = newCandidate(); + delete candidate.params; + delete candidate.metrics; + delete candidate.metadata; + delete candidate.info.ciJob; + return candidate; + }), + ); + + it('does not render params', () => { + expect(findNoDataMessage(NO_PARAMETERS_MESSAGE).exists()).toBe(true); + }); + + it('does not render metadata', () => { + expect(findNoDataMessage(NO_METADATA_MESSAGE).exists()).toBe(true); + }); + + it('does not render metrics', () => { + expect(findNoDataMessage(NO_METRICS_MESSAGE).exists()).toBe(true); + }); + + it('does not render CI info', () => { + expect(findNoDataMessage(NO_CI_MESSAGE).exists()).toBe(true); + }); + }); + + describe('Has CI, but no user or mr', () => { + beforeEach(() => + createWrapper(() => { + const candidate = newCandidate(); + delete candidate.info.ciJob.user; + delete candidate.info.ciJob.mergeRequest; + 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); + }); + }); + + describe('showInfoSection is set to false', () => { + beforeEach(() => createWrapper(() => CANDIDATE, false)); + + it('does not render the info section', () => { + expect(findLabel('MLflow run ID').exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ml/model_registry/components/candidate_list_row_spec.js b/spec/frontend/ml/model_registry/components/candidate_list_row_spec.js new file mode 100644 index 00000000000..5ac8d07ff01 --- /dev/null +++ b/spec/frontend/ml/model_registry/components/candidate_list_row_spec.js @@ -0,0 +1,39 @@ +import { GlLink, GlSprintf, GlTruncate } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import CandidateListRow from '~/ml/model_registry/components/candidate_list_row.vue'; +import { graphqlCandidates } from '../graphql_mock_data'; + +const CANDIDATE = graphqlCandidates[0]; + +let wrapper; +const createWrapper = (candidate = CANDIDATE) => { + wrapper = shallowMount(CandidateListRow, { + propsData: { candidate }, + stubs: { + GlSprintf, + GlTruncate, + }, + }); +}; + +const findListItem = () => wrapper.findComponent(ListItem); +const findLink = () => findListItem().findComponent(GlLink); +const findTruncated = () => findLink().findComponent(GlTruncate); +const findTooltip = () => findListItem().findComponent(TimeAgoTooltip); + +describe('ml/model_registry/components/candidate_list_row.vue', () => { + beforeEach(() => { + createWrapper(); + }); + + it('Has a link to the candidate', () => { + expect(findTruncated().props('text')).toBe(CANDIDATE.name); + expect(findLink().attributes('href')).toBe(CANDIDATE._links.showPath); + }); + + it('Shows created at', () => { + expect(findTooltip().props('time')).toBe(CANDIDATE.createdAt); + }); +}); diff --git a/spec/frontend/ml/model_registry/components/candidate_list_spec.js b/spec/frontend/ml/model_registry/components/candidate_list_spec.js new file mode 100644 index 00000000000..c10222a99fd --- /dev/null +++ b/spec/frontend/ml/model_registry/components/candidate_list_spec.js @@ -0,0 +1,182 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import CandidateList from '~/ml/model_registry/components/candidate_list.vue'; +import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import CandidateListRow from '~/ml/model_registry/components/candidate_list_row.vue'; +import getModelCandidatesQuery from '~/ml/model_registry/graphql/queries/get_model_candidates.query.graphql'; +import { GRAPHQL_PAGE_SIZE } from '~/ml/model_registry/constants'; +import { + emptyCandidateQuery, + modelCandidatesQuery, + graphqlCandidates, + graphqlPageInfo, +} from '../graphql_mock_data'; + +Vue.use(VueApollo); + +describe('ml/model_registry/components/candidate_list.vue', () => { + let wrapper; + let apolloProvider; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findLoader = () => wrapper.findComponent(PackagesListLoader); + const findRegistryList = () => wrapper.findComponent(RegistryList); + const findListRow = () => wrapper.findComponent(CandidateListRow); + const findAllRows = () => wrapper.findAllComponents(CandidateListRow); + + const mountComponent = ({ + props = {}, + resolver = jest.fn().mockResolvedValue(modelCandidatesQuery()), + } = {}) => { + const requestHandlers = [[getModelCandidatesQuery, resolver]]; + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMount(CandidateList, { + apolloProvider, + propsData: { + modelId: 2, + ...props, + }, + stubs: { + RegistryList, + }, + }); + }; + + beforeEach(() => { + jest.spyOn(Sentry, 'captureException').mockImplementation(); + }); + + describe('when list is loaded and has no data', () => { + const resolver = jest.fn().mockResolvedValue(emptyCandidateQuery); + beforeEach(async () => { + mountComponent({ resolver }); + await waitForPromises(); + }); + + it('displays empty slot message', () => { + expect(wrapper.text()).toContain('This model has no candidates'); + }); + + it('does not display loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('does not display rows', () => { + expect(findListRow().exists()).toBe(false); + }); + + it('does not display registry list', () => { + expect(findRegistryList().exists()).toBe(false); + }); + + it('does not display alert', () => { + expect(findAlert().exists()).toBe(false); + }); + }); + + describe('if load fails, alert', () => { + beforeEach(async () => { + const error = new Error('Failure!'); + mountComponent({ resolver: jest.fn().mockRejectedValue(error) }); + + await waitForPromises(); + }); + + it('is displayed', () => { + expect(findAlert().exists()).toBe(true); + }); + + it('shows error message', () => { + expect(findAlert().text()).toContain('Failed to load model candidates with error: Failure!'); + }); + + it('is not dismissible', () => { + expect(findAlert().props('dismissible')).toBe(false); + }); + + it('is of variant danger', () => { + expect(findAlert().attributes('variant')).toBe('danger'); + }); + + it('error is logged in sentry', () => { + expect(Sentry.captureException).toHaveBeenCalled(); + }); + }); + + describe('when list is loaded with data', () => { + beforeEach(async () => { + mountComponent(); + await waitForPromises(); + }); + + it('displays package registry list', () => { + expect(findRegistryList().exists()).toEqual(true); + }); + + it('binds the right props', () => { + expect(findRegistryList().props()).toMatchObject({ + items: graphqlCandidates, + pagination: {}, + isLoading: false, + hiddenDelete: true, + }); + }); + + it('displays candidate rows', () => { + expect(findAllRows().exists()).toEqual(true); + expect(findAllRows()).toHaveLength(graphqlCandidates.length); + }); + + it('binds the correct props', () => { + expect(findAllRows().at(0).props()).toMatchObject({ + candidate: expect.objectContaining(graphqlCandidates[0]), + }); + + expect(findAllRows().at(1).props()).toMatchObject({ + candidate: expect.objectContaining(graphqlCandidates[1]), + }); + }); + + it('does not display loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('does not display empty message', () => { + expect(findAlert().exists()).toBe(false); + }); + }); + + describe('when user interacts with pagination', () => { + const resolver = jest.fn().mockResolvedValue(modelCandidatesQuery()); + + beforeEach(async () => { + mountComponent({ resolver }); + await waitForPromises(); + }); + + it('when list emits next-page fetches the next set of records', async () => { + findRegistryList().vm.$emit('next-page'); + await waitForPromises(); + + expect(resolver).toHaveBeenLastCalledWith( + expect.objectContaining({ after: graphqlPageInfo.endCursor, first: GRAPHQL_PAGE_SIZE }), + ); + }); + + it('when list emits prev-page fetches the prev set of records', async () => { + findRegistryList().vm.$emit('prev-page'); + await waitForPromises(); + + expect(resolver).toHaveBeenLastCalledWith( + expect.objectContaining({ before: graphqlPageInfo.startCursor, last: GRAPHQL_PAGE_SIZE }), + ); + }); + }); +}); diff --git a/spec/frontend/ml/model_registry/components/empty_state_spec.js b/spec/frontend/ml/model_registry/components/empty_state_spec.js new file mode 100644 index 00000000000..e9477518f7d --- /dev/null +++ b/spec/frontend/ml/model_registry/components/empty_state_spec.js @@ -0,0 +1,47 @@ +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { MODEL_ENTITIES } from '~/ml/model_registry/constants'; +import EmptyState from '~/ml/model_registry/components/empty_state.vue'; + +let wrapper; +const createWrapper = (entityType) => { + wrapper = shallowMount(EmptyState, { propsData: { entityType } }); +}; + +const findEmptyState = () => wrapper.findComponent(GlEmptyState); + +describe('ml/model_registry/components/empty_state.vue', () => { + describe('when entity type is model', () => { + beforeEach(() => { + createWrapper(MODEL_ENTITIES.model); + }); + + it('shows the correct empty state', () => { + expect(findEmptyState().props()).toMatchObject({ + title: 'Start tracking your machine learning models', + description: 'Store and manage your machine learning models and versions', + primaryButtonText: 'Add a model', + primaryButtonLink: + '/help/user/project/ml/model_registry/index#creating-machine-learning-models-and-model-versions', + svgPath: 'file-mock', + }); + }); + }); + + describe('when entity type is model version', () => { + beforeEach(() => { + createWrapper(MODEL_ENTITIES.modelVersion); + }); + + it('shows the correct empty state', () => { + expect(findEmptyState().props()).toMatchObject({ + title: 'Manage versions of your machine learning model', + description: 'Use versions to track performance, parameters, and metadata', + primaryButtonText: 'Create a model version', + primaryButtonLink: + '/help/user/project/ml/model_registry/index#creating-machine-learning-models-and-model-versions', + svgPath: 'file-mock', + }); + }); + }); +}); diff --git a/spec/frontend/ml/model_registry/components/model_version_detail_spec.js b/spec/frontend/ml/model_registry/components/model_version_detail_spec.js new file mode 100644 index 00000000000..d1874346ad7 --- /dev/null +++ b/spec/frontend/ml/model_registry/components/model_version_detail_spec.js @@ -0,0 +1,66 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import ModelVersionDetail from '~/ml/model_registry/components/model_version_detail.vue'; +import PackageFiles from '~/packages_and_registries/package_registry/components/details/package_files.vue'; +import CandidateDetail from '~/ml/model_registry/components/candidate_detail.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { makeModelVersion, MODEL_VERSION } from '../mock_data'; + +Vue.use(VueApollo); + +let wrapper; +const createWrapper = (modelVersion = MODEL_VERSION) => { + const apolloProvider = createMockApollo([]); + wrapper = shallowMount(ModelVersionDetail, { apolloProvider, propsData: { modelVersion } }); +}; + +const findPackageFiles = () => wrapper.findComponent(PackageFiles); +const findCandidateDetail = () => wrapper.findComponent(CandidateDetail); + +describe('ml/model_registry/components/model_version_detail.vue', () => { + describe('base behaviour', () => { + beforeEach(() => createWrapper()); + + it('shows the description', () => { + expect(wrapper.text()).toContain(MODEL_VERSION.description); + }); + + it('shows the candidate', () => { + expect(findCandidateDetail().props('candidate')).toBe(MODEL_VERSION.candidate); + }); + + it('shows the mlflow label string', () => { + expect(wrapper.text()).toContain('MLflow run ID'); + }); + + it('shows the mlflow id', () => { + expect(wrapper.text()).toContain(MODEL_VERSION.candidate.info.eid); + }); + + it('renders files', () => { + expect(findPackageFiles().props()).toEqual({ + packageId: 'gid://gitlab/Packages::Package/12', + projectPath: MODEL_VERSION.projectPath, + packageType: 'ml_model', + canDelete: false, + }); + }); + }); + + describe('if package does not exist', () => { + beforeEach(() => createWrapper(makeModelVersion({ packageId: 0 }))); + + it('does not render files', () => { + expect(findPackageFiles().exists()).toBe(false); + }); + }); + + describe('if model version does not have description', () => { + beforeEach(() => createWrapper(makeModelVersion({ description: null }))); + + it('renders no description provided label', () => { + expect(wrapper.text()).toContain('No description provided'); + }); + }); +}); diff --git a/spec/frontend/ml/model_registry/components/model_version_list_spec.js b/spec/frontend/ml/model_registry/components/model_version_list_spec.js new file mode 100644 index 00000000000..41f7e71c543 --- /dev/null +++ b/spec/frontend/ml/model_registry/components/model_version_list_spec.js @@ -0,0 +1,184 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlAlert } from '@gitlab/ui'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import ModelVersionList from '~/ml/model_registry/components/model_version_list.vue'; +import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import ModelVersionRow from '~/ml/model_registry/components/model_version_row.vue'; +import getModelVersionsQuery from '~/ml/model_registry/graphql/queries/get_model_versions.query.graphql'; +import EmptyState from '~/ml/model_registry/components/empty_state.vue'; +import { GRAPHQL_PAGE_SIZE, MODEL_ENTITIES } from '~/ml/model_registry/constants'; +import { + emptyModelVersionsQuery, + modelVersionsQuery, + graphqlModelVersions, + graphqlPageInfo, +} from '../graphql_mock_data'; + +Vue.use(VueApollo); + +describe('ModelVersionList', () => { + let wrapper; + let apolloProvider; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findLoader = () => wrapper.findComponent(PackagesListLoader); + const findRegistryList = () => wrapper.findComponent(RegistryList); + const findEmptyState = () => wrapper.findComponent(EmptyState); + const findListRow = () => wrapper.findComponent(ModelVersionRow); + const findAllRows = () => wrapper.findAllComponents(ModelVersionRow); + + const mountComponent = ({ + props = {}, + resolver = jest.fn().mockResolvedValue(modelVersionsQuery()), + } = {}) => { + const requestHandlers = [[getModelVersionsQuery, resolver]]; + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMountExtended(ModelVersionList, { + apolloProvider, + propsData: { + modelId: 2, + ...props, + }, + stubs: { + RegistryList, + }, + }); + }; + + beforeEach(() => { + jest.spyOn(Sentry, 'captureException').mockImplementation(); + }); + + describe('when list is loaded and has no data', () => { + const resolver = jest.fn().mockResolvedValue(emptyModelVersionsQuery); + beforeEach(async () => { + mountComponent({ resolver }); + await waitForPromises(); + }); + + it('shows empty state', () => { + expect(findEmptyState().props('entityType')).toBe(MODEL_ENTITIES.modelVersion); + }); + + it('does not display loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('does not display rows', () => { + expect(findListRow().exists()).toBe(false); + }); + + it('does not display registry list', () => { + expect(findRegistryList().exists()).toBe(false); + }); + + it('does not display alert', () => { + expect(findAlert().exists()).toBe(false); + }); + }); + + describe('if load fails, alert', () => { + beforeEach(async () => { + const error = new Error('Failure!'); + mountComponent({ resolver: jest.fn().mockRejectedValue(error) }); + + await waitForPromises(); + }); + + it('is displayed', () => { + expect(findAlert().exists()).toBe(true); + }); + + it('shows error message', () => { + expect(findAlert().text()).toContain('Failed to load model versions with error: Failure!'); + }); + + it('is not dismissible', () => { + expect(findAlert().props('dismissible')).toBe(false); + }); + + it('is of variant danger', () => { + expect(findAlert().attributes('variant')).toBe('danger'); + }); + + it('error is logged in sentry', () => { + expect(Sentry.captureException).toHaveBeenCalled(); + }); + }); + + describe('when list is loaded with data', () => { + beforeEach(async () => { + mountComponent(); + await waitForPromises(); + }); + + it('displays package registry list', () => { + expect(findRegistryList().exists()).toEqual(true); + }); + + it('binds the right props', () => { + expect(findRegistryList().props()).toMatchObject({ + items: graphqlModelVersions, + pagination: {}, + isLoading: false, + hiddenDelete: true, + }); + }); + + it('displays package version rows', () => { + expect(findAllRows().exists()).toEqual(true); + expect(findAllRows()).toHaveLength(graphqlModelVersions.length); + }); + + it('binds the correct props', () => { + expect(findAllRows().at(0).props()).toMatchObject({ + modelVersion: expect.objectContaining(graphqlModelVersions[0]), + }); + + expect(findAllRows().at(1).props()).toMatchObject({ + modelVersion: expect.objectContaining(graphqlModelVersions[1]), + }); + }); + + it('does not display loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('does not display empty state', () => { + expect(findEmptyState().exists()).toBe(false); + }); + }); + + describe('when user interacts with pagination', () => { + const resolver = jest.fn().mockResolvedValue(modelVersionsQuery()); + + beforeEach(async () => { + mountComponent({ resolver }); + await waitForPromises(); + }); + + it('when list emits next-page fetches the next set of records', async () => { + findRegistryList().vm.$emit('next-page'); + await waitForPromises(); + + expect(resolver).toHaveBeenLastCalledWith( + expect.objectContaining({ after: graphqlPageInfo.endCursor, first: GRAPHQL_PAGE_SIZE }), + ); + }); + + it('when list emits prev-page fetches the prev set of records', async () => { + findRegistryList().vm.$emit('prev-page'); + await waitForPromises(); + + expect(resolver).toHaveBeenLastCalledWith( + expect.objectContaining({ before: graphqlPageInfo.startCursor, last: GRAPHQL_PAGE_SIZE }), + ); + }); + }); +}); diff --git a/spec/frontend/ml/model_registry/components/model_version_row_spec.js b/spec/frontend/ml/model_registry/components/model_version_row_spec.js new file mode 100644 index 00000000000..9f709f2e072 --- /dev/null +++ b/spec/frontend/ml/model_registry/components/model_version_row_spec.js @@ -0,0 +1,37 @@ +import { GlLink, GlSprintf, GlTruncate } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import ModelVersionRow from '~/ml/model_registry/components/model_version_row.vue'; +import { graphqlModelVersions } from '../graphql_mock_data'; + +let wrapper; +const createWrapper = (modelVersion = graphqlModelVersions[0]) => { + wrapper = shallowMount(ModelVersionRow, { + propsData: { modelVersion }, + stubs: { + GlSprintf, + GlTruncate, + }, + }); +}; + +const findListItem = () => wrapper.findComponent(ListItem); +const findLink = () => findListItem().findComponent(GlLink); +const findTruncated = () => findLink().findComponent(GlTruncate); +const findTooltip = () => findListItem().findComponent(TimeAgoTooltip); + +describe('ModelVersionRow', () => { + beforeEach(() => { + createWrapper(); + }); + + it('Has a link to the model version', () => { + expect(findTruncated().props('text')).toBe(graphqlModelVersions[0].version); + expect(findLink().attributes('href')).toBe(graphqlModelVersions[0]._links.showPath); + }); + + it('Shows created at', () => { + expect(findTooltip().props('time')).toBe(graphqlModelVersions[0].createdAt); + }); +}); diff --git a/spec/frontend/ml/model_registry/graphql_mock_data.js b/spec/frontend/ml/model_registry/graphql_mock_data.js new file mode 100644 index 00000000000..1c31ee4627f --- /dev/null +++ b/spec/frontend/ml/model_registry/graphql_mock_data.js @@ -0,0 +1,116 @@ +import { defaultPageInfo } from './mock_data'; + +export const graphqlPageInfo = { + ...defaultPageInfo, + __typename: 'PageInfo', +}; + +export const graphqlModelVersions = [ + { + createdAt: '2021-08-10T09:33:54Z', + id: 'gid://gitlab/Ml::ModelVersion/243', + version: '1.0.1', + _links: { + showPath: '/path/to/modelversion/243', + }, + __typename: 'MlModelVersion', + }, + { + createdAt: '2021-08-10T09:33:54Z', + id: 'gid://gitlab/Ml::ModelVersion/244', + version: '1.0.2', + _links: { + showPath: '/path/to/modelversion/244', + }, + __typename: 'MlModelVersion', + }, +]; + +export const modelVersionsQuery = (versions = graphqlModelVersions) => ({ + data: { + mlModel: { + id: 'gid://gitlab/Ml::Model/2', + versions: { + count: versions.length, + nodes: versions, + pageInfo: graphqlPageInfo, + __typename: 'MlModelConnection', + }, + __typename: 'MlModelType', + }, + }, +}); + +export const graphqlCandidates = [ + { + id: 'gid://gitlab/Ml::Candidate/1', + name: 'narwhal-aardvark-heron-6953', + createdAt: '2023-12-06T12:41:48Z', + _links: { + showPath: '/path/to/candidate/1', + }, + }, + { + id: 'gid://gitlab/Ml::Candidate/2', + name: 'anteater-chimpanzee-snake-1254', + createdAt: '2023-12-06T12:41:48Z', + _links: { + showPath: '/path/to/candidate/2', + }, + }, +]; + +export const modelCandidatesQuery = (candidates = graphqlCandidates) => ({ + data: { + mlModel: { + id: 'gid://gitlab/Ml::Model/2', + candidates: { + count: candidates.length, + nodes: candidates, + pageInfo: graphqlPageInfo, + __typename: 'MlCandidateConnection', + }, + __typename: 'MlModelType', + }, + }, +}); + +export const emptyModelVersionsQuery = { + data: { + mlModel: { + id: 'gid://gitlab/Ml::Model/2', + versions: { + count: 0, + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + endCursor: 'endCursor', + startCursor: 'startCursor', + }, + __typename: 'MlModelConnection', + }, + __typename: 'MlModelType', + }, + }, +}; + +export const emptyCandidateQuery = { + data: { + mlModel: { + id: 'gid://gitlab/Ml::Model/2', + candidates: { + count: 0, + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + endCursor: 'endCursor', + startCursor: 'startCursor', + }, + __typename: 'MlCandidateConnection', + }, + __typename: 'MlModelType', + }, + }, +}; diff --git a/spec/frontend/ml/model_registry/mock_data.js b/spec/frontend/ml/model_registry/mock_data.js index a820c323103..4399df38990 100644 --- a/spec/frontend/ml/model_registry/mock_data.js +++ b/spec/frontend/ml/model_registry/mock_data.js @@ -1,3 +1,45 @@ +export const newCandidate = () => ({ + params: [ + { name: 'Algorithm', value: 'Decision Tree' }, + { name: 'MaxDepth', value: '3' }, + ], + metrics: [ + { name: 'AUC', value: '.55', step: 0 }, + { name: 'Accuracy', value: '.99', step: 1 }, + { name: 'Accuracy', value: '.98', step: 2 }, + { name: 'Accuracy', value: '.97', step: 3 }, + { name: 'F1', value: '.1', step: 3 }, + ], + metadata: [ + { name: 'FileName', value: 'test.py' }, + { name: 'ExecutionTime', value: '.0856' }, + ], + info: { + iid: 'candidate_iid', + eid: 'abcdefg', + pathToArtifact: 'path_to_artifact', + experimentName: 'The Experiment', + pathToExperiment: 'path/to/experiment', + status: 'SUCCESS', + path: 'path_to_candidate', + ciJob: { + name: 'test', + path: 'path/to/job', + mergeRequest: { + path: 'path/to/mr', + iid: 1, + title: 'Some MR', + }, + user: { + path: 'path/to/ci/user', + name: 'CI User', + username: 'ciuser', + avatar: '/img.png', + }, + }, + }, +}); + const LATEST_VERSION = { version: '1.2.3', }; @@ -14,7 +56,21 @@ export const makeModel = ({ latestVersion } = { latestVersion: LATEST_VERSION }) export const MODEL = makeModel(); -export const MODEL_VERSION = { version: '1.2.3', model: MODEL }; +export const makeModelVersion = ({ + version = '1.2.3', + model = MODEL, + packageId = 12, + description = 'Model version description', +} = {}) => ({ + version, + model, + packageId, + description, + projectPath: 'path/to/project', + candidate: newCandidate(), +}); + +export const MODEL_VERSION = makeModelVersion(); export const mockModels = [ { diff --git a/spec/frontend/ml/model_registry/routes/models/index/components/mock_data.js b/spec/frontend/ml/model_registry/routes/models/index/components/mock_data.js deleted file mode 100644 index 841a543606f..00000000000 --- a/spec/frontend/ml/model_registry/routes/models/index/components/mock_data.js +++ /dev/null @@ -1,29 +0,0 @@ -export const mockModels = [ - { - name: 'model_1', - version: '1.0', - path: 'path/to/model_1', - versionCount: 3, - }, - { - name: 'model_2', - version: '1.1', - path: 'path/to/model_2', - versionCount: 1, - }, -]; - -export const modelWithoutVersion = { - name: 'model_without_version', - path: 'path/to/model_without_version', - versionCount: 0, -}; - -export const startCursor = 'eyJpZCI6IjE2In0'; - -export const defaultPageInfo = Object.freeze({ - startCursor, - endCursor: 'eyJpZCI6IjIifQ', - hasNextPage: true, - hasPreviousPage: true, -}); diff --git a/spec/frontend/nav/components/new_nav_toggle_spec.js b/spec/frontend/nav/components/new_nav_toggle_spec.js deleted file mode 100644 index cf8e59d6522..00000000000 --- a/spec/frontend/nav/components/new_nav_toggle_spec.js +++ /dev/null @@ -1,214 +0,0 @@ -import { mount, createWrapper } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import { getByText as getByTextHelper } from '@testing-library/dom'; -import { GlDisclosureDropdownItem, GlToggle } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; -import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; -import waitForPromises from 'helpers/wait_for_promises'; -import { createAlert } from '~/alert'; -import { s__ } from '~/locale'; -import { mockTracking } from 'helpers/tracking_helper'; - -jest.mock('~/alert'); - -const TEST_ENDPONT = 'https://example.com/toggle'; - -describe('NewNavToggle', () => { - useMockLocationHelper(); - - let wrapper; - let trackingSpy; - - const findToggle = () => wrapper.findComponent(GlToggle); - const findDisclosureItem = () => wrapper.findComponent(GlDisclosureDropdownItem); - - const createComponent = (propsData = { enabled: false }) => { - wrapper = mount(NewNavToggle, { - propsData: { - endpoint: TEST_ENDPONT, - ...propsData, - }, - }); - - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - }; - - const getByText = (text, options) => - createWrapper(getByTextHelper(wrapper.element, text, options)); - - describe('When rendered in scope of the new navigation', () => { - it('renders the disclosure item', () => { - createComponent({ newNavigation: true, enabled: true }); - expect(findDisclosureItem().exists()).toBe(true); - }); - - describe('when user preference is enabled', () => { - beforeEach(() => { - createComponent({ newNavigation: true, enabled: true }); - }); - - it('renders the toggle as enabled', () => { - expect(findToggle().props('value')).toBe(true); - }); - }); - - describe('when user preference is disabled', () => { - beforeEach(() => { - createComponent({ enabled: false }); - }); - - it('renders the toggle as disabled', () => { - expect(findToggle().props('value')).toBe(false); - }); - }); - - describe.each` - desc | actFn | toggleValue | trackingLabel | trackingProperty - ${'when toggle button is clicked'} | ${() => findToggle().trigger('click')} | ${false} | ${'enable_new_nav_beta'} | ${'navigation_top'} - ${'when menu item text is clicked'} | ${() => getByText('New navigation').trigger('click')} | ${false} | ${'enable_new_nav_beta'} | ${'navigation_top'} - ${'when toggle button is clicked'} | ${() => findToggle().trigger('click')} | ${true} | ${'disable_new_nav_beta'} | ${'nav_user_menu'} - ${'when menu item text is clicked'} | ${() => getByText('New navigation').trigger('click')} | ${true} | ${'disable_new_nav_beta'} | ${'nav_user_menu'} - `('$desc', ({ actFn, toggleValue, trackingLabel, trackingProperty }) => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - createComponent({ enabled: toggleValue }); - }); - - it('reloads the page on success', async () => { - mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_OK); - - actFn(); - await waitForPromises(); - - expect(window.location.reload).toHaveBeenCalled(); - }); - - it('shows an alert on error', async () => { - mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - - actFn(); - await waitForPromises(); - - expect(createAlert).toHaveBeenCalledWith( - expect.objectContaining({ - message: s__( - 'NorthstarNavigation|Could not update the new navigation preference. Please try again later.', - ), - }), - ); - expect(window.location.reload).not.toHaveBeenCalled(); - }); - - it('changes the toggle', async () => { - await actFn(); - - expect(findToggle().props('value')).toBe(!toggleValue); - }); - - it('tracks the Snowplow event', async () => { - mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_OK); - await actFn(); - await waitForPromises(); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_toggle', { - label: trackingLabel, - property: trackingProperty, - }); - }); - - afterEach(() => { - mock.restore(); - }); - }); - }); - - describe('When rendered in scope of the current navigation', () => { - it('renders its title', () => { - createComponent(); - expect(getByText('Navigation redesign').exists()).toBe(true); - }); - - describe('when user preference is enabled', () => { - beforeEach(() => { - createComponent({ enabled: true }); - }); - - it('renders the toggle as enabled', () => { - expect(findToggle().props('value')).toBe(true); - }); - }); - - describe('when user preference is disabled', () => { - beforeEach(() => { - createComponent({ enabled: false }); - }); - - it('renders the toggle as disabled', () => { - expect(findToggle().props('value')).toBe(false); - }); - }); - - describe.each` - desc | actFn | toggleValue | trackingLabel | trackingProperty - ${'when toggle button is clicked'} | ${() => findToggle().trigger('click')} | ${false} | ${'enable_new_nav_beta'} | ${'navigation_top'} - ${'when menu item text is clicked'} | ${() => getByText('New navigation').trigger('click')} | ${false} | ${'enable_new_nav_beta'} | ${'navigation_top'} - ${'when toggle button is clicked'} | ${() => findToggle().trigger('click')} | ${true} | ${'disable_new_nav_beta'} | ${'nav_user_menu'} - ${'when menu item text is clicked'} | ${() => getByText('New navigation').trigger('click')} | ${true} | ${'disable_new_nav_beta'} | ${'nav_user_menu'} - `('$desc', ({ actFn, toggleValue, trackingLabel, trackingProperty }) => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - createComponent({ enabled: toggleValue }); - }); - - it('reloads the page on success', async () => { - mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_OK); - - actFn(); - await waitForPromises(); - - expect(window.location.reload).toHaveBeenCalled(); - }); - - it('shows an alert on error', async () => { - mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - - actFn(); - await waitForPromises(); - - expect(createAlert).toHaveBeenCalledWith( - expect.objectContaining({ - message: s__( - 'NorthstarNavigation|Could not update the new navigation preference. Please try again later.', - ), - }), - ); - expect(window.location.reload).not.toHaveBeenCalled(); - }); - - it('changes the toggle', async () => { - await actFn(); - - expect(findToggle().props('value')).toBe(!toggleValue); - }); - - it('tracks the Snowplow event', async () => { - mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_OK); - await actFn(); - await waitForPromises(); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_toggle', { - label: trackingLabel, - property: trackingProperty, - }); - }); - - afterEach(() => { - mock.restore(); - }); - }); - }); -}); diff --git a/spec/frontend/nav/components/responsive_app_spec.js b/spec/frontend/nav/components/responsive_app_spec.js deleted file mode 100644 index 9d3b43520ec..00000000000 --- a/spec/frontend/nav/components/responsive_app_spec.js +++ /dev/null @@ -1,122 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import ResponsiveApp from '~/nav/components/responsive_app.vue'; -import ResponsiveHeader from '~/nav/components/responsive_header.vue'; -import ResponsiveHome from '~/nav/components/responsive_home.vue'; -import TopNavContainerView from '~/nav/components/top_nav_container_view.vue'; -import { resetMenuItemsActive } from '~/nav/utils/reset_menu_items_active'; -import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue'; -import { TEST_NAV_DATA } from '../mock_data'; - -describe('~/nav/components/responsive_app.vue', () => { - let wrapper; - - const createComponent = () => { - wrapper = shallowMount(ResponsiveApp, { - propsData: { - navData: TEST_NAV_DATA, - }, - stubs: { - KeepAliveSlots, - }, - }); - }; - const findHome = () => wrapper.findComponent(ResponsiveHome); - const findMobileOverlay = () => wrapper.find('[data-testid="mobile-overlay"]'); - const findSubviewHeader = () => wrapper.findComponent(ResponsiveHeader); - const findSubviewContainer = () => wrapper.findComponent(TopNavContainerView); - const hasMobileOverlayVisible = () => findMobileOverlay().classes('mobile-nav-open'); - - beforeEach(() => { - document.body.innerHTML = ''; - // Add test class to reset state + assert that we're adding classes correctly - document.body.className = 'test-class'; - }); - - describe('default', () => { - beforeEach(() => { - createComponent(); - }); - - it('shows home by default', () => { - expect(findHome().isVisible()).toBe(true); - expect(findHome().props()).toEqual({ - navData: resetMenuItemsActive(TEST_NAV_DATA), - }); - }); - - it.each` - events | expectation - ${[]} | ${false} - ${['bv::dropdown::show']} | ${true} - ${['bv::dropdown::show', 'bv::dropdown::hide']} | ${false} - `( - 'with root events $events, movile overlay visible = $expectation', - async ({ events, expectation }) => { - // `await...reduce(async` is like doing an `forEach(async (...))` excpet it works - await events.reduce(async (acc, evt) => { - await acc; - - wrapper.vm.$root.$emit(evt); - - await nextTick(); - }, Promise.resolve()); - - expect(hasMobileOverlayVisible()).toBe(expectation); - }, - ); - }); - - const projectsContainerProps = { - containerClass: 'gl-px-3', - frequentItemsDropdownType: ResponsiveApp.FREQUENT_ITEMS_PROJECTS.namespace, - frequentItemsVuexModule: ResponsiveApp.FREQUENT_ITEMS_PROJECTS.vuexModule, - currentItem: {}, - linksPrimary: TEST_NAV_DATA.views.projects.linksPrimary, - linksSecondary: TEST_NAV_DATA.views.projects.linksSecondary, - }; - const groupsContainerProps = { - containerClass: 'gl-px-3', - frequentItemsDropdownType: ResponsiveApp.FREQUENT_ITEMS_GROUPS.namespace, - frequentItemsVuexModule: ResponsiveApp.FREQUENT_ITEMS_GROUPS.vuexModule, - currentItem: {}, - linksPrimary: TEST_NAV_DATA.views.groups.linksPrimary, - linksSecondary: TEST_NAV_DATA.views.groups.linksSecondary, - }; - - describe.each` - view | header | containerProps - ${'projects'} | ${'Projects'} | ${projectsContainerProps} - ${'groups'} | ${'Groups'} | ${groupsContainerProps} - `('when menu item with $view is clicked', ({ view, header, containerProps }) => { - beforeEach(async () => { - createComponent(); - - findHome().vm.$emit('menu-item-click', { view }); - - await nextTick(); - }); - - it('shows header', () => { - expect(findSubviewHeader().text()).toBe(header); - }); - - it('shows container subview', () => { - expect(findSubviewContainer().props()).toEqual(containerProps); - }); - - it('hides home', () => { - expect(findHome().isVisible()).toBe(false); - }); - - describe('when header back button is clicked', () => { - beforeEach(() => { - findSubviewHeader().vm.$emit('menu-item-click', { view: 'home' }); - }); - - it('shows home', () => { - expect(findHome().isVisible()).toBe(true); - }); - }); - }); -}); diff --git a/spec/frontend/nav/components/responsive_header_spec.js b/spec/frontend/nav/components/responsive_header_spec.js deleted file mode 100644 index 2514035270a..00000000000 --- a/spec/frontend/nav/components/responsive_header_spec.js +++ /dev/null @@ -1,63 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import ResponsiveHeader from '~/nav/components/responsive_header.vue'; -import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue'; - -const TEST_SLOT_CONTENT = 'Test slot content'; - -describe('~/nav/components/top_nav_menu_sections.vue', () => { - let wrapper; - - const createComponent = () => { - wrapper = shallowMount(ResponsiveHeader, { - slots: { - default: TEST_SLOT_CONTENT, - }, - directives: { - GlTooltip: createMockDirective('gl-tooltip'), - }, - }); - }; - - const findMenuItem = () => wrapper.findComponent(TopNavMenuItem); - - beforeEach(() => { - createComponent(); - }); - - it('renders slot', () => { - expect(wrapper.text()).toBe(TEST_SLOT_CONTENT); - }); - - it('renders back button', () => { - const button = findMenuItem(); - - const tooltip = getBinding(button.element, 'gl-tooltip').value.title; - - expect(tooltip).toBe('Go back'); - expect(button.props()).toEqual({ - menuItem: { - id: 'home', - view: 'home', - icon: 'chevron-lg-left', - }, - iconOnly: true, - }); - }); - - it('emits nothing', () => { - expect(wrapper.emitted()).toEqual({}); - }); - - describe('when back button is clicked', () => { - beforeEach(() => { - findMenuItem().vm.$emit('click'); - }); - - it('emits menu-item-click', () => { - expect(wrapper.emitted()).toEqual({ - 'menu-item-click': [[{ id: 'home', view: 'home', icon: 'chevron-lg-left' }]], - }); - }); - }); -}); diff --git a/spec/frontend/nav/components/responsive_home_spec.js b/spec/frontend/nav/components/responsive_home_spec.js deleted file mode 100644 index 5a5cfc93607..00000000000 --- a/spec/frontend/nav/components/responsive_home_spec.js +++ /dev/null @@ -1,133 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import ResponsiveHome from '~/nav/components/responsive_home.vue'; -import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue'; -import TopNavMenuSections from '~/nav/components/top_nav_menu_sections.vue'; -import TopNavNewDropdown from '~/nav/components/top_nav_new_dropdown.vue'; -import { TEST_NAV_DATA } from '../mock_data'; - -const TEST_SEARCH_MENU_ITEM = { - id: 'search', - title: 'search', - icon: 'search', - href: '/search', -}; - -const TEST_NEW_DROPDOWN_VIEW_MODEL = { - title: 'new', - menu_sections: [], -}; - -describe('~/nav/components/responsive_home.vue', () => { - let wrapper; - let menuItemClickListener; - - const createComponent = (props = {}) => { - wrapper = shallowMount(ResponsiveHome, { - propsData: { - navData: TEST_NAV_DATA, - ...props, - }, - directives: { - GlTooltip: createMockDirective('gl-tooltip'), - }, - listeners: { - 'menu-item-click': menuItemClickListener, - }, - }); - }; - - const findSearchMenuItem = () => wrapper.findComponent(TopNavMenuItem); - const findNewDropdown = () => wrapper.findComponent(TopNavNewDropdown); - const findMenuSections = () => wrapper.findComponent(TopNavMenuSections); - - beforeEach(() => { - menuItemClickListener = jest.fn(); - }); - - describe('default', () => { - beforeEach(() => { - createComponent(); - }); - - it.each` - desc | fn - ${'does not show search menu item'} | ${findSearchMenuItem} - ${'does not show new dropdown'} | ${findNewDropdown} - `('$desc', ({ fn }) => { - expect(fn().exists()).toBe(false); - }); - - it('shows menu sections', () => { - expect(findMenuSections().props('sections')).toEqual([ - { id: 'primary', menuItems: TEST_NAV_DATA.primary }, - { id: 'secondary', menuItems: TEST_NAV_DATA.secondary }, - ]); - }); - - it('emits when menu sections emits', () => { - expect(menuItemClickListener).not.toHaveBeenCalled(); - - findMenuSections().vm.$emit('menu-item-click', TEST_NAV_DATA.primary[0]); - - expect(menuItemClickListener).toHaveBeenCalledWith(TEST_NAV_DATA.primary[0]); - }); - }); - - describe('without secondary', () => { - beforeEach(() => { - createComponent({ navData: { ...TEST_NAV_DATA, secondary: null } }); - }); - - it('shows menu sections', () => { - expect(findMenuSections().props('sections')).toEqual([ - { id: 'primary', menuItems: TEST_NAV_DATA.primary }, - ]); - }); - }); - - describe('with search view', () => { - beforeEach(() => { - createComponent({ - navData: { - ...TEST_NAV_DATA, - views: { search: TEST_SEARCH_MENU_ITEM }, - }, - }); - }); - - it('shows search menu item', () => { - expect(findSearchMenuItem().props()).toEqual({ - menuItem: TEST_SEARCH_MENU_ITEM, - iconOnly: true, - }); - }); - - it('shows tooltip for search', () => { - const tooltip = getBinding(findSearchMenuItem().element, 'gl-tooltip'); - expect(tooltip.value).toEqual({ title: TEST_SEARCH_MENU_ITEM.title }); - }); - }); - - describe('with new view', () => { - beforeEach(() => { - createComponent({ - navData: { - ...TEST_NAV_DATA, - views: { new: TEST_NEW_DROPDOWN_VIEW_MODEL }, - }, - }); - }); - - it('shows new dropdown', () => { - expect(findNewDropdown().props()).toEqual({ - viewModel: TEST_NEW_DROPDOWN_VIEW_MODEL, - }); - }); - - it('shows tooltip for new dropdown', () => { - const tooltip = getBinding(findNewDropdown().element, 'gl-tooltip'); - expect(tooltip.value).toEqual({ title: TEST_NEW_DROPDOWN_VIEW_MODEL.title }); - }); - }); -}); diff --git a/spec/frontend/nav/components/top_nav_app_spec.js b/spec/frontend/nav/components/top_nav_app_spec.js deleted file mode 100644 index 7f39552eb42..00000000000 --- a/spec/frontend/nav/components/top_nav_app_spec.js +++ /dev/null @@ -1,68 +0,0 @@ -import { GlNavItemDropdown } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; -import { mockTracking } from 'helpers/tracking_helper'; -import TopNavApp from '~/nav/components/top_nav_app.vue'; -import TopNavDropdownMenu from '~/nav/components/top_nav_dropdown_menu.vue'; -import { TEST_NAV_DATA } from '../mock_data'; - -describe('~/nav/components/top_nav_app.vue', () => { - let wrapper; - - const createComponent = () => { - wrapper = mount(TopNavApp, { - propsData: { - navData: TEST_NAV_DATA, - }, - }); - }; - - const createComponentShallow = () => { - wrapper = shallowMount(TopNavApp, { - propsData: { - navData: TEST_NAV_DATA, - }, - }); - }; - - const findNavItemDropdown = () => wrapper.findComponent(GlNavItemDropdown); - const findNavItemDropdowToggle = () => findNavItemDropdown().find('.js-top-nav-dropdown-toggle'); - const findMenu = () => wrapper.findComponent(TopNavDropdownMenu); - - describe('default', () => { - beforeEach(() => { - createComponentShallow(); - }); - - it('renders nav item dropdown', () => { - expect(findNavItemDropdown().attributes('href')).toBeUndefined(); - expect(findNavItemDropdown().attributes()).toMatchObject({ - icon: '', - text: '', - 'no-flip': '', - 'no-caret': '', - }); - }); - - it('renders top nav dropdown menu', () => { - expect(findMenu().props()).toStrictEqual({ - primary: TEST_NAV_DATA.primary, - secondary: TEST_NAV_DATA.secondary, - views: TEST_NAV_DATA.views, - }); - }); - }); - - describe('tracking', () => { - it('emits a tracking event when the toggle is clicked', () => { - const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - createComponent(); - - findNavItemDropdowToggle().trigger('click'); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_nav', { - label: 'hamburger_menu', - property: 'navigation_top', - }); - }); - }); -}); diff --git a/spec/frontend/nav/components/top_nav_container_view_spec.js b/spec/frontend/nav/components/top_nav_container_view_spec.js deleted file mode 100644 index 388ac243648..00000000000 --- a/spec/frontend/nav/components/top_nav_container_view_spec.js +++ /dev/null @@ -1,120 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { merge } from 'lodash'; -import { nextTick } from 'vue'; -import FrequentItemsApp from '~/frequent_items/components/app.vue'; -import { FREQUENT_ITEMS_PROJECTS } from '~/frequent_items/constants'; -import eventHub from '~/frequent_items/event_hub'; -import TopNavContainerView from '~/nav/components/top_nav_container_view.vue'; -import TopNavMenuSections from '~/nav/components/top_nav_menu_sections.vue'; -import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue'; -import { TEST_NAV_DATA } from '../mock_data'; - -const DEFAULT_PROPS = { - frequentItemsDropdownType: FREQUENT_ITEMS_PROJECTS.namespace, - frequentItemsVuexModule: FREQUENT_ITEMS_PROJECTS.vuexModule, - linksPrimary: TEST_NAV_DATA.primary, - linksSecondary: TEST_NAV_DATA.secondary, - containerClass: 'test-frequent-items-container-class', -}; -const TEST_OTHER_PROPS = { - namespace: 'projects', - currentUserName: 'test-user', - currentItem: { id: 'test' }, -}; - -describe('~/nav/components/top_nav_container_view.vue', () => { - let wrapper; - - const createComponent = (props = {}, options = {}) => { - wrapper = shallowMount(TopNavContainerView, { - propsData: { - ...DEFAULT_PROPS, - ...TEST_OTHER_PROPS, - ...props, - }, - ...options, - }); - }; - - const findMenuSections = () => wrapper.findComponent(TopNavMenuSections); - const findFrequentItemsApp = () => { - const parent = wrapper.findComponent(VuexModuleProvider); - - return { - vuexModule: parent.props('vuexModule'), - props: parent.findComponent(FrequentItemsApp).props(), - attributes: parent.findComponent(FrequentItemsApp).attributes(), - }; - }; - const findFrequentItemsContainer = () => wrapper.find('[data-testid="frequent-items-container"]'); - - it.each(['projects', 'groups'])( - 'emits frequent items event to event hub (%s)', - async (frequentItemsDropdownType) => { - const listener = jest.fn(); - eventHub.$on(`${frequentItemsDropdownType}-dropdownOpen`, listener); - createComponent({ frequentItemsDropdownType }); - - expect(listener).not.toHaveBeenCalled(); - - await nextTick(); - - expect(listener).toHaveBeenCalled(); - }, - ); - - describe('default', () => { - const EXTRA_ATTRS = { 'data-test-attribute': 'foo' }; - - beforeEach(() => { - createComponent({}, { attrs: EXTRA_ATTRS }); - }); - - it('does not inherit extra attrs', () => { - expect(wrapper.attributes()).toEqual({ - class: expect.any(String), - }); - }); - - it('renders frequent items app', () => { - expect(findFrequentItemsApp()).toEqual({ - vuexModule: DEFAULT_PROPS.frequentItemsVuexModule, - props: expect.objectContaining( - merge({ currentItem: { lastAccessedOn: Date.now() } }, TEST_OTHER_PROPS), - ), - attributes: expect.objectContaining(EXTRA_ATTRS), - }); - }); - - it('renders given container class', () => { - expect(findFrequentItemsContainer().classes(DEFAULT_PROPS.containerClass)).toBe(true); - }); - - it('renders menu sections', () => { - const sections = [ - { id: 'primary', menuItems: TEST_NAV_DATA.primary }, - { id: 'secondary', menuItems: TEST_NAV_DATA.secondary }, - ]; - - expect(findMenuSections().props()).toEqual({ - sections, - withTopBorder: true, - isPrimarySection: false, - }); - }); - }); - - describe('without secondary links', () => { - beforeEach(() => { - createComponent({ - linksSecondary: [], - }); - }); - - it('renders one menu item group', () => { - expect(findMenuSections().props('sections')).toEqual([ - { id: 'primary', menuItems: TEST_NAV_DATA.primary }, - ]); - }); - }); -}); diff --git a/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js b/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js deleted file mode 100644 index 1d516240306..00000000000 --- a/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js +++ /dev/null @@ -1,146 +0,0 @@ -import { shallowMount, mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import TopNavDropdownMenu from '~/nav/components/top_nav_dropdown_menu.vue'; -import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue'; -import TopNavMenuSections from '~/nav/components/top_nav_menu_sections.vue'; -import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue'; -import { TEST_NAV_DATA } from '../mock_data'; -import { stubComponent } from '../../__helpers__/stub_component'; - -describe('~/nav/components/top_nav_dropdown_menu.vue', () => { - let wrapper; - - const createComponent = (props = {}, mountFn = shallowMount) => { - wrapper = mountFn(TopNavDropdownMenu, { - propsData: { - primary: TEST_NAV_DATA.primary, - secondary: TEST_NAV_DATA.secondary, - views: TEST_NAV_DATA.views, - ...props, - }, - stubs: { - // Stub the keep-alive-slots so we don't render frequent items which uses a store - KeepAliveSlots: stubComponent(KeepAliveSlots), - }, - }); - }; - - const findMenuItems = () => wrapper.findAllComponents(TopNavMenuItem); - const findMenuSections = () => wrapper.findComponent(TopNavMenuSections); - const findMenuSidebar = () => wrapper.find('[data-testid="menu-sidebar"]'); - const findMenuSubview = () => wrapper.findComponent(KeepAliveSlots); - const hasFullWidthMenuSidebar = () => findMenuSidebar().classes('gl-w-full'); - - const withActiveIndex = (menuItems, activeIndex) => - menuItems.map((x, idx) => ({ - ...x, - active: idx === activeIndex, - })); - - beforeEach(() => { - jest.spyOn(console, 'error').mockImplementation(); - }); - - describe('default', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders menu sections', () => { - expect(findMenuSections().props()).toEqual({ - sections: [ - { id: 'primary', menuItems: TEST_NAV_DATA.primary }, - { id: 'secondary', menuItems: TEST_NAV_DATA.secondary }, - ], - withTopBorder: false, - isPrimarySection: true, - }); - }); - - it('has full width menu sidebar', () => { - expect(hasFullWidthMenuSidebar()).toBe(true); - }); - - it('renders hidden subview with no slot key', () => { - const subview = findMenuSubview(); - - expect(subview.isVisible()).toBe(false); - expect(subview.props()).toEqual({ slotKey: '' }); - }); - }); - - describe('with pre-initialized active view', () => { - beforeEach(() => { - // We opt for a small integration test, to make sure the event is handled correctly - // as it would in prod. - createComponent( - { - primary: withActiveIndex(TEST_NAV_DATA.primary, 1), - }, - mount, - ); - }); - - it('renders menu sections', () => { - expect(findMenuSections().props('sections')).toStrictEqual([ - { id: 'primary', menuItems: withActiveIndex(TEST_NAV_DATA.primary, 1) }, - { id: 'secondary', menuItems: TEST_NAV_DATA.secondary }, - ]); - }); - - it('does not have full width menu sidebar', () => { - expect(hasFullWidthMenuSidebar()).toBe(false); - }); - - it('renders visible subview with slot key', () => { - const subview = findMenuSubview(); - - expect(subview.isVisible()).toBe(true); - expect(subview.props('slotKey')).toBe(TEST_NAV_DATA.primary[1].view); - }); - - it('does not change view if non-view menu item is clicked', async () => { - const secondaryLink = findMenuItems().at(TEST_NAV_DATA.primary.length); - - // Ensure this doesn't have a view - expect(secondaryLink.props('menuItem').view).toBeUndefined(); - - secondaryLink.vm.$emit('click'); - - await nextTick(); - - expect(findMenuSubview().props('slotKey')).toBe(TEST_NAV_DATA.primary[1].view); - }); - - describe('when menu item is clicked', () => { - let primaryLink; - - beforeEach(async () => { - primaryLink = findMenuItems().at(0); - primaryLink.vm.$emit('click'); - await nextTick(); - }); - - it('clicked on link with view', () => { - expect(primaryLink.props('menuItem').view).toBe(TEST_NAV_DATA.views.projects.namespace); - }); - - it('changes active view', () => { - expect(findMenuSubview().props('slotKey')).toBe(TEST_NAV_DATA.primary[0].view); - }); - - it('changes active status on menu item', () => { - expect(findMenuSections().props('sections')).toStrictEqual([ - { - id: 'primary', - menuItems: withActiveIndex(TEST_NAV_DATA.primary, 0), - }, - { - id: 'secondary', - menuItems: withActiveIndex(TEST_NAV_DATA.secondary, -1), - }, - ]); - }); - }); - }); -}); diff --git a/spec/frontend/nav/components/top_nav_menu_item_spec.js b/spec/frontend/nav/components/top_nav_menu_item_spec.js deleted file mode 100644 index b9cf39b8c1d..00000000000 --- a/spec/frontend/nav/components/top_nav_menu_item_spec.js +++ /dev/null @@ -1,145 +0,0 @@ -import { GlButton, GlIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue'; - -const TEST_MENU_ITEM = { - title: 'Cheeseburger', - icon: 'search', - href: '/pretty/good/burger', - view: 'burger-view', - data: { qa_selector: 'not-a-real-selector', method: 'post', testFoo: 'test' }, -}; - -describe('~/nav/components/top_nav_menu_item.vue', () => { - let listener; - let wrapper; - - const createComponent = (props = {}) => { - wrapper = shallowMount(TopNavMenuItem, { - propsData: { - menuItem: TEST_MENU_ITEM, - ...props, - }, - listeners: { - click: listener, - }, - }); - }; - - const findButton = () => wrapper.findComponent(GlButton); - const findButtonIcons = () => - findButton() - .findAllComponents(GlIcon) - .wrappers.map((x) => ({ - name: x.props('name'), - classes: x.classes(), - })); - - beforeEach(() => { - listener = jest.fn(); - }); - - describe('default', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders button href and text', () => { - const button = findButton(); - - expect(button.attributes('href')).toBe(TEST_MENU_ITEM.href); - expect(button.text()).toBe(TEST_MENU_ITEM.title); - }); - - it('renders button data attributes', () => { - const button = findButton(); - - expect(button.attributes()).toMatchObject({ - 'data-qa-selector': TEST_MENU_ITEM.data.qa_selector, - 'data-method': TEST_MENU_ITEM.data.method, - 'data-test-foo': TEST_MENU_ITEM.data.testFoo, - }); - }); - - it('passes listeners to button', () => { - expect(listener).not.toHaveBeenCalled(); - - findButton().vm.$emit('click', 'TEST'); - - expect(listener).toHaveBeenCalledWith('TEST'); - }); - - it('renders expected icons', () => { - expect(findButtonIcons()).toEqual([ - { - name: TEST_MENU_ITEM.icon, - classes: ['gl-mr-3!'], - }, - { - name: 'chevron-right', - classes: ['gl-ml-auto'], - }, - ]); - }); - }); - - describe('with icon-only', () => { - beforeEach(() => { - createComponent({ iconOnly: true }); - }); - - it('does not render title or view icon', () => { - expect(wrapper.text()).toBe(''); - }); - - it('only renders menuItem icon', () => { - expect(findButtonIcons()).toEqual([ - { - name: TEST_MENU_ITEM.icon, - classes: [], - }, - ]); - }); - }); - - describe.each` - desc | menuItem | expectedIcons - ${'with no icon'} | ${{ ...TEST_MENU_ITEM, icon: null }} | ${['chevron-right']} - ${'with no view'} | ${{ ...TEST_MENU_ITEM, view: null }} | ${[TEST_MENU_ITEM.icon]} - ${'with no icon or view'} | ${{ ...TEST_MENU_ITEM, view: null, icon: null }} | ${[]} - `('$desc', ({ menuItem, expectedIcons }) => { - beforeEach(() => { - createComponent({ menuItem }); - }); - - it(`renders expected icons ${JSON.stringify(expectedIcons)}`, () => { - expect(findButtonIcons().map((x) => x.name)).toEqual(expectedIcons); - }); - }); - - describe.each` - desc | active | cssClass | expectedClasses - ${'default'} | ${false} | ${''} | ${[]} - ${'with css class'} | ${false} | ${'test-css-class testing-123'} | ${['test-css-class', 'testing-123']} - ${'with css class & active'} | ${true} | ${'test-css-class'} | ${['test-css-class', ...TopNavMenuItem.ACTIVE_CLASS.split(' ')]} - `('$desc', ({ active, cssClass, expectedClasses }) => { - beforeEach(() => { - createComponent({ - menuItem: { - ...TEST_MENU_ITEM, - active, - css_class: cssClass, - }, - }); - }); - - it('renders expected classes', () => { - expect(wrapper.classes()).toStrictEqual([ - 'top-nav-menu-item', - 'gl-display-block', - 'gl-pr-3!', - ...expectedClasses, - ]); - }); - }); -}); diff --git a/spec/frontend/nav/components/top_nav_menu_sections_spec.js b/spec/frontend/nav/components/top_nav_menu_sections_spec.js deleted file mode 100644 index 7a3e58fd964..00000000000 --- a/spec/frontend/nav/components/top_nav_menu_sections_spec.js +++ /dev/null @@ -1,138 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import TopNavMenuSections from '~/nav/components/top_nav_menu_sections.vue'; - -const TEST_SECTIONS = [ - { - id: 'primary', - menuItems: [ - { type: 'header', title: 'Heading' }, - { type: 'item', id: 'test', href: '/test/href' }, - { type: 'header', title: 'Another Heading' }, - { type: 'item', id: 'foo' }, - { type: 'item', id: 'bar' }, - ], - }, - { - id: 'secondary', - menuItems: [ - { type: 'item', id: 'lorem' }, - { type: 'item', id: 'ipsum' }, - ], - }, -]; - -describe('~/nav/components/top_nav_menu_sections.vue', () => { - let wrapper; - - const createComponent = (props = {}) => { - wrapper = shallowMount(TopNavMenuSections, { - propsData: { - sections: TEST_SECTIONS, - ...props, - }, - }); - }; - - const findMenuItemModels = (parent) => - parent.findAll('[data-testid="menu-header"],[data-testid="menu-item"]').wrappers.map((x) => { - return { - menuItem: x.vm - ? { - type: 'item', - ...x.props('menuItem'), - } - : { - type: 'header', - title: x.text(), - }, - classes: x.classes(), - }; - }); - const findSectionModels = () => - wrapper.findAll('[data-testid="menu-section"]').wrappers.map((x) => ({ - classes: x.classes(), - menuItems: findMenuItemModels(x), - })); - - describe('default', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders sections with menu items', () => { - const headerClasses = ['gl-px-4', 'gl-py-2', 'gl-text-gray-900', 'gl-display-block']; - const itemClasses = ['gl-w-full']; - - expect(findSectionModels()).toEqual([ - { - classes: [], - menuItems: TEST_SECTIONS[0].menuItems.map((menuItem, index) => { - const classes = menuItem.type === 'header' ? [...headerClasses] : [...itemClasses]; - if (index > 0) classes.push(menuItem.type === 'header' ? 'gl-pt-3!' : 'gl-mt-1'); - return { - menuItem, - classes, - }; - }), - }, - { - classes: [ - ...TopNavMenuSections.BORDER_CLASSES.split(' '), - 'gl-border-gray-50', - 'gl-mt-3', - ], - menuItems: TEST_SECTIONS[1].menuItems.map((menuItem, index) => { - const classes = menuItem.type === 'header' ? [...headerClasses] : [...itemClasses]; - if (index > 0) classes.push(menuItem.type === 'header' ? 'gl-pt-3!' : 'gl-mt-1'); - return { - menuItem, - classes, - }; - }), - }, - ]); - }); - - it('when clicked menu item with href, does nothing', () => { - const menuItem = wrapper.findAll('[data-testid="menu-item"]').at(0); - - menuItem.vm.$emit('click'); - - expect(wrapper.emitted()).toEqual({}); - }); - - it('when clicked menu item without href, emits "menu-item-click"', () => { - const menuItem = wrapper.findAll('[data-testid="menu-item"]').at(1); - - menuItem.vm.$emit('click'); - - expect(wrapper.emitted('menu-item-click')).toEqual([[TEST_SECTIONS[0].menuItems[3]]]); - }); - }); - - describe('with withTopBorder=true', () => { - beforeEach(() => { - createComponent({ withTopBorder: true }); - }); - - it('renders border classes for top section', () => { - expect(findSectionModels().map((x) => x.classes)).toEqual([ - [...TopNavMenuSections.BORDER_CLASSES.split(' '), 'gl-border-gray-50'], - [...TopNavMenuSections.BORDER_CLASSES.split(' '), 'gl-border-gray-50', 'gl-mt-3'], - ]); - }); - }); - - describe('with isPrimarySection=true', () => { - beforeEach(() => { - createComponent({ isPrimarySection: true }); - }); - - it('renders border classes for top section', () => { - expect(findSectionModels().map((x) => x.classes)).toEqual([ - [], - [...TopNavMenuSections.BORDER_CLASSES.split(' '), 'gl-border-gray-100', 'gl-mt-3'], - ]); - }); - }); -}); diff --git a/spec/frontend/nav/components/top_nav_new_dropdown_spec.js b/spec/frontend/nav/components/top_nav_new_dropdown_spec.js deleted file mode 100644 index 432ee5e9ecd..00000000000 --- a/spec/frontend/nav/components/top_nav_new_dropdown_spec.js +++ /dev/null @@ -1,142 +0,0 @@ -import { GlDropdown } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import TopNavNewDropdown from '~/nav/components/top_nav_new_dropdown.vue'; -import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; -import { TOP_NAV_INVITE_MEMBERS_COMPONENT } from '~/invite_members/constants'; - -const TEST_VIEW_MODEL = { - title: 'Dropdown', - menu_sections: [ - { - title: 'Section 1', - menu_items: [ - { id: 'foo-1', title: 'Foo 1', href: '/foo/1' }, - { id: 'foo-2', title: 'Foo 2', href: '/foo/2' }, - { id: 'foo-3', title: 'Foo 3', href: '/foo/3' }, - ], - }, - { - title: 'Section 2', - menu_items: [ - { id: 'bar-1', title: 'Bar 1', href: '/bar/1' }, - { id: 'bar-2', title: 'Bar 2', href: '/bar/2' }, - { - id: 'invite', - title: '_invite members title_', - component: TOP_NAV_INVITE_MEMBERS_COMPONENT, - icon: '_icon_', - data: { - trigger_element: '_trigger_element_', - trigger_source: '_trigger_source_', - }, - }, - ], - }, - ], -}; - -describe('~/nav/components/top_nav_menu_sections.vue', () => { - let wrapper; - - const createComponent = (props = {}) => { - wrapper = shallowMount(TopNavNewDropdown, { - propsData: { - viewModel: TEST_VIEW_MODEL, - ...props, - }, - }); - }; - - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findInviteMembersTrigger = () => wrapper.findComponent(InviteMembersTrigger); - const findDropdownContents = () => - findDropdown() - .findAll('[data-testid]') - .wrappers.map((child) => { - const type = child.attributes('data-testid'); - - if (type === 'divider') { - return { type }; - } - if (type === 'header') { - return { type, text: child.text() }; - } - - return { - type, - text: child.text(), - href: child.attributes('href'), - }; - }); - - describe('default', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders dropdown parent', () => { - expect(findDropdown().props()).toMatchObject({ - text: TEST_VIEW_MODEL.title, - textSrOnly: true, - icon: 'plus', - }); - }); - - it('renders dropdown content', () => { - const hrefItems = TEST_VIEW_MODEL.menu_sections[1].menu_items.filter((item) => - Boolean(item.href), - ); - - expect(findDropdownContents()).toEqual([ - { - type: 'header', - text: TEST_VIEW_MODEL.menu_sections[0].title, - }, - ...TEST_VIEW_MODEL.menu_sections[0].menu_items.map(({ title, href }) => ({ - type: 'item', - href, - text: title, - })), - { - type: 'divider', - }, - { - type: 'header', - text: TEST_VIEW_MODEL.menu_sections[1].title, - }, - ...hrefItems.map(({ title, href }) => ({ - type: 'item', - href, - text: title, - })), - ]); - expect(findInviteMembersTrigger().props()).toMatchObject({ - displayText: '_invite members title_', - icon: '_icon_', - triggerElement: 'dropdown-_trigger_element_', - triggerSource: '_trigger_source_', - }); - }); - }); - - describe('with only 1 section', () => { - beforeEach(() => { - createComponent({ - viewModel: { - ...TEST_VIEW_MODEL, - menu_sections: TEST_VIEW_MODEL.menu_sections.slice(0, 1), - }, - }); - }); - - it('renders dropdown content without headers and dividers', () => { - expect(findDropdownContents()).toEqual( - TEST_VIEW_MODEL.menu_sections[0].menu_items.map(({ title, href }) => ({ - type: 'item', - href, - text: title, - })), - ); - }); - }); -}); diff --git a/spec/frontend/nav/mock_data.js b/spec/frontend/nav/mock_data.js deleted file mode 100644 index 2052acfe001..00000000000 --- a/spec/frontend/nav/mock_data.js +++ /dev/null @@ -1,39 +0,0 @@ -import { range } from 'lodash'; - -export const TEST_NAV_DATA = { - menuTitle: 'Test Menu Title', - primary: [ - ...['projects', 'groups'].map((view) => ({ - id: view, - href: null, - title: view, - view, - })), - ...range(0, 2).map((idx) => ({ - id: `primary-link-${idx}`, - href: `/path/to/primary/${idx}`, - title: `Title ${idx}`, - })), - ], - secondary: range(0, 2).map((idx) => ({ - id: `secondary-link-${idx}`, - href: `/path/to/secondary/${idx}`, - title: `SecTitle ${idx}`, - })), - views: { - projects: { - namespace: 'projects', - currentUserName: '', - currentItem: {}, - linksPrimary: [{ id: 'project-link', href: '/path/to/projects', title: 'Project Link' }], - linksSecondary: [], - }, - groups: { - namespace: 'groups', - currentUserName: '', - currentItem: {}, - linksPrimary: [], - linksSecondary: [{ id: 'group-link', href: '/path/to/groups', title: 'Group Link' }], - }, - }, -}; diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 1309fd79c14..8f761476c7c 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -9,7 +9,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import batchComments from '~/batch_comments/stores/modules/batch_comments'; -import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; +import { fetchUserCounts } from '~/super_sidebar/user_counts_fetch'; import { createAlert } from '~/alert'; import { STATUS_CLOSED, STATUS_OPEN } from '~/issues/constants'; import axios from '~/lib/utils/axios_utils'; @@ -26,7 +26,7 @@ import { mockTracking } from 'helpers/tracking_helper'; import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data'; jest.mock('autosize'); -jest.mock('~/commons/nav/user_merge_requests'); +jest.mock('~/super_sidebar/user_counts_fetch'); jest.mock('~/alert'); Vue.use(Vuex); @@ -586,7 +586,7 @@ describe('issue_comment_form component', () => { await nextTick(); - expect(refreshUserMergeRequestCounts).toHaveBeenCalled(); + expect(fetchUserCounts).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/notes/components/discussion_filter_spec.js b/spec/frontend/notes/components/discussion_filter_spec.js index 87ccb5b7394..dfc901bf1b3 100644 --- a/spec/frontend/notes/components/discussion_filter_spec.js +++ b/spec/frontend/notes/components/discussion_filter_spec.js @@ -6,7 +6,7 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import Vuex from 'vuex'; import { TEST_HOST } from 'helpers/test_constants'; import createEventHub from '~/helpers/event_hub_factory'; - +import * as urlUtility from '~/lib/utils/url_utility'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import DiscussionFilter from '~/notes/components/discussion_filter.vue'; @@ -40,7 +40,7 @@ describe('DiscussionFilter component', () => { const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); - const mountComponent = () => { + const mountComponent = ({ propsData = {} } = {}) => { const discussions = [ { ...discussionMock, @@ -63,11 +63,12 @@ describe('DiscussionFilter component', () => { store.state.discussions = discussions; - return mount(DiscussionFilter, { + wrapper = mount(DiscussionFilter, { store, propsData: { filters: discussionFiltersMock, selectedValue: DISCUSSION_FILTERS_DEFAULT_VALUE, + ...propsData, }, }); }; @@ -88,7 +89,7 @@ describe('DiscussionFilter component', () => { describe('default', () => { beforeEach(() => { - wrapper = mountComponent(); + mountComponent(); jest.spyOn(store, 'dispatch').mockImplementation(); }); @@ -105,7 +106,7 @@ describe('DiscussionFilter component', () => { describe('when asc', () => { beforeEach(() => { - wrapper = mountComponent(); + mountComponent(); jest.spyOn(store, 'dispatch').mockImplementation(); }); @@ -125,7 +126,7 @@ describe('DiscussionFilter component', () => { describe('when desc', () => { beforeEach(() => { - wrapper = mountComponent(); + mountComponent(); store.state.discussionSortOrder = DESC; jest.spyOn(store, 'dispatch').mockImplementation(); }); @@ -150,7 +151,7 @@ describe('DiscussionFilter component', () => { describe('discussion filter functionality', () => { beforeEach(() => { - wrapper = mountComponent(); + mountComponent(); }); it('renders the all filters', () => { @@ -215,7 +216,7 @@ describe('DiscussionFilter component', () => { currentTab: 'show', }; - wrapper = mountComponent(); + mountComponent(); }); afterEach(() => { @@ -239,7 +240,7 @@ describe('DiscussionFilter component', () => { it('does not update the filter when the current filter is "Show all activity"', async () => { window.location.hash = `note_${discussionMock.notes[0].id}`; - wrapper = mountComponent(); + mountComponent(); await nextTick(); const filtered = findGlDisclosureDropdownItems().filter((el) => el.classes('is-active')); @@ -250,7 +251,7 @@ describe('DiscussionFilter component', () => { it('only updates filter when the URL links to a note', async () => { window.location.hash = `testing123`; - wrapper = mountComponent(); + mountComponent(); await nextTick(); const filtered = findGlDisclosureDropdownItems().filter((el) => el.classes('is-active')); @@ -260,12 +261,32 @@ describe('DiscussionFilter component', () => { }); it('does not fetch discussions when there is no hash', async () => { - window.location.hash = ''; - const selectFilterSpy = jest.spyOn(wrapper.vm, 'selectFilter').mockImplementation(() => {}); - wrapper = mountComponent(); + mountComponent(); + const dispatchSpy = jest.spyOn(store, 'dispatch'); await nextTick(); - expect(selectFilterSpy).not.toHaveBeenCalled(); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + describe('selected value is not default state', () => { + beforeEach(() => { + mountComponent({ + propsData: { selectedValue: 2 }, + }); + }); + it('fetch discussions when there is hash', async () => { + jest.spyOn(urlUtility, 'getLocationHash').mockReturnValueOnce('note_123'); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + window.dispatchEvent(new Event('hashchange')); + + await nextTick(); + expect(dispatchSpy).toHaveBeenCalledWith('filterDiscussion', { + filter: 0, + path: 'http://test.host/example', + persistFilter: false, + }); + }); }); }); }); diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js index fc50afcb01d..47663360ce8 100644 --- a/spec/frontend/notes/components/note_actions_spec.js +++ b/spec/frontend/notes/components/note_actions_spec.js @@ -1,5 +1,5 @@ import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import { stubComponent } from 'helpers/stub_component'; @@ -43,11 +43,10 @@ describe('noteActions', () => { store.state.isPromoteCommentToTimelineEventInProgress = isPromotionInProgress; }; - const mountNoteActions = (propsData, computed) => { - return mount(noteActions, { + const mountNoteActions = (propsData) => { + return shallowMount(noteActions, { store, propsData, - computed, stubs: { GlDisclosureDropdown: stubComponent(GlDisclosureDropdown, { methods: { @@ -190,15 +189,14 @@ describe('noteActions', () => { }; beforeEach(() => { - wrapper = mountNoteActions(props, { - targetType: () => 'issue', - }); + wrapper = mountNoteActions(props); store.state.noteableData = { current_user: { can_set_issue_metadata: true, }, }; store.state.userData = userDataMock; + store.state.noteableData.targetType = 'issue'; }); afterEach(() => { diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index f07ba1e032f..938ca1f5939 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -1129,9 +1129,12 @@ describe('Actions Notes Store', () => { describe('setConfidentiality', () => { it('calls the correct mutation with the correct args', () => { - testAction(actions.setConfidentiality, true, { noteableData: { confidential: false } }, [ - { type: mutationTypes.SET_ISSUE_CONFIDENTIAL, payload: true }, - ]); + return testAction( + actions.setConfidentiality, + true, + { noteableData: { confidential: false } }, + [{ type: mutationTypes.SET_ISSUE_CONFIDENTIAL, payload: true }], + ); }); }); diff --git a/spec/frontend/observability/client_spec.js b/spec/frontend/observability/client_spec.js index b41b303f57d..e7b68a2346e 100644 --- a/spec/frontend/observability/client_spec.js +++ b/spec/frontend/observability/client_spec.js @@ -18,6 +18,7 @@ describe('buildClient', () => { const servicesUrl = 'https://example.com/services'; const operationsUrl = 'https://example.com/services/$SERVICE_NAME$/operations'; const metricsUrl = 'https://example.com/metrics'; + const metricsSearchUrl = 'https://example.com/metrics/search'; const FETCHING_TRACES_ERROR = 'traces are missing/invalid in the response'; const apiConfig = { @@ -26,6 +27,7 @@ describe('buildClient', () => { servicesUrl, operationsUrl, metricsUrl, + metricsSearchUrl, }; const getQueryParam = () => decodeURIComponent(axios.get.mock.calls[0][1].params.toString()); @@ -311,6 +313,16 @@ describe('buildClient', () => { expect(getQueryParam()).toBe(`sort=${SORTING_OPTIONS.TIMESTAMP_DESC}`); }); + it('ignores non-array filters', async () => { + await client.fetchTraces({ + filters: { + traceId: { operator: '=', value: 'foo' }, + }, + }); + + expect(getQueryParam()).toBe(`sort=${SORTING_OPTIONS.TIMESTAMP_DESC}`); + }); + it('ignores unsupported operators', async () => { await client.fetchTraces({ filters: { @@ -429,10 +441,84 @@ describe('buildClient', () => { expect(axios.get).toHaveBeenCalledTimes(1); expect(axios.get).toHaveBeenCalledWith(metricsUrl, { withCredentials: true, + params: expect.any(URLSearchParams), }); expect(result).toEqual(mockResponse); }); + describe('query filter', () => { + beforeEach(() => { + axiosMock.onGet(metricsUrl).reply(200, { + metrics: [], + }); + }); + + it('does not set any query param without filters', async () => { + await client.fetchMetrics(); + + expect(getQueryParam()).toBe(''); + }); + + it('sets the start_with query param based on the search filter', async () => { + await client.fetchMetrics({ + filters: { search: [{ value: 'foo' }, { value: 'bar' }, { value: ' ' }] }, + }); + expect(getQueryParam()).toBe('starts_with=foo+bar'); + }); + + it('ignores empty search', async () => { + await client.fetchMetrics({ + filters: { + search: [{ value: ' ' }, { value: '' }, { value: null }, { value: undefined }], + }, + }); + expect(getQueryParam()).toBe(''); + }); + + it('ignores unsupported filters', async () => { + await client.fetchMetrics({ + filters: { + unsupportedFilter: [{ operator: '=', value: 'foo' }], + }, + }); + + expect(getQueryParam()).toBe(''); + }); + + it('ignores non-array search filters', async () => { + await client.fetchMetrics({ + filters: { + search: { value: 'foo' }, + }, + }); + + expect(getQueryParam()).toBe(''); + }); + + it('adds the search limit param if specified with the search filter', async () => { + await client.fetchMetrics({ + filters: { search: [{ value: 'foo' }] }, + limit: 50, + }); + expect(getQueryParam()).toBe('starts_with=foo&limit=50'); + }); + + it('does not add the search limit param if the search filter is missing', async () => { + await client.fetchMetrics({ + limit: 50, + }); + expect(getQueryParam()).toBe(''); + }); + + it('does not add the search limit param if the search filter is empty', async () => { + await client.fetchMetrics({ + limit: 50, + search: [{ value: ' ' }, { value: '' }, { value: null }, { value: undefined }], + }); + expect(getQueryParam()).toBe(''); + }); + }); + it('rejects if metrics are missing', async () => { axiosMock.onGet(metricsUrl).reply(200, {}); @@ -447,4 +533,40 @@ describe('buildClient', () => { expectErrorToBeReported(new Error(FETCHING_METRICS_ERROR)); }); }); + + describe('fetchMetric', () => { + it('fetches the metric from the API', async () => { + const data = { results: [] }; + axiosMock.onGet(metricsSearchUrl).reply(200, data); + + const result = await client.fetchMetric('name', 'type'); + + expect(axios.get).toHaveBeenCalledTimes(1); + expect(axios.get).toHaveBeenCalledWith(metricsSearchUrl, { + withCredentials: true, + params: new URLSearchParams({ mname: 'name', mtype: 'type' }), + }); + expect(result).toEqual(data.results); + }); + + it('rejects if results is missing from the response', async () => { + axiosMock.onGet(metricsSearchUrl).reply(200, {}); + const e = 'metrics are missing/invalid in the response'; + + await expect(client.fetchMetric('name', 'type')).rejects.toThrow(e); + expectErrorToBeReported(new Error(e)); + }); + + it('rejects if metric name is missing', async () => { + const e = 'fetchMetric() - metric name is required.'; + await expect(client.fetchMetric()).rejects.toThrow(e); + expectErrorToBeReported(new Error(e)); + }); + + it('rejects if metric type is missing', async () => { + const e = 'fetchMetric() - metric type is required.'; + await expect(client.fetchMetric('name')).rejects.toThrow(e); + expectErrorToBeReported(new Error(e)); + }); + }); }); diff --git a/spec/frontend/organizations/index/components/app_spec.js b/spec/frontend/organizations/index/components/app_spec.js index 175b1e1c552..670eb34bffd 100644 --- a/spec/frontend/organizations/index/components/app_spec.js +++ b/spec/frontend/organizations/index/components/app_spec.js @@ -5,9 +5,9 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; -import { organizations } from '~/organizations/mock_data'; -import resolvers from '~/organizations/shared/graphql/resolvers'; -import organizationsQuery from '~/organizations/index/graphql/organizations.query.graphql'; +import { DEFAULT_PER_PAGE } from '~/api'; +import { organizations as nodes, pageInfo, pageInfoEmpty } from '~/organizations/mock_data'; +import organizationsQuery from '~/organizations/shared/graphql/queries/organizations.query.graphql'; import OrganizationsIndexApp from '~/organizations/index/components/app.vue'; import OrganizationsView from '~/organizations/index/components/organizations_view.vue'; import { MOCK_NEW_ORG_URL } from '../mock_data'; @@ -20,8 +20,27 @@ describe('OrganizationsIndexApp', () => { let wrapper; let mockApollo; - const createComponent = (mockResolvers = resolvers) => { - mockApollo = createMockApollo([[organizationsQuery, mockResolvers]]); + const organizations = { + nodes, + pageInfo, + }; + + const organizationEmpty = { + nodes: [], + pageInfo: pageInfoEmpty, + }; + + const successHandler = jest.fn().mockResolvedValue({ + data: { + currentUser: { + id: 'gid://gitlab/User/1', + organizations, + }, + }, + }); + + const createComponent = (handler = successHandler) => { + mockApollo = createMockApollo([[organizationsQuery, handler]]); wrapper = shallowMountExtended(OrganizationsIndexApp, { apolloProvider: mockApollo, @@ -35,53 +54,168 @@ describe('OrganizationsIndexApp', () => { mockApollo = null; }); + // Finders const findOrganizationHeaderText = () => wrapper.findByText('Organizations'); const findNewOrganizationButton = () => wrapper.findComponent(GlButton); const findOrganizationsView = () => wrapper.findComponent(OrganizationsView); - const loadingResolver = jest.fn().mockReturnValue(new Promise(() => {})); - const successfulResolver = (nodes) => - jest.fn().mockResolvedValue({ - data: { currentUser: { id: 1, organizations: { nodes } } }, + // Assertions + const itRendersHeaderText = () => { + it('renders the header text', () => { + expect(findOrganizationHeaderText().exists()).toBe(true); + }); + }; + + const itRendersNewOrganizationButton = () => { + it('render new organization button with correct link', () => { + expect(findNewOrganizationButton().attributes('href')).toBe(MOCK_NEW_ORG_URL); + }); + }; + + const itDoesNotRenderErrorMessage = () => { + it('does not render an error message', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); + }; + + const itDoesNotRenderHeaderText = () => { + it('does not render the header text', () => { + expect(findOrganizationHeaderText().exists()).toBe(false); + }); + }; + + const itDoesNotRenderNewOrganizationButton = () => { + it('does not render new organization button', () => { + expect(findNewOrganizationButton().exists()).toBe(false); + }); + }; + + describe('when API call is loading', () => { + beforeEach(() => { + createComponent(jest.fn().mockReturnValue(new Promise(() => {}))); + }); + + itRendersHeaderText(); + itRendersNewOrganizationButton(); + itDoesNotRenderErrorMessage(); + + it('renders the organizations view with loading prop set to true', () => { + expect(findOrganizationsView().props('loading')).toBe(true); + }); + }); + + describe('when API call is successful', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + itRendersHeaderText(); + itRendersNewOrganizationButton(); + itDoesNotRenderErrorMessage(); + + it('passes organizations to view component', () => { + expect(findOrganizationsView().props()).toMatchObject({ + loading: false, + organizations, + }); }); - const errorResolver = jest.fn().mockRejectedValue('error'); + }); - describe.each` - description | mockResolver | headerText | newOrgLink | loading | orgsData | error - ${'when API call is loading'} | ${loadingResolver} | ${true} | ${MOCK_NEW_ORG_URL} | ${true} | ${[]} | ${false} - ${'when API returns successful with results'} | ${successfulResolver(organizations)} | ${true} | ${MOCK_NEW_ORG_URL} | ${false} | ${organizations} | ${false} - ${'when API returns successful without results'} | ${successfulResolver([])} | ${false} | ${false} | ${false} | ${[]} | ${false} - ${'when API returns error'} | ${errorResolver} | ${false} | ${false} | ${false} | ${[]} | ${true} - `('$description', ({ mockResolver, headerText, newOrgLink, loading, orgsData, error }) => { + describe('when API call is successful and returns no organizations', () => { beforeEach(async () => { - createComponent(mockResolver); + createComponent( + jest.fn().mockResolvedValue({ + data: { + currentUser: { + id: 'gid://gitlab/User/1', + organizations: organizationEmpty, + }, + }, + }), + ); await waitForPromises(); }); - it(`does ${headerText ? '' : 'not '}render the header text`, () => { - expect(findOrganizationHeaderText().exists()).toBe(headerText); + itDoesNotRenderHeaderText(); + itDoesNotRenderNewOrganizationButton(); + itDoesNotRenderErrorMessage(); + + it('renders view component with correct organizations and loading props', () => { + expect(findOrganizationsView().props()).toMatchObject({ + loading: false, + organizations: organizationEmpty, + }); }); + }); + + describe('when API call is not successful', () => { + const error = new Error(); - it(`does ${newOrgLink ? '' : 'not '}render new organization button with correct link`, () => { - expect( - findNewOrganizationButton().exists() && findNewOrganizationButton().attributes('href'), - ).toBe(newOrgLink); + beforeEach(async () => { + createComponent(jest.fn().mockRejectedValue(error)); + await waitForPromises(); }); - it(`renders the organizations view with ${loading} loading prop`, () => { - expect(findOrganizationsView().props('loading')).toBe(loading); + itDoesNotRenderHeaderText(); + itDoesNotRenderNewOrganizationButton(); + + it('renders view component with correct organizations and loading props', () => { + expect(findOrganizationsView().props()).toMatchObject({ + loading: false, + organizations: {}, + }); }); - it(`renders the organizations view with ${ - orgsData ? 'correct' : 'empty' - } organizations array prop`, () => { - expect(findOrganizationsView().props('organizations')).toStrictEqual(orgsData); + it('renders error message', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: + 'An error occurred loading user organizations. Please refresh the page to try again.', + error, + captureError: true, + }); }); + }); + + describe('when view component emits `next` event', () => { + const endCursor = 'mockEndCursor'; + + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('calls GraphQL query with correct pageInfo variables', async () => { + findOrganizationsView().vm.$emit('next', endCursor); + await waitForPromises(); + + expect(successHandler).toHaveBeenCalledWith({ + first: DEFAULT_PER_PAGE, + after: endCursor, + last: null, + before: null, + }); + }); + }); + + describe('when view component emits `prev` event', () => { + const startCursor = 'mockStartCursor'; + + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('calls GraphQL query with correct pageInfo variables', async () => { + findOrganizationsView().vm.$emit('prev', startCursor); + await waitForPromises(); - it(`does ${error ? '' : 'not '}render an error message`, () => { - return error - ? expect(createAlert).toHaveBeenCalled() - : expect(createAlert).not.toHaveBeenCalled(); + expect(successHandler).toHaveBeenCalledWith({ + first: null, + after: null, + last: DEFAULT_PER_PAGE, + before: startCursor, + }); }); }); }); diff --git a/spec/frontend/organizations/index/components/organizations_list_spec.js b/spec/frontend/organizations/index/components/organizations_list_spec.js index 0b59c212314..7d904ee802f 100644 --- a/spec/frontend/organizations/index/components/organizations_list_spec.js +++ b/spec/frontend/organizations/index/components/organizations_list_spec.js @@ -1,28 +1,84 @@ +import { GlKeysetPagination } from '@gitlab/ui'; +import { omit } from 'lodash'; import { shallowMount } from '@vue/test-utils'; import OrganizationsList from '~/organizations/index/components/organizations_list.vue'; import OrganizationsListItem from '~/organizations/index/components/organizations_list_item.vue'; -import { organizations } from '~/organizations/mock_data'; +import { organizations as nodes, pageInfo, pageInfoOnePage } from '~/organizations/mock_data'; describe('OrganizationsList', () => { let wrapper; - const createComponent = () => { + const createComponent = ({ propsData = {} } = {}) => { wrapper = shallowMount(OrganizationsList, { propsData: { - organizations, + organizations: { + nodes, + pageInfo, + }, + ...propsData, }, }); }; const findAllOrganizationsListItem = () => wrapper.findAllComponents(OrganizationsListItem); + const findPagination = () => wrapper.findComponent(GlKeysetPagination); describe('template', () => { - beforeEach(() => { + it('renders a list item for each organization', () => { createComponent(); + + expect(findAllOrganizationsListItem()).toHaveLength(nodes.length); }); - it('renders a list item for each organization', () => { - expect(findAllOrganizationsListItem()).toHaveLength(organizations.length); + describe('when there is one page of organizations', () => { + beforeEach(() => { + createComponent({ + propsData: { + organizations: { + nodes, + pageInfo: pageInfoOnePage, + }, + }, + }); + }); + + it('does not render pagination', () => { + expect(findPagination().exists()).toBe(false); + }); + }); + + describe('when there are multiple pages of organizations', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders pagination', () => { + expect(findPagination().props()).toMatchObject(omit(pageInfo, '__typename')); + }); + + describe('when `GlKeysetPagination` emits `next` event', () => { + const endCursor = 'mockEndCursor'; + + beforeEach(() => { + findPagination().vm.$emit('next', endCursor); + }); + + it('emits `next` event', () => { + expect(wrapper.emitted('next')).toEqual([[endCursor]]); + }); + }); + + describe('when `GlKeysetPagination` emits `prev` event', () => { + const startCursor = 'startEndCursor'; + + beforeEach(() => { + findPagination().vm.$emit('prev', startCursor); + }); + + it('emits `prev` event', () => { + expect(wrapper.emitted('prev')).toEqual([[startCursor]]); + }); + }); }); }); }); diff --git a/spec/frontend/organizations/index/components/organizations_view_spec.js b/spec/frontend/organizations/index/components/organizations_view_spec.js index 85a1c11a2b1..fe167a1418f 100644 --- a/spec/frontend/organizations/index/components/organizations_view_spec.js +++ b/spec/frontend/organizations/index/components/organizations_view_spec.js @@ -31,7 +31,7 @@ describe('OrganizationsView', () => { ${'when not loading and has no organizations'} | ${false} | ${[]} | ${MOCK_ORG_EMPTY_STATE_SVG} | ${MOCK_NEW_ORG_URL} `('$description', ({ loading, orgsData, emptyStateSvg, emptyStateUrl }) => { beforeEach(() => { - createComponent({ loading, organizations: orgsData }); + createComponent({ loading, organizations: { nodes: orgsData, pageInfo: {} } }); }); it(`does ${loading ? '' : 'not '}render loading icon`, () => { @@ -54,4 +54,30 @@ describe('OrganizationsView', () => { ).toBe(emptyStateUrl); }); }); + + describe('when `OrganizationsList` emits `next` event', () => { + const endCursor = 'mockEndCursor'; + + beforeEach(() => { + createComponent({ loading: false, organizations: { nodes: organizations, pageInfo: {} } }); + findOrganizationsList().vm.$emit('next', endCursor); + }); + + it('emits `next` event', () => { + expect(wrapper.emitted('next')).toEqual([[endCursor]]); + }); + }); + + describe('when `OrganizationsList` emits `prev` event', () => { + const startCursor = 'mockStartCursor'; + + beforeEach(() => { + createComponent({ loading: false, organizations: { nodes: organizations, pageInfo: {} } }); + findOrganizationsList().vm.$emit('prev', startCursor); + }); + + it('emits `next` event', () => { + expect(wrapper.emitted('prev')).toEqual([[startCursor]]); + }); + }); }); diff --git a/spec/frontend/organizations/settings/general/components/advanced_settings_spec.js b/spec/frontend/organizations/settings/general/components/advanced_settings_spec.js new file mode 100644 index 00000000000..34793200b0d --- /dev/null +++ b/spec/frontend/organizations/settings/general/components/advanced_settings_spec.js @@ -0,0 +1,25 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import AdvancedSettings from '~/organizations/settings/general/components/advanced_settings.vue'; +import ChangeUrl from '~/organizations/settings/general/components/change_url.vue'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; + +describe('AdvancedSettings', () => { + let wrapper; + const createComponent = () => { + wrapper = shallowMountExtended(AdvancedSettings); + }; + + const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); + + beforeEach(() => { + createComponent(); + }); + + it('renders settings block', () => { + expect(findSettingsBlock().exists()).toBe(true); + }); + + it('renders `ChangeUrl` component', () => { + expect(findSettingsBlock().findComponent(ChangeUrl).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/organizations/settings/general/components/app_spec.js b/spec/frontend/organizations/settings/general/components/app_spec.js index 6d75f8a9949..e954b927715 100644 --- a/spec/frontend/organizations/settings/general/components/app_spec.js +++ b/spec/frontend/organizations/settings/general/components/app_spec.js @@ -1,8 +1,9 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import OrganizationSettings from '~/organizations/settings/general/components/organization_settings.vue'; +import AdvancedSettings from '~/organizations/settings/general/components/advanced_settings.vue'; import App from '~/organizations/settings/general/components/app.vue'; -describe('OrganizationSettings', () => { +describe('OrganizationSettingsGeneralApp', () => { let wrapper; const createComponent = () => { @@ -16,4 +17,8 @@ describe('OrganizationSettings', () => { it('renders `Organization settings` section', () => { expect(wrapper.findComponent(OrganizationSettings).exists()).toBe(true); }); + + it('renders `Advanced` section', () => { + expect(wrapper.findComponent(AdvancedSettings).exists()).toBe(true); + }); }); diff --git a/spec/frontend/organizations/settings/general/components/change_url_spec.js b/spec/frontend/organizations/settings/general/components/change_url_spec.js new file mode 100644 index 00000000000..a4e3db0557c --- /dev/null +++ b/spec/frontend/organizations/settings/general/components/change_url_spec.js @@ -0,0 +1,191 @@ +import { GlButton, GlForm } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import Vue, { nextTick } from 'vue'; + +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ChangeUrl from '~/organizations/settings/general/components/change_url.vue'; +import organizationUpdateMutation from '~/organizations/settings/general/graphql/mutations/organization_update.mutation.graphql'; +import { + organizationUpdateResponse, + organizationUpdateResponseWithErrors, +} from '~/organizations/mock_data'; +import { createAlert } from '~/alert'; +import { visitUrlWithAlerts } from '~/lib/utils/url_utility'; +import FormErrorsAlert from '~/vue_shared/components/form/errors_alert.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +jest.mock('~/alert'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + visitUrlWithAlerts: jest.fn(), +})); + +Vue.use(VueApollo); + +describe('ChangeUrl', () => { + let wrapper; + let mockApollo; + + const defaultProvide = { + organization: { + id: 1, + name: 'GitLab', + path: 'foo-bar', + }, + organizationsPath: '/-/organizations', + rootUrl: 'http://127.0.0.1:3000/', + }; + + const successfulResponseHandler = jest.fn().mockResolvedValue(organizationUpdateResponse); + + const createComponent = ({ + handlers = [[organizationUpdateMutation, successfulResponseHandler]], + } = {}) => { + mockApollo = createMockApollo(handlers); + + wrapper = mountExtended(ChangeUrl, { + attachTo: document.body, + provide: defaultProvide, + apolloProvider: mockApollo, + }); + }; + + const findSubmitButton = () => wrapper.findComponent(GlButton); + const findOrganizationUrlField = () => wrapper.findByLabelText('Organization URL'); + const submitForm = async () => { + await wrapper.findComponent(GlForm).trigger('submit'); + await nextTick(); + }; + + afterEach(() => { + mockApollo = null; + }); + + it('renders `Organization URL` field', () => { + createComponent(); + + expect(findOrganizationUrlField().exists()).toBe(true); + }); + + it('disables submit button until `Organization URL` field is changed', async () => { + createComponent(); + + expect(findSubmitButton().props('disabled')).toBe(true); + + await findOrganizationUrlField().setValue('foo-bar-baz'); + + expect(findSubmitButton().props('disabled')).toBe(false); + }); + + describe('when form is submitted', () => { + it('requires `Organization URL` field', async () => { + createComponent(); + + await findOrganizationUrlField().setValue(''); + await submitForm(); + + expect(wrapper.findByText('Organization URL is required.').exists()).toBe(true); + }); + + it('requires `Organization URL` field to be a minimum of two characters', async () => { + createComponent(); + + await findOrganizationUrlField().setValue('f'); + await submitForm(); + + expect( + wrapper.findByText('Organization URL is too short (minimum is 2 characters).').exists(), + ).toBe(true); + }); + + describe('when API is loading', () => { + beforeEach(async () => { + createComponent({ + handlers: [ + [organizationUpdateMutation, jest.fn().mockReturnValueOnce(new Promise(() => {}))], + ], + }); + + await findOrganizationUrlField().setValue('foo-bar-baz'); + await submitForm(); + }); + + it('shows submit button as loading', () => { + expect(findSubmitButton().props('loading')).toBe(true); + }); + }); + + describe('when API request is successful', () => { + beforeEach(async () => { + createComponent(); + await findOrganizationUrlField().setValue('foo-bar-baz'); + await submitForm(); + await waitForPromises(); + }); + + it('calls mutation with correct variables and redirects user to new organization settings page with success alert', () => { + expect(successfulResponseHandler).toHaveBeenCalledWith({ + input: { + id: 'gid://gitlab/Organizations::Organization/1', + path: 'foo-bar-baz', + }, + }); + expect(visitUrlWithAlerts).toHaveBeenCalledWith( + `${organizationUpdateResponse.data.organizationUpdate.organization.webUrl}/settings/general`, + [ + { + id: 'organization-url-successfully-changed', + message: 'Organization URL successfully changed.', + variant: 'info', + }, + ], + ); + }); + }); + + describe('when API request is not successful', () => { + describe('when there is a network error', () => { + const error = new Error(); + + beforeEach(async () => { + createComponent({ + handlers: [[organizationUpdateMutation, jest.fn().mockRejectedValue(error)]], + }); + await findOrganizationUrlField().setValue('foo-bar-baz'); + await submitForm(); + await waitForPromises(); + }); + + it('displays error alert', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred changing your organization URL. Please try again.', + error, + captureError: true, + }); + }); + }); + + describe('when there are GraphQL errors', () => { + beforeEach(async () => { + createComponent({ + handlers: [ + [ + organizationUpdateMutation, + jest.fn().mockResolvedValue(organizationUpdateResponseWithErrors), + ], + ], + }); + await submitForm(); + await waitForPromises(); + }); + + it('displays form errors alert', () => { + expect(wrapper.findComponent(FormErrorsAlert).props('errors')).toEqual( + organizationUpdateResponseWithErrors.data.organizationUpdate.errors, + ); + }); + }); + }); + }); +}); diff --git a/spec/frontend/organizations/settings/general/components/organization_settings_spec.js b/spec/frontend/organizations/settings/general/components/organization_settings_spec.js index 7645b41e3bd..d1c637331a8 100644 --- a/spec/frontend/organizations/settings/general/components/organization_settings_spec.js +++ b/spec/frontend/organizations/settings/general/components/organization_settings_spec.js @@ -6,14 +6,26 @@ import OrganizationSettings from '~/organizations/settings/general/components/or import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; import NewEditForm from '~/organizations/shared/components/new_edit_form.vue'; import { FORM_FIELD_NAME, FORM_FIELD_ID } from '~/organizations/shared/constants'; -import resolvers from '~/organizations/shared/graphql/resolvers'; -import { createAlert, VARIANT_INFO } from '~/alert'; +import organizationUpdateMutation from '~/organizations/settings/general/graphql/mutations/organization_update.mutation.graphql'; +import { + organizationUpdateResponse, + organizationUpdateResponseWithErrors, +} from '~/organizations/mock_data'; +import { createAlert } from '~/alert'; +import { visitUrlWithAlerts } from '~/lib/utils/url_utility'; +import FormErrorsAlert from '~/vue_shared/components/form/errors_alert.vue'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; Vue.use(VueApollo); -jest.useFakeTimers(); jest.mock('~/alert'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + visitUrlWithAlerts: jest.fn(), +})); + +useMockLocationHelper(); describe('OrganizationSettings', () => { let wrapper; @@ -26,8 +38,12 @@ describe('OrganizationSettings', () => { }, }; - const createComponent = ({ mockResolvers = resolvers } = {}) => { - mockApollo = createMockApollo([], mockResolvers); + const successfulResponseHandler = jest.fn().mockResolvedValue(organizationUpdateResponse); + + const createComponent = ({ + handlers = [[organizationUpdateMutation, successfulResponseHandler]], + } = {}) => { + mockApollo = createMockApollo(handlers); wrapper = shallowMountExtended(OrganizationSettings, { provide: defaultProvide, @@ -66,13 +82,11 @@ describe('OrganizationSettings', () => { describe('when form is submitted', () => { describe('when API is loading', () => { beforeEach(async () => { - const mockResolvers = { - Mutation: { - updateOrganization: jest.fn().mockReturnValueOnce(new Promise(() => {})), - }, - }; - - createComponent({ mockResolvers }); + createComponent({ + handlers: [ + [organizationUpdateMutation, jest.fn().mockReturnValueOnce(new Promise(() => {}))], + ], + }); await submitForm(); }); @@ -86,39 +100,65 @@ describe('OrganizationSettings', () => { beforeEach(async () => { createComponent(); await submitForm(); - jest.runAllTimers(); await waitForPromises(); }); - it('displays info alert', () => { - expect(createAlert).toHaveBeenCalledWith({ - message: 'Organization was successfully updated.', - variant: VARIANT_INFO, + it('calls mutation with correct variables and displays info alert', () => { + expect(successfulResponseHandler).toHaveBeenCalledWith({ + input: { + id: 'gid://gitlab/Organizations::Organization/1', + name: 'Foo bar', + }, }); + expect(visitUrlWithAlerts).toHaveBeenCalledWith(window.location.href, [ + { + id: 'organization-successfully-updated', + message: 'Organization was successfully updated.', + variant: 'info', + }, + ]); }); }); describe('when API request is not successful', () => { - const error = new Error(); - - beforeEach(async () => { - const mockResolvers = { - Mutation: { - updateOrganization: jest.fn().mockRejectedValueOnce(error), - }, - }; + describe('when there is a network error', () => { + const error = new Error(); + + beforeEach(async () => { + createComponent({ + handlers: [[organizationUpdateMutation, jest.fn().mockRejectedValue(error)]], + }); + await submitForm(); + await waitForPromises(); + }); - createComponent({ mockResolvers }); - await submitForm(); - jest.runAllTimers(); - await waitForPromises(); + it('displays error alert', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred updating your organization. Please try again.', + error, + captureError: true, + }); + }); }); - it('displays error alert', () => { - expect(createAlert).toHaveBeenCalledWith({ - message: 'An error occurred updating your organization. Please try again.', - error, - captureError: true, + describe('when there are GraphQL errors', () => { + beforeEach(async () => { + createComponent({ + handlers: [ + [ + organizationUpdateMutation, + jest.fn().mockResolvedValue(organizationUpdateResponseWithErrors), + ], + ], + }); + await submitForm(); + await waitForPromises(); + }); + + it('displays form errors alert', () => { + expect(wrapper.findComponent(FormErrorsAlert).props('errors')).toEqual( + organizationUpdateResponseWithErrors.data.organizationUpdate.errors, + ); }); }); }); diff --git a/spec/frontend/organizations/shared/components/new_edit_form_spec.js b/spec/frontend/organizations/shared/components/new_edit_form_spec.js index 93f022a3259..1fcfc20bf1a 100644 --- a/spec/frontend/organizations/shared/components/new_edit_form_spec.js +++ b/spec/frontend/organizations/shared/components/new_edit_form_spec.js @@ -1,6 +1,8 @@ -import { GlButton, GlInputGroupText, GlTruncate } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; +import { nextTick } from 'vue'; import NewEditForm from '~/organizations/shared/components/new_edit_form.vue'; +import OrganizationUrlField from '~/organizations/shared/components/organization_url_field.vue'; import { FORM_FIELD_NAME, FORM_FIELD_ID, FORM_FIELD_PATH } from '~/organizations/shared/constants'; import { mountExtended } from 'helpers/vue_test_utils_helper'; @@ -29,7 +31,12 @@ describe('NewEditForm', () => { const findNameField = () => wrapper.findByLabelText('Organization name'); const findIdField = () => wrapper.findByLabelText('Organization ID'); - const findUrlField = () => wrapper.findByLabelText('Organization URL'); + const findUrlField = () => wrapper.findComponent(OrganizationUrlField); + + const setUrlFieldValue = async (value) => { + findUrlField().vm.$emit('input', value); + await nextTick(); + }; const submitForm = async () => { await wrapper.findByRole('button', { name: 'Create organization' }).trigger('click'); }; @@ -43,20 +50,17 @@ describe('NewEditForm', () => { it('renders `Organization URL` field', () => { createComponent(); - expect(wrapper.findComponent(GlInputGroupText).findComponent(GlTruncate).props('text')).toBe( - 'http://127.0.0.1:3000/-/organizations/', - ); expect(findUrlField().exists()).toBe(true); }); it('requires `Organization URL` field to be a minimum of two characters', async () => { createComponent(); - await findUrlField().setValue('f'); + await setUrlFieldValue('f'); await submitForm(); expect( - wrapper.findByText('Organization URL must be a minimum of two characters.').exists(), + wrapper.findByText('Organization URL is too short (minimum is 2 characters).').exists(), ).toBe(true); }); @@ -89,7 +93,7 @@ describe('NewEditForm', () => { it('sets initial values for fields', () => { expect(findNameField().element.value).toBe('Foo bar'); expect(findIdField().element.value).toBe('1'); - expect(findUrlField().element.value).toBe('foo-bar'); + expect(findUrlField().props('value')).toBe('foo-bar'); }); }); @@ -116,7 +120,7 @@ describe('NewEditForm', () => { createComponent(); await findNameField().setValue('Foo bar'); - await findUrlField().setValue('foo-bar'); + await setUrlFieldValue('foo-bar'); await submitForm(); }); @@ -134,7 +138,7 @@ describe('NewEditForm', () => { }); it('sets `Organization URL` when typing in `Organization name`', () => { - expect(findUrlField().element.value).toBe('foo-bar'); + expect(findUrlField().props('value')).toBe('foo-bar'); }); }); @@ -142,13 +146,13 @@ describe('NewEditForm', () => { beforeEach(async () => { createComponent(); - await findUrlField().setValue('foo-bar-baz'); + await setUrlFieldValue('foo-bar-baz'); await findNameField().setValue('Foo bar'); await submitForm(); }); it('does not modify `Organization URL` when typing in `Organization name`', () => { - expect(findUrlField().element.value).toBe('foo-bar-baz'); + expect(findUrlField().props('value')).toBe('foo-bar-baz'); }); }); diff --git a/spec/frontend/organizations/shared/components/organization_url_field_spec.js b/spec/frontend/organizations/shared/components/organization_url_field_spec.js new file mode 100644 index 00000000000..d854134e596 --- /dev/null +++ b/spec/frontend/organizations/shared/components/organization_url_field_spec.js @@ -0,0 +1,66 @@ +import { GlFormInputGroup, GlInputGroupText, GlTruncate, GlFormInput } from '@gitlab/ui'; + +import OrganizedUrlField from '~/organizations/shared/components/organization_url_field.vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; + +describe('OrganizationUrlField', () => { + let wrapper; + + const defaultProvide = { + organizationsPath: '/-/organizations', + rootUrl: 'http://127.0.0.1:3000/', + }; + + const defaultPropsData = { + id: 'organization-url', + value: 'foo-bar', + validation: { + invalidFeedback: 'Invalid', + state: false, + }, + }; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = mountExtended(OrganizedUrlField, { + attachTo: document.body, + provide: defaultProvide, + propsData: { + ...defaultPropsData, + ...propsData, + }, + }); + }; + + const findInputGroup = () => wrapper.findComponent(GlFormInputGroup); + const findInput = () => findInputGroup().findComponent(GlFormInput); + + it('renders organization url field with correct props', () => { + createComponent(); + + expect( + findInputGroup().findComponent(GlInputGroupText).findComponent(GlTruncate).props('text'), + ).toBe('http://127.0.0.1:3000/-/organizations/'); + expect(findInput().attributes('id')).toBe(defaultPropsData.id); + expect(findInput().vm.$attrs).toMatchObject({ + value: defaultPropsData.value, + invalidFeedback: defaultPropsData.validation.invalidFeedback, + state: defaultPropsData.validation.state, + }); + }); + + it('emits `input` event', () => { + createComponent(); + + findInput().vm.$emit('input', 'foo'); + + expect(wrapper.emitted('input')).toEqual([['foo']]); + }); + + it('emits `blur` event', () => { + createComponent(); + + findInput().vm.$emit('blur', true); + + expect(wrapper.emitted('blur')).toEqual([[true]]); + }); +}); diff --git a/spec/frontend/organizations/users/components/app_spec.js b/spec/frontend/organizations/users/components/app_spec.js index b30fd984099..30380bcf6a5 100644 --- a/spec/frontend/organizations/users/components/app_spec.js +++ b/spec/frontend/organizations/users/components/app_spec.js @@ -4,9 +4,16 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; +import { ORGANIZATION_USERS_PER_PAGE } from '~/organizations/constants'; import organizationUsersQuery from '~/organizations/users/graphql/organization_users.query.graphql'; import OrganizationsUsersApp from '~/organizations/users/components/app.vue'; -import { MOCK_ORGANIZATION_GID, MOCK_USERS } from '../mock_data'; +import OrganizationsUsersView from '~/organizations/users/components/users_view.vue'; +import { + MOCK_ORGANIZATION_GID, + MOCK_USERS, + MOCK_USERS_FORMATTED, + MOCK_PAGE_INFO, +} from '../mock_data'; jest.mock('~/alert'); @@ -15,10 +22,11 @@ Vue.use(VueApollo); const mockError = new Error(); const loadingResolver = jest.fn().mockReturnValue(new Promise(() => {})); -const successfulResolver = (nodes) => - jest.fn().mockResolvedValue({ - data: { organization: { id: 1, organizationUsers: { nodes } } }, +const successfulResolver = (nodes, pageInfo = {}) => { + return jest.fn().mockResolvedValue({ + data: { organization: { id: 1, organizationUsers: { nodes, pageInfo } } }, }); +}; const errorResolver = jest.fn().mockRejectedValueOnce(mockError); describe('OrganizationsUsersApp', () => { @@ -40,31 +48,31 @@ describe('OrganizationsUsersApp', () => { mockApollo = null; }); - const findOrganizationUsersLoading = () => wrapper.findByText('Loading'); - const findOrganizationUsers = () => wrapper.findByTestId('organization-users'); + const findOrganizationUsersView = () => wrapper.findComponent(OrganizationsUsersView); describe.each` - description | mockResolver | loading | userData | error - ${'when API call is loading'} | ${loadingResolver} | ${true} | ${[]} | ${false} - ${'when API returns successful with results'} | ${successfulResolver(MOCK_USERS)} | ${false} | ${MOCK_USERS} | ${false} - ${'when API returns successful without results'} | ${successfulResolver([])} | ${false} | ${[]} | ${false} - ${'when API returns error'} | ${errorResolver} | ${false} | ${[]} | ${true} - `('$description', ({ mockResolver, loading, userData, error }) => { + description | mockResolver | loading | userData | pageInfo | error + ${'when API call is loading'} | ${loadingResolver} | ${true} | ${[]} | ${{}} | ${false} + ${'when API returns successful with one page of results'} | ${successfulResolver(MOCK_USERS)} | ${false} | ${MOCK_USERS_FORMATTED} | ${{}} | ${false} + ${'when API returns successful with multiple pages of results'} | ${successfulResolver(MOCK_USERS, MOCK_PAGE_INFO)} | ${false} | ${MOCK_USERS_FORMATTED} | ${MOCK_PAGE_INFO} | ${false} + ${'when API returns successful without results'} | ${successfulResolver([])} | ${false} | ${[]} | ${{}} | ${false} + ${'when API returns error'} | ${errorResolver} | ${false} | ${[]} | ${{}} | ${true} + `('$description', ({ mockResolver, loading, userData, pageInfo, error }) => { beforeEach(async () => { createComponent(mockResolver); await waitForPromises(); }); - it(`does ${ - loading ? '' : 'not ' - }render the organization users view with loading placeholder`, () => { - expect(findOrganizationUsersLoading().exists()).toBe(loading); + it(`renders OrganizationUsersView with loading prop set to ${loading}`, () => { + expect(findOrganizationUsersView().props('loading')).toBe(loading); }); - it(`renders the organization users view with ${ - userData.length ? 'correct' : 'empty' - } users array raw data`, () => { - expect(JSON.parse(findOrganizationUsers().text())).toStrictEqual(userData); + it('renders OrganizationUsersView with correct users prop', () => { + expect(findOrganizationUsersView().props('users')).toStrictEqual(userData); + }); + + it('renders OrganizationUsersView with correct pageInfo prop', () => { + expect(findOrganizationUsersView().props('pageInfo')).toStrictEqual(pageInfo); }); it(`does ${error ? '' : 'not '}render an error message`, () => { @@ -78,4 +86,40 @@ describe('OrganizationsUsersApp', () => { : expect(createAlert).not.toHaveBeenCalled(); }); }); + + describe('Pagination', () => { + const mockResolver = successfulResolver(MOCK_USERS, MOCK_PAGE_INFO); + + beforeEach(async () => { + createComponent(mockResolver); + await waitForPromises(); + mockResolver.mockClear(); + }); + + it('handleNextPage calls organizationUsersQuery with correct pagination data', async () => { + findOrganizationUsersView().vm.$emit('next'); + await waitForPromises(); + + expect(mockResolver).toHaveBeenCalledWith({ + id: MOCK_ORGANIZATION_GID, + before: '', + after: MOCK_PAGE_INFO.endCursor, + first: ORGANIZATION_USERS_PER_PAGE, + last: null, + }); + }); + + it('handlePrevPage calls organizationUsersQuery with correct pagination data', async () => { + findOrganizationUsersView().vm.$emit('prev'); + await waitForPromises(); + + expect(mockResolver).toHaveBeenCalledWith({ + id: MOCK_ORGANIZATION_GID, + before: MOCK_PAGE_INFO.startCursor, + after: '', + first: ORGANIZATION_USERS_PER_PAGE, + last: null, + }); + }); + }); }); diff --git a/spec/frontend/organizations/users/components/users_view_spec.js b/spec/frontend/organizations/users/components/users_view_spec.js new file mode 100644 index 00000000000..d665c60d425 --- /dev/null +++ b/spec/frontend/organizations/users/components/users_view_spec.js @@ -0,0 +1,68 @@ +import { GlLoadingIcon, GlKeysetPagination } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import UsersView from '~/organizations/users/components/users_view.vue'; +import UsersTable from '~/vue_shared/components/users_table/users_table.vue'; +import { MOCK_PATHS, MOCK_USERS_FORMATTED, MOCK_PAGE_INFO } from '../mock_data'; + +describe('UsersView', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(UsersView, { + propsData: { + loading: false, + users: MOCK_USERS_FORMATTED, + pageInfo: MOCK_PAGE_INFO, + ...props, + }, + provide: { + paths: MOCK_PATHS, + }, + }); + }; + + const findGlLoading = () => wrapper.findComponent(GlLoadingIcon); + const findUsersTable = () => wrapper.findComponent(UsersTable); + const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination); + + describe.each` + description | loading | usersData + ${'when loading'} | ${true} | ${[]} + ${'when not loading and has users'} | ${false} | ${MOCK_USERS_FORMATTED} + ${'when not loading and has no users'} | ${false} | ${[]} + `('$description', ({ loading, usersData }) => { + beforeEach(() => { + createComponent({ loading, users: usersData }); + }); + + it(`does ${loading ? '' : 'not '}render loading icon`, () => { + expect(findGlLoading().exists()).toBe(loading); + }); + + it(`does ${!loading ? '' : 'not '}render users table`, () => { + expect(findUsersTable().exists()).toBe(!loading); + }); + + it(`does ${!loading ? '' : 'not '}render pagination`, () => { + expect(findGlKeysetPagination().exists()).toBe(Boolean(!loading)); + }); + }); + + describe('Pagination', () => { + beforeEach(() => { + createComponent(); + }); + + it('@next event forwards up to the parent component', () => { + findGlKeysetPagination().vm.$emit('next'); + + expect(wrapper.emitted('next')).toHaveLength(1); + }); + + it('@prev event forwards up to the parent component', () => { + findGlKeysetPagination().vm.$emit('prev'); + + expect(wrapper.emitted('prev')).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/organizations/users/mock_data.js b/spec/frontend/organizations/users/mock_data.js index 4f159c70c2c..16b3ec3bbcb 100644 --- a/spec/frontend/organizations/users/mock_data.js +++ b/spec/frontend/organizations/users/mock_data.js @@ -1,15 +1,31 @@ +const createUser = (id) => { + return { + id: `gid://gitlab/User/${id}`, + username: `test_user_${id}`, + avatarUrl: `/path/test_user_${id}`, + name: `Test User ${id}`, + publicEmail: `test_user_${id}@gitlab.com`, + createdAt: Date.now(), + lastActivityOn: Date.now(), + }; +}; + export const MOCK_ORGANIZATION_GID = 'gid://gitlab/Organizations::Organization/1'; +export const MOCK_PATHS = { + adminUser: '/admin/users/:id', +}; + export const MOCK_USERS = [ { badges: [], id: 'gid://gitlab/Organizations::OrganizationUser/3', - user: { id: 'gid://gitlab/User/3' }, + user: createUser(3), }, { badges: [], id: 'gid://gitlab/Organizations::OrganizationUser/2', - user: { id: 'gid://gitlab/User/2' }, + user: createUser(2), }, { badges: [ @@ -17,6 +33,18 @@ export const MOCK_USERS = [ { text: "It's you!", variant: 'muted' }, ], id: 'gid://gitlab/Organizations::OrganizationUser/1', - user: { id: 'gid://gitlab/User/1' }, + user: createUser(1), }, ]; + +export const MOCK_USERS_FORMATTED = MOCK_USERS.map(({ badges, user }) => { + return { ...user, badges, email: user.publicEmail }; +}); + +export const MOCK_PAGE_INFO = { + startCursor: 'aaaa', + endCursor: 'bbbb', + hasNextPage: true, + hasPreviousPage: true, + __typename: 'PageInfo', +}; diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js index 09e2c35d449..9f3431ef5a5 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js @@ -342,6 +342,13 @@ describe('tags list row', () => { expect(findDetailsRows().length).toBe(3); }); + it('has 2 details rows when revision is empty', async () => { + mountComponent({ tag: { ...tag, revision: '' } }); + await nextTick(); + + expect(findDetailsRows().length).toBe(2); + }); + describe.each` name | finderFunction | text | icon | clipboard ${'published date detail'} | ${findPublishedDateDetail} | ${'Published to the gitlab-org/gitlab-test/rails-12009 image repository at 13:29:38 UTC on 2020-11-03'} | ${'clock'} | ${false} diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap index 8e757c136ec..a544a679ff4 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap @@ -2,9 +2,9 @@ exports[`FileSha renders 1`] = ` <div - class="gl-align-items-center gl-border-b-1 gl-border-b-solid gl-border-gray-100 gl-display-flex gl-font-monospace gl-font-sm gl-py-2 gl-word-break-all" + class="gl-align-items-top gl-border-b-1 gl-border-b-solid gl-border-gray-100 gl-display-flex gl-font-monospace gl-font-sm gl-py-2 gl-word-break-all" > - <span> + <div> <div class="gl-px-4" > @@ -23,6 +23,6 @@ exports[`FileSha renders 1`] = ` variant="default" /> </div> - </span> + </div> </div> `; diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/__snapshots__/package_list_row_spec.js.snap index edba81da1f5..75cc7e5b78d 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/__snapshots__/package_list_row_spec.js.snap +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/__snapshots__/package_list_row_spec.js.snap @@ -9,10 +9,10 @@ exports[`packages_list_row renders 1`] = ` class="gl-align-items-center gl-display-flex gl-py-3" > <div - class="gl-align-items-stretch gl-display-flex gl-flex-grow-1 gl-justify-content-space-between gl-xs-flex-direction-column" + class="gl-align-items-stretch gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-justify-content-space-between gl-sm-flex-direction-row" > <div - class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-min-w-0 gl-xs-mb-3" + class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-mb-3 gl-min-w-0 gl-sm-mb-0" > <div class="gl-align-items-center gl-display-flex gl-font-weight-bold gl-min-h-6 gl-min-w-0 gl-text-body" diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap index 8e757c136ec..a544a679ff4 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap @@ -2,9 +2,9 @@ exports[`FileSha renders 1`] = ` <div - class="gl-align-items-center gl-border-b-1 gl-border-b-solid gl-border-gray-100 gl-display-flex gl-font-monospace gl-font-sm gl-py-2 gl-word-break-all" + class="gl-align-items-top gl-border-b-1 gl-border-b-solid gl-border-gray-100 gl-display-flex gl-font-monospace gl-font-sm gl-py-2 gl-word-break-all" > - <span> + <div> <div class="gl-px-4" > @@ -23,6 +23,6 @@ exports[`FileSha renders 1`] = ` variant="default" /> </div> - </span> + </div> </div> `; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js index 133941bbb2e..283c394a135 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js @@ -13,7 +13,7 @@ import { pypiMetadata, packageMetadataQuery, } from 'jest/packages_and_registries/package_registry/mock_data'; -import component from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue'; +import AdditionalMetadata from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue'; import { FETCH_PACKAGE_METADATA_ERROR_MESSAGE, PACKAGE_TYPE_NUGET, @@ -52,12 +52,9 @@ describe('Package Additional metadata', () => { const requestHandlers = [[getPackageMetadata, resolver]]; apolloProvider = createMockApollo(requestHandlers); - wrapper = shallowMountExtended(component, { + wrapper = shallowMountExtended(AdditionalMetadata, { apolloProvider, propsData: { ...defaultProps, ...props }, - stubs: { - component: { template: '<div data-testid="component-is"></div>' }, - }, }); }; @@ -91,7 +88,7 @@ describe('Package Additional metadata', () => { const title = findTitle(); expect(title.exists()).toBe(true); - expect(title.text()).toMatchInterpolatedText(component.i18n.componentTitle); + expect(title.text()).toMatchInterpolatedText(AdditionalMetadata.i18n.componentTitle); }); it('does not render gl-alert', () => { diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js index 67f5fbc9e80..39b525efdbc 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js @@ -21,14 +21,20 @@ describe('Package Additional Metadata', () => { }; const findPypiRequiredPython = () => wrapper.findByTestId('pypi-required-python'); + const findPypiAuthorEmail = () => wrapper.findByTestId('pypi-author-email'); + const findPypiSummary = () => wrapper.findByTestId('pypi-summary'); + const findPypiKeywords = () => wrapper.findByTestId('pypi-keywords'); beforeEach(() => { mountComponent(); }); it.each` - name | finderFunction | text | icon - ${'pypi-required-python'} | ${findPypiRequiredPython} | ${'Required Python: 1.0.0'} | ${'information-o'} + name | finderFunction | text | icon + ${'pypi-required-python'} | ${findPypiRequiredPython} | ${'Required Python: 1.0.0'} | ${'information-o'} + ${'pypi-author-email'} | ${findPypiAuthorEmail} | ${'Author email: "C. Schultz" <cschultz@example.com>'} | ${'mail'} + ${'pypi-summary'} | ${findPypiSummary} | ${'Summary: A module for collecting votes from beagles.'} | ${'doc-text'} + ${'pypi-keywords'} | ${findPypiKeywords} | ${'Keywords: dog,puppy,voting,election'} | ${'doc-text'} `('$name element', ({ finderFunction, text, icon }) => { const element = finderFunction(); expect(element.exists()).toBe(true); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap index 40fcd290b33..cbf2184d879 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap @@ -17,10 +17,10 @@ exports[`packages_list_row renders 1`] = ` /> </div> <div - class="gl-align-items-stretch gl-display-flex gl-flex-grow-1 gl-justify-content-space-between gl-xs-flex-direction-column" + class="gl-align-items-stretch gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-justify-content-space-between gl-sm-flex-direction-row" > <div - class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-min-w-0 gl-xs-mb-3" + class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-mb-3 gl-min-w-0 gl-sm-mb-0" > <div class="gl-align-items-center gl-display-flex gl-font-weight-bold gl-min-h-6 gl-min-w-0 gl-text-body" @@ -82,7 +82,7 @@ exports[`packages_list_row renders 1`] = ` Published <time datetime="2020-05-17T14:23:32Z" - title="May 17, 2020 2:23pm UTC" + title="May 17, 2020 at 2:23:32 PM GMT" > 1 month ago </time> diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js index f4e36f51c27..6a1c34df596 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js @@ -1,4 +1,5 @@ import { nextTick } from 'vue'; +import { GlFilteredSearchToken } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { sortableFields } from '~/packages_and_registries/package_registry/utils'; import component from '~/packages_and_registries/package_registry/components/list/package_search.vue'; @@ -7,7 +8,11 @@ import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; import { LIST_KEY_CREATED_AT } from '~/packages_and_registries/package_registry/constants'; -import { TOKEN_TYPE_TYPE } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + OPERATORS_IS, + TOKEN_TYPE_TYPE, + TOKEN_TYPE_VERSION, +} from '~/vue_shared/components/filtered_search_bar/constants'; describe('Package Search', () => { let wrapper; @@ -74,6 +79,13 @@ describe('Package Search', () => { token: PackageTypeToken, type: TOKEN_TYPE_TYPE, icon: 'package', + operators: OPERATORS_IS, + }), + expect.objectContaining({ + token: GlFilteredSearchToken, + type: TOKEN_TYPE_VERSION, + icon: 'doc-versions', + operators: OPERATORS_IS, }), ]), sortableFields: sortableFields(isGroupPage), @@ -102,6 +114,7 @@ describe('Package Search', () => { filters: { packageName: '', packageType: undefined, + packageVersion: '', }, sort: payload.sort, sorting: payload.sorting, @@ -114,6 +127,7 @@ describe('Package Search', () => { sort: 'CREATED_FOO', filters: [ { type: 'type', value: { data: 'Generic', operator: '=' }, id: 'token-3' }, + { type: 'version', value: { data: '1.0.1', operator: '=' }, id: 'token-6' }, { id: 'token-4', type: 'filtered-search-term', value: { data: 'gl' } }, { id: 'token-5', type: 'filtered-search-term', value: { data: '' } }, ], @@ -133,6 +147,7 @@ describe('Package Search', () => { filters: { packageName: 'gl', packageType: 'GENERIC', + packageVersion: '1.0.1', }, sort: payload.sort, sorting: payload.sorting, 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 6c03f91b73d..fdd64cbe6a5 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -183,7 +183,10 @@ export const composerMetadata = () => ({ export const pypiMetadata = () => ({ __typename: 'PypiMetadata', id: 'pypi-1', + authorEmail: '"C. Schultz" <cschultz@example.com>', + keywords: 'dog,puppy,voting,election', requiredPython: '1.0.0', + summary: 'A module for collecting votes from beagles.', }); export const mavenMetadata = () => ({ diff --git a/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js b/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js index 0ce2b86b9a4..db86be3b8ee 100644 --- a/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js @@ -44,7 +44,7 @@ describe('PackagesListApp', () => { const searchPayload = { sort: 'VERSION_DESC', - filters: { packageName: 'foo', packageType: 'CONAN' }, + filters: { packageName: 'foo', packageType: 'CONAN', packageVersion: '1.0.1' }, }; const findPackageTitle = () => wrapper.findComponent(PackageTitle); @@ -304,7 +304,12 @@ describe('PackagesListApp', () => { await waitForFirstRequest(); - findSearch().vm.$emit('update', searchPayload); + findSearch().vm.$emit('update', { + sort: 'VERSION_DESC', + filters: { + packageName: 'test', + }, + }); return nextTick(); }); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js index 12425909454..dfcabd14489 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js @@ -6,6 +6,7 @@ import * as commonUtils from '~/lib/utils/common_utils'; import component from '~/packages_and_registries/settings/project/components/registry_settings_app.vue'; import ContainerExpirationPolicy from '~/packages_and_registries/settings/project/components/container_expiration_policy.vue'; import PackagesCleanupPolicy from '~/packages_and_registries/settings/project/components/packages_cleanup_policy.vue'; +import DependencyProxyPackagesSettings from 'ee_component/packages_and_registries/settings/project/components/dependency_proxy_packages_settings.vue'; import { SHOW_SETUP_SUCCESS_ALERT, UPDATE_SETTINGS_SUCCESS_MESSAGE, @@ -18,11 +19,16 @@ describe('Registry Settings app', () => { const findContainerExpirationPolicy = () => wrapper.findComponent(ContainerExpirationPolicy); const findPackagesCleanupPolicy = () => wrapper.findComponent(PackagesCleanupPolicy); + const findDependencyProxyPackagesSettings = () => + wrapper.findComponent(DependencyProxyPackagesSettings); const findAlert = () => wrapper.findComponent(GlAlert); const defaultProvide = { + projectPath: 'path', showContainerRegistrySettings: true, showPackageRegistrySettings: true, + showDependencyProxySettings: false, + ...(IS_EE && { showDependencyProxySettings: true }), }; const mountComponent = (provide = defaultProvide) => { @@ -82,6 +88,7 @@ describe('Registry Settings app', () => { 'container cleanup policy $showContainerRegistrySettings and package cleanup policy is $showPackageRegistrySettings', ({ showContainerRegistrySettings, showPackageRegistrySettings }) => { mountComponent({ + ...defaultProvide, showContainerRegistrySettings, showPackageRegistrySettings, }); @@ -90,5 +97,16 @@ describe('Registry Settings app', () => { expect(findPackagesCleanupPolicy().exists()).toBe(showPackageRegistrySettings); }, ); + + if (IS_EE) { + it.each([true, false])('when showDependencyProxySettings is %s', (value) => { + mountComponent({ + ...defaultProvide, + showDependencyProxySettings: value, + }); + + expect(findDependencyProxyPackagesSettings().exists()).toBe(value); + }); + } }); }); diff --git a/spec/frontend/packages_and_registries/shared/utils_spec.js b/spec/frontend/packages_and_registries/shared/utils_spec.js index 1dc6bb261de..4676544c324 100644 --- a/spec/frontend/packages_and_registries/shared/utils_spec.js +++ b/spec/frontend/packages_and_registries/shared/utils_spec.js @@ -41,19 +41,20 @@ describe('Packages And Registries shared utils', () => { }); describe('extractFilterAndSorting', () => { it.each` - search | type | sort | orderBy | result - ${['one']} | ${'myType'} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [{ type: 'type', value: { data: 'myType' } }, { type: FILTERED_SEARCH_TERM, value: { data: 'one' } }] }} - ${['one']} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [{ type: FILTERED_SEARCH_TERM, value: { data: 'one' } }] }} - ${[]} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [] }} - ${null} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [] }} - ${null} | ${null} | ${null} | ${'foo'} | ${{ sorting: { orderBy: 'foo' }, filters: [] }} - ${null} | ${null} | ${null} | ${null} | ${{ sorting: {}, filters: [] }} + search | type | version | sort | orderBy | result + ${['one']} | ${'myType'} | ${'1.0.1'} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [{ type: 'type', value: { data: 'myType' } }, { type: 'version', value: { data: '1.0.1' } }, { type: FILTERED_SEARCH_TERM, value: { data: 'one' } }] }} + ${['one']} | ${null} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [{ type: FILTERED_SEARCH_TERM, value: { data: 'one' } }] }} + ${[]} | ${null} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [] }} + ${null} | ${null} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [] }} + ${null} | ${null} | ${null} | ${null} | ${'foo'} | ${{ sorting: { orderBy: 'foo' }, filters: [] }} + ${null} | ${null} | ${null} | ${null} | ${null} | ${{ sorting: {}, filters: [] }} `( 'returns sorting and filters objects in the correct form', - ({ search, type, sort, orderBy, result }) => { + ({ search, type, version, sort, orderBy, result }) => { const queryObject = { search, type, + version, sort, orderBy, }; diff --git a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js index be50858bc88..3db77469d6b 100644 --- a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js +++ b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js @@ -1,16 +1,23 @@ import { GlEmptyState, GlLoadingIcon, GlTableLite } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import axios from '~/lib/utils/axios_utils'; import waitForPromises from 'helpers/wait_for_promises'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { getParameterValues } from '~/lib/utils/url_utility'; + +import BulkImportsHistoryApp from '~/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue'; import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import BulkImportsHistoryApp from '~/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + getParameterValues: jest.fn().mockReturnValue([]), +})); describe('BulkImportsHistoryApp', () => { - const API_URL = '/api/v4/bulk_imports/entities'; + const BULK_IMPORTS_API_URL = '/api/v4/bulk_imports/entities'; const DEFAULT_HEADERS = { 'x-page': 1, @@ -73,14 +80,14 @@ describe('BulkImportsHistoryApp', () => { } const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); + const findPaginationBar = () => wrapper.findComponent(PaginationBar); beforeEach(() => { gon.api_version = 'v4'; - }); - beforeEach(() => { + getParameterValues.mockReturnValue([]); mock = new MockAdapter(axios); - mock.onGet(API_URL).reply(HTTP_STATUS_OK, DUMMY_RESPONSE, DEFAULT_HEADERS); + mock.onGet(BULK_IMPORTS_API_URL).reply(HTTP_STATUS_OK, DUMMY_RESPONSE, DEFAULT_HEADERS); }); afterEach(() => { @@ -94,9 +101,9 @@ describe('BulkImportsHistoryApp', () => { }); it('renders empty state when no data is available', async () => { - mock.onGet(API_URL).reply(HTTP_STATUS_OK, [], DEFAULT_HEADERS); + mock.onGet(BULK_IMPORTS_API_URL).reply(HTTP_STATUS_OK, [], DEFAULT_HEADERS); createComponent(); - await axios.waitForAll(); + await waitForPromises(); expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); expect(wrapper.findComponent(GlEmptyState).exists()).toBe(true); @@ -104,7 +111,7 @@ describe('BulkImportsHistoryApp', () => { it('renders table with data when history is available', async () => { createComponent(); - await axios.waitForAll(); + await waitForPromises(); const table = wrapper.findComponent(GlTableLite); expect(table.exists()).toBe(true); @@ -116,26 +123,46 @@ describe('BulkImportsHistoryApp', () => { const NEW_PAGE = 4; createComponent(); - await axios.waitForAll(); + await waitForPromises(); mock.resetHistory(); - wrapper.findComponent(PaginationBar).vm.$emit('set-page', NEW_PAGE); - await axios.waitForAll(); + findPaginationBar().vm.$emit('set-page', NEW_PAGE); + await waitForPromises(); expect(mock.history.get.length).toBe(1); expect(mock.history.get[0].params).toStrictEqual(expect.objectContaining({ page: NEW_PAGE })); }); }); + describe('when filtering by bulk_import_id param', () => { + const mockId = 2; + + beforeEach(() => { + getParameterValues.mockReturnValue([mockId]); + }); + + it('makes a request to bulk_import_history endpoint', async () => { + createComponent(); + await waitForPromises(); + + expect(mock.history.get.length).toBe(1); + expect(mock.history.get[0].url).toBe(`/api/v4/bulk_imports/${mockId}/entities`); + expect(mock.history.get[0].params).toStrictEqual({ + page: 1, + per_page: 20, + }); + }); + }); + it('changes page size when requested by pagination bar', async () => { const NEW_PAGE_SIZE = 4; createComponent(); - await axios.waitForAll(); + await waitForPromises(); mock.resetHistory(); - wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE); - await axios.waitForAll(); + findPaginationBar().vm.$emit('set-page-size', NEW_PAGE_SIZE); + await waitForPromises(); expect(mock.history.get.length).toBe(1); expect(mock.history.get[0].params).toStrictEqual( @@ -146,15 +173,14 @@ describe('BulkImportsHistoryApp', () => { it('resets page to 1 when page size is changed', async () => { const NEW_PAGE_SIZE = 4; - mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); createComponent(); - await axios.waitForAll(); - wrapper.findComponent(PaginationBar).vm.$emit('set-page', 2); - await axios.waitForAll(); + await waitForPromises(); + findPaginationBar().vm.$emit('set-page', 2); + await waitForPromises(); mock.resetHistory(); - wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE); - await axios.waitForAll(); + findPaginationBar().vm.$emit('set-page-size', NEW_PAGE_SIZE); + await waitForPromises(); expect(mock.history.get.length).toBe(1); expect(mock.history.get[0].params).toStrictEqual( @@ -166,18 +192,18 @@ describe('BulkImportsHistoryApp', () => { const NEW_PAGE_SIZE = 4; createComponent(); - await axios.waitForAll(); + await waitForPromises(); mock.resetHistory(); - wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE); - await axios.waitForAll(); + findPaginationBar().vm.$emit('set-page-size', NEW_PAGE_SIZE); + await waitForPromises(); expect(findLocalStorageSync().props('value')).toBe(NEW_PAGE_SIZE); }); it('renders link to destination_full_path for destination group', async () => { createComponent({ shallow: false }); - await axios.waitForAll(); + await waitForPromises(); expect(wrapper.find('tbody tr a').attributes().href).toBe( `/${DUMMY_RESPONSE[0].destination_full_path}`, @@ -187,9 +213,9 @@ describe('BulkImportsHistoryApp', () => { it('renders destination as text when destination_full_path is not defined', async () => { const RESPONSE = [{ ...DUMMY_RESPONSE[0], destination_full_path: null }]; - mock.onGet(API_URL).reply(HTTP_STATUS_OK, RESPONSE, DEFAULT_HEADERS); + mock.onGet(BULK_IMPORTS_API_URL).reply(HTTP_STATUS_OK, RESPONSE, DEFAULT_HEADERS); createComponent({ shallow: false }); - await axios.waitForAll(); + await waitForPromises(); expect(wrapper.find('tbody tr a').exists()).toBe(false); expect(wrapper.find('tbody tr span').text()).toBe( @@ -199,14 +225,14 @@ describe('BulkImportsHistoryApp', () => { it('adds slash to group urls', async () => { createComponent({ shallow: false }); - await axios.waitForAll(); + await waitForPromises(); expect(wrapper.find('tbody tr a').text()).toBe(`${DUMMY_RESPONSE[0].destination_full_path}/`); }); it('does not prefixes project urls with slash', async () => { createComponent({ shallow: false }); - await axios.waitForAll(); + await waitForPromises(); expect(wrapper.findAll('tbody tr a').at(1).text()).toBe( DUMMY_RESPONSE[1].destination_full_path, @@ -215,9 +241,9 @@ describe('BulkImportsHistoryApp', () => { describe('details button', () => { beforeEach(() => { - mock.onGet(API_URL).reply(HTTP_STATUS_OK, DUMMY_RESPONSE, DEFAULT_HEADERS); + mock.onGet(BULK_IMPORTS_API_URL).reply(HTTP_STATUS_OK, DUMMY_RESPONSE, DEFAULT_HEADERS); createComponent({ shallow: false }); - return axios.waitForAll(); + return waitForPromises(); }); it('renders details button if relevant item has failures', () => { @@ -255,7 +281,7 @@ describe('BulkImportsHistoryApp', () => { createComponent({ shallow: false }); await waitForPromises(); - expect(mock.history.get.map((x) => x.url)).toEqual([API_URL]); + expect(mock.history.get.map((x) => x.url)).toEqual([BULK_IMPORTS_API_URL]); }); }); @@ -279,7 +305,7 @@ describe('BulkImportsHistoryApp', () => { const RESPONSE = [mockCreatedImport, ...DUMMY_RESPONSE]; const POLL_HEADERS = { 'poll-interval': pollInterval }; - mock.onGet(API_URL).reply(HTTP_STATUS_OK, RESPONSE, DEFAULT_HEADERS); + mock.onGet(BULK_IMPORTS_API_URL).reply(HTTP_STATUS_OK, RESPONSE, DEFAULT_HEADERS); mock.onGet(mockRealtimeChangesPath).replyOnce(HTTP_STATUS_OK, [], POLL_HEADERS); mock .onGet(mockRealtimeChangesPath) @@ -293,7 +319,10 @@ describe('BulkImportsHistoryApp', () => { it('starts polling for realtime changes', () => { jest.advanceTimersByTime(pollInterval); - expect(mock.history.get.map((x) => x.url)).toEqual([API_URL, mockRealtimeChangesPath]); + expect(mock.history.get.map((x) => x.url)).toEqual([ + BULK_IMPORTS_API_URL, + mockRealtimeChangesPath, + ]); expect(wrapper.findAll('tbody tr').at(0).text()).toContain('Pending'); }); @@ -305,7 +334,7 @@ describe('BulkImportsHistoryApp', () => { await waitForPromises(); expect(mock.history.get.map((x) => x.url)).toEqual([ - API_URL, + BULK_IMPORTS_API_URL, mockRealtimeChangesPath, mockRealtimeChangesPath, ]); diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js index f6ecee4cd53..7cb0e3ee38b 100644 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js @@ -7,14 +7,15 @@ describe('Interval Pattern Input Component', () => { let oldWindowGl; let wrapper; + const mockMinute = 3; const mockHour = 4; const mockWeekDayIndex = 1; const mockDay = 1; const cronIntervalPresets = { - everyDay: `0 ${mockHour} * * *`, - everyWeek: `0 ${mockHour} * * ${mockWeekDayIndex}`, - everyMonth: `0 ${mockHour} ${mockDay} * *`, + everyDay: `${mockMinute} ${mockHour} * * *`, + everyWeek: `${mockMinute} ${mockHour} * * ${mockWeekDayIndex}`, + everyMonth: `${mockMinute} ${mockHour} ${mockDay} * *`, }; const customKey = 'custom'; const everyDayKey = 'everyDay'; @@ -40,6 +41,7 @@ describe('Interval Pattern Input Component', () => { propsData: { ...props }, data() { return { + randomMinute: data?.minute || mockMinute, randomHour: data?.hour || mockHour, randomWeekDayIndex: mockWeekDayIndex, randomDay: mockDay, @@ -108,12 +110,12 @@ describe('Interval Pattern Input Component', () => { describe('formattedTime computed property', () => { it.each` - desc | hour | expectedValue - ${'returns a time in the afternoon if the value of `random time` is higher than 12'} | ${13} | ${'1:00pm'} - ${'returns a time in the morning if the value of `random time` is lower than 12'} | ${11} | ${'11:00am'} - ${'returns "12:00pm" if the value of `random time` is exactly 12'} | ${12} | ${'12:00pm'} - `('$desc', ({ hour, expectedValue }) => { - createWrapper({}, { hour }); + desc | hour | minute | expectedValue + ${'returns a time in the afternoon if the value of `random time` is higher than 12'} | ${13} | ${7} | ${'1:07pm'} + ${'returns a time in the morning if the value of `random time` is lower than 12'} | ${11} | ${30} | ${'11:30am'} + ${'returns "12:05pm" if the value of `random time` is exactly 12 and the value of random minutes is 5'} | ${12} | ${5} | ${'12:05pm'} + `('$desc', ({ hour, minute, expectedValue }) => { + createWrapper({}, { hour, minute }); expect(wrapper.vm.formattedTime).toBe(expectedValue); }); @@ -128,9 +130,9 @@ describe('Interval Pattern Input Component', () => { const labels = findAllLabels().wrappers.map((el) => trimText(el.text())); expect(labels).toEqual([ - 'Every day (at 4:00am)', - 'Every week (Monday at 4:00am)', - 'Every month (Day 1 at 4:00am)', + 'Every day (at 4:03am)', + 'Every week (Monday at 4:03am)', + 'Every month (Day 1 at 4:03am)', 'Custom', ]); }); 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 index 4ac3a511fa2..8145eb6fbd4 100644 --- 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 @@ -1,27 +1,30 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { GlBadge, GlLoadingIcon, GlModal, GlSprintf, GlToggle } from '@gitlab/ui'; +import { 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 BetaBadge from '~/vue_shared/components/badges/beta_badge.vue'; import catalogResourcesCreate from '~/pages/projects/shared/permissions/graphql/mutations/catalog_resources_create.mutation.graphql'; +import catalogResourcesDestroy from '~/pages/projects/shared/permissions/graphql/mutations/catalog_resources_destroy.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 CiCatalogSettings from '~/pages/projects/shared/permissions/components/ci_catalog_settings.vue'; -import { mockCiCatalogSettingsResponse } from './mock_data'; +import { generateCatalogSettingsResponse } from './mock_data'; Vue.use(VueApollo); jest.mock('~/alert'); +const showToast = jest.fn(); + describe('CiCatalogSettings', () => { let wrapper; let ciCatalogSettingsResponse; let catalogResourcesCreateResponse; + let catalogResourcesDestroyResponse; const fullPath = 'gitlab-org/gitlab'; @@ -29,6 +32,7 @@ describe('CiCatalogSettings', () => { const handlers = [ [getCiCatalogSettingsQuery, ciCatalogSettingsHandler], [catalogResourcesCreate, catalogResourcesCreateResponse], + [catalogResourcesDestroy, catalogResourcesDestroyResponse], ]; const mockApollo = createMockApollo(handlers); @@ -39,6 +43,11 @@ describe('CiCatalogSettings', () => { stubs: { GlSprintf, }, + mocks: { + $toast: { + show: showToast, + }, + }, apolloProvider: mockApollo, }); @@ -46,15 +55,34 @@ describe('CiCatalogSettings', () => { }; const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findBadge = () => wrapper.findComponent(GlBadge); + const findBadge = () => wrapper.findComponent(BetaBadge); const findModal = () => wrapper.findComponent(GlModal); const findToggle = () => wrapper.findComponent(GlToggle); - const findCiCatalogSettings = () => wrapper.findByTestId('ci-catalog-settings'); + const removeCatalogResource = () => { + findToggle().vm.$emit('change'); + findModal().vm.$emit('primary'); + return waitForPromises(); + }; + + const setCatalogResource = () => { + findToggle().vm.$emit('change'); + return waitForPromises(); + }; + beforeEach(() => { - ciCatalogSettingsResponse = jest.fn().mockResolvedValue(mockCiCatalogSettingsResponse); + ciCatalogSettingsResponse = jest.fn(); + catalogResourcesDestroyResponse = jest.fn(); catalogResourcesCreateResponse = jest.fn(); + + ciCatalogSettingsResponse.mockResolvedValue(generateCatalogSettingsResponse()); + catalogResourcesCreateResponse.mockResolvedValue({ + data: { catalogResourcesCreate: { errors: [] } }, + }); + catalogResourcesDestroyResponse.mockResolvedValue({ + data: { catalogResourcesDestroy: { errors: [] } }, + }); }); describe('when initial queries are loading', () => { @@ -81,31 +109,68 @@ describe('CiCatalogSettings', () => { expect(findCiCatalogSettings().exists()).toBe(true); }); - it('renders the experiment badge', () => { + it('renders the beta 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 the project is not a CI/CD resource', () => { + beforeEach(async () => { + await createComponent(); }); - describe('when queries have loaded', () => { - beforeEach(() => { - catalogResourcesCreateResponse.mockResolvedValue(mockCiCatalogSettingsResponse); + describe('and the toggle is clicked', () => { + it('does not show a confirmation modal', async () => { + expect(findModal().props('visible')).toBe(false); + + await findToggle().vm.$emit('change', true); + + expect(findModal().props('visible')).toBe(false); + }); + + it('calls the mutation with the correct input', async () => { + expect(catalogResourcesCreateResponse).toHaveBeenCalledTimes(0); + + await setCatalogResource(); + + expect(catalogResourcesCreateResponse).toHaveBeenCalledTimes(1); + expect(catalogResourcesCreateResponse).toHaveBeenCalledWith({ + input: { + projectPath: fullPath, + }, + }); }); - it('shows the modal when the toggle is clicked', async () => { + describe('when the mutation is successful', () => { + it('shows a toast message with a success message', async () => { + expect(showToast).not.toHaveBeenCalled(); + + await setCatalogResource(); + + expect(showToast).toHaveBeenCalledWith('This project is now a CI/CD Catalog resource.'); + }); + }); + }); + }); + + describe('when the project is a CI/CD resource', () => { + beforeEach(async () => { + ciCatalogSettingsResponse.mockResolvedValue(generateCatalogSettingsResponse(true)); + await createComponent(); + }); + + describe('and the toggle is clicked', () => { + it('shows a confirmation modal', async () => { expect(findModal().props('visible')).toBe(false); - await findToggle().vm.$emit('change', true); + await findToggle().vm.$emit('change', false); expect(findModal().props('visible')).toBe(true); - expect(findModal().props('actionPrimary').text).toBe(i18n.modal.actionPrimary.text); + expect(findModal().props('actionPrimary').text).toBe('Remove from the CI/CD catalog'); }); it('hides the modal when cancel is clicked', () => { @@ -117,31 +182,85 @@ describe('CiCatalogSettings', () => { }); it('calls the mutation with the correct input from the modal click', async () => { - expect(catalogResourcesCreateResponse).toHaveBeenCalledTimes(0); + expect(catalogResourcesDestroyResponse).toHaveBeenCalledTimes(0); - findToggle().vm.$emit('change', true); - findModal().vm.$emit('primary'); - await waitForPromises(); + await removeCatalogResource(); - expect(catalogResourcesCreateResponse).toHaveBeenCalledTimes(1); - expect(catalogResourcesCreateResponse).toHaveBeenCalledWith({ + expect(catalogResourcesDestroyResponse).toHaveBeenCalledTimes(1); + expect(catalogResourcesDestroyResponse).toHaveBeenCalledWith({ input: { projectPath: fullPath, }, }); }); + + it('shows a toast message when the mutation has worked', async () => { + expect(showToast).not.toHaveBeenCalled(); + + await removeCatalogResource(); + + expect(showToast).toHaveBeenCalledWith( + 'This project is no longer a CI/CD Catalog resource.', + ); + }); }); }); - describe('when the query is unsuccessful', () => { - const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + describe('mutation errors', () => { + const createGraphqlError = { data: { catalogResourcesCreate: { errors: ['graphql error'] } } }; + const destroyGraphqlError = { + data: { catalogResourcesDestroy: { errors: ['graphql error'] } }, + }; - it('throws an error', async () => { - await createComponent({ ciCatalogSettingsHandler: failedHandler }); + beforeEach(() => { + createAlert.mockClear(); + }); + it.each` + name | errorType | jestResolver | mockResponse | expectedMessage + ${'create'} | ${'unhandled server error with a message'} | ${'mockRejectedValue'} | ${new Error('server error')} | ${'server error'} + ${'create'} | ${'unhandled server error without a message'} | ${'mockRejectedValue'} | ${new Error()} | ${'Unable to set project as a CI/CD Catalog resource.'} + ${'create'} | ${'handled Graphql error'} | ${'mockResolvedValue'} | ${createGraphqlError} | ${'graphql error'} + ${'destroy'} | ${'unhandled server'} | ${'mockRejectedValue'} | ${new Error('server error')} | ${'server error'} + ${'destroy'} | ${'unhandled server'} | ${'mockRejectedValue'} | ${new Error()} | ${'Unable to remove project as a CI/CD Catalog resource.'} + ${'destroy'} | ${'handled Graphql error'} | ${'mockResolvedValue'} | ${destroyGraphqlError} | ${'graphql error'} + `( + 'when $name mutation returns an $errorType', + async ({ name, jestResolver, mockResponse, expectedMessage }) => { + let mutationMock = catalogResourcesCreateResponse; + let toggleAction = setCatalogResource; + + if (name === 'destroy') { + mutationMock = catalogResourcesDestroyResponse; + toggleAction = removeCatalogResource; + ciCatalogSettingsResponse.mockResolvedValue(generateCatalogSettingsResponse(true)); + } + + await createComponent(); + mutationMock[jestResolver](mockResponse); + + expect(showToast).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); + + await toggleAction(); + + expect(showToast).not.toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledWith({ message: expectedMessage }); + }, + ); + }); + + describe('when the query is unsuccessful', () => { + beforeEach(async () => { + const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + await createComponent({ ciCatalogSettingsHandler: failedHandler }); await waitForPromises(); + }); - expect(createAlert).toHaveBeenCalledWith({ message: i18n.catalogResourceQueryError }); + it('throws an error', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: 'There was a problem fetching the CI/CD Catalog setting.', + }); }); }); }); diff --git a/spec/frontend/pages/projects/shared/permissions/components/mock_data.js b/spec/frontend/pages/projects/shared/permissions/components/mock_data.js index 44bbf2a5eb2..cf51604e1b0 100644 --- a/spec/frontend/pages/projects/shared/permissions/components/mock_data.js +++ b/spec/frontend/pages/projects/shared/permissions/components/mock_data.js @@ -1,7 +1,10 @@ -export const mockCiCatalogSettingsResponse = { - data: { - catalogResourcesCreate: { - errors: [], +export const generateCatalogSettingsResponse = (isCatalogResource = false) => { + return { + data: { + project: { + id: 'gid://gitlab/Project/149', + isCatalogResource, + }, }, - }, + }; }; 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 8b672ff3f32..207ce8c1ffa 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 @@ -137,6 +137,7 @@ describe('Settings Panel', () => { const findMonitorSettings = () => wrapper.findComponent({ ref: 'monitor-settings' }); const findModelExperimentsSettings = () => wrapper.findComponent({ ref: 'model-experiments-settings' }); + const findModelRegistrySettings = () => wrapper.findComponent({ ref: 'model-registry-settings' }); describe('Project Visibility', () => { it('should set the project visibility help path', () => { @@ -758,4 +759,11 @@ describe('Settings Panel', () => { expect(findModelExperimentsSettings().exists()).toBe(true); }); }); + describe('Model registry', () => { + it('shows model registry toggle', () => { + wrapper = mountComponent({}); + + expect(findModelRegistrySettings().exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/pages/shared/nav/sidebar_tracking_spec.js b/spec/frontend/pages/shared/nav/sidebar_tracking_spec.js deleted file mode 100644 index 04f53e048ed..00000000000 --- a/spec/frontend/pages/shared/nav/sidebar_tracking_spec.js +++ /dev/null @@ -1,160 +0,0 @@ -import { setHTMLFixture } from 'helpers/fixtures'; -import { initSidebarTracking } from '~/pages/shared/nav/sidebar_tracking'; - -describe('~/pages/shared/nav/sidebar_tracking.js', () => { - beforeEach(() => { - setHTMLFixture(` - <aside class="nav-sidebar"> - <div class="nav-sidebar-inner-scroll"> - <ul class="sidebar-top-level-items"> - <li data-track-label="project_information_menu" class="home"> - <a aria-label="Project information" class="shortcuts-project-information has-sub-items" href=""> - <span class="nav-icon-container"> - <svg class="s16" data-testid="project-icon"> - <use xlink:href="/assets/icons-1b2dadc4c3d49797908ba67b8f10da5d63dd15d859bde28d66fb60bbb97a4dd5.svg#project"></use> - </svg> - </span> - <span class="nav-item-name">Project information</span> - </a> - <ul class="sidebar-sub-level-items"> - <li class="fly-out-top-item"> - <a aria-label="Project information" href="#"> - <strong class="fly-out-top-item-name">Project information</strong> - </a> - </li> - <li class="divider fly-out-top-item"></li> - <li data-track-label="activity" class=""> - <a aria-label="Activity" class="shortcuts-project-activity" href=#"> - <span>Activity</span> - </a> - </li> - <li data-track-label="labels" class=""> - <a aria-label="Labels" href="#"> - <span>Labels</span> - </a> - </li> - <li data-track-label="members" class=""> - <a aria-label="Members" href="#"> - <span>Members</span> - </a> - </li> - </ul> - </li> - </ul> - </div> - </aside> - `); - - initSidebarTracking(); - }); - - describe('sidebar is not collapsed', () => { - describe('menu is not expanded', () => { - it('sets the proper data tracking attributes when clicking on menu', () => { - const menu = document.querySelector('li[data-track-label="project_information_menu"]'); - const menuLink = menu.querySelector('a'); - - menu.classList.add('is-over', 'is-showing-fly-out'); - menuLink.click(); - - expect(menu).toHaveTrackingAttributes({ - action: 'click_menu', - extra: JSON.stringify({ - sidebar_display: 'Expanded', - menu_display: 'Fly out', - }), - }); - }); - - it('sets the proper data tracking attributes when clicking on submenu', () => { - const menu = document.querySelector('li[data-track-label="activity"]'); - const menuLink = menu.querySelector('a'); - const submenuList = document.querySelector('ul.sidebar-sub-level-items'); - - submenuList.classList.add('fly-out-list'); - menuLink.click(); - - expect(menu).toHaveTrackingAttributes({ - action: 'click_menu_item', - extra: JSON.stringify({ - sidebar_display: 'Expanded', - menu_display: 'Fly out', - }), - }); - }); - }); - - describe('menu is expanded', () => { - it('sets the proper data tracking attributes when clicking on menu', () => { - const menu = document.querySelector('li[data-track-label="project_information_menu"]'); - const menuLink = menu.querySelector('a'); - - menu.classList.add('active'); - menuLink.click(); - - expect(menu).toHaveTrackingAttributes({ - action: 'click_menu', - extra: JSON.stringify({ - sidebar_display: 'Expanded', - menu_display: 'Expanded', - }), - }); - }); - - it('sets the proper data tracking attributes when clicking on submenu', () => { - const menu = document.querySelector('li[data-track-label="activity"]'); - const menuLink = menu.querySelector('a'); - - menu.classList.add('active'); - menuLink.click(); - - expect(menu).toHaveTrackingAttributes({ - action: 'click_menu_item', - extra: JSON.stringify({ - sidebar_display: 'Expanded', - menu_display: 'Expanded', - }), - }); - }); - }); - }); - - describe('sidebar is collapsed', () => { - beforeEach(() => { - document.querySelector('aside.nav-sidebar').classList.add('js-sidebar-collapsed'); - }); - - it('sets the proper data tracking attributes when clicking on menu', () => { - const menu = document.querySelector('li[data-track-label="project_information_menu"]'); - const menuLink = menu.querySelector('a'); - - menu.classList.add('is-over', 'is-showing-fly-out'); - menuLink.click(); - - expect(menu).toHaveTrackingAttributes({ - action: 'click_menu', - extra: JSON.stringify({ - sidebar_display: 'Collapsed', - menu_display: 'Fly out', - }), - }); - }); - - it('sets the proper data tracking attributes when clicking on submenu', () => { - const menu = document.querySelector('li[data-track-label="activity"]'); - const menuLink = menu.querySelector('a'); - const submenuList = document.querySelector('ul.sidebar-sub-level-items'); - - submenuList.classList.add('fly-out-list'); - menuLink.click(); - - expect(menu).toHaveTrackingAttributes({ - action: 'click_menu_item', - extra: JSON.stringify({ - sidebar_display: 'Collapsed', - menu_display: 'Fly out', - }), - }); - }); - }); -}); diff --git a/spec/frontend/pages/shared/wikis/components/wiki_export_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_export_spec.js deleted file mode 100644 index b7002412561..00000000000 --- a/spec/frontend/pages/shared/wikis/components/wiki_export_spec.js +++ /dev/null @@ -1,48 +0,0 @@ -import { GlDisclosureDropdown } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import WikiExport from '~/pages/shared/wikis/components/wiki_export.vue'; -import printMarkdownDom from '~/lib/print_markdown_dom'; - -jest.mock('~/lib/print_markdown_dom'); - -describe('pages/shared/wikis/components/wiki_export', () => { - let wrapper; - - const createComponent = (provide) => { - wrapper = shallowMount(WikiExport, { - provide, - }); - }; - - const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); - const findPrintItem = () => - findDropdown() - .props('items') - .find((x) => x.text === 'Print as PDF'); - - describe('print', () => { - beforeEach(() => { - document.body.innerHTML = '<div id="content-body">Content</div>'; - }); - - afterEach(() => { - document.body.innerHTML = ''; - }); - - it('should print the content', () => { - createComponent({ - target: '#content-body', - title: 'test title', - stylesheet: [], - }); - - findPrintItem().action(); - - expect(printMarkdownDom).toHaveBeenCalledWith({ - target: document.querySelector('#content-body'), - title: 'test title', - stylesheet: [], - }); - }); - }); -}); diff --git a/spec/frontend/pages/shared/wikis/components/wiki_more_dropdown_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_more_dropdown_spec.js new file mode 100644 index 00000000000..830377ff39f --- /dev/null +++ b/spec/frontend/pages/shared/wikis/components/wiki_more_dropdown_spec.js @@ -0,0 +1,83 @@ +import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import WikiMoreDropdown from '~/pages/shared/wikis/components/wiki_more_dropdown.vue'; +import printMarkdownDom from '~/lib/print_markdown_dom'; + +jest.mock('~/lib/print_markdown_dom'); + +describe('pages/shared/wikis/components/wiki_more_dropdown', () => { + let wrapper; + + const createComponent = (provide) => { + wrapper = shallowMountExtended(WikiMoreDropdown, { + provide: { + history: 'https://history.url/path', + print: { + target: '#content-body', + title: 'test title', + stylesheet: [], + }, + ...provide, + }, + stubs: { + GlDisclosureDropdown, + GlDisclosureDropdownItem, + }, + }); + }; + + const findHistoryItem = () => wrapper.findByTestId('page-history-button'); + const findPrintItem = () => wrapper.findByTestId('page-print-button'); + + describe('history', () => { + it('renders if `history` is set', () => { + createComponent({ history: false }); + + expect(findHistoryItem().exists()).toBe(false); + + createComponent(); + + expect(findHistoryItem().exists()).toBe(true); + }); + + it('should have history page url', () => { + createComponent(); + + expect(findHistoryItem().attributes('href')).toBe('https://history.url/path'); + }); + }); + + describe('print', () => { + beforeEach(() => { + document.body.innerHTML = '<div id="content-body">Content</div>'; + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('renders if `print` is set', () => { + createComponent({ print: false }); + + expect(findPrintItem().exists()).toBe(false); + + createComponent(); + + expect(findPrintItem().exists()).toBe(true); + }); + + it('should print the content', () => { + createComponent(); + + expect(findPrintItem().exists()).toBe(true); + + findPrintItem().trigger('click'); + + expect(printMarkdownDom).toHaveBeenCalledWith({ + target: document.querySelector('#content-body'), + title: 'test title', + stylesheet: [], + }); + }); + }); +}); diff --git a/spec/frontend/persistent_user_callout_spec.js b/spec/frontend/persistent_user_callout_spec.js index 376575a8acb..a9bfc0003bf 100644 --- a/spec/frontend/persistent_user_callout_spec.js +++ b/spec/frontend/persistent_user_callout_spec.js @@ -24,6 +24,7 @@ describe('PersistentUserCallout', () => { > <button type="button" class="js-close js-close-primary"></button> <button type="button" class="js-close js-close-secondary"></button> + <a class="js-close-and-follow-link" href="/somewhere-pleasant">A Link</a> </div> `; @@ -65,6 +66,8 @@ describe('PersistentUserCallout', () => { return fixture; } + useMockLocationHelper(); + describe('dismiss', () => { const buttons = {}; let mockAxios; @@ -178,8 +181,6 @@ describe('PersistentUserCallout', () => { let mockAxios; let persistentUserCallout; - useMockLocationHelper(); - beforeEach(() => { const fixture = createFollowLinkFixture(); const container = fixture.querySelector('.container'); @@ -222,6 +223,53 @@ describe('PersistentUserCallout', () => { }); }); + describe('dismiss and follow links', () => { + let link; + let mockAxios; + let persistentUserCallout; + + beforeEach(() => { + const fixture = createFixture(); + const container = fixture.querySelector('.container'); + link = fixture.querySelector('.js-close-and-follow-link'); + mockAxios = new MockAdapter(axios); + + persistentUserCallout = new PersistentUserCallout(container); + jest.spyOn(persistentUserCallout.container, 'remove').mockImplementation(() => {}); + }); + + afterEach(() => { + mockAxios.restore(); + }); + + it('uses a link to trigger callout and defers following until callout is finished', async () => { + const { href } = link; + mockAxios.onPost(dismissEndpoint).replyOnce(HTTP_STATUS_OK); + + link.click(); + + await waitForPromises(); + + expect(window.location.assign).toHaveBeenCalledWith(href); + expect(persistentUserCallout.container.remove).not.toHaveBeenCalled(); + expect(mockAxios.history.post[0].data).toBe(JSON.stringify({ feature_name: featureName })); + }); + + it('invokes Flash when the dismiss request fails', async () => { + mockAxios.onPost(dismissEndpoint).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); + + link.click(); + + await waitForPromises(); + + expect(window.location.assign).not.toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledWith({ + message: + 'An error occurred while acknowledging the notification. Refresh the page and try again.', + }); + }); + }); + describe('factory', () => { it('returns an instance of PersistentUserCallout with the provided container property', () => { const fixture = createFixture(); diff --git a/spec/frontend/profile/edit/components/profile_edit_app_spec.js b/spec/frontend/profile/edit/components/profile_edit_app_spec.js index 31a368aefa9..39bf597352b 100644 --- a/spec/frontend/profile/edit/components/profile_edit_app_spec.js +++ b/spec/frontend/profile/edit/components/profile_edit_app_spec.js @@ -3,7 +3,6 @@ import MockAdapter from 'axios-mock-adapter'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { readFileAsDataURL } from '~/lib/utils/file_utility'; import axios from '~/lib/utils/axios_utils'; import ProfileEditApp from '~/profile/edit/components/profile_edit_app.vue'; import UserAvatar from '~/profile/edit/components/user_avatar.vue'; @@ -103,6 +102,8 @@ describe('Profile Edit App', () => { }); it('syncs header avatars', async () => { + jest.spyOn(document, 'dispatchEvent'); + jest.spyOn(URL, 'createObjectURL'); mockAxios.onPut(stubbedProfilePath).reply(200, { message: successMessage, }); @@ -112,7 +113,8 @@ describe('Profile Edit App', () => { await waitForPromises(); - expect(readFileAsDataURL).toHaveBeenCalledWith(mockAvatarFile); + expect(URL.createObjectURL).toHaveBeenCalledWith(mockAvatarFile); + expect(document.dispatchEvent).toHaveBeenCalledWith(new CustomEvent('userAvatar:update')); }); it('contains changes from the status form', async () => { diff --git a/spec/frontend/profile/edit/components/user_avatar_spec.js b/spec/frontend/profile/edit/components/user_avatar_spec.js index caa3356b49f..7c4f74d6bfb 100644 --- a/spec/frontend/profile/edit/components/user_avatar_spec.js +++ b/spec/frontend/profile/edit/components/user_avatar_spec.js @@ -46,6 +46,7 @@ describe('Edit User Avatar', () => { ...defaultProvides, ...provides, }, + attachTo: document.body, }); }; @@ -65,7 +66,7 @@ describe('Edit User Avatar', () => { modalCrop: '.modal-profile-crop', pickImageEl: '.js-choose-user-avatar-button', uploadImageBtn: '.js-upload-user-avatar', - modalCropImg: '.modal-profile-crop-image', + modalCropImg: expect.any(HTMLImageElement), onBlobChange: expect.any(Function), }); expect(glCropDataMock).toHaveBeenCalledWith('glcrop'); diff --git a/spec/frontend/projects/commit/store/actions_spec.js b/spec/frontend/projects/commit/store/actions_spec.js index adb87142fee..7ff1af86f35 100644 --- a/spec/frontend/projects/commit/store/actions_spec.js +++ b/spec/frontend/projects/commit/store/actions_spec.js @@ -25,7 +25,7 @@ describe('Commit form modal store actions', () => { describe('clearModal', () => { it('commits CLEAR_MODAL mutation', () => { - testAction(actions.clearModal, {}, {}, [ + return testAction(actions.clearModal, {}, {}, [ { type: types.CLEAR_MODAL, }, @@ -35,7 +35,7 @@ describe('Commit form modal store actions', () => { describe('requestBranches', () => { it('commits REQUEST_BRANCHES mutation', () => { - testAction(actions.requestBranches, {}, {}, [ + return testAction(actions.requestBranches, {}, {}, [ { type: types.REQUEST_BRANCHES, }, @@ -74,7 +74,7 @@ describe('Commit form modal store actions', () => { describe('setBranch', () => { it('commits SET_BRANCH mutation', () => { - testAction( + return testAction( actions.setBranch, {}, {}, @@ -96,7 +96,7 @@ describe('Commit form modal store actions', () => { describe('setSelectedBranch', () => { it('commits SET_SELECTED_BRANCH mutation', () => { - testAction(actions.setSelectedBranch, {}, {}, [ + return testAction(actions.setSelectedBranch, {}, {}, [ { type: types.SET_SELECTED_BRANCH, payload: {}, @@ -109,7 +109,7 @@ describe('Commit form modal store actions', () => { it('commits SET_BRANCHES_ENDPOINT mutation', () => { const endpoint = 'some/endpoint'; - testAction(actions.setBranchesEndpoint, endpoint, {}, [ + return testAction(actions.setBranchesEndpoint, endpoint, {}, [ { type: types.SET_BRANCHES_ENDPOINT, payload: endpoint, @@ -122,7 +122,7 @@ describe('Commit form modal store actions', () => { const id = 1; it('commits SET_SELECTED_PROJECT mutation', () => { - testAction( + return testAction( actions.setSelectedProject, id, {}, diff --git a/spec/frontend/projects/commits/store/actions_spec.js b/spec/frontend/projects/commits/store/actions_spec.js index 8afa2a6fb8f..e42587d5aad 100644 --- a/spec/frontend/projects/commits/store/actions_spec.js +++ b/spec/frontend/projects/commits/store/actions_spec.js @@ -53,7 +53,7 @@ describe('Project commits actions', () => { const data = [{ id: 1 }]; mock.onGet(path).replyOnce(HTTP_STATUS_OK, data); - testAction( + return testAction( actions.fetchAuthors, null, state, @@ -66,7 +66,7 @@ describe('Project commits actions', () => { const path = '/-/autocomplete/users.json'; mock.onGet(path).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); - testAction(actions.fetchAuthors, null, state, [], [{ type: 'receiveAuthorsError' }]); + return testAction(actions.fetchAuthors, null, state, [], [{ type: 'receiveAuthorsError' }]); }); }); }); diff --git a/spec/frontend/projects/components/shared/delete_modal_spec.js b/spec/frontend/projects/components/shared/delete_modal_spec.js index c6213fd4b6d..7e040db4beb 100644 --- a/spec/frontend/projects/components/shared/delete_modal_spec.js +++ b/spec/frontend/projects/components/shared/delete_modal_spec.js @@ -49,7 +49,7 @@ describe('DeleteModal', () => { attributes: { variant: 'danger', disabled: true, - 'data-qa-selector': 'confirm_delete_button', + 'data-testid': 'confirm-delete-button', }, }, actionCancel: { diff --git a/spec/frontend/projects/settings/components/default_branch_selector_spec.js b/spec/frontend/projects/settings/components/default_branch_selector_spec.js index 9baea5c5517..aa50683b185 100644 --- a/spec/frontend/projects/settings/components/default_branch_selector_spec.js +++ b/spec/frontend/projects/settings/components/default_branch_selector_spec.js @@ -4,6 +4,7 @@ import RefSelector from '~/ref/components/ref_selector.vue'; import { REF_TYPE_BRANCHES } from '~/ref/constants'; describe('projects/settings/components/default_branch_selector', () => { + const disabled = true; const persistedDefaultBranch = 'main'; const projectId = '123'; let wrapper; @@ -13,6 +14,7 @@ describe('projects/settings/components/default_branch_selector', () => { const buildWrapper = () => { wrapper = shallowMount(DefaultBranchSelector, { propsData: { + disabled, persistedDefaultBranch, projectId, }, @@ -25,6 +27,7 @@ describe('projects/settings/components/default_branch_selector', () => { it('displays a RefSelector component', () => { expect(findRefSelector().props()).toEqual({ + disabled, value: persistedDefaultBranch, enabledRefTypes: [REF_TYPE_BRANCHES], projectId, 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 7c8cc1bb38d..4e3554131c6 100644 --- a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js +++ b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js @@ -8,6 +8,7 @@ import { import { last } from 'lodash'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import { getUsers, getGroups, getDeployKeys } from '~/projects/settings/api/access_dropdown_api'; import AccessDropdown, { i18n } from '~/projects/settings/components/access_dropdown.vue'; @@ -77,6 +78,7 @@ describe('Access Level Dropdown', () => { label, disabled, preselectedItems, + stubs = {}, } = {}) => { wrapper = shallowMountExtended(AccessDropdown, { propsData: { @@ -90,6 +92,7 @@ describe('Access Level Dropdown', () => { stubs: { GlSprintf, GlDropdown, + ...stubs, }, }); }; @@ -373,15 +376,22 @@ describe('Access Level Dropdown', () => { }); describe('on dropdown open', () => { + const focusInput = jest.fn(); + beforeEach(() => { - createComponent(); + createComponent({ + stubs: { + GlSearchBoxByType: stubComponent(GlSearchBoxByType, { + methods: { focusInput }, + }), + }, + }); }); it('should set the search input focus', () => { - wrapper.vm.$refs.search.focusInput = jest.fn(); findDropdown().vm.$emit('shown'); - expect(wrapper.vm.$refs.search.focusInput).toHaveBeenCalled(); + expect(focusInput).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/projects/settings_service_desk/components/custom_email_spec.js b/spec/frontend/projects/settings_service_desk/components/custom_email_spec.js index 2808a25296d..0a593f3812a 100644 --- a/spec/frontend/projects/settings_service_desk/components/custom_email_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/custom_email_spec.js @@ -3,7 +3,6 @@ import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import CustomEmail from '~/projects/settings_service_desk/components/custom_email.vue'; import { - I18N_VERIFICATION_ERRORS, I18N_STATE_VERIFICATION_STARTED, I18N_STATE_VERIFICATION_FAILED, I18N_STATE_VERIFICATION_FAILED_RESET_PARAGRAPH, @@ -15,6 +14,7 @@ describe('CustomEmail', () => { let wrapper; const defaultProps = { + incomingEmail: 'incoming+test-1-issue-@example.com', customEmail: 'user@example.com', smtpAddress: 'smtp.example.com', verificationState: 'started', @@ -70,18 +70,21 @@ describe('CustomEmail', () => { }); describe('verification error', () => { - it.each([ - 'smtp_host_issue', - 'invalid_credentials', - 'mail_not_received_within_timeframe', - 'incorrect_from', - 'incorrect_token', - ])('displays %s label and description', (error) => { + it.each` + error | label | description + ${'smtp_host_issue'} | ${'SMTP host issue'} | ${'A connection to the specified host could not be made or an SSL issue occurred.'} + ${'invalid_credentials'} | ${'Invalid credentials'} | ${'The given credentials (username and password) were rejected by the SMTP server, or you need to explicitly set an authentication method.'} + ${'mail_not_received_within_timeframe'} | ${'Verification email not received within timeframe'} | ${"The verification email wasn't received in time. There is a 30 minutes timeframe for verification emails to appear in your instance's Service Desk. Make sure that you have set up email forwarding correctly."} + ${'incorrect_from'} | ${'Incorrect From header'} | ${'Check your forwarding settings and make sure the original email sender remains in the From header.'} + ${'incorrect_token'} | ${'Incorrect verification token'} | ${"The received email didn't contain the verification token that was sent to your email address."} + ${'read_timeout'} | ${'Read timeout'} | ${'The SMTP server did not respond in time.'} + ${'incorrect_forwarding_target'} | ${'Incorrect forwarding target'} | ${`Forward all emails to the custom email address to ${defaultProps.incomingEmail}`} + `('displays $error label and description', ({ error, label, description }) => { createWrapper({ verificationError: error }); const text = wrapper.text(); - expect(text).toContain(I18N_VERIFICATION_ERRORS[error].label); - expect(text).toContain(I18N_VERIFICATION_ERRORS[error].description); + expect(text).toContain(label); + expect(text).toContain(description); }); }); diff --git a/spec/frontend/projects/settings_service_desk/components/custom_email_wrapper_spec.js b/spec/frontend/projects/settings_service_desk/components/custom_email_wrapper_spec.js index 174e05ceeee..8d3a7a5fde5 100644 --- a/spec/frontend/projects/settings_service_desk/components/custom_email_wrapper_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/custom_email_wrapper_spec.js @@ -38,6 +38,12 @@ describe('CustomEmailWrapper', () => { customEmailEndpoint: '/flightjs/Flight/-/service_desk/custom_email', }; + const defaultCustomEmailProps = { + incomingEmail: defaultProps.incomingEmail, + customEmail: 'user@example.com', + smtpAddress: 'smtp.example.com', + }; + const showToast = jest.fn(); const createWrapper = (props = {}) => { @@ -117,8 +123,7 @@ describe('CustomEmailWrapper', () => { expect(showToast).toHaveBeenCalledWith(I18N_TOAST_SAVED); expect(findCustomEmail().props()).toEqual({ - customEmail: 'user@example.com', - smtpAddress: 'smtp.example.com', + ...defaultCustomEmailProps, verificationState: 'started', verificationError: null, isEnabled: false, @@ -140,8 +145,7 @@ describe('CustomEmailWrapper', () => { it('displays CustomEmail component', () => { expect(findCustomEmail().props()).toEqual({ - customEmail: 'user@example.com', - smtpAddress: 'smtp.example.com', + ...defaultCustomEmailProps, verificationState: 'started', verificationError: null, isEnabled: false, @@ -193,8 +197,7 @@ describe('CustomEmailWrapper', () => { it('fetches data from endpoint and displays CustomEmail component', () => { expect(findCustomEmail().props()).toEqual({ - customEmail: 'user@example.com', - smtpAddress: 'smtp.example.com', + ...defaultCustomEmailProps, verificationState: 'failed', verificationError: 'smtp_host_issue', isEnabled: false, @@ -225,8 +228,7 @@ describe('CustomEmailWrapper', () => { it('fetches data from endpoint and displays CustomEmail component', () => { expect(findCustomEmail().props()).toEqual({ - customEmail: 'user@example.com', - smtpAddress: 'smtp.example.com', + ...defaultCustomEmailProps, verificationState: 'finished', verificationError: null, isEnabled: false, @@ -257,8 +259,7 @@ describe('CustomEmailWrapper', () => { expect(showToast).toHaveBeenCalledWith(I18N_TOAST_ENABLED); expect(findCustomEmail().props()).toEqual({ - customEmail: 'user@example.com', - smtpAddress: 'smtp.example.com', + ...defaultCustomEmailProps, verificationState: 'finished', verificationError: null, isEnabled: true, @@ -279,8 +280,7 @@ describe('CustomEmailWrapper', () => { it('fetches data from endpoint and displays CustomEmail component', () => { expect(findCustomEmail().props()).toEqual({ - customEmail: 'user@example.com', - smtpAddress: 'smtp.example.com', + ...defaultCustomEmailProps, verificationState: 'finished', verificationError: null, isEnabled: true, @@ -301,8 +301,7 @@ describe('CustomEmailWrapper', () => { expect(showToast).toHaveBeenCalledWith(I18N_TOAST_DISABLED); expect(findCustomEmail().props()).toEqual({ - customEmail: 'user@example.com', - smtpAddress: 'smtp.example.com', + ...defaultCustomEmailProps, verificationState: 'finished', verificationError: null, isEnabled: false, 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 0eec981b67d..185a85cdb80 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 @@ -22,15 +22,13 @@ describe('ServiceDeskRoot', () => { isIssueTrackerEnabled: true, outgoingName: 'GitLab Support Bot', projectKey: 'key', + reopenIssueOnExternalParticipantNote: true, addExternalParticipantsFromCc: true, selectedTemplate: 'Bug', selectedFileTemplateProjectId: 42, templates: ['Bug', 'Documentation'], publicProject: false, customEmailEndpoint: '/gitlab-org/gitlab-test/-/service_desk/custom_email', - glFeatures: { - serviceDeskCustomEmail: true, - }, }; const getAlertText = () => wrapper.findComponent(GlAlert).text(); @@ -63,6 +61,8 @@ describe('ServiceDeskRoot', () => { incomingEmail: provideData.initialIncomingEmail, initialOutgoingName: provideData.outgoingName, initialProjectKey: provideData.projectKey, + initialReopenIssueOnExternalParticipantNote: + provideData.reopenIssueOnExternalParticipantNote, initialAddExternalParticipantsFromCc: provideData.addExternalParticipantsFromCc, initialSelectedTemplate: provideData.selectedTemplate, initialSelectedFileTemplateProjectId: provideData.selectedFileTemplateProjectId, @@ -87,7 +87,7 @@ describe('ServiceDeskRoot', () => { const alertBodyLink = alertEl.findComponent(GlLink); expect(alertBodyLink.exists()).toBe(true); expect(alertBodyLink.attributes('href')).toBe( - '/help/user/project/service_desk.html#use-an-additional-service-desk-alias-email', + '/help/user/project/service_desk/configure.html#use-an-additional-service-desk-alias-email', ); expect(alertBodyLink.text()).toBe('How do I create a custom email address?'); }); @@ -149,6 +149,7 @@ describe('ServiceDeskRoot', () => { selectedTemplate: 'Bug', outgoingName: 'GitLab Support Bot', projectKey: 'key', + reopenIssueOnExternalParticipantNote: true, addExternalParticipantsFromCc: true, }; @@ -163,6 +164,7 @@ describe('ServiceDeskRoot', () => { outgoing_name: 'GitLab Support Bot', project_key: 'key', service_desk_enabled: true, + reopen_issue_on_external_participant_note: true, add_external_participants_from_cc: true, }); }); @@ -182,6 +184,7 @@ describe('ServiceDeskRoot', () => { selectedTemplate: 'Bug', outgoingName: 'GitLab Support Bot', projectKey: 'key', + reopen_issue_on_external_participant_note: true, addExternalParticipantsFromCc: true, }; @@ -227,15 +230,5 @@ describe('ServiceDeskRoot', () => { expect(wrapper.findComponent(CustomEmailWrapper).exists()).toBe(false); }); }); - - describe('when feature flag service_desk_custom_email is disabled', () => { - beforeEach(() => { - wrapper = createComponent({ glFeatures: { serviceDeskCustomEmail: false } }); - }); - - it('is not rendered', () => { - expect(wrapper.findComponent(CustomEmailWrapper).exists()).toBe(false); - }); - }); }); }); 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 6449f9bb68e..f7bdb2455e9 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,4 +1,4 @@ -import { GlButton, GlDropdown, GlFormCheckbox, GlLoadingIcon, GlToggle, GlAlert } 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'; @@ -19,7 +19,10 @@ describe('ServiceDeskSetting', () => { const findSuffixFormGroup = () => wrapper.findByTestId('suffix-form-group'); const findIssueTrackerInfo = () => wrapper.findComponent(GlAlert); const findIssueHelpLink = () => wrapper.findByTestId('issue-help-page'); - const findAddExternalParticipantsFromCcCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findReopenIssueOnExternalParticipantNoteCheckbox = () => + wrapper.findByTestId('reopen-issue-on-external-participant-note'); + const findAddExternalParticipantsFromCcCheckbox = () => + wrapper.findByTestId('add-external-participants-from-cc'); const createComponent = ({ props = {}, provide = {} } = {}) => extendedWrapper( @@ -212,6 +215,27 @@ describe('ServiceDeskSetting', () => { }); }); + describe('reopen issue on external participant note checkbox', () => { + it('is rendered', () => { + wrapper = createComponent(); + expect(findReopenIssueOnExternalParticipantNoteCheckbox().exists()).toBe(true); + }); + + it('forwards false as initial value to the checkbox', () => { + wrapper = createComponent({ props: { initialReopenIssueOnExternalParticipantNote: false } }); + expect(findReopenIssueOnExternalParticipantNoteCheckbox().find('input').element.checked).toBe( + false, + ); + }); + + it('forwards true as initial value to the checkbox', () => { + wrapper = createComponent({ props: { initialReopenIssueOnExternalParticipantNote: true } }); + expect(findReopenIssueOnExternalParticipantNoteCheckbox().find('input').element.checked).toBe( + true, + ); + }); + }); + describe('add external participants from cc checkbox', () => { it('is rendered', () => { wrapper = createComponent(); @@ -249,7 +273,8 @@ describe('ServiceDeskSetting', () => { initialSelectedFileTemplateProjectId: 42, initialOutgoingName: 'GitLab Support Bot', initialProjectKey: 'key', - initialAddExternalParticipantsFromCc: false, + initialReopenIssueOnExternalParticipantNote: true, + initialAddExternalParticipantsFromCc: true, }, }); @@ -262,7 +287,8 @@ describe('ServiceDeskSetting', () => { fileTemplateProjectId: 42, outgoingName: 'GitLab Support Bot', projectKey: 'key', - addExternalParticipantsFromCc: false, + reopenIssueOnExternalParticipantNote: true, + addExternalParticipantsFromCc: true, }; expect(wrapper.emitted('save')[0]).toEqual([payload]); @@ -288,6 +314,10 @@ describe('ServiceDeskSetting', () => { expect(findButton().exists()).toBe(false); }); + it('does not render reopen issue on external participant note checkbox', () => { + expect(findReopenIssueOnExternalParticipantNoteCheckbox().exists()).toBe(false); + }); + it('does not render add external participants from cc checkbox', () => { expect(findAddExternalParticipantsFromCcCheckbox().exists()).toBe(false); }); diff --git a/spec/frontend/read_more_spec.js b/spec/frontend/read_more_spec.js index 5f7bd32e231..9b25c56f193 100644 --- a/spec/frontend/read_more_spec.js +++ b/spec/frontend/read_more_spec.js @@ -1,4 +1,3 @@ -import htmlProjectsOverview from 'test_fixtures/projects/overview.html'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import initReadMore from '~/read_more'; @@ -11,7 +10,12 @@ describe('Read more click-to-expand functionality', () => { describe('expands target element', () => { beforeEach(() => { - setHTMLFixture(htmlProjectsOverview); + setHTMLFixture(` + <p class="read-more-container">Target</p> + <button type="button" class="js-read-more-trigger"> + <span>Button text</span> + </button> + `); }); it('adds "is-expanded" class to target element', () => { diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js index 26010a1cfa6..39924a3a77a 100644 --- a/spec/frontend/ref/components/ref_selector_spec.js +++ b/spec/frontend/ref/components/ref_selector_spec.js @@ -46,7 +46,7 @@ describe('Ref selector component', () => { let commitApiCallSpy; let requestSpies; - const createComponent = (mountOverrides = {}, propsData = {}) => { + const createComponent = ({ overrides = {}, propsData = {} } = {}) => { wrapper = mountExtended( RefSelector, merge( @@ -64,7 +64,7 @@ describe('Ref selector component', () => { }, store: createStore(), }, - mountOverrides, + overrides, ), ); }; @@ -211,7 +211,7 @@ describe('Ref selector component', () => { const id = 'git-ref'; beforeEach(() => { - createComponent({ attrs: { id } }); + createComponent({ overrides: { attrs: { id } } }); return waitForRequests(); }); @@ -326,7 +326,7 @@ describe('Ref selector component', () => { describe('branches', () => { describe('when the branches search returns results', () => { beforeEach(() => { - createComponent({}, { useSymbolicRefNames: true }); + createComponent({ propsData: { useSymbolicRefNames: true } }); return waitForRequests(); }); @@ -389,7 +389,7 @@ describe('Ref selector component', () => { describe('tags', () => { describe('when the tags search returns results', () => { beforeEach(() => { - createComponent({}, { useSymbolicRefNames: true }); + createComponent({ propsData: { useSymbolicRefNames: true } }); return waitForRequests(); }); @@ -569,6 +569,20 @@ describe('Ref selector component', () => { }); }); }); + + describe('disabled', () => { + it('does not disable the dropdown', () => { + createComponent(); + expect(findListbox().props('disabled')).toBe(false); + }); + + it('disables the dropdown', async () => { + createComponent({ propsData: { disabled: true } }); + expect(findListbox().props('disabled')).toBe(true); + await selectFirstBranch(); + expect(wrapper.emitted('input')).toBeUndefined(); + }); + }); }); describe('with non-default ref types', () => { @@ -691,9 +705,7 @@ describe('Ref selector component', () => { }); beforeEach(() => { - createComponent({ - scopedSlots: { footer: createFooter }, - }); + createComponent({ overrides: { scopedSlots: { footer: createFooter } } }); updateQuery('abcd1234'); diff --git a/spec/frontend/ref/stores/actions_spec.js b/spec/frontend/ref/stores/actions_spec.js index c6aac8c9c98..49e0b36259c 100644 --- a/spec/frontend/ref/stores/actions_spec.js +++ b/spec/frontend/ref/stores/actions_spec.js @@ -28,7 +28,7 @@ describe('Ref selector Vuex store actions', () => { describe('setEnabledRefTypes', () => { it(`commits ${types.SET_ENABLED_REF_TYPES} with the enabled ref types`, () => { - testAction(actions.setProjectId, ALL_REF_TYPES, state, [ + return testAction(actions.setProjectId, ALL_REF_TYPES, state, [ { type: types.SET_PROJECT_ID, payload: ALL_REF_TYPES }, ]); }); @@ -37,7 +37,7 @@ describe('Ref selector Vuex store actions', () => { describe('setProjectId', () => { it(`commits ${types.SET_PROJECT_ID} with the new project ID`, () => { const projectId = '4'; - testAction(actions.setProjectId, projectId, state, [ + return testAction(actions.setProjectId, projectId, state, [ { type: types.SET_PROJECT_ID, payload: projectId }, ]); }); @@ -46,7 +46,7 @@ describe('Ref selector Vuex store actions', () => { describe('setSelectedRef', () => { it(`commits ${types.SET_SELECTED_REF} with the new selected ref name`, () => { const selectedRef = 'v1.2.3'; - testAction(actions.setSelectedRef, selectedRef, state, [ + return testAction(actions.setSelectedRef, selectedRef, state, [ { type: types.SET_SELECTED_REF, payload: selectedRef }, ]); }); @@ -55,14 +55,16 @@ describe('Ref selector Vuex store actions', () => { describe('setParams', () => { it(`commits ${types.SET_PARAMS} with the provided params`, () => { const params = { sort: 'updated_asc' }; - testAction(actions.setParams, params, state, [{ type: types.SET_PARAMS, payload: params }]); + return testAction(actions.setParams, params, state, [ + { type: types.SET_PARAMS, payload: params }, + ]); }); }); describe('search', () => { it(`commits ${types.SET_QUERY} with the new search query`, () => { const query = 'hello'; - testAction(actions.search, query, state, [{ type: types.SET_QUERY, payload: query }]); + return testAction(actions.search, query, state, [{ type: types.SET_QUERY, payload: query }]); }); it.each` @@ -73,7 +75,7 @@ describe('Ref selector Vuex store actions', () => { `(`dispatches fetch actions for enabled ref types`, ({ enabledRefTypes, expectedActions }) => { const query = 'hello'; state.enabledRefTypes = enabledRefTypes; - testAction( + return testAction( actions.search, query, state, diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js index d18437ccec3..a55b6cdef92 100644 --- a/spec/frontend/releases/stores/modules/detail/actions_spec.js +++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js @@ -93,7 +93,7 @@ describe('Release edit/new actions', () => { describe('loadDraftRelease', () => { it(`with no saved release, it commits ${types.INITIALIZE_EMPTY_RELEASE}`, () => { - testAction({ + return testAction({ action: actions.loadDraftRelease, state, expectedMutations: [{ type: types.INITIALIZE_EMPTY_RELEASE }], @@ -203,7 +203,7 @@ describe('Release edit/new actions', () => { describe('saveRelease', () => { it(`commits ${types.REQUEST_SAVE_RELEASE} and then dispatched "createRelease"`, () => { - testAction({ + return testAction({ action: actions.saveRelease, state, expectedMutations: [{ type: types.REQUEST_SAVE_RELEASE }], @@ -218,7 +218,7 @@ describe('Release edit/new actions', () => { describe('initializeRelease', () => { it('dispatches "fetchRelease"', () => { - testAction({ + return testAction({ action: actions.initializeRelease, state, expectedActions: [{ type: 'fetchRelease' }], @@ -228,7 +228,7 @@ describe('Release edit/new actions', () => { describe('saveRelease', () => { it(`commits ${types.REQUEST_SAVE_RELEASE} and then dispatched "updateRelease"`, () => { - testAction({ + return testAction({ action: actions.saveRelease, state, expectedMutations: [{ type: types.REQUEST_SAVE_RELEASE }], diff --git a/spec/frontend/repository/commits_service_spec.js b/spec/frontend/repository/commits_service_spec.js index 5fb683bd370..d779abcbfd6 100644 --- a/spec/frontend/repository/commits_service_spec.js +++ b/spec/frontend/repository/commits_service_spec.js @@ -14,7 +14,7 @@ describe('commits service', () => { beforeEach(() => { mock = new MockAdapter(axios); - + window.gon.features = { encodingLogsTree: true }; mock.onGet(url).reply(HTTP_STATUS_OK, [], {}); jest.spyOn(axios, 'get'); @@ -48,14 +48,27 @@ describe('commits service', () => { }); it('encodes the path and ref', async () => { - const encodedRef = encodeURIComponent(refWithSpecialCharMock); - const encodedUrl = `/some-project/-/refs/${encodedRef}/logs_tree/with%20%24peci%40l%20ch%40rs%2F`; + const encodedRef = encodeURI(refWithSpecialCharMock); + const encodedUrl = `/some-project/-/refs/${encodedRef}/logs_tree/with%20$peci@l%20ch@rs/`; await requestCommits(1, 'some-project', 'with $peci@l ch@rs/', refWithSpecialCharMock); expect(axios.get).toHaveBeenCalledWith(encodedUrl, expect.anything()); }); + describe('when encodingLogsTree FF is off', () => { + beforeEach(() => { + window.gon.features = {}; + }); + + it('encodes the path and ref with encodeURIComponent', async () => { + const encodedRef = encodeURIComponent(refWithSpecialCharMock); + const encodedUrl = `/some-project/-/refs/${encodedRef}/logs_tree/with%20%24peci%40l%20ch%40rs%2F`; + await requestCommits(1, 'some-project', 'with $peci@l ch@rs/', refWithSpecialCharMock); + expect(axios.get).toHaveBeenCalledWith(encodedUrl, expect.anything()); + }); + }); + it('calls axios get once per batch', async () => { await Promise.all([requestCommits(0), requestCommits(1), requestCommits(23)]); diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index e0d2984893b..cd5bc08faf0 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -75,6 +75,7 @@ const createComponent = async (mockData = {}, mountFn = shallowMount, mockRoute createMergeRequestIn = userPermissionsMock.createMergeRequestIn, isBinary, inject = {}, + blobBlameInfo = true, } = mockData; const blobInfo = { @@ -138,7 +139,7 @@ const createComponent = async (mockData = {}, mountFn = shallowMount, mockRoute ...inject, glFeatures: { highlightJsWorker: false, - blobBlameInfo: true, + blobBlameInfo, }, }, }), @@ -185,7 +186,7 @@ describe('Blob content viewer component', () => { expect(findBlobHeader().props('hideViewerSwitcher')).toEqual(false); expect(findBlobHeader().props('blob')).toEqual(simpleViewerMock); expect(findBlobHeader().props('showForkSuggestion')).toEqual(false); - expect(findBlobHeader().props('showBlameToggle')).toEqual(false); + expect(findBlobHeader().props('showBlameToggle')).toEqual(true); expect(findBlobHeader().props('projectPath')).toEqual(propsMock.projectPath); expect(findBlobHeader().props('projectId')).toEqual(projectMock.id); expect(mockRouterPush).not.toHaveBeenCalled(); @@ -197,15 +198,15 @@ describe('Blob content viewer component', () => { await nextTick(); }; - it('renders a blame toggle for JSON files', async () => { - await createComponent({ blob: { ...simpleViewerMock, language: 'json' } }); + it('renders a blame toggle', async () => { + await createComponent({ blob: simpleViewerMock }); expect(findBlobHeader().props('showBlameToggle')).toEqual(true); }); it('adds blame param to the URL and passes `showBlame` to the SourceViewer', async () => { loadViewer.mockReturnValueOnce(SourceViewerNew); - await createComponent({ blob: { ...simpleViewerMock, language: 'json' } }); + await createComponent({ blob: simpleViewerMock }); await triggerBlame(); @@ -217,6 +218,25 @@ describe('Blob content viewer component', () => { expect(mockRouterPush).toHaveBeenCalledWith({ query: { blame: '0' } }); expect(findSourceViewerNew().props('showBlame')).toBe(false); }); + + describe('blobBlameInfo feature flag disabled', () => { + it('does not render a blame toggle', async () => { + await createComponent({ blob: simpleViewerMock, blobBlameInfo: false }); + + expect(findBlobHeader().props('showBlameToggle')).toEqual(false); + }); + }); + + describe('when viewing rich content', () => { + it('always shows the blame when clicking on the blame button', async () => { + loadViewer.mockReturnValueOnce(SourceViewerNew); + const query = { plain: '0', blame: '1' }; + await createComponent({ blob: simpleViewerMock }, shallowMount, { query }); + await triggerBlame(); + + expect(findSourceViewerNew().props('showBlame')).toBe(true); + }); + }); }); it('creates an alert when the BlobHeader component emits an error', async () => { @@ -260,6 +280,7 @@ describe('Blob content viewer component', () => { expect(mockAxios.history.get).toHaveLength(1); expect(mockAxios.history.get[0].url).toBe(legacyViewerUrl); + expect(findBlobHeader().props('showBlameToggle')).toEqual(false); }); it('loads a legacy viewer when a viewer component is not available', async () => { diff --git a/spec/frontend/repository/components/blob_controls_spec.js b/spec/frontend/repository/components/blob_controls_spec.js index 3ced5f6c4d2..53ebabebf1d 100644 --- a/spec/frontend/repository/components/blob_controls_spec.js +++ b/spec/frontend/repository/components/blob_controls_spec.js @@ -8,6 +8,7 @@ import blobControlsQuery from '~/repository/queries/blob_controls.query.graphql' import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createRouter from '~/repository/router'; import { updateElementsVisibility } from '~/repository/utils/dom'; +import { resetShortcutsForTests } from '~/behaviors/shortcuts'; import ShortcutsBlob from '~/behaviors/shortcuts/shortcuts_blob'; import BlobLinePermalinkUpdater from '~/blob/blob_line_permalink_updater'; import { blobControlsDataMock, refMock } from '../mock_data'; @@ -32,6 +33,8 @@ const createComponent = async () => { mockResolver = jest.fn().mockResolvedValue({ data: { project } }); + await resetShortcutsForTests(); + wrapper = shallowMountExtended(BlobControls, { router, apolloProvider: createMockApollo([[blobControlsQuery, mockResolver]]), diff --git a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap index e14f41e2ed2..378aacd47fa 100644 --- a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap +++ b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap @@ -47,7 +47,7 @@ exports[`Repository table row component renders a symlink table row 1`] = ` <gl-intersection-observer-stub> <timeago-tooltip-stub cssclass="" - datetimeformat="DATE_WITH_TIME_FORMAT" + datetimeformat="asDateTime" time="2019-01-01" tooltipplacement="top" /> @@ -103,7 +103,7 @@ exports[`Repository table row component renders table row 1`] = ` <gl-intersection-observer-stub> <timeago-tooltip-stub cssclass="" - datetimeformat="DATE_WITH_TIME_FORMAT" + datetimeformat="asDateTime" time="2019-01-01" tooltipplacement="top" /> @@ -159,7 +159,7 @@ exports[`Repository table row component renders table row for path with special <gl-intersection-observer-stub> <timeago-tooltip-stub cssclass="" - datetimeformat="DATE_WITH_TIME_FORMAT" + datetimeformat="asDateTime" time="2019-01-01" tooltipplacement="top" /> diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js index c0eb65b28fe..311e5ca86f8 100644 --- a/spec/frontend/repository/components/tree_content_spec.js +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -162,26 +162,19 @@ describe('Repository table component', () => { describe('commit data', () => { const path = ''; - it('loads commit data for both top and bottom batches when row-appear event is emitted', () => { - const rowNumber = 50; - + it('loads commit data for the nearest page', () => { createComponent({ path }); - findFileTable().vm.$emit('row-appear', rowNumber); + findFileTable().vm.$emit('row-appear', 49); + findFileTable().vm.$emit('row-appear', 15); - expect(isRequested).toHaveBeenCalledWith(rowNumber); + expect(isRequested).toHaveBeenCalledWith(49); + expect(isRequested).toHaveBeenCalledWith(15); expect(loadCommits.mock.calls).toEqual([ - ['', path, '', rowNumber, 'heads'], - ['', path, '', rowNumber - 25, 'heads'], + ['', path, '', 25, 'heads'], + ['', path, '', 0, 'heads'], ]); }); - - it('loads commit data once if rowNumber is zero', () => { - createComponent({ path }); - findFileTable().vm.$emit('row-appear', 0); - - expect(loadCommits.mock.calls).toEqual([['', path, '', 0, 'heads']]); - }); }); describe('error handling', () => { diff --git a/spec/frontend/repository/mixins/highlight_mixin_spec.js b/spec/frontend/repository/mixins/highlight_mixin_spec.js index 50cfd71d686..c635c09d1aa 100644 --- a/spec/frontend/repository/mixins/highlight_mixin_spec.js +++ b/spec/frontend/repository/mixins/highlight_mixin_spec.js @@ -1,9 +1,18 @@ import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; import { splitIntoChunks } from '~/vue_shared/components/source_viewer/workers/highlight_utils'; import highlightMixin from '~/repository/mixins/highlight_mixin'; import LineHighlighter from '~/blob/line_highlighter'; +import waitForPromises from 'helpers/wait_for_promises'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { TEXT_FILE_TYPE } from '~/repository/constants'; -import { LINES_PER_CHUNK } from '~/vue_shared/components/source_viewer/constants'; +import { + LINES_PER_CHUNK, + EVENT_ACTION, + EVENT_LABEL_FALLBACK, +} from '~/vue_shared/components/source_viewer/constants'; +import Tracking from '~/tracking'; const lineHighlighter = new LineHighlighter(); jest.mock('~/blob/line_highlighter', () => jest.fn().mockReturnValue({ highlightHash: jest.fn() })); @@ -11,6 +20,7 @@ jest.mock('~/vue_shared/components/source_viewer/workers/highlight_utils', () => splitIntoChunks: jest.fn().mockResolvedValue([]), })); +const mockAxios = new MockAdapter(axios); const workerMock = { postMessage: jest.fn() }; const onErrorMock = jest.fn(); @@ -21,7 +31,10 @@ describe('HighlightMixin', () => { const rawTextBlob = contentArray.join('\n'); const languageMock = 'json'; - const createComponent = ({ fileType = TEXT_FILE_TYPE, language = languageMock } = {}) => { + const createComponent = ( + { fileType = TEXT_FILE_TYPE, language = languageMock, externalStorageUrl, rawPath } = {}, + isUsingLfs = false, + ) => { const simpleViewer = { fileType }; const dummyComponent = { @@ -32,7 +45,10 @@ describe('HighlightMixin', () => { }, template: '<div>{{chunks[0]?.highlightedContent}}</div>', created() { - this.initHighlightWorker({ rawTextBlob, simpleViewer, language, fileType }); + this.initHighlightWorker( + { rawTextBlob, simpleViewer, language, fileType, externalStorageUrl, rawPath }, + isUsingLfs, + ); }, methods: { onError: onErrorMock }, }; @@ -45,13 +61,6 @@ describe('HighlightMixin', () => { describe('initHighlightWorker', () => { const firstSeventyLines = contentArray.slice(0, LINES_PER_CHUNK).join('\n'); - it('does not instruct worker if file is not a JSON file', () => { - workerMock.postMessage.mockClear(); - createComponent({ language: 'javascript' }); - - expect(workerMock.postMessage).not.toHaveBeenCalled(); - }); - it('generates a chunk for the first 70 lines of raw text', () => { expect(splitIntoChunks).toHaveBeenCalledWith(languageMock, firstSeventyLines); }); @@ -74,6 +83,23 @@ describe('HighlightMixin', () => { }); }); + describe('auto-detects if a language cannot be loaded', () => { + const unknownLanguage = 'some_unknown_language'; + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + createComponent({ language: unknownLanguage }); + }); + + it('emits a tracking event for the fallback', () => { + const eventData = { label: EVENT_LABEL_FALLBACK, property: unknownLanguage }; + expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); + }); + + it('calls the onError method', () => { + expect(onErrorMock).toHaveBeenCalled(); + }); + }); + describe('worker message handling', () => { const CHUNK_MOCK = { startingFrom: 0, totalLines: 70, highlightedContent: 'some content' }; @@ -87,4 +113,32 @@ describe('HighlightMixin', () => { expect(lineHighlighter.highlightHash).toHaveBeenCalledWith(hash); }); }); + + describe('LFS blobs', () => { + const rawPath = '/org/project/-/raw/file.xml'; + const externalStorageUrl = 'http://127.0.0.1:9000/lfs-objects/91/12/1341234'; + const mockParams = { content: rawTextBlob, language: languageMock, fileType: TEXT_FILE_TYPE }; + + afterEach(() => mockAxios.reset()); + + it('Uses externalStorageUrl to fetch content if present', async () => { + mockAxios.onGet(externalStorageUrl).replyOnce(HTTP_STATUS_OK, rawTextBlob); + createComponent({ rawPath, externalStorageUrl }, true); + await waitForPromises(); + + expect(mockAxios.history.get).toHaveLength(1); + expect(mockAxios.history.get[0].url).toBe(externalStorageUrl); + expect(workerMock.postMessage).toHaveBeenCalledWith(mockParams); + }); + + it('Falls back to rawPath to fetch content', async () => { + mockAxios.onGet(rawPath).replyOnce(HTTP_STATUS_OK, rawTextBlob); + createComponent({ rawPath }, true); + await waitForPromises(); + + expect(mockAxios.history.get).toHaveLength(1); + expect(mockAxios.history.get[0].url).toBe(rawPath); + expect(workerMock.postMessage).toHaveBeenCalledWith(mockParams); + }); + }); }); diff --git a/spec/frontend/search/sidebar/components/all_scopes_start_filters_spec.js b/spec/frontend/search/sidebar/components/all_scopes_start_filters_spec.js new file mode 100644 index 00000000000..cd43214ed38 --- /dev/null +++ b/spec/frontend/search/sidebar/components/all_scopes_start_filters_spec.js @@ -0,0 +1,28 @@ +import { shallowMount } from '@vue/test-utils'; +import GroupFilter from '~/search/sidebar/components/group_filter.vue'; +import ProjectFilter from '~/search/sidebar/components/project_filter.vue'; +import AllScopesStartFilters from '~/search/sidebar/components/all_scopes_start_filters.vue'; + +describe('GlobalSearch AllScopesStartFilters', () => { + let wrapper; + + const findGroupFilter = () => wrapper.findComponent(GroupFilter); + const findProjectFilter = () => wrapper.findComponent(ProjectFilter); + + const createComponent = () => { + wrapper = shallowMount(AllScopesStartFilters); + }; + + describe('Renders correctly', () => { + beforeEach(() => { + createComponent(); + }); + it('renders ArchivedFilter', () => { + expect(findGroupFilter().exists()).toBe(true); + }); + + it('renders FiltersTemplate', () => { + expect(findProjectFilter().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/search/sidebar/components/app_spec.js b/spec/frontend/search/sidebar/components/app_spec.js index c2d88493d71..3ff6bbf7666 100644 --- a/spec/frontend/search/sidebar/components/app_spec.js +++ b/spec/frontend/search/sidebar/components/app_spec.js @@ -18,10 +18,9 @@ import NotesFilters from '~/search/sidebar/components/notes_filters.vue'; import CommitsFilters from '~/search/sidebar/components/commits_filters.vue'; import MilestonesFilters from '~/search/sidebar/components/milestones_filters.vue'; import WikiBlobsFilters from '~/search/sidebar/components/wiki_blobs_filters.vue'; -import ScopeLegacyNavigation from '~/search/sidebar/components/scope_legacy_navigation.vue'; -import SmallScreenDrawerNavigation from '~/search/sidebar/components/small_screen_drawer_navigation.vue'; import ScopeSidebarNavigation from '~/search/sidebar/components/scope_sidebar_navigation.vue'; import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; +import AllScopesStartFilters from '~/search/sidebar/components/all_scopes_start_filters.vue'; jest.mock('~/super_sidebar/super_sidebar_collapsed_state_manager'); @@ -45,11 +44,6 @@ describe('GlobalSearchSidebar', () => { wrapper = shallowMount(GlobalSearchSidebar, { store, - provide: { - glFeatures: { - searchProjectWikisHideArchivedProjects: true, - }, - }, }); }; @@ -62,10 +56,9 @@ describe('GlobalSearchSidebar', () => { const findCommitsFilters = () => wrapper.findComponent(CommitsFilters); const findMilestonesFilters = () => wrapper.findComponent(MilestonesFilters); const findWikiBlobsFilters = () => wrapper.findComponent(WikiBlobsFilters); - const findScopeLegacyNavigation = () => wrapper.findComponent(ScopeLegacyNavigation); - const findSmallScreenDrawerNavigation = () => wrapper.findComponent(SmallScreenDrawerNavigation); const findScopeSidebarNavigation = () => wrapper.findComponent(ScopeSidebarNavigation); const findDomElementListener = () => wrapper.findComponent(DomElementListener); + const findAllScopesStartFilters = () => wrapper.findComponent(AllScopesStartFilters); describe('renders properly', () => { describe('always', () => { @@ -79,31 +72,50 @@ describe('GlobalSearchSidebar', () => { }); describe.each` - scope | filter | searchType | isShown - ${'issues'} | ${findIssuesFilters} | ${SEARCH_TYPE_BASIC} | ${true} - ${'merge_requests'} | ${findMergeRequestsFilters} | ${SEARCH_TYPE_BASIC} | ${true} - ${'projects'} | ${findProjectsFilters} | ${SEARCH_TYPE_BASIC} | ${true} - ${'blobs'} | ${findBlobsFilters} | ${SEARCH_TYPE_BASIC} | ${false} - ${'blobs'} | ${findBlobsFilters} | ${SEARCH_TYPE_ADVANCED} | ${true} - ${'blobs'} | ${findBlobsFilters} | ${SEARCH_TYPE_ZOEKT} | ${false} - ${'notes'} | ${findNotesFilters} | ${SEARCH_TYPE_BASIC} | ${true} - ${'notes'} | ${findNotesFilters} | ${SEARCH_TYPE_ADVANCED} | ${true} - ${'commits'} | ${findCommitsFilters} | ${SEARCH_TYPE_BASIC} | ${true} - ${'commits'} | ${findCommitsFilters} | ${SEARCH_TYPE_ADVANCED} | ${true} - ${'milestones'} | ${findMilestonesFilters} | ${SEARCH_TYPE_BASIC} | ${true} - ${'milestones'} | ${findMilestonesFilters} | ${SEARCH_TYPE_ADVANCED} | ${true} - ${'wiki_blobs'} | ${findWikiBlobsFilters} | ${SEARCH_TYPE_BASIC} | ${true} - ${'wiki_blobs'} | ${findWikiBlobsFilters} | ${SEARCH_TYPE_ADVANCED} | ${true} - `('with sidebar $scope scope:', ({ scope, filter, searchType, isShown }) => { + scope | filter + ${'issues'} | ${findIssuesFilters} + ${'issues'} | ${findAllScopesStartFilters} + ${'merge_requests'} | ${findMergeRequestsFilters} + ${'merge_requests'} | ${findAllScopesStartFilters} + ${'projects'} | ${findProjectsFilters} + ${'projects'} | ${findAllScopesStartFilters} + ${'blobs'} | ${findAllScopesStartFilters} + ${'notes'} | ${findNotesFilters} + ${'notes'} | ${findAllScopesStartFilters} + ${'commits'} | ${findCommitsFilters} + ${'commits'} | ${findAllScopesStartFilters} + ${'milestones'} | ${findMilestonesFilters} + ${'milestones'} | ${findAllScopesStartFilters} + ${'wiki_blobs'} | ${findWikiBlobsFilters} + ${'wiki_blobs'} | ${findAllScopesStartFilters} + `('with sidebar scope: $scope', ({ scope, filter }) => { + describe.each([SEARCH_TYPE_BASIC, SEARCH_TYPE_ADVANCED])( + 'with search_type %s', + (searchType) => { + beforeEach(() => { + getterSpies.currentScope = jest.fn(() => scope); + createComponent({ urlQuery: { scope }, searchType }); + }); + + it(`renders correctly ${filter.name.replace('find', '')}`, () => { + expect(filter().exists()).toBe(true); + }); + }, + ); + }); + + describe.each` + scope | filter | searchType | isShown + ${'blobs'} | ${findBlobsFilters} | ${SEARCH_TYPE_BASIC} | ${false} + ${'blobs'} | ${findBlobsFilters} | ${SEARCH_TYPE_ADVANCED} | ${true} + ${'blobs'} | ${findBlobsFilters} | ${SEARCH_TYPE_ZOEKT} | ${false} + `('sidebar blobs scope:', ({ scope, filter, searchType, isShown }) => { beforeEach(() => { getterSpies.currentScope = jest.fn(() => scope); createComponent({ urlQuery: { scope }, searchType }); }); - it(`renders correctly filter ${filter.name.replace( - 'find', - '', - )} when search_type ${searchType}`, () => { + it(`renders correctly filter BlobsFilters when search_type ${searchType}`, () => { expect(filter().exists()).toBe(isShown); }); }); @@ -129,46 +141,27 @@ describe('GlobalSearchSidebar', () => { }); }); - describe.each` - currentScope | sidebarNavShown | legacyNavShown - ${'issues'} | ${false} | ${true} - ${'test'} | ${false} | ${true} - ${'issues'} | ${true} | ${false} - ${'test'} | ${true} | ${false} - `( - 'renders navigation for scope $currentScope', - ({ currentScope, sidebarNavShown, legacyNavShown }) => { - beforeEach(() => { - getterSpies.currentScope = jest.fn(() => currentScope); - createComponent({ useSidebarNavigation: sidebarNavShown }); - }); - - it(`renders navigation correctly with legacyNavShown ${legacyNavShown}`, () => { - expect(findScopeLegacyNavigation().exists()).toBe(legacyNavShown); - expect(findSmallScreenDrawerNavigation().exists()).toBe(legacyNavShown); - }); - - it(`renders navigation correctly with sidebarNavShown ${sidebarNavShown}`, () => { - expect(findScopeSidebarNavigation().exists()).toBe(sidebarNavShown); - }); - }, - ); - }); + describe.each(['issues', 'test'])('for scope %p', (currentScope) => { + beforeEach(() => { + getterSpies.currentScope = jest.fn(() => currentScope); + createComponent(); + }); - describe('when useSidebarNavigation=true', () => { - beforeEach(() => { - createComponent({ useSidebarNavigation: true }); + it(`renders navigation correctly`, () => { + expect(findScopeSidebarNavigation().exists()).toBe(true); + }); }); + }); - it('toggles super sidebar when button is clicked', () => { - const elListener = findDomElementListener(); + it('toggles super sidebar when button is clicked', () => { + createComponent(); + const elListener = findDomElementListener(); - expect(toggleSuperSidebarCollapsed).not.toHaveBeenCalled(); + expect(toggleSuperSidebarCollapsed).not.toHaveBeenCalled(); - elListener.vm.$emit('click'); + elListener.vm.$emit('click'); - expect(toggleSuperSidebarCollapsed).toHaveBeenCalledTimes(1); - expect(elListener.props('selector')).toBe('#js-open-mobile-filters'); - }); + expect(toggleSuperSidebarCollapsed).toHaveBeenCalledTimes(1); + expect(elListener.props('selector')).toBe('#js-open-mobile-filters'); }); }); diff --git a/spec/frontend/search/sidebar/components/archived_filter_spec.js b/spec/frontend/search/sidebar/components/archived_filter_spec.js index 9ed677ca297..9e8ababa5da 100644 --- a/spec/frontend/search/sidebar/components/archived_filter_spec.js +++ b/spec/frontend/search/sidebar/components/archived_filter_spec.js @@ -33,7 +33,7 @@ describe('ArchivedFilter', () => { const findCheckboxFilter = () => wrapper.findComponent(GlFormCheckboxGroup); const findCheckboxFilterLabel = () => wrapper.findByTestId('label'); - const findH5 = () => wrapper.findComponent('h5'); + const findTitle = () => wrapper.findByTestId('archived-filter-title'); describe('old sidebar', () => { beforeEach(() => { @@ -45,8 +45,8 @@ describe('ArchivedFilter', () => { }); it('renders the divider', () => { - expect(findH5().exists()).toBe(true); - expect(findH5().text()).toBe(archivedFilterData.headerLabel); + expect(findTitle().exists()).toBe(true); + expect(findTitle().text()).toBe(archivedFilterData.headerLabel); }); it('wraps the label element with a tooltip', () => { @@ -66,8 +66,8 @@ describe('ArchivedFilter', () => { }); it("doesn't render the divider", () => { - expect(findH5().exists()).toBe(true); - expect(findH5().text()).toBe(archivedFilterData.headerLabel); + expect(findTitle().exists()).toBe(true); + expect(findTitle().text()).toBe(archivedFilterData.headerLabel); }); it('wraps the label element with a tooltip', () => { diff --git a/spec/frontend/search/sidebar/components/blobs_filters_spec.js b/spec/frontend/search/sidebar/components/blobs_filters_spec.js index 245ddb8f8bb..3f1feae8527 100644 --- a/spec/frontend/search/sidebar/components/blobs_filters_spec.js +++ b/spec/frontend/search/sidebar/components/blobs_filters_spec.js @@ -17,13 +17,11 @@ describe('GlobalSearch BlobsFilters', () => { currentScope: () => 'blobs', }; - const createComponent = ({ initialState = {} } = {}) => { + const createComponent = () => { const store = new Vuex.Store({ state: { urlQuery: MOCK_QUERY, - useSidebarNavigation: false, searchType: SEARCH_TYPE_ADVANCED, - ...initialState, }, getters: defaultGetters, }); @@ -35,10 +33,9 @@ describe('GlobalSearch BlobsFilters', () => { const findLanguageFilter = () => wrapper.findComponent(LanguageFilter); const findArchivedFilter = () => wrapper.findComponent(ArchivedFilter); - const findDividers = () => wrapper.findAll('hr'); beforeEach(() => { - createComponent({}); + createComponent(); }); it('renders LanguageFilter', () => { @@ -48,31 +45,4 @@ describe('GlobalSearch BlobsFilters', () => { it('renders ArchivedFilter', () => { expect(findArchivedFilter().exists()).toBe(true); }); - - it('renders divider correctly', () => { - expect(findDividers()).toHaveLength(1); - }); - - describe('Renders correctly in new nav', () => { - beforeEach(() => { - createComponent({ - initialState: { - searchType: SEARCH_TYPE_ADVANCED, - useSidebarNavigation: true, - }, - }); - }); - - it('renders correctly LanguageFilter', () => { - expect(findLanguageFilter().exists()).toBe(true); - }); - - it('renders correctly ArchivedFilter', () => { - expect(findArchivedFilter().exists()).toBe(true); - }); - - it("doesn't render dividers", () => { - expect(findDividers()).toHaveLength(0); - }); - }); }); diff --git a/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js b/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js index 6444ec10466..fedbd407b0b 100644 --- a/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js +++ b/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js @@ -22,23 +22,11 @@ describe('ConfidentialityFilter', () => { const findRadioFilter = () => wrapper.findComponent(RadioFilter); - describe('old sidebar', () => { - beforeEach(() => { - createComponent({ useSidebarNavigation: false }); - }); - - it('renders the component', () => { - expect(findRadioFilter().exists()).toBe(true); - }); + beforeEach(() => { + createComponent(); }); - describe('new sidebar', () => { - beforeEach(() => { - createComponent({ useSidebarNavigation: true }); - }); - - it('renders the component', () => { - expect(findRadioFilter().exists()).toBe(true); - }); + it('renders the component', () => { + expect(findRadioFilter().exists()).toBe(true); }); }); diff --git a/spec/frontend/search/sidebar/components/filters_template_spec.js b/spec/frontend/search/sidebar/components/filters_template_spec.js index f1a807c5ceb..18144e25ac3 100644 --- a/spec/frontend/search/sidebar/components/filters_template_spec.js +++ b/spec/frontend/search/sidebar/components/filters_template_spec.js @@ -52,7 +52,6 @@ describe('GlobalSearchSidebarLanguageFilter', () => { }; const findForm = () => wrapper.findComponent(GlForm); - const findDividers = () => wrapper.findAll('hr'); const findApplyButton = () => wrapper.findComponent(GlButton); const findResetButton = () => wrapper.findComponent(GlLink); const findSlotContent = () => wrapper.findByText('Filters Content'); @@ -66,10 +65,6 @@ describe('GlobalSearchSidebarLanguageFilter', () => { expect(findForm().exists()).toBe(true); }); - it('renders dividers', () => { - expect(findDividers()).toHaveLength(2); - }); - it('renders slot content', () => { expect(findSlotContent().exists()).toBe(true); }); diff --git a/spec/frontend/search/topbar/components/group_filter_spec.js b/spec/frontend/search/sidebar/components/group_filter_spec.js index fa8036a7f97..a90a8a38267 100644 --- a/spec/frontend/search/topbar/components/group_filter_spec.js +++ b/spec/frontend/search/sidebar/components/group_filter_spec.js @@ -1,13 +1,14 @@ import { shallowMount } from '@vue/test-utils'; +import { cloneDeep } from 'lodash'; import Vue from 'vue'; // eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import { MOCK_GROUP, MOCK_QUERY, CURRENT_SCOPE } from 'jest/search/mock_data'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { GROUPS_LOCAL_STORAGE_KEY } from '~/search/store/constants'; -import GroupFilter from '~/search/topbar/components/group_filter.vue'; -import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue'; -import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '~/search/topbar/constants'; +import GroupFilter from '~/search/sidebar/components/group_filter.vue'; +import SearchableDropdown from '~/search/sidebar/components/searchable_dropdown.vue'; +import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '~/search/sidebar/constants'; Vue.use(Vuex); @@ -27,6 +28,7 @@ describe('GroupFilter', () => { const defaultProps = { initialData: null, + searchHandler: jest.fn(), }; const createComponent = (initialState, props) => { @@ -68,19 +70,6 @@ describe('GroupFilter', () => { createComponent(); }); - describe('when @search is emitted', () => { - const search = 'test'; - - beforeEach(() => { - findSearchableDropdown().vm.$emit('search', search); - }); - - it('calls fetchGroups with the search paramter', () => { - expect(actionSpies.fetchGroups).toHaveBeenCalledTimes(1); - expect(actionSpies.fetchGroups).toHaveBeenCalledWith(expect.any(Object), search); - }); - }); - describe('when @change is emitted with Any', () => { beforeEach(() => { findSearchableDropdown().vm.$emit('change', ANY_OPTION); @@ -148,11 +137,12 @@ describe('GroupFilter', () => { describe('when initialData is set', () => { beforeEach(() => { - createComponent({}, { initialData: MOCK_GROUP }); + createComponent({ groupInitialJson: { ...MOCK_GROUP } }, {}); }); it('sets selectedGroup to ANY_OPTION', () => { - expect(wrapper.vm.selectedGroup).toBe(MOCK_GROUP); + // cloneDeep to fix Property or method `nodeType` is not defined bug + expect(cloneDeep(wrapper.vm.selectedGroup)).toStrictEqual(MOCK_GROUP); }); }); }); @@ -169,7 +159,13 @@ describe('GroupFilter', () => { initialData ? 'has' : 'does not have' } an initial group`, () => { beforeEach(() => { - createComponent({ query: { ...MOCK_QUERY, nav_source: navSource } }, { initialData }); + createComponent( + { + query: { ...MOCK_QUERY, nav_source: navSource }, + groupInitialJson: { ...initialData }, + }, + {}, + ); }); it(`${callMethod ? 'does' : 'does not'} call setFrequentGroup`, () => { diff --git a/spec/frontend/search/sidebar/components/issues_filters_spec.js b/spec/frontend/search/sidebar/components/issues_filters_spec.js index 860c5c147a6..ce9c6c2bb0c 100644 --- a/spec/frontend/search/sidebar/components/issues_filters_spec.js +++ b/spec/frontend/search/sidebar/components/issues_filters_spec.js @@ -19,11 +19,10 @@ describe('GlobalSearch IssuesFilters', () => { currentScope: () => 'issues', }; - const createComponent = ({ initialState = {}, searchIssueLabelAggregation = true } = {}) => { + const createComponent = ({ initialState = {} } = {}) => { const store = new Vuex.Store({ state: { urlQuery: MOCK_QUERY, - useSidebarNavigation: false, searchType: SEARCH_TYPE_ADVANCED, ...initialState, }, @@ -32,11 +31,6 @@ describe('GlobalSearch IssuesFilters', () => { wrapper = shallowMount(IssuesFilters, { store, - provide: { - glFeatures: { - searchIssueLabelAggregation, - }, - }, }); }; @@ -44,17 +38,10 @@ describe('GlobalSearch IssuesFilters', () => { const findConfidentialityFilter = () => wrapper.findComponent(ConfidentialityFilter); const findLabelFilter = () => wrapper.findComponent(LabelFilter); const findArchivedFilter = () => wrapper.findComponent(ArchivedFilter); - const findDividers = () => wrapper.findAll('hr'); - describe.each` - description | searchIssueLabelAggregation - ${'Renders correctly with Label Filter disabled'} | ${false} - ${'Renders correctly with Label Filter enabled'} | ${true} - `('$description', ({ searchIssueLabelAggregation }) => { + describe('Renders filters correctly with advanced search', () => { beforeEach(() => { - createComponent({ - searchIssueLabelAggregation, - }); + createComponent(); }); it('renders StatusFilter', () => { @@ -69,17 +56,8 @@ describe('GlobalSearch IssuesFilters', () => { expect(findArchivedFilter().exists()).toBe(true); }); - it(`renders correctly LabelFilter when searchIssueLabelAggregation is ${searchIssueLabelAggregation}`, () => { - expect(findLabelFilter().exists()).toBe(searchIssueLabelAggregation); - }); - - it('renders divider correctly', () => { - // two dividers can't be disabled - let dividersCount = 2; - if (searchIssueLabelAggregation) { - dividersCount += 1; - } - expect(findDividers()).toHaveLength(dividersCount); + it('renders correctly LabelFilter', () => { + expect(findLabelFilter().exists()).toBe(true); }); }); @@ -102,41 +80,6 @@ describe('GlobalSearch IssuesFilters', () => { it("doesn't render ArchivedFilter", () => { expect(findArchivedFilter().exists()).toBe(true); }); - - it('renders 1 divider', () => { - expect(findDividers()).toHaveLength(2); - }); - }); - - describe('Renders correctly in new nav', () => { - beforeEach(() => { - createComponent({ - initialState: { - searchType: SEARCH_TYPE_ADVANCED, - useSidebarNavigation: true, - }, - searchIssueLabelAggregation: true, - }); - }); - it('renders StatusFilter', () => { - expect(findStatusFilter().exists()).toBe(true); - }); - - it('renders ConfidentialityFilter', () => { - expect(findConfidentialityFilter().exists()).toBe(true); - }); - - it('renders LabelFilter', () => { - expect(findLabelFilter().exists()).toBe(true); - }); - - it('renders ArchivedFilter', () => { - expect(findArchivedFilter().exists()).toBe(true); - }); - - it("doesn't render dividers", () => { - expect(findDividers()).toHaveLength(0); - }); }); describe('Renders correctly with wrong scope', () => { @@ -159,9 +102,5 @@ describe('GlobalSearch IssuesFilters', () => { it("doesn't render ArchivedFilter", () => { expect(findArchivedFilter().exists()).toBe(false); }); - - it("doesn't render dividers", () => { - expect(findDividers()).toHaveLength(0); - }); }); }); diff --git a/spec/frontend/search/sidebar/components/label_filter_spec.js b/spec/frontend/search/sidebar/components/label_filter_spec.js index 9d2a0c5e739..7641036b9f6 100644 --- a/spec/frontend/search/sidebar/components/label_filter_spec.js +++ b/spec/frontend/search/sidebar/components/label_filter_spec.js @@ -85,11 +85,6 @@ describe('GlobalSearchSidebarLabelFilter', () => { wrapper = mountExtended(LabelFilter, { store, - provide: { - glFeatures: { - searchIssueLabelAggregation: true, - }, - }, }); }; diff --git a/spec/frontend/search/sidebar/components/merge_requests_filters_spec.js b/spec/frontend/search/sidebar/components/merge_requests_filters_spec.js index b02228a418f..8cd3cb45a20 100644 --- a/spec/frontend/search/sidebar/components/merge_requests_filters_spec.js +++ b/spec/frontend/search/sidebar/components/merge_requests_filters_spec.js @@ -21,7 +21,6 @@ describe('GlobalSearch MergeRequestsFilters', () => { const store = new Vuex.Store({ state: { urlQuery: MOCK_QUERY, - useSidebarNavigation: false, searchType: SEARCH_TYPE_ADVANCED, ...initialState, }, @@ -35,7 +34,6 @@ describe('GlobalSearch MergeRequestsFilters', () => { const findStatusFilter = () => wrapper.findComponent(StatusFilter); const findArchivedFilter = () => wrapper.findComponent(ArchivedFilter); - const findDividers = () => wrapper.findAll('hr'); describe('Renders correctly with Archived Filter', () => { beforeEach(() => { @@ -46,8 +44,8 @@ describe('GlobalSearch MergeRequestsFilters', () => { expect(findStatusFilter().exists()).toBe(true); }); - it('renders divider correctly', () => { - expect(findDividers()).toHaveLength(1); + it('renders ArchivedFilter', () => { + expect(findArchivedFilter().exists()).toBe(true); }); }); @@ -60,33 +58,9 @@ describe('GlobalSearch MergeRequestsFilters', () => { expect(findStatusFilter().exists()).toBe(true); }); - it('renders render ArchivedFilter', () => { - expect(findArchivedFilter().exists()).toBe(true); - }); - - it('renders 1 divider', () => { - expect(findDividers()).toHaveLength(1); - }); - }); - - describe('Renders correctly in new nav', () => { - beforeEach(() => { - createComponent({ - searchType: SEARCH_TYPE_ADVANCED, - useSidebarNavigation: true, - }); - }); - it('renders StatusFilter', () => { - expect(findStatusFilter().exists()).toBe(true); - }); - it('renders ArchivedFilter', () => { expect(findArchivedFilter().exists()).toBe(true); }); - - it("doesn't render divider", () => { - expect(findDividers()).toHaveLength(0); - }); }); describe('Renders correctly with wrong scope', () => { @@ -101,9 +75,5 @@ describe('GlobalSearch MergeRequestsFilters', () => { it("doesn't render ArchivedFilter", () => { expect(findArchivedFilter().exists()).toBe(false); }); - - it("doesn't render dividers", () => { - expect(findDividers()).toHaveLength(0); - }); }); }); diff --git a/spec/frontend/search/topbar/components/project_filter_spec.js b/spec/frontend/search/sidebar/components/project_filter_spec.js index e7808370098..817ec77380f 100644 --- a/spec/frontend/search/topbar/components/project_filter_spec.js +++ b/spec/frontend/search/sidebar/components/project_filter_spec.js @@ -1,13 +1,14 @@ import { shallowMount } from '@vue/test-utils'; +import { cloneDeep } from 'lodash'; import Vue from 'vue'; // eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import { MOCK_PROJECT, MOCK_QUERY, CURRENT_SCOPE } from 'jest/search/mock_data'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { PROJECTS_LOCAL_STORAGE_KEY } from '~/search/store/constants'; -import ProjectFilter from '~/search/topbar/components/project_filter.vue'; -import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue'; -import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '~/search/topbar/constants'; +import ProjectFilter from '~/search/sidebar/components/project_filter.vue'; +import SearchableDropdown from '~/search/sidebar/components/searchable_dropdown.vue'; +import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '~/search/sidebar/constants'; Vue.use(Vuex); @@ -27,12 +28,15 @@ describe('ProjectFilter', () => { const defaultProps = { initialData: null, + projectInitialJson: MOCK_PROJECT, + searchHandler: jest.fn(), }; const createComponent = (initialState, props) => { const store = new Vuex.Store({ state: { query: MOCK_QUERY, + projectInitialJson: MOCK_PROJECT, ...initialState, }, actions: actionSpies, @@ -68,18 +72,6 @@ describe('ProjectFilter', () => { createComponent(); }); - describe('when @search is emitted', () => { - const search = 'test'; - - beforeEach(() => { - findSearchableDropdown().vm.$emit('search', search); - }); - - it('calls fetchProjects with the search paramter', () => { - expect(actionSpies.fetchProjects).toHaveBeenCalledWith(expect.any(Object), search); - }); - }); - describe('when @change is emitted', () => { describe('with Any', () => { beforeEach(() => { @@ -139,17 +131,17 @@ describe('ProjectFilter', () => { describe('selectedProject', () => { describe('when initialData is null', () => { beforeEach(() => { - createComponent(); + createComponent({ projectInitialJson: ANY_OPTION }, {}); }); it('sets selectedProject to ANY_OPTION', () => { - expect(wrapper.vm.selectedProject).toBe(ANY_OPTION); + expect(cloneDeep(wrapper.vm.selectedProject)).toStrictEqual(ANY_OPTION); }); }); describe('when initialData is set', () => { beforeEach(() => { - createComponent({}, { initialData: MOCK_PROJECT }); + createComponent({ projectInitialJson: MOCK_PROJECT }, {}); }); it('sets selectedProject to the initialData', () => { @@ -170,7 +162,13 @@ describe('ProjectFilter', () => { initialData ? 'has' : 'does not have' } an initial project`, () => { beforeEach(() => { - createComponent({ query: { ...MOCK_QUERY, nav_source: navSource } }, { initialData }); + createComponent( + { + query: { ...MOCK_QUERY, nav_source: navSource }, + projectInitialJson: { ...initialData }, + }, + {}, + ); }); it(`${callMethod ? 'does' : 'does not'} call setFrequentProject`, () => { diff --git a/spec/frontend/search/sidebar/components/scope_legacy_navigation_spec.js b/spec/frontend/search/sidebar/components/scope_legacy_navigation_spec.js deleted file mode 100644 index 63d8b34fcf0..00000000000 --- a/spec/frontend/search/sidebar/components/scope_legacy_navigation_spec.js +++ /dev/null @@ -1,145 +0,0 @@ -import { GlNav, GlNavItem, GlIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import { MOCK_QUERY, MOCK_NAVIGATION } from 'jest/search/mock_data'; -import ScopeLegacyNavigation from '~/search/sidebar/components/scope_legacy_navigation.vue'; - -Vue.use(Vuex); - -const MOCK_NAVIGATION_ENTRIES = Object.entries(MOCK_NAVIGATION); - -describe('ScopeLegacyNavigation', () => { - let wrapper; - - const actionSpies = { - fetchSidebarCount: jest.fn(), - }; - - const getterSpies = { - currentScope: jest.fn(() => 'issues'), - }; - - const createComponent = (initialState) => { - const store = new Vuex.Store({ - state: { - urlQuery: MOCK_QUERY, - navigation: MOCK_NAVIGATION, - ...initialState, - }, - actions: actionSpies, - getters: getterSpies, - }); - - wrapper = shallowMount(ScopeLegacyNavigation, { - store, - }); - }; - - const findNavElement = () => wrapper.find('nav'); - const findGlNav = () => wrapper.findComponent(GlNav); - const findGlNavItems = () => wrapper.findAllComponents(GlNavItem); - const findGlNavItemActive = () => wrapper.find('[active=true]'); - const findGlNavItemActiveLabel = () => findGlNavItemActive().find('[data-testid="label"]'); - const findGlNavItemActiveCount = () => findGlNavItemActive().find('[data-testid="count"]'); - - describe('scope navigation', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders section', () => { - expect(findNavElement().exists()).toBe(true); - }); - - it('renders nav component', () => { - expect(findGlNav().exists()).toBe(true); - }); - - it('renders all nav item components', () => { - expect(findGlNavItems()).toHaveLength(MOCK_NAVIGATION_ENTRIES.length); - }); - - it('has all proper links', () => { - const linkAtPosition = 3; - const { link } = MOCK_NAVIGATION_ENTRIES[linkAtPosition][1]; - - expect(findGlNavItems().at(linkAtPosition).attributes('href')).toBe(link); - }); - }); - - describe('scope navigation sets proper state with url scope set', () => { - beforeEach(() => { - createComponent(); - }); - - it('has correct active item', () => { - expect(findGlNavItemActive().exists()).toBe(true); - expect(findGlNavItemActiveLabel().text()).toBe('Issues'); - }); - - it('has correct active item count', () => { - expect(findGlNavItemActiveCount().text()).toBe('2.4K'); - }); - - it('does not have plus sign after count text', () => { - expect(findGlNavItemActive().findComponent(GlIcon).exists()).toBe(false); - }); - - it('has count is highlighted correctly', () => { - expect(findGlNavItemActiveCount().classes('gl-text-gray-900')).toBe(true); - }); - }); - - describe('scope navigation sets proper state with NO url scope set', () => { - beforeEach(() => { - getterSpies.currentScope = jest.fn(() => 'projects'); - createComponent({ - urlQuery: {}, - navigation: { - ...MOCK_NAVIGATION, - projects: { - ...MOCK_NAVIGATION.projects, - active: true, - }, - issues: { - ...MOCK_NAVIGATION.issues, - active: false, - }, - }, - }); - }); - - it('has correct active item', () => { - expect(findGlNavItemActive().exists()).toBe(true); - expect(findGlNavItemActiveLabel().text()).toBe('Projects'); - }); - - it('has correct active item count', () => { - expect(findGlNavItemActiveCount().text()).toBe('10K'); - }); - - it('has correct active item count and over limit sign', () => { - expect(findGlNavItemActive().findComponent(GlIcon).exists()).toBe(true); - }); - }); - - describe.each` - searchTherm | hasBeenCalled - ${null} | ${0} - ${'test'} | ${1} - `('fetchSidebarCount', ({ searchTherm, hasBeenCalled }) => { - beforeEach(() => { - createComponent({ - urlQuery: { - search: searchTherm, - }, - }); - }); - - it('is only called when search term is set', () => { - expect(actionSpies.fetchSidebarCount).toHaveBeenCalledTimes(hasBeenCalled); - }); - }); -}); diff --git a/spec/frontend/search/sidebar/components/scope_sidebar_navigation_spec.js b/spec/frontend/search/sidebar/components/scope_sidebar_navigation_spec.js index d85942b9634..44c243d15f7 100644 --- a/spec/frontend/search/sidebar/components/scope_sidebar_navigation_spec.js +++ b/spec/frontend/search/sidebar/components/scope_sidebar_navigation_spec.js @@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; // eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; +import sidebarEventHub from '~/super_sidebar/event_hub'; 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'; @@ -49,6 +50,7 @@ describe('ScopeSidebarNavigation', () => { describe('scope navigation', () => { beforeEach(() => { + jest.spyOn(sidebarEventHub, '$emit'); createComponent({ urlQuery: { ...MOCK_QUERY, search: 'test' } }); }); @@ -71,6 +73,11 @@ describe('ScopeSidebarNavigation', () => { expect(findNavItems().at(linkAtPosition).findComponent('a').attributes('href')).toBe(link); }); + + it('always emits toggle-menu-header event', () => { + expect(sidebarEventHub.$emit).toHaveBeenCalledWith('toggle-menu-header', false); + expect(sidebarEventHub.$emit).toHaveBeenCalledTimes(1); + }); }); describe('scope navigation sets proper state with url scope set', () => { diff --git a/spec/frontend/search/sidebar/components/searchable_dropdown_spec.js b/spec/frontend/search/sidebar/components/searchable_dropdown_spec.js new file mode 100644 index 00000000000..c8f157e4fe4 --- /dev/null +++ b/spec/frontend/search/sidebar/components/searchable_dropdown_spec.js @@ -0,0 +1,117 @@ +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { cloneDeep } from 'lodash'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports +import Vuex from 'vuex'; +import waitForPromises from 'helpers/wait_for_promises'; +import { MOCK_GROUPS, MOCK_QUERY } from 'jest/search/mock_data'; +import SearchableDropdown from '~/search/sidebar/components/searchable_dropdown.vue'; +import { ANY_OPTION, GROUP_DATA } from '~/search/sidebar/constants'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; + +Vue.use(Vuex); + +describe('Global Search Searchable Dropdown', () => { + let wrapper; + + const defaultProps = { + headerText: GROUP_DATA.headerText, + name: GROUP_DATA.name, + fullName: GROUP_DATA.fullName, + loading: false, + selectedItem: ANY_OPTION, + items: [], + frequentItems: [{ ...MOCK_GROUPS[0] }], + searchHandler: jest.fn(), + }; + + const createComponent = (initialState, props) => { + const store = new Vuex.Store({ + state: { + query: MOCK_QUERY, + ...initialState, + }, + }); + + wrapper = shallowMount(SearchableDropdown, { + store, + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + const findGlDropdown = () => wrapper.findComponent(GlCollapsibleListbox); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders GlDropdown', () => { + expect(findGlDropdown().exists()).toBe(true); + }); + + const propItems = [ + { text: '', options: [{ value: ANY_OPTION.name, text: ANY_OPTION.name, ...ANY_OPTION }] }, + { + text: 'Frequently searched', + options: [{ value: MOCK_GROUPS[0].id, text: MOCK_GROUPS[0].full_name, ...MOCK_GROUPS[0] }], + }, + { + text: 'All available groups', + options: [{ value: MOCK_GROUPS[1].id, text: MOCK_GROUPS[1].full_name, ...MOCK_GROUPS[1] }], + }, + ]; + + beforeEach(() => { + createComponent({}, { items: MOCK_GROUPS }); + }); + + it('contains correct set of items', () => { + expect(findGlDropdown().props('items')).toStrictEqual(propItems); + }); + + it('renders searchable prop', () => { + expect(findGlDropdown().props('searchable')).toBe(true); + }); + + describe('events', () => { + it('emits select', () => { + findGlDropdown().vm.$emit('select', 1); + expect(cloneDeep(wrapper.emitted('change')[0][0])).toStrictEqual(MOCK_GROUPS[0]); + }); + + it('emits reset', () => { + findGlDropdown().vm.$emit('reset'); + expect(cloneDeep(wrapper.emitted('change')[0][0])).toStrictEqual(ANY_OPTION); + }); + + it('emits first-open', () => { + findGlDropdown().vm.$emit('shown'); + expect(wrapper.emitted('first-open')).toHaveLength(1); + findGlDropdown().vm.$emit('shown'); + expect(wrapper.emitted('first-open')).toHaveLength(1); + }); + }); + }); + + describe('when @search is emitted', () => { + const search = 'test'; + + beforeEach(async () => { + createComponent(); + findGlDropdown().vm.$emit('search', search); + + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await waitForPromises(); + }); + + it('calls fetchGroups with the search paramter', () => { + expect(defaultProps.searchHandler).toHaveBeenCalledTimes(1); + expect(defaultProps.searchHandler).toHaveBeenCalledWith(search); + }); + }); +}); diff --git a/spec/frontend/search/sidebar/components/small_screen_drawer_navigation_spec.js b/spec/frontend/search/sidebar/components/small_screen_drawer_navigation_spec.js deleted file mode 100644 index 5ab4afba7f0..00000000000 --- a/spec/frontend/search/sidebar/components/small_screen_drawer_navigation_spec.js +++ /dev/null @@ -1,68 +0,0 @@ -import { nextTick } from 'vue'; -import { GlDrawer } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; -import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; -import SmallScreenDrawerNavigation from '~/search/sidebar/components/small_screen_drawer_navigation.vue'; - -describe('ScopeLegacyNavigation', () => { - let wrapper; - let closeSpy; - let toggleSpy; - - const createComponent = () => { - wrapper = shallowMountExtended(SmallScreenDrawerNavigation, { - slots: { - default: '<div data-testid="default-slot-content">test</div>', - }, - }); - }; - - const findGlDrawer = () => wrapper.findComponent(GlDrawer); - const findTitle = () => wrapper.findComponent('h2'); - const findSlot = () => wrapper.findByTestId('default-slot-content'); - const findDomElementListener = () => wrapper.findComponent(DomElementListener); - - describe('small screen navigation', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders drawer', () => { - expect(findGlDrawer().exists()).toBe(true); - expect(findGlDrawer().attributes('zindex')).toBe(DRAWER_Z_INDEX.toString()); - expect(findGlDrawer().attributes('headerheight')).toBe('0'); - }); - - it('renders title', () => { - expect(findTitle().exists()).toBe(true); - }); - - it('renders slots', () => { - expect(findSlot().exists()).toBe(true); - }); - }); - - describe('actions', () => { - beforeEach(() => { - closeSpy = jest.spyOn(SmallScreenDrawerNavigation.methods, 'closeSmallScreenFilters'); - toggleSpy = jest.spyOn(SmallScreenDrawerNavigation.methods, 'toggleSmallScreenFilters'); - createComponent(); - }); - - it('calls onClose', () => { - findGlDrawer().vm.$emit('close'); - expect(closeSpy).toHaveBeenCalled(); - }); - - it('calls toggleSmallScreenFilters', async () => { - expect(findGlDrawer().props('open')).toBe(false); - - findDomElementListener().vm.$emit('click'); - await nextTick(); - - expect(toggleSpy).toHaveBeenCalled(); - expect(findGlDrawer().props('open')).toBe(true); - }); - }); -}); diff --git a/spec/frontend/search/sidebar/components/status_filter_spec.js b/spec/frontend/search/sidebar/components/status_filter_spec.js index c230341c172..719932a79ef 100644 --- a/spec/frontend/search/sidebar/components/status_filter_spec.js +++ b/spec/frontend/search/sidebar/components/status_filter_spec.js @@ -22,23 +22,9 @@ describe('StatusFilter', () => { const findRadioFilter = () => wrapper.findComponent(RadioFilter); - describe('old sidebar', () => { - beforeEach(() => { - createComponent({ useSidebarNavigation: false }); - }); - - it('renders the component', () => { - expect(findRadioFilter().exists()).toBe(true); - }); - }); + it('renders the component', () => { + createComponent(); - describe('new sidebar', () => { - beforeEach(() => { - createComponent({ useSidebarNavigation: true }); - }); - - it('renders the component', () => { - expect(findRadioFilter().exists()).toBe(true); - }); + expect(findRadioFilter().exists()).toBe(true); }); }); diff --git a/spec/frontend/search/store/mutations_spec.js b/spec/frontend/search/store/mutations_spec.js index a517932b0eb..3462d4a326b 100644 --- a/spec/frontend/search/store/mutations_spec.js +++ b/spec/frontend/search/store/mutations_spec.js @@ -31,7 +31,7 @@ describe('Global Search Store Mutations', () => { mutations[types.RECEIVE_GROUPS_SUCCESS](state, MOCK_GROUPS); expect(state.fetchingGroups).toBe(false); - expect(state.groups).toBe(MOCK_GROUPS); + expect(state.groups).toStrictEqual(MOCK_GROUPS); }); }); @@ -57,7 +57,7 @@ describe('Global Search Store Mutations', () => { mutations[types.RECEIVE_PROJECTS_SUCCESS](state, MOCK_PROJECTS); expect(state.fetchingProjects).toBe(false); - expect(state.projects).toBe(MOCK_PROJECTS); + expect(state.projects).toStrictEqual(MOCK_PROJECTS); }); }); diff --git a/spec/frontend/search/topbar/components/app_spec.js b/spec/frontend/search/topbar/components/app_spec.js index 9704277c86b..d17bdc2a6e1 100644 --- a/spec/frontend/search/topbar/components/app_spec.js +++ b/spec/frontend/search/topbar/components/app_spec.js @@ -1,14 +1,14 @@ -import { GlSearchBoxByClick, GlButton } from '@gitlab/ui'; +import { GlSearchBoxByType, GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; // eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import { MOCK_QUERY } from 'jest/search/mock_data'; import { stubComponent } from 'helpers/stub_component'; import GlobalSearchTopbar from '~/search/topbar/components/app.vue'; -import GroupFilter from '~/search/topbar/components/group_filter.vue'; -import ProjectFilter from '~/search/topbar/components/project_filter.vue'; import MarkdownDrawer from '~/vue_shared/components/markdown_drawer/markdown_drawer.vue'; +import SearchTypeIndicator from '~/search/topbar/components/search_type_indicator.vue'; +import { ENTER_KEY } from '~/lib/utils/keys'; import { SYNTAX_OPTIONS_ADVANCED_DOCUMENT, SYNTAX_OPTIONS_ZOEKT_DOCUMENT, @@ -41,42 +41,22 @@ describe('GlobalSearchTopbar', () => { }); }; - const findGlSearchBox = () => wrapper.findComponent(GlSearchBoxByClick); - const findGroupFilter = () => wrapper.findComponent(GroupFilter); - const findProjectFilter = () => wrapper.findComponent(ProjectFilter); + const findGlSearchBox = () => wrapper.findComponent(GlSearchBoxByType); const findSyntaxOptionButton = () => wrapper.findComponent(GlButton); const findSyntaxOptionDrawer = () => wrapper.findComponent(MarkdownDrawer); + const findSearchTypeIndicator = () => wrapper.findComponent(SearchTypeIndicator); describe('template', () => { beforeEach(() => { createComponent(); }); - describe('Search box', () => { - it('renders always', () => { - expect(findGlSearchBox().exists()).toBe(true); - }); + it('always renders Search box', () => { + expect(findGlSearchBox().exists()).toBe(true); }); - describe.each` - snippets | showFilters - ${null} | ${true} - ${{ query: { snippets: '' } }} | ${true} - ${{ query: { snippets: false } }} | ${true} - ${{ query: { snippets: true } }} | ${false} - ${{ query: { snippets: 'false' } }} | ${true} - ${{ query: { snippets: 'true' } }} | ${false} - `('topbar filters', ({ snippets, showFilters }) => { - beforeEach(() => { - createComponent(snippets); - }); - - it(`does${showFilters ? '' : ' not'} render when snippets is ${JSON.stringify( - snippets, - )}`, () => { - expect(findGroupFilter().exists()).toBe(showFilters); - expect(findProjectFilter().exists()).toBe(showFilters); - }); + it('always renders Search indicator', () => { + expect(findSearchTypeIndicator().exists()).toBe(true); }); describe.each` @@ -128,15 +108,15 @@ describe('GlobalSearchTopbar', () => { }); describe.each` - state | defaultBranchName | hasSyntaxOptions - ${{ query: { repository_ref: '' }, searchType: 'basic' }} | ${'master'} | ${false} - ${{ query: { repository_ref: 'v0.1' }, searchType: 'basic' }} | ${''} | ${false} - ${{ query: { repository_ref: 'master' }, searchType: 'basic' }} | ${'master'} | ${false} - ${{ query: { repository_ref: 'master' }, searchType: 'advanced' }} | ${''} | ${false} - ${{ query: { repository_ref: '' }, searchType: 'advanced' }} | ${'master'} | ${true} - ${{ query: { repository_ref: 'v0.1' }, searchType: 'advanced' }} | ${''} | ${false} - ${{ query: { repository_ref: 'master' }, searchType: 'advanced' }} | ${'master'} | ${true} - ${{ query: { repository_ref: 'master' }, searchType: 'zoekt' }} | ${'master'} | ${true} + state | hasSyntaxOptions + ${{ query: { repository_ref: '' }, searchType: 'basic', searchLevel: 'project', defaultBranchName: 'master' }} | ${false} + ${{ query: { repository_ref: 'v0.1' }, searchType: 'basic', searchLevel: 'project', defaultBranchName: '' }} | ${false} + ${{ query: { repository_ref: 'master' }, searchType: 'basic', searchLevel: 'project', defaultBranchName: 'master' }} | ${false} + ${{ query: { repository_ref: 'master' }, searchType: 'advanced', searchLevel: 'project', defaultBranchName: '' }} | ${false} + ${{ query: { repository_ref: '' }, searchType: 'advanced', searchLevel: 'project', defaultBranchName: 'master' }} | ${true} + ${{ query: { repository_ref: 'v0.1' }, searchType: 'advanced', searchLevel: 'project', defaultBranchName: '' }} | ${false} + ${{ query: { repository_ref: 'master' }, searchType: 'advanced', searchLevel: 'project', defaultBranchName: 'master' }} | ${true} + ${{ query: { repository_ref: 'master' }, searchType: 'zoekt', searchLevel: 'project', defaultBranchName: 'master' }} | ${true} `( `the syntax option based on component state`, ({ state, defaultBranchName, hasSyntaxOptions }) => { @@ -162,9 +142,10 @@ describe('GlobalSearchTopbar', () => { createComponent(); }); - it('clicking search button inside search box calls applyQuery', () => { - findGlSearchBox().vm.$emit('submit', { preventDefault: () => {} }); + it('clicking search button inside search box calls applyQuery', async () => { + await nextTick(); + findGlSearchBox().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); expect(actionSpies.applyQuery).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/search/topbar/components/search_type_indicator_spec.js b/spec/frontend/search/topbar/components/search_type_indicator_spec.js new file mode 100644 index 00000000000..d69ca6dfb16 --- /dev/null +++ b/spec/frontend/search/topbar/components/search_type_indicator_spec.js @@ -0,0 +1,128 @@ +import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports +import Vuex from 'vuex'; +import { GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { MOCK_QUERY } from 'jest/search/mock_data'; +import SearchTypeIndicator from '~/search/topbar/components/search_type_indicator.vue'; + +Vue.use(Vuex); + +describe('SearchTypeIndicator', () => { + let wrapper; + + const actionSpies = { + applyQuery: jest.fn(), + setQuery: jest.fn(), + preloadStoredFrequentItems: jest.fn(), + }; + + const createComponent = (initialState = {}) => { + const store = new Vuex.Store({ + state: { + query: MOCK_QUERY, + ...initialState, + }, + actions: actionSpies, + }); + + wrapper = shallowMountExtended(SearchTypeIndicator, { + store, + stubs: { + GlSprintf, + }, + }); + }; + + const findIndicator = (id) => wrapper.findAllByTestId(id); + const findDocsLink = () => wrapper.findComponentByTestId('docs-link'); + const findSyntaxDocsLink = () => wrapper.findComponentByTestId('syntax-docs-link'); + + // searchType and search level params cobination in this test reflects + // all possible combinations + + describe.each` + searchType | searchLevel | repository | showSearchTypeIndicator + ${'advanced'} | ${'project'} | ${'master'} | ${'advanced-enabled'} + ${'advanced'} | ${'project'} | ${'v0.1'} | ${'advanced-disabled'} + ${'advanced'} | ${'group'} | ${'master'} | ${'advanced-enabled'} + ${'advanced'} | ${'global'} | ${'master'} | ${'advanced-enabled'} + ${'zoekt'} | ${'project'} | ${'master'} | ${'zoekt-enabled'} + ${'zoekt'} | ${'project'} | ${'v0.1'} | ${'zoekt-disabled'} + ${'zoekt'} | ${'group'} | ${'master'} | ${'zoekt-enabled'} + `( + 'search type indicator for $searchType $searchLevel', + ({ searchType, repository, showSearchTypeIndicator, searchLevel }) => { + beforeEach(() => { + createComponent({ + query: { repository_ref: repository }, + searchType, + searchLevel, + defaultBranchName: 'master', + }); + }); + it('renders correctly', () => { + expect(findIndicator(showSearchTypeIndicator).exists()).toBe(true); + }); + }, + ); + + describe.each` + searchType | repository | showSearchTypeIndicator + ${'basic'} | ${'master'} | ${true} + ${'basic'} | ${'v0.1'} | ${true} + `( + 'search type indicator for $searchType and $repository', + ({ searchType, repository, showSearchTypeIndicator }) => { + beforeEach(() => { + createComponent({ + query: { repository_ref: repository }, + searchType, + defaultBranchName: 'master', + }); + }); + it.each(['zoekt-enabled', 'zoekt-disabled', 'advanced-enabled', 'advanced-disabled'])( + 'renders correct indicator %s', + () => { + expect(findIndicator(searchType).exists()).toBe(showSearchTypeIndicator); + }, + ); + }, + ); + + describe.each` + searchType | docsLink + ${'advanced'} | ${'/help/user/search/advanced_search'} + ${'zoekt'} | ${'/help/user/search/exact_code_search'} + `('documentation link for $searchType', ({ searchType, docsLink }) => { + beforeEach(() => { + createComponent({ + query: { repository_ref: 'master' }, + searchType, + searchLevel: 'project', + defaultBranchName: 'master', + }); + }); + it('has correct link', () => { + expect(findDocsLink().attributes('href')).toBe(docsLink); + }); + }); + + describe.each` + searchType | syntaxdocsLink + ${'advanced'} | ${'/help/user/search/advanced_search#use-the-advanced-search-syntax'} + ${'zoekt'} | ${'/help/user/search/exact_code_search#syntax'} + `('Syntax documentation $searchType', ({ searchType, syntaxdocsLink }) => { + beforeEach(() => { + createComponent({ + query: { repository_ref: '000' }, + searchType, + searchLevel: 'project', + defaultBranchName: 'master', + }); + }); + it('has correct link', () => { + expect(findSyntaxDocsLink().attributes('href')).toBe(syntaxdocsLink); + }); + }); +}); diff --git a/spec/frontend/search/topbar/components/searchable_dropdown_item_spec.js b/spec/frontend/search/topbar/components/searchable_dropdown_item_spec.js deleted file mode 100644 index c911fe53d40..00000000000 --- a/spec/frontend/search/topbar/components/searchable_dropdown_item_spec.js +++ /dev/null @@ -1,93 +0,0 @@ -import { GlDropdownItem, GlAvatar } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { MOCK_GROUPS } from 'jest/search/mock_data'; -import { truncateNamespace } from '~/lib/utils/text_utility'; -import SearchableDropdownItem from '~/search/topbar/components/searchable_dropdown_item.vue'; -import { GROUP_DATA } from '~/search/topbar/constants'; - -describe('Global Search Searchable Dropdown Item', () => { - let wrapper; - - const defaultProps = { - item: MOCK_GROUPS[0], - selectedItem: MOCK_GROUPS[0], - name: GROUP_DATA.name, - fullName: GROUP_DATA.fullName, - }; - - const createComponent = (props) => { - wrapper = shallowMountExtended(SearchableDropdownItem, { - propsData: { - ...defaultProps, - ...props, - }, - }); - }; - - const findGlDropdownItem = () => wrapper.findComponent(GlDropdownItem); - const findGlAvatar = () => wrapper.findComponent(GlAvatar); - const findDropdownTitle = () => wrapper.findByTestId('item-title'); - const findDropdownSubtitle = () => wrapper.findByTestId('item-namespace'); - - describe('template', () => { - describe('always', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders GlDropdownItem', () => { - expect(findGlDropdownItem().exists()).toBe(true); - }); - - it('renders GlAvatar', () => { - expect(findGlAvatar().exists()).toBe(true); - }); - - it('renders Dropdown Title correctly', () => { - const titleEl = findDropdownTitle(); - - expect(titleEl.exists()).toBe(true); - expect(titleEl.text()).toBe(MOCK_GROUPS[0][GROUP_DATA.name]); - }); - - it('renders Dropdown Subtitle correctly', () => { - const subtitleEl = findDropdownSubtitle(); - - expect(subtitleEl.exists()).toBe(true); - expect(subtitleEl.text()).toBe(truncateNamespace(MOCK_GROUPS[0][GROUP_DATA.fullName])); - }); - }); - - describe('when item === selectedItem', () => { - beforeEach(() => { - createComponent({ item: MOCK_GROUPS[0], selectedItem: MOCK_GROUPS[0] }); - }); - - it('marks the dropdown as checked', () => { - expect(findGlDropdownItem().attributes('ischecked')).toBe('true'); - }); - }); - - describe('when item !== selectedItem', () => { - beforeEach(() => { - createComponent({ item: MOCK_GROUPS[0], selectedItem: MOCK_GROUPS[1] }); - }); - - it('marks the dropdown as not checked', () => { - expect(findGlDropdownItem().attributes('ischecked')).toBeUndefined(); - }); - }); - }); - - describe('actions', () => { - beforeEach(() => { - createComponent(); - }); - - it('clicking the dropdown item $emits change with the item', () => { - findGlDropdownItem().vm.$emit('click'); - - expect(wrapper.emitted('change')[0]).toEqual([MOCK_GROUPS[0]]); - }); - }); -}); diff --git a/spec/frontend/search/topbar/components/searchable_dropdown_spec.js b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js deleted file mode 100644 index 5acaa1c1900..00000000000 --- a/spec/frontend/search/topbar/components/searchable_dropdown_spec.js +++ /dev/null @@ -1,220 +0,0 @@ -import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data'; -import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue'; -import { ANY_OPTION, GROUP_DATA } from '~/search/topbar/constants'; - -Vue.use(Vuex); - -describe('Global Search Searchable Dropdown', () => { - let wrapper; - - const defaultProps = { - headerText: GROUP_DATA.headerText, - name: GROUP_DATA.name, - fullName: GROUP_DATA.fullName, - loading: false, - selectedItem: ANY_OPTION, - items: [], - }; - - const createComponent = (initialState, props, mountFn = shallowMount) => { - const store = new Vuex.Store({ - state: { - query: MOCK_QUERY, - ...initialState, - }, - }); - - wrapper = extendedWrapper( - mountFn(SearchableDropdown, { - store, - propsData: { - ...defaultProps, - ...props, - }, - }), - ); - }; - - const findGlDropdown = () => wrapper.findComponent(GlDropdown); - const findGlDropdownSearch = () => findGlDropdown().findComponent(GlSearchBoxByType); - const findDropdownText = () => findGlDropdown().find('.dropdown-toggle-text'); - const findSearchableDropdownItems = () => wrapper.findAllByTestId('searchable-items'); - const findFrequentDropdownItems = () => wrapper.findAllByTestId('frequent-items'); - const findAnyDropdownItem = () => findGlDropdown().findComponent(GlDropdownItem); - const findFirstSearchableDropdownItem = () => findSearchableDropdownItems().at(0); - const findFirstFrequentDropdownItem = () => findFrequentDropdownItems().at(0); - const findLoader = () => wrapper.findComponent(GlSkeletonLoader); - - describe('template', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders GlDropdown', () => { - expect(findGlDropdown().exists()).toBe(true); - }); - - describe('findGlDropdownSearch', () => { - it('renders always', () => { - expect(findGlDropdownSearch().exists()).toBe(true); - }); - - it('has debounce prop', () => { - expect(findGlDropdownSearch().attributes('debounce')).toBe('500'); - }); - - describe('onSearch', () => { - const search = 'test search'; - - beforeEach(() => { - findGlDropdownSearch().vm.$emit('input', search); - }); - - it('$emits @search when input event is fired from GlSearchBoxByType', () => { - expect(wrapper.emitted('search')[0]).toEqual([search]); - }); - }); - }); - - describe('Searchable Dropdown Items', () => { - describe('when loading is false', () => { - beforeEach(() => { - createComponent({}, { items: MOCK_GROUPS }); - }); - - it('does not render loader', () => { - expect(findLoader().exists()).toBe(false); - }); - - it('renders the Any Dropdown', () => { - expect(findAnyDropdownItem().exists()).toBe(true); - }); - - it('renders searchable dropdown item for each item', () => { - expect(findSearchableDropdownItems()).toHaveLength(MOCK_GROUPS.length); - }); - }); - - describe('when loading is true', () => { - beforeEach(() => { - createComponent({}, { loading: true, items: MOCK_GROUPS }); - }); - - it('does render loader', () => { - expect(findLoader().exists()).toBe(true); - }); - - it('renders the Any Dropdown', () => { - expect(findAnyDropdownItem().exists()).toBe(true); - }); - - it('does not render searchable dropdown items', () => { - expect(findSearchableDropdownItems()).toHaveLength(0); - }); - }); - }); - - describe.each` - searchText | frequentItems | length - ${''} | ${[]} | ${0} - ${''} | ${MOCK_GROUPS} | ${MOCK_GROUPS.length} - ${'test'} | ${[]} | ${0} - ${'test'} | ${MOCK_GROUPS} | ${0} - `('Frequent Dropdown Items', ({ searchText, frequentItems, length }) => { - describe(`when search is ${searchText} and frequentItems length is ${frequentItems.length}`, () => { - beforeEach(() => { - createComponent({}, { frequentItems }); - findGlDropdownSearch().vm.$emit('input', searchText); - }); - - it(`should${length ? '' : ' not'} render frequent dropdown items`, () => { - expect(findFrequentDropdownItems()).toHaveLength(length); - }); - }); - }); - - describe('Dropdown Text', () => { - describe('when selectedItem is any', () => { - beforeEach(() => { - createComponent({}, {}, mount); - }); - - it('sets dropdown text to Any', () => { - expect(findDropdownText().text()).toBe(ANY_OPTION.name); - }); - }); - - describe('selectedItem is set', () => { - beforeEach(() => { - createComponent({}, { selectedItem: MOCK_GROUP }, mount); - }); - - it('sets dropdown text to the selectedItem name', () => { - expect(findDropdownText().text()).toBe(MOCK_GROUP[GROUP_DATA.name]); - }); - }); - }); - }); - - describe('actions', () => { - beforeEach(() => { - createComponent({}, { items: MOCK_GROUPS, frequentItems: MOCK_GROUPS }); - }); - - it('clicking "Any" dropdown item $emits @change with ANY_OPTION', () => { - findAnyDropdownItem().vm.$emit('click'); - - expect(wrapper.emitted('change')[0]).toEqual([ANY_OPTION]); - }); - - it('on searchable item @change, the wrapper $emits change with the item', () => { - findFirstSearchableDropdownItem().vm.$emit('change', MOCK_GROUPS[0]); - - expect(wrapper.emitted('change')[0]).toEqual([MOCK_GROUPS[0]]); - }); - - it('on frequent item @change, the wrapper $emits change with the item', () => { - findFirstFrequentDropdownItem().vm.$emit('change', MOCK_GROUPS[0]); - - expect(wrapper.emitted('change')[0]).toEqual([MOCK_GROUPS[0]]); - }); - - describe('opening the dropdown', () => { - beforeEach(() => { - findGlDropdown().vm.$emit('show'); - }); - - it('$emits @search and @first-open on the first open', () => { - expect(wrapper.emitted('search')[0]).toStrictEqual(['']); - expect(wrapper.emitted('first-open')[0]).toStrictEqual([]); - }); - - describe('when the dropdown has been opened', () => { - it('$emits @search with the searchText', async () => { - const searchText = 'foo'; - - findGlDropdownSearch().vm.$emit('input', searchText); - await nextTick(); - - expect(wrapper.emitted('search')[1]).toStrictEqual([searchText]); - expect(wrapper.emitted('first-open')).toHaveLength(1); - }); - - it('does not emit @first-open again', async () => { - expect(wrapper.emitted('first-open')).toHaveLength(1); - - findGlDropdownSearch().vm.$emit('input'); - await nextTick(); - - expect(wrapper.emitted('first-open')).toHaveLength(1); - }); - }); - }); - }); -}); diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index 364fe733a41..94d888bb067 100644 --- a/spec/frontend/security_configuration/components/app_spec.js +++ b/spec/frontend/security_configuration/components/app_spec.js @@ -5,10 +5,10 @@ import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; import stubChildren from 'helpers/stub_children'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import SecurityConfigurationApp, { i18n } from '~/security_configuration/components/app.vue'; +import SecurityConfigurationApp from '~/security_configuration/components/app.vue'; import AutoDevopsAlert from '~/security_configuration/components/auto_dev_ops_alert.vue'; import AutoDevopsEnabledAlert from '~/security_configuration/components/auto_dev_ops_enabled_alert.vue'; -import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from '~/security_configuration/components/constants'; +import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from '~/security_configuration/constants'; import FeatureCard from '~/security_configuration/components/feature_card.vue'; import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue'; import { securityFeaturesMock, provideMock } from '../mock_data'; @@ -19,6 +19,8 @@ const { vulnerabilityTrainingDocsPath, projectFullPath } = provideMock; useLocalStorageSpy(); Vue.use(VueApollo); +const { i18n } = SecurityConfigurationApp; + describe('~/security_configuration/components/app', () => { let wrapper; let userCalloutDismissSpy; diff --git a/spec/frontend/security_configuration/components/continuous_vulnerability_scan_spec.js b/spec/frontend/security_configuration/components/continuous_vulnerability_scan_spec.js deleted file mode 100644 index 84a468e4dd8..00000000000 --- a/spec/frontend/security_configuration/components/continuous_vulnerability_scan_spec.js +++ /dev/null @@ -1,124 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlBadge, GlToggle } from '@gitlab/ui'; -import VueApollo from 'vue-apollo'; -import Vue from 'vue'; -import ProjectSetContinuousVulnerabilityScanning from '~/security_configuration/graphql/project_set_continuous_vulnerability_scanning.graphql'; -import ContinuousVulnerabilityScan from '~/security_configuration/components/continuous_vulnerability_scan.vue'; -import createMockApollo from 'helpers/mock_apollo_helper'; - -Vue.use(VueApollo); - -const setCVSMockResponse = { - data: { - projectSetContinuousVulnerabilityScanning: { - continuousVulnerabilityScanningEnabled: true, - errors: [], - }, - }, -}; - -const defaultProvide = { - continuousVulnerabilityScansEnabled: true, - projectFullPath: 'project/full/path', -}; - -describe('ContinuousVulnerabilityScan', () => { - let wrapper; - let apolloProvider; - let requestHandlers; - - const createComponent = (options) => { - requestHandlers = { - setCVSMutationHandler: jest.fn().mockResolvedValue(setCVSMockResponse), - }; - - apolloProvider = createMockApollo([ - [ProjectSetContinuousVulnerabilityScanning, requestHandlers.setCVSMutationHandler], - ]); - - wrapper = shallowMount(ContinuousVulnerabilityScan, { - propsData: { - feature: { - available: true, - configured: true, - }, - }, - provide: { - glFeatures: { - dependencyScanningOnAdvisoryIngestion: true, - }, - ...defaultProvide, - }, - apolloProvider, - ...options, - }); - }; - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - apolloProvider = null; - }); - - const findBadge = () => wrapper.findComponent(GlBadge); - const findToggle = () => wrapper.findComponent(GlToggle); - - it('renders the component', () => { - expect(wrapper.exists()).toBe(true); - }); - - it('renders the correct title', () => { - expect(wrapper.text()).toContain('Continuous Vulnerability Scan'); - }); - - it('renders the badge and toggle component with correct values', () => { - expect(findBadge().exists()).toBe(true); - expect(findBadge().text()).toBe('Experiment'); - - expect(findToggle().exists()).toBe(true); - expect(findToggle().props('value')).toBe(defaultProvide.continuousVulnerabilityScansEnabled); - }); - - it('should disable toggle when feature is not configured', () => { - createComponent({ - propsData: { - feature: { - available: true, - configured: false, - }, - }, - }); - expect(findToggle().props('disabled')).toBe(true); - }); - - it('calls mutation on toggle change with correct payload', () => { - findToggle().vm.$emit('change', true); - - expect(requestHandlers.setCVSMutationHandler).toHaveBeenCalledWith({ - input: { - projectPath: 'project/full/path', - enable: true, - }, - }); - }); - - describe('when feature flag is disabled', () => { - beforeEach(() => { - createComponent({ - provide: { - glFeatures: { - dependencyScanningOnAdvisoryIngestion: false, - }, - ...defaultProvide, - }, - }); - }); - - it('should not render toggle and badge', () => { - expect(findToggle().exists()).toBe(false); - expect(findBadge().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/security_configuration/components/feature_card_spec.js b/spec/frontend/security_configuration/components/feature_card_spec.js index c715d01dd58..9efee2a409a 100644 --- a/spec/frontend/security_configuration/components/feature_card_spec.js +++ b/spec/frontend/security_configuration/components/feature_card_spec.js @@ -1,8 +1,7 @@ import { GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import Vue from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import { securityFeatures } from '~/security_configuration/components/constants'; +import { securityFeatures } from '~/security_configuration/constants'; import FeatureCard from '~/security_configuration/components/feature_card.vue'; import FeatureCardBadge from '~/security_configuration/components/feature_card_badge.vue'; import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; @@ -14,10 +13,6 @@ import { import { manageViaMRErrorMessage } from '../constants'; import { makeFeature } from './utils'; -const MockComponent = Vue.component('MockComponent', { - render: (createElement) => createElement('span'), -}); - describe('FeatureCard component', () => { let feature; let wrapper; @@ -394,17 +389,4 @@ describe('FeatureCard component', () => { }); }); }); - - describe('when a slot component is passed', () => { - beforeEach(() => { - feature = makeFeature({ - slotComponent: MockComponent, - }); - createComponent({ feature }); - }); - - it('renders the component properly', () => { - expect(wrapper.findComponent(MockComponent).exists()).toBe(true); - }); - }); }); diff --git a/spec/frontend/security_configuration/components/training_provider_list_spec.js b/spec/frontend/security_configuration/components/training_provider_list_spec.js index 5b2b3f46df6..ef20d8f56a4 100644 --- a/spec/frontend/security_configuration/components/training_provider_list_spec.js +++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js @@ -19,8 +19,8 @@ import { TRACK_TOGGLE_TRAINING_PROVIDER_LABEL, TRACK_PROVIDER_LEARN_MORE_CLICK_ACTION, TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL, + TEMP_PROVIDER_URLS, } from '~/security_configuration/constants'; -import { TEMP_PROVIDER_URLS } from '~/security_configuration/components/constants'; import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue'; import { updateSecurityTrainingOptimisticResponse } from '~/security_configuration/graphql/cache_utils'; import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql'; @@ -61,10 +61,9 @@ const TEMP_PROVIDER_LOGOS = { svg: '<svg>Secure Code Warrior</svg>', }, }; -jest.mock('~/security_configuration/components/constants', () => { +jest.mock('~/security_configuration/constants', () => { return { - TEMP_PROVIDER_URLS: jest.requireActual('~/security_configuration/components/constants') - .TEMP_PROVIDER_URLS, + TEMP_PROVIDER_URLS: jest.requireActual('~/security_configuration/constants').TEMP_PROVIDER_URLS, // NOTE: Jest hoists all mocks to the top so we can't use TEMP_PROVIDER_LOGOS // here directly. TEMP_PROVIDER_LOGOS: { diff --git a/spec/frontend/security_configuration/mock_data.js b/spec/frontend/security_configuration/mock_data.js index df10d33e2f0..208256afdbd 100644 --- a/spec/frontend/security_configuration/mock_data.js +++ b/spec/frontend/security_configuration/mock_data.js @@ -4,7 +4,7 @@ import { SAST_DESCRIPTION, SAST_HELP_PATH, SAST_CONFIG_HELP_PATH, -} from '~/security_configuration/components/constants'; +} from '~/security_configuration/constants'; import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants'; export const testProjectPath = 'foo/bar'; diff --git a/spec/frontend/security_configuration/utils_spec.js b/spec/frontend/security_configuration/utils_spec.js index ea04e9e7993..3c6d4baa30f 100644 --- a/spec/frontend/security_configuration/utils_spec.js +++ b/spec/frontend/security_configuration/utils_spec.js @@ -1,5 +1,5 @@ import { augmentFeatures, translateScannerNames } from '~/security_configuration/utils'; -import { SCANNER_NAMES_MAP } from '~/security_configuration/components/constants'; +import { SCANNER_NAMES_MAP } from '~/security_configuration/constants'; describe('augmentFeatures', () => { const mockSecurityFeatures = [ diff --git a/spec/frontend/set_status_modal/set_status_form_spec.js b/spec/frontend/set_status_modal/set_status_form_spec.js index e24561a9862..5fcbecfa1dc 100644 --- a/spec/frontend/set_status_modal/set_status_form_spec.js +++ b/spec/frontend/set_status_modal/set_status_form_spec.js @@ -84,11 +84,11 @@ describe('SetStatusForm', () => { it('displays time that status will clear', async () => { await createComponent({ propsData: { - currentClearStatusAfter: '2022-12-05 11:00:00 UTC', + currentClearStatusAfter: '2022-12-05T11:00:00Z', }, }); - expect(wrapper.findByRole('button', { name: '11:00am' }).exists()).toBe(true); + expect(wrapper.findByRole('button', { name: '11:00 AM' }).exists()).toBe(true); }); }); @@ -96,11 +96,13 @@ describe('SetStatusForm', () => { it('displays date and time that status will clear', async () => { await createComponent({ propsData: { - currentClearStatusAfter: '2022-12-06 11:00:00 UTC', + currentClearStatusAfter: '2022-12-06T11:00:00Z', }, }); - expect(wrapper.findByRole('button', { name: 'Dec 6, 2022 11:00am' }).exists()).toBe(true); + expect(wrapper.findByRole('button', { name: 'Dec 6, 2022, 11:00 AM' }).exists()).toBe( + true, + ); }); }); @@ -110,11 +112,11 @@ describe('SetStatusForm', () => { await createComponent({ propsData: { clearStatusAfter: thirtyMinutes, - currentClearStatusAfter: '2022-12-05 11:00:00 UTC', + currentClearStatusAfter: '2022-12-05T11:00:00Z', }, }); - expect(wrapper.findByRole('button', { name: '12:30am' }).exists()).toBe(true); + expect(wrapper.findByRole('button', { name: '12:30 AM' }).exists()).toBe(true); }); }); @@ -123,11 +125,11 @@ describe('SetStatusForm', () => { await createComponent({ propsData: { clearStatusAfter: oneDay, - currentClearStatusAfter: '2022-12-06 11:00:00 UTC', + currentClearStatusAfter: '2022-12-06T11:00:00Z', }, }); - expect(wrapper.findByRole('button', { name: 'Dec 6, 2022 12:00am' }).exists()).toBe( + expect(wrapper.findByRole('button', { name: 'Dec 6, 2022, 12:00 AM' }).exists()).toBe( true, ); }); diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js index 9c79d564625..7ae2884170e 100644 --- a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js +++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js @@ -131,12 +131,12 @@ describe('SetStatusModalWrapper', () => { beforeEach(async () => { await initEmojiMock(); - wrapper = createComponent({ currentClearStatusAfter: '2022-12-06 11:00:00 UTC' }); + wrapper = createComponent({ currentClearStatusAfter: '2022-12-06T11:00:00Z' }); return initModal(); }); it('displays date and time that status will expire in dropdown toggle button', () => { - expect(wrapper.findByRole('button', { name: 'Dec 6, 2022 11:00am' }).exists()).toBe(true); + expect(wrapper.findByRole('button', { name: 'Dec 6, 2022, 11:00 AM' }).exists()).toBe(true); }); }); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js index cd391765dde..bcef99afc46 100644 --- a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js @@ -14,13 +14,14 @@ Vue.use(Vuex); describe('DropdownContentsCreateView', () => { let wrapper; + let store; + const colors = Object.keys(mockSuggestedColors).map((color) => ({ [color]: mockSuggestedColors[color], })); const createComponent = (initialState = mockConfig) => { - const store = new Vuex.Store(labelSelectModule()); - + store = new Vuex.Store(labelSelectModule()); store.dispatch('setInitialState', initialState); wrapper = shallowMountExtended(DropdownContentsCreateView, { @@ -47,7 +48,7 @@ describe('DropdownContentsCreateView', () => { it('returns `true` when `labelCreateInProgress` is true', async () => { await findColorSelectorInput().vm.$emit('input', '#ff0000'); await findLabelTitleInput().vm.$emit('input', 'Foo'); - wrapper.vm.$store.dispatch('requestCreateLabel'); + store.dispatch('requestCreateLabel'); await nextTick(); @@ -81,7 +82,6 @@ describe('DropdownContentsCreateView', () => { describe('getColorName', () => { it('returns color name from color object', () => { expect(findAllLinks().at(0).attributes('title')).toBe(Object.values(colors[0]).pop()); - expect(wrapper.vm.getColorName(colors[0])).toBe(Object.values(colors[0]).pop()); }); }); @@ -97,20 +97,17 @@ describe('DropdownContentsCreateView', () => { describe('handleCreateClick', () => { it('calls action `createLabel` with object containing `labelTitle` & `selectedColor`', async () => { - jest.spyOn(wrapper.vm, 'createLabel').mockImplementation(); - + jest.spyOn(store, 'dispatch').mockImplementation(); await findColorSelectorInput().vm.$emit('input', '#ff0000'); await findLabelTitleInput().vm.$emit('input', 'Foo'); findCreateClickButton().vm.$emit('click'); await nextTick(); - expect(wrapper.vm.createLabel).toHaveBeenCalledWith( - expect.objectContaining({ - title: 'Foo', - color: '#ff0000', - }), - ); + expect(store.dispatch).toHaveBeenCalledWith('createLabel', { + title: 'Foo', + color: '#ff0000', + }); }); }); }); @@ -186,7 +183,7 @@ describe('DropdownContentsCreateView', () => { }); it('shows gl-loading-icon within create button element when `labelCreateInProgress` is `true`', async () => { - wrapper.vm.$store.dispatch('requestCreateLabel'); + store.dispatch('requestCreateLabel'); await nextTick(); const loadingIconEl = wrapper.find('.dropdown-actions').findComponent(GlLoadingIcon); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js index c27afb75375..663bfbb48cc 100644 --- a/spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js @@ -254,7 +254,7 @@ describe('LabelsSelect Actions', () => { describe('updateLabelsSetState', () => { it('updates labels `set` state to match `selectedLabels`', () => { - testAction( + return testAction( actions.updateLabelsSetState, {}, state, diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js index d70b989b493..21068c2858d 100644 --- a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js @@ -3,14 +3,15 @@ import { shallowMount } from '@vue/test-utils'; import DropdownValue from '~/sidebar/components/labels/labels_select_widget/dropdown_value.vue'; -import { mockRegularLabel, mockScopedLabel } from './mock_data'; +import { mockRegularLabel, mockScopedLabel, mockLockedLabel } from './mock_data'; describe('DropdownValue', () => { let wrapper; const findAllLabels = () => wrapper.findAllComponents(GlLabel); - const findRegularLabel = () => findAllLabels().at(1); + const findRegularLabel = () => findAllLabels().at(2); const findScopedLabel = () => findAllLabels().at(0); + const findLockedLabel = () => findAllLabels().at(1); const findWrapper = () => wrapper.find('[data-testid="value-wrapper"]'); const findEmptyPlaceholder = () => wrapper.find('[data-testid="empty-placeholder"]'); @@ -18,7 +19,7 @@ describe('DropdownValue', () => { wrapper = shallowMount(DropdownValue, { slots, propsData: { - selectedLabels: [mockRegularLabel, mockScopedLabel], + selectedLabels: [mockLockedLabel, mockRegularLabel, mockScopedLabel], allowLabelRemove: true, labelsFilterBasePath: '/gitlab-org/my-project/issues', labelsFilterParam: 'label_name', @@ -69,8 +70,8 @@ describe('DropdownValue', () => { expect(findEmptyPlaceholder().exists()).toBe(false); }); - it('renders a list of two labels', () => { - expect(findAllLabels().length).toBe(2); + it('renders a list of three labels', () => { + expect(findAllLabels().length).toBe(3); }); it('passes correct props to the regular label', () => { @@ -96,5 +97,19 @@ describe('DropdownValue', () => { wrapper.find('.sidebar-collapsed-icon').trigger('click'); expect(wrapper.emitted('onCollapsedValueClick')).toEqual([[]]); }); + + it('does not show close button if label is locked', () => { + createComponent({ + supportsLockOnMerge: true, + }); + expect(findLockedLabel().props('showCloseButton')).toBe(false); + }); + + it('shows close button if label is not locked', () => { + createComponent({ + supportsLockOnMerge: true, + }); + expect(findRegularLabel().props('showCloseButton')).toBe(true); + }); }); }); diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js index 715dd4e034e..c516dddf0ce 100644 --- a/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js @@ -1,7 +1,7 @@ import { GlLabel } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import EmbeddedLabelsList from '~/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue'; -import { mockRegularLabel, mockScopedLabel } from './mock_data'; +import { mockRegularLabel, mockScopedLabel, mockLockedLabel } from './mock_data'; describe('EmbeddedLabelsList', () => { let wrapper; @@ -13,12 +13,13 @@ describe('EmbeddedLabelsList', () => { .at(0); const findRegularLabel = () => findLabelByTitle(mockRegularLabel.title); const findScopedLabel = () => findLabelByTitle(mockScopedLabel.title); + const findLockedLabel = () => findLabelByTitle(mockLockedLabel.title); const createComponent = (props = {}, slots = {}) => { wrapper = shallowMountExtended(EmbeddedLabelsList, { slots, propsData: { - selectedLabels: [mockRegularLabel, mockScopedLabel], + selectedLabels: [mockRegularLabel, mockScopedLabel, mockLockedLabel], allowLabelRemove: true, labelsFilterBasePath: '/gitlab-org/my-project/issues', labelsFilterParam: 'label_name', @@ -47,8 +48,8 @@ describe('EmbeddedLabelsList', () => { createComponent(); }); - it('renders a list of two labels', () => { - expect(findAllLabels()).toHaveLength(2); + it('renders a list of three labels', () => { + expect(findAllLabels()).toHaveLength(3); }); it('passes correct props to the regular label', () => { @@ -69,5 +70,12 @@ describe('EmbeddedLabelsList', () => { findRegularLabel().vm.$emit('close'); expect(wrapper.emitted('onLabelRemove')).toStrictEqual([[mockRegularLabel.id]]); }); + + it('does not show close button if label is locked', () => { + createComponent({ + supportsLockOnMerge: true, + }); + expect(findLockedLabel().props('showCloseButton')).toBe(false); + }); }); }); diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js b/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js index b0b473625bb..5039f00fe4b 100644 --- a/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js @@ -14,6 +14,16 @@ export const mockScopedLabel = { textColor: '#FFFFFF', }; +export const mockLockedLabel = { + id: 30, + title: 'Bar Label', + description: 'Bar', + color: '#DADA55', + textColor: '#FFFFFF', + lockOnMerge: true, + lock_on_merge: true, +}; + export const mockLabels = [ mockRegularLabel, mockScopedLabel, diff --git a/spec/frontend/sidebar/components/reviewers/sidebar_reviewers_spec.js b/spec/frontend/sidebar/components/reviewers/sidebar_reviewers_spec.js index a221d28704b..ae31e60254f 100644 --- a/spec/frontend/sidebar/components/reviewers/sidebar_reviewers_spec.js +++ b/spec/frontend/sidebar/components/reviewers/sidebar_reviewers_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import axios from 'axios'; import AxiosMockAdapter from 'axios-mock-adapter'; import VueApollo from 'vue-apollo'; @@ -8,8 +8,11 @@ import SidebarReviewers from '~/sidebar/components/reviewers/sidebar_reviewers.v import SidebarService from '~/sidebar/services/sidebar_service'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarStore from '~/sidebar/stores/sidebar_store'; +import { fetchUserCounts } from '~/super_sidebar/user_counts_fetch'; import Mock from '../../mock_data'; +jest.mock('~/super_sidebar/user_counts_fetch'); + Vue.use(VueApollo); describe('sidebar reviewers', () => { @@ -39,7 +42,7 @@ describe('sidebar reviewers', () => { axiosMock = new AxiosMockAdapter(axios); mediator = new SidebarMediator(Mock.mediator); - jest.spyOn(mediator, 'saveReviewers'); + jest.spyOn(mediator, 'saveReviewers').mockResolvedValue({}); jest.spyOn(mediator, 'addSelfReview'); }); @@ -60,6 +63,17 @@ describe('sidebar reviewers', () => { expect(mediator.saveReviewers).toHaveBeenCalled(); }); + it('re-fetches user counts after saving reviewers', async () => { + createComponent(); + + expect(fetchUserCounts).not.toHaveBeenCalled(); + + wrapper.vm.saveReviewers(); + await nextTick(); + + expect(fetchUserCounts).toHaveBeenCalled(); + }); + it('calls the mediator when "reviewBySelf" method is called', () => { createComponent(); diff --git a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js index 66bc1f393ae..d7a5e4ba3ba 100644 --- a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js +++ b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js @@ -4,7 +4,7 @@ import { TEST_HOST } from 'helpers/test_constants'; import ReviewerAvatarLink from '~/sidebar/components/reviewers/reviewer_avatar_link.vue'; import UncollapsedReviewerList from '~/sidebar/components/reviewers/uncollapsed_reviewer_list.vue'; -const userDataMock = ({ approved = false } = {}) => ({ +const userDataMock = ({ approved = false, reviewState = 'UNREVIEWED' } = {}) => ({ id: 1, name: 'Root', state: 'active', @@ -16,6 +16,7 @@ const userDataMock = ({ approved = false } = {}) => ({ canUpdate: true, reviewed: true, approved, + reviewState, }, }); @@ -204,4 +205,28 @@ describe('UncollapsedReviewerList component', () => { ); }); }); + + describe('reviewer state icons', () => { + it.each` + reviewState | approved | icon + ${'UNREVIEWED'} | ${false} | ${'dotted-circle'} + ${'REVIEWED'} | ${true} | ${'status-success'} + ${'REVIEWED'} | ${false} | ${'comment'} + ${'REQUESTED_CHANGES'} | ${false} | ${'status-alert'} + `( + 'renders $icon for reviewState:$reviewState and approved:$approved', + ({ reviewState, approved, icon }) => { + const user = userDataMock({ approved, reviewState }); + + createComponent( + { + users: [user], + }, + { mrRequestChanges: true }, + ); + + expect(wrapper.find('[data-testid="reviewer-state-icon"]').props('name')).toBe(icon); + }, + ); + }); }); diff --git a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js index c1c3c1fea91..f3709e67037 100644 --- a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js +++ b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js @@ -1,11 +1,11 @@ import { GlLink, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; import createMockApollo from 'helpers/mock_apollo_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import { extendedWrapper } 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 { createAlert } from '~/alert'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -97,59 +97,56 @@ describe('SidebarDropdownWidget', () => { ...requestHandlers, ]); - wrapper = extendedWrapper( - mount(SidebarDropdownWidget, { - provide: { canUpdate: true }, - apolloProvider: mockApollo, - propsData: { - workspacePath: mockIssue.projectPath, - attrWorkspacePath: mockIssue.projectPath, - iid: mockIssue.iid, - issuableType: TYPE_ISSUE, - issuableAttribute: IssuableAttributeType.Milestone, - }, - attachTo: document.body, - }), - ); + wrapper = mountExtended(SidebarDropdownWidget, { + provide: { canUpdate: true }, + apolloProvider: mockApollo, + propsData: { + workspacePath: mockIssue.projectPath, + attrWorkspacePath: mockIssue.projectPath, + iid: mockIssue.iid, + issuableType: TYPE_ISSUE, + issuableAttribute: IssuableAttributeType.Milestone, + }, + attachTo: document.body, + }); await waitForApollo(); }; const createComponent = ({ data = {}, mutationPromise = mutationSuccess, queries = {} } = {}) => { - wrapper = extendedWrapper( - shallowMount(SidebarDropdownWidget, { - provide: { canUpdate: true }, - data() { - return data; - }, - propsData: { - workspacePath: '', - attrWorkspacePath: '', - iid: '', - issuableType: TYPE_ISSUE, - issuableAttribute: IssuableAttributeType.Milestone, - }, - mocks: { - $apollo: { - mutate: mutationPromise(), - queries: { - issuable: { loading: false }, - attributesList: { loading: false }, - ...queries, - }, + wrapper = shallowMountExtended(SidebarDropdownWidget, { + provide: { canUpdate: true }, + data() { + return data; + }, + propsData: { + workspacePath: '', + attrWorkspacePath: '', + iid: '', + issuableType: TYPE_ISSUE, + issuableAttribute: IssuableAttributeType.Milestone, + }, + mocks: { + $apollo: { + mutate: mutationPromise(), + queries: { + issuable: { loading: false }, + attributesList: { loading: false }, + ...queries, }, }, - directives: { - GlTooltip: createMockDirective('gl-tooltip'), - }, - stubs: { - SidebarEditableItem, - GlSearchBoxByType, - }, - }), - ); - - wrapper.vm.$refs.dropdown.show = jest.fn(); + }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + stubs: { + SidebarEditableItem, + GlSearchBoxByType, + SidebarDropdown: stubComponent(SidebarDropdown, { + methods: { show: jest.fn() }, + }), + }, + }); }; describe('when not editing', () => { diff --git a/spec/frontend/sidebar/components/time_tracking/set_time_estimate_form_spec.js b/spec/frontend/sidebar/components/time_tracking/set_time_estimate_form_spec.js index 657fb52d62c..37d7b3b6781 100644 --- a/spec/frontend/sidebar/components/time_tracking/set_time_estimate_form_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/set_time_estimate_form_spec.js @@ -6,6 +6,7 @@ import setIssueTimeEstimateWithoutErrors from 'test_fixtures/graphql/issue_set_t import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { stubComponent } from 'helpers/stub_component'; import SetTimeEstimateForm from '~/sidebar/components/time_tracking/set_time_estimate_form.vue'; import issueSetTimeEstimateMutation from '~/sidebar/queries/issue_set_time_estimate.mutation.graphql'; @@ -75,10 +76,13 @@ describe('Set Time Estimate Form', () => { timeTracking, }, apolloProvider: createMockApollo([[issueSetTimeEstimateMutation, mutationResolverMock]]), + stubs: { + GlModal: stubComponent(GlModal, { + methods: { close: modalCloseMock }, + }), + }, }); - wrapper.vm.$refs.modal.close = modalCloseMock; - findModal().vm.$emit('show'); await nextTick(); }; diff --git a/spec/frontend/sidebar/sidebar_mediator_spec.js b/spec/frontend/sidebar/sidebar_mediator_spec.js index 9c12088216b..4dc285fc3c8 100644 --- a/spec/frontend/sidebar/sidebar_mediator_spec.js +++ b/spec/frontend/sidebar/sidebar_mediator_spec.js @@ -9,7 +9,6 @@ import Mock from './mock_data'; jest.mock('~/alert'); jest.mock('~/vue_shared/plugins/global_toast'); -jest.mock('~/commons/nav/user_merge_requests'); describe('Sidebar mediator', () => { const { mediator: mediatorMockData } = Mock; diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap index 92511acc4f8..4a42b7168a3 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap @@ -22,7 +22,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = /> </div> <div - class="gfm-form gl-overflow-hidden js-expanded js-vue-markdown-field md-area position-relative" + class="gfm-form js-expanded js-vue-markdown-field md-area position-relative" data-uploads-path="" > <markdown-header-stub diff --git a/spec/frontend/snippets/components/snippet_blob_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_edit_spec.js index b699e056576..53993921621 100644 --- a/spec/frontend/snippets/components/snippet_blob_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_edit_spec.js @@ -102,10 +102,20 @@ describe('Snippet Blob Edit component', () => { describe('with unloaded blob and JSON content', () => { beforeEach(() => { + jest.spyOn(axios, 'get'); axiosMock.onGet(TEST_FULL_PATH).reply(HTTP_STATUS_OK, TEST_JSON_CONTENT); createComponent(); }); + it('makes an API request for the blob content', () => { + const expectedConfig = { + transformResponse: [expect.any(Function)], + headers: { 'Cache-Control': 'no-cache' }, + }; + + expect(axios.get).toHaveBeenCalledWith(TEST_FULL_PATH, expectedConfig); + }); + // This checks against this issue https://gitlab.com/gitlab-org/gitlab/-/issues/241199 it('emits raw content', async () => { await waitForPromises(); diff --git a/spec/frontend/snippets/components/snippet_title_spec.js b/spec/frontend/snippets/components/snippet_title_spec.js index 0a3b57c9244..9e6a30885d4 100644 --- a/spec/frontend/snippets/components/snippet_title_spec.js +++ b/spec/frontend/snippets/components/snippet_title_spec.js @@ -1,71 +1,104 @@ -import { GlSprintf } from '@gitlab/ui'; +import { GlSprintf, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import SnippetDescription from '~/snippets/components/snippet_description_view.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import SnippetTitle from '~/snippets/components/snippet_title.vue'; -describe('Snippet header component', () => { +describe('Snippet title component', () => { let wrapper; const title = 'The property of Thor'; const description = 'Do not touch this hammer'; const descriptionHtml = `<h2>${description}</h2>`; - const snippet = { - snippet: { - title, - description, - descriptionHtml, - }, - }; - - function createComponent({ props = snippet } = {}) { - const defaultProps = { ...props }; + function createComponent({ propsData = {} } = {}) { wrapper = shallowMount(SnippetTitle, { propsData: { - ...defaultProps, + snippet: { + title, + description, + descriptionHtml, + }, + ...propsData, + }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), }, }); } - it('renders itself', () => { - createComponent(); - expect(wrapper.find('.snippet-header').exists()).toBe(true); - }); + const findIcon = () => wrapper.findComponent(GlIcon); + const findTooltip = () => getBinding(findIcon().element, 'gl-tooltip'); - it('renders snippets title and description', () => { - createComponent(); + describe('default state', () => { + beforeEach(() => { + createComponent(); + }); - expect(wrapper.text().trim()).toContain(title); - expect(wrapper.findComponent(SnippetDescription).props('description')).toBe(descriptionHtml); - }); + it('renders itself', () => { + expect(wrapper.find('.snippet-header').exists()).toBe(true); + }); - it('does not render recent changes time stamp if there were no updates', () => { - createComponent(); - expect(wrapper.findComponent(GlSprintf).exists()).toBe(false); - }); + it('does not render spam icon when author is not banned', () => { + expect(findIcon().exists()).toBe(false); + }); - it('does not render recent changes time stamp if the time for creation and updates match', () => { - const props = Object.assign(snippet, { - snippet: { - ...snippet.snippet, - createdAt: '2019-12-16T21:45:36Z', - updatedAt: '2019-12-16T21:45:36Z', - }, + it('renders snippets title and description', () => { + expect(wrapper.text().trim()).toContain(title); + expect(wrapper.findComponent(SnippetDescription).props('description')).toBe(descriptionHtml); }); - createComponent({ props }); - expect(wrapper.findComponent(GlSprintf).exists()).toBe(false); - }); + it('does not render recent changes time stamp if there were no updates', () => { + expect(wrapper.findComponent(GlSprintf).exists()).toBe(false); + }); - it('renders translated string with most recent changes timestamp if changes were made', () => { - const props = Object.assign(snippet, { - snippet: { - ...snippet.snippet, - createdAt: '2019-12-16T21:45:36Z', - updatedAt: '2019-15-16T21:45:36Z', - }, + it('does not render recent changes time stamp if the time for creation and updates match', () => { + createComponent({ + propsData: { + snippet: { + createdAt: '2019-12-16T21:45:36Z', + updatedAt: '2019-12-16T21:45:36Z', + }, + }, + }); + + expect(wrapper.findComponent(GlSprintf).exists()).toBe(false); + }); + + it('renders translated string with most recent changes timestamp if changes were made', () => { + createComponent({ + propsData: { + snippet: { + createdAt: '2019-12-16T21:45:36Z', + updatedAt: '2019-15-16T21:45:36Z', + }, + }, + }); + + expect(wrapper.findComponent(GlSprintf).exists()).toBe(true); }); - createComponent({ props }); + }); + + describe('when author is snippet is banned', () => { + it('renders spam icon and tooltip when author is banned', () => { + createComponent({ + propsData: { + snippet: { + hidden: true, + }, + }, + }); + + expect(findIcon().props()).toMatchObject({ + ariaLabel: 'Hidden', + name: 'spam', + size: 16, + }); - expect(wrapper.findComponent(GlSprintf).exists()).toBe(true); + expect(findIcon().attributes('title')).toBe( + 'This snippet is hidden because its author has been banned', + ); + + expect(findTooltip()).toBeDefined(); + }); }); }); diff --git a/spec/frontend/snippets/test_utils.js b/spec/frontend/snippets/test_utils.js index 76b03c0aa0d..9d42e9fa26c 100644 --- a/spec/frontend/snippets/test_utils.js +++ b/spec/frontend/snippets/test_utils.js @@ -45,6 +45,7 @@ export const createGQLSnippet = () => ({ message: '', }, }, + hidden: false, }); export const createGQLSnippetsQueryResponse = (snippets) => ({ diff --git a/spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js b/spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js index e63768a03c0..38e1baabf41 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js @@ -1,14 +1,32 @@ import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import FrequentItems from '~/super_sidebar/components/global_search/components/frequent_items.vue'; import FrequentGroups from '~/super_sidebar/components/global_search/components/frequent_groups.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import currentUserFrecentGroupsQuery from '~/super_sidebar/graphql/queries/current_user_frecent_groups.query.graphql'; +import waitForPromises from 'helpers/wait_for_promises'; +import { frecentGroupsMock } from '../../../mock_data'; + +Vue.use(VueApollo); describe('FrequentlyVisitedGroups', () => { let wrapper; const groupsPath = '/mock/group/path'; + const currentUserFrecentGroupsQueryHandler = jest.fn().mockResolvedValue({ + data: { + frecentGroups: frecentGroupsMock, + }, + }); const createComponent = (options) => { + const mockApollo = createMockApollo([ + [currentUserFrecentGroupsQuery, currentUserFrecentGroupsQueryHandler], + ]); + wrapper = shallowMount(FrequentGroups, { + apolloProvider: mockApollo, provide: { groupsPath, }, @@ -28,19 +46,25 @@ describe('FrequentlyVisitedGroups', () => { expect(findFrequentItems().props()).toMatchObject({ emptyStateText: 'Groups you visit often will appear here.', groupName: 'Frequently visited groups', - maxItems: 3, - storageKey: null, viewAllItemsIcon: 'group', viewAllItemsText: 'View all my groups', viewAllItemsPath: groupsPath, }); }); - it('with a user, passes a storage key string to FrequentItems', () => { - gon.current_username = 'test_user'; + it('loads frecent groups', () => { + createComponent(); + + expect(currentUserFrecentGroupsQueryHandler).toHaveBeenCalled(); + expect(findFrequentItems().props('loading')).toBe(true); + }); + + it('passes fetched groups to FrequentItems', async () => { createComponent(); + await waitForPromises(); - expect(findFrequentItems().props('storageKey')).toBe('test_user/frequent-groups'); + expect(findFrequentItems().props('items')).toEqual(frecentGroupsMock); + expect(findFrequentItems().props('loading')).toBe(false); }); it('passes attrs to FrequentItems', () => { diff --git a/spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js b/spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js index aae1fc543f9..b48a9ca6457 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js @@ -28,7 +28,6 @@ describe('FrequentlyVisitedItem', () => { }; const findProjectAvatar = () => wrapper.findComponent(ProjectAvatar); - const findRemoveButton = () => wrapper.findByRole('button'); const findSubtitle = () => wrapper.findByTestId('subtitle'); beforeEach(() => { @@ -53,46 +52,4 @@ describe('FrequentlyVisitedItem', () => { await wrapper.setProps({ item: { ...mockItem, subtitle: null } }); expect(findSubtitle().exists()).toBe(false); }); - - describe('clicking the remove button', () => { - const bubbledClickSpy = jest.fn(); - const clickSpy = jest.fn(); - - beforeEach(() => { - wrapper.element.addEventListener('click', bubbledClickSpy); - const button = findRemoveButton(); - button.element.addEventListener('click', clickSpy); - button.trigger('click'); - }); - - it('emits a remove event on clicking the remove button', () => { - expect(wrapper.emitted('remove')).toEqual([[mockItem]]); - }); - - it('stops the native event from bubbling and prevents its default behavior', () => { - expect(bubbledClickSpy).not.toHaveBeenCalled(); - expect(clickSpy.mock.calls[0][0].defaultPrevented).toBe(true); - }); - }); - - describe('pressing enter on the remove button', () => { - const bubbledKeydownSpy = jest.fn(); - const keydownSpy = jest.fn(); - - beforeEach(() => { - wrapper.element.addEventListener('keydown', bubbledKeydownSpy); - const button = findRemoveButton(); - button.element.addEventListener('keydown', keydownSpy); - button.trigger('keydown.enter'); - }); - - it('emits a remove event on clicking the remove button', () => { - expect(wrapper.emitted('remove')).toEqual([[mockItem]]); - }); - - it('stops the native event from bubbling and prevents its default behavior', () => { - expect(bubbledKeydownSpy).not.toHaveBeenCalled(); - expect(keydownSpy.mock.calls[0][0].defaultPrevented).toBe(true); - }); - }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js b/spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js index 4700e9c7e10..7876dd92701 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js @@ -2,28 +2,14 @@ import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlIcon } from '@gi import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import GlobalSearchFrequentItems from '~/super_sidebar/components/global_search/components/frequent_items.vue'; import FrequentItem from '~/super_sidebar/components/global_search/components/frequent_item.vue'; -import { getItemsFromLocalStorage, removeItemFromLocalStorage } from '~/super_sidebar/utils'; -import { cachedFrequentProjects } from 'jest/super_sidebar/mock_data'; - -jest.mock('~/super_sidebar/utils', () => { - const original = jest.requireActual('~/super_sidebar/utils'); - - return { - ...original, - getItemsFromLocalStorage: jest.fn(), - removeItemFromLocalStorage: jest.fn(), - }; -}); +import FrequentItemSkeleton from '~/super_sidebar/components/global_search/components/frequent_item_skeleton.vue'; +import { frecentGroupsMock } from 'jest/super_sidebar/mock_data'; describe('FrequentlyVisitedItems', () => { let wrapper; - const storageKey = 'mockStorageKey'; - const mockStoredItems = JSON.parse(cachedFrequentProjects); const mockProps = { emptyStateText: 'mock empty state text', groupName: 'mock group name', - maxItems: 42, - storageKey, viewAllItemsText: 'View all items', viewAllItemsIcon: 'question-o', viewAllItemsPath: '/mock/all_items', @@ -42,118 +28,97 @@ describe('FrequentlyVisitedItems', () => { }; const findItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); + const findSkeleton = () => wrapper.findComponent(FrequentItemSkeleton); const findItemRenderer = (root) => root.findComponent(FrequentItem); - const setStoredItems = (items) => { - getItemsFromLocalStorage.mockReturnValue(items); - }; + describe('common behavior', () => { + beforeEach(() => { + createComponent({ + items: frecentGroupsMock, + }); + }); - beforeEach(() => { - setStoredItems(mockStoredItems); + it('renders the group name', () => { + expect(wrapper.text()).toContain(mockProps.groupName); + }); + + it('renders the view all items link', () => { + const lastItem = findItems().at(-1); + expect(lastItem.props('item')).toMatchObject({ + text: mockProps.viewAllItemsText, + href: mockProps.viewAllItemsPath, + }); + + const icon = lastItem.findComponent(GlIcon); + expect(icon.props('name')).toBe(mockProps.viewAllItemsIcon); + }); }); - describe('without a storage key', () => { + describe('while items are being fetched', () => { beforeEach(() => { - createComponent({ storageKey: null }); + createComponent({ + loading: true, + }); }); - it('does not render anything', () => { - expect(wrapper.html()).toBe(''); + it('shows the loading state', () => { + expect(findSkeleton().exists()).toBe(true); }); - it('emits a nothing-to-render event', () => { - expect(wrapper.emitted('nothing-to-render')).toEqual([[]]); + it('does not show the empty state', () => { + expect(wrapper.text()).not.toContain(mockProps.emptyStateText); }); }); - describe('with a storageKey', () => { + describe('when there are no items', () => { beforeEach(() => { createComponent(); }); - describe('common behavior', () => { - it('calls getItemsFromLocalStorage', () => { - expect(getItemsFromLocalStorage).toHaveBeenCalledWith({ - storageKey, - maxItems: mockProps.maxItems, - }); - }); - - it('renders the group name', () => { - expect(wrapper.text()).toContain(mockProps.groupName); - }); - - it('renders the view all items link', () => { - const lastItem = findItems().at(-1); - expect(lastItem.props('item')).toMatchObject({ - text: mockProps.viewAllItemsText, - href: mockProps.viewAllItemsPath, - }); - - const icon = lastItem.findComponent(GlIcon); - expect(icon.props('name')).toBe(mockProps.viewAllItemsIcon); - }); + it('does not show the loading state', () => { + expect(findSkeleton().exists()).toBe(false); }); - describe('with stored items', () => { - it('renders the items', () => { - const items = findItems(); - - mockStoredItems.forEach((storedItem, index) => { - const dropdownItem = items.at(index); - - // Check GlDisclosureDropdownItem's item has the right structure - expect(dropdownItem.props('item')).toMatchObject({ - text: storedItem.name, - href: storedItem.webUrl, - }); - - // Check FrequentItem's item has the right structure - expect(findItemRenderer(dropdownItem).props('item')).toMatchObject({ - id: storedItem.id, - title: storedItem.name, - subtitle: expect.any(String), - avatar: storedItem.avatarUrl, - }); - }); - }); + it('shows the empty state', () => { + expect(wrapper.text()).toContain(mockProps.emptyStateText); + }); + }); - it('does not render the empty state text', () => { - expect(wrapper.text()).not.toContain('mock empty state text'); + describe('when there are items', () => { + beforeEach(() => { + createComponent({ + items: frecentGroupsMock, }); + }); - describe('removing an item', () => { - let itemToRemove; + it('renders the items', () => { + const items = findItems(); - beforeEach(() => { - const itemRenderer = findItemRenderer(findItems().at(0)); - itemToRemove = itemRenderer.props('item'); - itemRenderer.vm.$emit('remove', itemToRemove); - }); + frecentGroupsMock.forEach((item, index) => { + const dropdownItem = items.at(index); - it('calls removeItemFromLocalStorage when an item emits a remove event', () => { - expect(removeItemFromLocalStorage).toHaveBeenCalledWith({ - storageKey, - item: itemToRemove, - }); + // Check GlDisclosureDropdownItem's item has the right structure + expect(dropdownItem.props('item')).toMatchObject({ + text: item.name, + href: item.webUrl, }); - it('no longer renders that item', () => { - const renderedItemTexts = findItems().wrappers.map((item) => item.props('item').text); - expect(renderedItemTexts).not.toContain(itemToRemove.text); + // Check FrequentItem's item has the right structure + expect(findItemRenderer(dropdownItem).props('item')).toMatchObject({ + id: item.id, + title: item.name, + subtitle: expect.any(String), + avatar: item.avatarUrl, }); }); }); - }); - describe('with no stored items', () => { - beforeEach(() => { - setStoredItems([]); - createComponent(); + it('does not show the loading state', () => { + expect(findSkeleton().exists()).toBe(false); }); - it('renders the empty state text', () => { - expect(wrapper.text()).toContain(mockProps.emptyStateText); + it('does not show the empty state', () => { + expect(wrapper.text()).not.toContain(mockProps.emptyStateText); }); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js b/spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js index 7554c123574..b7123f295f7 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js @@ -1,14 +1,32 @@ import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import FrequentItems from '~/super_sidebar/components/global_search/components/frequent_items.vue'; import FrequentProjects from '~/super_sidebar/components/global_search/components/frequent_projects.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import currentUserFrecentProjectsQuery from '~/super_sidebar/graphql/queries/current_user_frecent_projects.query.graphql'; +import waitForPromises from 'helpers/wait_for_promises'; +import { frecentProjectsMock } from '../../../mock_data'; + +Vue.use(VueApollo); describe('FrequentlyVisitedProjects', () => { let wrapper; const projectsPath = '/mock/project/path'; + const currentUserFrecentProjectsQueryHandler = jest.fn().mockResolvedValue({ + data: { + frecentProjects: frecentProjectsMock, + }, + }); const createComponent = (options) => { + const mockApollo = createMockApollo([ + [currentUserFrecentProjectsQuery, currentUserFrecentProjectsQueryHandler], + ]); + wrapper = shallowMount(FrequentProjects, { + apolloProvider: mockApollo, provide: { projectsPath, }, @@ -28,19 +46,25 @@ describe('FrequentlyVisitedProjects', () => { expect(findFrequentItems().props()).toMatchObject({ emptyStateText: 'Projects you visit often will appear here.', groupName: 'Frequently visited projects', - maxItems: 5, - storageKey: null, viewAllItemsIcon: 'project', viewAllItemsText: 'View all my projects', viewAllItemsPath: projectsPath, }); }); - it('with a user, passes a storage key string to FrequentItems', () => { - gon.current_username = 'test_user'; + it('loads frecent projects', () => { + createComponent(); + + expect(currentUserFrecentProjectsQueryHandler).toHaveBeenCalled(); + expect(findFrequentItems().props('loading')).toBe(true); + }); + + it('passes fetched projects to FrequentItems', async () => { createComponent(); + await waitForPromises(); - expect(findFrequentItems().props('storageKey')).toBe('test_user/frequent-projects'); + expect(findFrequentItems().props('items')).toEqual(frecentProjectsMock); + expect(findFrequentItems().props('loading')).toBe(false); }); it('passes attrs to FrequentItems', () => { diff --git a/spec/frontend/super_sidebar/components/help_center_spec.js b/spec/frontend/super_sidebar/components/help_center_spec.js index 39537b65fa5..8e9e3e8ba20 100644 --- a/spec/frontend/super_sidebar/components/help_center_spec.js +++ b/spec/frontend/super_sidebar/components/help_center_spec.js @@ -94,7 +94,6 @@ describe('HelpCenter component', () => { it('passes custom offset to the dropdown', () => { expect(findDropdown().props('dropdownOffset')).toEqual({ - crossAxis: -4, mainAxis: 4, }); }); @@ -169,14 +168,13 @@ describe('HelpCenter component', () => { describe('showWhatsNew', () => { beforeEach(() => { - beforeEach(() => { - createWrapper({ ...sidebarData, show_version_check: true }); - }); + createWrapper({ ...sidebarData, show_version_check: true }); + findButton("What's new 5").click(); }); it('shows the "What\'s new" slideout', () => { - expect(toggleWhatsNewDrawer).toHaveBeenCalledWith(expect.any(Object)); + expect(toggleWhatsNewDrawer).toHaveBeenCalledWith(sidebarData.whats_new_version_digest); }); it('shows the existing "What\'s new" slideout instance on subsequent clicks', () => { diff --git a/spec/frontend/super_sidebar/components/nav_item_link_spec.js b/spec/frontend/super_sidebar/components/nav_item_link_spec.js index 5cc1bd01d0f..59fa6d022ae 100644 --- a/spec/frontend/super_sidebar/components/nav_item_link_spec.js +++ b/spec/frontend/super_sidebar/components/nav_item_link_spec.js @@ -29,7 +29,7 @@ describe('NavItemLink component', () => { expect(wrapper.attributes()).toEqual({ href: '/foo', - class: 'gl-bg-t-gray-a-08', + class: 'super-sidebar-nav-item-current', 'aria-current': 'page', }); }); diff --git a/spec/frontend/super_sidebar/components/nav_item_router_link_spec.js b/spec/frontend/super_sidebar/components/nav_item_router_link_spec.js index a7ca56325fe..dfae5e96cd8 100644 --- a/spec/frontend/super_sidebar/components/nav_item_router_link_spec.js +++ b/spec/frontend/super_sidebar/components/nav_item_router_link_spec.js @@ -45,7 +45,9 @@ describe('NavItemRouterLink component', () => { routerLinkSlotProps: { isActive: true }, }); - expect(wrapper.findComponent(RouterLinkStub).props('activeClass')).toBe('gl-bg-t-gray-a-08'); + expect(wrapper.findComponent(RouterLinkStub).props('activeClass')).toBe( + 'super-sidebar-nav-item-current', + ); expect(wrapper.attributes()).toEqual({ href: '/foo', 'aria-current': 'page', diff --git a/spec/frontend/super_sidebar/components/scroll_scrim_spec.js b/spec/frontend/super_sidebar/components/scroll_scrim_spec.js new file mode 100644 index 00000000000..ff1e9968f9b --- /dev/null +++ b/spec/frontend/super_sidebar/components/scroll_scrim_spec.js @@ -0,0 +1,60 @@ +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ScrollScrim from '~/super_sidebar/components/scroll_scrim.vue'; +import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; + +describe('ScrollScrim', () => { + let wrapper; + const { trigger: triggerIntersection } = useMockIntersectionObserver(); + + const createWrapper = () => { + wrapper = shallowMountExtended(ScrollScrim, {}); + }; + + beforeEach(() => { + createWrapper(); + }); + + const findTopBoundary = () => wrapper.vm.$refs['top-boundary']; + const findBottomBoundary = () => wrapper.vm.$refs['bottom-boundary']; + + describe('top scrim', () => { + describe('when top boundary is visible', () => { + it('does not show', async () => { + triggerIntersection(findTopBoundary(), { entry: { isIntersecting: true } }); + await nextTick(); + + expect(wrapper.classes()).not.toContain('top-scrim-visible'); + }); + }); + + describe('when top boundary is not visible', () => { + it('does show', async () => { + triggerIntersection(findTopBoundary(), { entry: { isIntersecting: false } }); + await nextTick(); + + expect(wrapper.classes()).toContain('top-scrim-visible'); + }); + }); + }); + + describe('bottom scrim', () => { + describe('when bottom boundary is visible', () => { + it('does not show', async () => { + triggerIntersection(findBottomBoundary(), { entry: { isIntersecting: true } }); + await nextTick(); + + expect(wrapper.classes()).not.toContain('bottom-scrim-visible'); + }); + }); + + describe('when bottom boundary is not visible', () => { + it('does show', async () => { + triggerIntersection(findBottomBoundary(), { entry: { isIntersecting: false } }); + await nextTick(); + + expect(wrapper.classes()).toContain('bottom-scrim-visible'); + }); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/super_sidebar_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_spec.js index 92736b99e14..9718cb7ad15 100644 --- a/spec/frontend/super_sidebar/components/super_sidebar_spec.js +++ b/spec/frontend/super_sidebar/components/super_sidebar_spec.js @@ -1,4 +1,7 @@ import { nextTick } from 'vue'; +import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils'; +import sidebarEventHub from '~/super_sidebar/event_hub'; +import ExtraInfo from 'jh_else_ce/super_sidebar/components/extra_info.vue'; import { Mousetrap } from '~/lib/mousetrap'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SuperSidebar from '~/super_sidebar/components/super_sidebar.vue'; @@ -23,6 +26,7 @@ import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { trackContextAccess } from '~/super_sidebar/utils'; import { sidebarData as mockSidebarData, loggedOutSidebarData } from '../mock_data'; +const { lg, xl } = breakpoints; const initialSidebarState = { ...sidebarState }; jest.mock('~/super_sidebar/super_sidebar_collapsed_state_manager'); @@ -56,6 +60,8 @@ describe('SuperSidebar component', () => { const findTrialStatusWidget = () => wrapper.findByTestId(trialStatusWidgetStubTestId); const findTrialStatusPopover = () => wrapper.findByTestId(trialStatusPopoverStubTestId); const findSidebarMenu = () => wrapper.findComponent(SidebarMenu); + const findAdminLink = () => wrapper.findByTestId('sidebar-admin-link'); + const findContextHeader = () => wrapper.findComponent('#super-sidebar-context-header'); let trackingSpy = null; const createWrapper = ({ @@ -128,6 +134,11 @@ describe('SuperSidebar component', () => { expect(findHelpCenter().props('sidebarData')).toBe(mockSidebarData); }); + it('renders extra info section', () => { + createWrapper(); + expect(wrapper.findComponent(ExtraInfo).exists()).toBe(true); + }); + it('does not render SidebarMenu when items are empty', () => { createWrapper(); expect(findSidebarMenu().exists()).toBe(false); @@ -207,6 +218,15 @@ describe('SuperSidebar component', () => { expect(wrapper.text()).toContain('Your work'); }); + it('handles event toggle-menu-header correctly', async () => { + createWrapper(); + + sidebarEventHub.$emit('toggle-menu-header', false); + + await nextTick(); + expect(findContextHeader().exists()).toBe(false); + }); + describe('item access tracking', () => { it('does not track anything if logged out', () => { createWrapper({ sidebarData: loggedOutSidebarData }); @@ -299,8 +319,8 @@ describe('SuperSidebar component', () => { createWrapper(); }); - it('allows overflow', () => { - expect(findNavContainer().classes()).toContain('gl-overflow-auto'); + it('allows overflow with scroll scrim', () => { + expect(findNavContainer().element.tagName).toContain('SCROLL-SCRIM'); }); }); @@ -314,4 +334,46 @@ describe('SuperSidebar component', () => { expect(findTrialStatusPopover().exists()).toBe(true); }); }); + + describe('keyboard interactivity', () => { + it('does not bind keydown events on screens xl and above', async () => { + jest.spyOn(document, 'addEventListener'); + jest.spyOn(bp, 'windowWidth').mockReturnValue(xl); + createWrapper(); + + isCollapsed.mockReturnValue(false); + await nextTick(); + + expect(document.addEventListener).not.toHaveBeenCalled(); + }); + + it('binds keydown events on screens below xl', () => { + jest.spyOn(document, 'addEventListener'); + jest.spyOn(bp, 'windowWidth').mockReturnValue(lg); + createWrapper(); + + expect(document.addEventListener).toHaveBeenCalledWith('keydown', wrapper.vm.focusTrap); + }); + }); + + describe('link to Admin area', () => { + describe('when user is admin', () => { + it('renders', () => { + createWrapper({ + sidebarData: { + ...mockSidebarData, + is_admin: true, + }, + }); + expect(findAdminLink().attributes('href')).toBe(mockSidebarData.admin_url); + }); + }); + + describe('when user is not admin', () => { + it('renders', () => { + createWrapper(); + expect(findAdminLink().exists()).toBe(false); + }); + }); + }); }); diff --git a/spec/frontend/super_sidebar/components/user_menu_spec.js b/spec/frontend/super_sidebar/components/user_menu_spec.js index 45a60fce00a..4af3247693b 100644 --- a/spec/frontend/super_sidebar/components/user_menu_spec.js +++ b/spec/frontend/super_sidebar/components/user_menu_spec.js @@ -1,8 +1,10 @@ import { GlAvatar, GlDisclosureDropdown } from '@gitlab/ui'; +import { nextTick } from 'vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { stubComponent } from 'helpers/stub_component'; import UserMenu from '~/super_sidebar/components/user_menu.vue'; import UserMenuProfileItem from '~/super_sidebar/components/user_menu_profile_item.vue'; +import SetStatusModal from '~/set_status_modal/set_status_modal_wrapper.vue'; import { mockTracking } from 'helpers/tracking_helper'; import PersistentUserCallout from '~/persistent_user_callout'; import { userMenuMockData, userMenuMockStatus, userMenuMockPipelineMinutes } from '../mock_data'; @@ -13,6 +15,7 @@ describe('UserMenu component', () => { const GlEmoji = { template: '<img/>' }; const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findSetStatusModal = () => wrapper.findComponent(SetStatusModal); const showDropdown = () => findDropdown().vm.$emit('shown'); const closeDropdownSpy = jest.fn(); @@ -28,6 +31,7 @@ describe('UserMenu component', () => { stubs: { GlEmoji, GlAvatar: true, + SetStatusModal: stubComponent(SetStatusModal), ...stubs, }, provide: { @@ -74,6 +78,20 @@ describe('UserMenu component', () => { }); }); + it('updates avatar url on custom avatar update event', async () => { + const url = `${userMenuMockData.avatar_url}-new-avatar`; + + document.dispatchEvent(new CustomEvent('userAvatar:update', { detail: { url } })); + await nextTick(); + + const avatar = toggle.findComponent(GlAvatar); + expect(avatar.exists()).toBe(true); + expect(avatar.props()).toMatchObject({ + entityName: userMenuMockData.name, + src: url, + }); + }); + it('renders screen reader text', () => { expect(toggle.find('.gl-sr-only').text()).toBe(`${userMenuMockData.name} user’s menu`); }); @@ -91,31 +109,46 @@ describe('UserMenu component', () => { describe('User status item', () => { let item; - const setItem = ({ can_update, busy, customized, stubs } = {}) => { - createWrapper({ status: { ...userMenuMockStatus, can_update, busy, customized } }, stubs); + const setItem = async ({ + can_update: canUpdate = false, + busy = false, + customized = false, + stubs, + } = {}) => { + createWrapper( + { status: { ...userMenuMockStatus, can_update: canUpdate, busy, customized } }, + stubs, + ); + // Mock mounting the modal if we can update + if (canUpdate) { + expect(wrapper.vm.setStatusModalReady).toEqual(false); + findSetStatusModal().vm.$emit('mounted'); + await nextTick(); + expect(wrapper.vm.setStatusModalReady).toEqual(true); + } item = wrapper.findByTestId('status-item'); }; describe('When user cannot update the status', () => { - it('does not render the status menu item', () => { - setItem(); + it('does not render the status menu item', async () => { + await setItem(); expect(item.exists()).toBe(false); }); }); describe('When user can update the status', () => { - it('renders the status menu item', () => { - setItem({ can_update: true }); + it('renders the status menu item', async () => { + await setItem({ can_update: true }); expect(item.exists()).toBe(true); + expect(item.find('button').attributes()).toMatchObject({ + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', + 'data-track-label': 'user_edit_status', + }); }); - it('should set the CSS class for triggering status update modal', () => { - setItem({ can_update: true }); - expect(item.find('.js-set-status-modal-trigger').exists()).toBe(true); - }); - - it('should close the dropdown when status modal opened', () => { - setItem({ + it('should close the dropdown when status modal opened', async () => { + await setItem({ can_update: true, stubs: { GlDisclosureDropdown: stubComponent(GlDisclosureDropdown, { @@ -139,57 +172,75 @@ describe('UserMenu component', () => { ${true} | ${true} | ${'Edit status'} `( 'when busy is "$busy" and customized is "$customized" the label is "$label"', - ({ busy, customized, label }) => { - setItem({ can_update: true, busy, customized }); + async ({ busy, customized, label }) => { + await setItem({ can_update: true, busy, customized }); expect(item.text()).toBe(label); }, ); }); + }); + }); + + describe('set status modal', () => { + describe('when the user cannot update the status', () => { + it('should not render the modal', () => { + createWrapper({ + status: { ...userMenuMockStatus, can_update: false }, + }); - describe('Status update modal wrapper', () => { - const findModalWrapper = () => wrapper.find('.js-set-status-modal-wrapper'); + expect(findSetStatusModal().exists()).toBe(false); + }); + }); - it('renders the modal wrapper', () => { - setItem({ can_update: true }); - expect(findModalWrapper().exists()).toBe(true); + describe('when the user can update the status', () => { + describe.each` + busy | customized + ${true} | ${true} + ${true} | ${false} + ${false} | ${true} + `('and the status is busy or customized', ({ busy, customized }) => { + it('should pass the current status to the modal', () => { + createWrapper({ + status: { ...userMenuMockStatus, can_update: true, busy, customized }, + }); + + expect(findSetStatusModal().exists()).toBe(true); + expect(findSetStatusModal().props()).toMatchObject({ + defaultEmoji: 'speech_balloon', + currentEmoji: userMenuMockStatus.emoji, + currentMessage: userMenuMockStatus.message, + currentAvailability: userMenuMockStatus.availability, + currentClearStatusAfter: userMenuMockStatus.clear_after, + }); }); - describe('when user cannot update status', () => { - it('sets default data attributes', () => { - setItem({ can_update: true }); - expect(findModalWrapper().attributes()).toMatchObject({ - 'data-current-emoji': '', - 'data-current-message': '', - 'data-default-emoji': 'speech_balloon', - }); + it('casts falsey values to empty strings', () => { + createWrapper({ + status: { can_update: true, busy, customized }, + }); + + expect(findSetStatusModal().exists()).toBe(true); + expect(findSetStatusModal().props()).toMatchObject({ + defaultEmoji: 'speech_balloon', + currentEmoji: '', + currentMessage: '', + currentAvailability: '', + currentClearStatusAfter: '', }); }); + }); + + describe('and the status is neither busy nor customized', () => { + it('should pass an empty status to the modal', () => { + createWrapper({ + status: { ...userMenuMockStatus, can_update: true, busy: false, customized: false }, + }); - describe.each` - busy | customized - ${true} | ${true} - ${true} | ${false} - ${false} | ${true} - ${false} | ${false} - `(`when user can update status`, ({ busy, customized }) => { - it(`and ${busy ? 'is busy' : 'is not busy'} and status ${ - customized ? 'is' : 'is not' - } customized sets user status data attributes`, () => { - setItem({ can_update: true, busy, customized }); - if (busy || customized) { - expect(findModalWrapper().attributes()).toMatchObject({ - 'data-current-emoji': userMenuMockStatus.emoji, - 'data-current-message': userMenuMockStatus.message, - 'data-current-availability': userMenuMockStatus.availability, - 'data-current-clear-status-after': userMenuMockStatus.clear_after, - }); - } else { - expect(findModalWrapper().attributes()).toMatchObject({ - 'data-current-emoji': '', - 'data-current-message': '', - 'data-default-emoji': 'speech_balloon', - }); - } + expect(findSetStatusModal().exists()).toBe(true); + expect(findSetStatusModal().props()).toMatchObject({ + defaultEmoji: 'speech_balloon', + currentEmoji: '', + currentMessage: '', }); }); }); diff --git a/spec/frontend/super_sidebar/mock_data.js b/spec/frontend/super_sidebar/mock_data.js index d2d2faedbf8..fc264ad5e0a 100644 --- a/spec/frontend/super_sidebar/mock_data.js +++ b/spec/frontend/super_sidebar/mock_data.js @@ -79,6 +79,8 @@ export const contextSwitcherLinks = [ export const sidebarData = { is_logged_in: true, + is_admin: false, + admin_url: '/admin', current_menu_items: [], current_context: {}, current_context_header: 'Your work', @@ -188,6 +190,26 @@ export const userMenuMockData = { canary_toggle_com_url: 'https://next.gitlab.com', }; +export const frecentGroupsMock = [ + { + id: 'gid://gitlab/Group/1', + name: 'Frecent group 1', + namespace: 'Frecent Namespace 1', + webUrl: '/frecent-namespace-1/frecent-group-1', + avatarUrl: '/uploads/-/avatar1.png', + }, +]; + +export const frecentProjectsMock = [ + { + id: 'gid://gitlab/Project/1', + name: 'Frecent project 1', + namespace: 'Frecent Namespace 1 / Frecent project 1', + webUrl: '/frecent-namespace-1/frecent-project-1', + avatarUrl: '/uploads/-/avatar1.png', + }, +]; + export const cachedFrequentProjects = JSON.stringify([ { id: 1, @@ -283,3 +305,32 @@ export const cachedFrequentGroups = JSON.stringify([ frequency: 3, }, ]); + +export const unsortedFrequentItems = [ + { id: 1, frequency: 12, lastAccessedOn: 1491400843391 }, + { id: 2, frequency: 14, lastAccessedOn: 1488240890738 }, + { id: 3, frequency: 44, lastAccessedOn: 1497675908472 }, + { id: 4, frequency: 8, lastAccessedOn: 1497979281815 }, + { id: 5, frequency: 34, lastAccessedOn: 1488089211943 }, + { id: 6, frequency: 14, lastAccessedOn: 1493517292488 }, + { id: 7, frequency: 42, lastAccessedOn: 1486815299875 }, + { id: 8, frequency: 33, lastAccessedOn: 1500762279114 }, + { id: 10, frequency: 46, lastAccessedOn: 1483251641543 }, +]; + +/** + * This const has a specific order which tests authenticity + * of `getTopFrequentItems` method so + * DO NOT change order of items in this const. + */ +export const sortedFrequentItems = [ + { id: 10, frequency: 46, lastAccessedOn: 1483251641543 }, + { id: 3, frequency: 44, lastAccessedOn: 1497675908472 }, + { id: 7, frequency: 42, lastAccessedOn: 1486815299875 }, + { id: 5, frequency: 34, lastAccessedOn: 1488089211943 }, + { id: 8, frequency: 33, lastAccessedOn: 1500762279114 }, + { id: 6, frequency: 14, lastAccessedOn: 1493517292488 }, + { id: 2, frequency: 14, lastAccessedOn: 1488240890738 }, + { id: 1, frequency: 12, lastAccessedOn: 1491400843391 }, + { id: 4, frequency: 8, lastAccessedOn: 1497979281815 }, +]; diff --git a/spec/frontend/super_sidebar/user_counts_manager_spec.js b/spec/frontend/super_sidebar/user_counts_manager_spec.js index b5074620195..3b2ee5b0991 100644 --- a/spec/frontend/super_sidebar/user_counts_manager_spec.js +++ b/spec/frontend/super_sidebar/user_counts_manager_spec.js @@ -6,6 +6,7 @@ import { userCounts, destroyUserCountsManager, } from '~/super_sidebar/user_counts_manager'; +import { fetchUserCounts } from '~/super_sidebar/user_counts_fetch'; jest.mock('~/api'); @@ -118,15 +119,30 @@ describe('User Merge Requests', () => { createUserCountsManager(); }); - it('fetches counts from API, stores and rebroadcasts them', async () => { - expect(userCounts).toMatchObject(userCountDefaults); + describe('manually created event', () => { + it('fetches counts from API, stores and rebroadcasts them', async () => { + expect(userCounts).toMatchObject(userCountDefaults); + + document.dispatchEvent(new CustomEvent('userCounts:fetch')); + await waitForPromises(); + + expect(UserApi.getUserCounts).toHaveBeenCalled(); + expect(userCounts).toMatchObject(userCountUpdate); + expect(channelMock.postMessage).toHaveBeenLastCalledWith(userCounts); + }); + }); + + describe('fetchUserCounts helper', () => { + it('fetches counts from API, stores and rebroadcasts them', async () => { + expect(userCounts).toMatchObject(userCountDefaults); - document.dispatchEvent(new CustomEvent('userCounts:fetch')); - await waitForPromises(); + fetchUserCounts(); + await waitForPromises(); - expect(UserApi.getUserCounts).toHaveBeenCalled(); - expect(userCounts).toMatchObject(userCountUpdate); - expect(channelMock.postMessage).toHaveBeenLastCalledWith(userCounts); + expect(UserApi.getUserCounts).toHaveBeenCalled(); + expect(userCounts).toMatchObject(userCountUpdate); + expect(channelMock.postMessage).toHaveBeenLastCalledWith(userCounts); + }); }); }); diff --git a/spec/frontend/super_sidebar/utils_spec.js b/spec/frontend/super_sidebar/utils_spec.js index 43eb82f5928..a9e4345f9cc 100644 --- a/spec/frontend/super_sidebar/utils_spec.js +++ b/spec/frontend/super_sidebar/utils_spec.js @@ -1,20 +1,13 @@ import MockAdapter from 'axios-mock-adapter'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; -import { - getTopFrequentItems, - trackContextAccess, - getItemsFromLocalStorage, - removeItemFromLocalStorage, - ariaCurrent, -} from '~/super_sidebar/utils'; +import { getTopFrequentItems, trackContextAccess, ariaCurrent } from '~/super_sidebar/utils'; import axios from '~/lib/utils/axios_utils'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import AccessorUtilities from '~/lib/utils/accessor'; -import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/frequent_items/constants'; +import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/super_sidebar/constants'; import { HTTP_STATUS_OK, HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status'; import waitForPromises from 'helpers/wait_for_promises'; -import { unsortedFrequentItems, sortedFrequentItems } from '../frequent_items/mock_data'; -import { cachedFrequentProjects } from './mock_data'; +import { unsortedFrequentItems, sortedFrequentItems } from './mock_data'; jest.mock('~/sentry/sentry_browser_wrapper'); @@ -24,7 +17,7 @@ describe('Super sidebar utils spec', () => { describe('getTopFrequentItems', () => { const maxItems = 3; - it.each([undefined, null])('returns empty array if `items` is %s', (items) => { + it.each([undefined, null, []])('returns empty array if `items` is %s', (items) => { const result = getTopFrequentItems(items); expect(result.length).toBe(0); @@ -224,125 +217,6 @@ describe('Super sidebar utils spec', () => { }); }); - describe('getItemsFromLocalStorage', () => { - const storageKey = 'mockStorageKey'; - const maxItems = 5; - const storedItems = JSON.parse(cachedFrequentProjects); - - beforeEach(() => { - window.localStorage.setItem(storageKey, cachedFrequentProjects); - }); - - describe('when localStorage cannot be accessed', () => { - beforeEach(() => { - jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false); - }); - - it('returns an empty array', () => { - const items = getItemsFromLocalStorage({ storageKey, maxItems }); - expect(items).toEqual([]); - }); - }); - - describe('when localStorage contains parseable data', () => { - it('returns an array of items limited by max items', () => { - const items = getItemsFromLocalStorage({ storageKey, maxItems }); - expect(items.length).toEqual(maxItems); - - items.forEach((item) => { - expect(storedItems).toContainEqual(item); - }); - }); - - it('returns all items if max items is large', () => { - const items = getItemsFromLocalStorage({ storageKey, maxItems: 1 }); - expect(items.length).toEqual(1); - - expect(storedItems).toContainEqual(items[0]); - }); - }); - - describe('when localStorage contains unparseable data', () => { - let items; - - beforeEach(() => { - window.localStorage.setItem(storageKey, 'unparseable'); - items = getItemsFromLocalStorage({ storageKey, maxItems }); - }); - - it('logs an error to Sentry', () => { - expect(Sentry.captureException).toHaveBeenCalled(); - }); - - it('returns an empty array', () => { - expect(items).toEqual([]); - }); - }); - }); - - describe('removeItemFromLocalStorage', () => { - const storageKey = 'mockStorageKey'; - const originalStoredItems = JSON.parse(cachedFrequentProjects); - - beforeEach(() => { - window.localStorage.setItem(storageKey, cachedFrequentProjects); - }); - - describe('when given an item to delete', () => { - let items; - let modifiedStoredItems; - - beforeEach(() => { - items = removeItemFromLocalStorage({ storageKey, item: { id: 3 } }); - modifiedStoredItems = JSON.parse(window.localStorage.getItem(storageKey)); - }); - - it('removes the item from localStorage', () => { - expect(modifiedStoredItems.length).toBe(originalStoredItems.length - 1); - expect(modifiedStoredItems).not.toContainEqual(originalStoredItems[2]); - }); - - it('returns the resulting stored structure', () => { - expect(items).toEqual(modifiedStoredItems); - }); - }); - - describe('when given an unknown item to delete', () => { - let items; - let modifiedStoredItems; - - beforeEach(() => { - items = removeItemFromLocalStorage({ storageKey, item: { id: 'does-not-exist' } }); - modifiedStoredItems = JSON.parse(window.localStorage.getItem(storageKey)); - }); - - it('does not change the stored value', () => { - expect(modifiedStoredItems).toEqual(originalStoredItems); - }); - - it('returns the stored structure', () => { - expect(items).toEqual(originalStoredItems); - }); - }); - - describe('when localStorage has unparseable data', () => { - let items; - - beforeEach(() => { - window.localStorage.setItem(storageKey, 'unparseable'); - items = removeItemFromLocalStorage({ storageKey, item: { id: 3 } }); - }); - - it('logs an error to Sentry', () => { - expect(Sentry.captureException).toHaveBeenCalled(); - }); - - it('returns an empty array', () => { - expect(items).toEqual([]); - }); - }); - }); - describe('ariaCurrent', () => { it.each` isActive | expected diff --git a/spec/frontend/task_list_spec.js b/spec/frontend/task_list_spec.js index e79c516a694..605ae028049 100644 --- a/spec/frontend/task_list_spec.js +++ b/spec/frontend/task_list_spec.js @@ -126,14 +126,19 @@ describe('TaskList', () => { }); describe('update', () => { - it('should disable task list items and make a patch request then enable them again', () => { - const response = { data: { lock_version: 3 } }; + const setupTaskListAndMocks = (options) => { + taskList = new TaskList(options); + jest.spyOn(taskList, 'enableTaskListItems').mockImplementation(() => {}); jest.spyOn(taskList, 'disableTaskListItems').mockImplementation(() => {}); jest.spyOn(taskList, 'onUpdate').mockImplementation(() => {}); jest.spyOn(taskList, 'onSuccess').mockImplementation(() => {}); - jest.spyOn(axios, 'patch').mockReturnValue(Promise.resolve(response)); + jest.spyOn(axios, 'patch').mockResolvedValue({ data: { lock_version: 3 } }); + + return taskList; + }; + const performTest = (options) => { const value = 'hello world'; const endpoint = '/foo'; const target = $(`<input data-update-url="${endpoint}" value="${value}" />`); @@ -144,10 +149,11 @@ describe('TaskList', () => { lineSource: '- [ ] check item', }; const event = { target, detail }; + const dataType = options.dataType === 'incident' ? 'issue' : options.dataType; const patchData = { - [taskListOptions.dataType]: { - [taskListOptions.fieldName]: value, - lock_version: taskListOptions.lockVersion, + [dataType]: { + [options.fieldName]: value, + lock_version: options.lockVersion, update_task: { index: detail.index, checked: detail.checked, @@ -165,8 +171,42 @@ describe('TaskList', () => { expect(taskList.disableTaskListItems).toHaveBeenCalledWith(event); expect(axios.patch).toHaveBeenCalledWith(endpoint, patchData); expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event); - expect(taskList.onSuccess).toHaveBeenCalledWith(response.data); - expect(taskList.lockVersion).toEqual(response.data.lock_version); + expect(taskList.onSuccess).toHaveBeenCalledWith({ lock_version: 3 }); + expect(taskList.lockVersion).toEqual(3); + }); + }; + + it('should disable task list items and make a patch request then enable them again', () => { + taskList = setupTaskListAndMocks(taskListOptions); + + return performTest(taskListOptions); + }); + + describe('for merge requests', () => { + it('should wrap the patch request payload in merge_request', () => { + const options = { + selector: '.task-list', + dataType: 'merge_request', + fieldName: 'description', + lockVersion: 2, + }; + taskList = setupTaskListAndMocks(options); + + return performTest(options); + }); + }); + + describe('for incidents', () => { + it('should wrap the patch request payload in issue', () => { + const options = { + selector: '.task-list', + dataType: 'incident', + fieldName: 'description', + lockVersion: 2, + }; + taskList = setupTaskListAndMocks(options); + + return performTest(options); }); }); }); diff --git a/spec/frontend/tracking/internal_events_spec.js b/spec/frontend/tracking/internal_events_spec.js index 44a048a4b5f..295b08f4b1c 100644 --- a/spec/frontend/tracking/internal_events_spec.js +++ b/spec/frontend/tracking/internal_events_spec.js @@ -1,15 +1,9 @@ import API from '~/api'; -import { mockTracking } from 'helpers/tracking_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import InternalEvents from '~/tracking/internal_events'; -import { - GITLAB_INTERNAL_EVENT_CATEGORY, - SERVICE_PING_SCHEMA, - LOAD_INTERNAL_EVENTS_SELECTOR, -} from '~/tracking/constants'; +import { LOAD_INTERNAL_EVENTS_SELECTOR } from '~/tracking/constants'; import * as utils from '~/tracking/utils'; import { Tracker } from '~/tracking/tracker'; -import { extraContext } from './mock_data'; jest.mock('~/api', () => ({ trackInternalEvent: jest.fn(), @@ -41,26 +35,6 @@ describe('InternalEvents', () => { expect(InternalEvents.trackBrowserSDK).toHaveBeenCalledTimes(1); expect(InternalEvents.trackBrowserSDK).toHaveBeenCalledWith(event); }); - - it('trackEvent calls tracking.event functions with correct arguments', () => { - const trackingSpy = mockTracking(GITLAB_INTERNAL_EVENT_CATEGORY, undefined, jest.spyOn); - - InternalEvents.trackEvent(event, { context: extraContext }); - - expect(trackingSpy).toHaveBeenCalledTimes(1); - expect(trackingSpy).toHaveBeenCalledWith(GITLAB_INTERNAL_EVENT_CATEGORY, event, { - context: [ - { - schema: SERVICE_PING_SCHEMA, - data: { - event_name: event, - data_source: 'redis_hll', - }, - }, - extraContext, - ], - }); - }); }); describe('mixin', () => { @@ -68,17 +42,13 @@ describe('InternalEvents', () => { const Component = { template: ` <div> - <button data-testid="button1" @click="handleButton1Click">Button 1</button> - <button data-testid="button2" @click="handleButton2Click">Button 2</button> + <button data-testid="button" @click="handleButton1Click">Button</button> </div> `, methods: { handleButton1Click() { this.trackEvent(event); }, - handleButton2Click() { - this.trackEvent(event, extraContext); - }, }, mixins: [InternalEvents.mixin()], }; @@ -90,20 +60,10 @@ describe('InternalEvents', () => { it('this.trackEvent function calls InternalEvent`s track function with an event', async () => { const trackEventSpy = jest.spyOn(InternalEvents, 'trackEvent'); - await wrapper.findByTestId('button1').trigger('click'); - - expect(trackEventSpy).toHaveBeenCalledTimes(1); - expect(trackEventSpy).toHaveBeenCalledWith(event, {}); - }); - - it("this.trackEvent function calls InternalEvent's track function with an event and data", async () => { - const data = extraContext; - const trackEventSpy = jest.spyOn(InternalEvents, 'trackEvent'); - - await wrapper.findByTestId('button2').trigger('click'); + await wrapper.findByTestId('button').trigger('click'); expect(trackEventSpy).toHaveBeenCalledTimes(1); - expect(trackEventSpy).toHaveBeenCalledWith(event, data); + expect(trackEventSpy).toHaveBeenCalledWith(event); }); }); diff --git a/spec/frontend/usage_quotas/storage/components/project_storage_app_spec.js b/spec/frontend/usage_quotas/storage/components/project_storage_app_spec.js index 0ae01083a09..babefe1dd19 100644 --- a/spec/frontend/usage_quotas/storage/components/project_storage_app_spec.js +++ b/spec/frontend/usage_quotas/storage/components/project_storage_app_spec.js @@ -16,7 +16,8 @@ import { NAMESPACE_STORAGE_TYPES, TOTAL_USAGE_DEFAULT_TEXT, } from '~/usage_quotas/storage/constants'; -import getProjectStorageStatistics from '~/usage_quotas/storage/queries/project_storage.query.graphql'; +import getCostFactoredProjectStorageStatistics from 'ee_else_ce/usage_quotas/storage/queries/cost_factored_project_storage.query.graphql'; +import getProjectStorageStatistics from 'ee_else_ce/usage_quotas/storage/queries/project_storage.query.graphql'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { mockGetProjectStorageStatisticsGraphQLResponse, @@ -38,7 +39,10 @@ describe('ProjectStorageApp', () => { response = jest.fn().mockResolvedValue(mockedValue); } - const requestHandlers = [[getProjectStorageStatistics, response]]; + const requestHandlers = [ + [getProjectStorageStatistics, response], + [getCostFactoredProjectStorageStatistics, response], + ]; return createMockApollo(requestHandlers); }; @@ -187,4 +191,30 @@ describe('ProjectStorageApp', () => { ]); }); }); + + describe('when displayCostFactoredStorageSizeOnProjectPages feature flag is enabled', () => { + let mockApollo; + beforeEach(async () => { + mockApollo = createMockApolloProvider({ + mockedValue: mockGetProjectStorageStatisticsGraphQLResponse, + }); + createComponent({ + mockApollo, + provide: { + glFeatures: { + displayCostFactoredStorageSizeOnProjectPages: true, + }, + }, + }); + await waitForPromises(); + }); + + it('renders correct total usage', () => { + const expectedValue = numberToHumanSize( + mockGetProjectStorageStatisticsGraphQLResponse.data.project.statistics.storageSize, + 1, + ); + expect(findUsagePercentage().text()).toBe(expectedValue); + }); + }); }); diff --git a/spec/frontend/user_lists/components/user_lists_table_spec.js b/spec/frontend/user_lists/components/user_lists_table_spec.js index 96e9705f02b..26b33bcd46d 100644 --- a/spec/frontend/user_lists/components/user_lists_table_spec.js +++ b/spec/frontend/user_lists/components/user_lists_table_spec.js @@ -5,6 +5,7 @@ import { nextTick } from 'vue'; import { timeagoLanguageCode } from '~/lib/utils/datetime/timeago_utility'; import UserListsTable from '~/user_lists/components/user_lists_table.vue'; import { userList } from 'jest/feature_flags/mock_data'; +import { localeDateFormat } from '~/lib/utils/datetime/locale_dateformat'; jest.mock('timeago.js', () => ({ format: jest.fn().mockReturnValue('2 weeks ago'), @@ -33,7 +34,7 @@ describe('User Lists Table', () => { it('should set the title for a tooltip on the created stamp', () => { expect(wrapper.find('[data-testid="ffUserListTimestamp"]').attributes('title')).toBe( - 'Feb 4, 2020 8:13am UTC', + localeDateFormat.asDateTimeFull.format(userList.created_at), ); }); 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 c81f4328d2a..c3ed131d6e3 100644 --- a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js @@ -1,11 +1,11 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { GlButton, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import { createMockSubscription as createMockApolloSubscription } from 'mock-apollo-client'; import approvedByCurrentUser from 'test_fixtures/graphql/merge_requests/approvals/approvals.query.graphql.json'; -import { visitUrl } from '~/lib/utils/url_utility'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { createAlert } from '~/alert'; @@ -29,11 +29,6 @@ jest.mock('~/alert', () => ({ dismiss: mockAlertDismiss, })), })); -jest.mock('~/lib/utils/url_utility', () => ({ - ...jest.requireActual('~/lib/utils/url_utility'), - visitUrl: jest.fn(), -})); - const TEST_HELP_PATH = 'help/path'; const testApprovedBy = () => [1, 7, 10].map((id) => ({ id })); const testApprovals = () => ({ @@ -53,6 +48,9 @@ describe('MRWidget approvals', () => { let wrapper; let service; let mr; + const submitSpy = jest.fn().mockImplementation((e) => { + e.preventDefault(); + }); const createComponent = (options = {}, responses = { query: approvedByCurrentUser }) => { mockedSubscription = createMockApolloSubscription(); @@ -68,7 +66,7 @@ describe('MRWidget approvals', () => { apolloProvider.defaultClient.setRequestHandler(query, stream); }); - wrapper = shallowMount(Approvals, { + wrapper = shallowMountExtended(Approvals, { apolloProvider, propsData: { mr, @@ -78,7 +76,18 @@ describe('MRWidget approvals', () => { provide, stubs: { GlSprintf, + GlForm: { + data() { + return { submitSpy }; + }, + // Workaround jsdom not implementing form submit + template: '<form @submit="submitSpy"><slot></slot></form>', + }, + GlButton: stubComponent(GlButton, { + template: '<button><slot></slot></button>', + }), }, + attachTo: document.body, }); }; @@ -257,11 +266,11 @@ describe('MRWidget approvals', () => { }); describe('when SAML auth is required and user clicks Approve with SAML', () => { - const fakeGroupSamlPath = '/example_group_saml'; + const fakeSamlPath = '/example_group_saml'; beforeEach(async () => { mr.requireSamlAuthToApprove = true; - mr.samlApprovalPath = fakeGroupSamlPath; + mr.samlApprovalPath = fakeSamlPath; createComponent({}, { query: createCanApproveResponse() }); await waitForPromises(); @@ -269,9 +278,10 @@ describe('MRWidget approvals', () => { it('redirects the user to the group SAML path', async () => { const action = findAction(); - action.vm.$emit('click'); - await nextTick(); - expect(visitUrl).toHaveBeenCalledWith(fakeGroupSamlPath); + + await action.trigger('click'); + + expect(submitSpy).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/checks/draft_spec.js b/spec/frontend/vue_merge_request_widget/components/checks/draft_spec.js new file mode 100644 index 00000000000..cc605c8c83d --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/checks/draft_spec.js @@ -0,0 +1,196 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; + +import getStateQueryResponse from 'test_fixtures/graphql/merge_requests/get_state.query.graphql.json'; + +import { createAlert } from '~/alert'; + +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +import MergeRequest from '~/merge_request'; + +import DraftCheck from '~/vue_merge_request_widget/components/checks/draft.vue'; +import { + DRAFT_CHECK_READY, + DRAFT_CHECK_ERROR, +} from '~/vue_merge_request_widget/components/checks/i18n'; +import { FAILURE_REASONS } from '~/vue_merge_request_widget/components/checks/message.vue'; + +import draftQuery from '~/vue_merge_request_widget/queries/states/draft.query.graphql'; +import getStateQuery from '~/vue_merge_request_widget/queries/get_state.query.graphql'; +import removeDraftMutation from '~/vue_merge_request_widget/queries/toggle_draft.mutation.graphql'; + +Vue.use(VueApollo); + +const TEST_PROJECT_ID = getStateQueryResponse.data.project.id; +const TEST_MR_ID = getStateQueryResponse.data.project.mergeRequest.id; +const TEST_MR_IID = '23'; +const TEST_MR_TITLE = 'Test MR Title'; +const TEST_PROJECT_PATH = 'lorem/ipsum'; + +jest.mock('~/alert'); +jest.mock('~/merge_request', () => ({ toggleDraftStatus: jest.fn() })); + +describe('~/vue_merge_request_widget/components/checks/draft.vue', () => { + let wrapper; + let apolloProvider; + + let draftQuerySpy; + let removeDraftMutationSpy; + + const findMarkReadyButton = () => wrapper.findByTestId('mark-as-ready-button'); + + const createDraftQueryResponse = (canUpdateMergeRequest) => ({ + data: { + project: { + __typename: 'Project', + id: TEST_PROJECT_ID, + mergeRequest: { + __typename: 'MergeRequest', + id: TEST_MR_ID, + draft: true, + title: TEST_MR_TITLE, + mergeableDiscussionsState: false, + userPermissions: { + updateMergeRequest: canUpdateMergeRequest, + }, + }, + }, + }, + }); + const createRemoveDraftMutationResponse = () => ({ + data: { + mergeRequestSetDraft: { + __typename: 'MergeRequestSetWipPayload', + errors: [], + mergeRequest: { + __typename: 'MergeRequest', + id: TEST_MR_ID, + title: TEST_MR_TITLE, + draft: false, + mergeableDiscussionsState: true, + }, + }, + }, + }); + + const createComponent = async () => { + wrapper = mountExtended(DraftCheck, { + apolloProvider, + propsData: { + mr: { + issuableId: TEST_MR_ID, + title: TEST_MR_TITLE, + iid: TEST_MR_IID, + targetProjectFullPath: TEST_PROJECT_PATH, + }, + check: { + identifier: 'draft_status', + status: 'FAILED', + }, + }, + }); + + await waitForPromises(); + + // why: draft.vue has some coupling that this query has been read before + // for some reason this has to happen **after** the component has mounted + // or apollo throws errors. + apolloProvider.defaultClient.cache.writeQuery({ + query: getStateQuery, + variables: { + projectPath: TEST_PROJECT_PATH, + iid: TEST_MR_IID, + }, + data: getStateQueryResponse.data, + }); + }; + + beforeEach(() => { + draftQuerySpy = jest.fn().mockResolvedValue(createDraftQueryResponse(true)); + removeDraftMutationSpy = jest.fn().mockResolvedValue(createRemoveDraftMutationResponse()); + + apolloProvider = createMockApollo([ + [draftQuery, draftQuerySpy], + [removeDraftMutation, removeDraftMutationSpy], + ]); + }); + + describe('when user can update MR', () => { + beforeEach(async () => { + await createComponent(); + }); + + it('renders text', () => { + const message = wrapper.text(); + expect(message).toContain(FAILURE_REASONS.draft_status); + }); + + it('renders mark ready button', () => { + expect(findMarkReadyButton().text()).toBe(DRAFT_CHECK_READY); + }); + + it('does not call remove draft mutation', () => { + expect(removeDraftMutationSpy).not.toHaveBeenCalled(); + }); + + describe('when mark ready button is clicked', () => { + beforeEach(async () => { + findMarkReadyButton().vm.$emit('click'); + + await waitForPromises(); + }); + + it('calls mutation spy', () => { + expect(removeDraftMutationSpy).toHaveBeenCalledWith({ + draft: false, + iid: TEST_MR_IID, + projectPath: TEST_PROJECT_PATH, + }); + }); + + it('does not create alert', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); + + it('calls toggleDraftStatus', () => { + expect(MergeRequest.toggleDraftStatus).toHaveBeenCalledWith(TEST_MR_TITLE, true); + }); + }); + + describe('when mutation fails and ready button is clicked', () => { + beforeEach(async () => { + removeDraftMutationSpy.mockRejectedValue(new Error('TEST FAIL')); + findMarkReadyButton().vm.$emit('click'); + + await waitForPromises(); + }); + + it('creates alert', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: DRAFT_CHECK_ERROR, + }); + }); + + it('does not call toggleDraftStatus', () => { + expect(MergeRequest.toggleDraftStatus).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when user cannot update MR', () => { + beforeEach(async () => { + draftQuerySpy.mockResolvedValue(createDraftQueryResponse(false)); + + createComponent(); + + await waitForPromises(); + }); + + it('does not render mark ready button', () => { + expect(findMarkReadyButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/checks/rebase_spec.js b/spec/frontend/vue_merge_request_widget/components/checks/rebase_spec.js index d6c01aee3b1..d621999337d 100644 --- a/spec/frontend/vue_merge_request_widget/components/checks/rebase_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/checks/rebase_spec.js @@ -28,7 +28,7 @@ const mockPipelineNodes = [ const mockQueryHandler = ({ rebaseInProgress = false, targetBranch = '', - pushToSourceBranch = false, + pushToSourceBranch = true, nodes = mockPipelineNodes, } = {}) => jest.fn().mockResolvedValue({ @@ -279,7 +279,7 @@ describe('Merge request merge checks rebase component', () => { await waitForPromises(); - expect(findRebaseWithoutCiButton().exists()).toBe(true); + expect(findRebaseWithoutCiButton().exists()).toBe(false); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js b/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js index d39098b27c2..b19095cc686 100644 --- a/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js @@ -138,7 +138,7 @@ describe('Merge request merge checks component', () => { it.each` identifier ${'conflict'} - ${'unresolved_discussions'} + ${'discussions_not_resolved'} ${'need_rebase'} ${'default'} `('renders $identifier merge check', async ({ identifier }) => { diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_expandable_section_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_expandable_section_spec.js index 8eaed998eb5..5a5d29d3194 100644 --- a/spec/frontend/vue_merge_request_widget/components/mr_widget_expandable_section_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_expandable_section_spec.js @@ -39,7 +39,7 @@ describe('MrWidgetExpanableSection', () => { const collapse = findCollapse(); expect(collapse.exists()).toBe(true); - expect(collapse.attributes('visible')).toBeUndefined(); + expect(collapse.props('visible')).toBe(false); }); }); @@ -60,7 +60,7 @@ describe('MrWidgetExpanableSection', () => { const collapse = findCollapse(); expect(collapse.exists()).toBe(true); - expect(collapse.attributes('visible')).toBe('true'); + expect(collapse.props('visible')).toBe(true); }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js index 35b4e222e01..3f0eb946194 100644 --- a/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js @@ -8,6 +8,7 @@ import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import MRWidgetPipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; import { SUCCESS } from '~/vue_merge_request_widget/constants'; +import { localeDateFormat } from '~/lib/utils/datetime/locale_dateformat'; import mockData from '../mock_data'; describe('MRWidgetPipeline', () => { @@ -93,7 +94,7 @@ describe('MRWidgetPipeline', () => { it('should render pipeline finished timestamp', () => { expect(findPipelineFinishedAt().attributes()).toMatchObject({ - title: 'Apr 7, 2017 2:00pm UTC', + title: localeDateFormat.asDateTimeFull.format(mockData.pipeline.details.finished_at), datetime: mockData.pipeline.details.finished_at, }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js index b210327aa31..65c4970bc76 100644 --- a/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js @@ -54,5 +54,12 @@ describe('MR widget status icon component', () => { expect(findIcon().exists()).toBe(true); expect(findIcon().props().name).toBe('merge-request-close'); }); + + it('renders empty status icon', () => { + createWrapper({ status: 'empty' }); + + expect(findStatusIcon().exists()).toBe(true); + expect(findStatusIcon().props().iconName).toBe('neutral'); + }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap b/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap index ecf4040cbda..ec0af7c8a7b 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap +++ b/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap @@ -8,7 +8,7 @@ exports[`New ready to merge state component renders permission text if canMerge status="success" /> <p - class="gl-font-weight-bold gl-m-0! gl-text-gray-900! media-body" + class="gl-font-weight-bold gl-mb-0! gl-mt-1 gl-text-gray-900! media-body" > Ready to merge by members who can write to the target branch. </p> @@ -23,7 +23,7 @@ exports[`New ready to merge state component renders permission text if canMerge status="success" /> <p - class="gl-font-weight-bold gl-m-0! gl-text-gray-900! media-body" + class="gl-font-weight-bold gl-mb-0! gl-mt-1 gl-text-gray-900! media-body" > Ready to merge! </p> diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_conflicts_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_conflicts_spec.js index 7f0a171d712..af10d7d5eb7 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_conflicts_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_conflicts_spec.js @@ -1,10 +1,17 @@ import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; import { TEST_HOST } from 'helpers/test_constants'; import { removeBreakLine } from 'helpers/text_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import userPermissionsQuery from '~/vue_merge_request_widget/queries/permissions.query.graphql'; +import conflictsStateQuery from '~/vue_merge_request_widget/queries/states/conflicts.query.graphql'; import ConflictsComponent from '~/vue_merge_request_widget/components/states/mr_widget_conflicts.vue'; +Vue.use(VueApollo); + describe('MRWidgetConflicts', () => { let wrapper; const path = '/conflicts'; @@ -20,34 +27,57 @@ describe('MRWidgetConflicts', () => { const resolveConflictsBtnText = 'Resolve conflicts'; const mergeLocallyBtnText = 'Resolve locally'; - async function createComponent(propsData = {}) { - wrapper = extendedWrapper( - mount(ConflictsComponent, { - propsData, - data() { - return { + const defaultApolloProvider = (mockData = {}) => { + const userData = { + data: { + project: { + id: 234, + mergeRequest: { + id: 234, userPermissions: { - canMerge: propsData.mr.canMerge, - pushToSourceBranch: propsData.mr.canPushToSourceBranch, - }, - state: { - shouldBeRebased: propsData.mr.shouldBeRebased, - sourceBranchProtected: propsData.mr.sourceBranchProtected, + canMerge: mockData.canMerge || false, + pushToSourceBranch: mockData.canPushToSourceBranch || false, }, - }; + }, }, - mocks: { - $apollo: { - queries: { - userPermissions: { loading: false }, - stateData: { loading: false }, + }, + }; + + const mrData = { + data: { + project: { + id: 234, + mergeRequest: { + id: 234, + shouldBeRebased: mockData.shouldBeRebased || false, + sourceBranchProtected: mockData.sourceBranchProtected || false, + userPermissions: { + pushToSourceBranch: mockData.canPushToSourceBranch || false, }, }, }, + }, + }; + + return createMockApollo([ + [userPermissionsQuery, jest.fn().mockResolvedValue(userData)], + [conflictsStateQuery, jest.fn().mockResolvedValue(mrData)], + ]); + }; + + async function createComponent({ + propsData, + queryData, + apolloProvider = defaultApolloProvider(queryData), + } = {}) { + wrapper = extendedWrapper( + mount(ConflictsComponent, { + apolloProvider, + propsData, }), ); - await nextTick(); + await waitForPromises(); } // There are two permissions we need to consider: @@ -62,11 +92,15 @@ describe('MRWidgetConflicts', () => { describe('when allowed to merge but not allowed to push to source branch', () => { beforeEach(async () => { await createComponent({ - mr: { + propsData: { + mr: { + conflictsDocsPath: '', + }, + }, + queryData: { canMerge: true, canPushToSourceBranch: false, conflictResolutionPath: path, - conflictsDocsPath: '', }, }); }); @@ -89,11 +123,15 @@ describe('MRWidgetConflicts', () => { describe('when not allowed to merge but allowed to push to source branch', () => { beforeEach(async () => { await createComponent({ - mr: { + propsData: { + mr: { + conflictResolutionPath: path, + conflictsDocsPath: '', + }, + }, + queryData: { canMerge: false, canPushToSourceBranch: true, - conflictResolutionPath: path, - conflictsDocsPath: '', }, }); }); @@ -116,11 +154,15 @@ describe('MRWidgetConflicts', () => { describe('when allowed to merge and push to source branch', () => { beforeEach(async () => { await createComponent({ - mr: { + queryData: { canMerge: true, canPushToSourceBranch: true, - conflictResolutionPath: path, - conflictsDocsPath: '', + }, + propsData: { + mr: { + conflictResolutionPath: path, + conflictsDocsPath: '', + }, }, }); }); @@ -144,10 +186,14 @@ describe('MRWidgetConflicts', () => { describe('when user does not have permission to push to source branch', () => { it('should show proper message', async () => { await createComponent({ - mr: { + propsData: { + mr: { + conflictsDocsPath: '', + }, + }, + queryData: { canMerge: false, canPushToSourceBranch: false, - conflictsDocsPath: '', }, }); @@ -156,10 +202,14 @@ describe('MRWidgetConflicts', () => { it('should not have action buttons', async () => { await createComponent({ - mr: { + queryData: { canMerge: false, canPushToSourceBranch: false, - conflictsDocsPath: '', + }, + propsData: { + mr: { + conflictsDocsPath: '', + }, }, }); @@ -169,10 +219,14 @@ describe('MRWidgetConflicts', () => { it('should not have resolve button when no conflict resolution path', async () => { await createComponent({ - mr: { + propsData: { + mr: { + conflictResolutionPath: null, + conflictsDocsPath: '', + }, + }, + queryData: { canMerge: true, - conflictResolutionPath: null, - conflictsDocsPath: '', }, }); @@ -183,9 +237,13 @@ describe('MRWidgetConflicts', () => { describe('when fast-forward or semi-linear merge enabled', () => { it('should tell you to rebase locally', async () => { await createComponent({ - mr: { + propsData: { + mr: { + conflictsDocsPath: '', + }, + }, + queryData: { shouldBeRebased: true, - conflictsDocsPath: '', }, }); @@ -196,12 +254,16 @@ describe('MRWidgetConflicts', () => { describe('when source branch protected', () => { beforeEach(async () => { await createComponent({ - mr: { + propsData: { + mr: { + conflictResolutionPath: TEST_HOST, + conflictsDocsPath: '', + }, + }, + queryData: { canMerge: true, - canPushToSourceBranch: true, - conflictResolutionPath: TEST_HOST, sourceBranchProtected: true, - conflictsDocsPath: '', + canPushToSourceBranch: true, }, }); }); @@ -214,12 +276,16 @@ describe('MRWidgetConflicts', () => { describe('when source branch not protected', () => { beforeEach(async () => { await createComponent({ - mr: { - canMerge: true, + propsData: { + mr: { + conflictResolutionPath: TEST_HOST, + conflictsDocsPath: '', + }, + }, + queryData: { canPushToSourceBranch: true, - conflictResolutionPath: TEST_HOST, + canMerge: true, sourceBranchProtected: false, - conflictsDocsPath: '', }, }); }); @@ -229,4 +295,21 @@ describe('MRWidgetConflicts', () => { expect(findResolveButton().attributes('href')).toEqual(TEST_HOST); }); }); + + describe('error states', () => { + it('when project is null due to expired session it does not throw', async () => { + const fn = async () => { + await createComponent({ + propsData: { mr: {} }, + apolloProvider: createMockApollo([ + [conflictsStateQuery, jest.fn().mockResolvedValue({ data: { project: null } })], + [userPermissionsQuery, jest.fn().mockResolvedValue({ data: { project: null } })], + ]), + }); + await waitForPromises(); + }; + + await expect(fn()).resolves.not.toThrow(); + }); + }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js index 85acd5f9a9e..328c0134368 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js @@ -1,8 +1,12 @@ import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import simplePoll from '~/lib/utils/simple_poll'; import MrWidgetMerging from '~/vue_merge_request_widget/components/states/mr_widget_merging.vue'; import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; +import { STATUS_MERGED } from '~/issues/constants'; +import { fetchUserCounts } from '~/super_sidebar/user_counts_fetch'; +jest.mock('~/super_sidebar/user_counts_fetch'); jest.mock('~/lib/utils/simple_poll', () => jest.fn().mockImplementation(jest.requireActual('~/lib/utils/simple_poll').default), ); @@ -13,7 +17,7 @@ describe('MRWidgetMerging', () => { const pollMock = jest.fn().mockResolvedValue(); const GlEmoji = { template: '<img />' }; - beforeEach(() => { + const createComponent = () => { wrapper = shallowMount(MrWidgetMerging, { propsData: { mr: { @@ -29,14 +33,18 @@ describe('MRWidgetMerging', () => { GlEmoji, }, }); - }); + }; it('renders information about merge request being merged', () => { + createComponent(); + const message = wrapper.findComponent(BoldText).props('message'); expect(message).toContain('Merging!'); }); describe('initiateMergePolling', () => { + beforeEach(createComponent); + it('should call simplePoll', () => { expect(simplePoll).toHaveBeenCalledWith(expect.any(Function), { timeout: 0 }); }); @@ -45,4 +53,15 @@ describe('MRWidgetMerging', () => { expect(pollMock).toHaveBeenCalled(); }); }); + + describe('on successful merge', () => { + it('should re-fetch user counts', async () => { + pollMock.mockResolvedValueOnce({ data: { state: STATUS_MERGED } }); + createComponent(); + + await nextTick(); + + expect(fetchUserCounts).toHaveBeenCalled(); + }); + }); }); 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 016eac05727..d8eec165395 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 @@ -1,5 +1,6 @@ -import { GlSprintf } from '@gitlab/ui'; +import { GlSprintf, GlLink } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { helpPagePath } from '~/helpers/help_page_helper'; import NothingToMerge from '~/vue_merge_request_widget/components/states/nothing_to_merge.vue'; describe('NothingToMerge', () => { @@ -14,6 +15,7 @@ describe('NothingToMerge', () => { }; const findNothingToMergeTextBody = () => wrapper.findByTestId('nothing-to-merge-body'); + const findHelpLink = () => wrapper.findComponent(GlLink); describe('With Blob link', () => { beforeEach(() => { @@ -26,5 +28,9 @@ describe('NothingToMerge', () => { '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('renders text with link to CI Help Page', () => { + expect(findHelpLink().attributes('href')).toBe(helpPagePath('ci/quick_start/index.html')); + }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js index 9239807ae71..1b7338744e8 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -1,9 +1,10 @@ -import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import { GlSprintf } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; import produce from 'immer'; +import { createMockSubscription as createMockApolloSubscription } from 'mock-apollo-client'; import readyToMergeResponse from 'test_fixtures/graphql/merge_requests/states/ready_to_merge.query.graphql.json'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql'; @@ -15,13 +16,11 @@ import SquashBeforeMerge from '~/vue_merge_request_widget/components/states/squa import MergeFailedPipelineConfirmationDialog from '~/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog.vue'; import { MWPS_MERGE_STRATEGY } from '~/vue_merge_request_widget/constants'; import eventHub from '~/vue_merge_request_widget/event_hub'; +import readyToMergeSubscription from '~/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql'; jest.mock('~/lib/utils/simple_poll', () => jest.fn().mockImplementation(jest.requireActual('~/lib/utils/simple_poll').default), ); -jest.mock('~/commons/nav/user_merge_requests', () => ({ - refreshUserMergeRequestCounts: jest.fn(), -})); const commitMessage = readyToMergeResponse.data.project.mergeRequest.defaultMergeCommitMessage; const squashCommitMessage = @@ -82,6 +81,7 @@ Vue.use(VueApollo); let service; let wrapper; let readyToMergeResponseSpy; +let mockedSubscription; const createReadyToMergeResponse = (customMr) => { return produce(readyToMergeResponse, (draft) => { @@ -90,7 +90,21 @@ const createReadyToMergeResponse = (customMr) => { }; const createComponent = (customConfig = {}, createState = true) => { - wrapper = shallowMount(ReadyToMerge, { + mockedSubscription = createMockApolloSubscription(); + const apolloProvider = createMockApollo([[readyToMergeQuery, readyToMergeResponseSpy]]); + const subscriptionResponse = { + data: { mergeRequestMergeStatusUpdated: { ...readyToMergeResponse.data.project.mergeRequest } }, + }; + subscriptionResponse.data.mergeRequestMergeStatusUpdated.defaultMergeCommitMessage = + 'New default merge commit message'; + + const subscriptionHandlers = [[readyToMergeSubscription, () => mockedSubscription]]; + + subscriptionHandlers.forEach(([query, stream]) => { + apolloProvider.defaultClient.setRequestHandler(query, stream); + }); + + wrapper = shallowMountExtended(ReadyToMerge, { propsData: { mr: createTestMr(customConfig), service, @@ -112,7 +126,7 @@ const createComponent = (customConfig = {}, createState = true) => { CommitEdit, GlSprintf, }, - apolloProvider: createMockApollo([[readyToMergeQuery, readyToMergeResponseSpy]]), + apolloProvider, }); }; @@ -843,4 +857,60 @@ describe('ReadyToMerge', () => { expect(wrapper.text()).not.toContain('Auto-merge enabled'); }); }); + + describe('commit message', () => { + it('updates commit message from subscription', async () => { + createComponent({ mr: { id: 1 } }); + + await waitForPromises(); + + await wrapper.findByTestId('widget_edit_commit_message').vm.$emit('input', true); + + expect(wrapper.findByTestId('merge-commit-message').props('value')).not.toEqual( + 'Updated commit message', + ); + + mockedSubscription.next({ + data: { + mergeRequestMergeStatusUpdated: { + ...readyToMergeResponse.data.project.mergeRequest, + defaultMergeCommitMessage: 'Updated commit message', + }, + }, + }); + + await waitForPromises(); + + expect(wrapper.findByTestId('merge-commit-message').props('value')).toEqual( + 'Updated commit message', + ); + }); + + it('does not update commit message from subscription if commit message has been manually changed', async () => { + createComponent({ mr: { id: 1 } }); + + await waitForPromises(); + + await wrapper.findByTestId('widget_edit_commit_message').vm.$emit('input', true); + + await wrapper + .findByTestId('merge-commit-message') + .vm.$emit('input', 'Manually updated commit message'); + + mockedSubscription.next({ + data: { + mergeRequestMergeStatusUpdated: { + ...readyToMergeResponse.data.project.mergeRequest, + defaultMergeCommitMessage: 'Updated commit message', + }, + }, + }); + + await waitForPromises(); + + expect(wrapper.findByTestId('merge-commit-message').props('value')).toEqual( + 'Manually updated commit message', + ); + }); + }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/work_in_progress_spec.js b/spec/frontend/vue_merge_request_widget/components/states/work_in_progress_spec.js index f46829539a8..f01df2ca419 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/work_in_progress_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/work_in_progress_spec.js @@ -42,6 +42,9 @@ describe('~/vue_merge_request_widget/components/states/work_in_progress.vue', () mergeRequest: { __typename: 'MergeRequest', id: TEST_MR_ID, + draft: true, + title: TEST_MR_TITLE, + mergeableDiscussionsState: false, userPermissions: { updateMergeRequest: canUpdateMergeRequest, }, @@ -179,4 +182,17 @@ describe('~/vue_merge_request_widget/components/states/work_in_progress.vue', () expect(findWIPButton().exists()).toBe(false); }); }); + + describe('when project is null', () => { + beforeEach(async () => { + draftQuerySpy.mockResolvedValue({ data: { project: null } }); + createComponent(); + await waitForPromises(); + }); + + // This is to mitigate https://gitlab.com/gitlab-org/gitlab/-/issues/413627 + it('does not throw any error', () => { + expect(wrapper.exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap b/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap index d5d3f56e451..f2a66ad2ff2 100644 --- a/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap +++ b/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap @@ -49,7 +49,7 @@ exports[`~/vue_merge_request_widget/components/widget/dynamic_content.vue render name="MyWidget" /> <div - class="gl-display-flex gl-w-full" + class="gl-display-flex gl-flex-direction-column gl-w-full" > <div class="gl-display-flex gl-flex-grow-1" @@ -88,8 +88,7 @@ exports[`~/vue_merge_request_widget/components/widget/dynamic_content.vue render > <li> <div - class="gl-align-items-center gl-display-flex" - data-qa-selector="child_content" + class="gl-align-items-baseline gl-display-flex" > <div class="gl-min-w-0 gl-w-full" @@ -111,7 +110,7 @@ exports[`~/vue_merge_request_widget/components/widget/dynamic_content.vue render class="gl-align-items-baseline gl-display-flex" > <div - class="gl-display-flex gl-w-full" + class="gl-display-flex gl-flex-direction-column gl-w-full" > <div class="gl-display-flex gl-flex-grow-1" diff --git a/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js b/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js deleted file mode 100644 index d5e04c666e0..00000000000 --- a/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js +++ /dev/null @@ -1,224 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import { GlBadge } from '@gitlab/ui'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import { trimText } from 'helpers/text_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import axios from '~/lib/utils/axios_utils'; -import codeQualityExtension from '~/vue_merge_request_widget/extensions/code_quality/index.vue'; -import { - HTTP_STATUS_INTERNAL_SERVER_ERROR, - HTTP_STATUS_NO_CONTENT, - HTTP_STATUS_OK, -} from '~/lib/utils/http_status'; -import { - i18n, - codeQualityPrefixes, -} from '~/vue_merge_request_widget/extensions/code_quality/constants'; -import { - codeQualityResponseNewErrors, - codeQualityResponseResolvedErrors, - codeQualityResponseResolvedAndNewErrors, - codeQualityResponseNoErrors, -} from './mock_data'; - -describe('Code Quality extension', () => { - let wrapper; - let mock; - const endpoint = '/root/repo/-/merge_requests/4/codequality_reports.json'; - - const mockApi = (statusCode, data) => { - mock.onGet(endpoint).reply(statusCode, data); - }; - - const findToggleCollapsedButton = () => wrapper.findByTestId('toggle-button'); - const findAllExtensionListItems = () => wrapper.findAllByTestId('extension-list-item'); - const isCollapsable = () => wrapper.findByTestId('toggle-button').exists(); - const getNeutralIcon = () => wrapper.findByTestId('status-neutral-icon').exists(); - const getAlertIcon = () => wrapper.findByTestId('status-alert-icon').exists(); - const getSuccessIcon = () => wrapper.findByTestId('status-success-icon').exists(); - - const createComponent = () => { - wrapper = mountExtended(codeQualityExtension, { - propsData: { - mr: { - codequality: endpoint, - codequalityReportsPath: endpoint, - blobPath: { - head_path: 'example/path', - base_path: 'example/path', - }, - }, - }, - }); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - describe('summary', () => { - it('displays loading text', () => { - mockApi(HTTP_STATUS_OK, codeQualityResponseNewErrors); - - createComponent(); - - expect(wrapper.text()).toBe(i18n.loading); - }); - - it('with a 204 response, continues to display loading state', async () => { - mockApi(HTTP_STATUS_NO_CONTENT, ''); - createComponent(); - - await waitForPromises(); - - expect(wrapper.text()).toBe(i18n.loading); - }); - - it('displays failed loading text', async () => { - mockApi(HTTP_STATUS_INTERNAL_SERVER_ERROR); - - createComponent(); - - await waitForPromises(); - - expect(wrapper.text()).toBe(i18n.error); - expect(isCollapsable()).toBe(false); - }); - - it('displays new Errors finding', async () => { - mockApi(HTTP_STATUS_OK, codeQualityResponseNewErrors); - - createComponent(); - - await waitForPromises(); - expect(wrapper.text()).toBe( - i18n - .singularCopy( - i18n.findings(codeQualityResponseNewErrors.new_errors, codeQualityPrefixes.new), - ) - .replace(/%{strong_start}/g, '') - .replace(/%{strong_end}/g, ''), - ); - expect(isCollapsable()).toBe(true); - expect(getAlertIcon()).toBe(true); - }); - - it('displays resolved Errors finding', async () => { - mockApi(HTTP_STATUS_OK, codeQualityResponseResolvedErrors); - - createComponent(); - - await waitForPromises(); - expect(wrapper.text()).toBe( - i18n - .singularCopy( - i18n.findings( - codeQualityResponseResolvedErrors.resolved_errors, - codeQualityPrefixes.fixed, - ), - ) - .replace(/%{strong_start}/g, '') - .replace(/%{strong_end}/g, ''), - ); - expect(isCollapsable()).toBe(true); - expect(getSuccessIcon()).toBe(true); - }); - - it('displays quality improvement and degradation', async () => { - mockApi(HTTP_STATUS_OK, codeQualityResponseResolvedAndNewErrors); - - createComponent(); - await waitForPromises(); - - // replacing strong tags because they will not be found in the rendered text - expect(wrapper.text()).toBe( - i18n - .improvementAndDegradationCopy( - i18n.findings( - codeQualityResponseResolvedAndNewErrors.resolved_errors, - codeQualityPrefixes.fixed, - ), - i18n.findings( - codeQualityResponseResolvedAndNewErrors.new_errors, - codeQualityPrefixes.new, - ), - ) - .replace(/%{strong_start}/g, '') - .replace(/%{strong_end}/g, ''), - ); - expect(isCollapsable()).toBe(true); - expect(getAlertIcon()).toBe(true); - }); - - it('displays no detected errors', async () => { - mockApi(HTTP_STATUS_OK, codeQualityResponseNoErrors); - - createComponent(); - - await waitForPromises(); - - expect(wrapper.text()).toBe(i18n.noChanges); - expect(isCollapsable()).toBe(false); - expect(getNeutralIcon()).toBe(true); - }); - }); - - describe('expanded data', () => { - beforeEach(async () => { - mockApi(HTTP_STATUS_OK, codeQualityResponseResolvedAndNewErrors); - - createComponent(); - - await waitForPromises(); - - findToggleCollapsedButton().trigger('click'); - - await waitForPromises(); - }); - - it('displays all report list items in viewport', () => { - expect(findAllExtensionListItems()).toHaveLength(4); - }); - - it('displays report list item formatted', () => { - const text = { - newError: trimText(findAllExtensionListItems().at(0).text().replace(/\s+/g, ' ').trim()), - resolvedError: findAllExtensionListItems().at(2).text().replace(/\s+/g, ' ').trim(), - }; - - expect(text.newError).toContain( - "Minor - Parsing error: 'return' outside of function in index.js:12", - ); - expect(text.resolvedError).toContain( - "Minor - Parsing error: 'return' outside of function in index.js:12 Fixed", - ); - }); - - it('displays report list item formatted with check_name', () => { - const text = { - newError: trimText(findAllExtensionListItems().at(1).text().replace(/\s+/g, ' ').trim()), - resolvedError: findAllExtensionListItems().at(3).text().replace(/\s+/g, ' ').trim(), - }; - - expect(text.newError).toContain( - 'Minor - Rubocop/Metrics/ParameterLists - Avoid parameter lists longer than 5 parameters. [12/5] in main.rb:3', - ); - expect(text.resolvedError).toContain( - 'Minor - Rubocop/Metrics/ParameterLists - Avoid parameter lists longer than 5 parameters. [12/5] in main.rb:3 Fixed', - ); - }); - - it('adds fixed indicator (badge) when error is resolved', () => { - expect(findAllExtensionListItems().at(3).findComponent(GlBadge).exists()).toBe(true); - expect(findAllExtensionListItems().at(3).findComponent(GlBadge).text()).toEqual(i18n.fixed); - }); - - it('should not add fixed indicator (badge) when error is new', () => { - expect(findAllExtensionListItems().at(0).findComponent(GlBadge).exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js b/spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js deleted file mode 100644 index e66c1521ff5..00000000000 --- a/spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js +++ /dev/null @@ -1,101 +0,0 @@ -export const codeQualityResponseNewErrors = { - status: 'failed', - new_errors: [ - { - description: "Parsing error: 'return' outside of function", - severity: 'minor', - file_path: 'index.js', - line: 12, - }, - { - description: 'TODO found', - severity: 'minor', - file_path: '.gitlab-ci.yml', - line: 73, - }, - ], - resolved_errors: [], - existing_errors: [], - summary: { - total: 12235, - resolved: 0, - errored: 12235, - }, -}; - -export const codeQualityResponseResolvedErrors = { - status: 'success', - new_errors: [], - resolved_errors: [ - { - description: "Parsing error: 'return' outside of function", - severity: 'minor', - file_path: 'index.js', - line: 12, - }, - { - description: 'TODO found', - severity: 'minor', - file_path: '.gitlab-ci.yml', - line: 73, - }, - ], - existing_errors: [], - summary: { - total: 12235, - resolved: 0, - errored: 12235, - }, -}; - -export const codeQualityResponseResolvedAndNewErrors = { - status: 'failed', - new_errors: [ - { - description: "Parsing error: 'return' outside of function", - severity: 'minor', - file_path: 'index.js', - line: 12, - }, - { - description: 'Avoid parameter lists longer than 5 parameters. [12/5]', - check_name: 'Rubocop/Metrics/ParameterLists', - severity: 'minor', - file_path: 'main.rb', - line: 3, - }, - ], - resolved_errors: [ - { - description: "Parsing error: 'return' outside of function", - severity: 'minor', - file_path: 'index.js', - line: 12, - }, - { - description: 'Avoid parameter lists longer than 5 parameters. [12/5]', - check_name: 'Rubocop/Metrics/ParameterLists', - severity: 'minor', - file_path: 'main.rb', - line: 3, - }, - ], - existing_errors: [], - summary: { - total: 12233, - resolved: 1, - errored: 12233, - }, -}; - -export const codeQualityResponseNoErrors = { - status: 'failed', - new_errors: [], - resolved_errors: [], - existing_errors: [], - summary: { - total: 12234, - resolved: 0, - errored: 12234, - }, -}; diff --git a/spec/frontend/vue_shared/alert_details/alert_details_spec.js b/spec/frontend/vue_shared/alert_details/alert_details_spec.js index 6c2b21053f0..d2dfb6ee1bf 100644 --- a/spec/frontend/vue_shared/alert_details/alert_details_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_details_spec.js @@ -1,16 +1,19 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { joinPaths } from '~/lib/utils/url_utility'; import Tracking from '~/tracking'; import AlertDetails from '~/vue_shared/alert_details/components/alert_details.vue'; import AlertSummaryRow from '~/vue_shared/alert_details/components/alert_summary_row.vue'; import { PAGE_CONFIG, SEVERITY_LEVELS } from '~/vue_shared/alert_details/constants'; import createIssueMutation from '~/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql'; +import alertQuery from '~/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; import MetricImagesTab from '~/vue_shared/components/metric_images/metric_images_tab.vue'; import createStore from '~/vue_shared/components/metric_images/store/'; @@ -27,20 +30,57 @@ describe('AlertDetails', () => { let environmentData = { name: environmentName, path: environmentPath }; let mock; let wrapper; + let requestHandlers; const projectPath = 'root/alerts'; const projectIssuesPath = 'root/alerts/-/issues'; const projectId = '1'; const $router = { push: jest.fn() }; + const defaultHandlers = { + createIssueMutationMock: jest.fn().mockResolvedValue({ + data: { + createAlertIssue: { + errors: [], + issue: { + id: 'id', + iid: 'iid', + webUrl: 'webUrl', + }, + }, + }, + }), + alertQueryMock: jest.fn().mockResolvedValue({ + data: { + project: { + id: '1', + alertManagementAlerts: { + nodes: [], + }, + }, + }, + }), + }; + + const createMockApolloProvider = (handlers) => { + Vue.use(VueApollo); + requestHandlers = handlers; + + return createMockApollo([ + [alertQuery, handlers.alertQueryMock], + [createIssueMutation, handlers.createIssueMutationMock], + ]); + }; + function mountComponent({ data, - loading = false, mountMethod = shallowMount, provide = {}, stubs = {}, + handlers = defaultHandlers, } = {}) { wrapper = extendedWrapper( mountMethod(AlertDetails, { + apolloProvider: createMockApolloProvider(handlers), provide: { alertId: 'alertId', projectPath, @@ -59,15 +99,6 @@ describe('AlertDetails', () => { }; }, mocks: { - $apollo: { - mutate: jest.fn(), - queries: { - alert: { - loading, - }, - sidebarStatus: {}, - }, - }, $router, $route: { params: {} }, }, @@ -139,7 +170,6 @@ describe('AlertDetails', () => { describe('Metrics tab', () => { it('should mount without errors', () => { mountComponent({ - mountMethod: mount, provide: { canUpdate: true, iid: '1', @@ -216,7 +246,6 @@ describe('AlertDetails', () => { it('should display "Create incident" button when incident doesn\'t exist yet', async () => { const issue = null; mountComponent({ - mountMethod: mount, data: { alert: { ...mockAlert, issue } }, }); @@ -226,23 +255,16 @@ describe('AlertDetails', () => { }); it('calls `$apollo.mutate` with `createIssueQuery`', () => { - const issueIid = '10'; mountComponent({ mountMethod: mount, data: { alert: { ...mockAlert } }, }); - jest - .spyOn(wrapper.vm.$apollo, 'mutate') - .mockResolvedValue({ data: { createAlertIssue: { issue: { iid: issueIid } } } }); findCreateIncidentBtn().trigger('click'); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: createIssueMutation, - variables: { - iid: mockAlert.iid, - projectPath, - }, + expect(requestHandlers.createIssueMutationMock).toHaveBeenCalledWith({ + iid: mockAlert.iid, + projectPath, }); }); @@ -251,25 +273,44 @@ describe('AlertDetails', () => { mountComponent({ mountMethod: mount, data: { alert: { ...mockAlert, alertIid: 1 } }, + handlers: { + ...defaultHandlers, + createIssueMutationMock: jest.fn().mockRejectedValue(new Error(errorMsg)), + }, }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg); findCreateIncidentBtn().trigger('click'); await waitForPromises(); - expect(findIncidentCreationAlert().text()).toBe(errorMsg); + expect(findIncidentCreationAlert().text()).toBe(`Error: ${errorMsg}`); }); }); describe('View full alert details', () => { - beforeEach(() => { - mountComponent({ data: { alert: mockAlert } }); + beforeEach(async () => { + mountComponent({ + data: { alert: mockAlert }, + handlers: { + ...defaultHandlers, + alertQueryMock: jest.fn().mockResolvedValue({ + data: { + project: { + id: '1', + alertManagementAlerts: { + nodes: [{ id: '1' }], + }, + }, + }, + }), + }, + }); + await waitForPromises(); }); it('should display a table of raw alert details data', () => { - const details = findDetailsTable(); - expect(details.exists()).toBe(true); - expect(details.props()).toStrictEqual({ + expect(findDetailsTable().exists()).toBe(true); + + expect(findDetailsTable().props()).toStrictEqual({ alert: mockAlert, statuses: PAGE_CONFIG.OPERATIONS.STATUSES, loading: false, @@ -279,7 +320,7 @@ describe('AlertDetails', () => { describe('loading state', () => { beforeEach(() => { - mountComponent({ loading: true }); + mountComponent(); }); it('displays a loading state when loading', () => { diff --git a/spec/frontend/vue_shared/components/ci_icon_spec.js b/spec/frontend/vue_shared/components/ci_icon/ci_icon_spec.js index cbb725bf9e6..792470c8e89 100644 --- a/spec/frontend/vue_shared/components/ci_icon_spec.js +++ b/spec/frontend/vue_shared/components/ci_icon/ci_icon_spec.js @@ -1,6 +1,6 @@ import { GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; const mockStatus = { group: 'success', diff --git a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js index 53218d794c7..b825a578cee 100644 --- a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js +++ b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js @@ -19,7 +19,7 @@ describe('Confirm Danger Modal', () => { const findModal = () => wrapper.findComponent(GlModal); const findConfirmationPhrase = () => wrapper.findByTestId('confirm-danger-phrase'); - const findConfirmationInput = () => wrapper.findByTestId('confirm-danger-input'); + const findConfirmationInput = () => wrapper.findByTestId('confirm-danger-field'); const findDefaultWarning = () => wrapper.findByTestId('confirm-danger-warning'); const findAdditionalMessage = () => wrapper.findByTestId('confirm-danger-message'); const findPrimaryAction = () => findModal().props('actionPrimary'); diff --git a/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js b/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js index 810269257b6..e2c3fc89525 100644 --- a/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js @@ -152,7 +152,8 @@ describe('Diff Stats Dropdown', () => { }); it('focuses the first item when pressing the down key within the search box', () => { - const spy = jest.spyOn(wrapper.vm, 'focusFirstItem'); + const { element } = wrapper.find('.gl-new-dropdown-item'); + const spy = jest.spyOn(element, 'focus'); findSearchBox().vm.$emit('keydown', new KeyboardEvent({ key: ARROW_DOWN_KEY })); expect(spy).toHaveBeenCalled(); diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js index dd5a05a40c6..1a9a08a9656 100644 --- a/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js +++ b/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js @@ -1,8 +1,8 @@ import { GlDropdown, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; - import { nextTick } from 'vue'; import DropdownWidget from '~/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue'; +import { stubComponent, RENDER_ALL_SLOTS_TEMPLATE } from 'helpers/stub_component'; describe('DropdownWidget component', () => { let wrapper; @@ -27,11 +27,14 @@ describe('DropdownWidget component', () => { ...props, }, stubs: { - GlDropdown, + GlDropdown: stubComponent(GlDropdown, { + methods: { + hide: jest.fn(), + }, + template: RENDER_ALL_SLOTS_TEMPLATE, + }), }, }); - - jest.spyOn(findDropdown().vm, 'hide').mockImplementation(); }; beforeEach(() => { diff --git a/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js b/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js index 1376133ec37..02da6079466 100644 --- a/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js +++ b/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js @@ -8,7 +8,7 @@ import waitForPromises from 'helpers/wait_for_promises'; describe('EntitySelect', () => { let wrapper; let fetchItemsMock; - let fetchInitialSelectionTextMock; + let fetchInitialSelectionMock; // Mocks const itemMock = { @@ -96,16 +96,16 @@ describe('EntitySelect', () => { }); it("fetches the initially selected value's name", async () => { - fetchInitialSelectionTextMock = jest.fn().mockImplementation(() => itemMock.text); + fetchInitialSelectionMock = jest.fn().mockImplementation(() => itemMock); createComponent({ props: { - fetchInitialSelectionText: fetchInitialSelectionTextMock, + fetchInitialSelection: fetchInitialSelectionMock, initialSelection: itemMock.value, }, }); await nextTick(); - expect(fetchInitialSelectionTextMock).toHaveBeenCalledTimes(1); + expect(fetchInitialSelectionMock).toHaveBeenCalledTimes(1); expect(findListbox().props('toggleText')).toBe(itemMock.text); }); }); @@ -188,7 +188,7 @@ describe('EntitySelect', () => { findListbox().vm.$emit('reset'); await nextTick(); - expect(Object.keys(wrapper.emitted('input')[2][0]).length).toBe(0); + expect(wrapper.emitted('input')[2][0]).toEqual({}); }); }); }); diff --git a/spec/frontend/vue_shared/components/entity_select/organization_select_spec.js b/spec/frontend/vue_shared/components/entity_select/organization_select_spec.js index ea029ba4f27..6dc38bbd0c6 100644 --- a/spec/frontend/vue_shared/components/entity_select/organization_select_spec.js +++ b/spec/frontend/vue_shared/components/entity_select/organization_select_spec.js @@ -1,35 +1,35 @@ import VueApollo from 'vue-apollo'; -import Vue, { nextTick } from 'vue'; -import { GlCollapsibleListbox } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Vue from 'vue'; +import { GlCollapsibleListbox, GlAlert } from '@gitlab/ui'; +import { chunk } from 'lodash'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import OrganizationSelect from '~/vue_shared/components/entity_select/organization_select.vue'; import EntitySelect from '~/vue_shared/components/entity_select/entity_select.vue'; +import { DEFAULT_PER_PAGE } from '~/api'; import { ORGANIZATION_TOGGLE_TEXT, ORGANIZATION_HEADER_TEXT, FETCH_ORGANIZATIONS_ERROR, FETCH_ORGANIZATION_ERROR, } from '~/vue_shared/components/entity_select/constants'; -import resolvers from '~/organizations/shared/graphql/resolvers'; -import organizationsQuery from '~/organizations/index/graphql/organizations.query.graphql'; -import { organizations as organizationsMock } from '~/organizations/mock_data'; +import getCurrentUserOrganizationsQuery from '~/organizations/shared/graphql/queries/organizations.query.graphql'; +import getOrganizationQuery from '~/organizations/shared/graphql/queries/organization.query.graphql'; +import { organizations as nodes, pageInfo, pageInfoEmpty } from '~/organizations/mock_data'; import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; Vue.use(VueApollo); -jest.useFakeTimers(); - describe('OrganizationSelect', () => { let wrapper; let mockApollo; // Mocks - const [organizationMock] = organizationsMock; - - // Stubs - const GlAlert = { - template: '<div><slot /></div>', + const [organization] = nodes; + const organizations = { + nodes, + pageInfo, }; // Props @@ -44,23 +44,26 @@ describe('OrganizationSelect', () => { const findEntitySelect = () => wrapper.findComponent(EntitySelect); const findAlert = () => wrapper.findComponent(GlAlert); + // Mock handlers const handleInput = jest.fn(); + const getCurrentUserOrganizationsQueryHandler = jest.fn().mockResolvedValue({ + data: { currentUser: { id: 'gid://gitlab/User/1', __typename: 'CurrentUser', organizations } }, + }); + const getOrganizationQueryHandler = jest.fn().mockResolvedValue({ + data: { organization }, + }); // Helpers - const createComponent = ({ props = {}, mockResolvers = resolvers, handlers } = {}) => { - mockApollo = createMockApollo( - handlers || [ - [ - organizationsQuery, - jest.fn().mockResolvedValueOnce({ - data: { currentUser: { id: 1, organizations: { nodes: organizationsMock } } }, - }), - ], - ], - mockResolvers, - ); - - wrapper = shallowMountExtended(OrganizationSelect, { + const createComponent = ({ + props = {}, + handlers = [ + [getCurrentUserOrganizationsQuery, getCurrentUserOrganizationsQueryHandler], + [getOrganizationQuery, getOrganizationQueryHandler], + ], + } = {}) => { + mockApollo = createMockApollo(handlers); + + wrapper = mountExtended(OrganizationSelect, { apolloProvider: mockApollo, propsData: { label, @@ -70,10 +73,6 @@ describe('OrganizationSelect', () => { toggleClass, ...props, }, - stubs: { - GlAlert, - EntitySelect, - }, listeners: { input: handleInput, }, @@ -81,10 +80,6 @@ describe('OrganizationSelect', () => { }; const openListbox = () => findListbox().vm.$emit('shown'); - afterEach(() => { - mockApollo = null; - }); - describe('entity_select props', () => { beforeEach(() => { createComponent(); @@ -107,40 +102,31 @@ describe('OrganizationSelect', () => { describe('on mount', () => { it('fetches organizations when the listbox is opened', async () => { createComponent(); - await nextTick(); - jest.runAllTimers(); - await waitForPromises(); - openListbox(); - jest.runAllTimers(); await waitForPromises(); - expect(findListbox().props('items')).toEqual([ - { text: organizationsMock[0].name, value: 1 }, - { text: organizationsMock[1].name, value: 2 }, - { text: organizationsMock[2].name, value: 3 }, - ]); + + const expectedItems = nodes.map((node) => ({ + ...node, + text: node.name, + value: getIdFromGraphQLId(node.id), + })); + + expect(findListbox().props('items')).toEqual(expectedItems); }); describe('with an initial selection', () => { it("fetches the initially selected value's name", async () => { - createComponent({ props: { initialSelection: organizationMock.id } }); - await nextTick(); - jest.runAllTimers(); + createComponent({ props: { initialSelection: organization.id } }); await waitForPromises(); - expect(findListbox().props('toggleText')).toBe(organizationMock.name); + expect(findListbox().props('toggleText')).toBe(organization.name); }); it('show an error if fetching initially selected fails', async () => { - const mockResolvers = { - Query: { - organization: jest.fn().mockRejectedValueOnce(new Error()), - }, - }; - - createComponent({ props: { initialSelection: organizationMock.id }, mockResolvers }); - await nextTick(); - jest.runAllTimers(); + createComponent({ + props: { initialSelection: organization.id }, + handlers: [[getOrganizationQuery, jest.fn().mockRejectedValueOnce()]], + }); expect(findAlert().exists()).toBe(false); @@ -152,18 +138,59 @@ describe('OrganizationSelect', () => { }); }); + describe('when listbox bottom is reached and there are more organizations to load', () => { + const [firstPage, secondPage] = chunk(nodes, Math.ceil(nodes.length / 2)); + const getCurrentUserOrganizationsQueryMultiplePagesHandler = jest + .fn() + .mockResolvedValueOnce({ + data: { + currentUser: { + id: 'gid://gitlab/User/1', + __typename: 'CurrentUser', + organizations: { nodes: firstPage, pageInfo }, + }, + }, + }) + .mockResolvedValueOnce({ + data: { + currentUser: { + id: 'gid://gitlab/User/1', + __typename: 'CurrentUser', + organizations: { nodes: secondPage, pageInfo: pageInfoEmpty }, + }, + }, + }); + + beforeEach(async () => { + createComponent({ + handlers: [ + [getCurrentUserOrganizationsQuery, getCurrentUserOrganizationsQueryMultiplePagesHandler], + [getOrganizationQuery, getOrganizationQueryHandler], + ], + }); + openListbox(); + await waitForPromises(); + + findListbox().vm.$emit('bottom-reached'); + await waitForPromises(); + }); + + it('calls graphQL query correct `after` variable', () => { + expect(getCurrentUserOrganizationsQueryMultiplePagesHandler).toHaveBeenCalledWith({ + after: pageInfo.endCursor, + first: DEFAULT_PER_PAGE, + }); + expect(findListbox().props('infiniteScroll')).toBe(false); + }); + }); + it('shows an error when fetching organizations fails', async () => { createComponent({ - handlers: [[organizationsQuery, jest.fn().mockRejectedValueOnce(new Error())]], + handlers: [[getCurrentUserOrganizationsQuery, jest.fn().mockRejectedValueOnce()]], }); - await nextTick(); - jest.runAllTimers(); - await waitForPromises(); - openListbox(); expect(findAlert().exists()).toBe(false); - jest.runAllTimers(); await waitForPromises(); expect(findAlert().exists()).toBe(true); 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 bb612a13209..3a5c7d7729f 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 @@ -1,11 +1,4 @@ -import { - GlFilteredSearch, - GlButtonGroup, - GlButton, - GlDropdown, - GlDropdownItem, - GlFormCheckbox, -} from '@gitlab/ui'; +import { GlDropdownItem, GlSorting, GlFilteredSearch, GlFormCheckbox } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; import { nextTick } from 'vue'; @@ -13,7 +6,6 @@ import RecentSearchesService from '~/filtered_search/services/recent_searches_se import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; import { FILTERED_SEARCH_TERM, - SORT_DIRECTION, TOKEN_TYPE_AUTHOR, TOKEN_TYPE_LABEL, TOKEN_TYPE_MILESTONE, @@ -48,6 +40,7 @@ const createComponent = ({ recentSearchesStorageKey = 'requirements', tokens = mockAvailableTokens, sortOptions, + initialSortBy, initialFilterValue = [], showCheckbox = false, checkboxChecked = false, @@ -61,6 +54,7 @@ const createComponent = ({ recentSearchesStorageKey, tokens, sortOptions, + initialSortBy, initialFilterValue, showCheckbox, checkboxChecked, @@ -72,34 +66,38 @@ const createComponent = ({ describe('FilteredSearchBarRoot', () => { let wrapper; - const findGlButton = () => wrapper.findComponent(GlButton); - const findGlDropdown = () => wrapper.findComponent(GlDropdown); + const findGlSorting = () => wrapper.findComponent(GlSorting); const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); - beforeEach(() => { - wrapper = createComponent({ sortOptions: mockSortOptions }); - }); - describe('data', () => { - it('initializes `filterValue`, `selectedSortOption` and `selectedSortDirection` data props and displays the sort dropdown', () => { - expect(wrapper.vm.filterValue).toEqual([]); - expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[0]); - expect(wrapper.vm.selectedSortDirection).toBe(SORT_DIRECTION.descending); - expect(wrapper.findComponent(GlButtonGroup).exists()).toBe(true); - expect(wrapper.findComponent(GlButton).exists()).toBe(true); - expect(wrapper.findComponent(GlDropdown).exists()).toBe(true); - expect(wrapper.findComponent(GlDropdownItem).exists()).toBe(true); + describe('when `sortOptions` are provided', () => { + beforeEach(() => { + wrapper = createComponent({ sortOptions: mockSortOptions }); + }); + + it('sets a correct initial value for GlFilteredSearch', () => { + expect(findGlFilteredSearch().props('value')).toEqual([]); + }); + + it('emits an event with the selectedSortOption provided by default', async () => { + findGlSorting().vm.$emit('sortByChange', mockSortOptions[1].id); + await nextTick(); + + expect(wrapper.emitted('onSort')[0]).toEqual([mockSortOptions[1].sortDirection.descending]); + }); + + it('emits an event with the selectedSortDirection provided by default', async () => { + findGlSorting().vm.$emit('sortDirectionChange', true); + await nextTick(); + + expect(wrapper.emitted('onSort')[0]).toEqual([mockSortOptions[0].sortDirection.ascending]); + }); }); - it('does not initialize `selectedSortOption` and `selectedSortDirection` when `sortOptions` is not applied and hides the sort dropdown', () => { - const wrapperNoSort = createComponent(); + it('does not initialize the sort dropdown when `sortOptions` are not provided', () => { + wrapper = createComponent(); - expect(wrapperNoSort.vm.filterValue).toEqual([]); - expect(wrapperNoSort.vm.selectedSortOption).toBe(undefined); - expect(wrapperNoSort.findComponent(GlButtonGroup).exists()).toBe(false); - expect(wrapperNoSort.findComponent(GlButton).exists()).toBe(false); - expect(wrapperNoSort.findComponent(GlDropdown).exists()).toBe(false); - expect(wrapperNoSort.findComponent(GlDropdownItem).exists()).toBe(false); + expect(findGlSorting().exists()).toBe(false); }); }); @@ -125,27 +123,27 @@ describe('FilteredSearchBarRoot', () => { }); describe('sortDirectionIcon', () => { - it('renders `sort-highest` descending icon by default', () => { - expect(findGlButton().props('icon')).toBe('sort-highest'); - expect(findGlButton().attributes()).toMatchObject({ - 'aria-label': 'Sort direction: Descending', - title: 'Sort direction: Descending', - }); + beforeEach(() => { + wrapper = createComponent({ sortOptions: mockSortOptions }); + }); + + it('passes isAscending=false to GlSorting by default', () => { + expect(findGlSorting().props('isAscending')).toBe(false); }); it('renders `sort-lowest` ascending icon when the sort button is clicked', async () => { - findGlButton().vm.$emit('click'); + findGlSorting().vm.$emit('sortDirectionChange', true); await nextTick(); - expect(findGlButton().props('icon')).toBe('sort-lowest'); - expect(findGlButton().attributes()).toMatchObject({ - 'aria-label': 'Sort direction: Ascending', - title: 'Sort direction: Ascending', - }); + expect(findGlSorting().props('isAscending')).toBe(true); }); }); describe('filteredRecentSearches', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + it('returns array of recent searches filtering out any string type (unsupported) items', async () => { // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details // eslint-disable-next-line no-restricted-syntax @@ -227,34 +225,37 @@ describe('FilteredSearchBarRoot', () => { }); }); - describe('handleSortOptionClick', () => { - it('emits component event `onSort` with selected sort by value', () => { - wrapper.vm.handleSortOptionClick(mockSortOptions[1]); + describe('handleSortOptionChange', () => { + it('emits component event `onSort` with selected sort by value', async () => { + wrapper = createComponent({ sortOptions: mockSortOptions }); + + findGlSorting().vm.$emit('sortByChange', mockSortOptions[1].id); + await nextTick(); expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[1]); expect(wrapper.emitted('onSort')[0]).toEqual([mockSortOptions[1].sortDirection.descending]); }); }); - describe('handleSortDirectionClick', () => { + describe('handleSortDirectionChange', () => { beforeEach(() => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - selectedSortOption: mockSortOptions[0], + wrapper = createComponent({ + sortOptions: mockSortOptions, + initialSortBy: mockSortOptions[0].sortDirection.descending, }); }); - it('sets `selectedSortDirection` to be opposite of its current value', () => { - expect(wrapper.vm.selectedSortDirection).toBe(SORT_DIRECTION.descending); + it('sets sort direction to be opposite of its current value', async () => { + expect(findGlSorting().props('isAscending')).toBe(false); - wrapper.vm.handleSortDirectionClick(); + findGlSorting().vm.$emit('sortDirectionChange', true); + await nextTick(); - expect(wrapper.vm.selectedSortDirection).toBe(SORT_DIRECTION.ascending); + expect(findGlSorting().props('isAscending')).toBe(true); }); it('emits component event `onSort` with opposite of currently selected sort by value', () => { - wrapper.vm.handleSortDirectionClick(); + findGlSorting().vm.$emit('sortDirectionChange', true); expect(wrapper.emitted('onSort')[0]).toEqual([mockSortOptions[0].sortDirection.ascending]); }); @@ -288,6 +289,8 @@ describe('FilteredSearchBarRoot', () => { const mockFilters = [tokenValueAuthor, 'foo']; beforeEach(async () => { + wrapper = 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({ @@ -358,19 +361,14 @@ describe('FilteredSearchBarRoot', () => { }); describe('template', () => { - beforeEach(async () => { + it('renders gl-filtered-search component', async () => { + wrapper = 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({ - selectedSortOption: mockSortOptions[0], - selectedSortDirection: SORT_DIRECTION.descending, + await wrapper.setData({ recentSearches: mockHistoryItems, }); - await nextTick(); - }); - - it('renders gl-filtered-search component', () => { const glFilteredSearchEl = wrapper.findComponent(GlFilteredSearch); expect(glFilteredSearchEl.props('placeholder')).toBe('Filter requirements'); @@ -454,25 +452,28 @@ describe('FilteredSearchBarRoot', () => { }); it('renders sort dropdown component', () => { - expect(wrapper.findComponent(GlButtonGroup).exists()).toBe(true); - expect(wrapper.findComponent(GlDropdown).exists()).toBe(true); - expect(wrapper.findComponent(GlDropdown).props('text')).toBe(mockSortOptions[0].title); - }); - - it('renders sort dropdown items', () => { - const dropdownItemsEl = wrapper.findAllComponents(GlDropdownItem); + wrapper = createComponent({ sortOptions: mockSortOptions }); - expect(dropdownItemsEl).toHaveLength(mockSortOptions.length); - expect(dropdownItemsEl.at(0).text()).toBe(mockSortOptions[0].title); - expect(dropdownItemsEl.at(0).props('isChecked')).toBe(true); - expect(dropdownItemsEl.at(1).text()).toBe(mockSortOptions[1].title); + expect(findGlSorting().exists()).toBe(true); }); - it('renders sort direction button', () => { - const sortButtonEl = wrapper.findComponent(GlButton); - - expect(sortButtonEl.attributes('title')).toBe('Sort direction: Descending'); - expect(sortButtonEl.props('icon')).toBe('sort-highest'); + it('renders sort dropdown items', () => { + wrapper = createComponent({ sortOptions: mockSortOptions }); + + const { sortOptions, sortBy } = findGlSorting().props(); + + expect(sortOptions).toEqual([ + { + value: mockSortOptions[0].id, + text: mockSortOptions[0].title, + }, + { + value: mockSortOptions[1].id, + text: mockSortOptions[1].title, + }, + ]); + + expect(sortBy).toBe(mockSortOptions[0].id); }); }); @@ -483,6 +484,10 @@ describe('FilteredSearchBarRoot', () => { value: { data: '' }, }; + beforeEach(() => { + wrapper = createComponent({ sortOptions: mockSortOptions }); + }); + it('syncs filter value', async () => { await wrapper.setProps({ initialFilterValue: [tokenValue], syncFilterAndSort: true }); @@ -498,17 +503,33 @@ describe('FilteredSearchBarRoot', () => { it('syncs sort values', async () => { await wrapper.setProps({ initialSortBy: 'updated_asc', syncFilterAndSort: true }); - expect(findGlDropdown().props('text')).toBe('Last updated'); - expect(findGlButton().props('icon')).toBe('sort-lowest'); - expect(findGlButton().attributes('aria-label')).toBe('Sort direction: Ascending'); + expect(findGlSorting().props()).toMatchObject({ + sortBy: 2, + isAscending: true, + }); }); it('does not sync sort values when syncFilterAndSort=false', async () => { await wrapper.setProps({ initialSortBy: 'updated_asc', syncFilterAndSort: false }); - expect(findGlDropdown().props('text')).toBe('Created date'); - expect(findGlButton().props('icon')).toBe('sort-highest'); - expect(findGlButton().attributes('aria-label')).toBe('Sort direction: Descending'); + expect(findGlSorting().props()).toMatchObject({ + sortBy: 1, + isAscending: false, + }); + }); + + it('does not sync sort values when initialSortBy is unset', async () => { + // Give initialSort some value which changes the current sort option... + await wrapper.setProps({ initialSortBy: 'updated_asc', syncFilterAndSort: true }); + + // ... Read the new sort options... + const { sortBy, isAscending } = findGlSorting().props(); + + // ... Then *unset* initialSortBy... + await wrapper.setProps({ initialSortBy: undefined }); + + // ... The sort options should not have changed. + expect(findGlSorting().props()).toMatchObject({ sortBy, isAscending }); }); }); }); 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 88618de6979..1d6834a5604 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 @@ -156,9 +156,12 @@ describe('BaseToken', () => { it('uses last item in list when value is an array', () => { const mockGetActiveTokenValue = jest.fn(); + const config = { ...mockConfig, multiSelect: true }; + wrapper = createComponent({ props: { - value: { data: mockLabels.map((l) => l.title) }, + config, + value: { data: mockLabels.map((l) => l.title), operator: '||' }, suggestions: mockLabels, getActiveTokenValue: mockGetActiveTokenValue, }, @@ -409,8 +412,9 @@ describe('BaseToken', () => { }); it('emits token-selected event when groupMultiSelectTokens: true', () => { + const config = { ...mockConfig, multiSelect: true }; wrapper = createComponent({ - props: { suggestions: mockLabels }, + props: { suggestions: mockLabels, config, value: { operator: '||' } }, groupMultiSelectTokens: true, }); @@ -419,9 +423,10 @@ describe('BaseToken', () => { expect(wrapper.emitted('token-selected')).toEqual([[mockTokenValue.title]]); }); - it('does not emit token-selected event when groupMultiSelectTokens: true', () => { + it('does not emit token-selected event when groupMultiSelectTokens: false', () => { + const config = { ...mockConfig, multiSelect: true }; wrapper = createComponent({ - props: { suggestions: mockLabels }, + props: { suggestions: mockLabels, config, value: { operator: '||' } }, groupMultiSelectTokens: false, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/date_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/date_token_spec.js index 56a59790210..34d0c7f0566 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/date_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/date_token_spec.js @@ -42,7 +42,7 @@ describe('DateToken', () => { findDatepicker().vm.$emit('close'); expect(findGlFilteredSearchToken().emitted()).toEqual({ - complete: [[]], + complete: [['2014-10-13']], select: [['2014-10-13']], }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js index 36e82b39df4..ee54fb5b941 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js @@ -5,15 +5,12 @@ import { GlDropdownDivider, } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; import { sortMilestonesByDueDate } from '~/milestones/utils'; - import searchMilestonesQuery from '~/issues/list/queries/search_milestones.query.graphql'; import { DEFAULT_MILESTONES } from '~/vue_shared/components/filtered_search_bar/constants'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; @@ -70,7 +67,6 @@ function createComponent(options = {}) { } describe('MilestoneToken', () => { - let mock; let wrapper; const findBaseToken = () => wrapper.findComponent(BaseToken); @@ -80,14 +76,9 @@ describe('MilestoneToken', () => { }; beforeEach(() => { - mock = new MockAdapter(axios); wrapper = createComponent(); }); - afterEach(() => { - mock.restore(); - }); - describe('methods', () => { describe('fetchMilestones', () => { it('sets loading state', async () => { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js index 4462d1bfaf5..decf843091e 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js @@ -313,11 +313,11 @@ describe('UserToken', () => { describe('multiSelect', () => { it('renders check icons in suggestions when multiSelect is true', async () => { wrapper = createComponent({ - value: { data: [mockUsers[0].username, mockUsers[1].username], operator: '=' }, + value: { data: [mockUsers[0].username, mockUsers[1].username], operator: '||' }, data: { users: mockUsers, }, - config: { ...mockAuthorToken, multiSelect: true, initialUsers: mockUsers }, + config: { ...mockAuthorToken, multiSelect: true }, active: true, stubs: { Portal: true }, groupMultiSelectTokens: true, @@ -327,18 +327,17 @@ describe('UserToken', () => { const suggestions = wrapper.findAllComponents(GlFilteredSearchSuggestion); - expect(findIconAtSuggestion(1).exists()).toBe(false); - expect(findIconAtSuggestion(2).props('name')).toBe('check'); - expect(findIconAtSuggestion(3).props('name')).toBe('check'); + expect(findIconAtSuggestion(0).props('name')).toBe('check'); + expect(findIconAtSuggestion(1).props('name')).toBe('check'); + expect(findIconAtSuggestion(2).exists()).toBe(false); // test for left padding on unchecked items (so alignment is correct) - expect(findIconAtSuggestion(4).exists()).toBe(false); - expect(suggestions.at(4).find('.gl-pl-6').exists()).toBe(true); + expect(suggestions.at(2).find('.gl-pl-6').exists()).toBe(true); }); it('renders multiple users when multiSelect is true', async () => { wrapper = createComponent({ - value: { data: [mockUsers[0].username, mockUsers[1].username], operator: '=' }, + value: { data: [mockUsers[0].username, mockUsers[1].username], operator: '||' }, data: { users: mockUsers, }, @@ -363,7 +362,7 @@ describe('UserToken', () => { it('adds new user to multi-select-values', () => { wrapper = createComponent({ - value: { data: [mockUsers[0].username], operator: '=' }, + value: { data: [mockUsers[0].username], operator: '||' }, data: { users: mockUsers, }, @@ -383,7 +382,7 @@ describe('UserToken', () => { it('removes existing user from array', () => { const initialUsers = [mockUsers[0].username, mockUsers[1].username]; wrapper = createComponent({ - value: { data: initialUsers, operator: '=' }, + value: { data: initialUsers, operator: '||' }, data: { users: mockUsers, }, @@ -399,7 +398,7 @@ describe('UserToken', () => { it('clears input field after token selected', () => { wrapper = createComponent({ - value: { data: [mockUsers[0].username, mockUsers[1].username], operator: '=' }, + value: { data: [mockUsers[0].username, mockUsers[1].username], operator: '||' }, data: { users: mockUsers, }, @@ -410,7 +409,7 @@ describe('UserToken', () => { findBaseToken().vm.$emit('token-selected', 'test'); - expect(wrapper.emitted('input')).toEqual([[{ operator: '=', data: '' }]]); + expect(wrapper.emitted('input')).toEqual([[{ operator: '||', data: '' }]]); }); }); diff --git a/spec/frontend/vue_shared/components/keep_alive_slots_spec.js b/spec/frontend/vue_shared/components/keep_alive_slots_spec.js deleted file mode 100644 index f69a883ee4d..00000000000 --- a/spec/frontend/vue_shared/components/keep_alive_slots_spec.js +++ /dev/null @@ -1,118 +0,0 @@ -import { nextTick } from 'vue'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue'; - -const SLOT_1 = { - slotKey: 'slot-1', - title: 'Hello 1', -}; -const SLOT_2 = { - slotKey: 'slot-2', - title: 'Hello 2', -}; - -describe('~/vue_shared/components/keep_alive_slots.vue', () => { - let wrapper; - - const createSlotContent = ({ slotKey, title }) => ` - <div data-testid="slot-child" data-slot-id="${slotKey}"> - <h1>${title}</h1> - <input type="text" /> - </div> - `; - const createComponent = (props = {}) => { - wrapper = mountExtended(KeepAliveSlots, { - propsData: props, - slots: { - [SLOT_1.slotKey]: createSlotContent(SLOT_1), - [SLOT_2.slotKey]: createSlotContent(SLOT_2), - }, - }); - }; - - const findRenderedSlots = () => - wrapper.findAllByTestId('slot-child').wrappers.map((x) => ({ - title: x.find('h1').text(), - inputValue: x.find('input').element.value, - isVisible: x.isVisible(), - })); - - describe('default', () => { - beforeEach(() => { - createComponent(); - }); - - it('doesnt show anything', () => { - expect(findRenderedSlots()).toEqual([]); - }); - - describe('when slotKey is changed', () => { - beforeEach(async () => { - wrapper.setProps({ slotKey: SLOT_1.slotKey }); - await nextTick(); - }); - - it('shows slot', () => { - expect(findRenderedSlots()).toEqual([ - { - title: SLOT_1.title, - isVisible: true, - inputValue: '', - }, - ]); - }); - - it('hides everything when slotKey cannot be found', async () => { - wrapper.setProps({ slotKey: '' }); - await nextTick(); - - expect(findRenderedSlots()).toEqual([ - { - title: SLOT_1.title, - isVisible: false, - inputValue: '', - }, - ]); - }); - - describe('when user intreracts then slotKey changes again', () => { - beforeEach(async () => { - wrapper.find('input').setValue('TEST'); - wrapper.setProps({ slotKey: SLOT_2.slotKey }); - await nextTick(); - }); - - it('keeps first slot alive but hidden', () => { - expect(findRenderedSlots()).toEqual([ - { - title: SLOT_1.title, - isVisible: false, - inputValue: 'TEST', - }, - { - title: SLOT_2.title, - isVisible: true, - inputValue: '', - }, - ]); - }); - }); - }); - }); - - describe('initialized with slotKey', () => { - beforeEach(() => { - createComponent({ slotKey: SLOT_2.slotKey }); - }); - - it('shows slot', () => { - expect(findRenderedSlots()).toEqual([ - { - title: SLOT_2.title, - isVisible: true, - inputValue: '', - }, - ]); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/list_selector/deploy_key_item_spec.js b/spec/frontend/vue_shared/components/list_selector/deploy_key_item_spec.js new file mode 100644 index 00000000000..96be5b345a1 --- /dev/null +++ b/spec/frontend/vue_shared/components/list_selector/deploy_key_item_spec.js @@ -0,0 +1,61 @@ +import { GlIcon, GlButton } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import DeployKeyItem from '~/vue_shared/components/list_selector/deploy_key_item.vue'; + +describe('DeployKeyItem spec', () => { + let wrapper; + + const MOCK_DATA = { title: 'Some key', owner: 'root', id: '123' }; + + const createComponent = (props) => { + wrapper = shallowMountExtended(DeployKeyItem, { + propsData: { + data: MOCK_DATA, + ...props, + }, + }); + }; + + const findIcon = () => wrapper.findComponent(GlIcon); + const findDeleteButton = () => wrapper.findComponent(GlButton); + const findWrapper = () => wrapper.findByTestId('deploy-key-wrapper'); + + beforeEach(() => createComponent()); + + it('renders a key icon component', () => { + expect(findIcon().props('name')).toBe('key'); + }); + + it('renders a title and username', () => { + expect(wrapper.text()).toContain('Some key'); + expect(wrapper.text()).toContain('@root'); + }); + + it('does not render a delete button by default', () => { + expect(findDeleteButton().exists()).toBe(false); + }); + + it('emits a select event when the wrapper is clicked', () => { + findWrapper().trigger('click'); + + expect(wrapper.emitted('select')).toEqual([[MOCK_DATA.id]]); + }); + + describe('Delete button', () => { + beforeEach(() => createComponent({ canDelete: true })); + + it('renders a delete button', () => { + expect(findDeleteButton().exists()).toBe(true); + expect(findDeleteButton().props('icon')).toBe('remove'); + }); + + it('emits a delete event if the delete button is clicked', () => { + const stopPropagation = jest.fn(); + + findDeleteButton().vm.$emit('click', { stopPropagation }); + + expect(stopPropagation).toHaveBeenCalled(); + expect(wrapper.emitted('delete')).toEqual([[MOCK_DATA.id]]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/list_selector/index_spec.js b/spec/frontend/vue_shared/components/list_selector/index_spec.js index 11e64a91eb0..6de9a77582c 100644 --- a/spec/frontend/vue_shared/components/list_selector/index_spec.js +++ b/spec/frontend/vue_shared/components/list_selector/index_spec.js @@ -7,6 +7,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import ListSelector from '~/vue_shared/components/list_selector/index.vue'; import UserItem from '~/vue_shared/components/list_selector/user_item.vue'; import GroupItem from '~/vue_shared/components/list_selector/group_item.vue'; +import DeployKeyItem from '~/vue_shared/components/list_selector/deploy_key_item.vue'; import groupsAutocompleteQuery from '~/graphql_shared/queries/groups_autocomplete.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -20,18 +21,21 @@ describe('List Selector spec', () => { let fakeApollo; const USERS_MOCK_PROPS = { - title: 'Users', projectPath: 'some/project/path', groupPath: 'some/group/path', type: 'users', }; const GROUPS_MOCK_PROPS = { - title: 'Groups', projectPath: 'some/project/path', type: 'groups', }; + const DEPLOY_KEYS_MOCK_PROPS = { + projectPath: 'some/project/path', + type: 'deployKeys', + }; + const groupsAutocompleteQuerySuccess = jest.fn().mockResolvedValue(GROUPS_RESPONSE_MOCK); const createComponent = async (props) => { @@ -56,6 +60,7 @@ describe('List Selector spec', () => { const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); const findAllUserComponents = () => wrapper.findAllComponents(UserItem); const findAllGroupComponents = () => wrapper.findAllComponents(GroupItem); + const findAllDeployKeyComponents = () => wrapper.findAllComponents(DeployKeyItem); beforeEach(() => { jest.spyOn(Api, 'projectUsers').mockResolvedValue(USERS_RESPONSE_MOCK); @@ -254,4 +259,46 @@ describe('List Selector spec', () => { }); }); }); + + describe('Deploy keys type', () => { + beforeEach(() => createComponent(DEPLOY_KEYS_MOCK_PROPS)); + + it('renders a correct title', () => { + expect(findTitle().exists()).toBe(true); + expect(findTitle().text()).toContain('Deploy keys'); + }); + + it('renders the correct icon', () => { + expect(findIcon().props('name')).toBe('key'); + }); + + describe('selected items', () => { + const selectedKey = { title: 'MyKey', owner: 'peter', id: '123' }; + const selectedItems = [selectedKey]; + beforeEach(() => createComponent({ ...DEPLOY_KEYS_MOCK_PROPS, selectedItems })); + + it('renders a heading with the total selected items', () => { + expect(findTitle().text()).toContain('Deploy keys'); + expect(findTitle().text()).toContain('1'); + }); + + it('renders a deploy key component for each selected item', () => { + expect(findAllDeployKeyComponents().length).toBe(selectedItems.length); + expect(findAllDeployKeyComponents().at(0).props()).toMatchObject({ + data: selectedKey, + canDelete: true, + }); + }); + + it('emits a delete event when a delete event is emitted from the deploy key component', () => { + const id = '123'; + findAllDeployKeyComponents().at(0).vm.$emit('delete', id); + + expect(wrapper.emitted('delete')).toEqual([[id]]); + }); + + // TODO - add a test for the select event once we have API integration + // https://gitlab.com/gitlab-org/gitlab/-/issues/432494 + }); + }); }); diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js index 40875ed5dbc..57f6d751efd 100644 --- a/spec/frontend/vue_shared/components/markdown/header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/header_spec.js @@ -82,6 +82,14 @@ describe('Markdown field header component', () => { }); }); + it('attach file button should have data-button-type attribute', () => { + const attachButton = findToolbarButtonByProp('icon', 'paperclip'); + + // Used for dropzone_input.js as `clickable` property + // to prevent triggers upload file by clicking on the edge of textarea + expect(attachButton.attributes('data-button-type')).toBe('attach-file'); + }); + it('hides markdown preview when previewMarkdown is false', () => { expect(findPreviewToggle().text()).toBe('Preview'); }); diff --git a/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js b/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js index 544466a22ca..626b1df5474 100644 --- a/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js +++ b/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js @@ -43,7 +43,7 @@ describe('Metrics tab store actions', () => { it('should call success action when fetching metric images', () => { service.getMetricImages.mockImplementation(() => Promise.resolve(fileList)); - testAction(actions.fetchImages, null, state, [ + return testAction(actions.fetchImages, null, state, [ { type: types.REQUEST_METRIC_IMAGES }, { type: types.RECEIVE_METRIC_IMAGES_SUCCESS, @@ -80,7 +80,7 @@ describe('Metrics tab store actions', () => { it('should call success action when uploading an image', () => { service.uploadMetricImage.mockImplementation(() => Promise.resolve(fileList[0])); - testAction(actions.uploadImage, payload, state, [ + return testAction(actions.uploadImage, payload, state, [ { type: types.REQUEST_METRIC_UPLOAD }, { type: types.RECEIVE_METRIC_UPLOAD_SUCCESS, @@ -112,7 +112,7 @@ describe('Metrics tab store actions', () => { it('should call success action when updating an image', () => { service.updateMetricImage.mockImplementation(() => Promise.resolve()); - testAction(actions.updateImage, payload, state, [ + return testAction(actions.updateImage, payload, state, [ { type: types.REQUEST_METRIC_UPLOAD }, { type: types.RECEIVE_METRIC_UPDATE_SUCCESS, @@ -140,7 +140,7 @@ describe('Metrics tab store actions', () => { it('should call success action when deleting an image', () => { service.deleteMetricImage.mockImplementation(() => Promise.resolve()); - testAction(actions.deleteImage, payload, state, [ + return testAction(actions.deleteImage, payload, state, [ { type: types.RECEIVE_METRIC_DELETE_SUCCESS, payload, @@ -151,7 +151,7 @@ describe('Metrics tab store actions', () => { describe('initial data', () => { it('should set the initial data correctly', () => { - testAction(actions.setInitialData, initialData, state, [ + return testAction(actions.setInitialData, initialData, state, [ { type: types.SET_INITIAL_DATA, payload: initialData }, ]); }); diff --git a/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js b/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js index 7efc0e162b8..a67276ac64a 100644 --- a/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js @@ -11,6 +11,7 @@ import searchProjectsWithinGroupQuery from '~/issues/list/queries/search_project import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility'; import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { stubComponent } from 'helpers/stub_component'; import { emptySearchProjectsQueryResponse, emptySearchProjectsWithinGroupQueryResponse, @@ -42,6 +43,7 @@ describe('NewResourceDropdown component', () => { queryResponse = searchProjectsQueryResponse, mountFn = shallowMount, propsData = {}, + stubs = {}, } = {}) => { const requestHandlers = [[query, jest.fn().mockResolvedValue(queryResponse)]]; const apolloProvider = createMockApollo(requestHandlers); @@ -49,6 +51,9 @@ describe('NewResourceDropdown component', () => { wrapper = mountFn(NewResourceDropdown, { apolloProvider, propsData, + stubs: { + ...stubs, + }, }); }; @@ -81,13 +86,18 @@ describe('NewResourceDropdown component', () => { }); it('focuses on input when dropdown is shown', async () => { - mountComponent({ mountFn: mount }); - - const inputSpy = jest.spyOn(findInput().vm, 'focusInput'); + const inputMock = jest.fn(); + mountComponent({ + stubs: { + GlSearchBoxByType: stubComponent(GlSearchBoxByType, { + methods: { focusInput: inputMock }, + }), + }, + }); await showDropdown(); - expect(inputSpy).toHaveBeenCalledTimes(1); + expect(inputMock).toHaveBeenCalledTimes(1); }); describe.each` diff --git a/spec/frontend/vue_shared/components/number_to_human_size/number_to_human_size_spec.js b/spec/frontend/vue_shared/components/number_to_human_size/number_to_human_size_spec.js new file mode 100644 index 00000000000..6dd22211c96 --- /dev/null +++ b/spec/frontend/vue_shared/components/number_to_human_size/number_to_human_size_spec.js @@ -0,0 +1,47 @@ +import { shallowMount } from '@vue/test-utils'; +import NumberToHumanSize from '~/vue_shared/components/number_to_human_size/number_to_human_size.vue'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; + +describe('NumberToHumanSize', () => { + /** @type {import('@vue/test-utils').Wrapper} */ + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(NumberToHumanSize, { + propsData: { + ...props, + }, + }); + }; + + it('formats the value', () => { + const value = 1024; + createComponent({ value }); + + const expectedValue = numberToHumanSize(value, 1); + expect(wrapper.text()).toBe(expectedValue); + }); + + it('handles number of fraction digits', () => { + const value = 1024 + 254; + const fractionDigits = 2; + createComponent({ value, fractionDigits }); + + const expectedValue = numberToHumanSize(value, fractionDigits); + expect(wrapper.text()).toBe(expectedValue); + }); + + describe('plain-zero', () => { + it('hides label for zero values', () => { + createComponent({ value: 0, plainZero: true }); + expect(wrapper.text()).toBe('0'); + }); + + it('shows text for non-zero values', () => { + const value = 163; + const expectedValue = numberToHumanSize(value, 1); + createComponent({ value, plainZero: true }); + expect(wrapper.text()).toBe(expectedValue); + }); + }); +}); 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 index c7b2363026a..cd18058abec 100644 --- 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 @@ -62,6 +62,7 @@ describe('Chunk component', () => { it('renders highlighted content', () => { expect(findContent().text()).toBe(CHUNK_2.highlightedContent); + expect(findContent().attributes('style')).toBe('margin-left: 96px;'); }); }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js b/spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js index 49e3083f8ed..c84a39274f8 100644 --- a/spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js @@ -6,6 +6,7 @@ import { LINES_PER_CHUNK, NEWLINE } from '~/vue_shared/components/source_viewer/ jest.mock('highlight.js/lib/core', () => ({ highlight: jest.fn().mockReturnValue({ value: 'highlighted content' }), registerLanguage: jest.fn(), + getLanguage: jest.fn(), })); jest.mock('~/vue_shared/components/source_viewer/plugins/index', () => ({ @@ -28,11 +29,37 @@ describe('Highlight utility', () => { expect(registerPlugins).toHaveBeenCalled(); }); + describe('sub-languages', () => { + const languageDefinition = { + subLanguage: 'xml', + contains: [{ subLanguage: 'javascript' }, { subLanguage: 'typescript' }], + }; + + beforeEach(async () => { + jest.spyOn(hljs, 'getLanguage').mockReturnValue(languageDefinition); + await highlight(fileType, rawContent, language); + }); + + it('registers the primary sub-language', () => { + expect(hljs.registerLanguage).toHaveBeenCalledWith( + languageDefinition.subLanguage, + expect.any(Function), + ); + }); + + it.each(languageDefinition.contains)( + 'registers the rest of the sub-languages', + ({ subLanguage }) => { + expect(hljs.registerLanguage).toHaveBeenCalledWith(subLanguage, expect.any(Function)); + }, + ); + }); + it('highlights the content', () => { expect(hljs.highlight).toHaveBeenCalledWith(rawContent, { language }); }); - it('splits the content into chunks', () => { + it('splits the content into chunks', async () => { const contentArray = Array.from({ length: 140 }, () => 'newline'); // simulate 140 lines of code const chunks = [ @@ -52,7 +79,7 @@ describe('Highlight utility', () => { }, ]; - expect(highlight(fileType, contentArray.join(NEWLINE), language)).toEqual( + expect(await highlight(fileType, contentArray.join(NEWLINE), language)).toEqual( expect.arrayContaining(chunks), ); }); @@ -71,7 +98,7 @@ describe('unsupported languages', () => { expect(hljs.highlight).not.toHaveBeenCalled(); }); - it('does not return a result', () => { - expect(highlight(fileType, rawContent, unsupportedLanguage)).toBe(undefined); + it('does not return a result', async () => { + expect(await highlight(fileType, rawContent, unsupportedLanguage)).toBe(undefined); }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/mock_data.js b/spec/frontend/vue_shared/components/source_viewer/mock_data.js index cfff3a15b77..c98f945fc54 100644 --- a/spec/frontend/vue_shared/components/source_viewer/mock_data.js +++ b/spec/frontend/vue_shared/components/source_viewer/mock_data.js @@ -79,6 +79,7 @@ export const BLAME_DATA_QUERY_RESPONSE_MOCK = { titleHtml: 'Upload New File', message: 'Upload New File', authoredDate: '2022-10-31T10:38:30+00:00', + authorName: 'Peter', authorGravatar: 'path/to/gravatar', webPath: '/commit/1234', author: {}, 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 index ee7164515f6..86dc9afaacc 100644 --- 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 @@ -1,11 +1,15 @@ -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { setHTMLFixture } from 'helpers/fixtures'; 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 { + EVENT_ACTION, + EVENT_LABEL_VIEWER, + CODEOWNERS_FILE_NAME, +} from '~/vue_shared/components/source_viewer/constants'; import Tracking from '~/tracking'; import LineHighlighter from '~/blob/line_highlighter'; import addBlobLinksTracking from '~/blob/blob_links_tracking'; @@ -13,6 +17,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import blameDataQuery from '~/vue_shared/components/source_viewer/queries/blame_data.query.graphql'; import Blame from '~/vue_shared/components/source_viewer/components/blame_info.vue'; import * as utils from '~/vue_shared/components/source_viewer/utils'; +import CodeownersValidation from 'ee_component/blob/components/codeowners_validation.vue'; import { BLOB_DATA_MOCK, @@ -43,16 +48,17 @@ describe('Source Viewer component', () => { const blameInfo = BLAME_DATA_QUERY_RESPONSE_MOCK.data.project.repository.blobs.nodes[0].blame.groups; - const createComponent = ({ showBlame = true } = {}) => { + const createComponent = ({ showBlame = true, blob = {} } = {}) => { fakeApollo = createMockApollo([[blameDataQuery, blameDataQueryHandlerSuccess]]); wrapper = shallowMountExtended(SourceViewer, { apolloProvider: fakeApollo, mocks: { $route: { hash } }, propsData: { - blob: BLOB_DATA_MOCK, + blob: { ...blob, ...BLOB_DATA_MOCK }, chunks: CHUNKS_MOCK, projectPath: 'test', + currentRef: 'main', showBlame, }, }); @@ -111,22 +117,18 @@ describe('Source Viewer component', () => { }); it('calls the query only once per chunk', async () => { - jest.spyOn(wrapper.vm.$apollo, 'query'); - // We trigger the `appear` event multiple times here in order to simulate the user scrolling past the chunk more than once. // In this scenario we only want to query the backend once. await triggerChunkAppear(); await triggerChunkAppear(); - expect(wrapper.vm.$apollo.query).toHaveBeenCalledTimes(1); + expect(blameDataQueryHandlerSuccess).toHaveBeenCalledTimes(1); }); it('requests blame information for overlapping chunk', async () => { - jest.spyOn(wrapper.vm.$apollo, 'query'); - await triggerChunkAppear(1); - expect(wrapper.vm.$apollo.query).toHaveBeenCalledTimes(2); + expect(blameDataQueryHandlerSuccess).toHaveBeenCalledTimes(2); expect(blameDataQueryHandlerSuccess).toHaveBeenCalledWith( expect.objectContaining({ fromLine: 71, toLine: 110 }), ); @@ -156,4 +158,20 @@ describe('Source Viewer component', () => { expect(lineHighlighter.highlightHash).toHaveBeenCalledWith(hash); }); }); + + describe('Codeowners validation', () => { + const findCodeownersValidation = () => wrapper.findComponent(CodeownersValidation); + + it('does not render codeowners validation when file is not CODEOWNERS', async () => { + await createComponent(); + await nextTick(); + expect(findCodeownersValidation().exists()).toBe(false); + }); + + it('renders codeowners validation when file is CODEOWNERS', async () => { + await createComponent({ blob: { name: CODEOWNERS_FILE_NAME } }); + await nextTick(); + expect(findCodeownersValidation().exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js b/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js index 41cf1d2b2e8..21c58d662e3 100644 --- a/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js +++ b/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js @@ -2,9 +2,9 @@ import { shallowMount } from '@vue/test-utils'; import { GlTruncate } from '@gitlab/ui'; import timezoneMock from 'timezone-mock'; -import { formatDate, getTimeago } from '~/lib/utils/datetime_utility'; -import { DATE_ONLY_FORMAT } from '~/lib/utils/datetime/constants'; +import { getTimeago } from '~/lib/utils/datetime_utility'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { DATE_ONLY_FORMAT } from '~/lib/utils/datetime/locale_dateformat'; describe('Time ago with tooltip component', () => { let vm; @@ -33,7 +33,7 @@ describe('Time ago with tooltip component', () => { it('should render timeago with a bootstrap tooltip', () => { buildVm(); - expect(vm.attributes('title')).toEqual(formatDate(timestamp)); + expect(vm.attributes('title')).toEqual('May 8, 2017 at 2:57:39 PM GMT'); expect(vm.text()).toEqual(timeAgoTimestamp); }); diff --git a/spec/frontend/vue_shared/components/vuex_module_provider_spec.js b/spec/frontend/vue_shared/components/vuex_module_provider_spec.js deleted file mode 100644 index 95f557b10c1..00000000000 --- a/spec/frontend/vue_shared/components/vuex_module_provider_spec.js +++ /dev/null @@ -1,39 +0,0 @@ -import { mount } from '@vue/test-utils'; -import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue'; - -const TestComponent = { - inject: ['vuexModule'], - template: `<div data-testid="vuexModule">{{ vuexModule }}</div> `, -}; - -const TEST_VUEX_MODULE = 'testVuexModule'; - -describe('~/vue_shared/components/vuex_module_provider', () => { - let wrapper; - - const findProvidedVuexModule = () => wrapper.find('[data-testid="vuexModule"]').text(); - - const createComponent = (extraParams = {}) => { - wrapper = mount(VuexModuleProvider, { - propsData: { - vuexModule: TEST_VUEX_MODULE, - }, - slots: { - default: TestComponent, - }, - ...extraParams, - }); - }; - - it('provides "vuexModule" set from prop', () => { - createComponent(); - expect(findProvidedVuexModule()).toBe(TEST_VUEX_MODULE); - }); - - it('provides "vuexModel" set from "vuex-module" prop when using @vue/compat', () => { - createComponent({ - propsData: { 'vuex-module': TEST_VUEX_MODULE }, - }); - expect(findProvidedVuexModule()).toBe(TEST_VUEX_MODULE); - }); -}); diff --git a/spec/frontend/vue_shared/directives/track_event_spec.js b/spec/frontend/vue_shared/directives/track_event_spec.js index fc69e884258..8b4a68e394a 100644 --- a/spec/frontend/vue_shared/directives/track_event_spec.js +++ b/spec/frontend/vue_shared/directives/track_event_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import Tracking from '~/tracking'; import TrackEvent from '~/vue_shared/directives/track_event'; @@ -10,34 +10,53 @@ describe('TrackEvent directive', () => { const clickButton = () => wrapper.find('button').trigger('click'); - const createComponent = (trackingOptions) => - Vue.component('DummyElement', { - directives: { - TrackEvent, + const DummyTrackComponent = Vue.component('DummyTrackComponent', { + directives: { + TrackEvent, + }, + props: { + category: { + type: String, + required: false, + default: '', }, - data() { - return { - trackingOptions, - }; + action: { + type: String, + required: false, + default: '', }, - template: '<button v-track-event="trackingOptions"></button>', - }); + label: { + type: String, + required: false, + default: '', + }, + }, + template: '<button v-track-event="{ category, action, label }"></button>', + }); - const mountComponent = (trackingOptions) => shallowMount(createComponent(trackingOptions)); + const mountComponent = ({ propsData = {} } = {}) => { + wrapper = shallowMount(DummyTrackComponent, { + propsData, + }); + }; it('does not track the event if required arguments are not provided', () => { - wrapper = mountComponent(); + mountComponent(); clickButton(); expect(Tracking.event).not.toHaveBeenCalled(); }); - it('tracks event on click if tracking info provided', () => { - wrapper = mountComponent({ - category: 'Tracking', - action: 'click_trackable_btn', - label: 'Trackable Info', + it('tracks event on click if tracking info provided', async () => { + mountComponent({ + propsData: { + category: 'Tracking', + action: 'click_trackable_btn', + label: 'Trackable Info', + }, }); + + await nextTick(); clickButton(); expect(Tracking.event).toHaveBeenCalledWith('Tracking', 'click_trackable_btn', { diff --git a/spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js b/spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js index 1a490359040..94234a03664 100644 --- a/spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js +++ b/spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js @@ -16,6 +16,7 @@ const fullPath = '/full-path'; const labelsFilterBasePath = '/labels-filter-base-path'; const initialLabels = []; const issuableType = 'issue'; +const issuableSupportsLockOnMerge = false; const labelType = WORKSPACE_PROJECT; const variant = VARIANT_EMBEDDED; const workspaceType = WORKSPACE_PROJECT; @@ -36,6 +37,7 @@ describe('IssuableLabelSelector', () => { labelsFilterBasePath, initialLabels, issuableType, + issuableSupportsLockOnMerge, labelType, variant, workspaceType, diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js index 47da111b604..98a87ddbcce 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js @@ -6,6 +6,7 @@ import IssuableItem from '~/vue_shared/issuable/list/components/issuable_item.vu import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import IssuableAssignees from '~/issuable/components/issue_assignees.vue'; +import { localeDateFormat } from '~/lib/utils/datetime/locale_dateformat'; import { mockIssuable, mockRegularLabel } from '../mock_data'; const createComponent = ({ @@ -168,15 +169,20 @@ describe('IssuableItem', () => { it('returns timestamp based on `issuable.updatedAt` when the issue is open', () => { wrapper = createComponent(); - expect(findTimestampWrapper().attributes('title')).toBe('Sep 10, 2020 11:41am UTC'); + expect(findTimestampWrapper().attributes('title')).toBe( + localeDateFormat.asDateTimeFull.format(mockIssuable.updatedAt), + ); }); it('returns timestamp based on `issuable.closedAt` when the issue is closed', () => { + const closedAt = '2020-06-18T11:30:00Z'; wrapper = createComponent({ - issuable: { ...mockIssuable, closedAt: '2020-06-18T11:30:00Z', state: 'closed' }, + issuable: { ...mockIssuable, closedAt, state: 'closed' }, }); - expect(findTimestampWrapper().attributes('title')).toBe('Jun 18, 2020 11:30am UTC'); + expect(findTimestampWrapper().attributes('title')).toBe( + localeDateFormat.asDateTimeFull.format(closedAt), + ); }); it('returns timestamp based on `issuable.updatedAt` when the issue is closed but `issuable.closedAt` is undefined', () => { @@ -184,7 +190,9 @@ describe('IssuableItem', () => { issuable: { ...mockIssuable, closedAt: undefined, state: 'closed' }, }); - expect(findTimestampWrapper().attributes('title')).toBe('Sep 10, 2020 11:41am UTC'); + expect(findTimestampWrapper().attributes('title')).toBe( + localeDateFormat.asDateTimeFull.format(mockIssuable.updatedAt), + ); }); }); @@ -409,7 +417,9 @@ describe('IssuableItem', () => { const createdAtEl = wrapper.find('[data-testid="issuable-created-at"]'); expect(createdAtEl.exists()).toBe(true); - expect(createdAtEl.attributes('title')).toBe('Jun 29, 2020 1:52pm UTC'); + expect(createdAtEl.attributes('title')).toBe( + localeDateFormat.asDateTimeFull.format(mockIssuable.createdAt), + ); expect(createdAtEl.text()).toBe(wrapper.vm.createdAt); }); @@ -535,7 +545,9 @@ describe('IssuableItem', () => { const timestampEl = wrapper.find('[data-testid="issuable-timestamp"]'); - expect(timestampEl.attributes('title')).toBe('Sep 10, 2020 11:41am UTC'); + expect(timestampEl.attributes('title')).toBe( + localeDateFormat.asDateTimeFull.format(mockIssuable.updatedAt), + ); expect(timestampEl.text()).toBe(wrapper.vm.formattedTimestamp); }); @@ -549,13 +561,16 @@ describe('IssuableItem', () => { }); it('renders issuable closedAt info and does not render updatedAt info', () => { + const closedAt = '2022-06-18T11:30:00Z'; wrapper = createComponent({ - issuable: { ...mockIssuable, closedAt: '2022-06-18T11:30:00Z', state: 'closed' }, + issuable: { ...mockIssuable, closedAt, state: 'closed' }, }); const timestampEl = wrapper.find('[data-testid="issuable-timestamp"]'); - expect(timestampEl.attributes('title')).toBe('Jun 18, 2022 11:30am UTC'); + expect(timestampEl.attributes('title')).toBe( + localeDateFormat.asDateTimeFull.format(closedAt), + ); expect(timestampEl.text()).toBe(wrapper.vm.formattedTimestamp); }); }); 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 51aae9b4512..a2a059d5b18 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 @@ -4,6 +4,7 @@ import VueDraggable from 'vuedraggable'; import { nextTick } from 'vue'; import { TEST_HOST } from 'helpers/test_constants'; +import { DRAG_DELAY } from '~/sortable/constants'; import IssuableItem from '~/vue_shared/issuable/list/components/issuable_item.vue'; import IssuableListRoot from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; @@ -476,6 +477,11 @@ describe('IssuableListRoot', () => { expect(findIssuableItem().classes()).toContain('gl-cursor-grab'); }); + it('sets delay and delayOnTouchOnly attributes on list', () => { + expect(findVueDraggable().vm.$attrs.delay).toBe(DRAG_DELAY); + expect(findVueDraggable().vm.$attrs.delayOnTouchOnly).toBe(true); + }); + it('emits a "reorder" event when user updates the issue order', () => { const oldIndex = 4; const newIndex = 6; diff --git a/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js b/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js index f2509aead77..d5c6ece8cb5 100644 --- a/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js +++ b/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js @@ -1,3 +1,4 @@ +import { GlButton, GlIcon } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { nextTick } from 'vue'; import Cookies from '~/lib/utils/cookies'; @@ -18,6 +19,10 @@ const createComponent = () => { <button class="js-todo">Todo</button> `, }, + stubs: { + GlButton, + GlIcon, + }, }); }; @@ -62,9 +67,8 @@ describe('IssuableSidebarRoot', () => { const buttonEl = findToggleSidebarButton(); expect(buttonEl.exists()).toBe(true); - expect(buttonEl.attributes('title')).toBe('Toggle sidebar'); - expect(buttonEl.find('span').text()).toBe('Collapse sidebar'); - expect(wrapper.findByTestId('icon-collapse').isVisible()).toBe(true); + expect(buttonEl.attributes('title')).toBe('Collapse sidebar'); + expect(wrapper.findByTestId('chevron-double-lg-right-icon').isVisible()).toBe(true); }); describe('when collapsing the sidebar', () => { @@ -116,12 +120,12 @@ describe('IssuableSidebarRoot', () => { assertPageLayoutClasses({ isExpanded: false }); }); - it('renders sidebar toggle button with text and icon', () => { + it('renders sidebar toggle button with title and icon', () => { const buttonEl = findToggleSidebarButton(); expect(buttonEl.exists()).toBe(true); - expect(buttonEl.attributes('title')).toBe('Toggle sidebar'); - expect(wrapper.findByTestId('icon-expand').isVisible()).toBe(true); + expect(buttonEl.attributes('title')).toBe('Expand sidebar'); + expect(wrapper.findByTestId('chevron-double-lg-left-icon').isVisible()).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 109b7732539..716de45f4b4 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 @@ -116,21 +116,23 @@ describe('Experimental new namespace creation app', () => { expect(findLegacyContainer().exists()).toBe(true); }); - describe.each` - featureFlag | isSuperSidebarCollapsed | isToggleVisible - ${true} | ${true} | ${true} - ${true} | ${false} | ${false} - ${false} | ${true} | ${false} - ${false} | ${false} | ${false} - `('Super sidebar toggle', ({ featureFlag, isSuperSidebarCollapsed, isToggleVisible }) => { - beforeEach(() => { - sidebarState.isCollapsed = isSuperSidebarCollapsed; - gon.use_new_navigation = featureFlag; - createComponent(); + describe('SuperSidebarToggle', () => { + describe('when collapsed', () => { + it('shows sidebar toggle', () => { + sidebarState.isCollapsed = true; + createComponent(); + + expect(findSuperSidebarToggle().exists()).toBe(true); + }); }); - it(`${isToggleVisible ? 'is visible' : 'is not visible'}`, () => { - expect(findSuperSidebarToggle().exists()).toBe(isToggleVisible); + describe('when not collapsed', () => { + it('does not show sidebar toggle', () => { + sidebarState.isCollapsed = false; + createComponent(); + + expect(findSuperSidebarToggle().exists()).toBe(false); + }); }); }); @@ -170,17 +172,10 @@ describe('Experimental new namespace creation app', () => { }); describe('top bar', () => { - it('adds "top-bar-fixed" and "container-fluid" classes when new navigation enabled', () => { - gon.use_new_navigation = true; + it('has "top-bar-fixed" and "container-fluid" classes', () => { createComponent(); expect(findTopBar().classes()).toEqual(['top-bar-fixed', 'container-fluid']); }); - - it('does not add classes when new navigation is not enabled', () => { - createComponent(); - - expect(findTopBar().classes()).toEqual([]); - }); }); }); diff --git a/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js index f3d0d66cdd1..2b36344cfa8 100644 --- a/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js +++ b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js @@ -2,7 +2,7 @@ import { GlButton } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { featureToMutationMap } from 'ee_else_ce/security_configuration/components/constants'; +import { featureToMutationMap } from 'ee_else_ce/security_configuration/constants'; import createMockApollo from 'helpers/mock_apollo_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; diff --git a/spec/frontend/webhooks/components/form_url_app_spec.js b/spec/frontend/webhooks/components/form_url_app_spec.js index cbeff184e9d..fe8bba68610 100644 --- a/spec/frontend/webhooks/components/form_url_app_spec.js +++ b/spec/frontend/webhooks/components/form_url_app_spec.js @@ -1,5 +1,5 @@ import { nextTick } from 'vue'; -import { GlFormGroup, GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui'; +import { GlFormGroup, GlFormRadio, GlFormRadioGroup, GlLink, GlAlert } from '@gitlab/ui'; import { scrollToElement } from '~/lib/utils/common_utils'; import FormUrlApp from '~/webhooks/components/form_url_app.vue'; @@ -30,6 +30,7 @@ describe('FormUrlApp', () => { const findFormUrlPreview = () => wrapper.findByTestId('form-url-preview'); const findUrlMaskSection = () => wrapper.findByTestId('url-mask-section'); const findFormEl = () => document.querySelector('.js-webhook-form'); + const findAlert = () => wrapper.findComponent(GlAlert); const submitForm = () => findFormEl().dispatchEvent(new Event('submit')); describe('template', () => { @@ -156,6 +157,23 @@ describe('FormUrlApp', () => { }); }); + describe('token will be cleared warning', () => { + beforeEach(() => { + createComponent({ initialUrl: 'url' }); + }); + + it('is hidden when URL has not changed', () => { + expect(findAlert().exists()).toBe(false); + }); + + it('is displayed when URL has changed', async () => { + findFormUrl().vm.$emit('input', 'another_url'); + await nextTick(); + + expect(findAlert().exists()).toBe(true); + }); + }); + describe('validations', () => { const inputRequiredText = FormUrlApp.i18n.inputRequired; diff --git a/spec/frontend/whats_new/store/actions_spec.js b/spec/frontend/whats_new/store/actions_spec.js index 5f5e4e53be2..908aa3aaeea 100644 --- a/spec/frontend/whats_new/store/actions_spec.js +++ b/spec/frontend/whats_new/store/actions_spec.js @@ -11,8 +11,8 @@ describe('whats new actions', () => { describe('openDrawer', () => { useLocalStorageSpy(); - it('should commit openDrawer', () => { - testAction(actions.openDrawer, 'digest-hash', {}, [{ type: types.OPEN_DRAWER }]); + it('should commit openDrawer', async () => { + await testAction(actions.openDrawer, 'digest-hash', {}, [{ type: types.OPEN_DRAWER }]); expect(window.localStorage.setItem).toHaveBeenCalledWith( 'display-whats-new-notification', @@ -23,7 +23,7 @@ describe('whats new actions', () => { describe('closeDrawer', () => { it('should commit closeDrawer', () => { - testAction(actions.closeDrawer, {}, {}, [{ type: types.CLOSE_DRAWER }]); + return testAction(actions.closeDrawer, {}, {}, [{ type: types.CLOSE_DRAWER }]); }); }); @@ -52,7 +52,7 @@ describe('whats new actions', () => { .onGet('/-/whats_new', { params: { page: undefined, v: undefined } }) .replyOnce(HTTP_STATUS_OK, [{ title: 'GitLab Stories' }]); - testAction( + return testAction( actions.fetchItems, {}, {}, @@ -69,7 +69,7 @@ describe('whats new actions', () => { .onGet('/-/whats_new', { params: { page: 8, v: 42 } }) .replyOnce(HTTP_STATUS_OK, [{ title: 'GitLab Stories' }]); - testAction( + return testAction( actions.fetchItems, { page: 8, versionDigest: 42 }, {}, @@ -80,11 +80,11 @@ describe('whats new actions', () => { }); it('if already fetching, does not fetch', () => { - testAction(actions.fetchItems, {}, { fetching: true }, []); + return testAction(actions.fetchItems, {}, { fetching: true }, []); }); it('should commit fetching, setFeatures and setPagination', () => { - testAction(actions.fetchItems, {}, {}, [ + return testAction(actions.fetchItems, {}, {}, [ { type: types.SET_FETCHING, payload: true }, { type: types.ADD_FEATURES, payload: [{ title: 'Whats New Drawer', url: 'www.url.com' }] }, { type: types.SET_PAGE_INFO, payload: { nextPage: 2 } }, @@ -94,8 +94,10 @@ describe('whats new actions', () => { }); describe('setDrawerBodyHeight', () => { - testAction(actions.setDrawerBodyHeight, 42, {}, [ - { type: types.SET_DRAWER_BODY_HEIGHT, payload: 42 }, - ]); + it('should commit setDrawerBodyHeight', () => { + return testAction(actions.setDrawerBodyHeight, 42, {}, [ + { type: types.SET_DRAWER_BODY_HEIGHT, payload: 42 }, + ]); + }); }); }); diff --git a/spec/frontend/whats_new/utils/notification_spec.js b/spec/frontend/whats_new/utils/notification_spec.js deleted file mode 100644 index 020d833c578..00000000000 --- a/spec/frontend/whats_new/utils/notification_spec.js +++ /dev/null @@ -1,73 +0,0 @@ -import htmlWhatsNewNotification from 'test_fixtures_static/whats_new_notification.html'; -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import { useLocalStorageSpy } from 'helpers/local_storage_helper'; -import { setNotification, getVersionDigest } from '~/whats_new/utils/notification'; - -describe('~/whats_new/utils/notification', () => { - useLocalStorageSpy(); - - let wrapper; - - const findNotificationEl = () => wrapper.querySelector('.header-help'); - const findNotificationCountEl = () => wrapper.querySelector('.js-whats-new-notification-count'); - const getAppEl = () => wrapper.querySelector('.app'); - - beforeEach(() => { - setHTMLFixture(htmlWhatsNewNotification); - wrapper = document.querySelector('.whats-new-notification-fixture-root'); - }); - - afterEach(() => { - wrapper.remove(); - resetHTMLFixture(); - }); - - describe('setNotification', () => { - const subject = () => setNotification(getAppEl()); - - it("when storage key doesn't exist it adds notifications class", () => { - const notificationEl = findNotificationEl(); - - expect(notificationEl.classList).not.toContain('with-notifications'); - - subject(); - - expect(findNotificationCountEl()).not.toBe(null); - expect(notificationEl.classList).toContain('with-notifications'); - }); - - 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'); - - expect(findNotificationCountEl()).not.toBe(null); - - subject(); - - 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', () => { - it('retrieves the storage key data attribute from the el', () => { - expect(getVersionDigest(getAppEl())).toBe('version-digest'); - }); - }); -}); diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js index 3a84ba4bd5e..660ff671a80 100644 --- a/spec/frontend/work_items/components/item_title_spec.js +++ b/spec/frontend/work_items/components/item_title_spec.js @@ -2,11 +2,12 @@ import { shallowMount } from '@vue/test-utils'; import { escape } from 'lodash'; import ItemTitle from '~/work_items/components/item_title.vue'; -const createComponent = ({ title = 'Sample title', disabled = false } = {}) => +const createComponent = ({ title = 'Sample title', disabled = false, useH1 = false } = {}) => shallowMount(ItemTitle, { propsData: { title, disabled, + useH1, }, }); @@ -27,6 +28,12 @@ describe('ItemTitle', () => { expect(findInputEl().text()).toBe('Sample title'); }); + it('renders H1 if useH1 is true, otherwise renders H2', () => { + expect(wrapper.element.tagName).toBe('H2'); + wrapper = createComponent({ useH1: true }); + expect(wrapper.element.tagName).toBe('H1'); + }); + it('renders title contents with editing disabled', () => { wrapper = createComponent({ disabled: true, 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 596283a9590..97aed1d548e 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,4 +1,4 @@ -import { GlButton, GlDisclosureDropdown } from '@gitlab/ui'; +import { GlDisclosureDropdown } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -17,7 +17,7 @@ describe('Work Item Note Actions', () => { const showSpy = jest.fn(); const findReplyButton = () => wrapper.findComponent(ReplyButton); - const findEditButton = () => wrapper.findComponent(GlButton); + const findEditButton = () => wrapper.findByTestId('note-actions-edit'); const findEmojiButton = () => wrapper.findByTestId('note-emoji-button'); const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); const findDeleteNoteButton = () => wrapper.findByTestId('delete-note-action'); @@ -64,6 +64,7 @@ describe('Work Item Note Actions', () => { projectName, }, provide: { + isGroup: false, glFeatures: { workItemsMvc2: true, }, diff --git a/spec/frontend/work_items/components/notes/work_item_note_awards_list_spec.js b/spec/frontend/work_items/components/notes/work_item_note_awards_list_spec.js index ce915635946..6ce4c09329f 100644 --- a/spec/frontend/work_items/components/notes/work_item_note_awards_list_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_note_awards_list_spec.js @@ -9,6 +9,7 @@ import AwardsList from '~/vue_shared/components/awards_list.vue'; import WorkItemNoteAwardsList from '~/work_items/components/notes/work_item_note_awards_list.vue'; import addAwardEmojiMutation from '~/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql'; import removeAwardEmojiMutation from '~/work_items/graphql/notes/work_item_note_remove_award_emoji.mutation.graphql'; +import groupWorkItemNotesByIidQuery from '~/work_items/graphql/notes/group_work_item_notes_by_iid.query.graphql'; import workItemNotesByIidQuery from '~/work_items/graphql/notes/work_item_notes_by_iid.query.graphql'; import { mockWorkItemNotesResponseWithComments, @@ -45,7 +46,9 @@ describe('Work Item Note Awards List', () => { const findAwardsList = () => wrapper.findComponent(AwardsList); const createComponent = ({ + isGroup = false, note = firstNote, + query = workItemNotesByIidQuery, addAwardEmojiMutationHandler = addAwardEmojiMutationSuccessHandler, removeAwardEmojiMutationHandler = removeAwardEmojiMutationSuccessHandler, } = {}) => { @@ -55,12 +58,15 @@ describe('Work Item Note Awards List', () => { ]); apolloProvider.clients.defaultClient.writeQuery({ - query: workItemNotesByIidQuery, + query, variables: { fullPath, iid: workItemIid }, ...mockWorkItemNotesResponseWithComments, }); wrapper = shallowMount(WorkItemNoteAwardsList, { + provide: { + isGroup, + }, propsData: { fullPath, workItemIid, @@ -89,54 +95,58 @@ describe('Work Item Note Awards List', () => { expect(findAwardsList().props('canAwardEmoji')).toBe(hasAwardEmojiPermission); }); - it('adds award if not already awarded', async () => { - createComponent(); - await waitForPromises(); - - findAwardsList().vm.$emit('award', EMOJI_THUMBSUP); - - expect(addAwardEmojiMutationSuccessHandler).toHaveBeenCalledWith({ - awardableId: firstNote.id, - name: EMOJI_THUMBSUP, - }); - }); + it.each` + isGroup | query + ${true} | ${groupWorkItemNotesByIidQuery} + ${false} | ${workItemNotesByIidQuery} + `( + 'adds award if not already awarded in both group and project contexts', + async ({ isGroup, query }) => { + createComponent({ isGroup, query }); + await waitForPromises(); + + findAwardsList().vm.$emit('award', EMOJI_THUMBSUP); + + expect(addAwardEmojiMutationSuccessHandler).toHaveBeenCalledWith({ + awardableId: firstNote.id, + name: EMOJI_THUMBSUP, + }); + }, + ); it('emits error if awarding emoji fails', async () => { - createComponent({ - addAwardEmojiMutationHandler: jest.fn().mockRejectedValue('oh no'), - }); - await waitForPromises(); + createComponent({ addAwardEmojiMutationHandler: jest.fn().mockRejectedValue('oh no') }); findAwardsList().vm.$emit('award', EMOJI_THUMBSUP); - await waitForPromises(); expect(wrapper.emitted('error')).toEqual([[__('Failed to add emoji. Please try again')]]); }); - it('removes award if already awarded', async () => { - const removeAwardEmojiMutationHandler = removeAwardEmojiMutationSuccessHandler; - - createComponent({ removeAwardEmojiMutationHandler }); - - findAwardsList().vm.$emit('award', EMOJI_THUMBSDOWN); - - await waitForPromises(); - - expect(removeAwardEmojiMutationHandler).toHaveBeenCalledWith({ - awardableId: firstNote.id, - name: EMOJI_THUMBSDOWN, - }); - }); + it.each` + isGroup | query + ${true} | ${groupWorkItemNotesByIidQuery} + ${false} | ${workItemNotesByIidQuery} + `( + 'removes award if already awarded in both group and project contexts', + async ({ isGroup, query }) => { + const removeAwardEmojiMutationHandler = removeAwardEmojiMutationSuccessHandler; + createComponent({ isGroup, query, removeAwardEmojiMutationHandler }); + + findAwardsList().vm.$emit('award', EMOJI_THUMBSDOWN); + await waitForPromises(); + + expect(removeAwardEmojiMutationHandler).toHaveBeenCalledWith({ + awardableId: firstNote.id, + name: EMOJI_THUMBSDOWN, + }); + }, + ); it('restores award if remove fails', async () => { - createComponent({ - removeAwardEmojiMutationHandler: jest.fn().mockRejectedValue('oh no'), - }); - await waitForPromises(); + createComponent({ removeAwardEmojiMutationHandler: jest.fn().mockRejectedValue('oh no') }); findAwardsList().vm.$emit('award', EMOJI_THUMBSDOWN); - await waitForPromises(); expect(wrapper.emitted('error')).toEqual([[__('Failed to remove emoji. Please try again')]]); diff --git a/spec/frontend/work_items/components/notes/work_item_notes_activity_header_spec.js b/spec/frontend/work_items/components/notes/work_item_notes_activity_header_spec.js index daf74f7a93b..dff54fef9fe 100644 --- a/spec/frontend/work_items/components/notes/work_item_notes_activity_header_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_notes_activity_header_spec.js @@ -9,7 +9,8 @@ import { describe('Work Item Note Activity Header', () => { let wrapper; - const findActivityLabelHeading = () => wrapper.find('h3'); + const findActivityLabelH2Heading = () => wrapper.find('h2'); + const findActivityLabelH3Heading = () => wrapper.find('h3'); const findActivityFilterDropdown = () => wrapper.findByTestId('work-item-filter'); const findActivitySortDropdown = () => wrapper.findByTestId('work-item-sort'); @@ -18,6 +19,7 @@ describe('Work Item Note Activity Header', () => { sortOrder = ASC, workItemType = 'Task', discussionFilter = WORK_ITEM_NOTES_FILTER_ALL_NOTES, + useH2 = false, } = {}) => { wrapper = shallowMountExtended(WorkItemNotesActivityHeader, { propsData: { @@ -25,6 +27,7 @@ describe('Work Item Note Activity Header', () => { sortOrder, workItemType, discussionFilter, + useH2, }, }); }; @@ -34,7 +37,18 @@ describe('Work Item Note Activity Header', () => { }); it('Should have the Activity label', () => { - expect(findActivityLabelHeading().text()).toBe(WorkItemNotesActivityHeader.i18n.activityLabel); + expect(findActivityLabelH3Heading().text()).toBe( + WorkItemNotesActivityHeader.i18n.activityLabel, + ); + }); + + it('Should render an H2 instead of an H3 if useH2 is true', () => { + createComponent(); + expect(findActivityLabelH3Heading().exists()).toBe(true); + expect(findActivityLabelH2Heading().exists()).toBe(false); + createComponent({ useH2: true }); + expect(findActivityLabelH2Heading().exists()).toBe(true); + expect(findActivityLabelH3Heading().exists()).toBe(false); }); it('Should have Activity filtering dropdown', () => { diff --git a/spec/frontend/work_items/components/work_item_ancestors/disclosure_hierarchy_item_spec.js b/spec/frontend/work_items/components/work_item_ancestors/disclosure_hierarchy_item_spec.js new file mode 100644 index 00000000000..2cfe61654ad --- /dev/null +++ b/spec/frontend/work_items/components/work_item_ancestors/disclosure_hierarchy_item_spec.js @@ -0,0 +1,53 @@ +import { GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; + +import DisclosureHierarchyItem from '~/work_items/components/work_item_ancestors/disclosure_hierarchy_item.vue'; +import { mockDisclosureHierarchyItems } from './mock_data'; + +describe('DisclosurePathItem', () => { + let wrapper; + + const findIcon = () => wrapper.findComponent(GlIcon); + + const createComponent = (props = {}, options = {}) => { + return shallowMount(DisclosureHierarchyItem, { + propsData: { + item: mockDisclosureHierarchyItems[0], + ...props, + }, + ...options, + }); + }; + + beforeEach(() => { + wrapper = createComponent(); + }); + + describe('renders the item', () => { + it('renders the inline icon', () => { + expect(findIcon().exists()).toBe(true); + expect(findIcon().props('name')).toBe(mockDisclosureHierarchyItems[0].icon); + }); + }); + + describe('item slot', () => { + beforeEach(() => { + wrapper = createComponent(null, { + scopedSlots: { + default: ` + <div + data-testid="item-slot-content"> + {{ props.item.title }} + </div> + `, + }, + }); + }); + + it('contains all elements passed into the additional slot', () => { + const item = wrapper.find('[data-testid="item-slot-content"]'); + + expect(item.text()).toBe(mockDisclosureHierarchyItems[0].title); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_ancestors/disclosure_hierarchy_spec.js b/spec/frontend/work_items/components/work_item_ancestors/disclosure_hierarchy_spec.js new file mode 100644 index 00000000000..b808c13c3e7 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_ancestors/disclosure_hierarchy_spec.js @@ -0,0 +1,99 @@ +import { shallowMount } from '@vue/test-utils'; + +import { GlDisclosureDropdown, GlTooltip } from '@gitlab/ui'; +import DisclosureHierarchy from '~/work_items/components/work_item_ancestors//disclosure_hierarchy.vue'; +import DisclosureHierarchyItem from '~/work_items/components/work_item_ancestors/disclosure_hierarchy_item.vue'; +import { mockDisclosureHierarchyItems } from './mock_data'; + +describe('DisclosurePath', () => { + let wrapper; + + const createComponent = (props = {}, options = {}) => { + return shallowMount(DisclosureHierarchy, { + propsData: { + items: mockDisclosureHierarchyItems, + ...props, + }, + ...options, + }); + }; + + const listItems = () => wrapper.findAllComponents(DisclosureHierarchyItem); + const itemAt = (index) => listItems().at(index); + const itemTextAt = (index) => itemAt(index).props('item').title; + + beforeEach(() => { + wrapper = createComponent(); + }); + + describe('renders the list of items', () => { + it('renders the correct number of items', () => { + expect(listItems().length).toBe(mockDisclosureHierarchyItems.length); + }); + + it('renders the items in the correct order', () => { + expect(itemTextAt(0)).toContain(mockDisclosureHierarchyItems[0].title); + expect(itemTextAt(4)).toContain(mockDisclosureHierarchyItems[4].title); + expect(itemTextAt(9)).toContain(mockDisclosureHierarchyItems[9].title); + }); + }); + + describe('slots', () => { + beforeEach(() => { + wrapper = createComponent(null, { + scopedSlots: { + default: ` + <div + :data-itemid="props.itemId" + data-testid="item-slot-content"> + {{ props.item.title }} + </div> + `, + }, + }); + }); + + it('contains all elements passed into the default slot', () => { + mockDisclosureHierarchyItems.forEach((item, index) => { + const disclosureItem = wrapper.findAll('[data-testid="item-slot-content"]').at(index); + + expect(disclosureItem.text()).toBe(item.title); + expect(disclosureItem.attributes('data-itemid')).toContain('disclosure-'); + }); + }); + }); + + describe('with ellipsis', () => { + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findTooltip = () => wrapper.findComponent(GlTooltip); + const findTooltipText = () => findTooltip().text(); + const tooltipText = 'Display more items'; + + beforeEach(() => { + wrapper = createComponent({ withEllipsis: true, ellipsisTooltipLabel: tooltipText }); + }); + + describe('renders items and dropdown', () => { + it('renders 2 items', () => { + expect(listItems().length).toBe(2); + }); + + it('renders first and last items', () => { + expect(itemTextAt(0)).toContain(mockDisclosureHierarchyItems[0].title); + expect(itemTextAt(1)).toContain( + mockDisclosureHierarchyItems[mockDisclosureHierarchyItems.length - 1].title, + ); + }); + + it('renders dropdown with the rest of the items passed down', () => { + expect(findDropdown().exists()).toBe(true); + expect(findDropdown().props('items').length).toBe(mockDisclosureHierarchyItems.length - 2); + }); + + it('renders tooltip with text passed as prop', () => { + expect(findTooltip().exists()).toBe(true); + expect(findTooltipText()).toBe(tooltipText); + }); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_ancestors/mock_data.js b/spec/frontend/work_items/components/work_item_ancestors/mock_data.js new file mode 100644 index 00000000000..8e7f99658de --- /dev/null +++ b/spec/frontend/work_items/components/work_item_ancestors/mock_data.js @@ -0,0 +1,197 @@ +export const mockDisclosureHierarchyItems = [ + { + title: 'First', + icon: 'epic', + href: '#', + }, + { + title: 'Second', + icon: 'epic', + href: '#', + }, + { + title: 'Third', + icon: 'epic', + href: '#', + }, + { + title: 'Fourth', + icon: 'epic', + href: '#', + }, + { + title: 'Fifth', + icon: 'issues', + href: '#', + }, + { + title: 'Sixth', + icon: 'issues', + href: '#', + }, + { + title: 'Seventh', + icon: 'issues', + href: '#', + }, + { + title: 'Eighth', + icon: 'issue-type-task', + href: '#', + disabled: true, + }, + { + title: 'Ninth', + icon: 'issue-type-task', + href: '#', + }, + { + title: 'Tenth', + icon: 'issue-type-task', + href: '#', + }, +]; + +export const workItemAncestorsQueryResponse = { + data: { + workItem: { + __typename: 'WorkItem', + id: 'gid://gitlab/WorkItem/1', + title: 'Test', + widgets: [ + { + __typename: 'WorkItemWidgetHierarchy', + type: 'HIERARCHY', + parent: { + id: 'gid://gitlab/Issue/1', + }, + ancestors: { + nodes: [ + { + id: 'gid://gitlab/WorkItem/444', + iid: '4', + reference: '#40', + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, + confidential: false, + title: '123', + state: 'OPEN', + webUrl: '/gitlab-org/gitlab-test/-/work_items/4', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/2', + name: 'Issue', + iconName: 'issue-type-issue', + }, + }, + ], + }, + }, + ], + }, + }, +}; + +export const workItemThreeAncestorsQueryResponse = { + data: { + workItem: { + __typename: 'WorkItem', + id: 'gid://gitlab/WorkItem/1', + title: 'Test', + workItemType: { + __typename: 'WorkItemType', + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + iconName: 'issue-type-task', + }, + widgets: [ + { + __typename: 'WorkItemWidgetHierarchy', + type: 'HIERARCHY', + parent: { + id: 'gid://gitlab/Issue/1', + }, + ancestors: { + nodes: [ + { + id: 'gid://gitlab/WorkItem/444', + iid: '4', + reference: '#40', + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, + confidential: false, + title: '123', + state: 'OPEN', + webUrl: '/gitlab-org/gitlab-test/-/work_items/4', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/2', + name: 'Issue', + iconName: 'issue-type-issue', + }, + }, + { + id: 'gid://gitlab/WorkItem/445', + iid: '5', + reference: '#41', + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, + confidential: false, + title: '1234', + state: 'OPEN', + webUrl: '/gitlab-org/gitlab-test/-/work_items/5', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/2', + name: 'Issue', + iconName: 'issue-type-issue', + }, + }, + { + id: 'gid://gitlab/WorkItem/446', + iid: '6', + reference: '#42', + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, + confidential: false, + title: '12345', + state: 'OPEN', + webUrl: '/gitlab-org/gitlab-test/-/work_items/6', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/2', + name: 'Issue', + iconName: 'issue-type-issue', + }, + }, + ], + }, + }, + ], + }, + }, +}; + +export const workItemEmptyAncestorsQueryResponse = { + data: { + workItem: { + __typename: 'WorkItem', + id: 'gid://gitlab/WorkItem/1', + title: 'Test', + workItemType: { + __typename: 'WorkItemType', + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + iconName: 'issue-type-task', + }, + widgets: [ + { + __typename: 'WorkItemWidgetHierarchy', + type: 'HIERARCHY', + parent: { + id: null, + }, + ancestors: { + nodes: [], + }, + }, + ], + }, + }, +}; diff --git a/spec/frontend/work_items/components/work_item_ancestors/work_item_ancestors_spec.js b/spec/frontend/work_items/components/work_item_ancestors/work_item_ancestors_spec.js new file mode 100644 index 00000000000..a9f66b20f06 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_ancestors/work_item_ancestors_spec.js @@ -0,0 +1,117 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlPopover } 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 { createAlert } from '~/alert'; +import DisclosureHierarchy from '~/work_items/components/work_item_ancestors/disclosure_hierarchy.vue'; +import WorkItemAncestors from '~/work_items/components/work_item_ancestors/work_item_ancestors.vue'; +import workItemAncestorsQuery from '~/work_items/graphql/work_item_ancestors.query.graphql'; +import { formatAncestors } from '~/work_items/utils'; + +import { workItemTask } from '../../mock_data'; +import { + workItemAncestorsQueryResponse, + workItemEmptyAncestorsQueryResponse, + workItemThreeAncestorsQueryResponse, +} from './mock_data'; + +Vue.use(VueApollo); +jest.mock('~/alert'); + +describe('WorkItemAncestors', () => { + let wrapper; + let mockApollo; + + const workItemAncestorsQueryHandler = jest.fn().mockResolvedValue(workItemAncestorsQueryResponse); + const workItemEmptyAncestorsQueryHandler = jest + .fn() + .mockResolvedValue(workItemEmptyAncestorsQueryResponse); + const workItemThreeAncestorsQueryHandler = jest + .fn() + .mockResolvedValue(workItemThreeAncestorsQueryResponse); + const workItemAncestorsFailureHandler = jest.fn().mockRejectedValue(new Error()); + + const findDisclosureHierarchy = () => wrapper.findComponent(DisclosureHierarchy); + const findPopover = () => wrapper.findComponent(GlPopover); + + const createComponent = ({ + props = {}, + options = {}, + ancestorsQueryHandler = workItemAncestorsQueryHandler, + } = {}) => { + mockApollo = createMockApollo([[workItemAncestorsQuery, ancestorsQueryHandler]]); + return mountExtended(WorkItemAncestors, { + apolloProvider: mockApollo, + propsData: { + workItem: workItemTask, + ...props, + }, + ...options, + }); + }; + + beforeEach(async () => { + createAlert.mockClear(); + wrapper = createComponent(); + await waitForPromises(); + }); + + it('fetches work item ancestors', () => { + expect(workItemAncestorsQueryHandler).toHaveBeenCalled(); + }); + + it('displays DisclosureHierarchy component with ancestors when work item has at least one ancestor', () => { + expect(findDisclosureHierarchy().exists()).toBe(true); + expect(findDisclosureHierarchy().props('items')).toEqual( + expect.objectContaining(formatAncestors(workItemAncestorsQueryResponse.data.workItem)), + ); + }); + + it('does not display DisclosureHierarchy component when work item has no ancestor', async () => { + wrapper = createComponent({ ancestorsQueryHandler: workItemEmptyAncestorsQueryHandler }); + await waitForPromises(); + + expect(findDisclosureHierarchy().exists()).toBe(false); + }); + + it('displays work item info in popover on hover and focus', () => { + expect(findPopover().exists()).toBe(true); + expect(findPopover().props('triggers')).toBe('hover focus'); + + const ancestor = findDisclosureHierarchy().props('items')[0]; + + expect(findPopover().text()).toContain(ancestor.title); + expect(findPopover().text()).toContain(ancestor.reference); + }); + + describe('when work item has less than 3 ancestors', () => { + it('does not activate ellipsis option for DisclosureHierarchy component', () => { + expect(findDisclosureHierarchy().props('withEllipsis')).toBe(false); + }); + }); + + describe('when work item has at least 3 ancestors', () => { + beforeEach(async () => { + wrapper = createComponent({ ancestorsQueryHandler: workItemThreeAncestorsQueryHandler }); + await waitForPromises(); + }); + + it('activates ellipsis option for DisclosureHierarchy component', () => { + expect(findDisclosureHierarchy().props('withEllipsis')).toBe(true); + }); + }); + + it('creates alert when the query fails', async () => { + createComponent({ ancestorsQueryHandler: workItemAncestorsFailureHandler }); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + captureError: true, + error: expect.any(Object), + message: 'Something went wrong while fetching ancestors.', + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js b/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js index 123cf647674..48ec84ceb85 100644 --- a/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js +++ b/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js @@ -1,11 +1,20 @@ +import { nextTick } from 'vue'; import { shallowMount } from '@vue/test-utils'; import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; import WorkItemDueDate from '~/work_items/components/work_item_due_date.vue'; import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue'; - +import WorkItemParentInline from '~/work_items/components/work_item_parent_inline.vue'; +import WorkItemParent from '~/work_items/components/work_item_parent_with_edit.vue'; +import waitForPromises from 'helpers/wait_for_promises'; import WorkItemAttributesWrapper from '~/work_items/components/work_item_attributes_wrapper.vue'; -import { workItemResponseFactory } from '../mock_data'; +import { + workItemResponseFactory, + taskType, + issueType, + objectiveType, + keyResultType, +} from '../mock_data'; describe('WorkItemAttributesWrapper component', () => { let wrapper; @@ -16,8 +25,13 @@ describe('WorkItemAttributesWrapper component', () => { const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees); const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels); const findWorkItemMilestone = () => wrapper.findComponent(WorkItemMilestone); + const findWorkItemParentInline = () => wrapper.findComponent(WorkItemParentInline); + const findWorkItemParent = () => wrapper.findComponent(WorkItemParent); - const createComponent = ({ workItem = workItemQueryResponse.data.workItem } = {}) => { + const createComponent = ({ + workItem = workItemQueryResponse.data.workItem, + workItemsMvc2 = true, + } = {}) => { wrapper = shallowMount(WorkItemAttributesWrapper, { propsData: { fullPath: 'group/project', @@ -29,6 +43,9 @@ describe('WorkItemAttributesWrapper component', () => { hasOkrsFeature: true, hasIssuableHealthStatusFeature: true, projectNamespace: 'namespace', + glFeatures: { + workItemsMvc2, + }, }, stubs: { WorkItemWeight: true, @@ -94,4 +111,54 @@ describe('WorkItemAttributesWrapper component', () => { expect(findWorkItemMilestone().exists()).toBe(exists); }); }); + + describe('parent widget', () => { + describe.each` + description | workItemType | exists + ${'when work item type is task'} | ${taskType} | ${true} + ${'when work item type is objective'} | ${objectiveType} | ${true} + ${'when work item type is keyresult'} | ${keyResultType} | ${true} + ${'when work item type is issue'} | ${issueType} | ${false} + `('$description', ({ workItemType, exists }) => { + it(`${exists ? 'renders' : 'does not render'} parent component`, async () => { + const response = workItemResponseFactory({ workItemType }); + createComponent({ workItem: response.data.workItem }); + + await waitForPromises(); + + expect(findWorkItemParent().exists()).toBe(exists); + }); + }); + + it('renders WorkItemParent when workItemsMvc2 enabled', async () => { + createComponent(); + + await waitForPromises(); + + expect(findWorkItemParent().exists()).toBe(true); + expect(findWorkItemParentInline().exists()).toBe(false); + }); + + it('renders WorkItemParentInline when workItemsMvc2 disabled', async () => { + createComponent({ workItemsMvc2: false }); + + await waitForPromises(); + + expect(findWorkItemParent().exists()).toBe(false); + expect(findWorkItemParentInline().exists()).toBe(true); + }); + + it('emits an error event to the wrapper', async () => { + const response = workItemResponseFactory({ parentWidgetPresent: true }); + createComponent({ workItem: response.data.workItem }); + const updateError = 'Failed to update'; + + await waitForPromises(); + + findWorkItemParent().vm.$emit('error', updateError); + await nextTick(); + + expect(wrapper.emitted('error')).toEqual([[updateError]]); + }); + }); }); 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 6fa3a70c3eb..f77d6c89035 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 @@ -61,7 +61,6 @@ describe('WorkItemDetailModal component', () => { expect(findWorkItemDetail().props()).toEqual({ isModal: true, workItemIid: '1', - workItemParentId: null, }); }); 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 acfe4571cd2..d63bb94c3f0 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -1,10 +1,4 @@ -import { - GlAlert, - GlSkeletonLoader, - GlButton, - GlEmptyState, - GlIntersectionObserver, -} from '@gitlab/ui'; +import { GlAlert, GlSkeletonLoader, GlEmptyState } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -15,6 +9,7 @@ import setWindowLocation from 'helpers/set_window_location_helper'; import { stubComponent } from 'helpers/stub_component'; import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; import WorkItemActions from '~/work_items/components/work_item_actions.vue'; +import WorkItemAncestors from '~/work_items/components/work_item_ancestors/work_item_ancestors.vue'; import WorkItemDescription from '~/work_items/components/work_item_description.vue'; import WorkItemCreatedUpdated from '~/work_items/components/work_item_created_updated.vue'; import WorkItemAttributesWrapper from '~/work_items/components/work_item_attributes_wrapper.vue'; @@ -23,13 +18,13 @@ import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree import WorkItemRelationships from '~/work_items/components/work_item_relationships/work_item_relationships.vue'; import WorkItemNotes from '~/work_items/components/work_item_notes.vue'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; +import WorkItemStickyHeader from '~/work_items/components/work_item_sticky_header.vue'; import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; import WorkItemTodos from '~/work_items/components/work_item_todos.vue'; import { i18n } from '~/work_items/constants'; import groupWorkItemByIidQuery from '~/work_items/graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; -import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql'; import workItemUpdatedSubscription from '~/work_items/graphql/work_item_updated.subscription.graphql'; import { @@ -74,8 +69,7 @@ describe('WorkItemDetail component', () => { const findCreatedUpdated = () => wrapper.findComponent(WorkItemCreatedUpdated); const findWorkItemDescription = () => wrapper.findComponent(WorkItemDescription); const findWorkItemAttributesWrapper = () => wrapper.findComponent(WorkItemAttributesWrapper); - const findParent = () => wrapper.findByTestId('work-item-parent'); - const findParentButton = () => findParent().findComponent(GlButton); + const findAncestors = () => wrapper.findComponent(WorkItemAncestors); const findCloseButton = () => wrapper.findByTestId('work-item-close'); const findWorkItemType = () => wrapper.findByTestId('work-item-type'); const findHierarchyTree = () => wrapper.findComponent(WorkItemTree); @@ -84,11 +78,9 @@ describe('WorkItemDetail component', () => { const findModal = () => wrapper.findComponent(WorkItemDetailModal); const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector); const findWorkItemTodos = () => wrapper.findComponent(WorkItemTodos); - const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); - const findStickyHeader = () => wrapper.findByTestId('work-item-sticky-header'); + const findStickyHeader = () => wrapper.findComponent(WorkItemStickyHeader); const findWorkItemTwoColumnViewContainer = () => wrapper.findByTestId('work-item-overview'); const findRightSidebar = () => wrapper.findByTestId('work-item-overview-right-sidebar'); - const triggerPageScroll = () => findIntersectionObserver().vm.$emit('disappear'); const createComponent = ({ isGroup = false, @@ -96,7 +88,7 @@ describe('WorkItemDetail component', () => { updateInProgress = false, workItemIid = '1', handler = successHandler, - confidentialityMock = [updateWorkItemMutation, jest.fn()], + mutationHandler, error = undefined, workItemsMvc2Enabled = false, linkedWorkItemsEnabled = false, @@ -105,8 +97,8 @@ describe('WorkItemDetail component', () => { apolloProvider: createMockApollo([ [workItemByIidQuery, handler], [groupWorkItemByIidQuery, groupSuccessHandler], + [updateWorkItemMutation, mutationHandler], [workItemUpdatedSubscription, workItemUpdatedSubscriptionHandler], - confidentialityMock, ]), isLoggedIn: isLoggedIn(), propsData: { @@ -134,6 +126,7 @@ describe('WorkItemDetail component', () => { reportAbusePath: '/report/abuse/path', }, stubs: { + WorkItemAncestors: true, WorkItemWeight: true, WorkItemIteration: true, WorkItemHealthStatus: true, @@ -236,119 +229,52 @@ describe('WorkItemDetail component', () => { describe('confidentiality', () => { const errorMessage = 'Mutation failed'; - const confidentialWorkItem = workItemByIidResponseFactory({ - confidential: true, - }); - const workItem = confidentialWorkItem.data.workspace.workItems.nodes[0]; - - // Mocks for work item without parent - const withoutParentExpectedInputVars = { id, confidential: true }; - const toggleConfidentialityWithoutParentHandler = jest.fn().mockResolvedValue({ - data: { - workItemUpdate: { - workItem, - errors: [], - }, - }, - }); - const withoutParentHandlerMock = jest - .fn() - .mockResolvedValue(workItemQueryResponseWithoutParent); - const confidentialityWithoutParentMock = [ - updateWorkItemMutation, - toggleConfidentialityWithoutParentHandler, - ]; - const confidentialityWithoutParentFailureMock = [ - updateWorkItemMutation, - jest.fn().mockRejectedValue(new Error(errorMessage)), - ]; - - // Mocks for work item with parent - const withParentExpectedInputVars = { - id: mockParent.parent.id, - taskData: { id, confidential: true }, - }; - const toggleConfidentialityWithParentHandler = jest.fn().mockResolvedValue({ + const confidentialWorkItem = workItemByIidResponseFactory({ confidential: true }); + const mutationHandler = jest.fn().mockResolvedValue({ data: { workItemUpdate: { - workItem: { - id: workItem.id, - descriptionHtml: workItem.description, - }, - task: { - workItem, - confidential: true, - }, + workItem: confidentialWorkItem.data.workspace.workItems.nodes[0], errors: [], }, }, }); - const confidentialityWithParentMock = [ - updateWorkItemTaskMutation, - toggleConfidentialityWithParentHandler, - ]; - const confidentialityWithParentFailureMock = [ - updateWorkItemTaskMutation, - jest.fn().mockRejectedValue(new Error(errorMessage)), - ]; - - describe.each` - context | handlerMock | confidentialityMock | confidentialityFailureMock | inputVariables - ${'no parent'} | ${withoutParentHandlerMock} | ${confidentialityWithoutParentMock} | ${confidentialityWithoutParentFailureMock} | ${withoutParentExpectedInputVars} - ${'parent'} | ${successHandler} | ${confidentialityWithParentMock} | ${confidentialityWithParentFailureMock} | ${withParentExpectedInputVars} - `( - 'when work item has $context', - ({ handlerMock, confidentialityMock, confidentialityFailureMock, inputVariables }) => { - it('sends updateInProgress props to child component', async () => { - createComponent({ - handler: handlerMock, - confidentialityMock, - }); - - await waitForPromises(); - - findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true); - await nextTick(); - - expect(findCreatedUpdated().props('updateInProgress')).toBe(true); - }); + it('sends updateInProgress props to child component', async () => { + createComponent({ mutationHandler }); + await waitForPromises(); - it('emits workItemUpdated when mutation is successful', async () => { - createComponent({ - handler: handlerMock, - confidentialityMock, - }); + findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true); + await nextTick(); - await waitForPromises(); + expect(findCreatedUpdated().props('updateInProgress')).toBe(true); + }); - findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true); - await waitForPromises(); + it('emits workItemUpdated when mutation is successful', async () => { + createComponent({ mutationHandler }); + await waitForPromises(); - expect(wrapper.emitted('workItemUpdated')).toEqual([[{ confidential: true }]]); - expect(confidentialityMock[1]).toHaveBeenCalledWith({ - input: inputVariables, - }); - }); + findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true); + await waitForPromises(); - it('shows an alert when mutation fails', async () => { - createComponent({ - handler: handlerMock, - confidentialityMock: confidentialityFailureMock, - }); + expect(wrapper.emitted('workItemUpdated')).toEqual([[{ confidential: true }]]); + expect(mutationHandler).toHaveBeenCalledWith({ + input: { + id: 'gid://gitlab/WorkItem/1', + confidential: true, + }, + }); + }); - await waitForPromises(); - findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true); - await waitForPromises(); - expect(wrapper.emitted('workItemUpdated')).toBeUndefined(); + it('shows an alert when mutation fails', async () => { + createComponent({ mutationHandler: jest.fn().mockRejectedValue(new Error(errorMessage)) }); + await waitForPromises(); - await nextTick(); + findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true); + await waitForPromises(); - expect(findAlert().exists()).toBe(true); - expect(findAlert().text()).toBe(errorMessage); - }); - }, - ); + expect(wrapper.emitted('workItemUpdated')).toBeUndefined(); + expect(findAlert().text()).toBe(errorMessage); + }); }); describe('description', () => { @@ -366,19 +292,19 @@ describe('WorkItemDetail component', () => { }); }); - describe('secondary breadcrumbs', () => { - it('does not show secondary breadcrumbs by default', () => { + describe('ancestors widget', () => { + it('does not show ancestors widget by default', () => { createComponent(); - expect(findParent().exists()).toBe(false); + expect(findAncestors().exists()).toBe(false); }); - it('does not show secondary breadcrumbs if there is not a parent', async () => { + it('does not show ancestors widget if there is not a parent', async () => { createComponent({ handler: jest.fn().mockResolvedValue(workItemQueryResponseWithoutParent) }); await waitForPromises(); - expect(findParent().exists()).toBe(false); + expect(findAncestors().exists()).toBe(false); }); it('shows title in the header when there is no parent', async () => { @@ -396,45 +322,8 @@ describe('WorkItemDetail component', () => { return waitForPromises(); }); - it('shows secondary breadcrumbs if there is a parent', () => { - expect(findParent().exists()).toBe(true); - }); - - it('shows parent breadcrumb icon', () => { - expect(findParentButton().props('icon')).toBe(mockParent.parent.workItemType.iconName); - }); - - it('shows parent title and iid', () => { - expect(findParentButton().text()).toBe( - `${mockParent.parent.title} #${mockParent.parent.iid}`, - ); - }); - - it('sets the parent breadcrumb URL pointing to issue page when parent type is `Issue`', () => { - expect(findParentButton().attributes().href).toBe('../../-/issues/5'); - }); - - it('sets the parent breadcrumb URL based on parent webUrl when parent type is not `Issue`', async () => { - const mockParentObjective = { - parent: { - ...mockParent.parent, - workItemType: { - id: mockParent.parent.workItemType.id, - name: 'Objective', - iconName: 'issue-type-objective', - }, - }, - }; - const parentResponse = workItemByIidResponseFactory(mockParentObjective); - createComponent({ handler: jest.fn().mockResolvedValue(parentResponse) }); - await waitForPromises(); - - expect(findParentButton().attributes().href).toBe(mockParentObjective.parent.webUrl); - }); - - it('shows work item type and iid', () => { - const { iid } = workItemQueryResponse.data.workspace.workItems.nodes[0]; - expect(findParent().text()).toContain(`#${iid}`); + it('shows ancestors widget if there is a parent', () => { + expect(findAncestors().exists()).toBe(true); }); it('does not show title in the header when parent exists', () => { @@ -769,8 +658,7 @@ describe('WorkItemDetail component', () => { expect(findWorkItemTwoColumnViewContainer().classes()).not.toContain('work-item-overview'); }); - it('does not have sticky header', () => { - expect(findIntersectionObserver().exists()).toBe(false); + it('does not have sticky header component', () => { expect(findStickyHeader().exists()).toBe(false); }); @@ -789,18 +677,7 @@ describe('WorkItemDetail component', () => { expect(findWorkItemTwoColumnViewContainer().classes()).toContain('work-item-overview'); }); - it('does not show sticky header by default', () => { - expect(findStickyHeader().exists()).toBe(false); - }); - - it('has the sticky header when the page is scrolled', async () => { - expect(findIntersectionObserver().exists()).toBe(true); - - global.pageYOffset = 100; - triggerPageScroll(); - - await nextTick(); - + it('renders the work item sticky header component', () => { expect(findStickyHeader().exists()).toBe(true); }); diff --git a/spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_actions_split_button_spec.js index 55d5b34ae70..630ffa1a699 100644 --- a/spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_actions_split_button_spec.js @@ -1,12 +1,40 @@ import { GlDisclosureDropdown, GlDisclosureDropdownGroup } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import OkrActionsSplitButton from '~/work_items/components/work_item_links/okr_actions_split_button.vue'; +import WorkItemActionsSplitButton from '~/work_items/components/work_item_links/work_item_actions_split_button.vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +const okrActions = [ + { + name: 'Objective', + items: [ + { + text: 'New objective', + }, + { + text: 'Existing objective', + }, + ], + }, + { + name: 'Key result', + items: [ + { + text: 'New key result', + }, + { + text: 'Existing key result', + }, + ], + }, +]; + const createComponent = () => { return extendedWrapper( - shallowMount(OkrActionsSplitButton, { + shallowMount(WorkItemActionsSplitButton, { + propsData: { + actions: okrActions, + }, stubs: { GlDisclosureDropdown, }, @@ -21,7 +49,7 @@ describe('RelatedItemsTree', () => { wrapper = createComponent(); }); - describe('OkrActionsSplitButton', () => { + describe('WorkItemActionsSplitButton', () => { describe('template', () => { it('renders objective and key results sections', () => { expect(wrapper.findAllComponents(GlDisclosureDropdownGroup).at(0).props('group').name).toBe( 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 6c1d1035c3d..49a674e73c8 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,28 +1,36 @@ -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import { GlToggle } 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 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'; -import OkrActionsSplitButton from '~/work_items/components/work_item_links/okr_actions_split_button.vue'; +import WorkItemActionsSplitButton from '~/work_items/components/work_item_links/work_item_actions_split_button.vue'; +import getAllowedWorkItemChildTypes from '~/work_items//graphql/work_item_allowed_children.query.graphql'; import { FORM_TYPES, WORK_ITEM_TYPE_ENUM_OBJECTIVE, WORK_ITEM_TYPE_ENUM_KEY_RESULT, } from '~/work_items/constants'; -import { childrenWorkItems } from '../../mock_data'; +import { childrenWorkItems, allowedChildrenTypesResponse } from '../../mock_data'; + +Vue.use(VueApollo); describe('WorkItemTree', () => { let wrapper; const findEmptyState = () => wrapper.findByTestId('tree-empty'); - const findToggleFormSplitButton = () => wrapper.findComponent(OkrActionsSplitButton); + const findToggleFormSplitButton = () => wrapper.findComponent(WorkItemActionsSplitButton); const findForm = () => wrapper.findComponent(WorkItemLinksForm); const findWidgetWrapper = () => wrapper.findComponent(WidgetWrapper); const findWorkItemLinkChildrenWrapper = () => wrapper.findComponent(WorkItemChildrenWrapper); const findShowLabelsToggle = () => wrapper.findComponent(GlToggle); + const allowedChildrenTypesHandler = jest.fn().mockResolvedValue(allowedChildrenTypesResponse); + const createComponent = ({ workItemType = 'Objective', parentWorkItemType = 'Objective', @@ -31,6 +39,9 @@ describe('WorkItemTree', () => { canUpdate = true, } = {}) => { wrapper = shallowMountExtended(WorkItemTree, { + apolloProvider: createMockApollo([ + [getAllowedWorkItemChildTypes, allowedChildrenTypesHandler], + ]), propsData: { fullPath: 'test/project', workItemType, @@ -79,18 +90,25 @@ describe('WorkItemTree', () => { expect(findWidgetWrapper().props('error')).toBe(errorMessage); }); + it('fetches allowed children types for current work item', async () => { + createComponent(); + await waitForPromises(); + + expect(allowedChildrenTypesHandler).toHaveBeenCalled(); + }); + it.each` - option | event | formType | childType - ${'New objective'} | ${'showCreateObjectiveForm'} | ${FORM_TYPES.create} | ${WORK_ITEM_TYPE_ENUM_OBJECTIVE} - ${'Existing objective'} | ${'showAddObjectiveForm'} | ${FORM_TYPES.add} | ${WORK_ITEM_TYPE_ENUM_OBJECTIVE} - ${'New key result'} | ${'showCreateKeyResultForm'} | ${FORM_TYPES.create} | ${WORK_ITEM_TYPE_ENUM_KEY_RESULT} - ${'Existing key result'} | ${'showAddKeyResultForm'} | ${FORM_TYPES.add} | ${WORK_ITEM_TYPE_ENUM_KEY_RESULT} + option | formType | childType + ${'New objective'} | ${FORM_TYPES.create} | ${WORK_ITEM_TYPE_ENUM_OBJECTIVE} + ${'Existing objective'} | ${FORM_TYPES.add} | ${WORK_ITEM_TYPE_ENUM_OBJECTIVE} + ${'New key result'} | ${FORM_TYPES.create} | ${WORK_ITEM_TYPE_ENUM_KEY_RESULT} + ${'Existing key result'} | ${FORM_TYPES.add} | ${WORK_ITEM_TYPE_ENUM_KEY_RESULT} `( - 'when selecting $option from split button, renders the form passing $formType and $childType', - async ({ event, formType, childType }) => { + 'when triggering action $option, renders the form passing $formType and $childType', + async ({ formType, childType }) => { createComponent(); - findToggleFormSplitButton().vm.$emit(event); + wrapper.vm.showAddForm(formType, childType); await nextTick(); expect(findForm().exists()).toBe(true); @@ -122,7 +140,7 @@ describe('WorkItemTree', () => { it('emits `addChild` event when form emits `addChild` event', async () => { createComponent(); - findToggleFormSplitButton().vm.$emit('showCreateObjectiveForm'); + wrapper.vm.showAddForm(FORM_TYPES.create, WORK_ITEM_TYPE_ENUM_OBJECTIVE); await nextTick(); findForm().vm.$emit('addChild'); diff --git a/spec/frontend/work_items/components/work_item_notes_spec.js b/spec/frontend/work_items/components/work_item_notes_spec.js index 9e02e0708d4..2620242000e 100644 --- a/spec/frontend/work_items/components/work_item_notes_spec.js +++ b/spec/frontend/work_items/components/work_item_notes_spec.js @@ -10,6 +10,7 @@ import WorkItemNotes from '~/work_items/components/work_item_notes.vue'; import WorkItemDiscussion from '~/work_items/components/notes/work_item_discussion.vue'; import WorkItemAddNote from '~/work_items/components/notes/work_item_add_note.vue'; import WorkItemNotesActivityHeader from '~/work_items/components/notes/work_item_notes_activity_header.vue'; +import groupWorkItemNotesByIidQuery from '~/work_items/graphql/notes/group_work_item_notes_by_iid.query.graphql'; import workItemNotesByIidQuery from '~/work_items/graphql/notes/work_item_notes_by_iid.query.graphql'; import deleteWorkItemNoteMutation from '~/work_items/graphql/notes/delete_work_item_notes.mutation.graphql'; import workItemNoteCreatedSubscription from '~/work_items/graphql/notes/work_item_note_created.subscription.graphql'; @@ -63,6 +64,9 @@ describe('WorkItemNotes component', () => { const findWorkItemCommentNoteAtIndex = (index) => findAllWorkItemCommentNotes().at(index); const findDeleteNoteModal = () => wrapper.findComponent(GlModal); + const groupWorkItemNotesQueryHandler = jest + .fn() + .mockResolvedValue(mockWorkItemNotesByIidResponse); const workItemNotesQueryHandler = jest.fn().mockResolvedValue(mockWorkItemNotesByIidResponse); const workItemMoreNotesQueryHandler = jest.fn().mockResolvedValue(mockMoreWorkItemNotesResponse); const workItemNotesWithCommentsQueryHandler = jest @@ -87,17 +91,22 @@ describe('WorkItemNotes component', () => { workItemIid = mockWorkItemIid, defaultWorkItemNotesQueryHandler = workItemNotesQueryHandler, deleteWINoteMutationHandler = deleteWorkItemNoteMutationSuccessHandler, + isGroup = false, isModal = false, isWorkItemConfidential = false, } = {}) => { wrapper = shallowMount(WorkItemNotes, { apolloProvider: createMockApollo([ [workItemNotesByIidQuery, defaultWorkItemNotesQueryHandler], + [groupWorkItemNotesByIidQuery, groupWorkItemNotesQueryHandler], [deleteWorkItemNoteMutation, deleteWINoteMutationHandler], [workItemNoteCreatedSubscription, notesCreateSubscriptionHandler], [workItemNoteUpdatedSubscription, notesUpdateSubscriptionHandler], [workItemNoteDeletedSubscription, notesDeleteSubscriptionHandler], ]), + provide: { + isGroup, + }, propsData: { fullPath: 'test-path', workItemId, @@ -354,4 +363,22 @@ describe('WorkItemNotes component', () => { expect(findWorkItemCommentNoteAtIndex(0).props('isWorkItemConfidential')).toBe(true); }); + + describe('when project context', () => { + it('calls the project work item query', async () => { + createComponent(); + await waitForPromises(); + + expect(workItemNotesQueryHandler).toHaveBeenCalled(); + }); + }); + + describe('when group context', () => { + it('calls the group work item query', async () => { + createComponent({ isGroup: true }); + await waitForPromises(); + + expect(groupWorkItemNotesQueryHandler).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/work_items/components/work_item_parent_spec.js b/spec/frontend/work_items/components/work_item_parent_inline_spec.js index 11fe6dffbfa..3e4f99d5935 100644 --- a/spec/frontend/work_items/components/work_item_parent_spec.js +++ b/spec/frontend/work_items/components/work_item_parent_inline_spec.js @@ -6,7 +6,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; -import WorkItemParent from '~/work_items/components/work_item_parent.vue'; +import WorkItemParentInline from '~/work_items/components/work_item_parent_inline.vue'; import { removeHierarchyChild } from '~/work_items/graphql/cache_utils'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import groupWorkItemsQuery from '~/work_items/graphql/group_work_items.query.graphql'; @@ -26,7 +26,7 @@ jest.mock('~/work_items/graphql/cache_utils', () => ({ removeHierarchyChild: jest.fn(), })); -describe('WorkItemParent component', () => { +describe('WorkItemParentInline component', () => { Vue.use(VueApollo); let wrapper; @@ -50,7 +50,7 @@ describe('WorkItemParent component', () => { mutationHandler = successUpdateWorkItemMutationHandler, isGroup = false, } = {}) => { - wrapper = shallowMountExtended(WorkItemParent, { + wrapper = shallowMountExtended(WorkItemParentInline, { apolloProvider: createMockApollo([ [projectWorkItemsQuery, searchQueryHandler], [groupWorkItemsQuery, groupWorkItemsSuccessHandler], diff --git a/spec/frontend/work_items/components/work_item_parent_with_edit_spec.js b/spec/frontend/work_items/components/work_item_parent_with_edit_spec.js new file mode 100644 index 00000000000..61e43456479 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_parent_with_edit_spec.js @@ -0,0 +1,409 @@ +import { GlForm, GlCollapsibleListbox } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { __ } from '~/locale'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import WorkItemParent from '~/work_items/components/work_item_parent_with_edit.vue'; +import { removeHierarchyChild } from '~/work_items/graphql/cache_utils'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import groupWorkItemsQuery from '~/work_items/graphql/group_work_items.query.graphql'; +import projectWorkItemsQuery from '~/work_items/graphql/project_work_items.query.graphql'; +import { WORK_ITEM_TYPE_ENUM_OBJECTIVE } from '~/work_items/constants'; + +import { + availableObjectivesResponse, + mockParentWidgetResponse, + updateWorkItemMutationResponseFactory, + searchedObjectiveResponse, + updateWorkItemMutationErrorResponse, +} from '../mock_data'; + +jest.mock('~/sentry/sentry_browser_wrapper'); +jest.mock('~/work_items/graphql/cache_utils', () => ({ + removeHierarchyChild: jest.fn(), +})); + +describe('WorkItemParent component', () => { + Vue.use(VueApollo); + + let wrapper; + + const workItemId = 'gid://gitlab/WorkItem/1'; + const workItemType = 'Objective'; + const mockFullPath = 'full-path'; + + const groupWorkItemsSuccessHandler = jest.fn().mockResolvedValue(availableObjectivesResponse); + const availableWorkItemsSuccessHandler = jest.fn().mockResolvedValue(availableObjectivesResponse); + const availableWorkItemsFailureHandler = jest.fn().mockRejectedValue(new Error()); + + const findHeader = () => wrapper.find('h3'); + const findEditButton = () => wrapper.find('[data-testid="edit-parent"]'); + const findApplyButton = () => wrapper.find('[data-testid="apply-parent"]'); + + const findLoadingIcon = () => wrapper.find('[data-testid="loading-icon-parent"]'); + const findLabel = () => wrapper.find('label'); + const findForm = () => wrapper.findComponent(GlForm); + const findCollapsibleListbox = () => wrapper.findComponent(GlCollapsibleListbox); + + const successUpdateWorkItemMutationHandler = jest + .fn() + .mockResolvedValue(updateWorkItemMutationResponseFactory({ parent: mockParentWidgetResponse })); + + const createComponent = ({ + canUpdate = true, + parent = null, + searchQueryHandler = availableWorkItemsSuccessHandler, + mutationHandler = successUpdateWorkItemMutationHandler, + isEditing = false, + isGroup = false, + } = {}) => { + wrapper = mountExtended(WorkItemParent, { + apolloProvider: createMockApollo([ + [projectWorkItemsQuery, searchQueryHandler], + [groupWorkItemsQuery, groupWorkItemsSuccessHandler], + [updateWorkItemMutation, mutationHandler], + ]), + provide: { + fullPath: mockFullPath, + isGroup, + }, + propsData: { + canUpdate, + parent, + workItemId, + workItemType, + }, + }); + + if (isEditing) { + findEditButton().trigger('click'); + } + }; + + beforeEach(() => { + createComponent(); + }); + + describe('label', () => { + it('shows header when not editing', () => { + createComponent(); + + expect(findHeader().exists()).toBe(true); + expect(findHeader().classes('gl-sr-only')).toBe(false); + expect(findLabel().exists()).toBe(false); + }); + + it('shows label and hides header while editing', async () => { + createComponent({ isEditing: true }); + + await nextTick(); + + expect(findLabel().exists()).toBe(true); + expect(findHeader().classes('gl-sr-only')).toBe(true); + }); + }); + + describe('edit button', () => { + it('is not shown if user cannot edit', () => { + createComponent({ canUpdate: false }); + + expect(findEditButton().exists()).toBe(false); + }); + + it('is shown if user can edit', () => { + createComponent({ canUpdate: true }); + + expect(findEditButton().exists()).toBe(true); + }); + + it('triggers edit mode on click', async () => { + createComponent(); + + findEditButton().trigger('click'); + + await nextTick(); + + expect(findLabel().exists()).toBe(true); + expect(findForm().exists()).toBe(true); + }); + + it('is replaced by Apply button while editing', async () => { + createComponent(); + + findEditButton().trigger('click'); + + await nextTick(); + + expect(findEditButton().exists()).toBe(false); + expect(findApplyButton().exists()).toBe(true); + }); + }); + + describe('loading icon', () => { + const selectWorkItem = async (workItem) => { + await findCollapsibleListbox().vm.$emit('select', workItem); + }; + + it('shows loading icon while update is in progress', async () => { + createComponent(); + findEditButton().trigger('click'); + + await nextTick(); + + selectWorkItem('gid://gitlab/WorkItem/716'); + + await nextTick(); + expect(findLoadingIcon().exists()).toBe(true); + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('shows loading icon when unassign is clicked', async () => { + createComponent({ parent: mockParentWidgetResponse }); + findEditButton().trigger('click'); + + await nextTick(); + + findCollapsibleListbox().vm.$emit('reset'); + + await nextTick(); + expect(findLoadingIcon().exists()).toBe(true); + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('value', () => { + it('shows None when no parent is set', () => { + createComponent(); + + expect(wrapper.text()).toContain(__('None')); + }); + + it('shows parent when parent is set', () => { + createComponent({ parent: mockParentWidgetResponse }); + + expect(wrapper.text()).not.toContain(__('None')); + expect(wrapper.text()).toContain(mockParentWidgetResponse.title); + }); + }); + + describe('form', () => { + it('is not shown while not editing', async () => { + await createComponent(); + + expect(findForm().exists()).toBe(false); + }); + + it('is shown while editing', async () => { + await createComponent({ isEditing: true }); + + expect(findForm().exists()).toBe(true); + }); + }); + + describe('Parent Input', () => { + it('is not shown while not editing', async () => { + await createComponent(); + + expect(findCollapsibleListbox().exists()).toBe(false); + }); + + it('renders the collapsible listbox with required props', async () => { + await createComponent({ isEditing: true }); + + expect(findCollapsibleListbox().exists()).toBe(true); + expect(findCollapsibleListbox().props()).toMatchObject({ + items: [], + headerText: 'Assign parent', + category: 'primary', + loading: false, + isCheckCentered: true, + searchable: true, + searching: false, + infiniteScroll: false, + noResultsText: 'No matching results', + toggleText: 'None', + searchPlaceholder: 'Search', + resetButtonLabel: 'Unassign', + }); + }); + it('shows loading while searching', async () => { + await createComponent({ isEditing: true }); + + await findCollapsibleListbox().vm.$emit('shown'); + expect(findCollapsibleListbox().props('searching')).toBe(true); + }); + }); + + describe('work items query', () => { + it('loads work items in the listbox', async () => { + await createComponent({ isEditing: true }); + await findCollapsibleListbox().vm.$emit('shown'); + + await waitForPromises(); + + expect(findCollapsibleListbox().props('searching')).toBe(false); + expect(findCollapsibleListbox().props('items')).toStrictEqual([ + { text: 'Objective 101', value: 'gid://gitlab/WorkItem/716' }, + { text: 'Objective 103', value: 'gid://gitlab/WorkItem/712' }, + { text: 'Objective 102', value: 'gid://gitlab/WorkItem/711' }, + ]); + expect(availableWorkItemsSuccessHandler).toHaveBeenCalled(); + }); + + it('emits error when the query fails', async () => { + await createComponent({ + searchQueryHandler: availableWorkItemsFailureHandler, + isEditing: true, + }); + + await findCollapsibleListbox().vm.$emit('shown'); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([ + ['Something went wrong while fetching items. Please try again.'], + ]); + }); + + it('searches item when input data is entered', async () => { + const searchedItemQueryHandler = jest.fn().mockResolvedValue(searchedObjectiveResponse); + await createComponent({ + searchQueryHandler: searchedItemQueryHandler, + isEditing: true, + }); + + await findCollapsibleListbox().vm.$emit('shown'); + + await waitForPromises(); + + expect(searchedItemQueryHandler).toHaveBeenCalledWith({ + fullPath: 'full-path', + searchTerm: '', + types: [WORK_ITEM_TYPE_ENUM_OBJECTIVE], + in: undefined, + iid: null, + isNumber: false, + }); + + await findCollapsibleListbox().vm.$emit('search', 'Objective 101'); + + expect(searchedItemQueryHandler).toHaveBeenCalledWith({ + fullPath: 'full-path', + searchTerm: 'Objective 101', + types: [WORK_ITEM_TYPE_ENUM_OBJECTIVE], + in: 'TITLE', + iid: null, + isNumber: false, + }); + + await nextTick(); + + expect(findCollapsibleListbox().props('items')).toStrictEqual([ + { text: 'Objective 101', value: 'gid://gitlab/WorkItem/716' }, + ]); + }); + }); + + describe('listbox', () => { + const selectWorkItem = async (workItem) => { + await findCollapsibleListbox().vm.$emit('select', workItem); + }; + + it('calls mutation when item is selected', async () => { + await createComponent({ isEditing: true }); + selectWorkItem('gid://gitlab/WorkItem/716'); + + await waitForPromises(); + + expect(successUpdateWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { + id: 'gid://gitlab/WorkItem/1', + hierarchyWidget: { + parentId: 'gid://gitlab/WorkItem/716', + }, + }, + }); + + expect(removeHierarchyChild).toHaveBeenCalledWith({ + cache: expect.anything(Object), + fullPath: mockFullPath, + iid: undefined, + isGroup: false, + workItem: { id: 'gid://gitlab/WorkItem/1' }, + }); + }); + + it('calls mutation when item is unassigned', async () => { + const unAssignParentWorkItemMutationHandler = jest + .fn() + .mockResolvedValue(updateWorkItemMutationResponseFactory({ parent: null })); + await createComponent({ + parent: { + iid: '1', + }, + mutationHandler: unAssignParentWorkItemMutationHandler, + }); + + findEditButton().trigger('click'); + + await nextTick(); + + findCollapsibleListbox().vm.$emit('reset'); + + await waitForPromises(); + + expect(unAssignParentWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { + id: 'gid://gitlab/WorkItem/1', + hierarchyWidget: { + parentId: null, + }, + }, + }); + expect(removeHierarchyChild).toHaveBeenCalledWith({ + cache: expect.anything(Object), + fullPath: mockFullPath, + iid: '1', + isGroup: false, + workItem: { id: 'gid://gitlab/WorkItem/1' }, + }); + }); + + it('emits error when mutation fails', async () => { + await createComponent({ + mutationHandler: jest.fn().mockResolvedValue(updateWorkItemMutationErrorResponse), + isEditing: true, + }); + + selectWorkItem('gid://gitlab/WorkItem/716'); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([['Error!']]); + }); + + it('emits error and captures exception in sentry when network request fails', async () => { + const error = new Error('error'); + await createComponent({ + mutationHandler: jest.fn().mockRejectedValue(error), + isEditing: true, + }); + + selectWorkItem('gid://gitlab/WorkItem/716'); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([ + ['Something went wrong while updating the objective. Please try again.'], + ]); + expect(Sentry.captureException).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_state_toggle_button_spec.js b/spec/frontend/work_items/components/work_item_state_toggle_spec.js index a210bd50422..a210bd50422 100644 --- a/spec/frontend/work_items/components/work_item_state_toggle_button_spec.js +++ b/spec/frontend/work_items/components/work_item_state_toggle_spec.js diff --git a/spec/frontend/work_items/components/work_item_sticky_header_spec.js b/spec/frontend/work_items/components/work_item_sticky_header_spec.js new file mode 100644 index 00000000000..4b7818044b1 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_sticky_header_spec.js @@ -0,0 +1,59 @@ +import { GlIntersectionObserver } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { STATE_OPEN } from '~/work_items/constants'; +import { workItemResponseFactory } from 'jest/work_items/mock_data'; +import WorkItemStickyHeader from '~/work_items/components/work_item_sticky_header.vue'; +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; +import WorkItemActions from '~/work_items/components/work_item_actions.vue'; +import WorkItemTodos from '~/work_items/components/work_item_todos.vue'; + +describe('WorkItemStickyHeader', () => { + let wrapper; + + const workItemResponse = workItemResponseFactory({ canUpdate: true, confidential: true }).data + .workItem; + + const createComponent = () => { + wrapper = shallowMountExtended(WorkItemStickyHeader, { + propsData: { + workItem: workItemResponse, + fullPath: '/test', + isStickyHeaderShowing: true, + workItemNotificationsSubscribed: true, + updateInProgress: false, + parentWorkItemConfidentiality: false, + showWorkItemCurrentUserTodos: true, + isModal: false, + currentUserTodos: [], + workItemState: STATE_OPEN, + }, + }); + }; + const findStickyHeader = () => wrapper.findByTestId('work-item-sticky-header'); + const findConfidentialityBadge = () => wrapper.findComponent(ConfidentialityBadge); + const findWorkItemActions = () => wrapper.findComponent(WorkItemActions); + const findWorkItemTodos = () => wrapper.findComponent(WorkItemTodos); + const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); + const triggerPageScroll = () => findIntersectionObserver().vm.$emit('disappear'); + + beforeEach(() => { + createComponent(); + }); + + it('has the sticky header when the page is scrolled', async () => { + global.pageYOffset = 100; + triggerPageScroll(); + + await nextTick(); + + expect(findStickyHeader().exists()).toBe(true); + }); + + it('has the components of confidentiality, actions, todos and title', () => { + expect(findConfidentialityBadge().exists()).toBe(true); + expect(findWorkItemActions().exists()).toBe(true); + expect(findWorkItemTodos().exists()).toBe(true); + expect(wrapper.findByText(workItemResponse.title).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_title_spec.js b/spec/frontend/work_items/components/work_item_title_spec.js index 0f466bcf691..de740e5fbc5 100644 --- a/spec/frontend/work_items/components/work_item_title_spec.js +++ b/spec/frontend/work_items/components/work_item_title_spec.js @@ -8,7 +8,6 @@ import ItemTitle from '~/work_items/components/item_title.vue'; import WorkItemTitle from '~/work_items/components/work_item_title.vue'; import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; -import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql'; import { updateWorkItemMutationResponse, workItemQueryResponse } from '../mock_data'; describe('WorkItemTitle component', () => { @@ -20,22 +19,14 @@ describe('WorkItemTitle component', () => { const findItemTitle = () => wrapper.findComponent(ItemTitle); - const createComponent = ({ - workItemParentId, - mutationHandler = mutationSuccessHandler, - canUpdate = true, - } = {}) => { + const createComponent = ({ mutationHandler = mutationSuccessHandler, canUpdate = true } = {}) => { const { id, title, workItemType } = workItemQueryResponse.data.workItem; wrapper = shallowMount(WorkItemTitle, { - apolloProvider: createMockApollo([ - [updateWorkItemMutation, mutationHandler], - [updateWorkItemTaskMutation, mutationHandler], - ]), + apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), propsData: { workItemId: id, workItemTitle: title, workItemType: workItemType.name, - workItemParentId, canUpdate, }, }); @@ -77,27 +68,6 @@ describe('WorkItemTitle component', () => { }); }); - it('calls WorkItemTaskUpdate if passed workItemParentId prop', () => { - const title = 'new title!'; - const workItemParentId = '1234'; - - createComponent({ - workItemParentId, - }); - - findItemTitle().vm.$emit('title-changed', title); - - expect(mutationSuccessHandler).toHaveBeenCalledWith({ - input: { - id: workItemParentId, - taskData: { - id: workItemQueryResponse.data.workItem.id, - title, - }, - }, - }); - }); - it('does not call a mutation when the title has not changed', () => { createComponent(); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 8df46403b90..9d4606eb95a 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -445,7 +445,7 @@ export const descriptionHtmlWithCheckboxes = ` </ul> `; -const taskType = { +export const taskType = { __typename: 'WorkItemType', id: 'gid://gitlab/WorkItems::Type/5', name: 'Task', @@ -459,6 +459,20 @@ export const objectiveType = { iconName: 'issue-type-objective', }; +export const keyResultType = { + __typename: 'WorkItemType', + id: 'gid://gitlab/WorkItems::Type/2411', + name: 'Key Result', + iconName: 'issue-type-keyresult', +}; + +export const issueType = { + __typename: 'WorkItemType', + id: 'gid://gitlab/WorkItems::Type/2411', + name: 'Issue', + iconName: 'issue-type-issue', +}; + export const mockEmptyLinkedItems = { type: WIDGET_TYPE_LINKED_ITEMS, blocked: false, @@ -3703,5 +3717,40 @@ export const updateWorkItemNotificationsMutationResponse = (subscribed) => ({ }, }); +export const allowedChildrenTypesResponse = { + data: { + workItem: { + id: 'gid://gitlab/WorkItem/634', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/6', + name: 'Objective', + widgetDefinitions: [ + { + type: 'HIERARCHY', + allowedChildTypes: { + nodes: [ + { + id: 'gid://gitlab/WorkItems::Type/7', + name: 'Key Result', + __typename: 'WorkItemType', + }, + { + id: 'gid://gitlab/WorkItems::Type/6', + name: 'Objective', + __typename: 'WorkItemType', + }, + ], + __typename: 'WorkItemTypeConnection', + }, + __typename: 'WorkItemWidgetDefinitionHierarchy', + }, + ], + __typename: 'WorkItemType', + }, + __typename: 'WorkItem', + }, + }, +}; + export const generateWorkItemsListWithId = (count) => Array.from({ length: count }, (_, i) => ({ id: `gid://gitlab/WorkItem/${i + 1}` })); diff --git a/spec/frontend/work_items/notes/award_utils_spec.js b/spec/frontend/work_items/notes/award_utils_spec.js index 8ae32ce5f40..43eceb13b67 100644 --- a/spec/frontend/work_items/notes/award_utils_spec.js +++ b/spec/frontend/work_items/notes/award_utils_spec.js @@ -2,6 +2,7 @@ import { getMutation, optimisticAwardUpdate } from '~/work_items/notes/award_uti import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import mockApollo from 'helpers/mock_apollo_helper'; import { __ } from '~/locale'; +import groupWorkItemNotesByIidQuery from '~/work_items/graphql/notes/group_work_item_notes_by_iid.query.graphql'; import workItemNotesByIidQuery from '~/work_items/graphql/notes/work_item_notes_by_iid.query.graphql'; import addAwardEmojiMutation from '~/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql'; import removeAwardEmojiMutation from '~/work_items/graphql/notes/work_item_note_remove_award_emoji.mutation.graphql'; @@ -105,5 +106,22 @@ describe('Work item note award utils', () => { expect(updatedNote.awardEmoji.nodes).toEqual([]); }); + + it.each` + description | isGroup | query + ${'calls project query when in project context'} | ${false} | ${workItemNotesByIidQuery} + ${'calls group query when in group context'} | ${true} | ${groupWorkItemNotesByIidQuery} + `('$description', ({ isGroup, query }) => { + const note = firstNote; + const { name } = mockAwardEmojiThumbsUp; + const cacheSpy = { updateQuery: jest.fn() }; + + optimisticAwardUpdate({ note, name, fullPath, isGroup, workItemIid })(cacheSpy); + + expect(cacheSpy.updateQuery).toHaveBeenCalledWith( + { query, variables: { fullPath, iid: workItemIid } }, + expect.any(Function), + ); + }); }); }); diff --git a/spec/frontend/work_items/pages/create_work_item_spec.js b/spec/frontend/work_items/pages/create_work_item_spec.js index 527f5890338..2c898f97ee9 100644 --- a/spec/frontend/work_items/pages/create_work_item_spec.js +++ b/spec/frontend/work_items/pages/create_work_item_spec.js @@ -8,7 +8,6 @@ import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; import ItemTitle from '~/work_items/components/item_title.vue'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql'; -import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql'; import { projectWorkItemTypesQueryResponse, createWorkItemMutationResponse } from '../mock_data'; jest.mock('~/lib/utils/uuids', () => ({ uuids: () => ['testuuid'] })); @@ -42,7 +41,6 @@ describe('Create work item component', () => { [ [projectWorkItemTypesQuery, queryHandler], [createWorkItemMutation, mutationHandler], - [createWorkItemFromTaskMutation, mutationHandler], ], {}, { typePolicies: { Project: { merge: true } } }, 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 84b10f30418..4854b5bfb77 100644 --- a/spec/frontend/work_items/pages/work_item_root_spec.js +++ b/spec/frontend/work_items/pages/work_item_root_spec.js @@ -49,7 +49,6 @@ describe('Work items root component', () => { expect(findWorkItemDetail().props()).toEqual({ isModal: false, - workItemParentId: null, workItemIid: '1', }); }); |