basant307/AI_Governance_Project
045
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import { vi, type Mock, type MockInstance } from 'vitest';8import type { Config } from '@qwen-code/qwen-code-core';9import {10 OutputFormat,11 FatalInputError,12 ToolErrorType,13} from '@qwen-code/qwen-code-core';14import {15 AlreadyReportedError,16 _resetExitLatchForTest,17 getErrorMessage,18 handleError,19 handleToolError,20 handleCancellationError,21 handleMaxTurnsExceededError,22} from './errors.js';23import { _resetCleanupFunctionsForTest, registerCleanup } from './cleanup.js';24 25const mockWriteStderrLine = vi.hoisted(() => vi.fn());26const debugLoggerSpy = vi.hoisted(() => ({27 debug: vi.fn(),28 info: vi.fn(),29 warn: vi.fn(),30 error: vi.fn(),31}));32 33// Mock the core modules34vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {35 const original =36 await importOriginal<typeof import('@qwen-code/qwen-code-core')>();37 38 return {39 ...original,40 createDebugLogger: () => ({41 debug: debugLoggerSpy.debug,42 info: debugLoggerSpy.info,43 warn: debugLoggerSpy.warn,44 error: debugLoggerSpy.error,45 }),46 parseAndFormatApiError: vi.fn((error: unknown) => {47 if (error instanceof Error) {48 return `API Error: ${error.message}`;49 }50 return `API Error: ${String(error)}`;51 }),52 JsonFormatter: vi.fn().mockImplementation(() => ({53 formatError: vi.fn((error: Error, code?: string | number) =>54 JSON.stringify(55 {56 error: {57 type: error.constructor.name,58 message: error.message,59 ...(code && { code }),60 },61 },62 null,63 2,64 ),65 ),66 })),67 FatalToolExecutionError: class extends Error {68 constructor(message: string) {69 super(message);70 this.name = 'FatalToolExecutionError';71 this.exitCode = 54;72 }73 exitCode: number;74 },75 FatalCancellationError: class extends Error {76 constructor(message: string) {77 super(message);78 this.name = 'FatalCancellationError';79 this.exitCode = 130;80 }81 exitCode: number;82 },83 };84});85 86vi.mock('./stdioHelpers.js', () => ({87 writeStderrLine: mockWriteStderrLine,88 writeStdoutLine: vi.fn(),89 clearScreen: vi.fn(),90}));91 92describe('errors', () => {93 let mockConfig: Config;94 let processExitSpy: MockInstance;95 let processStderrWriteSpy: MockInstance;96 97 beforeEach(() => {98 // Reset mocks99 vi.clearAllMocks();100 mockWriteStderrLine.mockClear();101 debugLoggerSpy.debug.mockClear();102 debugLoggerSpy.info.mockClear();103 debugLoggerSpy.warn.mockClear();104 debugLoggerSpy.error.mockClear();105 _resetCleanupFunctionsForTest();106 _resetExitLatchForTest();107 108 // Mock process.stderr.write109 processStderrWriteSpy = vi110 .spyOn(process.stderr, 'write')111 .mockImplementation(() => true);112 113 // Mock process.exit to throw instead of actually exiting114 processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {115 throw new Error(`process.exit called with code: ${code}`);116 });117 118 // Create mock config119 mockConfig = {120 getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),121 getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }),122 getDebugMode: vi.fn().mockReturnValue(true),123 isInteractive: vi.fn().mockReturnValue(false),124 } as unknown as Config;125 });126 127 afterEach(() => {128 processStderrWriteSpy.mockRestore();129 processExitSpy.mockRestore();130 });131 132 describe('getErrorMessage', () => {133 it('should return error message for Error instances', () => {134 const error = new Error('Test error message');135 expect(getErrorMessage(error)).toBe('Test error message');136 });137 138 it('should convert non-Error values to strings', () => {139 expect(getErrorMessage('string error')).toBe('string error');140 expect(getErrorMessage(123)).toBe('123');141 expect(getErrorMessage(null)).toBe('null');142 expect(getErrorMessage(undefined)).toBe('undefined');143 });144 145 it('should extract message from error-like objects', () => {146 const obj = { message: 'test error message' };147 expect(getErrorMessage(obj)).toBe('test error message');148 });149 150 it('should stringify plain objects without message property', () => {151 const obj = { code: 500, details: 'internal error' };152 expect(getErrorMessage(obj)).toBe(153 '{"code":500,"details":"internal error"}',154 );155 });156 157 it('should handle empty objects', () => {158 expect(getErrorMessage({})).toBe('{}');159 });160 161 it('should handle objects with non-string message property', () => {162 const obj = { message: 123 };163 expect(getErrorMessage(obj)).toBe('{"message":123}');164 });165 166 it('should fallback to String() when toJSON returns undefined', () => {167 const obj = {168 toJSON() {169 return undefined;170 },171 };172 expect(getErrorMessage(obj)).toBe('[object Object]');173 });174 });175 176 describe('handleError', () => {177 describe('in text mode', () => {178 beforeEach(() => {179 (180 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>181 ).mockReturnValue(OutputFormat.TEXT);182 });183 184 it('should log error message and re-throw', async () => {185 const testError = new Error('Test error');186 187 await expect(handleError(testError, mockConfig)).rejects.toThrow(188 testError,189 );190 191 expect(mockWriteStderrLine).toHaveBeenCalledWith(192 'API Error: Test error',193 );194 });195 196 it('should handle non-Error objects', async () => {197 const testError = 'String error';198 199 await expect(handleError(testError, mockConfig)).rejects.toThrow(200 testError,201 );202 203 expect(mockWriteStderrLine).toHaveBeenCalledWith(204 'API Error: String error',205 );206 });207 208 it('does not reformat or reprint AlreadyReportedError', async () => {209 // The non-interactive runner formats and prints the API error210 // itself, then throws AlreadyReportedError as a marker. handleError211 // must propagate that throw without producing a second stderr line212 // (the bug this fix targets) or running parseAndFormatApiError on213 // the already-formatted message (which would yield214 // "[API Error: [API Error: ...]]").215 const reported = new AlreadyReportedError(216 '[API Error: 402 Model X is not available for billing.]',217 );218 219 await expect(handleError(reported, mockConfig)).rejects.toBe(reported);220 221 expect(mockWriteStderrLine).not.toHaveBeenCalled();222 });223 });224 225 describe('in JSON mode', () => {226 beforeEach(() => {227 (228 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>229 ).mockReturnValue(OutputFormat.JSON);230 });231 232 it('should format error as JSON and exit with default code', async () => {233 const testError = new Error('Test error');234 235 await expect(handleError(testError, mockConfig)).rejects.toThrow(236 'process.exit called with code: 1',237 );238 239 expect(mockWriteStderrLine).toHaveBeenCalledWith(240 JSON.stringify(241 {242 error: {243 type: 'Error',244 message: 'Test error',245 code: 1,246 },247 },248 null,249 2,250 ),251 );252 });253 254 it('does not reformat or reprint AlreadyReportedError in JSON mode', async () => {255 const reported = new AlreadyReportedError(256 '[API Error: 402 Model X is not available for billing.]',257 42,258 );259 260 await expect(handleError(reported, mockConfig)).rejects.toThrow(261 'process.exit called with code: 42',262 );263 264 expect(mockWriteStderrLine).toHaveBeenCalledTimes(1);265 expect(mockWriteStderrLine).toHaveBeenCalledWith(266 JSON.stringify(267 {268 error: {269 type: 'AlreadyReportedError',270 message:271 '[API Error: 402 Model X is not available for billing.]',272 code: 42,273 },274 },275 null,276 2,277 ),278 );279 });280 281 it('should use custom error code when provided', async () => {282 const testError = new Error('Test error');283 284 await expect(handleError(testError, mockConfig, 42)).rejects.toThrow(285 'process.exit called with code: 42',286 );287 288 expect(mockWriteStderrLine).toHaveBeenCalledWith(289 JSON.stringify(290 {291 error: {292 type: 'Error',293 message: 'Test error',294 code: 42,295 },296 },297 null,298 2,299 ),300 );301 });302 303 it('should extract exitCode from FatalError instances', async () => {304 const fatalError = new FatalInputError('Fatal error');305 306 await expect(handleError(fatalError, mockConfig)).rejects.toThrow(307 'process.exit called with code: 42',308 );309 310 expect(mockWriteStderrLine).toHaveBeenCalledWith(311 JSON.stringify(312 {313 error: {314 type: 'FatalInputError',315 message: 'Fatal error',316 code: 42,317 },318 },319 null,320 2,321 ),322 );323 });324 325 it('should handle error with code property', async () => {326 const errorWithCode = new Error('Error with code') as Error & {327 code: number;328 };329 errorWithCode.code = 404;330 331 await expect(handleError(errorWithCode, mockConfig)).rejects.toThrow(332 'process.exit called with code: 404',333 );334 });335 336 it('should handle error with status property', async () => {337 const errorWithStatus = new Error('Error with status') as Error & {338 status: string;339 };340 errorWithStatus.status = 'TIMEOUT';341 342 await expect(handleError(errorWithStatus, mockConfig)).rejects.toThrow(343 'process.exit called with code: 1', // string codes become 1344 );345 346 expect(mockWriteStderrLine).toHaveBeenCalledWith(347 JSON.stringify(348 {349 error: {350 type: 'Error',351 message: 'Error with status',352 code: 'TIMEOUT',353 },354 },355 null,356 2,357 ),358 );359 });360 });361 });362 363 describe('handleToolError', () => {364 const toolName = 'test-tool';365 const toolError = new Error('Tool failed');366 367 describe('when debug mode is enabled', () => {368 beforeEach(() => {369 (mockConfig.getDebugMode as Mock).mockReturnValue(true);370 });371 372 describe('in text mode', () => {373 beforeEach(() => {374 (375 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>376 ).mockReturnValue(OutputFormat.TEXT);377 });378 379 it('should log error message to stderr and not exit', () => {380 handleToolError(toolName, toolError, mockConfig);381 382 expect(debugLoggerSpy.error).toHaveBeenCalledWith(383 'Error executing tool test-tool: Tool failed',384 );385 expect(processExitSpy).not.toHaveBeenCalled();386 });387 388 it('should use resultDisplay when provided and not exit', () => {389 handleToolError(390 toolName,391 toolError,392 mockConfig,393 'CUSTOM_ERROR',394 'Custom display message',395 );396 397 expect(debugLoggerSpy.error).toHaveBeenCalledWith(398 'Error executing tool test-tool: Custom display message',399 );400 expect(processExitSpy).not.toHaveBeenCalled();401 });402 });403 404 describe('in JSON mode', () => {405 beforeEach(() => {406 (407 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>408 ).mockReturnValue(OutputFormat.JSON);409 });410 411 it('should log error message to stderr and not exit', () => {412 handleToolError(toolName, toolError, mockConfig);413 414 // In JSON mode, should not exit (just log to stderr when debug mode is on)415 expect(debugLoggerSpy.error).toHaveBeenCalledWith(416 'Error executing tool test-tool: Tool failed',417 );418 expect(processExitSpy).not.toHaveBeenCalled();419 });420 421 it('should log error with custom error code and not exit', () => {422 handleToolError(toolName, toolError, mockConfig, 'CUSTOM_TOOL_ERROR');423 424 // In JSON mode, should not exit (just log to stderr when debug mode is on)425 expect(debugLoggerSpy.error).toHaveBeenCalledWith(426 'Error executing tool test-tool: Tool failed',427 );428 expect(processExitSpy).not.toHaveBeenCalled();429 });430 431 it('should log error with numeric error code and not exit', () => {432 handleToolError(toolName, toolError, mockConfig, 500);433 434 // In JSON mode, should not exit (just log to stderr when debug mode is on)435 expect(debugLoggerSpy.error).toHaveBeenCalledWith(436 'Error executing tool test-tool: Tool failed',437 );438 expect(processExitSpy).not.toHaveBeenCalled();439 });440 441 it('should prefer resultDisplay over error message and not exit', () => {442 handleToolError(443 toolName,444 toolError,445 mockConfig,446 'DISPLAY_ERROR',447 'Display message',448 );449 450 // In JSON mode, should not exit (just log to stderr when debug mode is on)451 expect(debugLoggerSpy.error).toHaveBeenCalledWith(452 'Error executing tool test-tool: Display message',453 );454 expect(processExitSpy).not.toHaveBeenCalled();455 });456 });457 458 describe('in STREAM_JSON mode', () => {459 beforeEach(() => {460 (461 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>462 ).mockReturnValue(OutputFormat.STREAM_JSON);463 });464 465 it('should log error message to stderr and not exit', () => {466 handleToolError(toolName, toolError, mockConfig);467 468 // Should not exit in STREAM_JSON mode (just log to stderr when debug mode is on)469 expect(debugLoggerSpy.error).toHaveBeenCalledWith(470 'Error executing tool test-tool: Tool failed',471 );472 expect(processExitSpy).not.toHaveBeenCalled();473 });474 });475 });476 477 describe('when debug mode is disabled', () => {478 beforeEach(() => {479 (mockConfig.getDebugMode as Mock).mockReturnValue(false);480 });481 482 it('should log error and not exit in text mode', () => {483 (484 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>485 ).mockReturnValue(OutputFormat.TEXT);486 487 handleToolError(toolName, toolError, mockConfig);488 489 expect(debugLoggerSpy.error).toHaveBeenCalledWith(490 'Error executing tool test-tool: Tool failed',491 );492 expect(processExitSpy).not.toHaveBeenCalled();493 });494 495 it('should log error and not exit in JSON mode', () => {496 (497 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>498 ).mockReturnValue(OutputFormat.JSON);499 500 handleToolError(toolName, toolError, mockConfig);501 502 expect(debugLoggerSpy.error).toHaveBeenCalledWith(503 'Error executing tool test-tool: Tool failed',504 );505 expect(processExitSpy).not.toHaveBeenCalled();506 });507 508 it('should log error and not exit in STREAM_JSON mode', () => {509 (510 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>511 ).mockReturnValue(OutputFormat.STREAM_JSON);512 513 handleToolError(toolName, toolError, mockConfig);514 515 expect(debugLoggerSpy.error).toHaveBeenCalledWith(516 'Error executing tool test-tool: Tool failed',517 );518 expect(processExitSpy).not.toHaveBeenCalled();519 });520 });521 522 describe('process exit behavior', () => {523 beforeEach(() => {524 (mockConfig.getDebugMode as Mock).mockReturnValue(true);525 });526 527 it('should never exit regardless of output format', () => {528 // Test in TEXT mode529 (530 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>531 ).mockReturnValue(OutputFormat.TEXT);532 handleToolError(toolName, toolError, mockConfig);533 expect(processExitSpy).not.toHaveBeenCalled();534 535 // Test in JSON mode536 (537 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>538 ).mockReturnValue(OutputFormat.JSON);539 handleToolError(toolName, toolError, mockConfig);540 expect(processExitSpy).not.toHaveBeenCalled();541 542 // Test in STREAM_JSON mode543 (544 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>545 ).mockReturnValue(OutputFormat.STREAM_JSON);546 handleToolError(toolName, toolError, mockConfig);547 expect(processExitSpy).not.toHaveBeenCalled();548 });549 });550 551 describe('permission denied warnings', () => {552 it('should show warning when EXECUTION_DENIED in non-interactive text mode', () => {553 (mockConfig.getDebugMode as Mock).mockReturnValue(false);554 (mockConfig.isInteractive as Mock).mockReturnValue(false);555 (556 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>557 ).mockReturnValue(OutputFormat.TEXT);558 559 handleToolError(560 toolName,561 toolError,562 mockConfig,563 ToolErrorType.EXECUTION_DENIED,564 );565 566 expect(processStderrWriteSpy).toHaveBeenCalledWith(567 expect.stringContaining(568 'Warning: Tool "test-tool" requires user approval',569 ),570 );571 expect(processStderrWriteSpy).toHaveBeenCalledWith(572 expect.stringContaining('use the -y flag (YOLO mode)'),573 );574 expect(processExitSpy).not.toHaveBeenCalled();575 });576 577 it('should not show warning when EXECUTION_DENIED in interactive mode', () => {578 (mockConfig.getDebugMode as Mock).mockReturnValue(false);579 (mockConfig.isInteractive as Mock).mockReturnValue(true);580 (581 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>582 ).mockReturnValue(OutputFormat.TEXT);583 584 handleToolError(585 toolName,586 toolError,587 mockConfig,588 ToolErrorType.EXECUTION_DENIED,589 );590 591 expect(processStderrWriteSpy).not.toHaveBeenCalled();592 expect(processExitSpy).not.toHaveBeenCalled();593 });594 595 it('should not show warning when EXECUTION_DENIED in JSON mode', () => {596 (mockConfig.getDebugMode as Mock).mockReturnValue(false);597 (mockConfig.isInteractive as Mock).mockReturnValue(false);598 (599 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>600 ).mockReturnValue(OutputFormat.JSON);601 602 handleToolError(603 toolName,604 toolError,605 mockConfig,606 ToolErrorType.EXECUTION_DENIED,607 );608 609 expect(processStderrWriteSpy).not.toHaveBeenCalled();610 expect(processExitSpy).not.toHaveBeenCalled();611 });612 613 it('should not show warning for non-EXECUTION_DENIED errors', () => {614 (mockConfig.getDebugMode as Mock).mockReturnValue(false);615 (mockConfig.isInteractive as Mock).mockReturnValue(false);616 (617 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>618 ).mockReturnValue(OutputFormat.TEXT);619 620 handleToolError(621 toolName,622 toolError,623 mockConfig,624 ToolErrorType.FILE_NOT_FOUND,625 );626 627 expect(processStderrWriteSpy).not.toHaveBeenCalled();628 expect(processExitSpy).not.toHaveBeenCalled();629 });630 });631 });632 633 describe('handleCancellationError', () => {634 describe('in text mode', () => {635 beforeEach(() => {636 (637 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>638 ).mockReturnValue(OutputFormat.TEXT);639 });640 641 it('should log cancellation message and exit with 130', async () => {642 await expect(handleCancellationError(mockConfig)).rejects.toThrow(643 'process.exit called with code: 130',644 );645 646 expect(mockWriteStderrLine).toHaveBeenCalledWith(647 'Operation cancelled.',648 );649 });650 });651 652 describe('in JSON mode', () => {653 beforeEach(() => {654 (655 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>656 ).mockReturnValue(OutputFormat.JSON);657 });658 659 it('should format cancellation as JSON and exit with 130', async () => {660 await expect(handleCancellationError(mockConfig)).rejects.toThrow(661 'process.exit called with code: 130',662 );663 664 expect(mockWriteStderrLine).toHaveBeenCalledWith(665 JSON.stringify(666 {667 error: {668 type: 'FatalCancellationError',669 message: 'Operation cancelled.',670 code: 130,671 },672 },673 null,674 2,675 ),676 );677 });678 });679 });680 681 describe('handleMaxTurnsExceededError', () => {682 describe('in text mode', () => {683 beforeEach(() => {684 (685 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>686 ).mockReturnValue(OutputFormat.TEXT);687 });688 689 it('should log max turns message and exit with 53', async () => {690 await expect(handleMaxTurnsExceededError(mockConfig)).rejects.toThrow(691 'process.exit called with code: 53',692 );693 694 expect(mockWriteStderrLine).toHaveBeenCalledWith(695 'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',696 );697 });698 });699 700 describe('in JSON mode', () => {701 beforeEach(() => {702 (703 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>704 ).mockReturnValue(OutputFormat.JSON);705 });706 707 it('should format max turns error as JSON and exit with 53', async () => {708 await expect(handleMaxTurnsExceededError(mockConfig)).rejects.toThrow(709 'process.exit called with code: 53',710 );711 712 expect(mockWriteStderrLine).toHaveBeenCalledWith(713 JSON.stringify(714 {715 error: {716 type: 'FatalTurnLimitedError',717 message:718 'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',719 code: 53,720 },721 },722 null,723 2,724 ),725 );726 });727 });728 729 describe('with --json-schema active', () => {730 // When the structured-output run hits maxSessionTurns the generic731 // "increase maxSessionTurns" message can be misleading: the real732 // cause is usually that structured_output never got called (denied733 // by permissions, unsatisfiable schema, prompt didn't instruct the734 // model). Append a contextual hint so users debugging a stuck735 // --json-schema run know where to look.736 beforeEach(() => {737 (mockConfig as unknown as { getJsonSchema: Mock }).getJsonSchema = vi738 .fn()739 .mockReturnValue({ type: 'object' });740 });741 742 it('appends a json-schema-specific hint in text mode', async () => {743 (744 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>745 ).mockReturnValue(OutputFormat.TEXT);746 747 await expect(handleMaxTurnsExceededError(mockConfig)).rejects.toThrow(748 'process.exit called with code: 53',749 );750 751 const written = mockWriteStderrLine.mock.calls[0]?.[0] as string;752 expect(written).toMatch(/Reached max session turns for this session\./);753 expect(written).toMatch(/--json-schema is active/);754 expect(written).toMatch(/permissions\.deny.*--exclude-tools/);755 });756 757 it('appends a json-schema-specific hint inside the JSON error message', async () => {758 (759 mockConfig.getOutputFormat as ReturnType<typeof vi.fn>760 ).mockReturnValue(OutputFormat.JSON);761 762 await expect(handleMaxTurnsExceededError(mockConfig)).rejects.toThrow(763 'process.exit called with code: 53',764 );765 766 const written = mockWriteStderrLine.mock.calls[0]?.[0] as string;767 const parsed = JSON.parse(written) as {768 error: { type: string; message: string; code: number };769 };770 expect(parsed.error.type).toBe('FatalTurnLimitedError');771 expect(parsed.error.code).toBe(53);772 expect(parsed.error.message).toMatch(/--json-schema is active/);773 });774 });775 });776 777 describe('cleanup-before-exit invariant', () => {778 // Regression: previously these handlers called process.exit synchronously,779 // bypassing the caller's runExitCleanup → flush() chain on SIGINT, max-780 // turn, and fatal-error paths. Same family as the EPIPE/process.exit781 // bug fixed for stdout in nonInteractiveCli.782 it('handleCancellationError drains registered cleanups before exit', async () => {783 const cleanupOrder: string[] = [];784 registerCleanup(() => {785 cleanupOrder.push('cleanup');786 });787 processExitSpy.mockImplementation((code) => {788 cleanupOrder.push(`exit:${code}`);789 throw new Error(`process.exit called with code: ${code}`);790 });791 792 await expect(handleCancellationError(mockConfig)).rejects.toThrow(793 'process.exit called with code: 130',794 );795 796 expect(cleanupOrder).toEqual(['cleanup', 'exit:130']);797 });798 799 it('handleMaxTurnsExceededError drains registered cleanups before exit', async () => {800 const cleanupOrder: string[] = [];801 registerCleanup(() => {802 cleanupOrder.push('cleanup');803 });804 processExitSpy.mockImplementation((code) => {805 cleanupOrder.push(`exit:${code}`);806 throw new Error(`process.exit called with code: ${code}`);807 });808 809 await expect(handleMaxTurnsExceededError(mockConfig)).rejects.toThrow(810 'process.exit called with code: 53',811 );812 813 expect(cleanupOrder).toEqual(['cleanup', 'exit:53']);814 });815 816 it('handleError drains registered cleanups before exit (JSON mode)', async () => {817 (mockConfig.getOutputFormat as ReturnType<typeof vi.fn>).mockReturnValue(818 OutputFormat.JSON,819 );820 const cleanupOrder: string[] = [];821 registerCleanup(() => {822 cleanupOrder.push('cleanup');823 });824 processExitSpy.mockImplementation((code) => {825 cleanupOrder.push(`exit:${code}`);826 throw new Error(`process.exit called with code: ${code}`);827 });828 829 await expect(handleError(new Error('boom'), mockConfig)).rejects.toThrow(830 'process.exit called with code: 1',831 );832 833 expect(cleanupOrder).toEqual(['cleanup', 'exit:1']);834 });835 836 it('a second terminating handler does not race the first into double-exit', async () => {837 // Models the real concurrency: SIGINT → handleCancellationError fires838 // while a stream rejection lands in the catch → handleError(JSON).839 // Without the exit-once latch we'd get duplicate cleanup runs +840 // duplicate process.exit calls + interleaved stderr writes.841 // (Text-mode handleError throws instead of exiting, so it isn't part842 // of the race — the latch lives on the exit path.)843 (mockConfig.getOutputFormat as ReturnType<typeof vi.fn>).mockReturnValue(844 OutputFormat.JSON,845 );846 847 let exitCalls = 0;848 processExitSpy.mockImplementation((code) => {849 exitCalls += 1;850 throw new Error(`process.exit called with code: ${code}`);851 });852 853 const first = handleCancellationError(mockConfig);854 const second = handleError(new Error('boom'), mockConfig);855 856 await expect(first).rejects.toThrow('process.exit called with code: 130');857 858 // The second handler is parked in the latch's unresolved promise.859 let secondSettled = false;860 void second.then(861 () => {862 secondSettled = true;863 },864 () => {865 secondSettled = true;866 },867 );868 await new Promise((r) => setTimeout(r, 20));869 870 expect(exitCalls).toBe(1);871 expect(secondSettled).toBe(false);872 });873 874 it('handleError drains registered cleanups before re-throw (text mode)', async () => {875 // Text mode re-throws to the caller; we still want the queue drained876 // first so the unhandled-rejection path doesn't lose records.877 (mockConfig.getOutputFormat as ReturnType<typeof vi.fn>).mockReturnValue(878 OutputFormat.TEXT,879 );880 const events: string[] = [];881 registerCleanup(() => {882 events.push('cleanup');883 });884 885 const original = new Error('boom');886 await expect(handleError(original, mockConfig)).rejects.toBe(original);887 888 expect(events).toEqual(['cleanup']);889 });890 });891});892 