UNPKG

@aaronshaf/ger

Version:

Gerrit CLI and SDK - A modern CLI tool and TypeScript SDK for Gerrit Code Review, built with Effect-TS

709 lines (607 loc) 23.2 kB
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test' import { Effect, Layer } from 'effect' import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' import { GerritApiServiceLive } from '@/api/gerrit' import { commentCommand } from '@/cli/commands/comment' import { ConfigService } from '@/services/config' import { createMockConfigService } from './helpers/config-mock' // Create MSW server const server = setupServer( // Default handler for auth check 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('comment command', () => { let mockConsoleLog: ReturnType<typeof mock> let mockConsoleError: ReturnType<typeof mock> let mockProcessStdin: { on: ReturnType<typeof mock> emit: (data: string) => void dataCallback?: (...args: unknown[]) => void endCallback?: (...args: unknown[]) => void } beforeAll(() => { server.listen({ onUnhandledRequest: 'bypass' }) }) afterAll(() => { server.close() }) beforeEach(() => { server.resetHandlers() mockConsoleLog = mock(() => {}) mockConsoleError = mock(() => {}) console.log = mockConsoleLog console.error = mockConsoleError // Mock process.stdin for batch tests mockProcessStdin = { on: mock((event: string, callback: (...args: unknown[]) => void) => { if (event === 'data') { mockProcessStdin.dataCallback = callback } else if (event === 'end') { mockProcessStdin.endCallback = callback } }), emit: (data: string) => { if (mockProcessStdin.dataCallback) { mockProcessStdin.dataCallback(data) } if (mockProcessStdin.endCallback) { mockProcessStdin.endCallback() } }, } }) afterEach(() => { server.resetHandlers() }) it('should post an overall comment', async () => { server.use( http.get('*/a/changes/:changeId', () => { return HttpResponse.text(`)]}'\n{ "id": "test-project~main~I123abc", "_number": 12345, "project": "test-project", "branch": "main", "change_id": "I123abc", "subject": "Test change", "status": "NEW", "created": "2024-01-15 10:00:00.000000000", "updated": "2024-01-15 10:00:00.000000000" }`) }), http.post('*/a/changes/:changeId/revisions/current/review', async ({ request }) => { const body = (await request.json()) as { message?: string; comments?: unknown } expect(body.message).toBe('This is a test comment') expect(body.comments).toBeUndefined() return HttpResponse.json({}) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = commentCommand('12345', { message: 'This is a test comment', }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) await Effect.runPromise(program) const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n') expect(output).toContain('✓ Comment posted successfully!') expect(output).toContain('Test change') }) it('should post a line-specific comment', async () => { server.use( http.get('*/a/changes/:changeId', () => { return HttpResponse.text(`)]}'\n{ "id": "test-project~main~I123abc", "_number": 12345, "project": "test-project", "branch": "main", "change_id": "I123abc", "subject": "Test change", "status": "NEW", "created": "2024-01-15 10:00:00.000000000", "updated": "2024-01-15 10:00:00.000000000" }`) }), http.post('*/a/changes/:changeId/revisions/current/review', async ({ request }) => { const body = (await request.json()) as { message?: string comments?: Record<string, Array<{ line: number; message: string; unresolved?: boolean }>> } expect(body.message).toBeUndefined() expect(body.comments).toBeDefined() expect(body.comments?.['src/main.js']).toBeDefined() expect(body.comments?.['src/main.js']?.[0].line).toBe(42) expect(body.comments?.['src/main.js']?.[0].message).toBe('Fix this issue') expect(body.comments?.['src/main.js']?.[0].unresolved).toBe(true) return HttpResponse.json({}) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = commentCommand('12345', { message: 'Fix this issue', file: 'src/main.js', line: 42, unresolved: true, }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) await Effect.runPromise(program) const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n') expect(output).toContain('✓ Comment posted successfully!') expect(output).toContain('File: src/main.js, Line: 42') expect(output).toContain('Status: Unresolved') }) it('should handle batch comments', async () => { // Override process.stdin temporarily const originalStdin = process.stdin Object.defineProperty(process, 'stdin', { value: mockProcessStdin, configurable: true, }) server.use( http.get('*/a/changes/:changeId', () => { return HttpResponse.text(`)]}'\n{ "id": "test-project~main~I123abc", "_number": 12345, "project": "test-project", "branch": "main", "change_id": "I123abc", "subject": "Test change", "status": "NEW", "created": "2024-01-15 10:00:00.000000000", "updated": "2024-01-15 10:00:00.000000000" }`) }), http.post('*/a/changes/:changeId/revisions/current/review', async ({ request }) => { const body = (await request.json()) as { message?: string comments?: Record<string, unknown[]> } // Array format doesn't include overall message expect(body.message).toBeUndefined() expect(body.comments).toBeDefined() expect(body.comments?.['src/main.js']?.length).toBe(2) expect(body.comments?.['src/utils.js']?.length).toBe(1) return HttpResponse.json({}) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = commentCommand('12345', { batch: true }).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer), ) // Simulate stdin data (array format) setTimeout(() => { mockProcessStdin.emit( JSON.stringify([ { file: 'src/main.js', line: 10, message: 'First comment' }, { file: 'src/main.js', line: 20, message: 'Second comment', unresolved: true }, { file: 'src/utils.js', line: 5, message: 'Utils comment' }, ]), ) }, 10) await Effect.runPromise(program) const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n') expect(output).toContain('✓ Comment posted successfully!') expect(output).toContain('Posted 3 line comment(s)') // Restore process.stdin Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true, }) }) it('should output XML format for line comments', async () => { server.use( http.get('*/a/changes/:changeId', () => { return HttpResponse.text(`)]}'\n{ "id": "test-project~main~I123abc", "_number": 12345, "project": "test-project", "branch": "main", "change_id": "I123abc", "subject": "Test change", "status": "NEW", "created": "2024-01-15 10:00:00.000000000", "updated": "2024-01-15 10:00:00.000000000" }`) }), http.post('*/a/changes/:changeId/revisions/current/review', async () => { return HttpResponse.json({}) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = commentCommand('12345', { message: 'Fix this', file: 'test.js', line: 10, xml: true, }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) await Effect.runPromise(program) const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n') expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>') expect(output).toContain('<comment_result>') expect(output).toContain('<status>success</status>') expect(output).toContain('<file>test.js</file>') expect(output).toContain('<line>10</line>') expect(output).toContain('<message><![CDATA[Fix this]]></message>') }) it('should provide detailed error for invalid JSON with input preview', async () => { const originalStdin = process.stdin Object.defineProperty(process, 'stdin', { value: mockProcessStdin, configurable: true, }) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = commentCommand('12345', { batch: true }).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer), ) const malformedJson = `[ { "file": "src/main.js", "line": 10, "message": "This is an unterminated string } ]` // Simulate invalid JSON input setTimeout(() => { mockProcessStdin.emit(malformedJson) }, 10) await expect(Effect.runPromise(program)).rejects.toThrow( /Invalid JSON input: .*Unterminated string.*\nInput \(\d+ chars, \d+ lines\):\n.*src\/main\.js.*\nExpected format:/s, ) // Restore process.stdin Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true, }) }) it('should reject invalid batch JSON', async () => { const originalStdin = process.stdin Object.defineProperty(process, 'stdin', { value: mockProcessStdin, configurable: true, }) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = commentCommand('12345', { batch: true }).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer), ) // Simulate invalid JSON setTimeout(() => { mockProcessStdin.emit('not valid json') }, 10) await expect(Effect.runPromise(program)).rejects.toThrow('Invalid batch input format') // Restore process.stdin Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true, }) }) it('should reject invalid batch schema', async () => { const originalStdin = process.stdin Object.defineProperty(process, 'stdin', { value: mockProcessStdin, configurable: true, }) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = commentCommand('12345', { batch: true }).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer), ) // Simulate invalid schema (array format) setTimeout(() => { mockProcessStdin.emit( JSON.stringify([ { message: 'Missing file path' }, // Invalid: missing file ]), ) }, 10) await expect(Effect.runPromise(program)).rejects.toThrow('Invalid batch input format') // Restore process.stdin Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true, }) }) it('should require message for line comments', async () => { const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = commentCommand('12345', { file: 'test.js', line: 10, // Missing message }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) await expect(Effect.runPromise(program)).rejects.toThrow( 'Message is required for line comments', ) }) it('should require message for overall comments when stdin is empty', async () => { const originalStdin = process.stdin Object.defineProperty(process, 'stdin', { value: mockProcessStdin, configurable: true, }) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = commentCommand('12345', {}).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer), ) // Simulate empty stdin setTimeout(() => { mockProcessStdin.emit('') }, 10) await expect(Effect.runPromise(program)).rejects.toThrow( 'Message is required. Use -m "your message" or pipe content to stdin', ) // Restore process.stdin Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true, }) }) it('should handle API errors gracefully', async () => { server.use( http.get('*/a/changes/:changeId', () => { return HttpResponse.text('Not found', { status: 404 }) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = commentCommand('12345', { message: 'Test comment', }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) await expect(Effect.runPromise(program)).rejects.toThrow('Failed to get change') }) it('should handle post review API errors', async () => { server.use( http.get('*/a/changes/:changeId', () => { return HttpResponse.text(`)]}'\n{ "id": "test-project~main~I123abc", "_number": 12345, "project": "test-project", "branch": "main", "change_id": "I123abc", "subject": "Test change", "status": "NEW", "created": "2024-01-15 10:00:00.000000000", "updated": "2024-01-15 10:00:00.000000000" }`) }), http.post('*/a/changes/:changeId/revisions/current/review', () => { return HttpResponse.text('Forbidden', { status: 403 }) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = commentCommand('12345', { message: 'Test comment', }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) await expect(Effect.runPromise(program)).rejects.toThrow('Failed to post comment') }) it('should output XML for batch comments', async () => { const originalStdin = process.stdin Object.defineProperty(process, 'stdin', { value: mockProcessStdin, configurable: true, }) server.use( http.get('*/a/changes/:changeId', () => { return HttpResponse.text(`)]}'\n{ "id": "test-project~main~I123abc", "_number": 12345, "project": "test-project", "branch": "main", "change_id": "I123abc", "subject": "Test change", "status": "NEW", "created": "2024-01-15 10:00:00.000000000", "updated": "2024-01-15 10:00:00.000000000" }`) }), http.post('*/a/changes/:changeId/revisions/current/review', async () => { return HttpResponse.json({}) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = commentCommand('12345', { batch: true, xml: true }).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer), ) // Simulate stdin data (array format) setTimeout(() => { mockProcessStdin.emit( JSON.stringify([ { file: 'src/main.js', line: 10, message: 'First comment' }, { file: 'src/main.js', line: 20, message: 'Second comment', unresolved: true }, ]), ) }, 10) await Effect.runPromise(program) const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n') expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>') expect(output).toContain('<comments>') expect(output).toContain('<file>src/main.js</file>') expect(output).toContain('<line>10</line>') expect(output).toContain('<line>20</line>') expect(output).toContain('<unresolved>true</unresolved>') expect(output).toContain('</comments>') // Restore process.stdin Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true, }) }) it('should accept piped input for overall comments', async () => { const originalStdin = process.stdin Object.defineProperty(process, 'stdin', { value: mockProcessStdin, configurable: true, }) server.use( http.get('*/a/changes/:changeId', () => { return HttpResponse.text(`)]}'\n{ "id": "test-project~main~I123abc", "_number": 12345, "project": "test-project", "branch": "main", "change_id": "I123abc", "subject": "Test change", "status": "NEW", "created": "2024-01-15 10:00:00.000000000", "updated": "2024-01-15 10:00:00.000000000" }`) }), http.post('*/a/changes/:changeId/revisions/current/review', async ({ request }) => { const body = (await request.json()) as { message?: string } expect(body.message).toBe('Piped comment message') return HttpResponse.json({}) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) // Test comment without message option (should read from stdin) const program = commentCommand('12345', {}).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer), ) // Simulate piped input setTimeout(() => { mockProcessStdin.emit('Piped comment message') }, 10) await Effect.runPromise(program) const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n') expect(output).toContain('✓ Comment posted successfully!') // Note: The message content is no longer displayed after successful posting // to avoid duplication with the review preview section // Restore process.stdin Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true, }) }) it('should trim whitespace from piped input', async () => { const originalStdin = process.stdin Object.defineProperty(process, 'stdin', { value: mockProcessStdin, configurable: true, }) server.use( http.get('*/a/changes/:changeId', () => { return HttpResponse.text(`)]}'\n{ "id": "test-project~main~I123abc", "_number": 12345, "project": "test-project", "branch": "main", "change_id": "I123abc", "subject": "Test change", "status": "NEW", "created": "2024-01-15 10:00:00.000000000", "updated": "2024-01-15 10:00:00.000000000" }`) }), http.post('*/a/changes/:changeId/revisions/current/review', async ({ request }) => { const body = (await request.json()) as { message?: string } expect(body.message).toBe('Trimmed message') return HttpResponse.json({}) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = commentCommand('12345', {}).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer), ) // Simulate piped input with whitespace setTimeout(() => { mockProcessStdin.emit(' \n Trimmed message \n ') }, 10) await Effect.runPromise(program) // Restore process.stdin Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true, }) }) it('should provide detailed error context for batch comment failures', async () => { const originalStdin = process.stdin Object.defineProperty(process, 'stdin', { value: mockProcessStdin, configurable: true, }) server.use( http.get('*/a/changes/:changeId', () => { return HttpResponse.text(`)]}'\n{ "id": "test-project~main~I123abc", "_number": 12345, "project": "test-project", "branch": "main", "change_id": "I123abc", "subject": "Test change", "status": "NEW" }`) }), http.post('*/a/changes/:changeId/revisions/current/review', () => { return HttpResponse.text( 'file app/models/auto_grade_result.rb not found in revision 386823,6', { status: 400 }, ) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = commentCommand('12345', { batch: true }).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer), ) // Simulate batch input setTimeout(() => { mockProcessStdin.emit( JSON.stringify([ { file: 'app/models/auto_grade_result.rb', line: 23, message: 'This needs improvement' }, { file: 'src/utils.js', line: 45, message: 'This is a very long comment message that should be truncated in the error output to keep it readable', }, ]), ) }, 10) await expect(Effect.runPromise(program)).rejects.toThrow( /Failed to post comment: file app\/models\/auto_grade_result\.rb not found in revision 386823,6\nTried to post: app\/models\/auto_grade_result\.rb:23 "This needs improvement", src\/utils\.js:45 "This is a very long comment message that should be\.\.\."/, ) // Restore process.stdin Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true, }) }) it('should provide detailed error context for line comment failures', async () => { server.use( http.get('*/a/changes/:changeId', () => { return HttpResponse.text(`)]}'\n{ "id": "test-project~main~I123abc", "_number": 12345, "project": "test-project", "branch": "main", "change_id": "I123abc", "subject": "Test change", "status": "NEW" }`) }), http.post('*/a/changes/:changeId/revisions/current/review', () => { return HttpResponse.text('file not found', { status: 400 }) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = commentCommand('12345', { file: 'missing-file.rb', line: 42, message: 'Test comment on missing file', }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) await expect(Effect.runPromise(program)).rejects.toThrow( 'Failed to post comment: file not found\nTried to post to missing-file.rb:42: "Test comment on missing file"', ) }) })