diff options
Diffstat (limited to 'packages/cli/src/ui')
| -rw-r--r-- | packages/cli/src/ui/App.test.tsx | 178 | ||||
| -rw-r--r-- | packages/cli/src/ui/App.tsx | 17 | ||||
| -rw-r--r-- | packages/cli/src/ui/utils/updateCheck.test.ts | 14 | ||||
| -rw-r--r-- | packages/cli/src/ui/utils/updateCheck.ts | 21 |
4 files changed, 210 insertions, 20 deletions
diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index f35f8cb7..fef4106a 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -23,6 +23,9 @@ import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js'; import { StreamingState, ConsoleMessageItem } from './types.js'; import { Tips } from './components/Tips.js'; +import { checkForUpdates, UpdateObject } from './utils/updateCheck.js'; +import { EventEmitter } from 'events'; +import { updateEventEmitter } from '../utils/updateEventEmitter.js'; // Define a more complete mock server config based on actual Config interface MockServerConfig { @@ -163,6 +166,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { MCPServerConfig: actualCore.MCPServerConfig, getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']), ideContext: ideContextMock, + isGitRepository: vi.fn(), }; }); @@ -220,6 +224,17 @@ vi.mock('./components/Header.js', () => ({ Header: vi.fn(() => null), })); +vi.mock('./utils/updateCheck.js', () => ({ + checkForUpdates: vi.fn(), +})); + +const mockedCheckForUpdates = vi.mocked(checkForUpdates); +const { isGitRepository: mockedIsGitRepository } = vi.mocked( + await import('@google/gemini-cli-core'), +); + +vi.mock('node:child_process'); + describe('App UI', () => { let mockConfig: MockServerConfig; let mockSettings: LoadedSettings; @@ -288,6 +303,169 @@ describe('App UI', () => { vi.clearAllMocks(); // Clear mocks after each test }); + describe('handleAutoUpdate', () => { + let spawnEmitter: EventEmitter; + + beforeEach(async () => { + const { spawn } = await import('node:child_process'); + spawnEmitter = new EventEmitter(); + spawnEmitter.stdout = new EventEmitter(); + spawnEmitter.stderr = new EventEmitter(); + (spawn as vi.Mock).mockReturnValue(spawnEmitter); + }); + + afterEach(() => { + delete process.env.GEMINI_CLI_DISABLE_AUTOUPDATER; + }); + + it('should not start the update process when running from git', async () => { + mockedIsGitRepository.mockResolvedValue(true); + const info: UpdateObject = { + update: { + name: '@google/gemini-cli', + latest: '1.1.0', + current: '1.0.0', + }, + message: 'Gemini CLI update available!', + }; + mockedCheckForUpdates.mockResolvedValue(info); + const { spawn } = await import('node:child_process'); + + const { unmount } = render( + <App + config={mockConfig as unknown as ServerConfig} + settings={mockSettings} + version={mockVersion} + />, + ); + currentUnmount = unmount; + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(spawn).not.toHaveBeenCalled(); + }); + + it('should show a success message when update succeeds', async () => { + mockedIsGitRepository.mockResolvedValue(false); + const info: UpdateObject = { + update: { + name: '@google/gemini-cli', + latest: '1.1.0', + current: '1.0.0', + }, + message: 'Update available', + }; + mockedCheckForUpdates.mockResolvedValue(info); + + const { lastFrame, unmount } = render( + <App + config={mockConfig as unknown as ServerConfig} + settings={mockSettings} + version={mockVersion} + />, + ); + currentUnmount = unmount; + + updateEventEmitter.emit('update-success', info); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(lastFrame()).toContain( + 'Update successful! The new version will be used on your next run.', + ); + }); + + it('should show an error message when update fails', async () => { + mockedIsGitRepository.mockResolvedValue(false); + const info: UpdateObject = { + update: { + name: '@google/gemini-cli', + latest: '1.1.0', + current: '1.0.0', + }, + message: 'Update available', + }; + mockedCheckForUpdates.mockResolvedValue(info); + + const { lastFrame, unmount } = render( + <App + config={mockConfig as unknown as ServerConfig} + settings={mockSettings} + version={mockVersion} + />, + ); + currentUnmount = unmount; + + updateEventEmitter.emit('update-failed', info); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(lastFrame()).toContain( + 'Automatic update failed. Please try updating manually', + ); + }); + + it('should show an error message when spawn fails', async () => { + mockedIsGitRepository.mockResolvedValue(false); + const info: UpdateObject = { + update: { + name: '@google/gemini-cli', + latest: '1.1.0', + current: '1.0.0', + }, + message: 'Update available', + }; + mockedCheckForUpdates.mockResolvedValue(info); + + const { lastFrame, unmount } = render( + <App + config={mockConfig as unknown as ServerConfig} + settings={mockSettings} + version={mockVersion} + />, + ); + currentUnmount = unmount; + + // We are testing the App's reaction to an `update-failed` event, + // which is what should be emitted when a spawn error occurs elsewhere. + updateEventEmitter.emit('update-failed', info); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(lastFrame()).toContain( + 'Automatic update failed. Please try updating manually', + ); + }); + + it('should not auto-update if GEMINI_CLI_DISABLE_AUTOUPDATER is true', async () => { + mockedIsGitRepository.mockResolvedValue(false); + process.env.GEMINI_CLI_DISABLE_AUTOUPDATER = 'true'; + const info: UpdateObject = { + update: { + name: '@google/gemini-cli', + latest: '1.1.0', + current: '1.0.0', + }, + message: 'Update available', + }; + mockedCheckForUpdates.mockResolvedValue(info); + const { spawn } = await import('node:child_process'); + + const { unmount } = render( + <App + config={mockConfig as unknown as ServerConfig} + settings={mockSettings} + version={mockVersion} + />, + ); + currentUnmount = unmount; + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(spawn).not.toHaveBeenCalled(); + }); + }); + it('should display active file when available', async () => { vi.mocked(ideContext.getIdeContext).mockReturnValue({ workspaceState: { diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 1ee8e8a8..7ac6936c 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -83,11 +83,12 @@ import { isGenericQuotaExceededError, UserTierId, } from '@google/gemini-cli-core'; -import { checkForUpdates } from './utils/updateCheck.js'; +import { UpdateObject } from './utils/updateCheck.js'; import ansiEscapes from 'ansi-escapes'; import { OverflowProvider } from './contexts/OverflowContext.js'; import { ShowMoreLines } from './components/ShowMoreLines.js'; import { PrivacyNotice } from './privacy/PrivacyNotice.js'; +import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from '../utils/events.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -110,15 +111,16 @@ export const AppWrapper = (props: AppProps) => ( const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { const isFocused = useFocus(); useBracketedPaste(); - const [updateMessage, setUpdateMessage] = useState<string | null>(null); + const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null); const { stdout } = useStdout(); const nightly = version.includes('nightly'); + const { history, addItem, clearItems, loadHistory } = useHistory(); useEffect(() => { - checkForUpdates().then(setUpdateMessage); - }, []); + const cleanup = setUpdateHandler(addItem, setUpdateInfo); + return cleanup; + }, [addItem]); - const { history, addItem, clearItems, loadHistory } = useHistory(); const { consoleMessages, handleNewMessage, @@ -757,9 +759,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { return ( <StreamingContext.Provider value={streamingState}> <Box flexDirection="column" width="90%"> - {/* Move UpdateNotification outside Static so it can re-render when updateMessage changes */} - {updateMessage && <UpdateNotification message={updateMessage} />} - {/* * The Static component is an Ink intrinsic in which there can only be 1 per application. * Because of this restriction we're hacking it slightly by having a 'header' item here to @@ -822,6 +821,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { {showHelp && <Help commands={slashCommands} />} <Box flexDirection="column" ref={mainControlsRef}> + {/* Move UpdateNotification to render update notification above input area */} + {updateInfo && <UpdateNotification message={updateInfo.message} />} {startupWarnings.length > 0 && ( <Box borderStyle="round" diff --git a/packages/cli/src/ui/utils/updateCheck.test.ts b/packages/cli/src/ui/utils/updateCheck.test.ts index 975c320d..4985afe8 100644 --- a/packages/cli/src/ui/utils/updateCheck.test.ts +++ b/packages/cli/src/ui/utils/updateCheck.test.ts @@ -50,7 +50,9 @@ describe('checkForUpdates', () => { name: 'test-package', version: '1.0.0', }); - updateNotifier.mockReturnValue({ update: null }); + updateNotifier.mockReturnValue({ + fetchInfo: vi.fn(async () => null), + }); const result = await checkForUpdates(); expect(result).toBeNull(); }); @@ -61,10 +63,12 @@ describe('checkForUpdates', () => { version: '1.0.0', }); updateNotifier.mockReturnValue({ - update: { current: '1.0.0', latest: '1.1.0' }, + fetchInfo: vi.fn(async () => ({ current: '1.0.0', latest: '1.1.0' })), }); + const result = await checkForUpdates(); - expect(result).toContain('1.0.0 → 1.1.0'); + expect(result?.message).toContain('1.0.0 → 1.1.0'); + expect(result?.update).toEqual({ current: '1.0.0', latest: '1.1.0' }); }); it('should return null if the latest version is the same as the current version', async () => { @@ -73,7 +77,7 @@ describe('checkForUpdates', () => { version: '1.0.0', }); updateNotifier.mockReturnValue({ - update: { current: '1.0.0', latest: '1.0.0' }, + fetchInfo: vi.fn(async () => ({ current: '1.0.0', latest: '1.0.0' })), }); const result = await checkForUpdates(); expect(result).toBeNull(); @@ -85,7 +89,7 @@ describe('checkForUpdates', () => { version: '1.1.0', }); updateNotifier.mockReturnValue({ - update: { current: '1.1.0', latest: '1.0.0' }, + fetchInfo: vi.fn(async () => ({ current: '1.0.0', latest: '0.09' })), }); const result = await checkForUpdates(); expect(result).toBeNull(); diff --git a/packages/cli/src/ui/utils/updateCheck.ts b/packages/cli/src/ui/utils/updateCheck.ts index 904a9890..b0a0de1b 100644 --- a/packages/cli/src/ui/utils/updateCheck.ts +++ b/packages/cli/src/ui/utils/updateCheck.ts @@ -4,11 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import updateNotifier from 'update-notifier'; +import updateNotifier, { UpdateInfo } from 'update-notifier'; import semver from 'semver'; import { getPackageJson } from '../../utils/package.js'; -export async function checkForUpdates(): Promise<string | null> { +export interface UpdateObject { + message: string; + update: UpdateInfo; +} + +export async function checkForUpdates(): Promise<UpdateObject | null> { try { // Skip update check when running from source (development mode) if (process.env.DEV === 'true') { @@ -30,11 +35,13 @@ export async function checkForUpdates(): Promise<string | null> { shouldNotifyInNpmScript: true, }); - if ( - notifier.update && - semver.gt(notifier.update.latest, notifier.update.current) - ) { - return `Gemini CLI update available! ${notifier.update.current} → ${notifier.update.latest}\nRun npm install -g ${packageJson.name} to update`; + const updateInfo = await notifier.fetchInfo(); + + if (updateInfo && semver.gt(updateInfo.latest, updateInfo.current)) { + return { + message: `Gemini CLI update available! ${updateInfo.current} → ${updateInfo.latest}`, + update: updateInfo, + }; } return null; |
