2025-06-10 15:48:39 -07:00
/ * *
* @license
* Copyright 2025 Google LLC
* SPDX - License - Identifier : Apache - 2.0
* /
import { vi } from 'vitest' ;
2025-08-25 22:11:27 +02:00
import * as fs from 'node:fs' ;
import * as os from 'node:os' ;
import * as path from 'node:path' ;
2025-06-10 15:48:39 -07:00
import {
EXTENSIONS_CONFIG_FILENAME ,
2025-08-25 17:02:10 +00:00
INSTALL_METADATA_FILENAME ,
2025-07-18 20:45:00 +02:00
annotateActiveExtensions ,
2025-08-26 14:36:55 +00:00
disableExtension ,
2025-08-26 15:49:00 +00:00
enableExtension ,
2025-08-25 17:02:10 +00:00
installExtension ,
2025-08-27 00:43:02 +00:00
loadExtension ,
2025-09-25 10:57:59 -07:00
loadExtensionConfig ,
2025-06-10 15:48:39 -07:00
loadExtensions ,
2025-08-27 00:43:02 +00:00
performWorkspaceExtensionMigration ,
2025-09-25 10:57:59 -07:00
requestConsentNonInteractive ,
2025-08-25 17:40:15 +00:00
uninstallExtension ,
2025-08-28 16:46:45 -04:00
type Extension ,
2025-06-10 15:48:39 -07:00
} from './extension.js' ;
2025-08-26 15:49:00 +00:00
import {
2025-08-28 16:46:45 -04:00
GEMINI_DIR ,
2025-08-26 15:49:00 +00:00
type GeminiCLIExtension ,
2025-09-12 13:38:54 -04:00
ExtensionUninstallEvent ,
2025-09-23 14:37:35 -04:00
ExtensionDisableEvent ,
2025-09-22 12:55:43 -04:00
ExtensionEnableEvent ,
2025-08-26 15:49:00 +00:00
} from '@google/gemini-cli-core' ;
2025-08-25 22:11:27 +02:00
import { execSync } from 'node:child_process' ;
2025-09-16 15:51:46 -04:00
import { SettingScope } from './settings.js' ;
2025-08-28 16:46:45 -04:00
import { isWorkspaceTrusted } from './trustedFolders.js' ;
2025-09-18 14:49:47 -07:00
import { createExtension } from '../test-utils/createExtension.js' ;
2025-09-16 15:51:46 -04:00
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js' ;
2025-08-25 17:02:10 +00:00
2025-09-10 09:35:48 -07:00
const mockGit = {
clone : vi.fn ( ) ,
getRemotes : vi.fn ( ) ,
fetch : vi.fn ( ) ,
checkout : vi.fn ( ) ,
listRemote : vi.fn ( ) ,
revparse : vi.fn ( ) ,
// Not a part of the actual API, but we need to use this to do the correct
// file system interactions.
path : vi.fn ( ) ,
} ;
2025-08-25 17:02:10 +00:00
vi . mock ( 'simple-git' , ( ) = > ( {
2025-09-10 09:35:48 -07:00
simpleGit : vi.fn ( ( path : string ) = > {
mockGit . path . mockReturnValue ( path ) ;
return mockGit ;
} ) ,
2025-08-25 17:02:10 +00:00
} ) ) ;
2025-06-10 15:48:39 -07:00
vi . mock ( 'os' , async ( importOriginal ) = > {
2025-09-18 14:49:47 -07:00
const mockedOs = await importOriginal < typeof os > ( ) ;
2025-06-10 15:48:39 -07:00
return {
2025-09-18 14:49:47 -07:00
. . . mockedOs ,
2025-06-10 15:48:39 -07:00
homedir : vi.fn ( ) ,
} ;
} ) ;
2025-08-28 16:46:45 -04:00
vi . mock ( './trustedFolders.js' , async ( importOriginal ) = > {
const actual = await importOriginal < typeof import ( './trustedFolders.js' ) > ( ) ;
return {
. . . actual ,
isWorkspaceTrusted : vi.fn ( ) ,
} ;
} ) ;
2025-09-22 12:55:43 -04:00
const mockLogExtensionEnable = vi . hoisted ( ( ) = > vi . fn ( ) ) ;
const mockLogExtensionInstallEvent = vi . hoisted ( ( ) = > vi . fn ( ) ) ;
const mockLogExtensionUninstall = vi . hoisted ( ( ) = > vi . fn ( ) ) ;
2025-09-23 14:37:35 -04:00
const mockLogExtensionDisable = vi . hoisted ( ( ) = > vi . fn ( ) ) ;
2025-09-09 12:12:56 -04:00
vi . mock ( '@google/gemini-cli-core' , async ( importOriginal ) = > {
const actual =
await importOriginal < typeof import ( '@google/gemini-cli-core' ) > ( ) ;
return {
. . . actual ,
2025-09-22 12:55:43 -04:00
logExtensionEnable : mockLogExtensionEnable ,
logExtensionInstallEvent : mockLogExtensionInstallEvent ,
logExtensionUninstall : mockLogExtensionUninstall ,
2025-09-23 14:37:35 -04:00
logExtensionDisable : mockLogExtensionDisable ,
2025-09-22 12:55:43 -04:00
ExtensionEnableEvent : vi.fn ( ) ,
2025-09-09 12:12:56 -04:00
ExtensionInstallEvent : vi.fn ( ) ,
2025-09-12 13:38:54 -04:00
ExtensionUninstallEvent : vi.fn ( ) ,
2025-09-23 14:37:35 -04:00
ExtensionDisableEvent : vi.fn ( ) ,
2025-09-09 12:12:56 -04:00
} ;
} ) ;
2025-08-25 17:02:10 +00:00
vi . mock ( 'child_process' , async ( importOriginal ) = > {
const actual = await importOriginal < typeof import ( 'child_process' ) > ( ) ;
return {
. . . actual ,
execSync : vi.fn ( ) ,
} ;
} ) ;
2025-08-27 00:43:02 +00:00
2025-09-12 10:53:30 -04:00
const mockQuestion = vi . hoisted ( ( ) = > vi . fn ( ) ) ;
const mockClose = vi . hoisted ( ( ) = > vi . fn ( ) ) ;
vi . mock ( 'node:readline' , ( ) = > ( {
createInterface : vi.fn ( ( ) = > ( {
question : mockQuestion ,
close : mockClose ,
} ) ) ,
} ) ) ;
2025-08-28 16:46:45 -04:00
const EXTENSIONS_DIRECTORY_NAME = path . join ( GEMINI_DIR , 'extensions' ) ;
2025-08-20 10:55:47 +09:00
2025-09-16 15:51:46 -04:00
describe ( 'extension tests' , ( ) = > {
2025-06-10 15:48:39 -07:00
let tempHomeDir : string ;
2025-09-16 15:51:46 -04:00
let tempWorkspaceDir : string ;
2025-09-05 11:44:41 -07:00
let userExtensionsDir : string ;
2025-06-10 15:48:39 -07:00
beforeEach ( ( ) = > {
tempHomeDir = fs . mkdtempSync (
path . join ( os . tmpdir ( ) , 'gemini-cli-test-home-' ) ,
) ;
2025-09-16 15:51:46 -04:00
tempWorkspaceDir = fs . mkdtempSync (
path . join ( tempHomeDir , 'gemini-cli-test-workspace-' ) ,
) ;
2025-09-05 11:44:41 -07:00
userExtensionsDir = path . join ( tempHomeDir , EXTENSIONS_DIRECTORY_NAME ) ;
fs . mkdirSync ( userExtensionsDir , { recursive : true } ) ;
2025-09-16 15:51:46 -04:00
vi . mocked ( os . homedir ) . mockReturnValue ( tempHomeDir ) ;
vi . mocked ( isWorkspaceTrusted ) . mockReturnValue ( true ) ;
vi . spyOn ( process , 'cwd' ) . mockReturnValue ( tempWorkspaceDir ) ;
mockQuestion . mockImplementation ( ( _query , callback ) = > callback ( 'y' ) ) ;
vi . mocked ( execSync ) . mockClear ( ) ;
Object . values ( mockGit ) . forEach ( ( fn ) = > fn . mockReset ( ) ) ;
2025-06-10 15:48:39 -07:00
} ) ;
afterEach ( ( ) = > {
fs . rmSync ( tempHomeDir , { recursive : true , force : true } ) ;
2025-09-16 15:51:46 -04:00
fs . rmSync ( tempWorkspaceDir , { recursive : true , force : true } ) ;
2025-08-26 02:13:16 +00:00
vi . restoreAllMocks ( ) ;
2025-09-16 15:51:46 -04:00
mockQuestion . mockClear ( ) ;
mockClose . mockClear ( ) ;
2025-06-10 15:48:39 -07:00
} ) ;
2025-09-16 15:51:46 -04:00
describe ( 'loadExtensions' , ( ) = > {
it ( 'should include extension path in loaded extension' , ( ) = > {
const extensionDir = path . join ( userExtensionsDir , 'test-extension' ) ;
fs . mkdirSync ( extensionDir , { recursive : true } ) ;
2025-07-28 18:40:47 -07:00
2025-09-16 15:51:46 -04:00
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'test-extension' ,
version : '1.0.0' ,
} ) ;
2025-07-28 18:40:47 -07:00
2025-09-16 15:51:46 -04:00
const extensions = loadExtensions ( ) ;
expect ( extensions ) . toHaveLength ( 1 ) ;
expect ( extensions [ 0 ] . path ) . toBe ( extensionDir ) ;
expect ( extensions [ 0 ] . config . name ) . toBe ( 'test-extension' ) ;
2025-08-28 16:46:45 -04:00
} ) ;
2025-06-10 15:48:39 -07:00
2025-09-16 15:51:46 -04:00
it ( 'should load context file path when GEMINI.md is present' , ( ) = > {
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'ext1' ,
version : '1.0.0' ,
addContextFile : true ,
} ) ;
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'ext2' ,
version : '2.0.0' ,
} ) ;
2025-06-11 13:34:35 -07:00
2025-09-16 15:51:46 -04:00
const extensions = loadExtensions ( ) ;
2025-06-11 13:34:35 -07:00
2025-09-16 15:51:46 -04:00
expect ( extensions ) . toHaveLength ( 2 ) ;
const ext1 = extensions . find ( ( e ) = > e . config . name === 'ext1' ) ;
const ext2 = extensions . find ( ( e ) = > e . config . name === 'ext2' ) ;
expect ( ext1 ? . contextFiles ) . toEqual ( [
path . join ( userExtensionsDir , 'ext1' , 'GEMINI.md' ) ,
] ) ;
expect ( ext2 ? . contextFiles ) . toEqual ( [ ] ) ;
2025-08-28 16:46:45 -04:00
} ) ;
2025-06-10 15:48:39 -07:00
2025-09-16 15:51:46 -04:00
it ( 'should load context file path from the extension config' , ( ) = > {
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'ext1' ,
version : '1.0.0' ,
addContextFile : false ,
contextFileName : 'my-context-file.md' ,
} ) ;
2025-06-10 15:48:39 -07:00
2025-09-16 15:51:46 -04:00
const extensions = loadExtensions ( ) ;
2025-08-26 02:13:16 +00:00
2025-09-16 15:51:46 -04:00
expect ( extensions ) . toHaveLength ( 1 ) ;
const ext1 = extensions . find ( ( e ) = > e . config . name === 'ext1' ) ;
expect ( ext1 ? . contextFiles ) . toEqual ( [
path . join ( userExtensionsDir , 'ext1' , 'my-context-file.md' ) ,
] ) ;
2025-08-28 16:46:45 -04:00
} ) ;
2025-08-26 14:36:55 +00:00
2025-09-16 15:51:46 -04:00
it ( 'should filter out disabled extensions' , ( ) = > {
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'disabled-extension' ,
version : '1.0.0' ,
} ) ;
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'enabled-extension' ,
version : '2.0.0' ,
} ) ;
disableExtension (
'disabled-extension' ,
SettingScope . User ,
tempWorkspaceDir ,
) ;
const extensions = loadExtensions ( ) ;
const activeExtensions = annotateActiveExtensions (
extensions ,
[ ] ,
tempWorkspaceDir ,
) . filter ( ( e ) = > e . isActive ) ;
expect ( activeExtensions ) . toHaveLength ( 1 ) ;
expect ( activeExtensions [ 0 ] . name ) . toBe ( 'enabled-extension' ) ;
2025-08-28 16:46:45 -04:00
} ) ;
2025-08-26 02:13:16 +00:00
2025-09-16 15:51:46 -04:00
it ( 'should hydrate variables' , ( ) = > {
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'test-extension' ,
version : '1.0.0' ,
addContextFile : false ,
contextFileName : undefined ,
mcpServers : {
'test-server' : {
cwd : '${extensionPath}${/}server' ,
} ,
} ,
} ) ;
2025-08-29 19:53:39 +02:00
2025-09-16 15:51:46 -04:00
const extensions = loadExtensions ( ) ;
expect ( extensions ) . toHaveLength ( 1 ) ;
const loadedConfig = extensions [ 0 ] . config ;
const expectedCwd = path . join (
userExtensionsDir ,
'test-extension' ,
'server' ,
) ;
expect ( loadedConfig . mcpServers ? . [ 'test-server' ] . cwd ) . toBe ( expectedCwd ) ;
2025-09-02 10:15:42 -07:00
} ) ;
2025-09-16 15:51:46 -04:00
it ( 'should load a linked extension correctly' , async ( ) = > {
const sourceExtDir = createExtension ( {
extensionsDir : tempWorkspaceDir ,
name : 'my-linked-extension' ,
version : '1.0.0' ,
contextFileName : 'context.md' ,
} ) ;
fs . writeFileSync ( path . join ( sourceExtDir , 'context.md' ) , 'linked context' ) ;
2025-09-12 10:53:30 -04:00
2025-09-25 10:57:59 -07:00
const extensionName = await installExtension (
{
source : sourceExtDir ,
type : 'link' ,
} ,
async ( _ ) = > true ,
) ;
2025-09-16 15:51:46 -04:00
expect ( extensionName ) . toEqual ( 'my-linked-extension' ) ;
const extensions = loadExtensions ( ) ;
expect ( extensions ) . toHaveLength ( 1 ) ;
2025-09-02 10:15:42 -07:00
2025-09-16 15:51:46 -04:00
const linkedExt = extensions [ 0 ] ;
expect ( linkedExt . config . name ) . toBe ( 'my-linked-extension' ) ;
2025-09-02 10:15:42 -07:00
2025-09-16 15:51:46 -04:00
expect ( linkedExt . path ) . toBe ( sourceExtDir ) ;
expect ( linkedExt . installMetadata ) . toEqual ( {
source : sourceExtDir ,
type : 'link' ,
} ) ;
expect ( linkedExt . contextFiles ) . toEqual ( [
path . join ( sourceExtDir , 'context.md' ) ,
] ) ;
2025-09-02 10:15:42 -07:00
} ) ;
2025-09-16 15:51:46 -04:00
it ( 'should resolve environment variables in extension configuration' , ( ) = > {
process . env . TEST_API_KEY = 'test-api-key-123' ;
process . env . TEST_DB_URL = 'postgresql://localhost:5432/testdb' ;
try {
const userExtensionsDir = path . join (
tempHomeDir ,
EXTENSIONS_DIRECTORY_NAME ,
) ;
fs . mkdirSync ( userExtensionsDir , { recursive : true } ) ;
const extDir = path . join ( userExtensionsDir , 'test-extension' ) ;
fs . mkdirSync ( extDir ) ;
// Write config to a separate file for clarity and good practices
const configPath = path . join ( extDir , EXTENSIONS_CONFIG_FILENAME ) ;
const extensionConfig = {
name : 'test-extension' ,
version : '1.0.0' ,
mcpServers : {
'test-server' : {
command : 'node' ,
args : [ 'server.js' ] ,
env : {
API_KEY : '$TEST_API_KEY' ,
DATABASE_URL : '${TEST_DB_URL}' ,
STATIC_VALUE : 'no-substitution' ,
} ,
} ,
} ,
} ;
fs . writeFileSync ( configPath , JSON . stringify ( extensionConfig ) ) ;
const extensions = loadExtensions ( ) ;
expect ( extensions ) . toHaveLength ( 1 ) ;
const extension = extensions [ 0 ] ;
expect ( extension . config . name ) . toBe ( 'test-extension' ) ;
expect ( extension . config . mcpServers ) . toBeDefined ( ) ;
const serverConfig = extension . config . mcpServers ? . [ 'test-server' ] ;
expect ( serverConfig ) . toBeDefined ( ) ;
expect ( serverConfig ? . env ) . toBeDefined ( ) ;
expect ( serverConfig ? . env ? . API_KEY ) . toBe ( 'test-api-key-123' ) ;
expect ( serverConfig ? . env ? . DATABASE_URL ) . toBe (
'postgresql://localhost:5432/testdb' ,
) ;
expect ( serverConfig ? . env ? . STATIC_VALUE ) . toBe ( 'no-substitution' ) ;
} finally {
delete process . env . TEST_API_KEY ;
delete process . env . TEST_DB_URL ;
}
} ) ;
2025-08-29 19:53:39 +02:00
2025-09-16 15:51:46 -04:00
it ( 'should handle missing environment variables gracefully' , ( ) = > {
2025-09-05 11:44:41 -07:00
const userExtensionsDir = path . join (
tempHomeDir ,
2025-08-29 19:53:39 +02:00
EXTENSIONS_DIRECTORY_NAME ,
) ;
2025-09-05 11:44:41 -07:00
fs . mkdirSync ( userExtensionsDir , { recursive : true } ) ;
2025-08-29 19:53:39 +02:00
2025-09-05 11:44:41 -07:00
const extDir = path . join ( userExtensionsDir , 'test-extension' ) ;
2025-08-29 19:53:39 +02:00
fs . mkdirSync ( extDir ) ;
const extensionConfig = {
name : 'test-extension' ,
version : '1.0.0' ,
mcpServers : {
'test-server' : {
command : 'node' ,
args : [ 'server.js' ] ,
env : {
2025-09-16 15:51:46 -04:00
MISSING_VAR : '$UNDEFINED_ENV_VAR' ,
MISSING_VAR_BRACES : '${ALSO_UNDEFINED}' ,
2025-08-29 19:53:39 +02:00
} ,
} ,
} ,
} ;
2025-09-16 15:51:46 -04:00
fs . writeFileSync (
path . join ( extDir , EXTENSIONS_CONFIG_FILENAME ) ,
JSON . stringify ( extensionConfig ) ,
) ;
2025-08-29 19:53:39 +02:00
2025-09-05 11:44:41 -07:00
const extensions = loadExtensions ( ) ;
2025-08-29 19:53:39 +02:00
expect ( extensions ) . toHaveLength ( 1 ) ;
const extension = extensions [ 0 ] ;
2025-09-16 15:51:46 -04:00
const serverConfig = extension . config . mcpServers ! [ 'test-server' ] ;
expect ( serverConfig . env ) . toBeDefined ( ) ;
expect ( serverConfig . env ! . MISSING_VAR ) . toBe ( '$UNDEFINED_ENV_VAR' ) ;
expect ( serverConfig . env ! . MISSING_VAR_BRACES ) . toBe ( '${ALSO_UNDEFINED}' ) ;
} ) ;
2025-09-19 21:15:40 -04:00
2025-09-22 09:34:52 +00:00
it ( 'should skip extensions with invalid JSON and log a warning' , ( ) = > {
const consoleSpy = vi
. spyOn ( console , 'error' )
. mockImplementation ( ( ) = > { } ) ;
// Good extension
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'good-ext' ,
version : '1.0.0' ,
} ) ;
// Bad extension
const badExtDir = path . join ( userExtensionsDir , 'bad-ext' ) ;
fs . mkdirSync ( badExtDir ) ;
const badConfigPath = path . join ( badExtDir , EXTENSIONS_CONFIG_FILENAME ) ;
fs . writeFileSync ( badConfigPath , '{ "name": "bad-ext"' ) ; // Malformed
const extensions = loadExtensions ( ) ;
expect ( extensions ) . toHaveLength ( 1 ) ;
expect ( extensions [ 0 ] . config . name ) . toBe ( 'good-ext' ) ;
expect ( consoleSpy ) . toHaveBeenCalledOnce ( ) ;
expect ( consoleSpy ) . toHaveBeenCalledWith (
expect . stringContaining (
` Warning: Skipping extension in ${ badExtDir } : Failed to load extension config from ${ badConfigPath } ` ,
) ,
) ;
consoleSpy . mockRestore ( ) ;
} ) ;
it ( 'should skip extensions with missing name and log a warning' , ( ) = > {
const consoleSpy = vi
. spyOn ( console , 'error' )
. mockImplementation ( ( ) = > { } ) ;
// Good extension
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'good-ext' ,
version : '1.0.0' ,
} ) ;
// Bad extension
const badExtDir = path . join ( userExtensionsDir , 'bad-ext-no-name' ) ;
fs . mkdirSync ( badExtDir ) ;
const badConfigPath = path . join ( badExtDir , EXTENSIONS_CONFIG_FILENAME ) ;
fs . writeFileSync ( badConfigPath , JSON . stringify ( { version : '1.0.0' } ) ) ;
const extensions = loadExtensions ( ) ;
expect ( extensions ) . toHaveLength ( 1 ) ;
expect ( extensions [ 0 ] . config . name ) . toBe ( 'good-ext' ) ;
expect ( consoleSpy ) . toHaveBeenCalledOnce ( ) ;
expect ( consoleSpy ) . toHaveBeenCalledWith (
expect . stringContaining (
` Warning: Skipping extension in ${ badExtDir } : Failed to load extension config from ${ badConfigPath } : Invalid configuration in ${ badConfigPath } : missing "name" ` ,
) ,
) ;
consoleSpy . mockRestore ( ) ;
} ) ;
2025-09-19 21:15:40 -04:00
it ( 'should filter trust out of mcp servers' , ( ) = > {
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'test-extension' ,
version : '1.0.0' ,
mcpServers : {
'test-server' : {
command : 'node' ,
args : [ 'server.js' ] ,
trust : true ,
} ,
} ,
} ) ;
const extensions = loadExtensions ( ) ;
expect ( extensions ) . toHaveLength ( 1 ) ;
const loadedConfig = extensions [ 0 ] . config ;
expect ( loadedConfig . mcpServers ? . [ 'test-server' ] . trust ) . toBeUndefined ( ) ;
} ) ;
2025-09-25 14:05:49 -04:00
it ( 'should throw an error for invalid extension names' , ( ) = > {
const consoleSpy = vi
. spyOn ( console , 'error' )
. mockImplementation ( ( ) = > { } ) ;
const badExtDir = createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'bad_name' ,
version : '1.0.0' ,
} ) ;
const extension = loadExtension ( {
extensionDir : badExtDir ,
workspaceDir : tempWorkspaceDir ,
} ) ;
expect ( extension ) . toBeNull ( ) ;
expect ( consoleSpy ) . toHaveBeenCalledWith (
expect . stringContaining ( 'Invalid extension name: "bad_name"' ) ,
) ;
consoleSpy . mockRestore ( ) ;
} ) ;
2025-08-29 19:53:39 +02:00
} ) ;
2025-09-16 15:51:46 -04:00
describe ( 'annotateActiveExtensions' , ( ) = > {
const extensions : Extension [ ] = [
{
path : '/path/to/ext1' ,
config : { name : 'ext1' , version : '1.0.0' } ,
contextFiles : [ ] ,
2025-08-29 19:53:39 +02:00
} ,
2025-09-16 15:51:46 -04:00
{
path : '/path/to/ext2' ,
config : { name : 'ext2' , version : '1.0.0' } ,
contextFiles : [ ] ,
} ,
{
path : '/path/to/ext3' ,
config : { name : 'ext3' , version : '1.0.0' } ,
contextFiles : [ ] ,
} ,
] ;
2025-08-29 19:53:39 +02:00
2025-09-16 15:51:46 -04:00
it ( 'should mark all extensions as active if no enabled extensions are provided' , ( ) = > {
const activeExtensions = annotateActiveExtensions (
extensions ,
[ ] ,
'/path/to/workspace' ,
) ;
expect ( activeExtensions ) . toHaveLength ( 3 ) ;
expect ( activeExtensions . every ( ( e ) = > e . isActive ) ) . toBe ( true ) ;
} ) ;
2025-08-29 19:53:39 +02:00
2025-09-16 15:51:46 -04:00
it ( 'should mark only the enabled extensions as active' , ( ) = > {
const activeExtensions = annotateActiveExtensions (
extensions ,
[ 'ext1' , 'ext3' ] ,
'/path/to/workspace' ,
) ;
expect ( activeExtensions ) . toHaveLength ( 3 ) ;
expect ( activeExtensions . find ( ( e ) = > e . name === 'ext1' ) ? . isActive ) . toBe (
true ,
) ;
expect ( activeExtensions . find ( ( e ) = > e . name === 'ext2' ) ? . isActive ) . toBe (
false ,
) ;
expect ( activeExtensions . find ( ( e ) = > e . name === 'ext3' ) ? . isActive ) . toBe (
true ,
) ;
} ) ;
2025-06-10 15:48:39 -07:00
2025-09-16 15:51:46 -04:00
it ( 'should mark all extensions as inactive when "none" is provided' , ( ) = > {
const activeExtensions = annotateActiveExtensions (
extensions ,
[ 'none' ] ,
'/path/to/workspace' ,
) ;
expect ( activeExtensions ) . toHaveLength ( 3 ) ;
expect ( activeExtensions . every ( ( e ) = > ! e . isActive ) ) . toBe ( true ) ;
} ) ;
2025-07-08 12:57:34 -04:00
2025-09-16 15:51:46 -04:00
it ( 'should handle case-insensitivity' , ( ) = > {
const activeExtensions = annotateActiveExtensions (
extensions ,
[ 'EXT1' ] ,
'/path/to/workspace' ,
) ;
expect ( activeExtensions . find ( ( e ) = > e . name === 'ext1' ) ? . isActive ) . toBe (
true ,
) ;
} ) ;
2025-07-08 12:57:34 -04:00
2025-09-16 15:51:46 -04:00
it ( 'should log an error for unknown extensions' , ( ) = > {
const consoleSpy = vi
. spyOn ( console , 'error' )
. mockImplementation ( ( ) = > { } ) ;
annotateActiveExtensions ( extensions , [ 'ext4' ] , '/path/to/workspace' ) ;
expect ( consoleSpy ) . toHaveBeenCalledWith ( 'Extension not found: ext4' ) ;
consoleSpy . mockRestore ( ) ;
} ) ;
2025-09-18 14:49:47 -07:00
describe ( 'autoUpdate' , ( ) = > {
it ( 'should be false if autoUpdate is not set in install metadata' , ( ) = > {
const activeExtensions = annotateActiveExtensions (
extensions ,
[ ] ,
tempHomeDir ,
) ;
expect (
activeExtensions . every (
( e ) = > e . installMetadata ? . autoUpdate === false ,
) ,
) . toBe ( false ) ;
} ) ;
it ( 'should be true if autoUpdate is true in install metadata' , ( ) = > {
const extensionsWithAutoUpdate : Extension [ ] = extensions . map ( ( e ) = > ( {
. . . e ,
installMetadata : {
. . . e . installMetadata ! ,
autoUpdate : true ,
} ,
} ) ) ;
const activeExtensions = annotateActiveExtensions (
extensionsWithAutoUpdate ,
[ ] ,
tempHomeDir ,
) ;
expect (
activeExtensions . every ( ( e ) = > e . installMetadata ? . autoUpdate === true ) ,
) . toBe ( true ) ;
} ) ;
it ( 'should respect the per-extension settings from install metadata' , ( ) = > {
const extensionsWithAutoUpdate : Extension [ ] = [
{
path : '/path/to/ext1' ,
config : { name : 'ext1' , version : '1.0.0' } ,
contextFiles : [ ] ,
installMetadata : {
source : 'test' ,
type : 'local' ,
autoUpdate : true ,
} ,
} ,
{
path : '/path/to/ext2' ,
config : { name : 'ext2' , version : '1.0.0' } ,
contextFiles : [ ] ,
installMetadata : {
source : 'test' ,
type : 'local' ,
autoUpdate : false ,
} ,
} ,
{
path : '/path/to/ext3' ,
config : { name : 'ext3' , version : '1.0.0' } ,
contextFiles : [ ] ,
} ,
] ;
const activeExtensions = annotateActiveExtensions (
extensionsWithAutoUpdate ,
[ ] ,
tempHomeDir ,
) ;
expect (
activeExtensions . find ( ( e ) = > e . name === 'ext1' ) ? . installMetadata
? . autoUpdate ,
) . toBe ( true ) ;
expect (
activeExtensions . find ( ( e ) = > e . name === 'ext2' ) ? . installMetadata
? . autoUpdate ,
) . toBe ( false ) ;
expect (
activeExtensions . find ( ( e ) = > e . name === 'ext3' ) ? . installMetadata
? . autoUpdate ,
) . toBe ( undefined ) ;
} ) ;
} ) ;
2025-07-08 12:57:34 -04:00
} ) ;
2025-09-16 15:51:46 -04:00
describe ( 'installExtension' , ( ) = > {
it ( 'should install an extension from a local path' , async ( ) = > {
const sourceExtDir = createExtension ( {
extensionsDir : tempHomeDir ,
name : 'my-local-extension' ,
version : '1.0.0' ,
} ) ;
const targetExtDir = path . join ( userExtensionsDir , 'my-local-extension' ) ;
const metadataPath = path . join ( targetExtDir , INSTALL_METADATA_FILENAME ) ;
2025-07-08 12:57:34 -04:00
2025-09-25 10:57:59 -07:00
await installExtension (
{ source : sourceExtDir , type : 'local' } ,
async ( _ ) = > true ,
) ;
2025-07-08 12:57:34 -04:00
2025-09-16 15:51:46 -04:00
expect ( fs . existsSync ( targetExtDir ) ) . toBe ( true ) ;
expect ( fs . existsSync ( metadataPath ) ) . toBe ( true ) ;
const metadata = JSON . parse ( fs . readFileSync ( metadataPath , 'utf-8' ) ) ;
expect ( metadata ) . toEqual ( {
source : sourceExtDir ,
type : 'local' ,
} ) ;
fs . rmSync ( targetExtDir , { recursive : true , force : true } ) ;
} ) ;
2025-07-08 12:57:34 -04:00
2025-09-16 15:51:46 -04:00
it ( 'should throw an error if the extension already exists' , async ( ) = > {
const sourceExtDir = createExtension ( {
extensionsDir : tempHomeDir ,
name : 'my-local-extension' ,
version : '1.0.0' ,
} ) ;
2025-09-25 10:57:59 -07:00
await installExtension (
{ source : sourceExtDir , type : 'local' } ,
async ( _ ) = > true ,
) ;
2025-09-16 15:51:46 -04:00
await expect (
2025-09-25 10:57:59 -07:00
installExtension (
{ source : sourceExtDir , type : 'local' } ,
async ( _ ) = > true ,
) ,
2025-09-16 15:51:46 -04:00
) . rejects . toThrow (
'Extension "my-local-extension" is already installed. Please uninstall it first.' ,
) ;
} ) ;
2025-08-25 17:02:10 +00:00
2025-09-16 15:51:46 -04:00
it ( 'should throw an error and cleanup if gemini-extension.json is missing' , async ( ) = > {
const sourceExtDir = path . join ( tempHomeDir , 'bad-extension' ) ;
fs . mkdirSync ( sourceExtDir , { recursive : true } ) ;
2025-09-22 09:34:52 +00:00
const configPath = path . join ( sourceExtDir , EXTENSIONS_CONFIG_FILENAME ) ;
2025-08-25 17:02:10 +00:00
2025-09-16 15:51:46 -04:00
await expect (
2025-09-25 10:57:59 -07:00
installExtension (
{ source : sourceExtDir , type : 'local' } ,
async ( _ ) = > true ,
) ,
2025-09-22 09:34:52 +00:00
) . rejects . toThrow ( ` Configuration file not found at ${ configPath } ` ) ;
2025-08-25 17:02:10 +00:00
2025-09-16 15:51:46 -04:00
const targetExtDir = path . join ( userExtensionsDir , 'bad-extension' ) ;
expect ( fs . existsSync ( targetExtDir ) ) . toBe ( false ) ;
2025-08-28 16:46:45 -04:00
} ) ;
2025-08-25 17:02:10 +00:00
2025-09-22 09:34:52 +00:00
it ( 'should throw an error for invalid JSON in gemini-extension.json' , async ( ) = > {
const sourceExtDir = path . join ( tempHomeDir , 'bad-json-ext' ) ;
fs . mkdirSync ( sourceExtDir , { recursive : true } ) ;
const configPath = path . join ( sourceExtDir , EXTENSIONS_CONFIG_FILENAME ) ;
fs . writeFileSync ( configPath , '{ "name": "bad-json", "version": "1.0.0"' ) ; // Malformed JSON
await expect (
2025-09-25 10:57:59 -07:00
installExtension (
{ source : sourceExtDir , type : 'local' } ,
async ( _ ) = > true ,
) ,
2025-09-22 09:34:52 +00:00
) . rejects . toThrow (
new RegExp (
` ^Failed to load extension config from ${ configPath . replace (
/\\/g ,
'\\\\' ,
) } ` ,
) ,
) ;
} ) ;
it ( 'should throw an error for missing name in gemini-extension.json' , async ( ) = > {
const sourceExtDir = createExtension ( {
extensionsDir : tempHomeDir ,
name : 'missing-name-ext' ,
version : '1.0.0' ,
} ) ;
const configPath = path . join ( sourceExtDir , EXTENSIONS_CONFIG_FILENAME ) ;
// Overwrite with invalid config
fs . writeFileSync ( configPath , JSON . stringify ( { version : '1.0.0' } ) ) ;
await expect (
2025-09-25 10:57:59 -07:00
installExtension (
{ source : sourceExtDir , type : 'local' } ,
async ( _ ) = > true ,
) ,
2025-09-22 09:34:52 +00:00
) . rejects . toThrow (
` Invalid configuration in ${ configPath } : missing "name" ` ,
) ;
} ) ;
2025-09-16 15:51:46 -04:00
it ( 'should install an extension from a git URL' , async ( ) = > {
const gitUrl = 'https://github.com/google/gemini-extensions.git' ;
const extensionName = 'gemini-extensions' ;
const targetExtDir = path . join ( userExtensionsDir , extensionName ) ;
const metadataPath = path . join ( targetExtDir , INSTALL_METADATA_FILENAME ) ;
mockGit . clone . mockImplementation ( async ( _ , destination ) = > {
fs . mkdirSync ( path . join ( mockGit . path ( ) , destination ) , {
recursive : true ,
} ) ;
fs . writeFileSync (
path . join ( mockGit . path ( ) , destination , EXTENSIONS_CONFIG_FILENAME ) ,
JSON . stringify ( { name : extensionName , version : '1.0.0' } ) ,
) ;
} ) ;
mockGit . getRemotes . mockResolvedValue ( [ { name : 'origin' } ] ) ;
2025-08-25 17:02:10 +00:00
2025-09-25 10:57:59 -07:00
await installExtension (
{ source : gitUrl , type : 'git' } ,
async ( _ ) = > true ,
) ;
2025-08-25 17:02:10 +00:00
2025-09-16 15:51:46 -04:00
expect ( fs . existsSync ( targetExtDir ) ) . toBe ( true ) ;
expect ( fs . existsSync ( metadataPath ) ) . toBe ( true ) ;
const metadata = JSON . parse ( fs . readFileSync ( metadataPath , 'utf-8' ) ) ;
expect ( metadata ) . toEqual ( {
source : gitUrl ,
type : 'git' ,
} ) ;
fs . rmSync ( targetExtDir , { recursive : true , force : true } ) ;
2025-08-28 16:46:45 -04:00
} ) ;
2025-08-25 17:02:10 +00:00
2025-09-16 15:51:46 -04:00
it ( 'should install a linked extension' , async ( ) = > {
const sourceExtDir = createExtension ( {
extensionsDir : tempHomeDir ,
name : 'my-linked-extension' ,
version : '1.0.0' ,
} ) ;
const targetExtDir = path . join ( userExtensionsDir , 'my-linked-extension' ) ;
const metadataPath = path . join ( targetExtDir , INSTALL_METADATA_FILENAME ) ;
const configPath = path . join ( targetExtDir , EXTENSIONS_CONFIG_FILENAME ) ;
2025-08-25 17:02:10 +00:00
2025-09-25 10:57:59 -07:00
await installExtension (
{ source : sourceExtDir , type : 'link' } ,
async ( _ ) = > true ,
) ;
2025-08-25 17:02:10 +00:00
2025-09-16 15:51:46 -04:00
expect ( fs . existsSync ( targetExtDir ) ) . toBe ( true ) ;
expect ( fs . existsSync ( metadataPath ) ) . toBe ( true ) ;
2025-08-25 17:02:10 +00:00
2025-09-16 15:51:46 -04:00
expect ( fs . existsSync ( configPath ) ) . toBe ( false ) ;
2025-08-25 17:02:10 +00:00
2025-09-16 15:51:46 -04:00
const metadata = JSON . parse ( fs . readFileSync ( metadataPath , 'utf-8' ) ) ;
expect ( metadata ) . toEqual ( {
source : sourceExtDir ,
type : 'link' ,
} ) ;
fs . rmSync ( targetExtDir , { recursive : true , force : true } ) ;
2025-08-25 17:02:10 +00:00
} ) ;
2025-09-16 15:51:46 -04:00
it ( 'should log to clearcut on successful install' , async ( ) = > {
const sourceExtDir = createExtension ( {
extensionsDir : tempHomeDir ,
name : 'my-local-extension' ,
version : '1.0.0' ,
} ) ;
2025-08-25 17:02:10 +00:00
2025-09-25 10:57:59 -07:00
await installExtension (
{ source : sourceExtDir , type : 'local' } ,
async ( _ ) = > true ,
) ;
2025-09-02 10:15:42 -07:00
2025-09-22 12:55:43 -04:00
expect ( mockLogExtensionInstallEvent ) . toHaveBeenCalled ( ) ;
2025-09-02 10:15:42 -07:00
} ) ;
2025-09-16 15:51:46 -04:00
it ( 'should show users information on their mcp server when installing' , async ( ) = > {
const consoleInfoSpy = vi . spyOn ( console , 'info' ) ;
const sourceExtDir = createExtension ( {
extensionsDir : tempHomeDir ,
name : 'my-local-extension' ,
version : '1.0.0' ,
mcpServers : {
'test-server' : {
command : 'node' ,
args : [ 'server.js' ] ,
description : 'a local mcp server' ,
} ,
'test-server-2' : {
description : 'a remote mcp server' ,
httpUrl : 'https://google.com' ,
} ,
} ,
} ) ;
2025-09-02 10:15:42 -07:00
2025-09-16 15:51:46 -04:00
mockQuestion . mockImplementation ( ( _query , callback ) = > callback ( 'y' ) ) ;
2025-09-02 10:15:42 -07:00
2025-09-16 15:51:46 -04:00
await expect (
2025-09-25 10:57:59 -07:00
installExtension (
{ source : sourceExtDir , type : 'local' } ,
requestConsentNonInteractive ,
) ,
2025-09-16 15:51:46 -04:00
) . resolves . toBe ( 'my-local-extension' ) ;
2025-09-02 10:15:42 -07:00
2025-09-16 15:51:46 -04:00
expect ( consoleInfoSpy ) . toHaveBeenCalledWith (
2025-09-22 19:50:12 -04:00
` Extensions may introduce unexpected behavior.
Ensure you have investigated the extension source and trust the author .
This extension will run the following MCP servers :
* test - server ( local ) : node server . js
* test - server - 2 ( remote ) : https : //google.com`,
2025-09-16 15:51:46 -04:00
) ;
2025-09-02 10:15:42 -07:00
} ) ;
2025-09-09 12:12:56 -04:00
2025-09-16 15:51:46 -04:00
it ( 'should continue installation if user accepts prompt for local extension with mcp servers' , async ( ) = > {
const sourceExtDir = createExtension ( {
extensionsDir : tempHomeDir ,
name : 'my-local-extension' ,
version : '1.0.0' ,
mcpServers : {
'test-server' : {
command : 'node' ,
args : [ 'server.js' ] ,
} ,
} ,
} ) ;
mockQuestion . mockImplementation ( ( _query , callback ) = > callback ( 'y' ) ) ;
await expect (
2025-09-25 10:57:59 -07:00
installExtension (
{ source : sourceExtDir , type : 'local' } ,
requestConsentNonInteractive ,
) ,
2025-09-16 15:51:46 -04:00
) . resolves . toBe ( 'my-local-extension' ) ;
expect ( mockQuestion ) . toHaveBeenCalledWith (
2025-09-22 19:50:12 -04:00
expect . stringContaining ( 'Do you want to continue? [Y/n]: ' ) ,
2025-09-16 15:51:46 -04:00
expect . any ( Function ) ,
) ;
2025-09-09 12:12:56 -04:00
} ) ;
2025-09-16 15:51:46 -04:00
it ( 'should cancel installation if user declines prompt for local extension with mcp servers' , async ( ) = > {
const sourceExtDir = createExtension ( {
extensionsDir : tempHomeDir ,
name : 'my-local-extension' ,
version : '1.0.0' ,
mcpServers : {
'test-server' : {
command : 'node' ,
args : [ 'server.js' ] ,
} ,
} ,
} ) ;
mockQuestion . mockImplementation ( ( _query , callback ) = > callback ( 'n' ) ) ;
2025-09-09 12:12:56 -04:00
2025-09-16 15:51:46 -04:00
await expect (
2025-09-25 10:57:59 -07:00
installExtension (
{ source : sourceExtDir , type : 'local' } ,
requestConsentNonInteractive ,
) ,
) . rejects . toThrow ( 'Installation cancelled.' ) ;
2025-09-16 15:51:46 -04:00
expect ( mockQuestion ) . toHaveBeenCalledWith (
2025-09-22 19:50:12 -04:00
expect . stringContaining ( 'Do you want to continue? [Y/n]: ' ) ,
2025-09-16 15:51:46 -04:00
expect . any ( Function ) ,
) ;
} ) ;
2025-09-17 09:24:38 -04:00
2025-09-18 14:49:47 -07:00
it ( 'should save the autoUpdate flag to the install metadata' , async ( ) = > {
const sourceExtDir = createExtension ( {
extensionsDir : tempHomeDir ,
name : 'my-local-extension' ,
version : '1.0.0' ,
} ) ;
const targetExtDir = path . join ( userExtensionsDir , 'my-local-extension' ) ;
const metadataPath = path . join ( targetExtDir , INSTALL_METADATA_FILENAME ) ;
2025-09-25 10:57:59 -07:00
await installExtension (
{
source : sourceExtDir ,
type : 'local' ,
autoUpdate : true ,
} ,
async ( _ ) = > true ,
) ;
2025-09-18 14:49:47 -07:00
expect ( fs . existsSync ( targetExtDir ) ) . toBe ( true ) ;
expect ( fs . existsSync ( metadataPath ) ) . toBe ( true ) ;
const metadata = JSON . parse ( fs . readFileSync ( metadataPath , 'utf-8' ) ) ;
expect ( metadata ) . toEqual ( {
source : sourceExtDir ,
type : 'local' ,
autoUpdate : true ,
} ) ;
fs . rmSync ( targetExtDir , { recursive : true , force : true } ) ;
} ) ;
2025-09-17 09:24:38 -04:00
it ( 'should ignore consent flow if not required' , async ( ) = > {
const sourceExtDir = createExtension ( {
extensionsDir : tempHomeDir ,
name : 'my-local-extension' ,
version : '1.0.0' ,
mcpServers : {
'test-server' : {
command : 'node' ,
args : [ 'server.js' ] ,
} ,
} ,
} ) ;
2025-09-25 10:57:59 -07:00
const mockRequestConsent = vi . fn ( ) ;
2025-09-17 09:24:38 -04:00
await expect (
2025-09-25 10:57:59 -07:00
installExtension (
{ source : sourceExtDir , type : 'local' } ,
mockRequestConsent ,
process . cwd ( ) ,
// Provide its own existing config as the previous config.
await loadExtensionConfig ( {
extensionDir : sourceExtDir ,
workspaceDir : process.cwd ( ) ,
} ) ,
) ,
2025-09-17 09:24:38 -04:00
) . resolves . toBe ( 'my-local-extension' ) ;
2025-09-25 10:57:59 -07:00
expect ( mockRequestConsent ) . not . toHaveBeenCalled ( ) ;
2025-09-17 09:24:38 -04:00
} ) ;
2025-09-25 14:05:49 -04:00
it ( 'should throw an error for invalid extension names' , async ( ) = > {
const sourceExtDir = createExtension ( {
extensionsDir : tempHomeDir ,
name : 'bad_name' ,
version : '1.0.0' ,
} ) ;
await expect (
installExtension ( { source : sourceExtDir , type : 'local' } ) ,
) . rejects . toThrow ( 'Invalid extension name: "bad_name"' ) ;
} ) ;
2025-09-09 12:12:56 -04:00
} ) ;
2025-09-12 10:53:30 -04:00
2025-09-16 15:51:46 -04:00
describe ( 'uninstallExtension' , ( ) = > {
it ( 'should uninstall an extension by name' , async ( ) = > {
const sourceExtDir = createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'my-local-extension' ,
version : '1.0.0' ,
} ) ;
await uninstallExtension ( 'my-local-extension' ) ;
expect ( fs . existsSync ( sourceExtDir ) ) . toBe ( false ) ;
2025-09-15 11:11:14 -04:00
} ) ;
2025-09-16 15:51:46 -04:00
it ( 'should uninstall an extension by name and retain existing extensions' , async ( ) = > {
const sourceExtDir = createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'my-local-extension' ,
version : '1.0.0' ,
} ) ;
const otherExtDir = createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'other-extension' ,
version : '1.0.0' ,
} ) ;
2025-09-15 11:11:14 -04:00
2025-09-16 15:51:46 -04:00
await uninstallExtension ( 'my-local-extension' ) ;
2025-09-15 11:11:14 -04:00
2025-09-16 15:51:46 -04:00
expect ( fs . existsSync ( sourceExtDir ) ) . toBe ( false ) ;
expect ( loadExtensions ( ) ) . toHaveLength ( 1 ) ;
expect ( fs . existsSync ( otherExtDir ) ) . toBe ( true ) ;
} ) ;
2025-09-15 11:11:14 -04:00
2025-09-16 15:51:46 -04:00
it ( 'should throw an error if the extension does not exist' , async ( ) = > {
await expect ( uninstallExtension ( 'nonexistent-extension' ) ) . rejects . toThrow (
2025-09-18 16:00:28 +00:00
'Extension not found.' ,
2025-09-16 15:51:46 -04:00
) ;
2025-09-12 10:53:30 -04:00
} ) ;
2025-09-16 15:51:46 -04:00
it ( 'should log uninstall event' , async ( ) = > {
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'my-local-extension' ,
version : '1.0.0' ,
} ) ;
2025-09-12 10:53:30 -04:00
2025-09-16 15:51:46 -04:00
await uninstallExtension ( 'my-local-extension' ) ;
2025-09-12 10:53:30 -04:00
2025-09-22 12:55:43 -04:00
expect ( mockLogExtensionUninstall ) . toHaveBeenCalled ( ) ;
expect ( ExtensionUninstallEvent ) . toHaveBeenCalledWith (
'my-local-extension' ,
'success' ,
2025-09-16 15:51:46 -04:00
) ;
} ) ;
2025-09-18 16:00:28 +00:00
it ( 'should uninstall an extension by its source URL' , async ( ) = > {
const gitUrl = 'https://github.com/google/gemini-sql-extension.git' ;
const sourceExtDir = createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'gemini-sql-extension' ,
version : '1.0.0' ,
installMetadata : {
source : gitUrl ,
type : 'git' ,
} ,
} ) ;
await uninstallExtension ( gitUrl ) ;
expect ( fs . existsSync ( sourceExtDir ) ) . toBe ( false ) ;
2025-09-22 12:55:43 -04:00
expect ( mockLogExtensionUninstall ) . toHaveBeenCalled ( ) ;
expect ( ExtensionUninstallEvent ) . toHaveBeenCalledWith (
'gemini-sql-extension' ,
'success' ,
2025-09-18 16:00:28 +00:00
) ;
} ) ;
it ( 'should fail to uninstall by URL if an extension has no install metadata' , async ( ) = > {
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'no-metadata-extension' ,
version : '1.0.0' ,
// No installMetadata provided
} ) ;
await expect (
uninstallExtension ( 'https://github.com/google/no-metadata-extension' ) ,
) . rejects . toThrow ( 'Extension not found.' ) ;
} ) ;
2025-09-12 10:53:30 -04:00
} ) ;
2025-09-16 15:51:46 -04:00
describe ( 'performWorkspaceExtensionMigration' , ( ) = > {
let workspaceExtensionsDir : string ;
beforeEach ( ( ) = > {
workspaceExtensionsDir = path . join (
tempWorkspaceDir ,
EXTENSIONS_DIRECTORY_NAME ,
) ;
fs . mkdirSync ( workspaceExtensionsDir , { recursive : true } ) ;
2025-09-12 10:53:30 -04:00
} ) ;
2025-09-16 15:51:46 -04:00
afterEach ( ( ) = > {
fs . rmSync ( workspaceExtensionsDir , { recursive : true , force : true } ) ;
} ) ;
2025-09-12 10:53:30 -04:00
2025-09-16 15:51:46 -04:00
describe ( 'folder trust' , ( ) = > {
it ( 'refuses to install extensions from untrusted folders' , async ( ) = > {
vi . mocked ( isWorkspaceTrusted ) . mockReturnValue ( false ) ;
const ext1Path = createExtension ( {
extensionsDir : workspaceExtensionsDir ,
name : 'ext1' ,
version : '1.0.0' ,
} ) ;
2025-09-12 10:53:30 -04:00
2025-09-16 15:51:46 -04:00
const failed = await performWorkspaceExtensionMigration ( [
2025-09-17 04:23:12 +00:00
loadExtension ( {
extensionDir : ext1Path ,
workspaceDir : tempWorkspaceDir ,
} ) ! ,
2025-09-16 15:51:46 -04:00
] ) ;
2025-08-25 17:02:10 +00:00
2025-09-16 15:51:46 -04:00
expect ( failed ) . toEqual ( [ 'ext1' ] ) ;
} ) ;
2025-08-25 17:40:15 +00:00
2025-09-16 15:51:46 -04:00
it ( 'does not copy extensions to the user dir' , async ( ) = > {
vi . mocked ( isWorkspaceTrusted ) . mockReturnValue ( false ) ;
const ext1Path = createExtension ( {
extensionsDir : workspaceExtensionsDir ,
name : 'ext1' ,
version : '1.0.0' ,
} ) ;
2025-09-25 10:57:59 -07:00
await performWorkspaceExtensionMigration (
[
loadExtension ( {
extensionDir : ext1Path ,
workspaceDir : tempWorkspaceDir ,
} ) ! ,
] ,
async ( _ ) = > true ,
) ;
2025-09-16 15:51:46 -04:00
const userExtensionsDir = path . join (
tempHomeDir ,
GEMINI_DIR ,
'extensions' ,
) ;
expect ( fs . readdirSync ( userExtensionsDir ) . length ) . toBe ( 0 ) ;
} ) ;
2025-08-25 17:40:15 +00:00
2025-09-16 15:51:46 -04:00
it ( 'does not load any extensions in the workspace config' , async ( ) = > {
vi . mocked ( isWorkspaceTrusted ) . mockReturnValue ( false ) ;
const ext1Path = createExtension ( {
extensionsDir : workspaceExtensionsDir ,
name : 'ext1' ,
version : '1.0.0' ,
} ) ;
2025-08-25 17:40:15 +00:00
2025-09-25 10:57:59 -07:00
await performWorkspaceExtensionMigration (
[
loadExtension ( {
extensionDir : ext1Path ,
workspaceDir : tempWorkspaceDir ,
} ) ! ,
] ,
async ( _ ) = > true ,
) ;
2025-09-16 15:51:46 -04:00
const extensions = loadExtensions ( ) ;
2025-09-12 13:38:54 -04:00
2025-09-16 15:51:46 -04:00
expect ( extensions ) . toEqual ( [ ] ) ;
} ) ;
2025-09-12 13:38:54 -04:00
} ) ;
2025-09-16 15:51:46 -04:00
it ( 'should install the extensions in the user directory' , async ( ) = > {
2025-08-28 16:46:45 -04:00
const ext1Path = createExtension ( {
extensionsDir : workspaceExtensionsDir ,
name : 'ext1' ,
version : '1.0.0' ,
} ) ;
2025-09-16 15:51:46 -04:00
const ext2Path = createExtension ( {
2025-08-28 16:46:45 -04:00
extensionsDir : workspaceExtensionsDir ,
2025-09-16 15:51:46 -04:00
name : 'ext2' ,
2025-08-28 16:46:45 -04:00
version : '1.0.0' ,
} ) ;
2025-09-16 15:51:46 -04:00
const extensionsToMigrate : Extension [ ] = [
2025-09-17 04:23:12 +00:00
loadExtension ( {
extensionDir : ext1Path ,
workspaceDir : tempWorkspaceDir ,
} ) ! ,
loadExtension ( {
extensionDir : ext2Path ,
workspaceDir : tempWorkspaceDir ,
} ) ! ,
2025-09-16 15:51:46 -04:00
] ;
2025-09-25 10:57:59 -07:00
const failed = await performWorkspaceExtensionMigration (
extensionsToMigrate ,
async ( _ ) = > true ,
) ;
2025-08-28 16:46:45 -04:00
2025-09-16 15:51:46 -04:00
expect ( failed ) . toEqual ( [ ] ) ;
2025-08-28 16:46:45 -04:00
const userExtensionsDir = path . join (
tempHomeDir ,
GEMINI_DIR ,
'extensions' ,
) ;
2025-09-16 15:51:46 -04:00
const userExt1Path = path . join ( userExtensionsDir , 'ext1' ) ;
const extensions = loadExtensions ( ) ;
2025-08-28 16:46:45 -04:00
2025-09-16 15:51:46 -04:00
expect ( extensions ) . toHaveLength ( 2 ) ;
const metadataPath = path . join ( userExt1Path , INSTALL_METADATA_FILENAME ) ;
expect ( fs . existsSync ( metadataPath ) ) . toBe ( true ) ;
const metadata = JSON . parse ( fs . readFileSync ( metadataPath , 'utf-8' ) ) ;
expect ( metadata ) . toEqual ( {
source : ext1Path ,
type : 'local' ,
} ) ;
2025-08-28 16:46:45 -04:00
} ) ;
2025-09-16 15:51:46 -04:00
it ( 'should return the names of failed installations' , async ( ) = > {
2025-08-28 16:46:45 -04:00
const ext1Path = createExtension ( {
extensionsDir : workspaceExtensionsDir ,
name : 'ext1' ,
version : '1.0.0' ,
} ) ;
2025-09-16 15:51:46 -04:00
const extensions : Extension [ ] = [
2025-09-17 04:23:12 +00:00
loadExtension ( {
extensionDir : ext1Path ,
workspaceDir : tempWorkspaceDir ,
} ) ! ,
2025-09-16 15:51:46 -04:00
{
path : '/ext/path/1' ,
config : { name : 'ext2' , version : '1.0.0' } ,
contextFiles : [ ] ,
} ,
] ;
2025-08-28 16:46:45 -04:00
2025-09-25 10:57:59 -07:00
const failed = await performWorkspaceExtensionMigration (
extensions ,
async ( _ ) = > true ,
) ;
2025-09-16 15:51:46 -04:00
expect ( failed ) . toEqual ( [ 'ext2' ] ) ;
2025-08-27 00:43:02 +00:00
} ) ;
} ) ;
2025-09-16 15:51:46 -04:00
describe ( 'disableExtension' , ( ) = > {
it ( 'should disable an extension at the user scope' , ( ) = > {
disableExtension ( 'my-extension' , SettingScope . User ) ;
expect (
isEnabled ( {
name : 'my-extension' ,
configDir : userExtensionsDir ,
enabledForPath : tempWorkspaceDir ,
} ) ,
) . toBe ( false ) ;
2025-09-12 09:20:04 -07:00
} ) ;
2025-09-16 15:51:46 -04:00
it ( 'should disable an extension at the workspace scope' , ( ) = > {
disableExtension (
'my-extension' ,
SettingScope . Workspace ,
tempWorkspaceDir ,
) ;
expect (
isEnabled ( {
name : 'my-extension' ,
configDir : userExtensionsDir ,
enabledForPath : tempHomeDir ,
} ) ,
) . toBe ( true ) ;
expect (
isEnabled ( {
name : 'my-extension' ,
configDir : userExtensionsDir ,
enabledForPath : tempWorkspaceDir ,
} ) ,
) . toBe ( false ) ;
2025-09-12 09:20:04 -07:00
} ) ;
2025-09-16 15:51:46 -04:00
it ( 'should handle disabling the same extension twice' , ( ) = > {
disableExtension ( 'my-extension' , SettingScope . User ) ;
disableExtension ( 'my-extension' , SettingScope . User ) ;
expect (
isEnabled ( {
name : 'my-extension' ,
configDir : userExtensionsDir ,
enabledForPath : tempWorkspaceDir ,
} ) ,
) . toBe ( false ) ;
2025-09-12 09:20:04 -07:00
} ) ;
2025-08-26 14:36:55 +00:00
2025-09-16 15:51:46 -04:00
it ( 'should throw an error if you request system scope' , ( ) = > {
expect ( ( ) = >
disableExtension ( 'my-extension' , SettingScope . System ) ,
) . toThrow ( 'System and SystemDefaults scopes are not supported.' ) ;
} ) ;
2025-09-23 14:37:35 -04:00
it ( 'should log a disable event' , ( ) = > {
disableExtension ( 'ext1' , SettingScope . Workspace ) ;
expect ( mockLogExtensionDisable ) . toHaveBeenCalled ( ) ;
expect ( ExtensionDisableEvent ) . toHaveBeenCalledWith (
'ext1' ,
SettingScope . Workspace ,
) ;
} ) ;
2025-08-26 14:36:55 +00:00
} ) ;
2025-09-16 15:51:46 -04:00
describe ( 'enableExtension' , ( ) = > {
afterAll ( ( ) = > {
vi . restoreAllMocks ( ) ;
} ) ;
2025-08-26 14:36:55 +00:00
2025-09-16 15:51:46 -04:00
const getActiveExtensions = ( ) : GeminiCLIExtension [ ] = > {
const extensions = loadExtensions ( ) ;
const activeExtensions = annotateActiveExtensions (
extensions ,
[ ] ,
tempWorkspaceDir ,
) ;
return activeExtensions . filter ( ( e ) = > e . isActive ) ;
} ;
2025-08-26 14:36:55 +00:00
2025-09-16 15:51:46 -04:00
it ( 'should enable an extension at the user scope' , ( ) = > {
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'ext1' ,
version : '1.0.0' ,
} ) ;
disableExtension ( 'ext1' , SettingScope . User ) ;
let activeExtensions = getActiveExtensions ( ) ;
expect ( activeExtensions ) . toHaveLength ( 0 ) ;
enableExtension ( 'ext1' , SettingScope . User ) ;
activeExtensions = getActiveExtensions ( ) ;
expect ( activeExtensions ) . toHaveLength ( 1 ) ;
expect ( activeExtensions [ 0 ] . name ) . toBe ( 'ext1' ) ;
} ) ;
2025-08-26 14:36:55 +00:00
2025-09-16 15:51:46 -04:00
it ( 'should enable an extension at the workspace scope' , ( ) = > {
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'ext1' ,
version : '1.0.0' ,
} ) ;
disableExtension ( 'ext1' , SettingScope . Workspace ) ;
let activeExtensions = getActiveExtensions ( ) ;
expect ( activeExtensions ) . toHaveLength ( 0 ) ;
enableExtension ( 'ext1' , SettingScope . Workspace ) ;
activeExtensions = getActiveExtensions ( ) ;
expect ( activeExtensions ) . toHaveLength ( 1 ) ;
expect ( activeExtensions [ 0 ] . name ) . toBe ( 'ext1' ) ;
} ) ;
2025-09-22 12:55:43 -04:00
it ( 'should log an enable event' , ( ) = > {
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'ext1' ,
version : '1.0.0' ,
} ) ;
disableExtension ( 'ext1' , SettingScope . Workspace ) ;
enableExtension ( 'ext1' , SettingScope . Workspace ) ;
expect ( mockLogExtensionEnable ) . toHaveBeenCalled ( ) ;
expect ( ExtensionEnableEvent ) . toHaveBeenCalledWith (
'ext1' ,
SettingScope . Workspace ,
) ;
} ) ;
2025-08-26 14:36:55 +00:00
} ) ;
} ) ;
2025-08-26 15:49:00 +00:00
2025-09-16 15:51:46 -04:00
function isEnabled ( options : {
name : string ;
configDir : string ;
enabledForPath : string ;
} ) {
const manager = new ExtensionEnablementManager ( options . configDir ) ;
return manager . isEnabled ( options . name , options . enabledForPath ) ;
}