blob: 52e1f535f2c9524f50c2b5b826028109b16534e6 [file]
/**
* @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');
});
});