Просмотр исходного кода

Add options to always approve write and execute operations

John Stearns 1 год назад
Родитель
Сommit
3c8a9c09dd

+ 21 - 0
jest.config.js

@@ -0,0 +1,21 @@
+/** @type {import('ts-jest').JestConfigWithTsJest} */
+module.exports = {
+    preset: 'ts-jest',
+    testEnvironment: 'node',
+    moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
+    transform: {
+        '^.+\\.tsx?$': ['ts-jest', {
+            tsconfig: 'tsconfig.json'
+        }]
+    },
+    testMatch: ['**/__tests__/**/*.test.ts'],
+    moduleNameMapper: {
+        '^vscode$': '<rootDir>/node_modules/@types/vscode/index.d.ts'
+    },
+    setupFiles: [],
+    globals: {
+        'ts-jest': {
+            diagnostics: false
+        }
+    }
+};

Разница между файлами не показана из-за своего большого размера
+ 624 - 340
package-lock.json


+ 5 - 2
package.json

@@ -136,16 +136,17 @@
   },
   "devDependencies": {
     "@types/diff": "^5.2.1",
+    "@types/jest": "^29.5.14",
     "@types/mocha": "^10.0.7",
     "@types/node": "20.x",
-    "@types/vscode": "^1.84.0",
+    "@types/vscode": "^1.95.0",
     "@typescript-eslint/eslint-plugin": "^7.14.1",
     "@typescript-eslint/parser": "^7.11.0",
     "@vscode/test-cli": "^0.0.9",
     "@vscode/test-electron": "^2.4.0",
-    "esbuild": "^0.21.5",
     "eslint": "^8.57.0",
     "npm-run-all": "^4.1.5",
+    "ts-jest": "^29.2.5",
     "typescript": "^5.4.5"
   },
   "dependencies": {
@@ -163,9 +164,11 @@
     "default-shell": "^2.2.0",
     "delay": "^6.0.0",
     "diff": "^5.2.0",
+    "esbuild": "^0.24.0",
     "fast-deep-equal": "^3.1.3",
     "globby": "^14.0.2",
     "isbinaryfile": "^5.0.2",
+    "jest": "^29.7.0",
     "mammoth": "^1.8.0",
     "monaco-vscode-textmate-theme-converter": "^0.1.7",
     "openai": "^4.61.0",

+ 26 - 7
src/core/Cline.ts

@@ -65,6 +65,9 @@ export class Cline {
 	private didEditFile: boolean = false
 	customInstructions?: string
 	alwaysAllowReadOnly: boolean
+	alwaysAllowWrite: boolean
+	alwaysAllowExecute: boolean
+
 	apiConversationHistory: Anthropic.MessageParam[] = []
 	clineMessages: ClineMessage[] = []
 	private askResponse?: ClineAskResponse
@@ -93,6 +96,8 @@ export class Cline {
 		apiConfiguration: ApiConfiguration,
 		customInstructions?: string,
 		alwaysAllowReadOnly?: boolean,
+		alwaysAllowWrite?: boolean,
+		alwaysAllowExecute?: boolean,
 		task?: string,
 		images?: string[],
 		historyItem?: HistoryItem
@@ -105,6 +110,8 @@ export class Cline {
 		this.diffViewProvider = new DiffViewProvider(cwd)
 		this.customInstructions = customInstructions
 		this.alwaysAllowReadOnly = alwaysAllowReadOnly ?? false
+		this.alwaysAllowWrite = alwaysAllowWrite ?? false
+		this.alwaysAllowExecute = alwaysAllowExecute ?? false		
 
 		if (historyItem) {
 			this.taskId = historyItem.id
@@ -1052,7 +1059,11 @@ export class Cline {
 							if (block.partial) {
 								// update gui message
 								const partialMessage = JSON.stringify(sharedMessageProps)
-								await this.ask("tool", partialMessage, block.partial).catch(() => {})
+								if (this.alwaysAllowWrite) {
+									await this.say("tool", partialMessage, undefined, block.partial)
+								} else {
+									await this.ask("tool", partialMessage, block.partial).catch(() => {})
+								}
 								// update editor
 								if (!this.diffViewProvider.isEditing) {
 									// open the editor and prepare to stream content in
@@ -1082,7 +1093,11 @@ export class Cline {
 								if (!this.diffViewProvider.isEditing) {
 									// show gui message before showing edit animation
 									const partialMessage = JSON.stringify(sharedMessageProps)
-									await this.ask("tool", partialMessage, true).catch(() => {}) // sending true for partial even though it's not a partial, this shows the edit row before the content is streamed into the editor
+									if (this.alwaysAllowWrite) {
+										await this.say("tool", partialMessage, undefined, true)
+									} else {
+										await this.ask("tool", partialMessage, true).catch(() => {}) // sending true for partial even though it's not a partial, this shows the edit row before the content is streamed into the editor
+									}
 									await this.diffViewProvider.open(relPath)
 								}
 								await this.diffViewProvider.update(newContent, true)
@@ -1101,7 +1116,7 @@ export class Cline {
 										  )
 										: undefined,
 								} satisfies ClineSayTool)
-								const didApprove = await askApproval("tool", completeMessage)
+								const didApprove = this.alwaysAllowWrite || (await askApproval("tool", completeMessage))
 								if (!didApprove) {
 									await this.diffViewProvider.revertChanges()
 									break
@@ -1492,9 +1507,13 @@ export class Cline {
 						const command: string | undefined = block.params.command
 						try {
 							if (block.partial) {
-								await this.ask("command", removeClosingTag("command", command), block.partial).catch(
-									() => {}
-								)
+								if (this.alwaysAllowExecute) {
+									await this.say("command", command, undefined, block.partial)
+								} else {
+									await this.ask("command", removeClosingTag("command", command), block.partial).catch(
+										() => {}
+									)
+								}								
 								break
 							} else {
 								if (!command) {
@@ -1505,7 +1524,7 @@ export class Cline {
 									break
 								}
 								this.consecutiveMistakeCount = 0
-								const didApprove = await askApproval("command", command)
+								const didApprove = this.alwaysAllowExecute || (await askApproval("command", command))
 								if (!didApprove) {
 									break
 								}

+ 322 - 0
src/core/__tests__/Cline.test.ts

@@ -0,0 +1,322 @@
+import { Cline } from '../Cline';
+import { ClineProvider } from '../webview/ClineProvider';
+import { ApiConfiguration } from '../../shared/api';
+import * as vscode from 'vscode';
+
+// Mock fileExistsAtPath
+jest.mock('../../utils/fs', () => ({
+    fileExistsAtPath: jest.fn().mockImplementation((filePath) => {
+        return filePath.includes('ui_messages.json') || 
+               filePath.includes('api_conversation_history.json');
+    })
+}));
+
+// Mock fs/promises
+const mockMessages = [{
+    ts: Date.now(),
+    type: 'say',
+    say: 'text',
+    text: 'historical task'
+}];
+
+jest.mock('fs/promises', () => ({
+    mkdir: jest.fn().mockResolvedValue(undefined),
+    writeFile: jest.fn().mockResolvedValue(undefined),
+    readFile: jest.fn().mockImplementation((filePath) => {
+        if (filePath.includes('ui_messages.json')) {
+            return Promise.resolve(JSON.stringify(mockMessages));
+        }
+        if (filePath.includes('api_conversation_history.json')) {
+            return Promise.resolve('[]');
+        }
+        return Promise.resolve('[]');
+    }),
+    unlink: jest.fn().mockResolvedValue(undefined),
+    rmdir: jest.fn().mockResolvedValue(undefined)
+}));
+
+// Mock dependencies
+jest.mock('vscode', () => {
+    const mockDisposable = { dispose: jest.fn() };
+    const mockEventEmitter = {
+        event: jest.fn(),
+        fire: jest.fn()
+    };
+
+    const mockTextDocument = {
+        uri: {
+            fsPath: '/mock/workspace/path/file.ts'
+        }
+    };
+
+    const mockTextEditor = {
+        document: mockTextDocument
+    };
+
+    const mockTab = {
+        input: {
+            uri: {
+                fsPath: '/mock/workspace/path/file.ts'
+            }
+        }
+    };
+
+    const mockTabGroup = {
+        tabs: [mockTab]
+    };
+
+    return {
+        window: {
+            createTextEditorDecorationType: jest.fn().mockReturnValue({
+                dispose: jest.fn()
+            }),
+            visibleTextEditors: [mockTextEditor],
+            tabGroups: {
+                all: [mockTabGroup]
+            }
+        },
+        workspace: {
+            workspaceFolders: [{
+                uri: {
+                    fsPath: '/mock/workspace/path'
+                },
+                name: 'mock-workspace',
+                index: 0
+            }],
+            onDidCreateFiles: jest.fn(() => mockDisposable),
+            onDidDeleteFiles: jest.fn(() => mockDisposable),
+            onDidRenameFiles: jest.fn(() => mockDisposable)
+        },
+        env: {
+            uriScheme: 'vscode',
+            language: 'en'
+        },
+        EventEmitter: jest.fn().mockImplementation(() => mockEventEmitter),
+        Disposable: {
+            from: jest.fn()
+        },
+        TabInputText: jest.fn()
+    };
+});
+
+// Mock p-wait-for to resolve immediately
+jest.mock('p-wait-for', () => ({
+    __esModule: true,
+    default: jest.fn().mockImplementation(async () => Promise.resolve())
+}));
+
+jest.mock('delay', () => ({
+    __esModule: true,
+    default: jest.fn().mockImplementation(async () => Promise.resolve())
+}));
+
+jest.mock('serialize-error', () => ({
+    __esModule: true,
+    serializeError: jest.fn().mockImplementation((error) => ({
+        name: error.name,
+        message: error.message,
+        stack: error.stack
+    }))
+}));
+
+jest.mock('strip-ansi', () => ({
+    __esModule: true,
+    default: jest.fn().mockImplementation((str) => str.replace(/\u001B\[\d+m/g, ''))
+}));
+
+jest.mock('globby', () => ({
+    __esModule: true,
+    globby: jest.fn().mockImplementation(async () => [])
+}));
+
+jest.mock('os-name', () => ({
+    __esModule: true,
+    default: jest.fn().mockReturnValue('Mock OS Name')
+}));
+
+jest.mock('default-shell', () => ({
+    __esModule: true,
+    default: '/bin/bash'  // Mock default shell path
+}));
+
+describe('Cline', () => {
+    let mockProvider: jest.Mocked<ClineProvider>;
+    let mockApiConfig: ApiConfiguration;
+    let mockOutputChannel: any;
+    let mockExtensionContext: vscode.ExtensionContext;
+    
+    beforeEach(() => {
+        // Setup mock extension context
+        mockExtensionContext = {
+            globalState: {
+                get: jest.fn().mockImplementation((key) => {
+                    if (key === 'taskHistory') {
+                        return [{
+                            id: '123',
+                            ts: Date.now(),
+                            task: 'historical task',
+                            tokensIn: 100,
+                            tokensOut: 200,
+                            cacheWrites: 0,
+                            cacheReads: 0,
+                            totalCost: 0.001
+                        }];
+                    }
+                    return undefined;
+                }),
+                update: jest.fn().mockImplementation((key, value) => Promise.resolve()),
+                keys: jest.fn().mockReturnValue([])
+            },
+            workspaceState: {
+                get: jest.fn().mockImplementation((key) => undefined),
+                update: jest.fn().mockImplementation((key, value) => Promise.resolve()),
+                keys: jest.fn().mockReturnValue([])
+            },
+            secrets: {
+                get: jest.fn().mockImplementation((key) => Promise.resolve(undefined)),
+                store: jest.fn().mockImplementation((key, value) => Promise.resolve()),
+                delete: jest.fn().mockImplementation((key) => Promise.resolve())
+            },
+            extensionUri: {
+                fsPath: '/mock/extension/path'
+            },
+            globalStorageUri: {
+                fsPath: '/mock/storage/path'
+            },
+            extension: {
+                packageJSON: {
+                    version: '1.0.0'
+                }
+            }
+        } as unknown as vscode.ExtensionContext;
+
+        // Setup mock output channel
+        mockOutputChannel = {
+            appendLine: jest.fn(),
+            append: jest.fn(),
+            clear: jest.fn(),
+            show: jest.fn(),
+            hide: jest.fn(),
+            dispose: jest.fn()
+        };
+
+        // Setup mock provider with output channel
+        mockProvider = new ClineProvider(mockExtensionContext, mockOutputChannel) as jest.Mocked<ClineProvider>;
+        
+        // Setup mock API configuration
+        mockApiConfig = {
+            apiProvider: 'anthropic',
+            apiModelId: 'claude-3-sonnet'
+        };
+
+        // Mock provider methods
+        mockProvider.postMessageToWebview = jest.fn().mockResolvedValue(undefined);
+        mockProvider.postStateToWebview = jest.fn().mockResolvedValue(undefined);
+        mockProvider.getTaskWithId = jest.fn().mockImplementation(async (id) => ({
+            historyItem: {
+                id,
+                ts: Date.now(),
+                task: 'historical task',
+                tokensIn: 100,
+                tokensOut: 200,
+                cacheWrites: 0,
+                cacheReads: 0,
+                totalCost: 0.001
+            },
+            taskDirPath: '/mock/storage/path/tasks/123',
+            apiConversationHistoryFilePath: '/mock/storage/path/tasks/123/api_conversation_history.json',
+            uiMessagesFilePath: '/mock/storage/path/tasks/123/ui_messages.json',
+            apiConversationHistory: []
+        }));
+    });
+
+    describe('constructor', () => {
+        it('should initialize with default settings', () => {
+            const cline = new Cline(
+                mockProvider,
+                mockApiConfig,
+                undefined, // customInstructions
+                undefined, // alwaysAllowReadOnly
+                undefined, // alwaysAllowWrite
+                undefined, // alwaysAllowExecute
+                'test task'
+            );
+
+            expect(cline.alwaysAllowReadOnly).toBe(false);
+            expect(cline.alwaysAllowWrite).toBe(false);
+            expect(cline.alwaysAllowExecute).toBe(false);
+        });
+
+        it('should respect provided settings', () => {
+            const cline = new Cline(
+                mockProvider,
+                mockApiConfig,
+                'custom instructions',
+                true,  // alwaysAllowReadOnly
+                true,  // alwaysAllowWrite
+                true,  // alwaysAllowExecute
+                'test task'
+            );
+
+            expect(cline.alwaysAllowReadOnly).toBe(true);
+            expect(cline.alwaysAllowWrite).toBe(true);
+            expect(cline.alwaysAllowExecute).toBe(true);
+            expect(cline.customInstructions).toBe('custom instructions');
+        });
+
+        it('should require either task or historyItem', () => {
+            expect(() => {
+                new Cline(
+                    mockProvider,
+                    mockApiConfig
+                );
+            }).toThrow('Either historyItem or task/images must be provided');
+        });
+    });
+
+    describe('file operations', () => {
+        let cline: Cline;
+
+        beforeEach(() => {
+            cline = new Cline(
+                mockProvider,
+                mockApiConfig,
+                undefined,
+                false,
+                false,
+                false,
+                'test task'
+            );
+        });
+
+        it('should bypass approval when alwaysAllowWrite is true', async () => {
+            const writeEnabledCline = new Cline(
+                mockProvider,
+                mockApiConfig,
+                undefined,
+                false,
+                true,  // alwaysAllowWrite
+                false,
+                'test task'
+            );
+
+            expect(writeEnabledCline.alwaysAllowWrite).toBe(true);
+            // The write operation would bypass approval in actual implementation
+        });
+
+        it('should require approval when alwaysAllowWrite is false', async () => {
+            const writeDisabledCline = new Cline(
+                mockProvider,
+                mockApiConfig,
+                undefined,
+                false,
+                false,  // alwaysAllowWrite
+                false,
+                'test task'
+            );
+
+            expect(writeDisabledCline.alwaysAllowWrite).toBe(false);
+            // The write operation would require approval in actual implementation
+        });
+    });    
+});

+ 31 - 5
src/core/webview/ClineProvider.ts

@@ -45,6 +45,8 @@ type GlobalStateKey =
 	| "lastShownAnnouncementId"
 	| "customInstructions"
 	| "alwaysAllowReadOnly"
+	| "alwaysAllowWrite"
+	| "alwaysAllowExecute"
 	| "taskHistory"
 	| "openAiBaseUrl"
 	| "openAiModelId"
@@ -185,18 +187,20 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 
 	async initClineWithTask(task?: string, images?: string[]) {
 		await this.clearTask() // ensures that an exising task doesn't exist before starting a new one, although this shouldn't be possible since user must clear task before starting a new one
-		const { apiConfiguration, customInstructions, alwaysAllowReadOnly } = await this.getState()
-		this.cline = new Cline(this, apiConfiguration, customInstructions, alwaysAllowReadOnly, task, images)
+		const { apiConfiguration, customInstructions, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute } = await this.getState()
+		this.cline = new Cline(this, apiConfiguration, customInstructions, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, task, images)
 	}
 
 	async initClineWithHistoryItem(historyItem: HistoryItem) {
 		await this.clearTask()
-		const { apiConfiguration, customInstructions, alwaysAllowReadOnly } = await this.getState()
+		const { apiConfiguration, customInstructions, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute } = await this.getState()
 		this.cline = new Cline(
 			this,
 			apiConfiguration,
 			customInstructions,
 			alwaysAllowReadOnly,
+			alwaysAllowWrite,
+			alwaysAllowExecute,
 			undefined,
 			undefined,
 			historyItem
@@ -401,6 +405,20 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 						}
 						await this.postStateToWebview()
 						break
+					case "alwaysAllowWrite":
+						await this.updateGlobalState("alwaysAllowWrite", message.bool ?? undefined)
+						if (this.cline) {
+							this.cline.alwaysAllowWrite = message.bool ?? false
+						}
+						await this.postStateToWebview()
+						break
+					case "alwaysAllowExecute":
+						await this.updateGlobalState("alwaysAllowExecute", message.bool ?? undefined)
+						if (this.cline) {
+							this.cline.alwaysAllowExecute = message.bool ?? false
+						}
+						await this.postStateToWebview()
+						break
 					case "askResponse":
 						this.cline?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
 						break
@@ -737,13 +755,15 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 	}
 
 	async getStateToPostToWebview() {
-		const { apiConfiguration, lastShownAnnouncementId, customInstructions, alwaysAllowReadOnly, taskHistory } =
+		const { apiConfiguration, lastShownAnnouncementId, customInstructions, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, taskHistory } =
 			await this.getState()
 		return {
 			version: this.context.extension?.packageJSON?.version ?? "",
 			apiConfiguration,
 			customInstructions,
-			alwaysAllowReadOnly,
+			alwaysAllowReadOnly: alwaysAllowReadOnly ?? false,
+			alwaysAllowWrite: alwaysAllowWrite ?? false,
+			alwaysAllowExecute: alwaysAllowExecute ?? false,
 			uriScheme: vscode.env.uriScheme,
 			clineMessages: this.cline?.clineMessages || [],
 			taskHistory: (taskHistory || []).filter((item) => item.ts && item.task).sort((a, b) => b.ts - a.ts),
@@ -828,6 +848,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			lastShownAnnouncementId,
 			customInstructions,
 			alwaysAllowReadOnly,
+			alwaysAllowWrite,
+			alwaysAllowExecute,
 			taskHistory,
 		] = await Promise.all([
 			this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
@@ -854,6 +876,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			this.getGlobalState("lastShownAnnouncementId") as Promise<string | undefined>,
 			this.getGlobalState("customInstructions") as Promise<string | undefined>,
 			this.getGlobalState("alwaysAllowReadOnly") as Promise<boolean | undefined>,
+			this.getGlobalState("alwaysAllowWrite") as Promise<boolean | undefined>,
+			this.getGlobalState("alwaysAllowExecute") as Promise<boolean | undefined>,
 			this.getGlobalState("taskHistory") as Promise<HistoryItem[] | undefined>,
 		])
 
@@ -898,6 +922,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			lastShownAnnouncementId,
 			customInstructions,
 			alwaysAllowReadOnly: alwaysAllowReadOnly ?? false,
+			alwaysAllowWrite: alwaysAllowWrite ?? false,
+			alwaysAllowExecute: alwaysAllowExecute ?? false,
 			taskHistory,
 		}
 	}

+ 3 - 0
src/shared/ExtensionMessage.ts

@@ -31,6 +31,8 @@ export interface ExtensionState {
 	apiConfiguration?: ApiConfiguration
 	customInstructions?: string
 	alwaysAllowReadOnly?: boolean
+	alwaysAllowWrite?: boolean
+	alwaysAllowExecute?: boolean
 	uriScheme?: string
 	clineMessages: ClineMessage[]
 	taskHistory: HistoryItem[]
@@ -74,6 +76,7 @@ export type ClineSay =
 	| "shell_integration_warning"
 	| "browser_action"
 	| "browser_action_result"
+	| "command"
 
 export interface ClineSayTool {
 	tool:

+ 2 - 0
src/shared/WebviewMessage.ts

@@ -5,6 +5,8 @@ export interface WebviewMessage {
 		| "apiConfiguration"
 		| "customInstructions"
 		| "alwaysAllowReadOnly"
+		| "alwaysAllowWrite"
+		| "alwaysAllowExecute"
 		| "webviewDidLaunch"
 		| "newTask"
 		| "askResponse"

+ 41 - 0
webview-ui/src/components/settings/SettingsView.tsx

@@ -19,6 +19,10 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 		setCustomInstructions,
 		alwaysAllowReadOnly,
 		setAlwaysAllowReadOnly,
+		alwaysAllowWrite,
+		setAlwaysAllowWrite,
+		alwaysAllowExecute,
+		setAlwaysAllowExecute,
 		openRouterModels,
 	} = useExtensionState()
 	const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
@@ -33,6 +37,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 			vscode.postMessage({ type: "apiConfiguration", apiConfiguration })
 			vscode.postMessage({ type: "customInstructions", text: customInstructions })
 			vscode.postMessage({ type: "alwaysAllowReadOnly", bool: alwaysAllowReadOnly })
+			vscode.postMessage({ type: "alwaysAllowWrite", bool: alwaysAllowWrite })
+			vscode.postMessage({ type: "alwaysAllowExecute", bool: alwaysAllowExecute })
 			onDone()
 		}
 	}
@@ -130,6 +136,41 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 					</p>
 				</div>
 
+				<div style={{ marginBottom: 5 }}>
+					<VSCodeCheckbox
+						checked={alwaysAllowWrite}
+						onChange={(e: any) => setAlwaysAllowWrite(e.target.checked)}>
+						<span style={{ fontWeight: "500" }}>Always approve write operations</span>
+					</VSCodeCheckbox>
+					<p
+						style={{
+							fontSize: "12px",
+							marginTop: "5px",
+							color: "var(--vscode-descriptionForeground)",
+						}}>
+						When enabled, Cline will automatically write to files and create directories
+						without requiring you to click the Approve button.
+					</p>
+				</div>
+
+				<div style={{ marginBottom: 5 }}>
+					<VSCodeCheckbox
+						checked={alwaysAllowExecute}
+						onChange={(e: any) => setAlwaysAllowExecute(e.target.checked)}>
+						<span style={{ fontWeight: "500" }}>Always approve execute operations</span>
+					</VSCodeCheckbox>
+					<p
+						style={{
+							fontSize: "12px",
+							marginTop: "5px",
+							color: "var(--vscode-descriptionForeground)",
+						}}>
+						When enabled, Cline will automatically CLI commands without requiring
+						you to click the Approve button.
+					</p>
+				</div>
+
+
 				{IS_DEV && (
 					<>
 						<div style={{ marginTop: "10px", marginBottom: "4px" }}>Debug</div>

+ 230 - 0
webview-ui/src/components/settings/__tests__/SettingsView.test.tsx

@@ -0,0 +1,230 @@
+import { render, screen, act } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { ExtensionStateContextType } from '../../../context/ExtensionStateContext'
+import SettingsView from '../SettingsView'
+import { vscode } from '../../../utils/vscode'
+import * as ExtensionStateContext from '../../../context/ExtensionStateContext'
+import { ModelInfo } from '../../../../../src/shared/api'
+
+// Mock dependencies
+jest.mock('../../../utils/vscode', () => ({
+    vscode: {
+        postMessage: jest.fn()
+    }
+}))
+
+// Mock validation functions
+jest.mock('../../../utils/validate', () => ({
+    validateApiConfiguration: jest.fn(() => undefined),
+    validateModelId: jest.fn(() => undefined)
+}))
+
+// Mock ApiOptions component
+jest.mock('../ApiOptions', () => ({
+    __esModule: true,
+    default: () => <div data-testid="mock-api-options" />
+}))
+
+// Mock VS Code components
+jest.mock('@vscode/webview-ui-toolkit/react', () => ({
+    VSCodeButton: ({ children, onClick }: any) => (
+        <button onClick={onClick}>{children}</button>
+    ),
+    VSCodeCheckbox: ({ children, checked, onChange }: any) => (
+        <label>
+            <input
+                type="checkbox"
+                checked={checked}
+                onChange={e => onChange(e)}
+                aria-checked={checked}
+            />
+            {children}
+        </label>
+    ),
+    VSCodeTextArea: ({ children, value, onInput }: any) => (
+        <textarea
+            data-testid="custom-instructions"
+            value={value}
+            readOnly
+            aria-label="Custom Instructions"
+        >{children}</textarea>
+    ),
+    VSCodeLink: ({ children, href }: any) => (
+        <a href={href}>{children}</a>
+    )
+}))
+
+describe('SettingsView', () => {
+    const mockOnDone = jest.fn()
+    const mockSetAlwaysAllowWrite = jest.fn()
+    const mockSetAlwaysAllowReadOnly = jest.fn()
+    const mockSetCustomInstructions = jest.fn()
+    const mockSetAlwaysAllowExecute = jest.fn()
+
+    let mockState: ExtensionStateContextType
+
+    const mockOpenRouterModels: Record<string, ModelInfo> = {
+        'claude-3-sonnet': {
+            maxTokens: 200000,
+            contextWindow: 200000,
+            supportsImages: true,
+            supportsComputerUse: true,
+            supportsPromptCache: true,
+            inputPrice: 0.000008,
+            outputPrice: 0.000024,
+            description: "Anthropic's Claude 3 Sonnet model"
+        }
+    }
+
+    beforeEach(() => {
+        jest.clearAllMocks()
+        
+        mockState = {
+            apiConfiguration: {
+                apiProvider: 'anthropic',
+                apiModelId: 'claude-3-sonnet'
+            },
+            version: '1.0.0',
+            customInstructions: 'Test instructions',
+            alwaysAllowReadOnly: true,
+            alwaysAllowWrite: true,
+            alwaysAllowExecute: true,
+            openRouterModels: mockOpenRouterModels,
+            didHydrateState: true,
+            showWelcome: false,
+            theme: 'dark',
+            filePaths: [],
+            taskHistory: [],
+            shouldShowAnnouncement: false,
+            clineMessages: [],
+            uriScheme: 'vscode',
+            
+            setAlwaysAllowReadOnly: mockSetAlwaysAllowReadOnly,
+            setAlwaysAllowWrite: mockSetAlwaysAllowWrite,
+            setCustomInstructions: mockSetCustomInstructions,
+            setAlwaysAllowExecute: mockSetAlwaysAllowExecute,
+            setApiConfiguration: jest.fn(),
+            setShowAnnouncement: jest.fn()
+        }
+        
+        // Mock the useExtensionState hook
+        jest.spyOn(ExtensionStateContext, 'useExtensionState').mockReturnValue(mockState)
+    })
+
+    const renderSettingsView = () => {
+        return render(
+            <SettingsView onDone={mockOnDone} />
+        )
+    }
+
+    describe('Checkboxes', () => {
+        it('should toggle alwaysAllowWrite checkbox', async () => {
+            mockState.alwaysAllowWrite = false
+            renderSettingsView()
+            
+            const writeCheckbox = screen.getByRole('checkbox', {
+                name: /Always approve write operations/i
+            })
+            
+            expect(writeCheckbox).not.toBeChecked()
+            await act(async () => {
+                await userEvent.click(writeCheckbox)
+            })
+            expect(mockSetAlwaysAllowWrite).toHaveBeenCalledWith(true)
+        })
+
+        it('should toggle alwaysAllowExecute checkbox', async () => {
+            mockState.alwaysAllowExecute = false
+            renderSettingsView()
+            
+            const executeCheckbox = screen.getByRole('checkbox', {
+                name: /Always approve execute operations/i
+            })
+            
+            expect(executeCheckbox).not.toBeChecked()
+            await act(async () => {
+                await userEvent.click(executeCheckbox)
+            })
+            expect(mockSetAlwaysAllowExecute).toHaveBeenCalledWith(true)
+        })
+
+        it('should toggle alwaysAllowReadOnly checkbox', async () => {
+            mockState.alwaysAllowReadOnly = false
+            renderSettingsView()
+            
+            const readOnlyCheckbox = screen.getByRole('checkbox', {
+                name: /Always approve read-only operations/i
+            })
+            
+            expect(readOnlyCheckbox).not.toBeChecked()
+            await act(async () => {
+                await userEvent.click(readOnlyCheckbox)
+            })
+            expect(mockSetAlwaysAllowReadOnly).toHaveBeenCalledWith(true)
+        })
+    })
+
+    describe('Form Submission', () => {
+        it('should send correct messages when form is submitted', async () => {
+            renderSettingsView()
+
+            // Submit form
+            const doneButton = screen.getByRole('button', { name: /Done/i })
+            await act(async () => {
+                await userEvent.click(doneButton)
+            })
+
+            // Verify messages were sent in the correct order
+            const calls = (vscode.postMessage as jest.Mock).mock.calls
+            expect(calls).toHaveLength(5)
+            
+            expect(calls[0][0]).toEqual({
+                type: 'apiConfiguration',
+                apiConfiguration: {
+                    apiProvider: 'anthropic',
+                    apiModelId: 'claude-3-sonnet'
+                }
+            })
+
+            expect(calls[1][0]).toEqual({
+                type: 'customInstructions',
+                text: 'Test instructions'
+            })
+
+            expect(calls[2][0]).toEqual({
+                type: 'alwaysAllowReadOnly',
+                bool: true
+            })
+
+            expect(calls[3][0]).toEqual({
+                type: 'alwaysAllowWrite',
+                bool: true
+            })
+
+            expect(calls[4][0]).toEqual({
+                type: 'alwaysAllowExecute',
+                bool: true
+            })
+
+            // Verify onDone was called
+            expect(mockOnDone).toHaveBeenCalled()
+        })
+    })
+
+    describe('Accessibility', () => {
+        it('should have accessible form controls', () => {
+            renderSettingsView()
+
+            // Check for proper labels and ARIA attributes
+            const writeCheckbox = screen.getByRole('checkbox', {
+                name: /Always approve write operations/i
+            })
+            expect(writeCheckbox).toHaveAttribute('aria-checked')
+
+            const textarea = screen.getByRole('textbox', {
+                name: /Custom Instructions/i
+            })
+            expect(textarea).toBeInTheDocument()
+        })
+    })
+})

+ 5 - 1
webview-ui/src/context/ExtensionStateContext.tsx

@@ -11,7 +11,7 @@ import { vscode } from "../utils/vscode"
 import { convertTextMateToHljs } from "../utils/textMateToHljs"
 import { findLastIndex } from "../../../src/shared/array"
 
-interface ExtensionStateContextType extends ExtensionState {
+export interface ExtensionStateContextType extends ExtensionState {
 	didHydrateState: boolean
 	showWelcome: boolean
 	theme: any
@@ -20,6 +20,8 @@ interface ExtensionStateContextType extends ExtensionState {
 	setApiConfiguration: (config: ApiConfiguration) => void
 	setCustomInstructions: (value?: string) => void
 	setAlwaysAllowReadOnly: (value: boolean) => void
+	setAlwaysAllowWrite: (value: boolean) => void
+	setAlwaysAllowExecute: (value: boolean) => void
 	setShowAnnouncement: (value: boolean) => void
 }
 
@@ -113,6 +115,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		setApiConfiguration: (value) => setState((prevState) => ({ ...prevState, apiConfiguration: value })),
 		setCustomInstructions: (value) => setState((prevState) => ({ ...prevState, customInstructions: value })),
 		setAlwaysAllowReadOnly: (value) => setState((prevState) => ({ ...prevState, alwaysAllowReadOnly: value })),
+		setAlwaysAllowWrite: (value) => setState((prevState) => ({ ...prevState, alwaysAllowWrite: value })),
+		setAlwaysAllowExecute: (value) => setState((prevState) => ({ ...prevState, alwaysAllowExecute: value })),
 		setShowAnnouncement: (value) => setState((prevState) => ({ ...prevState, shouldShowAnnouncement: value })),
 	}
 

Некоторые файлы не были показаны из-за большого количества измененных файлов