@aaronshaf/ger
Version:
Gerrit CLI and SDK - A modern CLI tool and TypeScript SDK for Gerrit Code Review, built with Effect-TS
350 lines (279 loc) • 11.4 kB
text/typescript
import { describe, it, expect, beforeEach, mock } from 'bun:test'
import { Effect } from 'effect'
// Create testable versions of the strategies by injecting dependencies
interface MockDeps {
execAsync: (cmd: string) => Promise<{ stdout: string; stderr: string }>
spawn: (command: string, options: any) => any
}
// Test implementation that mirrors the real strategy structure
const createTestStrategy = (name: string, command: string, flags: string[], deps: MockDeps) => ({
name,
isAvailable: () =>
Effect.gen(function* () {
try {
const result = yield* Effect.tryPromise({
try: () => deps.execAsync(`which ${command.split(' ')[0]}`),
catch: () => null,
}).pipe(Effect.orElseSucceed(() => null))
return Boolean(result && result.stdout.trim())
} catch {
return false
}
}),
executeReview: (prompt: string, options: { cwd?: string } = {}) =>
Effect.gen(function* () {
const result = yield* Effect.tryPromise({
try: async () => {
const child = deps.spawn(`${command} ${flags.join(' ')}`, {
shell: true,
stdio: ['pipe', 'pipe', 'pipe'],
cwd: options.cwd || process.cwd(),
})
child.stdin.write(prompt)
child.stdin.end()
let stdout = ''
let stderr = ''
child.stdout.on('data', (data: Buffer) => {
stdout += data.toString()
})
child.stderr.on('data', (data: Buffer) => {
stderr += data.toString()
})
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
child.on('close', (code: number) => {
if (code !== 0) {
reject(new Error(`${name} exited with code ${code}: ${stderr}`))
} else {
resolve({ stdout, stderr })
}
})
child.on('error', reject)
})
},
catch: (error) =>
new Error(`${name} failed: ${error instanceof Error ? error.message : String(error)}`),
})
// Extract response from <response> tags or use full output
const responseMatch = result.stdout.match(/<response>([\s\S]*?)<\/response>/i)
return responseMatch ? responseMatch[1].trim() : result.stdout.trim()
}),
})
describe('Review Strategy', () => {
let mockExecAsync: any
let mockSpawn: any
let mockChildProcess: any
beforeEach(() => {
mockChildProcess = {
stdin: {
write: mock(() => {}),
end: mock(() => {}),
},
stdout: {
on: mock(() => {}),
},
stderr: {
on: mock(() => {}),
},
on: mock(() => {}),
}
mockExecAsync = mock()
mockSpawn = mock(() => mockChildProcess)
})
const setupSuccessfulExecution = (output = 'AI response') => {
mockChildProcess.stdout.on.mockImplementation((event: string, callback: Function) => {
if (event === 'data') {
process.nextTick(() => callback(Buffer.from(output)))
}
})
mockChildProcess.stderr.on.mockImplementation((_event: string, _callback: Function) => {
// No stderr for success
})
mockChildProcess.on.mockImplementation((event: string, callback: Function) => {
if (event === 'close') {
process.nextTick(() => callback(0))
}
})
}
const setupFailedExecution = (exitCode = 1, stderr = 'Command failed') => {
mockChildProcess.stdout.on.mockImplementation((_event: string, _callback: Function) => {
// No stdout for failure
})
mockChildProcess.stderr.on.mockImplementation((event: string, callback: Function) => {
if (event === 'data') {
process.nextTick(() => callback(Buffer.from(stderr)))
}
})
mockChildProcess.on.mockImplementation((event: string, callback: Function) => {
if (event === 'close') {
process.nextTick(() => callback(exitCode))
}
})
}
describe('Claude CLI Strategy', () => {
let claudeStrategy: any
beforeEach(() => {
claudeStrategy = createTestStrategy('Claude CLI', 'claude', ['-p'], {
execAsync: mockExecAsync,
spawn: mockSpawn,
})
})
it('should check availability when claude is installed', async () => {
mockExecAsync.mockResolvedValueOnce({ stdout: '/usr/local/bin/claude', stderr: '' })
const available = await Effect.runPromise(claudeStrategy.isAvailable())
expect(available).toBe(true)
expect(mockExecAsync).toHaveBeenCalledWith('which claude')
})
it('should check availability when claude is not installed', async () => {
mockExecAsync.mockRejectedValueOnce(new Error('Command not found'))
const available = await Effect.runPromise(claudeStrategy.isAvailable())
expect(available).toBe(false)
})
it('should execute review successfully', async () => {
setupSuccessfulExecution('Claude AI response')
const response = await Effect.runPromise(
claudeStrategy.executeReview('Test prompt', { cwd: '/tmp' }),
)
expect(response).toBe('Claude AI response')
expect(mockSpawn).toHaveBeenCalledWith('claude -p', {
shell: true,
stdio: ['pipe', 'pipe', 'pipe'],
cwd: '/tmp',
})
expect(mockChildProcess.stdin.write).toHaveBeenCalledWith('Test prompt')
expect(mockChildProcess.stdin.end).toHaveBeenCalled()
})
it('should extract response from tags', async () => {
setupSuccessfulExecution('<response>Tagged content</response>')
const response = await Effect.runPromise(claudeStrategy.executeReview('Test prompt'))
expect(response).toBe('Tagged content')
})
it('should handle command failures', async () => {
setupFailedExecution(1, 'Claude CLI error')
try {
await Effect.runPromise(claudeStrategy.executeReview('Test prompt'))
expect(false).toBe(true) // Should not reach here
} catch (error: any) {
expect(error.message).toContain('Claude CLI failed')
}
})
})
describe('Gemini CLI Strategy', () => {
let geminiStrategy: any
beforeEach(() => {
geminiStrategy = createTestStrategy('Gemini CLI', 'gemini', ['-p'], {
execAsync: mockExecAsync,
spawn: mockSpawn,
})
})
it('should check availability', async () => {
mockExecAsync.mockResolvedValueOnce({ stdout: '/usr/local/bin/gemini', stderr: '' })
const available = await Effect.runPromise(geminiStrategy.isAvailable())
expect(available).toBe(true)
expect(mockExecAsync).toHaveBeenCalledWith('which gemini')
})
it('should use -p flag', async () => {
setupSuccessfulExecution('Gemini response')
const response = await Effect.runPromise(geminiStrategy.executeReview('Test prompt'))
expect(response).toBe('Gemini response')
expect(mockSpawn).toHaveBeenCalledWith('gemini -p', expect.any(Object))
})
it('should extract response from tags', async () => {
setupSuccessfulExecution('<response>Gemini tagged content</response>')
const response = await Effect.runPromise(geminiStrategy.executeReview('Test prompt'))
expect(response).toBe('Gemini tagged content')
})
})
describe('OpenCode CLI Strategy', () => {
let opencodeStrategy: any
beforeEach(() => {
opencodeStrategy = createTestStrategy('OpenCode CLI', 'opencode', ['-p'], {
execAsync: mockExecAsync,
spawn: mockSpawn,
})
})
it('should check availability', async () => {
mockExecAsync.mockResolvedValueOnce({ stdout: '/usr/local/bin/opencode', stderr: '' })
const available = await Effect.runPromise(opencodeStrategy.isAvailable())
expect(available).toBe(true)
expect(mockExecAsync).toHaveBeenCalledWith('which opencode')
})
it('should use -p flag', async () => {
setupSuccessfulExecution('OpenCode response')
const response = await Effect.runPromise(opencodeStrategy.executeReview('Test prompt'))
expect(response).toBe('OpenCode response')
expect(mockSpawn).toHaveBeenCalledWith('opencode -p', expect.any(Object))
})
it('should extract response from tags', async () => {
setupSuccessfulExecution('<response>OpenCode tagged content</response>')
const response = await Effect.runPromise(opencodeStrategy.executeReview('Test prompt'))
expect(response).toBe('OpenCode tagged content')
})
})
describe('Integration with actual service patterns', () => {
it('should demonstrate proper Effect patterns', async () => {
const mockStrategy = createTestStrategy('Mock CLI', 'mock', [], {
execAsync: mockExecAsync,
spawn: mockSpawn,
})
setupSuccessfulExecution('Integration test response')
// Test using Effect.gen patterns like the real service
const result = await Effect.runPromise(
Effect.gen(function* () {
// Test availability check
mockExecAsync.mockResolvedValueOnce({ stdout: '/usr/local/bin/mock', stderr: '' })
const available = yield* mockStrategy.isAvailable()
if (!available) {
return yield* Effect.fail(new Error('Strategy not available'))
}
// Test execution
const response = yield* mockStrategy.executeReview('Test prompt', { cwd: '/tmp' })
return response
}),
)
expect(result).toBe('Integration test response')
})
it('should handle error propagation correctly', async () => {
const mockStrategy = createTestStrategy('Failing CLI', 'failing', [], {
execAsync: mockExecAsync,
spawn: mockSpawn,
})
setupFailedExecution(1, 'Mock failure')
try {
await Effect.runPromise(
Effect.gen(function* () {
return yield* mockStrategy.executeReview('Test prompt')
}),
)
expect(false).toBe(true) // Should not reach here
} catch (error: any) {
expect(error.message).toContain('Failing CLI failed')
}
})
it('should test multiple strategy selection logic', async () => {
const strategies = [
createTestStrategy('Strategy A', 'a', [], { execAsync: mockExecAsync, spawn: mockSpawn }),
createTestStrategy('Strategy B', 'b', [], { execAsync: mockExecAsync, spawn: mockSpawn }),
createTestStrategy('Strategy C', 'c', [], { execAsync: mockExecAsync, spawn: mockSpawn }),
]
// Mock availability checks: A fails, B succeeds, C succeeds
mockExecAsync
.mockRejectedValueOnce(new Error('Command not found')) // A not available
.mockResolvedValueOnce({ stdout: '/usr/local/bin/b', stderr: '' }) // B available
.mockResolvedValueOnce({ stdout: '/usr/local/bin/c', stderr: '' }) // C available
const available = await Effect.runPromise(
Effect.gen(function* () {
const availableStrategies = []
for (const strategy of strategies) {
const isAvailable = yield* strategy.isAvailable()
if (isAvailable) {
availableStrategies.push(strategy)
}
}
return availableStrategies
}),
)
expect(available.length).toBe(2)
expect(available.map((s) => s.name)).toEqual(['Strategy B', 'Strategy C'])
})
})
})