import { findSignatureInBody, appendSignature, removeSignature, replaceSignature, cleanSignature, extractTextFromMarkdown, insertAtCursor, findNodeToInsertImage, setURLWithQueryAndSize, getContentNode, } from '../editorHelper'; import { EditorState } from '@chatwoot/prosemirror-schema'; import { EditorView } from '@chatwoot/prosemirror-schema'; import { Schema } from 'prosemirror-model'; // Define a basic ProseMirror schema const schema = new Schema({ nodes: { doc: { content: 'paragraph+' }, paragraph: { content: 'inline*', group: 'block', toDOM: () => ['p', 0], // Represents a paragraph as a
tag in the DOM. }, text: { group: 'inline', toDOM: node => node.text, // Represents text as its actual string value. }, mention: { attrs: { userId: { default: '' }, userFullName: { default: '' }, mentionType: { default: 'user' }, }, inline: true, group: 'inline', toDOM: node => [ 'span', { class: 'mention' }, `@${node.attrs.userFullName}`, ], }, }, }); // Initialize a basic EditorState for testing const createEditorState = (content = '') => { if (!content) { return EditorState.create({ schema, doc: schema.node('doc', null, [schema.node('paragraph')]), }); } return EditorState.create({ schema, doc: schema.node('doc', null, [ schema.node('paragraph', null, [schema.text(content)]), ]), }); }; const NEW_SIGNATURE = 'This is a new signature'; const DOES_NOT_HAVE_SIGNATURE = { 'no signature': { body: 'This is a test', signature: 'This is a signature', }, 'text after signature': { body: 'This is a test\n\n--\n\nThis is a signature\n\nThis is more text', signature: 'This is a signature', }, 'signature has images': { body: 'This is a test', signature: 'Testing\n', }, 'signature has non commonmark syntax': { body: 'This is a test', signature: '- Signature', }, 'signature has trailing spaces': { body: 'This is a test', signature: '**hello** \n**world**', }, }; const HAS_SIGNATURE = { 'signature at end': { body: 'This is a test\n\n--\n\nThis is a signature', signature: 'This is a signature', }, 'signature at end with spaces and new lines': { body: 'This is a test\n\n--\n\nThis is a signature \n\n', signature: 'This is a signature ', }, 'no text before signature': { body: '\n\n--\n\nThis is a signature', signature: 'This is a signature', }, 'signature has non-commonmark syntax': { body: '\n\n--\n\n* Signature', signature: '- Signature', }, }; describe('findSignatureInBody', () => { it('returns -1 if there is no signature', () => { Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => { const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key]; expect(findSignatureInBody(body, signature)).toBe(-1); }); }); it('returns the index of the signature if there is one', () => { Object.keys(HAS_SIGNATURE).forEach(key => { const { body, signature } = HAS_SIGNATURE[key]; expect(findSignatureInBody(body, signature)).toBeGreaterThan(0); }); }); }); describe('appendSignature', () => { it('appends the signature if it is not present', () => { Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => { const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key]; const cleanedSignature = cleanSignature(signature); expect( appendSignature(body, signature).includes(cleanedSignature) ).toBeTruthy(); }); }); it('does not append signature if already present', () => { Object.keys(HAS_SIGNATURE).forEach(key => { const { body, signature } = HAS_SIGNATURE[key]; expect(appendSignature(body, signature)).toBe(body); }); }); }); describe('cleanSignature', () => { it('removes any instance of horizontal rule', () => { const options = [ '---', '***', '___', '- - -', '* * *', '_ _ _', ' ---', '--- ', ' --- ', '-----', '*****', '_____', '- - - -', '* * * * *', '_ _ _ _ _ _', ' - - - - ', ' * * * * * ', ' _ _ _ _ _ _', '- - - - -', '* * * * * *', '_ _ _ _ _ _ _', ]; options.forEach(option => { expect(cleanSignature(option)).toBe(''); }); }); }); describe('removeSignature', () => { it('does not remove signature if not present', () => { Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => { const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key]; expect(removeSignature(body, signature)).toBe(body); }); }); it('removes signature if present at the end', () => { const { body, signature } = HAS_SIGNATURE['signature at end']; expect(removeSignature(body, signature)).toBe('This is a test\n\n'); }); it('removes signature if present with spaces and new lines', () => { const { body, signature } = HAS_SIGNATURE['signature at end with spaces and new lines']; expect(removeSignature(body, signature)).toBe('This is a test\n\n'); }); it('removes signature if present without text before it', () => { const { body, signature } = HAS_SIGNATURE['no text before signature']; expect(removeSignature(body, signature)).toBe('\n\n'); }); it('removes just the delimiter if no signature is present', () => { expect(removeSignature('This is a test\n\n--', 'This is a signature')).toBe( 'This is a test\n\n' ); }); }); describe('replaceSignature', () => { it('appends the new signature if not present', () => { Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => { const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key]; expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe( `${body}\n\n--\n\n${NEW_SIGNATURE}` ); }); }); it('removes signature if present at the end', () => { const { body, signature } = HAS_SIGNATURE['signature at end']; expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe( `This is a test\n\n--\n\n${NEW_SIGNATURE}` ); }); it('removes signature if present with spaces and new lines', () => { const { body, signature } = HAS_SIGNATURE['signature at end with spaces and new lines']; expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe( `This is a test\n\n--\n\n${NEW_SIGNATURE}` ); }); it('removes signature if present without text before it', () => { const { body, signature } = HAS_SIGNATURE['no text before signature']; expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe( `\n\n--\n\n${NEW_SIGNATURE}` ); }); }); describe('extractTextFromMarkdown', () => { it('should extract text from markdown and remove all images, code blocks, links, headers, bold, italic, lists etc.', () => { const markdown = ` # Hello World This is a **bold** text with a [link](https://example.com). \`\`\`javascript const foo = 'bar'; console.log(foo); \`\`\` Here's an image:  - List item 1 - List item 2 *Italic text* `; const expected = "Hello World\nThis is a bold text with a link.\nHere's an image:\nList item 1\nList item 2\nItalic text"; expect(extractTextFromMarkdown(markdown)).toEqual(expected); }); }); describe('insertAtCursor', () => { it('should return undefined if editorView is not provided', () => { const result = insertAtCursor(undefined, schema.text('Hello'), 0); expect(result).toBeUndefined(); }); it('should unwrap doc nodes that are wrapped in a paragraph', () => { const docNode = schema.node('doc', null, [ schema.node('paragraph', null, [schema.text('Hello')]), ]); const editorState = createEditorState(); const editorView = new EditorView(document.body, { state: editorState }); insertAtCursor(editorView, docNode, 0); // Check if node was unwrapped and inserted correctly expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello'); }); it('should insert node without replacing any content if "to" is not provided', () => { const editorState = createEditorState(); const editorView = new EditorView(document.body, { state: editorState }); insertAtCursor(editorView, schema.text('Hello'), 0); // Check if node was inserted correctly expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello'); }); it('should replace content between "from" and "to" with the provided node', () => { const editorState = createEditorState('ReplaceMe'); const editorView = new EditorView(document.body, { state: editorState }); insertAtCursor(editorView, schema.text('Hello'), 0, 8); // Check if content was replaced correctly expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello Me'); }); }); describe('findNodeToInsertImage', () => { let mockEditorState; beforeEach(() => { mockEditorState = { selection: { $from: { node: vi.fn(() => ({})), }, from: 0, }, schema: { nodes: { image: { create: vi.fn(attrs => ({ type: { name: 'image' }, attrs })), }, paragraph: { create: vi.fn((_, node) => ({ type: { name: 'paragraph' }, content: [node], })), }, }, }, }; }); it('should insert image directly into an empty paragraph', () => { const mockNode = { type: { name: 'paragraph' }, content: { size: 0, content: [] }, }; mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode); const result = findNodeToInsertImage(mockEditorState, 'image-url'); expect(result).toEqual({ node: { type: { name: 'image' }, attrs: { src: 'image-url' } }, pos: 0, }); }); it('should insert image directly into a paragraph without an image but with other content', () => { const mockNode = { type: { name: 'paragraph' }, content: { size: 1, content: [ { type: { name: 'text' }, }, ], }, }; mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode); mockEditorState.selection.from = 1; const result = findNodeToInsertImage(mockEditorState, 'image-url'); expect(result).toEqual({ node: { type: { name: 'image' }, attrs: { src: 'image-url' } }, pos: 2, // Because it should insert after the text, on a new line. }); }); it("should wrap image in a new paragraph when the current node isn't a paragraph", () => { const mockNode = { type: { name: 'not-a-paragraph' }, content: { size: 0, content: [] }, }; mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode); const result = findNodeToInsertImage(mockEditorState, 'image-url'); expect(result.node.type.name).toBe('paragraph'); expect(result.node.content[0].type.name).toBe('image'); expect(result.node.content[0].attrs.src).toBe('image-url'); expect(result.pos).toBe(0); }); it('should insert a new image directly into the paragraph that already contains an image', () => { const mockNode = { type: { name: 'paragraph' }, content: { size: 1, content: [ { type: { name: 'image', attrs: { src: 'existing-image-url' } }, }, ], }, }; mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode); mockEditorState.selection.from = 1; const result = findNodeToInsertImage(mockEditorState, 'image-url'); expect(result.node.type.name).toBe('image'); expect(result.node.attrs.src).toBe('image-url'); expect(result.pos).toBe(1); }); }); describe('setURLWithQueryAndSize', () => { let selectedNode; let editorView; beforeEach(() => { selectedNode = { setAttribute: vi.fn(), }; const tr = { setNodeMarkup: vi.fn().mockReturnValue({ docChanged: true, }), }; const state = { selection: { from: 0 }, tr, }; editorView = { state, dispatch: vi.fn(), }; }); it('updates the URL with the given size and updates the editor view', () => { const size = { height: '20px' }; setURLWithQueryAndSize(selectedNode, size, editorView); // Check if the editor view is updated expect(editorView.dispatch).toHaveBeenCalledTimes(1); }); it('updates the URL with the given size and updates the editor view with original size', () => { const size = { height: 'auto' }; setURLWithQueryAndSize(selectedNode, size, editorView); // Check if the editor view is updated expect(editorView.dispatch).toHaveBeenCalledTimes(1); }); it('does not update the editor view if the document has not changed', () => { editorView.state.tr.setNodeMarkup = vi.fn().mockReturnValue({ docChanged: false, }); const size = { height: '20px' }; setURLWithQueryAndSize(selectedNode, size, editorView); // Check if the editor view dispatch was not called expect(editorView.dispatch).not.toHaveBeenCalled(); }); it('does not perform any operations if selectedNode is not provided', () => { setURLWithQueryAndSize(null, { height: '20px' }, editorView); // Ensure the dispatch method wasn't called expect(editorView.dispatch).not.toHaveBeenCalled(); }); }); describe('getContentNode', () => { let mockEditorView; beforeEach(() => { mockEditorView = { state: { schema: { nodes: { mention: { create: vi.fn(attrs => ({ type: { name: 'mention' }, attrs, })), }, }, text: vi.fn(content => ({ type: { name: 'text' }, text: content })), }, }, }; }); describe('mention node creation', () => { it('creates a user mention node with correct attributes', () => { const userContent = { id: '123', name: 'John Doe', type: 'user', }; const result = getContentNode(mockEditorView, 'mention', userContent, { from: 0, to: 5, }); expect( mockEditorView.state.schema.nodes.mention.create ).toHaveBeenCalledWith({ userId: '123', userFullName: 'John Doe', mentionType: 'user', }); expect(result).toEqual({ node: { type: { name: 'mention' }, attrs: { userId: '123', userFullName: 'John Doe', mentionType: 'user', }, }, from: 0, to: 5, }); }); it('creates a team mention node with correct attributes', () => { const teamContent = { id: '456', name: 'Support Team', type: 'team', }; const result = getContentNode(mockEditorView, 'mention', teamContent, { from: 0, to: 5, }); expect( mockEditorView.state.schema.nodes.mention.create ).toHaveBeenCalledWith({ userId: '456', userFullName: 'Support Team', mentionType: 'team', }); expect(result).toEqual({ node: { type: { name: 'mention' }, attrs: { userId: '456', userFullName: 'Support Team', mentionType: 'team', }, }, from: 0, to: 5, }); }); it('defaults to user mention type when type is not specified', () => { const contentWithoutType = { id: '789', name: 'Jane Smith', }; getContentNode(mockEditorView, 'mention', contentWithoutType, { from: 0, to: 5, }); expect( mockEditorView.state.schema.nodes.mention.create ).toHaveBeenCalledWith({ userId: '789', userFullName: 'Jane Smith', mentionType: 'user', }); }); it('uses displayName over name when both are provided', () => { const contentWithDisplayName = { id: '101', name: 'john_doe', displayName: 'John Doe (Admin)', type: 'user', }; getContentNode(mockEditorView, 'mention', contentWithDisplayName, { from: 0, to: 5, }); expect( mockEditorView.state.schema.nodes.mention.create ).toHaveBeenCalledWith({ userId: '101', userFullName: 'John Doe (Admin)', mentionType: 'user', }); }); it('handles missing displayName by falling back to name', () => { const contentWithoutDisplayName = { id: '102', name: 'jane_smith', type: 'user', }; getContentNode(mockEditorView, 'mention', contentWithoutDisplayName, { from: 0, to: 5, }); expect( mockEditorView.state.schema.nodes.mention.create ).toHaveBeenCalledWith({ userId: '102', userFullName: 'jane_smith', mentionType: 'user', }); }); }); describe('unsupported node types', () => { it('returns null node for unsupported type', () => { const result = getContentNode(mockEditorView, 'unsupported', 'content', { from: 0, to: 5, }); expect(result).toEqual({ node: null, from: 0, to: 5, }); }); }); });