mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
test(mobile): split MobileNativeChatView.test.ts under the max-lines limit (#29368)
Pure move: the 36 test cases and their assertions are unchanged, now in six feature files. Shared mocks and render helpers live in MobileNativeChatView.harness.test-fixture.ts. The only non-move edits are typing and formatting: the harness adds getComposerEditGeneration to its default props (a required prop the fixture never passed), tests use hostType(node) for tag comparisons, and the typecheck baseline and reliability gate paths drop the removed file.
This commit is contained in:
@@ -290,10 +290,10 @@
|
||||
"oracle": "After acknowledged phone replies with stale host status, the exact card disappears and Send is enabled; unknown, refused or unverifiable-settlement replies retain enabled choices, while an older host's accepted reply dismisses like an acknowledged one; a same-occurrence view toggle retains pending acknowledgment and real replacements discard it. A slow 32 KiB SSH answer completes all chunks before Enter; cancellation preserves issued bytes but omits remaining groups. Paired settlement invokes the actual provider and lost callbacks remain unknown; older hosts deliver ordinary messages, launch and selector groups once and their accepted answers dismiss as before. An answered card stays hidden across a view remount; a collapsed card is a strip that frees the composer and expands back; an identical question in a new wait shows again; a composer send caught by a new card keeps its bubble without submitting or clearing the line under it.",
|
||||
"commands": [
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-observed-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx src/renderer/src/components/native-chat/use-native-chat-prompt-card-presentation.test.ts src/renderer/src/runtime/runtime-terminal-verified-input.test.ts src/renderer/src/components/native-chat/NativeChatComposer.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts src/main/runtime/runtime-terminal-writer.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts tests/e2e/native-chat-ssh-delivery.unit.test.ts --maxWorkers=2",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.test.ts mobile/src/session/mobile-prompt-controller.test.ts mobile/src/session/mobile-native-chat-permission-send.test.ts mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts mobile/src/session/use-mobile-native-chat-answer-send.test.ts mobile/src/session/use-mobile-native-chat-message-send.test.ts mobile/src/session/mobile-native-chat-send.test.ts mobile/src/session/MobileNativeChatCardHeaderAction.test.tsx mobile/src/session/MobileNativeChatPromptCard.test.tsx --maxWorkers=2",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.stop.test.ts mobile/src/session/MobileNativeChatView.failure-banner.test.ts mobile/src/session/MobileNativeChatView.rows.test.ts mobile/src/session/MobileNativeChatView.composer-lock.test.ts mobile/src/session/MobileNativeChatView.tail-follow.test.ts mobile/src/session/MobileNativeChatView.history-paging.test.ts mobile/src/session/mobile-prompt-controller.test.ts mobile/src/session/mobile-native-chat-permission-send.test.ts mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts mobile/src/session/use-mobile-native-chat-answer-send.test.ts mobile/src/session/use-mobile-native-chat-message-send.test.ts mobile/src/session/mobile-native-chat-send.test.ts mobile/src/session/MobileNativeChatCardHeaderAction.test.tsx mobile/src/session/MobileNativeChatPromptCard.test.tsx --maxWorkers=2",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm test tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts --maxWorkers=1",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-observed-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts --maxWorkers=2",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.test.ts --maxWorkers=2"
|
||||
"ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.stop.test.ts mobile/src/session/MobileNativeChatView.failure-banner.test.ts mobile/src/session/MobileNativeChatView.rows.test.ts mobile/src/session/MobileNativeChatView.composer-lock.test.ts mobile/src/session/MobileNativeChatView.tail-follow.test.ts mobile/src/session/MobileNativeChatView.history-paging.test.ts --maxWorkers=2"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx",
|
||||
@@ -327,7 +327,12 @@
|
||||
"mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts",
|
||||
"mobile/src/session/use-mobile-native-chat-controller.test.ts",
|
||||
"mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts",
|
||||
"mobile/src/session/MobileNativeChatView.test.ts",
|
||||
"mobile/src/session/MobileNativeChatView.stop.test.ts",
|
||||
"mobile/src/session/MobileNativeChatView.failure-banner.test.ts",
|
||||
"mobile/src/session/MobileNativeChatView.rows.test.ts",
|
||||
"mobile/src/session/MobileNativeChatView.composer-lock.test.ts",
|
||||
"mobile/src/session/MobileNativeChatView.tail-follow.test.ts",
|
||||
"mobile/src/session/MobileNativeChatView.history-paging.test.ts",
|
||||
"mobile/src/session/mobile-prompt-controller.test.ts",
|
||||
"mobile/src/session/mobile-native-chat-permission-send.test.ts",
|
||||
"mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts",
|
||||
@@ -429,7 +434,7 @@
|
||||
"date": "2026-10-05",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.test.ts --maxWorkers=2",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.stop.test.ts mobile/src/session/MobileNativeChatView.failure-banner.test.ts mobile/src/session/MobileNativeChatView.rows.test.ts mobile/src/session/MobileNativeChatView.composer-lock.test.ts mobile/src/session/MobileNativeChatView.tail-follow.test.ts mobile/src/session/MobileNativeChatView.history-paging.test.ts --maxWorkers=2",
|
||||
"result": "passed",
|
||||
"durationSeconds": 10.32,
|
||||
"summary": "73 tests passed across four explicit files after the controller extraction, covering the production view/card/composer, occurrence dismissal, controller session scoping, unknown/refused replies and stopping active dictation."
|
||||
@@ -447,7 +452,7 @@
|
||||
"date": "2026-10-06",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.test.ts mobile/src/session/mobile-prompt-controller.test.ts mobile/src/session/mobile-native-chat-permission-send.test.ts mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts mobile/src/session/use-mobile-native-chat-answer-send.test.ts mobile/src/session/use-mobile-native-chat-message-send.test.ts mobile/src/session/mobile-native-chat-send.test.ts mobile/src/session/MobileNativeChatCardHeaderAction.test.tsx mobile/src/session/MobileNativeChatPromptCard.test.tsx --maxWorkers=2",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.stop.test.ts mobile/src/session/MobileNativeChatView.failure-banner.test.ts mobile/src/session/MobileNativeChatView.rows.test.ts mobile/src/session/MobileNativeChatView.composer-lock.test.ts mobile/src/session/MobileNativeChatView.tail-follow.test.ts mobile/src/session/MobileNativeChatView.history-paging.test.ts mobile/src/session/mobile-prompt-controller.test.ts mobile/src/session/mobile-native-chat-permission-send.test.ts mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts mobile/src/session/use-mobile-native-chat-answer-send.test.ts mobile/src/session/use-mobile-native-chat-message-send.test.ts mobile/src/session/mobile-native-chat-send.test.ts mobile/src/session/MobileNativeChatCardHeaderAction.test.tsx mobile/src/session/MobileNativeChatPromptCard.test.tsx --maxWorkers=2",
|
||||
"result": "passed",
|
||||
"durationSeconds": 1.19,
|
||||
"summary": "186 tests passed across 12 explicit files after the round-3 review fixes, including older-host answers dismissing like acknowledged ones, collapse that keeps the card mounted, one card per occurrence, wait-start question identity and only Send blocked."
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { act } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
installChatViewHooks,
|
||||
composer,
|
||||
render,
|
||||
update
|
||||
} from './MobileNativeChatView.harness.test-fixture'
|
||||
|
||||
describe('MobileNativeChatView: composer lock and prompt cards', () => {
|
||||
installChatViewHooks()
|
||||
|
||||
it('keeps a visible lock through a subscribed-end lease blip', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await render({ inputLockReason: 'waiting' })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
expect(composer().props.disabled).toBe(true)
|
||||
|
||||
await update({ inputLockReason: null })
|
||||
expect(composer().props.disabled).toBe(true)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
await update({ inputLockReason: 'waiting' })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
|
||||
expect(composer().props.disabled).toBe(true)
|
||||
expect(composer().props.placeholder).toBe('Waiting for terminal…')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('unlocks after the lease stays ready', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await render({ inputLockReason: 'waiting' })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
await update({ inputLockReason: null })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(599)
|
||||
})
|
||||
expect(composer().props.disabled).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1)
|
||||
})
|
||||
|
||||
expect(composer().props.disabled).toBe(false)
|
||||
expect(composer().props.placeholder).toBe('Message, @files, /commands')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
// Why: a terminal-backed send types into the agent's prompt and could answer it; drafting cannot.
|
||||
describe('a prompt card owns Send in terminal-backed chat', () => {
|
||||
const permission = { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] }
|
||||
|
||||
it('blocks only Send while the card shows, and frees it when the card clears', async () => {
|
||||
await render({ permission })
|
||||
expect(composer().props.sendDisabled).toBe(true)
|
||||
expect(composer().props.disabled).toBe(false)
|
||||
expect(composer().props.placeholder).toBe('Message, @files, /commands')
|
||||
|
||||
await update({ permission: null })
|
||||
expect(composer().props.sendDisabled).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks Send for an ask and a heuristic question too', async () => {
|
||||
await render({
|
||||
ask: {
|
||||
questions: [{ question: 'Tabs?', multiSelect: false, options: [{ label: 'Tabs' }] }]
|
||||
}
|
||||
})
|
||||
expect(composer().props.sendDisabled).toBe(true)
|
||||
await update({
|
||||
question: { question: 'Name?', options: [], multiSelect: false, optionTokens: [] }
|
||||
})
|
||||
expect(composer().props.sendDisabled).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves a structured chat composer open: its host queues the send behind the prompt', async () => {
|
||||
await render({ permission, structuredActivityUi: true })
|
||||
expect(composer().props.sendDisabled).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
installChatViewHooks,
|
||||
assistantTurn,
|
||||
banners,
|
||||
bannerText,
|
||||
hostType,
|
||||
listIds,
|
||||
pressSend,
|
||||
render,
|
||||
root,
|
||||
update
|
||||
} from './MobileNativeChatView.harness.test-fixture'
|
||||
|
||||
describe('MobileNativeChatView: send and read failure banners', () => {
|
||||
installChatViewHooks()
|
||||
|
||||
// A read no retry gets past takes the whole pane, even over a transcript already on screen: its
|
||||
// words alone, and nothing that could only be refused again.
|
||||
it('leaves only the words of a read that failed for good, over a loaded transcript', async () => {
|
||||
const loaded = [assistantTurn('m1', 'earlier reply')]
|
||||
const words = 'This chat was saved by a newer Orca. Update Orca to open it.'
|
||||
const shown = (text: string) =>
|
||||
root().findAll((node) => hostType(node) === 'Text' && node.props.children === text)
|
||||
const composers = () => root().findAll((node) => hostType(node) === 'Composer')
|
||||
const lists = () => root().findAll((node) => hostType(node) === 'FlatList')
|
||||
const failure = {
|
||||
messages: loaded,
|
||||
folded: loaded,
|
||||
status: 'error' as const,
|
||||
error: words,
|
||||
canStop: true,
|
||||
permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] },
|
||||
// A resend answered unknown says nothing beside the read's words.
|
||||
sendErrorMessage: 'Message unconfirmed — check chat before retrying'
|
||||
}
|
||||
await render({ ...failure, readFailedFinally: true })
|
||||
expect(shown(words)).toHaveLength(1)
|
||||
expect(composers()).toHaveLength(0)
|
||||
expect(lists()).toHaveLength(0)
|
||||
expect(banners()).toHaveLength(0)
|
||||
expect(root().findAllByProps({ accessibilityLabel: 'Stop the agent' })).toHaveLength(0)
|
||||
|
||||
// A failure that can clear keeps the transcript and the composer.
|
||||
await update({ ...failure, error: "Orca couldn't open this chat's history right now." })
|
||||
expect(listIds()).toEqual(['m1'])
|
||||
expect(composers()).toHaveLength(1)
|
||||
expect(banners()).toHaveLength(1)
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,226 @@
|
||||
import { createElement, type ReactElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, expect, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { MobileNativeChatView } from './MobileNativeChatView'
|
||||
|
||||
const scrollToEnd = vi.hoisted(() => vi.fn())
|
||||
const scrollToOffset = vi.hoisted(() => vi.fn())
|
||||
const platform = vi.hoisted(() => ({ OS: 'ios' }))
|
||||
const pageScroller = vi.hoisted(() => ({ scrollHeight: 0 }))
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
FlatList: React.forwardRef((props, ref) => {
|
||||
const handle = { scrollToEnd, scrollToOffset, getScrollableNode: () => pageScroller }
|
||||
React.useImperativeHandle(ref, () => handle, [])
|
||||
return React.createElement('FlatList', props)
|
||||
}),
|
||||
Platform: platform,
|
||||
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('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' }))
|
||||
vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
|
||||
vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' }))
|
||||
vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' }))
|
||||
vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' }))
|
||||
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>
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}) =>
|
||||
React.createElement('Composer', {
|
||||
...props,
|
||||
accessibilityLabel: 'Send message',
|
||||
onPress: () => props.onSend('hi')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type Overrides = {
|
||||
messages?: Parameters<typeof MobileNativeChatView>[0]['messages']
|
||||
folded?: Parameters<typeof MobileNativeChatView>[0]['folded']
|
||||
streaming?: string | null
|
||||
sendErrorMessage?: string | null
|
||||
onClearSendError?: () => void
|
||||
inputLockReason?: 'disconnected' | 'waiting' | null
|
||||
onSend?: (text: string) => Promise<boolean>
|
||||
pending?: Parameters<typeof MobileNativeChatView>[0]['pending']
|
||||
structuredActivityUi?: boolean
|
||||
turnIndicator?: Parameters<typeof MobileNativeChatView>[0]['turnIndicator']
|
||||
agentWorking?: boolean
|
||||
canStop?: boolean
|
||||
ask?: Parameters<typeof MobileNativeChatView>[0]['ask']
|
||||
question?: Parameters<typeof MobileNativeChatView>[0]['question']
|
||||
permission?: Parameters<typeof MobileNativeChatView>[0]['permission']
|
||||
sendSurfaceId?: string
|
||||
keyboardInset?: number
|
||||
hasMore?: boolean
|
||||
onLoadEarlier?: () => void
|
||||
status?: Parameters<typeof MobileNativeChatView>[0]['status']
|
||||
error?: string
|
||||
readFailedFinally?: boolean
|
||||
actionablePromptIds?: readonly string[]
|
||||
}
|
||||
|
||||
export function assistantTurn(id: string, text: string): NativeChatMessage {
|
||||
return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' }
|
||||
}
|
||||
|
||||
function chatViewElement(overrides: Overrides): ReturnType<typeof createElement> {
|
||||
return createElement(MobileNativeChatView, {
|
||||
messages: [],
|
||||
folded: [],
|
||||
status: 'ready',
|
||||
streaming: null,
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
sendSurfaceId: 'tab-a',
|
||||
getSendCompletionGeneration: () => 0,
|
||||
getComposerEditGeneration: () => 0,
|
||||
pending: [],
|
||||
composerText: '',
|
||||
onComposerTextChange: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
// Stubs the frame clock and tears down the mounted view; call once at the top of a test file.
|
||||
export function installChatViewHooks(): void {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => callback(0), 0)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', (handle: ReturnType<typeof setTimeout>) =>
|
||||
clearTimeout(handle)
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
scrollToEnd.mockReset()
|
||||
scrollToOffset.mockReset()
|
||||
pageScroller.scrollHeight = 0
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
}
|
||||
|
||||
export function root(): ReactTestInstance {
|
||||
return renderer!.root
|
||||
}
|
||||
|
||||
// Mocked react-native components are string tags that ReactTestInstance's ElementType omits.
|
||||
export function hostType(node: ReactTestInstance): unknown {
|
||||
return node.type
|
||||
}
|
||||
|
||||
export async function render(overrides: Overrides = {}): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer = create(chatViewElement(overrides))
|
||||
})
|
||||
}
|
||||
|
||||
export async function update(overrides: Overrides = {}): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer?.update(chatViewElement(overrides))
|
||||
})
|
||||
}
|
||||
|
||||
// Ids of the rows the list is currently rendering.
|
||||
export function listIds(): string[] {
|
||||
return (list().props.data as { id: string }[]).map((row) => row.id)
|
||||
}
|
||||
|
||||
export function list(): ReactTestInstance {
|
||||
return renderer!.root.find((node) => hostType(node) === 'FlatList')
|
||||
}
|
||||
|
||||
export function renderedRow(id: string): ReactElement<Record<string, unknown>> {
|
||||
const listNode = list()
|
||||
const data = listNode.props.data as NativeChatMessage[]
|
||||
const index = data.findIndex((row) => row.id === id)
|
||||
return listNode.props.renderItem({ item: data[index], index })
|
||||
}
|
||||
|
||||
export function banners(): ReactTestInstance[] {
|
||||
return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert')
|
||||
}
|
||||
|
||||
export function composer(): ReactTestInstance {
|
||||
return renderer!.root.find((node) => hostType(node) === 'Composer')
|
||||
}
|
||||
|
||||
export function bannerText(): string {
|
||||
const [alert, ...rest] = banners()
|
||||
expect(rest).toHaveLength(0)
|
||||
return alert
|
||||
.findAll((node) => hostType(node) === 'Text')
|
||||
.map((node) => node.props.children)
|
||||
.join('')
|
||||
}
|
||||
|
||||
export async function pressSend(): Promise<void> {
|
||||
const composer = renderer!.root.find((node) => hostType(node) === 'Composer')
|
||||
await act(async () => {
|
||||
await composer.props.onPress()
|
||||
})
|
||||
}
|
||||
|
||||
export async function scrollAwayFromTail(): Promise<void> {
|
||||
await act(async () => {
|
||||
list().props.onScrollBeginDrag?.({})
|
||||
list().props.onScroll({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 200 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Vitest rejects exporting vi.hoisted bindings directly, so they are exported by name here.
|
||||
export { scrollToEnd, scrollToOffset, platform, pageScroller }
|
||||
@@ -0,0 +1,190 @@
|
||||
import { act } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
installChatViewHooks,
|
||||
assistantTurn,
|
||||
list,
|
||||
pageScroller,
|
||||
platform,
|
||||
render,
|
||||
root,
|
||||
scrollToEnd,
|
||||
scrollToOffset,
|
||||
update
|
||||
} from './MobileNativeChatView.harness.test-fixture'
|
||||
|
||||
describe('MobileNativeChatView: history paging and anchoring', () => {
|
||||
installChatViewHooks()
|
||||
|
||||
it('stops tail-follow before loading earlier history can resize the list', async () => {
|
||||
const folded = [assistantTurn('a1', 'History')]
|
||||
const onLoadEarlier = vi.fn()
|
||||
await render({ folded, hasMore: true, onLoadEarlier })
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
act(() => {
|
||||
list().props.onScrollBeginDrag?.({})
|
||||
list().props.onScroll({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 40 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onContentSizeChange(320, 950)
|
||||
})
|
||||
|
||||
expect(onLoadEarlier).toHaveBeenCalledOnce()
|
||||
expect(scrollToEnd).not.toHaveBeenCalled()
|
||||
expect(scrollToOffset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resumes following when the drag that paged in history ends at the tail', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
// A chat shorter than the screen: its top and its tail are the same place.
|
||||
const shortChat = {
|
||||
contentOffset: { y: 0 },
|
||||
contentSize: { height: 400 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
const folded = [assistantTurn('a1', 'Short history')]
|
||||
const onLoadEarlier = vi.fn()
|
||||
await render({ folded, hasMore: true, onLoadEarlier })
|
||||
|
||||
act(() => {
|
||||
list().props.onScrollBeginDrag?.({})
|
||||
list().props.onScroll({ nativeEvent: shortChat })
|
||||
list().props.onScrollEndDrag?.({ nativeEvent: shortChat })
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
act(() => list().props.onContentSizeChange(320, 950))
|
||||
|
||||
expect(onLoadEarlier).toHaveBeenCalledOnce()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 950 })
|
||||
expect(
|
||||
root().findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
|
||||
).toHaveLength(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('pages in history only from a scroll the reader made', async () => {
|
||||
const onLoadEarlier = vi.fn()
|
||||
await render({ folded: [assistantTurn('a1', 'Latest')], hasMore: true, onLoadEarlier })
|
||||
const nearTop = {
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 40 },
|
||||
contentSize: { height: 520 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
}
|
||||
|
||||
// The tail pin of a chat barely taller than the screen reports an offset near the top too.
|
||||
act(() => list().props.onScroll(nearTop))
|
||||
expect(onLoadEarlier).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
list().props.onScrollBeginDrag?.({})
|
||||
list().props.onScroll(nearTop)
|
||||
})
|
||||
expect(onLoadEarlier).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('anchors the first visible row, which the header above it is not', async () => {
|
||||
const folded = [assistantTurn('a1', 'History')]
|
||||
await render({ folded, hasMore: true })
|
||||
expect(list().props.maintainVisibleContentPosition).toEqual({ minIndexForVisible: 1 })
|
||||
|
||||
await update({ folded, hasMore: false })
|
||||
expect(list().props.maintainVisibleContentPosition).toEqual({ minIndexForVisible: 0 })
|
||||
})
|
||||
|
||||
describe('on the page, where the list reports no drag or momentum', () => {
|
||||
beforeEach(() => {
|
||||
platform.OS = 'web'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
platform.OS = 'ios'
|
||||
})
|
||||
|
||||
function scrolledTo(y: number, contentHeight: number) {
|
||||
return {
|
||||
nativeEvent: {
|
||||
contentOffset: { y },
|
||||
contentSize: { height: contentHeight },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('stops following when the reader scrolls up, and resumes at the end', async () => {
|
||||
await render({ folded: [assistantTurn('a1', 'Latest')] })
|
||||
act(() => {
|
||||
list().props.onContentSizeChange(320, 1_000)
|
||||
list().props.onScroll(scrolledTo(500, 1_000))
|
||||
})
|
||||
scrollToOffset.mockClear()
|
||||
|
||||
// No drag event arrives on the page: the move up is all there is to go on.
|
||||
act(() => {
|
||||
list().props.onScroll(scrolledTo(300, 1_000))
|
||||
list().props.onContentSizeChange(320, 1_200)
|
||||
})
|
||||
expect(scrollToOffset).not.toHaveBeenCalled()
|
||||
expect(
|
||||
root().findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
|
||||
).toHaveLength(1)
|
||||
|
||||
act(() => {
|
||||
list().props.onScroll(scrolledTo(700, 1_200))
|
||||
list().props.onContentSizeChange(320, 1_400)
|
||||
})
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_400 })
|
||||
})
|
||||
|
||||
it('does not take its own pin, landing short of a grown end, for the reader', async () => {
|
||||
await render({ folded: [assistantTurn('a1', 'Latest')] })
|
||||
act(() => {
|
||||
list().props.onContentSizeChange(320, 1_000)
|
||||
list().props.onScroll(scrolledTo(500, 1_000))
|
||||
})
|
||||
scrollToOffset.mockClear()
|
||||
|
||||
// The pin reached the end it was sent to; the reply had grown 400 past it by then.
|
||||
act(() => {
|
||||
list().props.onScroll(scrolledTo(700, 1_600))
|
||||
list().props.onContentSizeChange(320, 1_600)
|
||||
})
|
||||
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_600 })
|
||||
})
|
||||
|
||||
it("opens another chat at its measured end, not the last chat's", async () => {
|
||||
await render({ folded: [assistantTurn('a1', 'Chat A')] })
|
||||
act(() => list().props.onContentSizeChange(320, 1_000))
|
||||
pageScroller.scrollHeight = 2_500
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
await update({ folded: [assistantTurn('b1', 'Chat B')], sendSurfaceId: 'tab-b' })
|
||||
|
||||
expect(scrollToEnd).not.toHaveBeenCalled()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 2_500 })
|
||||
})
|
||||
|
||||
it('pages in history when the reader scrolls up to the top, not when a pin passes it', async () => {
|
||||
const onLoadEarlier = vi.fn()
|
||||
await render({ folded: [assistantTurn('a1', 'Latest')], hasMore: true, onLoadEarlier })
|
||||
|
||||
act(() => list().props.onScroll(scrolledTo(40, 540)))
|
||||
expect(onLoadEarlier).not.toHaveBeenCalled()
|
||||
|
||||
act(() => list().props.onScroll(scrolledTo(10, 540)))
|
||||
expect(onLoadEarlier).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
installChatViewHooks,
|
||||
assistantTurn,
|
||||
listIds,
|
||||
render,
|
||||
renderedRow,
|
||||
update
|
||||
} from './MobileNativeChatView.harness.test-fixture'
|
||||
|
||||
describe('MobileNativeChatView: rows and the streaming bubble', () => {
|
||||
installChatViewHooks()
|
||||
|
||||
// The gate that decides `streaming` lives in MobileNativeChatOverlay, which
|
||||
// outlives this view; see MobileNativeChatOverlay.test.ts.
|
||||
it('appends the gated streaming bubble after the folded transcript', async () => {
|
||||
const folded = [assistantTurn('a1', 'The tests pass.')]
|
||||
await render({ folded })
|
||||
expect(listIds()).toEqual(['a1'])
|
||||
|
||||
await update({ folded, streaming: 'The tests' })
|
||||
|
||||
expect(listIds()).toEqual(['a1', 'streaming'])
|
||||
})
|
||||
|
||||
it('tells a row whether the host still waits on the prompt it stands for', async () => {
|
||||
const rows = [assistantTurn('asked', 'Tea or coffee?'), assistantTurn('open', 'Which branch?')]
|
||||
await render({ messages: rows, folded: rows, actionablePromptIds: ['open'] })
|
||||
expect(renderedRow('asked').props.awaiting).toBe(false)
|
||||
expect(renderedRow('open').props.awaiting).toBe(true)
|
||||
})
|
||||
|
||||
it('renders an accepted optimistic image send without a queued state', async () => {
|
||||
await render({
|
||||
pending: [{ id: 'pending-1', text: 'look', images: ['file:///phone-photo.jpg'] }]
|
||||
})
|
||||
|
||||
expect(listIds()).toEqual(['pending-1'])
|
||||
expect(renderedRow('pending-1').props).not.toHaveProperty('queued')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
installChatViewHooks,
|
||||
hostType,
|
||||
render,
|
||||
root,
|
||||
update
|
||||
} from './MobileNativeChatView.harness.test-fixture'
|
||||
|
||||
describe('MobileNativeChatView: Stop control', () => {
|
||||
installChatViewHooks()
|
||||
|
||||
it("holds Stop and says Stopping while this phone's own Stop request is in flight", async () => {
|
||||
await render({
|
||||
structuredActivityUi: true,
|
||||
agentWorking: true,
|
||||
canStop: true,
|
||||
turnIndicator: {
|
||||
thinking: false,
|
||||
activityText: null,
|
||||
stopping: true,
|
||||
stopRequestInFlight: true
|
||||
}
|
||||
})
|
||||
const stop = root().find(
|
||||
(node) => hostType(node) === 'Pressable' && node.props.accessibilityLabel === 'Stopping…'
|
||||
)
|
||||
expect(stop.props.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['queue', 'Queue a message to run after the stop'],
|
||||
['send', 'Send a message to run after the stop']
|
||||
] as const)(
|
||||
'tells the composer a message sent now runs after the stop (%s)',
|
||||
async (afterStop, placeholder) => {
|
||||
await render({
|
||||
structuredActivityUi: true,
|
||||
agentWorking: true,
|
||||
canStop: true,
|
||||
turnIndicator: { thinking: false, activityText: null, stopping: true, afterStop }
|
||||
})
|
||||
const composer = root().find((node) => hostType(node) === 'Composer')
|
||||
expect(composer.props.placeholder).toBe(placeholder)
|
||||
}
|
||||
)
|
||||
|
||||
// A Stop the provider took and never answered ends only at a repeat Stop.
|
||||
it('keeps Stop for the repeat that escalates while the host alone says Stopping', async () => {
|
||||
await render({
|
||||
structuredActivityUi: true,
|
||||
agentWorking: true,
|
||||
canStop: true,
|
||||
turnIndicator: { thinking: false, activityText: null, stopping: true }
|
||||
})
|
||||
const stop = root().find(
|
||||
(node) => hostType(node) === 'Pressable' && node.props.accessibilityLabel === 'Stop the agent'
|
||||
)
|
||||
expect(stop.props.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps Stop hidden during a structured dispatch until a provider turn can be cancelled', async () => {
|
||||
const props = { structuredActivityUi: true, agentWorking: true, canStop: false }
|
||||
await render(props)
|
||||
const stops = () => root().findAll((node) => node.props.accessibilityLabel === 'Stop the agent')
|
||||
expect(stops()).toHaveLength(0)
|
||||
await update({ ...props, canStop: true })
|
||||
expect(stops()).toHaveLength(1)
|
||||
await update({ agentWorking: true })
|
||||
expect(stops()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,332 @@
|
||||
import { act } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
installChatViewHooks,
|
||||
assistantTurn,
|
||||
list,
|
||||
pressSend,
|
||||
render,
|
||||
root,
|
||||
scrollAwayFromTail,
|
||||
scrollToEnd,
|
||||
scrollToOffset,
|
||||
update
|
||||
} from './MobileNativeChatView.harness.test-fixture'
|
||||
|
||||
describe('MobileNativeChatView: tail-follow', () => {
|
||||
installChatViewHooks()
|
||||
|
||||
it('lets content growth own streaming tail-follow without a delayed animated command', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const folded = [assistantTurn('a1', 'Starting')]
|
||||
await render({ folded })
|
||||
await act(async () => {
|
||||
vi.runOnlyPendingTimers()
|
||||
})
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
await update({ folded, streaming: 'Streaming output' })
|
||||
act(() => list().props.onContentSizeChange(320, 900))
|
||||
|
||||
expect(scrollToOffset).toHaveBeenCalledOnce()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 900 })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60)
|
||||
})
|
||||
expect(scrollToOffset).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not treat programmatic scroll metrics as user intent', async () => {
|
||||
const folded = [assistantTurn('a1', 'Latest')]
|
||||
await render({ folded })
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
act(() => {
|
||||
list().props.onScroll({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 200 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onContentSizeChange(320, 1_300)
|
||||
})
|
||||
|
||||
expect(scrollToOffset).toHaveBeenCalledOnce()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_300 })
|
||||
expect(
|
||||
root().findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps tail-follow paused across the drag-to-momentum handoff', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const folded = [assistantTurn('a1', 'Latest')]
|
||||
await render({ folded })
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
act(() => list().props.onScrollBeginDrag?.({}))
|
||||
expect(
|
||||
root().findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
|
||||
).toHaveLength(0)
|
||||
|
||||
act(() => {
|
||||
list().props.onScroll({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 700 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onScrollEndDrag?.({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 700 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onContentSizeChange(320, 1_250)
|
||||
list().props.onMomentumScrollBegin?.({})
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
act(() => list().props.onContentSizeChange(320, 1_300))
|
||||
|
||||
expect(scrollToEnd).not.toHaveBeenCalled()
|
||||
expect(scrollToOffset).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
list().props.onMomentumScrollEnd?.({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 800 },
|
||||
contentSize: { height: 1_300 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onContentSizeChange(320, 1_350)
|
||||
})
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_350 })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses momentum-end metrics instead of a stale throttled scroll sample', async () => {
|
||||
const folded = [assistantTurn('a1', 'Latest')]
|
||||
await render({ folded })
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
act(() => {
|
||||
list().props.onScrollBeginDrag?.({})
|
||||
list().props.onScroll({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 700 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onScrollEndDrag?.({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 700 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onMomentumScrollBegin?.({})
|
||||
list().props.onMomentumScrollEnd?.({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 200 },
|
||||
contentSize: { height: 1_300 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onContentSizeChange(320, 1_350)
|
||||
})
|
||||
|
||||
expect(scrollToEnd).not.toHaveBeenCalled()
|
||||
expect(scrollToOffset).not.toHaveBeenCalled()
|
||||
expect(
|
||||
root().findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('uses finger-release metrics when no momentum event follows', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const folded = [assistantTurn('a1', 'Latest')]
|
||||
await render({ folded })
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
act(() => {
|
||||
list().props.onScrollBeginDrag?.({})
|
||||
list().props.onScroll({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 200 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onScrollEndDrag?.({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 620 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
act(() => list().props.onContentSizeChange(320, 1_250))
|
||||
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_250 })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('repins when content grows between tail release and drag settle', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const folded = [assistantTurn('a1', 'Latest')]
|
||||
await render({ folded })
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
act(() => {
|
||||
list().props.onScrollBeginDrag?.({})
|
||||
list().props.onScrollEndDrag?.({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 700 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onContentSizeChange(320, 1_250)
|
||||
})
|
||||
|
||||
expect(scrollToEnd).not.toHaveBeenCalled()
|
||||
expect(scrollToOffset).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
vi.runOnlyPendingTimers()
|
||||
})
|
||||
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })
|
||||
expect(
|
||||
root().findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
|
||||
).toHaveLength(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses measured content height when the first message fills an empty transcript', async () => {
|
||||
await render()
|
||||
|
||||
await pressSend()
|
||||
expect(scrollToEnd).not.toHaveBeenCalled()
|
||||
|
||||
await update({ folded: [assistantTurn('a1', 'First response')] })
|
||||
act(() => list().props.onContentSizeChange(320, 1_200))
|
||||
|
||||
expect(scrollToOffset).toHaveBeenCalledOnce()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_200 })
|
||||
})
|
||||
|
||||
it('routes an accepted send through the immediate nonanimated tail owner', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const folded = [assistantTurn('a1', 'History')]
|
||||
await render({ folded })
|
||||
await act(async () => {
|
||||
vi.runOnlyPendingTimers()
|
||||
})
|
||||
await scrollAwayFromTail()
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
await pressSend()
|
||||
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60)
|
||||
})
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('routes the latest-message chevron through the tail owner and resumes following', async () => {
|
||||
const folded = [assistantTurn('a1', 'History')]
|
||||
await render({ folded })
|
||||
await scrollAwayFromTail()
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
const chevron = root().find((node) => node.props.accessibilityLabel === 'Scroll to latest')
|
||||
act(() => chevron.props.onPress())
|
||||
expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })
|
||||
|
||||
act(() => list().props.onContentSizeChange(320, 1_300))
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_300 })
|
||||
})
|
||||
|
||||
// The view stays mounted across chat tabs, so the reader's place in one must not carry over.
|
||||
it('opens another chat at its newest message, following, keeping the Tools toggle', async () => {
|
||||
await render({ folded: [assistantTurn('a1', 'Chat A')] })
|
||||
await scrollAwayFromTail()
|
||||
const label = (text: string) => root().findAllByProps({ children: text })
|
||||
const jumpControls = () => root().findAllByProps({ accessibilityLabel: 'Scroll to latest' })
|
||||
act(() => label('Tools')[0].parent!.props.onPress())
|
||||
expect(jumpControls()).toHaveLength(1)
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
await update({ folded: [assistantTurn('b1', 'Chat B')], sendSurfaceId: 'tab-b' })
|
||||
|
||||
expect(scrollToEnd).toHaveBeenCalledWith({ animated: false })
|
||||
expect(jumpControls()).toHaveLength(0)
|
||||
act(() => list().props.onContentSizeChange(320, 900))
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 900 })
|
||||
expect(label('Collapse')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('repins after a keyboard-driven viewport layout only while following', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const folded = [assistantTurn('a1', 'Latest')]
|
||||
await render({ folded })
|
||||
await act(async () => {
|
||||
vi.runOnlyPendingTimers()
|
||||
})
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
await update({ folded, keyboardInset: 320 })
|
||||
act(() => list().props.onLayout?.({ nativeEvent: { layout: { height: 400 } } }))
|
||||
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60)
|
||||
})
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
|
||||
await scrollAwayFromTail()
|
||||
scrollToEnd.mockClear()
|
||||
await update({ folded, keyboardInset: 0 })
|
||||
act(() => list().props.onLayout?.({ nativeEvent: { layout: { height: 700 } } }))
|
||||
expect(scrollToEnd).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,939 +0,0 @@
|
||||
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 { MobileNativeChatView } from './MobileNativeChatView'
|
||||
|
||||
const scrollToEnd = vi.hoisted(() => vi.fn())
|
||||
const scrollToOffset = vi.hoisted(() => vi.fn())
|
||||
const platform = vi.hoisted(() => ({ OS: 'ios' }))
|
||||
const pageScroller = vi.hoisted(() => ({ scrollHeight: 0 }))
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
FlatList: React.forwardRef((props, ref) => {
|
||||
const handle = { scrollToEnd, scrollToOffset, getScrollableNode: () => pageScroller }
|
||||
React.useImperativeHandle(ref, () => handle, [])
|
||||
return React.createElement('FlatList', props)
|
||||
}),
|
||||
Platform: platform,
|
||||
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('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' }))
|
||||
vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
|
||||
vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' }))
|
||||
vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' }))
|
||||
vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' }))
|
||||
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>
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}) =>
|
||||
React.createElement('Composer', {
|
||||
...props,
|
||||
accessibilityLabel: 'Send message',
|
||||
onPress: () => props.onSend('hi')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type Overrides = {
|
||||
messages?: Parameters<typeof MobileNativeChatView>[0]['messages']
|
||||
folded?: Parameters<typeof MobileNativeChatView>[0]['folded']
|
||||
streaming?: string | null
|
||||
sendErrorMessage?: string | null
|
||||
onClearSendError?: () => void
|
||||
inputLockReason?: 'disconnected' | 'waiting' | null
|
||||
onSend?: (text: string) => Promise<boolean>
|
||||
pending?: Parameters<typeof MobileNativeChatView>[0]['pending']
|
||||
structuredActivityUi?: boolean
|
||||
turnIndicator?: Parameters<typeof MobileNativeChatView>[0]['turnIndicator']
|
||||
agentWorking?: boolean
|
||||
canStop?: boolean
|
||||
ask?: Parameters<typeof MobileNativeChatView>[0]['ask']
|
||||
question?: Parameters<typeof MobileNativeChatView>[0]['question']
|
||||
permission?: Parameters<typeof MobileNativeChatView>[0]['permission']
|
||||
sendSurfaceId?: string
|
||||
keyboardInset?: number
|
||||
hasMore?: boolean
|
||||
onLoadEarlier?: () => void
|
||||
status?: Parameters<typeof MobileNativeChatView>[0]['status']
|
||||
error?: string
|
||||
readFailedFinally?: boolean
|
||||
actionablePromptIds?: readonly string[]
|
||||
}
|
||||
|
||||
function assistantTurn(id: string, text: string): NativeChatMessage {
|
||||
return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' }
|
||||
}
|
||||
|
||||
function chatViewElement(overrides: Overrides): ReturnType<typeof createElement> {
|
||||
return createElement(MobileNativeChatView, {
|
||||
messages: [],
|
||||
folded: [],
|
||||
status: 'ready',
|
||||
streaming: null,
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
sendSurfaceId: 'tab-a',
|
||||
getSendCompletionGeneration: () => 0,
|
||||
pending: [],
|
||||
composerText: '',
|
||||
onComposerTextChange: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('MobileNativeChatView', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => callback(0), 0)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', (handle: ReturnType<typeof setTimeout>) =>
|
||||
clearTimeout(handle)
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
scrollToEnd.mockReset()
|
||||
scrollToOffset.mockReset()
|
||||
pageScroller.scrollHeight = 0
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function render(overrides: Overrides = {}): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer = create(chatViewElement(overrides))
|
||||
})
|
||||
}
|
||||
|
||||
async function update(overrides: Overrides = {}): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer?.update(chatViewElement(overrides))
|
||||
})
|
||||
}
|
||||
|
||||
it("holds Stop and says Stopping while this phone's own Stop request is in flight", async () => {
|
||||
await render({
|
||||
structuredActivityUi: true,
|
||||
agentWorking: true,
|
||||
canStop: true,
|
||||
turnIndicator: {
|
||||
thinking: false,
|
||||
activityText: null,
|
||||
stopping: true,
|
||||
stopRequestInFlight: true
|
||||
}
|
||||
})
|
||||
const stop = renderer!.root.find(
|
||||
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Stopping…'
|
||||
)
|
||||
expect(stop.props.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['queue', 'Queue a message to run after the stop'],
|
||||
['send', 'Send a message to run after the stop']
|
||||
] as const)(
|
||||
'tells the composer a message sent now runs after the stop (%s)',
|
||||
async (afterStop, placeholder) => {
|
||||
await render({
|
||||
structuredActivityUi: true,
|
||||
agentWorking: true,
|
||||
canStop: true,
|
||||
turnIndicator: { thinking: false, activityText: null, stopping: true, afterStop }
|
||||
})
|
||||
const composer = renderer!.root.find((node) => node.type === 'Composer')
|
||||
expect(composer.props.placeholder).toBe(placeholder)
|
||||
}
|
||||
)
|
||||
|
||||
// A Stop the provider took and never answered ends only at a repeat Stop.
|
||||
it('keeps Stop for the repeat that escalates while the host alone says Stopping', async () => {
|
||||
await render({
|
||||
structuredActivityUi: true,
|
||||
agentWorking: true,
|
||||
canStop: true,
|
||||
turnIndicator: { thinking: false, activityText: null, stopping: true }
|
||||
})
|
||||
const stop = renderer!.root.find(
|
||||
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Stop the agent'
|
||||
)
|
||||
expect(stop.props.disabled).toBe(false)
|
||||
})
|
||||
|
||||
/** Ids of the rows the list is currently rendering. */
|
||||
it('keeps Stop hidden during a structured dispatch until a provider turn can be cancelled', async () => {
|
||||
const props = { structuredActivityUi: true, agentWorking: true, canStop: false }
|
||||
await render(props)
|
||||
const stops = () =>
|
||||
renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Stop the agent')
|
||||
expect(stops()).toHaveLength(0)
|
||||
await update({ ...props, canStop: true })
|
||||
expect(stops()).toHaveLength(1)
|
||||
await update({ agentWorking: true })
|
||||
expect(stops()).toHaveLength(1)
|
||||
})
|
||||
|
||||
function listIds(): string[] {
|
||||
return (list().props.data as { id: string }[]).map((row) => row.id)
|
||||
}
|
||||
|
||||
function list(): ReactTestInstance {
|
||||
return renderer!.root.find((node) => node.type === 'FlatList')
|
||||
}
|
||||
|
||||
function renderedRow(id: string): ReturnType<typeof createElement> {
|
||||
const listNode = list()
|
||||
const data = listNode.props.data as NativeChatMessage[]
|
||||
const index = data.findIndex((row) => row.id === id)
|
||||
return listNode.props.renderItem({ item: data[index], index })
|
||||
}
|
||||
|
||||
function banners(): ReactTestInstance[] {
|
||||
return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert')
|
||||
}
|
||||
|
||||
function composer(): ReactTestInstance {
|
||||
return renderer!.root.find((node) => node.type === 'Composer')
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
async function scrollAwayFromTail(): Promise<void> {
|
||||
await act(async () => {
|
||||
list().props.onScrollBeginDrag?.({})
|
||||
list().props.onScroll({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 200 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// A read no retry gets past takes the whole pane, even over a transcript already on screen: its
|
||||
// words alone, and nothing that could only be refused again.
|
||||
it('leaves only the words of a read that failed for good, over a loaded transcript', async () => {
|
||||
const loaded = [assistantTurn('m1', 'earlier reply')]
|
||||
const words = 'This chat was saved by a newer Orca. Update Orca to open it.'
|
||||
const shown = (text: string) =>
|
||||
renderer!.root.findAll((node) => node.type === 'Text' && node.props.children === text)
|
||||
const composers = () => renderer!.root.findAll((node) => node.type === 'Composer')
|
||||
const lists = () => renderer!.root.findAll((node) => node.type === 'FlatList')
|
||||
const failure = {
|
||||
messages: loaded,
|
||||
folded: loaded,
|
||||
status: 'error' as const,
|
||||
error: words,
|
||||
canStop: true,
|
||||
permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] },
|
||||
// A resend answered unknown says nothing beside the read's words.
|
||||
sendErrorMessage: 'Message unconfirmed — check chat before retrying'
|
||||
}
|
||||
await render({ ...failure, readFailedFinally: true })
|
||||
expect(shown(words)).toHaveLength(1)
|
||||
expect(composers()).toHaveLength(0)
|
||||
expect(lists()).toHaveLength(0)
|
||||
expect(banners()).toHaveLength(0)
|
||||
expect(renderer!.root.findAllByProps({ accessibilityLabel: 'Stop the agent' })).toHaveLength(0)
|
||||
|
||||
// A failure that can clear keeps the transcript and the composer.
|
||||
await update({ ...failure, error: "Orca couldn't open this chat's history right now." })
|
||||
expect(listIds()).toEqual(['m1'])
|
||||
expect(composers()).toHaveLength(1)
|
||||
expect(banners()).toHaveLength(1)
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
// The gate that decides `streaming` lives in MobileNativeChatOverlay, which
|
||||
// outlives this view; see MobileNativeChatOverlay.test.ts.
|
||||
it('appends the gated streaming bubble after the folded transcript', async () => {
|
||||
const folded = [assistantTurn('a1', 'The tests pass.')]
|
||||
await render({ folded })
|
||||
expect(listIds()).toEqual(['a1'])
|
||||
|
||||
await update({ folded, streaming: 'The tests' })
|
||||
|
||||
expect(listIds()).toEqual(['a1', 'streaming'])
|
||||
})
|
||||
|
||||
it('lets content growth own streaming tail-follow without a delayed animated command', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const folded = [assistantTurn('a1', 'Starting')]
|
||||
await render({ folded })
|
||||
await act(async () => {
|
||||
vi.runOnlyPendingTimers()
|
||||
})
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
await update({ folded, streaming: 'Streaming output' })
|
||||
act(() => list().props.onContentSizeChange(320, 900))
|
||||
|
||||
expect(scrollToOffset).toHaveBeenCalledOnce()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 900 })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60)
|
||||
})
|
||||
expect(scrollToOffset).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('stops tail-follow before loading earlier history can resize the list', async () => {
|
||||
const folded = [assistantTurn('a1', 'History')]
|
||||
const onLoadEarlier = vi.fn()
|
||||
await render({ folded, hasMore: true, onLoadEarlier })
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
act(() => {
|
||||
list().props.onScrollBeginDrag?.({})
|
||||
list().props.onScroll({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 40 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onContentSizeChange(320, 950)
|
||||
})
|
||||
|
||||
expect(onLoadEarlier).toHaveBeenCalledOnce()
|
||||
expect(scrollToEnd).not.toHaveBeenCalled()
|
||||
expect(scrollToOffset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not treat programmatic scroll metrics as user intent', async () => {
|
||||
const folded = [assistantTurn('a1', 'Latest')]
|
||||
await render({ folded })
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
act(() => {
|
||||
list().props.onScroll({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 200 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onContentSizeChange(320, 1_300)
|
||||
})
|
||||
|
||||
expect(scrollToOffset).toHaveBeenCalledOnce()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_300 })
|
||||
expect(
|
||||
renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps tail-follow paused across the drag-to-momentum handoff', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const folded = [assistantTurn('a1', 'Latest')]
|
||||
await render({ folded })
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
act(() => list().props.onScrollBeginDrag?.({}))
|
||||
expect(
|
||||
renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
|
||||
).toHaveLength(0)
|
||||
|
||||
act(() => {
|
||||
list().props.onScroll({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 700 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onScrollEndDrag?.({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 700 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onContentSizeChange(320, 1_250)
|
||||
list().props.onMomentumScrollBegin?.({})
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
act(() => list().props.onContentSizeChange(320, 1_300))
|
||||
|
||||
expect(scrollToEnd).not.toHaveBeenCalled()
|
||||
expect(scrollToOffset).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
list().props.onMomentumScrollEnd?.({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 800 },
|
||||
contentSize: { height: 1_300 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onContentSizeChange(320, 1_350)
|
||||
})
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_350 })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses momentum-end metrics instead of a stale throttled scroll sample', async () => {
|
||||
const folded = [assistantTurn('a1', 'Latest')]
|
||||
await render({ folded })
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
act(() => {
|
||||
list().props.onScrollBeginDrag?.({})
|
||||
list().props.onScroll({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 700 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onScrollEndDrag?.({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 700 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onMomentumScrollBegin?.({})
|
||||
list().props.onMomentumScrollEnd?.({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 200 },
|
||||
contentSize: { height: 1_300 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onContentSizeChange(320, 1_350)
|
||||
})
|
||||
|
||||
expect(scrollToEnd).not.toHaveBeenCalled()
|
||||
expect(scrollToOffset).not.toHaveBeenCalled()
|
||||
expect(
|
||||
renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('uses finger-release metrics when no momentum event follows', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const folded = [assistantTurn('a1', 'Latest')]
|
||||
await render({ folded })
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
act(() => {
|
||||
list().props.onScrollBeginDrag?.({})
|
||||
list().props.onScroll({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 200 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onScrollEndDrag?.({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 620 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
act(() => list().props.onContentSizeChange(320, 1_250))
|
||||
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_250 })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('repins when content grows between tail release and drag settle', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const folded = [assistantTurn('a1', 'Latest')]
|
||||
await render({ folded })
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
act(() => {
|
||||
list().props.onScrollBeginDrag?.({})
|
||||
list().props.onScrollEndDrag?.({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 700 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onContentSizeChange(320, 1_250)
|
||||
})
|
||||
|
||||
expect(scrollToEnd).not.toHaveBeenCalled()
|
||||
expect(scrollToOffset).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
vi.runOnlyPendingTimers()
|
||||
})
|
||||
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })
|
||||
expect(
|
||||
renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
|
||||
).toHaveLength(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses measured content height when the first message fills an empty transcript', async () => {
|
||||
await render()
|
||||
|
||||
await pressSend()
|
||||
expect(scrollToEnd).not.toHaveBeenCalled()
|
||||
|
||||
await update({ folded: [assistantTurn('a1', 'First response')] })
|
||||
act(() => list().props.onContentSizeChange(320, 1_200))
|
||||
|
||||
expect(scrollToOffset).toHaveBeenCalledOnce()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_200 })
|
||||
})
|
||||
|
||||
it('resumes following when the drag that paged in history ends at the tail', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
// A chat shorter than the screen: its top and its tail are the same place.
|
||||
const shortChat = {
|
||||
contentOffset: { y: 0 },
|
||||
contentSize: { height: 400 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
const folded = [assistantTurn('a1', 'Short history')]
|
||||
const onLoadEarlier = vi.fn()
|
||||
await render({ folded, hasMore: true, onLoadEarlier })
|
||||
|
||||
act(() => {
|
||||
list().props.onScrollBeginDrag?.({})
|
||||
list().props.onScroll({ nativeEvent: shortChat })
|
||||
list().props.onScrollEndDrag?.({ nativeEvent: shortChat })
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
act(() => list().props.onContentSizeChange(320, 950))
|
||||
|
||||
expect(onLoadEarlier).toHaveBeenCalledOnce()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 950 })
|
||||
expect(
|
||||
renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
|
||||
).toHaveLength(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('pages in history only from a scroll the reader made', async () => {
|
||||
const onLoadEarlier = vi.fn()
|
||||
await render({ folded: [assistantTurn('a1', 'Latest')], hasMore: true, onLoadEarlier })
|
||||
const nearTop = {
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 40 },
|
||||
contentSize: { height: 520 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
}
|
||||
|
||||
// The tail pin of a chat barely taller than the screen reports an offset near the top too.
|
||||
act(() => list().props.onScroll(nearTop))
|
||||
expect(onLoadEarlier).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
list().props.onScrollBeginDrag?.({})
|
||||
list().props.onScroll(nearTop)
|
||||
})
|
||||
expect(onLoadEarlier).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('anchors the first visible row, which the header above it is not', async () => {
|
||||
const folded = [assistantTurn('a1', 'History')]
|
||||
await render({ folded, hasMore: true })
|
||||
expect(list().props.maintainVisibleContentPosition).toEqual({ minIndexForVisible: 1 })
|
||||
|
||||
await update({ folded, hasMore: false })
|
||||
expect(list().props.maintainVisibleContentPosition).toEqual({ minIndexForVisible: 0 })
|
||||
})
|
||||
|
||||
describe('on the page, where the list reports no drag or momentum', () => {
|
||||
beforeEach(() => {
|
||||
platform.OS = 'web'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
platform.OS = 'ios'
|
||||
})
|
||||
|
||||
function scrolledTo(y: number, contentHeight: number) {
|
||||
return {
|
||||
nativeEvent: {
|
||||
contentOffset: { y },
|
||||
contentSize: { height: contentHeight },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('stops following when the reader scrolls up, and resumes at the end', async () => {
|
||||
await render({ folded: [assistantTurn('a1', 'Latest')] })
|
||||
act(() => {
|
||||
list().props.onContentSizeChange(320, 1_000)
|
||||
list().props.onScroll(scrolledTo(500, 1_000))
|
||||
})
|
||||
scrollToOffset.mockClear()
|
||||
|
||||
// No drag event arrives on the page: the move up is all there is to go on.
|
||||
act(() => {
|
||||
list().props.onScroll(scrolledTo(300, 1_000))
|
||||
list().props.onContentSizeChange(320, 1_200)
|
||||
})
|
||||
expect(scrollToOffset).not.toHaveBeenCalled()
|
||||
expect(
|
||||
renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
|
||||
).toHaveLength(1)
|
||||
|
||||
act(() => {
|
||||
list().props.onScroll(scrolledTo(700, 1_200))
|
||||
list().props.onContentSizeChange(320, 1_400)
|
||||
})
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_400 })
|
||||
})
|
||||
|
||||
it('does not take its own pin, landing short of a grown end, for the reader', async () => {
|
||||
await render({ folded: [assistantTurn('a1', 'Latest')] })
|
||||
act(() => {
|
||||
list().props.onContentSizeChange(320, 1_000)
|
||||
list().props.onScroll(scrolledTo(500, 1_000))
|
||||
})
|
||||
scrollToOffset.mockClear()
|
||||
|
||||
// The pin reached the end it was sent to; the reply had grown 400 past it by then.
|
||||
act(() => {
|
||||
list().props.onScroll(scrolledTo(700, 1_600))
|
||||
list().props.onContentSizeChange(320, 1_600)
|
||||
})
|
||||
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_600 })
|
||||
})
|
||||
|
||||
it("opens another chat at its measured end, not the last chat's", async () => {
|
||||
await render({ folded: [assistantTurn('a1', 'Chat A')] })
|
||||
act(() => list().props.onContentSizeChange(320, 1_000))
|
||||
pageScroller.scrollHeight = 2_500
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
await update({ folded: [assistantTurn('b1', 'Chat B')], sendSurfaceId: 'tab-b' })
|
||||
|
||||
expect(scrollToEnd).not.toHaveBeenCalled()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 2_500 })
|
||||
})
|
||||
|
||||
it('pages in history when the reader scrolls up to the top, not when a pin passes it', async () => {
|
||||
const onLoadEarlier = vi.fn()
|
||||
await render({ folded: [assistantTurn('a1', 'Latest')], hasMore: true, onLoadEarlier })
|
||||
|
||||
act(() => list().props.onScroll(scrolledTo(40, 540)))
|
||||
expect(onLoadEarlier).not.toHaveBeenCalled()
|
||||
|
||||
act(() => list().props.onScroll(scrolledTo(10, 540)))
|
||||
expect(onLoadEarlier).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
it('routes an accepted send through the immediate nonanimated tail owner', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const folded = [assistantTurn('a1', 'History')]
|
||||
await render({ folded })
|
||||
await act(async () => {
|
||||
vi.runOnlyPendingTimers()
|
||||
})
|
||||
await scrollAwayFromTail()
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
await pressSend()
|
||||
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60)
|
||||
})
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('routes the latest-message chevron through the tail owner and resumes following', async () => {
|
||||
const folded = [assistantTurn('a1', 'History')]
|
||||
await render({ folded })
|
||||
await scrollAwayFromTail()
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
const chevron = renderer!.root.find(
|
||||
(node) => node.props.accessibilityLabel === 'Scroll to latest'
|
||||
)
|
||||
act(() => chevron.props.onPress())
|
||||
expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })
|
||||
|
||||
act(() => list().props.onContentSizeChange(320, 1_300))
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_300 })
|
||||
})
|
||||
|
||||
// The view stays mounted across chat tabs, so the reader's place in one must not carry over.
|
||||
it('opens another chat at its newest message, following, keeping the Tools toggle', async () => {
|
||||
await render({ folded: [assistantTurn('a1', 'Chat A')] })
|
||||
await scrollAwayFromTail()
|
||||
const label = (text: string) => renderer!.root.findAllByProps({ children: text })
|
||||
const jumpControls = () =>
|
||||
renderer!.root.findAllByProps({ accessibilityLabel: 'Scroll to latest' })
|
||||
act(() => label('Tools')[0].parent!.props.onPress())
|
||||
expect(jumpControls()).toHaveLength(1)
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
await update({ folded: [assistantTurn('b1', 'Chat B')], sendSurfaceId: 'tab-b' })
|
||||
|
||||
expect(scrollToEnd).toHaveBeenCalledWith({ animated: false })
|
||||
expect(jumpControls()).toHaveLength(0)
|
||||
act(() => list().props.onContentSizeChange(320, 900))
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 900 })
|
||||
expect(label('Collapse')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('repins after a keyboard-driven viewport layout only while following', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const folded = [assistantTurn('a1', 'Latest')]
|
||||
await render({ folded })
|
||||
await act(async () => {
|
||||
vi.runOnlyPendingTimers()
|
||||
})
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
await update({ folded, keyboardInset: 320 })
|
||||
act(() => list().props.onLayout?.({ nativeEvent: { layout: { height: 400 } } }))
|
||||
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60)
|
||||
})
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
|
||||
await scrollAwayFromTail()
|
||||
scrollToEnd.mockClear()
|
||||
await update({ folded, keyboardInset: 0 })
|
||||
act(() => list().props.onLayout?.({ nativeEvent: { layout: { height: 700 } } }))
|
||||
expect(scrollToEnd).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('tells a row whether the host still waits on the prompt it stands for', async () => {
|
||||
const rows = [assistantTurn('asked', 'Tea or coffee?'), assistantTurn('open', 'Which branch?')]
|
||||
await render({ messages: rows, folded: rows, actionablePromptIds: ['open'] })
|
||||
expect(renderedRow('asked').props.awaiting).toBe(false)
|
||||
expect(renderedRow('open').props.awaiting).toBe(true)
|
||||
})
|
||||
|
||||
it('renders an accepted optimistic image send without a queued state', async () => {
|
||||
await render({
|
||||
pending: [{ id: 'pending-1', text: 'look', images: ['file:///phone-photo.jpg'] }]
|
||||
})
|
||||
|
||||
expect(listIds()).toEqual(['pending-1'])
|
||||
expect(renderedRow('pending-1').props).not.toHaveProperty('queued')
|
||||
})
|
||||
|
||||
it('keeps a visible lock through a subscribed-end lease blip', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await render({ inputLockReason: 'waiting' })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
expect(composer().props.disabled).toBe(true)
|
||||
|
||||
await update({ inputLockReason: null })
|
||||
expect(composer().props.disabled).toBe(true)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
await update({ inputLockReason: 'waiting' })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
|
||||
expect(composer().props.disabled).toBe(true)
|
||||
expect(composer().props.placeholder).toBe('Waiting for terminal…')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('unlocks after the lease stays ready', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await render({ inputLockReason: 'waiting' })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
await update({ inputLockReason: null })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(599)
|
||||
})
|
||||
expect(composer().props.disabled).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1)
|
||||
})
|
||||
|
||||
expect(composer().props.disabled).toBe(false)
|
||||
expect(composer().props.placeholder).toBe('Message, @files, /commands')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
// Why: a terminal-backed send types into the agent's prompt and could answer it; drafting cannot.
|
||||
describe('a prompt card owns Send in terminal-backed chat', () => {
|
||||
const permission = { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] }
|
||||
|
||||
it('blocks only Send while the card shows, and frees it when the card clears', async () => {
|
||||
await render({ permission })
|
||||
expect(composer().props.sendDisabled).toBe(true)
|
||||
expect(composer().props.disabled).toBe(false)
|
||||
expect(composer().props.placeholder).toBe('Message, @files, /commands')
|
||||
|
||||
await update({ permission: null })
|
||||
expect(composer().props.sendDisabled).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks Send for an ask and a heuristic question too', async () => {
|
||||
await render({
|
||||
ask: {
|
||||
questions: [{ question: 'Tabs?', multiSelect: false, options: [{ label: 'Tabs' }] }]
|
||||
}
|
||||
})
|
||||
expect(composer().props.sendDisabled).toBe(true)
|
||||
await update({
|
||||
question: { question: 'Name?', options: [], multiSelect: false, optionTokens: [] }
|
||||
})
|
||||
expect(composer().props.sendDisabled).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves a structured chat composer open: its host queues the send behind the prompt', async () => {
|
||||
await render({ permission, structuredActivityUi: true })
|
||||
expect(composer().props.sendDisabled).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -47,7 +47,6 @@ src/session/MobileNativeChatQuestion.test.tsx
|
||||
src/session/MobileNativeChatSessionOptionPickers.test.ts
|
||||
src/session/MobileNativeChatSessionOptionRows.test.ts
|
||||
src/session/MobileNativeChatTurnStatus.test.ts
|
||||
src/session/MobileNativeChatView.test.ts
|
||||
src/session/QuickCommandsList.test.ts
|
||||
src/session/QuickCommandsSheet.test.ts
|
||||
src/session/ai-vault-resume-launch.test.ts
|
||||
|
||||
Reference in New Issue
Block a user