mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
* fix(mobile): a Retry that can only re-dial is not offered where nothing dials Six failed-load screens share one Retry shape: re-dial a host that is not connected, otherwise re-read. On the page the re-dial is inert (`client-context.web.tsx:55`) and each screen's load already re-runs when the shell's client reconnects, so in the disconnected state that Retry did nothing at all. `connectionRetryAction` makes the decision once and answers null when a re-dial is needed and none exists; agent history, the file explorer root, the file preview, git history, the source-control status gate and the diff review render no Retry for null. The explorer's per-folder Retry keeps its control: it queues the folder, and the queue drains on the next `connected` whoever brought it back. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page offers no re-dial, so no header offers one `forceReconnect` on the page was `() => Promise.resolve()`: the shell owns the connection and nothing in the document can re-dial it. The host header's Reconnect and the session header's "tap to retry" were wired to it and did nothing there. The context member is now nullable and the page's provider hands out null, so the compiler found every caller: both headers render no reconnect affordance for null, and the session status keeps the verdict label without promising a tap. Native providers and the recording adapters still pass a function, so nothing a phone renders changes. The session route's host-JSX parity hash moves for the header's extra null check; the page test doubles that stubbed the old inert re-dial now stub null. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): the auth-failed banner cites the page's re-dial as null Three comments and the banner's override reason still said the page's `forceReconnect` was an inert `() => Promise.resolve()`, and cited `client-context.web.tsx` lines the previous commit moved. They now say null and point at the lines that hold it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the session closure gains the page's Retry decision `connection-retry-action.ts` is the one module the Retry fix adds to the session route's page closure, reached through the explorer, source control and git history it docks. Measured on this head with all five generators run first, and diffed against the pre-change closure: one local module added, none removed. Session route closure 4207 -> 4208 modules, local 1021 -> 1022. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): the capability probe belongs on the page, and says why The push fence excluded `runtime-capability-probe.ts` because the session route and the host screen run it. The session half holds, and the probe works there: `status.get` carries no client identity and makes no write, the shell forwards it like any non-`native.` request, and the desktop's mobile allowlist admits it. The host-screen half no longer does: `codex-reset-credit-capability.ts` is reached only from `accounts.tsx`, which the bundle carries and the page hands to the native screen. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep the session retry test's cast under its disable line The formatter wrapped the cast onto the line after the disable comment, which left it uncovered. The cast now sits on its own line directly below the SAFETY note. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the agent-history Retry test mocks the pathname the handoff reads Main's page route handoff now subscribes to `usePathname` (#22300), and the Retry suite this branch added mounts that handoff with an `expo-router` mock that lacked it. Same one-line addition main made to the back-handoff suite. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the reload each hidden page Retry relies on Hiding a Retry on the page rests on the screen's load re-running when the shell's client reconnects, because nothing on the page re-dials. Only the explorer's folder drain pinned that. Each other site now has a case that starts unreachable with no Retry and asserts the load goes out on the client and state the reconnect delivers: agent history (status.get), file preview (the preview read), diff review (the snapshot load), git history (git.history) and source-control status (git.status, in the loaders suite because the panel test mocks the state hook). Each goes red when the `client`/`connState` dependencies it guards are removed; for source control that is both `loadStatus` and the `loadBranchCompare` it depends on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): one import of the transport types in the source-control loaders test CI's native code-quality audit denies the duplicate-import warning the reload pin added. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
462 lines
14 KiB
TypeScript
462 lines
14 KiB
TypeScript
import { createElement } from 'react'
|
|
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { MobileFileExplorerPanel } from './MobileFileExplorerPanel'
|
|
import type { MobileDirEntry } from './file-tree'
|
|
import type { RpcResponse } from '../transport/types'
|
|
|
|
type MockClient = {
|
|
sendRequest: ReturnType<typeof vi.fn>
|
|
}
|
|
|
|
const mockTransport = vi.hoisted(
|
|
(): {
|
|
client: MockClient | null
|
|
connectionState: string
|
|
forceReconnect: ReturnType<typeof vi.fn> | null
|
|
} => ({
|
|
client: null,
|
|
connectionState: 'connected',
|
|
forceReconnect: vi.fn()
|
|
})
|
|
)
|
|
|
|
vi.mock('react-native', async () => {
|
|
const React = await import('react')
|
|
return {
|
|
ActivityIndicator: 'ActivityIndicator',
|
|
FlatList: (props: {
|
|
data: unknown[]
|
|
keyExtractor: (item: unknown, index: number) => string
|
|
renderItem: (info: {
|
|
item: unknown
|
|
index: number
|
|
separators: Record<string, never>
|
|
}) => unknown
|
|
}) =>
|
|
React.createElement(
|
|
'FlatList',
|
|
props,
|
|
props.data.map((item, index) =>
|
|
React.createElement(
|
|
'FlatListItem',
|
|
{ key: props.keyExtractor(item, index) },
|
|
props.renderItem({ item, index, separators: {} })
|
|
)
|
|
)
|
|
),
|
|
Pressable: 'Pressable',
|
|
StyleSheet: {
|
|
create: (styles: unknown) => styles,
|
|
hairlineWidth: 1
|
|
},
|
|
Text: 'Text',
|
|
View: 'View'
|
|
}
|
|
})
|
|
|
|
vi.mock('react-native-safe-area-context', () => ({
|
|
SafeAreaView: 'SafeAreaView'
|
|
}))
|
|
|
|
vi.mock('expo-router', () => ({
|
|
useRouter: () => ({
|
|
back: vi.fn(),
|
|
push: vi.fn()
|
|
})
|
|
}))
|
|
|
|
vi.mock('lucide-react-native', () => ({
|
|
ChevronDown: 'ChevronDown',
|
|
ChevronLeft: 'ChevronLeft',
|
|
ChevronRight: 'ChevronRight',
|
|
File: 'File',
|
|
FileText: 'FileText',
|
|
Folder: 'Folder',
|
|
Image: 'Image',
|
|
X: 'X'
|
|
}))
|
|
|
|
vi.mock('../platform/haptics', () => ({
|
|
triggerSelection: vi.fn()
|
|
}))
|
|
|
|
vi.mock('../transport/client-context', () => ({
|
|
useForceReconnect: () => mockTransport.forceReconnect,
|
|
useHostClient: () => ({
|
|
client: mockTransport.client,
|
|
state: mockTransport.connectionState
|
|
})
|
|
}))
|
|
|
|
function entry(name: string, isDirectory = false): MobileDirEntry {
|
|
return { name, isDirectory }
|
|
}
|
|
|
|
function ok(result: MobileDirEntry[]): RpcResponse {
|
|
return { id: 'response-id', ok: true, result, _meta: { runtimeId: 'runtime-id' } }
|
|
}
|
|
|
|
function createMockClient(entriesByPath: Record<string, MobileDirEntry[]>): MockClient {
|
|
return {
|
|
sendRequest: vi.fn(async (_method: string, params: { relativePath: string }) => {
|
|
return ok(entriesByPath[params.relativePath] ?? [])
|
|
})
|
|
}
|
|
}
|
|
|
|
async function renderExplorer(): Promise<ReactTestRenderer> {
|
|
let renderer: ReactTestRenderer | null = null
|
|
await act(async () => {
|
|
renderer = create(
|
|
createElement(MobileFileExplorerPanel, {
|
|
hostId: 'host-a',
|
|
worktreeId: 'worktree-a',
|
|
name: 'Example Worktree',
|
|
embedded: true
|
|
})
|
|
)
|
|
})
|
|
if (!renderer) {
|
|
throw new Error('MobileFileExplorerPanel did not render')
|
|
}
|
|
return renderer
|
|
}
|
|
|
|
async function updateExplorer(renderer: ReactTestRenderer): Promise<void> {
|
|
await act(async () => {
|
|
renderer.update(
|
|
createElement(MobileFileExplorerPanel, {
|
|
hostId: 'host-a',
|
|
worktreeId: 'worktree-a',
|
|
name: 'Example Worktree',
|
|
embedded: true
|
|
})
|
|
)
|
|
})
|
|
}
|
|
|
|
async function pressByLabel(
|
|
renderer: ReactTestRenderer,
|
|
accessibilityLabel: string
|
|
): Promise<void> {
|
|
const pressable = renderer.root
|
|
.findAllByType('Pressable')
|
|
.find((node) => node.props.accessibilityLabel === accessibilityLabel)
|
|
if (!pressable) {
|
|
throw new Error(`Unable to find pressable: ${accessibilityLabel}`)
|
|
}
|
|
await act(async () => {
|
|
pressable.props.onPress()
|
|
})
|
|
}
|
|
|
|
function renderedText(renderer: ReactTestRenderer): string {
|
|
return renderer.root
|
|
.findAllByType('Text')
|
|
.flatMap((node) => node.props.children)
|
|
.join(' ')
|
|
}
|
|
|
|
describe('MobileFileExplorerPanel', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
mockTransport.client = null
|
|
mockTransport.connectionState = 'connected'
|
|
mockTransport.forceReconnect = vi.fn()
|
|
})
|
|
|
|
it('loads directories lazily and reuses cached children when reopened', async () => {
|
|
const client = createMockClient({
|
|
'': [entry('src', true), entry('README.md')],
|
|
src: [entry('app.ts'), entry('components', true)]
|
|
})
|
|
mockTransport.client = client
|
|
|
|
const renderer = await renderExplorer()
|
|
|
|
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
|
expect(client.sendRequest).toHaveBeenLastCalledWith('files.readDir', {
|
|
worktree: 'id:worktree-a',
|
|
relativePath: ''
|
|
})
|
|
|
|
await pressByLabel(renderer, 'Open folder src')
|
|
await vi.waitFor(() => expect(client.sendRequest).toHaveBeenCalledTimes(2))
|
|
expect(client.sendRequest).toHaveBeenLastCalledWith('files.readDir', {
|
|
worktree: 'id:worktree-a',
|
|
relativePath: 'src'
|
|
})
|
|
expect(renderedText(renderer)).toContain('app.ts')
|
|
|
|
await pressByLabel(renderer, 'Open folder src')
|
|
await pressByLabel(renderer, 'Open folder src')
|
|
|
|
expect(client.sendRequest).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('keeps the loaded tree visible during a transient disconnect', async () => {
|
|
const client = createMockClient({
|
|
'': [entry('src', true), entry('README.md')]
|
|
})
|
|
mockTransport.client = client
|
|
|
|
const renderer = await renderExplorer()
|
|
expect(renderedText(renderer)).toContain('src')
|
|
|
|
mockTransport.client = null
|
|
mockTransport.connectionState = 'disconnected'
|
|
await updateExplorer(renderer)
|
|
|
|
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
|
expect(renderedText(renderer)).toContain('src')
|
|
expect(renderedText(renderer)).toContain('README.md')
|
|
expect(renderedText(renderer)).not.toContain('Waiting for desktop...')
|
|
})
|
|
|
|
it('refreshes the root in the background after reconnect without blanking the tree', async () => {
|
|
const client = createMockClient({
|
|
'': [entry('src', true), entry('README.md')]
|
|
})
|
|
mockTransport.client = client
|
|
|
|
const renderer = await renderExplorer()
|
|
expect(renderedText(renderer)).toContain('README.md')
|
|
|
|
mockTransport.client = null
|
|
mockTransport.connectionState = 'disconnected'
|
|
await updateExplorer(renderer)
|
|
|
|
let resolveReload: ((response: RpcResponse) => void) | undefined
|
|
const reconnectedClient: MockClient = {
|
|
sendRequest: vi.fn(
|
|
() =>
|
|
new Promise<RpcResponse>((resolve) => {
|
|
resolveReload = resolve
|
|
})
|
|
)
|
|
}
|
|
mockTransport.client = reconnectedClient
|
|
mockTransport.connectionState = 'connected'
|
|
await updateExplorer(renderer)
|
|
|
|
expect(reconnectedClient.sendRequest).toHaveBeenCalledTimes(1)
|
|
expect(renderedText(renderer)).toContain('src')
|
|
expect(renderedText(renderer)).toContain('README.md')
|
|
|
|
await act(async () => {
|
|
resolveReload?.(ok([entry('src', true), entry('README.md'), entry('CHANGELOG.md')]))
|
|
})
|
|
expect(renderedText(renderer)).toContain('CHANGELOG.md')
|
|
})
|
|
|
|
it('keeps the cached tree when a post-reconnect root refresh fails', async () => {
|
|
const client = createMockClient({
|
|
'': [entry('src', true), entry('README.md')]
|
|
})
|
|
mockTransport.client = client
|
|
|
|
const renderer = await renderExplorer()
|
|
expect(renderedText(renderer)).toContain('README.md')
|
|
|
|
mockTransport.client = null
|
|
mockTransport.connectionState = 'disconnected'
|
|
await updateExplorer(renderer)
|
|
|
|
const failingClient: MockClient = {
|
|
sendRequest: vi.fn(async () => {
|
|
throw new Error('refresh failed')
|
|
})
|
|
}
|
|
mockTransport.client = failingClient
|
|
mockTransport.connectionState = 'connected'
|
|
await updateExplorer(renderer)
|
|
|
|
expect(failingClient.sendRequest).toHaveBeenCalledTimes(1)
|
|
expect(renderedText(renderer)).toContain('src')
|
|
expect(renderedText(renderer)).toContain('README.md')
|
|
expect(renderedText(renderer)).not.toContain('refresh failed')
|
|
})
|
|
|
|
it('falls back to the capped files.list against desktops without files.readDir', async () => {
|
|
const legacyClient: MockClient = {
|
|
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => {
|
|
if (method === 'files.readDir') {
|
|
return {
|
|
id: 'response-id',
|
|
ok: false,
|
|
error: {
|
|
code: 'forbidden',
|
|
message: "Method 'files.readDir' is not available to mobile clients"
|
|
},
|
|
_meta: { runtimeId: 'runtime-id' }
|
|
}
|
|
}
|
|
return {
|
|
id: 'response-id',
|
|
ok: true,
|
|
result: {
|
|
files: [
|
|
{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' },
|
|
{ relativePath: 'README.md', basename: 'README.md', kind: 'text' }
|
|
],
|
|
totalCount: 2,
|
|
truncated: false
|
|
},
|
|
_meta: { runtimeId: 'runtime-id' }
|
|
}
|
|
})
|
|
}
|
|
mockTransport.client = legacyClient
|
|
|
|
const renderer = await renderExplorer()
|
|
|
|
expect(legacyClient.sendRequest).toHaveBeenCalledWith('files.list', {
|
|
worktree: 'id:worktree-a'
|
|
})
|
|
expect(renderedText(renderer)).toContain('src')
|
|
expect(renderedText(renderer)).toContain('README.md')
|
|
|
|
await pressByLabel(renderer, 'Open folder src')
|
|
expect(renderedText(renderer)).toContain('app.ts')
|
|
// Every directory comes from the synthesized cache: one readDir attempt
|
|
// plus one files.list call total, no per-directory RPCs afterwards.
|
|
expect(legacyClient.sendRequest).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('surfaces the legacy cap note when the files.list fallback is truncated', async () => {
|
|
const legacyClient: MockClient = {
|
|
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => {
|
|
if (method === 'files.readDir') {
|
|
return {
|
|
id: 'response-id',
|
|
ok: false,
|
|
error: { code: 'method_not_found', message: 'Unknown method' },
|
|
_meta: { runtimeId: 'runtime-id' }
|
|
}
|
|
}
|
|
return {
|
|
id: 'response-id',
|
|
ok: true,
|
|
result: {
|
|
files: [{ relativePath: 'README.md', basename: 'README.md', kind: 'text' }],
|
|
totalCount: 6000,
|
|
truncated: true
|
|
},
|
|
_meta: { runtimeId: 'runtime-id' }
|
|
}
|
|
})
|
|
}
|
|
mockTransport.client = legacyClient
|
|
|
|
const renderer = await renderExplorer()
|
|
|
|
expect(renderedText(renderer)).toContain('README.md')
|
|
expect(renderedText(renderer)).toContain('Showing first 5000')
|
|
})
|
|
|
|
it('reports the files.list failure when the fallback itself fails', async () => {
|
|
const legacyClient: MockClient = {
|
|
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => {
|
|
if (method === 'files.readDir') {
|
|
return {
|
|
id: 'response-id',
|
|
ok: false,
|
|
error: {
|
|
code: 'forbidden',
|
|
message: "Method 'files.readDir' is not available to mobile clients"
|
|
},
|
|
_meta: { runtimeId: 'runtime-id' }
|
|
}
|
|
}
|
|
return {
|
|
id: 'response-id',
|
|
ok: false,
|
|
error: { code: 'internal', message: 'legacy list failed' },
|
|
_meta: { runtimeId: 'runtime-id' }
|
|
}
|
|
})
|
|
}
|
|
mockTransport.client = legacyClient
|
|
|
|
const renderer = await renderExplorer()
|
|
|
|
expect(renderedText(renderer)).toContain('legacy list failed')
|
|
expect(renderedText(renderer)).not.toContain('not available to mobile clients')
|
|
})
|
|
|
|
describe('Retry while the host is unreachable', () => {
|
|
function failingClient(failedPaths: Set<string>): MockClient {
|
|
return {
|
|
sendRequest: vi.fn(async (_method: string, params: { relativePath: string }) =>
|
|
failedPaths.has(params.relativePath)
|
|
? {
|
|
id: 'response-id',
|
|
ok: false,
|
|
error: { code: 'internal', message: 'read failed' },
|
|
_meta: { runtimeId: 'runtime-id' }
|
|
}
|
|
: ok(params.relativePath === '' ? [entry('src', true)] : [entry('app.ts')])
|
|
)
|
|
}
|
|
}
|
|
|
|
async function unreachableAfterRootFailure(): Promise<ReactTestRenderer> {
|
|
mockTransport.client = failingClient(new Set(['']))
|
|
const renderer = await renderExplorer()
|
|
mockTransport.connectionState = 'reconnecting'
|
|
await updateExplorer(renderer)
|
|
return renderer
|
|
}
|
|
|
|
function retryButtons(renderer: ReactTestRenderer) {
|
|
return renderer.root
|
|
.findAllByType('Pressable')
|
|
.filter((node) =>
|
|
node.findAllByType('Text').some((text) => text.props.children === 'Retry')
|
|
)
|
|
}
|
|
|
|
it('offers no root Retry on the page, where nothing can re-dial', async () => {
|
|
mockTransport.forceReconnect = null
|
|
const renderer = await unreachableAfterRootFailure()
|
|
expect(renderedText(renderer)).toContain('Waiting for desktop...')
|
|
expect(retryButtons(renderer)).toHaveLength(0)
|
|
})
|
|
|
|
it('still offers it natively, and re-dials this host', async () => {
|
|
const forceReconnect = vi.fn()
|
|
mockTransport.forceReconnect = forceReconnect
|
|
const renderer = await unreachableAfterRootFailure()
|
|
const [retry] = retryButtons(renderer)
|
|
await act(async () => {
|
|
retry?.props.onPress()
|
|
})
|
|
expect(forceReconnect.mock.calls).toEqual([['host-a']])
|
|
})
|
|
|
|
it("keeps a folder's Retry on the page, whose read runs when the shell reconnects", async () => {
|
|
// The one retry that does something without a re-dial: it queues the folder, and the
|
|
// queue drains on the next `connected` whoever brought it back.
|
|
mockTransport.forceReconnect = null
|
|
const client = failingClient(new Set(['src']))
|
|
mockTransport.client = client
|
|
const renderer = await renderExplorer()
|
|
await pressByLabel(renderer, 'Open folder src')
|
|
await vi.waitFor(() => expect(renderedText(renderer)).toContain('read failed'))
|
|
mockTransport.connectionState = 'reconnecting'
|
|
await updateExplorer(renderer)
|
|
client.sendRequest = failingClient(new Set()).sendRequest
|
|
await pressByLabel(renderer, 'Retry loading src')
|
|
mockTransport.connectionState = 'connected'
|
|
await updateExplorer(renderer)
|
|
await vi.waitFor(() =>
|
|
expect(client.sendRequest).toHaveBeenLastCalledWith('files.readDir', {
|
|
worktree: 'id:worktree-a',
|
|
relativePath: 'src'
|
|
})
|
|
)
|
|
})
|
|
})
|
|
})
|