@aaronshaf/ger 0.2.4 → 0.3.1

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