@aaronshaf/ger
Version:
Gerrit CLI and SDK - A modern CLI tool and TypeScript SDK for Gerrit Code Review, built with Effect-TS
654 lines (559 loc) • 24.4 kB
text/typescript
import { describe, test, expect, beforeAll, afterAll, afterEach, mock, spyOn } from 'bun:test'
import { HttpResponse, http } from 'msw'
import { setupServer } from 'msw/node'
import { Effect, Layer } from 'effect'
import { checkoutCommand } from '@/cli/commands/checkout'
import { GerritApiServiceLive } from '@/api/gerrit'
import { ConfigService } from '@/services/config'
import { createMockConfigService } from '../helpers/config-mock'
import type { ChangeInfo, RevisionInfo } from '@/schemas/gerrit'
import * as childProcess from 'node:child_process'
/**
* Integration tests for checkout command
*
* Tests complete command workflows including:
* - API interactions (change details, revisions)
* - Git operations (fetch, checkout, branch management)
* - Error handling (network errors, not found, etc.)
* - Various input formats and options
*/
const server = setupServer(
http.get('*/a/accounts/self', ({ request }) => {
const auth = request.headers.get('Authorization')
if (!auth || !auth.startsWith('Basic ')) {
return HttpResponse.text('Unauthorized', { status: 401 })
}
return HttpResponse.json({
_account_id: 1000,
name: 'Test User',
email: 'test@example.com',
})
}),
)
describe('Checkout Command - Integration Tests', () => {
let mockConsoleLog: ReturnType<typeof mock>
let mockConsoleError: ReturnType<typeof mock>
let mockExecSync: ReturnType<typeof spyOn>
const validChangeId = 'If5a3ae8cb5a107e187447802358417f311d0c4b1'
const mockChange: ChangeInfo = {
id: `test-project~main~${validChangeId}`,
_number: 12345,
project: 'test-project',
branch: 'main',
change_id: validChangeId,
subject: 'Test change',
status: 'NEW',
created: '2024-01-15 10:00:00.000000000',
updated: '2024-01-15 10:00:00.000000000',
}
const mockRevision: RevisionInfo = {
_number: 1,
ref: 'refs/changes/45/12345/1',
created: '2024-01-15 10:00:00.000000000',
uploader: {
_account_id: 1000,
name: 'Test User',
email: 'test@example.com',
},
}
beforeAll(() => {
server.listen({ onUnhandledRequest: 'bypass' })
mockConsoleLog = mock(() => {})
mockConsoleError = mock(() => {})
console.log = mockConsoleLog
console.error = mockConsoleError
})
afterAll(() => {
server.close()
})
afterEach(() => {
server.resetHandlers()
mockConsoleLog.mockClear()
mockConsoleError.mockClear()
mockExecSync?.mockRestore()
})
test('should handle change not found error', async () => {
server.use(
http.get('*/a/changes/:changeId', () => {
return HttpResponse.text('Not Found', { status: 404 })
}),
)
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
const program = checkoutCommand('99999', {}).pipe(
Effect.provide(GerritApiServiceLive),
Effect.provide(mockConfigLayer),
)
// Expect the effect to fail with an ApiError
const result = await Effect.runPromise(program.pipe(Effect.either))
expect(result._tag).toBe('Left')
})
test('should fetch change details successfully', async () => {
// Mock git operations to simulate being in a git repo
mockExecSync = spyOn(childProcess, 'execSync').mockImplementation(((
command: string,
_options?: unknown,
) => {
if (command === 'git rev-parse --git-dir') return Buffer.from('.git\n')
if (command === 'git symbolic-ref --short HEAD') return Buffer.from('main\n')
if (command === 'git remote -v') {
return Buffer.from('origin\thttps://test.gerrit.com/repo.git\t(push)\n')
}
if (command.startsWith('git rev-parse --verify review/12345')) {
throw new Error('branch does not exist')
}
if (command.startsWith('git fetch')) return Buffer.from('')
if (command.startsWith('git checkout -b')) return Buffer.from('')
if (command.startsWith('git branch --set-upstream-to')) return Buffer.from('')
return Buffer.from('')
}) as typeof childProcess.execSync)
server.use(
http.get('*/a/changes/12345', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
}),
http.get('*/a/changes/12345/revisions/current', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockRevision)}`)
}),
)
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
const program = checkoutCommand('12345', {}).pipe(
Effect.provide(GerritApiServiceLive),
Effect.provide(mockConfigLayer),
)
const result = await Effect.runPromise(program.pipe(Effect.either))
// Check that we made the API calls and got change details
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
expect(output).toContain('Checking out Gerrit change')
expect(output).toContain('12345')
expect(output).toContain('Test change')
expect(output).toContain('Created and checked out review/12345')
// Should succeed now with mocked git operations
expect(result._tag).toBe('Right')
})
test('should parse URL input correctly', async () => {
mockExecSync = spyOn(childProcess, 'execSync').mockImplementation(((
command: string,
_options?: unknown,
) => {
if (command === 'git rev-parse --git-dir') return Buffer.from('.git\n')
if (command === 'git symbolic-ref --short HEAD') return Buffer.from('main\n')
if (command === 'git remote -v') {
return Buffer.from('origin\thttps://test.gerrit.com/repo.git\t(push)\n')
}
if (command.startsWith('git rev-parse --verify review/12345')) {
throw new Error('branch does not exist')
}
return Buffer.from('')
}) as typeof childProcess.execSync)
server.use(
http.get('*/a/changes/12345', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
}),
http.get('*/a/changes/12345/revisions/current', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockRevision)}`)
}),
)
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
const program = checkoutCommand('https://test.gerrit.com/c/test-project/+/12345', {}).pipe(
Effect.provide(GerritApiServiceLive),
Effect.provide(mockConfigLayer),
)
const result = await Effect.runPromise(program.pipe(Effect.either))
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
expect(output).toContain('Checking out Gerrit change')
expect(output).toContain('12345')
expect(result._tag).toBe('Right')
})
test('should handle specific patchset request', async () => {
mockExecSync = spyOn(childProcess, 'execSync').mockImplementation(((
command: string,
_options?: unknown,
) => {
if (command === 'git rev-parse --git-dir') return Buffer.from('.git\n')
if (command === 'git symbolic-ref --short HEAD') return Buffer.from('main\n')
if (command === 'git remote -v') {
return Buffer.from('origin\thttps://test.gerrit.com/repo.git\t(push)\n')
}
if (command.startsWith('git rev-parse --verify review/12345')) {
throw new Error('branch does not exist')
}
if (command.startsWith('git fetch')) return Buffer.from('')
if (command.startsWith('git checkout -b')) return Buffer.from('')
if (command.startsWith('git branch --set-upstream-to')) return Buffer.from('')
return Buffer.from('')
}) as typeof childProcess.execSync)
const mockRevision2: RevisionInfo = {
_number: 2,
ref: 'refs/changes/45/12345/2',
created: '2024-01-15 11:00:00.000000000',
uploader: {
_account_id: 1000,
name: 'Test User',
email: 'test@example.com',
},
}
server.use(
http.get('*/a/changes/12345', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
}),
http.get('*/a/changes/12345/revisions/2', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockRevision2)}`)
}),
)
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
const program = checkoutCommand('12345/2', {}).pipe(
Effect.provide(GerritApiServiceLive),
Effect.provide(mockConfigLayer),
)
await Effect.runPromise(program.pipe(Effect.either))
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
expect(output).toContain('Patchset: 2')
})
test('should handle detach mode', async () => {
mockExecSync = spyOn(childProcess, 'execSync').mockImplementation(((
command: string,
_options?: unknown,
) => {
if (command === 'git rev-parse --git-dir') return Buffer.from('.git\n')
if (command === 'git remote -v') {
return Buffer.from('origin\thttps://test.gerrit.com/repo.git\t(push)\n')
}
return Buffer.from('')
}) as typeof childProcess.execSync)
server.use(
http.get('*/a/changes/12345', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
}),
http.get('*/a/changes/12345/revisions/current', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockRevision)}`)
}),
)
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
const program = checkoutCommand('12345', { detach: true }).pipe(
Effect.provide(GerritApiServiceLive),
Effect.provide(mockConfigLayer),
)
const result = await Effect.runPromise(program.pipe(Effect.either))
// Verify detach mode was indicated in output
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
expect(output).toContain('Checking out Gerrit change')
expect(output).toContain('12345')
expect(output).toContain('detached HEAD mode')
expect(result._tag).toBe('Right')
})
test('should handle network errors gracefully', async () => {
server.use(
http.get('*/a/changes/12345', () => {
return HttpResponse.error()
}),
)
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
const program = checkoutCommand('12345', {}).pipe(
Effect.provide(GerritApiServiceLive),
Effect.provide(mockConfigLayer),
)
// Expect the effect to fail with a network error
const result = await Effect.runPromise(program.pipe(Effect.either))
expect(result._tag).toBe('Left')
})
test('should handle Change-ID input', async () => {
mockExecSync = spyOn(childProcess, 'execSync').mockImplementation(((
command: string,
_options?: unknown,
) => {
if (command === 'git rev-parse --git-dir') return Buffer.from('.git\n')
if (command === 'git symbolic-ref --short HEAD') return Buffer.from('main\n')
if (command === 'git remote -v') {
return Buffer.from('origin\thttps://test.gerrit.com/repo.git\t(push)\n')
}
if (command.startsWith('git rev-parse --verify review/12345')) {
throw new Error('branch does not exist')
}
if (command.startsWith('git fetch')) return Buffer.from('')
if (command.startsWith('git checkout -b')) return Buffer.from('')
if (command.startsWith('git branch --set-upstream-to')) return Buffer.from('')
return Buffer.from('')
}) as typeof childProcess.execSync)
server.use(
http.get(`*/a/changes/${validChangeId}`, () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
}),
http.get(`*/a/changes/${validChangeId}/revisions/current`, () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockRevision)}`)
}),
)
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
const program = checkoutCommand(validChangeId, {}).pipe(
Effect.provide(GerritApiServiceLive),
Effect.provide(mockConfigLayer),
)
await Effect.runPromise(program.pipe(Effect.either))
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
// Should see the change number in the output
expect(output).toContain('12345')
})
test('should display change information', async () => {
mockExecSync = spyOn(childProcess, 'execSync').mockImplementation(((
command: string,
_options?: unknown,
) => {
if (command === 'git rev-parse --git-dir') return Buffer.from('.git\n')
if (command === 'git symbolic-ref --short HEAD') return Buffer.from('main\n')
if (command === 'git remote -v') {
return Buffer.from('origin\thttps://test.gerrit.com/repo.git\t(push)\n')
}
if (command.startsWith('git rev-parse --verify review/12345')) {
throw new Error('branch does not exist')
}
if (command.startsWith('git fetch')) return Buffer.from('')
if (command.startsWith('git checkout -b')) return Buffer.from('')
if (command.startsWith('git branch --set-upstream-to')) return Buffer.from('')
return Buffer.from('')
}) as typeof childProcess.execSync)
server.use(
http.get('*/a/changes/12345', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
}),
http.get('*/a/changes/12345/revisions/current', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockRevision)}`)
}),
)
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
const program = checkoutCommand('12345', {}).pipe(
Effect.provide(GerritApiServiceLive),
Effect.provide(mockConfigLayer),
)
await Effect.runPromise(program.pipe(Effect.either))
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
expect(output).toContain('Test change')
expect(output).toContain('12345')
})
test('should handle abandoned change', async () => {
mockExecSync = spyOn(childProcess, 'execSync').mockImplementation(((
command: string,
_options?: unknown,
) => {
if (command === 'git rev-parse --git-dir') return Buffer.from('.git\n')
if (command === 'git symbolic-ref --short HEAD') return Buffer.from('main\n')
if (command === 'git remote -v') {
return Buffer.from('origin\thttps://test.gerrit.com/repo.git\t(push)\n')
}
if (command.startsWith('git rev-parse --verify review/12345')) {
throw new Error('branch does not exist')
}
if (command.startsWith('git fetch')) return Buffer.from('')
if (command.startsWith('git checkout -b')) return Buffer.from('')
if (command.startsWith('git branch --set-upstream-to')) return Buffer.from('')
return Buffer.from('')
}) as typeof childProcess.execSync)
const abandonedChange: ChangeInfo = {
...mockChange,
status: 'ABANDONED',
}
server.use(
http.get('*/a/changes/12345', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(abandonedChange)}`)
}),
http.get('*/a/changes/12345/revisions/current', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockRevision)}`)
}),
)
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
const program = checkoutCommand('12345', {}).pipe(
Effect.provide(GerritApiServiceLive),
Effect.provide(mockConfigLayer),
)
await Effect.runPromise(program.pipe(Effect.either))
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
expect(output).toContain('ABANDONED')
})
test('should handle merged change', async () => {
mockExecSync = spyOn(childProcess, 'execSync').mockImplementation(((
command: string,
_options?: unknown,
) => {
if (command === 'git rev-parse --git-dir') return Buffer.from('.git\n')
if (command === 'git symbolic-ref --short HEAD') return Buffer.from('main\n')
if (command === 'git remote -v') {
return Buffer.from('origin\thttps://test.gerrit.com/repo.git\t(push)\n')
}
if (command.startsWith('git rev-parse --verify review/12345')) {
throw new Error('branch does not exist')
}
if (command.startsWith('git fetch')) return Buffer.from('')
if (command.startsWith('git checkout -b')) return Buffer.from('')
if (command.startsWith('git branch --set-upstream-to')) return Buffer.from('')
return Buffer.from('')
}) as typeof childProcess.execSync)
const mergedChange: ChangeInfo = {
...mockChange,
status: 'MERGED',
}
server.use(
http.get('*/a/changes/12345', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mergedChange)}`)
}),
http.get('*/a/changes/12345/revisions/current', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockRevision)}`)
}),
)
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
const program = checkoutCommand('12345', {}).pipe(
Effect.provide(GerritApiServiceLive),
Effect.provide(mockConfigLayer),
)
await Effect.runPromise(program.pipe(Effect.either))
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
expect(output).toContain('MERGED')
})
test('should update existing branch when branch already exists', async () => {
mockExecSync = spyOn(childProcess, 'execSync').mockImplementation(((
command: string,
_options?: unknown,
) => {
if (command === 'git rev-parse --git-dir') return Buffer.from('.git\n')
if (command === 'git symbolic-ref --short HEAD') return Buffer.from('main\n')
if (command === 'git remote -v') {
return Buffer.from('origin\thttps://test.gerrit.com/repo.git\t(push)\n')
}
if (command.startsWith('git rev-parse --verify review/12345')) {
// Branch exists
return Buffer.from('abc123\n')
}
if (command.startsWith('git fetch')) return Buffer.from('')
if (command.startsWith('git checkout review/12345')) return Buffer.from('')
if (command.startsWith('git reset --hard FETCH_HEAD')) return Buffer.from('')
if (command.startsWith('git branch --set-upstream-to')) return Buffer.from('')
return Buffer.from('')
}) as typeof childProcess.execSync)
server.use(
http.get('*/a/changes/12345', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
}),
http.get('*/a/changes/12345/revisions/current', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockRevision)}`)
}),
)
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
const program = checkoutCommand('12345', {}).pipe(
Effect.provide(GerritApiServiceLive),
Effect.provide(mockConfigLayer),
)
const result = await Effect.runPromise(program.pipe(Effect.either))
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
expect(output).toContain('Updated and checked out review/12345')
expect(result._tag).toBe('Right')
})
test('should handle when branch exists and is currently checked out', async () => {
mockExecSync = spyOn(childProcess, 'execSync').mockImplementation(((
command: string,
_options?: unknown,
) => {
if (command === 'git rev-parse --git-dir') return Buffer.from('.git\n')
if (command === 'git symbolic-ref --short HEAD') return Buffer.from('review/12345\n')
if (command === 'git remote -v') {
return Buffer.from('origin\thttps://test.gerrit.com/repo.git\t(push)\n')
}
if (command.startsWith('git rev-parse --verify review/12345')) {
// Branch exists
return Buffer.from('abc123\n')
}
if (command.startsWith('git fetch')) return Buffer.from('')
if (command.startsWith('git reset --hard FETCH_HEAD')) return Buffer.from('')
if (command.startsWith('git branch --set-upstream-to')) return Buffer.from('')
return Buffer.from('')
}) as typeof childProcess.execSync)
server.use(
http.get('*/a/changes/12345', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
}),
http.get('*/a/changes/12345/revisions/current', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockRevision)}`)
}),
)
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
const program = checkoutCommand('12345', {}).pipe(
Effect.provide(GerritApiServiceLive),
Effect.provide(mockConfigLayer),
)
const result = await Effect.runPromise(program.pipe(Effect.either))
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
expect(output).toContain('Updated and checked out review/12345')
// Should not try to switch branches since already on it
expect(result._tag).toBe('Right')
})
test('should fallback to origin when no remote matches Gerrit host', async () => {
mockExecSync = spyOn(childProcess, 'execSync').mockImplementation(((
command: string,
_options?: unknown,
) => {
if (command === 'git rev-parse --git-dir') return Buffer.from('.git\n')
if (command === 'git symbolic-ref --short HEAD') return Buffer.from('main\n')
if (command === 'git remote -v') {
// Remote with different hostname
return Buffer.from('origin\thttps://different.gerrit.com/repo.git\t(push)\n')
}
if (command.startsWith('git rev-parse --verify')) {
throw new Error('branch does not exist')
}
if (command.startsWith('git fetch')) return Buffer.from('')
if (command.startsWith('git checkout -b')) return Buffer.from('')
if (command.startsWith('git branch --set-upstream-to')) return Buffer.from('')
return Buffer.from('')
}) as typeof childProcess.execSync)
server.use(
http.get('*/a/changes/12345', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
}),
http.get('*/a/changes/12345/revisions/current', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockRevision)}`)
}),
)
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
const program = checkoutCommand('12345', {}).pipe(
Effect.provide(GerritApiServiceLive),
Effect.provide(mockConfigLayer),
)
const result = await Effect.runPromise(program.pipe(Effect.either))
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
// Should use 'origin' as fallback
expect(output).toContain('Remote: origin')
expect(result._tag).toBe('Right')
})
test('should use custom remote when specified', async () => {
mockExecSync = spyOn(childProcess, 'execSync').mockImplementation(((
command: string,
_options?: unknown,
) => {
if (command === 'git rev-parse --git-dir') return Buffer.from('.git\n')
if (command === 'git symbolic-ref --short HEAD') return Buffer.from('main\n')
if (command === 'git remote -v') {
return Buffer.from('upstream\thttps://test.gerrit.com/repo.git\t(push)\n')
}
if (command.startsWith('git rev-parse --verify')) {
throw new Error('branch does not exist')
}
if (command.startsWith('git fetch')) return Buffer.from('')
if (command.startsWith('git checkout -b')) return Buffer.from('')
if (command.startsWith('git branch --set-upstream-to')) return Buffer.from('')
return Buffer.from('')
}) as typeof childProcess.execSync)
server.use(
http.get('*/a/changes/12345', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
}),
http.get('*/a/changes/12345/revisions/current', () => {
return HttpResponse.text(`)]}'\n${JSON.stringify(mockRevision)}`)
}),
)
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
const program = checkoutCommand('12345', { remote: 'upstream' }).pipe(
Effect.provide(GerritApiServiceLive),
Effect.provide(mockConfigLayer),
)
const result = await Effect.runPromise(program.pipe(Effect.either))
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
// Should use specified remote
expect(output).toContain('Remote: upstream')
expect(result._tag).toBe('Right')
})
})