mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
156 lines
6.2 KiB
TypeScript
156 lines
6.2 KiB
TypeScript
import { createElement } from 'react'
|
||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||
import { MAX_TOOL_DETAIL_LENGTH } from './mobile-native-chat-tool-summary'
|
||
|
||
vi.mock('react-native', async () => {
|
||
const React = await import('react')
|
||
return {
|
||
Image: 'Image',
|
||
Pressable: 'Pressable',
|
||
Text: ({ children, ...props }: { children?: unknown }) =>
|
||
React.createElement('Text', props, children),
|
||
View: ({ children, ...props }: { children?: unknown }) =>
|
||
React.createElement('View', props, children),
|
||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
|
||
}
|
||
})
|
||
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
|
||
vi.mock('lucide-react-native', () => ({
|
||
ArrowUp: 'ArrowUp',
|
||
ChevronDown: 'ChevronDown',
|
||
Copy: 'Copy',
|
||
SquareChevronRight: 'SquareChevronRight'
|
||
}))
|
||
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
|
||
|
||
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
|
||
|
||
function userMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage {
|
||
return { id: 'u1', role: 'user', blocks, timestamp: null, source: 'transcript' }
|
||
}
|
||
|
||
function toolMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage {
|
||
return { id: 'a1', role: 'assistant', blocks, timestamp: null, source: 'transcript' }
|
||
}
|
||
|
||
describe('MobileNativeChatMessage', () => {
|
||
let renderer: ReactTestRenderer | null = null
|
||
|
||
afterEach(() => {
|
||
act(() => renderer?.unmount())
|
||
renderer = null
|
||
})
|
||
|
||
function render(
|
||
message: NativeChatMessage,
|
||
props: { toolsExpanded?: boolean } = {}
|
||
): ReactTestRenderer {
|
||
act(() => {
|
||
renderer = create(createElement(MobileNativeChatMessage, { message, ...props }))
|
||
})
|
||
return renderer!
|
||
}
|
||
|
||
const textIn = (node: ReactTestInstance): string[] =>
|
||
node.findAllByType('Text' as never).map((text) => String(text.children.join('')))
|
||
|
||
it('renders a loadable preview URI as an image thumbnail', () => {
|
||
const tree = render(userMessage([{ type: 'image-ref', url: 'file:///a.jpg', alt: 'a photo' }]))
|
||
const image = tree.root.findByType('Image' as never)
|
||
expect(image.props.source).toEqual({ uri: 'file:///a.jpg' })
|
||
expect(image.props.accessibilityLabel).toBe('a photo')
|
||
})
|
||
|
||
it('prefers the url over the path when both are present', () => {
|
||
const tree = render(
|
||
userMessage([{ type: 'image-ref', url: 'file:///local.jpg', path: '/tmp/host.png' }])
|
||
)
|
||
expect(tree.root.findByType('Image' as never).props.source).toEqual({
|
||
uri: 'file:///local.jpg'
|
||
})
|
||
})
|
||
|
||
it('falls back to a text placeholder for a bare host path', () => {
|
||
// A host temp path (e.g. on an SSH host) is not loadable on the device.
|
||
const tree = render(userMessage([{ type: 'image-ref', path: '/tmp/host.png' }]))
|
||
expect(tree.root.findAllByType('Image' as never)).toHaveLength(0)
|
||
const texts = tree.root
|
||
.findAllByType('Text' as never)
|
||
.map((node) => String(node.children.join('')))
|
||
expect(texts.some((text) => text.includes('/tmp/host.png'))).toBe(true)
|
||
})
|
||
|
||
it('labels a tool row with the target path instead of raw input JSON', () => {
|
||
const tree = render(
|
||
toolMessage([{ type: 'tool-call', name: 'Read', input: { file_path: 'src/index.ts' } }]),
|
||
{ toolsExpanded: true }
|
||
)
|
||
const texts = textIn(tree.root)
|
||
expect(texts).toContain('src/index.ts')
|
||
expect(texts.some((text) => text.includes('"file_path":"src/index.ts"'))).toBe(false)
|
||
})
|
||
|
||
it('bounds expanded diff-less tool input before native text layout', () => {
|
||
const tree = render(
|
||
toolMessage([
|
||
{ type: 'tool-call', name: 'CustomTool', input: { payload: 'x'.repeat(100_000) } }
|
||
]),
|
||
{ toolsExpanded: true }
|
||
)
|
||
const detail = textIn(tree.root).find((text) => text.startsWith('{\n'))
|
||
expect(detail).toHaveLength(MAX_TOOL_DETAIL_LENGTH + 1)
|
||
expect(detail?.endsWith('…')).toBe(true)
|
||
})
|
||
|
||
it('expands formatted detail for a collapsed JSON-string tool input', () => {
|
||
const tree = render(
|
||
toolMessage([
|
||
{
|
||
type: 'tool-call',
|
||
name: 'CustomTool',
|
||
input: '{"cmd":"git status","description":"Inspect changes"}'
|
||
}
|
||
])
|
||
)
|
||
const pressableWith = (label: string): ReactTestInstance =>
|
||
tree.root.findAllByType('Pressable' as never).find((node) => textIn(node).includes(label))!
|
||
|
||
act(() => pressableWith('1×').props.onPress())
|
||
// The row label is the command, and the detail stays closed until tapped.
|
||
expect(textIn(tree.root)).toContain('git status')
|
||
expect(textIn(tree.root).some((text) => text.startsWith('{\n'))).toBe(false)
|
||
|
||
act(() => pressableWith('CustomTool').props.onPress())
|
||
expect(textIn(tree.root)).toContain(
|
||
'{\n "cmd": "git status",\n "description": "Inspect changes"\n}'
|
||
)
|
||
})
|
||
|
||
it('does not echo the row label as detail when a row has nothing to expand', () => {
|
||
// The Tools toggle opens every row at once, bypassing the tap guard — a row
|
||
// whose formatted input is its own label would echo itself in a panel that
|
||
// no tap can dismiss.
|
||
const tree = render(toolMessage([{ type: 'tool-call', name: 'ListTodos', input: '{}' }]), {
|
||
toolsExpanded: true
|
||
})
|
||
expect(textIn(tree.root).filter((text) => text === '{}')).toHaveLength(1)
|
||
// The chevron has to agree with the panel, or the row claims to be open over
|
||
// nothing and the tap that would close it is guarded off. Only the run header
|
||
// is open here; the row itself stays collapsed.
|
||
expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1)
|
||
expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1)
|
||
})
|
||
|
||
it('does not expand a plain input that already fits in the row label', () => {
|
||
const input = 'x'.repeat(60)
|
||
const tree = render(toolMessage([{ type: 'tool-call', name: 'CustomTool', input }]), {
|
||
toolsExpanded: true
|
||
})
|
||
expect(textIn(tree.root).filter((text) => text === input)).toHaveLength(1)
|
||
expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1)
|
||
expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1)
|
||
})
|
||
})
|