Files
orca/mobile/src/session/MobileNativeChatMessage.copy.test.tsx
T
Jinwoo Hong 86f6258d54 refactor(mobile): delete native screens the OTA shell no longer reaches (#29979)
* fix(mobile): wall hosts that cannot serve the page instead of opening native screens

* refactor(mobile): drop the native-route state and the shell fallback prop; unknown routes say unavailable

* test(mobile): read the nullable page route refusal in its policy test

* refactor(mobile): every build is the OTA shell; drop the native/OTA build switch

The owner retired the native-screen fallback with no emergency native build, so
EXPO_PUBLIC_MOBILE_SHELL, mobileShellBuildKind, shellSwitchDecision, the Metro
cache-key suffix and both release workflows' shell input go. Every host route
mounts the shell; a route naming no screen the shell could open gets the
existing refusal screen instead of a native screen.

Claude-Session: https://claude.ai/code/session_6cea92f6

* refactor(mobile): delete native screens the OTA shell no longer reaches

With every build the OTA shell, these native siblings run nowhere: the
native-executed closure (index.js plus every non-web route) does not reach
them, and the page resolves their .web siblings. Each .web sibling takes the
plain name, its web-overrides entry goes, and the native-only rich Markdown
editor and Mermaid WebView generators and their outputs go with them.

Kept: native siblings the node test suite still renders shared screens
through (terminal WebView, browser frame path, clipboard, dictation, media
picker, live input, snapshot budget, input styles).

Claude-Session: https://claude.ai/code/session_6cea92f6

* test(mobile): drop the phone-preview comparison the deleted native sibling held

Claude-Session: https://claude.ai/code/session_6cea92f6

* refactor(mobile): drop the dead web route, flag-era comments and the one-value shell kind

The host route files no longer switch, so their comments say what they do now:
a route naming no screen shows the unavailable screen. app/h/[hostId]/web.tsx
duplicated index.tsx and nothing linked to it, so it and its stack entry go
(the bundle sweep is re-measured: 77 scripts at 15 routes). Diagnostics print
the bridge protocol on its own instead of a shell kind that has one value.

Claude-Session: https://claude.ai/code/session_6cea92f6

* refactor(mobile): one host for the rich editor, plain Text, and drop two unused native deps

MobileSelectableText was react-native's Text, so callers use Text. The rich
Markdown editor's host-facing seams (messages, URL prompt, keyboard inset) are
required now that the page is its only host; the WebView defaults go. Comments
that described deleted native components are rewritten, and
expo-image-manipulator and react-native-uitextview leave package.json and the
lockfile.

Claude-Session: https://claude.ai/code/session_6cea92f6
2026-10-17 02:23:51 -07:00

377 lines
14 KiB
TypeScript

import { createElement } from 'react'
import { act, create, 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 { colors } from '../theme/mobile-theme'
const { writeText, alert } = vi.hoisted(() => ({
writeText: vi.fn<(text: string) => Promise<void>>(),
alert: vi.fn()
}))
vi.mock('react-native', () => ({
Alert: { alert },
Image: 'Image',
Platform: { OS: 'ios' },
Pressable: 'Pressable',
Text: 'Text',
View: 'View',
StyleSheet: { create: (value: unknown) => value, hairlineWidth: 1 }
}))
vi.mock('lucide-react-native', () => ({ Copy: 'Copy', Check: 'Check' }))
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
vi.mock('../platform/clipboard', () => ({ useClipboardWriter: () => ({ writeText }) }))
vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
MobileNativeChatMessageActionsSheet: 'MessageActionsSheet'
}))
vi.mock('./MobileNativeChatReasoningRow', () => ({ MobileNativeChatReasoningRow: 'Reasoning' }))
vi.mock('./MobileNativeChatTurnStatus', () => ({ MobileNativeChatTurnStatus: 'TurnStatus' }))
vi.mock('./MobileNativeChatToolRun', () => ({ ToolRun: 'ToolRun' }))
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
import { MobileNativeChatLongPressContent } from './MobileNativeChatLongPressContent'
const first = '# Answer\n\n- first\n - nested\n\n```ts\n const x = 1\n```'
const last = '| Name | Value |\n| --- | --- |\n| x | 1 |\n\nFinal paragraph.\n'
const message: NativeChatMessage = {
id: 'reply',
role: 'assistant',
source: 'transcript',
timestamp: null,
blocks: [{ type: 'text', text: 'Started' }]
}
function pendingWrite() {
let settle: (outcome: 'success' | 'failure') => void = () => {
throw new Error('Clipboard write is not pending')
}
const promise = new Promise<void>((resolve, reject) => {
settle = (outcome) => {
if (outcome === 'success') {
resolve()
} else {
reject(new Error('Unavailable'))
}
}
})
return { promise, finish: (outcome: 'success' | 'failure') => settle(outcome) }
}
describe('iOS whole-message copy', () => {
let renderer: ReactTestRenderer | undefined
const nodes = (type: string) => renderer!.root.findAll((node) => String(node.type) === type)
function copyButton() {
const node = nodes('Pressable').find(
(button) => button.props.accessibilityLabel === 'Copy message'
)
if (!node) {
throw new Error('Copy message action is missing')
}
return node
}
function render(value = message) {
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message: value }))
})
}
beforeEach(() => {
vi.clearAllMocks()
writeText.mockResolvedValue(undefined)
})
afterEach(() => {
act(() => renderer?.unmount())
vi.useRealTimers()
})
it.each(['user', 'assistant'] as const)(
'copies current %s source outside the text after streaming',
async (role) => {
render({ ...message, role })
const current: NativeChatMessage = {
...message,
role,
blocks: [
{ type: 'text', text: first },
{ type: 'tool-call', name: 'Read', input: { file_path: 'private.txt' } },
{ type: 'image-ref', path: '/attachment.png' },
{ type: 'text', text: last }
]
}
act(() => renderer!.update(createElement(MobileNativeChatMessage, { message: current })))
const copy = copyButton()
expect(copy.props.accessibilityRole).toBe('button')
expect(copy.props.style({ pressed: false })[0]).toMatchObject({ width: 24, height: 24 })
expect(copy.findAll((node) => String(node.type) === 'Text')).toHaveLength(0)
expect(nodes('Copy')[0]?.props).toMatchObject({ size: 14, color: colors.textMuted })
expect(copy.props.onLongPress).toBeUndefined()
const body = renderer!.root.findByType(MobileNativeChatLongPressContent)
expect(body.findAll((node) => node.props.accessibilityLabel === 'Copy message')).toHaveLength(
0
)
expect(
copy.findAll((node) => ['MobileMarkdown', 'ToolRun'].includes(String(node.type)))
).toHaveLength(0)
for (const node of nodes('MobileMarkdown')) {
expect(node.props.onLongPress).toBeUndefined()
}
// A held press selects text natively: the body claims no press of its own.
expect(body.findAll((node) => String(node.type) === 'Pressable')).toHaveLength(0)
await act(async () => copy.props.onPress())
expect(writeText.mock.calls).toEqual([[first + '\n\n' + last]])
expect(nodes('MessageActionsSheet')).toHaveLength(0)
expect(nodes('Check')).toHaveLength(1)
expect(nodes('Check')[0]?.props).toMatchObject({ size: 14, color: colors.statusGreen })
expect(copyButton().props.accessibilityValue).toEqual({ text: 'Copied' })
expect(copyButton().props.style({ pressed: false })[0]).toMatchObject({
width: 24,
height: 24
})
expect(copyButton().findAll((node) => String(node.type) === 'Text')).toHaveLength(0)
}
)
it.each(['user', 'assistant'] as const)(
'shows the %s message copy button without a tap',
(role) => {
render({ ...message, role })
expect(copyButton()).toBeDefined()
}
)
it.each([
'Please repair this chart:\n::orca-visual{file="usage.html"}\nKeep this text.',
'::orca-visual{file="usage.html"}'
])('copies the entire visible own source %j', async (text) => {
render({ ...message, role: 'user', blocks: [{ type: 'text', text }] })
// Drawn as written: your own message never renders a visual.
expect(nodes('MobileMarkdown')).toHaveLength(1)
expect(nodes('MobileMarkdown')[0]?.props.content).toBe(text)
expect(nodes('MobileMarkdown')[0]?.props.renderVisual).toBeUndefined()
expect(copyButton().props.accessibilityRole).toBe('button')
await act(async () => copyButton().props.onPress())
expect(writeText.mock.calls).toEqual([[text]])
expect(nodes('Check')).toHaveLength(1)
})
it('omits assistant visual directives while retaining literal fenced code', async () => {
const text =
'Chart:\n::orca-visual{file="usage.html"}\nDone.\n```text\n::orca-visual{file="example.html"}\n```'
render({ ...message, blocks: [{ type: 'text', text }] })
await act(async () => copyButton().props.onPress())
expect(writeText.mock.calls).toEqual([
['Chart:\nDone.\n```text\n::orca-visual{file="example.html"}\n```']
])
expect(nodes('Check')).toHaveLength(1)
})
it('reports clipboard rejection without claiming Copied', async () => {
writeText.mockRejectedValue(new Error('Clipboard unavailable'))
render()
await act(async () => copyButton().props.onPress())
expect(alert).toHaveBeenCalledWith('Copy failed', 'Clipboard unavailable')
expect(nodes('Check')).toHaveLength(0)
expect(nodes('Text').some((node) => node.props.children === 'Copied')).toBe(false)
})
it('clears success when source changes and never creates idle-row timers', async () => {
vi.useFakeTimers()
render()
expect(vi.getTimerCount()).toBe(0)
await act(async () => copyButton().props.onPress())
expect(nodes('Check')).toHaveLength(1)
act(() =>
renderer!.update(
createElement(MobileNativeChatMessage, {
message: { ...message, blocks: [{ type: 'text', text: 'New source' }] }
})
)
)
expect(nodes('Check')).toHaveLength(0)
expect(vi.getTimerCount()).toBe(0)
act(() => renderer!.update(createElement(MobileNativeChatMessage, { message })))
expect(nodes('Check')).toHaveLength(0)
await act(async () => copyButton().props.onPress())
act(() => renderer!.unmount())
expect(vi.getTimerCount()).toBe(0)
})
it.each(['success', 'failure'] as const)(
'drops late %s feedback after unmount',
async (outcome) => {
vi.useFakeTimers()
let finish = () => {}
writeText.mockReturnValue(
new Promise<void>((resolve, reject) => {
finish = outcome === 'success' ? resolve : () => reject(new Error('Unavailable'))
})
)
render()
act(() => copyButton().props.onPress())
act(() => renderer!.unmount())
await act(async () => finish())
expect(alert).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
}
)
it.each(['success', 'failure'] as const)(
'suppresses stale success but reports %s after source changes',
async (outcome) => {
vi.useFakeTimers()
let finish = () => {}
writeText.mockReturnValue(
new Promise<void>((resolve, reject) => {
finish = outcome === 'success' ? resolve : () => reject(new Error('Unavailable'))
})
)
render()
act(() => copyButton().props.onPress())
act(() =>
renderer!.update(
createElement(MobileNativeChatMessage, {
message: { ...message, blocks: [{ type: 'text', text: 'Newest source' }] }
})
)
)
await act(async () => finish())
expect(writeText.mock.calls).toEqual([['Started']])
expect(nodes('Check')).toHaveLength(0)
if (outcome === 'failure') {
expect(alert).toHaveBeenCalledExactlyOnceWith('Copy failed', 'Unavailable')
} else {
expect(alert).not.toHaveBeenCalled()
}
expect(vi.getTimerCount()).toBe(0)
}
)
it('expires confirmation so later copies are still available', async () => {
vi.useFakeTimers()
render()
await act(async () => copyButton().props.onPress())
expect(nodes('Check')).toHaveLength(1)
act(() => {
vi.advanceTimersByTime(1500)
})
expect(nodes('Check')).toHaveLength(0)
await act(async () => copyButton().props.onPress())
expect(writeText).toHaveBeenCalledTimes(2)
})
it('keeps pending success stale when the original source returns', async () => {
vi.useFakeTimers()
const pending = pendingWrite()
writeText.mockReturnValueOnce(pending.promise)
render()
act(() => copyButton().props.onPress())
act(() =>
renderer!.update(
createElement(MobileNativeChatMessage, {
message: { ...message, blocks: [{ type: 'text', text: 'Changed source' }] }
})
)
)
act(() => renderer!.update(createElement(MobileNativeChatMessage, { message })))
await act(async () => pending.finish('success'))
expect(nodes('Check')).toHaveLength(0)
expect(alert).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
it.each(['success', 'failure'] as const)(
'ignores an older overlapping %s while the newest write is pending',
async (outcome) => {
vi.useFakeTimers()
const older = pendingWrite(),
newest = pendingWrite()
writeText.mockReturnValueOnce(older.promise).mockReturnValueOnce(newest.promise)
render()
act(() => copyButton().props.onPress())
act(() => copyButton().props.onPress())
await act(async () => older.finish(outcome))
expect(nodes('Check')).toHaveLength(0)
expect(alert).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
await act(async () => newest.finish('failure'))
expect(alert).toHaveBeenCalledExactlyOnceWith('Copy failed', 'Unavailable')
expect(nodes('Check')).toHaveLength(0)
expect(vi.getTimerCount()).toBe(0)
}
)
it.each([
['success', 'success'],
['success', 'failure'],
['failure', 'success'],
['failure', 'failure']
] as const)(
'ignores repeated older %s results after the newest %s',
async (olderOutcome, newestOutcome) => {
vi.useFakeTimers()
const older = pendingWrite(),
middle = pendingWrite(),
newest = pendingWrite()
writeText
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(middle.promise)
.mockReturnValueOnce(newest.promise)
render()
for (const text of ['Started', 'Middle source', 'Newest source']) {
act(() =>
renderer!.update(
createElement(MobileNativeChatMessage, {
message: { ...message, blocks: [{ type: 'text', text }] }
})
)
)
act(() => copyButton().props.onPress())
}
await act(async () => newest.finish(newestOutcome))
await act(async () => middle.finish(olderOutcome))
await act(async () => older.finish(olderOutcome))
expect(writeText.mock.calls).toEqual([['Started'], ['Middle source'], ['Newest source']])
expect(nodes('Check')).toHaveLength(newestOutcome === 'success' ? 1 : 0)
if (newestOutcome === 'failure') {
expect(alert).toHaveBeenCalledExactlyOnceWith('Copy failed', 'Unavailable')
expect(vi.getTimerCount()).toBe(0)
} else {
expect(alert).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(1)
act(() => {
vi.advanceTimersByTime(1499)
})
expect(nodes('Check')).toHaveLength(1)
act(() => {
vi.advanceTimersByTime(1)
})
expect(nodes('Check')).toHaveLength(0)
expect(vi.getTimerCount()).toBe(0)
}
}
)
it('clears an earlier success when the next copy is rejected', async () => {
vi.useFakeTimers()
render()
await act(async () => copyButton().props.onPress())
expect(nodes('Check')).toHaveLength(1)
writeText.mockRejectedValue(new Error('Clipboard unavailable'))
await act(async () => copyButton().props.onPress())
expect(nodes('Check')).toHaveLength(0)
expect(alert).toHaveBeenCalledWith('Copy failed', 'Clipboard unavailable')
expect(vi.getTimerCount()).toBe(0)
})
it('has no copy footer for image-only or tool-only messages', () => {
render({ ...message, blocks: [{ type: 'image-ref', path: '/attachment.png' }] })
expect(nodes('Pressable')).toHaveLength(0)
act(() =>
renderer!.update(
createElement(MobileNativeChatMessage, {
message: { ...message, blocks: [{ type: 'tool-call', name: 'Read', input: {} }] }
})
)
)
expect(nodes('Pressable')).toHaveLength(0)
})
})