mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
552 lines
19 KiB
TypeScript
552 lines
19 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { lstatMock, opendirMock } = vi.hoisted(() => ({
|
|
lstatMock: vi.fn(),
|
|
opendirMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('fs/promises', async () => {
|
|
const actual = await vi.importActual<typeof import('fs/promises')>('fs/promises') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
|
|
lstatMock.mockImplementation(actual.lstat)
|
|
opendirMock.mockImplementation(actual.opendir)
|
|
return {
|
|
...actual,
|
|
lstat: lstatMock,
|
|
opendir: opendirMock
|
|
}
|
|
})
|
|
|
|
import { mkdtemp, mkdir, rename, rm, symlink, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join } from 'node:path'
|
|
import {
|
|
classifyQuickOpenGitEntry,
|
|
createQuickOpenReaddirBudget,
|
|
expandQuickOpenGitFileListing,
|
|
isQuickOpenReaddirBudgetError,
|
|
listQuickOpenFilesWithReaddir,
|
|
parseQuickOpenGitLsFilesEntry,
|
|
QUICK_OPEN_READDIR_MAX_FILES
|
|
} from './quick-open-readdir-walk'
|
|
import { isFileListingCancellation } from './file-listing-cancellation'
|
|
|
|
const tempDirs: string[] = []
|
|
const SHA1 = '0123456789abcdef0123456789abcdef01234567'
|
|
const SHA256 = `${SHA1}89abcdef0123456789abcdef`
|
|
|
|
function staged(mode: string, path: string, sha = SHA1): string {
|
|
return `${mode} ${sha} 0\t${path}`
|
|
}
|
|
|
|
async function makeTempRoot(): Promise<string> {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-quick-open-readdir-'))
|
|
tempDirs.push(root)
|
|
return root
|
|
}
|
|
|
|
async function writeRel(root: string, relPath: string, content = 'x'): Promise<void> {
|
|
const absPath = join(root, ...relPath.split('/'))
|
|
await mkdir(dirname(absPath), { recursive: true })
|
|
await writeFile(absPath, content)
|
|
}
|
|
|
|
async function mkdirRel(root: string, relPath: string): Promise<void> {
|
|
await mkdir(join(root, ...relPath.split('/')), { recursive: true })
|
|
}
|
|
|
|
async function makeNestedRepo(root: string, relPath: string, gitEntry: 'dir' | 'file' = 'dir') {
|
|
await mkdirRel(root, relPath)
|
|
const gitPath = join(root, ...relPath.split('/'), '.git')
|
|
await (gitEntry === 'dir'
|
|
? mkdir(gitPath, { recursive: true })
|
|
: writeFile(gitPath, 'gitdir: ../.git/worktrees/example'))
|
|
}
|
|
|
|
afterEach(async () => {
|
|
vi.clearAllMocks()
|
|
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
|
|
})
|
|
|
|
describe('quick-open readdir walk', () => {
|
|
it('parses git ls-files stage output and bare untracked entries', () => {
|
|
expect(parseQuickOpenGitLsFilesEntry(staged('100644', 'src/index.ts'))).toEqual({
|
|
path: 'src/index.ts',
|
|
isGitlink: false,
|
|
isUntrackedDir: false
|
|
})
|
|
expect(parseQuickOpenGitLsFilesEntry(staged('160000', 'packages/app'))).toEqual({
|
|
path: 'packages/app',
|
|
isGitlink: true,
|
|
isUntrackedDir: false
|
|
})
|
|
expect(parseQuickOpenGitLsFilesEntry(staged('100755', 'bin/run', SHA256))).toEqual({
|
|
path: 'bin/run',
|
|
isGitlink: false,
|
|
isUntrackedDir: false
|
|
})
|
|
expect(parseQuickOpenGitLsFilesEntry('scratch.txt')).toEqual({
|
|
path: 'scratch.txt',
|
|
isGitlink: false,
|
|
isUntrackedDir: false
|
|
})
|
|
expect(parseQuickOpenGitLsFilesEntry('packages/lib/')).toEqual({
|
|
path: 'packages/lib/',
|
|
isGitlink: false,
|
|
isUntrackedDir: true
|
|
})
|
|
})
|
|
|
|
it('keeps ordinary git entries without lstat calls', async () => {
|
|
await expect(
|
|
expandQuickOpenGitFileListing({
|
|
rootPath: '/unused/root',
|
|
gitPaths: [
|
|
staged('100644', 'README.md'),
|
|
staged('100755', 'bin/run', SHA256),
|
|
'scratch.txt'
|
|
]
|
|
})
|
|
).resolves.toEqual(['README.md', 'bin/run', 'scratch.txt'])
|
|
|
|
expect(lstatMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('classifies nested repo placeholders without confusing extensionless files', async () => {
|
|
const root = await makeTempRoot()
|
|
await writeRel(root, 'Makefile')
|
|
await makeNestedRepo(root, 'packages/app')
|
|
await makeNestedRepo(root, 'packages/lib', 'file')
|
|
await mkdirRel(root, 'packages/unchecked')
|
|
|
|
await expect(classifyQuickOpenGitEntry(root, staged('100644', 'Makefile'))).resolves.toEqual({
|
|
kind: 'keep',
|
|
relPath: 'Makefile'
|
|
})
|
|
await expect(
|
|
classifyQuickOpenGitEntry(root, staged('160000', 'packages/app'))
|
|
).resolves.toEqual({
|
|
kind: 'fill-nested-repo',
|
|
relPath: 'packages/app'
|
|
})
|
|
await expect(classifyQuickOpenGitEntry(root, 'packages/lib/')).resolves.toEqual({
|
|
kind: 'fill-nested-repo',
|
|
relPath: 'packages/lib'
|
|
})
|
|
await expect(classifyQuickOpenGitEntry(root, 'packages/unchecked')).resolves.toEqual({
|
|
kind: 'keep',
|
|
relPath: 'packages/unchecked'
|
|
})
|
|
await expect(classifyQuickOpenGitEntry(root, 'packages/unchecked/')).resolves.toEqual({
|
|
kind: 'drop-placeholder',
|
|
relPath: 'packages/unchecked'
|
|
})
|
|
})
|
|
|
|
it('re-prefixes nested children and filters final workspace-relative paths', async () => {
|
|
const root = await makeTempRoot()
|
|
await writeRel(root, 'README.md')
|
|
await writeRel(root, 'Makefile')
|
|
await makeNestedRepo(root, 'packages/app')
|
|
await makeNestedRepo(root, 'packages/lib', 'file')
|
|
await mkdirRel(root, 'packages/empty')
|
|
await writeRel(root, 'packages/app/src/main.ts')
|
|
await writeRel(root, 'packages/app/node_modules/pkg/index.js')
|
|
await writeRel(root, 'packages/app/.git/config')
|
|
await writeRel(root, 'packages/app/linked-worktree/file.ts')
|
|
await writeRel(root, 'packages/lib/src/lib.ts')
|
|
|
|
await expect(
|
|
expandQuickOpenGitFileListing({
|
|
rootPath: root,
|
|
gitPaths: [
|
|
staged('100644', 'README.md'),
|
|
staged('100644', 'Makefile'),
|
|
staged('160000', 'packages/app'),
|
|
'packages/lib/',
|
|
'packages/empty/'
|
|
],
|
|
excludePathPrefixes: ['packages/app/linked-worktree']
|
|
})
|
|
).resolves.toEqual([
|
|
'README.md',
|
|
'Makefile',
|
|
'packages/app/src/main.ts',
|
|
'packages/lib/src/lib.ts'
|
|
])
|
|
})
|
|
|
|
it('rejects on cap and shares one budget across nested subtrees', async () => {
|
|
const root = await makeTempRoot()
|
|
await makeNestedRepo(root, 'packages/app')
|
|
await makeNestedRepo(root, 'packages/lib')
|
|
await writeRel(root, 'packages/app/a.ts')
|
|
await writeRel(root, 'packages/app/b.ts')
|
|
await writeRel(root, 'packages/lib/c.ts')
|
|
|
|
await expect(
|
|
expandQuickOpenGitFileListing({
|
|
rootPath: root,
|
|
gitPaths: [staged('160000', 'packages/app'), staged('160000', 'packages/lib')],
|
|
budget: createQuickOpenReaddirBudget({ maxFiles: 2 })
|
|
})
|
|
).rejects.toThrow('File listing exceeded')
|
|
})
|
|
|
|
it('prunes excluded nested subtrees during traversal without consuming the budget', async () => {
|
|
const root = await makeTempRoot()
|
|
await makeNestedRepo(root, 'packages/app')
|
|
await writeRel(root, 'packages/app/keep.ts')
|
|
// A large excluded subtree inside the nested repo: if it were walked before
|
|
// being filtered, it would exhaust the tiny budget and reject.
|
|
for (let i = 0; i < 20; i += 1) {
|
|
await writeRel(root, `packages/app/excluded/file-${i}.ts`)
|
|
}
|
|
|
|
await expect(
|
|
expandQuickOpenGitFileListing({
|
|
rootPath: root,
|
|
gitPaths: [staged('160000', 'packages/app')],
|
|
excludePathPrefixes: ['packages/app/excluded'],
|
|
budget: createQuickOpenReaddirBudget({ maxFiles: 5 })
|
|
})
|
|
).resolves.toEqual(['packages/app/keep.ts'])
|
|
})
|
|
|
|
it('expands allowed ignored directories without walking blocked or excluded directories', async () => {
|
|
const root = await makeTempRoot()
|
|
await writeRel(root, 'dist/generated.js')
|
|
await writeRel(root, 'node_modules/pkg/index.js')
|
|
await writeRel(root, '.cache/state.json')
|
|
await writeRel(root, '.local/share/state.json')
|
|
await writeRel(root, '.local/config.toml')
|
|
await writeRel(root, 'excluded/other.js')
|
|
|
|
await expect(
|
|
expandQuickOpenGitFileListing({
|
|
rootPath: root,
|
|
gitPaths: [],
|
|
directoryPaths: [
|
|
'dist/',
|
|
'.local/',
|
|
'node_modules/',
|
|
'.cache/',
|
|
'.local/share/',
|
|
'excluded/'
|
|
],
|
|
excludePathPrefixes: ['excluded'],
|
|
budget: createQuickOpenReaddirBudget({ maxFiles: 2 })
|
|
})
|
|
).resolves.toEqual(['.local/config.toml', 'dist/generated.js'])
|
|
|
|
const walkedPaths = opendirMock.mock.calls.map(([path]) => path)
|
|
expect(walkedPaths).toContain(join(root, 'dist'))
|
|
expect(walkedPaths).toContain(join(root, '.local'))
|
|
expect(walkedPaths).not.toContain(join(root, '.local', 'share'))
|
|
})
|
|
|
|
it('batches many allowed directory placeholders with bounded concurrency', async () => {
|
|
const root = await makeTempRoot()
|
|
const directoryPaths = Array.from({ length: 40 }, (_, index) => `generated-${index}/`)
|
|
await Promise.all(
|
|
directoryPaths.map((directoryPath, index) =>
|
|
writeRel(root, `${directoryPath}file-${index}.ts`)
|
|
)
|
|
)
|
|
|
|
const actual = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
|
|
let activeReads = 0
|
|
let maxActiveReads = 0
|
|
opendirMock.mockImplementation(async (...args: Parameters<typeof actual.opendir>) => {
|
|
activeReads++
|
|
maxActiveReads = Math.max(maxActiveReads, activeReads)
|
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
try {
|
|
return await actual.opendir(...args)
|
|
} finally {
|
|
activeReads--
|
|
}
|
|
})
|
|
|
|
try {
|
|
const files = await expandQuickOpenGitFileListing({
|
|
rootPath: root,
|
|
gitPaths: [],
|
|
directoryPaths
|
|
})
|
|
expect(files).toHaveLength(directoryPaths.length)
|
|
expect(maxActiveReads).toBeGreaterThan(1)
|
|
expect(maxActiveReads).toBeLessThanOrEqual(32)
|
|
} finally {
|
|
opendirMock.mockImplementation(actual.opendir)
|
|
}
|
|
})
|
|
|
|
it('preserves symlink leaves from collapsed Git directories without following them', async () => {
|
|
const root = await makeTempRoot()
|
|
await mkdirRel(root, 'scratch')
|
|
await writeRel(root, 'target/file.ts')
|
|
|
|
try {
|
|
await symlink(join(root, 'target', 'file.ts'), join(root, 'scratch', 'link.ts'))
|
|
await symlink(join(root, 'target'), join(root, 'scratch', 'linked-dir'), 'dir')
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === 'EPERM') {
|
|
return
|
|
}
|
|
throw err
|
|
}
|
|
|
|
await expect(
|
|
expandQuickOpenGitFileListing({
|
|
rootPath: root,
|
|
gitPaths: [],
|
|
directoryPaths: ['scratch/']
|
|
})
|
|
).resolves.toEqual(['scratch/link.ts', 'scratch/linked-dir'])
|
|
})
|
|
|
|
it('does not follow a collapsed directory replaced by a symlink', async () => {
|
|
const root = await makeTempRoot()
|
|
const outsideRoot = await makeTempRoot()
|
|
await writeRel(outsideRoot, 'secret.ts')
|
|
|
|
try {
|
|
await symlink(outsideRoot, join(root, 'dist'), 'dir')
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === 'EPERM') {
|
|
return
|
|
}
|
|
throw err
|
|
}
|
|
|
|
await expect(
|
|
expandQuickOpenGitFileListing({
|
|
rootPath: root,
|
|
gitPaths: [],
|
|
directoryPaths: ['dist/']
|
|
})
|
|
).resolves.toEqual([])
|
|
})
|
|
|
|
it('discards entries when a collapsed directory changes during opendir', async () => {
|
|
const root = await makeTempRoot()
|
|
const outsideRoot = await makeTempRoot()
|
|
await mkdirRel(root, 'dist')
|
|
await writeRel(outsideRoot, 'secret.ts')
|
|
const actual = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
|
|
const distPath = join(root, 'dist')
|
|
let swapped = false
|
|
opendirMock.mockImplementation(async (...args: Parameters<typeof actual.opendir>) => {
|
|
if (!swapped && args[0] === distPath) {
|
|
swapped = true
|
|
await rename(distPath, join(root, 'old-dist'))
|
|
await symlink(outsideRoot, distPath, 'dir')
|
|
}
|
|
return actual.opendir(...args)
|
|
})
|
|
|
|
try {
|
|
await expect(
|
|
expandQuickOpenGitFileListing({
|
|
rootPath: root,
|
|
gitPaths: [],
|
|
directoryPaths: ['dist/']
|
|
})
|
|
).resolves.toEqual([])
|
|
} finally {
|
|
opendirMock.mockImplementation(actual.opendir)
|
|
}
|
|
})
|
|
|
|
it('walks overlapping primary and ignored placeholders only once', async () => {
|
|
const root = await makeTempRoot()
|
|
await writeRel(root, 'foo/a.ts')
|
|
await writeRel(root, 'foo/bar/b.ts')
|
|
|
|
await expect(
|
|
expandQuickOpenGitFileListing({
|
|
rootPath: root,
|
|
gitPaths: [],
|
|
directoryPaths: ['foo/', 'foo/bar/'],
|
|
budget: createQuickOpenReaddirBudget({ maxFiles: 2 })
|
|
})
|
|
).resolves.toEqual(['foo/a.ts', 'foo/bar/b.ts'])
|
|
|
|
expect(
|
|
opendirMock.mock.calls.filter(([path]) => path === join(root, 'foo', 'bar'))
|
|
).toHaveLength(1)
|
|
})
|
|
|
|
it('rejects instead of returning a partial ignored-directory expansion', async () => {
|
|
const root = await makeTempRoot()
|
|
await writeRel(root, 'dist/a.js')
|
|
await writeRel(root, 'dist/b.js')
|
|
|
|
await expect(
|
|
expandQuickOpenGitFileListing({
|
|
rootPath: root,
|
|
gitPaths: [],
|
|
directoryPaths: ['dist/'],
|
|
budget: createQuickOpenReaddirBudget({ maxFiles: 1 })
|
|
})
|
|
).rejects.toThrow('File listing exceeded')
|
|
})
|
|
|
|
it('supports more than 10,000 files while keeping the default safety cap', async () => {
|
|
expect(QUICK_OPEN_READDIR_MAX_FILES).toBeGreaterThan(10_000)
|
|
const root = await makeTempRoot()
|
|
await mkdirRel(root, 'dist')
|
|
opendirMock.mockResolvedValueOnce({
|
|
async *[Symbol.asyncIterator]() {
|
|
for (let index = 0; index <= QUICK_OPEN_READDIR_MAX_FILES; index += 1) {
|
|
yield {
|
|
name: `file-${index}.ts`,
|
|
isDirectory: () => false,
|
|
isFile: () => true,
|
|
isSymbolicLink: () => false
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
// Why: directory collapse prevents generated trees from flooding the relay;
|
|
// the Git fallback must reject rather than silently return a partial list.
|
|
await expect(
|
|
expandQuickOpenGitFileListing({
|
|
rootPath: root,
|
|
gitPaths: [],
|
|
directoryPaths: ['dist/']
|
|
})
|
|
).rejects.toThrow(`File listing exceeded ${QUICK_OPEN_READDIR_MAX_FILES} files`)
|
|
})
|
|
|
|
it('identifies budget errors so callers can translate only those to install-rg guidance', () => {
|
|
expect(isQuickOpenReaddirBudgetError(new Error('File listing timed out'))).toBe(true)
|
|
expect(isQuickOpenReaddirBudgetError(new Error('File listing exceeded 10000 files'))).toBe(true)
|
|
// Genuine git failures must keep their own message, not the install-rg toast.
|
|
expect(isQuickOpenReaddirBudgetError(new Error('git ls-files killed by SIGTERM'))).toBe(false)
|
|
expect(isQuickOpenReaddirBudgetError('File listing timed out')).toBe(false)
|
|
})
|
|
|
|
it('rejects on deadline instead of returning a partial list', async () => {
|
|
const root = await makeTempRoot()
|
|
await writeRel(root, 'src/index.ts')
|
|
|
|
await expect(
|
|
listQuickOpenFilesWithReaddir(root, {
|
|
budget: createQuickOpenReaddirBudget({ nowMs: Date.now() - 2_000, timeoutMs: 1_000 })
|
|
})
|
|
).rejects.toThrow('File listing timed out')
|
|
})
|
|
|
|
it('returns a successful bounded prefix without consuming the safety cap', async () => {
|
|
const root = await makeTempRoot()
|
|
await writeRel(root, 'a.ts')
|
|
await writeRel(root, 'b.ts')
|
|
|
|
const files = await listQuickOpenFilesWithReaddir(root, { maxResults: 1 })
|
|
|
|
expect(files).toHaveLength(1)
|
|
expect(['a.ts', 'b.ts']).toContain(files[0])
|
|
})
|
|
|
|
it('does not list symlinked files or follow symlinked directories', async () => {
|
|
const root = await makeTempRoot()
|
|
await writeRel(root, 'src/index.ts')
|
|
await writeRel(root, 'target/file.ts')
|
|
|
|
try {
|
|
await symlink(join(root, 'src/index.ts'), join(root, 'src/link.ts'))
|
|
await symlink(join(root, 'target'), join(root, 'linked-dir'), 'dir')
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === 'EPERM') {
|
|
return
|
|
}
|
|
throw err
|
|
}
|
|
|
|
const files = await listQuickOpenFilesWithReaddir(root)
|
|
expect(files).toEqual(expect.arrayContaining(['src/index.ts', 'target/file.ts']))
|
|
expect(files).not.toContain('src/link.ts')
|
|
expect(files).not.toContain('linked-dir/file.ts')
|
|
})
|
|
|
|
it('walks an explicitly selected symlinked workspace root', async () => {
|
|
const targetRoot = await makeTempRoot()
|
|
const linkContainer = await makeTempRoot()
|
|
await writeRel(targetRoot, 'src/index.ts')
|
|
const linkedRoot = join(linkContainer, 'linked-workspace')
|
|
|
|
try {
|
|
await symlink(targetRoot, linkedRoot, 'dir')
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === 'EPERM') {
|
|
return
|
|
}
|
|
throw err
|
|
}
|
|
|
|
await expect(listQuickOpenFilesWithReaddir(linkedRoot)).resolves.toEqual(['src/index.ts'])
|
|
})
|
|
|
|
it('fills nested repo paths containing spaces and glob metacharacters', async () => {
|
|
const root = await makeTempRoot()
|
|
await makeNestedRepo(root, 'packages/app [one] space')
|
|
await writeRel(root, 'packages/app [one] space/src/main.ts')
|
|
|
|
await expect(
|
|
expandQuickOpenGitFileListing({
|
|
rootPath: root,
|
|
gitPaths: ['packages/app [one] space/']
|
|
})
|
|
).resolves.toEqual(['packages/app [one] space/src/main.ts'])
|
|
})
|
|
|
|
it('stops the walk with a cancellation error when the signal aborts (#7721)', async () => {
|
|
const root = await makeTempRoot()
|
|
await writeRel(root, 'src/a.ts')
|
|
await writeRel(root, 'src/b.ts')
|
|
|
|
const controller = new AbortController()
|
|
controller.abort()
|
|
|
|
const rejection = listQuickOpenFilesWithReaddir(root, { signal: controller.signal })
|
|
await expect(rejection).rejects.toSatisfy(isFileListingCancellation)
|
|
// Cancellation must never be mistaken for a budget error, which callers
|
|
// translate into "install rg" guidance.
|
|
await rejection.catch((err) => expect(isQuickOpenReaddirBudgetError(err)).toBe(false))
|
|
})
|
|
|
|
it('stops ignored-directory expansion when the signal aborts (#7721)', async () => {
|
|
const root = await makeTempRoot()
|
|
await writeRel(root, 'src/kept.ts')
|
|
|
|
const controller = new AbortController()
|
|
controller.abort()
|
|
|
|
await expect(
|
|
expandQuickOpenGitFileListing({
|
|
rootPath: root,
|
|
gitPaths: [],
|
|
directoryPaths: ['src/'],
|
|
signal: controller.signal
|
|
})
|
|
).rejects.toSatisfy(isFileListingCancellation)
|
|
})
|
|
|
|
it('rejects when cancellation lands during an empty opendir batch', async () => {
|
|
const root = await makeTempRoot()
|
|
const controller = new AbortController()
|
|
const actual = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
|
|
opendirMock.mockImplementationOnce(async (...args: Parameters<typeof actual.opendir>) => {
|
|
const entries = await actual.opendir(...args)
|
|
controller.abort()
|
|
return entries
|
|
})
|
|
|
|
await expect(
|
|
listQuickOpenFilesWithReaddir(root, { signal: controller.signal })
|
|
).rejects.toSatisfy(isFileListingCancellation)
|
|
})
|
|
})
|