@aaronshaf/ger
Version:
Gerrit CLI and SDK - A modern CLI tool and TypeScript SDK for Gerrit Code Review, built with Effect-TS
296 lines (236 loc) • 9.72 kB
text/typescript
import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test'
// Mock external dependencies
const mockFs = {
existsSync: mock(() => false),
mkdirSync: mock(),
}
const mockExecSync = mock()
const mockSpawnSync = mock()
const mockConsole = {
log: mock(),
error: mock(),
}
// Mock modules
mock.module('node:fs', () => mockFs)
mock.module('node:child_process', () => ({
execSync: mockExecSync,
spawnSync: mockSpawnSync,
}))
// Mock global console
global.console = mockConsole as any
describe('Workspace Command', () => {
beforeEach(() => {
// Reset all mocks
Object.values(mockFs).forEach((mock) => mock.mockReset())
mockExecSync.mockReset()
mockSpawnSync.mockReset()
mockConsole.log.mockReset()
mockConsole.error.mockReset()
// Set default mock behaviors
mockFs.existsSync.mockReturnValue(false)
mockExecSync.mockImplementation((command: string) => {
if (command === 'git rev-parse --git-dir') {
return '.git'
}
if (command === 'git rev-parse --show-toplevel') {
return '/repo/root'
}
if (command === 'git remote -v') {
return 'origin\thttps://gerrit.example.com/project\t(fetch)\n'
}
return ''
})
mockSpawnSync.mockReturnValue({ status: 0, stderr: '' })
})
afterEach(() => {
mock.restore()
})
describe('git repository validation', () => {
test('should detect git repository', () => {
mockExecSync.mockReturnValue('.git')
const result = mockExecSync('git rev-parse --git-dir')
expect(result).toBe('.git')
})
test('should handle non-git directory', () => {
mockExecSync.mockImplementation(() => {
throw new Error('Not a git repository')
})
expect(() => {
mockExecSync('git rev-parse --git-dir')
}).toThrow('Not a git repository')
})
test('should find repository root', () => {
mockExecSync.mockReturnValue('/custom/repo/path')
const result = mockExecSync('git rev-parse --show-toplevel')
expect(result).toBe('/custom/repo/path')
})
})
describe('change specification parsing', () => {
test('should parse numeric change ID', () => {
const changeSpec = '12345'
const parts = changeSpec.split(':')
expect(parts[0]).toBe('12345')
expect(parts[1]).toBeUndefined()
})
test('should parse change ID with patchset', () => {
const changeSpec = '12345:3'
const parts = changeSpec.split(':')
expect(parts[0]).toBe('12345')
expect(parts[1]).toBe('3')
})
test('should handle Change-Id format', () => {
const changeId = 'I1234567890abcdef1234567890abcdef12345678'
expect(changeId.startsWith('I')).toBe(true)
expect(changeId.length).toBe(41)
})
})
describe('remote matching', () => {
test('should parse git remotes output', () => {
const remoteOutput = 'origin\thttps://gerrit.example.com/project\t(fetch)\n'
const lines = remoteOutput.split('\n')
const match = lines[0].match(/^(\S+)\s+(\S+)\s+\(fetch\)$/)
expect(match).toBeDefined()
expect(match?.[1]).toBe('origin')
expect(match?.[2]).toBe('https://gerrit.example.com/project')
})
test('should match HTTP URLs', () => {
const gerritHost = 'https://gerrit.example.com'
const remoteUrl = 'https://gerrit.example.com/project'
const gerritHostname = new URL(gerritHost).hostname
const remoteHostname = new URL(remoteUrl).hostname
expect(gerritHostname).toBe(remoteHostname)
})
test('should match SSH URLs', () => {
const gerritHost = 'https://gerrit.example.com'
const sshUrl = 'git@gerrit.example.com:project'
const gerritHostname = new URL(gerritHost).hostname
const sshHostname = sshUrl.split('@')[1].split(':')[0]
expect(gerritHostname).toBe(sshHostname)
})
})
describe('workspace directory management', () => {
test('should check if directory exists', () => {
mockFs.existsSync.mockReturnValue(true)
const exists = mockFs.existsSync()
expect(exists).toBe(true)
})
test('should create directory recursively', () => {
mockFs.mkdirSync('/repo/root/.ger', { recursive: true })
expect(mockFs.mkdirSync).toHaveBeenCalledWith('/repo/root/.ger', { recursive: true })
})
test('should validate change numbers', () => {
const validChangeNumber = '12345'
const invalidChangeNumber = '../../../etc/passwd'
expect(/^\d+$/.test(validChangeNumber)).toBe(true)
expect(/^\d+$/.test(invalidChangeNumber)).toBe(false)
})
})
describe('git operations', () => {
test('should execute git fetch', () => {
mockSpawnSync.mockReturnValue({ status: 0, stderr: '' })
const result = mockSpawnSync('git', ['fetch', 'origin', 'refs/changes/45/12345/1'], {
encoding: 'utf8',
cwd: '/repo/root',
})
expect(result.status).toBe(0)
expect(mockSpawnSync).toHaveBeenCalledWith(
'git',
['fetch', 'origin', 'refs/changes/45/12345/1'],
{ encoding: 'utf8', cwd: '/repo/root' },
)
})
test('should handle git fetch failure', () => {
mockSpawnSync.mockReturnValue({ status: 1, stderr: 'fetch failed' })
const result = mockSpawnSync('git', ['fetch', 'origin', 'refs/changes/45/12345/1'])
expect(result.status).toBe(1)
expect(result.stderr).toBe('fetch failed')
})
test('should create git worktree', () => {
mockSpawnSync.mockReturnValue({ status: 0, stderr: '' })
const result = mockSpawnSync('git', ['worktree', 'add', '/workspace/path', 'FETCH_HEAD'])
expect(result.status).toBe(0)
})
test('should handle worktree creation failure', () => {
mockSpawnSync.mockReturnValue({ status: 1, stderr: 'worktree add failed' })
const result = mockSpawnSync('git', ['worktree', 'add', '/workspace/path', 'FETCH_HEAD'])
expect(result.status).toBe(1)
expect(result.stderr).toBe('worktree add failed')
})
})
describe('output formats', () => {
test('should output pretty format messages', () => {
console.log('Fetching change 12345: Test change subject')
console.log('Creating worktree at: /repo/root/.ger/12345')
console.log('✓ Workspace created successfully!')
console.log(' Run: cd /repo/root/.ger/12345')
expect(mockConsole.log).toHaveBeenCalledWith('Fetching change 12345: Test change subject')
expect(mockConsole.log).toHaveBeenCalledWith('Creating worktree at: /repo/root/.ger/12345')
expect(mockConsole.log).toHaveBeenCalledWith('✓ Workspace created successfully!')
expect(mockConsole.log).toHaveBeenCalledWith(' Run: cd /repo/root/.ger/12345')
})
test('should output XML format', () => {
console.log('<?xml version="1.0" encoding="UTF-8"?>')
console.log('<workspace>')
console.log(' <path>/repo/root/.ger/12345</path>')
console.log(' <change_number>12345</change_number>')
console.log(' <subject><![CDATA[Test change subject]]></subject>')
console.log(' <created>true</created>')
console.log('</workspace>')
expect(mockConsole.log).toHaveBeenCalledWith('<?xml version="1.0" encoding="UTF-8"?>')
expect(mockConsole.log).toHaveBeenCalledWith('<workspace>')
expect(mockConsole.log).toHaveBeenCalledWith(' <path>/repo/root/.ger/12345</path>')
expect(mockConsole.log).toHaveBeenCalledWith(' <change_number>12345</change_number>')
expect(mockConsole.log).toHaveBeenCalledWith(
' <subject><![CDATA[Test change subject]]></subject>',
)
expect(mockConsole.log).toHaveBeenCalledWith(' <created>true</created>')
expect(mockConsole.log).toHaveBeenCalledWith('</workspace>')
})
test('should output XML format for existing workspace', () => {
console.log('<?xml version="1.0" encoding="UTF-8"?>')
console.log('<workspace>')
console.log(' <path>/repo/root/.ger/12345</path>')
console.log(' <exists>true</exists>')
console.log('</workspace>')
expect(mockConsole.log).toHaveBeenCalledWith('<?xml version="1.0" encoding="UTF-8"?>')
expect(mockConsole.log).toHaveBeenCalledWith('<workspace>')
expect(mockConsole.log).toHaveBeenCalledWith(' <path>/repo/root/.ger/12345</path>')
expect(mockConsole.log).toHaveBeenCalledWith(' <exists>true</exists>')
expect(mockConsole.log).toHaveBeenCalledWith('</workspace>')
})
})
describe('path safety', () => {
test('should prevent path traversal in workspace names', () => {
const maliciousPath = '../../../etc/passwd'
const safePath = '12345'
expect(/^\d+$/.test(maliciousPath)).toBe(false)
expect(/^\d+$/.test(safePath)).toBe(true)
})
test('should use safe path joining', () => {
const repoRoot = '/repo/root'
const changeNumber = '12345'
const workspacePath = `${repoRoot}/.ger/${changeNumber}`
expect(workspacePath).toBe('/repo/root/.ger/12345')
expect(workspacePath).not.toContain('..')
})
})
describe('error handling', () => {
test('should handle command execution errors', () => {
mockExecSync.mockImplementation(() => {
throw new Error('Command failed')
})
expect(() => {
mockExecSync('git status')
}).toThrow('Command failed')
})
test('should handle spawn errors', () => {
mockSpawnSync.mockReturnValue({
status: 1,
stderr: 'Command not found',
})
const result = mockSpawnSync('nonexistent-command')
expect(result.status).toBe(1)
expect(result.stderr).toBe('Command not found')
})
})
})