@aaronshaf/ger 0.2.2 → 0.2.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronshaf/ger",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Gerrit CLI and SDK - A modern CLI tool and TypeScript SDK for Gerrit Code Review, built with Effect-TS",
5
5
  "keywords": [
6
6
  "gerrit",
@@ -7,6 +7,7 @@ import { formatDiffPretty } from '@/utils/diff-formatters'
7
7
  import { sanitizeCDATA, escapeXML } from '@/utils/shell-safety'
8
8
  import { formatDate } from '@/utils/formatters'
9
9
  import { getChangeIdFromHead, GitError, NoChangeIdError } from '@/utils/git-commit'
10
+ import { writeFileSync } from 'node:fs'
10
11
 
11
12
  interface ShowOptions {
12
13
  xml?: boolean
@@ -184,12 +185,12 @@ const removeUndefined = <T extends Record<string, any>>(obj: T): Partial<T> => {
184
185
  ) as Partial<T>
185
186
  }
186
187
 
187
- const formatShowJson = (
188
+ const formatShowJson = async (
188
189
  changeDetails: ChangeDetails,
189
190
  diff: string,
190
191
  commentsWithContext: Array<{ comment: CommentInfo; context?: any }>,
191
192
  messages: MessageInfo[],
192
- ): void => {
193
+ ): Promise<void> => {
193
194
  const output = {
194
195
  status: 'success',
195
196
  change: removeUndefined({
@@ -242,82 +243,118 @@ const formatShowJson = (
242
243
  ),
243
244
  }
244
245
 
245
- console.log(JSON.stringify(output, null, 2))
246
+ const jsonOutput = JSON.stringify(output, null, 2) + '\n'
247
+ // Write to stdout and ensure all data is flushed before process exits
248
+ // Using process.stdout.write with drain handling for large payloads
249
+ return new Promise<void>((resolve, reject) => {
250
+ const written = process.stdout.write(jsonOutput, (err) => {
251
+ if (err) {
252
+ reject(err)
253
+ } else {
254
+ resolve()
255
+ }
256
+ })
257
+
258
+ if (!written) {
259
+ // If write returned false, buffer is full, wait for drain
260
+ process.stdout.once('drain', resolve)
261
+ process.stdout.once('error', reject)
262
+ }
263
+ })
246
264
  }
247
265
 
248
- const formatShowXml = (
266
+ const formatShowXml = async (
249
267
  changeDetails: ChangeDetails,
250
268
  diff: string,
251
269
  commentsWithContext: Array<{ comment: CommentInfo; context?: any }>,
252
270
  messages: MessageInfo[],
253
- ): void => {
254
- console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
255
- console.log(`<show_result>`)
256
- console.log(` <status>success</status>`)
257
- console.log(` <change>`)
258
- console.log(` <id>${escapeXML(changeDetails.id)}</id>`)
259
- console.log(` <number>${changeDetails.number}</number>`)
260
- console.log(` <subject><![CDATA[${sanitizeCDATA(changeDetails.subject)}]]></subject>`)
261
- console.log(` <status>${escapeXML(changeDetails.status)}</status>`)
262
- console.log(` <project>${escapeXML(changeDetails.project)}</project>`)
263
- console.log(` <branch>${escapeXML(changeDetails.branch)}</branch>`)
264
- console.log(` <owner>`)
271
+ ): Promise<void> => {
272
+ // Build complete XML output as a single string to avoid multiple writes
273
+ const xmlParts: string[] = []
274
+ xmlParts.push(`<?xml version="1.0" encoding="UTF-8"?>`)
275
+ xmlParts.push(`<show_result>`)
276
+ xmlParts.push(` <status>success</status>`)
277
+ xmlParts.push(` <change>`)
278
+ xmlParts.push(` <id>${escapeXML(changeDetails.id)}</id>`)
279
+ xmlParts.push(` <number>${changeDetails.number}</number>`)
280
+ xmlParts.push(` <subject><![CDATA[${sanitizeCDATA(changeDetails.subject)}]]></subject>`)
281
+ xmlParts.push(` <status>${escapeXML(changeDetails.status)}</status>`)
282
+ xmlParts.push(` <project>${escapeXML(changeDetails.project)}</project>`)
283
+ xmlParts.push(` <branch>${escapeXML(changeDetails.branch)}</branch>`)
284
+ xmlParts.push(` <owner>`)
265
285
  if (changeDetails.owner.name) {
266
- console.log(` <name><![CDATA[${sanitizeCDATA(changeDetails.owner.name)}]]></name>`)
286
+ xmlParts.push(` <name><![CDATA[${sanitizeCDATA(changeDetails.owner.name)}]]></name>`)
267
287
  }
268
288
  if (changeDetails.owner.email) {
269
- console.log(` <email>${escapeXML(changeDetails.owner.email)}</email>`)
289
+ xmlParts.push(` <email>${escapeXML(changeDetails.owner.email)}</email>`)
270
290
  }
271
- console.log(` </owner>`)
272
- console.log(` <created>${escapeXML(changeDetails.created || '')}</created>`)
273
- console.log(` <updated>${escapeXML(changeDetails.updated || '')}</updated>`)
274
- console.log(` </change>`)
275
- console.log(` <diff><![CDATA[${sanitizeCDATA(diff)}]]></diff>`)
291
+ xmlParts.push(` </owner>`)
292
+ xmlParts.push(` <created>${escapeXML(changeDetails.created || '')}</created>`)
293
+ xmlParts.push(` <updated>${escapeXML(changeDetails.updated || '')}</updated>`)
294
+ xmlParts.push(` </change>`)
295
+ xmlParts.push(` <diff><![CDATA[${sanitizeCDATA(diff)}]]></diff>`)
276
296
 
277
297
  // Comments section
278
- console.log(` <comments>`)
279
- console.log(` <count>${commentsWithContext.length}</count>`)
298
+ xmlParts.push(` <comments>`)
299
+ xmlParts.push(` <count>${commentsWithContext.length}</count>`)
280
300
  for (const { comment } of commentsWithContext) {
281
- console.log(` <comment>`)
282
- if (comment.id) console.log(` <id>${escapeXML(comment.id)}</id>`)
283
- if (comment.path) console.log(` <path><![CDATA[${sanitizeCDATA(comment.path)}]]></path>`)
284
- if (comment.line) console.log(` <line>${comment.line}</line>`)
301
+ xmlParts.push(` <comment>`)
302
+ if (comment.id) xmlParts.push(` <id>${escapeXML(comment.id)}</id>`)
303
+ if (comment.path) xmlParts.push(` <path><![CDATA[${sanitizeCDATA(comment.path)}]]></path>`)
304
+ if (comment.line) xmlParts.push(` <line>${comment.line}</line>`)
285
305
  if (comment.author?.name) {
286
- console.log(` <author><![CDATA[${sanitizeCDATA(comment.author.name)}]]></author>`)
306
+ xmlParts.push(` <author><![CDATA[${sanitizeCDATA(comment.author.name)}]]></author>`)
287
307
  }
288
- if (comment.updated) console.log(` <updated>${escapeXML(comment.updated)}</updated>`)
308
+ if (comment.updated) xmlParts.push(` <updated>${escapeXML(comment.updated)}</updated>`)
289
309
  if (comment.message) {
290
- console.log(` <message><![CDATA[${sanitizeCDATA(comment.message)}]]></message>`)
310
+ xmlParts.push(` <message><![CDATA[${sanitizeCDATA(comment.message)}]]></message>`)
291
311
  }
292
- if (comment.unresolved) console.log(` <unresolved>true</unresolved>`)
293
- console.log(` </comment>`)
312
+ if (comment.unresolved) xmlParts.push(` <unresolved>true</unresolved>`)
313
+ xmlParts.push(` </comment>`)
294
314
  }
295
- console.log(` </comments>`)
315
+ xmlParts.push(` </comments>`)
296
316
 
297
317
  // Messages section
298
- console.log(` <messages>`)
299
- console.log(` <count>${messages.length}</count>`)
318
+ xmlParts.push(` <messages>`)
319
+ xmlParts.push(` <count>${messages.length}</count>`)
300
320
  for (const message of messages) {
301
- console.log(` <message>`)
302
- console.log(` <id>${escapeXML(message.id)}</id>`)
321
+ xmlParts.push(` <message>`)
322
+ xmlParts.push(` <id>${escapeXML(message.id)}</id>`)
303
323
  if (message.author?.name) {
304
- console.log(` <author><![CDATA[${sanitizeCDATA(message.author.name)}]]></author>`)
324
+ xmlParts.push(` <author><![CDATA[${sanitizeCDATA(message.author.name)}]]></author>`)
305
325
  }
306
326
  if (message.author?._account_id) {
307
- console.log(` <author_id>${message.author._account_id}</author_id>`)
327
+ xmlParts.push(` <author_id>${message.author._account_id}</author_id>`)
308
328
  }
309
- console.log(` <date>${escapeXML(message.date)}</date>`)
329
+ xmlParts.push(` <date>${escapeXML(message.date)}</date>`)
310
330
  if (message._revision_number) {
311
- console.log(` <revision>${message._revision_number}</revision>`)
331
+ xmlParts.push(` <revision>${message._revision_number}</revision>`)
312
332
  }
313
333
  if (message.tag) {
314
- console.log(` <tag>${escapeXML(message.tag)}</tag>`)
334
+ xmlParts.push(` <tag>${escapeXML(message.tag)}</tag>`)
315
335
  }
316
- console.log(` <message><![CDATA[${sanitizeCDATA(message.message)}]]></message>`)
317
- console.log(` </message>`)
336
+ xmlParts.push(` <message><![CDATA[${sanitizeCDATA(message.message)}]]></message>`)
337
+ xmlParts.push(` </message>`)
318
338
  }
319
- console.log(` </messages>`)
320
- console.log(`</show_result>`)
339
+ xmlParts.push(` </messages>`)
340
+ xmlParts.push(`</show_result>`)
341
+
342
+ const xmlOutput = xmlParts.join('\n') + '\n'
343
+ // Write to stdout with proper drain handling for large payloads
344
+ return new Promise<void>((resolve, reject) => {
345
+ const written = process.stdout.write(xmlOutput, (err) => {
346
+ if (err) {
347
+ reject(err)
348
+ } else {
349
+ resolve()
350
+ }
351
+ })
352
+
353
+ if (!written) {
354
+ process.stdout.once('drain', resolve)
355
+ process.stdout.once('error', reject)
356
+ }
357
+ })
321
358
  }
322
359
 
323
360
  export const showCommand = (
@@ -358,9 +395,11 @@ export const showCommand = (
358
395
 
359
396
  // Format output
360
397
  if (options.json) {
361
- formatShowJson(changeDetails, diff, commentsWithContext, messages)
398
+ yield* Effect.promise(() =>
399
+ formatShowJson(changeDetails, diff, commentsWithContext, messages),
400
+ )
362
401
  } else if (options.xml) {
363
- formatShowXml(changeDetails, diff, commentsWithContext, messages)
402
+ yield* Effect.promise(() => formatShowXml(changeDetails, diff, commentsWithContext, messages))
364
403
  } else {
365
404
  formatShowPretty(changeDetails, diff, commentsWithContext, messages)
366
405
  }
@@ -373,22 +412,57 @@ export const showCommand = (
373
412
  : String(error)
374
413
 
375
414
  if (options.json) {
376
- console.log(
377
- JSON.stringify(
378
- {
379
- status: 'error',
380
- error: errorMessage,
381
- },
382
- null,
383
- 2,
384
- ),
415
+ return Effect.promise(
416
+ () =>
417
+ new Promise<void>((resolve, reject) => {
418
+ const errorOutput =
419
+ JSON.stringify(
420
+ {
421
+ status: 'error',
422
+ error: errorMessage,
423
+ },
424
+ null,
425
+ 2,
426
+ ) + '\n'
427
+ const written = process.stdout.write(errorOutput, (err) => {
428
+ if (err) {
429
+ reject(err)
430
+ } else {
431
+ resolve()
432
+ }
433
+ })
434
+
435
+ if (!written) {
436
+ // Wait for drain if buffer is full
437
+ process.stdout.once('drain', resolve)
438
+ process.stdout.once('error', reject)
439
+ }
440
+ }),
385
441
  )
386
442
  } else if (options.xml) {
387
- console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
388
- console.log(`<show_result>`)
389
- console.log(` <status>error</status>`)
390
- console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`)
391
- console.log(`</show_result>`)
443
+ return Effect.promise(
444
+ () =>
445
+ new Promise<void>((resolve, reject) => {
446
+ const xmlError =
447
+ `<?xml version="1.0" encoding="UTF-8"?>\n` +
448
+ `<show_result>\n` +
449
+ ` <status>error</status>\n` +
450
+ ` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>\n` +
451
+ `</show_result>\n`
452
+ const written = process.stdout.write(xmlError, (err) => {
453
+ if (err) {
454
+ reject(err)
455
+ } else {
456
+ resolve()
457
+ }
458
+ })
459
+
460
+ if (!written) {
461
+ process.stdout.once('drain', resolve)
462
+ process.stdout.once('error', reject)
463
+ }
464
+ }),
465
+ )
392
466
  } else {
393
467
  console.error(`✗ Error: ${errorMessage}`)
394
468
  }
@@ -75,6 +75,7 @@ ${JSON.stringify(mockChange)}`)
75
75
 
76
76
  let capturedLogs: string[] = []
77
77
  let capturedErrors: string[] = []
78
+ let capturedStdout: string[] = []
78
79
 
79
80
  const mockConsoleLog = mock((...args: any[]) => {
80
81
  capturedLogs.push(args.join(' '))
@@ -83,8 +84,19 @@ const mockConsoleError = mock((...args: any[]) => {
83
84
  capturedErrors.push(args.join(' '))
84
85
  })
85
86
 
87
+ // Mock process.stdout.write to capture JSON/XML output and handle callbacks
88
+ const mockStdoutWrite = mock((chunk: any, callback?: any) => {
89
+ capturedStdout.push(String(chunk))
90
+ // Call the callback synchronously if provided
91
+ if (typeof callback === 'function') {
92
+ callback()
93
+ }
94
+ return true
95
+ })
96
+
86
97
  const originalConsoleLog = console.log
87
98
  const originalConsoleError = console.error
99
+ const originalStdoutWrite = process.stdout.write
88
100
 
89
101
  let spawnSpy: ReturnType<typeof spyOn>
90
102
 
@@ -94,20 +106,26 @@ beforeAll(() => {
94
106
  console.log = mockConsoleLog
95
107
  // @ts-ignore
96
108
  console.error = mockConsoleError
109
+ // @ts-ignore
110
+ process.stdout.write = mockStdoutWrite
97
111
  })
98
112
 
99
113
  afterAll(() => {
100
114
  server.close()
101
115
  console.log = originalConsoleLog
102
116
  console.error = originalConsoleError
117
+ // @ts-ignore
118
+ process.stdout.write = originalStdoutWrite
103
119
  })
104
120
 
105
121
  afterEach(() => {
106
122
  server.resetHandlers()
107
123
  mockConsoleLog.mockClear()
108
124
  mockConsoleError.mockClear()
125
+ mockStdoutWrite.mockClear()
109
126
  capturedLogs = []
110
127
  capturedErrors = []
128
+ capturedStdout = []
111
129
 
112
130
  if (spawnSpy) {
113
131
  spawnSpy.mockRestore()
@@ -184,7 +202,7 @@ Change-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1`
184
202
 
185
203
  await resultPromise
186
204
 
187
- const output = capturedLogs.join('\n')
205
+ const output = capturedStdout.join('')
188
206
  expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
189
207
  expect(output).toContain('<show_result>')
190
208
  expect(output).toContain('<status>success</status>')
@@ -297,7 +315,7 @@ This commit has no Change-ID footer.`
297
315
 
298
316
  await resultPromise
299
317
 
300
- const output = capturedLogs.join('\n')
318
+ const output = capturedStdout.join('')
301
319
  expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
302
320
  expect(output).toContain('<show_result>')
303
321
  expect(output).toContain('<status>error</status>')
@@ -27,6 +27,7 @@ const server = setupServer(
27
27
  // Store captured output
28
28
  let capturedLogs: string[] = []
29
29
  let capturedErrors: string[] = []
30
+ let capturedStdout: string[] = []
30
31
 
31
32
  // Mock console.log and console.error
32
33
  const mockConsoleLog = mock((...args: any[]) => {
@@ -36,9 +37,20 @@ const mockConsoleError = mock((...args: any[]) => {
36
37
  capturedErrors.push(args.join(' '))
37
38
  })
38
39
 
39
- // Store original console methods
40
+ // Mock process.stdout.write to capture JSON output and handle callbacks
41
+ const mockStdoutWrite = mock((chunk: any, callback?: any) => {
42
+ capturedStdout.push(String(chunk))
43
+ // Call the callback synchronously if provided
44
+ if (typeof callback === 'function') {
45
+ callback()
46
+ }
47
+ return true
48
+ })
49
+
50
+ // Store original methods
40
51
  const originalConsoleLog = console.log
41
52
  const originalConsoleError = console.error
53
+ const originalStdoutWrite = process.stdout.write
42
54
 
43
55
  beforeAll(() => {
44
56
  server.listen({ onUnhandledRequest: 'bypass' })
@@ -46,20 +58,26 @@ beforeAll(() => {
46
58
  console.log = mockConsoleLog
47
59
  // @ts-ignore
48
60
  console.error = mockConsoleError
61
+ // @ts-ignore
62
+ process.stdout.write = mockStdoutWrite
49
63
  })
50
64
 
51
65
  afterAll(() => {
52
66
  server.close()
53
67
  console.log = originalConsoleLog
54
68
  console.error = originalConsoleError
69
+ // @ts-ignore
70
+ process.stdout.write = originalStdoutWrite
55
71
  })
56
72
 
57
73
  afterEach(() => {
58
74
  server.resetHandlers()
59
75
  mockConsoleLog.mockClear()
60
76
  mockConsoleError.mockClear()
77
+ mockStdoutWrite.mockClear()
61
78
  capturedLogs = []
62
79
  capturedErrors = []
80
+ capturedStdout = []
63
81
  })
64
82
 
65
83
  describe('show command', () => {
@@ -201,7 +219,7 @@ describe('show command', () => {
201
219
 
202
220
  await Effect.runPromise(program)
203
221
 
204
- const output = capturedLogs.join('\n')
222
+ const output = capturedStdout.join('')
205
223
 
206
224
  expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
207
225
  expect(output).toContain('<show_result>')
@@ -258,7 +276,7 @@ describe('show command', () => {
258
276
 
259
277
  await Effect.runPromise(program)
260
278
 
261
- const output = capturedLogs.join('\n')
279
+ const output = capturedStdout.join('')
262
280
 
263
281
  expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
264
282
  expect(output).toContain('<show_result>')
@@ -301,7 +319,7 @@ describe('show command', () => {
301
319
 
302
320
  await Effect.runPromise(program)
303
321
 
304
- const output = capturedLogs.join('\n')
322
+ const output = capturedStdout.join('')
305
323
 
306
324
  expect(output).toContain('<subject><![CDATA[Fix "quotes" & <tags> in auth]]></subject>')
307
325
  expect(output).toContain('<branch>feature/fix&amp;improve</branch>')
@@ -450,7 +468,7 @@ describe('show command', () => {
450
468
 
451
469
  await Effect.runPromise(program)
452
470
 
453
- const output = capturedLogs.join('\n')
471
+ const output = capturedStdout.join('')
454
472
 
455
473
  // Parse JSON to verify it's valid
456
474
  const parsed = JSON.parse(output)
@@ -495,7 +513,7 @@ describe('show command', () => {
495
513
 
496
514
  await Effect.runPromise(program)
497
515
 
498
- const output = capturedLogs.join('\n')
516
+ const output = capturedStdout.join('')
499
517
 
500
518
  // Parse JSON to verify it's valid
501
519
  const parsed = JSON.parse(output)
@@ -516,7 +534,7 @@ describe('show command', () => {
516
534
 
517
535
  await Effect.runPromise(program)
518
536
 
519
- const output = capturedLogs.join('\n')
537
+ const output = capturedStdout.join('')
520
538
 
521
539
  // Extract comment sections to verify order
522
540
  const commentMatches = output.matchAll(
@@ -581,7 +599,7 @@ describe('show command', () => {
581
599
 
582
600
  await Effect.runPromise(program)
583
601
 
584
- const output = capturedLogs.join('\n')
602
+ const output = capturedStdout.join('')
585
603
  const parsed = JSON.parse(output)
586
604
 
587
605
  expect(parsed.messages).toBeDefined()
@@ -592,4 +610,204 @@ describe('show command', () => {
592
610
  expect(parsed.messages[0].author.name).toBe('Jenkins Bot')
593
611
  expect(parsed.messages[0].revision).toBe(2)
594
612
  })
613
+
614
+ test('should handle large JSON output without truncation', async () => {
615
+ // Create a large diff to simulate output > 64KB
616
+ const largeDiff = '--- a/large-file.js\n+++ b/large-file.js\n' + 'x'.repeat(100000)
617
+
618
+ const mockChange = generateMockChange({
619
+ _number: 12345,
620
+ subject: 'Large change with extensive diff',
621
+ })
622
+
623
+ // Create many comments to increase JSON size
624
+ const manyComments: Record<string, any[]> = {
625
+ 'src/file.js': Array.from({ length: 100 }, (_, i) => ({
626
+ id: `comment${i}`,
627
+ path: 'src/file.js',
628
+ line: i + 1,
629
+ message: `Comment ${i}: ${'a'.repeat(500)}`, // Make comments substantial
630
+ author: {
631
+ name: 'Reviewer',
632
+ email: 'reviewer@example.com',
633
+ },
634
+ updated: '2024-01-15 11:30:00.000000000',
635
+ unresolved: false,
636
+ })),
637
+ }
638
+
639
+ server.use(
640
+ http.get('*/a/changes/:changeId', () => {
641
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
642
+ }),
643
+ http.get('*/a/changes/:changeId/revisions/current/patch', () => {
644
+ return HttpResponse.text(btoa(largeDiff))
645
+ }),
646
+ http.get('*/a/changes/:changeId/revisions/current/comments', () => {
647
+ return HttpResponse.text(`)]}'\n${JSON.stringify(manyComments)}`)
648
+ }),
649
+ http.get('*/a/changes/:changeId/revisions/current/files/:fileName/diff', () => {
650
+ return HttpResponse.text('context')
651
+ }),
652
+ )
653
+
654
+ const mockConfigLayer = createMockConfigLayer()
655
+ const program = showCommand('12345', { json: true }).pipe(
656
+ Effect.provide(GerritApiServiceLive),
657
+ Effect.provide(mockConfigLayer),
658
+ )
659
+
660
+ await Effect.runPromise(program)
661
+
662
+ const output = capturedStdout.join('')
663
+
664
+ // Verify output is larger than 64KB (the previous truncation point)
665
+ expect(output.length).toBeGreaterThan(65536)
666
+
667
+ // Verify JSON is valid and complete
668
+ const parsed = JSON.parse(output)
669
+ expect(parsed.status).toBe('success')
670
+ expect(parsed.diff).toContain('x'.repeat(100000))
671
+ expect(parsed.comments.length).toBe(100)
672
+
673
+ // Verify last comment is present (proves no truncation)
674
+ const lastComment = parsed.comments[parsed.comments.length - 1]
675
+ expect(lastComment.message).toContain('Comment 99')
676
+ })
677
+
678
+ test('should handle stdout drain event when buffer is full', async () => {
679
+ setupMockHandlers()
680
+
681
+ // Store original stdout.write
682
+ const originalStdoutWrite = process.stdout.write
683
+
684
+ let drainCallback: (() => void) | null = null
685
+ let errorCallback: ((err: Error) => void) | null = null
686
+ let writeCallbackFn: ((err?: Error) => void) | null = null
687
+
688
+ // Mock stdout.write to simulate full buffer
689
+ const mockWrite = mock((chunk: any, callback?: any) => {
690
+ capturedStdout.push(String(chunk))
691
+ writeCallbackFn = callback
692
+ // Return false to simulate full buffer
693
+ return false
694
+ })
695
+
696
+ // Mock stdout.once to capture drain and error listeners
697
+ const mockOnce = mock((event: string, callback: any) => {
698
+ if (event === 'drain') {
699
+ drainCallback = callback
700
+ // Simulate drain event after a short delay
701
+ setTimeout(() => {
702
+ if (drainCallback) {
703
+ drainCallback()
704
+ if (writeCallbackFn) {
705
+ writeCallbackFn()
706
+ }
707
+ }
708
+ }, 10)
709
+ } else if (event === 'error') {
710
+ errorCallback = callback
711
+ }
712
+ return process.stdout
713
+ })
714
+
715
+ // Apply mocks
716
+ // @ts-ignore
717
+ process.stdout.write = mockWrite
718
+ // @ts-ignore
719
+ process.stdout.once = mockOnce
720
+
721
+ const mockConfigLayer = createMockConfigLayer()
722
+ const program = showCommand('12345', { json: true }).pipe(
723
+ Effect.provide(GerritApiServiceLive),
724
+ Effect.provide(mockConfigLayer),
725
+ )
726
+
727
+ await Effect.runPromise(program)
728
+
729
+ // Restore original stdout.write
730
+ // @ts-ignore
731
+ process.stdout.write = originalStdoutWrite
732
+
733
+ // Verify that write returned false (buffer full)
734
+ expect(mockWrite).toHaveBeenCalled()
735
+
736
+ // Verify that drain listener was registered
737
+ expect(mockOnce).toHaveBeenCalledWith('drain', expect.any(Function))
738
+
739
+ // Verify that error listener was registered for robustness
740
+ expect(mockOnce).toHaveBeenCalledWith('error', expect.any(Function))
741
+
742
+ // Verify output is still valid JSON despite drain handling
743
+ const output = capturedStdout.join('')
744
+ const parsed = JSON.parse(output)
745
+ expect(parsed.status).toBe('success')
746
+ expect(parsed.change.id).toBe('I123abc456def')
747
+ })
748
+
749
+ test('should handle large XML output without truncation', async () => {
750
+ // Create a large diff to simulate output > 64KB
751
+ const largeDiff = '--- a/large-file.js\n+++ b/large-file.js\n' + 'x'.repeat(100000)
752
+
753
+ const mockChange = generateMockChange({
754
+ _number: 12345,
755
+ subject: 'Large change with extensive diff',
756
+ })
757
+
758
+ // Create many comments to increase XML size
759
+ const manyComments: Record<string, any[]> = {
760
+ 'src/file.js': Array.from({ length: 100 }, (_, i) => ({
761
+ id: `comment${i}`,
762
+ path: 'src/file.js',
763
+ line: i + 1,
764
+ message: `Comment ${i}: ${'a'.repeat(500)}`,
765
+ author: {
766
+ name: 'Reviewer',
767
+ email: 'reviewer@example.com',
768
+ },
769
+ updated: '2024-01-15 11:30:00.000000000',
770
+ unresolved: false,
771
+ })),
772
+ }
773
+
774
+ server.use(
775
+ http.get('*/a/changes/:changeId', () => {
776
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
777
+ }),
778
+ http.get('*/a/changes/:changeId/revisions/current/patch', () => {
779
+ return HttpResponse.text(btoa(largeDiff))
780
+ }),
781
+ http.get('*/a/changes/:changeId/revisions/current/comments', () => {
782
+ return HttpResponse.text(`)]}'\n${JSON.stringify(manyComments)}`)
783
+ }),
784
+ http.get('*/a/changes/:changeId/revisions/current/files/:fileName/diff', () => {
785
+ return HttpResponse.text('context')
786
+ }),
787
+ )
788
+
789
+ const mockConfigLayer = createMockConfigLayer()
790
+ const program = showCommand('12345', { xml: true }).pipe(
791
+ Effect.provide(GerritApiServiceLive),
792
+ Effect.provide(mockConfigLayer),
793
+ )
794
+
795
+ await Effect.runPromise(program)
796
+
797
+ const output = capturedStdout.join('')
798
+
799
+ // Verify output is larger than 64KB
800
+ expect(output.length).toBeGreaterThan(65536)
801
+
802
+ // Verify XML is valid and complete
803
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
804
+ expect(output).toContain('<show_result>')
805
+ expect(output).toContain('<status>success</status>')
806
+ expect(output).toContain('x'.repeat(100000))
807
+ expect(output).toContain('<count>100</count>')
808
+ expect(output).toContain('</show_result>')
809
+
810
+ // Verify last comment is present (proves no truncation)
811
+ expect(output).toContain('Comment 99')
812
+ })
595
813
  })