diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-05-23 03:07:55 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-05-23 03:07:55 +0300 |
commit | 82f96a9ae2529898de0e91ccfad1d6457f3c1975 (patch) | |
tree | 7e189a0d2cef7df67aaa4f709e3f5bc312878cbd /spec/frontend | |
parent | c50e042a392687730db9b8c2607883485b258ae4 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec/frontend')
17 files changed, 764 insertions, 37 deletions
diff --git a/spec/frontend/__helpers__/mock_window_location_helper.js b/spec/frontend/__helpers__/mock_window_location_helper.js index de1e8c99b54..48bc788afaa 100644 --- a/spec/frontend/__helpers__/mock_window_location_helper.js +++ b/spec/frontend/__helpers__/mock_window_location_helper.js @@ -12,6 +12,7 @@ const useMockLocation = (fn) => { Object.defineProperty(window, 'location', { get: () => currentWindowLocation, + assign: jest.fn(), }); beforeEach(() => { diff --git a/spec/frontend/api/user_api_spec.js b/spec/frontend/api/user_api_spec.js index a879c229581..b2ecfeb8394 100644 --- a/spec/frontend/api/user_api_spec.js +++ b/spec/frontend/api/user_api_spec.js @@ -1,12 +1,14 @@ import MockAdapter from 'axios-mock-adapter'; import projects from 'test_fixtures/api/users/projects/get.json'; +import followers from 'test_fixtures/api/users/followers/get.json'; import { followUser, unfollowUser, associationsCount, updateUserStatus, getUserProjects, + getUserFollowers, } from '~/api/user_api'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; @@ -16,6 +18,7 @@ import { } from 'jest/admin/users/mock_data'; import { AVAILABILITY_STATUS } from '~/set_status_modal/constants'; import { timeRanges } from '~/vue_shared/constants'; +import { DEFAULT_PER_PAGE } from '~/api'; describe('~/api/user_api', () => { let axiosMock; @@ -112,4 +115,20 @@ describe('~/api/user_api', () => { expect(axiosMock.history.get[0].url).toBe(expectedUrl); }); }); + + describe('getUserFollowers', () => { + it('calls correct URL and returns expected response', async () => { + const expectedUrl = '/api/v4/users/1/followers'; + const expectedResponse = { data: followers }; + const params = { page: 2 }; + + axiosMock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, expectedResponse); + + await expect(getUserFollowers(1, params)).resolves.toEqual( + expect.objectContaining({ data: expectedResponse }), + ); + expect(axiosMock.history.get[0].url).toBe(expectedUrl); + expect(axiosMock.history.get[0].params).toEqual({ ...params, per_page: DEFAULT_PER_PAGE }); + }); + }); }); diff --git a/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js index 97716ce848c..309e5f76b9c 100644 --- a/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js @@ -65,6 +65,7 @@ describe('content_editor/components/bubble_menus/bubble_menu', () => { onHidden: expect.any(Function), onShow: expect.any(Function), appendTo: expect.any(Function), + maxWidth: 'auto', ...tippyOptions, }), }); diff --git a/spec/frontend/content_editor/components/bubble_menus/reference_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/reference_bubble_menu_spec.js new file mode 100644 index 00000000000..169f77dc054 --- /dev/null +++ b/spec/frontend/content_editor/components/bubble_menus/reference_bubble_menu_spec.js @@ -0,0 +1,247 @@ +import { GlLoadingIcon, GlListboxItem, GlCollapsibleListbox } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import eventHubFactory from '~/helpers/event_hub_factory'; +import ReferenceBubbleMenu from '~/content_editor/components/bubble_menus/reference_bubble_menu.vue'; +import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue'; +import { stubComponent } from 'helpers/stub_component'; +import Reference from '~/content_editor/extensions/reference'; +import { createTestEditor, emitEditorEvent, createDocBuilder } from '../../test_utils'; + +const mockIssue = { + href: 'https://gitlab.com/gitlab-org/gitlab-test/-/issues/24', + text: '#24', + expandedText: 'Et fuga quos omnis enim dolores amet impedit. (#24)', + fullyExpandedText: + 'Et fuga quos omnis enim dolores amet impedit. (#24) • Fernanda Adams • Sprint - Eligendi quas non inventore eum quaerat sit.', +}; +const mockMergeRequest = { + href: 'https://gitlab.com/gitlab-org/gitlab-test/-/merge_requests/2', + text: '!2', + expandedText: 'Qui possimus sit harum ut ipsam autem. (!2)', + fullyExpandedText: 'Qui possimus sit harum ut ipsam autem. (!2) • Margrett Wunsch • v0.0', +}; +const mockEpic = { + href: 'https://gitlab.com/groups/gitlab-org/-/epics/5', + text: '&5', + expandedText: 'Temporibus delectus distinctio quas sed non per... (&5)', +}; + +const supportedIssueDisplayFormats = ['Issue ID', 'Issue title', 'Issue summary']; + +const supportedMergeRequestDisplayFormats = [ + 'Merge request ID', + 'Merge request title', + 'Merge request summary', +]; + +const supportedEpicDisplayFormats = ['Epic ID', 'Epic title']; + +describe('content_editor/components/bubble_menus/reference_bubble_menu', () => { + let wrapper; + let tiptapEditor; + let contentEditor; + let eventHub; + let doc; + let p; + let reference; + + const buildExpectedDoc = (href, originalText, referenceType, text) => + doc(p(reference({ className: 'gfm', href, originalText, referenceType, text }))); + + const buildEditor = () => { + tiptapEditor = createTestEditor({ extensions: [Reference] }); + contentEditor = { resolveReference: jest.fn().mockImplementation(() => new Promise(() => {})) }; + eventHub = eventHubFactory(); + + ({ + builders: { doc, p, reference }, + } = createDocBuilder({ + tiptapEditor, + names: { + reference: { nodeType: Reference.name }, + }, + })); + }; + + const expectedDocs = { + issue: [ + () => + buildExpectedDoc( + 'https://gitlab.com/gitlab-org/gitlab-test/-/issues/24', + '#24', + 'issue', + '#24', + ), + () => + buildExpectedDoc( + 'https://gitlab.com/gitlab-org/gitlab-test/-/issues/24', + '#24+', + 'issue', + 'Et fuga quos omnis enim dolores amet impedit. (#24)', + ), + () => + buildExpectedDoc( + 'https://gitlab.com/gitlab-org/gitlab-test/-/issues/24', + '#24+s', + 'issue', + 'Et fuga quos omnis enim dolores amet impedit. (#24) • Fernanda Adams • Sprint - Eligendi quas non inventore eum quaerat sit.', + ), + ], + merge_request: [ + () => + buildExpectedDoc( + 'https://gitlab.com/gitlab-org/gitlab-test/-/merge_requests/2', + '!2', + 'merge_request', + '!2', + ), + () => + buildExpectedDoc( + 'https://gitlab.com/gitlab-org/gitlab-test/-/merge_requests/2', + '!2+', + 'merge_request', + 'Qui possimus sit harum ut ipsam autem. (!2)', + ), + () => + buildExpectedDoc( + 'https://gitlab.com/gitlab-org/gitlab-test/-/merge_requests/2', + '!2+s', + 'merge_request', + 'Qui possimus sit harum ut ipsam autem. (!2) • Margrett Wunsch • v0.0', + ), + ], + epic: [ + () => buildExpectedDoc('https://gitlab.com/groups/gitlab-org/-/epics/5', '&5', 'epic', '&5'), + () => + buildExpectedDoc( + 'https://gitlab.com/groups/gitlab-org/-/epics/5', + '&5+', + 'epic', + 'Temporibus delectus distinctio quas sed non per... (&5)', + ), + ], + }; + + const buildWrapper = () => { + wrapper = mountExtended(ReferenceBubbleMenu, { + provide: { + tiptapEditor, + contentEditor, + eventHub, + }, + stubs: { + BubbleMenu: stubComponent(BubbleMenu), + }, + }); + }; + + const showMenu = () => { + wrapper.findComponent(BubbleMenu).vm.$emit('show'); + return nextTick(); + }; + + const buildWrapperAndDisplayMenu = async () => { + buildWrapper(); + + await showMenu(); + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + }; + + beforeEach(() => { + buildEditor(); + + tiptapEditor + .chain() + .setContent( + '<a href="https://gitlab.com/gitlab-org/gitlab/issues/1" class="gfm" data-reference-type="issue" data-original="#1">#1</a>', + ) + .setNodeSelection(1) + .run(); + }); + + it('shows a loading indicator while the reference is being resolved', async () => { + await buildWrapperAndDisplayMenu(); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + + describe.each` + referenceType | mockReference | supportedDisplayFormats + ${'issue'} | ${mockIssue} | ${supportedIssueDisplayFormats} + ${'merge_request'} | ${mockMergeRequest} | ${supportedMergeRequestDisplayFormats} + ${'epic'} | ${mockEpic} | ${supportedEpicDisplayFormats} + `( + 'for reference type $referenceType', + ({ referenceType, mockReference, supportedDisplayFormats }) => { + beforeEach(async () => { + tiptapEditor + .chain() + .setContent( + `<a href="${mockReference.href}" class="gfm" data-reference-type="${referenceType}" data-original="${mockReference.text}">${mockReference.text}</a>`, + ) + .setNodeSelection(1) + .run(); + + contentEditor.resolveReference.mockImplementation(() => Promise.resolve(mockReference)); + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + }); + + it('shows a dropdown with supported display formats', async () => { + await buildWrapperAndDisplayMenu(); + + supportedDisplayFormats.forEach((format) => expect(wrapper.text()).toContain(format)); + }); + + describe.each` + option | displayFormat | selectedValue + ${0} | ${supportedDisplayFormats[0]} | ${''} + ${1} | ${supportedDisplayFormats[1]} | ${'+'} + ${2} | ${supportedDisplayFormats[2]} | ${'+s'} + `('on selecting option $option', ({ option, displayFormat, selectedValue }) => { + if (!displayFormat) return; + + const findDropdownItem = () => wrapper.findAllComponents(GlListboxItem).at(option); + + beforeEach(async () => { + await buildWrapperAndDisplayMenu(); + + findDropdownItem().trigger('click'); + }); + + it('selects the option', () => { + expect(wrapper.findComponent(GlCollapsibleListbox).props()).toMatchObject({ + selected: selectedValue, + toggleText: displayFormat, + }); + }); + + it('updates the reference in content editor', () => { + expect(tiptapEditor.getJSON()).toEqual(expectedDocs[referenceType][option]().toJSON()); + }); + }); + }, + ); + + describe('copy URL button', () => { + it('copies the reference link to clipboard', async () => { + jest.spyOn(navigator.clipboard, 'writeText'); + + await buildWrapperAndDisplayMenu(); + await wrapper.findByTestId('copy-reference-url').trigger('click'); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + 'https://gitlab.com/gitlab-org/gitlab/issues/1', + ); + }); + }); + + describe('remove reference button', () => { + it('removes the reference', async () => { + await buildWrapperAndDisplayMenu(); + await wrapper.findByTestId('remove-reference').trigger('click'); + + expect(tiptapEditor.getHTML()).toBe('<p></p>'); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index 852c8a9591a..44dd328025a 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -9,6 +9,7 @@ import EditorStateObserver from '~/content_editor/components/editor_state_observ import CodeBlockBubbleMenu from '~/content_editor/components/bubble_menus/code_block_bubble_menu.vue'; import LinkBubbleMenu from '~/content_editor/components/bubble_menus/link_bubble_menu.vue'; import MediaBubbleMenu from '~/content_editor/components/bubble_menus/media_bubble_menu.vue'; +import ReferenceBubbleMenu from '~/content_editor/components/bubble_menus/reference_bubble_menu.vue'; import FormattingToolbar from '~/content_editor/components/formatting_toolbar.vue'; import LoadingIndicator from '~/content_editor/components/loading_indicator.vue'; import waitForPromises from 'helpers/wait_for_promises'; @@ -267,7 +268,8 @@ describe('ContentEditor', () => { ${'link'} | ${LinkBubbleMenu} ${'media'} | ${MediaBubbleMenu} ${'codeBlock'} | ${CodeBlockBubbleMenu} - `('renders formatting bubble menu', ({ component }) => { + ${'reference'} | ${ReferenceBubbleMenu} + `('renders $name bubble menu', ({ component }) => { createWrapper(); expect(wrapper.findComponent(component).exists()).toBe(true); diff --git a/spec/frontend/content_editor/extensions/drawio_diagram_spec.js b/spec/frontend/content_editor/extensions/drawio_diagram_spec.js index 61dc164c99a..63ed08096b2 100644 --- a/spec/frontend/content_editor/extensions/drawio_diagram_spec.js +++ b/spec/frontend/content_editor/extensions/drawio_diagram_spec.js @@ -1,6 +1,5 @@ import DrawioDiagram from '~/content_editor/extensions/drawio_diagram'; import Image from '~/content_editor/extensions/image'; -import createAssetResolver from '~/content_editor/services/asset_resolver'; import { create } from '~/drawio/content_editor_facade'; import { launchDrawioEditor } from '~/drawio/drawio_editor'; import { createTestEditor, createDocBuilder } from '../test_utils'; @@ -19,12 +18,15 @@ describe('content_editor/extensions/drawio_diagram', () => { let paragraph; let image; let drawioDiagram; + let assetResolver; + const uploadsPath = '/uploads'; - const renderMarkdown = () => {}; beforeEach(() => { + assetResolver = new (class {})(); + tiptapEditor = createTestEditor({ - extensions: [Image, DrawioDiagram.configure({ uploadsPath, renderMarkdown })], + extensions: [Image, DrawioDiagram.configure({ uploadsPath, assetResolver })], }); const { builders } = createDocBuilder({ tiptapEditor, @@ -72,19 +74,12 @@ describe('content_editor/extensions/drawio_diagram', () => { describe('createOrEditDiagram command', () => { let editorFacade; - let assetResolver; beforeEach(() => { editorFacade = {}; - assetResolver = {}; tiptapEditor.commands.createOrEditDiagram(); create.mockReturnValueOnce(editorFacade); - createAssetResolver.mockReturnValueOnce(assetResolver); - }); - - it('creates a new instance of asset resolver', () => { - expect(createAssetResolver).toHaveBeenCalledWith({ renderMarkdown }); }); it('creates a new instance of the content_editor_facade', () => { diff --git a/spec/frontend/content_editor/extensions/reference_spec.js b/spec/frontend/content_editor/extensions/reference_spec.js new file mode 100644 index 00000000000..c25c7c41d75 --- /dev/null +++ b/spec/frontend/content_editor/extensions/reference_spec.js @@ -0,0 +1,162 @@ +import Reference from '~/content_editor/extensions/reference'; +import AssetResolver from '~/content_editor/services/asset_resolver'; +import { + RESOLVED_ISSUE_HTML, + RESOLVED_MERGE_REQUEST_HTML, + RESOLVED_EPIC_HTML, +} from '../test_constants'; +import { + createTestEditor, + createDocBuilder, + triggerNodeInputRule, + waitUntilTransaction, +} from '../test_utils'; + +describe('content_editor/extensions/reference', () => { + let tiptapEditor; + let doc; + let p; + let reference; + let renderMarkdown; + let assetResolver; + + beforeEach(() => { + renderMarkdown = jest.fn().mockImplementation(() => new Promise(() => {})); + assetResolver = new AssetResolver({ renderMarkdown }); + + tiptapEditor = createTestEditor({ + extensions: [Reference.configure({ assetResolver })], + }); + + ({ + builders: { doc, p, reference }, + } = createDocBuilder({ + tiptapEditor, + names: { + reference: { nodeType: Reference.name }, + }, + })); + }); + + describe('when typing a valid reference input rule', () => { + const buildExpectedDoc = (href, originalText, referenceType, text) => + doc(p(reference({ className: null, href, originalText, referenceType, text }), ' ')); + + it.each` + inputRuleText | mockReferenceHtml | expectedDoc + ${'#1 '} | ${RESOLVED_ISSUE_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/issues/1', '#1', 'issue', '#1 (closed)')} + ${'#1+ '} | ${RESOLVED_ISSUE_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/issues/1', '#1+', 'issue', '500 error on MR approvers edit page (#1 - closed)')} + ${'#1+s '} | ${RESOLVED_ISSUE_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/issues/1', '#1+s', 'issue', '500 error on MR approvers edit page (#1 - closed) • Unassigned')} + ${'!1 '} | ${RESOLVED_MERGE_REQUEST_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/merge_requests/1', '!1', 'merge_request', '!1 (merged)')} + ${'!1+ '} | ${RESOLVED_MERGE_REQUEST_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/merge_requests/1', '!1+', 'merge_request', 'Enhance the LDAP group synchronization (!1 - merged)')} + ${'!1+s '} | ${RESOLVED_MERGE_REQUEST_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/merge_requests/1', '!1+s', 'merge_request', 'Enhance the LDAP group synchronization (!1 - merged) • John Doe')} + ${'&1 '} | ${RESOLVED_EPIC_HTML} | ${() => buildExpectedDoc('/groups/gitlab-org/-/epics/1', '&1', 'epic', '&1')} + ${'&1+ '} | ${RESOLVED_EPIC_HTML} | ${() => buildExpectedDoc('/groups/gitlab-org/-/epics/1', '&1+', 'epic', 'Approvals in merge request list (&1)')} + `( + 'replaces the input rule ($inputRuleText) with a reference node', + async ({ inputRuleText, mockReferenceHtml, expectedDoc }) => { + await waitUntilTransaction({ + number: 2, + tiptapEditor, + action() { + renderMarkdown.mockResolvedValueOnce(mockReferenceHtml); + + tiptapEditor.commands.insertContent({ type: 'text', text: inputRuleText }); + triggerNodeInputRule({ tiptapEditor, inputRuleText }); + }, + }); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc().toJSON()); + }, + ); + + it('resolves multiple references in the same paragraph correctly', async () => { + await waitUntilTransaction({ + number: 2, + tiptapEditor, + action() { + renderMarkdown.mockResolvedValueOnce(RESOLVED_ISSUE_HTML); + + tiptapEditor.commands.insertContent({ type: 'text', text: '#1+ ' }); + triggerNodeInputRule({ tiptapEditor, inputRuleText: '#1+ ' }); + }, + }); + + await waitUntilTransaction({ + number: 2, + tiptapEditor, + action() { + renderMarkdown.mockResolvedValueOnce(RESOLVED_MERGE_REQUEST_HTML); + + tiptapEditor.commands.insertContent({ type: 'text', text: 'was resolved with !1+ ' }); + triggerNodeInputRule({ tiptapEditor, inputRuleText: 'was resolved with !1+ ' }); + }, + }); + + expect(tiptapEditor.getJSON()).toEqual( + doc( + p( + reference({ + referenceType: 'issue', + originalText: '#1+', + text: '500 error on MR approvers edit page (#1 - closed)', + href: '/gitlab-org/gitlab/-/issues/1', + }), + ' was resolved with ', + reference({ + referenceType: 'merge_request', + originalText: '!1+', + text: 'Enhance the LDAP group synchronization (!1 - merged)', + href: '/gitlab-org/gitlab/-/merge_requests/1', + }), + ' ', + ), + ).toJSON(), + ); + }); + + it('resolves the input rule lazily in the correct position if the user makes a change before the request resolves', async () => { + let resolvePromise; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + renderMarkdown.mockImplementation(() => promise); + + tiptapEditor.commands.insertContent({ type: 'text', text: '#1+ ' }); + triggerNodeInputRule({ tiptapEditor, inputRuleText: '#1+ ' }); + + // insert a new paragraph at a random location + tiptapEditor.commands.insertContentAt(0, { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello' }], + }); + + // update selection + tiptapEditor.commands.selectAll(); + + await waitUntilTransaction({ + number: 1, + tiptapEditor, + action() { + resolvePromise(RESOLVED_ISSUE_HTML); + }, + }); + + expect(tiptapEditor.state.doc).toEqual( + doc( + p('Hello'), + p( + reference({ + referenceType: 'issue', + originalText: '#1+', + text: '500 error on MR approvers edit page (#1 - closed)', + href: '/gitlab-org/gitlab/-/issues/1', + }), + ' ', + ), + ), + ); + }); + }); +}); diff --git a/spec/frontend/content_editor/services/asset_resolver_spec.js b/spec/frontend/content_editor/services/asset_resolver_spec.js index 0a99f823be3..292eec6db77 100644 --- a/spec/frontend/content_editor/services/asset_resolver_spec.js +++ b/spec/frontend/content_editor/services/asset_resolver_spec.js @@ -1,4 +1,9 @@ -import createAssetResolver from '~/content_editor/services/asset_resolver'; +import AssetResolver from '~/content_editor/services/asset_resolver'; +import { + RESOLVED_ISSUE_HTML, + RESOLVED_MERGE_REQUEST_HTML, + RESOLVED_EPIC_HTML, +} from '../test_constants'; describe('content_editor/services/asset_resolver', () => { let renderMarkdown; @@ -6,7 +11,7 @@ describe('content_editor/services/asset_resolver', () => { beforeEach(() => { renderMarkdown = jest.fn(); - assetResolver = createAssetResolver({ renderMarkdown }); + assetResolver = new AssetResolver({ renderMarkdown }); }); describe('resolveUrl', () => { @@ -21,6 +26,65 @@ describe('content_editor/services/asset_resolver', () => { }); }); + describe('resolveReference', () => { + const resolvedEpic = { + expandedText: 'Approvals in merge request list (&1)', + fullyExpandedText: 'Approvals in merge request list (&1)', + href: '/groups/gitlab-org/-/epics/1', + text: '&1', + }; + + const resolvedIssue = { + expandedText: '500 error on MR approvers edit page (#1 - closed)', + fullyExpandedText: '500 error on MR approvers edit page (#1 - closed) • Unassigned', + href: '/gitlab-org/gitlab/-/issues/1', + text: '#1 (closed)', + }; + + const resolvedMergeRequest = { + expandedText: 'Enhance the LDAP group synchronization (!1 - merged)', + fullyExpandedText: 'Enhance the LDAP group synchronization (!1 - merged) • John Doe', + href: '/gitlab-org/gitlab/-/merge_requests/1', + text: '!1 (merged)', + }; + + describe.each` + referenceType | referenceId | sentMarkdown | returnedHtml | resolvedReference + ${'issue'} | ${'#1'} | ${'#1 #1+ #1+s'} | ${RESOLVED_ISSUE_HTML} | ${resolvedIssue} + ${'merge_request'} | ${'!1'} | ${'!1 !1+ !1+s'} | ${RESOLVED_MERGE_REQUEST_HTML} | ${resolvedMergeRequest} + ${'epic'} | ${'&1'} | ${'&1 &1+ &1+s'} | ${RESOLVED_EPIC_HTML} | ${resolvedEpic} + `( + 'for reference type $referenceType', + ({ referenceType, referenceId, sentMarkdown, returnedHtml, resolvedReference }) => { + it(`resolves ${referenceType} reference to href, text, title and summary`, async () => { + renderMarkdown.mockResolvedValue(returnedHtml); + + expect(await assetResolver.resolveReference(referenceId)).toEqual(resolvedReference); + }); + + it.each` + suffix + ${''} + ${'+'} + ${'+s'} + `('strips suffix ("$suffix") before resolving', ({ suffix }) => { + assetResolver.resolveReference(referenceId + suffix); + expect(renderMarkdown).toHaveBeenCalledWith(sentMarkdown); + }); + }, + ); + + it.each` + case | sentMarkdown | returnedHtml + ${'no html is returned'} | ${''} | ${''} + ${'html contains no anchor tags'} | ${'no anchor tags'} | ${'<p>no anchor tags</p>'} + `('returns an empty object if $case', async ({ sentMarkdown, returnedHtml }) => { + renderMarkdown.mockResolvedValue(returnedHtml); + + expect(await assetResolver.resolveReference(sentMarkdown)).toEqual({}); + }); + }); + describe('renderDiagram', () => { it('resolves a diagram code to a url containing the diagram image', async () => { renderMarkdown.mockResolvedValue( diff --git a/spec/frontend/content_editor/services/create_content_editor_spec.js b/spec/frontend/content_editor/services/create_content_editor_spec.js index 53cd51b8c5f..b9a9c3ccd17 100644 --- a/spec/frontend/content_editor/services/create_content_editor_spec.js +++ b/spec/frontend/content_editor/services/create_content_editor_spec.js @@ -2,6 +2,7 @@ import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '~/content_editor/constants import { createContentEditor } from '~/content_editor/services/create_content_editor'; import createGlApiMarkdownDeserializer from '~/content_editor/services/gl_api_markdown_deserializer'; import createRemarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; +import AssetResolver from '~/content_editor/services/asset_resolver'; import { createTestContentEditorExtension } from '../test_utils'; jest.mock('~/emoji'); @@ -89,7 +90,7 @@ describe('content_editor/services/create_content_editor', () => { .options, ).toMatchObject({ uploadsPath, - renderMarkdown, + assetResolver: expect.any(AssetResolver), }); }); }); diff --git a/spec/frontend/content_editor/test_constants.js b/spec/frontend/content_editor/test_constants.js index 749f1234de0..cbd4f555e97 100644 --- a/spec/frontend/content_editor/test_constants.js +++ b/spec/frontend/content_editor/test_constants.js @@ -35,3 +35,12 @@ export const PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML = `<p data-sourcepos="1 export const PROJECT_WIKI_ATTACHMENT_LINK_HTML = `<p data-sourcepos="1:1-1:26" dir="auto"> <a href="/group1/project1/-/wikis/test-file.zip" data-canonical-src="test-file.zip">test-file</a> </p>`; + +export const RESOLVED_ISSUE_HTML = + '<p data-sourcepos="1:1-1:11" dir="auto"><a href="/gitlab-org/gitlab/-/issues/1" data-reference-type="issue" data-original="#1" data-link="false" data-link-reference="false" data-project="278964" data-issue="382515" data-project-path="gitlab-org/gitlab" data-iid="1" data-issue-type="issue" data-container="body" data-placement="top" title="500 error on MR approvers edit page" class="gfm gfm-issue">#1 (closed)</a> <a href="/gitlab-org/gitlab/-/issues/1" data-reference-type="issue" data-original="#1+" data-link="false" data-link-reference="false" data-project="278964" data-issue="382515" data-project-path="gitlab-org/gitlab" data-iid="1" data-reference-format="+" data-issue-type="issue" data-container="body" data-placement="top" title="500 error on MR approvers edit page" class="gfm gfm-issue">500 error on MR approvers edit page (#1 - closed)</a> <a href="/gitlab-org/gitlab/-/issues/1" data-reference-type="issue" data-original="#1+s" data-link="false" data-link-reference="false" data-project="278964" data-issue="382515" data-project-path="gitlab-org/gitlab" data-iid="1" data-reference-format="+s" data-issue-type="issue" data-container="body" data-placement="top" title="500 error on MR approvers edit page" class="gfm gfm-issue">500 error on MR approvers edit page (#1 - closed) • Unassigned</a></p>'; + +export const RESOLVED_MERGE_REQUEST_HTML = + '<p data-sourcepos="1:1-1:11" dir="auto"><a href="/gitlab-org/gitlab/-/merge_requests/1" data-reference-type="merge_request" data-original="!1" data-link="false" data-link-reference="false" data-project="278964" data-merge-request="83382" data-project-path="gitlab-org/gitlab" data-iid="1" data-container="body" data-placement="top" title="Enhance the LDAP group synchronization" class="gfm gfm-merge_request">!1 (merged)</a> <a href="/gitlab-org/gitlab/-/merge_requests/1" data-reference-type="merge_request" data-original="!1+" data-link="false" data-link-reference="false" data-project="278964" data-merge-request="83382" data-project-path="gitlab-org/gitlab" data-iid="1" data-reference-format="+" data-container="body" data-placement="top" title="Enhance the LDAP group synchronization" class="gfm gfm-merge_request">Enhance the LDAP group synchronization (!1 - merged)</a> <a href="/gitlab-org/gitlab/-/merge_requests/1" data-reference-type="merge_request" data-original="!1+s" data-link="false" data-link-reference="false" data-project="278964" data-merge-request="83382" data-project-path="gitlab-org/gitlab" data-iid="1" data-reference-format="+s" data-container="body" data-placement="top" title="Enhance the LDAP group synchronization" class="gfm gfm-merge_request">Enhance the LDAP group synchronization (!1 - merged) • John Doe</a></p>'; + +export const RESOLVED_EPIC_HTML = + '<p data-sourcepos="1:1-1:11" dir="auto"><a href="/groups/gitlab-org/-/epics/1" data-reference-type="epic" data-original="&amp;1" data-link="false" data-link-reference="false" data-group="9970" data-epic="1" data-container="body" data-placement="top" title="Approvals in merge request list" class="gfm gfm-epic has-tooltip">&1</a> <a href="/groups/gitlab-org/-/epics/1" data-reference-type="epic" data-original="&amp;1+" data-link="false" data-link-reference="false" data-group="9970" data-epic="1" data-reference-format="+" data-container="body" data-placement="top" title="Approvals in merge request list" class="gfm gfm-epic has-tooltip">Approvals in merge request list (&1)</a> <a href="/groups/gitlab-org/-/epics/1" data-reference-type="epic" data-original="&amp;1+s" data-link="false" data-link-reference="false" data-group="9970" data-epic="1" data-reference-format="+s" data-container="body" data-placement="top" title="Approvals in merge request list" class="gfm gfm-epic has-tooltip">Approvals in merge request list (&1)</a></p>'; diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js index 1f4a367e46c..802ea49631f 100644 --- a/spec/frontend/content_editor/test_utils.js +++ b/spec/frontend/content_editor/test_utils.js @@ -212,6 +212,22 @@ export const waitUntilNextDocTransaction = ({ tiptapEditor, action = () => {} }) }); }; +export const waitUntilTransaction = ({ tiptapEditor, number, action }) => { + return new Promise((resolve) => { + let counter = 0; + const handleTransaction = () => { + counter += 1; + if (counter === number) { + tiptapEditor.off('update', handleTransaction); + resolve(); + } + }; + + tiptapEditor.on('update', handleTransaction); + action(); + }); +}; + export const expectDocumentAfterTransaction = ({ tiptapEditor, number, expectedDoc, action }) => { return new Promise((resolve) => { let counter = 0; diff --git a/spec/frontend/fixtures/users.rb b/spec/frontend/fixtures/users.rb index 0e9d7475bf9..fb028a2e055 100644 --- a/spec/frontend/fixtures/users.rb +++ b/spec/frontend/fixtures/users.rb @@ -3,12 +3,22 @@ require 'spec_helper' RSpec.describe 'Users (GraphQL fixtures)', feature_category: :user_profile do + include JavaScriptFixturesHelpers + include ApiHelpers + + let_it_be(:followers) { create_list(:user, 5) } + let_it_be(:user) { create(:user, followers: followers) } + + describe API::Users, '(JavaScript fixtures)', type: :request do + it 'api/users/followers/get.json' do + get api("/users/#{user.id}/followers", user) + + expect(response).to be_successful + end + end + describe GraphQL::Query, type: :request do - include ApiHelpers include GraphqlHelpers - include JavaScriptFixturesHelpers - - let_it_be(:user) { create(:user) } context 'for user achievements' do let_it_be(:group) { create(:group, :public) } diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index 4bf3a779f00..f41fe140ba1 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -406,7 +406,9 @@ describe('URL utility', () => { Object.defineProperty(window, 'location', { writable: true, - value: new URL(TEST_HOST), + value: { + assign: jest.fn(), + }, }); }); @@ -417,11 +419,15 @@ describe('URL utility', () => { it('navigates to a page', () => { urlUtils.visitUrl(mockUrl); - expect(window.location.href).toBe(mockUrl); + expect(window.location.assign).toHaveBeenCalledWith(mockUrl); }); it('navigates to a new page', () => { - const otherWindow = {}; + const otherWindow = { + location: { + assign: jest.fn(), + }, + }; Object.defineProperty(window, 'open', { writable: true, @@ -431,7 +437,7 @@ describe('URL utility', () => { urlUtils.visitUrl(mockUrl, true); expect(otherWindow.opener).toBe(null); - expect(otherWindow.location).toBe(mockUrl); + expect(otherWindow.location.assign).toHaveBeenCalledWith(mockUrl); }); }); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js index a68087f7f57..e3feb99a9b5 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js @@ -286,8 +286,8 @@ describe('Container Expiration Policy Settings Form', () => { await submitForm(); - expect(window.location.href.endsWith('settings-path?showSetupSuccessAlert=true')).toBe( - true, + expect(window.location.assign).toHaveBeenCalledWith( + 'settings-path?showSetupSuccessAlert=true', ); }); diff --git a/spec/frontend/profile/components/follow_spec.js b/spec/frontend/profile/components/follow_spec.js new file mode 100644 index 00000000000..2555e41257f --- /dev/null +++ b/spec/frontend/profile/components/follow_spec.js @@ -0,0 +1,99 @@ +import { GlAvatarLabeled, GlAvatarLink, GlLoadingIcon, GlPagination } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; + +import users from 'test_fixtures/api/users/followers/get.json'; +import Follow from '~/profile/components/follow.vue'; +import { DEFAULT_PER_PAGE } from '~/api'; + +jest.mock('~/rest_api'); + +describe('FollowersTab', () => { + let wrapper; + + const defaultPropsData = { + users, + loading: false, + page: 1, + totalItems: 50, + }; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = shallowMount(Follow, { + propsData: { + ...defaultPropsData, + ...propsData, + }, + }); + }; + + const findPagination = () => wrapper.findComponent(GlPagination); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + describe('when `loading` prop is `true`', () => { + it('renders loading icon', () => { + createComponent({ propsData: { loading: true } }); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + }); + + describe('when `loading` prop is `false`', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not render loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders users', () => { + const avatarLinksHref = wrapper + .findAllComponents(GlAvatarLink) + .wrappers.map((avatarLinkWrapper) => avatarLinkWrapper.attributes('href')); + const expectedAvatarLinksHref = users.map((user) => user.web_url); + + const avatarLabeledProps = wrapper + .findAllComponents(GlAvatarLabeled) + .wrappers.map((avatarLabeledWrapper) => ({ + label: avatarLabeledWrapper.props('label'), + subLabel: avatarLabeledWrapper.props('subLabel'), + size: avatarLabeledWrapper.attributes('size'), + entityName: avatarLabeledWrapper.attributes('entity-name'), + entityId: avatarLabeledWrapper.attributes('entity-id'), + src: avatarLabeledWrapper.attributes('src'), + })); + const expectedAvatarLabeledProps = users.map((user) => ({ + src: user.avatar_url, + size: '48', + entityId: user.id.toString(), + entityName: user.name, + label: user.name, + subLabel: user.username, + })); + + expect(avatarLinksHref).toEqual(expectedAvatarLinksHref); + expect(avatarLabeledProps).toEqual(expectedAvatarLabeledProps); + }); + + it('renders `GlPagination` and passes correct props', () => { + expect(wrapper.findComponent(GlPagination).props()).toMatchObject({ + align: 'center', + value: defaultPropsData.page, + totalItems: defaultPropsData.totalItems, + perPage: DEFAULT_PER_PAGE, + prevText: Follow.i18n.prev, + nextText: Follow.i18n.next, + }); + }); + + describe('when `GlPagination` emits `input` event', () => { + it('emits `pagination-input` event', () => { + const nextPage = defaultPropsData.page + 1; + + findPagination().vm.$emit('input', nextPage); + + expect(wrapper.emitted('pagination-input')).toEqual([[nextPage]]); + }); + }); + }); +}); diff --git a/spec/frontend/profile/components/followers_tab_spec.js b/spec/frontend/profile/components/followers_tab_spec.js index 9cc5bdea9be..0370005d0a4 100644 --- a/spec/frontend/profile/components/followers_tab_spec.js +++ b/spec/frontend/profile/components/followers_tab_spec.js @@ -1,32 +1,127 @@ import { GlBadge, GlTab } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import followers from 'test_fixtures/api/users/followers/get.json'; import { s__ } from '~/locale'; import FollowersTab from '~/profile/components/followers_tab.vue'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Follow from '~/profile/components/follow.vue'; +import { getUserFollowers } from '~/rest_api'; +import { createAlert } from '~/alert'; +import waitForPromises from 'helpers/wait_for_promises'; +import { stubComponent } from 'helpers/stub_component'; + +jest.mock('~/rest_api'); +jest.mock('~/alert'); describe('FollowersTab', () => { let wrapper; const createComponent = () => { - wrapper = shallowMountExtended(FollowersTab, { + wrapper = shallowMount(FollowersTab, { provide: { - followers: 2, + followersCount: 2, + userId: 1, + }, + stubs: { + GlTab: stubComponent(GlTab, { + template: ` + <li> + <slot name="title"></slot> + <slot></slot> + </li> + `, + }), }, }); }; - it('renders `GlTab` and sets title', () => { - createComponent(); + const findGlBadge = () => wrapper.findComponent(GlBadge); + const findFollow = () => wrapper.findComponent(Follow); + + describe('when API request is loading', () => { + beforeEach(() => { + getUserFollowers.mockReturnValueOnce(new Promise(() => {})); + createComponent(); + }); + + it('renders `Follow` component and sets `loading` prop to `true`', () => { + expect(findFollow().props('loading')).toBe(true); + }); + }); + + describe('when API request is successful', () => { + beforeEach(async () => { + getUserFollowers.mockResolvedValueOnce({ + data: followers, + headers: { 'X-TOTAL': '6' }, + }); + createComponent(); + + await waitForPromises(); + }); + + it('renders `GlTab` and sets title', () => { + expect(wrapper.findComponent(GlTab).text()).toContain(s__('UserProfile|Followers')); + }); + + it('renders `GlBadge`, sets size and content', () => { + expect(findGlBadge().props('size')).toBe('sm'); + expect(findGlBadge().text()).toBe('2'); + }); + + it('renders `Follow` component and passes correct props', () => { + expect(findFollow().props()).toMatchObject({ + users: followers, + loading: false, + page: 1, + totalItems: 6, + }); + }); + + describe('when `Follow` component emits `pagination-input` event', () => { + it('calls API and updates `users` and `page` props', async () => { + const lastFollower = followers.at(-1); + const paginationFollowers = [ + { + ...lastFollower, + id: lastFollower.id + 1, + name: 'page 2 follower', + }, + ]; + + getUserFollowers.mockResolvedValueOnce({ + data: paginationFollowers, + headers: { 'X-TOTAL': '6' }, + }); - expect(wrapper.findComponent(GlTab).element.textContent).toContain( - s__('UserProfile|Followers'), - ); + findFollow().vm.$emit('pagination-input', 2); + + await waitForPromises(); + + expect(findFollow().props()).toMatchObject({ + users: paginationFollowers, + loading: false, + page: 2, + totalItems: 6, + }); + }); + }); }); - it('renders `GlBadge`, sets size and content', () => { - createComponent(); + describe('when API request is not successful', () => { + beforeEach(async () => { + getUserFollowers.mockRejectedValueOnce(new Error()); + createComponent(); - expect(wrapper.findComponent(GlBadge).attributes('size')).toBe('sm'); - expect(wrapper.findComponent(GlBadge).element.textContent).toBe('2'); + await waitForPromises(); + }); + + it('shows error alert', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: FollowersTab.i18n.errorMessage, + error: new Error(), + captureError: true, + }); + }); }); }); diff --git a/spec/frontend/profile/components/following_tab_spec.js b/spec/frontend/profile/components/following_tab_spec.js index c9d56360c3e..c0583cf4877 100644 --- a/spec/frontend/profile/components/following_tab_spec.js +++ b/spec/frontend/profile/components/following_tab_spec.js @@ -10,7 +10,7 @@ describe('FollowingTab', () => { const createComponent = () => { wrapper = shallowMountExtended(FollowingTab, { provide: { - followees: 3, + followeesCount: 3, }, }); }; |