@aaronshaf/ger 0.1.11 → 0.2.0

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.
@@ -0,0 +1,306 @@
1
+ import { describe, test, expect, beforeAll, afterAll, afterEach, mock, spyOn } from 'bun:test'
2
+ import { setupServer } from 'msw/node'
3
+ import { http, HttpResponse } from 'msw'
4
+ import { Effect, Layer } from 'effect'
5
+ import { showCommand } from '@/cli/commands/show'
6
+ import { GerritApiServiceLive } from '@/api/gerrit'
7
+ import { ConfigService } from '@/services/config'
8
+ import { generateMockChange } from '@/test-utils/mock-generator'
9
+ import { createMockConfigService } from './helpers/config-mock'
10
+ import * as childProcess from 'node:child_process'
11
+ import { EventEmitter } from 'node:events'
12
+
13
+ /**
14
+ * Integration tests for auto-detecting Change-ID from HEAD commit
15
+ */
16
+
17
+ const mockChange = generateMockChange({
18
+ _number: 392385,
19
+ change_id: 'If5a3ae8cb5a107e187447802358417f311d0c4b1',
20
+ subject: 'WIP: test',
21
+ status: 'NEW',
22
+ project: 'canvas-lms',
23
+ branch: 'master',
24
+ created: '2024-01-15 10:00:00.000000000',
25
+ updated: '2024-01-15 12:00:00.000000000',
26
+ owner: {
27
+ _account_id: 1001,
28
+ name: 'Test User',
29
+ email: 'test@example.com',
30
+ },
31
+ })
32
+
33
+ const mockDiff = `--- a/test.txt
34
+ +++ b/test.txt
35
+ @@ -1,1 +1,2 @@
36
+ original line
37
+ +new line`
38
+
39
+ const server = setupServer(
40
+ http.get('*/a/accounts/self', () => {
41
+ return HttpResponse.json({
42
+ _account_id: 1000,
43
+ name: 'Test User',
44
+ email: 'test@example.com',
45
+ })
46
+ }),
47
+
48
+ // Handler that matches the auto-detected Change-ID
49
+ http.get('*/a/changes/:changeId', ({ params }) => {
50
+ const { changeId } = params
51
+ if (changeId === 'If5a3ae8cb5a107e187447802358417f311d0c4b1') {
52
+ return HttpResponse.text(`)]}'
53
+ ${JSON.stringify(mockChange)}`)
54
+ }
55
+ return HttpResponse.text('Not Found', { status: 404 })
56
+ }),
57
+
58
+ http.get('*/a/changes/:changeId/revisions/current/patch', ({ params }) => {
59
+ const { changeId } = params
60
+ if (changeId === 'If5a3ae8cb5a107e187447802358417f311d0c4b1') {
61
+ return HttpResponse.text(btoa(mockDiff))
62
+ }
63
+ return HttpResponse.text('Not Found', { status: 404 })
64
+ }),
65
+
66
+ http.get('*/a/changes/:changeId/revisions/current/comments', ({ params }) => {
67
+ const { changeId } = params
68
+ if (changeId === 'If5a3ae8cb5a107e187447802358417f311d0c4b1') {
69
+ return HttpResponse.text(`)]}'
70
+ {}`)
71
+ }
72
+ return HttpResponse.text('Not Found', { status: 404 })
73
+ }),
74
+ )
75
+
76
+ let capturedLogs: string[] = []
77
+ let capturedErrors: string[] = []
78
+
79
+ const mockConsoleLog = mock((...args: any[]) => {
80
+ capturedLogs.push(args.join(' '))
81
+ })
82
+ const mockConsoleError = mock((...args: any[]) => {
83
+ capturedErrors.push(args.join(' '))
84
+ })
85
+
86
+ const originalConsoleLog = console.log
87
+ const originalConsoleError = console.error
88
+
89
+ let spawnSpy: ReturnType<typeof spyOn>
90
+
91
+ beforeAll(() => {
92
+ server.listen({ onUnhandledRequest: 'bypass' })
93
+ // @ts-ignore
94
+ console.log = mockConsoleLog
95
+ // @ts-ignore
96
+ console.error = mockConsoleError
97
+ })
98
+
99
+ afterAll(() => {
100
+ server.close()
101
+ console.log = originalConsoleLog
102
+ console.error = originalConsoleError
103
+ })
104
+
105
+ afterEach(() => {
106
+ server.resetHandlers()
107
+ mockConsoleLog.mockClear()
108
+ mockConsoleError.mockClear()
109
+ capturedLogs = []
110
+ capturedErrors = []
111
+
112
+ if (spawnSpy) {
113
+ spawnSpy.mockRestore()
114
+ }
115
+ })
116
+
117
+ const createMockConfigLayer = (): Layer.Layer<ConfigService, never, never> =>
118
+ Layer.succeed(ConfigService, createMockConfigService())
119
+
120
+ describe('show command with auto-detection', () => {
121
+ test('auto-detects Change-ID from HEAD commit when no argument provided', async () => {
122
+ const commitMessage = `feat: add feature
123
+
124
+ Change-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1`
125
+
126
+ // Mock git log command
127
+ const mockChildProcess = new EventEmitter()
128
+ // @ts-ignore
129
+ mockChildProcess.stdout = new EventEmitter()
130
+ // @ts-ignore
131
+ mockChildProcess.stderr = new EventEmitter()
132
+
133
+ spawnSpy = spyOn(childProcess, 'spawn')
134
+ spawnSpy.mockReturnValue(mockChildProcess as any)
135
+
136
+ const effect = showCommand(undefined, {}).pipe(
137
+ Effect.provide(GerritApiServiceLive),
138
+ Effect.provide(createMockConfigLayer()),
139
+ )
140
+
141
+ const resultPromise = Effect.runPromise(effect)
142
+
143
+ // Simulate git log success
144
+ setImmediate(() => {
145
+ // @ts-ignore
146
+ mockChildProcess.stdout.emit('data', Buffer.from(commitMessage))
147
+ mockChildProcess.emit('close', 0)
148
+ })
149
+
150
+ await resultPromise
151
+
152
+ const output = capturedLogs.join('\n')
153
+ expect(output).toContain('Change 392385')
154
+ expect(output).toContain('WIP: test')
155
+ expect(capturedErrors.length).toBe(0)
156
+ })
157
+
158
+ test('auto-detects Change-ID with --xml flag', async () => {
159
+ const commitMessage = `feat: add feature
160
+
161
+ Change-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1`
162
+
163
+ const mockChildProcess = new EventEmitter()
164
+ // @ts-ignore
165
+ mockChildProcess.stdout = new EventEmitter()
166
+ // @ts-ignore
167
+ mockChildProcess.stderr = new EventEmitter()
168
+
169
+ spawnSpy = spyOn(childProcess, 'spawn')
170
+ spawnSpy.mockReturnValue(mockChildProcess as any)
171
+
172
+ const effect = showCommand(undefined, { xml: true }).pipe(
173
+ Effect.provide(GerritApiServiceLive),
174
+ Effect.provide(createMockConfigLayer()),
175
+ )
176
+
177
+ const resultPromise = Effect.runPromise(effect)
178
+
179
+ setImmediate(() => {
180
+ // @ts-ignore
181
+ mockChildProcess.stdout.emit('data', Buffer.from(commitMessage))
182
+ mockChildProcess.emit('close', 0)
183
+ })
184
+
185
+ await resultPromise
186
+
187
+ const output = capturedLogs.join('\n')
188
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
189
+ expect(output).toContain('<show_result>')
190
+ expect(output).toContain('<status>success</status>')
191
+ expect(output).toContain('392385')
192
+ expect(capturedErrors.length).toBe(0)
193
+ })
194
+
195
+ test('shows error when no Change-ID in HEAD commit', async () => {
196
+ const commitMessage = `feat: add feature without Change-ID
197
+
198
+ This commit has no Change-ID footer.`
199
+
200
+ const mockChildProcess = new EventEmitter()
201
+ // @ts-ignore
202
+ mockChildProcess.stdout = new EventEmitter()
203
+ // @ts-ignore
204
+ mockChildProcess.stderr = new EventEmitter()
205
+
206
+ spawnSpy = spyOn(childProcess, 'spawn')
207
+ spawnSpy.mockReturnValue(mockChildProcess as any)
208
+
209
+ const effect = showCommand(undefined, {}).pipe(
210
+ Effect.provide(GerritApiServiceLive),
211
+ Effect.provide(createMockConfigLayer()),
212
+ )
213
+
214
+ const resultPromise = Effect.runPromise(effect)
215
+
216
+ setImmediate(() => {
217
+ // @ts-ignore
218
+ mockChildProcess.stdout.emit('data', Buffer.from(commitMessage))
219
+ mockChildProcess.emit('close', 0)
220
+ })
221
+
222
+ await resultPromise
223
+
224
+ const output = capturedErrors.join('\n')
225
+ expect(output).toContain('No Change-ID found in HEAD commit')
226
+ expect(capturedLogs.length).toBe(0)
227
+ })
228
+
229
+ test('shows error when not in git repository', async () => {
230
+ const mockChildProcess = new EventEmitter()
231
+ // @ts-ignore
232
+ mockChildProcess.stdout = new EventEmitter()
233
+ // @ts-ignore
234
+ mockChildProcess.stderr = new EventEmitter()
235
+
236
+ spawnSpy = spyOn(childProcess, 'spawn')
237
+ spawnSpy.mockReturnValue(mockChildProcess as any)
238
+
239
+ const effect = showCommand(undefined, {}).pipe(
240
+ Effect.provide(GerritApiServiceLive),
241
+ Effect.provide(createMockConfigLayer()),
242
+ )
243
+
244
+ const resultPromise = Effect.runPromise(effect)
245
+
246
+ setImmediate(() => {
247
+ // @ts-ignore
248
+ mockChildProcess.stderr.emit('data', Buffer.from('fatal: not a git repository'))
249
+ mockChildProcess.emit('close', 128)
250
+ })
251
+
252
+ await resultPromise
253
+
254
+ const output = capturedErrors.join('\n')
255
+ expect(output).toContain('fatal: not a git repository')
256
+ })
257
+
258
+ test('still works with explicit change-id argument', async () => {
259
+ // Don't mock git - should not be called when changeId is provided
260
+ const effect = showCommand('If5a3ae8cb5a107e187447802358417f311d0c4b1', {}).pipe(
261
+ Effect.provide(GerritApiServiceLive),
262
+ Effect.provide(createMockConfigLayer()),
263
+ )
264
+
265
+ await Effect.runPromise(effect)
266
+
267
+ const output = capturedLogs.join('\n')
268
+ expect(output).toContain('Change 392385')
269
+ expect(output).toContain('WIP: test')
270
+ expect(capturedErrors.length).toBe(0)
271
+ })
272
+
273
+ test('shows XML error when no Change-ID in commit with --xml flag', async () => {
274
+ const commitMessage = `feat: no change id`
275
+
276
+ const mockChildProcess = new EventEmitter()
277
+ // @ts-ignore
278
+ mockChildProcess.stdout = new EventEmitter()
279
+ // @ts-ignore
280
+ mockChildProcess.stderr = new EventEmitter()
281
+
282
+ spawnSpy = spyOn(childProcess, 'spawn')
283
+ spawnSpy.mockReturnValue(mockChildProcess as any)
284
+
285
+ const effect = showCommand(undefined, { xml: true }).pipe(
286
+ Effect.provide(GerritApiServiceLive),
287
+ Effect.provide(createMockConfigLayer()),
288
+ )
289
+
290
+ const resultPromise = Effect.runPromise(effect)
291
+
292
+ setImmediate(() => {
293
+ // @ts-ignore
294
+ mockChildProcess.stdout.emit('data', Buffer.from(commitMessage))
295
+ mockChildProcess.emit('close', 0)
296
+ })
297
+
298
+ await resultPromise
299
+
300
+ const output = capturedLogs.join('\n')
301
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
302
+ expect(output).toContain('<show_result>')
303
+ expect(output).toContain('<status>error</status>')
304
+ expect(output).toContain('No Change-ID found in HEAD commit')
305
+ })
306
+ })
@@ -238,7 +238,9 @@ describe('show command', () => {
238
238
  await Effect.runPromise(program)
239
239
 
240
240
  const output = capturedErrors.join('\n')
241
- expect(output).toContain('✗ Failed to fetch change details')
241
+ expect(output).toContain('✗ Error:')
242
+ // The error message will be from the network layer
243
+ expect(output.length).toBeGreaterThan(0)
242
244
  })
243
245
 
244
246
  test('should handle API errors gracefully in XML format', async () => {
@@ -436,4 +438,158 @@ describe('show command', () => {
436
438
  // Should filter out autogenerated messages
437
439
  expect(output).not.toContain('Uploaded patch set')
438
440
  })
441
+
442
+ test('should output JSON format when --json flag is used', async () => {
443
+ setupMockHandlers()
444
+
445
+ const mockConfigLayer = createMockConfigLayer()
446
+ const program = showCommand('12345', { json: true }).pipe(
447
+ Effect.provide(GerritApiServiceLive),
448
+ Effect.provide(mockConfigLayer),
449
+ )
450
+
451
+ await Effect.runPromise(program)
452
+
453
+ const output = capturedLogs.join('\n')
454
+
455
+ // Parse JSON to verify it's valid
456
+ const parsed = JSON.parse(output)
457
+
458
+ expect(parsed.status).toBe('success')
459
+ expect(parsed.change.id).toBe('I123abc456def')
460
+ expect(parsed.change.number).toBe(12345)
461
+ expect(parsed.change.subject).toBe('Fix authentication bug')
462
+ expect(parsed.change.status).toBe('NEW')
463
+ expect(parsed.change.project).toBe('test-project')
464
+ expect(parsed.change.branch).toBe('main')
465
+ expect(parsed.change.owner.name).toBe('John Doe')
466
+ expect(parsed.change.owner.email).toBe('john@example.com')
467
+
468
+ // Check diff is present
469
+ expect(parsed.diff).toContain('src/auth.js')
470
+ expect(parsed.diff).toContain('authenticate(user)')
471
+
472
+ // Check comments array
473
+ expect(Array.isArray(parsed.comments)).toBe(true)
474
+ expect(parsed.comments.length).toBe(3)
475
+ expect(parsed.comments[0].message).toContain('Clear commit message')
476
+ expect(parsed.comments[1].message).toBe('Good improvement!')
477
+ expect(parsed.comments[2].message).toBe('Consider adding JSDoc')
478
+
479
+ // Check messages array (should be empty for this test)
480
+ expect(Array.isArray(parsed.messages)).toBe(true)
481
+ })
482
+
483
+ test('should handle API errors gracefully in JSON format', async () => {
484
+ server.use(
485
+ http.get('*/a/changes/:changeId', () => {
486
+ return HttpResponse.json({ error: 'Change not found' }, { status: 404 })
487
+ }),
488
+ )
489
+
490
+ const mockConfigLayer = createMockConfigLayer()
491
+ const program = showCommand('12345', { json: true }).pipe(
492
+ Effect.provide(GerritApiServiceLive),
493
+ Effect.provide(mockConfigLayer),
494
+ )
495
+
496
+ await Effect.runPromise(program)
497
+
498
+ const output = capturedLogs.join('\n')
499
+
500
+ // Parse JSON to verify it's valid
501
+ const parsed = JSON.parse(output)
502
+
503
+ expect(parsed.status).toBe('error')
504
+ expect(parsed.error).toBeDefined()
505
+ expect(typeof parsed.error).toBe('string')
506
+ })
507
+
508
+ test('should sort comments by date in ascending order in XML output', async () => {
509
+ setupMockHandlers()
510
+
511
+ const mockConfigLayer = createMockConfigLayer()
512
+ const program = showCommand('12345', { xml: true }).pipe(
513
+ Effect.provide(GerritApiServiceLive),
514
+ Effect.provide(mockConfigLayer),
515
+ )
516
+
517
+ await Effect.runPromise(program)
518
+
519
+ const output = capturedLogs.join('\n')
520
+
521
+ // Extract comment sections to verify order
522
+ const commentMatches = output.matchAll(
523
+ /<comment>[\s\S]*?<updated>(.*?)<\/updated>[\s\S]*?<message><!\[CDATA\[(.*?)\]\]><\/message>[\s\S]*?<\/comment>/g,
524
+ )
525
+ const comments = Array.from(commentMatches).map((match) => ({
526
+ updated: match[1],
527
+ message: match[2],
528
+ }))
529
+
530
+ // Should have 3 comments
531
+ expect(comments.length).toBe(3)
532
+
533
+ // Comments should be in ascending date order (oldest first)
534
+ expect(comments[0].updated).toBe('2024-01-15 11:00:00.000000000')
535
+ expect(comments[0].message).toBe('Clear commit message')
536
+
537
+ expect(comments[1].updated).toBe('2024-01-15 11:30:00.000000000')
538
+ expect(comments[1].message).toBe('Good improvement!')
539
+
540
+ expect(comments[2].updated).toBe('2024-01-15 11:45:00.000000000')
541
+ expect(comments[2].message).toBe('Consider adding JSDoc')
542
+ })
543
+
544
+ test('should include messages in JSON output', async () => {
545
+ const mockChange = generateMockChange({
546
+ _number: 12345,
547
+ subject: 'Fix authentication bug',
548
+ })
549
+
550
+ const mockMessages: MessageInfo[] = [
551
+ {
552
+ id: 'msg1',
553
+ message: 'Patch Set 2: Verified-1\\n\\nBuild Failed https://jenkins.example.com/job/123',
554
+ author: { _account_id: 1001, name: 'Jenkins Bot' },
555
+ date: '2024-01-15 11:30:00.000000000',
556
+ _revision_number: 2,
557
+ },
558
+ ]
559
+
560
+ server.use(
561
+ http.get('*/a/changes/:changeId', ({ request }) => {
562
+ const url = new URL(request.url)
563
+ if (url.searchParams.get('o') === 'MESSAGES') {
564
+ return HttpResponse.text(`)]}'\n${JSON.stringify({ messages: mockMessages })}`)
565
+ }
566
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
567
+ }),
568
+ http.get('*/a/changes/:changeId/revisions/current/patch', () => {
569
+ return HttpResponse.text('diff content')
570
+ }),
571
+ http.get('*/a/changes/:changeId/revisions/current/comments', () => {
572
+ return HttpResponse.text(`)]}'\n{}`)
573
+ }),
574
+ )
575
+
576
+ const mockConfigLayer = createMockConfigLayer()
577
+ const program = showCommand('12345', { json: true }).pipe(
578
+ Effect.provide(GerritApiServiceLive),
579
+ Effect.provide(mockConfigLayer),
580
+ )
581
+
582
+ await Effect.runPromise(program)
583
+
584
+ const output = capturedLogs.join('\n')
585
+ const parsed = JSON.parse(output)
586
+
587
+ expect(parsed.messages).toBeDefined()
588
+ expect(Array.isArray(parsed.messages)).toBe(true)
589
+ expect(parsed.messages.length).toBe(1)
590
+ expect(parsed.messages[0].message).toContain('Build Failed')
591
+ expect(parsed.messages[0].message).toContain('https://jenkins.example.com')
592
+ expect(parsed.messages[0].author.name).toBe('Jenkins Bot')
593
+ expect(parsed.messages[0].revision).toBe(2)
594
+ })
439
595
  })
@@ -21,7 +21,8 @@ describe('GitWorktreeService Types and Structure', () => {
21
21
 
22
22
  test('should create service tag correctly', () => {
23
23
  expect(GitWorktreeService).toBeDefined()
24
- expect(typeof GitWorktreeService).toBe('function')
24
+ expect(typeof GitWorktreeService).toBe('object')
25
+ expect(GitWorktreeService.key).toBe('GitWorktreeService')
25
26
  })
26
27
 
27
28
  test('should be able to create mock service implementation', async () => {
package/tsconfig.json CHANGED
@@ -18,7 +18,8 @@
18
18
  "types": [
19
19
  "bun-types"
20
20
  ],
21
- "isolatedDeclarations": false,
21
+ "declaration": true,
22
+ "isolatedDeclarations": true,
22
23
  "noImplicitAny": true,
23
24
  "strictNullChecks": true,
24
25
  "strictFunctionTypes": true,