snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
614
apps/meteor/client/components/MarkdownText.spec.tsx
Normal file
614
apps/meteor/client/components/MarkdownText.spec.tsx
Normal file
@@ -0,0 +1,614 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import dompurify from 'dompurify';
|
||||
|
||||
import MarkdownText, { supportedURISchemes } from './MarkdownText';
|
||||
import '@testing-library/jest-dom';
|
||||
import { emoji } from '../lib/emoji/lib';
|
||||
import { getMarkdownParserLimit } from '../lib/getMarkdownParserLimit';
|
||||
|
||||
jest.mock('../lib/getMarkdownParserLimit');
|
||||
|
||||
const getMarkdownParserLimitMock = jest.mocked(getMarkdownParserLimit);
|
||||
|
||||
const MOCKED_BASE_URI = 'http://localhost/';
|
||||
|
||||
// Mock getBaseURI from @rocket.chat/ui-client to ensure consistent behavior in tests
|
||||
// This will affect the isExternal function imported above.
|
||||
jest.mock('@rocket.chat/ui-client', () => ({
|
||||
...jest.requireActual('@rocket.chat/ui-client'), // Import and retain default behavior for other exports
|
||||
getBaseURI: jest.fn(() => MOCKED_BASE_URI), // Mock getBaseURI for consistent test behavior
|
||||
}));
|
||||
|
||||
// List of common URI schemes. This list was taken from https://en.wikipedia.org/wiki/List_of_URI_schemes
|
||||
const commonUriSchemes = [
|
||||
'file',
|
||||
'ftp',
|
||||
'http',
|
||||
'https',
|
||||
'mailto',
|
||||
'tel',
|
||||
'imap',
|
||||
'irc',
|
||||
'nntp',
|
||||
'acap',
|
||||
'icap',
|
||||
'mtqp',
|
||||
'wss',
|
||||
'admin',
|
||||
'app',
|
||||
'freeplane',
|
||||
'geo',
|
||||
'javascript',
|
||||
'jdbc',
|
||||
'msteams',
|
||||
'ms-access',
|
||||
'ms-excel',
|
||||
'ms-infopath',
|
||||
'ms-powerpoint',
|
||||
'ms-project',
|
||||
'ms-publisher',
|
||||
'ms-spd',
|
||||
'ms-visio',
|
||||
'ms-word',
|
||||
'odbc',
|
||||
'psns',
|
||||
'rdar',
|
||||
's3',
|
||||
'shortcuts',
|
||||
'slack',
|
||||
'stratum',
|
||||
'trueconf',
|
||||
'viber',
|
||||
'zoommtgzoomus',
|
||||
];
|
||||
|
||||
const nonSupportedUriSchemes = commonUriSchemes.filter((scheme) => !supportedURISchemes.includes(scheme));
|
||||
|
||||
const testUris = [
|
||||
'example.com',
|
||||
'example.com/path',
|
||||
'example.com/path/to/resource',
|
||||
'localhost/home',
|
||||
'localhost/path/to/',
|
||||
'localhost/path/to/resource',
|
||||
];
|
||||
|
||||
const getTestCases = (schemes: string[], uris: string[]): { scheme: string; links: string[] }[] => {
|
||||
return schemes.map((scheme) => {
|
||||
return {
|
||||
scheme,
|
||||
links: uris.map((uri) => `${scheme}://${uri}`),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const isExternal = (link: string): boolean => link.indexOf(MOCKED_BASE_URI) !== 0;
|
||||
|
||||
const normalizeHtml = (html: any) => {
|
||||
return html.replace(/\s+/g, ' ').trim();
|
||||
};
|
||||
|
||||
const markdownText = `
|
||||
# Heading 1
|
||||
**Paragraph text**: *Bold with one asterisk* **Bold with two asterisks** Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
## Heading 2
|
||||
_Italic Text_: _Italic with one underscore_ __Italic with two underscores__ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
### Heading 3
|
||||
Lists, Links and elements
|
||||
**Unordered List**
|
||||
- List Item 1
|
||||
- List Item 2
|
||||
- List Item 3
|
||||
- List Item 4
|
||||
**Ordered List**
|
||||
1. List Item 1
|
||||
2. List Item 2
|
||||
3. List Item 3
|
||||
4. List Item 4
|
||||
**Links:**
|
||||
[Rocket.Chat](rocket.chat)
|
||||
gabriel.engel@rocket.chat
|
||||
+55991999999
|
||||
\`Inline code\`
|
||||
\`\`\`typescript
|
||||
const test = 'this is code'
|
||||
\`\`\`
|
||||
**Bold text within __Italics__**
|
||||
*Bold text with single asterik and underscore within _Italics_*
|
||||
__Italics within **Bold** text__
|
||||
_Italics within *Bold* text with single underscore and asterik_
|
||||
`;
|
||||
|
||||
it('should render html elements as expected using default parser', async () => {
|
||||
const { container } = render(<MarkdownText content={markdownText} variant='document' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const normalizedHtml = normalizeHtml(container.innerHTML);
|
||||
|
||||
expect(normalizedHtml).toContain('<h1>Heading 1</h1>');
|
||||
expect(normalizedHtml).toContain(
|
||||
'<strong>Paragraph text</strong>: <strong>Bold with one asterisk</strong> <strong>Bold with two asterisks</strong> Lorem ipsum dolor sit amet',
|
||||
);
|
||||
expect(normalizedHtml).toContain('<h2>Heading 2</h2>');
|
||||
expect(normalizedHtml).toContain(
|
||||
'<em>Italic Text</em>: <em>Italic with one underscore</em> <em>Italic with two underscores</em> Lorem ipsum dolor sit amet',
|
||||
);
|
||||
expect(normalizedHtml).toContain('<h3>Heading 3</h3>');
|
||||
expect(normalizedHtml).toContain('<ul> <li>List Item 1</li><li>List Item 2</li><li>List Item 3</li><li>List Item 4');
|
||||
expect(normalizedHtml).toContain('<ol> <li>List Item 1</li><li>List Item 2</li><li>List Item 3</li><li>List Item 4');
|
||||
|
||||
expect(normalizedHtml).toContain('<a');
|
||||
expect(normalizedHtml).toContain('title=""');
|
||||
expect(normalizedHtml).toContain('rel="nofollow noopener noreferrer"');
|
||||
expect(normalizedHtml).toContain('target="_blank"');
|
||||
expect(normalizedHtml).toContain('>Rocket.Chat</a>');
|
||||
|
||||
expect(normalizedHtml).toContain('href="mailto:gabriel.engel@rocket.chat"');
|
||||
expect(normalizedHtml).toContain('title="mailto:gabriel.engel@rocket.chat"');
|
||||
expect(normalizedHtml).toContain('gabriel.engel@rocket.chat');
|
||||
|
||||
expect(normalizedHtml).toContain('+55991999999');
|
||||
expect(normalizedHtml).toContain('<code>Inline code</code>');
|
||||
expect(normalizedHtml).toContain('<pre><code class="language-typescript">const test = \'this is code\' </code></pre>');
|
||||
expect(normalizedHtml).toContain('<strong>Bold text within <em>Italics</em></strong>');
|
||||
expect(normalizedHtml).toContain('<strong>Bold text with single asterik and underscore within <em>Italics</em></strong>');
|
||||
expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text</em>');
|
||||
expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text with single underscore and asterik</em>');
|
||||
});
|
||||
|
||||
it('should render html elements as expected using inline parser', async () => {
|
||||
const { container } = render(<MarkdownText content={markdownText} variant='inline' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const normalizedHtml = normalizeHtml(container.innerHTML);
|
||||
|
||||
expect(normalizedHtml).toContain('# Heading 1');
|
||||
expect(normalizedHtml).toContain(
|
||||
'<strong>Bold with one asterisk</strong> <strong>Bold with two asterisks</strong> Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
|
||||
);
|
||||
expect(normalizedHtml).toContain('## Heading 2');
|
||||
expect(normalizedHtml).toContain(
|
||||
'<em>Italic Text</em>: <em>Italic with one underscore</em> <em>Italic with two underscores</em> Lorem ipsum dolor sit amet',
|
||||
);
|
||||
expect(normalizedHtml).toContain('### Heading 3');
|
||||
expect(normalizedHtml).toContain('<strong>Unordered List</strong> - List Item 1 - List Item 2 - List Item 3 - List Item 4');
|
||||
expect(normalizedHtml).toContain('<strong>Ordered List</strong> 1. List Item 1 2. List Item 2 3. List Item 3 4. List Item 4');
|
||||
|
||||
expect(normalizedHtml).toContain('<a');
|
||||
expect(normalizedHtml).toContain('title=""');
|
||||
expect(normalizedHtml).toContain('rel="nofollow noopener noreferrer"');
|
||||
expect(normalizedHtml).toContain('target="_blank"');
|
||||
expect(normalizedHtml).toContain('>Rocket.Chat</a>');
|
||||
|
||||
expect(normalizedHtml).toContain('href="mailto:gabriel.engel@rocket.chat"');
|
||||
expect(normalizedHtml).toContain('title="mailto:gabriel.engel@rocket.chat"');
|
||||
expect(normalizedHtml).toContain('rel="nofollow noopener noreferrer"');
|
||||
expect(normalizedHtml).toContain('target="_blank"');
|
||||
|
||||
expect(normalizedHtml).toContain('+55991999999');
|
||||
expect(normalizedHtml).toContain('Inline code');
|
||||
expect(normalizedHtml).toContain(`typescript const test = 'this is code'`);
|
||||
expect(normalizedHtml).toContain('<strong>Bold text within <em>Italics</em></strong>');
|
||||
expect(normalizedHtml).toContain('<strong>Bold text with single asterik and underscore within <em>Italics</em></strong>');
|
||||
expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text</em>');
|
||||
expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text with single underscore and asterik</em>');
|
||||
});
|
||||
|
||||
describe('links handling', () => {
|
||||
it.each([
|
||||
{
|
||||
caseName: 'transform external http',
|
||||
link: 'http://example.com',
|
||||
query: () => screen.getByRole('link', { name: 'Test Link' }),
|
||||
expectedHref: 'http://example.com',
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
{
|
||||
caseName: 'transform external https',
|
||||
link: 'https://example.com',
|
||||
query: () => screen.getByRole('link', { name: 'Test Link' }),
|
||||
expectedHref: 'https://example.com',
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
{
|
||||
caseName: 'transform notes',
|
||||
link: 'notes://example.com/path',
|
||||
query: () => screen.getByRole('link', { name: 'Test Link' }),
|
||||
expectedHref: 'notes://example.com/path',
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
{
|
||||
caseName: 'transform ftp',
|
||||
link: 'ftp://example.com/file.txt',
|
||||
query: () => screen.getByRole('link', { name: 'Test Link' }),
|
||||
expectedHref: 'ftp://example.com/file.txt',
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
{
|
||||
caseName: 'transform ftps',
|
||||
link: 'ftps://example.com/file.txt',
|
||||
query: () => screen.getByRole('link', { name: 'Test Link' }),
|
||||
expectedHref: 'ftps://example.com/file.txt',
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
{
|
||||
caseName: 'transform tel',
|
||||
link: 'tel:+1234567890',
|
||||
query: () => screen.getByRole('link', { name: 'Test Link' }),
|
||||
expectedHref: 'tel:+1234567890',
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
{
|
||||
caseName: 'transform mailto',
|
||||
link: 'mailto:test@example.com',
|
||||
query: () => screen.getByRole('link', { name: 'mailto:test@example.com' }),
|
||||
expectedHref: 'mailto:test@example.com',
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: 'mailto:test@example.com',
|
||||
},
|
||||
{
|
||||
caseName: 'transform sms',
|
||||
link: 'sms:+1234567890?body=hello',
|
||||
query: () => screen.getByRole('link', { name: 'Test Link' }),
|
||||
expectedHref: 'sms:+1234567890?body=hello',
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
{
|
||||
caseName: 'transform cid',
|
||||
link: 'cid:someimage@example.com',
|
||||
query: () => screen.getByRole('link', { name: 'Test Link' }),
|
||||
expectedHref: 'cid:someimage@example.com',
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
{
|
||||
caseName: 'filter relative',
|
||||
link: '/channel/general',
|
||||
query: () => screen.getByText('Test Link'),
|
||||
expectedHref: undefined,
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
{
|
||||
caseName: 'transform absolute',
|
||||
link: 'http://localhost/another-channel',
|
||||
query: () => screen.getByRole('link', { name: 'Go to: another-channel' }),
|
||||
expectedHref: 'http://localhost/another-channel',
|
||||
expectedRel: undefined,
|
||||
expectedTarget: undefined,
|
||||
expectedTitleAttribute: 'Go to: another-channel',
|
||||
},
|
||||
{
|
||||
caseName: 'filter unknown scheme',
|
||||
link: 'invalid://example.com',
|
||||
query: () => screen.getByText('Test Link'),
|
||||
expectedHref: undefined,
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
{
|
||||
caseName: 'filter javascript',
|
||||
link: "javascript:alert('XSS')",
|
||||
query: () => screen.getByText('Test Link'),
|
||||
expectedHref: undefined,
|
||||
expectedRel: 'nofollow noopener noreferrer',
|
||||
expectedTarget: '_blank',
|
||||
expectedTitleAttribute: '',
|
||||
},
|
||||
] as const)('should $caseName links', ({ link, query, expectedHref, expectedRel, expectedTarget, expectedTitleAttribute }) => {
|
||||
const markdownContent = `[Test Link](${link})`;
|
||||
render(<MarkdownText content={markdownContent} variant='document' />, {
|
||||
wrapper: mockAppRoot().withTranslations('en', 'core', { Go_to_href: 'Go to: {{href}}' }).build(),
|
||||
});
|
||||
|
||||
const anchorElement = query();
|
||||
|
||||
expect(anchorElement).toBeInTheDocument();
|
||||
expect(anchorElement.tagName).toBe('A');
|
||||
expect(anchorElement).toHaveTextContent('Test Link');
|
||||
|
||||
if (expectedHref !== undefined) expect(anchorElement).toHaveAttribute('href', expectedHref);
|
||||
else expect(anchorElement).not.toHaveAttribute('href');
|
||||
|
||||
if (expectedRel !== undefined) expect(anchorElement).toHaveAttribute('rel', expectedRel);
|
||||
else expect(anchorElement).not.toHaveAttribute('rel');
|
||||
|
||||
if (expectedTarget !== undefined) expect(anchorElement).toHaveAttribute('target', expectedTarget);
|
||||
else expect(anchorElement).not.toHaveAttribute('target');
|
||||
|
||||
if (expectedTitleAttribute !== undefined) expect(anchorElement).toHaveAttribute('title', expectedTitleAttribute);
|
||||
else expect(anchorElement).not.toHaveAttribute('title');
|
||||
});
|
||||
|
||||
describe('multiple links', () => {
|
||||
it.each(getTestCases(supportedURISchemes, testUris))('supported scheme: $scheme', ({ scheme, links }) => {
|
||||
const getTextContent = (link: string) => `Test link - ${link}`;
|
||||
const markdownContent = links.map((link) => `[${getTextContent(link)}](${link})`).join('\n');
|
||||
|
||||
render(<MarkdownText content={markdownContent} variant='document' />, {
|
||||
wrapper: mockAppRoot().withTranslations('en', 'core', { Go_to_href: 'Go to: {{href}}' }).build(),
|
||||
});
|
||||
|
||||
links.forEach((link) => {
|
||||
const text = getTextContent(link);
|
||||
const title = `Go to: ${link.replace(MOCKED_BASE_URI, '')}`;
|
||||
const anchorElement = screen.getByText(text);
|
||||
|
||||
expect(anchorElement).toBeInTheDocument();
|
||||
expect(anchorElement.tagName).toBe('A');
|
||||
expect(anchorElement).toHaveTextContent(text);
|
||||
expect(anchorElement).toHaveAttribute('href', link);
|
||||
|
||||
if (scheme === 'mailto') {
|
||||
expect(anchorElement).toHaveAttribute('rel', 'nofollow noopener noreferrer');
|
||||
expect(anchorElement).toHaveAttribute('target', '_blank');
|
||||
expect(anchorElement).toHaveAttribute('href', link);
|
||||
expect(anchorElement).toHaveAttribute('title', link);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isExternal(link)) {
|
||||
expect(anchorElement).toHaveAttribute('rel', 'nofollow noopener noreferrer');
|
||||
expect(anchorElement).toHaveAttribute('target', '_blank');
|
||||
expect(anchorElement).toHaveAttribute('title', '');
|
||||
return;
|
||||
}
|
||||
|
||||
expect(anchorElement).toHaveAttribute('title', title);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(getTestCases(nonSupportedUriSchemes, testUris))('unsupported scheme: $scheme', ({ links }) => {
|
||||
const getTextContent = (link: string) => `Test link - ${link}`;
|
||||
const markdownContent = links.map((link) => `[${getTextContent(link)}](${link})`).join('\n');
|
||||
|
||||
render(<MarkdownText content={markdownContent} variant='document' />, {
|
||||
wrapper: mockAppRoot().withTranslations('en', 'core', { Go_to_href: 'Go to: {{href}}' }).build(),
|
||||
});
|
||||
|
||||
links.forEach((link) => {
|
||||
const text = getTextContent(link);
|
||||
const anchorElement = screen.getByText(text);
|
||||
|
||||
expect(anchorElement).toBeInTheDocument();
|
||||
expect(anchorElement.tagName).toBe('A');
|
||||
expect(anchorElement).toHaveTextContent(text);
|
||||
expect(anchorElement).not.toHaveAttribute('href');
|
||||
|
||||
expect(anchorElement).toHaveAttribute('rel', 'nofollow noopener noreferrer');
|
||||
expect(anchorElement).toHaveAttribute('target', '_blank');
|
||||
expect(anchorElement).toHaveAttribute('title', '');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('code handling', () => {
|
||||
it.each([
|
||||
{
|
||||
caseName: 'inline with special characters',
|
||||
content: '`2 < 3 > 1 & 4 "Test"`',
|
||||
expected: '<code>2 < 3 > 1 & 4 "Test"</code>',
|
||||
},
|
||||
{
|
||||
caseName: 'inline with encoded special characters',
|
||||
content: '`< = < > = > & = &`',
|
||||
expected: '<code>< = &lt; > = &gt; & = &amp;</code>',
|
||||
},
|
||||
{
|
||||
caseName: 'block with language',
|
||||
content: "```typescript\nconst test = 'this is code'\n```",
|
||||
expected: '<code class="language-typescript">const test = \'this is code\' </code>',
|
||||
},
|
||||
{
|
||||
caseName: 'block without language',
|
||||
content: '```\nTwo < Three > One & Four "Test"\n```',
|
||||
expected: '<code>Two < Three > One & Four "Test" </code>',
|
||||
},
|
||||
{
|
||||
caseName: 'block with encoded special characters',
|
||||
content: '```\nTwo < Three > One & Four "Test"\n```',
|
||||
expected: '<code>Two &lt; Three &gt; One &amp; Four "Test" </code>',
|
||||
},
|
||||
] as const)('should render $caseName', ({ content, expected }) => {
|
||||
render(<MarkdownText content={`${content}`} variant='document' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
expect(screen.getByRole('code').outerHTML).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('line breaks handling', () => {
|
||||
it('should convert newlines to <br> in document variant', () => {
|
||||
const content = 'First Line\nSecond Line\nThird Line';
|
||||
const { container } = render(<MarkdownText content={content} variant='document' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const html = container.innerHTML;
|
||||
expect(html).toContain('First Line<br>Second Line<br>Third Line');
|
||||
});
|
||||
|
||||
it('should convert newlines to <br> in inline variant', () => {
|
||||
const content = 'First Line\nSecond Line\nThird Line';
|
||||
const { container } = render(<MarkdownText content={content} variant='inline' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const html = container.innerHTML;
|
||||
expect(html).not.toContain('<br>');
|
||||
});
|
||||
|
||||
it('should not convert newlines to <br> in inlineWithoutBreaks variant', () => {
|
||||
const content = 'First Line\nSecond Line\nThird Line';
|
||||
const { container } = render(<MarkdownText content={content} variant='inlineWithoutBreaks' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const html = container.innerHTML;
|
||||
expect(html).not.toContain('<br>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DOMPurify hook registration', () => {
|
||||
it('should register hook only once at module level', () => {
|
||||
// Import the module to trigger hook registration
|
||||
|
||||
const addHookSpy = jest.spyOn(dompurify, 'addHook');
|
||||
|
||||
// Clear any previous calls from module initialization
|
||||
addHookSpy.mockClear();
|
||||
|
||||
const { rerender, unmount } = render(<MarkdownText content='[Test Link](https://example.com)' variant='document' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
// Hook should NOT be registered during component render (it's registered at module level)
|
||||
expect(addHookSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Re-rendering with different props should not register hook again
|
||||
rerender(<MarkdownText content='[Another Link](https://example.com)' variant='document' />);
|
||||
expect(addHookSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Rendering another instance should not register hook again
|
||||
render(<MarkdownText content='[Third Link](https://test.com)' variant='inline' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
expect(addHookSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Unmounting should not affect the module-level hook
|
||||
unmount();
|
||||
expect(addHookSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
addHookSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser limit handling', () => {
|
||||
beforeEach(() => {
|
||||
getMarkdownParserLimitMock.mockClear();
|
||||
});
|
||||
|
||||
it('should render plain text without parsing when content exceeds the limit', () => {
|
||||
getMarkdownParserLimitMock.mockReturnValue(5);
|
||||
|
||||
const longContent = '**this should not be bold** because it exceeds the limit';
|
||||
render(<MarkdownText content={longContent} variant='document' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const element = screen.getByText(longContent);
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element.tagName).not.toBe('STRONG');
|
||||
});
|
||||
|
||||
it('should render parsed markdown when content is within the limit', () => {
|
||||
getMarkdownParserLimitMock.mockReturnValue(Infinity);
|
||||
|
||||
const content = '**bold text**';
|
||||
render(<MarkdownText content={content} variant='document' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const boldText = screen.getByText('bold text');
|
||||
expect(boldText).toBeInTheDocument();
|
||||
expect(boldText.tagName).toBe('STRONG');
|
||||
});
|
||||
|
||||
it('should render parsed markdown when content length equals the limit', () => {
|
||||
const content = '**hi**';
|
||||
|
||||
getMarkdownParserLimitMock.mockReturnValue(content.length);
|
||||
|
||||
render(<MarkdownText content={content} variant='document' />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const parsedText = screen.getByText('hi');
|
||||
expect(parsedText).toBeInTheDocument();
|
||||
expect(parsedText.tagName).toBe('STRONG');
|
||||
});
|
||||
});
|
||||
|
||||
describe('emoji parsing', () => {
|
||||
// Minimal stand-in for the native emoji package that turns the `:smile:`
|
||||
// shortcode into the same markup the real renderer produces.
|
||||
const renderSmile = (text: string): string => text.replace(/:smile:/g, '<span class="emoji" title=":smile:">😄</span>');
|
||||
|
||||
beforeAll(() => {
|
||||
emoji.packages.native = {
|
||||
emojiCategories: [],
|
||||
emojisByCategory: {},
|
||||
toneList: {},
|
||||
render: renderSmile,
|
||||
renderPicker: () => undefined,
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete emoji.packages.native;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
getMarkdownParserLimitMock.mockReturnValue(Infinity);
|
||||
});
|
||||
|
||||
it('should render emoji shortcodes that appear in visible text', () => {
|
||||
const { container } = render(<MarkdownText content='hello :smile:' variant='document' parseEmoji />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const emojiElement = container.querySelector('.emoji');
|
||||
expect(emojiElement).toBeInTheDocument();
|
||||
expect(emojiElement).toHaveAttribute('title', ':smile:');
|
||||
expect(emojiElement).toHaveTextContent('😄');
|
||||
expect(container).not.toHaveTextContent(':smile:');
|
||||
});
|
||||
|
||||
it('should not corrupt a link whose href contains an emoji shortcode', () => {
|
||||
render(<MarkdownText content='[docs](https://example.test/:smile:)' variant='document' parseEmoji />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const anchorElement = screen.getByText('docs');
|
||||
expect(anchorElement.tagName).toBe('A');
|
||||
// The shortcode inside the href must stay untouched instead of being replaced by an emoji span
|
||||
expect(anchorElement).toHaveAttribute('href', 'https://example.test/:smile:');
|
||||
expect(anchorElement.querySelector('.emoji')).toBeNull();
|
||||
});
|
||||
|
||||
it('should render emoji in text while keeping a shortcode inside an href intact', () => {
|
||||
const { container } = render(<MarkdownText content=':smile: [docs](https://example.test/:smile:)' variant='document' parseEmoji />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const anchorElement = screen.getByText('docs');
|
||||
expect(anchorElement).toHaveAttribute('href', 'https://example.test/:smile:');
|
||||
expect(anchorElement.querySelector('.emoji')).toBeNull();
|
||||
|
||||
const emojiElement = container.querySelector('.emoji');
|
||||
expect(emojiElement).toBeInTheDocument();
|
||||
expect(emojiElement).toHaveTextContent('😄');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user