Skip to main content
Glama
set-project-root.test.ts24 kB
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { resolve } from 'path'; // Import the modules under test import { setProjectRootTool, handleSetProjectRoot, requireProjectRoot, type SetProjectRootArgs } from '../set-project-root.js'; import { projectContext } from '../../context/project-context.js'; import * as configLoader from '../../config/config-loader.js'; import type { ResolvedVitestMCPConfig } from '../../types/config-types.js'; // Mock dependencies with proper hoisting const mockFileExists = vi.hoisted(() => vi.fn()); const mockIsDirectory = vi.hoisted(() => vi.fn()); const mockReadFile = vi.hoisted(() => vi.fn()); vi.mock('fs', () => ({ promises: { readFile: mockReadFile, access: vi.fn(), stat: vi.fn(), } })); // Mock the dynamic import for fs/promises vi.doMock('fs/promises', () => ({ readFile: mockReadFile, access: vi.fn(), stat: vi.fn(), })); vi.mock('../../config/config-loader.js', () => ({ getConfig: vi.fn() })); vi.mock('../../utils/file-utils.js', () => ({ fileExists: mockFileExists, isDirectory: mockIsDirectory })); describe('set-project-root', () => { const mockConfig: ResolvedVitestMCPConfig = { testDefaults: { format: 'summary', timeout: 30000, watchMode: false }, coverageDefaults: { threshold: 80, format: 'summary', thresholds: { lines: 80, functions: 80, branches: 80, statements: 80 }, thresholdsExplicitlySet: false, exclude: [] }, discovery: { testPatterns: ['**/*.{test,spec}.{js,ts,jsx,tsx}'], excludePatterns: ['node_modules', 'dist', 'coverage', '.git'], maxDepth: 10 }, server: { verbose: false, validatePaths: true, allowRootExecution: false, workingDirectory: process.cwd() }, safety: { maxFiles: 100, requireConfirmation: true, allowedRunners: ['vitest'], allowedPaths: undefined as any } }; beforeEach(() => { vi.clearAllMocks(); projectContext.reset(); // Setup default mocks vi.mocked(configLoader.getConfig).mockResolvedValue(mockConfig); // Mock file existence - by default, return true for directories and package.json mockFileExists.mockImplementation(async (path: string) => { // Always return true for package.json to satisfy project validation if (path.endsWith('package.json')) return true; // Return true for directories by default return true; }); mockIsDirectory.mockResolvedValue(true); mockReadFile.mockResolvedValue(JSON.stringify({ name: 'test-project' })); // Reset environment variables delete process.env.VITEST_MCP_DEV_MODE; }); afterEach(() => { vi.restoreAllMocks(); }); describe('Tool Definition', () => { it('should have correct name and description', () => { // Arrange & Act & Assert expect(setProjectRootTool.name).toBe('set_project_root'); expect(setProjectRootTool.description).toContain('Set the project root directory'); expect(setProjectRootTool.description).toContain('must be called before using other tools'); }); it('should define proper input schema', () => { // Arrange & Act & Assert expect(setProjectRootTool.inputSchema).toEqual({ type: 'object', properties: { path: { type: 'string', description: 'Absolute path to the project root directory (must start with / on Unix or drive letter on Windows)' } }, required: ['path'] }); }); it('should require path parameter', () => { // Arrange & Act & Assert expect(setProjectRootTool.inputSchema.required).toContain('path'); }); it('should validate path parameter type', () => { // Arrange & Act & Assert expect(setProjectRootTool.inputSchema.properties.path.type).toBe('string'); }); }); describe('Path Validation', () => { it('should accept absolute paths', async () => { // Arrange const absolutePath = '/absolute/project/path'; const args: SetProjectRootArgs = { path: absolutePath }; // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(true); expect(result.projectRoot).toBe(absolutePath); expect(result.message).toContain('Project root set to'); }); it('should accept relative paths and resolve them', async () => { // Arrange const relativePath = './relative/path'; const resolvedPath = resolve(relativePath); const args: SetProjectRootArgs = { path: relativePath }; // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(true); expect(result.projectRoot).toBe(resolvedPath); }); it('should validate path exists', async () => { // Arrange const nonExistentPath = '/non/existent/path'; const args: SetProjectRootArgs = { path: nonExistentPath }; mockFileExists.mockResolvedValue(false); // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(false); expect(result.message).toContain('Directory does not exist'); }); it('should validate path is a directory', async () => { // Arrange const filePath = '/path/to/file.txt'; const args: SetProjectRootArgs = { path: filePath }; // Keep the file existing but mock it as not a directory mockFileExists.mockImplementation(async (path: string) => { if (path === filePath) return true; if (path.endsWith('package.json')) return true; return true; }); mockIsDirectory.mockResolvedValue(false); // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(false); expect(result.message).toContain('Path is not a directory'); }); it('should reject dangerous paths', async () => { // Arrange const configWithRestrictions = { ...mockConfig, safety: { ...mockConfig.safety, allowedPaths: ['/safe/path'] } }; vi.mocked(configLoader.getConfig).mockResolvedValue(configWithRestrictions); const dangerousPath = '/dangerous/path'; const args: SetProjectRootArgs = { path: dangerousPath }; // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(false); expect(result.message).toContain('Access denied'); expect(result.message).toContain('outside allowed directories'); }); it('should handle path traversal attempts', async () => { // Arrange const configWithRestrictions = { ...mockConfig, safety: { ...mockConfig.safety, allowedPaths: ['/safe/path'] } }; vi.mocked(configLoader.getConfig).mockResolvedValue(configWithRestrictions); const traversalPath = '/safe/path/../../../etc'; const args: SetProjectRootArgs = { path: traversalPath }; // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(false); expect(result.message).toContain('Access denied'); }); }); describe('Project Context Management', () => { it('should set project root in context', async () => { // Arrange const projectPath = '/test/project'; const args: SetProjectRootArgs = { path: projectPath }; // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(true); expect(projectContext.hasProjectRoot()).toBe(true); expect(projectContext.getProjectRoot()).toBe(projectPath); }); it('should replace previous project root', async () => { // Arrange const firstPath = '/first/project'; const secondPath = '/second/project'; await handleSetProjectRoot({ path: firstPath }); expect(projectContext.getProjectRoot()).toBe(firstPath); // Act const result = await handleSetProjectRoot({ path: secondPath }); // Assert expect(result.success).toBe(true); expect(projectContext.getProjectRoot()).toBe(secondPath); }); it('should clear previous context state', async () => { // Arrange const firstPath = '/first/project'; const secondPath = '/second/project'; await handleSetProjectRoot({ path: firstPath }); expect(projectContext.hasProjectRoot()).toBe(true); // Act await handleSetProjectRoot({ path: secondPath }); // Assert expect(projectContext.getProjectRoot()).toBe(secondPath); expect(projectContext.getProjectInfo()?.path).toBe(secondPath); }); it('should validate new project root', async () => { // Arrange const invalidPath = '/invalid/path'; const args: SetProjectRootArgs = { path: invalidPath }; // Make the directory not exist mockFileExists.mockImplementation(async (path: string) => { if (path === invalidPath) return false; // Don't return true for package.json if the directory doesn't exist return false; }); mockIsDirectory.mockResolvedValue(false); // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(false); expect(result.message).toContain('Directory does not exist'); }); it('should persist context across tool calls', async () => { // Arrange const projectPath = '/test/project'; await handleSetProjectRoot({ path: projectPath }); // Act const retrievedPath = requireProjectRoot(); // Assert expect(retrievedPath).toBe(projectPath); }); }); describe('Self-Protection', () => { it('should prevent setting root to vitest-mcp directory', async () => { // Arrange const vitestMcpPath = '/path/to/vitest-mcp'; const args: SetProjectRootArgs = { path: vitestMcpPath }; // Make sure the path exists and is a directory mockFileExists.mockImplementation(async (path: string) => { if (path === vitestMcpPath) return true; if (path.endsWith('package.json')) return true; return true; }); mockIsDirectory.mockResolvedValue(true); mockReadFile.mockResolvedValue(JSON.stringify({ name: '@djankies/vitest-mcp' })); // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(false); expect(result.message).toContain('Cannot set project root to the Vitest MCP package itself'); }); it('should allow self-targeting in development mode', async () => { // Arrange process.env.VITEST_MCP_DEV_MODE = 'true'; const vitestMcpPath = '/path/to/vitest-mcp'; const args: SetProjectRootArgs = { path: vitestMcpPath }; // Make sure the path exists and is a directory mockFileExists.mockImplementation(async (path: string) => { if (path === vitestMcpPath) return true; if (path.endsWith('package.json')) return true; return true; }); mockIsDirectory.mockResolvedValue(true); mockReadFile.mockResolvedValue(JSON.stringify({ name: '@djankies/vitest-mcp' })); // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(true); expect(result.message).toContain('Development mode enabled'); }); it('should check development mode environment variable', async () => { // Arrange process.env.VITEST_MCP_DEV_MODE = 'true'; const projectPath = '/test/project'; const args: SetProjectRootArgs = { path: projectPath }; // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(true); expect(result.message).toContain('Development mode enabled - self-targeting allowed'); }); it('should provide clear error for self-targeting', async () => { // Arrange const vitestMcpPath = '/path/to/vitest-mcp'; const args: SetProjectRootArgs = { path: vitestMcpPath }; // Make sure the path exists and is a directory mockFileExists.mockImplementation(async (path: string) => { if (path === vitestMcpPath) return true; if (path.endsWith('package.json')) return true; return true; }); mockIsDirectory.mockResolvedValue(true); mockReadFile.mockResolvedValue(JSON.stringify({ name: '@djankies/vitest-mcp' })); // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(false); expect(result.message).toContain('This tool is meant to test other projects, not itself'); expect(result.message).toContain('Set VITEST_MCP_DEV_MODE=true to override'); }); }); describe('Error Handling', () => { it('should handle non-existent directories', async () => { // Arrange const nonExistentPath = '/does/not/exist'; const args: SetProjectRootArgs = { path: nonExistentPath }; mockFileExists.mockResolvedValue(false); // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(false); expect(result.message).toContain('Failed to set project root'); expect(result.message).toContain('Directory does not exist'); expect(result.projectRoot).toBe(''); expect(result.projectName).toBe(''); }); it('should handle permission errors', async () => { // Arrange const restrictedPath = '/restricted/path'; const args: SetProjectRootArgs = { path: restrictedPath }; mockFileExists.mockRejectedValue(new Error('Permission denied')); // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(false); expect(result.message).toContain('Failed to set project root'); }); it('should handle invalid path formats', async () => { // Arrange const invalidPaths = ['', ' ', null as any, undefined as any]; for (const invalidPath of invalidPaths) { const args: SetProjectRootArgs = { path: invalidPath }; // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(false); expect(result.message).toContain('Path parameter is required'); } }); it('should provide helpful error messages', async () => { // Arrange const testCases = [ { scenario: 'empty path', args: { path: '' }, expectedMessage: 'Path parameter is required' }, { scenario: 'non-existent directory', args: { path: '/does/not/exist' }, setup: () => mockFileExists.mockResolvedValue(false), expectedMessage: 'Directory does not exist' }, { scenario: 'file instead of directory', args: { path: '/path/to/file.txt' }, setup: () => { mockFileExists.mockImplementation(async (path: string) => { if (path === '/path/to/file.txt') return true; if (path.endsWith('package.json')) return true; return true; }); mockIsDirectory.mockResolvedValue(false); }, expectedMessage: 'Path is not a directory' } ]; for (const testCase of testCases) { // Arrange if (testCase.setup) { testCase.setup(); } // Act const result = await handleSetProjectRoot(testCase.args); // Assert expect(result.success).toBe(false); expect(result.message).toContain(testCase.expectedMessage); // Reset mocks vi.clearAllMocks(); mockFileExists.mockResolvedValue(true); mockIsDirectory.mockResolvedValue(true); } }); it('should handle file path (not directory) arguments', async () => { // Arrange const filePath = '/path/to/file.txt'; const args: SetProjectRootArgs = { path: filePath }; // Make sure the file exists but is not a directory mockFileExists.mockImplementation(async (path: string) => { if (path === filePath) return true; if (path.endsWith('package.json')) return true; return true; }); mockIsDirectory.mockResolvedValue(false); // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(false); expect(result.message).toContain('Path is not a directory'); expect(result.projectRoot).toBe(''); expect(result.projectName).toBe(''); }); }); describe('Success Response', () => { it('should return success confirmation', async () => { // Arrange const projectPath = '/test/project'; const args: SetProjectRootArgs = { path: projectPath }; // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(true); expect(result.message).toContain('Project root set to'); }); it('should include resolved absolute path', async () => { // Arrange const relativePath = './relative/project'; const resolvedPath = resolve(relativePath); const args: SetProjectRootArgs = { path: relativePath }; // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(true); expect(result.projectRoot).toBe(resolvedPath); expect(result.message).toContain(resolvedPath); }); it('should include project validation results', async () => { // Arrange const projectPath = '/test/project'; const args: SetProjectRootArgs = { path: projectPath }; // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(true); expect(result.projectName).toBe('project'); // Extracted from path expect(result.projectRoot).toBe(projectPath); }); it('should provide context about next steps', async () => { // Arrange const projectPath = '/test/project'; const args: SetProjectRootArgs = { path: projectPath }; // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(true); expect(result.message).toContain('Project root set to'); expect(result.message).toContain(projectPath); }); }); describe('Integration', () => { it('should work with subsequent tool calls', async () => { // Arrange const projectPath = '/test/project'; await handleSetProjectRoot({ path: projectPath }); // Act const retrievedPath = requireProjectRoot(); // Assert expect(retrievedPath).toBe(projectPath); }); it('should integrate with file discovery tools', async () => { // Arrange const projectPath = '/test/project'; await handleSetProjectRoot({ path: projectPath }); // Act const context = projectContext.getProjectInfo(); // Assert expect(context).not.toBeNull(); expect(context!.path).toBe(projectPath); expect(context!.name).toBe('project'); }); it('should integrate with test execution tools', async () => { // Arrange const projectPath = '/test/project'; await handleSetProjectRoot({ path: projectPath }); // Act & Assert expect(() => requireProjectRoot()).not.toThrow(); expect(requireProjectRoot()).toBe(projectPath); }); it('should maintain context isolation between sessions', async () => { // Arrange const firstPath = '/first/project'; const secondPath = '/second/project'; // Act - First session await handleSetProjectRoot({ path: firstPath }); expect(projectContext.getProjectRoot()).toBe(firstPath); // Act - Reset context (simulating new session) projectContext.reset(); expect(() => projectContext.getProjectRoot()).toThrow('Project root has not been set'); // Act - Second session await handleSetProjectRoot({ path: secondPath }); // Assert expect(projectContext.getProjectRoot()).toBe(secondPath); }); }); describe('Path Resolution', () => { it('should resolve relative paths correctly', async () => { // Arrange const relativePath = './test/project'; const expectedAbsolute = resolve(relativePath); const args: SetProjectRootArgs = { path: relativePath }; // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(true); expect(result.projectRoot).toBe(expectedAbsolute); }); it('should handle home directory expansion', async () => { // Arrange const homePath = '~/projects/test'; const args: SetProjectRootArgs = { path: homePath }; // Note: Path.resolve() treats ~ as a literal character, not home expansion // The path gets resolved to an absolute path like "/current/dir/~/projects/test" // Since it becomes absolute and our mocks say it exists, it should succeed // Act const result = await handleSetProjectRoot(args); // Assert // Since resolve() makes it an absolute path and our mocks allow it, it should succeed expect(result.success).toBe(true); expect(result.projectRoot).toContain('~/projects/test'); }); it('should normalize path separators', async () => { // Arrange const mixedPath = '/test/project//subdir/../project'; const normalizedPath = resolve(mixedPath); const args: SetProjectRootArgs = { path: mixedPath }; // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(true); expect(result.projectRoot).toBe(normalizedPath); }); it('should handle symlinks appropriately', async () => { // Arrange const symlinkPath = '/path/to/symlink'; const args: SetProjectRootArgs = { path: symlinkPath }; // Mock file system calls to simulate symlink behavior mockFileExists.mockResolvedValue(true); mockIsDirectory.mockResolvedValue(true); // Act const result = await handleSetProjectRoot(args); // Assert expect(result.success).toBe(true); expect(result.projectRoot).toBe(symlinkPath); }); it('should validate resolved paths', async () => { // Arrange const configWithRestrictions = { ...mockConfig, safety: { ...mockConfig.safety, allowedPaths: ['/allowed/base'] } }; vi.mocked(configLoader.getConfig).mockResolvedValue(configWithRestrictions); const testCases = [ { path: '/allowed/base/project', shouldSucceed: true, description: 'path within allowed directory' }, { path: '/allowed/base/../outside', shouldSucceed: false, description: 'path that resolves outside allowed directory' }, { path: '/allowed/base', shouldSucceed: true, description: 'exact allowed path' } ]; for (const testCase of testCases) { // Act const result = await handleSetProjectRoot({ path: testCase.path }); // Assert if (testCase.shouldSucceed) { expect(result.success).toBe(true); } else { expect(result.success).toBe(false); expect(result.message).toContain('Access denied'); } } }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/djankies/vitest-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server