import fs from 'fs'; import path from 'path'; import { jest } from '@jest/globals'; import { removeProfileRules, getRulesProfile } from '../../../src/utils/rule-transformer.js'; import { removeTaskMasterMCPConfiguration } from '../../../src/utils/mcp-config-setup.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 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(process.cwd(), 'test-temp-')); // 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(() => {}); }); 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; return false; }); // Mock sequential calls to readdirSync to simulate the removal process mockReaddirSync // First call - get initial directory contents .mockReturnValueOnce([ 'cursor_rules.mdc', // Task Master file 'dev_workflow.mdc', // Task Master file 'self_improve.mdc', // Task Master file 'taskmaster.mdc', // Task Master file 'custom_rule.mdc', // Existing file (not Task Master) 'my_company_rules.mdc' // Existing file (not Task Master) ]) // Second call - check remaining files after removal .mockReturnValueOnce([ 'custom_rule.mdc', // Remaining existing file 'my_company_rules.mdc' // Remaining existing file ]) // Third call - check profile directory contents .mockReturnValueOnce(['rules', 'mcp.json']); const result = removeProfileRules(projectRoot, cursorProfile); expect(result.success).toBe(true); expect(result.filesRemoved).toEqual([ 'cursor_rules.mdc', 'dev_workflow.mdc', 'self_improve.mdc', 'taskmaster.mdc' ]); expect(result.notice).toContain('Preserved 2 existing rule files'); // 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/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.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; return false; }); // Mock rules directory with only Task Master files mockReaddirSync.mockImplementation((dirPath) => { if (dirPath.includes('.cursor/rules')) { // Before removal return [ 'cursor_rules.mdc', 'dev_workflow.mdc', 'self_improve.mdc', 'taskmaster.mdc' ]; } if (dirPath.includes('.cursor')) { // After rules removal, only mcp.json remains return ['mcp.json']; } return []; }); // Mock empty directory after removing Task Master files mockReaddirSync .mockReturnValueOnce([ 'cursor_rules.mdc', 'dev_workflow.mdc', 'self_improve.mdc', 'taskmaster.mdc' ]) .mockReturnValueOnce([]); // Empty after removal const result = removeProfileRules(projectRoot, cursorProfile); expect(result.success).toBe(true); expect(result.filesRemoved).toEqual([ 'cursor_rules.mdc', 'dev_workflow.mdc', 'self_improve.mdc', 'taskmaster.mdc' ]); // Verify rules directory was removed when empty expect(mockRmSync).toHaveBeenCalledWith( path.join(projectRoot, '.cursor/rules'), { recursive: true, 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([]) // rules dir empty after removal .mockReturnValueOnce(['mcp.json']); // Profile dir has 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 .mockReturnValueOnce([]) // rules dir empty after removal .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('Preserved 2 existing files/folders'); // 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', '--package=task-master-ai', '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) => { if (filePath.includes('.cursor')) return true; if (filePath.includes('mcp.json')) return true; 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); }); }); });