diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-02-23 09:09:23 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-02-23 09:09:23 +0300 |
commit | 8b4276f873461953ee5a1fc46f084779f5847e3a (patch) | |
tree | cc3435570e15234453e711c2ddcc9b0895d87eb4 /spec | |
parent | f34b26bb882947bcc1126de19fa55eb8763af32e (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
14 files changed, 932 insertions, 23 deletions
diff --git a/spec/features/work_items/work_item_children_spec.rb b/spec/features/work_items/work_item_children_spec.rb index f41fb86d13c..43a6b2771f6 100644 --- a/spec/features/work_items/work_item_children_spec.rb +++ b/spec/features/work_items/work_item_children_spec.rb @@ -132,5 +132,48 @@ RSpec.describe 'Work item children', :js, feature_category: :team_planning do end end end + + context 'in work item metadata' do + let_it_be(:label) { create(:label, title: 'Label 1', project: project) } + let_it_be(:milestone) { create(:milestone, project: project, title: 'v1') } + let_it_be(:task) do + create( + :work_item, + :task, + project: project, + labels: [label], + assignees: [user], + milestone: milestone + ) + end + + before do + visit project_issue_path(project, issue) + + wait_for_requests + end + + it 'displays labels, milestone and assignee for work item children', :aggregate_failures do + page.within('[data-testid="work-item-links"]') do + click_button 'Add' + click_button 'Existing task' + + find('[data-testid="work-item-token-select-input"]').set(task.title) + wait_for_all_requests + click_button task.title + + click_button 'Add task' + + wait_for_all_requests + end + + page.within('[data-testid="links-child"]') do + expect(page).to have_content(task.title) + expect(page).to have_content(label.title) + expect(page).to have_link(user.name) + expect(page).to have_content(milestone.title) + end + end + end end end 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 1fdddce3962..52af3264f33 100644 --- a/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js +++ b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js @@ -1,12 +1,12 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { NodeViewWrapper } from '@tiptap/vue-2'; -import { selectedRect as getSelectedRect } from '@_ueberdosis/prosemirror-tables'; +import { selectedRect as getSelectedRect } from '@tiptap/pm/tables'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import TableCellBaseWrapper from '~/content_editor/components/wrappers/table_cell_base.vue'; import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../../test_utils'; -jest.mock('@_ueberdosis/prosemirror-tables'); +jest.mock('@tiptap/pm/tables'); describe('content/components/wrappers/table_cell_base', () => { let wrapper; diff --git a/spec/frontend/content_editor/render_html_and_json_for_all_examples.js b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js index 5df901e0f15..bf29d4bdf23 100644 --- a/spec/frontend/content_editor/render_html_and_json_for_all_examples.js +++ b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js @@ -1,4 +1,4 @@ -import { DOMSerializer } from 'prosemirror-model'; +import { DOMSerializer } from '@tiptap/pm/model'; import createMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; import { createTiptapEditor } from 'jest/content_editor/test_utils'; diff --git a/spec/frontend/drawio/drawio_editor_spec.js b/spec/frontend/drawio/drawio_editor_spec.js new file mode 100644 index 00000000000..de93d042396 --- /dev/null +++ b/spec/frontend/drawio/drawio_editor_spec.js @@ -0,0 +1,443 @@ +import { launchDrawioEditor } from '~/drawio/drawio_editor'; +import { + DRAWIO_EDITOR_URL, + DRAWIO_FRAME_ID, + DIAGRAM_BACKGROUND_COLOR, + DRAWIO_IFRAME_TIMEOUT, +} from '~/drawio/constants'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; + +jest.mock('~/flash'); + +jest.useFakeTimers(); + +describe('drawio/drawio_editor', () => { + let editorFacade; + let drawioIFrameReceivedMessages; + const testSvg = '<svg></svg>'; + const testEncodedSvg = `data:image/svg+xml;base64,${btoa(testSvg)}`; + + const findDrawioIframe = () => document.getElementById(DRAWIO_FRAME_ID); + const waitForDrawioIFrameMessage = ({ messageNumber = 1 } = {}) => + new Promise((resolve) => { + let messageCounter = 0; + const iframe = findDrawioIframe(); + + iframe?.contentWindow.addEventListener('message', (event) => { + drawioIFrameReceivedMessages.push(event); + + messageCounter += 1; + + if (messageCounter === messageNumber) { + resolve(); + } + }); + }); + const expectDrawioIframeMessage = ({ expectation, messageNumber = 1 }) => { + expect(drawioIFrameReceivedMessages).toHaveLength(messageNumber); + expect(JSON.parse(drawioIFrameReceivedMessages[messageNumber - 1].data)).toEqual(expectation); + }; + const postMessageToParentWindow = (data) => { + const event = new Event('message'); + + Object.setPrototypeOf(event, { + source: findDrawioIframe().contentWindow, + data: JSON.stringify(data), + }); + + window.dispatchEvent(event); + }; + + beforeEach(() => { + editorFacade = { + getDiagram: jest.fn(), + uploadDiagram: jest.fn(), + insertDiagram: jest.fn(), + updateDiagram: jest.fn(), + }; + drawioIFrameReceivedMessages = []; + }); + + afterEach(() => { + jest.clearAllMocks(); + findDrawioIframe()?.remove(); + }); + + describe('initializing', () => { + beforeEach(() => { + launchDrawioEditor({ editorFacade }); + }); + + it('creates the drawio editor iframe and attaches it to the body', () => { + expect(findDrawioIframe().getAttribute('src')).toBe(DRAWIO_EDITOR_URL); + }); + }); + + describe(`when parent window does not receive configure event after ${DRAWIO_IFRAME_TIMEOUT} ms`, () => { + beforeEach(() => { + launchDrawioEditor({ editorFacade }); + }); + + it('disposes draw.io iframe', () => { + expect(findDrawioIframe()).not.toBe(null); + jest.runAllTimers(); + expect(findDrawioIframe()).toBe(null); + }); + + it('displays an alert indicating that the draw.io editor could not be loaded', () => { + jest.runAllTimers(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'The draw.io editor could not be loaded.', + }); + }); + }); + + describe('when parent window receives configure event', () => { + beforeEach(async () => { + launchDrawioEditor({ editorFacade }); + postMessageToParentWindow({ event: 'configure' }); + + await waitForDrawioIFrameMessage(); + }); + + it('sends configure action to the draw.io iframe', async () => { + expectDrawioIframeMessage({ + expectation: { + action: 'configure', + config: { + darkColor: '#202020', + settingsName: 'gitlab', + }, + colorSchemeMeta: false, + }, + }); + }); + + it('does not remove the iframe after the load error timeouts run', async () => { + jest.runAllTimers(); + + expect(findDrawioIframe()).not.toBe(null); + }); + }); + + describe('when parent window receives init event', () => { + describe('when there isn’t a diagram selected', () => { + beforeEach(() => { + editorFacade.getDiagram.mockResolvedValueOnce(null); + + launchDrawioEditor({ editorFacade }); + + postMessageToParentWindow({ event: 'init' }); + }); + + it('sends load action to the draw.io iframe with empty svg and title', async () => { + await waitForDrawioIFrameMessage(); + + expectDrawioIframeMessage({ + expectation: { + action: 'load', + xml: null, + border: 8, + background: DIAGRAM_BACKGROUND_COLOR, + dark: false, + title: null, + }, + }); + }); + }); + + describe('when there is a diagram selected', () => { + const diagramSvg = '<svg></svg>'; + const filename = 'diagram.drawio.svg'; + + beforeEach(() => { + editorFacade.getDiagram.mockResolvedValueOnce({ + diagramSvg, + filename, + contentType: 'image/svg+xml', + }); + + launchDrawioEditor({ editorFacade }); + postMessageToParentWindow({ event: 'init' }); + }); + + it('sends load action to the draw.io iframe with the selected diagram svg and filename', async () => { + await waitForDrawioIFrameMessage(); + + // Step 5: The draw.io editor will send the downloaded diagram to the iframe + expectDrawioIframeMessage({ + expectation: { + action: 'load', + xml: diagramSvg, + border: 8, + background: DIAGRAM_BACKGROUND_COLOR, + dark: false, + title: filename, + }, + }); + }); + }); + + describe('when there is an image selected that is not a diagram', () => { + beforeEach(() => { + editorFacade.getDiagram.mockResolvedValueOnce({ + contentType: 'image/png', + filename: 'image.png', + }); + + launchDrawioEditor({ editorFacade }); + + postMessageToParentWindow({ event: 'init' }); + }); + + it('displays an error alert indicating that the image is not a diagram', async () => { + expect(createAlert).toHaveBeenCalledWith({ + message: 'The selected image is not a diagram', + error: expect.any(Error), + }); + }); + + it('disposes the draw.io diagram iframe', () => { + expect(findDrawioIframe()).toBe(null); + }); + }); + + describe('when loading a diagram fails', () => { + beforeEach(() => { + editorFacade.getDiagram.mockRejectedValueOnce(new Error()); + + launchDrawioEditor({ editorFacade }); + + postMessageToParentWindow({ event: 'init' }); + }); + + it('displays an error alert indicating the failure', async () => { + expect(createAlert).toHaveBeenCalledWith({ + message: 'Cannot load the diagram into the draw.io editor', + error: expect.any(Error), + }); + }); + + it('disposes the draw.io diagram iframe', () => { + expect(findDrawioIframe()).toBe(null); + }); + }); + }); + + describe('when parent window receives prompt event', () => { + describe('when the filename is empty', () => { + beforeEach(() => { + launchDrawioEditor({ editorFacade }); + + postMessageToParentWindow({ event: 'prompt', value: '' }); + }); + + it('sends prompt action to the draw.io iframe requesting a filename', async () => { + await waitForDrawioIFrameMessage({ messageNumber: 1 }); + + expectDrawioIframeMessage({ + expectation: { + action: 'prompt', + titleKey: 'filename', + okKey: 'save', + defaultValue: 'diagram.drawio.svg', + }, + messageNumber: 1, + }); + }); + + it('sends dialog action to the draw.io iframe indicating that the filename cannot be empty', async () => { + await waitForDrawioIFrameMessage({ messageNumber: 2 }); + + expectDrawioIframeMessage({ + expectation: { + action: 'dialog', + titleKey: 'error', + messageKey: 'filenameShort', + buttonKey: 'ok', + }, + messageNumber: 2, + }); + }); + }); + + describe('when the event data is not empty', () => { + beforeEach(async () => { + launchDrawioEditor({ editorFacade }); + postMessageToParentWindow({ event: 'prompt', value: 'diagram.drawio.svg' }); + + await waitForDrawioIFrameMessage(); + }); + + it('starts the saving file process', () => { + expectDrawioIframeMessage({ + expectation: { + action: 'spinner', + show: true, + messageKey: 'saving', + }, + }); + }); + }); + }); + + describe('when parent receives export event', () => { + beforeEach(() => { + editorFacade.uploadDiagram.mockResolvedValueOnce({}); + }); + + it('reloads diagram in the draw.io editor', async () => { + launchDrawioEditor({ editorFacade }); + postMessageToParentWindow({ event: 'export', data: testEncodedSvg }); + + await waitForDrawioIFrameMessage(); + + expectDrawioIframeMessage({ + expectation: expect.objectContaining({ + action: 'load', + xml: expect.stringContaining(testSvg), + }), + }); + }); + + it('marks the diagram as modified in the draw.io editor', async () => { + launchDrawioEditor({ editorFacade }); + postMessageToParentWindow({ event: 'export', data: testEncodedSvg }); + + await waitForDrawioIFrameMessage({ messageNumber: 2 }); + + expectDrawioIframeMessage({ + expectation: expect.objectContaining({ + action: 'status', + modified: true, + }), + messageNumber: 2, + }); + }); + + describe('when the diagram filename is set', () => { + const TEST_FILENAME = 'diagram.drawio.svg'; + + beforeEach(() => { + launchDrawioEditor({ editorFacade, filename: TEST_FILENAME }); + }); + + it('displays loading spinner in the draw.io editor', async () => { + postMessageToParentWindow({ event: 'export', data: testEncodedSvg }); + + await waitForDrawioIFrameMessage({ messageNumber: 3 }); + + expectDrawioIframeMessage({ + expectation: { + action: 'spinner', + show: true, + messageKey: 'saving', + }, + messageNumber: 3, + }); + }); + + it('uploads exported diagram', async () => { + postMessageToParentWindow({ event: 'export', data: testEncodedSvg }); + + await waitForDrawioIFrameMessage({ messageNumber: 3 }); + + expect(editorFacade.uploadDiagram).toHaveBeenCalledWith({ + filename: TEST_FILENAME, + diagramSvg: expect.stringContaining(testSvg), + }); + }); + + describe('when uploading the exported diagram succeeds', () => { + it('displays an alert indicating that the diagram was uploaded successfully', async () => { + postMessageToParentWindow({ event: 'export', data: testEncodedSvg }); + + await waitForDrawioIFrameMessage({ messageNumber: 3 }); + + expect(createAlert).toHaveBeenCalledWith({ + message: expect.any(String), + variant: VARIANT_SUCCESS, + fadeTransition: true, + }); + }); + + it('disposes iframe', () => { + jest.runAllTimers(); + + expect(findDrawioIframe()).toBe(null); + }); + }); + + describe('when uploading the exported diagram fails', () => { + const uploadError = new Error(); + + beforeEach(() => { + editorFacade.uploadDiagram.mockReset(); + editorFacade.uploadDiagram.mockRejectedValue(uploadError); + + postMessageToParentWindow({ event: 'export', data: testEncodedSvg }); + }); + + it('hides loading indicator in the draw.io editor', async () => { + await waitForDrawioIFrameMessage({ messageNumber: 4 }); + + expectDrawioIframeMessage({ + expectation: { + action: 'spinner', + show: false, + }, + messageNumber: 4, + }); + }); + + it('displays an error dialog in the draw.io editor', async () => { + await waitForDrawioIFrameMessage({ messageNumber: 5 }); + + expectDrawioIframeMessage({ + expectation: { + action: 'dialog', + titleKey: 'error', + modified: true, + buttonKey: 'close', + messageKey: 'errorSavingFile', + }, + messageNumber: 5, + }); + }); + }); + }); + + describe('when diagram filename is not set', () => { + it('sends prompt action to the draw.io iframe', async () => { + launchDrawioEditor({ editorFacade }); + postMessageToParentWindow({ event: 'export', data: testEncodedSvg }); + + await waitForDrawioIFrameMessage({ messageNumber: 3 }); + + expect(drawioIFrameReceivedMessages[2].data).toEqual( + JSON.stringify({ + action: 'prompt', + titleKey: 'filename', + okKey: 'save', + defaultValue: 'diagram.drawio.svg', + }), + ); + }); + }); + }); + + describe('when parent window receives exit event', () => { + beforeEach(() => { + launchDrawioEditor({ editorFacade }); + }); + + it('disposes the the draw.io iframe', () => { + expect(findDrawioIframe()).not.toBe(null); + + postMessageToParentWindow({ event: 'exit' }); + + expect(findDrawioIframe()).toBe(null); + }); + }); +}); diff --git a/spec/frontend/drawio/markdown_field_editor_facade_spec.js b/spec/frontend/drawio/markdown_field_editor_facade_spec.js new file mode 100644 index 00000000000..992dcf0017c --- /dev/null +++ b/spec/frontend/drawio/markdown_field_editor_facade_spec.js @@ -0,0 +1,147 @@ +import AxiosMockAdapter from 'axios-mock-adapter'; +import { create } from '~/drawio/markdown_field_editor_facade'; +import * as textMarkdown from '~/lib/utils/text_markdown'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import axios from '~/lib/utils/axios_utils'; + +jest.mock('~/lib/utils/text_markdown'); + +describe('drawio/textareaMarkdownEditor', () => { + let textArea; + let textareaMarkdownEditor; + let axiosMock; + + const markdownPreviewPath = '/markdown/preview'; + const imageURL = '/assets/image.png'; + const diagramMarkdown = '![](image.png)'; + const diagramSvg = '<svg></svg>'; + const contentType = 'image/svg+xml'; + const filename = 'image.png'; + const newDiagramMarkdown = '![](newdiagram.svg)'; + const uploadsPath = '/uploads'; + + beforeEach(() => { + textArea = document.createElement('textarea'); + textareaMarkdownEditor = create({ textArea, markdownPreviewPath, uploadsPath }); + + document.body.appendChild(textArea); + }); + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + axiosMock.restore(); + textArea.remove(); + }); + + describe('getDiagram', () => { + describe('when there is a selected diagram', () => { + beforeEach(() => { + textMarkdown.resolveSelectedImage.mockReturnValueOnce({ + imageURL, + imageMarkdown: diagramMarkdown, + filename, + }); + axiosMock + .onGet(imageURL) + .reply(HTTP_STATUS_OK, diagramSvg, { 'content-type': contentType }); + }); + + it('returns diagram information', async () => { + const diagram = await textareaMarkdownEditor.getDiagram(); + + expect(textMarkdown.resolveSelectedImage).toHaveBeenCalledWith( + textArea, + markdownPreviewPath, + ); + + expect(diagram).toEqual({ + diagramMarkdown, + filename, + diagramSvg, + contentType, + }); + }); + }); + + describe('when there is not a selected diagram', () => { + beforeEach(() => { + textMarkdown.resolveSelectedImage.mockReturnValueOnce(null); + }); + + it('returns null', async () => { + const diagram = await textareaMarkdownEditor.getDiagram(); + + expect(textMarkdown.resolveSelectedImage).toHaveBeenCalledWith( + textArea, + markdownPreviewPath, + ); + + expect(diagram).toBe(null); + }); + }); + }); + + describe('updateDiagram', () => { + beforeEach(() => { + jest.spyOn(textArea, 'focus'); + jest.spyOn(textArea, 'dispatchEvent'); + + textArea.value = `diagram ${diagramMarkdown}`; + + textareaMarkdownEditor.updateDiagram({ + diagramMarkdown, + uploadResults: { link: { markdown: newDiagramMarkdown } }, + }); + }); + + it('focuses the textarea', () => { + expect(textArea.focus).toHaveBeenCalled(); + }); + + it('replaces previous diagram markdown with new diagram markdown', () => { + expect(textArea.value).toBe(`diagram ${newDiagramMarkdown}`); + }); + + it('dispatches input event in the textarea', () => { + expect(textArea.dispatchEvent).toHaveBeenCalledWith(new Event('input')); + }); + }); + + describe('insertDiagram', () => { + it('inserts markdown text and replaces any selected markdown in the textarea', () => { + textArea.value = `diagram ${diagramMarkdown}`; + textArea.setSelectionRange(0, 8); + + textareaMarkdownEditor.insertDiagram({ + uploadResults: { link: { markdown: newDiagramMarkdown } }, + }); + + expect(textMarkdown.insertMarkdownText).toHaveBeenCalledWith({ + textArea, + text: textArea.value, + tag: newDiagramMarkdown, + selected: textArea.value.substring(0, 8), + }); + }); + }); + + describe('uploadDiagram', () => { + it('sends a post request to the uploadsPath containing the diagram svg', async () => { + const link = { markdown: '![](diagram.drawio.svg)' }; + const blob = new Blob([diagramSvg], { type: 'image/svg+xml' }); + const formData = new FormData(); + + formData.append('file', blob, filename); + + axiosMock.onPost(uploadsPath, formData).reply(HTTP_STATUS_OK, { + link, + }); + + const response = await textareaMarkdownEditor.uploadDiagram({ diagramSvg, filename }); + + expect(response).toEqual({ link }); + }); + }); +}); diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js index 7aab1013fc0..2180ea7e6c2 100644 --- a/spec/frontend/lib/utils/text_markdown_spec.js +++ b/spec/frontend/lib/utils/text_markdown_spec.js @@ -1,12 +1,16 @@ import $ from 'jquery'; +import AxiosMockAdapter from 'axios-mock-adapter'; import { insertMarkdownText, keypressNoteText, compositionStartNoteText, compositionEndNoteText, updateTextForToolbarBtn, + resolveSelectedImage, } from '~/lib/utils/text_markdown'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import '~/lib/utils/jquery_at_who'; +import axios from '~/lib/utils/axios_utils'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; describe('init markdown', () => { @@ -14,6 +18,7 @@ describe('init markdown', () => { let textArea; let indentButton; let outdentButton; + let axiosMock; beforeAll(() => { setHTMLFixture( @@ -34,6 +39,14 @@ describe('init markdown', () => { document.execCommand = jest.fn(() => false); }); + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + axiosMock.restore(); + }); + afterAll(() => { resetHTMLFixture(); }); @@ -707,6 +720,55 @@ describe('init markdown', () => { }); }); + describe('resolveSelectedImage', () => { + const markdownPreviewPath = '/markdown/preview'; + const imageMarkdown = '![image](/uploads/image.png)'; + const imageAbsoluteUrl = '/abs/uploads/image.png'; + + describe('when textarea cursor is positioned on an image', () => { + beforeEach(() => { + axiosMock.onPost(markdownPreviewPath, { text: imageMarkdown }).reply(HTTP_STATUS_OK, { + body: ` + <p><a href="${imageAbsoluteUrl}"><img src="${imageAbsoluteUrl}"></a></p> + `, + }); + }); + + it('returns the image absolute URL, markdown, and filename', async () => { + textArea.value = `image ${imageMarkdown}`; + textArea.setSelectionRange(8, 8); + expect(await resolveSelectedImage(textArea, markdownPreviewPath)).toEqual({ + imageURL: imageAbsoluteUrl, + imageMarkdown, + filename: 'image.png', + }); + }); + }); + + describe('when textarea cursor is not positioned on an image', () => { + it.each` + markdown | selectionRange + ${`image ${imageMarkdown}`} | ${[4, 4]} + ${`!2 (issue)`} | ${[2, 2]} + `('returns null', async ({ markdown, selectionRange }) => { + textArea.value = markdown; + textArea.setSelectionRange(...selectionRange); + expect(await resolveSelectedImage(textArea, markdownPreviewPath)).toBe(null); + }); + }); + + describe('when textarea cursor is positioned between images', () => { + it('returns null', async () => { + const position = imageMarkdown.length + 1; + + textArea.value = `${imageMarkdown}\n\n${imageMarkdown}`; + textArea.setSelectionRange(position, position); + + expect(await resolveSelectedImage(textArea, markdownPreviewPath)).toBe(null); + }); + }); + }); + describe('Source Editor', () => { let editor; 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 fec300ddd7e..7eb0468c5be 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 @@ -28,10 +28,13 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = data-uploads-path="" > <markdown-header-stub + data-testid="markdownHeader" enablepreview="true" linecontent="" + markdownpreviewpath="foo/" restrictedtoolbaritems="" suggestionstartindex="0" + uploadspath="" /> <div diff --git a/spec/frontend/vue_shared/components/markdown/drawio_toolbar_button_spec.js b/spec/frontend/vue_shared/components/markdown/drawio_toolbar_button_spec.js new file mode 100644 index 00000000000..4daf26b0509 --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/drawio_toolbar_button_spec.js @@ -0,0 +1,67 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import DrawioToolbarButton from '~/vue_shared/components/markdown/drawio_toolbar_button.vue'; +import { launchDrawioEditor } from '~/drawio/drawio_editor'; +import { create } from '~/drawio/markdown_field_editor_facade'; + +jest.mock('~/drawio/drawio_editor'); +jest.mock('~/drawio/markdown_field_editor_facade'); + +describe('vue_shared/components/markdown/drawio_toolbar_button', () => { + let wrapper; + let textArea; + const uploadsPath = '/uploads'; + const markdownPreviewPath = '/markdown/preview'; + + const buildWrapper = (props = { uploadsPath, markdownPreviewPath }) => { + wrapper = shallowMount(DrawioToolbarButton, { + propsData: { + ...props, + }, + }); + }; + + beforeEach(() => { + textArea = document.createElement('textarea'); + textArea.classList.add('js-gfm-input'); + + document.body.appendChild(textArea); + }); + + afterEach(() => { + textArea.remove(); + wrapper.destroy(); + }); + + describe('default', () => { + it('renders button that launches draw.io editor', () => { + buildWrapper(); + + expect(wrapper.findComponent(GlButton).props()).toMatchObject({ + icon: 'diagram', + category: 'tertiary', + }); + }); + }); + + describe('when clicking button', () => { + it('launches draw.io editor', async () => { + const editorFacadeStub = {}; + + create.mockReturnValueOnce(editorFacadeStub); + + buildWrapper(); + + await wrapper.findComponent(GlButton).vm.$emit('click'); + + expect(create).toHaveBeenCalledWith({ + markdownPreviewPath, + textArea, + uploadsPath, + }); + expect(launchDrawioEditor).toHaveBeenCalledWith({ + editorFacade: editorFacadeStub, + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js index ed417097e1e..988d9780505 100644 --- a/spec/frontend/vue_shared/components/markdown/header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/header_spec.js @@ -3,6 +3,7 @@ import { nextTick } from 'vue'; import { GlTabs } from '@gitlab/ui'; import HeaderComponent from '~/vue_shared/components/markdown/header.vue'; import ToolbarButton from '~/vue_shared/components/markdown/toolbar_button.vue'; +import DrawioToolbarButton from '~/vue_shared/components/markdown/drawio_toolbar_button.vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; describe('Markdown field header component', () => { @@ -26,6 +27,7 @@ describe('Markdown field header component', () => { findToolbarButtons() .filter((button) => button.props(prop) === value) .at(0); + const findDrawioToolbarButton = () => wrapper.findComponent(DrawioToolbarButton); beforeEach(() => { window.gl = { @@ -197,4 +199,24 @@ describe('Markdown field header component', () => { expect(findToolbarButtons().length).toBe(defaultCount); }); }); + + describe('when drawIOEnabled is true', () => { + const uploadsPath = '/uploads'; + const markdownPreviewPath = '/preview'; + + beforeEach(() => { + createWrapper({ + drawioEnabled: true, + uploadsPath, + markdownPreviewPath, + }); + }); + + it('renders drawio toolbar button', () => { + expect(findDrawioToolbarButton().props()).toEqual({ + uploadsPath, + markdownPreviewPath, + }); + }); + }); }); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 8acf9fd94fb..a864ae75b7f 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -82,6 +82,7 @@ export const workItemQueryResponse = { userPermissions: { deleteWorkItem: false, updateWorkItem: false, + __typename: 'WorkItemPermissions', }, widgets: [ { @@ -182,6 +183,7 @@ export const updateWorkItemMutationResponse = { userPermissions: { deleteWorkItem: false, updateWorkItem: false, + __typename: 'WorkItemPermissions', }, widgets: [ { @@ -330,6 +332,7 @@ export const workItemResponseFactory = ({ userPermissions: { deleteWorkItem: canDelete, updateWorkItem: canUpdate, + __typename: 'WorkItemPermissions', }, widgets: [ { @@ -539,6 +542,7 @@ export const createWorkItemMutationResponse = { userPermissions: { deleteWorkItem: false, updateWorkItem: false, + __typename: 'WorkItemPermissions', }, widgets: [], }, @@ -587,6 +591,7 @@ export const createWorkItemFromTaskMutationResponse = { userPermissions: { deleteWorkItem: false, updateWorkItem: false, + __typename: 'WorkItemPermissions', }, widgets: [ { @@ -627,6 +632,7 @@ export const createWorkItemFromTaskMutationResponse = { userPermissions: { deleteWorkItem: false, updateWorkItem: false, + __typename: 'WorkItemPermissions', }, widgets: [], }, @@ -828,15 +834,20 @@ export const workItemHierarchyEmptyResponse = { data: { workItem: { id: 'gid://gitlab/WorkItem/1', + iid: 1, + state: 'OPEN', workItemType: { - id: 'gid://gitlab/WorkItems::Type/6', + id: 'gid://gitlab/WorkItems::Type/1', name: 'Issue', iconName: 'issue-type-issue', __typename: 'WorkItemType', }, title: 'New title', + description: '', createdAt: '2022-08-03T12:41:54Z', + updatedAt: null, closedAt: null, + author: mockAssignees[0], project: { __typename: 'Project', id: '1', @@ -846,14 +857,11 @@ export const workItemHierarchyEmptyResponse = { userPermissions: { deleteWorkItem: false, updateWorkItem: false, + __typename: 'WorkItemPermissions', }, confidential: false, widgets: [ { - type: 'DESCRIPTION', - __typename: 'WorkItemWidgetDescription', - }, - { type: 'HIERARCHY', parent: null, hasChildren: false, @@ -873,6 +881,8 @@ export const workItemHierarchyNoUpdatePermissionResponse = { data: { workItem: { id: 'gid://gitlab/WorkItem/1', + iid: 1, + state: 'OPEN', workItemType: { id: 'gid://gitlab/WorkItems::Type/6', name: 'Issue', @@ -880,9 +890,15 @@ export const workItemHierarchyNoUpdatePermissionResponse = { __typename: 'WorkItemType', }, title: 'New title', + description: '', + createdAt: '2022-08-03T12:41:54Z', + updatedAt: null, + closedAt: null, + author: mockAssignees[0], userPermissions: { deleteWorkItem: false, updateWorkItem: false, + __typename: 'WorkItemPermissions', }, project: { __typename: 'Project', @@ -893,10 +909,6 @@ export const workItemHierarchyNoUpdatePermissionResponse = { confidential: false, widgets: [ { - type: 'DESCRIPTION', - __typename: 'WorkItemWidgetDescription', - }, - { type: 'HIERARCHY', parent: null, hasChildren: true, @@ -949,6 +961,7 @@ export const workItemTask = { confidential: false, createdAt: '2022-08-03T12:41:54Z', closedAt: null, + widgets: [], __typename: 'WorkItem', }; @@ -966,6 +979,7 @@ export const confidentialWorkItemTask = { confidential: true, createdAt: '2022-08-03T12:41:54Z', closedAt: null, + widgets: [], __typename: 'WorkItem', }; @@ -983,6 +997,7 @@ export const closedWorkItemTask = { confidential: false, createdAt: '2022-08-03T12:41:54Z', closedAt: '2022-08-12T13:07:52Z', + widgets: [], __typename: 'WorkItem', }; @@ -1004,6 +1019,7 @@ export const childrenWorkItems = [ confidential: false, createdAt: '2022-08-03T12:41:54Z', closedAt: null, + widgets: [], __typename: 'WorkItem', }, ]; @@ -1014,15 +1030,19 @@ export const workItemHierarchyResponse = { id: 'gid://gitlab/WorkItem/1', iid: '1', workItemType: { - id: 'gid://gitlab/WorkItems::Type/6', - name: 'Objective', - iconName: 'issue-type-objective', + id: 'gid://gitlab/WorkItems::Type/1', + name: 'Issue', + iconName: 'issue-type-issue', __typename: 'WorkItemType', }, title: 'New title', userPermissions: { deleteWorkItem: true, updateWorkItem: true, + __typename: 'WorkItemPermissions', + }, + author: { + ...mockAssignees[0], }, confidential: false, project: { @@ -1031,12 +1051,13 @@ export const workItemHierarchyResponse = { fullPath: 'test-project-path', archived: false, }, + description: 'Issue description', + state: 'OPEN', + createdAt: '2022-08-03T12:41:54Z', + updatedAt: null, + closedAt: null, widgets: [ { - type: 'DESCRIPTION', - __typename: 'WorkItemWidgetDescription', - }, - { type: 'HIERARCHY', parent: null, hasChildren: true, @@ -1107,6 +1128,7 @@ export const workItemObjectiveWithChild = { userPermissions: { deleteWorkItem: true, updateWorkItem: true, + __typename: 'WorkItemPermissions', }, author: { ...mockAssignees[0], @@ -1173,6 +1195,7 @@ export const workItemHierarchyTreeResponse = { userPermissions: { deleteWorkItem: true, updateWorkItem: true, + __typename: 'WorkItemPermissions', }, confidential: false, project: { @@ -1249,6 +1272,7 @@ export const changeWorkItemParentMutationResponse = { userPermissions: { deleteWorkItem: true, updateWorkItem: true, + __typename: 'WorkItemPermissions', }, description: null, id: 'gid://gitlab/WorkItem/2', @@ -1621,6 +1645,7 @@ export const projectWorkItemResponse = { workItems: { nodes: [workItemQueryResponse.data.workItem], }, + __typename: 'Project', }, }, }; diff --git a/spec/lib/bulk_imports/clients/http_spec.rb b/spec/lib/bulk_imports/clients/http_spec.rb index 09753b3c23d..40261947750 100644 --- a/spec/lib/bulk_imports/clients/http_spec.rb +++ b/spec/lib/bulk_imports/clients/http_spec.rb @@ -284,7 +284,7 @@ RSpec.describe BulkImports::Clients::HTTP, feature_category: :importers do stub_request(:get, 'http://gitlab.example/api/v4/metadata?private_token=token') .to_return(status: 404, body: "", headers: { 'Content-Type' => 'application/json' }) - expect { subject.instance_version }.to raise_exception(BulkImports::Error, 'Import aborted as it was not possible to connect to the provided GitLab instance URL.') + expect { subject.instance_version }.to raise_exception(BulkImports::Error, 'Invalid source URL. Enter only the base URL of the source GitLab instance.') end end diff --git a/spec/lib/gitlab/seeders/ci/runner/runner_fleet_seeder_spec.rb b/spec/lib/gitlab/seeders/ci/runner/runner_fleet_seeder_spec.rb index fe52b586d49..fc16e889636 100644 --- a/spec/lib/gitlab/seeders/ci/runner/runner_fleet_seeder_spec.rb +++ b/spec/lib/gitlab/seeders/ci/runner/runner_fleet_seeder_spec.rb @@ -67,5 +67,29 @@ RSpec.describe ::Gitlab::Seeders::Ci::Runner::RunnerFleetSeeder, feature_categor expect(::Ci::Build.where(runner_id: project[:runner_ids])).to be_empty end end + + context 'when number of group runners exceeds plan limit' do + before do + create(:plan_limits, :default_plan, ci_registered_group_runners: 1) + end + + it { is_expected.to be_nil } + + it 'creates expected hierarchy', :aggregate_failures do + expect { seed }.not_to change { Ci::Runner.count } + end + end + + context 'when number of project runners exceeds plan limit' do + before do + create(:plan_limits, :default_plan, ci_registered_project_runners: 1) + end + + it { is_expected.to be_nil } + + it 'creates expected hierarchy', :aggregate_failures do + expect { seed }.not_to change { Ci::Runner.count } + end + end end end diff --git a/spec/requests/projects/wikis_controller_spec.rb b/spec/requests/projects/wikis_controller_spec.rb new file mode 100644 index 00000000000..4768e7134e8 --- /dev/null +++ b/spec/requests/projects/wikis_controller_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::WikisController, feature_category: :wiki do + using RSpec::Parameterized::TableSyntax + + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :wiki_repo, namespace: user.namespace) } + let_it_be(:project_wiki) { create(:project_wiki, project: project, user: user) } + let_it_be(:wiki_page) do + create(:wiki_page, + wiki: project_wiki, + title: 'home', content: "Look at this [image](#{path})\n\n ![alt text](#{path})") + end + + let_it_be(:csp_nonce) { 'just=some=noncense' } + + before do + sign_in(user) + + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:content_security_policy_nonce).and_return(csp_nonce) + end + end + + shared_examples 'embed.diagrams.net frame-src directive' do + it 'adds drawio frame-src directive to the Content Security Policy header' do + frame_src = response.headers['Content-Security-Policy'].split(';') + .map(&:strip) + .find { |entry| entry.starts_with?('frame-src') } + + expect(frame_src).to include('https://embed.diagrams.net') + end + end + + describe 'CSP policy' do + describe '#new' do + before do + get wiki_path(project_wiki, action: :new) + end + + it_behaves_like 'embed.diagrams.net frame-src directive' + end + + describe '#edit' do + before do + get wiki_page_path(project_wiki, wiki_page, action: 'edit') + end + + it_behaves_like 'embed.diagrams.net frame-src directive' + end + + describe '#create' do + before do + # Creating a page with an invalid title to render edit page + post wiki_path(project_wiki, action: 'create'), params: { wiki: { title: 'home' } } + end + + it_behaves_like 'embed.diagrams.net frame-src directive' + end + + describe '#update' do + before do + # Setting an invalid page title to render edit page + put wiki_page_path(project_wiki, wiki_page), params: { wiki: { title: '' } } + print(response.body) + end + + it_behaves_like 'embed.diagrams.net frame-src directive' + end + end +end diff --git a/spec/support/shared_examples/features/trial_email_validation_shared_example.rb b/spec/support/shared_examples/features/trial_email_validation_shared_example.rb index 5bab1e76e12..81c9ac1164b 100644 --- a/spec/support/shared_examples/features/trial_email_validation_shared_example.rb +++ b/spec/support/shared_examples/features/trial_email_validation_shared_example.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true RSpec.shared_examples 'user email validation' do - let(:email_hint_message) { 'We recommend a work email address.' } - let(:email_error_message) { 'Please provide a valid email address.' } + let(:email_hint_message) { _('We recommend a work email address.') } + let(:email_error_message) { _('Please provide a valid email address.') } let(:email_warning_message) do - 'This email address does not look right, are you sure you typed it correctly?' + _('This email address does not look right, are you sure you typed it correctly?') end it 'shows an error message until a correct email is entered' do |