Compare commits

...
37 changed files with 3532 additions and 2230 deletions
+5
View File
@@ -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.
+1 -1
View File
@@ -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)
+900 -550
View File
File diff suppressed because it is too large Load Diff
+12 -10
View File
@@ -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
}
})
})
+224
View File
@@ -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-errorTesting 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,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()
}
})
})
@@ -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)
})
})
})
@@ -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)
}
+6
View File
@@ -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
})
})
})
@@ -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)
})
})
})
+1
View File
@@ -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"
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest"
import { fixModelHtmlEscaping, removeInvalidChars } from "../string"
describe("fixModelHtmlEscaping", () => {
it("should convert &gt; to >", () => {
expect(fixModelHtmlEscaping("foo &gt; bar")).toBe("foo > bar")
})
it("should convert &lt; to <", () => {
expect(fixModelHtmlEscaping("foo &lt; bar")).toBe("foo < bar")
})
it('should convert &quot; to "', () => {
expect(fixModelHtmlEscaping("foo &quot;bar&quot;")).toBe('foo "bar"')
})
it("should convert &amp; to &", () => {
expect(fixModelHtmlEscaping("foo &amp; bar")).toBe("foo & bar")
})
it("should convert &apos; to '", () => {
expect(fixModelHtmlEscaping("foo &apos;bar&apos;")).toBe("foo 'bar'")
})
it("should handle multiple entities in the same string", () => {
expect(fixModelHtmlEscaping("&lt;div&gt;Hello &quot;World&quot; &amp; &apos;Universe&apos;&lt;/div&gt;")).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("helloworld")).toBe("helloworld")
})
it("should remove multiple replacement characters", () => {
expect(removeInvalidChars("h\uFFFDe\uFFFDl\uFFFDl\uFFFDo")).toBe("hello")
})
it("should remove multiple characters", () => {
expect(removeInvalidChars("hello")).toBe("hello")
})
it("should return unchanged string when no replacement characters are present", () => {
expect(removeInvalidChars("normal string")).toBe("normal string")
})
})
-57
View File
@@ -1,57 +0,0 @@
import { describe, it } from "mocha"
import "should"
import { fixModelHtmlEscaping, removeInvalidChars } from "./string"
describe("fixModelHtmlEscaping", () => {
it("should convert &gt; to >", () => {
fixModelHtmlEscaping("foo &gt; bar").should.equal("foo > bar")
})
it("should convert &lt; to <", () => {
fixModelHtmlEscaping("foo &lt; bar").should.equal("foo < bar")
})
it('should convert &quot; to "', () => {
fixModelHtmlEscaping("foo &quot;bar&quot;").should.equal('foo "bar"')
})
it("should convert &amp; to &", () => {
fixModelHtmlEscaping("foo &amp; bar").should.equal("foo & bar")
})
it("should convert &apos; to '", () => {
fixModelHtmlEscaping("foo &apos;bar&apos;").should.equal("foo 'bar'")
})
it("should handle multiple entities in the same string", () => {
fixModelHtmlEscaping("&lt;div&gt;Hello &quot;World&quot; &amp; &apos;Universe&apos;&lt;/div&gt;").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("helloworld").should.equal("helloworld")
})
it("should remove multiple replacement characters", () => {
removeInvalidChars("h\uFFFDe\uFFFDl\uFFFDl\uFFFDo").should.equal("hello")
})
it("should remove multiple characters", () => {
removeInvalidChars("hello").should.equal("hello")
})
it("should return unchanged string when no replacement characters are present", () => {
removeInvalidChars("normal string").should.equal("normal string")
})
})
+1 -1
View File
@@ -14,5 +14,5 @@
"rootDir": "src"
},
"include": ["src/**/*.test.ts"],
"exclude": ["src/test/**/*.js", "src/**/__tests__/*"]
"exclude": ["src/test/**/*.js"]
}
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs"
},
"include": ["test/**/*.ts"],
"exclude": ["node_modules"]
}
+22
View File
@@ -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"),
},
},
})
+1605 -1289
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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"
}
}
+2
View File
@@ -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,