Files
orca/mobile/src/session/MobileNativeChatView.test.ts
T

165 lines
4.9 KiB
TypeScript

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<boolean> }) =>
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<boolean>
}
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<void> {
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<void> {
const composer = renderer!.root.find((node) => node.type === 'Composer') as {
props: { onPress: () => Promise<boolean> }
}
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()
})
})