@aaronshaf/ger 0.1.0 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bun.lock +29 -4
- package/package.json +2 -1
- package/src/cli/commands/review.ts +221 -213
- package/src/cli/index.ts +13 -4
- package/src/prompts/default-review.md +45 -39
- package/src/prompts/system-inline-review.md +50 -25
- package/src/prompts/system-overall-review.md +33 -8
- package/src/services/git-worktree.ts +297 -0
- package/src/services/review-strategy.ts +373 -0
- package/src/utils/review-formatters.ts +89 -0
- package/src/utils/review-prompt-builder.ts +111 -0
- package/tests/review.test.ts +94 -628
- package/tests/unit/git-branch-detection.test.ts +83 -0
- package/tests/unit/git-worktree.test.ts +54 -0
- package/tests/unit/services/review-strategy.test.ts +494 -0
- package/src/services/ai-enhanced.ts +0 -167
- package/src/services/ai.ts +0 -182
- package/tests/ai-service.test.ts +0 -489
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { Effect, Layer } from 'effect'
|
|
3
|
+
import { GitWorktreeService, GitWorktreeServiceLive } from '@/services/git-worktree'
|
|
4
|
+
|
|
5
|
+
describe('Git Worktree Creation', () => {
|
|
6
|
+
test('should handle commit-based worktree creation in service interface', async () => {
|
|
7
|
+
// This test verifies that the GitWorktreeService creates worktrees using
|
|
8
|
+
// commit hashes to avoid branch conflicts (detached HEAD approach)
|
|
9
|
+
|
|
10
|
+
const mockGitService = {
|
|
11
|
+
validatePreconditions: () => Effect.succeed(undefined),
|
|
12
|
+
createWorktree: (changeId: string) => {
|
|
13
|
+
// Simulate commit-based worktree creation (detached HEAD)
|
|
14
|
+
const currentCommit = 'abc123def456' // Mock commit hash
|
|
15
|
+
return Effect.succeed({
|
|
16
|
+
path: `/tmp/test-worktree-${changeId}`,
|
|
17
|
+
changeId,
|
|
18
|
+
originalCwd: process.cwd(),
|
|
19
|
+
timestamp: Date.now(),
|
|
20
|
+
pid: process.pid,
|
|
21
|
+
})
|
|
22
|
+
},
|
|
23
|
+
fetchAndCheckoutPatchset: () => Effect.succeed(undefined),
|
|
24
|
+
cleanup: () => Effect.succeed(undefined),
|
|
25
|
+
getChangedFiles: () => Effect.succeed(['test.ts']),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const result = await Effect.runPromise(
|
|
29
|
+
Effect.gen(function* () {
|
|
30
|
+
const service = yield* GitWorktreeService
|
|
31
|
+
|
|
32
|
+
// This call should work without specifying a base branch
|
|
33
|
+
// The implementation will auto-detect main vs master vs other
|
|
34
|
+
const worktree = yield* service.createWorktree('12345')
|
|
35
|
+
|
|
36
|
+
return worktree
|
|
37
|
+
}).pipe(Effect.provide(Layer.succeed(GitWorktreeService, mockGitService))),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
expect(result.changeId).toBe('12345')
|
|
41
|
+
expect(result.path).toContain('12345')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('should demonstrate branch detection scenarios', () => {
|
|
45
|
+
// Test various branch detection patterns that the real implementation should handle
|
|
46
|
+
const testCases = [
|
|
47
|
+
{ input: 'refs/remotes/origin/main', expected: 'main' },
|
|
48
|
+
{ input: 'refs/remotes/origin/master', expected: 'master' },
|
|
49
|
+
{ input: 'refs/remotes/origin/develop', expected: 'develop' },
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
testCases.forEach(({ input, expected }) => {
|
|
53
|
+
// Simulate the regex pattern used in getDefaultBranch
|
|
54
|
+
const match = input.match(/refs\/remotes\/origin\/(.+)$/)
|
|
55
|
+
const result = match ? match[1] : 'main'
|
|
56
|
+
expect(result).toBe(expected)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('should handle branch list parsing scenarios', () => {
|
|
61
|
+
// Test branch list parsing scenarios
|
|
62
|
+
const testCases = [
|
|
63
|
+
{ input: ' origin/main\n origin/feature-branch', expected: 'main' },
|
|
64
|
+
{ input: ' origin/master\n origin/develop', expected: 'master' },
|
|
65
|
+
{ input: ' origin/main\n origin/master', expected: 'main' }, // main takes precedence
|
|
66
|
+
{ input: ' origin/feature-only', expected: 'main' }, // fallback
|
|
67
|
+
{ input: '', expected: 'main' }, // empty fallback
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
testCases.forEach(({ input, expected }) => {
|
|
71
|
+
// Simulate the branch detection logic
|
|
72
|
+
let result: string
|
|
73
|
+
if (input.includes('origin/main')) {
|
|
74
|
+
result = 'main'
|
|
75
|
+
} else if (input.includes('origin/master')) {
|
|
76
|
+
result = 'master'
|
|
77
|
+
} else {
|
|
78
|
+
result = 'main' // fallback
|
|
79
|
+
}
|
|
80
|
+
expect(result).toBe(expected)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { Effect, Layer } from 'effect'
|
|
3
|
+
import { GitWorktreeService, WorktreeInfo } from '@/services/git-worktree'
|
|
4
|
+
|
|
5
|
+
describe('GitWorktreeService Types and Structure', () => {
|
|
6
|
+
test('should export WorktreeInfo interface with correct structure', () => {
|
|
7
|
+
const mockWorktreeInfo: WorktreeInfo = {
|
|
8
|
+
path: '/tmp/test-worktree',
|
|
9
|
+
changeId: '12345',
|
|
10
|
+
originalCwd: '/test/current',
|
|
11
|
+
timestamp: Date.now(),
|
|
12
|
+
pid: process.pid,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
expect(mockWorktreeInfo.path).toBe('/tmp/test-worktree')
|
|
16
|
+
expect(mockWorktreeInfo.changeId).toBe('12345')
|
|
17
|
+
expect(mockWorktreeInfo.originalCwd).toBe('/test/current')
|
|
18
|
+
expect(typeof mockWorktreeInfo.timestamp).toBe('number')
|
|
19
|
+
expect(typeof mockWorktreeInfo.pid).toBe('number')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('should create service tag correctly', () => {
|
|
23
|
+
expect(GitWorktreeService).toBeDefined()
|
|
24
|
+
expect(typeof GitWorktreeService).toBe('function')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('should be able to create mock service implementation', async () => {
|
|
28
|
+
const mockService = {
|
|
29
|
+
validatePreconditions: () => Effect.succeed(undefined),
|
|
30
|
+
createWorktree: (changeId: string) =>
|
|
31
|
+
Effect.succeed({
|
|
32
|
+
path: `/tmp/test-worktree-${changeId}`,
|
|
33
|
+
changeId,
|
|
34
|
+
originalCwd: process.cwd(),
|
|
35
|
+
timestamp: Date.now(),
|
|
36
|
+
pid: process.pid,
|
|
37
|
+
}),
|
|
38
|
+
fetchAndCheckoutPatchset: () => Effect.succeed(undefined),
|
|
39
|
+
cleanup: () => Effect.succeed(undefined),
|
|
40
|
+
getChangedFiles: () => Effect.succeed(['test.ts']),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const result = await Effect.runPromise(
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
const service = yield* GitWorktreeService
|
|
46
|
+
const worktree = yield* service.createWorktree('12345')
|
|
47
|
+
return worktree
|
|
48
|
+
}).pipe(Effect.provide(Layer.succeed(GitWorktreeService, mockService))),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
expect(result.changeId).toBe('12345')
|
|
52
|
+
expect(result.path).toContain('12345')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, mock } from 'bun:test'
|
|
2
|
+
import { Effect } from 'effect'
|
|
3
|
+
|
|
4
|
+
// Create testable versions of the strategies by injecting dependencies
|
|
5
|
+
interface MockDeps {
|
|
6
|
+
execAsync: (cmd: string) => Promise<{ stdout: string; stderr: string }>
|
|
7
|
+
spawn: (command: string, options: any) => any
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Mock for Claude SDK
|
|
11
|
+
interface MockClaudeSDK {
|
|
12
|
+
query: any // Will be a Bun mock function
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Test implementation that mirrors the real strategy structure
|
|
16
|
+
const createTestStrategy = (name: string, command: string, flags: string[], deps: MockDeps) => ({
|
|
17
|
+
name,
|
|
18
|
+
isAvailable: () =>
|
|
19
|
+
Effect.gen(function* () {
|
|
20
|
+
try {
|
|
21
|
+
const result = yield* Effect.tryPromise({
|
|
22
|
+
try: () => deps.execAsync(`which ${command.split(' ')[0]}`),
|
|
23
|
+
catch: () => null,
|
|
24
|
+
}).pipe(Effect.orElseSucceed(() => null))
|
|
25
|
+
|
|
26
|
+
return Boolean(result && result.stdout.trim())
|
|
27
|
+
} catch {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
}),
|
|
31
|
+
executeReview: (prompt: string, options: { cwd?: string } = {}) =>
|
|
32
|
+
Effect.gen(function* () {
|
|
33
|
+
const result = yield* Effect.tryPromise({
|
|
34
|
+
try: async () => {
|
|
35
|
+
const child = deps.spawn(`${command} ${flags.join(' ')}`, {
|
|
36
|
+
shell: true,
|
|
37
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
38
|
+
cwd: options.cwd || process.cwd(),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
child.stdin.write(prompt)
|
|
42
|
+
child.stdin.end()
|
|
43
|
+
|
|
44
|
+
let stdout = ''
|
|
45
|
+
let stderr = ''
|
|
46
|
+
|
|
47
|
+
child.stdout.on('data', (data: Buffer) => {
|
|
48
|
+
stdout += data.toString()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
child.stderr.on('data', (data: Buffer) => {
|
|
52
|
+
stderr += data.toString()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
|
56
|
+
child.on('close', (code: number) => {
|
|
57
|
+
if (code !== 0) {
|
|
58
|
+
reject(new Error(`${name} exited with code ${code}: ${stderr}`))
|
|
59
|
+
} else {
|
|
60
|
+
resolve({ stdout, stderr })
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
child.on('error', reject)
|
|
65
|
+
})
|
|
66
|
+
},
|
|
67
|
+
catch: (error) =>
|
|
68
|
+
new Error(`${name} failed: ${error instanceof Error ? error.message : String(error)}`),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// Extract response from <response> tags or use full output
|
|
72
|
+
const responseMatch = result.stdout.match(/<response>([\s\S]*?)<\/response>/i)
|
|
73
|
+
return responseMatch ? responseMatch[1].trim() : result.stdout.trim()
|
|
74
|
+
}),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// Test implementation for Claude SDK strategy
|
|
78
|
+
const createSDKStrategy = (
|
|
79
|
+
name: string,
|
|
80
|
+
deps: { sdk: MockClaudeSDK | null; hasApiKey: boolean },
|
|
81
|
+
) => ({
|
|
82
|
+
name,
|
|
83
|
+
isAvailable: () =>
|
|
84
|
+
Effect.gen(function* () {
|
|
85
|
+
return Boolean(deps.sdk && deps.hasApiKey)
|
|
86
|
+
}),
|
|
87
|
+
executeReview: (prompt: string, options: { cwd?: string; systemPrompt?: string } = {}) =>
|
|
88
|
+
Effect.gen(function* () {
|
|
89
|
+
if (!deps.sdk) {
|
|
90
|
+
return yield* Effect.fail(new Error(`${name} not available`))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const result = yield* Effect.tryPromise({
|
|
94
|
+
try: async () => {
|
|
95
|
+
for await (const message of deps.sdk!.query({
|
|
96
|
+
prompt,
|
|
97
|
+
options: {
|
|
98
|
+
maxTurns: 3,
|
|
99
|
+
customSystemPrompt: options.systemPrompt || 'You are a code review expert.',
|
|
100
|
+
allowedTools: ['Read', 'Grep', 'Glob'],
|
|
101
|
+
cwd: options.cwd,
|
|
102
|
+
},
|
|
103
|
+
})) {
|
|
104
|
+
if (message.type === 'result' && message.subtype === 'success' && message.result) {
|
|
105
|
+
return message.result
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
throw new Error('No result received')
|
|
109
|
+
},
|
|
110
|
+
catch: (error) =>
|
|
111
|
+
new Error(`${name} failed: ${error instanceof Error ? error.message : String(error)}`),
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
return result
|
|
115
|
+
}),
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('Review Strategy', () => {
|
|
119
|
+
let mockExecAsync: any
|
|
120
|
+
let mockSpawn: any
|
|
121
|
+
let mockChildProcess: any
|
|
122
|
+
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
mockChildProcess = {
|
|
125
|
+
stdin: {
|
|
126
|
+
write: mock(() => {}),
|
|
127
|
+
end: mock(() => {}),
|
|
128
|
+
},
|
|
129
|
+
stdout: {
|
|
130
|
+
on: mock(() => {}),
|
|
131
|
+
},
|
|
132
|
+
stderr: {
|
|
133
|
+
on: mock(() => {}),
|
|
134
|
+
},
|
|
135
|
+
on: mock(() => {}),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
mockExecAsync = mock()
|
|
139
|
+
mockSpawn = mock(() => mockChildProcess)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const setupSuccessfulExecution = (output = 'AI response') => {
|
|
143
|
+
mockChildProcess.stdout.on.mockImplementation((event: string, callback: Function) => {
|
|
144
|
+
if (event === 'data') {
|
|
145
|
+
process.nextTick(() => callback(Buffer.from(output)))
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
mockChildProcess.stderr.on.mockImplementation((event: string, callback: Function) => {
|
|
150
|
+
// No stderr for success
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
mockChildProcess.on.mockImplementation((event: string, callback: Function) => {
|
|
154
|
+
if (event === 'close') {
|
|
155
|
+
process.nextTick(() => callback(0))
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const setupFailedExecution = (exitCode = 1, stderr = 'Command failed') => {
|
|
161
|
+
mockChildProcess.stdout.on.mockImplementation((event: string, callback: Function) => {
|
|
162
|
+
// No stdout for failure
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
mockChildProcess.stderr.on.mockImplementation((event: string, callback: Function) => {
|
|
166
|
+
if (event === 'data') {
|
|
167
|
+
process.nextTick(() => callback(Buffer.from(stderr)))
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
mockChildProcess.on.mockImplementation((event: string, callback: Function) => {
|
|
172
|
+
if (event === 'close') {
|
|
173
|
+
process.nextTick(() => callback(exitCode))
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
describe('Claude CLI Strategy', () => {
|
|
179
|
+
let claudeStrategy: any
|
|
180
|
+
|
|
181
|
+
beforeEach(() => {
|
|
182
|
+
claudeStrategy = createTestStrategy('Claude CLI', 'claude', ['-p'], {
|
|
183
|
+
execAsync: mockExecAsync,
|
|
184
|
+
spawn: mockSpawn,
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('should check availability when claude is installed', async () => {
|
|
189
|
+
mockExecAsync.mockResolvedValueOnce({ stdout: '/usr/local/bin/claude', stderr: '' })
|
|
190
|
+
|
|
191
|
+
const available = await Effect.runPromise(claudeStrategy.isAvailable())
|
|
192
|
+
|
|
193
|
+
expect(available).toBe(true)
|
|
194
|
+
expect(mockExecAsync).toHaveBeenCalledWith('which claude')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('should check availability when claude is not installed', async () => {
|
|
198
|
+
mockExecAsync.mockRejectedValueOnce(new Error('Command not found'))
|
|
199
|
+
|
|
200
|
+
const available = await Effect.runPromise(claudeStrategy.isAvailable())
|
|
201
|
+
|
|
202
|
+
expect(available).toBe(false)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('should execute review successfully', async () => {
|
|
206
|
+
setupSuccessfulExecution('Claude AI response')
|
|
207
|
+
|
|
208
|
+
const response = await Effect.runPromise(
|
|
209
|
+
claudeStrategy.executeReview('Test prompt', { cwd: '/tmp' }),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
expect(response).toBe('Claude AI response')
|
|
213
|
+
expect(mockSpawn).toHaveBeenCalledWith('claude -p', {
|
|
214
|
+
shell: true,
|
|
215
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
216
|
+
cwd: '/tmp',
|
|
217
|
+
})
|
|
218
|
+
expect(mockChildProcess.stdin.write).toHaveBeenCalledWith('Test prompt')
|
|
219
|
+
expect(mockChildProcess.stdin.end).toHaveBeenCalled()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('should extract response from tags', async () => {
|
|
223
|
+
setupSuccessfulExecution('<response>Tagged content</response>')
|
|
224
|
+
|
|
225
|
+
const response = await Effect.runPromise(claudeStrategy.executeReview('Test prompt'))
|
|
226
|
+
|
|
227
|
+
expect(response).toBe('Tagged content')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('should handle command failures', async () => {
|
|
231
|
+
setupFailedExecution(1, 'Claude CLI error')
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
await Effect.runPromise(claudeStrategy.executeReview('Test prompt'))
|
|
235
|
+
expect(false).toBe(true) // Should not reach here
|
|
236
|
+
} catch (error: any) {
|
|
237
|
+
expect(error.message).toContain('Claude CLI failed')
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
describe('Gemini CLI Strategy', () => {
|
|
243
|
+
let geminiStrategy: any
|
|
244
|
+
|
|
245
|
+
beforeEach(() => {
|
|
246
|
+
geminiStrategy = createTestStrategy('Gemini CLI', 'gemini', ['-p'], {
|
|
247
|
+
execAsync: mockExecAsync,
|
|
248
|
+
spawn: mockSpawn,
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('should check availability', async () => {
|
|
253
|
+
mockExecAsync.mockResolvedValueOnce({ stdout: '/usr/local/bin/gemini', stderr: '' })
|
|
254
|
+
|
|
255
|
+
const available = await Effect.runPromise(geminiStrategy.isAvailable())
|
|
256
|
+
|
|
257
|
+
expect(available).toBe(true)
|
|
258
|
+
expect(mockExecAsync).toHaveBeenCalledWith('which gemini')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('should use -p flag', async () => {
|
|
262
|
+
setupSuccessfulExecution('Gemini response')
|
|
263
|
+
|
|
264
|
+
const response = await Effect.runPromise(geminiStrategy.executeReview('Test prompt'))
|
|
265
|
+
|
|
266
|
+
expect(response).toBe('Gemini response')
|
|
267
|
+
expect(mockSpawn).toHaveBeenCalledWith('gemini -p', expect.any(Object))
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
describe('OpenCode CLI Strategy', () => {
|
|
272
|
+
let opencodeStrategy: any
|
|
273
|
+
|
|
274
|
+
beforeEach(() => {
|
|
275
|
+
opencodeStrategy = createTestStrategy('OpenCode CLI', 'opencode', ['-p'], {
|
|
276
|
+
execAsync: mockExecAsync,
|
|
277
|
+
spawn: mockSpawn,
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('should check availability', async () => {
|
|
282
|
+
mockExecAsync.mockResolvedValueOnce({ stdout: '/usr/local/bin/opencode', stderr: '' })
|
|
283
|
+
|
|
284
|
+
const available = await Effect.runPromise(opencodeStrategy.isAvailable())
|
|
285
|
+
|
|
286
|
+
expect(available).toBe(true)
|
|
287
|
+
expect(mockExecAsync).toHaveBeenCalledWith('which opencode')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('should use -p flag', async () => {
|
|
291
|
+
setupSuccessfulExecution('OpenCode response')
|
|
292
|
+
|
|
293
|
+
const response = await Effect.runPromise(opencodeStrategy.executeReview('Test prompt'))
|
|
294
|
+
|
|
295
|
+
expect(response).toBe('OpenCode response')
|
|
296
|
+
expect(mockSpawn).toHaveBeenCalledWith('opencode -p', expect.any(Object))
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
describe('Claude SDK Strategy', () => {
|
|
301
|
+
let mockSDK: MockClaudeSDK
|
|
302
|
+
let claudeSDKStrategy: any
|
|
303
|
+
|
|
304
|
+
beforeEach(() => {
|
|
305
|
+
mockSDK = {
|
|
306
|
+
query: mock(),
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
claudeSDKStrategy = createSDKStrategy('Claude SDK', {
|
|
310
|
+
sdk: mockSDK,
|
|
311
|
+
hasApiKey: true,
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('should check availability when SDK and API key are present', async () => {
|
|
316
|
+
const available = await Effect.runPromise(claudeSDKStrategy.isAvailable())
|
|
317
|
+
|
|
318
|
+
expect(available).toBe(true)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('should check availability when SDK is missing', async () => {
|
|
322
|
+
const strategy = createSDKStrategy('Claude SDK', {
|
|
323
|
+
sdk: null,
|
|
324
|
+
hasApiKey: true,
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
const available = await Effect.runPromise(strategy.isAvailable())
|
|
328
|
+
|
|
329
|
+
expect(available).toBe(false)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('should check availability when API key is missing', async () => {
|
|
333
|
+
const strategy = createSDKStrategy('Claude SDK', {
|
|
334
|
+
sdk: mockSDK,
|
|
335
|
+
hasApiKey: false,
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
const available = await Effect.runPromise(strategy.isAvailable())
|
|
339
|
+
|
|
340
|
+
expect(available).toBe(false)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('should execute review successfully', async () => {
|
|
344
|
+
mockSDK.query = mock(async function* () {
|
|
345
|
+
yield { type: 'message', content: 'Thinking...' }
|
|
346
|
+
yield { type: 'result', subtype: 'success', result: 'Claude SDK response' }
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
const response = await Effect.runPromise(
|
|
350
|
+
claudeSDKStrategy.executeReview('Test prompt', { cwd: '/tmp' }),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
expect(response).toBe('Claude SDK response')
|
|
354
|
+
expect(mockSDK.query).toHaveBeenCalledWith({
|
|
355
|
+
prompt: 'Test prompt',
|
|
356
|
+
options: {
|
|
357
|
+
maxTurns: 3,
|
|
358
|
+
customSystemPrompt: 'You are a code review expert.',
|
|
359
|
+
allowedTools: ['Read', 'Grep', 'Glob'],
|
|
360
|
+
cwd: '/tmp',
|
|
361
|
+
},
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('should use custom system prompt', async () => {
|
|
366
|
+
mockSDK.query = mock(async function* () {
|
|
367
|
+
yield { type: 'result', subtype: 'success', result: 'Custom prompt response' }
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
const response = await Effect.runPromise(
|
|
371
|
+
claudeSDKStrategy.executeReview('Test prompt', {
|
|
372
|
+
systemPrompt: 'Custom review prompt',
|
|
373
|
+
}),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
expect(response).toBe('Custom prompt response')
|
|
377
|
+
expect(mockSDK.query).toHaveBeenCalledWith({
|
|
378
|
+
prompt: 'Test prompt',
|
|
379
|
+
options: {
|
|
380
|
+
maxTurns: 3,
|
|
381
|
+
customSystemPrompt: 'Custom review prompt',
|
|
382
|
+
allowedTools: ['Read', 'Grep', 'Glob'],
|
|
383
|
+
cwd: undefined,
|
|
384
|
+
},
|
|
385
|
+
})
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('should handle SDK failure', async () => {
|
|
389
|
+
mockSDK.query = mock(async function* () {
|
|
390
|
+
throw new Error('SDK error')
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
await Effect.runPromise(claudeSDKStrategy.executeReview('Test prompt'))
|
|
395
|
+
expect(false).toBe(true) // Should not reach here
|
|
396
|
+
} catch (error: any) {
|
|
397
|
+
expect(error.message).toContain('Claude SDK failed')
|
|
398
|
+
}
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('should handle no result received', async () => {
|
|
402
|
+
mockSDK.query = mock(async function* () {
|
|
403
|
+
yield { type: 'message', content: 'No result' }
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
await Effect.runPromise(claudeSDKStrategy.executeReview('Test prompt'))
|
|
408
|
+
expect(false).toBe(true) // Should not reach here
|
|
409
|
+
} catch (error: any) {
|
|
410
|
+
expect(error.message).toContain('No result received')
|
|
411
|
+
}
|
|
412
|
+
})
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
describe('Integration with actual service patterns', () => {
|
|
416
|
+
it('should demonstrate proper Effect patterns', async () => {
|
|
417
|
+
const mockStrategy = createTestStrategy('Mock CLI', 'mock', [], {
|
|
418
|
+
execAsync: mockExecAsync,
|
|
419
|
+
spawn: mockSpawn,
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
setupSuccessfulExecution('Integration test response')
|
|
423
|
+
|
|
424
|
+
// Test using Effect.gen patterns like the real service
|
|
425
|
+
const result = await Effect.runPromise(
|
|
426
|
+
Effect.gen(function* () {
|
|
427
|
+
// Test availability check
|
|
428
|
+
mockExecAsync.mockResolvedValueOnce({ stdout: '/usr/local/bin/mock', stderr: '' })
|
|
429
|
+
const available = yield* mockStrategy.isAvailable()
|
|
430
|
+
|
|
431
|
+
if (!available) {
|
|
432
|
+
return yield* Effect.fail(new Error('Strategy not available'))
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Test execution
|
|
436
|
+
const response = yield* mockStrategy.executeReview('Test prompt', { cwd: '/tmp' })
|
|
437
|
+
return response
|
|
438
|
+
}),
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
expect(result).toBe('Integration test response')
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
it('should handle error propagation correctly', async () => {
|
|
445
|
+
const mockStrategy = createTestStrategy('Failing CLI', 'failing', [], {
|
|
446
|
+
execAsync: mockExecAsync,
|
|
447
|
+
spawn: mockSpawn,
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
setupFailedExecution(1, 'Mock failure')
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
await Effect.runPromise(
|
|
454
|
+
Effect.gen(function* () {
|
|
455
|
+
return yield* mockStrategy.executeReview('Test prompt')
|
|
456
|
+
}),
|
|
457
|
+
)
|
|
458
|
+
expect(false).toBe(true) // Should not reach here
|
|
459
|
+
} catch (error: any) {
|
|
460
|
+
expect(error.message).toContain('Failing CLI failed')
|
|
461
|
+
}
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
it('should test multiple strategy selection logic', async () => {
|
|
465
|
+
const strategies = [
|
|
466
|
+
createTestStrategy('Strategy A', 'a', [], { execAsync: mockExecAsync, spawn: mockSpawn }),
|
|
467
|
+
createTestStrategy('Strategy B', 'b', [], { execAsync: mockExecAsync, spawn: mockSpawn }),
|
|
468
|
+
createTestStrategy('Strategy C', 'c', [], { execAsync: mockExecAsync, spawn: mockSpawn }),
|
|
469
|
+
]
|
|
470
|
+
|
|
471
|
+
// Mock availability checks: A fails, B succeeds, C succeeds
|
|
472
|
+
mockExecAsync
|
|
473
|
+
.mockRejectedValueOnce(new Error('Command not found')) // A not available
|
|
474
|
+
.mockResolvedValueOnce({ stdout: '/usr/local/bin/b', stderr: '' }) // B available
|
|
475
|
+
.mockResolvedValueOnce({ stdout: '/usr/local/bin/c', stderr: '' }) // C available
|
|
476
|
+
|
|
477
|
+
const available = await Effect.runPromise(
|
|
478
|
+
Effect.gen(function* () {
|
|
479
|
+
const availableStrategies = []
|
|
480
|
+
for (const strategy of strategies) {
|
|
481
|
+
const isAvailable = yield* strategy.isAvailable()
|
|
482
|
+
if (isAvailable) {
|
|
483
|
+
availableStrategies.push(strategy)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return availableStrategies
|
|
487
|
+
}),
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
expect(available.length).toBe(2)
|
|
491
|
+
expect(available.map((s) => s.name)).toEqual(['Strategy B', 'Strategy C'])
|
|
492
|
+
})
|
|
493
|
+
})
|
|
494
|
+
})
|