Merge branch 'main' into jq/sound-setting-improvements

This commit is contained in:
Justin Quan
2024-12-16 00:08:21 -08:00
12 changed files with 508 additions and 418 deletions

View File

@@ -42,4 +42,4 @@ jobs:
run: npm run install:all
- name: Run unit tests
run: npx jest
run: npm test

View File

@@ -1,21 +1,9 @@
# Roo Cline Changelog
## [2.2.9]
- Fix a bug where Gemini was including line numbers in the search/replace content
## [2.2.8]
- More work on diff editing (better matching, indentation, logging)
## [2.2.7]
## [2.2.6 - 2.2.10]
- More fixes to search/replace diffs
## [2.2.6]
- Add a fuzzy match tolerance when applying diffs
## [2.2.5]
- Allow MCP servers to be enabled/disabled

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "roo-cline",
"version": "2.2.9",
"version": "2.2.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "roo-cline",
"version": "2.2.9",
"version": "2.2.10",
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.10.2",
"@anthropic-ai/sdk": "^0.26.0",

View File

@@ -3,7 +3,7 @@
"displayName": "Roo Cline",
"description": "A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.",
"publisher": "RooVeterinaryInc",
"version": "2.2.9",
"version": "2.2.10",
"icon": "assets/icons/rocket.png",
"galleryBanner": {
"color": "#617A91",
@@ -157,7 +157,7 @@
"package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production",
"pretest": "npm run compile-tests && npm run compile && npm run lint",
"start:webview": "cd webview-ui && npm run start",
"test": "jest",
"test": "jest && npm run test:webview",
"test:webview": "cd webview-ui && npm run test",
"prepare": "husky",
"publish:marketplace": "vsce publish",

View File

@@ -564,6 +564,153 @@ this.init();
});
})
describe('line number stripping', () => {
describe('line number stripping', () => {
let strategy: SearchReplaceDiffStrategy
beforeEach(() => {
strategy = new SearchReplaceDiffStrategy()
})
it('should strip line numbers from both search and replace sections', () => {
const originalContent = 'function test() {\n return true;\n}\n'
const diffContent = `test.ts
<<<<<<< SEARCH
1 | function test() {
2 | return true;
3 | }
=======
1 | function test() {
2 | return false;
3 | }
>>>>>>> REPLACE`
const result = strategy.applyDiff(originalContent, diffContent)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toBe('function test() {\n return false;\n}\n')
}
})
it('should not strip when not all lines have numbers in either section', () => {
const originalContent = 'function test() {\n return true;\n}\n'
const diffContent = `test.ts
<<<<<<< SEARCH
1 | function test() {
2 | return true;
3 | }
=======
1 | function test() {
return false;
3 | }
>>>>>>> REPLACE`
const result = strategy.applyDiff(originalContent, diffContent)
expect(result.success).toBe(false)
})
it('should preserve content that naturally starts with pipe', () => {
const originalContent = '|header|another|\n|---|---|\n|data|more|\n'
const diffContent = `test.ts
<<<<<<< SEARCH
1 | |header|another|
2 | |---|---|
3 | |data|more|
=======
1 | |header|another|
2 | |---|---|
3 | |data|updated|
>>>>>>> REPLACE`
const result = strategy.applyDiff(originalContent, diffContent)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toBe('|header|another|\n|---|---|\n|data|updated|\n')
}
})
it('should preserve indentation when stripping line numbers', () => {
const originalContent = ' function test() {\n return true;\n }\n'
const diffContent = `test.ts
<<<<<<< SEARCH
1 | function test() {
2 | return true;
3 | }
=======
1 | function test() {
2 | return false;
3 | }
>>>>>>> REPLACE`
const result = strategy.applyDiff(originalContent, diffContent)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toBe(' function test() {\n return false;\n }\n')
}
})
it('should handle different line numbers between sections', () => {
const originalContent = 'function test() {\n return true;\n}\n'
const diffContent = `test.ts
<<<<<<< SEARCH
10 | function test() {
11 | return true;
12 | }
=======
20 | function test() {
21 | return false;
22 | }
>>>>>>> REPLACE`
const result = strategy.applyDiff(originalContent, diffContent)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toBe('function test() {\n return false;\n}\n')
}
})
it('should not strip content that starts with pipe but no line number', () => {
const originalContent = '| Pipe\n|---|\n| Data\n'
const diffContent = `test.ts
<<<<<<< SEARCH
| Pipe
|---|
| Data
=======
| Pipe
|---|
| Updated
>>>>>>> REPLACE`
const result = strategy.applyDiff(originalContent, diffContent)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toBe('| Pipe\n|---|\n| Updated\n')
}
})
it('should handle mix of line-numbered and pipe-only content', () => {
const originalContent = '| Pipe\n|---|\n| Data\n'
const diffContent = `test.ts
<<<<<<< SEARCH
| Pipe
|---|
| Data
=======
1 | | Pipe
2 | |---|
3 | | NewData
>>>>>>> REPLACE`
const result = strategy.applyDiff(originalContent, diffContent)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toBe('1 | | Pipe\n2 | |---|\n3 | | NewData\n')
}
})
})
});
describe('fuzzy matching', () => {
let strategy: SearchReplaceDiffStrategy

View File

@@ -62,7 +62,6 @@ The tool will maintain proper indentation and formatting while making changes.
Only a single operation is allowed per tool use.
The SEARCH section must exactly match existing content including whitespace and indentation.
If you're not confident in the exact content to search for, use the read_file tool first to get the exact content.
IMPORTANT: The read_file tool returns the file content with line numbers prepended to each line. However, DO NOT include line numbers in the SEARCH and REPLACE sections of the diff content.
Parameters:
- path: (required) The path of the file to modify (relative to the current working directory ${cwd})
@@ -132,11 +131,26 @@ Your search/replace content here
};
}
const [_, searchContent, replaceContent] = match;
let [_, searchContent, replaceContent] = match;
// Detect line ending from original content
const lineEnding = originalContent.includes('\r\n') ? '\r\n' : '\n';
// Strip line numbers from search and replace content if every line starts with a line number
const hasLineNumbers = (content: string) => {
const lines = content.split(/\r?\n/);
return lines.length > 0 && lines.every(line => /^\d+\s+\|(?!\|)/.test(line));
};
if (hasLineNumbers(searchContent) && hasLineNumbers(replaceContent)) {
const stripLineNumbers = (content: string) => {
return content.replace(/^\d+\s+\|(?!\|)/gm, '')
};
searchContent = stripLineNumbers(searchContent);
replaceContent = stripLineNumbers(replaceContent);
}
// Split content into lines, handling both \n and \r\n
const searchLines = searchContent.split(/\r?\n/);
const replaceLines = replaceContent.split(/\r?\n/);

View File

@@ -30,7 +30,7 @@
"scripts": {
"start": "react-scripts start",
"build": "node ./scripts/build-react-no-split.js",
"test": "react-scripts test",
"test": "react-scripts test --watchAll=false",
"eject": "react-scripts eject"
},
"eslintConfig": {
@@ -57,7 +57,7 @@
},
"jest": {
"transformIgnorePatterns": [
"/node_modules/(?!(rehype-highlight|react-remark|unist-util-visit|vfile|unified|bail|is-plain-obj|trough|vfile-message|unist-util-stringify-position|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|character-entities|markdown-table|zwitch|longest-streak|escape-string-regexp|unist-util-is|hast-util-to-text)/)"
"/node_modules/(?!(rehype-highlight|react-remark|unist-util-visit|vfile|unified|bail|is-plain-obj|trough|vfile-message|unist-util-stringify-position|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|character-entities|markdown-table|zwitch|longest-streak|escape-string-regexp|unist-util-is|hast-util-to-text|@vscode/webview-ui-toolkit|@microsoft/fast-react-wrapper|@microsoft/fast-element|@microsoft/fast-foundation|@microsoft/fast-web-utilities|exenv-es6)/)"
],
"moduleNameMapper": {
"\\.(css|less|scss|sass)$": "identity-obj-proxy"

View File

@@ -2,7 +2,7 @@ import { VSCodeButton, VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@v
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { Virtuoso } from "react-virtuoso"
import { memo, useMemo, useState, useEffect } from "react"
import React, { memo, useMemo, useState, useEffect } from "react"
import Fuse, { FuseResult } from "fuse.js"
import { formatLargeNumber } from "../../utils/format"
@@ -82,30 +82,28 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
const taskHistorySearchResults = useMemo(() => {
let results = searchQuery ? highlight(fuse.search(searchQuery)) : presentableTasks
results.sort((a, b) => {
// First apply search if needed
const searchResults = searchQuery ? results : presentableTasks;
// Then sort the results
return [...searchResults].sort((a, b) => {
switch (sortOption) {
case "oldest":
return a.ts - b.ts
return (a.ts || 0) - (b.ts || 0);
case "mostExpensive":
return (b.totalCost || 0) - (a.totalCost || 0)
return (b.totalCost || 0) - (a.totalCost || 0);
case "mostTokens":
return (
(b.tokensIn || 0) +
(b.tokensOut || 0) +
(b.cacheWrites || 0) +
(b.cacheReads || 0) -
((a.tokensIn || 0) + (a.tokensOut || 0) + (a.cacheWrites || 0) + (a.cacheReads || 0))
)
const aTokens = (a.tokensIn || 0) + (a.tokensOut || 0) + (a.cacheWrites || 0) + (a.cacheReads || 0);
const bTokens = (b.tokensIn || 0) + (b.tokensOut || 0) + (b.cacheWrites || 0) + (b.cacheReads || 0);
return bTokens - aTokens;
case "mostRelevant":
// NOTE: you must never sort directly on object since it will cause members to be reordered
return searchQuery ? 0 : b.ts - a.ts // Keep fuse order if searching, otherwise sort by newest
// Keep fuse order if searching, otherwise sort by newest
return searchQuery ? 0 : (b.ts || 0) - (a.ts || 0);
case "newest":
default:
return b.ts - a.ts
return (b.ts || 0) - (a.ts || 0);
}
})
return results
});
}, [presentableTasks, searchQuery, fuse, sortOption])
return (
@@ -227,9 +225,16 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
overflowY: "scroll",
}}
data={taskHistorySearchResults}
data-testid="virtuoso-container"
components={{
List: React.forwardRef((props, ref) => (
<div {...props} ref={ref} data-testid="virtuoso-item-list" />
))
}}
itemContent={(index, item) => (
<div
key={item.id}
data-testid={`task-item-${item.id}`}
className="history-item"
style={{
cursor: "pointer",
@@ -263,23 +268,23 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
{formatDate(item.ts)}
</span>
<div style={{ display: "flex", gap: "4px" }}>
<VSCodeButton
appearance="icon"
<button
title="Copy Prompt"
className="copy-button"
data-appearance="icon"
onClick={(e) => handleCopyTask(e, item.task)}>
<span className="codicon codicon-copy"></span>
</VSCodeButton>
<VSCodeButton
appearance="icon"
</button>
<button
title="Delete Task"
className="delete-button"
data-appearance="icon"
onClick={(e) => {
e.stopPropagation()
handleDeleteHistoryItem(item.id)
}}
className="delete-button">
}}>
<span className="codicon codicon-trash"></span>
</VSCodeButton>
</button>
</div>
</div>
<div
@@ -298,6 +303,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
/>
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<div
data-testid="tokens-container"
style={{
display: "flex",
justifyContent: "space-between",
@@ -318,6 +324,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
Tokens:
</span>
<span
data-testid="tokens-in"
style={{
display: "flex",
alignItems: "center",
@@ -335,6 +342,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
{formatLargeNumber(item.tokensIn || 0)}
</span>
<span
data-testid="tokens-out"
style={{
display: "flex",
alignItems: "center",
@@ -357,6 +365,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
{!!item.cacheWrites && (
<div
data-testid="cache-container"
style={{
display: "flex",
alignItems: "center",
@@ -371,6 +380,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
Cache:
</span>
<span
data-testid="cache-writes"
style={{
display: "flex",
alignItems: "center",
@@ -388,6 +398,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
+{formatLargeNumber(item.cacheWrites || 0)}
</span>
<span
data-testid="cache-reads"
style={{
display: "flex",
alignItems: "center",
@@ -503,27 +514,44 @@ export const highlight = (
// Sort and merge overlapping regions
const mergedRegions = mergeRegions(regions)
let content = ""
let nextUnhighlightedRegionStartingIndex = 0
// Convert regions to a list of parts with their highlight status
const parts: { text: string; highlight: boolean }[] = []
let lastIndex = 0
mergedRegions.forEach((region) => {
const start = region[0]
const end = region[1]
const lastRegionNextIndex = end + 1
mergedRegions.forEach(([start, end]) => {
// Add non-highlighted text before this region
if (start > lastIndex) {
parts.push({
text: inputText.substring(lastIndex, start),
highlight: false
})
}
content += [
inputText.substring(nextUnhighlightedRegionStartingIndex, start),
`<span class="${highlightClassName}">`,
inputText.substring(start, lastRegionNextIndex),
"</span>",
].join("")
nextUnhighlightedRegionStartingIndex = lastRegionNextIndex
// Add highlighted text
parts.push({
text: inputText.substring(start, end + 1),
highlight: true
})
content += inputText.substring(nextUnhighlightedRegionStartingIndex)
lastIndex = end + 1
})
return content
// Add any remaining text
if (lastIndex < inputText.length) {
parts.push({
text: inputText.substring(lastIndex),
highlight: false
})
}
// Build final string
return parts
.map(part =>
part.highlight
? `<span class="${highlightClassName}">${part.text}</span>`
: part.text
)
.join('')
}
return fuseSearchResult

View File

@@ -1,362 +1,232 @@
import React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import HistoryView from '../HistoryView'
import { ExtensionStateContextProvider } from '../../../context/ExtensionStateContext'
import { useExtensionState } from '../../../context/ExtensionStateContext'
import { vscode } from '../../../utils/vscode'
import { highlight } from '../HistoryView'
import { FuseResult } from 'fuse.js'
// Mock vscode API
jest.mock('../../../utils/vscode', () => ({
vscode: {
postMessage: jest.fn(),
},
}))
interface VSCodeButtonProps {
children: React.ReactNode;
onClick?: (e: any) => void;
appearance?: string;
className?: string;
}
interface VSCodeTextFieldProps {
value?: string;
onInput?: (e: { target: { value: string } }) => void;
placeholder?: string;
style?: React.CSSProperties;
}
interface VSCodeRadioGroupProps {
children?: React.ReactNode;
value?: string;
onChange?: (e: { target: { value: string } }) => void;
style?: React.CSSProperties;
}
interface VSCodeRadioProps {
value: string;
children: React.ReactNode;
disabled?: boolean;
style?: React.CSSProperties;
}
// Mock VSCode components
jest.mock('@vscode/webview-ui-toolkit/react', () => ({
VSCodeButton: function MockVSCodeButton({
children,
onClick,
appearance,
className
}: VSCodeButtonProps) {
return (
<button
onClick={onClick}
data-appearance={appearance}
className={className}
>
{children}
</button>
)
},
VSCodeTextField: function MockVSCodeTextField({
value,
onInput,
placeholder,
style
}: VSCodeTextFieldProps) {
return (
<input
type="text"
value={value}
onChange={(e) => onInput?.({ target: { value: e.target.value } })}
placeholder={placeholder}
style={style}
/>
)
},
VSCodeRadioGroup: function MockVSCodeRadioGroup({
children,
value,
onChange,
style
}: VSCodeRadioGroupProps) {
return (
<div style={style} role="radiogroup" data-current-value={value}>
{children}
// Mock dependencies
jest.mock('../../../context/ExtensionStateContext')
jest.mock('../../../utils/vscode')
jest.mock('react-virtuoso', () => ({
Virtuoso: ({ data, itemContent }: any) => (
<div data-testid="virtuoso-container">
{data.map((item: any, index: number) => (
<div key={item.id} data-testid={`virtuoso-item-${item.id}`}>
{itemContent(index, item)}
</div>
)
},
VSCodeRadio: function MockVSCodeRadio({
value,
children,
disabled,
style
}: VSCodeRadioProps) {
return (
<label style={style}>
<input
type="radio"
value={value}
disabled={disabled}
data-testid={`radio-${value}`}
/>
{children}
</label>
)
}
))}
</div>
),
}))
// Mock window.navigator.clipboard
Object.assign(navigator, {
clipboard: {
writeText: jest.fn(),
},
})
// Mock window.postMessage to trigger state hydration
const mockPostMessage = (state: any) => {
window.postMessage({
type: 'state',
state: {
version: '1.0.0',
taskHistory: [],
...state
}
}, '*')
}
describe('HistoryView', () => {
const mockOnDone = jest.fn()
const sampleHistory = [
const mockTaskHistory = [
{
id: '1',
task: 'First task',
ts: Date.now() - 3000,
task: 'Test task 1',
ts: new Date('2022-02-16T00:00:00').getTime(),
tokensIn: 100,
tokensOut: 50,
totalCost: 0.002
totalCost: 0.002,
},
{
id: '2',
task: 'Second task',
ts: Date.now() - 2000,
task: 'Test task 2',
ts: new Date('2022-02-17T00:00:00').getTime(),
tokensIn: 200,
tokensOut: 100,
totalCost: 0.004
cacheWrites: 50,
cacheReads: 25,
},
{
id: '3',
task: 'Third task',
ts: Date.now() - 1000,
tokensIn: 300,
tokensOut: 150,
totalCost: 0.006
}
]
describe('HistoryView', () => {
beforeEach(() => {
// Reset all mocks before each test
jest.clearAllMocks()
jest.useFakeTimers()
// Mock useExtensionState implementation
;(useExtensionState as jest.Mock).mockReturnValue({
taskHistory: mockTaskHistory,
})
})
it('renders history items in correct order', () => {
render(
<ExtensionStateContextProvider>
<HistoryView onDone={mockOnDone} />
</ExtensionStateContextProvider>
)
mockPostMessage({ taskHistory: sampleHistory })
const historyItems = screen.getAllByText(/task/i)
expect(historyItems).toHaveLength(3)
expect(historyItems[0]).toHaveTextContent('Third task')
expect(historyItems[1]).toHaveTextContent('Second task')
expect(historyItems[2]).toHaveTextContent('First task')
afterEach(() => {
jest.useRealTimers()
})
it('handles sorting by different criteria', async () => {
render(
<ExtensionStateContextProvider>
<HistoryView onDone={mockOnDone} />
</ExtensionStateContextProvider>
)
it('renders history items correctly', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
mockPostMessage({ taskHistory: sampleHistory })
// Check if both tasks are rendered
expect(screen.getByTestId('virtuoso-item-1')).toBeInTheDocument()
expect(screen.getByTestId('virtuoso-item-2')).toBeInTheDocument()
expect(screen.getByText('Test task 1')).toBeInTheDocument()
expect(screen.getByText('Test task 2')).toBeInTheDocument()
})
// Test oldest sort
const oldestRadio = screen.getByTestId('radio-oldest')
it('handles search functionality', async () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Get search input and radio group
const searchInput = screen.getByPlaceholderText('Fuzzy search history...')
const radioGroup = screen.getByRole('radiogroup')
// Type in search
await userEvent.type(searchInput, 'task 1')
// Check if sort option automatically changes to "Most Relevant"
const mostRelevantRadio = within(radioGroup).getByLabelText('Most Relevant')
expect(mostRelevantRadio).not.toBeDisabled()
// Click and wait for radio update
fireEvent.click(mostRelevantRadio)
// Wait for radio button to be checked
const updatedRadio = await within(radioGroup).findByRole('radio', { name: 'Most Relevant', checked: true })
expect(updatedRadio).toBeInTheDocument()
})
it('handles sort options correctly', async () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
const radioGroup = screen.getByRole('radiogroup')
// Test changing sort options
const oldestRadio = within(radioGroup).getByLabelText('Oldest')
fireEvent.click(oldestRadio)
let historyItems = screen.getAllByText(/task/i)
expect(historyItems[0]).toHaveTextContent('First task')
expect(historyItems[2]).toHaveTextContent('Third task')
// Wait for oldest radio to be checked
const checkedOldestRadio = await within(radioGroup).findByRole('radio', { name: 'Oldest', checked: true })
expect(checkedOldestRadio).toBeInTheDocument()
// Test most expensive sort
const expensiveRadio = screen.getByTestId('radio-mostExpensive')
fireEvent.click(expensiveRadio)
const mostExpensiveRadio = within(radioGroup).getByLabelText('Most Expensive')
fireEvent.click(mostExpensiveRadio)
historyItems = screen.getAllByText(/task/i)
expect(historyItems[0]).toHaveTextContent('Third task')
expect(historyItems[2]).toHaveTextContent('First task')
// Test most tokens sort
const tokensRadio = screen.getByTestId('radio-mostTokens')
fireEvent.click(tokensRadio)
historyItems = screen.getAllByText(/task/i)
expect(historyItems[0]).toHaveTextContent('Third task')
expect(historyItems[2]).toHaveTextContent('First task')
// Wait for most expensive radio to be checked
const checkedExpensiveRadio = await within(radioGroup).findByRole('radio', { name: 'Most Expensive', checked: true })
expect(checkedExpensiveRadio).toBeInTheDocument()
})
it('handles search functionality and auto-switches to most relevant sort', async () => {
render(
<ExtensionStateContextProvider>
<HistoryView onDone={mockOnDone} />
</ExtensionStateContextProvider>
)
it('handles task selection', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
mockPostMessage({ taskHistory: sampleHistory })
// Click on first task
fireEvent.click(screen.getByText('Test task 1'))
const searchInput = screen.getByPlaceholderText('Fuzzy search history...')
fireEvent.change(searchInput, { target: { value: 'First' } })
const historyItems = screen.getAllByText(/task/i)
expect(historyItems).toHaveLength(1)
expect(historyItems[0]).toHaveTextContent('First task')
// Verify sort switched to Most Relevant
const radioGroup = screen.getByRole('radiogroup')
expect(radioGroup.getAttribute('data-current-value')).toBe('mostRelevant')
// Clear search and verify sort reverts
fireEvent.change(searchInput, { target: { value: '' } })
expect(radioGroup.getAttribute('data-current-value')).toBe('newest')
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'showTaskWithId',
text: '1',
})
})
it('handles copy functionality and shows/hides modal', async () => {
render(
<ExtensionStateContextProvider>
<HistoryView onDone={mockOnDone} />
</ExtensionStateContextProvider>
)
it('handles task deletion', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
mockPostMessage({ taskHistory: sampleHistory })
// Find and hover over first task
const taskContainer = screen.getByTestId('virtuoso-item-1')
fireEvent.mouseEnter(taskContainer)
const copyButtons = screen.getAllByRole('button', { hidden: true })
.filter(button => button.className.includes('copy-button'))
fireEvent.click(copyButtons[0])
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Third task')
// Verify modal appears
await waitFor(() => {
expect(screen.getByText('Prompt Copied to Clipboard')).toBeInTheDocument()
})
// Verify modal disappears
await waitFor(() => {
expect(screen.queryByText('Prompt Copied to Clipboard')).not.toBeInTheDocument()
}, { timeout: 2500 })
})
it('handles delete functionality', () => {
render(
<ExtensionStateContextProvider>
<HistoryView onDone={mockOnDone} />
</ExtensionStateContextProvider>
)
mockPostMessage({ taskHistory: sampleHistory })
const deleteButtons = screen.getAllByRole('button', { hidden: true })
.filter(button => button.className.includes('delete-button'))
fireEvent.click(deleteButtons[0])
const deleteButton = within(taskContainer).getByTitle('Delete Task')
fireEvent.click(deleteButton)
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'deleteTaskWithId',
text: '3'
text: '1',
})
})
it('handles task copying', async () => {
const mockClipboard = {
writeText: jest.fn().mockResolvedValue(undefined),
}
Object.assign(navigator, { clipboard: mockClipboard })
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find and hover over first task
const taskContainer = screen.getByTestId('virtuoso-item-1')
fireEvent.mouseEnter(taskContainer)
const copyButton = within(taskContainer).getByTitle('Copy Prompt')
await userEvent.click(copyButton)
// Verify clipboard API was called
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Test task 1')
// Wait for copy modal to appear
const copyModal = await screen.findByText('Prompt Copied to Clipboard')
expect(copyModal).toBeInTheDocument()
// Fast-forward timers and wait for modal to disappear
jest.advanceTimersByTime(2000)
await waitFor(() => {
expect(screen.queryByText('Prompt Copied to Clipboard')).not.toBeInTheDocument()
})
})
it('formats dates correctly', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find first task container and check date format
const taskContainer = screen.getByTestId('virtuoso-item-1')
const dateElement = within(taskContainer).getByText((content) => {
return content.includes('FEBRUARY 16') && content.includes('12:00 AM')
})
expect(dateElement).toBeInTheDocument()
})
it('displays token counts correctly', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find first task container
const taskContainer = screen.getByTestId('virtuoso-item-1')
// Find token counts within the task container
const tokensContainer = within(taskContainer).getByTestId('tokens-container')
expect(within(tokensContainer).getByTestId('tokens-in')).toHaveTextContent('100')
expect(within(tokensContainer).getByTestId('tokens-out')).toHaveTextContent('50')
})
it('displays cache information when available', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find second task container
const taskContainer = screen.getByTestId('virtuoso-item-2')
// Find cache info within the task container
const cacheContainer = within(taskContainer).getByTestId('cache-container')
expect(within(cacheContainer).getByTestId('cache-writes')).toHaveTextContent('+50')
expect(within(cacheContainer).getByTestId('cache-reads')).toHaveTextContent('25')
})
it('handles export functionality', () => {
render(
<ExtensionStateContextProvider>
<HistoryView onDone={mockOnDone} />
</ExtensionStateContextProvider>
)
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
mockPostMessage({ taskHistory: sampleHistory })
// Find and hover over second task
const taskContainer = screen.getByTestId('virtuoso-item-2')
fireEvent.mouseEnter(taskContainer)
const exportButtons = screen.getAllByRole('button', { hidden: true })
.filter(button => button.className.includes('export-button'))
fireEvent.click(exportButtons[0])
const exportButton = within(taskContainer).getByText('EXPORT')
fireEvent.click(exportButton)
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'exportTaskWithId',
text: '3'
})
})
it('calls onDone when Done button is clicked', () => {
render(
<ExtensionStateContextProvider>
<HistoryView onDone={mockOnDone} />
</ExtensionStateContextProvider>
)
const doneButton = screen.getByText('Done')
fireEvent.click(doneButton)
expect(mockOnDone).toHaveBeenCalled()
})
describe('highlight function', () => {
it('correctly highlights search matches', () => {
const testData = [{
item: { text: 'Hello world' },
matches: [{ key: 'text', value: 'Hello world', indices: [[0, 4]] }],
refIndex: 0
}] as FuseResult<any>[]
const result = highlight(testData)
expect(result[0].text).toBe('<span class="history-item-highlight">Hello</span> world')
})
it('handles multiple matches', () => {
const testData = [{
item: { text: 'Hello world Hello' },
matches: [{
key: 'text',
value: 'Hello world Hello',
indices: [[0, 4], [11, 15]]
}],
refIndex: 0
}] as FuseResult<any>[]
const result = highlight(testData)
expect(result[0].text).toBe(
'<span class="history-item-highlight">Hello</span> world ' +
'<span class="history-item-highlight">Hello</span>'
)
})
it('handles overlapping matches', () => {
const testData = [{
item: { text: 'Hello' },
matches: [{
key: 'text',
value: 'Hello',
indices: [[0, 2], [1, 4]]
}],
refIndex: 0
}] as FuseResult<any>[]
const result = highlight(testData)
expect(result[0].text).toBe('<span class="history-item-highlight">Hello</span>')
text: '2',
})
})
})

View File

@@ -9,6 +9,30 @@ jest.mock('../../../utils/vscode', () => ({
}
}))
jest.mock('@vscode/webview-ui-toolkit/react', () => ({
VSCodeCheckbox: function MockVSCodeCheckbox({
children,
checked,
onChange
}: {
children?: React.ReactNode;
checked?: boolean;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
return (
<label>
<input
type="checkbox"
role="checkbox"
checked={checked}
onChange={onChange}
/>
{children}
</label>
)
}
}))
describe('McpToolRow', () => {
const mockTool = {
name: 'test-tool',
@@ -33,14 +57,14 @@ describe('McpToolRow', () => {
expect(screen.queryByText('Always allow')).not.toBeInTheDocument()
})
it('shows always allow checkbox when serverName is provided', () => {
render(<McpToolRow tool={mockTool} serverName="test-server" />)
it('shows always allow checkbox when serverName and alwaysAllowMcp are provided', () => {
render(<McpToolRow tool={mockTool} serverName="test-server" alwaysAllowMcp={true} />)
expect(screen.getByText('Always allow')).toBeInTheDocument()
})
it('sends message to toggle always allow when checkbox is clicked', () => {
render(<McpToolRow tool={mockTool} serverName="test-server" />)
render(<McpToolRow tool={mockTool} serverName="test-server" alwaysAllowMcp={true} />)
const checkbox = screen.getByRole('checkbox')
fireEvent.click(checkbox)
@@ -59,22 +83,24 @@ describe('McpToolRow', () => {
alwaysAllow: true
}
render(<McpToolRow tool={alwaysAllowedTool} serverName="test-server" />)
render(<McpToolRow tool={alwaysAllowedTool} serverName="test-server" alwaysAllowMcp={true} />)
const checkbox = screen.getByRole('checkbox')
expect(checkbox).toBeChecked()
const checkbox = screen.getByRole('checkbox') as HTMLInputElement
expect(checkbox.checked).toBe(true)
})
it('prevents event propagation when clicking the checkbox', () => {
const mockStopPropagation = jest.fn()
render(<McpToolRow tool={mockTool} serverName="test-server" />)
const mockOnClick = jest.fn()
render(
<div onClick={mockOnClick}>
<McpToolRow tool={mockTool} serverName="test-server" alwaysAllowMcp={true} />
</div>
)
const container = screen.getByTestId('tool-row-container')
fireEvent.click(container, {
stopPropagation: mockStopPropagation
})
fireEvent.click(container)
expect(mockStopPropagation).toHaveBeenCalled()
expect(mockOnClick).not.toHaveBeenCalled()
})
it('displays input schema parameters when provided', () => {

View File

@@ -248,6 +248,12 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<VSCodeTextField
value={commandInput}
onInput={(e: any) => setCommandInput(e.target.value)}
onKeyDown={(e: any) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddCommand()
}
}}
placeholder="Enter command prefix (e.g., 'git ')"
style={{ flexGrow: 1 }}
/>

View File

@@ -1,5 +1,16 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom"
import '@testing-library/jest-dom';
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // Deprecated
removeListener: jest.fn(), // Deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});