import { launchDrawioEditor } from '~/drawio/drawio_editor'; import { DRAWIO_FRAME_ID, DIAGRAM_BACKGROUND_COLOR, DRAWIO_IFRAME_TIMEOUT, DIAGRAM_MAX_SIZE, } from '~/drawio/constants'; import { base64EncodeUnicode } from '~/lib/utils/text_utility'; import { createAlert, VARIANT_SUCCESS } from '~/alert'; const DRAWIO_EDITOR_URL = 'https://embed.diagrams.net/?ui=sketch&noSaveBtn=1&saveAndExit=1&keepmodified=1&spin=1&embed=1&libraries=1&configure=1&proto=json&toSvg=1'; const DRAWIO_EDITOR_ORIGIN = new URL(DRAWIO_EDITOR_URL).origin; jest.mock('~/alert'); jest.useFakeTimers(); describe('drawio/drawio_editor', () => { let editorFacade; let drawioIFrameReceivedMessages; const diagramURL = `${window.location.origin}/uploads/diagram.drawio.svg`; const testSvg = '😀'; const testEncodedSvg = `data:image/svg+xml;base64,${base64EncodeUnicode(testSvg)}`; const filename = 'diagram.drawio.svg'; 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 = []; gon.diagramsnet_url = DRAWIO_EDITOR_ORIGIN; }); afterEach(() => { 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); }); it('sets drawio-editor classname to the iframe', () => { expect(findDrawioIframe().classList).toContain('drawio-editor'); }); }); 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 diagrams.net 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', () => { expectDrawioIframeMessage({ expectation: { action: 'configure', config: { darkColor: '#202020', settingsName: 'gitlab', }, colorSchemeMeta: false, }, }); }); it('does not remove the iframe after the load error timeouts run', () => { 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 = ''; beforeEach(() => { editorFacade.getDiagram.mockResolvedValueOnce({ diagramURL, 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, }, }); }); it('sets the drawio iframe as visible and resets cursor', async () => { await waitForDrawioIFrameMessage(); expect(findDrawioIframe().style.visibility).toBe('visible'); expect(findDrawioIframe().style.cursor).toBe(''); }); it('scrolls window to the top', async () => { await waitForDrawioIFrameMessage(); expect(window.scrollX).toBe(0); }); }); describe.each` description | errorMessage | diagram ${'when there is an image selected that is not an svg file'} | ${'The selected image is not a valid SVG diagram'} | ${{ diagramURL, contentType: 'image/png', filename: 'image.png', }} ${'when the selected image is not an asset upload'} | ${'The selected image is not an asset uploaded in the application'} | ${{ diagramSvg: '', filename, contentType: 'image/svg+xml', diagramURL: 'https://example.com/image.drawio.svg', }} ${'when the selected image is too large'} | ${'The selected image is too large.'} | ${{ diagramSvg: 'x'.repeat(DIAGRAM_MAX_SIZE + 1), filename, contentType: 'image/svg+xml', diagramURL, }} `('$description', ({ errorMessage, diagram }) => { beforeEach(() => { editorFacade.getDiagram.mockResolvedValueOnce(diagram); launchDrawioEditor({ editorFacade }); postMessageToParentWindow({ event: 'init' }); }); it('displays an error alert indicating that the image is not a diagram', () => { expect(createAlert).toHaveBeenCalledWith({ message: errorMessage, 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', () => { expect(createAlert).toHaveBeenCalledWith({ message: 'Cannot load the diagram into the diagrams.net 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, drawioUrl: DRAWIO_EDITOR_ORIGIN, }); }); 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); }); }); });