mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-23 05:41:10 -05:00
Custom modes
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { ExtensionContext } from "vscode"
|
||||
import { ApiConfiguration } from "../../shared/api"
|
||||
import { Mode } from "../prompts/types"
|
||||
import { Mode } from "../../shared/modes"
|
||||
import { ApiConfigMeta } from "../../shared/ExtensionMessage"
|
||||
|
||||
export interface ApiConfigData {
|
||||
|
||||
190
src/core/config/CustomModesManager.ts
Normal file
190
src/core/config/CustomModesManager.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { CustomModesSettingsSchema } from "./CustomModesSchema"
|
||||
import { ModeConfig } from "../../shared/modes"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { arePathsEqual } from "../../utils/path"
|
||||
|
||||
export class CustomModesManager {
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private isWriting = false
|
||||
private writeQueue: Array<() => Promise<void>> = []
|
||||
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly onUpdate: () => Promise<void>,
|
||||
) {
|
||||
this.watchCustomModesFile()
|
||||
}
|
||||
|
||||
private async queueWrite(operation: () => Promise<void>): Promise<void> {
|
||||
this.writeQueue.push(operation)
|
||||
if (!this.isWriting) {
|
||||
await this.processWriteQueue()
|
||||
}
|
||||
}
|
||||
|
||||
private async processWriteQueue(): Promise<void> {
|
||||
if (this.isWriting || this.writeQueue.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isWriting = true
|
||||
try {
|
||||
while (this.writeQueue.length > 0) {
|
||||
const operation = this.writeQueue.shift()
|
||||
if (operation) {
|
||||
await operation()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isWriting = false
|
||||
}
|
||||
}
|
||||
|
||||
async getCustomModesFilePath(): Promise<string> {
|
||||
const settingsDir = await this.ensureSettingsDirectoryExists()
|
||||
const filePath = path.join(settingsDir, "cline_custom_modes.json")
|
||||
const fileExists = await fileExistsAtPath(filePath)
|
||||
if (!fileExists) {
|
||||
await this.queueWrite(async () => {
|
||||
await fs.writeFile(filePath, JSON.stringify({ customModes: [] }, null, 2))
|
||||
})
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
private async watchCustomModesFile(): Promise<void> {
|
||||
const settingsPath = await this.getCustomModesFilePath()
|
||||
this.disposables.push(
|
||||
vscode.workspace.onDidSaveTextDocument(async (document) => {
|
||||
if (arePathsEqual(document.uri.fsPath, settingsPath)) {
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const errorMessage =
|
||||
"Invalid custom modes format. Please ensure your settings follow the correct JSON format."
|
||||
let config: any
|
||||
try {
|
||||
config = JSON.parse(content)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
vscode.window.showErrorMessage(errorMessage)
|
||||
return
|
||||
}
|
||||
const result = CustomModesSettingsSchema.safeParse(config)
|
||||
if (!result.success) {
|
||||
vscode.window.showErrorMessage(errorMessage)
|
||||
return
|
||||
}
|
||||
await this.context.globalState.update("customModes", result.data.customModes)
|
||||
await this.onUpdate()
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async getCustomModes(): Promise<ModeConfig[]> {
|
||||
const modes = await this.context.globalState.get<ModeConfig[]>("customModes")
|
||||
|
||||
// Always read from file to ensure we have the latest
|
||||
try {
|
||||
const settingsPath = await this.getCustomModesFilePath()
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
|
||||
const settings = JSON.parse(content)
|
||||
const result = CustomModesSettingsSchema.safeParse(settings)
|
||||
if (result.success) {
|
||||
await this.context.globalState.update("customModes", result.data.customModes)
|
||||
return result.data.customModes
|
||||
}
|
||||
return modes ?? []
|
||||
} catch (error) {
|
||||
// Return empty array if there's an error reading the file
|
||||
}
|
||||
|
||||
return modes ?? []
|
||||
}
|
||||
|
||||
async updateCustomMode(slug: string, config: ModeConfig): Promise<void> {
|
||||
try {
|
||||
const settingsPath = await this.getCustomModesFilePath()
|
||||
|
||||
await this.queueWrite(async () => {
|
||||
// Read and update file
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const settings = JSON.parse(content)
|
||||
const currentModes = settings.customModes || []
|
||||
const updatedModes = currentModes.filter((m: ModeConfig) => m.slug !== slug)
|
||||
updatedModes.push(config)
|
||||
settings.customModes = updatedModes
|
||||
|
||||
const newContent = JSON.stringify(settings, null, 2)
|
||||
|
||||
// Write to file
|
||||
await fs.writeFile(settingsPath, newContent)
|
||||
|
||||
// Update global state
|
||||
await this.context.globalState.update("customModes", updatedModes)
|
||||
|
||||
// Notify about the update
|
||||
await this.onUpdate()
|
||||
})
|
||||
|
||||
// Success, no need for message
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to update custom mode: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCustomMode(slug: string): Promise<void> {
|
||||
try {
|
||||
const settingsPath = await this.getCustomModesFilePath()
|
||||
|
||||
await this.queueWrite(async () => {
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const settings = JSON.parse(content)
|
||||
|
||||
settings.customModes = (settings.customModes || []).filter((m: ModeConfig) => m.slug !== slug)
|
||||
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2))
|
||||
|
||||
await this.context.globalState.update("customModes", settings.customModes)
|
||||
await this.onUpdate()
|
||||
})
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to delete custom mode: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureSettingsDirectoryExists(): Promise<string> {
|
||||
const settingsDir = path.join(this.context.globalStorageUri.fsPath, "settings")
|
||||
await fs.mkdir(settingsDir, { recursive: true })
|
||||
return settingsDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the custom modes file and reset to default state
|
||||
*/
|
||||
async resetCustomModes(): Promise<void> {
|
||||
try {
|
||||
const filePath = await this.getCustomModesFilePath()
|
||||
await fs.writeFile(filePath, JSON.stringify({ customModes: [] }, null, 2))
|
||||
await this.context.globalState.update("customModes", [])
|
||||
await this.onUpdate()
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to reset custom modes: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const disposable of this.disposables) {
|
||||
disposable.dispose()
|
||||
}
|
||||
this.disposables = []
|
||||
}
|
||||
}
|
||||
60
src/core/config/CustomModesSchema.ts
Normal file
60
src/core/config/CustomModesSchema.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { z } from "zod"
|
||||
import { ModeConfig } from "../../shared/modes"
|
||||
import { TOOL_GROUPS, ToolGroup } from "../../shared/tool-groups"
|
||||
|
||||
// Create a schema for valid tool groups using the keys of TOOL_GROUPS
|
||||
const ToolGroupSchema = z.enum(Object.keys(TOOL_GROUPS) as [ToolGroup, ...ToolGroup[]])
|
||||
|
||||
// Schema for array of groups
|
||||
const GroupsArraySchema = z
|
||||
.array(ToolGroupSchema)
|
||||
.min(1, "At least one tool group is required")
|
||||
.refine(
|
||||
(groups) => {
|
||||
const seen = new Set()
|
||||
return groups.every((group) => {
|
||||
if (seen.has(group)) return false
|
||||
seen.add(group)
|
||||
return true
|
||||
})
|
||||
},
|
||||
{ message: "Duplicate groups are not allowed" },
|
||||
)
|
||||
|
||||
// Schema for mode configuration
|
||||
export const CustomModeSchema = z.object({
|
||||
slug: z.string().regex(/^[a-zA-Z0-9-]+$/, "Slug must contain only letters numbers and dashes"),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
roleDefinition: z.string().min(1, "Role definition is required"),
|
||||
customInstructions: z.string().optional(),
|
||||
groups: GroupsArraySchema,
|
||||
}) satisfies z.ZodType<ModeConfig>
|
||||
|
||||
// Schema for the entire custom modes settings file
|
||||
export const CustomModesSettingsSchema = z.object({
|
||||
customModes: z.array(CustomModeSchema).refine(
|
||||
(modes) => {
|
||||
const slugs = new Set()
|
||||
return modes.every((mode) => {
|
||||
if (slugs.has(mode.slug)) {
|
||||
return false
|
||||
}
|
||||
slugs.add(mode.slug)
|
||||
return true
|
||||
})
|
||||
},
|
||||
{
|
||||
message: "Duplicate mode slugs are not allowed",
|
||||
},
|
||||
),
|
||||
})
|
||||
|
||||
export type CustomModesSettings = z.infer<typeof CustomModesSettingsSchema>
|
||||
|
||||
/**
|
||||
* Validates a custom mode configuration against the schema
|
||||
* @throws {z.ZodError} if validation fails
|
||||
*/
|
||||
export function validateCustomMode(mode: unknown): asserts mode is ModeConfig {
|
||||
CustomModeSchema.parse(mode)
|
||||
}
|
||||
245
src/core/config/__tests__/CustomModesManager.test.ts
Normal file
245
src/core/config/__tests__/CustomModesManager.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
122
src/core/config/__tests__/CustomModesSchema.test.ts
Normal file
122
src/core/config/__tests__/CustomModesSchema.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
169
src/core/config/__tests__/CustomModesSettings.test.ts
Normal file
169
src/core/config/__tests__/CustomModesSettings.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
90
src/core/config/__tests__/GroupConfigSchema.test.ts
Normal file
90
src/core/config/__tests__/GroupConfigSchema.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user