mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-21 04:41:16 -05:00
Merge branch 'RooVetGit:main' into main
This commit is contained in:
2
.github/workflows/code-qa.yml
vendored
2
.github/workflows/code-qa.yml
vendored
@@ -42,4 +42,4 @@ jobs:
|
|||||||
run: npm run install:all
|
run: npm run install:all
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: npx jest
|
run: npm test
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
# Roo Cline Changelog
|
# Roo Cline Changelog
|
||||||
|
|
||||||
## [2.2.7]
|
## [2.2.6 - 2.2.10]
|
||||||
|
|
||||||
- More fixes to search/replace diffs
|
- More fixes to search/replace diffs
|
||||||
|
|
||||||
## [2.2.6]
|
|
||||||
|
|
||||||
- Add a fuzzy match tolerance when applying diffs
|
|
||||||
|
|
||||||
## [2.2.5]
|
## [2.2.5]
|
||||||
|
|
||||||
- Allow MCP servers to be enabled/disabled
|
- Allow MCP servers to be enabled/disabled
|
||||||
|
|||||||
27
README.md
27
README.md
@@ -1,18 +1,19 @@
|
|||||||
# Roo-Cline
|
# Roo-Cline
|
||||||
|
|
||||||
A fork of Cline, an autonomous coding agent, optimized for speed and flexibility.
|
A fork of Cline, an autonomous coding agent, tweaked for more speed and flexibility. It’s been mainly writing itself recently, with a light touch of human guidance here and there.
|
||||||
- Auto-approval capabilities for commands, write, and browser operations
|
|
||||||
- Support for .clinerules per-project custom instructions
|
## Features
|
||||||
- Ability to run side-by-side with Cline
|
|
||||||
- Unit test coverage (written almost entirely by Roo Cline!)
|
- Automatically approve commands, browsing, file writing, and MCP tools
|
||||||
- Support for playing sound effects
|
- Faster, more targeted edits via diffs (even on big files)
|
||||||
- Support for OpenRouter compression
|
- Detects and fixes missing code chunks
|
||||||
- Support for copying prompts from the history screen
|
- `.clinerules` for project-specific instructions
|
||||||
- Support for editing through diffs / handling truncated full-file edits
|
- Drag and drop images into chats
|
||||||
- Support for newer Gemini models (gemini-exp-1206 and gemini-2.0-flash-exp)
|
- Sound effects for feedback
|
||||||
- Support for dragging and dropping images into chats
|
- Quick prompt copying from history
|
||||||
- Support for auto-approving MCP tools
|
- OpenRouter compression support
|
||||||
- Support for enabling/disabling MCP servers
|
- Support for newer Gemini models (gemini-exp-1206, gemini-2.0-flash-exp)
|
||||||
|
- Runs alongside the original Cline
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "roo-cline",
|
"name": "roo-cline",
|
||||||
"version": "2.2.7",
|
"version": "2.2.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "roo-cline",
|
"name": "roo-cline",
|
||||||
"version": "2.2.7",
|
"version": "2.2.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/bedrock-sdk": "^0.10.2",
|
"@anthropic-ai/bedrock-sdk": "^0.10.2",
|
||||||
"@anthropic-ai/sdk": "^0.26.0",
|
"@anthropic-ai/sdk": "^0.26.0",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"displayName": "Roo Cline",
|
"displayName": "Roo Cline",
|
||||||
"description": "A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.",
|
"description": "A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.",
|
||||||
"publisher": "RooVeterinaryInc",
|
"publisher": "RooVeterinaryInc",
|
||||||
"version": "2.2.7",
|
"version": "2.2.10",
|
||||||
"icon": "assets/icons/rocket.png",
|
"icon": "assets/icons/rocket.png",
|
||||||
"galleryBanner": {
|
"galleryBanner": {
|
||||||
"color": "#617A91",
|
"color": "#617A91",
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
"package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production",
|
"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",
|
"pretest": "npm run compile-tests && npm run compile && npm run lint",
|
||||||
"start:webview": "cd webview-ui && npm run start",
|
"start:webview": "cd webview-ui && npm run start",
|
||||||
"test": "jest",
|
"test": "jest && npm run test:webview",
|
||||||
"test:webview": "cd webview-ui && npm run test",
|
"test:webview": "cd webview-ui && npm run test",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"publish:marketplace": "vsce publish",
|
"publish:marketplace": "vsce publish",
|
||||||
|
|||||||
@@ -1237,20 +1237,25 @@ export class Cline {
|
|||||||
const originalContent = await fs.readFile(absolutePath, "utf-8")
|
const originalContent = await fs.readFile(absolutePath, "utf-8")
|
||||||
|
|
||||||
// Apply the diff to the original content
|
// Apply the diff to the original content
|
||||||
let newContent = this.diffStrategy?.applyDiff(originalContent, diffContent) ?? false
|
const diffResult = this.diffStrategy?.applyDiff(originalContent, diffContent) ?? {
|
||||||
if (newContent === false) {
|
success: false,
|
||||||
|
error: "No diff strategy available"
|
||||||
|
}
|
||||||
|
if (!diffResult.success) {
|
||||||
this.consecutiveMistakeCount++
|
this.consecutiveMistakeCount++
|
||||||
await this.say("error", `Unable to apply diff to file - contents are out of sync: ${absolutePath}`)
|
const errorDetails = diffResult.details ? `\n\nDetails:\n${JSON.stringify(diffResult.details, null, 2)}` : ''
|
||||||
pushToolResult(`Error applying diff to file: ${absolutePath} - contents are out of sync. Try re-reading the relevant lines of the file and applying the diff again.`)
|
await this.say("error", `Unable to apply diff to file: ${absolutePath}\n${diffResult.error}${errorDetails}`)
|
||||||
|
pushToolResult(`Error applying diff to file: ${absolutePath}\n${diffResult.error}${errorDetails}`)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
const newContent = diffResult.content
|
||||||
|
|
||||||
this.consecutiveMistakeCount = 0
|
this.consecutiveMistakeCount = 0
|
||||||
|
|
||||||
// Show diff view before asking for approval
|
// Show diff view before asking for approval
|
||||||
this.diffViewProvider.editType = "modify"
|
this.diffViewProvider.editType = "modify"
|
||||||
await this.diffViewProvider.open(relPath);
|
await this.diffViewProvider.open(relPath);
|
||||||
await this.diffViewProvider.update(newContent, true);
|
await this.diffViewProvider.update(diffResult.content, true);
|
||||||
await this.diffViewProvider.scrollToFirstDiff();
|
await this.diffViewProvider.scrollToFirstDiff();
|
||||||
|
|
||||||
const completeMessage = JSON.stringify({
|
const completeMessage = JSON.stringify({
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ function hello() {
|
|||||||
>>>>>>> REPLACE`
|
>>>>>>> REPLACE`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe('function hello() {\n console.log("hello world")\n}\n')
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe('function hello() {\n console.log("hello world")\n}\n')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should match content with different surrounding whitespace', () => {
|
it('should match content with different surrounding whitespace', () => {
|
||||||
@@ -39,7 +42,10 @@ function example() {
|
|||||||
>>>>>>> REPLACE`
|
>>>>>>> REPLACE`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe('\nfunction example() {\n return 43;\n}\n\n')
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe('\nfunction example() {\n return 43;\n}\n\n')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should match content with different indentation in search block', () => {
|
it('should match content with different indentation in search block', () => {
|
||||||
@@ -56,7 +62,10 @@ function test() {
|
|||||||
>>>>>>> REPLACE`
|
>>>>>>> REPLACE`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe(' function test() {\n return false;\n }\n')
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(' function test() {\n return false;\n }\n')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle tab-based indentation', () => {
|
it('should handle tab-based indentation', () => {
|
||||||
@@ -73,7 +82,10 @@ function test() {
|
|||||||
>>>>>>> REPLACE`
|
>>>>>>> REPLACE`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe("function test() {\n\treturn false;\n}\n")
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe("function test() {\n\treturn false;\n}\n")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should preserve mixed tabs and spaces', () => {
|
it('should preserve mixed tabs and spaces', () => {
|
||||||
@@ -94,7 +106,10 @@ function test() {
|
|||||||
>>>>>>> REPLACE`
|
>>>>>>> REPLACE`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe("\tclass Example {\n\t constructor() {\n\t\tthis.value = 1;\n\t }\n\t}")
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe("\tclass Example {\n\t constructor() {\n\t\tthis.value = 1;\n\t }\n\t}")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle additional indentation with tabs', () => {
|
it('should handle additional indentation with tabs', () => {
|
||||||
@@ -112,7 +127,10 @@ function test() {
|
|||||||
>>>>>>> REPLACE`
|
>>>>>>> REPLACE`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe("\tfunction test() {\n\t\t// Add comment\n\t\treturn false;\n\t}")
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe("\tfunction test() {\n\t\t// Add comment\n\t\treturn false;\n\t}")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should preserve exact indentation characters when adding lines', () => {
|
it('should preserve exact indentation characters when adding lines', () => {
|
||||||
@@ -131,7 +149,10 @@ function test() {
|
|||||||
>>>>>>> REPLACE`
|
>>>>>>> REPLACE`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe("\tfunction test() {\n\t\t// First comment\n\t\t// Second comment\n\t\treturn true;\n\t}")
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe("\tfunction test() {\n\t\t// First comment\n\t\t// Second comment\n\t\treturn true;\n\t}")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle Windows-style CRLF line endings', () => {
|
it('should handle Windows-style CRLF line endings', () => {
|
||||||
@@ -148,7 +169,10 @@ function test() {
|
|||||||
>>>>>>> REPLACE`
|
>>>>>>> REPLACE`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe("function test() {\r\n return false;\r\n}\r\n")
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe("function test() {\r\n return false;\r\n}\r\n")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return false if search content does not match', () => {
|
it('should return false if search content does not match', () => {
|
||||||
@@ -165,7 +189,7 @@ function hello() {
|
|||||||
>>>>>>> REPLACE`
|
>>>>>>> REPLACE`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return false if diff format is invalid', () => {
|
it('should return false if diff format is invalid', () => {
|
||||||
@@ -173,7 +197,7 @@ function hello() {
|
|||||||
const diffContent = `test.ts\nInvalid diff format`
|
const diffContent = `test.ts\nInvalid diff format`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle multiple lines with proper indentation', () => {
|
it('should handle multiple lines with proper indentation', () => {
|
||||||
@@ -192,7 +216,10 @@ function hello() {
|
|||||||
>>>>>>> REPLACE`
|
>>>>>>> REPLACE`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe('class Example {\n constructor() {\n this.value = 0\n }\n\n getValue() {\n // Add logging\n console.log("Getting value")\n return this.value\n }\n}\n')
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe('class Example {\n constructor() {\n this.value = 0\n }\n\n getValue() {\n // Add logging\n console.log("Getting value")\n return this.value\n }\n}\n')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should preserve whitespace exactly in the output', () => {
|
it('should preserve whitespace exactly in the output', () => {
|
||||||
@@ -209,7 +236,10 @@ function hello() {
|
|||||||
>>>>>>> REPLACE`
|
>>>>>>> REPLACE`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe(" modified\n still indented\n end\n")
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(" modified\n still indented\n end\n")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should preserve indentation when adding new lines after existing content', () => {
|
it('should preserve indentation when adding new lines after existing content', () => {
|
||||||
@@ -226,9 +256,460 @@ function hello() {
|
|||||||
>>>>>>> REPLACE`
|
>>>>>>> REPLACE`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe(' onScroll={() => updateHighlights()}\n onDragOver={(e) => {\n e.preventDefault()\n e.stopPropagation()\n }}')
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(' onScroll={() => updateHighlights()}\n onDragOver={(e) => {\n e.preventDefault()\n e.stopPropagation()\n }}')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle varying indentation levels correctly', () => {
|
||||||
|
const originalContent = `
|
||||||
|
class Example {
|
||||||
|
constructor() {
|
||||||
|
this.value = 0;
|
||||||
|
if (true) {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`.trim();
|
||||||
|
|
||||||
|
const diffContent = `test.ts
|
||||||
|
<<<<<<< SEARCH
|
||||||
|
class Example {
|
||||||
|
constructor() {
|
||||||
|
this.value = 0;
|
||||||
|
if (true) {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
=======
|
||||||
|
class Example {
|
||||||
|
constructor() {
|
||||||
|
this.value = 1;
|
||||||
|
if (true) {
|
||||||
|
this.init();
|
||||||
|
this.setup();
|
||||||
|
this.validate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>>>>>>> REPLACE`.trim();
|
||||||
|
|
||||||
|
const result = strategy.applyDiff(originalContent, diffContent);
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(`
|
||||||
|
class Example {
|
||||||
|
constructor() {
|
||||||
|
this.value = 1;
|
||||||
|
if (true) {
|
||||||
|
this.init();
|
||||||
|
this.setup();
|
||||||
|
this.validate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`.trim());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle mixed indentation styles in the same file', () => {
|
||||||
|
const originalContent = `class Example {
|
||||||
|
constructor() {
|
||||||
|
this.value = 0;
|
||||||
|
if (true) {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`.trim();
|
||||||
|
const diffContent = `test.ts
|
||||||
|
<<<<<<< SEARCH
|
||||||
|
constructor() {
|
||||||
|
this.value = 0;
|
||||||
|
if (true) {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
=======
|
||||||
|
constructor() {
|
||||||
|
this.value = 1;
|
||||||
|
if (true) {
|
||||||
|
this.init();
|
||||||
|
this.validate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>>>>>>> REPLACE`;
|
||||||
|
|
||||||
|
const result = strategy.applyDiff(originalContent, diffContent);
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(`class Example {
|
||||||
|
constructor() {
|
||||||
|
this.value = 1;
|
||||||
|
if (true) {
|
||||||
|
this.init();
|
||||||
|
this.validate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle Python-style significant whitespace', () => {
|
||||||
|
const originalContent = `def example():
|
||||||
|
if condition:
|
||||||
|
do_something()
|
||||||
|
for item in items:
|
||||||
|
process(item)
|
||||||
|
return True`.trim();
|
||||||
|
const diffContent = `test.ts
|
||||||
|
<<<<<<< SEARCH
|
||||||
|
if condition:
|
||||||
|
do_something()
|
||||||
|
for item in items:
|
||||||
|
process(item)
|
||||||
|
=======
|
||||||
|
if condition:
|
||||||
|
do_something()
|
||||||
|
while items:
|
||||||
|
item = items.pop()
|
||||||
|
process(item)
|
||||||
|
>>>>>>> REPLACE`;
|
||||||
|
|
||||||
|
const result = strategy.applyDiff(originalContent, diffContent);
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(`def example():
|
||||||
|
if condition:
|
||||||
|
do_something()
|
||||||
|
while items:
|
||||||
|
item = items.pop()
|
||||||
|
process(item)
|
||||||
|
return True`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve empty lines with indentation', () => {
|
||||||
|
const originalContent = `function test() {
|
||||||
|
const x = 1;
|
||||||
|
|
||||||
|
if (x) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}`.trim();
|
||||||
|
const diffContent = `test.ts
|
||||||
|
<<<<<<< SEARCH
|
||||||
|
const x = 1;
|
||||||
|
|
||||||
|
if (x) {
|
||||||
|
=======
|
||||||
|
const x = 1;
|
||||||
|
|
||||||
|
// Check x
|
||||||
|
if (x) {
|
||||||
|
>>>>>>> REPLACE`;
|
||||||
|
|
||||||
|
const result = strategy.applyDiff(originalContent, diffContent);
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(`function test() {
|
||||||
|
const x = 1;
|
||||||
|
|
||||||
|
// Check x
|
||||||
|
if (x) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle indentation when replacing entire blocks', () => {
|
||||||
|
const originalContent = `class Test {
|
||||||
|
method() {
|
||||||
|
if (true) {
|
||||||
|
console.log("test");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`.trim();
|
||||||
|
const diffContent = `test.ts
|
||||||
|
<<<<<<< SEARCH
|
||||||
|
method() {
|
||||||
|
if (true) {
|
||||||
|
console.log("test");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
=======
|
||||||
|
method() {
|
||||||
|
try {
|
||||||
|
if (true) {
|
||||||
|
console.log("test");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>>>>>>> REPLACE`;
|
||||||
|
|
||||||
|
const result = strategy.applyDiff(originalContent, diffContent);
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(`class Test {
|
||||||
|
method() {
|
||||||
|
try {
|
||||||
|
if (true) {
|
||||||
|
console.log("test");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle negative indentation relative to search content', () => {
|
||||||
|
const originalContent = `class Example {
|
||||||
|
constructor() {
|
||||||
|
if (true) {
|
||||||
|
this.init();
|
||||||
|
this.setup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`.trim();
|
||||||
|
const diffContent = `test.ts
|
||||||
|
<<<<<<< SEARCH
|
||||||
|
this.init();
|
||||||
|
this.setup();
|
||||||
|
=======
|
||||||
|
this.init();
|
||||||
|
this.setup();
|
||||||
|
>>>>>>> REPLACE`;
|
||||||
|
|
||||||
|
const result = strategy.applyDiff(originalContent, diffContent);
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(`class Example {
|
||||||
|
constructor() {
|
||||||
|
if (true) {
|
||||||
|
this.init();
|
||||||
|
this.setup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle extreme negative indentation (no indent)', () => {
|
||||||
|
const originalContent = `class Example {
|
||||||
|
constructor() {
|
||||||
|
if (true) {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`.trim();
|
||||||
|
const diffContent = `test.ts
|
||||||
|
<<<<<<< SEARCH
|
||||||
|
this.init();
|
||||||
|
=======
|
||||||
|
this.init();
|
||||||
|
>>>>>>> REPLACE`;
|
||||||
|
|
||||||
|
const result = strategy.applyDiff(originalContent, diffContent);
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(`class Example {
|
||||||
|
constructor() {
|
||||||
|
if (true) {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed indentation changes in replace block', () => {
|
||||||
|
const originalContent = `class Example {
|
||||||
|
constructor() {
|
||||||
|
if (true) {
|
||||||
|
this.init();
|
||||||
|
this.setup();
|
||||||
|
this.validate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`.trim();
|
||||||
|
const diffContent = `test.ts
|
||||||
|
<<<<<<< SEARCH
|
||||||
|
this.init();
|
||||||
|
this.setup();
|
||||||
|
this.validate();
|
||||||
|
=======
|
||||||
|
this.init();
|
||||||
|
this.setup();
|
||||||
|
this.validate();
|
||||||
|
>>>>>>> REPLACE`;
|
||||||
|
|
||||||
|
const result = strategy.applyDiff(originalContent, diffContent);
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(`class Example {
|
||||||
|
constructor() {
|
||||||
|
if (true) {
|
||||||
|
this.init();
|
||||||
|
this.setup();
|
||||||
|
this.validate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
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', () => {
|
describe('fuzzy matching', () => {
|
||||||
let strategy: SearchReplaceDiffStrategy
|
let strategy: SearchReplaceDiffStrategy
|
||||||
@@ -253,7 +734,10 @@ function getData() {
|
|||||||
>>>>>>> REPLACE`
|
>>>>>>> REPLACE`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe('function getData() {\n const data = fetchData();\n return data.filter(Boolean);\n}\n')
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe('function getData() {\n const data = fetchData();\n return data.filter(Boolean);\n}\n')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not match when content is too different (<90% similar)', () => {
|
it('should not match when content is too different (<90% similar)', () => {
|
||||||
@@ -270,7 +754,7 @@ function processData(data) {
|
|||||||
>>>>>>> REPLACE`
|
>>>>>>> REPLACE`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should match content with extra whitespace', () => {
|
it('should match content with extra whitespace', () => {
|
||||||
@@ -287,7 +771,10 @@ function sum(a, b) {
|
|||||||
>>>>>>> REPLACE`
|
>>>>>>> REPLACE`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe('function sum(a, b) {\n return a + b + 1;\n}')
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe('function sum(a, b) {\n return a + b + 1;\n}')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -324,7 +811,9 @@ function two() {
|
|||||||
>>>>>>> REPLACE`
|
>>>>>>> REPLACE`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent, 5, 7)
|
const result = strategy.applyDiff(originalContent, diffContent, 5, 7)
|
||||||
expect(result).toBe(`function one() {
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(`function one() {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,6 +824,7 @@ function two() {
|
|||||||
function three() {
|
function three() {
|
||||||
return 3;
|
return 3;
|
||||||
}`)
|
}`)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should find and replace within buffer zone (5 lines before/after)', () => {
|
it('should find and replace within buffer zone (5 lines before/after)', () => {
|
||||||
@@ -365,7 +855,9 @@ function three() {
|
|||||||
// Even though we specify lines 5-7, it should still find the match at lines 9-11
|
// Even though we specify lines 5-7, it should still find the match at lines 9-11
|
||||||
// because it's within the 5-line buffer zone
|
// because it's within the 5-line buffer zone
|
||||||
const result = strategy.applyDiff(originalContent, diffContent, 5, 7)
|
const result = strategy.applyDiff(originalContent, diffContent, 5, 7)
|
||||||
expect(result).toBe(`function one() {
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(`function one() {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,6 +868,7 @@ function two() {
|
|||||||
function three() {
|
function three() {
|
||||||
return "three";
|
return "three";
|
||||||
}`)
|
}`)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not find matches outside search range and buffer zone', () => {
|
it('should not find matches outside search range and buffer zone', () => {
|
||||||
@@ -414,7 +907,7 @@ function five() {
|
|||||||
// Searching around function two() (lines 5-7)
|
// Searching around function two() (lines 5-7)
|
||||||
// function five() is more than 5 lines away, so it shouldn't match
|
// function five() is more than 5 lines away, so it shouldn't match
|
||||||
const result = strategy.applyDiff(originalContent, diffContent, 5, 7)
|
const result = strategy.applyDiff(originalContent, diffContent, 5, 7)
|
||||||
expect(result).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle search range at start of file', () => {
|
it('should handle search range at start of file', () => {
|
||||||
@@ -439,13 +932,16 @@ function one() {
|
|||||||
>>>>>>> REPLACE`
|
>>>>>>> REPLACE`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent, 1, 3)
|
const result = strategy.applyDiff(originalContent, diffContent, 1, 3)
|
||||||
expect(result).toBe(`function one() {
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(`function one() {
|
||||||
return "one";
|
return "one";
|
||||||
}
|
}
|
||||||
|
|
||||||
function two() {
|
function two() {
|
||||||
return 2;
|
return 2;
|
||||||
}`)
|
}`)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle search range at end of file', () => {
|
it('should handle search range at end of file', () => {
|
||||||
@@ -470,13 +966,16 @@ function two() {
|
|||||||
>>>>>>> REPLACE`
|
>>>>>>> REPLACE`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent, 5, 7)
|
const result = strategy.applyDiff(originalContent, diffContent, 5, 7)
|
||||||
expect(result).toBe(`function one() {
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(`function one() {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function two() {
|
function two() {
|
||||||
return "two";
|
return "two";
|
||||||
}`)
|
}`)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should match specific instance of duplicate code using line numbers', () => {
|
it('should match specific instance of duplicate code using line numbers', () => {
|
||||||
@@ -513,7 +1012,9 @@ function processData(data) {
|
|||||||
|
|
||||||
// Target the second instance of processData
|
// Target the second instance of processData
|
||||||
const result = strategy.applyDiff(originalContent, diffContent, 10, 12)
|
const result = strategy.applyDiff(originalContent, diffContent, 10, 12)
|
||||||
expect(result).toBe(`function processData(data) {
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(`function processData(data) {
|
||||||
return data.map(x => x * 2);
|
return data.map(x => x * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,6 +1032,7 @@ function processData(data) {
|
|||||||
function moreStuff() {
|
function moreStuff() {
|
||||||
console.log("world");
|
console.log("world");
|
||||||
}`)
|
}`)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should search from start line to end of file when only start_line is provided', () => {
|
it('should search from start line to end of file when only start_line is provided', () => {
|
||||||
@@ -560,7 +1062,9 @@ function three() {
|
|||||||
|
|
||||||
// Only provide start_line, should search from there to end of file
|
// Only provide start_line, should search from there to end of file
|
||||||
const result = strategy.applyDiff(originalContent, diffContent, 8)
|
const result = strategy.applyDiff(originalContent, diffContent, 8)
|
||||||
expect(result).toBe(`function one() {
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(`function one() {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,6 +1075,7 @@ function two() {
|
|||||||
function three() {
|
function three() {
|
||||||
return "three";
|
return "three";
|
||||||
}`)
|
}`)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should search from start of file to end line when only end_line is provided', () => {
|
it('should search from start of file to end line when only end_line is provided', () => {
|
||||||
@@ -600,7 +1105,9 @@ function one() {
|
|||||||
|
|
||||||
// Only provide end_line, should search from start of file to there
|
// Only provide end_line, should search from start of file to there
|
||||||
const result = strategy.applyDiff(originalContent, diffContent, undefined, 4)
|
const result = strategy.applyDiff(originalContent, diffContent, undefined, 4)
|
||||||
expect(result).toBe(`function one() {
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(`function one() {
|
||||||
return "one";
|
return "one";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,6 +1118,102 @@ function two() {
|
|||||||
function three() {
|
function three() {
|
||||||
return 3;
|
return 3;
|
||||||
}`)
|
}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should prioritize exact line match over expanded search', () => {
|
||||||
|
const originalContent = `
|
||||||
|
function one() {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function process() {
|
||||||
|
return "old";
|
||||||
|
}
|
||||||
|
|
||||||
|
function process() {
|
||||||
|
return "old";
|
||||||
|
}
|
||||||
|
|
||||||
|
function two() {
|
||||||
|
return 2;
|
||||||
|
}`
|
||||||
|
const diffContent = `test.ts
|
||||||
|
<<<<<<< SEARCH
|
||||||
|
function process() {
|
||||||
|
return "old";
|
||||||
|
}
|
||||||
|
=======
|
||||||
|
function process() {
|
||||||
|
return "new";
|
||||||
|
}
|
||||||
|
>>>>>>> REPLACE`
|
||||||
|
|
||||||
|
// Should match the second instance exactly at lines 10-12
|
||||||
|
// even though the first instance at 6-8 is within the expanded search range
|
||||||
|
const result = strategy.applyDiff(originalContent, diffContent, 10, 12)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(`
|
||||||
|
function one() {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function process() {
|
||||||
|
return "old";
|
||||||
|
}
|
||||||
|
|
||||||
|
function process() {
|
||||||
|
return "new";
|
||||||
|
}
|
||||||
|
|
||||||
|
function two() {
|
||||||
|
return 2;
|
||||||
|
}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fall back to expanded search only if exact match fails', () => {
|
||||||
|
const originalContent = `
|
||||||
|
function one() {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function process() {
|
||||||
|
return "target";
|
||||||
|
}
|
||||||
|
|
||||||
|
function two() {
|
||||||
|
return 2;
|
||||||
|
}`.trim()
|
||||||
|
const diffContent = `test.ts
|
||||||
|
<<<<<<< SEARCH
|
||||||
|
function process() {
|
||||||
|
return "target";
|
||||||
|
}
|
||||||
|
=======
|
||||||
|
function process() {
|
||||||
|
return "updated";
|
||||||
|
}
|
||||||
|
>>>>>>> REPLACE`
|
||||||
|
|
||||||
|
// Specify wrong line numbers (3-5), but content exists at 6-8
|
||||||
|
// Should still find and replace it since it's within the expanded range
|
||||||
|
const result = strategy.applyDiff(originalContent, diffContent, 3, 5)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(`function one() {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function process() {
|
||||||
|
return "updated";
|
||||||
|
}
|
||||||
|
|
||||||
|
function two() {
|
||||||
|
return 2;
|
||||||
|
}`)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ function calculateTotal(items: number[]): number {
|
|||||||
export { calculateTotal };`
|
export { calculateTotal };`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe(expected)
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(expected)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should successfully apply a diff adding a new method', () => {
|
it('should successfully apply a diff adding a new method', () => {
|
||||||
@@ -93,7 +96,10 @@ export { calculateTotal };`
|
|||||||
}`
|
}`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe(expected)
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(expected)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should successfully apply a diff modifying imports', () => {
|
it('should successfully apply a diff modifying imports', () => {
|
||||||
@@ -128,7 +134,10 @@ function App() {
|
|||||||
}`
|
}`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe(expected)
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(expected)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should successfully apply a diff with multiple hunks', () => {
|
it('should successfully apply a diff with multiple hunks', () => {
|
||||||
@@ -190,7 +199,10 @@ async function processFile(path: string) {
|
|||||||
export { processFile };`
|
export { processFile };`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe(expected)
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(expected)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle empty original content', () => {
|
it('should handle empty original content', () => {
|
||||||
@@ -207,7 +219,10 @@ export { processFile };`
|
|||||||
}\n`
|
}\n`
|
||||||
|
|
||||||
const result = strategy.applyDiff(originalContent, diffContent)
|
const result = strategy.applyDiff(originalContent, diffContent)
|
||||||
expect(result).toBe(expected)
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.content).toBe(expected)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DiffStrategy } from "../types"
|
import { DiffStrategy, DiffResult } from "../types"
|
||||||
|
|
||||||
function levenshteinDistance(a: string, b: string): number {
|
function levenshteinDistance(a: string, b: string): number {
|
||||||
const matrix: number[][] = [];
|
const matrix: number[][] = [];
|
||||||
@@ -115,24 +115,84 @@ Your search/replace content here
|
|||||||
</apply_diff>`
|
</apply_diff>`
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): string | false {
|
applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): DiffResult {
|
||||||
// Extract the search and replace blocks
|
// Extract the search and replace blocks
|
||||||
const match = diffContent.match(/<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/);
|
const match = diffContent.match(/<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return false;
|
// Log detailed format information
|
||||||
|
console.log('Invalid Diff Format Debug:', {
|
||||||
|
expectedFormat: "<<<<<<< SEARCH\\n[search content]\\n=======\\n[replace content]\\n>>>>>>> REPLACE",
|
||||||
|
tip: "Make sure to include both SEARCH and REPLACE sections with correct markers"
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Invalid diff format - missing required SEARCH/REPLACE sections"
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const [_, searchContent, replaceContent] = match;
|
let [_, searchContent, replaceContent] = match;
|
||||||
|
|
||||||
// Detect line ending from original content
|
// Detect line ending from original content
|
||||||
const lineEnding = originalContent.includes('\r\n') ? '\r\n' : '\n';
|
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
|
// Split content into lines, handling both \n and \r\n
|
||||||
const searchLines = searchContent.split(/\r?\n/);
|
const searchLines = searchContent.split(/\r?\n/);
|
||||||
const replaceLines = replaceContent.split(/\r?\n/);
|
const replaceLines = replaceContent.split(/\r?\n/);
|
||||||
const originalLines = originalContent.split(/\r?\n/);
|
const originalLines = originalContent.split(/\r?\n/);
|
||||||
|
|
||||||
// Determine search range based on provided line numbers
|
// First try exact line range if provided
|
||||||
|
let matchIndex = -1;
|
||||||
|
let bestMatchScore = 0;
|
||||||
|
let bestMatchContent = "";
|
||||||
|
|
||||||
|
if (startLine !== undefined && endLine !== undefined) {
|
||||||
|
// Convert to 0-based index
|
||||||
|
const exactStartIndex = startLine - 1;
|
||||||
|
const exactEndIndex = endLine - 1;
|
||||||
|
|
||||||
|
if (exactStartIndex < 0 || exactEndIndex >= originalLines.length) {
|
||||||
|
// Log detailed debug information
|
||||||
|
console.log('Invalid Line Range Debug:', {
|
||||||
|
requestedRange: { start: startLine, end: endLine },
|
||||||
|
fileBounds: { start: 1, end: originalLines.length }
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Line range ${startLine}-${endLine} is invalid (file has ${originalLines.length} lines)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check exact range first
|
||||||
|
const originalChunk = originalLines.slice(exactStartIndex, exactEndIndex + 1).join('\n');
|
||||||
|
const searchChunk = searchLines.join('\n');
|
||||||
|
|
||||||
|
const similarity = getSimilarity(originalChunk, searchChunk);
|
||||||
|
if (similarity >= this.fuzzyThreshold) {
|
||||||
|
matchIndex = exactStartIndex;
|
||||||
|
bestMatchScore = similarity;
|
||||||
|
bestMatchContent = originalChunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no match found in exact range, try expanded range
|
||||||
|
if (matchIndex === -1) {
|
||||||
let searchStartIndex = 0;
|
let searchStartIndex = 0;
|
||||||
let searchEndIndex = originalLines.length;
|
let searchEndIndex = originalLines.length;
|
||||||
|
|
||||||
@@ -146,10 +206,7 @@ Your search/replace content here
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the search content in the original using fuzzy matching
|
// Find the search content in the expanded range using fuzzy matching
|
||||||
let matchIndex = -1;
|
|
||||||
let bestMatchScore = 0;
|
|
||||||
|
|
||||||
for (let i = searchStartIndex; i <= searchEndIndex - searchLines.length; i++) {
|
for (let i = searchStartIndex; i <= searchEndIndex - searchLines.length; i++) {
|
||||||
// Join the lines and calculate overall similarity
|
// Join the lines and calculate overall similarity
|
||||||
const originalChunk = originalLines.slice(i, i + searchLines.length).join('\n');
|
const originalChunk = originalLines.slice(i, i + searchLines.length).join('\n');
|
||||||
@@ -159,12 +216,26 @@ Your search/replace content here
|
|||||||
if (similarity > bestMatchScore) {
|
if (similarity > bestMatchScore) {
|
||||||
bestMatchScore = similarity;
|
bestMatchScore = similarity;
|
||||||
matchIndex = i;
|
matchIndex = i;
|
||||||
|
bestMatchContent = originalChunk;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Require similarity to meet threshold
|
// Require similarity to meet threshold
|
||||||
if (matchIndex === -1 || bestMatchScore < this.fuzzyThreshold) {
|
if (matchIndex === -1 || bestMatchScore < this.fuzzyThreshold) {
|
||||||
return false;
|
const searchChunk = searchLines.join('\n');
|
||||||
|
// Log detailed debug information to console
|
||||||
|
console.log('Search/Replace Debug Info:', {
|
||||||
|
similarity: bestMatchScore,
|
||||||
|
threshold: this.fuzzyThreshold,
|
||||||
|
searchContent: searchChunk,
|
||||||
|
bestMatch: bestMatchContent || undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `No sufficiently similar match found${startLine !== undefined ? ` near lines ${startLine}-${endLine}` : ''} (${Math.round(bestMatchScore * 100)}% similar, needs ${Math.round(this.fuzzyThreshold * 100)}%)`
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the matched lines from the original content
|
// Get the matched lines from the original content
|
||||||
@@ -192,17 +263,28 @@ Your search/replace content here
|
|||||||
const currentIndent = currentIndentMatch ? currentIndentMatch[0] : '';
|
const currentIndent = currentIndentMatch ? currentIndentMatch[0] : '';
|
||||||
const searchBaseIndent = searchIndents[0] || '';
|
const searchBaseIndent = searchIndents[0] || '';
|
||||||
|
|
||||||
// Calculate the relative indentation from the search content
|
// Calculate the relative indentation level
|
||||||
const relativeIndent = currentIndent.slice(searchBaseIndent.length);
|
const searchBaseLevel = searchBaseIndent.length;
|
||||||
|
const currentLevel = currentIndent.length;
|
||||||
|
const relativeLevel = currentLevel - searchBaseLevel;
|
||||||
|
|
||||||
// Apply the matched indentation plus any relative indentation
|
// If relative level is negative, remove indentation from matched indent
|
||||||
return matchedIndent + relativeIndent + line.trim();
|
// If positive, add to matched indent
|
||||||
|
const finalIndent = relativeLevel < 0
|
||||||
|
? matchedIndent.slice(0, Math.max(0, matchedIndent.length + relativeLevel))
|
||||||
|
: matchedIndent + currentIndent.slice(searchBaseLevel);
|
||||||
|
|
||||||
|
return finalIndent + line.trim();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Construct the final content
|
// Construct the final content
|
||||||
const beforeMatch = originalLines.slice(0, matchIndex);
|
const beforeMatch = originalLines.slice(0, matchIndex);
|
||||||
const afterMatch = originalLines.slice(matchIndex + searchLines.length);
|
const afterMatch = originalLines.slice(matchIndex + searchLines.length);
|
||||||
|
|
||||||
return [...beforeMatch, ...indentedReplaceLines, ...afterMatch].join(lineEnding);
|
const finalContent = [...beforeMatch, ...indentedReplaceLines, ...afterMatch].join(lineEnding);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
content: finalContent
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { applyPatch } from "diff"
|
import { applyPatch } from "diff"
|
||||||
import { DiffStrategy } from "../types"
|
import { DiffStrategy, DiffResult } from "../types"
|
||||||
|
|
||||||
export class UnifiedDiffStrategy implements DiffStrategy {
|
export class UnifiedDiffStrategy implements DiffStrategy {
|
||||||
getToolDescription(cwd: string): string {
|
getToolDescription(cwd: string): string {
|
||||||
@@ -108,7 +108,30 @@ Your diff here
|
|||||||
</apply_diff>`
|
</apply_diff>`
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDiff(originalContent: string, diffContent: string): string | false {
|
applyDiff(originalContent: string, diffContent: string): DiffResult {
|
||||||
return applyPatch(originalContent, diffContent) as string | false
|
try {
|
||||||
|
const result = applyPatch(originalContent, diffContent)
|
||||||
|
if (result === false) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Failed to apply unified diff - patch rejected",
|
||||||
|
details: {
|
||||||
|
searchContent: diffContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
content: result
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Error applying unified diff: ${error.message}`,
|
||||||
|
details: {
|
||||||
|
searchContent: diffContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Interface for implementing different diff strategies
|
* Interface for implementing different diff strategies
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export type DiffResult =
|
||||||
|
| { success: true; content: string }
|
||||||
|
| { success: false; error: string; details?: {
|
||||||
|
similarity?: number;
|
||||||
|
threshold?: number;
|
||||||
|
matchedRange?: { start: number; end: number };
|
||||||
|
searchContent?: string;
|
||||||
|
bestMatch?: string;
|
||||||
|
}};
|
||||||
|
|
||||||
export interface DiffStrategy {
|
export interface DiffStrategy {
|
||||||
/**
|
/**
|
||||||
* Get the tool description for this diff strategy
|
* Get the tool description for this diff strategy
|
||||||
@@ -15,7 +26,7 @@ export interface DiffStrategy {
|
|||||||
* @param diffContent The diff content in the strategy's format
|
* @param diffContent The diff content in the strategy's format
|
||||||
* @param startLine Optional line number where the search block starts. If not provided, searches the entire file.
|
* @param startLine Optional line number where the search block starts. If not provided, searches the entire file.
|
||||||
* @param endLine Optional line number where the search block ends. If not provided, searches the entire file.
|
* @param endLine Optional line number where the search block ends. If not provided, searches the entire file.
|
||||||
* @returns The new content after applying the diff, or false if the diff could not be applied
|
* @returns A DiffResult object containing either the successful result or error details
|
||||||
*/
|
*/
|
||||||
applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): string | false
|
applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): DiffResult
|
||||||
}
|
}
|
||||||
|
|||||||
66
src/integrations/editor/__tests__/detect-omission.test.ts
Normal file
66
src/integrations/editor/__tests__/detect-omission.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { detectCodeOmission } from '../detect-omission'
|
||||||
|
|
||||||
|
describe('detectCodeOmission', () => {
|
||||||
|
const originalContent = `function example() {
|
||||||
|
// Some code
|
||||||
|
const x = 1;
|
||||||
|
const y = 2;
|
||||||
|
return x + y;
|
||||||
|
}`
|
||||||
|
|
||||||
|
it('should detect square bracket line range omission', () => {
|
||||||
|
const newContent = `[Previous content from line 1-305 remains exactly the same]
|
||||||
|
const z = 3;`
|
||||||
|
expect(detectCodeOmission(originalContent, newContent)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect single-line comment omission', () => {
|
||||||
|
const newContent = `// Lines 1-50 remain unchanged
|
||||||
|
const z = 3;`
|
||||||
|
expect(detectCodeOmission(originalContent, newContent)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect multi-line comment omission', () => {
|
||||||
|
const newContent = `/* Previous content remains the same */
|
||||||
|
const z = 3;`
|
||||||
|
expect(detectCodeOmission(originalContent, newContent)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect HTML-style comment omission', () => {
|
||||||
|
const newContent = `<!-- Existing content unchanged -->
|
||||||
|
const z = 3;`
|
||||||
|
expect(detectCodeOmission(originalContent, newContent)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect JSX-style comment omission', () => {
|
||||||
|
const newContent = `{/* Rest of the code remains the same */}
|
||||||
|
const z = 3;`
|
||||||
|
expect(detectCodeOmission(originalContent, newContent)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect Python-style comment omission', () => {
|
||||||
|
const newContent = `# Previous content remains unchanged
|
||||||
|
const z = 3;`
|
||||||
|
expect(detectCodeOmission(originalContent, newContent)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not detect regular comments without omission keywords', () => {
|
||||||
|
const newContent = `// Adding new functionality
|
||||||
|
const z = 3;`
|
||||||
|
expect(detectCodeOmission(originalContent, newContent)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not detect when comment is part of original content', () => {
|
||||||
|
const originalWithComment = `// Content remains unchanged
|
||||||
|
${originalContent}`
|
||||||
|
const newContent = `// Content remains unchanged
|
||||||
|
const z = 3;`
|
||||||
|
expect(detectCodeOmission(originalWithComment, newContent)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not detect code that happens to contain omission keywords', () => {
|
||||||
|
const newContent = `const remains = 'some value';
|
||||||
|
const unchanged = true;`
|
||||||
|
expect(detectCodeOmission(originalContent, newContent)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
export function detectCodeOmission(originalFileContent: string, newFileContent: string): boolean {
|
export function detectCodeOmission(originalFileContent: string, newFileContent: string): boolean {
|
||||||
const originalLines = originalFileContent.split("\n")
|
const originalLines = originalFileContent.split("\n")
|
||||||
const newLines = newFileContent.split("\n")
|
const newLines = newFileContent.split("\n")
|
||||||
const omissionKeywords = ["remain", "remains", "unchanged", "rest", "previous", "existing", "..."]
|
const omissionKeywords = ["remain", "remains", "unchanged", "rest", "previous", "existing", "content", "same", "..."]
|
||||||
|
|
||||||
const commentPatterns = [
|
const commentPatterns = [
|
||||||
/^\s*\/\//, // Single-line comment for most languages
|
/^\s*\/\//, // Single-line comment for most languages
|
||||||
@@ -15,6 +15,7 @@ export function detectCodeOmission(originalFileContent: string, newFileContent:
|
|||||||
/^\s*\/\*/, // Multi-line comment opening
|
/^\s*\/\*/, // Multi-line comment opening
|
||||||
/^\s*{\s*\/\*/, // JSX comment opening
|
/^\s*{\s*\/\*/, // JSX comment opening
|
||||||
/^\s*<!--/, // HTML comment opening
|
/^\s*<!--/, // HTML comment opening
|
||||||
|
/^\s*\[/, // Square bracket notation
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const line of newLines) {
|
for (const line of newLines) {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"build": "node ./scripts/build-react-no-split.js",
|
"build": "node ./scripts/build-react-no-split.js",
|
||||||
"test": "react-scripts test",
|
"test": "react-scripts test --watchAll=false",
|
||||||
"eject": "react-scripts eject"
|
"eject": "react-scripts eject"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"transformIgnorePatterns": [
|
"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": {
|
"moduleNameMapper": {
|
||||||
"\\.(css|less|scss|sass)$": "identity-obj-proxy"
|
"\\.(css|less|scss|sass)$": "identity-obj-proxy"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { VSCodeButton, VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@v
|
|||||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||||
import { vscode } from "../../utils/vscode"
|
import { vscode } from "../../utils/vscode"
|
||||||
import { Virtuoso } from "react-virtuoso"
|
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 Fuse, { FuseResult } from "fuse.js"
|
||||||
import { formatLargeNumber } from "../../utils/format"
|
import { formatLargeNumber } from "../../utils/format"
|
||||||
|
|
||||||
@@ -82,30 +82,28 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
|||||||
const taskHistorySearchResults = useMemo(() => {
|
const taskHistorySearchResults = useMemo(() => {
|
||||||
let results = searchQuery ? highlight(fuse.search(searchQuery)) : presentableTasks
|
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) {
|
switch (sortOption) {
|
||||||
case "oldest":
|
case "oldest":
|
||||||
return a.ts - b.ts
|
return (a.ts || 0) - (b.ts || 0);
|
||||||
case "mostExpensive":
|
case "mostExpensive":
|
||||||
return (b.totalCost || 0) - (a.totalCost || 0)
|
return (b.totalCost || 0) - (a.totalCost || 0);
|
||||||
case "mostTokens":
|
case "mostTokens":
|
||||||
return (
|
const aTokens = (a.tokensIn || 0) + (a.tokensOut || 0) + (a.cacheWrites || 0) + (a.cacheReads || 0);
|
||||||
(b.tokensIn || 0) +
|
const bTokens = (b.tokensIn || 0) + (b.tokensOut || 0) + (b.cacheWrites || 0) + (b.cacheReads || 0);
|
||||||
(b.tokensOut || 0) +
|
return bTokens - aTokens;
|
||||||
(b.cacheWrites || 0) +
|
|
||||||
(b.cacheReads || 0) -
|
|
||||||
((a.tokensIn || 0) + (a.tokensOut || 0) + (a.cacheWrites || 0) + (a.cacheReads || 0))
|
|
||||||
)
|
|
||||||
case "mostRelevant":
|
case "mostRelevant":
|
||||||
// NOTE: you must never sort directly on object since it will cause members to be reordered
|
// Keep fuse order if searching, otherwise sort by newest
|
||||||
return searchQuery ? 0 : b.ts - a.ts // Keep fuse order if searching, otherwise sort by newest
|
return searchQuery ? 0 : (b.ts || 0) - (a.ts || 0);
|
||||||
case "newest":
|
case "newest":
|
||||||
default:
|
default:
|
||||||
return b.ts - a.ts
|
return (b.ts || 0) - (a.ts || 0);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
return results
|
|
||||||
}, [presentableTasks, searchQuery, fuse, sortOption])
|
}, [presentableTasks, searchQuery, fuse, sortOption])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -227,9 +225,16 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
|||||||
overflowY: "scroll",
|
overflowY: "scroll",
|
||||||
}}
|
}}
|
||||||
data={taskHistorySearchResults}
|
data={taskHistorySearchResults}
|
||||||
|
data-testid="virtuoso-container"
|
||||||
|
components={{
|
||||||
|
List: React.forwardRef((props, ref) => (
|
||||||
|
<div {...props} ref={ref} data-testid="virtuoso-item-list" />
|
||||||
|
))
|
||||||
|
}}
|
||||||
itemContent={(index, item) => (
|
itemContent={(index, item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
data-testid={`task-item-${item.id}`}
|
||||||
className="history-item"
|
className="history-item"
|
||||||
style={{
|
style={{
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
@@ -263,23 +268,23 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
|||||||
{formatDate(item.ts)}
|
{formatDate(item.ts)}
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: "flex", gap: "4px" }}>
|
<div style={{ display: "flex", gap: "4px" }}>
|
||||||
<VSCodeButton
|
<button
|
||||||
appearance="icon"
|
|
||||||
title="Copy Prompt"
|
title="Copy Prompt"
|
||||||
className="copy-button"
|
className="copy-button"
|
||||||
|
data-appearance="icon"
|
||||||
onClick={(e) => handleCopyTask(e, item.task)}>
|
onClick={(e) => handleCopyTask(e, item.task)}>
|
||||||
<span className="codicon codicon-copy"></span>
|
<span className="codicon codicon-copy"></span>
|
||||||
</VSCodeButton>
|
</button>
|
||||||
<VSCodeButton
|
<button
|
||||||
appearance="icon"
|
|
||||||
title="Delete Task"
|
title="Delete Task"
|
||||||
|
className="delete-button"
|
||||||
|
data-appearance="icon"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleDeleteHistoryItem(item.id)
|
handleDeleteHistoryItem(item.id)
|
||||||
}}
|
}}>
|
||||||
className="delete-button">
|
|
||||||
<span className="codicon codicon-trash"></span>
|
<span className="codicon codicon-trash"></span>
|
||||||
</VSCodeButton>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -298,6 +303,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
|||||||
/>
|
/>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||||
<div
|
<div
|
||||||
|
data-testid="tokens-container"
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
@@ -318,6 +324,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
|||||||
Tokens:
|
Tokens:
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
|
data-testid="tokens-in"
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -335,6 +342,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
|||||||
{formatLargeNumber(item.tokensIn || 0)}
|
{formatLargeNumber(item.tokensIn || 0)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
|
data-testid="tokens-out"
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -357,6 +365,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
|||||||
|
|
||||||
{!!item.cacheWrites && (
|
{!!item.cacheWrites && (
|
||||||
<div
|
<div
|
||||||
|
data-testid="cache-container"
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -371,6 +380,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
|||||||
Cache:
|
Cache:
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
|
data-testid="cache-writes"
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -388,6 +398,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
|||||||
+{formatLargeNumber(item.cacheWrites || 0)}
|
+{formatLargeNumber(item.cacheWrites || 0)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
|
data-testid="cache-reads"
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -503,27 +514,44 @@ export const highlight = (
|
|||||||
// Sort and merge overlapping regions
|
// Sort and merge overlapping regions
|
||||||
const mergedRegions = mergeRegions(regions)
|
const mergedRegions = mergeRegions(regions)
|
||||||
|
|
||||||
let content = ""
|
// Convert regions to a list of parts with their highlight status
|
||||||
let nextUnhighlightedRegionStartingIndex = 0
|
const parts: { text: string; highlight: boolean }[] = []
|
||||||
|
let lastIndex = 0
|
||||||
|
|
||||||
mergedRegions.forEach((region) => {
|
mergedRegions.forEach(([start, end]) => {
|
||||||
const start = region[0]
|
// Add non-highlighted text before this region
|
||||||
const end = region[1]
|
if (start > lastIndex) {
|
||||||
const lastRegionNextIndex = end + 1
|
parts.push({
|
||||||
|
text: inputText.substring(lastIndex, start),
|
||||||
|
highlight: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
content += [
|
// Add highlighted text
|
||||||
inputText.substring(nextUnhighlightedRegionStartingIndex, start),
|
parts.push({
|
||||||
`<span class="${highlightClassName}">`,
|
text: inputText.substring(start, end + 1),
|
||||||
inputText.substring(start, lastRegionNextIndex),
|
highlight: true
|
||||||
"</span>",
|
|
||||||
].join("")
|
|
||||||
|
|
||||||
nextUnhighlightedRegionStartingIndex = lastRegionNextIndex
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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
|
return fuseSearchResult
|
||||||
|
|||||||
@@ -1,362 +1,232 @@
|
|||||||
import React from 'react'
|
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 HistoryView from '../HistoryView'
|
||||||
import { ExtensionStateContextProvider } from '../../../context/ExtensionStateContext'
|
import { useExtensionState } from '../../../context/ExtensionStateContext'
|
||||||
import { vscode } from '../../../utils/vscode'
|
import { vscode } from '../../../utils/vscode'
|
||||||
import { highlight } from '../HistoryView'
|
|
||||||
import { FuseResult } from 'fuse.js'
|
|
||||||
|
|
||||||
// Mock vscode API
|
// Mock dependencies
|
||||||
jest.mock('../../../utils/vscode', () => ({
|
jest.mock('../../../context/ExtensionStateContext')
|
||||||
vscode: {
|
jest.mock('../../../utils/vscode')
|
||||||
postMessage: jest.fn(),
|
jest.mock('react-virtuoso', () => ({
|
||||||
},
|
Virtuoso: ({ data, itemContent }: any) => (
|
||||||
}))
|
<div data-testid="virtuoso-container">
|
||||||
|
{data.map((item: any, index: number) => (
|
||||||
interface VSCodeButtonProps {
|
<div key={item.id} data-testid={`virtuoso-item-${item.id}`}>
|
||||||
children: React.ReactNode;
|
{itemContent(index, item)}
|
||||||
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}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
},
|
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock window.navigator.clipboard
|
const mockTaskHistory = [
|
||||||
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 = [
|
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
task: 'First task',
|
task: 'Test task 1',
|
||||||
ts: Date.now() - 3000,
|
ts: new Date('2022-02-16T00:00:00').getTime(),
|
||||||
tokensIn: 100,
|
tokensIn: 100,
|
||||||
tokensOut: 50,
|
tokensOut: 50,
|
||||||
totalCost: 0.002
|
totalCost: 0.002,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
task: 'Second task',
|
task: 'Test task 2',
|
||||||
ts: Date.now() - 2000,
|
ts: new Date('2022-02-17T00:00:00').getTime(),
|
||||||
tokensIn: 200,
|
tokensIn: 200,
|
||||||
tokensOut: 100,
|
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(() => {
|
beforeEach(() => {
|
||||||
|
// Reset all mocks before each test
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
|
jest.useFakeTimers()
|
||||||
|
|
||||||
|
// Mock useExtensionState implementation
|
||||||
|
;(useExtensionState as jest.Mock).mockReturnValue({
|
||||||
|
taskHistory: mockTaskHistory,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders history items in correct order', () => {
|
afterEach(() => {
|
||||||
render(
|
jest.useRealTimers()
|
||||||
<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')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles sorting by different criteria', async () => {
|
it('renders history items correctly', () => {
|
||||||
render(
|
const onDone = jest.fn()
|
||||||
<ExtensionStateContextProvider>
|
render(<HistoryView onDone={onDone} />)
|
||||||
<HistoryView onDone={mockOnDone} />
|
|
||||||
</ExtensionStateContextProvider>
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
it('handles search functionality', async () => {
|
||||||
const oldestRadio = screen.getByTestId('radio-oldest')
|
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)
|
fireEvent.click(oldestRadio)
|
||||||
|
|
||||||
let historyItems = screen.getAllByText(/task/i)
|
// Wait for oldest radio to be checked
|
||||||
expect(historyItems[0]).toHaveTextContent('First task')
|
const checkedOldestRadio = await within(radioGroup).findByRole('radio', { name: 'Oldest', checked: true })
|
||||||
expect(historyItems[2]).toHaveTextContent('Third task')
|
expect(checkedOldestRadio).toBeInTheDocument()
|
||||||
|
|
||||||
// Test most expensive sort
|
const mostExpensiveRadio = within(radioGroup).getByLabelText('Most Expensive')
|
||||||
const expensiveRadio = screen.getByTestId('radio-mostExpensive')
|
fireEvent.click(mostExpensiveRadio)
|
||||||
fireEvent.click(expensiveRadio)
|
|
||||||
|
|
||||||
historyItems = screen.getAllByText(/task/i)
|
// Wait for most expensive radio to be checked
|
||||||
expect(historyItems[0]).toHaveTextContent('Third task')
|
const checkedExpensiveRadio = await within(radioGroup).findByRole('radio', { name: 'Most Expensive', checked: true })
|
||||||
expect(historyItems[2]).toHaveTextContent('First task')
|
expect(checkedExpensiveRadio).toBeInTheDocument()
|
||||||
|
|
||||||
// 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')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles search functionality and auto-switches to most relevant sort', async () => {
|
it('handles task selection', () => {
|
||||||
render(
|
const onDone = jest.fn()
|
||||||
<ExtensionStateContextProvider>
|
render(<HistoryView onDone={onDone} />)
|
||||||
<HistoryView onDone={mockOnDone} />
|
|
||||||
</ExtensionStateContextProvider>
|
|
||||||
)
|
|
||||||
|
|
||||||
mockPostMessage({ taskHistory: sampleHistory })
|
// Click on first task
|
||||||
|
fireEvent.click(screen.getByText('Test task 1'))
|
||||||
|
|
||||||
const searchInput = screen.getByPlaceholderText('Fuzzy search history...')
|
// Verify vscode message was sent
|
||||||
fireEvent.change(searchInput, { target: { value: 'First' } })
|
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||||
|
type: 'showTaskWithId',
|
||||||
const historyItems = screen.getAllByText(/task/i)
|
text: '1',
|
||||||
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')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles copy functionality and shows/hides modal', async () => {
|
it('handles task deletion', () => {
|
||||||
render(
|
const onDone = jest.fn()
|
||||||
<ExtensionStateContextProvider>
|
render(<HistoryView onDone={onDone} />)
|
||||||
<HistoryView onDone={mockOnDone} />
|
|
||||||
</ExtensionStateContextProvider>
|
|
||||||
)
|
|
||||||
|
|
||||||
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 })
|
const deleteButton = within(taskContainer).getByTitle('Delete Task')
|
||||||
.filter(button => button.className.includes('copy-button'))
|
fireEvent.click(deleteButton)
|
||||||
|
|
||||||
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])
|
|
||||||
|
|
||||||
|
// Verify vscode message was sent
|
||||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||||
type: 'deleteTaskWithId',
|
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', () => {
|
it('handles export functionality', () => {
|
||||||
render(
|
const onDone = jest.fn()
|
||||||
<ExtensionStateContextProvider>
|
render(<HistoryView onDone={onDone} />)
|
||||||
<HistoryView onDone={mockOnDone} />
|
|
||||||
</ExtensionStateContextProvider>
|
|
||||||
)
|
|
||||||
|
|
||||||
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 })
|
const exportButton = within(taskContainer).getByText('EXPORT')
|
||||||
.filter(button => button.className.includes('export-button'))
|
fireEvent.click(exportButton)
|
||||||
|
|
||||||
fireEvent.click(exportButtons[0])
|
|
||||||
|
|
||||||
|
// Verify vscode message was sent
|
||||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||||
type: 'exportTaskWithId',
|
type: 'exportTaskWithId',
|
||||||
text: '3'
|
text: '2',
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
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>')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -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', () => {
|
describe('McpToolRow', () => {
|
||||||
const mockTool = {
|
const mockTool = {
|
||||||
name: 'test-tool',
|
name: 'test-tool',
|
||||||
@@ -33,14 +57,14 @@ describe('McpToolRow', () => {
|
|||||||
expect(screen.queryByText('Always allow')).not.toBeInTheDocument()
|
expect(screen.queryByText('Always allow')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows always allow checkbox when serverName is provided', () => {
|
it('shows always allow checkbox when serverName and alwaysAllowMcp are provided', () => {
|
||||||
render(<McpToolRow tool={mockTool} serverName="test-server" />)
|
render(<McpToolRow tool={mockTool} serverName="test-server" alwaysAllowMcp={true} />)
|
||||||
|
|
||||||
expect(screen.getByText('Always allow')).toBeInTheDocument()
|
expect(screen.getByText('Always allow')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sends message to toggle always allow when checkbox is clicked', () => {
|
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')
|
const checkbox = screen.getByRole('checkbox')
|
||||||
fireEvent.click(checkbox)
|
fireEvent.click(checkbox)
|
||||||
@@ -59,22 +83,24 @@ describe('McpToolRow', () => {
|
|||||||
alwaysAllow: true
|
alwaysAllow: true
|
||||||
}
|
}
|
||||||
|
|
||||||
render(<McpToolRow tool={alwaysAllowedTool} serverName="test-server" />)
|
render(<McpToolRow tool={alwaysAllowedTool} serverName="test-server" alwaysAllowMcp={true} />)
|
||||||
|
|
||||||
const checkbox = screen.getByRole('checkbox')
|
const checkbox = screen.getByRole('checkbox') as HTMLInputElement
|
||||||
expect(checkbox).toBeChecked()
|
expect(checkbox.checked).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('prevents event propagation when clicking the checkbox', () => {
|
it('prevents event propagation when clicking the checkbox', () => {
|
||||||
const mockStopPropagation = jest.fn()
|
const mockOnClick = jest.fn()
|
||||||
render(<McpToolRow tool={mockTool} serverName="test-server" />)
|
render(
|
||||||
|
<div onClick={mockOnClick}>
|
||||||
|
<McpToolRow tool={mockTool} serverName="test-server" alwaysAllowMcp={true} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
const container = screen.getByTestId('tool-row-container')
|
const container = screen.getByTestId('tool-row-container')
|
||||||
fireEvent.click(container, {
|
fireEvent.click(container)
|
||||||
stopPropagation: mockStopPropagation
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockStopPropagation).toHaveBeenCalled()
|
expect(mockOnClick).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('displays input schema parameters when provided', () => {
|
it('displays input schema parameters when provided', () => {
|
||||||
|
|||||||
@@ -245,6 +245,12 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
|
|||||||
<VSCodeTextField
|
<VSCodeTextField
|
||||||
value={commandInput}
|
value={commandInput}
|
||||||
onInput={(e: any) => setCommandInput(e.target.value)}
|
onInput={(e: any) => setCommandInput(e.target.value)}
|
||||||
|
onKeyDown={(e: any) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleAddCommand()
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder="Enter command prefix (e.g., 'git ')"
|
placeholder="Enter command prefix (e.g., 'git ')"
|
||||||
style={{ flexGrow: 1 }}
|
style={{ flexGrow: 1 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
import '@testing-library/jest-dom';
|
||||||
// allows you to do things like:
|
|
||||||
// expect(element).toHaveTextContent(/react/i)
|
// Mock window.matchMedia
|
||||||
// learn more: https://github.com/testing-library/jest-dom
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
import "@testing-library/jest-dom"
|
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(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user