diff --git a/src/core/config/__tests__/CustomModesManager.spec.ts b/src/core/config/__tests__/CustomModesManager.spec.ts index d571f1a058..617e93e483 100644 --- a/src/core/config/__tests__/CustomModesManager.spec.ts +++ b/src/core/config/__tests__/CustomModesManager.spec.ts @@ -1213,6 +1213,714 @@ customModes: }) }) - // Add the remaining test suites that were in the original file... - // (The rest of the test file continues with updateCustomMode, deleteCustomMode, etc.) + describe("updateCustomMode", () => { + it("should update mode in settings file while preserving .roomodes precedence", async () => { + const newMode: ModeConfig = { + slug: "mode1", + name: "Updated Mode 1", + roleDefinition: "Updated Role 1", + groups: ["read"], + source: "global", + } + + const roomodesModes = [ + { + slug: "mode1", + name: "Roomodes Mode 1", + roleDefinition: "Role 1", + groups: ["read"], + source: "project", + }, + ] + + const existingModes = [ + { slug: "mode2", name: "Mode 2", roleDefinition: "Role 2", groups: ["read"], source: "global" }, + ] + + let settingsContent = { customModes: existingModes } + let roomodesContent = { customModes: roomodesModes } + + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockRoomodes) { + return yaml.stringify(roomodesContent) + } + if (path === mockSettingsPath) { + return yaml.stringify(settingsContent) + } + throw new Error("File not found") + }) + ;(fs.writeFile as Mock).mockImplementation(async (path: string, content: string, _encoding?: string) => { + if (path === mockSettingsPath) { + settingsContent = yaml.parse(content) + } + if (path === mockRoomodes) { + roomodesContent = yaml.parse(content) + } + return Promise.resolve() + }) + + await manager.updateCustomMode("mode1", newMode) + + // The mode should be written to its source file (roomodes in this case since it exists there) + // But since we're updating with source: "global", it should write to settings file + // However, the implementation preserves the sourceFile, so it writes to roomodes + expect(fs.writeFile).toHaveBeenCalled() + + // Verify the content of the write + const writeCall = (fs.writeFile as Mock).mock.calls[0] + const content = yaml.parse(writeCall[1]) + expect(content.customModes).toContainEqual( + expect.objectContaining({ + slug: "mode1", + name: "Updated Mode 1", + roleDefinition: "Updated Role 1", + }), + ) + + // Should update global state with merged modes where .roomodes takes precedence + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "customModes", + expect.arrayContaining([ + expect.objectContaining({ + slug: "mode1", + name: "Roomodes Mode 1", // .roomodes version should take precedence + source: "project", + }), + ]), + ) + + // Should trigger onUpdate + expect(mockOnUpdate).toHaveBeenCalled() + }) + + it("creates .roomodes file when adding project-specific mode", async () => { + const projectMode: ModeConfig = { + slug: "project-mode", + name: "Project Mode", + roleDefinition: "Project Role", + groups: ["read"], + source: "project", + } + + // Mock .roomodes to not exist initially + let roomodesContent: any = null + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockSettingsPath + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: [] }) + } + if (path === mockRoomodes) { + if (!roomodesContent) { + throw new Error("File not found") + } + return yaml.stringify(roomodesContent) + } + throw new Error("File not found") + }) + ;(fs.writeFile as Mock).mockImplementation(async (path: string, content: string) => { + if (path === mockRoomodes) { + roomodesContent = yaml.parse(content) + } + return Promise.resolve() + }) + + await manager.updateCustomMode("project-mode", projectMode) + + // Verify .roomodes was created with the project mode + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), // Don't check exact path as it may have different separators on different platforms + expect.stringContaining("project-mode"), + "utf-8", + ) + + // Verify the path is correct regardless of separators + const writeCall = (fs.writeFile as Mock).mock.calls[0] + expect(path.normalize(writeCall[0])).toBe(path.normalize(mockRoomodes)) + + // Verify the content written to .roomodes + expect(roomodesContent).toEqual({ + customModes: [ + expect.objectContaining({ + slug: "project-mode", + name: "Project Mode", + roleDefinition: "Project Role", + source: "project", + }), + ], + }) + }) + + it("queues write operations", async () => { + const mode1: ModeConfig = { + slug: "mode1", + name: "Mode 1", + roleDefinition: "Role 1", + groups: ["read"], + source: "global", + } + const mode2: ModeConfig = { + slug: "mode2", + name: "Mode 2", + roleDefinition: "Role 2", + groups: ["read"], + source: "global", + } + + let settingsContent = { customModes: [] } + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify(settingsContent) + } + throw new Error("File not found") + }) + ;(fs.writeFile as Mock).mockImplementation(async (path: string, content: string, _encoding?: string) => { + if (path === mockSettingsPath) { + settingsContent = yaml.parse(content) + } + return Promise.resolve() + }) + + // Start both updates simultaneously + await Promise.all([manager.updateCustomMode("mode1", mode1), manager.updateCustomMode("mode2", mode2)]) + + // Verify final state in settings file + expect(settingsContent.customModes).toHaveLength(2) + expect(settingsContent.customModes.map((m: ModeConfig) => m.name)).toContain("Mode 1") + expect(settingsContent.customModes.map((m: ModeConfig) => m.name)).toContain("Mode 2") + + // Verify global state was updated + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "customModes", + expect.arrayContaining([ + expect.objectContaining({ + slug: "mode1", + name: "Mode 1", + source: "global", + }), + expect.objectContaining({ + slug: "mode2", + name: "Mode 2", + source: "global", + }), + ]), + ) + + // Should trigger onUpdate + expect(mockOnUpdate).toHaveBeenCalled() + }) + }) + + describe("File Operations", () => { + it("creates settings directory if it doesn't exist", async () => { + const settingsPath = path.join(mockStoragePath, "settings", GlobalFileNames.customModes) + await manager.getCustomModesFilePath() + + expect(fs.mkdir).toHaveBeenCalledWith(path.dirname(settingsPath), { recursive: true }) + }) + + it("creates default config if file doesn't exist", async () => { + const settingsPath = path.join(mockStoragePath, "settings", GlobalFileNames.customModes) + + // Mock fileExists to return false first time, then true + let firstCall = true + ;(fileExistsAtPath as Mock).mockImplementation(async () => { + if (firstCall) { + firstCall = false + return false + } + return true + }) + + await manager.getCustomModesFilePath() + + expect(fs.writeFile).toHaveBeenCalledWith(settingsPath, expect.stringMatching(/^customModes: \[\]/)) + }) + + it("watches file for changes", async () => { + const configPath = path.join(mockStoragePath, "settings", GlobalFileNames.customModes) + + ;(fs.readFile as Mock).mockResolvedValue(yaml.stringify({ customModes: [] })) + ;(arePathsEqual as Mock).mockImplementation( + (path1: string, path2: string) => path.normalize(path1) === path.normalize(path2), + ) + + // Mock createFileSystemWatcher to return a mock watcher + const mockWatcher = { + onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), + onDidCreate: vi.fn().mockReturnValue({ dispose: vi.fn() }), + onDidDelete: vi.fn().mockReturnValue({ dispose: vi.fn() }), + dispose: vi.fn(), + } + const createFileSystemWatcherMock = vi.fn().mockReturnValue(mockWatcher) + ;(vscode.workspace as any).createFileSystemWatcher = createFileSystemWatcherMock + + // Temporarily set NODE_ENV to allow file watching + const originalNodeEnv = process.env.NODE_ENV + process.env.NODE_ENV = "development" + + try { + // Create a new manager to trigger the file watcher setup + const testManager = new CustomModesManager(mockContext, mockOnUpdate) + + // Wait a bit for the async watchCustomModesFiles to complete + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Verify createFileSystemWatcher was called + expect(createFileSystemWatcherMock).toHaveBeenCalled() + + // Get the onChange callback that was registered + const onChangeCall = mockWatcher.onDidChange.mock.calls[0] + expect(onChangeCall).toBeDefined() + const [onChangeCallback] = onChangeCall + + // Simulate file change event + await onChangeCallback() + + // Verify file was processed + expect(fs.readFile).toHaveBeenCalledWith(configPath, "utf-8") + expect(mockContext.globalState.update).toHaveBeenCalled() + expect(mockOnUpdate).toHaveBeenCalled() + + // Clean up + testManager.dispose() + } finally { + // Restore original NODE_ENV + process.env.NODE_ENV = originalNodeEnv + } + }) + }) + + describe("deleteCustomMode", () => { + it("deletes mode from settings file", async () => { + const existingMode = { + slug: "mode-to-delete", + name: "Mode To Delete", + roleDefinition: "Test role", + groups: ["read"], + source: "global", + } + + let settingsContent = { customModes: [existingMode] } + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify(settingsContent) + } + throw new Error("File not found") + }) + ;(fs.writeFile as Mock).mockImplementation(async (path: string, content: string, encoding?: string) => { + if (path === mockSettingsPath && encoding === "utf-8") { + settingsContent = yaml.parse(content) + } + return Promise.resolve() + }) + + // Mock the global state update to actually update the settingsContent + ;(mockContext.globalState.update as Mock).mockImplementation((key: string, value: any) => { + if (key === "customModes") { + settingsContent.customModes = value + } + return Promise.resolve() + }) + + await manager.deleteCustomMode("mode-to-delete") + + // Verify mode was removed from settings file + expect(settingsContent.customModes).toHaveLength(0) + + // Verify global state was updated + expect(mockContext.globalState.update).toHaveBeenCalledWith("customModes", []) + + // Should trigger onUpdate + expect(mockOnUpdate).toHaveBeenCalled() + }) + + it("handles errors gracefully", async () => { + const mockShowError = vi.fn() + ;(vscode.window.showErrorMessage as Mock) = mockShowError + ;(fs.writeFile as Mock).mockRejectedValue(new Error("Write error")) + + await manager.deleteCustomMode("non-existent-mode") + + expect(mockShowError).toHaveBeenCalledWith("customModes.errors.deleteFailed") + }) + }) + + describe("updateModesInFile", () => { + it("handles corrupted YAML content gracefully", async () => { + const corruptedYaml = "customModes: [invalid yaml content" + ;(fs.readFile as Mock).mockResolvedValue(corruptedYaml) + + const newMode: ModeConfig = { + slug: "test-mode", + name: "Test Mode", + roleDefinition: "Test Role", + groups: ["read"], + source: "global", + } + + await manager.updateCustomMode("test-mode", newMode) + + // Verify that a valid YAML structure was written + const writeCall = (fs.writeFile as Mock).mock.calls[0] + const writtenContent = yaml.parse(writeCall[1]) + expect(writtenContent).toEqual({ + customModes: [ + expect.objectContaining({ + slug: "test-mode", + name: "Test Mode", + roleDefinition: "Test Role", + }), + ], + }) + }) + }) + + describe("importModeWithRules", () => { + it("should return error when YAML content is invalid", async () => { + const invalidYaml = "invalid yaml content" + + const result = await manager.importModeWithRules(invalidYaml) + + expect(result.success).toBe(false) + expect(result.error).toContain("Invalid import format") + }) + + it("should return error when no custom modes found in YAML", async () => { + const emptyYaml = yaml.stringify({ customModes: [] }) + + const result = await manager.importModeWithRules(emptyYaml) + + expect(result.success).toBe(false) + expect(result.error).toBe("Invalid import format: Expected 'customModes' array in YAML") + }) + + it("should return error when no workspace is available", async () => { + ;(getWorkspacePath as Mock).mockReturnValue(null) + const validYaml = yaml.stringify({ + customModes: [ + { + slug: "test-mode", + name: "Test Mode", + roleDefinition: "Test Role", + groups: ["read"], + }, + ], + }) + + const result = await manager.importModeWithRules(validYaml) + + expect(result.success).toBe(false) + expect(result.error).toBe("No workspace found") + }) + + it("should successfully import mode without rules files", async () => { + const importYaml = yaml.stringify({ + customModes: [ + { + slug: "imported-mode", + name: "Imported Mode", + roleDefinition: "Imported Role", + groups: ["read", "edit"], + }, + ], + }) + + let roomodesContent: any = null + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: [] }) + } + if (path === mockRoomodes && roomodesContent) { + return yaml.stringify(roomodesContent) + } + throw new Error("File not found") + }) + ;(fs.writeFile as Mock).mockImplementation(async (path: string, content: string) => { + if (path === mockRoomodes) { + roomodesContent = yaml.parse(content) + } + return Promise.resolve() + }) + + const result = await manager.importModeWithRules(importYaml) + + expect(result.success).toBe(true) + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining(".roomodes"), + expect.stringContaining("imported-mode"), + "utf-8", + ) + }) + + it("should successfully import mode with rules files", async () => { + const importYaml = yaml.stringify({ + customModes: [ + { + slug: "imported-mode", + name: "Imported Mode", + roleDefinition: "Imported Role", + groups: ["read"], + rulesFiles: [ + { + relativePath: "rules-imported-mode/rule1.md", + content: "Rule 1 content", + }, + { + relativePath: "rules-imported-mode/subfolder/rule2.md", + content: "Rule 2 content", + }, + ], + }, + ], + }) + + let roomodesContent: any = null + let writtenFiles: Record = {} + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: [] }) + } + if (path === mockRoomodes && roomodesContent) { + return yaml.stringify(roomodesContent) + } + throw new Error("File not found") + }) + ;(fs.writeFile as Mock).mockImplementation(async (path: string, content: string) => { + if (path === mockRoomodes) { + roomodesContent = yaml.parse(content) + } else { + writtenFiles[path] = content + } + return Promise.resolve() + }) + ;(fs.mkdir as Mock).mockResolvedValue(undefined) + + const result = await manager.importModeWithRules(importYaml) + + expect(result.success).toBe(true) + + // Verify mode was imported + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining(".roomodes"), + expect.stringContaining("imported-mode"), + "utf-8", + ) + + // Verify rules files were created + expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining("rules-imported-mode"), { + recursive: true, + }) + expect(fs.mkdir).toHaveBeenCalledWith( + expect.stringContaining(path.join("rules-imported-mode", "subfolder")), + { recursive: true }, + ) + + // Verify file contents + const rule1Path = Object.keys(writtenFiles).find((p) => p.includes("rule1.md")) + const rule2Path = Object.keys(writtenFiles).find((p) => p.includes("rule2.md")) + expect(writtenFiles[rule1Path!]).toBe("Rule 1 content") + expect(writtenFiles[rule2Path!]).toBe("Rule 2 content") + }) + + it("should import multiple modes at once", async () => { + const importYaml = yaml.stringify({ + customModes: [ + { + slug: "mode1", + name: "Mode 1", + roleDefinition: "Role 1", + groups: ["read"], + }, + { + slug: "mode2", + name: "Mode 2", + roleDefinition: "Role 2", + groups: ["edit"], + rulesFiles: [ + { + relativePath: "rules-mode2/rule.md", + content: "Mode 2 rules", + }, + ], + }, + ], + }) + + let roomodesContent: any = null + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: [] }) + } + if (path === mockRoomodes && roomodesContent) { + return yaml.stringify(roomodesContent) + } + throw new Error("File not found") + }) + ;(fs.writeFile as Mock).mockImplementation(async (path: string, content: string) => { + if (path === mockRoomodes) { + roomodesContent = yaml.parse(content) + } + return Promise.resolve() + }) + + const result = await manager.importModeWithRules(importYaml) + + expect(result.success).toBe(true) + expect(roomodesContent.customModes).toHaveLength(2) + expect(roomodesContent.customModes[0].slug).toBe("mode1") + expect(roomodesContent.customModes[1].slug).toBe("mode2") + }) + }) + + describe("checkRulesDirectoryHasContent", () => { + it("should return false when no workspace is available", async () => { + ;(getWorkspacePath as Mock).mockReturnValue(null) + + const result = await manager.checkRulesDirectoryHasContent("test-mode") + + expect(result).toBe(false) + }) + + it("should return false when mode is not in .roomodes file", async () => { + const roomodesContent = { customModes: [{ slug: "other-mode", name: "Other Mode" }] } + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockRoomodes + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockRoomodes) { + return yaml.stringify(roomodesContent) + } + throw new Error("File not found") + }) + + const result = await manager.checkRulesDirectoryHasContent("test-mode") + + expect(result).toBe(false) + }) + + it("should return false when rules directory doesn't exist", async () => { + const roomodesContent = { customModes: [{ slug: "test-mode", name: "Test Mode" }] } + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockRoomodes + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockRoomodes) { + return yaml.stringify(roomodesContent) + } + throw new Error("File not found") + }) + ;(fs.stat as Mock).mockRejectedValue(new Error("Directory not found")) + + const result = await manager.checkRulesDirectoryHasContent("test-mode") + + expect(result).toBe(false) + }) + + it("should return true when rules directory has content files", async () => { + const roomodesContent = { customModes: [{ slug: "test-mode", name: "Test Mode" }] } + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockRoomodes + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockRoomodes) { + return yaml.stringify(roomodesContent) + } + if (path.includes("rules-test-mode")) { + return "Some rule content" + } + throw new Error("File not found") + }) + ;(fs.stat as Mock).mockResolvedValue({ isDirectory: () => true }) + ;(fs.readdir as Mock).mockResolvedValue([ + { name: "rule1.md", isFile: () => true, parentPath: "/mock/workspace/.roo/rules-test-mode" }, + ]) + + const result = await manager.checkRulesDirectoryHasContent("test-mode") + + expect(result).toBe(true) + }) + }) + + describe("exportModeWithRules", () => { + it("should return error when mode is not found", async () => { + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: [] }) + } + throw new Error("File not found") + }) + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockSettingsPath + }) + + const result = await manager.exportModeWithRules("test-mode") + + expect(result.success).toBe(false) + expect(result.error).toBe("Mode not found") + }) + + it("should successfully export mode without rules when rules directory doesn't exist", async () => { + const roomodesContent = { + customModes: [{ slug: "test-mode", name: "Test Mode", roleDefinition: "Test Role", groups: ["read"] }], + } + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockRoomodes + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockRoomodes) { + return yaml.stringify(roomodesContent) + } + throw new Error("File not found") + }) + ;(fs.stat as Mock).mockRejectedValue(new Error("Directory not found")) + + const result = await manager.exportModeWithRules("test-mode") + + expect(result.success).toBe(true) + expect(result.yaml).toContain("test-mode") + expect(result.yaml).toContain("Test Mode") + }) + + it("should successfully export mode with rules for a custom mode in .roomodes", async () => { + const roomodesContent = { + customModes: [ + { + slug: "test-mode", + name: "Test Mode", + roleDefinition: "Test Role", + groups: ["read"], + customInstructions: "Existing instructions", + }, + ], + } + + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockRoomodes + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockRoomodes) { + return yaml.stringify(roomodesContent) + } + if (path.includes("rules-test-mode")) { + return "New rule content from files" + } + throw new Error("File not found") + }) + ;(fs.stat as Mock).mockResolvedValue({ isDirectory: () => true }) + ;(fs.readdir as Mock).mockResolvedValue([ + { name: "rule1.md", isFile: () => true, parentPath: "/mock/workspace/.roo/rules-test-mode" }, + ]) + + const result = await manager.exportModeWithRules("test-mode") + + expect(result.success).toBe(true) + expect(result.yaml).toContain("test-mode") + expect(result.yaml).toContain("Existing instructions") + expect(result.yaml).toContain("New rule content from files") + // Should NOT delete the rules directory + expect(fs.rm).not.toHaveBeenCalled() + }) + }) })