diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 13aae88d38..aac8b3ac60 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -21,7 +21,33 @@ import { } from './gemini.js'; import { type LoadedSettings } from './config/settings.js'; import { appEvents, AppEvent } from './utils/events.js'; -import type { Config } from '@google/gemini-cli-core'; +import { type Config } from '@google/gemini-cli-core'; +import { act } from 'react'; +import { type InitializationResult } from './core/initializer.js'; + +const performance = vi.hoisted(() => ({ + now: vi.fn(), +})); +vi.stubGlobal('performance', performance); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + recordSlowRender: vi.fn(), + }; +}); + +vi.mock('ink', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // Mock here so we can spyOn the render function. ink uses ESM which doesn't + // allow us to spyOn it directly. + render: vi.fn((_node, options) => actual.render(_node, options)), + }; +}); // Custom error to identify mock process.exit calls class MockProcessExitError extends Error { @@ -295,6 +321,7 @@ describe('gemini.tsx main function kitty protocol', () => { } else { delete process.env['GEMINI_CLI_NO_RELAUNCH']; } + vi.restoreAllMocks(); }); it('should call setRawMode and detectAndEnableKittyProtocol when isInteractive is true', async () => { @@ -367,7 +394,9 @@ describe('gemini.tsx main function kitty protocol', () => { recordResponses: undefined, }); - await main(); + await act(async () => { + await main(); + }); expect(setRawModeSpy).toHaveBeenCalledWith(true); expect(detectAndEnableKittyProtocol).toHaveBeenCalledTimes(1); @@ -382,7 +411,7 @@ describe('validateDnsResolutionOrder', () => { }); afterEach(() => { - consoleWarnSpy.mockRestore(); + vi.restoreAllMocks(); }); it('should return "ipv4first" when the input is "ipv4first"', () => { @@ -423,6 +452,12 @@ describe('startInteractiveUI', () => { } as LoadedSettings; const mockStartupWarnings = ['warning1']; const mockWorkspaceRoot = '/root'; + const mockInitializationResult = { + authError: null, + themeError: null, + shouldOpenAuthDialog: false, + geminiMdFileCount: 0, + }; vi.mock('./utils/version.js', () => ({ getCliVersion: vi.fn(() => Promise.resolve('1.0.0')), @@ -442,26 +477,33 @@ describe('startInteractiveUI', () => { runExitCleanup: vi.fn(), })); - vi.mock('ink', () => ({ - render: vi.fn().mockReturnValue({ unmount: vi.fn() }), - })); - - beforeEach(() => { - vi.clearAllMocks(); + afterEach(() => { + vi.restoreAllMocks(); }); + async function startTestInteractiveUI( + config: Config, + settings: LoadedSettings, + startupWarnings: string[], + workspaceRoot: string, + initializationResult: InitializationResult, + ) { + await act(async () => { + await startInteractiveUI( + config, + settings, + startupWarnings, + workspaceRoot, + initializationResult, + ); + }); + } + it('should render the UI with proper React context and exitOnCtrlC disabled', async () => { const { render } = await import('ink'); const renderSpy = vi.mocked(render); - const mockInitializationResult = { - authError: null, - themeError: null, - shouldOpenAuthDialog: false, - geminiMdFileCount: 0, - }; - - await startInteractiveUI( + await startTestInteractiveUI( mockConfig, mockSettings, mockStartupWarnings, @@ -477,6 +519,7 @@ describe('startInteractiveUI', () => { expect(options).toEqual({ exitOnCtrlC: false, isScreenReaderEnabled: false, + onRender: expect.any(Function), }); // Verify React element structure is valid (but don't deep dive into JSX internals) @@ -488,14 +531,7 @@ describe('startInteractiveUI', () => { const { checkForUpdates } = await import('./ui/utils/updateCheck.js'); const { registerCleanup } = await import('./utils/cleanup.js'); - const mockInitializationResult = { - authError: null, - themeError: null, - shouldOpenAuthDialog: false, - geminiMdFileCount: 0, - }; - - await startInteractiveUI( + await startTestInteractiveUI( mockConfig, mockSettings, mockStartupWarnings, @@ -517,6 +553,36 @@ describe('startInteractiveUI', () => { expect(checkForUpdates).toHaveBeenCalledTimes(1); }); + it('should not recordSlowRender when less than threshold', async () => { + const { recordSlowRender } = await import('@google/gemini-cli-core'); + performance.now.mockReturnValueOnce(0); + await startTestInteractiveUI( + mockConfig, + mockSettings, + mockStartupWarnings, + mockWorkspaceRoot, + mockInitializationResult, + ); + + expect(recordSlowRender).not.toHaveBeenCalled(); + }); + + it('should call recordSlowRender when more than threshold', async () => { + const { recordSlowRender } = await import('@google/gemini-cli-core'); + performance.now.mockReturnValueOnce(0); + performance.now.mockReturnValueOnce(300); + + await startTestInteractiveUI( + mockConfig, + mockSettings, + mockStartupWarnings, + mockWorkspaceRoot, + mockInitializationResult, + ); + + expect(recordSlowRender).toHaveBeenCalledWith(mockConfig, 300); + }); + it.each([ { screenReader: true, @@ -537,14 +603,7 @@ describe('startInteractiveUI', () => { getScreenReader: () => screenReader, } as Config; - const mockInitializationResult = { - authError: null, - themeError: null, - shouldOpenAuthDialog: false, - geminiMdFileCount: 0, - }; - - await startInteractiveUI( + await startTestInteractiveUI( mockConfigWithScreenReader, mockSettings, mockStartupWarnings, diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 17c5b807ef..ef9239ca66 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { render } from 'ink'; +import { render, type RenderOptions } from 'ink'; import { AppContainer } from './ui/AppContainer.js'; import { loadCliConfig, parseArguments } from './config/config.js'; import * as cliConfig from './config/config.js'; @@ -32,7 +32,7 @@ import { runExitCleanup, } from './utils/cleanup.js'; import { getCliVersion } from './utils/version.js'; -import type { Config } from '@google/gemini-cli-core'; +import { type Config } from '@google/gemini-cli-core'; import { sessionId, logUserPrompt, @@ -40,6 +40,7 @@ import { getOauthClient, UserPromptEvent, debugLogger, + recordSlowRender, } from '@google/gemini-cli-core'; import { initializeApp, @@ -70,6 +71,8 @@ import { ExtensionManager } from './config/extension-manager.js'; import { createPolicyUpdater } from './config/policy.js'; import { requestConsentNonInteractive } from './config/extensions/consent.js'; +const SLOW_RENDER_MS = 200; + export function validateDnsResolutionOrder( order: string | undefined, ): DnsResolutionOrder { @@ -205,7 +208,12 @@ export async function startInteractiveUI( { exitOnCtrlC: false, isScreenReaderEnabled: config.getScreenReader(), - }, + onRender: ({ renderTime }: { renderTime: number }) => { + if (renderTime > SLOW_RENDER_MS) { + recordSlowRender(config, renderTime); + } + }, + } as RenderOptions, ); checkForUpdates(settings)