import { EditorState, EditorView, buildMessageSchema, MessageMarkdownTransformer, } from '@chatwoot/prosemirror-schema'; import { FORMATTING } from 'dashboard/constants/editor'; import { Schema } from 'prosemirror-model'; import { appendSignature, calculateMenuPosition, cleanSignature, collapseSelection, createVariableInputRule, extractTextFromMarkdown, findNodeToInsertImage, findSignatureInBody, getAgentVariables, getContactVariables, getContentNode, getFormattingForEditor, getMenuAnchor, getSelectionCoords, insertAtCursor, removeSignature, replaceSignature, stripInlineBase64Images, stripUnsupportedFormatting, stripUnsupportedMarkdown, } from '../editorHelper'; // 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![](http://localhost:3000/rails/active_storage/blobs/redirect/some-hash/image.png)', }, '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('stripUnsupportedMarkdown', () => { const richSignature = '**Bold** _italic_ [link](http://example.com) ![](http://localhost:3000/image.png)'; it('keeps all formatting for Email channel (supports image, link, strong, em)', () => { const result = stripUnsupportedMarkdown(richSignature, 'Channel::Email'); expect(result).toContain('**Bold**'); expect(result).toContain('_italic_'); expect(result).toContain('[link](http://example.com)'); expect(result).toContain('![](http://localhost:3000/image.png)'); }); it('strips images but keeps bold/italic for Api channel', () => { const result = stripUnsupportedMarkdown(richSignature, 'Channel::Api'); expect(result).toContain('**Bold**'); expect(result).toContain('_italic_'); expect(result).toContain('link'); // link text kept expect(result).not.toContain('[link]('); // link syntax removed expect(result).not.toContain('![]('); // image removed }); it('strips images but keeps bold/italic/link for Telegram channel', () => { const result = stripUnsupportedMarkdown(richSignature, 'Channel::Telegram'); expect(result).toContain('**Bold**'); expect(result).toContain('_italic_'); expect(result).toContain('[link](http://example.com)'); expect(result).not.toContain('![]('); }); it('strips all formatting for SMS channel', () => { const result = stripUnsupportedMarkdown(richSignature, 'Channel::Sms'); expect(result).toContain('Bold'); expect(result).toContain('italic'); expect(result).toContain('link'); expect(result).not.toContain('**'); expect(result).not.toContain('_'); expect(result).not.toContain('['); expect(result).not.toContain('![]('); }); it('returns empty string for empty input', () => { expect(stripUnsupportedMarkdown('', 'Channel::Api')).toBe(''); expect(stripUnsupportedMarkdown(null, 'Channel::Api')).toBe(''); }); describe('with cleanWhitespace parameter', () => { const textWithWhitespace = '**Bold** text\n\nWith multiple\n\nLine breaks\n\n And spaces '; it('cleans whitespace when cleanWhitespace=true (default)', () => { const result = stripUnsupportedMarkdown( textWithWhitespace, 'Channel::Api', true ); expect(result).toBe( '**Bold** text\nWith multiple\nLine breaks\nAnd spaces' ); expect(result).not.toContain('\n\n'); expect(result).not.toContain(' '); }); it('preserves whitespace when cleanWhitespace=false', () => { const result = stripUnsupportedMarkdown( textWithWhitespace, 'Channel::Api', false ); expect(result).toContain('\n\n'); expect(result).toContain(' And spaces '); expect(result).toBe( '**Bold** text\n\nWith multiple\n\nLine breaks\n\n And spaces ' ); }); it('strips formatting but preserves whitespace for messages', () => { const messageWithFormatting = '**Bold**\n\n`code`\n\nNormal text'; const result = stripUnsupportedMarkdown( messageWithFormatting, 'Channel::Sms', false ); expect(result).toBe('Bold\n\ncode\n\nNormal text'); expect(result).toContain('\n\n'); expect(result).not.toContain('**'); expect(result).not.toContain('`'); }); }); }); describe('appendSignature with channelType', () => { const signatureWithImage = 'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)'; it('keeps images for Email channel', () => { const result = appendSignature( 'Hello', signatureWithImage, 'Channel::Email' ); expect(result).toContain('![](http://localhost:3000/image.png'); }); it('keeps images for WebWidget channel', () => { const result = appendSignature( 'Hello', signatureWithImage, 'Channel::WebWidget' ); expect(result).toContain('![](http://localhost:3000/image.png'); }); it('strips images but keeps text for Api channel', () => { const result = appendSignature('Hello', signatureWithImage, 'Channel::Api'); expect(result).not.toContain('![]('); expect(result).toContain('Thanks'); }); it('strips images but keeps text for WhatsApp channel', () => { const result = appendSignature( 'Hello', signatureWithImage, 'Channel::Whatsapp' ); expect(result).not.toContain('![]('); expect(result).toContain('Thanks'); }); it('keeps images when channelType is not provided', () => { const result = appendSignature('Hello', signatureWithImage); expect(result).toContain('![](http://localhost:3000/image.png'); }); it('keeps bold/italic for channels that support them', () => { const boldSignature = '**Bold** *italic* Thanks'; const result = appendSignature('Hello', boldSignature, 'Channel::Api'); // Api supports strong and em expect(result).toContain('**Bold**'); expect(result).toContain('*italic*'); }); }); 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' ); }); it('strips blank-paragraph marker before the delimiter', () => { expect(removeSignature('hey\n\n\\\n--\n\nHello there', 'Hello there')).toBe( 'hey' ); }); it('strips multiple consecutive blank-paragraph markers before the delimiter', () => { expect( removeSignature('wewe\n\n\\\n\\\n\\\n--\n\nHello there', 'Hello there') ).toBe('wewe'); }); it('strips dangling hardbreak when signature shared a paragraph with "--"', () => { expect(removeSignature('hey\n\n--\\\nHello there', 'Hello there')).toBe( 'hey\n\n' ); }); it('preserves trailing backslash in user text when appending', () => { expect(appendSignature('The path is C:\\', 'Best\nAgent')).toContain( 'C:\\' ); expect(appendSignature('C:\\\n', 'Best\nAgent')).toContain('C:\\'); expect(appendSignature('C:\\\n\n', 'Best\nAgent')).toContain('C:\\'); }); it('preserves trailing backslash in user text when removing', () => { expect(removeSignature('C:\\\n--\n\nBest\nAgent', 'Best\nAgent')).toContain( 'C:\\' ); expect(removeSignature('C:\\\n--', 'no matching sig')).toContain('C:\\'); expect(removeSignature('C:\\\nBest\\\nAgent', 'Best\nAgent')).toContain( 'C:\\' ); expect(removeSignature('notes\n\\\n--', 'no matching sig')).toContain('\\'); }); }); describe('removeSignature with stripped signature', () => { const signatureWithImage = 'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)'; it('removes stripped signature from body', () => { // Simulate a body where signature was added with images stripped const bodyWithStrippedSignature = 'Hello\n\n--\n\nThanks'; const result = removeSignature( bodyWithStrippedSignature, signatureWithImage ); expect(result).toBe('Hello\n\n'); }); it('removes original signature from body', () => { // Simulate a body where signature was added with images (using cleanSignature format) const cleanedSig = cleanSignature(signatureWithImage); const bodyWithOriginalSignature = `Hello\n\n--\n\n${cleanedSig}`; const result = removeSignature( bodyWithOriginalSignature, signatureWithImage ); expect(result).toBe('Hello\n\n'); }); it('handles signature without images', () => { const simpleSignature = 'Best regards'; const body = 'Hello\n\n--\n\nBest regards'; const result = removeSignature(body, simpleSignature); expect(result).toBe('Hello\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: ![alt text](https://example.com/image.png) - 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('stripInlineBase64Images', () => { it('removes markdown data:image base64 images and sets hasInlineImages', () => { const content = 'Hello\n![x](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE)\nWorld'; const { sanitizedContent, hasInlineImages } = stripInlineBase64Images(content); expect(hasInlineImages).toBe(true); expect(sanitizedContent).not.toContain('data:image/png;base64'); expect(sanitizedContent).toContain('Hello'); expect(sanitizedContent).toContain('World'); }); it('leaves hosted image markdown unchanged', () => { const content = '![](https://example.com/logo.png)'; const { sanitizedContent, hasInlineImages } = stripInlineBase64Images(content); expect(hasInlineImages).toBe(false); expect(sanitizedContent).toBe(content); }); it('returns empty hasInlineImages for empty input', () => { expect(stripInlineBase64Images('')).toEqual({ sanitizedContent: '', hasInlineImages: false, }); }); }); describe('collapseSelection', () => { it('collapses a text range to a cursor at its head', () => { const editorView = new EditorView(document.body, { state: createEditorState('Hello world'), }); // Build a TextSelection via the initial selection's constructor (avoids // importing prosemirror-state, which isn't a direct dep). const { doc, selection } = editorView.state; editorView.dispatch( editorView.state.tr.setSelection(selection.constructor.create(doc, 1, 6)) ); expect(editorView.state.selection.empty).toBe(false); collapseSelection(editorView); expect(editorView.state.selection.empty).toBe(true); expect(editorView.state.selection.head).toBe(6); }); it('leaves an already-collapsed selection as a cursor', () => { const editorView = new EditorView(document.body, { state: createEditorState('Hi'), }); collapseSelection(editorView); expect(editorView.state.selection.empty).toBe(true); }); }); describe('insertAtCursor', () => { it('should return undefined if editorView is not provided', () => { const result = insertAtCursor(undefined, schema.text('Hello'), 0); expect(result).toBeUndefined(); }); it('should insert text node at cursor position', () => { const editorState = createEditorState(); const editorView = new EditorView(document.body, { state: editorState }); insertAtCursor(editorView, schema.text('Hello'), 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('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, }); }); }); }); describe('getFormattingForEditor', () => { describe('context-specific formatting', () => { it('returns default formatting for Context::Default', () => { const result = getFormattingForEditor('Context::Default'); expect(result).toEqual(FORMATTING['Context::Default']); }); it('returns signature formatting for Context::MessageSignature', () => { const result = getFormattingForEditor('Context::MessageSignature'); expect(result).toEqual(FORMATTING['Context::MessageSignature']); }); it('returns widget builder formatting for Context::InboxSettings', () => { const result = getFormattingForEditor('Context::InboxSettings'); expect(result).toEqual(FORMATTING['Context::InboxSettings']); }); }); describe('fallback behavior', () => { it('returns default formatting for unknown channel type', () => { const result = getFormattingForEditor('Channel::Unknown'); expect(result).toEqual(FORMATTING['Context::Default']); }); it('returns default formatting for null channel type', () => { const result = getFormattingForEditor(null); expect(result).toEqual(FORMATTING['Context::Default']); }); it('returns default formatting for undefined channel type', () => { const result = getFormattingForEditor(undefined); expect(result).toEqual(FORMATTING['Context::Default']); }); it('returns default formatting for empty string', () => { const result = getFormattingForEditor(''); expect(result).toEqual(FORMATTING['Context::Default']); }); }); describe('return value structure', () => { it('always returns an object with marks, nodes, and menu properties', () => { const result = getFormattingForEditor('Channel::Email'); expect(result).toHaveProperty('marks'); expect(result).toHaveProperty('nodes'); expect(result).toHaveProperty('menu'); expect(Array.isArray(result.marks)).toBe(true); expect(Array.isArray(result.nodes)).toBe(true); expect(Array.isArray(result.menu)).toBe(true); }); }); }); describe('stripUnsupportedFormatting', () => { describe('when schema supports all formatting', () => { const fullSchema = { marks: { strong: {}, em: {}, code: {}, strike: {}, link: {} }, nodes: { bulletList: {}, orderedList: {}, codeBlock: {}, blockquote: {} }, }; it('preserves all formatting when schema supports it', () => { const content = '**bold** and *italic* and `code`'; expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content); }); it('preserves links when schema supports them', () => { const content = 'Check [this link](https://example.com)'; expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content); }); it('preserves autolinks when schema supports links', () => { const content = 'Check out '; expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content); }); it('preserves various URI scheme autolinks', () => { const content = 'Email or call '; expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content); }); it('preserves email autolinks', () => { const content = 'Contact us at '; expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content); }); it('preserves lists when schema supports them', () => { const content = '- item 1\n- item 2\n1. first\n2. second'; expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content); }); }); describe('when schema has no formatting support (eg:SMS channel)', () => { const emptySchema = { marks: {}, nodes: {}, }; it('strips bold formatting', () => { expect(stripUnsupportedFormatting('**bold text**', emptySchema)).toBe( 'bold text' ); expect(stripUnsupportedFormatting('__bold text__', emptySchema)).toBe( 'bold text' ); }); it('strips italic formatting', () => { expect(stripUnsupportedFormatting('*italic text*', emptySchema)).toBe( 'italic text' ); expect(stripUnsupportedFormatting('_italic text_', emptySchema)).toBe( 'italic text' ); }); it('preserves underscores in URLs and mid-word positions', () => { // Underscores in URLs should not be stripped as italic formatting expect( stripUnsupportedFormatting( 'https://www.chatwoot.com/new_first_second-third/ssd', emptySchema ) ).toBe('https://www.chatwoot.com/new_first_second-third/ssd'); // Underscores in variable names should not be stripped expect( stripUnsupportedFormatting('some_variable_name', emptySchema) ).toBe('some_variable_name'); // But actual italic formatting with spaces should still be stripped expect( stripUnsupportedFormatting('hello _world_ there', emptySchema) ).toBe('hello world there'); }); it('strips inline code formatting', () => { expect(stripUnsupportedFormatting('`inline code`', emptySchema)).toBe( 'inline code' ); }); it('strips strikethrough formatting', () => { expect(stripUnsupportedFormatting('~~strikethrough~~', emptySchema)).toBe( 'strikethrough' ); }); it('keeps link text and URL when schema does not support links', () => { expect( stripUnsupportedFormatting( 'Check [this link](https://example.com)', emptySchema ) ).toBe('Check this link: https://example.com'); }); it('drops the hidden link title when preserving the URL', () => { expect( stripUnsupportedFormatting( 'Check [docs](https://example.com "Docs")', emptySchema ) ).toBe('Check docs: https://example.com'); expect( stripUnsupportedFormatting( 'Check [docs]( "Docs")', emptySchema ) ).toBe('Check docs: https://example.com'); }); // Output is re-parsed before sending, so assert the final text // (strip + re-parse); the re-parse turns serializer escapes into literals. describe('links round-trip through re-parse without crashing', () => { const smsSchema = buildMessageSchema([], []); // no marks, no nodes const sendAs = md => new MessageMarkdownTransformer(smsSchema).parse( stripUnsupportedFormatting(md, smsSchema) ).textContent; it('keeps escaped parens/underscores anywhere in the URL', () => { expect( sendAs('See [wiki](https://en.wikipedia.org/wiki/Foo\\_\\(bar\\))') ).toBe('See wiki: https://en.wikipedia.org/wiki/Foo_(bar)'); expect(sendAs('See [wiki](https://host/a\\_\\(b\\)c)')).toBe( 'See wiki: https://host/a_(b)c' ); }); it('drops the label when it equals the URL even when escaped', () => { expect( sendAs( '[www.example.com/Foo\\_\\(bar\\)](www.example.com/Foo\\_\\(bar\\))' ) ).toBe('www.example.com/Foo_(bar)'); }); it('does not reintroduce emphasis from an escaped label', () => { expect(sendAs('[Use \\_id\\_](https://example.com)')).toBe( 'Use _id_: https://example.com' ); }); it('flattens a label containing an escaped closing bracket', () => { expect(sendAs('[FAQ \\[v2\\]](https://example.com)')).toBe( 'FAQ [v2]: https://example.com' ); }); }); it('leaves bare URLs untouched so channels can auto-link them', () => { expect( stripUnsupportedFormatting('Visit www.example.com now', emptySchema) ).toBe('Visit www.example.com now'); expect( stripUnsupportedFormatting('Visit ', emptySchema) ).toBe('Visit https://example.com'); }); it('converts autolinks to plain URLs when schema does not support links', () => { const content = 'Visit for more info'; const expected = 'Visit https://cegrafic.com/catalogo/ for more info'; expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected); }); it('handles multiple autolinks in content', () => { const content = 'Check and '; const expected = 'Check https://example.com and https://test.com'; expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected); }); it('converts URI scheme autolinks to plain text', () => { const content = 'Email or call '; const expected = 'Email mailto:support@example.com or call tel:+1234567890'; expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected); }); it('converts email autolinks to plain text', () => { const content = 'Reach us at for help'; const expected = 'Reach us at admin@chatwoot.com for help'; expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected); }); it('handles mixed autolink types', () => { const content = 'Visit or email '; const expected = 'Visit https://example.com or email info@example.com'; expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected); }); it('strips bullet list markers', () => { expect( stripUnsupportedFormatting('- item 1\n- item 2', emptySchema) ).toBe('item 1\nitem 2'); expect( stripUnsupportedFormatting('* item 1\n* item 2', emptySchema) ).toBe('item 1\nitem 2'); }); it('strips ordered list markers', () => { expect( stripUnsupportedFormatting('1. first\n2. second', emptySchema) ).toBe('first\nsecond'); }); it('strips code block markers', () => { expect( stripUnsupportedFormatting('```javascript\ncode here\n```', emptySchema) ).toBe('code here\n'); }); it('strips blockquote markers', () => { expect(stripUnsupportedFormatting('> quoted text', emptySchema)).toBe( 'quoted text' ); }); it('handles complex content with multiple formatting types', () => { const content = '**Bold** and *italic* with `code` and [link](url)\n- list item'; const expected = 'Bold and italic with code and link: url\nlist item'; expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected); }); }); describe('when schema has partial support', () => { const partialSchema = { marks: { strong: {}, em: {} }, nodes: {}, }; it('preserves supported marks and strips unsupported ones', () => { const content = '**bold** and `code`'; expect(stripUnsupportedFormatting(content, partialSchema)).toBe( '**bold** and code' ); }); it('strips unsupported nodes but keeps supported marks', () => { const content = '**bold** text\n- list item'; expect(stripUnsupportedFormatting(content, partialSchema)).toBe( '**bold** text\nlist item' ); }); }); describe('edge cases', () => { it('returns content unchanged if content is empty', () => { expect(stripUnsupportedFormatting('', {})).toBe(''); }); it('returns content unchanged if content is null', () => { expect(stripUnsupportedFormatting(null, {})).toBe(null); }); it('returns content unchanged if content is undefined', () => { expect(stripUnsupportedFormatting(undefined, {})).toBe(undefined); }); it('returns content unchanged if schema is null', () => { expect(stripUnsupportedFormatting('**bold**', null)).toBe('**bold**'); }); it('handles nested formatting correctly', () => { const emptySchema = { marks: {}, nodes: {} }; // After stripping bold (**), the remaining *and italic* becomes italic and is stripped too expect( stripUnsupportedFormatting('**bold *and italic***', emptySchema) ).toBe('bold and italic'); }); }); }); describe('Menu positioning helpers', () => { const mockEditorView = { coordsAtPos: vi.fn((pos, bias) => { // Return different coords based on position if (bias === 1) return { top: 100, bottom: 120, left: 50, right: 100 }; return { top: 100, bottom: 120, left: 150, right: 200 }; }), }; const wrapperRect = { top: 50, bottom: 300, left: 0, right: 400, width: 400 }; describe('getSelectionCoords', () => { it('returns selection coordinates with onTop flag', () => { const selection = { from: 0, to: 10 }; const result = getSelectionCoords(mockEditorView, selection, wrapperRect); expect(result).toHaveProperty('start'); expect(result).toHaveProperty('end'); expect(result).toHaveProperty('selTop'); expect(result).toHaveProperty('onTop'); }); }); describe('getMenuAnchor', () => { it('returns end.left when menu is below selection', () => { const coords = { start: { left: 50 }, end: { left: 150 }, onTop: false }; expect(getMenuAnchor(coords, wrapperRect, false)).toBe(150); }); it('returns start.left for LTR when menu is above and visible', () => { const coords = { start: { top: 100, left: 50 }, end: {}, onTop: true }; expect(getMenuAnchor(coords, wrapperRect, false)).toBe(50); }); it('returns start.right for RTL when menu is above and visible', () => { const coords = { start: { top: 100, right: 100 }, end: {}, onTop: true }; expect(getMenuAnchor(coords, wrapperRect, true)).toBe(100); }); }); describe('calculateMenuPosition', () => { it('returns bounded left and top positions', () => { const coords = { start: { top: 100, bottom: 120, left: 50 }, end: { top: 100, bottom: 120, left: 150 }, selTop: 100, onTop: false, }; const result = calculateMenuPosition(coords, wrapperRect, false); expect(result).toHaveProperty('left'); expect(result).toHaveProperty('top'); expect(result).toHaveProperty('width', 300); expect(result.left).toBeGreaterThanOrEqual(0); }); }); }); describe('getAgentVariables', () => { it('builds agent variables from the user', () => { expect( getAgentVariables({ name: 'John Doe', email: 'john@example.com' }) ).toEqual({ 'agent.name': 'John Doe', 'agent.first_name': 'John', 'agent.last_name': 'Doe', 'agent.email': 'john@example.com', }); }); it('normalizes casing like the backend UserDrop (Ruby capitalize)', () => { const variables = getAgentVariables({ name: 'JANE doE' }); expect(variables['agent.name']).toBe('Jane Doe'); expect(variables['agent.first_name']).toBe('Jane'); expect(variables['agent.last_name']).toBe('Doe'); }); it('ignores extra whitespace between words', () => { expect(getAgentVariables({ name: ' john doe ' })['agent.name']).toBe( 'John Doe' ); }); it('leaves last_name empty for single-word names', () => { const variables = getAgentVariables({ name: 'john' }); expect(variables['agent.first_name']).toBe('John'); expect(variables['agent.last_name']).toBe(''); }); it('handles a missing name', () => { const variables = getAgentVariables({ email: 'john@example.com' }); expect(variables['agent.name']).toBe(''); expect(variables['agent.first_name']).toBe(''); expect(variables['agent.last_name']).toBe(''); }); }); describe('getContactVariables', () => { it('normalizes casing like the backend ContactDrop (Ruby capitalize)', () => { expect(getContactVariables({ name: 'JANE doE' })).toEqual({ 'contact.name': 'Jane Doe', 'contact.first_name': 'Jane', 'contact.last_name': 'Doe', }); }); it('leaves last_name empty for single-word names', () => { const variables = getContactVariables({ name: 'john' }); expect(variables['contact.first_name']).toBe('John'); expect(variables['contact.last_name']).toBe(''); }); it('handles a missing contact', () => { expect(getContactVariables(undefined)['contact.name']).toBe(''); }); }); describe('createVariableInputRule', () => { // Editor holding `{{key}` so we can simulate typing the final `}`. const buildView = (typed, { isPrivate = false, variables = {} } = {}) => { const plugin = createVariableInputRule({ isPrivate: () => isPrivate, getVariables: () => variables, }); const state = EditorState.create({ schema, doc: schema.node('doc', null, [ schema.node('paragraph', null, [schema.text(typed)]), ]), plugins: [plugin], }); return new EditorView(document.body, { state }); }; // Types the closing `}`; when the rule declines, insert it like the browser would. const typeClosingBrace = view => { const end = view.state.doc.content.size - 1; const handled = view.someProp('handleTextInput', fn => fn(view, end, end, '}') ); if (!handled) { view.dispatch(view.state.tr.insertText('}', end, end)); } }; it('resolves a manually typed {{variable}} to its value on the closing brace', () => { const view = buildView('{{contact.name}', { variables: { 'contact.name': 'John' }, }); typeClosingBrace(view); expect(view.state.doc.textContent).toBe('John'); view.destroy(); }); it('resolves boolean/non-string values', () => { const view = buildView('{{contact.custom_attribute.cloudCustomer}', { variables: { 'contact.custom_attribute.cloudCustomer': true }, }); typeClosingBrace(view); expect(view.state.doc.textContent).toBe('true'); view.destroy(); }); it('keeps the placeholder when the variable has no value', () => { const view = buildView('{{contact.email}', { variables: {} }); typeClosingBrace(view); expect(view.state.doc.textContent).toBe('{{contact.email}}'); view.destroy(); }); it('keeps the placeholder when the value itself contains Liquid syntax', () => { const view = buildView('{{contact.name}', { variables: { 'contact.name': '{{agent.email}}' }, }); typeClosingBrace(view); expect(view.state.doc.textContent).toBe('{{contact.name}}'); view.destroy(); }); it('does not resolve inside a private note', () => { const view = buildView('{{contact.name}', { isPrivate: true, variables: { 'contact.name': 'John' }, }); typeClosingBrace(view); expect(view.state.doc.textContent).toBe('{{contact.name}}'); view.destroy(); }); });