CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes45downloads
errors.test.ts892 linesDownload Raw Back to utils
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 
basant307/AI_Governance_Project · CoolFace