import { act, fireEvent, render, screen } from '@testing-library/react-native';
import * as Clipboard from 'expo-clipboard';
import React from 'react';
import { MessageBubble } from '../components/chat/MessageBubble';
import { ChatMessage } from '../store/useAppStore';
jest.mock('expo-clipboard', () => ({ setStringAsync: jest.fn(() => Promise.resolve(true)) }));
let actionsProps: any;
jest.mock('../components/chat/MessageActions', () => ({
MessageActions: (props: any) => {
actionsProps = props;
const { Text } = require('react-native');
return actions;
},
}));
const message = (overrides: Partial = {}): ChatMessage => ({
id: '1',
role: 'assistant',
content: 'hello **world**',
timestamp: new Date(2026, 0, 1, 13, 5).getTime(),
...overrides,
});
beforeEach(() => {
actionsProps = undefined;
(Clipboard.setStringAsync as jest.Mock).mockClear();
});
describe('MessageBubble user message', () => {
it('shows the plain text and keeps the actions hidden until touched', () => {
render();
expect(screen.getByText('hi there')).toBeTruthy();
expect(screen.queryByText('actions')).toBeNull();
});
it('reveals edit and delete (but not regenerate) after a touch', () => {
const onEdit = jest.fn();
const onDelete = jest.fn();
const onRegenerate = jest.fn();
render(
,
);
fireEvent(screen.getByText('hi').parent!.parent!.parent!.parent!, 'touchStart');
expect(screen.getByText('actions')).toBeTruthy();
expect(actionsProps.role).toBe('user');
expect(actionsProps.onEdit).toBe(onEdit);
expect(actionsProps.onDelete).toBe(onDelete);
expect(actionsProps.onRegenerate).toBeUndefined();
});
it('never renders actions for an empty message', () => {
render();
fireEvent(screen.getByText(/\d/).parent!.parent!.parent!, 'touchStart');
expect(screen.queryByText('actions')).toBeNull();
});
});
describe('MessageBubble assistant message', () => {
it('renders markdown and always shows actions', () => {
render();
expect(screen.getByText('world')).toBeTruthy();
expect(screen.getByText('actions')).toBeTruthy();
});
it('offers regenerate only on the last assistant message and never edit', () => {
const onEdit = jest.fn();
const onRegenerate = jest.fn();
const { rerender } = render();
expect(actionsProps.onRegenerate).toBeUndefined();
expect(actionsProps.onEdit).toBeUndefined();
rerender();
expect(actionsProps.onRegenerate).toBe(onRegenerate);
expect(actionsProps.onEdit).toBeUndefined();
});
it('flags actions as streaming only for the last assistant message', () => {
const { rerender } = render();
expect(actionsProps.isStreaming).toBeFalsy();
rerender();
expect(actionsProps.isStreaming).toBe(true);
});
it('renders nothing inside the bubble or actions while an empty answer is streaming', () => {
render();
expect(screen.queryByText('actions')).toBeNull();
});
it('renders an empty answer as markdown when not streaming', () => {
render();
expect(screen.queryByText('actions')).toBeNull();
});
});
describe('MessageBubble metadata', () => {
it('shows the time and the edited badge only for edited messages', () => {
const { rerender } = render();
expect(screen.queryByText(/edited/)).toBeNull();
rerender();
expect(screen.getByText(/ยท edited/)).toBeTruthy();
});
});
describe('MessageBubble code blocks', () => {
const fenced = 'text\n\n```ts\nconst a = 1;\n```\n';
it('shows the language, the code and copies it to the clipboard', async () => {
jest.useFakeTimers();
try {
render();
expect(screen.getByText('ts')).toBeTruthy();
expect(screen.getByText(/const a = 1;/)).toBeTruthy();
expect(screen.getByText('Copy')).toBeTruthy();
await act(async () => {
fireEvent.press(screen.getByText('Copy'));
});
expect(Clipboard.setStringAsync).toHaveBeenCalledWith('const a = 1;\n');
expect(screen.getByText('Copied!')).toBeTruthy();
act(() => {
jest.advanceTimersByTime(2000);
});
expect(screen.getByText('Copy')).toBeTruthy();
} finally {
jest.useRealTimers();
}
});
it('labels a fence without a language as "code"', () => {
render();
expect(screen.getByText('code')).toBeTruthy();
});
it('renders indented code blocks with a "code" header and copy button', async () => {
render();
expect(screen.getByText('code')).toBeTruthy();
await act(async () => {
fireEvent.press(screen.getByText('Copy'));
});
expect(Clipboard.setStringAsync).toHaveBeenCalledWith(expect.stringContaining('indented()'));
});
});