@aaronshaf/ger 0.2.4 → 0.2.5

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,691 @@
1
+ import { describe, test, expect, beforeAll, afterAll, afterEach, mock } from 'bun:test'
2
+ import { setupServer } from 'msw/node'
3
+ import { http, HttpResponse } from 'msw'
4
+ import { Effect, Layer } from 'effect'
5
+ import { buildStatusCommand } from '@/cli/commands/build-status'
6
+ import { GerritApiServiceLive } from '@/api/gerrit'
7
+ import { ConfigService } from '@/services/config'
8
+ import type { MessageInfo } from '@/schemas/gerrit'
9
+ import { createMockConfigService } from './helpers/config-mock'
10
+
11
+ const server = setupServer(
12
+ // Default handler for auth check
13
+ http.get('*/a/accounts/self', ({ request }) => {
14
+ const auth = request.headers.get('Authorization')
15
+ if (!auth || !auth.startsWith('Basic ')) {
16
+ return HttpResponse.text('Unauthorized', { status: 401 })
17
+ }
18
+ return HttpResponse.json({
19
+ _account_id: 1000,
20
+ name: 'Test User',
21
+ email: 'test@example.com',
22
+ })
23
+ }),
24
+ )
25
+
26
+ // Store captured output
27
+ let capturedStdout: string[] = []
28
+ let capturedErrors: string[] = []
29
+
30
+ // Mock process.stdout.write to capture JSON output
31
+ const mockStdoutWrite = mock((chunk: any) => {
32
+ capturedStdout.push(String(chunk))
33
+ return true
34
+ })
35
+
36
+ // Mock console.error to capture errors
37
+ const mockConsoleError = mock((...args: any[]) => {
38
+ capturedErrors.push(args.join(' '))
39
+ })
40
+
41
+ // Mock process.exit to prevent test termination
42
+ const mockProcessExit = mock((_code?: number) => {
43
+ throw new Error('Process exited')
44
+ })
45
+
46
+ // Store original methods
47
+ const originalStdoutWrite = process.stdout.write
48
+ const originalConsoleError = console.error
49
+ const originalProcessExit = process.exit
50
+
51
+ beforeAll(() => {
52
+ server.listen({ onUnhandledRequest: 'bypass' })
53
+ // @ts-ignore - Mocking stdout
54
+ process.stdout.write = mockStdoutWrite
55
+ // @ts-ignore - Mocking console
56
+ console.error = mockConsoleError
57
+ // @ts-ignore - Mocking process.exit
58
+ process.exit = mockProcessExit
59
+ })
60
+
61
+ afterAll(() => {
62
+ server.close()
63
+ // @ts-ignore - Restoring stdout
64
+ process.stdout.write = originalStdoutWrite
65
+ console.error = originalConsoleError
66
+ // @ts-ignore - Restoring process.exit
67
+ process.exit = originalProcessExit
68
+ })
69
+
70
+ afterEach(() => {
71
+ server.resetHandlers()
72
+ mockStdoutWrite.mockClear()
73
+ mockConsoleError.mockClear()
74
+ mockProcessExit.mockClear()
75
+ capturedStdout = []
76
+ capturedErrors = []
77
+ })
78
+
79
+ describe('build-status command', () => {
80
+ const createMockConfigLayer = () => Layer.succeed(ConfigService, createMockConfigService())
81
+
82
+ test('returns pending when no Build Started message found', async () => {
83
+ const messages: MessageInfo[] = [
84
+ {
85
+ id: 'msg1',
86
+ message: 'Patch Set 1',
87
+ date: '2024-01-15 10:00:00.000000000',
88
+ author: {
89
+ _account_id: 1001,
90
+ name: 'Test User',
91
+ },
92
+ },
93
+ {
94
+ id: 'msg2',
95
+ message: 'Review comment',
96
+ date: '2024-01-15 10:30:00.000000000',
97
+ author: {
98
+ _account_id: 1002,
99
+ name: 'Reviewer',
100
+ },
101
+ },
102
+ ]
103
+
104
+ server.use(
105
+ http.get('*/a/changes/12345', ({ request }) => {
106
+ const url = new URL(request.url)
107
+ if (url.searchParams.get('o') === 'MESSAGES') {
108
+ return HttpResponse.json(
109
+ { messages },
110
+ {
111
+ headers: { 'Content-Type': 'application/json' },
112
+ },
113
+ )
114
+ }
115
+ return HttpResponse.text('Not Found', { status: 404 })
116
+ }),
117
+ )
118
+
119
+ const effect = buildStatusCommand('12345').pipe(
120
+ Effect.provide(GerritApiServiceLive),
121
+ Effect.provide(createMockConfigLayer()),
122
+ )
123
+
124
+ await Effect.runPromise(effect)
125
+
126
+ expect(capturedStdout.length).toBe(1)
127
+ const output = JSON.parse(capturedStdout[0])
128
+ expect(output).toEqual({ state: 'pending' })
129
+ })
130
+
131
+ test('returns running when Build Started but no verification', async () => {
132
+ const messages: MessageInfo[] = [
133
+ {
134
+ id: 'msg1',
135
+ message: 'Patch Set 1',
136
+ date: '2024-01-15 10:00:00.000000000',
137
+ author: {
138
+ _account_id: 1001,
139
+ name: 'Test User',
140
+ },
141
+ },
142
+ {
143
+ id: 'msg2',
144
+ message: 'Build Started',
145
+ date: '2024-01-15 10:05:00.000000000',
146
+ author: {
147
+ _account_id: 9999,
148
+ name: 'CI Bot',
149
+ },
150
+ },
151
+ {
152
+ id: 'msg3',
153
+ message: 'Some other message',
154
+ date: '2024-01-15 10:10:00.000000000',
155
+ author: {
156
+ _account_id: 1002,
157
+ name: 'Reviewer',
158
+ },
159
+ },
160
+ ]
161
+
162
+ server.use(
163
+ http.get('*/a/changes/12345', ({ request }) => {
164
+ const url = new URL(request.url)
165
+ if (url.searchParams.get('o') === 'MESSAGES') {
166
+ return HttpResponse.json(
167
+ { messages },
168
+ {
169
+ headers: { 'Content-Type': 'application/json' },
170
+ },
171
+ )
172
+ }
173
+ return HttpResponse.text('Not Found', { status: 404 })
174
+ }),
175
+ )
176
+
177
+ const effect = buildStatusCommand('12345').pipe(
178
+ Effect.provide(GerritApiServiceLive),
179
+ Effect.provide(createMockConfigLayer()),
180
+ )
181
+
182
+ await Effect.runPromise(effect)
183
+
184
+ expect(capturedStdout.length).toBe(1)
185
+ const output = JSON.parse(capturedStdout[0])
186
+ expect(output).toEqual({ state: 'running' })
187
+ })
188
+
189
+ test('returns success when Verified+1 after Build Started', async () => {
190
+ const messages: MessageInfo[] = [
191
+ {
192
+ id: 'msg1',
193
+ message: 'Patch Set 1',
194
+ date: '2024-01-15 10:00:00.000000000',
195
+ author: {
196
+ _account_id: 1001,
197
+ name: 'Test User',
198
+ },
199
+ },
200
+ {
201
+ id: 'msg2',
202
+ message: 'Build Started',
203
+ date: '2024-01-15 10:05:00.000000000',
204
+ author: {
205
+ _account_id: 9999,
206
+ name: 'CI Bot',
207
+ },
208
+ },
209
+ {
210
+ id: 'msg3',
211
+ message: 'Patch Set 1: Verified+1',
212
+ date: '2024-01-15 10:15:00.000000000',
213
+ author: {
214
+ _account_id: 9999,
215
+ name: 'CI Bot',
216
+ },
217
+ },
218
+ ]
219
+
220
+ server.use(
221
+ http.get('*/a/changes/12345', ({ request }) => {
222
+ const url = new URL(request.url)
223
+ if (url.searchParams.get('o') === 'MESSAGES') {
224
+ return HttpResponse.json(
225
+ { messages },
226
+ {
227
+ headers: { 'Content-Type': 'application/json' },
228
+ },
229
+ )
230
+ }
231
+ return HttpResponse.text('Not Found', { status: 404 })
232
+ }),
233
+ )
234
+
235
+ const effect = buildStatusCommand('12345').pipe(
236
+ Effect.provide(GerritApiServiceLive),
237
+ Effect.provide(createMockConfigLayer()),
238
+ )
239
+
240
+ await Effect.runPromise(effect)
241
+
242
+ expect(capturedStdout.length).toBe(1)
243
+ const output = JSON.parse(capturedStdout[0])
244
+ expect(output).toEqual({ state: 'success' })
245
+ })
246
+
247
+ test('returns failure when Verified-1 after Build Started', async () => {
248
+ const messages: MessageInfo[] = [
249
+ {
250
+ id: 'msg1',
251
+ message: 'Patch Set 1',
252
+ date: '2024-01-15 10:00:00.000000000',
253
+ author: {
254
+ _account_id: 1001,
255
+ name: 'Test User',
256
+ },
257
+ },
258
+ {
259
+ id: 'msg2',
260
+ message: 'Build Started',
261
+ date: '2024-01-15 10:05:00.000000000',
262
+ author: {
263
+ _account_id: 9999,
264
+ name: 'CI Bot',
265
+ },
266
+ },
267
+ {
268
+ id: 'msg3',
269
+ message: 'Patch Set 1: Verified-1\n\nBuild Failed',
270
+ date: '2024-01-15 10:20:00.000000000',
271
+ author: {
272
+ _account_id: 9999,
273
+ name: 'CI Bot',
274
+ },
275
+ },
276
+ ]
277
+
278
+ server.use(
279
+ http.get('*/a/changes/12345', ({ request }) => {
280
+ const url = new URL(request.url)
281
+ if (url.searchParams.get('o') === 'MESSAGES') {
282
+ return HttpResponse.json(
283
+ { messages },
284
+ {
285
+ headers: { 'Content-Type': 'application/json' },
286
+ },
287
+ )
288
+ }
289
+ return HttpResponse.text('Not Found', { status: 404 })
290
+ }),
291
+ )
292
+
293
+ const effect = buildStatusCommand('12345').pipe(
294
+ Effect.provide(GerritApiServiceLive),
295
+ Effect.provide(createMockConfigLayer()),
296
+ )
297
+
298
+ await Effect.runPromise(effect)
299
+
300
+ expect(capturedStdout.length).toBe(1)
301
+ const output = JSON.parse(capturedStdout[0])
302
+ expect(output).toEqual({ state: 'failure' })
303
+ })
304
+
305
+ test('ignores Verified messages before Build Started', async () => {
306
+ const messages: MessageInfo[] = [
307
+ {
308
+ id: 'msg1',
309
+ message: 'Patch Set 1: Verified+1',
310
+ date: '2024-01-15 09:00:00.000000000',
311
+ author: {
312
+ _account_id: 9999,
313
+ name: 'CI Bot',
314
+ },
315
+ },
316
+ {
317
+ id: 'msg2',
318
+ message: 'Build Started',
319
+ date: '2024-01-15 10:00:00.000000000',
320
+ author: {
321
+ _account_id: 9999,
322
+ name: 'CI Bot',
323
+ },
324
+ },
325
+ ]
326
+
327
+ server.use(
328
+ http.get('*/a/changes/12345', ({ request }) => {
329
+ const url = new URL(request.url)
330
+ if (url.searchParams.get('o') === 'MESSAGES') {
331
+ return HttpResponse.json(
332
+ { messages },
333
+ {
334
+ headers: { 'Content-Type': 'application/json' },
335
+ },
336
+ )
337
+ }
338
+ return HttpResponse.text('Not Found', { status: 404 })
339
+ }),
340
+ )
341
+
342
+ const effect = buildStatusCommand('12345').pipe(
343
+ Effect.provide(GerritApiServiceLive),
344
+ Effect.provide(createMockConfigLayer()),
345
+ )
346
+
347
+ await Effect.runPromise(effect)
348
+
349
+ expect(capturedStdout.length).toBe(1)
350
+ const output = JSON.parse(capturedStdout[0])
351
+ expect(output).toEqual({ state: 'running' })
352
+ })
353
+
354
+ test('uses most recent Build Started message', async () => {
355
+ const messages: MessageInfo[] = [
356
+ {
357
+ id: 'msg1',
358
+ message: 'Build Started',
359
+ date: '2024-01-15 09:00:00.000000000',
360
+ author: {
361
+ _account_id: 9999,
362
+ name: 'CI Bot',
363
+ },
364
+ },
365
+ {
366
+ id: 'msg2',
367
+ message: 'Patch Set 1: Verified-1',
368
+ date: '2024-01-15 09:30:00.000000000',
369
+ author: {
370
+ _account_id: 9999,
371
+ name: 'CI Bot',
372
+ },
373
+ },
374
+ {
375
+ id: 'msg3',
376
+ message: 'Build Started',
377
+ date: '2024-01-15 10:00:00.000000000',
378
+ author: {
379
+ _account_id: 9999,
380
+ name: 'CI Bot',
381
+ },
382
+ },
383
+ ]
384
+
385
+ server.use(
386
+ http.get('*/a/changes/12345', ({ request }) => {
387
+ const url = new URL(request.url)
388
+ if (url.searchParams.get('o') === 'MESSAGES') {
389
+ return HttpResponse.json(
390
+ { messages },
391
+ {
392
+ headers: { 'Content-Type': 'application/json' },
393
+ },
394
+ )
395
+ }
396
+ return HttpResponse.text('Not Found', { status: 404 })
397
+ }),
398
+ )
399
+
400
+ const effect = buildStatusCommand('12345').pipe(
401
+ Effect.provide(GerritApiServiceLive),
402
+ Effect.provide(createMockConfigLayer()),
403
+ )
404
+
405
+ await Effect.runPromise(effect)
406
+
407
+ expect(capturedStdout.length).toBe(1)
408
+ const output = JSON.parse(capturedStdout[0])
409
+ // Should be running because the most recent Build Started has no verification after it
410
+ expect(output).toEqual({ state: 'running' })
411
+ })
412
+
413
+ test('returns not_found when change does not exist', async () => {
414
+ server.use(
415
+ http.get('*/a/changes/99999', () => {
416
+ return HttpResponse.text('Not Found', { status: 404 })
417
+ }),
418
+ )
419
+
420
+ const effect = buildStatusCommand('99999').pipe(
421
+ Effect.provide(GerritApiServiceLive),
422
+ Effect.provide(createMockConfigLayer()),
423
+ )
424
+
425
+ await Effect.runPromise(effect)
426
+
427
+ expect(capturedStdout.length).toBe(1)
428
+ const output = JSON.parse(capturedStdout[0])
429
+ expect(output).toEqual({ state: 'not_found' })
430
+ })
431
+
432
+ test('handles empty message list', async () => {
433
+ server.use(
434
+ http.get('*/a/changes/12345', ({ request }) => {
435
+ const url = new URL(request.url)
436
+ if (url.searchParams.get('o') === 'MESSAGES') {
437
+ return HttpResponse.json(
438
+ { messages: [] },
439
+ {
440
+ headers: { 'Content-Type': 'application/json' },
441
+ },
442
+ )
443
+ }
444
+ return HttpResponse.text('Not Found', { status: 404 })
445
+ }),
446
+ )
447
+
448
+ const effect = buildStatusCommand('12345').pipe(
449
+ Effect.provide(GerritApiServiceLive),
450
+ Effect.provide(createMockConfigLayer()),
451
+ )
452
+
453
+ await Effect.runPromise(effect)
454
+
455
+ expect(capturedStdout.length).toBe(1)
456
+ const output = JSON.parse(capturedStdout[0])
457
+ // Empty messages means change exists but has no activity - returns pending
458
+ expect(output).toEqual({ state: 'pending' })
459
+ })
460
+
461
+ test('returns first match when both Verified+1 and Verified-1 after Build Started', async () => {
462
+ const messages: MessageInfo[] = [
463
+ {
464
+ id: 'msg1',
465
+ message: 'Build Started',
466
+ date: '2024-01-15 10:00:00.000000000',
467
+ author: {
468
+ _account_id: 9999,
469
+ name: 'CI Bot',
470
+ },
471
+ },
472
+ {
473
+ id: 'msg2',
474
+ message: 'Patch Set 1: Verified-1',
475
+ date: '2024-01-15 10:15:00.000000000',
476
+ author: {
477
+ _account_id: 9999,
478
+ name: 'CI Bot',
479
+ },
480
+ },
481
+ {
482
+ id: 'msg3',
483
+ message: 'Patch Set 2: Verified+1',
484
+ date: '2024-01-15 10:30:00.000000000',
485
+ author: {
486
+ _account_id: 9999,
487
+ name: 'CI Bot',
488
+ },
489
+ },
490
+ ]
491
+
492
+ server.use(
493
+ http.get('*/a/changes/12345', ({ request }) => {
494
+ const url = new URL(request.url)
495
+ if (url.searchParams.get('o') === 'MESSAGES') {
496
+ return HttpResponse.json(
497
+ { messages },
498
+ {
499
+ headers: { 'Content-Type': 'application/json' },
500
+ },
501
+ )
502
+ }
503
+ return HttpResponse.text('Not Found', { status: 404 })
504
+ }),
505
+ )
506
+
507
+ const effect = buildStatusCommand('12345').pipe(
508
+ Effect.provide(GerritApiServiceLive),
509
+ Effect.provide(createMockConfigLayer()),
510
+ )
511
+
512
+ await Effect.runPromise(effect)
513
+
514
+ expect(capturedStdout.length).toBe(1)
515
+ const output = JSON.parse(capturedStdout[0])
516
+ // Should return first verification result (failure)
517
+ expect(output).toEqual({ state: 'failure' })
518
+ })
519
+
520
+ test('does not match malformed verification messages', async () => {
521
+ const messages: MessageInfo[] = [
522
+ {
523
+ id: 'msg1',
524
+ message: 'Build Started',
525
+ date: '2024-01-15 10:00:00.000000000',
526
+ author: {
527
+ _account_id: 9999,
528
+ name: 'CI Bot',
529
+ },
530
+ },
531
+ {
532
+ id: 'msg2',
533
+ message: 'Please verify this +1 thanks',
534
+ date: '2024-01-15 10:15:00.000000000',
535
+ author: {
536
+ _account_id: 1001,
537
+ name: 'Reviewer',
538
+ },
539
+ },
540
+ {
541
+ id: 'msg3',
542
+ message: 'We are not verified -1 yet',
543
+ date: '2024-01-15 10:20:00.000000000',
544
+ author: {
545
+ _account_id: 1002,
546
+ name: 'Reviewer',
547
+ },
548
+ },
549
+ ]
550
+
551
+ server.use(
552
+ http.get('*/a/changes/12345', ({ request }) => {
553
+ const url = new URL(request.url)
554
+ if (url.searchParams.get('o') === 'MESSAGES') {
555
+ return HttpResponse.json(
556
+ { messages },
557
+ {
558
+ headers: { 'Content-Type': 'application/json' },
559
+ },
560
+ )
561
+ }
562
+ return HttpResponse.text('Not Found', { status: 404 })
563
+ }),
564
+ )
565
+
566
+ const effect = buildStatusCommand('12345').pipe(
567
+ Effect.provide(GerritApiServiceLive),
568
+ Effect.provide(createMockConfigLayer()),
569
+ )
570
+
571
+ await Effect.runPromise(effect)
572
+
573
+ expect(capturedStdout.length).toBe(1)
574
+ const output = JSON.parse(capturedStdout[0])
575
+ // Malformed messages should not match, so build is still running
576
+ expect(output).toEqual({ state: 'running' })
577
+ })
578
+
579
+ test('handles network error (500)', async () => {
580
+ server.use(
581
+ http.get('*/a/changes/12345', () => {
582
+ return HttpResponse.text('Internal Server Error', { status: 500 })
583
+ }),
584
+ )
585
+
586
+ const effect = buildStatusCommand('12345').pipe(
587
+ Effect.provide(GerritApiServiceLive),
588
+ Effect.provide(createMockConfigLayer()),
589
+ )
590
+
591
+ try {
592
+ await Effect.runPromise(effect)
593
+ } catch (_error) {
594
+ // Should throw error and call process.exit
595
+ expect(mockProcessExit).toHaveBeenCalledWith(1)
596
+ expect(capturedErrors.length).toBeGreaterThan(0)
597
+ }
598
+ })
599
+
600
+ test('handles same timestamp for Build Started and Verified', async () => {
601
+ const sameTimestamp = '2024-01-15 10:00:00.000000000'
602
+ const messages: MessageInfo[] = [
603
+ {
604
+ id: 'msg1',
605
+ message: 'Build Started',
606
+ date: sameTimestamp,
607
+ author: {
608
+ _account_id: 9999,
609
+ name: 'CI Bot',
610
+ },
611
+ },
612
+ {
613
+ id: 'msg2',
614
+ message: 'Patch Set 1: Verified+1',
615
+ date: sameTimestamp,
616
+ author: {
617
+ _account_id: 9999,
618
+ name: 'CI Bot',
619
+ },
620
+ },
621
+ ]
622
+
623
+ server.use(
624
+ http.get('*/a/changes/12345', ({ request }) => {
625
+ const url = new URL(request.url)
626
+ if (url.searchParams.get('o') === 'MESSAGES') {
627
+ return HttpResponse.json(
628
+ { messages },
629
+ {
630
+ headers: { 'Content-Type': 'application/json' },
631
+ },
632
+ )
633
+ }
634
+ return HttpResponse.text('Not Found', { status: 404 })
635
+ }),
636
+ )
637
+
638
+ const effect = buildStatusCommand('12345').pipe(
639
+ Effect.provide(GerritApiServiceLive),
640
+ Effect.provide(createMockConfigLayer()),
641
+ )
642
+
643
+ await Effect.runPromise(effect)
644
+
645
+ expect(capturedStdout.length).toBe(1)
646
+ const output = JSON.parse(capturedStdout[0])
647
+ // Same timestamp means Verified is not after Build Started, so running
648
+ expect(output).toEqual({ state: 'running' })
649
+ })
650
+
651
+ test('matches Build Started with different spacing', async () => {
652
+ const messages: MessageInfo[] = [
653
+ {
654
+ id: 'msg1',
655
+ message: 'Build Started', // Extra space
656
+ date: '2024-01-15 10:00:00.000000000',
657
+ author: {
658
+ _account_id: 9999,
659
+ name: 'CI Bot',
660
+ },
661
+ },
662
+ ]
663
+
664
+ server.use(
665
+ http.get('*/a/changes/12345', ({ request }) => {
666
+ const url = new URL(request.url)
667
+ if (url.searchParams.get('o') === 'MESSAGES') {
668
+ return HttpResponse.json(
669
+ { messages },
670
+ {
671
+ headers: { 'Content-Type': 'application/json' },
672
+ },
673
+ )
674
+ }
675
+ return HttpResponse.text('Not Found', { status: 404 })
676
+ }),
677
+ )
678
+
679
+ const effect = buildStatusCommand('12345').pipe(
680
+ Effect.provide(GerritApiServiceLive),
681
+ Effect.provide(createMockConfigLayer()),
682
+ )
683
+
684
+ await Effect.runPromise(effect)
685
+
686
+ expect(capturedStdout.length).toBe(1)
687
+ const output = JSON.parse(capturedStdout[0])
688
+ // Regex should handle extra whitespace
689
+ expect(output).toEqual({ state: 'running' })
690
+ })
691
+ })