Skip to content

Commit c0afb00

Browse files
committed
add test
1 parent 6cf5ca7 commit c0afb00

File tree

1 file changed

+300
-0
lines changed

1 file changed

+300
-0
lines changed
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
// npx vitest run src/api/providers/__tests__/claude-code.spec.ts
2+
3+
// Mocks must come first, before imports
4+
vi.mock("../../../integrations/claude-code/run", () => ({
5+
runClaudeCode: vi.fn(),
6+
}))
7+
8+
// Mock Claude Code process events
9+
const createMockClaudeProcess = () => {
10+
const eventHandlers: Record<string, any> = {}
11+
let hasEnded = false
12+
13+
const mockProcess = {
14+
stdout: {
15+
on: vi.fn((event: string, handler: any) => {
16+
eventHandlers[`stdout_${event}`] = handler
17+
}),
18+
},
19+
stderr: {
20+
on: vi.fn((event: string, handler: any) => {
21+
eventHandlers[`stderr_${event}`] = handler
22+
}),
23+
},
24+
on: vi.fn((event: string, handler: any) => {
25+
eventHandlers[event] = handler
26+
}),
27+
pid: 12345,
28+
_eventHandlers: eventHandlers,
29+
_simulateStdout: (data: string) => {
30+
if (eventHandlers.stdout_data && !hasEnded) {
31+
// Use setTimeout to ensure async behavior
32+
setTimeout(() => eventHandlers.stdout_data(Buffer.from(data)), 0)
33+
}
34+
},
35+
_simulateStderr: (data: string) => {
36+
if (eventHandlers.stderr_data && !hasEnded) {
37+
setTimeout(() => eventHandlers.stderr_data(Buffer.from(data)), 0)
38+
}
39+
},
40+
_simulateClose: (code: number) => {
41+
hasEnded = true
42+
if (eventHandlers.close) {
43+
// Delay close to allow data processing
44+
setTimeout(() => eventHandlers.close(code), 10)
45+
}
46+
},
47+
_simulateError: (error: Error) => {
48+
hasEnded = true
49+
if (eventHandlers.error) {
50+
setTimeout(() => eventHandlers.error(error), 0)
51+
}
52+
},
53+
}
54+
55+
return mockProcess
56+
}
57+
58+
import type { Anthropic } from "@anthropic-ai/sdk"
59+
import { ClaudeCodeHandler } from "../claude-code"
60+
import { ApiHandlerOptions } from "../../../shared/api"
61+
import { runClaudeCode } from "../../../integrations/claude-code/run"
62+
63+
const mockRunClaudeCode = vi.mocked(runClaudeCode)
64+
65+
describe("ClaudeCodeHandler", () => {
66+
let handler: ClaudeCodeHandler
67+
let mockOptions: ApiHandlerOptions
68+
let mockProcess: ReturnType<typeof createMockClaudeProcess>
69+
70+
beforeEach(() => {
71+
mockOptions = {
72+
claudeCodePath: "/custom/path/to/claude",
73+
apiModelId: "claude-sonnet-4-20250514",
74+
}
75+
mockProcess = createMockClaudeProcess()
76+
mockRunClaudeCode.mockReturnValue(mockProcess as any)
77+
handler = new ClaudeCodeHandler(mockOptions)
78+
vi.clearAllMocks()
79+
})
80+
81+
describe("constructor", () => {
82+
it("should initialize with provided options", () => {
83+
expect(handler).toBeInstanceOf(ClaudeCodeHandler)
84+
expect(handler.getModel().id).toBe(mockOptions.apiModelId)
85+
})
86+
87+
it("should handle undefined claudeCodePath", () => {
88+
const handlerWithoutPath = new ClaudeCodeHandler({
89+
...mockOptions,
90+
claudeCodePath: undefined,
91+
})
92+
expect(handlerWithoutPath).toBeInstanceOf(ClaudeCodeHandler)
93+
})
94+
})
95+
96+
describe("createMessage", () => {
97+
const systemPrompt = "You are a helpful assistant."
98+
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
99+
100+
it("should call runClaudeCode with correct parameters", async () => {
101+
const messageGenerator = handler.createMessage(systemPrompt, messages)
102+
const iterator = messageGenerator[Symbol.asyncIterator]()
103+
104+
// Trigger close immediately to end the iteration
105+
setImmediate(() => {
106+
mockProcess._simulateClose(0)
107+
})
108+
109+
await iterator.next()
110+
111+
expect(mockRunClaudeCode).toHaveBeenCalledWith({
112+
systemPrompt,
113+
messages,
114+
path: mockOptions.claudeCodePath,
115+
modelId: mockOptions.apiModelId,
116+
})
117+
})
118+
119+
it("should handle successful Claude Code output", async () => {
120+
const messageGenerator = handler.createMessage(systemPrompt, messages)
121+
const chunks: any[] = []
122+
123+
// Start collecting chunks in the background
124+
const chunkPromise = (async () => {
125+
for await (const chunk of messageGenerator) {
126+
chunks.push(chunk)
127+
}
128+
})()
129+
130+
// Wait a tick to ensure the generator has started
131+
await new Promise((resolve) => setTimeout(resolve, 10))
132+
133+
// Simulate Claude Code JSON output
134+
mockProcess._simulateStdout('{"type":"system","subtype":"init","session_id":"test"}\n')
135+
await new Promise((resolve) => setTimeout(resolve, 10))
136+
137+
mockProcess._simulateStdout(
138+
JSON.stringify({
139+
type: "assistant",
140+
message: {
141+
id: "test-message",
142+
role: "assistant",
143+
content: [{ type: "text", text: "Hello from Claude Code!" }],
144+
stop_reason: null,
145+
usage: {
146+
input_tokens: 10,
147+
output_tokens: 5,
148+
cache_read_input_tokens: 2,
149+
cache_creation_input_tokens: 1,
150+
},
151+
},
152+
}) + "\n",
153+
)
154+
await new Promise((resolve) => setTimeout(resolve, 10))
155+
156+
// Don't close with exitCode 0 immediately as it would end the while loop
157+
// Just verify text chunk processing
158+
mockProcess._simulateClose(0)
159+
160+
// Wait for the chunk collection to complete
161+
await chunkPromise
162+
163+
const textChunks = chunks.filter((chunk) => chunk.type === "text")
164+
165+
expect(textChunks).toHaveLength(1)
166+
expect(textChunks[0].text).toBe("Hello from Claude Code!")
167+
})
168+
169+
it("should handle Claude Code exit with error code", async () => {
170+
const messageGenerator = handler.createMessage(systemPrompt, messages)
171+
172+
setImmediate(() => {
173+
mockProcess._simulateStderr("Claude Code error: Invalid model")
174+
mockProcess._simulateClose(1)
175+
})
176+
177+
await expect(async () => {
178+
for await (const chunk of messageGenerator) {
179+
// Should throw before yielding any chunks
180+
}
181+
}).rejects.toThrow("Claude Code process exited with code 1")
182+
})
183+
184+
it("should handle invalid JSON output gracefully", async () => {
185+
const messageGenerator = handler.createMessage(systemPrompt, messages)
186+
const chunks: any[] = []
187+
188+
setImmediate(() => {
189+
mockProcess._simulateStdout("Invalid JSON\n")
190+
mockProcess._simulateStdout("Another invalid line\n")
191+
mockProcess._simulateClose(0)
192+
})
193+
194+
for await (const chunk of messageGenerator) {
195+
chunks.push(chunk)
196+
}
197+
198+
const textChunks = chunks.filter((chunk) => chunk.type === "text")
199+
expect(textChunks).toHaveLength(2)
200+
expect(textChunks[0].text).toBe("Invalid JSON")
201+
expect(textChunks[1].text).toBe("Another invalid line")
202+
})
203+
204+
it("should handle invalid model name errors with specific message", async () => {
205+
const messageGenerator = handler.createMessage(systemPrompt, messages)
206+
207+
setImmediate(() => {
208+
mockProcess._simulateStdout(
209+
JSON.stringify({
210+
type: "assistant",
211+
message: {
212+
id: "test-message",
213+
role: "assistant",
214+
content: [{ type: "text", text: "Invalid model name: not-supported-model" }],
215+
stop_reason: "error",
216+
usage: { input_tokens: 10, output_tokens: 5 },
217+
},
218+
}) + "\n",
219+
)
220+
})
221+
222+
await expect(async () => {
223+
for await (const chunk of messageGenerator) {
224+
// Should throw when processing the error message
225+
}
226+
}).rejects.toThrow(
227+
"Invalid model name: not-supported-model\n\nAPI keys and subscription plans allow different models. Make sure the selected model is included in your plan.",
228+
)
229+
})
230+
})
231+
232+
describe("getModel", () => {
233+
it("should return default model if no model ID is provided", () => {
234+
const handlerWithoutModel = new ClaudeCodeHandler({
235+
claudeCodePath: "/path/to/claude",
236+
apiModelId: undefined,
237+
})
238+
const model = handlerWithoutModel.getModel()
239+
expect(model.id).toBe("claude-sonnet-4-20250514") // default model
240+
expect(model.info).toBeDefined()
241+
})
242+
243+
it("should return specified model if valid model ID is provided", () => {
244+
const model = handler.getModel()
245+
expect(model.id).toBe(mockOptions.apiModelId)
246+
expect(model.info).toBeDefined()
247+
})
248+
249+
it("should return default model for invalid model ID", () => {
250+
const handlerWithInvalidModel = new ClaudeCodeHandler({
251+
claudeCodePath: "/path/to/claude",
252+
apiModelId: "invalid-model-id",
253+
})
254+
const model = handlerWithInvalidModel.getModel()
255+
expect(model.id).toBe("claude-sonnet-4-20250514") // falls back to default
256+
expect(model.info).toBeDefined()
257+
})
258+
})
259+
260+
describe("attemptParseChunk", () => {
261+
it("should parse valid JSON chunks", () => {
262+
const validJson = '{"type":"assistant","message":{"content":[{"type":"text","text":"test"}]}}'
263+
// Access private method for testing
264+
const result = (handler as any).attemptParseChunk(validJson)
265+
expect(result).toEqual({
266+
type: "assistant",
267+
message: {
268+
content: [{ type: "text", text: "test" }],
269+
},
270+
})
271+
})
272+
273+
it("should return null for invalid JSON", () => {
274+
const invalidJson = "invalid json"
275+
const result = (handler as any).attemptParseChunk(invalidJson)
276+
expect(result).toBeNull()
277+
})
278+
279+
it("should log warning for JSON-like strings that fail to parse", () => {
280+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
281+
const malformedJson = '{"type":"test", invalid}'
282+
const result = (handler as any).attemptParseChunk(malformedJson)
283+
expect(result).toBeNull()
284+
expect(consoleSpy).toHaveBeenCalledWith(
285+
"Failed to parse potential JSON chunk from Claude Code:",
286+
expect.any(Error),
287+
)
288+
consoleSpy.mockRestore()
289+
})
290+
291+
it("should not log warning for plain text that doesn't look like JSON", () => {
292+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
293+
const plainText = "This is just plain text"
294+
const result = (handler as any).attemptParseChunk(plainText)
295+
expect(result).toBeNull()
296+
expect(consoleSpy).not.toHaveBeenCalled()
297+
consoleSpy.mockRestore()
298+
})
299+
})
300+
})

0 commit comments

Comments
 (0)