| /** | |
| * @license | |
| * Copyright 2022 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import '../test/test-setup'; | |
| import './codemirror-element'; | |
| import {redo, undo} from '@codemirror/commands'; | |
| import {assert, fixture, html} from '@open-wc/testing'; | |
| import {CodeMirrorElement} from './codemirror-element'; | |
| import {EditorState} from '@codemirror/state'; | |
| suite('codemirror-element tests', () => { | |
| let element: CodeMirrorElement; | |
| setup(async () => { | |
| element = await fixture<CodeMirrorElement>( | |
| html`<codemirror-element></codemirror-element>`, | |
| ); | |
| element.lineNum = 123; | |
| element.prefs = { | |
| match_brackets: true, | |
| }; | |
| await element.updateComplete; | |
| }); | |
| async function waitForEditorInstance( | |
| el: CodeMirrorElement, | |
| attempts = 20, | |
| delayMs = 50, | |
| ) { | |
| for (let i = 0; i < attempts && !el.editor; i++) { | |
| await new Promise<void>(resolve => { | |
| setTimeout(() => resolve(), delayMs); | |
| }); | |
| } | |
| return el.editor; | |
| } | |
| function waitForContentChangeEvent(el: CodeMirrorElement) { | |
| return new Promise<CustomEvent<{value: string}>>(resolve => { | |
| el.addEventListener( | |
| 'content-change', | |
| (event: Event): void => { | |
| resolve(event as CustomEvent<{value: string}>); | |
| }, | |
| {once: true}, | |
| ); | |
| }); | |
| } | |
| function dispatchPaste(editor: CodeMirrorElement, text: string) { | |
| const editorView = editor.editor; | |
| assert.isOk(editorView); | |
| const pasteEvent = new ClipboardEvent('paste', { | |
| bubbles: true, | |
| cancelable: true, | |
| clipboardData: new DataTransfer(), | |
| }); | |
| pasteEvent.clipboardData!.setData('text/plain', text); | |
| editorView!.contentDOM.dispatchEvent(pasteEvent); | |
| } | |
| test('dispatches content-change event when editor content changes', async () => { | |
| const initialContent = 'first line\nsecond line\n'; | |
| const editorElement = await fixture<CodeMirrorElement>( | |
| html` | |
| <codemirror-element .fileContent=${initialContent}></codemirror-element> | |
| `, | |
| ); | |
| await editorElement.updateComplete; | |
| const editorView = await waitForEditorInstance(editorElement); | |
| assert.isOk(editorView); | |
| const eventPromise = waitForContentChangeEvent(editorElement); | |
| editorView!.dispatch({ | |
| changes: { | |
| from: 0, | |
| to: 0, | |
| insert: 'X', | |
| }, | |
| }); | |
| const event = await eventPromise; | |
| assert.isString(event.detail.value); | |
| assert.isAbove(event.detail.value.length, initialContent.length); | |
| }); | |
| test('content-change preserves LF for LF-only inputs', async () => { | |
| const editorElement = await fixture<CodeMirrorElement>( | |
| html` | |
| <codemirror-element .fileContent=${'a\nb\n'}></codemirror-element> | |
| `, | |
| ); | |
| await editorElement.updateComplete; | |
| const editorView = await waitForEditorInstance(editorElement); | |
| assert.isOk(editorView); | |
| const eventPromise = waitForContentChangeEvent(editorElement); | |
| editorView!.dispatch({ | |
| changes: { | |
| from: 0, | |
| to: 0, | |
| insert: 'x', | |
| }, | |
| }); | |
| const event = await eventPromise; | |
| assert.notInclude(event.detail.value, '\r\n'); | |
| assert.include(event.detail.value, '\n'); | |
| assert.isTrue(event.detail.value.endsWith('\n')); | |
| }); | |
| test('content-change preserves CRLF for CRLF inputs', async () => { | |
| const editorElement = await fixture<CodeMirrorElement>( | |
| html` | |
| <codemirror-element .fileContent=${'a\r\nb\r\n'}></codemirror-element> | |
| `, | |
| ); | |
| await editorElement.updateComplete; | |
| const editorView = await waitForEditorInstance(editorElement); | |
| assert.isOk(editorView); | |
| const eventPromise = waitForContentChangeEvent(editorElement); | |
| editorView!.dispatch({ | |
| changes: { | |
| from: 0, | |
| to: 0, | |
| insert: 'x', | |
| }, | |
| }); | |
| const event = await eventPromise; | |
| assert.include(event.detail.value, '\r\n'); | |
| assert.isTrue(event.detail.value.endsWith('\r\n')); | |
| }); | |
| test('paste with CRLF updates document line separator', async () => { | |
| const editorElement = await fixture<CodeMirrorElement>( | |
| html` <codemirror-element .fileContent=${''}></codemirror-element> `, | |
| ); | |
| await editorElement.updateComplete; | |
| const editorView = await waitForEditorInstance(editorElement); | |
| assert.isOk(editorView); | |
| assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\n'); | |
| const contentPromise = waitForContentChangeEvent(editorElement); | |
| dispatchPaste(editorElement, 'a\r\nb\r\n'); | |
| const event = await contentPromise; | |
| assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\r\n'); | |
| assert.include(event.detail.value, '\r\n'); | |
| assert.notInclude(event.detail.value.replace(/\r\n/g, ''), '\n'); | |
| }); | |
| test('replacing entire document with CRLF via paste preserves CRLF', async () => { | |
| const editorElement = await fixture<CodeMirrorElement>( | |
| html` <codemirror-element .fileContent=${'hello'}></codemirror-element> `, | |
| ); | |
| await editorElement.updateComplete; | |
| const editorView = await waitForEditorInstance(editorElement); | |
| assert.isOk(editorView); | |
| assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\n'); | |
| /* | |
| * Select the entire document so the paste replaces it. | |
| */ | |
| editorView!.dispatch({ | |
| selection: { | |
| anchor: 0, | |
| head: editorView!.state.doc.length, | |
| }, | |
| }); | |
| const contentPromise = waitForContentChangeEvent(editorElement); | |
| dispatchPaste(editorElement, 'a\r\nb\r\n'); | |
| const event = await contentPromise; | |
| /* | |
| * The paste handler sees the raw clipboard data before CodeMirror | |
| * normalizes it, so it can correctly change the active separator. | |
| */ | |
| assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\r\n'); | |
| assert.equal(event.detail.value, 'a\r\nb\r\n'); | |
| }); | |
| test('pasting text into existing CRLF document preserves CRLF', async () => { | |
| const editorElement = await fixture<CodeMirrorElement>( | |
| html` | |
| <codemirror-element | |
| .fileContent=${'first line\r\nsecond line\r\n'} | |
| ></codemirror-element> | |
| `, | |
| ); | |
| await editorElement.updateComplete; | |
| const editorView = await waitForEditorInstance(editorElement); | |
| assert.isOk(editorView); | |
| assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\r\n'); | |
| /* | |
| * Put the cursor in the middle of the document. | |
| */ | |
| editorView!.dispatch({ | |
| selection: { | |
| anchor: 5, | |
| }, | |
| }); | |
| const contentPromise = waitForContentChangeEvent(editorElement); | |
| /* | |
| * Pasting a single word has no line ending of its own. | |
| * It must not change the document's existing line ending. | |
| */ | |
| dispatchPaste(editorElement, 'foo'); | |
| const event = await contentPromise; | |
| assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\r\n'); | |
| assert.include(event.detail.value, '\r\n'); | |
| assert.notInclude(event.detail.value.replace(/\r\n/g, ''), '\n'); | |
| }); | |
| test('typing Enter with keyboard updates document content', async () => { | |
| const editorElement = await fixture<CodeMirrorElement>( | |
| html` <codemirror-element .fileContent=${'hello'}></codemirror-element> `, | |
| ); | |
| await editorElement.updateComplete; | |
| const editorView = await waitForEditorInstance(editorElement); | |
| assert.isOk(editorView); | |
| editorView!.focus(); | |
| editorView!.dispatch({ | |
| selection: { | |
| anchor: editorView!.state.doc.length, | |
| }, | |
| }); | |
| const keyEvent = new KeyboardEvent('keydown', { | |
| key: 'Enter', | |
| code: 'Enter', | |
| bubbles: true, | |
| cancelable: true, | |
| }); | |
| editorView!.contentDOM.dispatchEvent(keyEvent); | |
| assert.equal(editorView!.state.sliceDoc(), 'hello\n'); | |
| }); | |
| test('typing with keyboard updates document content', async () => { | |
| const editorElement = await fixture<CodeMirrorElement>( | |
| html` <codemirror-element .fileContent=${''}></codemirror-element> `, | |
| ); | |
| await editorElement.updateComplete; | |
| const editorView = await waitForEditorInstance(editorElement); | |
| assert.isOk(editorView); | |
| editorView!.focus(); | |
| const contentPromise = waitForContentChangeEvent(editorElement); | |
| /* | |
| * Simulate text being entered through the browser's editing | |
| * machinery rather than dispatching a CodeMirror transaction. | |
| */ | |
| const contentDOM = editorView!.contentDOM; | |
| contentDOM.focus(); | |
| document.execCommand('insertText', false, 'hello'); | |
| const event = await contentPromise; | |
| assert.equal(event.detail.value, 'hello'); | |
| assert.equal(editorView!.state.sliceDoc(), 'hello'); | |
| }); | |
| test('undo restores CRLF line ending after replacing document with LF paste', async () => { | |
| const editorElement = await fixture<CodeMirrorElement>( | |
| html` | |
| <codemirror-element .fileContent=${'a\r\nb\r\n'}></codemirror-element> | |
| `, | |
| ); | |
| await editorElement.updateComplete; | |
| const editorView = await waitForEditorInstance(editorElement); | |
| assert.isOk(editorView); | |
| assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\r\n'); | |
| /* | |
| * Select the entire document so the paste replaces it. | |
| */ | |
| editorView!.dispatch({ | |
| selection: { | |
| anchor: 0, | |
| head: editorView!.state.doc.length, | |
| }, | |
| }); | |
| const contentPromise = waitForContentChangeEvent(editorElement); | |
| dispatchPaste(editorElement, 'x\ny\n'); | |
| await contentPromise; | |
| /* | |
| * Replacing the entire document with LF text changes the | |
| * active line ending to LF. | |
| */ | |
| let value = editorView!.state.sliceDoc(); | |
| assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\n'); | |
| assert.equal(value, 'x\ny\n'); | |
| /* | |
| * Undo must restore both the document and the CRLF | |
| * line-ending configuration. | |
| */ | |
| undo(editorView!); | |
| value = editorView!.state.sliceDoc(); | |
| assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\r\n'); | |
| assert.equal(value, 'a\r\nb\r\n'); | |
| }); | |
| test('redo restores LF line ending after undoing full-document LF paste', async () => { | |
| const editorElement = await fixture<CodeMirrorElement>( | |
| html` | |
| <codemirror-element .fileContent=${'a\r\nb\r\n'}></codemirror-element> | |
| `, | |
| ); | |
| await editorElement.updateComplete; | |
| const editorView = await waitForEditorInstance(editorElement); | |
| assert.isOk(editorView); | |
| assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\r\n'); | |
| /* | |
| * Select the entire document so the paste replaces it. | |
| */ | |
| editorView!.dispatch({ | |
| selection: { | |
| anchor: 0, | |
| head: editorView!.state.doc.length, | |
| }, | |
| }); | |
| const contentPromise = waitForContentChangeEvent(editorElement); | |
| dispatchPaste(editorElement, 'x\ny\n'); | |
| await contentPromise; | |
| /* | |
| * The full-document replacement changes CRLF to LF. | |
| */ | |
| let value = editorView!.state.sliceDoc(); | |
| assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\n'); | |
| assert.equal(value, 'x\ny\n'); | |
| /* | |
| * Undo restores the original CRLF state. | |
| */ | |
| undo(editorView!); | |
| value = editorView!.state.sliceDoc(); | |
| assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\r\n'); | |
| assert.equal(value, 'a\r\nb\r\n'); | |
| /* | |
| * Redo restores the LF state. | |
| */ | |
| redo(editorView!); | |
| value = editorView!.state.sliceDoc(); | |
| assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\n'); | |
| assert.equal(value, 'x\ny\n'); | |
| }); | |
| test('copy preserves CRLF line endings', () => { | |
| const editorView = element.editor; | |
| assert.isOk(editorView); | |
| editorView!.dispatch({ | |
| changes: { | |
| from: 0, | |
| to: editorView!.state.doc.length, | |
| insert: 'a\r\nb\r\n', | |
| }, | |
| }); | |
| editorView!.dispatch({ | |
| selection: { | |
| anchor: 0, | |
| head: editorView!.state.doc.length, | |
| }, | |
| }); | |
| const copyEvent = new ClipboardEvent('copy', { | |
| bubbles: true, | |
| cancelable: true, | |
| clipboardData: new DataTransfer(), | |
| }); | |
| editorView!.contentDOM.dispatchEvent(copyEvent); | |
| const copiedText = copyEvent.clipboardData!.getData('text/plain'); | |
| assert.include(copiedText, '\r\n'); | |
| assert.notEqual(copiedText, 'a\nb\n'); | |
| }); | |
| test('copy preserves LF line endings', () => { | |
| const editorView = element.editor; | |
| assert.isOk(editorView); | |
| editorView!.dispatch({ | |
| changes: { | |
| from: 0, | |
| to: editorView!.state.doc.length, | |
| insert: 'a\nb\n', | |
| }, | |
| }); | |
| editorView!.dispatch({ | |
| selection: { | |
| anchor: 0, | |
| head: editorView!.state.doc.length, | |
| }, | |
| }); | |
| const copyEvent = new ClipboardEvent('copy', { | |
| bubbles: true, | |
| cancelable: true, | |
| clipboardData: new DataTransfer(), | |
| }); | |
| editorView!.contentDOM.dispatchEvent(copyEvent); | |
| const copiedText = copyEvent.clipboardData!.getData('text/plain'); | |
| assert.include(copiedText, '\n'); | |
| assert.notInclude(copiedText, '\r\n'); | |
| }); | |
| test('converts mixed line endings to CRLF', async () => { | |
| const mixedContent = 'first line\r\nsecond line\nthird line\r\n'; | |
| const editorElement = await fixture<CodeMirrorElement>( | |
| html` | |
| <codemirror-element .fileContent=${mixedContent}></codemirror-element> | |
| `, | |
| ); | |
| await editorElement.updateComplete; | |
| const editorView = await waitForEditorInstance(editorElement); | |
| assert.isOk(editorView); | |
| /* | |
| * Mixed line endings should be normalized to CRLF when the | |
| * editor is initialized. | |
| */ | |
| const value = editorView!.state.sliceDoc(); | |
| assert.equal(value, 'first line\r\nsecond line\r\nthird line\r\n'); | |
| /* | |
| * Verify that there are no bare LF line endings. | |
| */ | |
| assert.notInclude(value.replace(/\r\n/g, ''), '\n'); | |
| /* | |
| * The active line separator should also be CRLF. | |
| */ | |
| assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\r\n'); | |
| }); | |
| }); |