diff --git a/apps/mobile/src/components/agents/chat-composer-send-once.mounted.test.tsx b/apps/mobile/src/components/agents/chat-composer-send-once.mounted.test.tsx new file mode 100644 index 0000000000..c9edf8cc8c --- /dev/null +++ b/apps/mobile/src/components/agents/chat-composer-send-once.mounted.test.tsx @@ -0,0 +1,430 @@ +/* eslint-disable max-lines -- the mocked native surface needs one mock block per bridge */ +/* eslint-disable require-await, @typescript-eslint/require-await -- mock factories settle without await, matching chat-composer.test.ts */ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as chat-composer-input-row.mounted.test.tsx) */ +import * as React from 'react'; +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ChatComposer } from './chat-composer'; + +// Pins the session send contract at the component level: ONE send press sends +// the current draft exactly once, with the keyboard reported open or closed +// and across input focus changes; an empty draft never sends; a second press +// while the first send is in flight is deduplicated. The press invokes the +// mounted Send Pressable's handler after asserting it is not disabled, so the +// full JS submit path (coalescer flush, voice settle, submit lock, admission +// gate, optimistic clear) runs exactly as a device tap reaches it. + +// ── native bridges ─────────────────────────────────────────────────────────── +const keyboardListeners = vi.hoisted(() => ({ + show: null as ((event: { endCoordinates: { height: number } }) => void) | null, + hide: null as (() => void) | null, +})); +const keyboardDismiss = vi.hoisted(() => vi.fn()); + +vi.mock('react-native', () => ({ + AccessibilityInfo: { + addEventListener: vi.fn(() => ({ remove: vi.fn() })), + announceForAccessibility: vi.fn(), + isReduceTransparencyEnabled: vi.fn(async () => false), + }, + Alert: { alert: vi.fn() }, + AppState: { addEventListener: vi.fn(() => ({ remove: vi.fn() })) }, + I18nManager: { isRTL: false }, + Keyboard: { + addListener: vi.fn((event: string, handler: never) => { + if (event === 'keyboardWillShow' || event === 'keyboardDidShow') { + keyboardListeners.show = handler; + } + if (event === 'keyboardWillHide' || event === 'keyboardDidHide') { + keyboardListeners.hide = handler; + } + return { remove: vi.fn() }; + }), + dismiss: keyboardDismiss, + }, + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + Text: 'Text', + TextInput: 'TextInput', + View: 'View', + useWindowDimensions: () => ({ fontScale: 1, height: 800, scale: 1, width: 400 }), +})); + +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 }), +})); + +vi.mock('react-native-gesture-handler', () => ({ + // The dismiss pan is native-device behavior; here the detector passes the + // row through so the JS submit path runs unmodified. + Gesture: { + Pan: () => { + const builder = { + runOnJS: () => builder, + activeOffsetY: () => builder, + failOffsetX: () => builder, + enabled: () => builder, + onStart: () => builder, + }; + return builder; + }, + }, + GestureDetector: ({ children }: { children: React.ReactElement }) => children, +})); + +vi.mock('react-native-reanimated', () => ({ + default: { View: 'Animated.View' }, + FadeIn: { duration: vi.fn(() => ({})) }, + FadeOut: { duration: vi.fn(() => ({})) }, +})); + +vi.mock('@/lib/a11y/motion', () => ({ + selectReducedMotionEntrance: (reduced: boolean, entrance: T) => + reduced ? undefined : entrance, + useMotionPolicy: () => ({ reducedMotion: false, scrollAnimated: true }), +})); + +vi.mock('expo-haptics', () => ({ + impactAsync: vi.fn(async () => undefined), + ImpactFeedbackStyle: { Light: 'light', Medium: 'medium' }, +})); + +vi.mock('@expo/react-native-action-sheet', () => ({ + useActionSheet: () => ({ showActionSheetWithOptions: vi.fn() }), +})); + +vi.mock('sonner-native', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + +vi.mock('expo-router', () => ({ + useNavigation: () => ({ dispatch: vi.fn() }), +})); + +vi.mock('@/lib/navigation/prevent-remove', () => ({ + usePreventRemove: vi.fn(), +})); + +// ── presentation children ──────────────────────────────────────────────────── +vi.mock('@/components/ui/accessible-status', () => ({ AccessibleStatus: () => null })); +vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/icons', () => ({ + ArrowUp: 'ArrowUp', + CornerDownLeft: 'CornerDownLeft', + Paperclip: 'Paperclip', + Square: 'Square', +})); +vi.mock('@/components/ui/blur-bar', () => ({ + BlurBar: ({ children }: { children: React.ReactElement }) => children, +})); +vi.mock('@/components/agents/attachment-preview-strip', () => ({ + AttachmentPreviewStrip: () => null, +})); +vi.mock('@/components/agents/chat-toolbar', () => ({ ChatToolbar: () => null })); +vi.mock('@/components/agents/slash-command-suggestions', () => ({ + SlashCommandSuggestions: () => null, +})); +vi.mock('@/components/agents/suggestion-card', () => ({ SuggestionCard: () => null })); +vi.mock('@/components/agents/remote-session-exit-alert', () => ({ + showRemoteSessionExitConfirmation: vi.fn(async () => true), +})); +vi.mock('@/components/agents/remote-session-exit-confirmation', () => ({ + confirmRemoteSessionExit: vi.fn(async (_confirm: unknown, run: () => Promise) => run()), +})); +vi.mock('@/components/agents/attachment-picker', () => ({ + pickAgentAttachments: vi.fn(async () => []), +})); +vi.mock('@/components/voice-input-control', () => ({ + VoiceInputButton: 'VoiceInputButton', + VoiceInputStatus: () => null, +})); + +// ── hooks and libs ─────────────────────────────────────────────────────────── +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ + accentSoftForeground: '#000', + destructiveForeground: '#f00', + foreground: '#000', + mutedForeground: '#666', + primaryForeground: '#fff', + }), +})); + +vi.mock('@/lib/hooks/use-current-user-id', () => ({ + useCurrentUserId: () => ({ userId: 'user-1', isLoading: false }), +})); + +vi.mock('@/lib/hooks/use-return-sends-message-preference', () => ({ + useReturnSendsMessagePreference: () => ({ + returnSendsMessage: false, + hasLoaded: true, + setReturnSendsMessage: vi.fn(), + }), +})); + +vi.mock('@/lib/persist/drafts', () => ({ + clearDraft: vi.fn(), + saveDraft: vi.fn(), +})); +vi.mock('@/lib/persist/use-draft-flush', () => ({ + useDraftFlushOnBackground: vi.fn(), +})); +vi.mock('@/lib/share-prefill', () => ({ + useSharePrefill: vi.fn(), +})); + +const uploadMock = vi.hoisted(() => ({ + addCandidates: vi.fn(async () => undefined), + attachments: [] as unknown[], + clearOptimistic: vi.fn(), + commitSent: vi.fn(), + hasFailedAttachments: false, + isUploading: false, + moveAttachment: vi.fn(), + releaseUnclaimedUploads: vi.fn(), + removeAttachment: vi.fn(), + reorderAttachments: vi.fn(), + restoreChips: vi.fn(), + restoreFileParts: vi.fn(), + retryAttachment: vi.fn(), + uploadPending: vi.fn(async () => ({ ok: true as const })), +})); +vi.mock('@/lib/agent-attachments/use-agent-attachment-upload', () => ({ + useAgentAttachmentUpload: () => uploadMock, +})); +vi.mock('@/lib/agent-attachments/use-android-pending-picker-recovery', () => ({ + useAndroidPendingPickerRecovery: vi.fn(), +})); +vi.mock('@/lib/agent-attachments/use-clipboard-paste', () => ({ + clipboardPasteEmptyMessage: () => 'Clipboard is empty', + useClipboardPaste: () => ({ paste: vi.fn() }), +})); + +vi.mock('@/lib/voice-input/use-voice-input', () => ({ + useVoiceInput: () => ({ + abort: vi.fn(async () => true), + available: true, + isActive: false, + settleBeforeSubmit: vi.fn(async () => true), + status: 'idle' as const, + toggle: vi.fn(async () => undefined), + }), +})); + +// ── harness ────────────────────────────────────────────────────────────────── +function baseProps(overrides: Partial[0]> = {}) { + return { + activeSessionType: null, + attachmentsEnabled: false, + commands: [], + commandState: null, + disabled: false, + isStreaming: false, + model: 'model-a', + modelOptions: [], + mode: 'build' as never, + onCreateSession: vi.fn(async () => true), + onExitSession: vi.fn(async () => undefined), + onModeChange: vi.fn(), + onModelSelect: vi.fn(), + onRestartSession: vi.fn(async () => true), + onSend: vi.fn(async () => undefined), + onSendCommand: vi.fn(async () => true), + onStop: vi.fn(async () => undefined), + variant: '', + ...overrides, + }; +} + +async function mountComposer( + props: Parameters[0] +): Promise { + const holder: { current?: TestRenderer.ReactTestRenderer } = {}; + await act(async () => { + holder.current = TestRenderer.create(createElement(ChatComposer, props)); + }); + if (holder.current === undefined) { + throw new Error('renderer was not created'); + } + return holder.current; +} + +type SendControl = { disabled: boolean; press: () => void }; + +function sendControl(root: TestRenderer.ReactTestInstance, label = 'Send message'): SendControl { + const matches = root.findAll( + node => typeof node.type === 'string' && node.props.accessibilityLabel === label + ); + const node = matches[0]; + if (node === undefined) { + throw new Error(`pressable "${label}" not found`); + } + const props = node.props as { disabled?: boolean; onPress?: () => void }; + if (props.onPress === undefined) { + throw new Error(`pressable "${label}" has no onPress`); + } + return { disabled: props.disabled === true, press: props.onPress }; +} + +async function tick(ms = 0): Promise { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} + +function findTextInput(root: TestRenderer.ReactTestInstance): TestRenderer.ReactTestInstance { + return root.find(node => typeof node.type === 'string' && (node.type as string) === 'TextInput'); +} + +async function typeIntoComposer( + renderer: TestRenderer.ReactTestRenderer, + text: string +): Promise { + const input = findTextInput(renderer.root); + await act(async () => { + (input.props.onChangeText as (value: string) => void)(text); + // One macrotask publishes the coalesced derived state (hasText, counter). + await tick(); + }); +} + +/** Drains the submit chain's macrotask rounds (voice settle → upload → send). */ +async function flushSubmitChain(): Promise { + for (let round = 0; round < 8; round += 1) { + // eslint-disable-next-line no-await-in-loop -- each round settles one link of the async submit chain + await tick(); + } +} + +describe('ChatComposer send — one tap sends exactly once', () => { + beforeEach(() => { + keyboardListeners.show = null; + keyboardListeners.hide = null; + keyboardDismiss.mockClear(); + uploadMock.uploadPending.mockClear(); + uploadMock.uploadPending.mockImplementation(async () => ({ ok: true as const })); + }); + + it('sends the typed message on the first send press, keyboard closed', async () => { + const onSend = vi.fn(async () => undefined); + const renderer = await mountComposer(baseProps({ onSend })); + + await typeIntoComposer(renderer, 'hello from the repro'); + + const send = sendControl(renderer.root); + expect(send.disabled).toBe(false); + + await act(async () => { + send.press(); + await flushSubmitChain(); + }); + + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSend).toHaveBeenCalledWith('hello from the repro', expect.anything()); + + renderer.unmount(); + }); + + it('sends the typed message on the first send press, keyboard open', async () => { + const onSend = vi.fn(async () => undefined); + const renderer = await mountComposer(baseProps({ onSend })); + + // Keyboard open: the composer's listener tracks the reported height. + await act(async () => { + keyboardListeners.show?.({ endCoordinates: { height: 336 } }); + }); + await typeIntoComposer(renderer, 'typed with the keyboard up'); + + const send = sendControl(renderer.root); + expect(send.disabled).toBe(false); + + await act(async () => { + send.press(); + await flushSubmitChain(); + }); + + expect(onSend).toHaveBeenCalledTimes(1); + + renderer.unmount(); + }); + + it('sends once across input focus and blur changes before the press', async () => { + const onSend = vi.fn(async () => undefined); + const renderer = await mountComposer(baseProps({ onSend })); + + const input = findTextInput(renderer.root); + await act(async () => { + (input.props.onFocus as () => void)(); + (input.props.onChangeText as (value: string) => void)('focus changed mid-draft'); + await tick(); + (input.props.onBlur as () => void)(); + }); + + const send = sendControl(renderer.root); + expect(send.disabled).toBe(false); + + await act(async () => { + send.press(); + await flushSubmitChain(); + }); + + expect(onSend).toHaveBeenCalledTimes(1); + + renderer.unmount(); + }); + + it('does not send on a press with an empty draft', async () => { + const onSend = vi.fn(async () => undefined); + const renderer = await mountComposer(baseProps({ onSend })); + + const send = sendControl(renderer.root); + expect(send.disabled).toBe(true); + + // Even if the press handler ran, the empty draft must not send. + await act(async () => { + send.press(); + await flushSubmitChain(); + }); + + expect(onSend).not.toHaveBeenCalled(); + + renderer.unmount(); + }); + + it('ignores a second send press while the first send is in flight', async () => { + let releaseSend: (() => void) | undefined = undefined; + const onSend = vi.fn( + async (): Promise => + new Promise(resolve => { + releaseSend = resolve; + }) + ); + const renderer = await mountComposer(baseProps({ onSend })); + + await typeIntoComposer(renderer, 'in-flight dedupe'); + + const send = sendControl(renderer.root); + expect(send.disabled).toBe(false); + await act(async () => { + send.press(); + // The first send stays pending until releaseSend fires below. + }); + + await act(async () => { + send.press(); + await flushSubmitChain(); + }); + + await act(async () => { + releaseSend?.(); + await flushSubmitChain(); + }); + + expect(onSend).toHaveBeenCalledTimes(1); + + renderer.unmount(); + }); +}); diff --git a/apps/mobile/src/components/agents/markdown-renderer.test.ts b/apps/mobile/src/components/agents/markdown-renderer.test.ts index 97bff4ee3c..576f582e68 100644 --- a/apps/mobile/src/components/agents/markdown-renderer.test.ts +++ b/apps/mobile/src/components/agents/markdown-renderer.test.ts @@ -11,6 +11,13 @@ import { confirmAndOpenMarkdownLink } from './markdown-link-confirm'; import { type MarkdownPalette } from './markdown-palette'; import { type MarkdownRenderer } from './markdown-renderer'; +// The first createRenderer() pays the full react-native-marked import, and the +// empty-fence suite re-imports that graph after vi.resetModules(). On a loaded +// machine (the full gate saturates every core) either import can stretch past +// the 5 s vitest default and fail a healthy test. The file-wide timeout leaves +// that headroom; a genuinely hung import still fails, only later. +vi.setConfig({ testTimeout: 30_000 }); + // react-native-marked is externalized by vitest, so vi.mock('react-native') does // not intercept its nested requires. Patch Module._load before loading the // library so the real Renderer (and github-slugger) can construct under node. diff --git a/apps/mobile/src/lib/auth/auth-context.test.tsx b/apps/mobile/src/lib/auth/auth-context.test.tsx index 19742e4ebd..dda160f0e3 100644 --- a/apps/mobile/src/lib/auth/auth-context.test.tsx +++ b/apps/mobile/src/lib/auth/auth-context.test.tsx @@ -9,6 +9,14 @@ import type * as AuthContextModule from './auth-context'; import type * as ContextScopeModule from '../context-scope'; import type * as TokenOwnerModule from './token-owner'; +// Every test re-imports the auth module graph after vi.resetModules() and the +// failure-matrix tests wait out real 250/500/1000 ms retry backoffs. On a +// loaded machine (the full gate saturates every core) both stretch several +// fold, so the 5 s vitest default times out healthy tests and the abandoned +// act scopes cascade failures across the file. The file-wide timeout leaves +// that headroom; a genuinely hung bootstrap still fails, only later. +vi.setConfig({ testTimeout: 30_000 }); + // ---- hoisted mocks ---- const hoisted = vi.hoisted(() => { @@ -398,12 +406,17 @@ async function mountAndGetContext(): Promise<{ } /** Flush act passes on real timers until bootstrap stops loading, bounded so a - * stuck provider fails as a timeout rather than hanging the suite. */ + * stuck provider fails as a timeout rather than hanging the suite. The budget + * is real wall-clock time, not an iteration count: a loaded machine stretches + * every 20 ms cycle, and an iteration-counted budget would expire while the + * provider's equally stretched retry backoffs (250/500/1000 ms) are still in + * flight. */ async function settleBootstrap( read: () => AuthContextValue | undefined, - budgetMs = 4000 + budgetMs = 30_000 ): Promise { - for (let elapsed = 0; elapsed <= budgetMs; elapsed += 20) { + const startedAt = Date.now(); + while (Date.now() - startedAt <= budgetMs) { // eslint-disable-next-line no-await-in-loop -- polling must flush and re-check sequentially between act cycles await act(async () => { await new Promise(resolve => { @@ -1882,7 +1895,7 @@ describe('startup credential read failure', () => { expect(hoisted.deepLinkLaunch.setCurrentDeepLinkUserId).not.toHaveBeenCalled(); unmount(); - }, 15_000); + }, 60_000); it('restores the session when retryRestore runs after the storage recovers', async () => { // Every attempt of the first bootstrap fails; the retry's reads succeed. @@ -1907,7 +1920,7 @@ describe('startup credential read failure', () => { expect(getCtx().token).toBe('stored-token'); unmount(); - }, 15_000); + }, 60_000); it('a failed retry settles back onto the restore error surface', async () => { // Four reads for the first bootstrap, four for the retry: every attempt @@ -1933,7 +1946,7 @@ describe('startup credential read failure', () => { expect(getCtx().token).toBeUndefined(); unmount(); - }, 15_000); + }, 60_000); it('sends the person to login when the retry finds no stored session', async () => { // Every attempt of the first bootstrap fails; the retry's reads resolve @@ -1958,7 +1971,7 @@ describe('startup credential read failure', () => { expect(getCtx().token).toBeUndefined(); unmount(); - }, 15_000); + }, 60_000); it('does not resurrect the restore error surface when signOut lands mid-retry', async () => { // Four reads for the first bootstrap, four for the in-flight retry: the @@ -1985,8 +1998,10 @@ describe('startup credential read failure', () => { // Let the abandoned retry's remaining reads exhaust (~1.75 s of backoff) // and flush its catch. Resurrecting the flag here would repaint the error // screen over the login route, where the sign-out dedupe makes a second - // tap a no-op — the escape hatch would be permanently dead. - for (let waited = 0; waited <= 4000 && readCount() < 8; waited += 20) { + // tap a no-op — the escape hatch would be permanently dead. The budget is + // real wall-clock time for the same reason as settleBootstrap above. + const abandonedSettleStartedAt = Date.now(); + while (Date.now() - abandonedSettleStartedAt <= 30_000 && readCount() < 8) { // eslint-disable-next-line no-await-in-loop -- polling must flush and re-check sequentially between act cycles await act(async () => { await new Promise(resolve => { @@ -2005,7 +2020,7 @@ describe('startup credential read failure', () => { expect(getCtx().token).toBeUndefined(); unmount(); - }, 15_000); + }, 60_000); it('clears the restore failure when signOut is used as the escape hatch', async () => { failTokenReads(4); @@ -2023,5 +2038,5 @@ describe('startup credential read failure', () => { expect(getCtx().token).toBeUndefined(); unmount(); - }, 15_000); + }, 60_000); });