import { createElement } from 'react' import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MobileNativeChatView } from './MobileNativeChatView' vi.mock('react-native', () => ({ ActivityIndicator: 'ActivityIndicator', FlatList: 'FlatList', Pressable: 'Pressable', StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, Text: 'Text', View: 'View' })) vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) })) vi.mock('react-native-gesture-handler', () => { const chain = { runOnJS: () => chain, onStart: () => chain, onUpdate: () => chain } return { Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain }, GestureDetector: 'GestureDetector', GestureHandlerRootView: 'GestureHandlerRootView' } }) vi.mock('lucide-react-native', () => ({ ArrowDown: 'ArrowDown', ChevronsDownUp: 'ChevronsDownUp', ChevronsUpDown: 'ChevronsUpDown', Square: 'Square' })) vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) vi.mock('./MobileAgentWorkingIndicator', () => ({ MobileAgentWorkingIndicator: 'WorkingIndicator' })) // Stand-in composer: exposes the view's `handleSend` through a pressable, which is // the only composer behaviour these banner tests exercise. vi.mock('./MobileNativeChatComposer', async () => { const React = await import('react') return { MobileNativeChatComposer: (props: { onSend: (text: string) => Promise }) => React.createElement('Composer', { accessibilityLabel: 'Send message', onPress: () => props.onSend('hi') }) } }) type Overrides = { sendErrorMessage?: string | null onClearSendError?: () => void inputLockReason?: 'disconnected' | 'waiting' | null onSend?: (text: string) => Promise } function suppressRendererWarning(): () => void { const original = console.error const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { return } original(...args) }) return () => spy.mockRestore() } describe('MobileNativeChatView send-error banner', () => { let renderer: ReactTestRenderer | null = null beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true }) afterEach(() => { act(() => renderer?.unmount()) renderer = null }) async function render(overrides: Overrides = {}): Promise { const restore = suppressRendererWarning() try { await act(async () => { renderer = create( createElement(MobileNativeChatView, { messages: [], status: 'ready', onSend: overrides.onSend ?? vi.fn().mockResolvedValue(true), pending: [], composerText: '', onComposerTextChange: vi.fn(), ...overrides }) ) }) } finally { restore() } } function banners(): ReactTestInstance[] { return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert') } function bannerText(): string { const [alert, ...rest] = banners() expect(rest).toHaveLength(0) return alert .findAll((node) => node.type === 'Text') .map((node) => node.props.children) .join('') } async function pressSend(): Promise { const composer = renderer!.root.find((node) => node.type === 'Composer') as { props: { onPress: () => Promise } } await act(async () => { await composer.props.onPress() }) } it('renders the route-reported failure verbatim', async () => { await render({ sendErrorMessage: 'Permission reply failed' }) expect(banners()).toHaveLength(1) expect(bannerText()).toContain('Permission reply failed') }) it('does not duplicate the route banner when the composer rejects', async () => { const onClearSendError = vi.fn() await render({ onSend: vi.fn().mockResolvedValue(false), inputLockReason: 'disconnected', sendErrorMessage: 'Stop failed', onClearSendError }) await pressSend() expect(onClearSendError).not.toHaveBeenCalled() expect(banners()).toHaveLength(1) expect(bannerText()).toContain('Stop failed') expect(bannerText()).toBe('Stop failed') }) it('retires the route-owned banner once a send is accepted', async () => { const onClearSendError = vi.fn() await render({ sendErrorMessage: 'Stop failed', onClearSendError }) await pressSend() expect(onClearSendError).toHaveBeenCalledOnce() }) })