mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-20 04:11:10 -05:00
794 lines
19 KiB
TypeScript
794 lines
19 KiB
TypeScript
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 <div data-testid="browser-session">{JSON.stringify(messages)}</div>
|
|
}
|
|
}))
|
|
|
|
jest.mock('../ChatRow', () => ({
|
|
__esModule: true,
|
|
default: function MockChatRow({ message }: { message: ClineMessage }) {
|
|
return <div data-testid="chat-row">{JSON.stringify(message)}</div>
|
|
}
|
|
}))
|
|
|
|
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<HTMLInputElement>) {
|
|
return (
|
|
<div data-testid="chat-textarea">
|
|
<input ref={ref} type="text" onChange={(e) => props.onSend(e.target.value)} />
|
|
</div>
|
|
)
|
|
})
|
|
}
|
|
})
|
|
|
|
jest.mock('../TaskHeader', () => ({
|
|
__esModule: true,
|
|
default: function MockTaskHeader({ task }: { task: ClineMessage }) {
|
|
return <div data-testid="task-header">{JSON.stringify(task)}</div>
|
|
}
|
|
}))
|
|
|
|
// Mock VSCode components
|
|
jest.mock('@vscode/webview-ui-toolkit/react', () => ({
|
|
VSCodeButton: function MockVSCodeButton({
|
|
children,
|
|
onClick,
|
|
appearance
|
|
}: {
|
|
children: React.ReactNode;
|
|
onClick?: () => void;
|
|
appearance?: string;
|
|
}) {
|
|
return <button onClick={onClick} data-appearance={appearance}>{children}</button>
|
|
},
|
|
VSCodeTextField: function MockVSCodeTextField({
|
|
value,
|
|
onInput,
|
|
placeholder
|
|
}: {
|
|
value?: string;
|
|
onInput?: (e: { target: { value: string } }) => void;
|
|
placeholder?: string;
|
|
}) {
|
|
return (
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
onChange={(e) => onInput?.({ target: { value: e.target.value } })}
|
|
placeholder={placeholder}
|
|
/>
|
|
)
|
|
},
|
|
VSCodeLink: function MockVSCodeLink({
|
|
children,
|
|
href
|
|
}: {
|
|
children: React.ReactNode;
|
|
href?: string;
|
|
}) {
|
|
return <a href={href}>{children}</a>
|
|
}
|
|
}))
|
|
|
|
// Mock window.postMessage to trigger state hydration
|
|
const mockPostMessage = (state: Partial<ExtensionState>) => {
|
|
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('auto-approves browser actions when alwaysAllowBrowser is enabled', async () => {
|
|
render(
|
|
<ExtensionStateContextProvider>
|
|
<ChatView
|
|
isHidden={false}
|
|
showAnnouncement={false}
|
|
hideAnnouncement={() => {}}
|
|
showHistoryView={() => {}}
|
|
/>
|
|
</ExtensionStateContextProvider>
|
|
)
|
|
|
|
// First hydrate state with initial task
|
|
mockPostMessage({
|
|
alwaysAllowBrowser: true,
|
|
clineMessages: [
|
|
{
|
|
type: 'say',
|
|
say: 'task',
|
|
ts: Date.now() - 2000,
|
|
text: 'Initial task'
|
|
}
|
|
]
|
|
})
|
|
|
|
// Then send the browser action ask message
|
|
mockPostMessage({
|
|
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(
|
|
<ExtensionStateContextProvider>
|
|
<ChatView
|
|
isHidden={false}
|
|
showAnnouncement={false}
|
|
hideAnnouncement={() => {}}
|
|
showHistoryView={() => {}}
|
|
/>
|
|
</ExtensionStateContextProvider>
|
|
)
|
|
|
|
// First hydrate state with initial task
|
|
mockPostMessage({
|
|
alwaysAllowReadOnly: true,
|
|
clineMessages: [
|
|
{
|
|
type: 'say',
|
|
say: 'task',
|
|
ts: Date.now() - 2000,
|
|
text: 'Initial task'
|
|
}
|
|
]
|
|
})
|
|
|
|
// Then send the read-only tool ask message
|
|
mockPostMessage({
|
|
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'
|
|
})
|
|
})
|
|
})
|
|
|
|
it('auto-approves write tools when alwaysAllowWrite is enabled', async () => {
|
|
render(
|
|
<ExtensionStateContextProvider>
|
|
<ChatView
|
|
isHidden={false}
|
|
showAnnouncement={false}
|
|
hideAnnouncement={() => {}}
|
|
showHistoryView={() => {}}
|
|
/>
|
|
</ExtensionStateContextProvider>
|
|
)
|
|
|
|
// First hydrate state with initial task
|
|
mockPostMessage({
|
|
alwaysAllowWrite: true,
|
|
clineMessages: [
|
|
{
|
|
type: 'say',
|
|
say: 'task',
|
|
ts: Date.now() - 2000,
|
|
text: 'Initial task'
|
|
}
|
|
]
|
|
})
|
|
|
|
// Then send the write tool ask message
|
|
mockPostMessage({
|
|
alwaysAllowWrite: true,
|
|
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('auto-approves allowed commands when alwaysAllowExecute is enabled', async () => {
|
|
render(
|
|
<ExtensionStateContextProvider>
|
|
<ChatView
|
|
isHidden={false}
|
|
showAnnouncement={false}
|
|
hideAnnouncement={() => {}}
|
|
showHistoryView={() => {}}
|
|
/>
|
|
</ExtensionStateContextProvider>
|
|
)
|
|
|
|
// First hydrate state with initial task
|
|
mockPostMessage({
|
|
alwaysAllowExecute: true,
|
|
allowedCommands: ['npm test'],
|
|
clineMessages: [
|
|
{
|
|
type: 'say',
|
|
say: 'task',
|
|
ts: Date.now() - 2000,
|
|
text: 'Initial task'
|
|
}
|
|
]
|
|
})
|
|
|
|
// Then send the command ask message
|
|
mockPostMessage({
|
|
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(
|
|
<ExtensionStateContextProvider>
|
|
<ChatView
|
|
isHidden={false}
|
|
showAnnouncement={false}
|
|
hideAnnouncement={() => {}}
|
|
showHistoryView={() => {}}
|
|
/>
|
|
</ExtensionStateContextProvider>
|
|
)
|
|
|
|
// First hydrate state with initial task
|
|
mockPostMessage({
|
|
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({
|
|
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(
|
|
<ExtensionStateContextProvider>
|
|
<ChatView
|
|
isHidden={false}
|
|
showAnnouncement={false}
|
|
hideAnnouncement={() => {}}
|
|
showHistoryView={() => {}}
|
|
/>
|
|
</ExtensionStateContextProvider>
|
|
)
|
|
|
|
// 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'
|
|
]
|
|
|
|
for (const command of allowedChainedCommands) {
|
|
jest.clearAllMocks()
|
|
|
|
// First hydrate state with initial task
|
|
mockPostMessage({
|
|
alwaysAllowExecute: true,
|
|
allowedCommands: ['npm test', 'npm run build'],
|
|
clineMessages: [
|
|
{
|
|
type: 'say',
|
|
say: 'task',
|
|
ts: Date.now() - 2000,
|
|
text: 'Initial task'
|
|
}
|
|
]
|
|
})
|
|
|
|
// Then send the chained command ask message
|
|
mockPostMessage({
|
|
alwaysAllowExecute: true,
|
|
allowedCommands: ['npm test', 'npm run build'],
|
|
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(
|
|
<ExtensionStateContextProvider>
|
|
<ChatView
|
|
isHidden={false}
|
|
showAnnouncement={false}
|
|
hideAnnouncement={() => {}}
|
|
showHistoryView={() => {}}
|
|
/>
|
|
</ExtensionStateContextProvider>
|
|
)
|
|
|
|
// 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 /',
|
|
'npm test $(echo dangerous)',
|
|
'npm test `echo dangerous`'
|
|
]
|
|
|
|
disallowedChainedCommands.forEach(command => {
|
|
// First hydrate state with initial task
|
|
mockPostMessage({
|
|
alwaysAllowExecute: true,
|
|
allowedCommands: ['npm test'],
|
|
clineMessages: [
|
|
{
|
|
type: 'say',
|
|
say: 'task',
|
|
ts: Date.now() - 2000,
|
|
text: 'Initial task'
|
|
}
|
|
]
|
|
})
|
|
|
|
// Then send the chained command ask message
|
|
mockPostMessage({
|
|
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: 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'
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('ChatView - Sound Playing Tests', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks()
|
|
})
|
|
|
|
it('does not play sound for auto-approved browser actions', async () => {
|
|
render(
|
|
<ExtensionStateContextProvider>
|
|
<ChatView
|
|
isHidden={false}
|
|
showAnnouncement={false}
|
|
hideAnnouncement={() => {}}
|
|
showHistoryView={() => {}}
|
|
/>
|
|
</ExtensionStateContextProvider>
|
|
)
|
|
|
|
// First hydrate state with initial task and streaming
|
|
mockPostMessage({
|
|
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({
|
|
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(
|
|
<ExtensionStateContextProvider>
|
|
<ChatView
|
|
isHidden={false}
|
|
showAnnouncement={false}
|
|
hideAnnouncement={() => {}}
|
|
showHistoryView={() => {}}
|
|
/>
|
|
</ExtensionStateContextProvider>
|
|
)
|
|
|
|
// First hydrate state with initial task and streaming
|
|
mockPostMessage({
|
|
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({
|
|
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(
|
|
<ExtensionStateContextProvider>
|
|
<ChatView
|
|
isHidden={false}
|
|
showAnnouncement={false}
|
|
hideAnnouncement={() => {}}
|
|
showHistoryView={() => {}}
|
|
/>
|
|
</ExtensionStateContextProvider>
|
|
)
|
|
|
|
// 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(
|
|
<ExtensionStateContextProvider>
|
|
<ChatView
|
|
isHidden={false}
|
|
showAnnouncement={false}
|
|
hideAnnouncement={() => {}}
|
|
showHistoryView={() => {}}
|
|
/>
|
|
</ExtensionStateContextProvider>
|
|
)
|
|
|
|
// 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'
|
|
})
|
|
})
|
|
})
|
|
})
|