mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 00:02:35 +00:00
399 lines
12 KiB
TypeScript
399 lines
12 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: null as MockClient | 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 suppressReactTestRendererDeprecationWarning(): () => void {
|
|
const originalConsoleError = console.error
|
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
|
const firstArg = args[0]
|
|
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
|
return
|
|
}
|
|
originalConsoleError(...args)
|
|
})
|
|
return () => consoleErrorSpy.mockRestore()
|
|
}
|
|
|
|
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
|
|
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
|
try {
|
|
await act(async () => {
|
|
renderer = create(
|
|
createElement(MobileFileExplorerPanel, {
|
|
hostId: 'host-a',
|
|
worktreeId: 'worktree-a',
|
|
name: 'Example Worktree',
|
|
embedded: true
|
|
})
|
|
)
|
|
})
|
|
} finally {
|
|
restoreConsoleError()
|
|
}
|
|
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')
|
|
})
|
|
})
|