Files
orca/mobile/src/components/MobileSourceCopyButton.web.test.tsx
T
Brennan Benson 94d2462379 fix(mobile): copy complete chat replies and loaded markdown (#26152)
* fix(mobile): make message and markdown source copy reachable

* fix(mobile): keep copy failure feedback during streaming

* fix(mobile): complete source copy on paired web pages

* Align mobile source Copy controls with desktop

* Update chat status test for icon-only Copy

* Preserve literal source when copying own messages

* fix(mobile): show a message's copy button only after tapping it

Like desktop's hover-only copy, the chat message copy button stays hidden
until the message is tapped; another tap hides it. The tap target is not a
single accessible element, so screen readers still reach the text inside.

* fix(mobile): a held press on a message selects text without toggling Copy

On iPad the release of a long-press selection, or of a selection-handle drag,
also counted as a tap and toggled the message's copy button. Claiming the long
press keeps only quick taps as toggles; native selection is unaffected.

* test(mobile): load the web clipboard in the copy-button test the way other tests do

vi.importActual with a relative path resolved from the mocker under CI's
runner and failed to find ../platform/clipboard.web; a plain import() in the
mock factory resolves from the test file.
2026-10-12 15:54:52 -07:00

279 lines
9.0 KiB
TypeScript

// @vitest-environment happy-dom
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Alert } from 'react-native'
import { RpcClientProvider } from '../transport/client-context.web'
import {
createFakeBridgePortPair,
type BridgePortPair
} from '../mobile-web-shell/bridge/bridge-port-pair-test-harness'
import { MobileSourceCopyButton } from './MobileSourceCopyButton'
import { colors } from '../theme/mobile-theme'
vi.mock('react-native', () => vi.importActual<typeof import('react-native')>('react-native-web'))
vi.mock('../platform/clipboard', () => import('../platform/clipboard.web'))
vi.mock('lucide-react-native', () => ({
Copy: ({ size, color }: { size: number; color: string }) =>
createElement('svg', { 'data-icon': 'copy', width: size, height: size, stroke: color }),
Check: ({ size, color }: { size: number; color: string }) =>
createElement('svg', { 'data-icon': 'check', width: size, height: size, stroke: color })
}))
vi.mock('../transport/host-client-hooks', () => ({
useDisconnectHostClient: () => () => {},
useForceReconnect: () => null,
useForgetHostClient: () => () => {},
useHostClient: () => ({ client: null, clientId: null, state: 'disconnected' }),
usePrimeHosts: () => () => {},
useRefreshHostClient: () => () => {}
}))
function pendingWrite() {
let finish: (written: boolean) => void = () => {
throw new Error('No pending clipboard write')
}
const promise = new Promise<{ written: boolean }>((resolve) => {
finish = (written) => resolve({ written })
})
return { promise, finish: (written: boolean) => finish(written) }
}
describe('source Copy feedback in the paired web page', () => {
let root: Root | undefined
let container: HTMLDivElement
let pair: BridgePortPair
const write = vi.fn<() => Promise<{ written: boolean }>>()
const alert = vi.spyOn(Alert, 'alert')
function button(): HTMLElement {
const control = container.querySelector('[role="button"]')
if (!(control instanceof HTMLElement)) {
throw new Error('Copy control is missing')
}
return control
}
function expectIcon(name: 'copy' | 'check', color: string) {
const icon = button().querySelector('svg')
expect(icon?.getAttribute('data-icon')).toBe(name)
expect(icon?.getAttribute('width')).toBe('14')
expect(icon?.getAttribute('height')).toBe('14')
expect(icon?.getAttribute('stroke')).toBe(color)
expect(button().textContent).toBe('')
}
function expectFailure() {
expectIcon('copy', colors.statusRed)
expect(button().getAttribute('aria-label')).toBe("Couldn't copy")
expect(button().getAttribute('aria-valuetext')).toBe("Couldn't copy")
}
async function render(text = '# Held source', partial = false) {
await act(async () => {
root?.render(
<RpcClientProvider client={pair.client}>
<MobileSourceCopyButton text={text} partial={partial} accessibilityLabel="Copy source" />
</RpcClientProvider>
)
})
}
async function press() {
await act(async () => {
button().click()
await pair.flush()
})
}
beforeEach(async () => {
write.mockReset().mockResolvedValue({ written: true })
alert.mockClear()
pair = createFakeBridgePortPair({ serveNativeVerb: write })
await pair.flush()
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
})
afterEach(async () => {
await act(async () => root?.unmount())
container.remove()
vi.useRealTimers()
})
it('keeps a compact icon-only control through success and expiry', async () => {
await render()
vi.useFakeTimers()
const initialClass = button().className
expect(button().getAttribute('aria-label')).toBe('Copy source')
expectIcon('copy', colors.textMuted)
await press()
expectIcon('check', colors.statusGreen)
expect(button().getAttribute('aria-valuetext')).toBe('Copied')
expect(button().className).toBe(initialClass)
await act(async () => {
vi.advanceTimersByTime(1500)
})
expectIcon('copy', colors.textMuted)
expect(button().className).toBe(initialClass)
})
it.each(['rejected request', 'written:false'] as const)(
'shows a visible failure for %s and allows retry',
async (outcome) => {
if (outcome === 'written:false') {
write.mockResolvedValueOnce({ written: false })
} else {
write.mockRejectedValueOnce(new Error('Clipboard unavailable'))
}
await render()
const initialClass = button().className
await press()
expectFailure()
expect(button().className).toBe(initialClass)
expect(alert).not.toHaveBeenCalled()
expect(write).toHaveBeenCalledExactlyOnceWith('native.clipboard.write', {
mime: 'text',
value: '# Held source'
})
expect(pair.rpc.requests).toEqual([])
await press()
expectIcon('check', colors.statusGreen)
expect(button().getAttribute('aria-label')).toBe('Copy source')
expect(write).toHaveBeenCalledTimes(2)
}
)
it('shows a missing-grant refusal without making a device call', async () => {
pair = createFakeBridgePortPair({
routeGrants: ['navigate', 'storage'],
serveNativeVerb: write
})
await pair.flush()
await render()
await press()
expectFailure()
expect(write).not.toHaveBeenCalled()
expect(alert).not.toHaveBeenCalled()
})
it('reports the mounted latest refusal after source updates and clears it on expiry', async () => {
const pending = pendingWrite()
write.mockReturnValueOnce(pending.promise)
await render()
vi.useFakeTimers()
await press()
await render('Streaming update')
await act(async () => {
pending.finish(false)
await pair.flush()
})
expectFailure()
expect(write).toHaveBeenCalledWith('native.clipboard.write', {
mime: 'text',
value: '# Held source'
})
await act(async () => {
vi.advanceTimersByTime(1500)
})
expectIcon('copy', colors.textMuted)
})
it('keeps the latest failure visible across continued streaming until expiry', async () => {
await render()
vi.useFakeTimers()
write.mockResolvedValueOnce({ written: false })
await press()
expectFailure()
await render('Updated source')
expectFailure()
await render('Another streaming update')
expectFailure()
await act(async () => {
vi.advanceTimersByTime(1500)
})
expectIcon('copy', colors.textMuted)
expect(vi.getTimerCount()).toBe(0)
})
it.each([true, false])('drops a superseded written:%s reply', async (written) => {
const older = pendingWrite()
write.mockReturnValueOnce(older.promise)
await render()
await press()
write.mockResolvedValueOnce({ written: false })
await press()
expectFailure()
await act(async () => {
older.finish(written)
await pair.flush()
})
expectFailure()
expect(alert).not.toHaveBeenCalled()
})
it.each([true, false])('drops late written:%s feedback after unmount', async (written) => {
const pending = pendingWrite()
write.mockReturnValueOnce(pending.promise)
await render()
vi.useFakeTimers()
await press()
await act(async () => root?.unmount())
root = undefined
await act(async () => {
pending.finish(written)
await pair.flush()
})
expect(container.textContent).toBe('')
expect(alert).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
it('keeps success stale after an A to B to A source change', async () => {
const pending = pendingWrite()
write.mockReturnValueOnce(pending.promise)
await render()
vi.useFakeTimers()
await press()
await render('Other source')
await render()
await act(async () => {
pending.finish(true)
await pair.flush()
})
expectIcon('copy', colors.textMuted)
expect(vi.getTimerCount()).toBe(0)
})
it('restores the loaded-source label after failure expires', async () => {
await render('# Loaded portion', true)
vi.useFakeTimers()
write.mockResolvedValueOnce({ written: false })
await press()
expect(button().textContent).toBe('Failed')
await act(async () => {
vi.advanceTimersByTime(1500)
})
expect(button().textContent).toBe('Copy loaded')
expect(write).toHaveBeenCalledWith('native.clipboard.write', {
mime: 'text',
value: '# Loaded portion'
})
})
it('confirms the loaded portion without changing the partial footprint', async () => {
await render('# Loaded portion', true)
const initialClass = button().className
expect(button().textContent).toBe('Copy loaded')
await press()
expect(button().textContent).toBe('Copied loaded')
expect(button().querySelector('svg')?.getAttribute('data-icon')).toBe('check')
expect(button().getAttribute('aria-valuetext')).toBe('Copied loaded')
expect(button().className).toBe(initialClass)
expect(write).toHaveBeenCalledWith('native.clipboard.write', {
mime: 'text',
value: '# Loaded portion'
})
})
})