mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
* fix(ssh): fence stale kills and retired pane replay * fix(ssh): support cancellable interactive authentication * fix(ssh): await remote catalog before snapshot adoption * fix(pty): contain Windows ConPTY input failures * fix(power): avoid redundant macOS display blocking * perf(editor): narrow markdown override subscriptions * fix(quick-open): close directory handles after reads * refactor(linux): remove unused proc socket scanner * fix(usage): apply flat Sonnet 4.6 pricing * ci: prime Node next native test cache * docs(skills): resolve snapshot cleanup data path * fix(ssh): recover install locks after host reboot * test(ssh): recognize boot-aware install locks * test(ssh): prove previous-boot lock recovery live * test(wire): pin pre-metadata release coverage * fix(terminal): preserve remote tab ownership through recovery races * test(runtime): fence replaced terminal handles in agent guard * fix(ssh): preserve remote snapshot authority across polls * fix(pty): contain late ConPTY output EPIPE * test(pty): register Windows exit watcher before kill * fix: close SSH and tab readiness race gaps * fix(tabs): retain headless order and placeholder titles * fix(build): avoid parallel electron-vite config race * test(windows): avoid MSYS temp path rewriting * test(windows): avoid killing exited PTY * fix(pty): avoid late ConPTY input teardown race * fix(terminal): sync reconnect error ownership after commit * fix(runtime): use canonical worktree identity comparison * test(ssh): assert complete cold-hydration baseline * test(windows): invoke quoted retention fixture via PowerShell * test(windows): read ConPTY grid through mode con * fix(terminal): publish PTY replacements atomically * fix(terminal): infer stale identity on reattach * fix(terminal): fence stale pane PTY callbacks * fix(terminal): fence stale pane binds after rebind * fix(terminal): reject stale pane transport callbacks * fix(terminal): fence mirrored reattach spawn callbacks * fix(terminal): replace stale pane PTYs on remount * fix(ci): size the Windows launcher-compile test budget from measurement `native-smoke (windows-latest)` fails ~4.5% of runs on `preserves a multiline argument through the compiled remote launcher` with "Test timed out in 15000ms" — on unrelated PRs, for reasons that have nothing to do with them. Across 176 sampled attempts it is the only red that job produced, and it hit seven different PRs in two days: #16900, #16904, #16915, #16955 (twice), #16979, #17014, #17085. The test is six process creations: powershell.exe forks csc.exe, then the freshly compiled orca.exe forks node.exe, twice. Hosted Windows runners periodically slow process creation down, and this test amplifies that far harder than anything else in the job. Comparing the 80 attempts where it ran under 3s against the 12 where it ran over 12s, its own median goes 2198ms -> 15917ms (7.2x) while the same file's powershell-only test moves 556 -> 686ms (1.2x), the cmd.exe and Git Bash process tests in the neighbouring file move 1.4x, and the other 35 files put together move 1.5x. Measured across those 176 attempts: 1881ms to 35438ms, p50 4264ms, correlation +0.881 with the job's total Vitest duration. 8 of 176 (4.5%) exceeded the 15s cap; 2 of 176 (1.1%) also exceeded the shared 30s testTimeout, so deleting the override and inheriting the config is not enough on its own. 60s clears all 176 with 1.7x headroom on the worst. This is slow, not hung. Every body here is synchronous spawnSync, so Vitest cannot interrupt one — the timer fires only after the body returns and the reported duration is real elapsed time. That is why a failure reads `× ... 22464ms` under `Test timed out in 15000ms`. The work finished; the stopwatch was short. Seven reruns at one identical head measured 2053 / 4680 / 5551 / 8732 / 13506 / 14868 / 21937ms — the last of those would have been red on code that had not changed. The 15s came from #8897, which raised this test off Vitest's built-in 5s default because the job then ran bare `pnpm vitest run`. #8909 landed 3h27m later and pointed the job at config/vitest.config.ts, which is the real fix for that. The constant stayed behind and has been the binding budget ever since. * fix(terminal): fence stale remount reattach ownership * fix(terminal): reconcile mounted pane identity after replacement * fix(terminal): fence stale reattach fallback ownership * fix(terminal): fence deferred SSH reattach ownership * fix(terminal): fence stale split pane ownership callbacks * fix(terminal): keep stale spawns from consuming startup --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
560 lines
19 KiB
TypeScript
560 lines
19 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import type * as NodeFsPromises from 'node:fs/promises'
|
|
|
|
const { lstatMock, opendirMock } = vi.hoisted(() => ({
|
|
lstatMock: vi.fn(),
|
|
opendirMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('fs/promises', async () => {
|
|
const actual = await vi.importActual<typeof NodeFsPromises>('fs/promises')
|
|
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('closes the directory when cancellation lands after opendir', async () => {
|
|
const root = await makeTempRoot()
|
|
const controller = new AbortController()
|
|
const actual = await vi.importActual<typeof NodeFsPromises>('node:fs/promises')
|
|
let closeCalls = 0
|
|
opendirMock.mockImplementationOnce(async (...args: Parameters<typeof actual.opendir>) => {
|
|
const directory = await actual.opendir(...args)
|
|
const close = directory.close.bind(directory)
|
|
directory.close = async () => {
|
|
closeCalls += 1
|
|
await close()
|
|
}
|
|
controller.abort()
|
|
return directory
|
|
})
|
|
|
|
await expect(
|
|
listQuickOpenFilesWithReaddir(root, { signal: controller.signal })
|
|
).rejects.toSatisfy(isFileListingCancellation)
|
|
expect(closeCalls).toBe(1)
|
|
})
|
|
})
|