Files
orca/mobile/src/session/MobileNativeChatMessage.test.ts
T
Brennan Benson 6b43b51711 fix(mobile): gate the tool detail panel on having detail (STA-3333)
The Tools toggle opens every row at once, bypassing the row's tap guard,
so a row with nothing to expand rendered its own label again underneath
itself — and the tap that would dismiss it is a no-op. Matches desktop.
2026-08-04 03:45:39 -07:00

155 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MAX_TOOL_RESULT_CHARS } from './mobile-native-chat-message-styles'
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
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function render(
message: NativeChatMessage,
props: { toolsExpanded?: boolean } = {}
): ReactTestRenderer {
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
return
}
original(...a)
})
try {
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message, ...props }))
})
} finally {
spy.mockRestore()
}
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_RESULT_CHARS + 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)
})
})