diff --git a/mobile/src/session/QuickCommandRow.test.ts b/mobile/src/session/QuickCommandRow.test.ts new file mode 100644 index 00000000000..c7f7fa4f31c --- /dev/null +++ b/mobile/src/session/QuickCommandRow.test.ts @@ -0,0 +1,122 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types' +import { QuickCommandRow } from './QuickCommandRow' + +const clipboard = vi.hoisted(() => ({ setStringAsync: vi.fn(() => Promise.resolve(true)) })) +const haptics = vi.hoisted(() => ({ notificationAsync: vi.fn(() => Promise.resolve()) })) + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ + Check: 'Check', + Copy: 'Copy', + Pencil: 'Pencil', + Play: 'Play', + Trash2: 'Trash2' +})) + +vi.mock('expo-clipboard', () => clipboard) + +vi.mock('expo-haptics', () => ({ + ...haptics, + performAndroidHapticsAsync: vi.fn(() => Promise.resolve()), + AndroidHaptics: { Reject: 'reject' }, + NotificationFeedbackType: { Error: 'error', Success: 'success' } +})) + +vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' })) + +const COMMAND: TerminalQuickCommand = { + id: 'qc-1', + label: 'Run tests', + command: 'run the tests', + appendEnter: true +} + +/** + * The seventh migrated write, and the one that does not answer a refusal with a toast. + * + * A row inside a scrolling list says so on its own control: the copy button's label becomes + * "Couldn't copy" and its icon turns red for the same 1500 ms the toast would have lasted. What it + * had no way to say was anything a thumb could feel, and the button sits under the thumb that just + * pressed it. The seam rejects on a refusal rather than resolving false, so these two cases are the + * difference between the row that shows a green check over nothing copied and the row that does not. + */ +describe('the quick-command row when the pasteboard refuses the text', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + clipboard.setStringAsync.mockReset() + haptics.notificationAsync.mockReset() + haptics.notificationAsync.mockImplementation(() => Promise.resolve()) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + /** + * The copy control, found by its label rather than its position among the row's four buttons. + * + * All four labels the button can carry, because the label is what the copy state changes: a + * finder keyed to one of them stops finding the button in the state it is meant to read. + */ + const COPY_LABELS = new Set([ + `Copy ${COMMAND.label}`, + 'Copied', + "Couldn't copy", + 'Nothing to copy' + ]) + + function copyButton() { + const button = renderer!.root + .findAll((node) => node.props.accessibilityRole === 'button') + .find((node) => COPY_LABELS.has(String(node.props.accessibilityLabel))) + if (button === undefined) { + throw new Error('the row has no copy button') + } + return button + } + + async function mountAndCopy(): Promise { + await act(async () => { + renderer = create( + createElement(QuickCommandRow, { + command: COMMAND, + first: true, + onLaunch: vi.fn(), + onEdit: vi.fn(), + onDelete: vi.fn(), + disabled: false + }) + ) + }) + await act(async () => { + copyButton().props.onPress() + }) + } + + it('says it could not copy and buzzes the error', async () => { + clipboard.setStringAsync.mockResolvedValue(false) + await mountAndCopy() + expect(copyButton().props.accessibilityLabel).toBe("Couldn't copy") + expect(haptics.notificationAsync).toHaveBeenCalledWith('error') + }) + + it('shows the copied label and no error buzz when the write lands', async () => { + // The control: a failure assertion is only evidence if the success path reads differently. + clipboard.setStringAsync.mockResolvedValue(true) + await mountAndCopy() + expect(copyButton().props.accessibilityLabel).toBe('Copied') + expect(haptics.notificationAsync).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/session/QuickCommandRow.tsx b/mobile/src/session/QuickCommandRow.tsx index f251a00dd20..4bb9b8aec8f 100644 --- a/mobile/src/session/QuickCommandRow.tsx +++ b/mobile/src/session/QuickCommandRow.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { View, Text, Pressable, StyleSheet } from 'react-native' import { useClipboardWriter } from '../platform/clipboard' +import { triggerError } from '../platform/haptics' import { Check, Copy, Pencil, Play, Trash2 } from 'lucide-react-native' import { colors, spacing, typography } from '../theme/mobile-theme' import { MobileAgentIcon } from '../components/MobileAgentIcon' @@ -74,6 +75,9 @@ export function QuickCommandRow({ } setFeedback({ body, status: 'copied' }) } catch { + // The row says so on its own control rather than in a toast; the buzz is the part a thumb + // resting on the button it just pressed can notice without looking. + triggerError() if (!mountedRef.current) { return } diff --git a/mobile/src/session/QuickCommandsList.test.ts b/mobile/src/session/QuickCommandsList.test.ts index 37e41990a08..aae0bf7bbe3 100644 --- a/mobile/src/session/QuickCommandsList.test.ts +++ b/mobile/src/session/QuickCommandsList.test.ts @@ -27,6 +27,14 @@ vi.mock('lucide-react-native', () => ({ vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) +// The row buzzes when a copy is refused, and the real module reads `__DEV__` at import. +vi.mock('expo-haptics', () => ({ + notificationAsync: vi.fn(), + performAndroidHapticsAsync: vi.fn(), + AndroidHaptics: { Reject: 'reject' }, + NotificationFeedbackType: { Error: 'error', Success: 'success' } +})) + vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' })) describe('QuickCommandsList search', () => {