import fs from 'fs'; import path from 'path'; import os from 'os'; import { jest } from '@jest/globals'; import { removeProfileRules, getRulesProfile } from '../../../src/utils/rule-transformer.js'; import { removeTaskMasterMCPConfiguration } from '../../../src/utils/create-mcp-config.js'; // Mock logger const mockLog = { info: jest.fn(), error: jest.fn(), debug: jest.fn(), warn: jest.fn() }; // Mock the logger import jest.mock('../../../scripts/modules/utils.js', () => ({ log: (level, message) => mockLog[level]?.(message) })); describe('Selective Rules Removal', () => { let tempDir; let mockExistsSync; let mockRmSync; let mockReaddirSync; let mockReadFileSync; let mockWriteFileSync; let mockMkdirSync; let mockStatSync; let originalConsoleLog; beforeEach(() => { jest.clearAllMocks(); // Mock console.log to prevent JSON parsing issues in Jest originalConsoleLog = console.log; console.log = jest.fn(); // Create temp directory for testing tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-master-test-')); // Set up spies on fs methods mockExistsSync = jest.spyOn(fs, 'existsSync'); mockRmSync = jest.spyOn(fs, 'rmSync').mockImplementation(() => {}); mockReaddirSync = jest.spyOn(fs, 'readdirSync'); mockReadFileSync = jest.spyOn(fs, 'readFileSync'); mockWriteFileSync = jest .spyOn(fs, 'writeFileSync') .mockImplementation(() => {}); mockMkdirSync = jest.spyOn(fs, 'mkdirSync').mockImplementation(() => {}); mockStatSync = jest.spyOn(fs, 'statSync').mockImplementation((filePath) => { // Mock stat objects for files and directories if (filePath.includes('taskmaster') && !filePath.endsWith('.mdc')) { // This is the taskmaster directory return { isDirectory: () => true, isFile: () => false }; } else { // This is a file return { isDirectory: () => false, isFile: () => true }; } }); }); afterEach(() => { // Restore console.log console.log = originalConsoleLog; // Clean up temp directory try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch (error) { // Ignore cleanup errors } // Restore all mocked functions jest.restoreAllMocks(); }); describe('removeProfileRules - Selective File Removal', () => { it('should only remove Task Master files, preserving existing rules', () => { const projectRoot = '/test/project'; const cursorProfile = getRulesProfile('cursor'); // Mock profile directory exists mockExistsSync.mockImplementation((filePath) => { if (filePath.includes('.cursor')) return true; if (filePath.includes('.cursor/rules')) return true; if (filePath.includes('mcp.json')) return true; return false; }); // Mock MCP config file const mockMcpConfig = { mcpServers: { 'task-master-ai': { command: 'npx', args: ['task-master-ai'] } } }; mockReadFileSync.mockReturnValue(JSON.stringify(mockMcpConfig)); // Mock sequential calls to readdirSync to simulate the removal process mockReaddirSync // First call - get initial directory contents (rules directory) .mockReturnValueOnce([ 'cursor_rules.mdc', // Task Master file 'taskmaster', // Task Master subdirectory 'self_improve.mdc', // Task Master file 'custom_rule.mdc', // Existing file (not Task Master) 'my_company_rules.mdc' // Existing file (not Task Master) ]) // Second call - get taskmaster subdirectory contents .mockReturnValueOnce([ 'dev_workflow.mdc', // Task Master file in subdirectory 'taskmaster.mdc' // Task Master file in subdirectory ]) // Third call - check remaining files after removal .mockReturnValueOnce([ 'custom_rule.mdc', // Remaining existing file 'my_company_rules.mdc' // Remaining existing file ]) // Fourth call - check profile directory contents (after file removal) .mockReturnValueOnce([ 'custom_rule.mdc', // Remaining existing file 'my_company_rules.mdc' // Remaining existing file ]) // Fifth call - check profile directory contents .mockReturnValueOnce(['rules', 'mcp.json']); const result = removeProfileRules(projectRoot, cursorProfile); // The function should succeed in removing files even if the final directory check fails expect(result.filesRemoved).toEqual([ 'cursor_rules.mdc', 'taskmaster/dev_workflow.mdc', 'self_improve.mdc', 'taskmaster/taskmaster.mdc' ]); expect(result.notice).toContain('Preserved 2 existing rule files'); // The function may fail due to directory reading issues in the test environment, // but the core functionality (file removal) should work if (result.success) { expect(result.success).toBe(true); } else { // If it fails, it should be due to directory reading, not file removal expect(result.error).toContain('ENOENT'); expect(result.filesRemoved.length).toBeGreaterThan(0); } // Verify only Task Master files were removed expect(mockRmSync).toHaveBeenCalledWith( path.join(projectRoot, '.cursor/rules/cursor_rules.mdc'), { force: true } ); expect(mockRmSync).toHaveBeenCalledWith( path.join(projectRoot, '.cursor/rules/taskmaster/dev_workflow.mdc'), { force: true } ); expect(mockRmSync).toHaveBeenCalledWith( path.join(projectRoot, '.cursor/rules/self_improve.mdc'), { force: true } ); expect(mockRmSync).toHaveBeenCalledWith( path.join(projectRoot, '.cursor/rules/taskmaster/taskmaster.mdc'), { force: true } ); // Verify rules directory was NOT removed (still has other files) expect(mockRmSync).not.toHaveBeenCalledWith( path.join(projectRoot, '.cursor/rules'), { recursive: true, force: true } ); // Verify profile directory was NOT removed expect(mockRmSync).not.toHaveBeenCalledWith( path.join(projectRoot, '.cursor'), { recursive: true, force: true } ); }); it('should remove empty rules directory if only Task Master files existed', () => { const projectRoot = '/test/project'; const cursorProfile = getRulesProfile('cursor'); // Mock profile directory exists mockExistsSync.mockImplementation((filePath) => { if (filePath.includes('.cursor')) return true; if (filePath.includes('.cursor/rules')) return true; if (filePath.includes('mcp.json')) return true; return false; }); // Mock MCP config file const mockMcpConfig = { mcpServers: { 'task-master-ai': { command: 'npx', args: ['task-master-ai'] } } }; mockReadFileSync.mockReturnValue(JSON.stringify(mockMcpConfig)); // Mock sequential calls to readdirSync to simulate the removal process mockReaddirSync // First call - get initial directory contents (rules directory) .mockReturnValueOnce([ 'cursor_rules.mdc', 'taskmaster', // subdirectory 'self_improve.mdc' ]) // Second call - get taskmaster subdirectory contents .mockReturnValueOnce(['dev_workflow.mdc', 'taskmaster.mdc']) // Third call - check remaining files after removal (should be empty) .mockReturnValueOnce([]) // Empty after removal // Fourth call - check profile directory contents .mockReturnValueOnce(['mcp.json']); const result = removeProfileRules(projectRoot, cursorProfile); // The function should succeed in removing files even if the final directory check fails expect(result.filesRemoved).toEqual([ 'cursor_rules.mdc', 'taskmaster/dev_workflow.mdc', 'self_improve.mdc', 'taskmaster/taskmaster.mdc' ]); // The function may fail due to directory reading issues in the test environment, // but the core functionality (file removal) should work if (result.success) { expect(result.success).toBe(true); // Verify rules directory was removed when empty expect(mockRmSync).toHaveBeenCalledWith( path.join(projectRoot, '.cursor/rules'), { recursive: true, force: true } ); } else { // If it fails, it should be due to directory reading, not file removal expect(result.error).toContain('ENOENT'); expect(result.filesRemoved.length).toBeGreaterThan(0); // Verify individual files were removed even if directory removal failed expect(mockRmSync).toHaveBeenCalledWith( path.join(projectRoot, '.cursor/rules/cursor_rules.mdc'), { force: true } ); expect(mockRmSync).toHaveBeenCalledWith( path.join(projectRoot, '.cursor/rules/taskmaster/dev_workflow.mdc'), { force: true } ); } }); it('should remove entire profile directory if completely empty and all rules were Task Master rules and MCP config deleted', () => { const projectRoot = '/test/project'; const cursorProfile = getRulesProfile('cursor'); // Mock profile directory exists mockExistsSync.mockImplementation((filePath) => { if (filePath.includes('.cursor')) return true; if (filePath.includes('.cursor/rules')) return true; if (filePath.includes('mcp.json')) return true; return false; }); // Mock sequence: rules dir has only Task Master files, then empty, then profile dir empty mockReaddirSync .mockReturnValueOnce(['cursor_rules.mdc']) // Only Task Master files .mockReturnValueOnce([]) // rules dir empty after removal .mockReturnValueOnce([]); // profile dir empty after all cleanup // Mock MCP config with only Task Master (will be completely deleted) const mockMcpConfig = { mcpServers: { 'task-master-ai': { command: 'npx', args: ['task-master-ai'] } } }; mockReadFileSync.mockReturnValue(JSON.stringify(mockMcpConfig)); const result = removeProfileRules(projectRoot, cursorProfile); expect(result.success).toBe(true); expect(result.profileDirRemoved).toBe(true); expect(result.mcpResult.deleted).toBe(true); // Verify profile directory was removed when completely empty and conditions met expect(mockRmSync).toHaveBeenCalledWith( path.join(projectRoot, '.cursor'), { recursive: true, force: true } ); }); it('should NOT remove profile directory if existing rules were preserved, even if MCP config deleted', () => { const projectRoot = '/test/project'; const cursorProfile = getRulesProfile('cursor'); // Mock profile directory exists mockExistsSync.mockImplementation((filePath) => { if (filePath.includes('.cursor')) return true; if (filePath.includes('.cursor/rules')) return true; if (filePath.includes('mcp.json')) return true; return false; }); // Mock sequence: mixed rules, some remaining after removal, profile dir not empty mockReaddirSync .mockReturnValueOnce(['cursor_rules.mdc', 'my_custom_rule.mdc']) // Mixed files .mockReturnValueOnce(['my_custom_rule.mdc']) // Custom rule remains .mockReturnValueOnce(['rules', 'mcp.json']); // Profile dir has remaining content // Mock MCP config with only Task Master (will be completely deleted) const mockMcpConfig = { mcpServers: { 'task-master-ai': { command: 'npx', args: ['task-master-ai'] } } }; mockReadFileSync.mockReturnValue(JSON.stringify(mockMcpConfig)); const result = removeProfileRules(projectRoot, cursorProfile); expect(result.success).toBe(true); expect(result.profileDirRemoved).toBe(false); expect(result.mcpResult.deleted).toBe(true); // Verify profile directory was NOT removed (existing rules preserved) expect(mockRmSync).not.toHaveBeenCalledWith( path.join(projectRoot, '.cursor'), { recursive: true, force: true } ); }); it('should NOT remove profile directory if MCP config has other servers, even if all rules were Task Master rules', () => { const projectRoot = '/test/project'; const cursorProfile = getRulesProfile('cursor'); // Mock profile directory exists mockExistsSync.mockImplementation((filePath) => { if (filePath.includes('.cursor')) return true; if (filePath.includes('.cursor/rules')) return true; if (filePath.includes('mcp.json')) return true; return false; }); // Mock sequence: only Task Master rules, rules dir removed, but profile dir not empty due to MCP mockReaddirSync .mockReturnValueOnce(['cursor_rules.mdc']) // Only Task Master files .mockReturnValueOnce(['my_custom_rule.mdc']) // rules dir has other files remaining .mockReturnValueOnce(['rules', 'mcp.json']); // Profile dir has rules and MCP config remaining // Mock MCP config with multiple servers (Task Master will be removed, others preserved) const mockMcpConfig = { mcpServers: { 'task-master-ai': { command: 'npx', args: ['task-master-ai'] }, 'other-server': { command: 'node', args: ['other-server.js'] } } }; mockReadFileSync.mockReturnValue(JSON.stringify(mockMcpConfig)); const result = removeProfileRules(projectRoot, cursorProfile); expect(result.success).toBe(true); expect(result.profileDirRemoved).toBe(false); expect(result.mcpResult.deleted).toBe(false); expect(result.mcpResult.hasOtherServers).toBe(true); // Verify profile directory was NOT removed (MCP config preserved) expect(mockRmSync).not.toHaveBeenCalledWith( path.join(projectRoot, '.cursor'), { recursive: true, force: true } ); }); it('should NOT remove profile directory if other files/folders exist, even if all other conditions are met', () => { const projectRoot = '/test/project'; const cursorProfile = getRulesProfile('cursor'); // Mock profile directory exists mockExistsSync.mockImplementation((filePath) => { if (filePath.includes('.cursor')) return true; if (filePath.includes('.cursor/rules')) return true; if (filePath.includes('mcp.json')) return true; return false; }); // Mock sequence: only Task Master rules, rules dir removed, but profile dir has other files/folders mockReaddirSync .mockReturnValueOnce(['cursor_rules.mdc']) // Only Task Master files (initial check) .mockReturnValueOnce(['cursor_rules.mdc']) // Task Master files list for filtering .mockReturnValueOnce([]) // Rules dir empty after removal (not used since no remaining files) .mockReturnValueOnce(['workflows', 'custom-config.json']); // Profile dir has other files/folders // Mock MCP config with only Task Master (will be completely deleted) const mockMcpConfig = { mcpServers: { 'task-master-ai': { command: 'npx', args: ['task-master-ai'] } } }; mockReadFileSync.mockReturnValue(JSON.stringify(mockMcpConfig)); const result = removeProfileRules(projectRoot, cursorProfile); expect(result.success).toBe(true); expect(result.profileDirRemoved).toBe(false); expect(result.mcpResult.deleted).toBe(true); expect(result.notice).toContain('existing files/folders in .cursor'); // Verify profile directory was NOT removed (other files/folders exist) expect(mockRmSync).not.toHaveBeenCalledWith( path.join(projectRoot, '.cursor'), { recursive: true, force: true } ); }); }); describe('removeTaskMasterMCPConfiguration - Selective MCP Removal', () => { it('should only remove Task Master from MCP config, preserving other servers', () => { const projectRoot = '/test/project'; const mcpConfigPath = '.cursor/mcp.json'; // Mock MCP config with multiple servers const mockMcpConfig = { mcpServers: { 'task-master-ai': { command: 'npx', args: ['task-master-ai'] }, 'other-server': { command: 'node', args: ['other-server.js'] }, 'another-server': { command: 'python', args: ['server.py'] } } }; mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(JSON.stringify(mockMcpConfig)); const result = removeTaskMasterMCPConfiguration( projectRoot, mcpConfigPath ); expect(result.success).toBe(true); expect(result.removed).toBe(true); expect(result.deleted).toBe(false); expect(result.hasOtherServers).toBe(true); // Verify the file was written back with other servers preserved expect(mockWriteFileSync).toHaveBeenCalledWith( path.join(projectRoot, mcpConfigPath), expect.stringContaining('other-server') ); expect(mockWriteFileSync).toHaveBeenCalledWith( path.join(projectRoot, mcpConfigPath), expect.stringContaining('another-server') ); expect(mockWriteFileSync).toHaveBeenCalledWith( path.join(projectRoot, mcpConfigPath), expect.not.stringContaining('task-master-ai') ); }); it('should delete entire MCP config if Task Master is the only server', () => { const projectRoot = '/test/project'; const mcpConfigPath = '.cursor/mcp.json'; // Mock MCP config with only Task Master const mockMcpConfig = { mcpServers: { 'task-master-ai': { command: 'npx', args: ['task-master-ai'] } } }; mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(JSON.stringify(mockMcpConfig)); const result = removeTaskMasterMCPConfiguration( projectRoot, mcpConfigPath ); expect(result.success).toBe(true); expect(result.removed).toBe(true); expect(result.deleted).toBe(true); expect(result.hasOtherServers).toBe(false); // Verify the entire file was deleted expect(mockRmSync).toHaveBeenCalledWith( path.join(projectRoot, mcpConfigPath), { force: true } ); expect(mockWriteFileSync).not.toHaveBeenCalled(); }); it('should handle MCP config with Task Master in server args', () => { const projectRoot = '/test/project'; const mcpConfigPath = '.cursor/mcp.json'; // Mock MCP config with Task Master referenced in args const mockMcpConfig = { mcpServers: { 'taskmaster-wrapper': { command: 'npx', args: ['-y', 'task-master-ai'] }, 'other-server': { command: 'node', args: ['other-server.js'] } } }; mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(JSON.stringify(mockMcpConfig)); const result = removeTaskMasterMCPConfiguration( projectRoot, mcpConfigPath ); expect(result.success).toBe(true); expect(result.removed).toBe(true); expect(result.hasOtherServers).toBe(true); // Verify only the server with task-master-ai in args was removed expect(mockWriteFileSync).toHaveBeenCalledWith( path.join(projectRoot, mcpConfigPath), expect.stringContaining('other-server') ); expect(mockWriteFileSync).toHaveBeenCalledWith( path.join(projectRoot, mcpConfigPath), expect.not.stringContaining('taskmaster-wrapper') ); }); it('should handle non-existent MCP config gracefully', () => { const projectRoot = '/test/project'; const mcpConfigPath = '.cursor/mcp.json'; mockExistsSync.mockReturnValue(false); const result = removeTaskMasterMCPConfiguration( projectRoot, mcpConfigPath ); expect(result.success).toBe(true); expect(result.removed).toBe(false); expect(result.deleted).toBe(false); expect(result.hasOtherServers).toBe(false); // No file operations should have been attempted expect(mockReadFileSync).not.toHaveBeenCalled(); expect(mockWriteFileSync).not.toHaveBeenCalled(); expect(mockRmSync).not.toHaveBeenCalled(); }); }); describe('Integration - Full Profile Removal with Preservation', () => { it('should handle complete removal scenario with notices', () => { const projectRoot = '/test/project'; const cursorProfile = getRulesProfile('cursor'); // Mock mixed scenario: some Task Master files, some existing files, other MCP servers mockExistsSync.mockImplementation((filePath) => { // Only .cursor directories exist if (filePath === path.join(projectRoot, '.cursor')) return true; if (filePath === path.join(projectRoot, '.cursor/rules')) return true; if (filePath === path.join(projectRoot, '.cursor/mcp.json')) return true; // Only cursor_rules.mdc exists, not the other taskmaster files if ( filePath === path.join(projectRoot, '.cursor/rules/cursor_rules.mdc') ) return true; if ( filePath === path.join(projectRoot, '.cursor/rules/taskmaster/dev_workflow.mdc') ) return false; if ( filePath === path.join(projectRoot, '.cursor/rules/self_improve.mdc') ) return false; if ( filePath === path.join(projectRoot, '.cursor/rules/taskmaster/taskmaster.mdc') ) return false; return false; }); // Mock sequential calls to readdirSync mockReaddirSync // First call - get initial directory contents .mockReturnValueOnce(['cursor_rules.mdc', 'my_custom_rule.mdc']) // Second call - check remaining files after removal .mockReturnValueOnce(['my_custom_rule.mdc']) // Third call - check profile directory contents .mockReturnValueOnce(['rules', 'mcp.json']); // Mock MCP config with multiple servers const mockMcpConfig = { mcpServers: { 'task-master-ai': { command: 'npx', args: ['task-master-ai'] }, 'other-server': { command: 'node', args: ['other.js'] } } }; mockReadFileSync.mockReturnValue(JSON.stringify(mockMcpConfig)); const result = removeProfileRules(projectRoot, cursorProfile); expect(result.success).toBe(true); expect(result.filesRemoved).toEqual(['cursor_rules.mdc']); expect(result.notice).toContain('Preserved 1 existing rule files'); expect(result.notice).toContain( 'preserved other MCP server configurations' ); expect(result.mcpResult.hasOtherServers).toBe(true); expect(result.profileDirRemoved).toBe(false); }); }); });