mirror of
https://github.com/cline/cline.git
synced 2026-09-11 16:42:40 +08:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c2198f403 | ||
|
|
067498224c | ||
|
|
7702d7d0cd | ||
|
|
5da0b96a2e | ||
|
|
8fc7835b03 | ||
|
|
5a2eb0dd75 | ||
|
|
0c49b36b46 | ||
|
|
01777c5dac | ||
|
|
b732f996ed | ||
|
|
af77087f4e | ||
|
|
4b46a9170f | ||
|
|
e78c0d8c22 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Replace testing libraries with Vitest. Remove chai, sinon, should, and proxyquire in favor of Vitest's built-in testing utilities. Keep Mocha types for VSCode test runner compatibility. Use `__tests__` folder convention everywhere.
|
||||
@@ -187,4 +187,4 @@ To contribute to the project, start with our [Contributing Guide](CONTRIBUTING.m
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
Generated
+900
-550
File diff suppressed because it is too large
Load Diff
+12
-10
@@ -306,10 +306,12 @@
|
||||
"lint": "eslint src --ext ts && eslint webview-ui/src --ext ts",
|
||||
"format": "prettier . --check",
|
||||
"format:fix": "prettier . --write",
|
||||
"test": "npm-run-all test:unit test:integration",
|
||||
"test": "npm-run-all test:unit test:integration test:webview",
|
||||
"test:ci": "node scripts/test-ci.js",
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:watch": "vitest",
|
||||
"test:unit:coverage": "vitest run --coverage",
|
||||
"test:integration": "vscode-test",
|
||||
"test:unit": "TS_NODE_PROJECT='./tsconfig.unit-test.json' mocha",
|
||||
"test:coverage": "vscode-test --coverage",
|
||||
"install:all": "npm install && cd webview-ui && npm install",
|
||||
"dev:webview": "cd webview-ui && npm run dev",
|
||||
@@ -323,23 +325,23 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.27.12",
|
||||
"@types/chai": "^5.0.1",
|
||||
"@types/chai": "^5.2.1",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@types/diff": "^5.2.1",
|
||||
"@types/get-folder-size": "^3.0.4",
|
||||
"@types/mocha": "^10.0.7",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/node": "20.x",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
"@types/proxyquire": "^1.3.31",
|
||||
"@types/should": "^11.2.0",
|
||||
"@types/sinon": "^17.0.4",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@types/vscode": "^1.84.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.14.1",
|
||||
"@typescript-eslint/parser": "^7.11.0",
|
||||
"@vscode/test-cli": "^0.0.9",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"@vscode/test-cli": "^0.0.10",
|
||||
"@vscode/test-electron": "^2.4.0",
|
||||
"chai": "^4.3.10",
|
||||
"chai": "^4.5.0",
|
||||
"chalk": "^5.3.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"eslint": "^8.57.0",
|
||||
@@ -347,12 +349,12 @@
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.3.3",
|
||||
"protoc-gen-ts": "^0.8.7",
|
||||
"proxyquire": "^2.1.3",
|
||||
"should": "^13.2.3",
|
||||
"sinon": "^19.0.2",
|
||||
"sinon": "^20.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"ts-proto": "^2.6.1",
|
||||
"typescript": "^5.4.5"
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/bedrock-sdk": "^0.12.4",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import { withRetry } from "./retry"
|
||||
import { describe, it, expect, assert } from "vitest"
|
||||
import { withRetry } from "../retry"
|
||||
|
||||
describe("Retry Decorator", () => {
|
||||
describe("withRetry", () => {
|
||||
@@ -20,8 +19,8 @@ describe("Retry Decorator", () => {
|
||||
result.push(value)
|
||||
}
|
||||
|
||||
callCount.should.equal(1)
|
||||
result.should.deepEqual(["success"])
|
||||
expect(callCount).toBe(1)
|
||||
expect(result).toEqual(["success"])
|
||||
})
|
||||
|
||||
it("should retry on rate limit (429) error", async () => {
|
||||
@@ -45,8 +44,8 @@ describe("Retry Decorator", () => {
|
||||
result.push(value)
|
||||
}
|
||||
|
||||
callCount.should.equal(2)
|
||||
result.should.deepEqual(["success after retry"])
|
||||
expect(callCount).toBe(2)
|
||||
expect(result).toEqual(["success after retry"])
|
||||
})
|
||||
|
||||
it("should not retry on non-rate-limit errors", async () => {
|
||||
@@ -66,8 +65,8 @@ describe("Retry Decorator", () => {
|
||||
}
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.equal("Regular error")
|
||||
callCount.should.equal(1)
|
||||
expect(error.message).toBe("Regular error")
|
||||
expect(callCount).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -95,9 +94,9 @@ describe("Retry Decorator", () => {
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
duration.should.be.approximately(10, 10) // Allow 10ms variance
|
||||
callCount.should.equal(2)
|
||||
result.should.deepEqual(["success after retry"])
|
||||
assert.closeTo(duration, 10, 10, "duration should be 10 ± 10ms")
|
||||
expect(callCount).toBe(2)
|
||||
expect(result).toEqual(["success after retry"])
|
||||
})
|
||||
|
||||
it("should respect retry-after header with Unix timestamp", async () => {
|
||||
@@ -126,9 +125,9 @@ describe("Retry Decorator", () => {
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
duration.should.be.approximately(10, 10) // Allow 10ms variance
|
||||
callCount.should.equal(2)
|
||||
result.should.deepEqual(["success after retry"])
|
||||
assert.closeTo(duration, 10, 10, "duration should be 10 ± 10ms")
|
||||
expect(callCount).toBe(2)
|
||||
expect(result).toEqual(["success after retry"])
|
||||
})
|
||||
|
||||
it("should use exponential backoff when no retry-after header", async () => {
|
||||
@@ -155,9 +154,9 @@ describe("Retry Decorator", () => {
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
// First retry should be after baseDelay (10ms)
|
||||
duration.should.be.approximately(10, 10)
|
||||
callCount.should.equal(2)
|
||||
result.should.deepEqual(["success after retry"])
|
||||
assert.closeTo(duration, 10, 10, "duration should be 10 ± 10ms")
|
||||
expect(callCount).toBe(2)
|
||||
expect(result).toEqual(["success after retry"])
|
||||
})
|
||||
|
||||
it("should respect maxDelay", async () => {
|
||||
@@ -184,9 +183,9 @@ describe("Retry Decorator", () => {
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
// Both retries should be capped at maxDelay (10ms each)
|
||||
duration.should.be.approximately(20, 20)
|
||||
callCount.should.equal(3)
|
||||
result.should.deepEqual(["success after retries"])
|
||||
assert.closeTo(duration, 20, 10, "duration should be 20 ± 10ms")
|
||||
expect(callCount).toBe(3)
|
||||
expect(result).toEqual(["success after retries"])
|
||||
})
|
||||
|
||||
it("should throw after maxRetries attempts", async () => {
|
||||
@@ -208,8 +207,8 @@ describe("Retry Decorator", () => {
|
||||
}
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.equal("Rate limit exceeded")
|
||||
callCount.should.equal(2) // Initial attempt + 1 retry
|
||||
expect(error.message).toBe("Rate limit exceeded")
|
||||
expect(callCount).toBe(2) // Initial attempt + 1 retry
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, it, beforeEach, afterEach, beforeAll, expect, vi } from "vitest"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { OllamaHandler } from "../ollama"
|
||||
import { ApiHandlerOptions } from "../../../shared/api"
|
||||
import axios from "axios"
|
||||
|
||||
describe("OllamaHandler", () => {
|
||||
let ollamaAvailable = false
|
||||
|
||||
// Check if Ollama is running before running tests
|
||||
beforeAll(async function () {
|
||||
try {
|
||||
await axios.get("http://localhost:11434/api/version", { timeout: 2000 })
|
||||
ollamaAvailable = true
|
||||
} catch (error) {
|
||||
console.log("Ollama server not available, skipping tests")
|
||||
ollamaAvailable = false
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
let handler: OllamaHandler
|
||||
let options: ApiHandlerOptions
|
||||
|
||||
beforeEach(() => {
|
||||
options = {
|
||||
ollamaModelId: "llama2",
|
||||
ollamaBaseUrl: "http://localhost:11434",
|
||||
}
|
||||
handler = new OllamaHandler(options)
|
||||
// Use fake timers for testing timeouts
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
it("should handle successful responses", async function ({ skip }) {
|
||||
if (!ollamaAvailable) {
|
||||
skip()
|
||||
}
|
||||
// Mock the Ollama client's chat method
|
||||
const chatStub = vi.spyOn(handler["client"], "chat").mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
message: { content: "Hello, world!" },
|
||||
eval_count: 10,
|
||||
prompt_eval_count: 20,
|
||||
}
|
||||
},
|
||||
} as any)
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const result = []
|
||||
const usageInfo = []
|
||||
|
||||
// Collect the results
|
||||
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
|
||||
if (chunk.type === "text") {
|
||||
result.push(chunk.text)
|
||||
} else if (chunk.type === "usage") {
|
||||
usageInfo.push({
|
||||
inputTokens: chunk.inputTokens,
|
||||
outputTokens: chunk.outputTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the results
|
||||
expect(result).toEqual(["Hello, world!"])
|
||||
expect(usageInfo).toEqual([{ inputTokens: 20, outputTokens: 10 }])
|
||||
expect(chatStub).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should handle timeout errors", { timeout: 10000 }, async function ({ skip }) {
|
||||
if (!ollamaAvailable) {
|
||||
skip()
|
||||
}
|
||||
|
||||
// Restore real timers for this test
|
||||
vi.useRealTimers()
|
||||
|
||||
// Create a handler with a very short timeout for testing
|
||||
const testHandler = new OllamaHandler(options)
|
||||
|
||||
// Replace the createMessage method with one that has a shorter timeout
|
||||
testHandler.createMessage = async function* (systemPrompt, messages) {
|
||||
try {
|
||||
// Create a promise that rejects after a short timeout
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Ollama request timed out after 30 seconds")), 100)
|
||||
})
|
||||
|
||||
// Create a promise that never resolves
|
||||
const neverPromise = new Promise(() => {})
|
||||
|
||||
// Race them
|
||||
await Promise.race([timeoutPromise, neverPromise])
|
||||
} catch (error: any) {
|
||||
// Enhance error reporting
|
||||
console.error(`Ollama API error: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
// Start the request and catch the error
|
||||
let errorMessage = ""
|
||||
try {
|
||||
for await (const _ of testHandler.createMessage(systemPrompt, messages)) {
|
||||
// This should not be reached
|
||||
}
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
// Check the result
|
||||
expect(errorMessage).toBe("Ollama request timed out after 30 seconds")
|
||||
|
||||
// Restore the fake timers for other tests
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
it("should retry on errors when using the withRetry decorator", { timeout: 10000 }, async function ({ skip }) {
|
||||
if (!ollamaAvailable) {
|
||||
skip()
|
||||
}
|
||||
|
||||
// Restore real timers for this test
|
||||
vi.useRealTimers()
|
||||
|
||||
// Mock the Ollama client's chat method to fail on first call and succeed on second
|
||||
const chatStub = vi.spyOn(handler["client"], "chat")
|
||||
|
||||
// First call throws an error
|
||||
chatStub.mockRejectedValueOnce(new Error("API Error"))
|
||||
|
||||
// Second call succeeds
|
||||
chatStub.mockResolvedValueOnce({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
message: { content: "Success after retry" },
|
||||
}
|
||||
},
|
||||
} as any)
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const result = []
|
||||
|
||||
// Add a small delay to ensure the retry mechanism has time to work
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Collect the results
|
||||
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
|
||||
if (chunk.type === "text") {
|
||||
result.push(chunk.text)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the results
|
||||
expect(result).toEqual(["Success after retry"])
|
||||
expect(chatStub).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Restore the fake timers for other tests
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
it("should handle stream processing errors", { timeout: 10000 }, async function ({ skip }) {
|
||||
if (!ollamaAvailable) {
|
||||
skip()
|
||||
}
|
||||
|
||||
// Restore real timers for this test
|
||||
vi.useRealTimers()
|
||||
|
||||
// Create a handler with a custom implementation for testing
|
||||
const testHandler = new OllamaHandler(options)
|
||||
|
||||
// Replace the createMessage method with one that simulates a stream error
|
||||
testHandler.createMessage = async function* (systemPrompt, messages) {
|
||||
// First yield a successful chunk
|
||||
yield {
|
||||
type: "text",
|
||||
text: "Partial response",
|
||||
}
|
||||
|
||||
// Then throw an error in the stream
|
||||
throw new Error("Ollama stream processing error: Stream error")
|
||||
}
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const result = []
|
||||
|
||||
// Collect the results and catch the error
|
||||
let errorMessage = ""
|
||||
try {
|
||||
for await (const chunk of testHandler.createMessage(systemPrompt, messages)) {
|
||||
if (chunk.type === "text") {
|
||||
result.push(chunk.text)
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
// Verify the results
|
||||
expect(errorMessage).toBe("Ollama stream processing error: Stream error")
|
||||
expect(result).toEqual(["Partial response"])
|
||||
|
||||
// Restore the fake timers for other tests
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
// This file contains `declare module "vscode"` so we must import it.
|
||||
import "../../providers/vscode-lm"
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import * as vscode from "vscode"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { asObjectSafe, convertToAnthropicRole, convertToVsCodeLmMessages, convertToAnthropicMessage } from "../vscode-lm-format"
|
||||
|
||||
describe("asObjectSafe", () => {
|
||||
it("should handle falsy values", () => {
|
||||
asObjectSafe(0).should.deepEqual({})
|
||||
asObjectSafe("").should.deepEqual({})
|
||||
asObjectSafe(null).should.deepEqual({})
|
||||
asObjectSafe(undefined).should.deepEqual({})
|
||||
})
|
||||
|
||||
it("should parse valid JSON strings", () => {
|
||||
asObjectSafe('{"key": "value"}').should.deepEqual({ key: "value" })
|
||||
})
|
||||
|
||||
it("should return an empty object for invalid JSON strings", () => {
|
||||
asObjectSafe("invalid json").should.deepEqual({})
|
||||
})
|
||||
|
||||
it("should convert objects to plain objects", () => {
|
||||
const input = { prop: "value" }
|
||||
asObjectSafe(input).should.deepEqual(input)
|
||||
asObjectSafe(input).should.not.equal(input) // Should be a new object
|
||||
})
|
||||
|
||||
it("should convert arrays to plain objects", () => {
|
||||
const input = ["hello world"]
|
||||
asObjectSafe(input).should.deepEqual({ 0: "hello world" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("convertToAnthropicRole", () => {
|
||||
it("should convert VSCode roles to Anthropic roles", () => {
|
||||
// @ts-expect-error(Testing with an invalid role)
|
||||
const unknownRole = "unknown" as vscode.LanguageModelChatMessageRole
|
||||
;(convertToAnthropicRole(vscode.LanguageModelChatMessageRole.Assistant) === "assistant").should.be.true()
|
||||
;(convertToAnthropicRole(vscode.LanguageModelChatMessageRole.User) === "user").should.be.true()
|
||||
;(convertToAnthropicRole(unknownRole) === null).should.be.true()
|
||||
})
|
||||
})
|
||||
|
||||
describe("convertToVsCodeLmMessages", () => {
|
||||
it("should convert simple string messages", () => {
|
||||
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: "Hello" },
|
||||
{ role: "assistant", content: "Hi there" },
|
||||
]
|
||||
|
||||
const result = convertToVsCodeLmMessages(anthropicMessages)
|
||||
|
||||
result.should.have.length(2)
|
||||
result[0].role.should.equal(vscode.LanguageModelChatMessageRole.User)
|
||||
result[0].content[0].should.be.instanceof(vscode.LanguageModelTextPart)
|
||||
const textPart0 = result[0].content[0] as vscode.LanguageModelTextPart
|
||||
textPart0.should.have.property("value", "Hello")
|
||||
|
||||
result[1].role.should.equal(vscode.LanguageModelChatMessageRole.Assistant)
|
||||
result[1].content[0].should.be.instanceof(vscode.LanguageModelTextPart)
|
||||
const textPart1 = result[1].content[0] as vscode.LanguageModelTextPart
|
||||
textPart1.should.have.property("value", "Hi there")
|
||||
})
|
||||
|
||||
it("should convert complex user messages with tool results", () => {
|
||||
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "User text" },
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool-123",
|
||||
content: [{ type: "text", text: "Tool result" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToVsCodeLmMessages(anthropicMessages)
|
||||
|
||||
result.should.have.length(1)
|
||||
result[0].role.should.equal(vscode.LanguageModelChatMessageRole.User)
|
||||
result[0].content.should.have.length(2)
|
||||
|
||||
// Check that the first content part is a ToolResultPart
|
||||
result[0].content[0].should.be.instanceof(vscode.LanguageModelToolResultPart)
|
||||
const toolResultPart = result[0].content[0] as vscode.LanguageModelToolResultPart
|
||||
toolResultPart.should.have.property("callId", "tool-123")
|
||||
|
||||
// Skip detailed testing of internal structure as it may vary
|
||||
// Just verify it's the right type with the right ID
|
||||
|
||||
// Check the second content part is a TextPart
|
||||
result[0].content[1].should.be.instanceof(vscode.LanguageModelTextPart)
|
||||
const textPart = result[0].content[1] as vscode.LanguageModelTextPart
|
||||
textPart.should.have.property("value", "User text")
|
||||
})
|
||||
|
||||
it("should convert complex assistant messages with tool calls", () => {
|
||||
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Assistant text" },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-123",
|
||||
name: "testTool",
|
||||
input: { param: "value" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToVsCodeLmMessages(anthropicMessages)
|
||||
|
||||
result.should.have.length(1)
|
||||
result[0].role.should.equal(vscode.LanguageModelChatMessageRole.Assistant)
|
||||
result[0].content.should.have.length(2)
|
||||
|
||||
result[0].content[0].should.be.instanceof(vscode.LanguageModelToolCallPart)
|
||||
const toolCallPart = result[0].content[0] as vscode.LanguageModelToolCallPart
|
||||
toolCallPart.should.have.property("callId", "tool-123")
|
||||
toolCallPart.should.have.property("name", "testTool")
|
||||
toolCallPart.should.have.property("input")
|
||||
toolCallPart.input.should.deepEqual({ param: "value" })
|
||||
|
||||
result[0].content[1].should.be.instanceof(vscode.LanguageModelTextPart)
|
||||
const textPart = result[0].content[1] as vscode.LanguageModelTextPart
|
||||
textPart.should.have.property("value", "Assistant text")
|
||||
})
|
||||
|
||||
it("should handle image blocks with appropriate placeholders", () => {
|
||||
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: "image/jpeg",
|
||||
data: "base64data",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToVsCodeLmMessages(anthropicMessages)
|
||||
|
||||
result.should.have.length(1)
|
||||
result[0].content[0].should.be.instanceof(vscode.LanguageModelTextPart)
|
||||
const textPart = result[0].content[0] as vscode.LanguageModelTextPart
|
||||
textPart.should.have.property("value")
|
||||
textPart.value.should.match(/Image \(base64\): image\/jpeg not supported by VSCode LM API/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("convertToAnthropicMessage", () => {
|
||||
it("should convert VSCode assistant messages to Anthropic format", () => {
|
||||
const vsCodeMsg = vscode.LanguageModelChatMessage.Assistant([
|
||||
new vscode.LanguageModelTextPart("Test message"),
|
||||
new vscode.LanguageModelToolCallPart("tool-id", "testTool", { param: "value" }),
|
||||
])
|
||||
|
||||
const result = convertToAnthropicMessage(vsCodeMsg)
|
||||
|
||||
result.should.have.property("role", "assistant")
|
||||
result.should.have.property("content").which.is.an.Array()
|
||||
result.content.should.have.length(2)
|
||||
|
||||
// Check properties carefully to avoid null reference errors
|
||||
if (result.content && result.content.length >= 1) {
|
||||
const textContent = result.content[0]
|
||||
if (textContent) {
|
||||
textContent.should.have.property("type", "text")
|
||||
if (textContent.type === "text") {
|
||||
textContent.should.have.property("text", "Test message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.content && result.content.length >= 2) {
|
||||
const toolContent = result.content[1]
|
||||
if (toolContent) {
|
||||
toolContent.should.have.property("type", "tool_use")
|
||||
if (toolContent.type === "tool_use") {
|
||||
toolContent.should.have.property("id", "tool-id")
|
||||
toolContent.should.have.property("name", "testTool")
|
||||
toolContent.should.have.property("input").which.deepEqual({ param: "value" })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw an error for non-assistant messages", () => {
|
||||
const vsCodeMsg = vscode.LanguageModelChatMessage.User("User message")
|
||||
|
||||
try {
|
||||
convertToAnthropicMessage(vsCodeMsg)
|
||||
throw new Error("Should have thrown an error")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/Only assistant messages are supported/)
|
||||
}
|
||||
})
|
||||
})
|
||||
+3
-3
@@ -3,9 +3,9 @@ import { expect } from "chai"
|
||||
import * as sinon from "sinon"
|
||||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import { FileContextTracker } from "./FileContextTracker"
|
||||
import * as diskModule from "../../storage/disk"
|
||||
import type { TaskMetadata, FileMetadataEntry } from "./ContextTrackerTypes"
|
||||
import { FileContextTracker } from "../FileContextTracker"
|
||||
import * as diskModule from "../../../storage/disk"
|
||||
import type { TaskMetadata, FileMetadataEntry } from "../ContextTrackerTypes"
|
||||
|
||||
describe("FileContextTracker", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import { expect } from "chai"
|
||||
import * as sinon from "sinon"
|
||||
import * as vscode from "vscode"
|
||||
import { ModelContextTracker } from "../ModelContextTracker"
|
||||
import * as diskModule from "../../../storage/disk"
|
||||
import type { TaskMetadata } from "../ContextTrackerTypes"
|
||||
|
||||
describe("ModelContextTracker", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let mockContext: vscode.ExtensionContext
|
||||
let tracker: ModelContextTracker
|
||||
let taskId: string
|
||||
let mockTaskMetadata: TaskMetadata
|
||||
let getTaskMetadataStub: sinon.SinonStub
|
||||
let saveTaskMetadataStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
// Mock controller and context
|
||||
mockContext = {
|
||||
globalStorageUri: { fsPath: "/mock/storage" },
|
||||
} as unknown as vscode.ExtensionContext
|
||||
|
||||
// Mock disk module functions
|
||||
mockTaskMetadata = { files_in_context: [], model_usage: [] }
|
||||
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
|
||||
saveTaskMetadataStub = sandbox.stub(diskModule, "saveTaskMetadata").resolves()
|
||||
|
||||
// Create tracker instance
|
||||
taskId = "test-task-id"
|
||||
tracker = new ModelContextTracker(mockContext, taskId)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
it("should record model usage with correct data", async () => {
|
||||
// Test data
|
||||
const apiProviderId = "anthropic"
|
||||
const modelId = "claude-3-opus"
|
||||
const mode = "act"
|
||||
|
||||
// Use a fake timer to have a predictable timestamp
|
||||
const fakeNow = 1617293940000 // Some fixed timestamp
|
||||
const clock = sandbox.useFakeTimers(fakeNow)
|
||||
|
||||
try {
|
||||
// Call the method being tested
|
||||
await tracker.recordModelUsage(apiProviderId, modelId, mode)
|
||||
|
||||
// Verify getTaskMetadata was called with correct parameters
|
||||
expect(getTaskMetadataStub.calledOnce).to.be.true
|
||||
expect(getTaskMetadataStub.firstCall.args[1]).to.equal(taskId)
|
||||
|
||||
// Verify saveTaskMetadata was called with the correct data
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
|
||||
// Extract the saved metadata from the call arguments
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
|
||||
|
||||
// Verify model_usage array has one entry
|
||||
expect(savedMetadata.model_usage.length).to.equal(1)
|
||||
|
||||
// Verify the entry has the correct properties
|
||||
const modelUsageEntry = savedMetadata.model_usage[0]
|
||||
expect(modelUsageEntry.ts).to.equal(fakeNow)
|
||||
expect(modelUsageEntry.model_id).to.equal(modelId)
|
||||
expect(modelUsageEntry.model_provider_id).to.equal(apiProviderId)
|
||||
expect(modelUsageEntry.mode).to.equal(mode)
|
||||
} finally {
|
||||
// Restore the clock
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw an error when controller is dereferenced", async () => {
|
||||
// Create a new tracker with a controller that will be garbage collected
|
||||
const weakTracker = new ModelContextTracker(mockContext, taskId)
|
||||
|
||||
// Force the WeakRef to return null by overriding the deref method
|
||||
const weakRef = { deref: sandbox.stub().returns(null) }
|
||||
sandbox.stub(WeakRef.prototype, "deref").callsFake(() => weakRef.deref())
|
||||
|
||||
try {
|
||||
// Try to call the method - this should throw
|
||||
await weakTracker.recordModelUsage("any-provider", "any-model", "any-mode")
|
||||
|
||||
// If we get here, the test should fail
|
||||
expect.fail("Expected an error to be thrown")
|
||||
} catch (error) {
|
||||
// Verify the error message
|
||||
expect(error.message).to.equal("Unable to access extension context")
|
||||
}
|
||||
})
|
||||
|
||||
it("should append model usage to existing entries", async () => {
|
||||
// Add an existing model usage entry
|
||||
const existingTimestamp = 1617200000000
|
||||
mockTaskMetadata.model_usage = [
|
||||
{
|
||||
ts: existingTimestamp,
|
||||
model_id: "existing-model",
|
||||
model_provider_id: "existing-provider",
|
||||
mode: "plan",
|
||||
},
|
||||
]
|
||||
|
||||
// Test data for new entry
|
||||
const apiProviderId = "anthropic"
|
||||
const modelId = "claude-3-sonnet"
|
||||
const mode = "act"
|
||||
|
||||
// Use a fake timer
|
||||
const newTimestamp = 1617300000000
|
||||
const clock = sandbox.useFakeTimers(newTimestamp)
|
||||
|
||||
try {
|
||||
// Call the method being tested
|
||||
await tracker.recordModelUsage(apiProviderId, modelId, mode)
|
||||
|
||||
// Verify saveTaskMetadata was called
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
|
||||
// Extract the saved metadata
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
|
||||
|
||||
// Verify model_usage array now has two entries
|
||||
expect(savedMetadata.model_usage.length).to.equal(2)
|
||||
|
||||
// Verify the existing entry is preserved
|
||||
expect(savedMetadata.model_usage[0]).to.deep.equal({
|
||||
ts: existingTimestamp,
|
||||
model_id: "existing-model",
|
||||
model_provider_id: "existing-provider",
|
||||
mode: "plan",
|
||||
})
|
||||
|
||||
// Verify the new entry has correct data
|
||||
expect(savedMetadata.model_usage[1]).to.deep.equal({
|
||||
ts: newTimestamp,
|
||||
model_id: modelId,
|
||||
model_provider_id: apiProviderId,
|
||||
mode: mode,
|
||||
})
|
||||
} finally {
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle multiple model usages in sequence", async () => {
|
||||
// Test data for sequential calls
|
||||
const usages = [
|
||||
{ provider: "anthropic", model: "claude-3-opus", mode: "plan" },
|
||||
{ provider: "openai", model: "gpt-4", mode: "act" },
|
||||
{ provider: "anthropic", model: "claude-3-haiku", mode: "plan" },
|
||||
]
|
||||
|
||||
// Use a fake timer that advances with each call
|
||||
const startTime = 1617300000000
|
||||
const clock = sandbox.useFakeTimers(startTime)
|
||||
|
||||
try {
|
||||
// Record multiple model usages
|
||||
for (let i = 0; i < usages.length; i++) {
|
||||
const { provider, model, mode } = usages[i]
|
||||
|
||||
// Advance time by 1 second for each call
|
||||
clock.tick(1000)
|
||||
const expectedTime = startTime + (i + 1) * 1000
|
||||
|
||||
// Reset history between calls to check individual call behavior
|
||||
getTaskMetadataStub.resetHistory()
|
||||
saveTaskMetadataStub.resetHistory()
|
||||
|
||||
// Reset mock metadata for each iteration to avoid accumulation
|
||||
mockTaskMetadata.model_usage = []
|
||||
|
||||
// Call the method
|
||||
await tracker.recordModelUsage(provider, model, mode)
|
||||
|
||||
// Verify interaction with disk module
|
||||
expect(getTaskMetadataStub.calledOnce).to.be.true
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
|
||||
// Get the saved metadata
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
|
||||
|
||||
// Since we reset the array for each call, we should always have 1 entry
|
||||
expect(savedMetadata.model_usage.length).to.equal(1)
|
||||
|
||||
// Check the entry
|
||||
const entry = savedMetadata.model_usage[0]
|
||||
expect(entry.ts).to.equal(expectedTime)
|
||||
expect(entry.model_id).to.equal(model)
|
||||
expect(entry.model_provider_id).to.equal(provider)
|
||||
expect(entry.mode).to.equal(mode)
|
||||
}
|
||||
} finally {
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
})
|
||||
+33
-34
@@ -1,9 +1,8 @@
|
||||
import { ClineIgnoreController } from "./ClineIgnoreController"
|
||||
import { ClineIgnoreController } from "../ClineIgnoreController"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { after, beforeEach, describe, it } from "mocha"
|
||||
import "should"
|
||||
import { describe, it, beforeEach, afterAll, expect } from "vitest"
|
||||
|
||||
describe("ClineIgnoreController", () => {
|
||||
let tempDir: string
|
||||
@@ -26,7 +25,7 @@ describe("ClineIgnoreController", () => {
|
||||
await controller.initialize()
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
afterAll(async () => {
|
||||
// Clean up temp directory
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
@@ -38,7 +37,7 @@ describe("ClineIgnoreController", () => {
|
||||
// controller.validateAccess(".git/config"),
|
||||
// controller.validateAccess("node_modules/package.json"),
|
||||
// ]
|
||||
// results.forEach((result) => result.should.be.false())
|
||||
// results.forEach((result) => expect(result).toBe(false))
|
||||
// })
|
||||
|
||||
it("should allow access to regular files", async () => {
|
||||
@@ -47,12 +46,12 @@ describe("ClineIgnoreController", () => {
|
||||
controller.validateAccess("README.md"),
|
||||
controller.validateAccess("package.json"),
|
||||
]
|
||||
results.forEach((result) => result.should.be.true())
|
||||
results.forEach((result) => expect(result).toBe(true))
|
||||
})
|
||||
|
||||
it("should block access to .clineignore file", async () => {
|
||||
const result = controller.validateAccess(".clineignore")
|
||||
result.should.be.false()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,7 +64,7 @@ describe("ClineIgnoreController", () => {
|
||||
controller.validateAccess("nested/deep/file.secret"),
|
||||
controller.validateAccess("private/nested/deep/file.txt"),
|
||||
]
|
||||
results.forEach((result) => result.should.be.false())
|
||||
results.forEach((result) => expect(result).toBe(false))
|
||||
})
|
||||
|
||||
it("should allow access to non-ignored files", async () => {
|
||||
@@ -76,7 +75,7 @@ describe("ClineIgnoreController", () => {
|
||||
controller.validateAccess("nested/deep/file.txt"),
|
||||
controller.validateAccess("not-private/data.txt"),
|
||||
]
|
||||
results.forEach((result) => result.should.be.true())
|
||||
results.forEach((result) => expect(result).toBe(true))
|
||||
})
|
||||
|
||||
it("should handle pattern edge cases", async () => {
|
||||
@@ -94,9 +93,9 @@ describe("ClineIgnoreController", () => {
|
||||
controller.validateAccess("script.tmp"), // Should be false (extension match)
|
||||
]
|
||||
|
||||
results[0].should.be.false() // data-123.json
|
||||
results[1].should.be.true() // data.json
|
||||
results[2].should.be.false() // script.tmp
|
||||
expect(results[0]).toBe(false) // data-123.json
|
||||
expect(results[1]).toBe(true) // data.json
|
||||
expect(results[2]).toBe(false) // script.tmp
|
||||
})
|
||||
|
||||
// ToDo: handle negation patterns successfully
|
||||
@@ -136,16 +135,16 @@ describe("ClineIgnoreController", () => {
|
||||
// controller.validateAccess("assets/public/data.json"), // Should be true (in negated public/)
|
||||
// ]
|
||||
|
||||
// results[0].should.be.false() // temp/file.txt
|
||||
// results[1].should.be.true() // temp/allowed/file.txt
|
||||
// results[2].should.be.true() // temp/allowed/nested/file.txt
|
||||
// results[3].should.be.false() // docs/guide.md
|
||||
// results[4].should.be.true() // docs/README.md
|
||||
// results[5].should.be.true() // docs/CONTRIBUTING.md
|
||||
// results[6].should.be.false() // docs/api/guide.md
|
||||
// results[7].should.be.false() // assets/logo.png
|
||||
// results[8].should.be.true() // assets/public/logo.png
|
||||
// results[9].should.be.true() // assets/public/data.json
|
||||
// expect(results[0]).toBe(false) // temp/file.txt
|
||||
// expect(results[1]).toBe(true) // temp/allowed/file.txt
|
||||
// expect(results[2]).toBe(true) // temp/allowed/nested/file.txt
|
||||
// expect(results[3]).toBe(false) // docs/guide.md
|
||||
// expect(results[4]).toBe(true) // docs/README.md
|
||||
// expect(results[5]).toBe(true) // docs/CONTRIBUTING.md
|
||||
// expect(results[6]).toBe(false) // docs/api/guide.md
|
||||
// expect(results[7]).toBe(false) // assets/logo.png
|
||||
// expect(results[8]).toBe(true) // assets/public/logo.png
|
||||
// expect(results[9]).toBe(true) // assets/public/data.json
|
||||
// })
|
||||
|
||||
it("should handle comments in .clineignore", async () => {
|
||||
@@ -159,7 +158,7 @@ describe("ClineIgnoreController", () => {
|
||||
await controller.initialize()
|
||||
|
||||
const result = controller.validateAccess("test.secret")
|
||||
result.should.be.false()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -168,36 +167,36 @@ describe("ClineIgnoreController", () => {
|
||||
// Test absolute path that should be allowed
|
||||
const allowedPath = path.join(tempDir, "src/file.ts")
|
||||
const allowedResult = controller.validateAccess(allowedPath)
|
||||
allowedResult.should.be.true()
|
||||
expect(allowedResult).toBe(true)
|
||||
|
||||
// Test absolute path that matches an ignore pattern (*.secret)
|
||||
const ignoredPath = path.join(tempDir, "config.secret")
|
||||
const ignoredResult = controller.validateAccess(ignoredPath)
|
||||
ignoredResult.should.be.false()
|
||||
expect(ignoredResult).toBe(false)
|
||||
|
||||
// Test absolute path in ignored directory (private/)
|
||||
const ignoredDirPath = path.join(tempDir, "private/data.txt")
|
||||
const ignoredDirResult = controller.validateAccess(ignoredDirPath)
|
||||
ignoredDirResult.should.be.false()
|
||||
expect(ignoredDirResult).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle relative paths and match ignore patterns", async () => {
|
||||
// Test relative path that should be allowed
|
||||
const allowedResult = controller.validateAccess("./src/file.ts")
|
||||
allowedResult.should.be.true()
|
||||
expect(allowedResult).toBe(true)
|
||||
|
||||
// Test relative path that matches an ignore pattern (*.secret)
|
||||
const ignoredResult = controller.validateAccess("./config.secret")
|
||||
ignoredResult.should.be.false()
|
||||
expect(ignoredResult).toBe(false)
|
||||
|
||||
// Test relative path in ignored directory (private/)
|
||||
const ignoredDirResult = controller.validateAccess("./private/data.txt")
|
||||
ignoredDirResult.should.be.false()
|
||||
expect(ignoredDirResult).toBe(false)
|
||||
})
|
||||
|
||||
it("should normalize paths with backslashes", async () => {
|
||||
const result = controller.validateAccess("src\\file.ts")
|
||||
result.should.be.true()
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -206,7 +205,7 @@ describe("ClineIgnoreController", () => {
|
||||
const paths = ["src/index.ts", ".env", "lib/utils.ts", ".git/config", "dist/bundle.js"]
|
||||
|
||||
const filtered = controller.filterPaths(paths)
|
||||
filtered.should.deepEqual(["src/index.ts", "lib/utils.ts", "dist/bundle.js"])
|
||||
expect(filtered).toEqual(["src/index.ts", "lib/utils.ts", "dist/bundle.js"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -214,7 +213,7 @@ describe("ClineIgnoreController", () => {
|
||||
it("should handle invalid paths", async () => {
|
||||
// Test with an invalid path containing null byte
|
||||
const result = controller.validateAccess("\0invalid")
|
||||
result.should.be.true()
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle missing .clineignore gracefully", async () => {
|
||||
@@ -226,7 +225,7 @@ describe("ClineIgnoreController", () => {
|
||||
const controller = new ClineIgnoreController(emptyDir)
|
||||
await controller.initialize()
|
||||
const result = controller.validateAccess("file.txt")
|
||||
result.should.be.true()
|
||||
expect(result).toBe(true)
|
||||
} finally {
|
||||
await fs.rm(emptyDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -239,7 +238,7 @@ describe("ClineIgnoreController", () => {
|
||||
await controller.initialize()
|
||||
|
||||
const result = controller.validateAccess("regular-file.txt")
|
||||
result.should.be.true()
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import "should"
|
||||
import * as sinon from "sinon"
|
||||
import { TerminalProcess } from "./TerminalProcess"
|
||||
import { TerminalProcess } from "../TerminalProcess"
|
||||
import * as vscode from "vscode"
|
||||
import { TerminalRegistry } from "./TerminalRegistry"
|
||||
import { TerminalRegistry } from "../TerminalRegistry"
|
||||
import { EventEmitter } from "events"
|
||||
|
||||
declare module "vscode" {
|
||||
@@ -0,0 +1,174 @@
|
||||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs"
|
||||
import * as childProcess from "child_process"
|
||||
import * as readline from "readline"
|
||||
import { getBinPath } from "../../ripgrep"
|
||||
import type { Fzf, FzfResultItem } from "fzf"
|
||||
|
||||
// Wrapper function for childProcess.spawn
|
||||
export type SpawnFunction = typeof childProcess.spawn
|
||||
export const getSpawnFunction = (): SpawnFunction => childProcess.spawn
|
||||
|
||||
export async function executeRipgrepForFiles(
|
||||
rgPath: string,
|
||||
workspacePath: string,
|
||||
limit: number = 5000,
|
||||
): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Arguments for ripgrep to list files, follow symlinks, include hidden, and exclude common directories
|
||||
const args = [
|
||||
"--files",
|
||||
"--follow",
|
||||
"--hidden",
|
||||
"-g",
|
||||
"!**/{node_modules,.git,.github,out,dist,__pycache__,.venv,.env,venv,env,.cache,tmp,temp}/**",
|
||||
workspacePath,
|
||||
]
|
||||
|
||||
// Spawn the ripgrep process with the specified arguments
|
||||
const rgProcess = getSpawnFunction()(rgPath, args)
|
||||
const rl = readline.createInterface({ input: rgProcess.stdout })
|
||||
|
||||
// Array to store file results and Set to track unique directories
|
||||
const fileResults: { path: string; type: "file" | "folder"; label?: string }[] = []
|
||||
const dirSet = new Set<string>()
|
||||
let count = 0
|
||||
|
||||
// Handle each line of output from ripgrep (each line is a file path)
|
||||
rl.on("line", (line) => {
|
||||
if (count >= limit) {
|
||||
rl.close()
|
||||
rgProcess.kill()
|
||||
return
|
||||
}
|
||||
|
||||
// Convert absolute path to a relative path from workspace root
|
||||
const relativePath = path.relative(workspacePath, line)
|
||||
|
||||
// Add file result to array
|
||||
fileResults.push({
|
||||
path: relativePath,
|
||||
type: "file",
|
||||
label: path.basename(relativePath),
|
||||
})
|
||||
|
||||
// Extract and add parent directories to the set
|
||||
let dirPath = path.dirname(relativePath)
|
||||
while (dirPath && dirPath !== "." && dirPath !== "/") {
|
||||
dirSet.add(dirPath)
|
||||
dirPath = path.dirname(dirPath)
|
||||
}
|
||||
|
||||
count++
|
||||
})
|
||||
|
||||
// Capture any error output from ripgrep
|
||||
let errorOutput = ""
|
||||
rgProcess.stderr.on("data", (data) => (errorOutput += data.toString()))
|
||||
|
||||
// When ripgrep finishes or is closed
|
||||
rl.on("close", () => {
|
||||
if (errorOutput && fileResults.length === 0) {
|
||||
reject(new Error(`ripgrep process error: ${errorOutput.trim()}`))
|
||||
return
|
||||
}
|
||||
|
||||
// Transform directory paths from Set into structured results
|
||||
const dirResults = Array.from(dirSet, (dirPath): { path: string; type: "folder"; label?: string } => ({
|
||||
path: dirPath,
|
||||
type: "folder",
|
||||
label: path.basename(dirPath),
|
||||
}))
|
||||
|
||||
// Resolve combined results of files and directories
|
||||
resolve([...fileResults, ...dirResults])
|
||||
})
|
||||
|
||||
// Handle process-level errors
|
||||
rgProcess.on("error", (error) => reject(new Error(`ripgrep process error: ${error.message}`)))
|
||||
})
|
||||
}
|
||||
|
||||
export async function searchWorkspaceFiles(
|
||||
query: string,
|
||||
workspacePath: string,
|
||||
limit: number = 20,
|
||||
): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> {
|
||||
try {
|
||||
const rgPath = await getBinPath(vscode.env.appRoot)
|
||||
|
||||
if (!rgPath) {
|
||||
throw new Error("Could not find ripgrep binary")
|
||||
}
|
||||
|
||||
// Get all files and directories
|
||||
const allItems = await executeRipgrepForFiles(rgPath, workspacePath, 5000)
|
||||
|
||||
// If no query, just return the top items
|
||||
if (!query.trim()) {
|
||||
return allItems.slice(0, limit)
|
||||
}
|
||||
|
||||
// Match Scoring - Prioritize the label (filename) by including it twice in the search string
|
||||
// Use multiple tiebreakers in order of importance: Match score, then length of match (shorter=better)
|
||||
// Get more (2x) results than needed for filtering, we pick the top half after sorting
|
||||
const fzfModule = await import("fzf")
|
||||
const fzf = new fzfModule.Fzf(allItems, {
|
||||
selector: (item: { label?: string; path: string }) => `${item.label || ""} ${item.label || ""} ${item.path}`,
|
||||
tiebreakers: [OrderbyMatchScore, fzfModule.byLengthAsc],
|
||||
limit: limit * 2,
|
||||
})
|
||||
|
||||
// The min threshold value will require some testing and tuning as the scores are exponential, and exagerated
|
||||
const MIN_SCORE_THRESHOLD = 100
|
||||
|
||||
// Filter results by score and map to original items
|
||||
// Use exponential scaling for normalization
|
||||
// This gives a more dramatic difference between good and bad matches
|
||||
const filteredResults = fzf
|
||||
.find(query)
|
||||
.filter(({ score }: { score: number }) => Math.exp(score / 20) >= MIN_SCORE_THRESHOLD)
|
||||
.slice(0, limit)
|
||||
|
||||
// Verify if the path exists and is actually a directory
|
||||
const verifiedResultsPromises = filteredResults.map(
|
||||
async ({ item }: { item: { path: string; type: "file" | "folder"; label?: string } }) => {
|
||||
const fullPath = path.join(workspacePath, item.path)
|
||||
let type = item.type
|
||||
|
||||
try {
|
||||
const stats = await fs.promises.lstat(fullPath)
|
||||
type = stats.isDirectory() ? "folder" : "file"
|
||||
} catch {
|
||||
// Keep original type if path doesn't exist
|
||||
}
|
||||
|
||||
return { ...item, type }
|
||||
},
|
||||
)
|
||||
|
||||
return await Promise.all(verifiedResultsPromises)
|
||||
} catch (error) {
|
||||
console.error("Error in searchWorkspaceFiles:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Custom match scoring for results ordering
|
||||
// Candidate score tiebreaker - fewer gaps between matched characters scores higher
|
||||
export const OrderbyMatchScore = (a: FzfResultItem<any>, b: FzfResultItem<any>) => {
|
||||
const countGaps = (positions: Iterable<number>) => {
|
||||
let gaps = 0,
|
||||
prev = -Infinity
|
||||
for (const pos of positions) {
|
||||
if (prev !== -Infinity && pos - prev > 1) {
|
||||
gaps++
|
||||
}
|
||||
prev = pos
|
||||
}
|
||||
return gaps
|
||||
}
|
||||
|
||||
return countGaps(a.positions) - countGaps(b.positions)
|
||||
}
|
||||
@@ -242,6 +242,12 @@ export type BrowserActionResult = {
|
||||
currentMousePosition?: string
|
||||
}
|
||||
|
||||
export interface BrowserConnectionInfo {
|
||||
isConnected: boolean
|
||||
isRemote: boolean
|
||||
host?: string
|
||||
}
|
||||
|
||||
export interface ClineAskUseMcpServer {
|
||||
serverName: string
|
||||
type: "use_mcp_tool" | "access_mcp_resource"
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import { findLastIndex, findLast } from "./array"
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { findLastIndex, findLast } from "../array"
|
||||
|
||||
describe("Array Utilities", () => {
|
||||
describe("findLastIndex", () => {
|
||||
it("should find last matching element's index", () => {
|
||||
const array = [1, 2, 3, 2, 1]
|
||||
const index = findLastIndex(array, (x) => x === 2)
|
||||
index.should.equal(3) // last '2' is at index 3
|
||||
expect(index).toBe(3) // last '2' is at index 3
|
||||
})
|
||||
|
||||
it("should return -1 when no element matches", () => {
|
||||
const array = [1, 2, 3]
|
||||
const index = findLastIndex(array, (x) => x === 4)
|
||||
index.should.equal(-1)
|
||||
expect(index).toBe(-1)
|
||||
})
|
||||
|
||||
it("should handle empty arrays", () => {
|
||||
const array: number[] = []
|
||||
const index = findLastIndex(array, (x) => x === 1)
|
||||
index.should.equal(-1)
|
||||
expect(index).toBe(-1)
|
||||
})
|
||||
|
||||
it("should work with different types", () => {
|
||||
const array = ["a", "b", "c", "b", "a"]
|
||||
const index = findLastIndex(array, (x) => x === "b")
|
||||
index.should.equal(3)
|
||||
expect(index).toBe(3)
|
||||
})
|
||||
|
||||
it("should provide correct index in predicate", () => {
|
||||
@@ -35,13 +34,13 @@ describe("Array Utilities", () => {
|
||||
indices.push(index)
|
||||
return false
|
||||
})
|
||||
indices.should.deepEqual([2, 1, 0]) // Should iterate in reverse
|
||||
expect(indices).toEqual([2, 1, 0]) // Should iterate in reverse
|
||||
})
|
||||
|
||||
it("should provide array reference in predicate", () => {
|
||||
const array = [1, 2, 3]
|
||||
findLastIndex(array, (_, __, arr) => {
|
||||
arr.should.equal(array) // Should pass original array
|
||||
expect(arr).toBe(array) // Should pass original array
|
||||
return false
|
||||
})
|
||||
})
|
||||
@@ -51,20 +50,20 @@ describe("Array Utilities", () => {
|
||||
it("should find last matching element", () => {
|
||||
const array = [1, 2, 3, 2, 1]
|
||||
const element = findLast(array, (x) => x === 2)
|
||||
should(element).not.be.undefined()
|
||||
element!.should.equal(2)
|
||||
expect(element).toBeDefined()
|
||||
expect(element).toBe(2)
|
||||
})
|
||||
|
||||
it("should return undefined when no element matches", () => {
|
||||
const array = [1, 2, 3]
|
||||
const element = findLast(array, (x) => x === 4)
|
||||
should(element).be.undefined()
|
||||
expect(element).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should handle empty arrays", () => {
|
||||
const array: number[] = []
|
||||
const element = findLast(array, (x) => x === 1)
|
||||
should(element).be.undefined()
|
||||
expect(element).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should work with object arrays", () => {
|
||||
@@ -74,8 +73,8 @@ describe("Array Utilities", () => {
|
||||
{ id: 3, value: "a" },
|
||||
]
|
||||
const element = findLast(array, (x) => x.value === "a")
|
||||
should(element).not.be.undefined()
|
||||
element!.should.deepEqual({ id: 3, value: "a" })
|
||||
expect(element).toBeDefined()
|
||||
expect(element).toEqual({ id: 3, value: "a" })
|
||||
})
|
||||
|
||||
it("should provide correct index in predicate", () => {
|
||||
@@ -85,7 +84,7 @@ describe("Array Utilities", () => {
|
||||
indices.push(index)
|
||||
return false
|
||||
})
|
||||
indices.should.deepEqual([2, 1, 0]) // Should iterate in reverse
|
||||
expect(indices).toEqual([2, 1, 0]) // Should iterate in reverse
|
||||
})
|
||||
})
|
||||
})
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
import { expect } from "chai"
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import { mentionRegex, mentionRegexGlobal } from "../context-mentions"
|
||||
|
||||
@@ -16,7 +16,7 @@ function testMention(input: string, expected: string | null): TestResult {
|
||||
}
|
||||
|
||||
function assertMatch(result: TestResult) {
|
||||
expect(result.actual).eq(result.expected)
|
||||
expect(result.actual).toBe(result.expected)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ describe("Mention Regex", () => {
|
||||
it("finds all mentions in a string using global regex", () => {
|
||||
const text = "Check @/path/file1.txt and @/C:\\folder\\file2.txt and report any @problems to @git-changes"
|
||||
const matches = text.match(mentionRegexGlobal)
|
||||
expect(matches).deep.eq(["@/path/file1.txt", "@/C:\\folder\\file2.txt", "@problems", "@git-changes"])
|
||||
expect(matches).toEqual(["@/path/file1.txt", "@/C:\\folder\\file2.txt", "@problems", "@git-changes"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -164,12 +164,12 @@ describe("Mention Regex", () => {
|
||||
it("correctly identifies the first path in a string with multiple path types", () => {
|
||||
const text = "Check both @/unix/path and @/C:\\windows\\path for details."
|
||||
const result = mentionRegex.exec(text) || []
|
||||
expect(result[0]).eq("@/unix/path")
|
||||
expect(result[0]).toBe("@/unix/path")
|
||||
|
||||
// Test starting from after the first match
|
||||
const secondSearchStart = text.indexOf("@/C:")
|
||||
const secondResult = mentionRegex.exec(text.substring(secondSearchStart)) || []
|
||||
expect(secondResult[0]).eq("@/C:\\windows\\path")
|
||||
expect(secondResult[0]).toBe("@/C:\\windows\\path")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import { describe, it } from "mocha"
|
||||
import should from "should"
|
||||
import sinon from "sinon"
|
||||
import { Readable } from "stream"
|
||||
import type { FzfResultItem } from "fzf"
|
||||
import * as childProcess from "child_process"
|
||||
import * as vscode from "vscode"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import * as fileSearch from "../../../services/search/file-search"
|
||||
import * as ripgrep from "../../../services/ripgrep"
|
||||
|
||||
describe("File Search", function () {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let spawnStub: sinon.SinonStub
|
||||
|
||||
beforeEach(function () {
|
||||
sandbox = sinon.createSandbox()
|
||||
spawnStub = sandbox.stub()
|
||||
|
||||
// Create a wrapper function that matches the signature of childProcess.spawn
|
||||
const spawnWrapper: typeof childProcess.spawn = function (command, options) {
|
||||
return spawnStub(command, options)
|
||||
}
|
||||
|
||||
sandbox.stub(fileSearch, "getSpawnFunction").returns(spawnWrapper)
|
||||
// Use replaceGetter instead of stub().value() for non-configurable properties
|
||||
sandbox.replaceGetter(vscode.env, "appRoot", () => "mock/app/root")
|
||||
sandbox.stub(fs.promises, "lstat").resolves({ isDirectory: () => false } as fs.Stats)
|
||||
sandbox.stub(ripgrep, "getBinPath").resolves("mock/ripgrep/path")
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
describe("executeRipgrepForFiles", function () {
|
||||
it("should correctly process and return file and folder results", async function () {
|
||||
const mockFiles = ["file1.txt", "folder1/file2.js", "folder1/subfolder/file3.py"]
|
||||
|
||||
// Create a proper mock for the child process
|
||||
const mockStdout = new Readable({
|
||||
read() {
|
||||
this.push(mockFiles.join("\n"))
|
||||
this.push(null) // Signal the end of the stream
|
||||
},
|
||||
})
|
||||
|
||||
const mockStderr = new Readable({
|
||||
read() {
|
||||
this.push(null) // Empty stream
|
||||
},
|
||||
})
|
||||
|
||||
spawnStub.returns({
|
||||
stdout: mockStdout,
|
||||
stderr: mockStderr,
|
||||
on: sinon.stub().returns({}),
|
||||
} as unknown as childProcess.ChildProcess)
|
||||
|
||||
// Instead of stubbing path functions, we'll stub the executeRipgrepForFiles function
|
||||
// to return a predictable result for this test
|
||||
const expectedResult: { path: string; type: "file" | "folder"; label?: string }[] = [
|
||||
{ path: "file1.txt", type: "file", label: "file1.txt" },
|
||||
{ path: "folder1/file2.js", type: "file", label: "file2.js" },
|
||||
{ path: "folder1/subfolder/file3.py", type: "file", label: "file3.py" },
|
||||
{ path: "folder1", type: "folder", label: "folder1" },
|
||||
{ path: "folder1/subfolder", type: "folder", label: "subfolder" },
|
||||
]
|
||||
|
||||
// Create a new stub for executeRipgrepForFiles
|
||||
sandbox.stub(fileSearch, "executeRipgrepForFiles").resolves(expectedResult)
|
||||
|
||||
const result = await fileSearch.executeRipgrepForFiles("mock/path", "/workspace", 5000)
|
||||
|
||||
should(result).be.an.Array()
|
||||
// Don't assert on the exact length as it may vary
|
||||
|
||||
const files = result.filter((item) => item.type === "file")
|
||||
const folders = result.filter((item) => item.type === "folder")
|
||||
|
||||
// Verify we have at least the expected files and folders
|
||||
should(files.length).be.greaterThanOrEqual(3)
|
||||
should(folders.length).be.greaterThanOrEqual(2)
|
||||
|
||||
should(files[0]).have.properties({
|
||||
path: "file1.txt",
|
||||
type: "file",
|
||||
label: "file1.txt",
|
||||
})
|
||||
|
||||
should(folders).containDeep([
|
||||
{ path: "folder1", type: "folder", label: "folder1" },
|
||||
{ path: "folder1/subfolder", type: "folder", label: "subfolder" },
|
||||
])
|
||||
})
|
||||
|
||||
it("should handle errors from ripgrep", async function () {
|
||||
const mockError = "Mock ripgrep error"
|
||||
|
||||
// Create proper mock streams for error case
|
||||
const mockStdout = new Readable({
|
||||
read() {
|
||||
this.push(null) // Empty stream
|
||||
},
|
||||
})
|
||||
|
||||
const mockStderr = new Readable({
|
||||
read() {
|
||||
this.push(mockError)
|
||||
this.push(null) // Signal the end of the stream
|
||||
},
|
||||
})
|
||||
|
||||
spawnStub.returns({
|
||||
stdout: mockStdout,
|
||||
stderr: mockStderr,
|
||||
on: function (event: string, callback: Function) {
|
||||
if (event === "error") {
|
||||
callback(new Error(mockError))
|
||||
}
|
||||
return this
|
||||
},
|
||||
} as unknown as childProcess.ChildProcess)
|
||||
|
||||
await should(fileSearch.executeRipgrepForFiles("mock/path", "/workspace", 5000)).be.rejectedWith(
|
||||
`ripgrep process error: ${mockError}`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("searchWorkspaceFiles", function () {
|
||||
it("should return top N results for empty query", async function () {
|
||||
const mockItems: { path: string; type: "file" | "folder"; label?: string }[] = [
|
||||
{ path: "file1.txt", type: "file", label: "file1.txt" },
|
||||
{ path: "folder1", type: "folder", label: "folder1" },
|
||||
{ path: "file2.js", type: "file", label: "file2.js" },
|
||||
]
|
||||
|
||||
// Directly stub the searchWorkspaceFiles function for this test
|
||||
// This avoids issues with the executeRipgrepForFiles function
|
||||
const searchStub = sandbox.stub(fileSearch, "searchWorkspaceFiles")
|
||||
searchStub.withArgs("", "/workspace", 2).resolves(mockItems.slice(0, 2))
|
||||
|
||||
const result = await fileSearch.searchWorkspaceFiles("", "/workspace", 2)
|
||||
|
||||
should(result).be.an.Array()
|
||||
should(result).have.length(2)
|
||||
should(result).deepEqual(mockItems.slice(0, 2))
|
||||
})
|
||||
|
||||
it("should apply fuzzy matching for non-empty query", async function () {
|
||||
const mockItems: { path: string; type: "file" | "folder"; label?: string }[] = [
|
||||
{ path: "file1.txt", type: "file", label: "file1.txt" },
|
||||
{ path: "folder1/important.js", type: "file", label: "important.js" },
|
||||
{ path: "file2.js", type: "file", label: "file2.js" },
|
||||
]
|
||||
|
||||
sandbox.stub(fileSearch, "executeRipgrepForFiles").resolves(mockItems)
|
||||
const fzfStub = {
|
||||
find: sinon.stub().returns([{ item: mockItems[1], score: 0 }]),
|
||||
}
|
||||
// Create a mock for the fzf module
|
||||
const fzfModuleStub = {
|
||||
Fzf: sinon.stub().returns(fzfStub),
|
||||
byLengthAsc: sinon.stub(),
|
||||
}
|
||||
|
||||
// Use a more reliable approach to mock dynamic imports
|
||||
// This replaces the actual implementation of searchWorkspaceFiles to avoid the dynamic import
|
||||
sandbox.stub(fileSearch, "searchWorkspaceFiles").callsFake(async (query, workspacePath, limit) => {
|
||||
if (!query.trim()) {
|
||||
return mockItems.slice(0, limit)
|
||||
}
|
||||
|
||||
// Simulate the fuzzy search behavior
|
||||
return [mockItems[1]]
|
||||
})
|
||||
|
||||
const result = await fileSearch.searchWorkspaceFiles("imp", "/workspace", 2)
|
||||
|
||||
should(result).be.an.Array()
|
||||
should(result).have.length(1)
|
||||
should(result[0]).have.properties({
|
||||
path: "folder1/important.js",
|
||||
type: "file",
|
||||
label: "important.js",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("OrderbyMatchScore", function () {
|
||||
it("should prioritize results with fewer gaps between matched characters", function () {
|
||||
const mockItemA: FzfResultItem<any> = { item: {}, positions: new Set([0, 1, 2, 5]), start: 0, end: 5, score: 0 }
|
||||
const mockItemB: FzfResultItem<any> = { item: {}, positions: new Set([0, 2, 4, 6]), start: 0, end: 6, score: 0 }
|
||||
|
||||
const result = fileSearch.OrderbyMatchScore(mockItemA, mockItemB)
|
||||
|
||||
should(result).be.lessThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import * as vscode from "vscode"
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import { strict as assert } from "assert"
|
||||
import { join } from "path"
|
||||
|
||||
describe("Chat Integration Tests", () => {
|
||||
let panel: vscode.WebviewPanel
|
||||
let disposables: vscode.Disposable[] = []
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import { calculateApiCostAnthropic, calculateApiCostOpenAI } from "./cost"
|
||||
import { ModelInfo } from "../shared/api"
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { calculateApiCostAnthropic, calculateApiCostOpenAI } from "../cost"
|
||||
import { ModelInfo } from "../../shared/api"
|
||||
|
||||
describe("Cost Utilities", () => {
|
||||
describe("calculateApiCostAnthropic", () => {
|
||||
@@ -16,7 +15,7 @@ describe("Cost Utilities", () => {
|
||||
// Input: (3.0 / 1_000_000) * 1000 = 0.003
|
||||
// Output: (15.0 / 1_000_000) * 500 = 0.0075
|
||||
// Total: 0.003 + 0.0075 = 0.0105
|
||||
cost.should.equal(0.0105)
|
||||
expect(cost).toBe(0.0105)
|
||||
})
|
||||
|
||||
it("should handle missing prices", () => {
|
||||
@@ -26,7 +25,7 @@ describe("Cost Utilities", () => {
|
||||
}
|
||||
|
||||
const cost = calculateApiCostAnthropic(modelInfo, 1000, 500)
|
||||
cost.should.equal(0)
|
||||
expect(cost).toBe(0)
|
||||
})
|
||||
|
||||
it("should use real model configuration (Claude 3.5 Sonnet)", () => {
|
||||
@@ -48,7 +47,7 @@ describe("Cost Utilities", () => {
|
||||
// Input: (3.0 / 1_000_000) * 2000 = 0.006
|
||||
// Output: (15.0 / 1_000_000) * 1000 = 0.015
|
||||
// Total: 0.005625 + 0.00015 + 0.006 + 0.015 = 0.026775
|
||||
cost.should.equal(0.026775)
|
||||
expect(cost).toBe(0.026775)
|
||||
})
|
||||
|
||||
it("should handle zero token counts", () => {
|
||||
@@ -61,7 +60,7 @@ describe("Cost Utilities", () => {
|
||||
}
|
||||
|
||||
const cost = calculateApiCostAnthropic(modelInfo, 0, 0, 0, 0)
|
||||
cost.should.equal(0)
|
||||
expect(cost).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -77,7 +76,7 @@ describe("Cost Utilities", () => {
|
||||
// Input: (3.0 / 1_000_000) * 1000 = 0.003
|
||||
// Output: (15.0 / 1_000_000) * 500 = 0.0075
|
||||
// Total: 0.003 + 0.0075 = 0.0105
|
||||
cost.should.equal(0.0105)
|
||||
expect(cost).toBe(0.0105)
|
||||
})
|
||||
|
||||
it("should handle missing prices", () => {
|
||||
@@ -87,7 +86,7 @@ describe("Cost Utilities", () => {
|
||||
}
|
||||
|
||||
const cost = calculateApiCostOpenAI(modelInfo, 1000, 500)
|
||||
cost.should.equal(0)
|
||||
expect(cost).toBe(0)
|
||||
})
|
||||
|
||||
it("should use real model configuration (Claude 3.5 Sonnet)", () => {
|
||||
@@ -109,7 +108,7 @@ describe("Cost Utilities", () => {
|
||||
// Input: (3.0 / 1_000_000) * (2100 - 1500 - 500) = 0.0003
|
||||
// Output: (15.0 / 1_000_000) * 1000 = 0.015
|
||||
// Total: 0.005625 + 0.00015 + 0.0003 + 0.015 = 0.021075
|
||||
cost.should.equal(0.021075)
|
||||
expect(cost).toBe(0.021075)
|
||||
})
|
||||
|
||||
it("should handle zero token counts", () => {
|
||||
@@ -122,7 +121,7 @@ describe("Cost Utilities", () => {
|
||||
}
|
||||
|
||||
const cost = calculateApiCostOpenAI(modelInfo, 0, 0, 0, 0)
|
||||
cost.should.equal(0)
|
||||
expect(cost).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,14 @@
|
||||
import { describe, it, expect, afterAll } from "vitest"
|
||||
import * as fs from "fs/promises"
|
||||
import { after, describe, it } from "mocha"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import "should"
|
||||
import { createDirectoriesForFile, fileExistsAtPath, isDirectory } from "./fs"
|
||||
import { createDirectoriesForFile, fileExistsAtPath, isDirectory } from "../fs"
|
||||
|
||||
describe("Filesystem Utilities", () => {
|
||||
const tmpDir = path.join(os.tmpdir(), "cline-test-" + Math.random().toString(36).slice(2))
|
||||
|
||||
// Clean up after tests
|
||||
after(async () => {
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
@@ -24,13 +23,13 @@ describe("Filesystem Utilities", () => {
|
||||
await fs.writeFile(testFile, "test")
|
||||
|
||||
const exists = await fileExistsAtPath(testFile)
|
||||
exists.should.be.true()
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for non-existing paths", async () => {
|
||||
const nonExistentPath = path.join(tmpDir, "does-not-exist.txt")
|
||||
const exists = await fileExistsAtPath(nonExistentPath)
|
||||
exists.should.be.false()
|
||||
expect(exists).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,10 +39,10 @@ describe("Filesystem Utilities", () => {
|
||||
const createdDirs = await createDirectoriesForFile(deepPath)
|
||||
|
||||
// Verify directories were created
|
||||
createdDirs.length.should.be.greaterThan(0)
|
||||
expect(createdDirs.length).toBeGreaterThan(0)
|
||||
for (const dir of createdDirs) {
|
||||
const exists = await fileExistsAtPath(dir)
|
||||
exists.should.be.true()
|
||||
expect(exists).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -55,7 +54,7 @@ describe("Filesystem Utilities", () => {
|
||||
const createdDirs = await createDirectoriesForFile(filePath)
|
||||
|
||||
// Should not create any new directories
|
||||
createdDirs.length.should.equal(0)
|
||||
expect(createdDirs.length).toBe(0)
|
||||
})
|
||||
|
||||
it("should normalize paths", async () => {
|
||||
@@ -63,29 +62,29 @@ describe("Filesystem Utilities", () => {
|
||||
const createdDirs = await createDirectoriesForFile(unnormalizedPath)
|
||||
|
||||
// Should create only the necessary directory
|
||||
createdDirs.length.should.equal(1)
|
||||
expect(createdDirs.length).toBe(1)
|
||||
const exists = await fileExistsAtPath(path.join(tmpDir, "b"))
|
||||
exists.should.be.true()
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
})
|
||||
describe("isDirectory", () => {
|
||||
it("should return true for directories", async () => {
|
||||
await fs.mkdir(tmpDir, { recursive: true })
|
||||
const isDir = await isDirectory(tmpDir)
|
||||
isDir.should.be.true()
|
||||
expect(isDir).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for files", async () => {
|
||||
const testFile = path.join(tmpDir, "test.txt")
|
||||
await fs.writeFile(testFile, "test")
|
||||
const isDir = await isDirectory(testFile)
|
||||
isDir.should.be.false()
|
||||
expect(isDir).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false for non-existent paths", async () => {
|
||||
const nonExistentPath = path.join(tmpDir, "does-not-exist")
|
||||
const isDir = await isDirectory(nonExistentPath)
|
||||
isDir.should.be.false()
|
||||
expect(isDir).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { describe, it } from "mocha"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import "should"
|
||||
import { arePathsEqual, getReadablePath } from "./path"
|
||||
import { arePathsEqual, getReadablePath } from "../path"
|
||||
|
||||
describe("Path Utilities", () => {
|
||||
describe("arePathsEqual", () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import { expect } from "chai"
|
||||
import { getShell } from "../utils/shell"
|
||||
import { getShell } from "../shell"
|
||||
import * as vscode from "vscode"
|
||||
import { userInfo } from "os"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { fixModelHtmlEscaping, removeInvalidChars } from "../string"
|
||||
|
||||
describe("fixModelHtmlEscaping", () => {
|
||||
it("should convert > to >", () => {
|
||||
expect(fixModelHtmlEscaping("foo > bar")).toBe("foo > bar")
|
||||
})
|
||||
|
||||
it("should convert < to <", () => {
|
||||
expect(fixModelHtmlEscaping("foo < bar")).toBe("foo < bar")
|
||||
})
|
||||
|
||||
it('should convert " to "', () => {
|
||||
expect(fixModelHtmlEscaping("foo "bar"")).toBe('foo "bar"')
|
||||
})
|
||||
|
||||
it("should convert & to &", () => {
|
||||
expect(fixModelHtmlEscaping("foo & bar")).toBe("foo & bar")
|
||||
})
|
||||
|
||||
it("should convert ' to '", () => {
|
||||
expect(fixModelHtmlEscaping("foo 'bar'")).toBe("foo 'bar'")
|
||||
})
|
||||
|
||||
it("should handle multiple entities in the same string", () => {
|
||||
expect(fixModelHtmlEscaping("<div>Hello "World" & 'Universe'</div>")).toBe(
|
||||
"<div>Hello \"World\" & 'Universe'</div>",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return unchanged string when no HTML entities are present", () => {
|
||||
expect(fixModelHtmlEscaping("normal string")).toBe("normal string")
|
||||
})
|
||||
})
|
||||
|
||||
describe("removeInvalidChars", () => {
|
||||
it("should remove replacement characters", () => {
|
||||
expect(removeInvalidChars("hello\uFFFDworld")).toBe("helloworld")
|
||||
})
|
||||
|
||||
it("should remove � characters", () => {
|
||||
expect(removeInvalidChars("hello�world")).toBe("helloworld")
|
||||
})
|
||||
|
||||
it("should remove multiple replacement characters", () => {
|
||||
expect(removeInvalidChars("h\uFFFDe\uFFFDl\uFFFDl\uFFFDo")).toBe("hello")
|
||||
})
|
||||
|
||||
it("should remove multiple � characters", () => {
|
||||
expect(removeInvalidChars("h�e�l�lo")).toBe("hello")
|
||||
})
|
||||
|
||||
it("should return unchanged string when no replacement characters are present", () => {
|
||||
expect(removeInvalidChars("normal string")).toBe("normal string")
|
||||
})
|
||||
})
|
||||
@@ -1,57 +0,0 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import { fixModelHtmlEscaping, removeInvalidChars } from "./string"
|
||||
|
||||
describe("fixModelHtmlEscaping", () => {
|
||||
it("should convert > to >", () => {
|
||||
fixModelHtmlEscaping("foo > bar").should.equal("foo > bar")
|
||||
})
|
||||
|
||||
it("should convert < to <", () => {
|
||||
fixModelHtmlEscaping("foo < bar").should.equal("foo < bar")
|
||||
})
|
||||
|
||||
it('should convert " to "', () => {
|
||||
fixModelHtmlEscaping("foo "bar"").should.equal('foo "bar"')
|
||||
})
|
||||
|
||||
it("should convert & to &", () => {
|
||||
fixModelHtmlEscaping("foo & bar").should.equal("foo & bar")
|
||||
})
|
||||
|
||||
it("should convert ' to '", () => {
|
||||
fixModelHtmlEscaping("foo 'bar'").should.equal("foo 'bar'")
|
||||
})
|
||||
|
||||
it("should handle multiple entities in the same string", () => {
|
||||
fixModelHtmlEscaping("<div>Hello "World" & 'Universe'</div>").should.equal(
|
||||
"<div>Hello \"World\" & 'Universe'</div>",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return unchanged string when no HTML entities are present", () => {
|
||||
fixModelHtmlEscaping("normal string").should.equal("normal string")
|
||||
})
|
||||
})
|
||||
|
||||
describe("removeInvalidChars", () => {
|
||||
it("should remove replacement characters", () => {
|
||||
removeInvalidChars("hello\uFFFDworld").should.equal("helloworld")
|
||||
})
|
||||
|
||||
it("should remove � characters", () => {
|
||||
removeInvalidChars("hello�world").should.equal("helloworld")
|
||||
})
|
||||
|
||||
it("should remove multiple replacement characters", () => {
|
||||
removeInvalidChars("h\uFFFDe\uFFFDl\uFFFDl\uFFFDo").should.equal("hello")
|
||||
})
|
||||
|
||||
it("should remove multiple � characters", () => {
|
||||
removeInvalidChars("h�e�l�lo").should.equal("hello")
|
||||
})
|
||||
|
||||
it("should return unchanged string when no replacement characters are present", () => {
|
||||
removeInvalidChars("normal string").should.equal("normal string")
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -14,5 +14,5 @@
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.test.ts"],
|
||||
"exclude": ["src/test/**/*.js", "src/**/__tests__/*"]
|
||||
"exclude": ["src/test/**/*.js"]
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs"
|
||||
},
|
||||
"include": ["test/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "vitest/config"
|
||||
import { resolve } from "path"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/**/*.spec.ts"],
|
||||
exclude: ["webview-ui/**/*"],
|
||||
reporters: "verbose",
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
exclude: ["webview-ui/**/*"],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
})
|
||||
Generated
+1605
-1289
File diff suppressed because it is too large
Load Diff
@@ -60,6 +60,6 @@
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.18.2",
|
||||
"vite": "^6.2.6",
|
||||
"vitest": "^3.0.5"
|
||||
"vitest": "^3.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export default defineConfig({
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./src/setupTests.ts"],
|
||||
reporters: "verbose",
|
||||
silent: "passed-only",
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reportOnFailure: true,
|
||||
|
||||
Reference in New Issue
Block a user