Files
hermes-agent/apps/desktop/src/app/file-browser/use-project-tree.test.ts
T

191 lines
6.8 KiB
TypeScript

import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesReadDirResult } from '@/global'
import { resetProjectTreeState, useProjectTree } from './use-project-tree'
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
beforeEach(() => {
resetProjectTreeState()
readDir.mockReset()
;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir }
})
afterEach(() => {
resetProjectTreeState()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
function ok(entries: { name: string; path: string; isDirectory: boolean }[]): HermesReadDirResult {
return { entries }
}
describe('useProjectTree', () => {
it('starts empty when cwd is blank and skips IPC', async () => {
const { result } = renderHook(() => useProjectTree(''))
await waitFor(() => expect(result.current.rootLoading).toBe(false))
expect(result.current.data).toEqual([])
expect(result.current.rootError).toBeNull()
expect(readDir).not.toHaveBeenCalled()
})
it('loads root entries on mount and sorts folders before files', async () => {
readDir.mockResolvedValueOnce(
ok([
{ name: 'README.md', path: '/p/README.md', isDirectory: false },
{ name: 'src', path: '/p/src', isDirectory: true }
])
)
const { result } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.data.length).toBe(2))
expect(readDir).toHaveBeenCalledWith('/p')
// Hook trusts main-process sort order; folders/files preserved as supplied.
expect(result.current.data.map(n => n.name)).toEqual(['README.md', 'src'])
// Folder children start undefined (lazy load on first expand).
expect(result.current.data.find(n => n.name === 'src')?.children).toBeUndefined()
expect(result.current.data.find(n => n.name === 'src')?.isDirectory).toBe(true)
expect(result.current.data.find(n => n.name === 'README.md')?.isDirectory).toBe(false)
})
it('records rootError when readDir returns an error', async () => {
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
const { result } = renderHook(() => useProjectTree('/locked'))
await waitFor(() => expect(result.current.rootError).toBe('EACCES'))
expect(result.current.data).toEqual([])
})
it('lazy-loads children on loadChildren and replaces the placeholder', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'src', path: '/p/src', isDirectory: true }]))
readDir.mockResolvedValueOnce(
ok([
{ name: 'index.ts', path: '/p/src/index.ts', isDirectory: false },
{ name: 'lib', path: '/p/src/lib', isDirectory: true }
])
)
const { result } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.data.length).toBe(1))
await act(async () => {
await result.current.loadChildren('/p/src')
})
const src = result.current.data[0]
expect(src.children?.map(n => n.name)).toEqual(['index.ts', 'lib'])
expect(src.loading).toBe(false)
expect(src.error).toBeUndefined()
})
it('keeps loaded tree state across remounts for the same cwd', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'src', path: '/p/src', isDirectory: true }]))
const { result, unmount } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.data.length).toBe(1))
act(() => {
result.current.setNodeOpen('/p/src', true)
})
unmount()
const remounted = renderHook(() => useProjectTree('/p'))
expect(remounted.result.current.data.map(n => n.name)).toEqual(['src'])
expect(remounted.result.current.openState).toEqual({ '/p/src': true })
expect(readDir).toHaveBeenCalledTimes(1)
})
it('captures per-folder error code and leaves the folder expandable but empty', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }]))
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
const { result } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.data.length).toBe(1))
await act(async () => {
await result.current.loadChildren('/p/priv')
})
expect(result.current.data[0].error).toBe('EACCES')
expect(result.current.data[0].children).toEqual([])
})
it('dedupes concurrent loadChildren calls for the same id', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'src', path: '/p/src', isDirectory: true }]))
let resolveChildren: ((value: HermesReadDirResult) => void) | undefined
readDir.mockImplementationOnce(
() =>
new Promise<HermesReadDirResult>(resolve => {
resolveChildren = resolve
})
)
const { result } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.data.length).toBe(1))
await act(async () => {
// First call enters inflight, second short-circuits, third also short-circuits.
void result.current.loadChildren('/p/src')
void result.current.loadChildren('/p/src')
void result.current.loadChildren('/p/src')
resolveChildren?.(ok([{ name: 'a.ts', path: '/p/src/a.ts', isDirectory: false }]))
})
// Mount load + a single folder fetch — duplicates were dropped.
expect(readDir).toHaveBeenCalledTimes(2)
})
it('refreshRoot reloads the root and clears prior error', async () => {
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
readDir.mockResolvedValueOnce(ok([{ name: 'README.md', path: '/p/README.md', isDirectory: false }]))
const { result } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.rootError).toBe('EACCES'))
await act(async () => {
await result.current.refreshRoot()
})
expect(result.current.rootError).toBeNull()
expect(result.current.data.map(n => n.name)).toEqual(['README.md'])
})
it('reloads when cwd changes', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'one', path: '/a/one', isDirectory: false }]))
readDir.mockResolvedValueOnce(ok([{ name: 'two', path: '/b/two', isDirectory: false }]))
const { rerender, result } = renderHook(({ cwd }) => useProjectTree(cwd), { initialProps: { cwd: '/a' } })
await waitFor(() => expect(result.current.data[0]?.name).toBe('one'))
rerender({ cwd: '/b' })
await waitFor(() => expect(result.current.data[0]?.name).toBe('two'))
expect(readDir).toHaveBeenLastCalledWith('/b')
})
it('returns no-bridge gracefully when window.hermesDesktop is missing', async () => {
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
const { result } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.rootError).toBe('no-bridge'))
expect(result.current.data).toEqual([])
})
})