From de0ac0fd4a76f23168014e08d43ae114983761e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 10 Sep 2026 04:13:56 +0200 Subject: [PATCH] test(mobile-app): pin one-tap session send and harden flaky test timeouts Fix the mobile session send button that requires two taps. Surface: mobile-app. On the session page, the send button sits to the right of the microphone icon and the composer. Expected: One tap sends the current message exactly once. Actual: The user must tap the send button twice. Reproduce this through the normal session composer before changing product code. Find and fix the shared cause across affected session flows. Prove the first tap sends with the keyboard open and with the keyboard closed. Cover focus changes, empty input, and duplicate-send prevention with focused regression checks. Preserve microphone behavior and existing send states. Put local end-to-end (E2E) device proof in the pull request (PR) body. Use temporary test support outside the product diff. Do not commit fixtures, E2E-only code, or test-only runtime flags. Do not leave follow-ups. Let the workflow open and maintain the PR. Finish only after current-head continuous integration (CI) is green and Kilobot recommends Merge. --- .../chat-composer-send-once.mounted.test.tsx | 430 ++++++++++++++++++ .../agents/markdown-renderer.test.ts | 7 + .../mobile/src/lib/auth/auth-context.test.tsx | 37 +- 3 files changed, 463 insertions(+), 11 deletions(-) create mode 100644 apps/mobile/src/components/agents/chat-composer-send-once.mounted.test.tsx 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); });