From bb1c48a87018e8486196279c9ddaad9f6bd13457 Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Tue, 17 Mar 2026 13:22:05 -0400 Subject: [PATCH] test(cli): add tests for builtin extension collisions and migration --- .../my-extension/gemini-extension.json | 1 + .../cli/src/config/extension-manager.test.ts | 94 +++++++++++++++++++ packages/cli/src/config/extension-manager.ts | 32 ++++--- 3 files changed, 113 insertions(+), 14 deletions(-) create mode 100644 packages/cli/extensions-dir/my-extension/gemini-extension.json diff --git a/packages/cli/extensions-dir/my-extension/gemini-extension.json b/packages/cli/extensions-dir/my-extension/gemini-extension.json new file mode 100644 index 0000000000..2c50f16fff --- /dev/null +++ b/packages/cli/extensions-dir/my-extension/gemini-extension.json @@ -0,0 +1 @@ +{ "name": "my-extension", "version": "1.0.0", "mcpServers": {} } diff --git a/packages/cli/src/config/extension-manager.test.ts b/packages/cli/src/config/extension-manager.test.ts index 67636d922e..899fca4c24 100644 --- a/packages/cli/src/config/extension-manager.test.ts +++ b/packages/cli/src/config/extension-manager.test.ts @@ -22,6 +22,7 @@ import { getRealPath, type CustomTheme, IntegrityDataStatus, + coreEvents, } from '@google/gemini-cli-core'; const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); @@ -697,4 +698,97 @@ describe('ExtensionManager', () => { ); }); }); + + describe('Builtin Extensions', () => { + let builtinExtensionsDir: string; + + beforeEach(() => { + builtinExtensionsDir = fs.mkdtempSync( + path.join(tempHomeDir, 'gemini-cli-test-builtin-'), + ); + }); + + it('should warn and prefer builtin when names collide', async () => { + // 1. Create a manual extension named 'test-ext' + createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-ext', + version: '1.0.0-manual', + }); + + // 2. Create a builtin extension named 'test-ext' + createExtension({ + extensionsDir: builtinExtensionsDir, + name: 'test-ext', + version: '1.0.0-builtin', + }); + + const emitSpy = vi.spyOn(coreEvents, 'emitFeedback'); + + // Create a FRESH manager to ensure loadExtensions actually runs + const manager = new ExtensionManager({ + settings: createTestMergedSettings(), + workspaceDir: tempWorkspaceDir, + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: null, + }); + + const extensions = await manager.loadExtensions(builtinExtensionsDir); + + // Verify builtin took precedence + const testExt = extensions.find((e) => e.name === 'test-ext'); + expect(testExt).toBeDefined(); + expect(testExt?.version).toBe('1.0.0-builtin'); + + // Verify warning was shown + expect(emitSpy).toHaveBeenCalledWith( + 'warning', + expect.stringContaining('Extension "test-ext" is now built-in'), + ); + }); + + it('should warn when legacy conductor is found while loading sdd builtin', async () => { + // 1. Create a manual extension named 'conductor' + createExtension({ + extensionsDir: userExtensionsDir, + name: 'conductor', + version: '0.1.0', + }); + + // 2. Create a builtin extension named 'sdd' + createExtension({ + extensionsDir: builtinExtensionsDir, + name: 'sdd', + version: '1.0.0', + }); + + const emitSpy = vi.spyOn(coreEvents, 'emitFeedback'); + + // Create a FRESH manager + const manager = new ExtensionManager({ + settings: createTestMergedSettings(), + workspaceDir: tempWorkspaceDir, + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: null, + }); + + const extensions = await manager.loadExtensions(builtinExtensionsDir); + + // Verify both are loaded (logic currently loads both but warns) + expect(extensions.find((e) => e.name === 'conductor')).toBeDefined(); + expect(extensions.find((e) => e.name === 'sdd')).toBeDefined(); + + // Verify warning was shown + expect(emitSpy).toHaveBeenCalledWith( + 'warning', + expect.stringContaining( + 'The "conductor" extension has been renamed to "sdd"', + ), + ); + expect(emitSpy).toHaveBeenCalledWith( + 'warning', + expect.stringContaining('.gemini/specs/'), + ); + }); + }); }); diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 2424c3eea3..9a8c8299b4 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -604,7 +604,7 @@ Would you like to attempt to install via "git clone" instead?`, /** * Loads all installed extensions, should only be called once. */ - async loadExtensions(): Promise { + async loadExtensions(builtinDir?: string): Promise { if (this.loadedExtensions) { throw new Error('Extensions already loaded, only load extensions once.'); } @@ -637,26 +637,30 @@ Would you like to attempt to install via "git clone" instead?`, (ext): ext is GeminiCLIExtension => ext !== null, ); - let builtinExtensionsDir = path.join( - path.dirname(fileURLToPath(import.meta.url)), - 'extensions', - 'builtin', - ); - if (!fs.existsSync(builtinExtensionsDir)) { + let builtinExtensionsDir = builtinDir; + if (!builtinExtensionsDir) { builtinExtensionsDir = path.join( path.dirname(fileURLToPath(import.meta.url)), - '..', - '..', - '..', - '..', - 'core', - 'src', 'extensions', 'builtin', ); + if (!fs.existsSync(builtinExtensionsDir)) { + builtinExtensionsDir = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', + '..', + '..', + 'core', + 'src', + 'extensions', + 'builtin', + ); + } } - if (!process.env['VITEST'] && fs.existsSync(builtinExtensionsDir)) { + const loadBuiltins = builtinDir || !process.env['VITEST']; + if (loadBuiltins && fs.existsSync(builtinExtensionsDir)) { const builtinSubdirs = await fs.promises.readdir(builtinExtensionsDir); const builtinPromises = builtinSubdirs.map((subdir) => {