import assert from 'node:assert/strict'; import path from 'node:path'; import { Readable } from 'node:stream'; import test from 'node:test'; import { createFileTreeService } from '@/modules/file-tree/file-tree.service.js'; import type { FileTreeDirectoryEntry, FileTreeFileSystem, FileTreeServiceDependencies, FileTreeStats, } from '@/shared/types.js'; import { AppError } from '@/shared/utils.js'; function createDirectoryEntry(name: string, directory: boolean): FileTreeDirectoryEntry { return { name, isDirectory: () => directory, }; } /** * Adapts a path-keyed listing to the streaming directory contract so tests keep * describing directories as plain arrays. */ function createDirectoryReader( listDirectory: (directoryPath: string) => FileTreeDirectoryEntry[], ): FileTreeFileSystem['openDirectory'] { return async function* openDirectory(directoryPath) { yield* listDirectory(directoryPath); }; } function createStats(directory: boolean, mode: number): FileTreeStats { return { size: directory ? 0 : 24, mtime: new Date('2026-01-02T03:04:05.000Z'), mode, isDirectory: () => directory, isSymbolicLink: () => false, }; } function createFakeFileSystem( overrides: Partial = {}, ): FileTreeFileSystem { const unexpectedOperation = async (): Promise => { throw new Error('Unexpected File Tree filesystem operation'); }; return { access: unexpectedOperation, stat: unexpectedOperation, lstat: unexpectedOperation, openDirectory: () => ({ [Symbol.asyncIterator]: () => ({ next: unexpectedOperation }), }), realpath: unexpectedOperation, readTextFile: unexpectedOperation, writeTextFile: unexpectedOperation, makeDirectory: unexpectedOperation, rename: unexpectedOperation, removeDirectory: unexpectedOperation, unlink: unexpectedOperation, copyFile: unexpectedOperation, createReadStream: () => Readable.from([]), ...overrides, }; } function createDependencies( fileSystem: FileTreeFileSystem, projectRoot: string, ): FileTreeServiceDependencies { return { fileSystem, projects: { getProjectPathById: async () => projectRoot, }, workspace: { rootPath: projectRoot, validatePath: async (candidatePath) => ({ valid: true, resolvedPath: candidatePath }), }, resolveMimeType: () => 'text/plain', fileSystemConcurrency: 4, logger: { error: () => undefined }, }; } test('listProjectFiles applies gitignore alongside hard directory exclusions', async () => { const projectRoot = path.resolve('file-tree-test-project'); const documentationDirectory = path.join(projectRoot, 'docs'); const buildDocumentationDirectory = path.join(documentationDirectory, 'build'); const gitDirectory = path.join(projectRoot, '.git'); const nodeModulesDirectory = path.join(projectRoot, 'node_modules'); const sourceDirectory = path.join(projectRoot, 'src'); const readDirectories: string[] = []; const fileSystem = createFakeFileSystem({ access: async () => undefined, readTextFile: async (filePath) => { assert.equal(filePath, path.join(projectRoot, '.gitignore')); return '*.log'; }, openDirectory: createDirectoryReader((directoryPath) => { readDirectories.push(directoryPath); if (directoryPath === projectRoot) { return [ createDirectoryEntry('.git', true), createDirectoryEntry('node_modules', true), createDirectoryEntry('README.md', false), createDirectoryEntry('docs', true), createDirectoryEntry('src', true), ]; } if (directoryPath === documentationDirectory) { return [createDirectoryEntry('build', true)]; } if (directoryPath === buildDocumentationDirectory) { return [createDirectoryEntry('foo.md', false)]; } if (directoryPath === sourceDirectory) { return [createDirectoryEntry('index.ts', false)]; } return []; }), lstat: async (candidatePath) => createStats( candidatePath === documentationDirectory || candidatePath === buildDocumentationDirectory || candidatePath === sourceDirectory || candidatePath === gitDirectory || candidatePath === nodeModulesDirectory, 0o754, ), }); const service = createFileTreeService(createDependencies(fileSystem, projectRoot)); const tree = await service.listProjectFiles('project-1', { respectGitignore: true }); assert.deepEqual(tree.map((entry) => entry.name), ['docs', 'src', 'README.md']); const documentationEntry = tree[0]; assert.deepEqual(documentationEntry?.children?.map((entry) => entry.name), ['build']); assert.deepEqual(documentationEntry?.children?.[0]?.children?.map((entry) => entry.name), ['foo.md']); const sourceEntry = tree[1]; assert.ok(sourceEntry); assert.equal(sourceEntry.type, 'directory'); assert.equal(sourceEntry.permissions, '754'); assert.equal(sourceEntry.permissionsRwx, 'rwxr-xr--'); assert.deepEqual(sourceEntry.children?.map((entry) => entry.name), ['index.ts']); assert.equal(readDirectories.includes(gitDirectory), false); assert.equal(readDirectories.includes(nodeModulesDirectory), false); }); test('listProjectFiles excludes gitignored entries only when requested', async () => { const projectRoot = path.resolve('file-tree-test-project'); const cacheDirectory = path.join(projectRoot, 'cache'); const sourceDirectory = path.join(projectRoot, 'src'); const readDirectories: string[] = []; const fileSystem = createFakeFileSystem({ access: async () => undefined, readTextFile: async (filePath) => { assert.equal(filePath, path.join(projectRoot, '.gitignore')); return ['*.log', '!keep.log', 'cache/', 'src/generated.ts'].join('\n'); }, openDirectory: createDirectoryReader((directoryPath) => { readDirectories.push(directoryPath); if (directoryPath === projectRoot) { return [ createDirectoryEntry('.gitignore', false), createDirectoryEntry('cache', true), createDirectoryEntry('ignored.log', false), createDirectoryEntry('keep.log', false), createDirectoryEntry('src', true), ]; } if (directoryPath === cacheDirectory) { return [createDirectoryEntry('cached.txt', false)]; } if (directoryPath === sourceDirectory) { return [ createDirectoryEntry('generated.ts', false), createDirectoryEntry('index.ts', false), ]; } return []; }), lstat: async (candidatePath) => createStats( candidatePath === cacheDirectory || candidatePath === sourceDirectory, 0o644, ), }); const service = createFileTreeService(createDependencies(fileSystem, projectRoot)); const tree = await service.listProjectFiles('project-1', { respectGitignore: true }); assert.deepEqual(tree.map((entry) => entry.name), ['src', '.gitignore', 'keep.log']); assert.deepEqual(tree[0]?.children?.map((entry) => entry.name), ['index.ts']); assert.equal(readDirectories.includes(cacheDirectory), false); }); test('listProjectFiles falls back to conventional directory names when no gitignore exists', async () => { const projectRoot = path.resolve('file-tree-test-project'); const documentationDirectory = path.join(projectRoot, 'docs'); const buildDocumentationDirectory = path.join(documentationDirectory, 'build'); const nodeModulesDirectory = path.join(projectRoot, 'node_modules'); const readDirectories: string[] = []; const fileSystem = createFakeFileSystem({ access: async () => undefined, readTextFile: async () => { throw Object.assign(new Error('missing'), { code: 'ENOENT' }); }, openDirectory: createDirectoryReader((directoryPath) => { readDirectories.push(directoryPath); if (directoryPath === projectRoot) { return [ createDirectoryEntry('debug.log', false), createDirectoryEntry('docs', true), createDirectoryEntry('node_modules', true), ]; } if (directoryPath === documentationDirectory) { return [ createDirectoryEntry('build', true), createDirectoryEntry('guide.md', false), ]; } if (directoryPath === buildDocumentationDirectory) { return [createDirectoryEntry('generated.md', false)]; } return []; }), lstat: async (candidatePath) => createStats(candidatePath === documentationDirectory, 0o644), }); const service = createFileTreeService(createDependencies(fileSystem, projectRoot)); const tree = await service.listProjectFiles('project-1', { respectGitignore: true }); assert.deepEqual(tree.map((entry) => entry.name), ['docs', 'debug.log']); assert.deepEqual(tree[0]?.children?.map((entry) => entry.name), ['guide.md']); assert.equal(readDirectories.includes(nodeModulesDirectory), false); assert.equal(readDirectories.includes(buildDocumentationDirectory), false); }); test('listProjectFiles rejects a tree that exceeds the server entry limit', async () => { const projectRoot = path.resolve('file-tree-test-project'); const fileSystem = createFakeFileSystem({ access: async () => undefined, openDirectory: createDirectoryReader((directoryPath) => directoryPath === projectRoot ? Array.from({ length: 10_001 }, (_, index) => createDirectoryEntry(`file-${index}.txt`, false)) : []), lstat: async () => createStats(false, 0o644), }); const service = createFileTreeService(createDependencies(fileSystem, projectRoot)); await assert.rejects( service.listProjectFiles('project-1'), (error: unknown) => error instanceof AppError && error.code === 'FILE_TREE_TOO_LARGE' && error.statusCode === 413, ); }); test('listProjectFiles abandons a directory stream as soon as the entry limit is passed', async () => { const projectRoot = path.resolve('file-tree-test-project'); let streamedEntries = 0; const fileSystem = createFakeFileSystem({ access: async () => undefined, // Endless on purpose: the walk has to stop consuming the stream itself // instead of waiting for the directory listing to be materialized. openDirectory: async function* (directoryPath) { if (directoryPath !== projectRoot) { return; } for (let index = 0; ; index += 1) { streamedEntries += 1; yield createDirectoryEntry(`file-${index}.txt`, false); } }, lstat: async () => createStats(false, 0o644), }); const service = createFileTreeService(createDependencies(fileSystem, projectRoot)); await assert.rejects( service.listProjectFiles('project-1'), (error: unknown) => error instanceof AppError && error.code === 'FILE_TREE_TOO_LARGE' && error.statusCode === 413, ); // The budget plus the single entry that proves it was exceeded. assert.equal(streamedEntries, 10_001); }); test('listProjectFiles shares the entry limit across nested directories', async () => { const projectRoot = path.resolve('file-tree-test-project'); const firstDirectory = path.join(projectRoot, 'first'); const secondDirectory = path.join(projectRoot, 'second'); const directoryPaths = new Set([firstDirectory, secondDirectory]); const fileSystem = createFakeFileSystem({ access: async () => undefined, openDirectory: createDirectoryReader((directoryPath) => { if (directoryPath === projectRoot) { return [ createDirectoryEntry('first', true), createDirectoryEntry('second', true), ]; } if (directoryPaths.has(directoryPath)) { return Array.from( { length: 5_000 }, (_, index) => createDirectoryEntry(`${path.basename(directoryPath)}-${index}.txt`, false), ); } return []; }), lstat: async (candidatePath) => createStats(directoryPaths.has(candidatePath), 0o644), }); const service = createFileTreeService(createDependencies(fileSystem, projectRoot)); await assert.rejects( service.listProjectFiles('project-1'), (error: unknown) => error instanceof AppError && error.code === 'FILE_TREE_TOO_LARGE' && error.statusCode === 413, ); }); test('readTextFile rejects traversal before invoking the filesystem adapter', async () => { const projectRoot = path.resolve('file-tree-test-project'); const readPaths: string[] = []; const fileSystem = createFakeFileSystem({ readTextFile: async (filePath) => { readPaths.push(filePath); return 'should not be read'; }, }); const service = createFileTreeService(createDependencies(fileSystem, projectRoot)); await assert.rejects( service.readTextFile('project-1', '../secret.txt'), (error: unknown) => error instanceof AppError && error.code === 'PATH_OUTSIDE_PROJECT' && error.statusCode === 403, ); assert.deepEqual(readPaths, []); }); test('createEntry performs filesystem mutation only through the injected adapter', async () => { const projectRoot = path.resolve('file-tree-test-project'); const targetPath = path.join(projectRoot, 'notes.txt'); const writtenFiles: Array<{ filePath: string; content: string }> = []; const fileSystem = createFakeFileSystem({ access: async (candidatePath) => { if (candidatePath === targetPath) { throw Object.assign(new Error('missing'), { code: 'ENOENT' }); } }, writeTextFile: async (filePath, content) => { writtenFiles.push({ filePath, content }); }, }); const service = createFileTreeService(createDependencies(fileSystem, projectRoot)); const result = await service.createEntry({ projectId: 'project-1', parentPath: projectRoot, type: 'file', name: 'notes.txt', }); assert.equal(result.path, targetPath); assert.deepEqual(writtenFiles, [{ filePath: targetPath, content: '' }]); });