Custom modes

This commit is contained in:
Matt Rubens
2025-01-18 03:39:26 -05:00
parent 332245c33a
commit b8e0aa0cde
65 changed files with 3749 additions and 1531 deletions

View File

@@ -0,0 +1,245 @@
import { ModeConfig } from "../../../shared/modes"
import { CustomModesManager } from "../CustomModesManager"
import * as vscode from "vscode"
import * as fs from "fs/promises"
import * as path from "path"
// Mock dependencies
jest.mock("vscode")
jest.mock("fs/promises")
jest.mock("../../../utils/fs", () => ({
fileExistsAtPath: jest.fn().mockResolvedValue(false),
}))
describe("CustomModesManager", () => {
let manager: CustomModesManager
let mockContext: vscode.ExtensionContext
let mockOnUpdate: jest.Mock
let mockStoragePath: string
beforeEach(() => {
// Reset mocks
jest.clearAllMocks()
// Mock storage path
mockStoragePath = "/test/storage/path"
// Mock context
mockContext = {
globalStorageUri: { fsPath: mockStoragePath },
globalState: {
get: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(undefined),
},
} as unknown as vscode.ExtensionContext
// Mock onUpdate callback
mockOnUpdate = jest.fn().mockResolvedValue(undefined)
// Mock fs.mkdir to do nothing
;(fs.mkdir as jest.Mock).mockResolvedValue(undefined)
// Create manager instance
manager = new CustomModesManager(mockContext, mockOnUpdate)
})
describe("Mode Configuration Validation", () => {
test("validates valid custom mode configuration", async () => {
const validMode = {
slug: "test-mode",
name: "Test Mode",
roleDefinition: "Test role definition",
groups: ["read"] as const,
} satisfies ModeConfig
// Mock file read/write operations
;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] }))
;(fs.writeFile as jest.Mock).mockResolvedValue(undefined)
await manager.updateCustomMode(validMode.slug, validMode)
// Verify file was written with the new mode
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining("cline_custom_modes.json"),
expect.stringContaining(validMode.name),
)
// Verify global state was updated
expect(mockContext.globalState.update).toHaveBeenCalledWith(
"customModes",
expect.arrayContaining([validMode]),
)
// Verify onUpdate was called
expect(mockOnUpdate).toHaveBeenCalled()
})
test("handles file read errors gracefully", async () => {
// Mock fs.readFile to throw error
;(fs.readFile as jest.Mock).mockRejectedValueOnce(new Error("Test error"))
const modes = await manager.getCustomModes()
// Should return empty array on error
expect(modes).toEqual([])
})
test("handles file write errors gracefully", async () => {
const validMode = {
slug: "123e4567-e89b-12d3-a456-426614174000",
name: "Test Mode",
roleDefinition: "Test role definition",
groups: ["read"] as const,
} satisfies ModeConfig
// Mock fs.writeFile to throw error
;(fs.writeFile as jest.Mock).mockRejectedValueOnce(new Error("Write error"))
const mockShowError = jest.fn()
;(vscode.window.showErrorMessage as jest.Mock) = mockShowError
await manager.updateCustomMode(validMode.slug, validMode)
// Should show error message
expect(mockShowError).toHaveBeenCalledWith(expect.stringContaining("Write error"))
})
})
describe("File Operations", () => {
test("creates settings directory if it doesn't exist", async () => {
const configPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
await manager.getCustomModesFilePath()
expect(fs.mkdir).toHaveBeenCalledWith(path.dirname(configPath), { recursive: true })
})
test("creates default config if file doesn't exist", async () => {
const configPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
await manager.getCustomModesFilePath()
expect(fs.writeFile).toHaveBeenCalledWith(configPath, JSON.stringify({ customModes: [] }, null, 2))
})
test("watches file for changes", async () => {
// Mock file path resolution
const configPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] }))
// Create manager and wait for initialization
const manager = new CustomModesManager(mockContext, mockOnUpdate)
await manager.getCustomModesFilePath() // This ensures watchCustomModesFile has completed
// Get the registered callback
const registerCall = (vscode.workspace.onDidSaveTextDocument as jest.Mock).mock.calls[0]
expect(registerCall).toBeDefined()
const [callback] = registerCall
// Simulate file save event
const mockDocument = {
uri: { fsPath: configPath },
}
await callback(mockDocument)
// Verify file was processed
expect(fs.readFile).toHaveBeenCalledWith(configPath, "utf-8")
expect(mockContext.globalState.update).toHaveBeenCalled()
expect(mockOnUpdate).toHaveBeenCalled()
// Verify file content was processed
expect(fs.readFile).toHaveBeenCalled()
})
})
describe("Mode Operations", () => {
const validMode = {
slug: "123e4567-e89b-12d3-a456-426614174000",
name: "Test Mode",
roleDefinition: "Test role definition",
groups: ["read"] as const,
} satisfies ModeConfig
beforeEach(() => {
// Mock fs.readFile to return empty config
;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] }))
})
test("adds new custom mode", async () => {
await manager.updateCustomMode(validMode.slug, validMode)
expect(fs.writeFile).toHaveBeenCalledWith(expect.any(String), expect.stringContaining(validMode.name))
expect(mockOnUpdate).toHaveBeenCalled()
})
test("updates existing custom mode", async () => {
// Mock existing mode
;(fs.readFile as jest.Mock).mockResolvedValue(
JSON.stringify({
customModes: [validMode],
}),
)
const updatedMode = {
...validMode,
name: "Updated Name",
}
await manager.updateCustomMode(validMode.slug, updatedMode)
expect(fs.writeFile).toHaveBeenCalledWith(expect.any(String), expect.stringContaining("Updated Name"))
expect(mockOnUpdate).toHaveBeenCalled()
})
test("deletes custom mode", async () => {
// Mock existing mode
;(fs.readFile as jest.Mock).mockResolvedValue(
JSON.stringify({
customModes: [validMode],
}),
)
await manager.deleteCustomMode(validMode.slug)
expect(fs.writeFile).toHaveBeenCalledWith(expect.any(String), expect.not.stringContaining(validMode.name))
expect(mockOnUpdate).toHaveBeenCalled()
})
test("queues write operations", async () => {
const mode1 = {
...validMode,
name: "Mode 1",
}
const mode2 = {
...validMode,
slug: "mode-2",
name: "Mode 2",
}
// Mock initial empty state and track writes
let currentModes: ModeConfig[] = []
;(fs.readFile as jest.Mock).mockImplementation(() => JSON.stringify({ customModes: currentModes }))
;(fs.writeFile as jest.Mock).mockImplementation(async (path, content) => {
const data = JSON.parse(content)
currentModes = data.customModes
return Promise.resolve()
})
// Start both updates simultaneously
await Promise.all([
manager.updateCustomMode(mode1.slug, mode1),
manager.updateCustomMode(mode2.slug, mode2),
])
// Verify final state
expect(currentModes).toHaveLength(2)
expect(currentModes.map((m) => m.name)).toContain("Mode 1")
expect(currentModes.map((m) => m.name)).toContain("Mode 2")
// Verify write was called with both modes
const lastWriteCall = (fs.writeFile as jest.Mock).mock.calls.pop()
const finalContent = JSON.parse(lastWriteCall[1])
expect(finalContent.customModes).toHaveLength(2)
expect(finalContent.customModes.map((m: ModeConfig) => m.name)).toContain("Mode 1")
expect(finalContent.customModes.map((m: ModeConfig) => m.name)).toContain("Mode 2")
})
})
})

View File

@@ -0,0 +1,122 @@
import { validateCustomMode } from "../CustomModesSchema"
import { ModeConfig } from "../../../shared/modes"
import { ZodError } from "zod"
describe("CustomModesSchema", () => {
describe("validateCustomMode", () => {
test("accepts valid mode configuration", () => {
const validMode = {
slug: "123e4567-e89b-12d3-a456-426614174000",
name: "Test Mode",
roleDefinition: "Test role definition",
groups: ["read"] as const,
} satisfies ModeConfig
expect(() => validateCustomMode(validMode)).not.toThrow()
})
test("accepts mode with multiple groups", () => {
const validMode = {
slug: "123e4567-e89b-12d3-a456-426614174000",
name: "Test Mode",
roleDefinition: "Test role definition",
groups: ["read", "edit", "browser"] as const,
} satisfies ModeConfig
expect(() => validateCustomMode(validMode)).not.toThrow()
})
test("accepts mode with optional customInstructions", () => {
const validMode = {
slug: "123e4567-e89b-12d3-a456-426614174000",
name: "Test Mode",
roleDefinition: "Test role definition",
customInstructions: "Custom instructions",
groups: ["read"] as const,
} satisfies ModeConfig
expect(() => validateCustomMode(validMode)).not.toThrow()
})
test("rejects missing required fields", () => {
const invalidModes = [
{}, // All fields missing
{ name: "Test" }, // Missing most fields
{
name: "Test",
roleDefinition: "Role",
}, // Missing slug and groups
]
invalidModes.forEach((invalidMode) => {
expect(() => validateCustomMode(invalidMode)).toThrow(ZodError)
})
})
test("rejects invalid slug format", () => {
const invalidMode = {
slug: "not@a@valid@slug",
name: "Test Mode",
roleDefinition: "Test role definition",
groups: ["read"] as const,
} satisfies Omit<ModeConfig, "slug"> & { slug: string }
expect(() => validateCustomMode(invalidMode)).toThrow(ZodError)
expect(() => validateCustomMode(invalidMode)).toThrow("Slug must contain only letters numbers and dashes")
})
test("rejects empty strings in required fields", () => {
const emptyNameMode = {
slug: "123e4567-e89b-12d3-a456-426614174000",
name: "",
roleDefinition: "Test role definition",
groups: ["read"] as const,
} satisfies ModeConfig
const emptyRoleMode = {
slug: "123e4567-e89b-12d3-a456-426614174000",
name: "Test Mode",
roleDefinition: "",
groups: ["read"] as const,
} satisfies ModeConfig
expect(() => validateCustomMode(emptyNameMode)).toThrow("Name is required")
expect(() => validateCustomMode(emptyRoleMode)).toThrow("Role definition is required")
})
test("rejects invalid group configurations", () => {
const invalidGroupMode = {
slug: "123e4567-e89b-12d3-a456-426614174000",
name: "Test Mode",
roleDefinition: "Test role definition",
groups: ["not-a-valid-group"] as any,
}
expect(() => validateCustomMode(invalidGroupMode)).toThrow(ZodError)
})
test("rejects empty groups array", () => {
const invalidMode = {
slug: "123e4567-e89b-12d3-a456-426614174000",
name: "Test Mode",
roleDefinition: "Test role definition",
groups: [] as const,
} satisfies ModeConfig
expect(() => validateCustomMode(invalidMode)).toThrow("At least one tool group is required")
})
test("handles null and undefined gracefully", () => {
expect(() => validateCustomMode(null)).toThrow(ZodError)
expect(() => validateCustomMode(undefined)).toThrow(ZodError)
})
test("rejects non-object inputs", () => {
const invalidInputs = [42, "string", true, [], () => {}]
invalidInputs.forEach((input) => {
expect(() => validateCustomMode(input)).toThrow(ZodError)
})
})
})
})

View File

@@ -0,0 +1,169 @@
import { CustomModesSettingsSchema } from "../CustomModesSchema"
import { ModeConfig } from "../../../shared/modes"
import { ZodError } from "zod"
describe("CustomModesSettings", () => {
const validMode = {
slug: "123e4567-e89b-12d3-a456-426614174000",
name: "Test Mode",
roleDefinition: "Test role definition",
groups: ["read"] as const,
} satisfies ModeConfig
describe("schema validation", () => {
test("accepts valid settings", () => {
const validSettings = {
customModes: [validMode],
}
expect(() => {
CustomModesSettingsSchema.parse(validSettings)
}).not.toThrow()
})
test("accepts empty custom modes array", () => {
const validSettings = {
customModes: [],
}
expect(() => {
CustomModesSettingsSchema.parse(validSettings)
}).not.toThrow()
})
test("accepts multiple custom modes", () => {
const validSettings = {
customModes: [
validMode,
{
...validMode,
slug: "987fcdeb-51a2-43e7-89ab-cdef01234567",
name: "Another Mode",
},
],
}
expect(() => {
CustomModesSettingsSchema.parse(validSettings)
}).not.toThrow()
})
test("rejects missing customModes field", () => {
const invalidSettings = {} as any
expect(() => {
CustomModesSettingsSchema.parse(invalidSettings)
}).toThrow(ZodError)
})
test("rejects invalid mode in array", () => {
const invalidSettings = {
customModes: [
validMode,
{
...validMode,
slug: "not@a@valid@slug", // Invalid slug
},
],
}
expect(() => {
CustomModesSettingsSchema.parse(invalidSettings)
}).toThrow(ZodError)
expect(() => {
CustomModesSettingsSchema.parse(invalidSettings)
}).toThrow("Slug must contain only letters numbers and dashes")
})
test("rejects non-array customModes", () => {
const invalidSettings = {
customModes: "not an array",
}
expect(() => {
CustomModesSettingsSchema.parse(invalidSettings)
}).toThrow(ZodError)
})
test("rejects null or undefined", () => {
expect(() => {
CustomModesSettingsSchema.parse(null)
}).toThrow(ZodError)
expect(() => {
CustomModesSettingsSchema.parse(undefined)
}).toThrow(ZodError)
})
test("rejects duplicate mode slugs", () => {
const duplicateSettings = {
customModes: [
validMode,
{ ...validMode }, // Same slug
],
}
expect(() => {
CustomModesSettingsSchema.parse(duplicateSettings)
}).toThrow("Duplicate mode slugs are not allowed")
})
test("rejects invalid group configurations in modes", () => {
const invalidSettings = {
customModes: [
{
...validMode,
groups: ["invalid_group"] as any,
},
],
}
expect(() => {
CustomModesSettingsSchema.parse(invalidSettings)
}).toThrow(ZodError)
})
test("handles multiple groups", () => {
const validSettings = {
customModes: [
{
...validMode,
groups: ["read", "edit", "browser"] as const,
},
],
}
expect(() => {
CustomModesSettingsSchema.parse(validSettings)
}).not.toThrow()
})
})
describe("type inference", () => {
test("inferred type includes all required fields", () => {
const settings = {
customModes: [validMode],
}
// TypeScript compilation will fail if the type is incorrect
expect(settings.customModes[0].slug).toBeDefined()
expect(settings.customModes[0].name).toBeDefined()
expect(settings.customModes[0].roleDefinition).toBeDefined()
expect(settings.customModes[0].groups).toBeDefined()
})
test("inferred type allows optional fields", () => {
const settings = {
customModes: [
{
...validMode,
customInstructions: "Optional instructions",
},
],
}
// TypeScript compilation will fail if the type is incorrect
expect(settings.customModes[0].customInstructions).toBeDefined()
})
})
})

View File

@@ -0,0 +1,90 @@
import { CustomModeSchema } from "../CustomModesSchema"
import { ModeConfig } from "../../../shared/modes"
describe("GroupConfigSchema", () => {
const validBaseMode = {
slug: "123e4567-e89b-12d3-a456-426614174000",
name: "Test Mode",
roleDefinition: "Test role definition",
}
describe("group format validation", () => {
test("accepts single group", () => {
const mode = {
...validBaseMode,
groups: ["read"] as const,
} satisfies ModeConfig
expect(() => CustomModeSchema.parse(mode)).not.toThrow()
})
test("accepts multiple groups", () => {
const mode = {
...validBaseMode,
groups: ["read", "edit", "browser"] as const,
} satisfies ModeConfig
expect(() => CustomModeSchema.parse(mode)).not.toThrow()
})
test("accepts all available groups", () => {
const mode = {
...validBaseMode,
groups: ["read", "edit", "browser", "command", "mcp"] as const,
} satisfies ModeConfig
expect(() => CustomModeSchema.parse(mode)).not.toThrow()
})
test("rejects non-array group format", () => {
const mode = {
...validBaseMode,
groups: "not-an-array" as any,
}
expect(() => CustomModeSchema.parse(mode)).toThrow()
})
test("rejects empty groups array", () => {
const mode = {
...validBaseMode,
groups: [] as const,
} satisfies ModeConfig
expect(() => CustomModeSchema.parse(mode)).toThrow("At least one tool group is required")
})
test("rejects invalid group names", () => {
const mode = {
...validBaseMode,
groups: ["invalid_group"] as any,
}
expect(() => CustomModeSchema.parse(mode)).toThrow()
})
test("rejects duplicate groups", () => {
const mode = {
...validBaseMode,
groups: ["read", "read"] as any,
}
expect(() => CustomModeSchema.parse(mode)).toThrow("Duplicate groups are not allowed")
})
test("rejects null or undefined groups", () => {
const modeWithNull = {
...validBaseMode,
groups: null as any,
}
const modeWithUndefined = {
...validBaseMode,
groups: undefined as any,
}
expect(() => CustomModeSchema.parse(modeWithNull)).toThrow()
expect(() => CustomModeSchema.parse(modeWithUndefined)).toThrow()
})
})
})