|
|
@@ -1,309 +1,549 @@
|
|
|
-import { render, screen, act } from '@testing-library/react'
|
|
|
-import userEvent from '@testing-library/user-event'
|
|
|
-import { ExtensionStateContextType } from '../../../context/ExtensionStateContext'
|
|
|
+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'
|
|
|
-import * as ExtensionStateContext from '../../../context/ExtensionStateContext'
|
|
|
|
|
|
-// Mock vscode
|
|
|
-jest.mock('../../../utils/vscode', () => ({
|
|
|
- vscode: {
|
|
|
- postMessage: jest.fn()
|
|
|
- }
|
|
|
-}))
|
|
|
+// Define minimal types needed for testing
|
|
|
+interface ClineMessage {
|
|
|
+ type: 'say' | 'ask';
|
|
|
+ say?: string;
|
|
|
+ ask?: string;
|
|
|
+ ts: number;
|
|
|
+ text?: string;
|
|
|
+ partial?: boolean;
|
|
|
+}
|
|
|
|
|
|
-// Mock all components that use problematic dependencies
|
|
|
-jest.mock('../../common/CodeBlock', () => ({
|
|
|
- __esModule: true,
|
|
|
- default: () => <div data-testid="mock-code-block" />
|
|
|
-}))
|
|
|
+interface ExtensionState {
|
|
|
+ version: string;
|
|
|
+ clineMessages: ClineMessage[];
|
|
|
+ taskHistory: any[];
|
|
|
+ shouldShowAnnouncement: boolean;
|
|
|
+ allowedCommands: string[];
|
|
|
+ alwaysAllowExecute: boolean;
|
|
|
+ [key: string]: any;
|
|
|
+}
|
|
|
|
|
|
-jest.mock('../../common/MarkdownBlock', () => ({
|
|
|
- __esModule: true,
|
|
|
- default: () => <div data-testid="mock-markdown-block" />
|
|
|
+// Mock vscode API
|
|
|
+jest.mock('../../../utils/vscode', () => ({
|
|
|
+ vscode: {
|
|
|
+ postMessage: jest.fn(),
|
|
|
+ },
|
|
|
}))
|
|
|
|
|
|
+// Mock components that use ESM dependencies
|
|
|
jest.mock('../BrowserSessionRow', () => ({
|
|
|
- __esModule: true,
|
|
|
- default: () => <div data-testid="mock-browser-session-row" />
|
|
|
+ __esModule: true,
|
|
|
+ default: function MockBrowserSessionRow({ messages }: { messages: ClineMessage[] }) {
|
|
|
+ return <div data-testid="browser-session">{JSON.stringify(messages)}</div>
|
|
|
+ }
|
|
|
}))
|
|
|
|
|
|
-// Update ChatRow mock to capture props
|
|
|
-let chatRowProps = null
|
|
|
jest.mock('../ChatRow', () => ({
|
|
|
- __esModule: true,
|
|
|
- default: (props: any) => {
|
|
|
- chatRowProps = props
|
|
|
- return <div data-testid="mock-chat-row" />
|
|
|
- }
|
|
|
+ __esModule: true,
|
|
|
+ default: function MockChatRow({ message }: { message: ClineMessage }) {
|
|
|
+ return <div data-testid="chat-row">{JSON.stringify(message)}</div>
|
|
|
+ }
|
|
|
}))
|
|
|
|
|
|
-// Mock Virtuoso component
|
|
|
-jest.mock('react-virtuoso', () => ({
|
|
|
- Virtuoso: ({ children }: any) => (
|
|
|
- <div data-testid="mock-virtuoso">{children}</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 VS Code components
|
|
|
+// Mock VSCode components
|
|
|
jest.mock('@vscode/webview-ui-toolkit/react', () => ({
|
|
|
- VSCodeButton: ({ children, onClick }: any) => (
|
|
|
- <button onClick={onClick}>{children}</button>
|
|
|
- ),
|
|
|
- VSCodeProgressRing: () => <div data-testid="progress-ring" />
|
|
|
+ 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>
|
|
|
+ }
|
|
|
}))
|
|
|
|
|
|
-describe('ChatView', () => {
|
|
|
- const mockShowHistoryView = jest.fn()
|
|
|
- const mockHideAnnouncement = jest.fn()
|
|
|
+// 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
|
|
|
+ }
|
|
|
+ }, '*')
|
|
|
+}
|
|
|
|
|
|
- let mockState: ExtensionStateContextType
|
|
|
+describe('ChatView - Auto Approval Tests', () => {
|
|
|
+ beforeEach(() => {
|
|
|
+ jest.clearAllMocks()
|
|
|
+ })
|
|
|
|
|
|
- beforeEach(() => {
|
|
|
- jest.clearAllMocks()
|
|
|
-
|
|
|
- mockState = {
|
|
|
- clineMessages: [],
|
|
|
- apiConfiguration: {
|
|
|
- apiProvider: 'anthropic',
|
|
|
- apiModelId: 'claude-3-sonnet'
|
|
|
- },
|
|
|
- version: '1.0.0',
|
|
|
- customInstructions: '',
|
|
|
- alwaysAllowReadOnly: true,
|
|
|
- alwaysAllowWrite: true,
|
|
|
- alwaysAllowExecute: true,
|
|
|
- alwaysAllowBrowser: true,
|
|
|
- openRouterModels: {},
|
|
|
- didHydrateState: true,
|
|
|
- showWelcome: false,
|
|
|
- theme: 'dark',
|
|
|
- filePaths: [],
|
|
|
- taskHistory: [],
|
|
|
- shouldShowAnnouncement: false,
|
|
|
- uriScheme: 'vscode',
|
|
|
-
|
|
|
- setApiConfiguration: jest.fn(),
|
|
|
- setShowAnnouncement: jest.fn(),
|
|
|
- setCustomInstructions: jest.fn(),
|
|
|
- setAlwaysAllowReadOnly: jest.fn(),
|
|
|
- setAlwaysAllowWrite: jest.fn(),
|
|
|
- setAlwaysAllowExecute: jest.fn(),
|
|
|
- setAlwaysAllowBrowser: jest.fn()
|
|
|
+ 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'
|
|
|
}
|
|
|
-
|
|
|
- // Mock the useExtensionState hook
|
|
|
- jest.spyOn(ExtensionStateContext, 'useExtensionState').mockReturnValue(mockState)
|
|
|
+ ]
|
|
|
})
|
|
|
|
|
|
- const renderChatView = () => {
|
|
|
- return render(
|
|
|
- <ChatView
|
|
|
- isHidden={false}
|
|
|
- showAnnouncement={false}
|
|
|
- hideAnnouncement={mockHideAnnouncement}
|
|
|
- showHistoryView={mockShowHistoryView}
|
|
|
- />
|
|
|
- )
|
|
|
- }
|
|
|
+ // 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
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ })
|
|
|
|
|
|
- describe('Always Allow Logic', () => {
|
|
|
- it('should auto-approve read-only tool actions when alwaysAllowReadOnly is true', () => {
|
|
|
- mockState.clineMessages = [
|
|
|
- {
|
|
|
- type: 'ask',
|
|
|
- ask: 'tool',
|
|
|
- text: JSON.stringify({ tool: 'readFile' }),
|
|
|
- ts: Date.now(),
|
|
|
- }
|
|
|
- ]
|
|
|
- renderChatView()
|
|
|
-
|
|
|
- expect(vscode.postMessage).toHaveBeenCalledWith({
|
|
|
- type: 'askResponse',
|
|
|
- askResponse: 'yesButtonClicked'
|
|
|
- })
|
|
|
- })
|
|
|
+ // Wait for the auto-approval message
|
|
|
+ await waitFor(() => {
|
|
|
+ expect(vscode.postMessage).toHaveBeenCalledWith({
|
|
|
+ type: 'askResponse',
|
|
|
+ askResponse: 'yesButtonClicked'
|
|
|
+ })
|
|
|
+ })
|
|
|
+ })
|
|
|
|
|
|
- it('should auto-approve all file listing tool types when alwaysAllowReadOnly is true', () => {
|
|
|
- const fileListingTools = [
|
|
|
- 'readFile', 'listFiles', 'listFilesTopLevel',
|
|
|
- 'listFilesRecursive', 'listCodeDefinitionNames', 'searchFiles'
|
|
|
- ]
|
|
|
-
|
|
|
- fileListingTools.forEach(tool => {
|
|
|
- jest.clearAllMocks()
|
|
|
- mockState.clineMessages = [
|
|
|
- {
|
|
|
- type: 'ask',
|
|
|
- ask: 'tool',
|
|
|
- text: JSON.stringify({ tool }),
|
|
|
- ts: Date.now(),
|
|
|
- }
|
|
|
- ]
|
|
|
- renderChatView()
|
|
|
-
|
|
|
- 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>
|
|
|
+ )
|
|
|
|
|
|
- it('should auto-approve write tool actions when alwaysAllowWrite is true', () => {
|
|
|
- mockState.clineMessages = [
|
|
|
- {
|
|
|
- type: 'ask',
|
|
|
- ask: 'tool',
|
|
|
- text: JSON.stringify({ tool: 'editedExistingFile' }),
|
|
|
- ts: Date.now(),
|
|
|
- }
|
|
|
- ]
|
|
|
- renderChatView()
|
|
|
-
|
|
|
- expect(vscode.postMessage).toHaveBeenCalledWith({
|
|
|
- type: 'askResponse',
|
|
|
- askResponse: 'yesButtonClicked'
|
|
|
- })
|
|
|
- })
|
|
|
+ // First hydrate state with initial task
|
|
|
+ mockPostMessage({
|
|
|
+ alwaysAllowReadOnly: true,
|
|
|
+ clineMessages: [
|
|
|
+ {
|
|
|
+ type: 'say',
|
|
|
+ say: 'task',
|
|
|
+ ts: Date.now() - 2000,
|
|
|
+ text: 'Initial task'
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ })
|
|
|
|
|
|
- it('should auto-approve allowed execute commands when alwaysAllowExecute is true', () => {
|
|
|
- mockState.clineMessages = [
|
|
|
- {
|
|
|
- type: 'ask',
|
|
|
- ask: 'command',
|
|
|
- text: 'npm install',
|
|
|
- ts: Date.now(),
|
|
|
- }
|
|
|
- ]
|
|
|
- renderChatView()
|
|
|
-
|
|
|
- expect(vscode.postMessage).toHaveBeenCalledWith({
|
|
|
- type: 'askResponse',
|
|
|
- askResponse: 'yesButtonClicked'
|
|
|
- })
|
|
|
- })
|
|
|
+ // 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
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ })
|
|
|
|
|
|
- it('should not auto-approve disallowed execute commands even when alwaysAllowExecute is true', () => {
|
|
|
- mockState.clineMessages = [
|
|
|
- {
|
|
|
- type: 'ask',
|
|
|
- ask: 'command',
|
|
|
- text: 'rm -rf /',
|
|
|
- ts: Date.now(),
|
|
|
- }
|
|
|
- ]
|
|
|
- renderChatView()
|
|
|
-
|
|
|
- expect(vscode.postMessage).not.toHaveBeenCalled()
|
|
|
- })
|
|
|
+ // Wait for the auto-approval message
|
|
|
+ await waitFor(() => {
|
|
|
+ expect(vscode.postMessage).toHaveBeenCalledWith({
|
|
|
+ type: 'askResponse',
|
|
|
+ askResponse: 'yesButtonClicked'
|
|
|
+ })
|
|
|
+ })
|
|
|
+ })
|
|
|
|
|
|
- it('should not auto-approve commands with chaining characters when alwaysAllowExecute is true', () => {
|
|
|
- mockState.clineMessages = [
|
|
|
- {
|
|
|
- type: 'ask',
|
|
|
- ask: 'command',
|
|
|
- text: 'npm install && rm -rf /',
|
|
|
- ts: Date.now(),
|
|
|
- }
|
|
|
- ]
|
|
|
- renderChatView()
|
|
|
-
|
|
|
- expect(vscode.postMessage).not.toHaveBeenCalled()
|
|
|
+ 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'
|
|
|
+ }
|
|
|
+ ]
|
|
|
})
|
|
|
|
|
|
- it('should auto-approve browser actions when alwaysAllowBrowser is true', () => {
|
|
|
- mockState.clineMessages = [
|
|
|
- {
|
|
|
- type: 'ask',
|
|
|
- ask: 'browser_action_launch',
|
|
|
- ts: Date.now(),
|
|
|
- }
|
|
|
- ]
|
|
|
- renderChatView()
|
|
|
-
|
|
|
- expect(vscode.postMessage).toHaveBeenCalledWith({
|
|
|
- type: 'askResponse',
|
|
|
- askResponse: 'yesButtonClicked'
|
|
|
- })
|
|
|
+ // 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
|
|
|
+ }
|
|
|
+ ]
|
|
|
})
|
|
|
|
|
|
- it('should not auto-approve when corresponding alwaysAllow flag is false', () => {
|
|
|
- mockState.alwaysAllowReadOnly = false
|
|
|
- mockState.clineMessages = [
|
|
|
- {
|
|
|
- type: 'ask',
|
|
|
- ask: 'tool',
|
|
|
- text: JSON.stringify({ tool: 'readFile' }),
|
|
|
- ts: Date.now(),
|
|
|
- }
|
|
|
- ]
|
|
|
- renderChatView()
|
|
|
-
|
|
|
- expect(vscode.postMessage).not.toHaveBeenCalled()
|
|
|
+ // Wait for the auto-approval message
|
|
|
+ await waitFor(() => {
|
|
|
+ expect(vscode.postMessage).toHaveBeenCalledWith({
|
|
|
+ type: 'askResponse',
|
|
|
+ askResponse: 'yesButtonClicked'
|
|
|
+ })
|
|
|
})
|
|
|
+ }
|
|
|
})
|
|
|
|
|
|
- describe('Streaming State', () => {
|
|
|
- it('should show cancel button while streaming and trigger cancel on click', async () => {
|
|
|
- mockState.clineMessages = [
|
|
|
- {
|
|
|
- type: 'say',
|
|
|
- say: 'task',
|
|
|
- ts: Date.now(),
|
|
|
- },
|
|
|
- {
|
|
|
- type: 'say',
|
|
|
- say: 'text',
|
|
|
- partial: true,
|
|
|
- ts: Date.now(),
|
|
|
- }
|
|
|
- ]
|
|
|
- renderChatView()
|
|
|
-
|
|
|
- const cancelButton = screen.getByText('Cancel')
|
|
|
- await userEvent.click(cancelButton)
|
|
|
-
|
|
|
- expect(vscode.postMessage).toHaveBeenCalledWith({
|
|
|
- type: 'cancelTask'
|
|
|
- })
|
|
|
+ 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'
|
|
|
+ }
|
|
|
+ ]
|
|
|
})
|
|
|
|
|
|
- it('should show terminate button when task is paused and trigger terminate on click', async () => {
|
|
|
- mockState.clineMessages = [
|
|
|
- {
|
|
|
- type: 'ask',
|
|
|
- ask: 'resume_task',
|
|
|
- ts: Date.now(),
|
|
|
- }
|
|
|
- ]
|
|
|
- renderChatView()
|
|
|
-
|
|
|
- const terminateButton = screen.getByText('Terminate')
|
|
|
- await userEvent.click(terminateButton)
|
|
|
-
|
|
|
- expect(vscode.postMessage).toHaveBeenCalledWith({
|
|
|
- type: 'clearTask'
|
|
|
- })
|
|
|
+ // 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
|
|
|
+ }
|
|
|
+ ]
|
|
|
})
|
|
|
|
|
|
- it('should show retry button when API error occurs and trigger retry on click', async () => {
|
|
|
- mockState.clineMessages = [
|
|
|
- {
|
|
|
- type: 'ask',
|
|
|
- ask: 'api_req_failed',
|
|
|
- ts: Date.now(),
|
|
|
- }
|
|
|
- ]
|
|
|
- renderChatView()
|
|
|
-
|
|
|
- const retryButton = screen.getByText('Retry')
|
|
|
- await userEvent.click(retryButton)
|
|
|
-
|
|
|
- expect(vscode.postMessage).toHaveBeenCalledWith({
|
|
|
- type: 'askResponse',
|
|
|
- askResponse: 'yesButtonClicked'
|
|
|
- })
|
|
|
+ // Verify no auto-approval message was sent for chained commands with disallowed parts
|
|
|
+ expect(vscode.postMessage).not.toHaveBeenCalledWith({
|
|
|
+ type: 'askResponse',
|
|
|
+ askResponse: 'yesButtonClicked'
|
|
|
})
|
|
|
+ })
|
|
|
})
|
|
|
+ })
|
|
|
})
|