mattermost-mobile-upstream/app/components/markdown/markdown.test.tsx
Joram Wilander 6ec7bef334
MM-70688 Autolink phone numbers in posts as tel: links (#10143)
* Add phone number matching helpers for tel: autolinks.

Detect E.164, grouped national numbers, and tel: URIs while skipping
bare digit runs that look like ticket IDs or dates.

Co-authored-by: joram <joram@mattermost.com>

* Autolink phone numbers in post markdown as tel: links.

Wrap recognized numbers in markdown link nodes after parsing so tapping
opens the device dialer. Skip code, existing links, and unsafe posts.

Co-authored-by: joram <joram@mattermost.com>

* Copy phone numbers from tel: markdown links on long-press.

Treat tel: like mailto: in link and channel-header sheets, and cover
opening tel: URLs through the existing openLink path.

Co-authored-by: joram <joram@mattermost.com>

* Reject truncated phone matches inside longer digit sequences.

Skip past hyphens and dots when checking match boundaries so overlong
numbers and numeric prefixes are not autolinked. Keep spaces and
parentheses as boundaries so adjacent numbers still match.

Also address CodeRabbit test nits: assert match counts, prefix
autolinkPhoneNumbers cases with should, and assert rendered tel: links.

Co-authored-by: joram <joram@mattermost.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-14 14:32:17 -04:00

313 lines
10 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react-native';
import * as CommonMark from 'commonmark';
const {Node} = CommonMark;
import React from 'react';
import {StyleSheet} from 'react-native';
import {Preferences, Screens} from '@constants';
import {renderWithIntl} from '@test/intl-test-helper';
import {getMarkdownTextStyles} from '@utils/markdown';
import {typography} from '@utils/typography';
import Markdown, {testExports} from './markdown';
import * as Transforms from './transform';
const Parser = jest.requireActual('commonmark').Parser;
jest.mock('@screens/navigation', () => ({
navigateToRoot: jest.fn(),
}));
jest.mock('./markdown_link', () => {
const MockReact = require('react');
const {Text} = require('react-native');
return {
__esModule: true,
default: ({href, children}: {href: string; children: React.ReactNode}) => (
MockReact.createElement(Text, {testID: 'markdown_link', href}, children)
),
};
});
describe('Markdown', () => {
const baseProps: React.ComponentProps<typeof Markdown> = {
baseTextStyle: {},
enableInlineLatex: true,
enableLatex: true,
location: Screens.CHANNEL,
maxNodes: 2000,
theme: Preferences.THEMES.denim,
};
describe('error handling', () => {
test('should render Markdown normally', () => {
renderWithIntl(
<Markdown
{...baseProps}
value='This is a test'
/>,
);
expect(screen.getByText('This is a test')).toBeVisible();
});
test('should catch errors when parsing Markdown', () => {
jest.spyOn(CommonMark, 'Parser').mockImplementation(() => {
const parser = new Parser();
parser.incorporateLine = () => {
throw new Error('test error');
};
return parser;
});
renderWithIntl(
<Markdown
{...baseProps}
value='This is a test'
/>,
);
expect(screen.queryByText('This is a text')).not.toBeVisible();
expect(screen.getByText('An error occurred while parsing this text')).toBeVisible();
});
test('should catch errors when parsing Markdown', () => {
jest.spyOn(CommonMark, 'Parser').mockImplementation(() => {
const parser = new Parser();
parser.incorporateLine = () => {
throw new Error('test error');
};
return parser;
});
renderWithIntl(
<Markdown
{...baseProps}
value='This is a test'
/>,
);
expect(screen.queryByText('This is a text')).not.toBeVisible();
expect(screen.getByText('An error occurred while parsing this text')).toBeVisible();
});
test('should catch errors when transforming Markdown', () => {
jest.spyOn(Transforms, 'highlightMentions').mockImplementation(() => {
throw new Error('test error');
});
renderWithIntl(
<Markdown
{...baseProps}
value='This is a test'
/>,
);
expect(screen.queryByText('This is a text')).not.toBeVisible();
expect(screen.getByText('An error occurred while parsing this text')).toBeVisible();
});
test('should catch errors when rendering Markdown', () => {
jest.spyOn(CommonMark, 'Parser').mockImplementation(() => {
const parser = new Parser();
parser.parse = () => {
// This replicates what was returned by the parser to cause MM-61148
const document = new Node('document');
const paragraph = new Node('paragraph');
(paragraph as any)._string_content = 'some text';
document.appendChild(new Node('table'));
return document;
};
return parser;
});
renderWithIntl(
<Markdown
{...baseProps}
value='This is a test'
/>,
);
expect(screen.queryByText('This is a text')).not.toBeVisible();
expect(screen.getByText('An error occurred while rendering this text')).toBeVisible();
});
test('should catch errors when with Latex', () => {
const value =
'```latex\n' +
'\\\\\\\\asdfasdfasdf\n' +
'```\n';
renderWithIntl(
<Markdown
{...baseProps}
value={value}
/>,
);
expect(screen.queryByText('This is a text')).not.toBeVisible();
expect(screen.getByText('An error occurred while rendering this text')).toBeVisible();
});
});
describe('renderHashtagWithStyles', () => {
const {renderHashtagWithStyles} = testExports;
test('should render hashtag with correct text and link styling', () => {
const baseTextStyle = {
color: '#000000',
...typography('Body', 200),
};
const textStyles = getMarkdownTextStyles(baseProps.theme);
const context: string[] = [];
renderWithIntl(
renderHashtagWithStyles(context, 'test', textStyles, baseTextStyle),
);
const hashtagElement = screen.getByText('#test');
expect(hashtagElement).toBeVisible();
const flattenedStyle = StyleSheet.flatten(hashtagElement.props.style);
expect(flattenedStyle).toMatchObject({
fontSize: 16,
color: baseProps.theme.linkColor,
});
});
test('should render hashtag in heading with correct heading font size', () => {
const baseTextStyle = {
color: '#000000',
...typography('Body', 200),
};
const textStyles = getMarkdownTextStyles(baseProps.theme);
const context: string[] = ['heading1'];
renderWithIntl(
renderHashtagWithStyles(context, 'hashtag', textStyles, baseTextStyle),
);
const hashtagElement = screen.getByText('#hashtag');
expect(hashtagElement).toBeVisible();
const flattenedStyle = StyleSheet.flatten(hashtagElement.props.style);
expect(flattenedStyle).toMatchObject({
fontSize: 28,
});
});
test('should apply heading2 styles when in heading2 context', () => {
const baseTextStyle = {
color: '#000000',
...typography('Body', 200),
};
const textStyles = getMarkdownTextStyles(baseProps.theme);
const context: string[] = ['heading2'];
renderWithIntl(
renderHashtagWithStyles(context, 'heading2tag', textStyles, baseTextStyle),
);
const hashtagElement = screen.getByText('#heading2tag');
expect(hashtagElement).toBeVisible();
const flattenedStyle = StyleSheet.flatten(hashtagElement.props.style);
expect(flattenedStyle).toMatchObject({
fontSize: 25,
});
});
test('should have clickable onPress handler', () => {
const baseTextStyle = {
color: '#000000',
...typography('Body', 200),
};
const textStyles = getMarkdownTextStyles(baseProps.theme);
const context: string[] = [];
renderWithIntl(
renderHashtagWithStyles(context, 'clickable', textStyles, baseTextStyle),
);
const hashtagElement = screen.getByText('#clickable');
expect(hashtagElement.props.onPress).toBeDefined();
});
test('should include both base text style and link color', () => {
const baseTextStyle = {
color: '#000000',
...typography('Body', 200),
fontFamily: 'OpenSans',
};
const textStyles = getMarkdownTextStyles(baseProps.theme);
const context: string[] = [];
renderWithIntl(
renderHashtagWithStyles(context, 'styled', textStyles, baseTextStyle),
);
const hashtagElement = screen.getByText('#styled');
const flattenedStyle = StyleSheet.flatten(hashtagElement.props.style);
expect(flattenedStyle).toMatchObject({
fontSize: 16,
fontFamily: 'OpenSans',
color: baseProps.theme.linkColor,
});
});
});
describe('phone number autolinking', () => {
beforeEach(() => {
jest.restoreAllMocks();
});
it('should autolink phone numbers when links are enabled', () => {
renderWithIntl(
<Markdown
{...baseProps}
value='Call 555-123-4567'
/>,
);
const links = screen.getAllByTestId('markdown_link');
expect(links).toHaveLength(1);
expect(links[0].props.href).toBe('tel:5551234567');
expect(screen.getByText('555-123-4567')).toBeVisible();
});
it('should not autolink phone numbers when links are disabled', () => {
renderWithIntl(
<Markdown
{...baseProps}
disableLinks={true}
value='Call 555-123-4567'
/>,
);
expect(screen.queryAllByTestId('markdown_link')).toHaveLength(0);
expect(screen.getByText('Call 555-123-4567')).toBeVisible();
});
it('should not autolink phone numbers when the post has unsafe links', () => {
renderWithIntl(
<Markdown
{...baseProps}
isUnsafeLinksPost={true}
value='Call 555-123-4567'
/>,
);
expect(screen.queryAllByTestId('markdown_link')).toHaveLength(0);
expect(screen.getByText('Call 555-123-4567')).toBeVisible();
});
});
});