basant307/AI_Governance_Project
045
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import {8 describe,9 it,10 expect,11 vi,12 beforeEach,13 afterEach,14 type MockInstance,15} from 'vitest';16import { readFileSync } from 'node:fs';17import {18 createNonInteractivePromptId,19 main,20 registerLspHotReload,21 setupUnhandledRejectionHandler,22 validateDnsResolutionOrder,23} from './gemini.js';24import { startInteractiveUI } from './ui/startInteractiveUI.js';25import type { CliArgs } from './config/config.js';26import { type LoadedSettings } from './config/settings.js';27import { appEvents, AppEvent } from './utils/events.js';28import type { Config } from '@qwen-code/qwen-code-core';29import { ApprovalMode, OutputFormat } from '@qwen-code/qwen-code-core';30 31const mockWriteStderrLine = vi.hoisted(() => vi.fn());32const mockHandleListExtensions = vi.hoisted(() => vi.fn());33const mockStartEarlyStartupPrefetches = vi.hoisted(() => vi.fn());34const mockStartPostRenderPrefetches = vi.hoisted(() => vi.fn());35const mockRunAcpAgent = vi.hoisted(() => vi.fn());36const lspConfigWatcherMock = vi.hoisted(() => ({37 instances: [] as Array<{38 listener?: (event: unknown) => void | Promise<void>;39 startWatching: ReturnType<typeof vi.fn>;40 stopWatching: ReturnType<typeof vi.fn>;41 }>,42}));43 44describe('gemini import boundary', () => {45 it('does not statically import ACP or noninteractive auth branches', () => {46 const source = readFileSync('src/gemini.tsx', 'utf8');47 48 expect(source).not.toContain(49 "import { runAcpAgent } from './acp-integration/acpAgent.js'",50 );51 expect(source).not.toContain(52 "import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'",53 );54 expect(source).not.toContain(55 "import { initializeApp } from './core/initializer.js'",56 );57 expect(source).toMatch(58 /await import\(\s*['"]\.\/acp-integration\/acpAgent\.js['"]\s*\)/,59 );60 expect(source).toMatch(61 /await import\(\s*['"]\.\/validateNonInterActiveAuth\.js['"]\s*\)/,62 );63 expect(source).toMatch(64 /await import\(\s*['"]\.\/core\/initializer\.js['"]\s*\)/,65 );66 });67});68 69// Custom error to identify mock process.exit calls70class MockProcessExitError extends Error {71 constructor(readonly code?: string | number | null | undefined) {72 super('PROCESS_EXIT_MOCKED');73 this.name = 'MockProcessExitError';74 }75}76 77// Mock dependencies78vi.mock('./config/settings.js', async (importOriginal) => {79 const actual = await importOriginal<typeof import('./config/settings.js')>();80 return {81 ...actual,82 loadSettings: vi.fn(),83 createMinimalSettings: vi.fn(),84 };85});86 87vi.mock('./config/config.js', () => ({88 loadCliConfig: vi.fn().mockResolvedValue({89 getSandbox: vi.fn(() => false),90 getQuestion: vi.fn(() => ''),91 isInteractive: () => false,92 isLspEnabled: () => false,93 getLspClient: () => undefined,94 getWarnings: vi.fn(() => []),95 isSafeMode: vi.fn(() => false),96 getModelsConfig: vi.fn(() => ({ getCurrentAuthType: () => null })),97 } as unknown as Config),98 parseArguments: vi.fn().mockResolvedValue({}),99 isDebugMode: vi.fn(() => false),100 buildDisabledSkillNamesProvider: vi.fn(() => () => new Set<string>()),101}));102 103vi.mock('read-package-up', () => ({104 readPackageUp: vi.fn().mockResolvedValue({105 packageJson: { name: 'test-pkg', version: 'test-version' },106 path: '/fake/path/package.json',107 }),108}));109 110vi.mock('update-notifier', () => ({111 default: vi.fn(() => ({112 notify: vi.fn(),113 })),114}));115 116vi.mock('./utils/events.js', async (importOriginal) => {117 const actual = await importOriginal<typeof import('./utils/events.js')>();118 return {119 ...actual,120 appEvents: {121 emit: vi.fn(),122 },123 };124});125 126vi.mock('./utils/sandbox.js', () => ({127 sandbox_command: vi.fn(() => ''), // Default to no sandbox command128 start_sandbox: vi.fn(() => Promise.resolve()), // Mock as an async function that resolves129}));130 131vi.mock('./utils/stdioHelpers.js', () => ({132 writeStderrLine: mockWriteStderrLine,133 writeStdoutLine: vi.fn(),134 clearScreen: vi.fn(),135}));136 137vi.mock('./utils/relaunch.js', () => ({138 relaunchAppInChildProcess: vi.fn(),139 relaunchOnExitCode: vi.fn((fn: () => Promise<number>) => fn()),140}));141 142vi.mock('./config/sandboxConfig.js', () => ({143 loadSandboxConfig: vi.fn(),144}));145 146vi.mock('./core/initializer.js', () => ({147 initializeApp: vi.fn().mockResolvedValue({148 authError: null,149 themeError: null,150 shouldOpenAuthDialog: false,151 geminiMdFileCount: 0,152 }),153}));154 155vi.mock('./startup/startup-prefetch.js', () => ({156 startEarlyStartupPrefetches: (...args: unknown[]) =>157 mockStartEarlyStartupPrefetches(...args),158 startPostRenderPrefetches: (...args: unknown[]) =>159 mockStartPostRenderPrefetches(...args),160}));161 162vi.mock('./acp-integration/acpAgent.js', () => ({163 runAcpAgent: (...args: unknown[]) => mockRunAcpAgent(...args),164}));165 166vi.mock('./commands/extensions/list.js', () => ({167 handleList: mockHandleListExtensions,168}));169 170vi.mock('./ui/AppContainer.js', () => ({171 AppContainer: () => null,172}));173 174// Stub the settings watcher: main() constructs one and calls startWatching()175// in non-bare mode. The real implementation reads settings.user/.workspace176// paths and arms chokidar file watchers, neither of which these main()-flow177// tests supply or want as a side effect.178vi.mock('./config/settingsWatcher.js', () => ({179 SettingsWatcher: class {180 startWatching() {}181 stopWatching() {}182 addChangeListener() {183 return () => {};184 }185 },186}));187 188vi.mock('./config/lsp-config-watcher.js', () => ({189 LspConfigWatcher: class {190 listener?: (event: unknown) => void | Promise<void>;191 startWatching = vi.fn(192 (listener: (event: unknown) => void | Promise<void>) => {193 this.listener = listener;194 },195 );196 stopWatching = vi.fn();197 198 constructor() {199 lspConfigWatcherMock.instances.push(this);200 }201 },202}));203 204function withLspDisabledConfig<T extends object>(205 config: T,206): T & {207 isLspEnabled: () => boolean;208 getLspClient: () => undefined;209} {210 return {211 isLspEnabled: () => false,212 getLspClient: () => undefined,213 ...config,214 };215}216 217describe('gemini.tsx main function', () => {218 let originalEnvGeminiSandbox: string | undefined;219 let originalEnvSandbox: string | undefined;220 let originalEnvQwenCodeSimple: string | undefined;221 let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =222 [];223 224 beforeEach(() => {225 lspConfigWatcherMock.instances.length = 0;226 // Store and clear sandbox-related env variables to ensure a consistent test environment227 originalEnvGeminiSandbox = process.env['QWEN_SANDBOX'];228 originalEnvSandbox = process.env['SANDBOX'];229 originalEnvQwenCodeSimple = process.env['QWEN_CODE_SIMPLE'];230 delete process.env['QWEN_SANDBOX'];231 delete process.env['SANDBOX'];232 delete process.env['QWEN_CODE_SIMPLE'];233 234 initialUnhandledRejectionListeners =235 process.listeners('unhandledRejection');236 });237 238 afterEach(() => {239 // Restore original env variables240 if (originalEnvGeminiSandbox !== undefined) {241 process.env['QWEN_SANDBOX'] = originalEnvGeminiSandbox;242 } else {243 delete process.env['QWEN_SANDBOX'];244 }245 if (originalEnvSandbox !== undefined) {246 process.env['SANDBOX'] = originalEnvSandbox;247 } else {248 delete process.env['SANDBOX'];249 }250 if (originalEnvQwenCodeSimple !== undefined) {251 process.env['QWEN_CODE_SIMPLE'] = originalEnvQwenCodeSimple;252 } else {253 delete process.env['QWEN_CODE_SIMPLE'];254 }255 256 const currentListeners = process.listeners('unhandledRejection');257 const addedListener = currentListeners.find(258 (listener) => !initialUnhandledRejectionListeners.includes(listener),259 );260 261 if (addedListener) {262 process.removeListener('unhandledRejection', addedListener);263 }264 vi.restoreAllMocks();265 });266 267 it('verifies that we dont load the config before relaunchAppInChildProcess', async () => {268 const processExitSpy = vi269 .spyOn(process, 'exit')270 .mockImplementation((code) => {271 throw new MockProcessExitError(code);272 });273 const { relaunchAppInChildProcess } = await import('./utils/relaunch.js');274 const { loadCliConfig } = await import('./config/config.js');275 const { loadSettings } = await import('./config/settings.js');276 const { loadSandboxConfig } = await import('./config/sandboxConfig.js');277 vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);278 279 const callOrder: string[] = [];280 vi.mocked(relaunchAppInChildProcess).mockImplementation(async () => {281 callOrder.push('relaunch');282 });283 vi.mocked(loadCliConfig).mockImplementation(async () => {284 callOrder.push('loadCliConfig');285 return {286 isInteractive: () => false,287 getQuestion: () => '',288 getSandbox: () => false,289 getApprovalMode: () => ApprovalMode.DEFAULT,290 getDebugMode: () => false,291 getListExtensions: () => false,292 getMcpServers: () => ({}),293 getTopTierMcpServers: () => undefined,294 initialize: vi.fn(),295 waitForMcpReady: vi.fn().mockResolvedValue(undefined),296 getIdeMode: () => false,297 getExperimentalZedIntegration: () => false,298 getScreenReader: () => false,299 getGeminiMdFileCount: () => 0,300 getProjectRoot: () => '/',301 getOutputFormat: () => OutputFormat.TEXT,302 getWarnings: () => [],303 isSafeMode: () => false,304 getModelsConfig: () => ({ getCurrentAuthType: () => null }),305 getSessionId: () => 'test-session-id',306 } as unknown as Config;307 });308 vi.mocked(loadSettings).mockReturnValue({309 errors: [],310 merged: {311 advanced: { autoConfigureMemory: true },312 security: { auth: {} },313 ui: {},314 },315 setValue: vi.fn(),316 forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),317 migrationWarnings: [],318 getUserHooks: () => undefined,319 getProjectHooks: () => undefined,320 } as never);321 try {322 await main();323 } catch (e) {324 // Mocked process exit throws an error.325 if (!(e instanceof MockProcessExitError)) throw e;326 }327 328 // It is critical that we call relaunch before loadCliConfig to avoid329 // loading config in the outer process when we are going to relaunch.330 // By ensuring we don't load the config we also ensure we don't trigger any331 // operations that might require loading the config such as such as332 // initializing mcp servers.333 // For the sandbox case we still have to load a partial cli config.334 // we can authorize outside the sandbox.335 expect(callOrder).toEqual(['relaunch', 'loadCliConfig']);336 processExitSpy.mockRestore();337 });338 339 it('handles --list-extensions before sandbox and app config startup', async () => {340 vi.clearAllMocks();341 const processExitSpy = vi342 .spyOn(process, 'exit')343 .mockImplementation((code) => {344 throw new MockProcessExitError(code);345 });346 347 const { loadCliConfig, parseArguments } = await import(348 './config/config.js'349 );350 const { loadSettings } = await import('./config/settings.js');351 const { loadSandboxConfig } = await import('./config/sandboxConfig.js');352 353 vi.mocked(parseArguments).mockResolvedValue({354 listExtensions: true,355 } as unknown as CliArgs);356 vi.mocked(loadSettings).mockReturnValue({357 errors: [],358 merged: {359 advanced: {},360 security: { auth: {} },361 ui: {},362 },363 setValue: vi.fn(),364 forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),365 migrationWarnings: [],366 getUserHooks: () => undefined,367 getProjectHooks: () => undefined,368 } as never);369 mockHandleListExtensions.mockResolvedValue(undefined);370 371 try {372 await main();373 } catch (e) {374 if (!(e instanceof MockProcessExitError)) throw e;375 }376 377 expect(mockHandleListExtensions).toHaveBeenCalledOnce();378 expect(processExitSpy).toHaveBeenCalledWith(0);379 expect(loadSandboxConfig).not.toHaveBeenCalled();380 expect(loadCliConfig).not.toHaveBeenCalled();381 382 processExitSpy.mockRestore();383 });384 385 it('should skip full settings discovery in bare mode', async () => {386 const originalArgv = process.argv;387 process.argv = ['node', 'script.js', '--bare'];388 389 const { loadCliConfig, parseArguments } = await import(390 './config/config.js'391 );392 const { loadSettings, createMinimalSettings } = await import(393 './config/settings.js'394 );395 const { loadSandboxConfig } = await import('./config/sandboxConfig.js');396 const { relaunchAppInChildProcess } = await import('./utils/relaunch.js');397 const nonInteractiveModule = await import('./nonInteractiveCli.js');398 const processExitSpy = vi399 .spyOn(process, 'exit')400 .mockImplementation((code) => {401 throw new MockProcessExitError(code);402 });403 404 const minimalSettings = {405 errors: [],406 merged: {},407 setValue: vi.fn(),408 forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),409 migrationWarnings: [],410 getUserHooks: () => undefined,411 getProjectHooks: () => undefined,412 };413 const configStub = {414 isInteractive: () => false,415 getQuestion: () => 'bare prompt',416 getSandbox: () => false,417 getApprovalMode: () => ApprovalMode.DEFAULT,418 getDebugMode: () => false,419 getListExtensions: () => false,420 getMcpServers: () => ({}),421 getTopTierMcpServers: () => undefined,422 initialize: vi.fn().mockResolvedValue(undefined),423 waitForMcpReady: vi.fn().mockResolvedValue(undefined),424 getIdeMode: () => false,425 getExperimentalZedIntegration: () => false,426 getScreenReader: () => false,427 getGeminiMdFileCount: () => 0,428 getProjectRoot: () => '/',429 getOutputFormat: () => OutputFormat.TEXT,430 getWarnings: () => [],431 isSafeMode: () => false,432 getModelsConfig: () => ({ getCurrentAuthType: () => null }),433 getSessionId: () => 'test-session-id',434 } as unknown as Config;435 436 vi.mocked(parseArguments).mockResolvedValue({437 bare: true,438 } as unknown as CliArgs);439 vi.mocked(createMinimalSettings).mockReturnValue(minimalSettings as never);440 vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);441 vi.mocked(relaunchAppInChildProcess).mockResolvedValue(undefined);442 vi.mocked(loadCliConfig).mockResolvedValue(configStub);443 vi.spyOn(nonInteractiveModule, 'runNonInteractive').mockResolvedValue(0);444 445 try {446 await main();447 } catch (error) {448 if (!(error instanceof MockProcessExitError)) {449 throw error;450 }451 } finally {452 process.argv = originalArgv;453 processExitSpy.mockRestore();454 }455 456 expect(createMinimalSettings).toHaveBeenCalledOnce();457 expect(loadSettings).not.toHaveBeenCalled();458 expect(loadCliConfig).toHaveBeenCalledWith(459 {},460 expect.objectContaining({ bare: true }),461 process.cwd(),462 undefined,463 {464 userHooks: undefined,465 projectHooks: undefined,466 },467 expect.any(Function),468 undefined,469 // settingsWatcher: not started in bare mode470 undefined,471 );472 });473 474 describe('registerLspHotReload', () => {475 it('does not register a watcher when LSP is disabled', () => {476 const registerCleanup = vi.fn();477 478 registerLspHotReload(479 withLspDisabledConfig({480 getProjectRoot: () => '/workspace',481 }) as unknown as Config,482 registerCleanup,483 );484 485 expect(lspConfigWatcherMock.instances).toHaveLength(0);486 expect(registerCleanup).not.toHaveBeenCalled();487 });488 489 it('does not register a watcher when the client cannot reinitialize', () => {490 const registerCleanup = vi.fn();491 492 registerLspHotReload(493 {494 isLspEnabled: () => true,495 getLspClient: () => ({}),496 getProjectRoot: () => '/workspace',497 } as unknown as Config,498 registerCleanup,499 );500 501 expect(lspConfigWatcherMock.instances).toHaveLength(0);502 expect(registerCleanup).not.toHaveBeenCalled();503 });504 505 it('emits an LSP status update after successful reload', async () => {506 const registerCleanup = vi.fn();507 const reinitializeLsp = vi.fn(async () => ({508 reconcile: {509 added: ['clangd'],510 removed: [],511 restarted: [],512 unchanged: [],513 failed: [],514 },515 skipped: [],516 }));517 518 registerLspHotReload(519 {520 isLspEnabled: () => true,521 getLspClient: () => ({ reinitialize: vi.fn() }),522 getProjectRoot: () => '/workspace',523 reinitializeLsp,524 } as unknown as Config,525 registerCleanup,526 );527 528 await lspConfigWatcherMock.instances[0]?.listener?.({529 path: '/workspace/.lsp.json',530 changeType: 'modified',531 });532 533 expect(reinitializeLsp).toHaveBeenCalledOnce();534 expect(appEvents.emit).toHaveBeenCalledWith(AppEvent.LspStatusChanged);535 });536 537 it('emits an LSP status update when reload is skipped by the config', async () => {538 const reinitializeLsp = vi.fn(async () => undefined);539 540 registerLspHotReload(541 {542 isLspEnabled: () => true,543 getLspClient: () => ({ reinitialize: vi.fn() }),544 getProjectRoot: () => '/workspace',545 reinitializeLsp,546 } as unknown as Config,547 vi.fn(),548 );549 550 await lspConfigWatcherMock.instances[0]?.listener?.({551 path: '/workspace/.lsp.json',552 changeType: 'modified',553 });554 555 expect(reinitializeLsp).toHaveBeenCalledOnce();556 expect(appEvents.emit).not.toHaveBeenCalledWith(557 AppEvent.LogError,558 expect.any(String),559 );560 expect(appEvents.emit).toHaveBeenCalledWith(AppEvent.LspStatusChanged);561 });562 563 it('emits a user-visible error and rejects when reload fails', async () => {564 const reinitializeLsp = vi.fn(async () => {565 throw new Error('invalid lsp json');566 });567 568 registerLspHotReload(569 {570 isLspEnabled: () => true,571 getLspClient: () => ({ reinitialize: vi.fn() }),572 getProjectRoot: () => '/workspace',573 reinitializeLsp,574 } as unknown as Config,575 vi.fn(),576 );577 578 await expect(579 lspConfigWatcherMock.instances[0]?.listener?.({580 path: '/workspace/.lsp.json',581 changeType: 'modified',582 }),583 ).rejects.toThrow('invalid lsp json');584 585 expect(appEvents.emit).toHaveBeenCalledWith(586 AppEvent.LogError,587 'Failed to reload LSP server settings: invalid lsp json. Some LSP servers may have been partially updated. Run with --debug for details.',588 );589 });590 591 it('emits a user-visible error and rejects when reload has failed servers', async () => {592 const reinitializeLsp = vi.fn(async () => ({593 reconcile: {594 added: [],595 removed: [],596 restarted: [],597 unchanged: [],598 failed: ['clangd'],599 },600 skipped: [],601 }));602 603 registerLspHotReload(604 {605 isLspEnabled: () => true,606 getLspClient: () => ({ reinitialize: vi.fn() }),607 getProjectRoot: () => '/workspace',608 reinitializeLsp,609 } as unknown as Config,610 vi.fn(),611 );612 613 await expect(614 lspConfigWatcherMock.instances[0]?.listener?.({615 path: '/workspace/.lsp.json',616 changeType: 'modified',617 }),618 ).rejects.toThrow('LSP reload partially completed');619 620 expect(appEvents.emit).toHaveBeenCalledWith(621 AppEvent.LogError,622 'LSP reload partially completed: changed=<none>, failed=clangd. Run with --debug for details.',623 );624 expect(appEvents.emit).toHaveBeenCalledWith(AppEvent.LspStatusChanged);625 });626 627 it('surfaces invalid config without reinitializing LSP', async () => {628 const reinitializeLsp = vi.fn();629 630 registerLspHotReload(631 {632 isLspEnabled: () => true,633 getLspClient: () => ({ reinitialize: vi.fn() }),634 getProjectRoot: () => '/workspace',635 reinitializeLsp,636 } as unknown as Config,637 vi.fn(),638 );639 640 await lspConfigWatcherMock.instances[0]?.listener?.({641 path: '/workspace/.lsp.json',642 changeType: 'invalid',643 error:644 'Invalid JSON in .lsp.json; existing LSP runtime state is unchanged.',645 });646 647 expect(reinitializeLsp).not.toHaveBeenCalled();648 expect(appEvents.emit).toHaveBeenCalledWith(649 AppEvent.LogError,650 'Invalid JSON in .lsp.json; existing LSP runtime state is unchanged.',651 );652 });653 });654 655 it('writes non-interactive warnings discovered during config initialization', async () => {656 const originalNoRelaunch = process.env['QWEN_CODE_NO_RELAUNCH'];657 const originalIsTTY = Object.getOwnPropertyDescriptor(658 process.stdin,659 'isTTY',660 );661 process.env['QWEN_CODE_NO_RELAUNCH'] = 'true';662 Object.defineProperty(process.stdin, 'isTTY', {663 value: true,664 configurable: true,665 });666 667 const processExitSpy = vi668 .spyOn(process, 'exit')669 .mockImplementation((code) => {670 throw new MockProcessExitError(code);671 });672 const { loadCliConfig, parseArguments } = await import(673 './config/config.js'674 );675 const { loadSettings } = await import('./config/settings.js');676 const cleanupModule = await import('./utils/cleanup.js');677 const validatorModule = await import('./validateNonInterActiveAuth.js');678 const nonInteractiveModule = await import('./nonInteractiveCli.js');679 const initializerModule = await import('./core/initializer.js');680 const startupWarningsModule = await import('./utils/startupWarnings.js');681 const userStartupWarningsModule = await import(682 './utils/userStartupWarnings.js'683 );684 685 mockWriteStderrLine.mockClear();686 vi.mocked(cleanupModule.runExitCleanup).mockResolvedValue(undefined);687 vi.spyOn(initializerModule, 'initializeApp').mockResolvedValue({688 authError: null,689 themeError: null,690 shouldOpenAuthDialog: false,691 geminiMdFileCount: 0,692 });693 vi.spyOn(startupWarningsModule, 'getStartupWarnings').mockResolvedValue([]);694 vi.spyOn(695 userStartupWarningsModule,696 'getUserStartupWarnings',697 ).mockResolvedValue([]);698 vi.spyOn(nonInteractiveModule, 'runNonInteractive').mockResolvedValue(0);699 700 let initialized = false;701 const configStub = {702 isInteractive: () => false,703 getQuestion: () => 'hello',704 getSandbox: () => false,705 getApprovalMode: () => ApprovalMode.DEFAULT,706 getDebugMode: () => false,707 getListExtensions: () => false,708 getMcpServers: () => ({}),709 getTopTierMcpServers: () => undefined,710 initialize: vi.fn().mockImplementation(async () => {711 initialized = true;712 }),713 waitForMcpReady: vi.fn().mockResolvedValue(undefined),714 getFailedMcpServerNames: () => [],715 getIdeMode: () => false,716 getExperimentalZedIntegration: () => false,717 getScreenReader: () => false,718 getGeminiMdFileCount: () => 0,719 getProjectRoot: () => '/',720 getOutputFormat: () => OutputFormat.TEXT,721 getWarnings: () => (initialized ? ['late memory warning'] : []),722 isSafeMode: () => false,723 getModelsConfig: () => ({ getCurrentAuthType: () => null }),724 getContentGeneratorConfig: () => undefined,725 getUsageStatisticsEnabled: () => true,726 getSessionId: () => 'test-session-id',727 getProxy: () => undefined,728 } as unknown as Config;729 730 vi.mocked(parseArguments).mockResolvedValue({731 extensions: [],732 } as unknown as CliArgs);733 vi.mocked(loadSettings).mockReturnValue({734 errors: [],735 merged: {736 advanced: {},737 security: { auth: {} },738 ui: {},739 },740 setValue: vi.fn(),741 forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),742 migrationWarnings: [],743 getUserHooks: () => undefined,744 getProjectHooks: () => undefined,745 } as never);746 vi.mocked(loadCliConfig).mockResolvedValue(configStub);747 vi.spyOn(validatorModule, 'validateNonInteractiveAuth').mockResolvedValue(748 configStub,749 );750 751 try {752 await main();753 } catch (error) {754 if (!(error instanceof MockProcessExitError)) {755 throw error;756 }757 } finally {758 processExitSpy.mockRestore();759 if (originalIsTTY) {760 Object.defineProperty(process.stdin, 'isTTY', originalIsTTY);761 } else {762 delete (process.stdin as { isTTY?: unknown }).isTTY;763 }764 if (originalNoRelaunch !== undefined) {765 process.env['QWEN_CODE_NO_RELAUNCH'] = originalNoRelaunch;766 } else {767 delete process.env['QWEN_CODE_NO_RELAUNCH'];768 }769 }770 771 expect(mockWriteStderrLine).toHaveBeenCalledWith('late memory warning');772 expect(initializerModule.initializeApp).toHaveBeenCalledWith(773 configStub,774 expect.any(Object),775 { deferIdeConnection: false },776 );777 });778 779 it('creates non-interactive prompt ids that preserve session correlation', () => {780 expect(createNonInteractivePromptId('test-session-id')).toBe(781 'test-session-id########0',782 );783 });784 785 const runSandboxRelaunch = async (786 argv: string[],787 sessionId = '123e4567-e89b-12d3-a456-426614174000',788 ): Promise<string[]> => {789 const originalArgv = process.argv;790 process.argv = argv;791 const processExitSpy = vi792 .spyOn(process, 'exit')793 .mockImplementation((code) => {794 throw new MockProcessExitError(code);795 });796 797 const { loadCliConfig, parseArguments } = await import(798 './config/config.js'799 );800 const { loadSettings } = await import('./config/settings.js');801 const { loadSandboxConfig } = await import('./config/sandboxConfig.js');802 const { start_sandbox } = await import('./utils/sandbox.js');803 804 vi.mocked(start_sandbox).mockClear();805 vi.mocked(parseArguments).mockResolvedValue({806 debug: true,807 prompt: 'hello',808 extensions: [],809 } as unknown as CliArgs);810 vi.mocked(loadSettings).mockReturnValue({811 errors: [],812 merged: {813 advanced: {},814 security: { auth: {} },815 ui: {},816 },817 setValue: vi.fn(),818 forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),819 migrationWarnings: [],820 getUserHooks: () => undefined,821 getProjectHooks: () => undefined,822 } as never);823 vi.mocked(loadSandboxConfig).mockResolvedValue({824 command: 'sandbox-exec',825 image: '',826 });827 vi.mocked(loadCliConfig).mockResolvedValue({828 getModelsConfig: () => ({ getCurrentAuthType: () => null }),829 getSessionId: () => sessionId,830 } as unknown as Config);831 832 try {833 await main();834 } catch (error) {835 if (!(error instanceof MockProcessExitError)) {836 throw error;837 }838 } finally {839 process.argv = originalArgv;840 processExitSpy.mockRestore();841 }842 843 expect(start_sandbox).toHaveBeenCalledOnce();844 return vi.mocked(start_sandbox).mock.calls[0]![3]!;845 };846 847 it('passes the outer session ID into the sandbox child process', async () => {848 const sessionId = '123e4567-e89b-12d3-a456-426614174000';849 const sandboxArgs = await runSandboxRelaunch(850 ['node', 'script.js', '--debug', '-p', 'hello'],851 sessionId,852 );853 854 const idx = sandboxArgs.indexOf('--sandbox-session-id');855 expect(idx).not.toBe(-1);856 expect(sandboxArgs[idx + 1]).toBe(sessionId);857 expect(sandboxArgs).not.toContain('--session-id');858 });859 860 it('does not pass an empty session ID into the sandbox child process', async () => {861 const sandboxArgs = await runSandboxRelaunch(862 ['node', 'script.js', '--debug', '-p', 'hello'],863 '',864 );865 866 expect(sandboxArgs).not.toContain('--sandbox-session-id');867 expect(sandboxArgs).not.toContain('--session-id');868 });869 870 it.each([871 ['--continue', ['node', 'script.js', '--debug', '--continue']],872 ['-c', ['node', 'script.js', '--debug', '-c']],873 ['--resume', ['node', 'script.js', '--debug', '--resume', 'session-id']],874 ['-r', ['node', 'script.js', '--debug', '-r', 'session-id']],875 [876 '--session-id',877 [878 'node',879 'script.js',880 '--debug',881 '--session-id',882 '123e4567-e89b-12d3-a456-426614174999',883 ],884 ],885 ])(886 'does not inject sandbox session ID when argv contains %s',887 async (_flag, argv) => {888 const sandboxArgs = await runSandboxRelaunch(argv);889 890 expect(sandboxArgs).not.toContain('--sandbox-session-id');891 },892 );893 894 it('inserts the sandbox session ID before the argument separator', async () => {895 const sessionId = '123e4567-e89b-12d3-a456-426614174000';896 const sandboxArgs = await runSandboxRelaunch(897 ['node', 'script.js', '--debug', '--', '--not-a-cli-flag'],898 sessionId,899 );900 901 expect(sandboxArgs).toEqual([902 'node',903 'script.js',904 '--debug',905 '--sandbox-session-id',906 sessionId,907 '--',908 '--not-a-cli-flag',909 ]);910 });911 912 it('should log unhandled promise rejections and open debug console on first error', async () => {913 const processExitSpy = vi914 .spyOn(process, 'exit')915 .mockImplementation((code) => {916 throw new MockProcessExitError(code);917 });918 const appEventsMock = vi.mocked(appEvents);919 const rejectionError = new Error('Test unhandled rejection');920 921 setupUnhandledRejectionHandler();922 // Simulate an unhandled rejection.923 // We are not using Promise.reject here as vitest will catch it.924 // Instead we will dispatch the event manually.925 process.emit('unhandledRejection', rejectionError, Promise.resolve());926 927 // We need to wait for the rejection handler to be called.928 await new Promise(process.nextTick);929 930 expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvent.OpenDebugConsole);931 expect(appEventsMock.emit).toHaveBeenCalledWith(932 AppEvent.LogError,933 expect.stringContaining('Unhandled Promise Rejection'),934 );935 expect(appEventsMock.emit).toHaveBeenCalledWith(936 AppEvent.LogError,937 expect.stringContaining('Please file a bug report using the /bug tool.'),938 );939 940 // Simulate a second rejection941 const secondRejectionError = new Error('Second test unhandled rejection');942 process.emit('unhandledRejection', secondRejectionError, Promise.resolve());943 await new Promise(process.nextTick);944 945 // Ensure emit was only called once for OpenDebugConsole946 const openDebugConsoleCalls = appEventsMock.emit.mock.calls.filter(947 (call) => call[0] === AppEvent.OpenDebugConsole,948 );949 expect(openDebugConsoleCalls.length).toBe(1);950 951 // Avoid the process.exit error from being thrown.952 processExitSpy.mockRestore();953 });954 955 it('invokes runNonInteractiveStreamJson and performs cleanup in stream-json mode', async () => {956 const originalIsTTY = Object.getOwnPropertyDescriptor(957 process.stdin,958 'isTTY',959 );960 const originalIsRaw = Object.getOwnPropertyDescriptor(961 process.stdin,962 'isRaw',963 );964 Object.defineProperty(process.stdin, 'isTTY', {965 value: false, // 在 stream-json 模式下应为 false966 configurable: true,967 });968 Object.defineProperty(process.stdin, 'isRaw', {969 value: false,970 configurable: true,971 });972 973 const processExitSpy = vi974 .spyOn(process, 'exit')975 .mockImplementation((code) => {976 throw new MockProcessExitError(code);977 });978 979 const { loadCliConfig, parseArguments } = await import(980 './config/config.js'981 );982 const { loadSettings } = await import('./config/settings.js');983 const cleanupModule = await import('./utils/cleanup.js');984 const validatorModule = await import('./validateNonInterActiveAuth.js');985 const streamJsonModule = await import('./nonInteractive/session.js');986 const initializerModule = await import('./core/initializer.js');987 const startupWarningsModule = await import('./utils/startupWarnings.js');988 const userStartupWarningsModule = await import(989 './utils/userStartupWarnings.js'990 );991 992 vi.mocked(cleanupModule.cleanupCheckpoints).mockResolvedValue(undefined);993 vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => {});994 const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);995 runExitCleanupMock.mockResolvedValue(undefined);996 vi.spyOn(initializerModule, 'initializeApp').mockResolvedValue({997 authError: null,998 themeError: null,999 shouldOpenAuthDialog: false,1000 geminiMdFileCount: 0,1001 });1002 vi.spyOn(startupWarningsModule, 'getStartupWarnings').mockResolvedValue([]);1003 vi.spyOn(1004 userStartupWarningsModule,1005 'getUserStartupWarnings',1006 ).mockResolvedValue([]);1007 1008 const validatedConfig = { validated: true } as unknown as Config;1009 const validateAuthSpy = vi1010 .spyOn(validatorModule, 'validateNonInteractiveAuth')1011 .mockResolvedValue(validatedConfig);1012 const runStreamJsonSpy = vi1013 .spyOn(streamJsonModule, 'runNonInteractiveStreamJson')1014 .mockResolvedValue(undefined);1015 1016 vi.mocked(loadSettings).mockReturnValue({1017 errors: [],1018 merged: {1019 advanced: {},1020 security: { auth: {} },1021 ui: {},1022 },1023 setValue: vi.fn(),1024 forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),1025 migrationWarnings: [],1026 getUserHooks: () => undefined,1027 getProjectHooks: () => undefined,1028 } as never);1029 1030 vi.mocked(parseArguments).mockResolvedValue({1031 extensions: [],1032 } as never);1033 1034 const configStub = {1035 isInteractive: () => false,1036 getQuestion: () => ' hello stream ',1037 getSandbox: () => false,1038 getApprovalMode: () => ApprovalMode.DEFAULT,1039 getDebugMode: () => false,1040 getListExtensions: () => false,1041 getMcpServers: () => ({}),1042 getTopTierMcpServers: () => undefined,1043 initialize: vi.fn().mockResolvedValue(undefined),1044 waitForMcpReady: vi.fn().mockResolvedValue(undefined),1045 getIdeMode: () => false,1046 getExperimentalZedIntegration: () => false,1047 getScreenReader: () => false,1048 getGeminiMdFileCount: () => 0,1049 getProjectRoot: () => '/',1050 getInputFormat: () => 'stream-json',1051 getContentGeneratorConfig: () => ({ authType: 'test-auth' }),1052 getWarnings: () => [],1053 isSafeMode: () => false,1054 getModelsConfig: () => ({ getCurrentAuthType: () => null }),1055 getUsageStatisticsEnabled: () => true,1056 getSessionId: () => 'test-session-id',1057 getOutputFormat: () => OutputFormat.TEXT,1058 } as unknown as Config;1059 1060 vi.mocked(loadCliConfig).mockResolvedValue(configStub);1061 1062 process.env['SANDBOX'] = '1';1063 try {1064 await main();1065 } catch (error) {1066 if (!(error instanceof MockProcessExitError)) {1067 throw error;1068 }1069 } finally {1070 processExitSpy.mockRestore();1071 if (originalIsTTY) {1072 Object.defineProperty(process.stdin, 'isTTY', originalIsTTY);1073 } else {1074 delete (process.stdin as { isTTY?: unknown }).isTTY;1075 }1076 if (originalIsRaw) {1077 Object.defineProperty(process.stdin, 'isRaw', originalIsRaw);1078 } else {1079 delete (process.stdin as { isRaw?: unknown }).isRaw;1080 }1081 delete process.env['SANDBOX'];1082 }1083 1084 expect(runStreamJsonSpy).toHaveBeenCalledTimes(1);1085 const [configArg, inputArg, settingsArg] = runStreamJsonSpy.mock.calls[0];1086 expect(configArg).toBe(validatedConfig);1087 expect(inputArg).toBe('hello stream');1088 // Regression guard: PR-A's progressive-MCP refactor previously1089 // dropped the `settings` argument here, which silently fell back to1090 // `createMinimalSettings()` inside `runNonInteractiveStreamJson`.1091 // The parallel `runNonInteractive` path still received settings, so1092 // stream-json sessions lost any user-configured permission /1093 // approval / hook setup.1094 expect(settingsArg).toBeDefined();1095 1096 expect(validateAuthSpy).toHaveBeenCalledWith(1097 undefined,1098 configStub,1099 expect.any(Object),1100 );1101 expect(initializerModule.initializeApp).toHaveBeenCalledWith(1102 configStub,1103 expect.any(Object),1104 { deferIdeConnection: false },1105 );1106 expect(runExitCleanupMock).toHaveBeenCalledTimes(1);1107 });1108});1109 1110describe('gemini.tsx main function kitty protocol', () => {1111 let originalEnvNoRelaunch: string | undefined;1112 let setRawModeSpy: MockInstance<1113 (mode: boolean) => NodeJS.ReadStream & { fd: 0 }1114 >;1115 let initialSigintListeners: NodeJS.SignalsListener[];1116 let initialSigtermListeners: NodeJS.SignalsListener[];1117 1118 beforeEach(() => {1119 // Set no relaunch in tests since process spawning causing issues in tests1120 originalEnvNoRelaunch = process.env['QWEN_CODE_NO_RELAUNCH'];1121 process.env['QWEN_CODE_NO_RELAUNCH'] = 'true';1122 initialSigintListeners = process.listeners(1123 'SIGINT',1124 ) as NodeJS.SignalsListener[];1125 initialSigtermListeners = process.listeners(1126 'SIGTERM',1127 ) as NodeJS.SignalsListener[];1128 1129 // eslint-disable-next-line @typescript-eslint/no-explicit-any1130 if (!(process.stdin as any).setRawMode) {1131 // eslint-disable-next-line @typescript-eslint/no-explicit-any1132 (process.stdin as any).setRawMode = vi.fn();1133 }1134 setRawModeSpy = vi.spyOn(process.stdin, 'setRawMode');1135 1136 Object.defineProperty(process.stdin, 'isTTY', {1137 value: true,1138 configurable: true,1139 });1140 Object.defineProperty(process.stdin, 'isRaw', {1141 value: false,1142 configurable: true,1143 });1144 });1145 1146 afterEach(() => {1147 for (const listener of process.listeners('SIGINT')) {1148 if (!initialSigintListeners.includes(listener)) {1149 process.removeListener('SIGINT', listener as NodeJS.SignalsListener);1150 }1151 }1152 for (const listener of process.listeners('SIGTERM')) {1153 if (!initialSigtermListeners.includes(listener)) {1154 process.removeListener('SIGTERM', listener as NodeJS.SignalsListener);1155 }1156 }1157 1158 // Restore original env variables1159 if (originalEnvNoRelaunch !== undefined) {1160 process.env['QWEN_CODE_NO_RELAUNCH'] = originalEnvNoRelaunch;1161 } else {1162 delete process.env['QWEN_CODE_NO_RELAUNCH'];1163 }1164 vi.restoreAllMocks();1165 });1166 1167 it('should call setRawMode and detectAndEnableKittyProtocol when isInteractive is true', async () => {1168 const { detectAndEnableKittyProtocol } = await import(1169 './ui/utils/kittyProtocolDetector.js'1170 );1171 const { loadCliConfig, parseArguments } = await import(1172 './config/config.js'1173 );1174 const { loadSettings } = await import('./config/settings.js');1175 const initializerModule = await import('./core/initializer.js');1176 const initializeAppSpy = vi1177 .spyOn(initializerModule, 'initializeApp')1178 .mockResolvedValue({1179 authError: null,1180 themeError: null,1181 shouldOpenAuthDialog: false,1182 geminiMdFileCount: 0,1183 });1184 vi.mocked(loadCliConfig).mockResolvedValue({1185 isInteractive: () => true,1186 getQuestion: () => '',1187 getSandbox: () => false,1188 getDebugMode: () => false,1189 getListExtensions: () => false,1190 getMcpServers: () => ({}),1191 getTopTierMcpServers: () => undefined,1192 initialize: vi.fn(),1193 waitForMcpReady: vi.fn().mockResolvedValue(undefined),1194 getIdeMode: () => false,1195 getExperimentalZedIntegration: () => false,1196 getScreenReader: () => false,1197 getGeminiMdFileCount: () => 0,1198 getWarnings: () => [],1199 isSafeMode: () => false,1200 getModelsConfig: () => ({ getCurrentAuthType: () => null }),