import React from 'react' import { render, waitFor } from '@testing-library/react' import ChatView from '../ChatView' import { ExtensionStateContextProvider } from '../../../context/ExtensionStateContext' import { vscode } from '../../../utils/vscode' // Define minimal types needed for testing interface ClineMessage { type: 'say' | 'ask'; say?: string; ask?: string; ts: number; text?: string; partial?: boolean; } interface ExtensionState { version: string; clineMessages: ClineMessage[]; taskHistory: any[]; shouldShowAnnouncement: boolean; allowedCommands: string[]; alwaysAllowExecute: boolean; [key: string]: any; } // Mock vscode API jest.mock('../../../utils/vscode', () => ({ vscode: { postMessage: jest.fn(), }, })) // Mock components that use ESM dependencies jest.mock('../BrowserSessionRow', () => ({ __esModule: true, default: function MockBrowserSessionRow({ messages }: { messages: ClineMessage[] }) { return
{JSON.stringify(messages)}
} })) jest.mock('../ChatRow', () => ({ __esModule: true, default: function MockChatRow({ message }: { message: ClineMessage }) { return
{JSON.stringify(message)}
} })) jest.mock('../AutoApproveMenu', () => ({ __esModule: true, default: () => null, })) interface ChatTextAreaProps { onSend: (value: string) => void; inputValue?: string; textAreaDisabled?: boolean; placeholderText?: string; selectedImages?: string[]; shouldDisableImages?: boolean; } jest.mock('../ChatTextArea', () => { const mockReact = require('react') return { __esModule: true, default: mockReact.forwardRef(function MockChatTextArea(props: ChatTextAreaProps, ref: React.ForwardedRef) { return (
props.onSend(e.target.value)} />
) }) } }) jest.mock('../TaskHeader', () => ({ __esModule: true, default: function MockTaskHeader({ task }: { task: ClineMessage }) { return
{JSON.stringify(task)}
} })) // Mock VSCode components jest.mock('@vscode/webview-ui-toolkit/react', () => ({ VSCodeButton: function MockVSCodeButton({ children, onClick, appearance }: { children: React.ReactNode; onClick?: () => void; appearance?: string; }) { return }, VSCodeTextField: function MockVSCodeTextField({ value, onInput, placeholder }: { value?: string; onInput?: (e: { target: { value: string } }) => void; placeholder?: string; }) { return ( onInput?.({ target: { value: e.target.value } })} placeholder={placeholder} /> ) }, VSCodeLink: function MockVSCodeLink({ children, href }: { children: React.ReactNode; href?: string; }) { return {children} } })) // Mock window.postMessage to trigger state hydration const mockPostMessage = (state: Partial) => { window.postMessage({ type: 'state', state: { version: '1.0.0', clineMessages: [], taskHistory: [], shouldShowAnnouncement: false, allowedCommands: [], alwaysAllowExecute: false, ...state } }, '*') } describe('ChatView - Auto Approval Tests', () => { beforeEach(() => { jest.clearAllMocks() }) it('does not auto-approve any actions when autoApprovalEnabled is false', () => { render( {}} showHistoryView={() => {}} /> ) // First hydrate state with initial task mockPostMessage({ autoApprovalEnabled: false, alwaysAllowBrowser: true, alwaysAllowReadOnly: true, alwaysAllowWrite: true, alwaysAllowExecute: true, allowedCommands: ['npm test'], clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' } ] }) // Test various types of actions that should not be auto-approved const testCases = [ { ask: 'browser_action_launch', text: JSON.stringify({ action: 'launch', url: 'http://example.com' }) }, { ask: 'tool', text: JSON.stringify({ tool: 'readFile', path: 'test.txt' }) }, { ask: 'tool', text: JSON.stringify({ tool: 'editedExistingFile', path: 'test.txt' }) }, { ask: 'command', text: 'npm test' } ] testCases.forEach(testCase => { mockPostMessage({ autoApprovalEnabled: false, alwaysAllowBrowser: true, alwaysAllowReadOnly: true, alwaysAllowWrite: true, alwaysAllowExecute: true, allowedCommands: ['npm test'], clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' }, { type: 'ask', ask: testCase.ask, ts: Date.now(), text: testCase.text, partial: false } ] }) // Verify no auto-approval message was sent expect(vscode.postMessage).not.toHaveBeenCalledWith({ type: 'askResponse', askResponse: 'yesButtonClicked' }) }) }) it('auto-approves browser actions when alwaysAllowBrowser is enabled', async () => { render( {}} showHistoryView={() => {}} /> ) // First hydrate state with initial task mockPostMessage({ autoApprovalEnabled: true, alwaysAllowBrowser: true, clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' } ] }) // Then send the browser action ask message mockPostMessage({ autoApprovalEnabled: true, alwaysAllowBrowser: true, clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' }, { type: 'ask', ask: 'browser_action_launch', ts: Date.now(), text: JSON.stringify({ action: 'launch', url: 'http://example.com' }), partial: false } ] }) // Wait for the auto-approval message await waitFor(() => { expect(vscode.postMessage).toHaveBeenCalledWith({ type: 'askResponse', askResponse: 'yesButtonClicked' }) }) }) it('auto-approves read-only tools when alwaysAllowReadOnly is enabled', async () => { render( {}} showHistoryView={() => {}} /> ) // First hydrate state with initial task mockPostMessage({ autoApprovalEnabled: true, alwaysAllowReadOnly: true, clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' } ] }) // Then send the read-only tool ask message mockPostMessage({ autoApprovalEnabled: true, alwaysAllowReadOnly: true, clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' }, { type: 'ask', ask: 'tool', ts: Date.now(), text: JSON.stringify({ tool: 'readFile', path: 'test.txt' }), partial: false } ] }) // Wait for the auto-approval message await waitFor(() => { expect(vscode.postMessage).toHaveBeenCalledWith({ type: 'askResponse', askResponse: 'yesButtonClicked' }) }) }) describe('Write Tool Auto-Approval Tests', () => { it('auto-approves write tools when alwaysAllowWrite is enabled and message is a tool request', async () => { render( {}} showHistoryView={() => {}} /> ) // First hydrate state with initial task mockPostMessage({ autoApprovalEnabled: true, alwaysAllowWrite: true, writeDelayMs: 0, clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' } ] }) // Then send the write tool ask message mockPostMessage({ autoApprovalEnabled: true, alwaysAllowWrite: true, writeDelayMs: 0, clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' }, { type: 'ask', ask: 'tool', ts: Date.now(), text: JSON.stringify({ tool: 'editedExistingFile', path: 'test.txt' }), partial: false } ] }) // Wait for the auto-approval message await waitFor(() => { expect(vscode.postMessage).toHaveBeenCalledWith({ type: 'askResponse', askResponse: 'yesButtonClicked' }) }) }) it('does not auto-approve write operations when alwaysAllowWrite is enabled but message is not a tool request', () => { render( {}} showHistoryView={() => {}} /> ) // First hydrate state with initial task mockPostMessage({ autoApprovalEnabled: true, alwaysAllowWrite: true, clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' } ] }) // Then send a non-tool write operation message mockPostMessage({ autoApprovalEnabled: true, alwaysAllowWrite: true, clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' }, { type: 'ask', ask: 'write_operation', ts: Date.now(), text: JSON.stringify({ path: 'test.txt', content: 'test content' }), partial: false } ] }) // Verify no auto-approval message was sent expect(vscode.postMessage).not.toHaveBeenCalledWith({ type: 'askResponse', askResponse: 'yesButtonClicked' }) }) }) it('auto-approves allowed commands when alwaysAllowExecute is enabled', async () => { render( {}} showHistoryView={() => {}} /> ) // First hydrate state with initial task mockPostMessage({ autoApprovalEnabled: true, alwaysAllowExecute: true, allowedCommands: ['npm test'], clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' } ] }) // Then send the command ask message mockPostMessage({ autoApprovalEnabled: true, alwaysAllowExecute: true, allowedCommands: ['npm test'], clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' }, { type: 'ask', ask: 'command', ts: Date.now(), text: 'npm test', partial: false } ] }) // Wait for the auto-approval message await waitFor(() => { expect(vscode.postMessage).toHaveBeenCalledWith({ type: 'askResponse', askResponse: 'yesButtonClicked' }) }) }) it('does not auto-approve disallowed commands even when alwaysAllowExecute is enabled', () => { render( {}} showHistoryView={() => {}} /> ) // First hydrate state with initial task mockPostMessage({ autoApprovalEnabled: true, alwaysAllowExecute: true, allowedCommands: ['npm test'], clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' } ] }) // Then send the disallowed command ask message mockPostMessage({ autoApprovalEnabled: true, alwaysAllowExecute: true, allowedCommands: ['npm test'], clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' }, { type: 'ask', ask: 'command', ts: Date.now(), text: 'rm -rf /', partial: false } ] }) // Verify no auto-approval message was sent expect(vscode.postMessage).not.toHaveBeenCalledWith({ type: 'askResponse', askResponse: 'yesButtonClicked' }) }) describe('Command Chaining Tests', () => { it('auto-approves chained commands when all parts are allowed', async () => { render( {}} showHistoryView={() => {}} /> ) // Test various allowed command chaining scenarios const allowedChainedCommands = [ 'npm test && npm run build', 'npm test; npm run build', 'npm test || npm run build', 'npm test | npm run build', // Add test for quoted pipes which should be treated as part of the command, not as a chain operator 'echo "hello | world"', 'npm test "param with | inside" && npm run build', // PowerShell command with Select-String 'npm test 2>&1 | Select-String -NotMatch "node_modules" | Select-String "FAIL|Error"' ] for (const command of allowedChainedCommands) { jest.clearAllMocks() // First hydrate state with initial task mockPostMessage({ autoApprovalEnabled: true, alwaysAllowExecute: true, allowedCommands: ['npm test', 'npm run build', 'echo', 'Select-String'], clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' } ] }) // Then send the chained command ask message mockPostMessage({ autoApprovalEnabled: true, alwaysAllowExecute: true, allowedCommands: ['npm test', 'npm run build', 'echo', 'Select-String'], clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' }, { type: 'ask', ask: 'command', ts: Date.now(), text: command, partial: false } ] }) // Wait for the auto-approval message await waitFor(() => { expect(vscode.postMessage).toHaveBeenCalledWith({ type: 'askResponse', askResponse: 'yesButtonClicked' }) }) } }) it('does not auto-approve chained commands when any part is disallowed', () => { render( {}} showHistoryView={() => {}} /> ) // Test various command chaining scenarios with disallowed parts const disallowedChainedCommands = [ 'npm test && rm -rf /', 'npm test; rm -rf /', 'npm test || rm -rf /', 'npm test | rm -rf /', // Test subshell execution using $() and backticks 'npm test $(echo dangerous)', 'npm test `echo dangerous`', // Test unquoted pipes with disallowed commands 'npm test | rm -rf /', // Test PowerShell command with disallowed parts 'npm test 2>&1 | Select-String -NotMatch "node_modules" | rm -rf /' ] disallowedChainedCommands.forEach(command => { // First hydrate state with initial task mockPostMessage({ alwaysAllowExecute: true, allowedCommands: ['npm test', 'Select-String'], clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' } ] }) // Then send the chained command ask message mockPostMessage({ autoApprovalEnabled: true, alwaysAllowExecute: true, allowedCommands: ['npm test', 'Select-String'], clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' }, { type: 'ask', ask: 'command', ts: Date.now(), text: command, partial: false } ] }) // Verify no auto-approval message was sent for chained commands with disallowed parts expect(vscode.postMessage).not.toHaveBeenCalledWith({ type: 'askResponse', askResponse: 'yesButtonClicked' }) }) }) it('handles complex PowerShell command chains correctly', async () => { render( {}} showHistoryView={() => {}} /> ) // Test PowerShell specific command chains const powershellCommands = { allowed: [ 'npm test 2>&1 | Select-String -NotMatch "node_modules"', 'npm test 2>&1 | Select-String "FAIL|Error"', 'npm test 2>&1 | Select-String -NotMatch "node_modules" | Select-String "FAIL|Error"' ], disallowed: [ 'npm test 2>&1 | Select-String -NotMatch "node_modules" | rm -rf /', 'npm test 2>&1 | Select-String "FAIL|Error" && del /F /Q *', 'npm test 2>&1 | Select-String -NotMatch "node_modules" | Remove-Item -Recurse' ] } // Test allowed PowerShell commands for (const command of powershellCommands.allowed) { jest.clearAllMocks() mockPostMessage({ autoApprovalEnabled: true, alwaysAllowExecute: true, allowedCommands: ['npm test', 'Select-String'], clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' } ] }) mockPostMessage({ autoApprovalEnabled: true, alwaysAllowExecute: true, allowedCommands: ['npm test', 'Select-String'], clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' }, { type: 'ask', ask: 'command', ts: Date.now(), text: command, partial: false } ] }) await waitFor(() => { expect(vscode.postMessage).toHaveBeenCalledWith({ type: 'askResponse', askResponse: 'yesButtonClicked' }) }) } // Test disallowed PowerShell commands for (const command of powershellCommands.disallowed) { jest.clearAllMocks() mockPostMessage({ autoApprovalEnabled: true, alwaysAllowExecute: true, allowedCommands: ['npm test', 'Select-String'], clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' } ] }) mockPostMessage({ autoApprovalEnabled: true, alwaysAllowExecute: true, allowedCommands: ['npm test', 'Select-String'], clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' }, { type: 'ask', ask: 'command', ts: Date.now(), text: command, partial: false } ] }) expect(vscode.postMessage).not.toHaveBeenCalledWith({ type: 'askResponse', askResponse: 'yesButtonClicked' }) } }) }) }) describe('ChatView - Sound Playing Tests', () => { beforeEach(() => { jest.clearAllMocks() }) it('does not play sound for auto-approved browser actions', async () => { render( {}} showHistoryView={() => {}} /> ) // First hydrate state with initial task and streaming mockPostMessage({ autoApprovalEnabled: true, alwaysAllowBrowser: true, clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' }, { type: 'say', say: 'api_req_started', ts: Date.now() - 1000, text: JSON.stringify({}), partial: true } ] }) // Then send the browser action ask message (streaming finished) mockPostMessage({ autoApprovalEnabled: true, alwaysAllowBrowser: true, clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' }, { type: 'ask', ask: 'browser_action_launch', ts: Date.now(), text: JSON.stringify({ action: 'launch', url: 'http://example.com' }), partial: false } ] }) // Verify no sound was played expect(vscode.postMessage).not.toHaveBeenCalledWith({ type: 'playSound', audioType: expect.any(String) }) }) it('plays notification sound for non-auto-approved browser actions', async () => { render( {}} showHistoryView={() => {}} /> ) // First hydrate state with initial task and streaming mockPostMessage({ autoApprovalEnabled: true, alwaysAllowBrowser: false, clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' }, { type: 'say', say: 'api_req_started', ts: Date.now() - 1000, text: JSON.stringify({}), partial: true } ] }) // Then send the browser action ask message (streaming finished) mockPostMessage({ autoApprovalEnabled: true, alwaysAllowBrowser: false, clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' }, { type: 'ask', ask: 'browser_action_launch', ts: Date.now(), text: JSON.stringify({ action: 'launch', url: 'http://example.com' }), partial: false } ] }) // Verify notification sound was played await waitFor(() => { expect(vscode.postMessage).toHaveBeenCalledWith({ type: 'playSound', audioType: 'notification' }) }) }) it('plays celebration sound for completion results', async () => { render( {}} showHistoryView={() => {}} /> ) // First hydrate state with initial task and streaming mockPostMessage({ clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' }, { type: 'say', say: 'api_req_started', ts: Date.now() - 1000, text: JSON.stringify({}), partial: true } ] }) // Then send the completion result message (streaming finished) mockPostMessage({ clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' }, { type: 'ask', ask: 'completion_result', ts: Date.now(), text: 'Task completed successfully', partial: false } ] }) // Verify celebration sound was played await waitFor(() => { expect(vscode.postMessage).toHaveBeenCalledWith({ type: 'playSound', audioType: 'celebration' }) }) }) it('plays progress_loop sound for api failures', async () => { render( {}} showHistoryView={() => {}} /> ) // First hydrate state with initial task and streaming mockPostMessage({ clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' }, { type: 'say', say: 'api_req_started', ts: Date.now() - 1000, text: JSON.stringify({}), partial: true } ] }) // Then send the api failure message (streaming finished) mockPostMessage({ clineMessages: [ { type: 'say', say: 'task', ts: Date.now() - 2000, text: 'Initial task' }, { type: 'ask', ask: 'api_req_failed', ts: Date.now(), text: 'API request failed', partial: false } ] }) // Verify progress_loop sound was played await waitFor(() => { expect(vscode.postMessage).toHaveBeenCalledWith({ type: 'playSound', audioType: 'progress_loop' }) }) }) })