| import { afterEach, describe, expect, it } from 'bun:test' |
| import { execFileSync } from 'child_process' |
| import * as fs from 'fs' |
| import * as fsp from 'fs/promises' |
| import * as os from 'os' |
| import * as path from 'path' |
| import { handleFilesystemRoute } from '../api/filesystem.js' |
| import { clearFilesystemAccessRootsForTests } from '../services/filesystemAccessRoots.js' |
| import { getRepositoryContext } from '../services/repositoryLaunchService.js' |
|
|
| const cleanupDirs = new Set<string>() |
|
|
| function makeUrl(route: string, params: Record<string, string>): URL { |
| const url = new URL(`http://localhost${route}`) |
| for (const [key, value] of Object.entries(params)) { |
| url.searchParams.set(key, value) |
| } |
| return url |
| } |
|
|
| afterEach(async () => { |
| for (const dir of cleanupDirs) { |
| await fsp.rm(dir, { recursive: true, force: true }) |
| } |
| cleanupDirs.clear() |
| clearFilesystemAccessRootsForTests() |
| }) |
|
|
| function git(cwd: string, ...args: string[]): string { |
| return execFileSync('git', args, { |
| cwd, |
| encoding: 'utf8', |
| }) |
| } |
|
|
| function isWithinPath(targetPath: string, rootPath: string): boolean { |
| const target = path.resolve(targetPath) |
| const root = path.resolve(rootPath) |
| return target === root || target.startsWith(`${root}${path.sep}`) |
| } |
|
|
| async function makeExternalFixtureDir(): Promise<string | null> { |
| const candidates = ['/var/tmp', '/private/var/tmp', '/Users/Shared'] |
|
|
| for (const baseDir of candidates) { |
| try { |
| const stat = await fsp.stat(baseDir) |
| if (!stat.isDirectory()) continue |
| const fixtureDir = await fsp.mkdtemp(path.join(baseDir, 'claude-filesystem-test-')) |
| const isDefaultAllowed = |
| isWithinPath(fixtureDir, os.homedir()) || |
| isWithinPath(fixtureDir, '/tmp') || |
| (process.platform === 'darwin' && isWithinPath(fixtureDir, '/private/tmp')) |
| if (!isDefaultAllowed) return fixtureDir |
| await fsp.rm(fixtureDir, { recursive: true, force: true }) |
| } catch { |
| |
| } |
| } |
|
|
| return null |
| } |
|
|
| describe('filesystem API', () => { |
| it('allows browsing a directory under the user home directory', async () => { |
| const homeFixtureDir = await fsp.mkdtemp(path.join(os.homedir(), 'claude-filesystem-test-')) |
| cleanupDirs.add(homeFixtureDir) |
| await fsp.writeFile(path.join(homeFixtureDir, 'note.txt'), 'hello') |
|
|
| const res = await handleFilesystemRoute( |
| '/api/filesystem/browse', |
| makeUrl('/api/filesystem/browse', { |
| path: homeFixtureDir, |
| includeFiles: 'true', |
| }), |
| ) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() as { entries: Array<{ name: string }> } |
| expect(body.entries.some((entry) => entry.name === 'note.txt')).toBe(true) |
| }) |
|
|
| it('allows browsing a selected workspace outside the default home/tmp roots', async () => { |
| const externalFixtureDir = await makeExternalFixtureDir() |
| if (!externalFixtureDir) return |
|
|
| cleanupDirs.add(externalFixtureDir) |
| await fsp.writeFile(path.join(externalFixtureDir, 'note.txt'), 'hello') |
|
|
| const deniedRes = await handleFilesystemRoute( |
| '/api/filesystem/browse', |
| makeUrl('/api/filesystem/browse', { |
| path: externalFixtureDir, |
| includeFiles: 'true', |
| }), |
| ) |
| expect(deniedRes.status).toBe(403) |
|
|
| await getRepositoryContext(externalFixtureDir) |
|
|
| const res = await handleFilesystemRoute( |
| '/api/filesystem/browse', |
| makeUrl('/api/filesystem/browse', { |
| path: externalFixtureDir, |
| includeFiles: 'true', |
| }), |
| ) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() as { entries: Array<{ name: string }> } |
| expect(body.entries.some((entry) => entry.name === 'note.txt')).toBe(true) |
| }) |
|
|
| it('fuzzy searches files and directories below the selected root', async () => { |
| const homeFixtureDir = await fsp.mkdtemp(path.join(os.homedir(), 'claude-filesystem-test-')) |
| cleanupDirs.add(homeFixtureDir) |
| git(homeFixtureDir, 'init') |
| await fsp.mkdir(path.join(homeFixtureDir, 'src', 'commands'), { recursive: true }) |
| await fsp.mkdir(path.join(homeFixtureDir, 'src', 'commands', 'files'), { recursive: true }) |
| await fsp.mkdir(path.join(homeFixtureDir, 'src', 'constants'), { recursive: true }) |
| await fsp.mkdir(path.join(homeFixtureDir, 'src', 'hooks'), { recursive: true }) |
| await fsp.mkdir(path.join(homeFixtureDir, 'desktop', 'src'), { recursive: true }) |
| await fsp.mkdir(path.join(homeFixtureDir, 'scripts', 'quality-gate', 'baseline', 'fixtures', 'cross-module-refactor', 'src'), { recursive: true }) |
| await fsp.mkdir(path.join(homeFixtureDir, '__pycache__'), { recursive: true }) |
| await fsp.mkdir(path.join(homeFixtureDir, 'node_modules', 'pkg'), { recursive: true }) |
| await fsp.mkdir(path.join(homeFixtureDir, '.venv', 'lib'), { recursive: true }) |
| await fsp.mkdir(path.join(homeFixtureDir, 'tmp-ignore'), { recursive: true }) |
| await fsp.writeFile(path.join(homeFixtureDir, '.gitignore'), ['__pycache__/', 'node_modules/', '.venv/', 'venv/'].join('\n')) |
| await fsp.writeFile(path.join(homeFixtureDir, '.ignore'), 'tmp-ignore/') |
| await fsp.writeFile(path.join(homeFixtureDir, 'src', 'commands', 'files.ts'), 'export {}') |
| await fsp.writeFile(path.join(homeFixtureDir, 'src', 'commands', 'files', 'index.ts'), 'export {}') |
| await fsp.writeFile(path.join(homeFixtureDir, 'src', 'constants', 'fileSearch.ts'), 'export {}') |
| await fsp.writeFile(path.join(homeFixtureDir, 'src', 'hooks', 'useFileSearch.ts'), 'export {}') |
| await fsp.writeFile(path.join(homeFixtureDir, 'desktop', 'src', 'main.ts'), 'export {}') |
| await fsp.writeFile(path.join(homeFixtureDir, 'scripts', 'quality-gate', 'baseline', 'fixtures', 'cross-module-refactor', 'src', 'index.ts'), 'export {}') |
| await fsp.writeFile(path.join(homeFixtureDir, '__pycache__', 'fileSearch.cpython-311.pyc'), '') |
| await fsp.writeFile(path.join(homeFixtureDir, 'node_modules', 'pkg', 'files.js'), '') |
| await fsp.writeFile(path.join(homeFixtureDir, '.venv', 'lib', 'files.py'), '') |
| await fsp.writeFile(path.join(homeFixtureDir, 'tmp-ignore', 'files.tmp'), '') |
|
|
| const res = await handleFilesystemRoute( |
| '/api/filesystem/browse', |
| makeUrl('/api/filesystem/browse', { |
| path: homeFixtureDir, |
| search: 'files', |
| includeFiles: 'true', |
| }), |
| ) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() as { entries: Array<{ name: string; relativePath?: string; isDirectory: boolean }> } |
| expect(body.entries).toEqual(expect.arrayContaining([ |
| expect.objectContaining({ |
| name: 'files.ts', |
| relativePath: 'src/commands/files.ts', |
| isDirectory: false, |
| }), |
| ])) |
| expect(body.entries.some((entry) => entry.relativePath === 'src/constants/fileSearch.ts')).toBe(true) |
| expect(body.entries.find((entry) => entry.relativePath === 'src/commands/files')?.isDirectory).toBe(true) |
| expect(body.entries.some((entry) => entry.relativePath === '__pycache__/fileSearch.cpython-311.pyc')).toBe(false) |
| expect(body.entries.some((entry) => entry.relativePath === 'node_modules/pkg/files.js')).toBe(false) |
| expect(body.entries.some((entry) => entry.relativePath === '.venv/lib/files.py')).toBe(false) |
| expect(body.entries.some((entry) => entry.relativePath === 'tmp-ignore/files.tmp')).toBe(false) |
|
|
| const srcRes = await handleFilesystemRoute( |
| '/api/filesystem/browse', |
| makeUrl('/api/filesystem/browse', { |
| path: homeFixtureDir, |
| search: 'src', |
| includeFiles: 'true', |
| }), |
| ) |
|
|
| expect(srcRes.status).toBe(200) |
| const srcBody = await srcRes.json() as { entries: Array<{ relativePath?: string }> } |
| const srcPaths = srcBody.entries.map(entry => entry.relativePath) |
| expect(srcPaths[0]).toBe('src') |
| expect(srcPaths.indexOf('src/hooks')).toBeGreaterThan(-1) |
| expect(srcPaths.indexOf('desktop/src')).toBeGreaterThan(-1) |
| expect(srcPaths.indexOf('scripts/quality-gate/baseline/fixtures/cross-module-refactor/src')).toBeGreaterThan(-1) |
| expect(srcPaths.indexOf('src/hooks')).toBeLessThan(srcPaths.indexOf('desktop/src')) |
| expect(srcPaths.indexOf('src/hooks')).toBeLessThan(srcPaths.indexOf('scripts/quality-gate/baseline/fixtures/cross-module-refactor/src')) |
| }) |
|
|
| it('falls back to ripgrep search outside git and still respects ignore files', async () => { |
| const homeFixtureDir = await fsp.mkdtemp(path.join(os.homedir(), 'claude-filesystem-test-')) |
| cleanupDirs.add(homeFixtureDir) |
| await fsp.mkdir(path.join(homeFixtureDir, 'app'), { recursive: true }) |
| await fsp.mkdir(path.join(homeFixtureDir, 'node_modules', 'pkg'), { recursive: true }) |
| await fsp.writeFile(path.join(homeFixtureDir, '.gitignore'), 'node_modules/') |
| await fsp.writeFile(path.join(homeFixtureDir, 'app', 'cache-result.ts'), 'export {}') |
| await fsp.writeFile(path.join(homeFixtureDir, 'node_modules', 'pkg', 'cache-result.js'), '') |
|
|
| const res = await handleFilesystemRoute( |
| '/api/filesystem/browse', |
| makeUrl('/api/filesystem/browse', { |
| path: homeFixtureDir, |
| search: 'cache', |
| includeFiles: 'true', |
| }), |
| ) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() as { entries: Array<{ relativePath?: string; isDirectory: boolean }> } |
| expect(body.entries).toEqual(expect.arrayContaining([ |
| expect.objectContaining({ |
| relativePath: 'app/cache-result.ts', |
| isDirectory: false, |
| }), |
| ])) |
| expect(body.entries.some((entry) => entry.relativePath === 'node_modules/pkg/cache-result.js')).toBe(false) |
| }) |
|
|
| it('accepts /private/tmp aliases on macOS for browsing and file serving', async () => { |
| if (process.platform !== 'darwin') return |
|
|
| const tmpFixtureDir = await fsp.mkdtemp('/tmp/claude-filesystem-test-') |
| cleanupDirs.add(tmpFixtureDir) |
| const canonicalTmpDir = fs.realpathSync(tmpFixtureDir) |
| const imagePath = path.join(canonicalTmpDir, 'preview.png') |
| await fsp.writeFile( |
| imagePath, |
| Buffer.from('89504e470d0a1a0a0000000d4948445200000001000000010802000000907753de0000000c49444154789c63606060000000040001f61738550000000049454e44ae426082', 'hex'), |
| ) |
|
|
| const browseRes = await handleFilesystemRoute( |
| '/api/filesystem/browse', |
| makeUrl('/api/filesystem/browse', { |
| path: canonicalTmpDir, |
| includeFiles: 'true', |
| }), |
| ) |
| expect(browseRes.status).toBe(200) |
| const browseBody = await browseRes.json() as { entries: Array<{ name: string }> } |
| expect(browseBody.entries.some((entry) => entry.name === 'preview.png')).toBe(true) |
|
|
| const fileRes = await handleFilesystemRoute( |
| '/api/filesystem/file', |
| makeUrl('/api/filesystem/file', { |
| path: imagePath, |
| }), |
| ) |
| expect(fileRes.status).toBe(200) |
| expect(fileRes.headers.get('Content-Type')).toBe('image/png') |
| }) |
| }) |
|
|