Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ jobs:
- name: Typecheck files
run: yarn typecheck

- name: Run unit tests
run: yarn test

rn-build-library:
needs: changes
if: needs.changes.outputs.react-native == 'true'
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ We can help you build your next dream product –
- [Other Events](docs/INPUT.md#other-events)
- [Customizing Styles](docs/INPUT.md#customizing-enrichedmarkdowntextinput--styles)
- [API Reference](#api-reference)
- [Testing with Jest](docs/TESTING.md)
- [Web Support](docs/WEB.md)
- [macOS Support](docs/MACOS.md)
- [Compatibility Table](#compatibility-table)
Expand Down
112 changes: 112 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Testing with Jest

`EnrichedMarkdownTextInput` and `EnrichedMarkdownText` are Fabric/codegen native
components. Under Jest there is no native view manager, so rendering them — or
calling any imperative ref method such as `focus()`, `setValue()`, or
`toggleBold()`, which dispatch native commands — throws. To let you test screens
that embed these components, the package ships an importable mock that renders
plain React Native primitives and exposes every imperative method as a
[`jest.fn()`](https://jestjs.io/docs/mock-functions) spy.

## Setup

Point Jest at the shipped mock from a setup file (for example `jest.setup.js`)
listed in your config's `setupFilesAfterEnv`:

```js
// jest.setup.js
jest.mock('react-native-enriched-markdown', () =>
require('react-native-enriched-markdown/jest'),
);
```

The mock is distributed as compiled ES modules, so — like most React Native
libraries — the package must be transformed by Jest. Add it to
`transformIgnorePatterns` in your Jest config:

```js
// jest.config.js
module.exports = {
preset: 'jest-expo', // or 'react-native'
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|react-native-enriched-markdown))',
],
};
```

## What the mock does

- **Renders a real `TextInput`.** React Native Testing Library queries
(`getByTestId`, `getByPlaceholderText`, …) and `fireEvent.changeText` work out
of the box. `EnrichedMarkdownText` renders its `markdown` prop as plain text.
- **Emits change events on user input.** Typing fires `onChangeText` and, when a
handler is provided, `onChangeMarkdown`. The mock cannot parse markdown, so it
forwards the raw text to `onChangeMarkdown` as a stand-in.
- **Mirrors `setValue` suppression.** Calling `setValue()` updates the rendered
text so the programmatic value is observable, but emits **no** change events —
matching the native component's suppression of emits for programmatic updates.
- **Exposes every imperative method as a spy.** `toggleBold`, `insertMention`,
`setSelection`, and the rest are `jest.fn()`s you can assert against. The async
methods resolve sensible values: `getMarkdown()` resolves the current text and
`getCaretRect()` resolves `{ x: 0, y: 0, width: 0, height: 0 }`.

The mock is authored against the library's real public types, so it is
type-checked against the actual component API on every release and cannot
silently fall behind when methods or props are added.

## Examples

Asserting user input reaches your handlers:

```tsx
import { render, screen, fireEvent } from '@testing-library/react-native';
import { EnrichedMarkdownTextInput } from 'react-native-enriched-markdown';

test('reports typed text', () => {
const onChangeMarkdown = jest.fn();
render(
<EnrichedMarkdownTextInput
testID="composer"
onChangeMarkdown={onChangeMarkdown}
/>,
);

fireEvent.changeText(screen.getByTestId('composer'), 'hello');

expect(onChangeMarkdown).toHaveBeenCalledWith('hello');
});
```

Asserting a toolbar button invokes the right imperative method:

```tsx
import { createRef } from 'react';
import { render, screen, fireEvent } from '@testing-library/react-native';
import {
EnrichedMarkdownTextInput,
type EnrichedMarkdownTextInputInstance,
} from 'react-native-enriched-markdown';

test('bold button toggles bold', () => {
const ref = createRef<EnrichedMarkdownTextInputInstance>();
render(<EnrichedMarkdownTextInput ref={ref} />);

ref.current!.toggleBold();

expect(ref.current!.toggleBold).toHaveBeenCalledTimes(1);
});
```

Reading a programmatic value back out:

```tsx
ref.current!.setValue('**bold**');
await expect(ref.current!.getMarkdown()).resolves.toBe('**bold**');
```

## Limitations

The mock does not parse or render markdown formatting — it stores and echoes raw
text. It is meant for testing your components' wiring (event handlers, ref calls,
conditional rendering), not the library's rendering or parsing behavior, which is
covered by the library's own end-to-end tests.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"test:e2e:android:update-screenshots": ".maestro/scripts/run-tests.sh --platform android --update-screenshots",
"test:e2e:ios:update-screenshots": ".maestro/scripts/run-tests.sh --platform ios --update-screenshots",
"typecheck": "yarn workspace react-native-enriched-markdown typecheck",
"test": "yarn workspace react-native-enriched-markdown test",
"lint": "eslint \"**/*.{js,ts,tsx}\" --no-warn-ignored",
"lint-clang:ios": "yarn workspace react-native-enriched-markdown lint-clang:ios",
"lint-clang:ios:fix": "yarn workspace react-native-enriched-markdown lint-clang:ios:fix",
Expand Down
144 changes: 144 additions & 0 deletions packages/react-native-enriched-markdown/__tests__/jest-mock.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { act, createRef } from 'react';
import type { ReactElement } from 'react';
import { createRoot } from 'test-renderer';
import type { Root, TestInstance } from 'test-renderer';
import { EnrichedMarkdownTextInput, EnrichedMarkdownText } from '../src/jest';
import type { EnrichedMarkdownTextInputInstance } from '../src/EnrichedMarkdownTextInput';

const INSTANCE_METHODS: (keyof EnrichedMarkdownTextInputInstance)[] = [
'focus',
'blur',
'measure',
'measureInWindow',
'measureLayout',
'setValue',
'setSelection',
'toggleBold',
'toggleItalic',
'toggleUnderline',
'toggleStrikethrough',
'toggleSpoiler',
'toggleHeading',
'toggleUnorderedList',
'toggleOrderedList',
'indentList',
'outdentList',
'setLink',
'insertLink',
'insertMention',
'startMention',
'removeLink',
'copyToClipboard',
'getMarkdown',
'getCaretRect',
];

function renderMock(element: ReactElement) {
const root: Root = createRoot({ textComponentTypes: ['Text', 'TextInput'] });
act(() => {
root.render(element);
});
const byTestId = (testID: string): TestInstance => {
const [match] = root.container.queryAll((i) => i.props.testID === testID);
if (!match) throw new Error(`No element found with testID "${testID}"`);
return match;
};
return { root, byTestId };
}

describe('EnrichedMarkdownTextInput mock', () => {
it('renders a queryable TextInput seeded with defaultValue', () => {
const { byTestId } = renderMock(
<EnrichedMarkdownTextInput testID="input" defaultValue="hello" />
);
expect(byTestId('input').props.value).toBe('hello');
});

it('emits onChangeText and onChangeMarkdown on user input', () => {
const onChangeText = jest.fn();
const onChangeMarkdown = jest.fn();
const { byTestId } = renderMock(
<EnrichedMarkdownTextInput
testID="input"
onChangeText={onChangeText}
onChangeMarkdown={onChangeMarkdown}
/>
);

act(() => {
byTestId('input').props.onChangeText('typed');
});

expect(onChangeText).toHaveBeenCalledWith('typed');
expect(onChangeMarkdown).toHaveBeenCalledWith('typed');
expect(byTestId('input').props.value).toBe('typed');
});

it('setValue updates the rendered value without emitting change events', () => {
const onChangeText = jest.fn();
const onChangeMarkdown = jest.fn();
const ref = createRef<EnrichedMarkdownTextInputInstance>();
const { byTestId } = renderMock(
<EnrichedMarkdownTextInput
ref={ref}
testID="input"
onChangeText={onChangeText}
onChangeMarkdown={onChangeMarkdown}
/>
);

act(() => {
ref.current!.setValue('**programmatic**');
});

expect(byTestId('input').props.value).toBe('**programmatic**');
expect(onChangeText).not.toHaveBeenCalled();
expect(onChangeMarkdown).not.toHaveBeenCalled();
expect(ref.current!.setValue).toHaveBeenCalledWith('**programmatic**');
});

it('exposes every imperative method as a spy', () => {
const ref = createRef<EnrichedMarkdownTextInputInstance>();
renderMock(<EnrichedMarkdownTextInput ref={ref} />);

for (const method of INSTANCE_METHODS) {
expect(jest.isMockFunction(ref.current![method])).toBe(true);
}

act(() => {
ref.current!.toggleBold();
ref.current!.insertMention('Ada', 'user://ada');
});
expect(ref.current!.toggleBold).toHaveBeenCalledTimes(1);
expect(ref.current!.insertMention).toHaveBeenCalledWith(
'Ada',
'user://ada'
);
});

it('resolves async ref methods with sensible values', async () => {
const ref = createRef<EnrichedMarkdownTextInputInstance>();
renderMock(<EnrichedMarkdownTextInput ref={ref} defaultValue="seed" />);

await expect(ref.current!.getMarkdown()).resolves.toBe('seed');
act(() => {
ref.current!.setValue('next');
});
await expect(ref.current!.getMarkdown()).resolves.toBe('next');
await expect(ref.current!.getCaretRect()).resolves.toEqual({
x: 0,
y: 0,
width: 0,
height: 0,
});
});
});

describe('EnrichedMarkdownText mock', () => {
it('renders its markdown as plain text', () => {
const { byTestId } = renderMock(
<EnrichedMarkdownText testID="display" markdown="# Title" />
);
expect(byTestId('display').children).toContain('# Title');
});
});
4 changes: 4 additions & 0 deletions packages/react-native-enriched-markdown/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
preset: '@react-native/jest-preset',
testMatch: ['**/__tests__/**/*.test.{ts,tsx}'],
};
1 change: 1 addition & 0 deletions packages/react-native-enriched-markdown/jest.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './src/jest';
5 changes: 5 additions & 0 deletions packages/react-native-enriched-markdown/jest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use strict';

// Subpath entry for `require('react-native-enriched-markdown/jest')`.
// Re-exports the compiled mock (built from `src/jest` by react-native-builder-bob).
module.exports = require('./lib/module/jest');
9 changes: 9 additions & 0 deletions packages/react-native-enriched-markdown/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
"ios",
"cpp",
"*.podspec",
"jest.js",
"jest.d.ts",
"react-native.config.js",
"!ios/build",
"!android/build",
Expand All @@ -31,6 +33,7 @@
"scripts": {
"build:wasm": "bash ../core/cpp/wasm/build.sh",
"typecheck": "tsc",
"test": "jest",
"lint-clang:ios": "find ios/ \\( -iname \"*.h\" -o -iname \"*.m\" -o -iname \"*.mm\" \\) | grep -v -e Pods -e build | xargs npx clang-format -i -n --Werror",
"lint-clang:ios:fix": "find ios/ \\( -iname \"*.h\" -o -iname \"*.m\" -o -iname \"*.mm\" \\) | grep -v -e Pods -e build | xargs npx clang-format -i",
"lint-clang:android": "find android/ \\( -iname \"*.h\" -o -iname \"*.cpp\" \\) | grep -v -e build | xargs npx clang-format -i -n --Werror",
Expand Down Expand Up @@ -83,15 +86,21 @@
}
},
"devDependencies": {
"@babel/core": "^7.25.0",
"@expo/config-plugins": "^55.0.6",
"@react-native/babel-preset": "0.85.0",
"@react-native/jest-preset": "0.85.0",
"@types/jest": "^29.5.0",
"@types/node": "^22.0.0",
"@types/react": "^19.2.0",
"babel-jest": "^29.7.0",
"clang-format": "^1.8.0",
"del-cli": "^7.0.0",
"jest": "^29.7.0",
"react": "19.2.3",
"react-native": "0.85.0",
"react-native-builder-bob": "^0.41.0",
"test-renderer": "^1.0.0",
"typescript": "^6.0.2"
},
"react-native-builder-bob": {
Expand Down
Loading
Loading