2025-06-10 15:48:39 -07:00
/ * *
* @license
* Copyright 2025 Google LLC
* SPDX - License - Identifier : Apache - 2.0
* /
2025-11-20 20:57:59 -08:00
import {
vi ,
type MockedFunction ,
describe ,
it ,
expect ,
beforeEach ,
afterEach ,
afterAll ,
} 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 {
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-10-28 14:48:50 -04:00
KeychainTokenStorage ,
2026-01-13 23:40:23 -08:00
loadAgentsFromDirectory ,
loadSkillsFromDir ,
2025-08-26 15:49:00 +00:00
} from '@google/gemini-cli-core' ;
2026-01-15 09:26:10 -08:00
import {
loadSettings ,
createTestMergedSettings ,
SettingScope ,
} from './settings.js' ;
2025-10-31 19:17:01 +00:00
import {
isWorkspaceTrusted ,
resetTrustedFoldersForTesting ,
} 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-10-15 14:29:16 -07:00
import { join } from 'node:path' ;
2025-10-23 11:39:36 -07:00
import {
EXTENSIONS_CONFIG_FILENAME ,
EXTENSIONS_DIRECTORY_NAME ,
INSTALL_METADATA_FILENAME ,
} from './extensions/variables.js' ;
import { hashValue , ExtensionManager } from './extension-manager.js' ;
import { ExtensionStorage } from './extensions/storage.js' ;
import { INSTALL_WARNING_MESSAGE } from './extensions/consent.js' ;
2025-10-23 11:47:08 -04:00
import type { ExtensionSetting } from './extensions/extensionSettings.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-10-15 14:29:16 -07:00
const mockDownloadFromGithubRelease = vi . hoisted ( ( ) = > vi . fn ( ) ) ;
vi . mock ( './extensions/github.js' , async ( importOriginal ) = > {
const original =
await importOriginal < typeof import ( './extensions/github.js' ) > ( ) ;
return {
. . . original ,
downloadFromGitHubRelease : mockDownloadFromGithubRelease ,
} ;
} ) ;
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
2025-10-28 10:32:15 -07:00
const mockHomedir = vi . hoisted ( ( ) = > vi . fn ( ( ) = > '/tmp/mock-home' ) ) ;
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-10-28 10:32:15 -07:00
homedir : mockHomedir ,
2025-06-10 15:48:39 -07:00
} ;
} ) ;
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-10-10 14:28:13 -07:00
const mockLogExtensionUpdateEvent = 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-10-10 14:28:13 -07:00
logExtensionUpdateEvent : mockLogExtensionUpdateEvent ,
2025-09-23 14:37:35 -04:00
logExtensionDisable : mockLogExtensionDisable ,
2026-01-06 20:09:39 -08:00
homedir : mockHomedir ,
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-10-28 14:48:50 -04:00
KeychainTokenStorage : vi.fn ( ) . mockImplementation ( ( ) = > ( {
getSecret : vi.fn ( ) ,
setSecret : vi.fn ( ) ,
deleteSecret : vi.fn ( ) ,
listSecrets : vi.fn ( ) ,
isAvailable : vi.fn ( ) . mockResolvedValue ( true ) ,
} ) ) ,
2026-01-13 23:40:23 -08:00
loadAgentsFromDirectory : vi
. fn ( )
. mockImplementation ( async ( ) = > ( { agents : [ ] , errors : [ ] } ) ) ,
loadSkillsFromDir : vi.fn ( ) . mockImplementation ( async ( ) = > [ ] ) ,
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-10-28 14:48:50 -04:00
interface MockKeychainStorage {
getSecret : ReturnType < typeof vi.fn > ;
setSecret : ReturnType < typeof vi.fn > ;
deleteSecret : ReturnType < typeof vi.fn > ;
listSecrets : ReturnType < typeof vi.fn > ;
isAvailable : ReturnType < typeof vi.fn > ;
}
2025-11-20 10:44:02 -08:00
describe ( 'extension tests' , ( ) = > {
2025-11-20 20:57:59 -08:00
let tempHomeDir : string ;
let tempWorkspaceDir : string ;
let userExtensionsDir : string ;
let extensionManager : ExtensionManager ;
let mockRequestConsent : MockedFunction < ( consent : string ) = > Promise < boolean > > ;
let mockPromptForSettings : MockedFunction <
( setting : ExtensionSetting ) = > Promise < string >
> ;
let mockKeychainStorage : MockKeychainStorage ;
let keychainData : Record < string , string > ;
2025-06-10 15:48:39 -07:00
beforeEach ( ( ) = > {
2025-10-28 14:48:50 -04:00
vi . clearAllMocks ( ) ;
keychainData = { } ;
mockKeychainStorage = {
getSecret : vi
. fn ( )
. mockImplementation ( async ( key : string ) = > keychainData [ key ] || null ) ,
setSecret : vi
. fn ( )
. mockImplementation ( async ( key : string , value : string ) = > {
keychainData [ key ] = value ;
} ) ,
deleteSecret : vi.fn ( ) . mockImplementation ( async ( key : string ) = > {
delete keychainData [ key ] ;
} ) ,
listSecrets : vi
. fn ( )
. mockImplementation ( async ( ) = > Object . keys ( keychainData ) ) ,
isAvailable : vi.fn ( ) . mockResolvedValue ( true ) ,
} ;
(
KeychainTokenStorage as unknown as ReturnType < typeof vi.fn >
) . mockImplementation ( ( ) = > mockKeychainStorage ) ;
2026-01-13 23:40:23 -08:00
vi . mocked ( loadAgentsFromDirectory ) . mockResolvedValue ( {
agents : [ ] ,
errors : [ ] ,
} ) ;
vi . mocked ( loadSkillsFromDir ) . mockResolvedValue ( [ ] ) ;
2025-06-10 15:48:39 -07:00
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 ) ;
2025-10-23 11:39:36 -07:00
mockRequestConsent = vi . fn ( ) ;
mockRequestConsent . mockResolvedValue ( true ) ;
mockPromptForSettings = vi . fn ( ) ;
mockPromptForSettings . mockResolvedValue ( '' ) ;
2025-09-05 11:44:41 -07:00
fs . mkdirSync ( userExtensionsDir , { recursive : true } ) ;
2025-09-16 15:51:46 -04:00
vi . mocked ( os . homedir ) . mockReturnValue ( tempHomeDir ) ;
2025-10-10 13:40:13 -07:00
vi . mocked ( isWorkspaceTrusted ) . mockReturnValue ( {
isTrusted : true ,
source : undefined ,
} ) ;
2025-09-16 15:51:46 -04:00
vi . spyOn ( process , 'cwd' ) . mockReturnValue ( tempWorkspaceDir ) ;
2026-01-14 10:16:42 -05:00
const settings = loadSettings ( tempWorkspaceDir ) . merged ;
2026-01-15 09:26:10 -08:00
settings . experimental . extensionConfig = true ;
2025-10-23 11:39:36 -07:00
extensionManager = new ExtensionManager ( {
workspaceDir : tempWorkspaceDir ,
requestConsent : mockRequestConsent ,
requestSetting : mockPromptForSettings ,
2026-01-14 10:16:42 -05:00
settings ,
2025-10-23 11:39:36 -07:00
} ) ;
2025-10-31 19:17:01 +00:00
resetTrustedFoldersForTesting ( ) ;
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-06-10 15:48:39 -07:00
} ) ;
2025-09-16 15:51:46 -04:00
describe ( 'loadExtensions' , ( ) = > {
2025-10-28 14:48:50 -04:00
it ( 'should include extension path in loaded extension' , async ( ) = > {
2025-09-16 15:51:46 -04:00
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-10-28 14:48:50 -04:00
const extensions = await extensionManager . loadExtensions ( ) ;
2025-09-16 15:51:46 -04:00
expect ( extensions ) . toHaveLength ( 1 ) ;
expect ( extensions [ 0 ] . path ) . toBe ( extensionDir ) ;
2025-10-08 07:31:41 -07:00
expect ( extensions [ 0 ] . name ) . toBe ( 'test-extension' ) ;
2025-08-28 16:46:45 -04:00
} ) ;
2025-06-10 15:48:39 -07:00
2025-10-28 14:48:50 -04:00
it ( 'should load context file path when GEMINI.md is present' , async ( ) = > {
2025-09-16 15:51:46 -04:00
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-10-28 14:48:50 -04:00
const extensions = await extensionManager . loadExtensions ( ) ;
2025-06-11 13:34:35 -07:00
2025-09-16 15:51:46 -04:00
expect ( extensions ) . toHaveLength ( 2 ) ;
2025-10-08 07:31:41 -07:00
const ext1 = extensions . find ( ( e ) = > e . name === 'ext1' ) ;
const ext2 = extensions . find ( ( e ) = > e . name === 'ext2' ) ;
2025-09-16 15:51:46 -04:00
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-10-28 14:48:50 -04:00
it ( 'should load context file path from the extension config' , async ( ) = > {
2025-09-16 15:51:46 -04:00
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-10-28 14:48:50 -04:00
const extensions = await extensionManager . loadExtensions ( ) ;
2025-08-26 02:13:16 +00:00
2025-09-16 15:51:46 -04:00
expect ( extensions ) . toHaveLength ( 1 ) ;
2025-10-08 07:31:41 -07:00
const ext1 = extensions . find ( ( e ) = > e . name === 'ext1' ) ;
2025-09-16 15:51:46 -04:00
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-10-28 14:48:50 -04:00
it ( 'should annotate disabled extensions' , async ( ) = > {
2025-09-16 15:51:46 -04:00
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'disabled-extension' ,
version : '1.0.0' ,
} ) ;
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'enabled-extension' ,
version : '2.0.0' ,
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
await extensionManager . disableExtension (
2025-09-16 15:51:46 -04:00
'disabled-extension' ,
SettingScope . User ,
) ;
2025-10-28 09:04:30 -07:00
const extensions = extensionManager . getExtensions ( ) ;
2025-10-20 16:15:23 -07:00
expect ( extensions ) . toHaveLength ( 2 ) ;
expect ( extensions [ 0 ] . name ) . toBe ( 'disabled-extension' ) ;
expect ( extensions [ 0 ] . isActive ) . toBe ( false ) ;
expect ( extensions [ 1 ] . name ) . toBe ( 'enabled-extension' ) ;
expect ( extensions [ 1 ] . isActive ) . toBe ( true ) ;
2025-08-28 16:46:45 -04:00
} ) ;
2025-08-26 02:13:16 +00:00
2025-10-28 14:48:50 -04:00
it ( 'should hydrate variables' , async ( ) = > {
2025-09-16 15:51:46 -04:00
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-10-28 14:48:50 -04:00
const extensions = await extensionManager . loadExtensions ( ) ;
2025-09-16 15:51:46 -04:00
expect ( extensions ) . toHaveLength ( 1 ) ;
const expectedCwd = path . join (
userExtensionsDir ,
'test-extension' ,
'server' ,
) ;
2025-10-08 07:31:41 -07:00
expect ( extensions [ 0 ] . 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-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-28 09:04:30 -07:00
const extension = await extensionManager . installOrUpdateExtension ( {
2025-10-23 11:39:36 -07:00
source : sourceExtDir ,
type : 'link' ,
} ) ;
2025-09-16 15:51:46 -04:00
2025-10-28 09:04:30 -07:00
expect ( extension . name ) . toEqual ( 'my-linked-extension' ) ;
const extensions = extensionManager . getExtensions ( ) ;
2025-09-16 15:51:46 -04:00
expect ( extensions ) . toHaveLength ( 1 ) ;
2025-09-02 10:15:42 -07:00
2025-09-16 15:51:46 -04:00
const linkedExt = extensions [ 0 ] ;
2025-10-08 07:31:41 -07:00
expect ( linkedExt . 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-10-24 10:45:58 -07:00
it ( 'should hydrate ${extensionPath} correctly for linked extensions' , async ( ) = > {
const sourceExtDir = createExtension ( {
extensionsDir : tempWorkspaceDir ,
name : 'my-linked-extension-with-path' ,
version : '1.0.0' ,
mcpServers : {
'test-server' : {
command : 'node' ,
2025-10-24 11:42:49 -07:00
args : [ '${extensionPath}${/}server${/}index.js' ] ,
cwd : '${extensionPath}${/}server' ,
2025-10-24 10:45:58 -07:00
} ,
} ,
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-24 10:45:58 -07:00
await extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'link' ,
} ) ;
2025-10-28 09:04:30 -07:00
const extensions = extensionManager . getExtensions ( ) ;
2025-10-24 10:45:58 -07:00
expect ( extensions ) . toHaveLength ( 1 ) ;
expect ( extensions [ 0 ] . mcpServers ? . [ 'test-server' ] . cwd ) . toBe (
path . join ( sourceExtDir , 'server' ) ,
) ;
2025-10-24 11:55:31 -07:00
expect ( extensions [ 0 ] . mcpServers ? . [ 'test-server' ] . args ) . toEqual ( [
path . join ( sourceExtDir , 'server' , 'index.js' ) ,
] ) ;
2025-10-24 10:45:58 -07:00
} ) ;
2025-10-28 14:48:50 -04:00
it ( 'should resolve environment variables in extension configuration' , async ( ) = > {
2025-10-10 13:40:13 -07:00
process . env [ 'TEST_API_KEY' ] = 'test-api-key-123' ;
process . env [ 'TEST_DB_URL' ] = 'postgresql://localhost:5432/testdb' ;
2025-09-16 15:51:46 -04:00
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 ) ) ;
2025-10-28 14:48:50 -04:00
const extensions = await extensionManager . loadExtensions ( ) ;
2025-09-16 15:51:46 -04:00
expect ( extensions ) . toHaveLength ( 1 ) ;
const extension = extensions [ 0 ] ;
2025-10-08 07:31:41 -07:00
expect ( extension . name ) . toBe ( 'test-extension' ) ;
expect ( extension . mcpServers ) . toBeDefined ( ) ;
2025-09-16 15:51:46 -04:00
2025-10-08 07:31:41 -07:00
const serverConfig = extension . mcpServers ? . [ 'test-server' ] ;
2025-09-16 15:51:46 -04:00
expect ( serverConfig ) . toBeDefined ( ) ;
expect ( serverConfig ? . env ) . toBeDefined ( ) ;
2025-10-10 13:40:13 -07:00
expect ( serverConfig ? . env ? . [ 'API_KEY' ] ) . toBe ( 'test-api-key-123' ) ;
expect ( serverConfig ? . env ? . [ 'DATABASE_URL' ] ) . toBe (
2025-09-16 15:51:46 -04:00
'postgresql://localhost:5432/testdb' ,
) ;
2025-10-10 13:40:13 -07:00
expect ( serverConfig ? . env ? . [ 'STATIC_VALUE' ] ) . toBe ( 'no-substitution' ) ;
2025-09-16 15:51:46 -04:00
} finally {
2025-10-10 13:40:13 -07:00
delete process . env [ 'TEST_API_KEY' ] ;
delete process . env [ 'TEST_DB_URL' ] ;
2025-09-16 15:51:46 -04:00
}
} ) ;
2025-08-29 19:53:39 +02:00
2025-10-28 14:48:50 -04:00
it ( 'should resolve environment variables from an extension .env file' , async ( ) = > {
2025-10-23 11:47:08 -04:00
const extDir = createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'test-extension' ,
version : '1.0.0' ,
mcpServers : {
'test-server' : {
command : 'node' ,
args : [ 'server.js' ] ,
env : {
API_KEY : '$MY_API_KEY' ,
STATIC_VALUE : 'no-substitution' ,
} ,
} ,
} ,
2025-10-28 14:48:50 -04:00
settings : [
{
name : 'My API Key' ,
description : 'API key for testing.' ,
envVar : 'MY_API_KEY' ,
} ,
] ,
2025-10-23 11:47:08 -04:00
} ) ;
const envFilePath = path . join ( extDir , '.env' ) ;
fs . writeFileSync ( envFilePath , 'MY_API_KEY=test-key-from-file\n' ) ;
2025-10-28 14:48:50 -04:00
const extensions = await extensionManager . loadExtensions ( ) ;
2025-10-23 11:47:08 -04:00
expect ( extensions ) . toHaveLength ( 1 ) ;
const extension = extensions [ 0 ] ;
const serverConfig = extension . mcpServers ! [ 'test-server' ] ;
expect ( serverConfig . env ) . toBeDefined ( ) ;
expect ( serverConfig . env ! [ 'API_KEY' ] ) . toBe ( 'test-key-from-file' ) ;
expect ( serverConfig . env ! [ 'STATIC_VALUE' ] ) . toBe ( 'no-substitution' ) ;
} ) ;
2025-10-28 14:48:50 -04:00
it ( 'should handle missing environment variables gracefully' , async ( ) = > {
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-10-28 14:48:50 -04:00
const extensions = await extensionManager . loadExtensions ( ) ;
2025-08-29 19:53:39 +02:00
expect ( extensions ) . toHaveLength ( 1 ) ;
const extension = extensions [ 0 ] ;
2025-10-08 07:31:41 -07:00
const serverConfig = extension . mcpServers ! [ 'test-server' ] ;
2025-09-16 15:51:46 -04:00
expect ( serverConfig . env ) . toBeDefined ( ) ;
2025-10-10 13:40:13 -07:00
expect ( serverConfig . env ! [ 'MISSING_VAR' ] ) . toBe ( '$UNDEFINED_ENV_VAR' ) ;
expect ( serverConfig . env ! [ 'MISSING_VAR_BRACES' ] ) . toBe ( '${ALSO_UNDEFINED}' ) ;
2025-09-16 15:51:46 -04:00
} ) ;
2025-09-19 21:15:40 -04:00
2025-10-28 14:48:50 -04:00
it ( 'should skip extensions with invalid JSON and log a warning' , async ( ) = > {
2025-11-20 20:57:59 -08:00
const consoleSpy = vi
. spyOn ( console , 'error' )
2025-09-22 09:34:52 +00:00
. 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
2025-10-28 14:48:50 -04:00
const extensions = await extensionManager . loadExtensions ( ) ;
2025-09-22 09:34:52 +00:00
expect ( extensions ) . toHaveLength ( 1 ) ;
2025-10-08 07:31:41 -07:00
expect ( extensions [ 0 ] . name ) . toBe ( 'good-ext' ) ;
2025-11-20 20:57:59 -08:00
expect ( consoleSpy ) . toHaveBeenCalledExactlyOnceWith (
2025-09-22 09:34:52 +00:00
expect . stringContaining (
` Warning: Skipping extension in ${ badExtDir } : Failed to load extension config from ${ badConfigPath } ` ,
) ,
) ;
2025-11-20 20:57:59 -08:00
consoleSpy . mockRestore ( ) ;
2025-09-22 09:34:52 +00:00
} ) ;
2025-10-28 14:48:50 -04:00
it ( 'should skip extensions with missing name and log a warning' , async ( ) = > {
2025-11-20 20:57:59 -08:00
const consoleSpy = vi
. spyOn ( console , 'error' )
2025-09-22 09:34:52 +00:00
. 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' } ) ) ;
2025-10-28 14:48:50 -04:00
const extensions = await extensionManager . loadExtensions ( ) ;
2025-09-22 09:34:52 +00:00
expect ( extensions ) . toHaveLength ( 1 ) ;
2025-10-08 07:31:41 -07:00
expect ( extensions [ 0 ] . name ) . toBe ( 'good-ext' ) ;
2025-11-20 20:57:59 -08:00
expect ( consoleSpy ) . toHaveBeenCalledExactlyOnceWith (
2025-09-22 09:34:52 +00:00
expect . stringContaining (
` Warning: Skipping extension in ${ badExtDir } : Failed to load extension config from ${ badConfigPath } : Invalid configuration in ${ badConfigPath } : missing "name" ` ,
) ,
) ;
2025-11-20 20:57:59 -08:00
consoleSpy . mockRestore ( ) ;
2025-09-22 09:34:52 +00:00
} ) ;
2025-10-28 14:48:50 -04:00
it ( 'should filter trust out of mcp servers' , async ( ) = > {
2025-09-19 21:15:40 -04:00
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'test-extension' ,
version : '1.0.0' ,
mcpServers : {
'test-server' : {
command : 'node' ,
args : [ 'server.js' ] ,
trust : true ,
} ,
} ,
} ) ;
2025-10-28 14:48:50 -04:00
const extensions = await extensionManager . loadExtensions ( ) ;
2025-09-19 21:15:40 -04:00
expect ( extensions ) . toHaveLength ( 1 ) ;
2025-10-08 07:31:41 -07:00
expect ( extensions [ 0 ] . mcpServers ? . [ 'test-server' ] . trust ) . toBeUndefined ( ) ;
2025-09-19 21:15:40 -04:00
} ) ;
2025-09-25 14:05:49 -04:00
2025-10-28 14:48:50 -04:00
it ( 'should throw an error for invalid extension names' , async ( ) = > {
2025-09-25 14:05:49 -04:00
const consoleSpy = vi
. spyOn ( console , 'error' )
. mockImplementation ( ( ) = > { } ) ;
2025-10-28 09:04:30 -07:00
createExtension ( {
2025-09-25 14:05:49 -04:00
extensionsDir : userExtensionsDir ,
name : 'bad_name' ,
version : '1.0.0' ,
} ) ;
2025-10-28 14:48:50 -04:00
const extensions = await extensionManager . loadExtensions ( ) ;
const extension = extensions . find ( ( e ) = > e . name === 'bad_name' ) ;
2025-09-25 14:05:49 -04:00
2025-10-28 09:04:30 -07:00
expect ( extension ) . toBeUndefined ( ) ;
2025-11-20 20:57:59 -08:00
expect ( consoleSpy ) . toHaveBeenCalledWith (
expect . stringContaining ( 'Invalid extension name: "bad_name"' ) ,
) ;
2025-09-25 14:05:49 -04:00
consoleSpy . mockRestore ( ) ;
} ) ;
2025-11-20 10:44:02 -08:00
2025-11-20 20:57:59 -08:00
it ( 'should not load github extensions if blockGitExtensions is set' , async ( ) = > {
2025-11-11 18:37:01 +00:00
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'my-ext' ,
version : '1.0.0' ,
installMetadata : {
type : 'git' ,
source : 'http://somehost.com/foo/bar' ,
} ,
} ) ;
2026-01-15 09:26:10 -08:00
const blockGitExtensionsSetting = createTestMergedSettings ( {
security : { blockGitExtensions : true } ,
} ) ;
2025-11-20 20:57:59 -08:00
extensionManager = new ExtensionManager ( {
workspaceDir : tempWorkspaceDir ,
requestConsent : mockRequestConsent ,
requestSetting : mockPromptForSettings ,
settings : blockGitExtensionsSetting ,
2025-10-17 13:29:07 -07:00
} ) ;
2025-11-20 10:44:02 -08:00
const extensions = await extensionManager . loadExtensions ( ) ;
const extension = extensions . find ( ( e ) = > e . name === 'my-ext' ) ;
2025-10-17 13:29:07 -07:00
2025-11-20 20:57:59 -08:00
expect ( extension ) . toBeUndefined ( ) ;
2025-11-20 10:44:02 -08:00
} ) ;
2025-10-17 13:29:07 -07:00
2026-01-08 12:59:30 -05:00
it ( 'should not load any extensions if admin.extensions.enabled is false' , async ( ) = > {
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'test-extension' ,
version : '1.0.0' ,
} ) ;
const loadedSettings = loadSettings ( tempWorkspaceDir ) . merged ;
loadedSettings . admin . extensions . enabled = false ;
extensionManager = new ExtensionManager ( {
workspaceDir : tempWorkspaceDir ,
requestConsent : mockRequestConsent ,
requestSetting : mockPromptForSettings ,
settings : loadedSettings ,
} ) ;
const extensions = await extensionManager . loadExtensions ( ) ;
expect ( extensions ) . toEqual ( [ ] ) ;
} ) ;
it ( 'should not load mcpServers if admin.mcp.enabled is false' , async ( ) = > {
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'test-extension' ,
version : '1.0.0' ,
mcpServers : {
'test-server' : { command : 'echo' , args : [ 'hello' ] } ,
} ,
} ) ;
const loadedSettings = loadSettings ( tempWorkspaceDir ) . merged ;
loadedSettings . admin . mcp . enabled = false ;
extensionManager = new ExtensionManager ( {
workspaceDir : tempWorkspaceDir ,
requestConsent : mockRequestConsent ,
requestSetting : mockPromptForSettings ,
settings : loadedSettings ,
} ) ;
const extensions = await extensionManager . loadExtensions ( ) ;
expect ( extensions ) . toHaveLength ( 1 ) ;
expect ( extensions [ 0 ] . mcpServers ) . toBeUndefined ( ) ;
} ) ;
it ( 'should load mcpServers if admin.mcp.enabled is true' , async ( ) = > {
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'test-extension' ,
version : '1.0.0' ,
mcpServers : {
'test-server' : { command : 'echo' , args : [ 'hello' ] } ,
} ,
} ) ;
const loadedSettings = loadSettings ( tempWorkspaceDir ) . merged ;
loadedSettings . admin . mcp . enabled = true ;
extensionManager = new ExtensionManager ( {
workspaceDir : tempWorkspaceDir ,
requestConsent : mockRequestConsent ,
requestSetting : mockPromptForSettings ,
settings : loadedSettings ,
} ) ;
const extensions = await extensionManager . loadExtensions ( ) ;
expect ( extensions ) . toHaveLength ( 1 ) ;
expect ( extensions [ 0 ] . mcpServers ) . toEqual ( {
'test-server' : { command : 'echo' , args : [ 'hello' ] } ,
} ) ;
} ) ;
2025-11-20 20:57:59 -08:00
describe ( 'id generation' , ( ) = > {
it . each ( [
{
description : 'should generate id from source for non-github git urls' ,
installMetadata : {
type : 'git' as const ,
source : 'http://somehost.com/foo/bar' ,
} ,
expectedIdSource : 'http://somehost.com/foo/bar' ,
2025-11-20 10:44:02 -08:00
} ,
2025-11-20 20:57:59 -08:00
{
description :
'should generate id from owner/repo for github http urls' ,
installMetadata : {
type : 'git' as const ,
source : 'http://github.com/foo/bar' ,
} ,
expectedIdSource : 'https://github.com/foo/bar' ,
2025-11-20 10:44:02 -08:00
} ,
2025-11-20 20:57:59 -08:00
{
description : 'should generate id from owner/repo for github ssh urls' ,
installMetadata : {
type : 'git' as const ,
source : 'git@github.com:foo/bar' ,
} ,
expectedIdSource : 'https://github.com/foo/bar' ,
} ,
{
description :
'should generate id from source for github-release extension' ,
installMetadata : {
type : 'github-release' as const ,
source : 'https://github.com/foo/bar' ,
} ,
expectedIdSource : 'https://github.com/foo/bar' ,
} ,
{
description :
'should generate id from the original source for local extension' ,
installMetadata : {
type : 'local' as const ,
source : '/some/path' ,
} ,
expectedIdSource : '/some/path' ,
} ,
] ) ( '$description' , async ( { installMetadata , expectedIdSource } ) = > {
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'my-ext' ,
version : '1.0.0' ,
installMetadata ,
} ) ;
const extensions = await extensionManager . loadExtensions ( ) ;
const extension = extensions . find ( ( e ) = > e . name === 'my-ext' ) ;
expect ( extension ? . id ) . toBe ( hashValue ( expectedIdSource ) ) ;
2025-10-17 13:29:07 -07:00
} ) ;
2025-11-20 20:57:59 -08:00
it ( 'should generate id from the original source for linked extensions' , async ( ) = > {
const extDevelopmentDir = path . join ( tempHomeDir , 'local_extensions' ) ;
const actualExtensionDir = createExtension ( {
extensionsDir : extDevelopmentDir ,
name : 'link-ext-name' ,
version : '1.0.0' ,
} ) ;
await extensionManager . loadExtensions ( ) ;
await extensionManager . installOrUpdateExtension ( {
type : 'link' ,
source : actualExtensionDir ,
} ) ;
2025-10-17 13:29:07 -07:00
2025-11-20 20:57:59 -08:00
const extension = extensionManager
. getExtensions ( )
. find ( ( e ) = > e . name === 'link-ext-name' ) ;
expect ( extension ? . id ) . toBe ( hashValue ( actualExtensionDir ) ) ;
2025-10-17 13:29:07 -07:00
} ) ;
2025-11-20 20:57:59 -08:00
it ( 'should generate id from name for extension with no install metadata' , async ( ) = > {
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'no-meta-name' ,
version : '1.0.0' ,
} ) ;
const extensions = await extensionManager . loadExtensions ( ) ;
const extension = extensions . find ( ( e ) = > e . name === 'no-meta-name' ) ;
expect ( extension ? . id ) . toBe ( hashValue ( 'no-meta-name' ) ) ;
2025-10-17 13:29:07 -07:00
} ) ;
2025-12-03 15:07:37 -05:00
it ( 'should load extension hooks and hydrate variables' , async ( ) = > {
const extDir = createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'hook-extension' ,
version : '1.0.0' ,
} ) ;
const hooksDir = path . join ( extDir , 'hooks' ) ;
fs . mkdirSync ( hooksDir ) ;
const hooksConfig = {
hooks : {
BeforeTool : [
{
matcher : '.*' ,
hooks : [
{
type : 'command' ,
command : 'echo ${extensionPath}' ,
} ,
] ,
} ,
] ,
} ,
} ;
fs . writeFileSync (
path . join ( hooksDir , 'hooks.json' ) ,
JSON . stringify ( hooksConfig ) ,
) ;
2025-12-08 20:51:42 -05:00
const settings = loadSettings ( tempWorkspaceDir ) . merged ;
2026-01-06 13:33:37 -08:00
settings . hooks . enabled = true ;
2025-12-08 20:51:42 -05:00
extensionManager = new ExtensionManager ( {
workspaceDir : tempWorkspaceDir ,
requestConsent : mockRequestConsent ,
requestSetting : mockPromptForSettings ,
settings ,
} ) ;
2025-12-03 15:07:37 -05:00
const extensions = await extensionManager . loadExtensions ( ) ;
expect ( extensions ) . toHaveLength ( 1 ) ;
const extension = extensions [ 0 ] ;
expect ( extension . hooks ) . toBeDefined ( ) ;
expect ( extension . hooks ? . BeforeTool ) . toHaveLength ( 1 ) ;
expect ( extension . hooks ? . BeforeTool ? . [ 0 ] . hooks [ 0 ] . command ) . toBe (
` echo ${ extDir } ` ,
) ;
} ) ;
2026-01-06 13:33:37 -08:00
it ( 'should not load hooks if hooks.enabled is false' , async ( ) = > {
2025-12-08 20:51:42 -05:00
const extDir = createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'hook-extension-disabled' ,
version : '1.0.0' ,
} ) ;
const hooksDir = path . join ( extDir , 'hooks' ) ;
fs . mkdirSync ( hooksDir ) ;
fs . writeFileSync (
path . join ( hooksDir , 'hooks.json' ) ,
JSON . stringify ( { hooks : { BeforeTool : [ ] } } ) ,
) ;
const settings = loadSettings ( tempWorkspaceDir ) . merged ;
2026-01-06 13:33:37 -08:00
settings . hooks . enabled = false ;
2025-12-08 20:51:42 -05:00
extensionManager = new ExtensionManager ( {
workspaceDir : tempWorkspaceDir ,
requestConsent : mockRequestConsent ,
requestSetting : mockPromptForSettings ,
settings ,
} ) ;
const extensions = await extensionManager . loadExtensions ( ) ;
expect ( extensions ) . toHaveLength ( 1 ) ;
expect ( extensions [ 0 ] . hooks ) . toBeUndefined ( ) ;
} ) ;
2025-12-03 15:07:37 -05:00
it ( 'should warn about hooks during installation' , async ( ) = > {
const requestConsentSpy = vi . fn ( ) . mockResolvedValue ( true ) ;
extensionManager . setRequestConsent ( requestConsentSpy ) ;
const sourceExtDir = path . join (
tempWorkspaceDir ,
'hook-extension-source' ,
) ;
fs . mkdirSync ( sourceExtDir , { recursive : true } ) ;
const hooksDir = path . join ( sourceExtDir , 'hooks' ) ;
fs . mkdirSync ( hooksDir ) ;
fs . writeFileSync (
path . join ( hooksDir , 'hooks.json' ) ,
JSON . stringify ( { hooks : { } } ) ,
) ;
fs . writeFileSync (
path . join ( sourceExtDir , 'gemini-extension.json' ) ,
JSON . stringify ( {
name : 'hook-extension-install' ,
version : '1.0.0' ,
} ) ,
) ;
await extensionManager . loadExtensions ( ) ;
await extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'local' ,
} ) ;
expect ( requestConsentSpy ) . toHaveBeenCalledWith (
expect . stringContaining ( '⚠️ This extension contains Hooks' ) ,
) ;
} ) ;
2025-10-17 13:29:07 -07:00
} ) ;
2025-08-29 19:53:39 +02: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-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-23 11:39:36 -07:00
await extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'local' ,
} ) ;
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-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-23 11:39:36 -07:00
await extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'local' ,
} ) ;
2025-09-16 15:51:46 -04:00
await expect (
2025-10-23 11:39:36 -07:00
extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'local' ,
} ) ,
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-10-23 11:39:36 -07:00
extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'local' ,
} ) ,
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-10-23 11:39:36 -07:00
extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'local' ,
} ) ,
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-10-23 11:39:36 -07:00
extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'local' ,
} ) ,
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 ( ) = > {
2025-10-09 15:06:06 -07:00
const gitUrl = 'https://somehost.com/somerepo.git' ;
const extensionName = 'some-extension' ;
2025-09-16 15:51:46 -04:00
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-10-15 14:29:16 -07:00
mockDownloadFromGithubRelease . mockResolvedValue ( {
success : false ,
failureReason : 'no release data' ,
type : 'github-release' ,
} ) ;
2025-08-25 17:02:10 +00:00
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-23 11:39:36 -07:00
await extensionManager . installOrUpdateExtension ( {
source : gitUrl ,
type : 'git' ,
} ) ;
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' ,
} ) ;
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-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-23 11:39:36 -07:00
await extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'link' ,
} ) ;
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-11-11 18:37:01 +00:00
it ( 'should not install a github extension if blockGitExtensions is set' , async ( ) = > {
const gitUrl = 'https://somehost.com/somerepo.git' ;
2026-01-15 09:26:10 -08:00
const blockGitExtensionsSetting = createTestMergedSettings ( {
security : { blockGitExtensions : true } ,
} ) ;
2025-11-11 18:37:01 +00:00
extensionManager = new ExtensionManager ( {
workspaceDir : tempWorkspaceDir ,
requestConsent : mockRequestConsent ,
requestSetting : mockPromptForSettings ,
settings : blockGitExtensionsSetting ,
} ) ;
await extensionManager . loadExtensions ( ) ;
await expect (
extensionManager . installOrUpdateExtension ( {
source : gitUrl ,
type : 'git' ,
} ) ,
) . rejects . toThrow (
'Installing extensions from remote sources is disallowed by your current settings.' ,
) ;
} ) ;
2025-10-31 19:17:01 +00:00
it ( 'should prompt for trust if workspace is not trusted' , async ( ) = > {
vi . mocked ( isWorkspaceTrusted ) . mockReturnValue ( {
isTrusted : false ,
source : undefined ,
} ) ;
const sourceExtDir = createExtension ( {
extensionsDir : tempHomeDir ,
name : 'my-local-extension' ,
version : '1.0.0' ,
} ) ;
await extensionManager . loadExtensions ( ) ;
await extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'local' ,
} ) ;
expect ( mockRequestConsent ) . toHaveBeenCalledWith (
` The current workspace at " ${ tempWorkspaceDir } " is not trusted. Do you want to trust this workspace to install extensions? ` ,
) ;
} ) ;
it ( 'should not install if user denies trust' , async ( ) = > {
vi . mocked ( isWorkspaceTrusted ) . mockReturnValue ( {
isTrusted : false ,
source : undefined ,
} ) ;
mockRequestConsent . mockImplementation ( async ( message ) = > {
if (
message . includes (
'is not trusted. Do you want to trust this workspace to install extensions?' ,
)
) {
return false ;
}
return true ;
} ) ;
const sourceExtDir = createExtension ( {
extensionsDir : tempHomeDir ,
name : 'my-local-extension' ,
version : '1.0.0' ,
} ) ;
await extensionManager . loadExtensions ( ) ;
await expect (
extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'local' ,
} ) ,
) . rejects . toThrow (
` Could not install extension because the current workspace at ${ tempWorkspaceDir } is not trusted. ` ,
) ;
} ) ;
it ( 'should add the workspace to trusted folders if user consents' , async ( ) = > {
const trustedFoldersPath = path . join (
tempHomeDir ,
'.gemini' ,
'trustedFolders.json' ,
) ;
vi . mocked ( isWorkspaceTrusted ) . mockReturnValue ( {
isTrusted : false ,
source : undefined ,
} ) ;
const sourceExtDir = createExtension ( {
extensionsDir : tempHomeDir ,
name : 'my-local-extension' ,
version : '1.0.0' ,
} ) ;
await extensionManager . loadExtensions ( ) ;
await extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'local' ,
} ) ;
expect ( fs . existsSync ( trustedFoldersPath ) ) . toBe ( true ) ;
const trustedFolders = JSON . parse (
fs . readFileSync ( trustedFoldersPath , 'utf-8' ) ,
) ;
expect ( trustedFolders [ tempWorkspaceDir ] ) . toBe ( 'TRUST_FOLDER' ) ;
} ) ;
2025-10-10 14:28:13 -07:00
describe . each ( [ true , false ] ) (
'with previous extension config: %s' ,
( isUpdate : boolean ) = > {
let sourceExtDir : string ;
beforeEach ( async ( ) = > {
sourceExtDir = createExtension ( {
extensionsDir : tempHomeDir ,
name : 'my-local-extension' ,
version : '1.1.0' ,
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-10 14:28:13 -07:00
if ( isUpdate ) {
2025-10-23 11:39:36 -07:00
await extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'local' ,
} ) ;
2025-10-10 14:28:13 -07:00
}
// Clears out any calls to mocks from the above function calls.
vi . clearAllMocks ( ) ;
} ) ;
2025-08-25 17:02:10 +00:00
2025-10-10 14:28:13 -07:00
it ( ` should log an ${ isUpdate ? 'update' : 'install' } event to clearcut on success ` , async ( ) = > {
2025-10-23 11:39:36 -07:00
await extensionManager . installOrUpdateExtension (
2025-10-10 14:28:13 -07:00
{ source : sourceExtDir , type : 'local' } ,
isUpdate
? {
name : 'my-local-extension' ,
version : '1.0.0' ,
}
: undefined ,
) ;
if ( isUpdate ) {
expect ( mockLogExtensionUpdateEvent ) . toHaveBeenCalled ( ) ;
expect ( mockLogExtensionInstallEvent ) . not . toHaveBeenCalled ( ) ;
} else {
expect ( mockLogExtensionInstallEvent ) . toHaveBeenCalled ( ) ;
expect ( mockLogExtensionUpdateEvent ) . not . toHaveBeenCalled ( ) ;
}
} ) ;
2025-09-02 10:15:42 -07:00
2025-10-10 14:28:13 -07:00
it ( ` should ${ isUpdate ? 'not ' : '' } alter the extension enablement configuration ` , async ( ) = > {
2025-10-17 16:08:57 -07:00
const enablementManager = new ExtensionEnablementManager ( ) ;
2025-10-10 14:28:13 -07:00
enablementManager . enable ( 'my-local-extension' , true , '/some/scope' ) ;
2025-10-23 11:39:36 -07:00
await extensionManager . installOrUpdateExtension (
2025-10-10 14:28:13 -07:00
{ source : sourceExtDir , type : 'local' } ,
isUpdate
? {
name : 'my-local-extension' ,
version : '1.0.0' ,
}
: undefined ,
) ;
const config = enablementManager . readConfig ( ) [ 'my-local-extension' ] ;
if ( isUpdate ) {
expect ( config ) . not . toBeUndefined ( ) ;
expect ( config . overrides ) . toContain ( '/some/scope/*' ) ;
} else {
expect ( config ) . not . toContain ( '/some/scope/*' ) ;
}
} ) ;
} ,
) ;
2025-09-02 10:15:42 -07:00
2025-10-08 22:41:22 +02:00
it ( 'should show users information on their ansi escaped mcp servers when installing' , async ( ) = > {
2025-09-16 15:51:46 -04:00
const sourceExtDir = createExtension ( {
extensionsDir : tempHomeDir ,
name : 'my-local-extension' ,
version : '1.0.0' ,
mcpServers : {
'test-server' : {
2025-10-08 22:41:22 +02:00
command : 'node dobadthing \u001b[12D\u001b[K' ,
2025-09-16 15:51:46 -04:00
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-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-09-16 15:51:46 -04:00
await expect (
2025-10-23 11:39:36 -07:00
extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'local' ,
} ) ,
2025-10-28 09:04:30 -07:00
) . resolves . toMatchObject ( {
name : 'my-local-extension' ,
} ) ;
2025-09-02 10:15:42 -07:00
2025-10-09 15:06:06 -07:00
expect ( mockRequestConsent ) . toHaveBeenCalledWith (
2025-09-29 14:19:19 -07:00
` Installing extension "my-local-extension".
2025-09-22 19:50:12 -04:00
This extension will run the following MCP servers :
2025-10-08 22:41:22 +02:00
* test - server ( local ) : node dobadthing \ \ u001b [ 12 D \ \ u001b [ K server . js
2026-01-14 17:47:02 -08:00
* test - server - 2 ( remote ) : https : //google.com
$ { INSTALL_WARNING_MESSAGE } ` ,
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' ] ,
} ,
} ,
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-09-16 15:51:46 -04:00
await expect (
2025-10-23 11:39:36 -07:00
extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'local' ,
} ) ,
2025-10-28 09:04:30 -07:00
) . resolves . toMatchObject ( { name : 'my-local-extension' } ) ;
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' ] ,
} ,
} ,
} ) ;
2025-10-23 11:39:36 -07:00
mockRequestConsent . mockResolvedValue ( false ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-09-16 15:51:46 -04:00
await expect (
2025-10-23 11:39:36 -07:00
extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'local' ,
} ) ,
2025-09-29 14:19:19 -07:00
) . rejects . toThrow ( 'Installation cancelled for "my-local-extension".' ) ;
2025-09-16 15:51:46 -04:00
} ) ;
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-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-23 11:39:36 -07:00
await extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'local' ,
autoUpdate : 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-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-23 11:39:36 -07:00
// Install it with hard coded consent first.
await extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'local' ,
} ) ;
expect ( mockRequestConsent ) . toHaveBeenCalledOnce ( ) ;
2025-09-25 10:57:59 -07:00
2025-10-10 14:28:13 -07:00
// Now update it without changing anything.
2025-09-17 09:24:38 -04:00
await expect (
2025-10-23 11:39:36 -07:00
extensionManager . installOrUpdateExtension (
2025-09-25 10:57:59 -07:00
{ source : sourceExtDir , type : 'local' } ,
// Provide its own existing config as the previous config.
2025-10-23 11:39:36 -07:00
await extensionManager . loadExtensionConfig ( sourceExtDir ) ,
2025-09-25 10:57:59 -07:00
) ,
2025-10-28 09:04:30 -07:00
) . resolves . toMatchObject ( { name : 'my-local-extension' } ) ;
2025-09-25 10:57:59 -07:00
2025-10-23 11:39:36 -07:00
// Still only called once
expect ( mockRequestConsent ) . toHaveBeenCalledOnce ( ) ;
2025-09-17 09:24:38 -04:00
} ) ;
2025-09-25 14:05:49 -04:00
2025-10-23 11:47:08 -04:00
it ( 'should prompt for settings if promptForSettings' , async ( ) = > {
const sourceExtDir = createExtension ( {
extensionsDir : tempHomeDir ,
name : 'my-local-extension' ,
version : '1.0.0' ,
settings : [
{
name : 'API Key' ,
description : 'Your API key for the service.' ,
envVar : 'MY_API_KEY' ,
} ,
] ,
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-23 11:39:36 -07:00
await extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'local' ,
} ) ;
2025-10-23 11:47:08 -04:00
2025-10-23 11:39:36 -07:00
expect ( mockPromptForSettings ) . toHaveBeenCalled ( ) ;
2025-10-23 11:47:08 -04:00
} ) ;
it ( 'should not prompt for settings if promptForSettings is false' , async ( ) = > {
const sourceExtDir = createExtension ( {
extensionsDir : tempHomeDir ,
name : 'my-local-extension' ,
version : '1.0.0' ,
settings : [
{
name : 'API Key' ,
description : 'Your API key for the service.' ,
envVar : 'MY_API_KEY' ,
} ,
] ,
} ) ;
2025-10-23 11:39:36 -07:00
extensionManager = new ExtensionManager ( {
workspaceDir : tempWorkspaceDir ,
requestConsent : mockRequestConsent ,
requestSetting : null ,
2025-10-28 09:04:30 -07:00
settings : loadSettings ( tempWorkspaceDir ) . merged ,
2025-10-23 11:39:36 -07:00
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-23 11:39:36 -07:00
await extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'local' ,
} ) ;
2025-10-23 11:47:08 -04:00
} ) ;
it ( 'should only prompt for new settings on update, and preserve old settings' , async ( ) = > {
// 1. Create and install the "old" version of the extension.
const oldSourceExtDir = createExtension ( {
extensionsDir : tempHomeDir , // Create it in a temp location first
name : 'my-local-extension' ,
version : '1.0.0' ,
settings : [
{
name : 'API Key' ,
description : 'Your API key for the service.' ,
envVar : 'MY_API_KEY' ,
} ,
] ,
} ) ;
2025-10-23 11:39:36 -07:00
mockPromptForSettings . mockResolvedValueOnce ( 'old-api-key' ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-23 11:47:08 -04:00
// Install it so it exists in the userExtensionsDir
2025-10-23 11:39:36 -07:00
await extensionManager . installOrUpdateExtension ( {
source : oldSourceExtDir ,
type : 'local' ,
} ) ;
2025-10-23 11:47:08 -04:00
const envPath = new ExtensionStorage (
'my-local-extension' ,
) . getEnvFilePath ( ) ;
expect ( fs . existsSync ( envPath ) ) . toBe ( true ) ;
let envContent = fs . readFileSync ( envPath , 'utf-8' ) ;
expect ( envContent ) . toContain ( 'MY_API_KEY=old-api-key' ) ;
2025-10-23 11:39:36 -07:00
expect ( mockPromptForSettings ) . toHaveBeenCalledTimes ( 1 ) ;
2025-10-23 11:47:08 -04:00
// 2. Create the "new" version of the extension in a new source directory.
const newSourceExtDir = createExtension ( {
extensionsDir : path.join ( tempHomeDir , 'new-source' ) , // Another temp location
name : 'my-local-extension' , // Same name
version : '1.1.0' , // New version
settings : [
{
name : 'API Key' ,
description : 'Your API key for the service.' ,
envVar : 'MY_API_KEY' ,
} ,
{
name : 'New Setting' ,
description : 'A new setting.' ,
envVar : 'NEW_SETTING' ,
} ,
] ,
} ) ;
2025-12-16 21:28:18 -08:00
const previousExtensionConfig =
await extensionManager . loadExtensionConfig (
path . join ( userExtensionsDir , 'my-local-extension' ) ,
) ;
2025-10-23 11:39:36 -07:00
mockPromptForSettings . mockResolvedValueOnce ( 'new-setting-value' ) ;
2025-10-23 11:47:08 -04:00
// 3. Call installOrUpdateExtension to perform the update.
2025-10-23 11:39:36 -07:00
await extensionManager . installOrUpdateExtension (
2025-10-23 11:47:08 -04:00
{ source : newSourceExtDir , type : 'local' } ,
previousExtensionConfig ,
) ;
2025-10-23 11:39:36 -07:00
expect ( mockPromptForSettings ) . toHaveBeenCalledTimes ( 2 ) ;
expect ( mockPromptForSettings ) . toHaveBeenCalledWith (
2025-10-23 11:47:08 -04:00
expect . objectContaining ( { name : 'New Setting' } ) ,
) ;
expect ( fs . existsSync ( envPath ) ) . toBe ( true ) ;
envContent = fs . readFileSync ( envPath , 'utf-8' ) ;
expect ( envContent ) . toContain ( 'MY_API_KEY=old-api-key' ) ;
expect ( envContent ) . toContain ( 'NEW_SETTING=new-setting-value' ) ;
} ) ;
2026-01-06 12:26:01 -05:00
it ( 'should auto-update if settings have changed' , async ( ) = > {
2025-10-23 11:47:08 -04:00
// 1. Install initial version with autoUpdate: true
const oldSourceExtDir = createExtension ( {
extensionsDir : tempHomeDir ,
name : 'my-auto-update-ext' ,
version : '1.0.0' ,
settings : [
{
name : 'OLD_SETTING' ,
envVar : 'OLD_SETTING' ,
description : 'An old setting' ,
} ,
] ,
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-23 11:39:36 -07:00
await extensionManager . installOrUpdateExtension ( {
source : oldSourceExtDir ,
type : 'local' ,
autoUpdate : true ,
} ) ;
2025-10-23 11:47:08 -04:00
// 2. Create new version with different settings
2026-01-06 12:26:01 -05:00
const extensionDir = createExtension ( {
2025-10-23 11:47:08 -04:00
extensionsDir : tempHomeDir ,
name : 'my-auto-update-ext' ,
version : '1.1.0' ,
settings : [
{
name : 'NEW_SETTING' ,
envVar : 'NEW_SETTING' ,
description : 'A new setting' ,
} ,
] ,
} ) ;
2025-12-16 21:28:18 -08:00
const previousExtensionConfig =
await extensionManager . loadExtensionConfig (
path . join ( userExtensionsDir , 'my-auto-update-ext' ) ,
) ;
2025-10-23 11:47:08 -04:00
// 3. Attempt to update and assert it fails
2026-01-06 12:26:01 -05:00
const updatedExtension = await extensionManager . installOrUpdateExtension (
{
source : extensionDir ,
type : 'local' ,
autoUpdate : true ,
} ,
previousExtensionConfig ,
2025-10-23 11:47:08 -04:00
) ;
2026-01-06 12:26:01 -05:00
expect ( updatedExtension . version ) . toBe ( '1.1.0' ) ;
expect ( extensionManager . getExtensions ( ) [ 0 ] . version ) . toBe ( '1.1.0' ) ;
2025-10-23 11:47:08 -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 (
2025-10-23 11:39:36 -07:00
extensionManager . installOrUpdateExtension ( {
source : sourceExtDir ,
type : 'local' ,
} ) ,
2025-09-25 14:05:49 -04:00
) . rejects . toThrow ( 'Invalid extension name: "bad_name"' ) ;
} ) ;
2025-10-15 14:29:16 -07:00
describe ( 'installing from github' , ( ) = > {
const gitUrl = 'https://github.com/google/gemini-test-extension.git' ;
const extensionName = 'gemini-test-extension' ;
beforeEach ( ( ) = > {
// Mock the git clone behavior for github installs that fallback to it.
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' } ] ) ;
} ) ;
afterEach ( ( ) = > {
vi . restoreAllMocks ( ) ;
} ) ;
it ( 'should install from a github release successfully' , async ( ) = > {
const targetExtDir = path . join ( userExtensionsDir , extensionName ) ;
mockDownloadFromGithubRelease . mockResolvedValue ( {
success : true ,
tagName : 'v1.0.0' ,
type : 'github-release' ,
} ) ;
const tempDir = path . join ( tempHomeDir , 'temp-ext' ) ;
fs . mkdirSync ( tempDir , { recursive : true } ) ;
createExtension ( {
extensionsDir : tempDir ,
name : extensionName ,
version : '1.0.0' ,
} ) ;
vi . spyOn ( ExtensionStorage , 'createTmpDir' ) . mockResolvedValue (
join ( tempDir , extensionName ) ,
) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-23 11:39:36 -07:00
await extensionManager . installOrUpdateExtension ( {
source : gitUrl ,
type : 'github-release' ,
} ) ;
2025-10-15 14:29:16 -07:00
expect ( fs . existsSync ( targetExtDir ) ) . toBe ( true ) ;
const metadataPath = path . join ( targetExtDir , INSTALL_METADATA_FILENAME ) ;
expect ( fs . existsSync ( metadataPath ) ) . toBe ( true ) ;
const metadata = JSON . parse ( fs . readFileSync ( metadataPath , 'utf-8' ) ) ;
expect ( metadata ) . toEqual ( {
source : gitUrl ,
type : 'github-release' ,
releaseTag : 'v1.0.0' ,
} ) ;
} ) ;
it ( 'should fallback to git clone if github release download fails and user consents' , async ( ) = > {
mockDownloadFromGithubRelease . mockResolvedValue ( {
success : false ,
failureReason : 'failed to download asset' ,
errorMessage : 'download failed' ,
type : 'github-release' ,
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-23 11:39:36 -07:00
await extensionManager . installOrUpdateExtension (
2025-10-15 14:29:16 -07:00
{ source : gitUrl , type : 'github-release' } , // Use github-release to force consent
) ;
// It gets called once to ask for a git clone, and once to consent to
// the actual extension features.
2025-10-23 11:39:36 -07:00
expect ( mockRequestConsent ) . toHaveBeenCalledTimes ( 2 ) ;
expect ( mockRequestConsent ) . toHaveBeenCalledWith (
2025-10-15 14:29:16 -07:00
expect . stringContaining (
'Would you like to attempt to install via "git clone" instead?' ,
) ,
) ;
expect ( mockGit . clone ) . toHaveBeenCalled ( ) ;
const metadataPath = path . join (
userExtensionsDir ,
extensionName ,
INSTALL_METADATA_FILENAME ,
) ;
const metadata = JSON . parse ( fs . readFileSync ( metadataPath , 'utf-8' ) ) ;
expect ( metadata . type ) . toBe ( 'git' ) ;
} ) ;
it ( 'should throw an error if github release download fails and user denies consent' , async ( ) = > {
mockDownloadFromGithubRelease . mockResolvedValue ( {
success : false ,
errorMessage : 'download failed' ,
type : 'github-release' ,
} ) ;
2025-10-23 11:39:36 -07:00
mockRequestConsent . mockResolvedValue ( false ) ;
2025-10-15 14:29:16 -07:00
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-15 14:29:16 -07:00
await expect (
2025-10-23 11:39:36 -07:00
extensionManager . installOrUpdateExtension ( {
source : gitUrl ,
type : 'github-release' ,
} ) ,
2025-10-15 14:29:16 -07:00
) . rejects . toThrow (
` Failed to install extension ${ gitUrl } : download failed ` ,
) ;
2025-10-23 11:39:36 -07:00
expect ( mockRequestConsent ) . toHaveBeenCalledExactlyOnceWith (
2025-10-15 14:29:16 -07:00
expect . stringContaining (
'Would you like to attempt to install via "git clone" instead?' ,
) ,
) ;
expect ( mockGit . clone ) . not . toHaveBeenCalled ( ) ;
} ) ;
it ( 'should fallback to git clone without consent if no release data is found on first install' , async ( ) = > {
mockDownloadFromGithubRelease . mockResolvedValue ( {
success : false ,
failureReason : 'no release data' ,
type : 'github-release' ,
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-23 11:39:36 -07:00
await extensionManager . installOrUpdateExtension ( {
source : gitUrl ,
type : 'git' ,
} ) ;
2025-10-15 14:29:16 -07:00
// We should not see the request to use git clone, this is a repo that
// has no github releases so it is the only install method.
2025-10-23 11:39:36 -07:00
expect ( mockRequestConsent ) . toHaveBeenCalledExactlyOnceWith (
2025-10-15 14:29:16 -07:00
expect . stringContaining (
'Installing extension "gemini-test-extension"' ,
) ,
) ;
expect ( mockGit . clone ) . toHaveBeenCalled ( ) ;
const metadataPath = path . join (
userExtensionsDir ,
extensionName ,
INSTALL_METADATA_FILENAME ,
) ;
const metadata = JSON . parse ( fs . readFileSync ( metadataPath , 'utf-8' ) ) ;
expect ( metadata . type ) . toBe ( 'git' ) ;
} ) ;
it ( 'should ask for consent if no release data is found for an existing github-release extension' , async ( ) = > {
mockDownloadFromGithubRelease . mockResolvedValue ( {
success : false ,
failureReason : 'no release data' ,
errorMessage : 'No release data found' ,
type : 'github-release' ,
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-23 11:39:36 -07:00
await extensionManager . installOrUpdateExtension (
2025-10-15 14:29:16 -07:00
{ source : gitUrl , type : 'github-release' } , // Note the type
) ;
2025-10-23 11:39:36 -07:00
expect ( mockRequestConsent ) . toHaveBeenCalledWith (
2025-10-15 14:29:16 -07:00
expect . stringContaining (
'Would you like to attempt to install via "git clone" instead?' ,
) ,
) ;
expect ( mockGit . clone ) . toHaveBeenCalled ( ) ;
} ) ;
} ) ;
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' ,
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-23 11:39:36 -07:00
await extensionManager . uninstallExtension ( 'my-local-extension' , false ) ;
2025-09-16 15:51:46 -04:00
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-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-23 11:39:36 -07:00
await extensionManager . uninstallExtension ( 'my-local-extension' , false ) ;
2025-09-15 11:11:14 -04:00
2025-09-16 15:51:46 -04:00
expect ( fs . existsSync ( sourceExtDir ) ) . toBe ( false ) ;
2025-10-28 09:04:30 -07:00
expect ( extensionManager . getExtensions ( ) ) . toHaveLength ( 1 ) ;
2025-09-16 15:51:46 -04:00
expect ( fs . existsSync ( otherExtDir ) ) . toBe ( true ) ;
} ) ;
2025-09-15 11:11:14 -04:00
2025-11-11 10:19:06 -08:00
it ( 'should uninstall an extension on non-matching extension directory name' , async ( ) = > {
// Create an extension with a name that differs from the directory name.
const sourceExtDir = createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'My-Local-Extension' ,
version : '1.0.0' ,
} ) ;
const newSourceExtDir = path . join (
userExtensionsDir ,
'my-local-extension' ,
) ;
fs . renameSync ( sourceExtDir , newSourceExtDir ) ;
const otherExtDir = createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'other-extension' ,
version : '1.0.0' ,
} ) ;
await extensionManager . loadExtensions ( ) ;
await extensionManager . uninstallExtension ( 'my-local-extension' , false ) ;
expect ( fs . existsSync ( sourceExtDir ) ) . toBe ( false ) ;
expect ( fs . existsSync ( newSourceExtDir ) ) . toBe ( false ) ;
expect ( extensionManager . getExtensions ( ) ) . toHaveLength ( 1 ) ;
expect ( fs . existsSync ( otherExtDir ) ) . toBe ( true ) ;
} ) ;
2025-09-16 15:51:46 -04:00
it ( 'should throw an error if the extension does not exist' , async ( ) = > {
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-10 14:28:13 -07:00
await expect (
2025-10-23 11:39:36 -07:00
extensionManager . uninstallExtension ( 'nonexistent-extension' , false ) ,
2025-10-10 14:28:13 -07:00
) . rejects . toThrow ( 'Extension not found.' ) ;
2025-09-12 10:53:30 -04:00
} ) ;
2025-10-10 14:28:13 -07:00
describe . each ( [ true , false ] ) ( 'with isUpdate: %s' , ( isUpdate : boolean ) = > {
it ( ` should ${ isUpdate ? 'not ' : '' } log uninstall event ` , async ( ) = > {
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'my-local-extension' ,
version : '1.0.0' ,
2025-10-21 16:55:16 -04:00
installMetadata : {
source : userExtensionsDir ,
type : 'local' ,
} ,
2025-10-10 14:28:13 -07:00
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-23 11:39:36 -07:00
await extensionManager . uninstallExtension (
'my-local-extension' ,
isUpdate ,
) ;
2025-10-10 14:28:13 -07:00
if ( isUpdate ) {
expect ( mockLogExtensionUninstall ) . not . toHaveBeenCalled ( ) ;
expect ( ExtensionUninstallEvent ) . not . toHaveBeenCalled ( ) ;
} else {
expect ( mockLogExtensionUninstall ) . toHaveBeenCalled ( ) ;
expect ( ExtensionUninstallEvent ) . toHaveBeenCalledWith (
2025-12-15 09:40:54 -08:00
'my-local-extension' ,
2025-10-21 16:55:16 -04:00
hashValue ( 'my-local-extension' ) ,
hashValue ( userExtensionsDir ) ,
2025-10-10 14:28:13 -07:00
'success' ,
) ;
}
2025-09-16 15:51:46 -04:00
} ) ;
2025-09-12 10:53:30 -04:00
2025-10-10 14:28:13 -07:00
it ( ` should ${ isUpdate ? 'not ' : '' } alter the extension enablement configuration ` , async ( ) = > {
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'test-extension' ,
version : '1.0.0' ,
} ) ;
2025-10-17 16:08:57 -07:00
const enablementManager = new ExtensionEnablementManager ( ) ;
2025-10-10 14:28:13 -07:00
enablementManager . enable ( 'test-extension' , true , '/some/scope' ) ;
2025-09-12 10:53:30 -04:00
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-23 11:39:36 -07:00
await extensionManager . uninstallExtension ( 'test-extension' , isUpdate ) ;
2025-10-10 14:28:13 -07:00
const config = enablementManager . readConfig ( ) [ 'test-extension' ] ;
if ( isUpdate ) {
expect ( config ) . not . toBeUndefined ( ) ;
expect ( config . overrides ) . toEqual ( [ '/some/scope/*' ] ) ;
} else {
expect ( config ) . toBeUndefined ( ) ;
}
} ) ;
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' ,
} ,
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-10-23 11:39:36 -07:00
await extensionManager . uninstallExtension ( gitUrl , false ) ;
2025-09-18 16:00:28 +00:00
expect ( fs . existsSync ( sourceExtDir ) ) . toBe ( false ) ;
2025-09-22 12:55:43 -04:00
expect ( mockLogExtensionUninstall ) . toHaveBeenCalled ( ) ;
expect ( ExtensionUninstallEvent ) . toHaveBeenCalledWith (
2025-12-15 09:40:54 -08:00
'gemini-sql-extension' ,
2025-10-21 16:55:16 -04:00
hashValue ( 'gemini-sql-extension' ) ,
hashValue ( 'https://github.com/google/gemini-sql-extension' ) ,
2025-09-22 12:55:43 -04:00
'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
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-09-18 16:00:28 +00:00
await expect (
2025-10-23 11:39:36 -07:00
extensionManager . uninstallExtension (
2025-10-10 14:28:13 -07:00
'https://github.com/google/no-metadata-extension' ,
false ,
) ,
2025-09-18 16:00:28 +00:00
) . rejects . toThrow ( 'Extension not found.' ) ;
} ) ;
2025-09-12 10:53:30 -04:00
} ) ;
2025-09-16 15:51:46 -04:00
describe ( 'disableExtension' , ( ) = > {
2025-10-28 14:48:50 -04:00
it ( 'should disable an extension at the user scope' , async ( ) = > {
2025-09-25 21:44:28 -04:00
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'my-extension' ,
version : '1.0.0' ,
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-11-04 07:51:18 -08:00
await extensionManager . disableExtension (
'my-extension' ,
SettingScope . User ,
) ;
2025-09-16 15:51:46 -04:00
expect (
isEnabled ( {
name : 'my-extension' ,
enabledForPath : tempWorkspaceDir ,
} ) ,
) . toBe ( false ) ;
2025-09-12 09:20:04 -07:00
} ) ;
2025-10-28 14:48:50 -04:00
it ( 'should disable an extension at the workspace scope' , async ( ) = > {
2025-09-25 21:44:28 -04:00
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'my-extension' ,
version : '1.0.0' ,
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-11-04 07:51:18 -08:00
await extensionManager . disableExtension (
'my-extension' ,
SettingScope . Workspace ,
) ;
2025-09-16 15:51:46 -04:00
expect (
isEnabled ( {
name : 'my-extension' ,
enabledForPath : tempHomeDir ,
} ) ,
) . toBe ( true ) ;
expect (
isEnabled ( {
name : 'my-extension' ,
enabledForPath : tempWorkspaceDir ,
} ) ,
) . toBe ( false ) ;
2025-09-12 09:20:04 -07:00
} ) ;
2025-10-28 14:48:50 -04:00
it ( 'should handle disabling the same extension twice' , async ( ) = > {
2025-09-25 21:44:28 -04:00
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'my-extension' ,
version : '1.0.0' ,
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-11-04 07:51:18 -08:00
await extensionManager . disableExtension (
'my-extension' ,
SettingScope . User ,
) ;
await extensionManager . disableExtension (
'my-extension' ,
SettingScope . User ,
) ;
2025-09-16 15:51:46 -04:00
expect (
isEnabled ( {
name : 'my-extension' ,
enabledForPath : tempWorkspaceDir ,
} ) ,
) . toBe ( false ) ;
2025-09-12 09:20:04 -07:00
} ) ;
2025-08-26 14:36:55 +00:00
2025-10-28 14:48:50 -04:00
it ( 'should throw an error if you request system scope' , async ( ) = > {
2025-12-02 07:11:40 +09:00
await expect ( async ( ) = >
extensionManager . disableExtension ( 'my-extension' , SettingScope . System ) ,
2025-10-28 14:48:50 -04:00
) . rejects . toThrow ( 'System and SystemDefaults scopes are not supported.' ) ;
2025-09-16 15:51:46 -04:00
} ) ;
2025-09-23 14:37:35 -04:00
2025-10-28 14:48:50 -04:00
it ( 'should log a disable event' , async ( ) = > {
2025-09-25 21:44:28 -04:00
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'ext1' ,
version : '1.0.0' ,
2025-10-21 16:55:16 -04:00
installMetadata : {
source : userExtensionsDir ,
type : 'local' ,
} ,
2025-09-25 21:44:28 -04:00
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-11-04 07:51:18 -08:00
await extensionManager . disableExtension ( 'ext1' , SettingScope . Workspace ) ;
2025-09-23 14:37:35 -04:00
expect ( mockLogExtensionDisable ) . toHaveBeenCalled ( ) ;
expect ( ExtensionDisableEvent ) . toHaveBeenCalledWith (
2025-12-15 09:40:54 -08:00
'ext1' ,
2025-10-21 16:55:16 -04:00
hashValue ( 'ext1' ) ,
hashValue ( userExtensionsDir ) ,
2025-09-23 14:37:35 -04:00
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 [ ] = > {
2025-10-28 09:04:30 -07:00
const extensions = extensionManager . getExtensions ( ) ;
2025-10-20 16:15:23 -07:00
return extensions . filter ( ( e ) = > e . isActive ) ;
2025-09-16 15:51:46 -04:00
} ;
2025-08-26 14:36:55 +00:00
2025-10-28 14:48:50 -04:00
it ( 'should enable an extension at the user scope' , async ( ) = > {
2025-09-16 15:51:46 -04:00
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'ext1' ,
version : '1.0.0' ,
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-11-04 07:51:18 -08:00
await extensionManager . disableExtension ( 'ext1' , SettingScope . User ) ;
2025-09-16 15:51:46 -04:00
let activeExtensions = getActiveExtensions ( ) ;
expect ( activeExtensions ) . toHaveLength ( 0 ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . enableExtension ( 'ext1' , SettingScope . User ) ;
2025-12-16 21:28:18 -08:00
activeExtensions = getActiveExtensions ( ) ;
2025-09-16 15:51:46 -04:00
expect ( activeExtensions ) . toHaveLength ( 1 ) ;
expect ( activeExtensions [ 0 ] . name ) . toBe ( 'ext1' ) ;
} ) ;
2025-08-26 14:36:55 +00:00
2025-10-28 14:48:50 -04:00
it ( 'should enable an extension at the workspace scope' , async ( ) = > {
2025-09-16 15:51:46 -04:00
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'ext1' ,
version : '1.0.0' ,
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-11-04 07:51:18 -08:00
await extensionManager . disableExtension ( 'ext1' , SettingScope . Workspace ) ;
2025-09-16 15:51:46 -04:00
let activeExtensions = getActiveExtensions ( ) ;
expect ( activeExtensions ) . toHaveLength ( 0 ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . enableExtension ( 'ext1' , SettingScope . Workspace ) ;
2025-12-16 21:28:18 -08:00
activeExtensions = getActiveExtensions ( ) ;
2025-09-16 15:51:46 -04:00
expect ( activeExtensions ) . toHaveLength ( 1 ) ;
expect ( activeExtensions [ 0 ] . name ) . toBe ( 'ext1' ) ;
} ) ;
2025-09-22 12:55:43 -04:00
2025-10-28 14:48:50 -04:00
it ( 'should log an enable event' , async ( ) = > {
2025-09-22 12:55:43 -04:00
createExtension ( {
extensionsDir : userExtensionsDir ,
name : 'ext1' ,
version : '1.0.0' ,
2025-10-21 16:55:16 -04:00
installMetadata : {
source : userExtensionsDir ,
type : 'local' ,
} ,
2025-09-22 12:55:43 -04:00
} ) ;
2025-10-28 14:48:50 -04:00
await extensionManager . loadExtensions ( ) ;
2025-11-04 07:51:18 -08:00
await extensionManager . disableExtension ( 'ext1' , SettingScope . Workspace ) ;
await extensionManager . enableExtension ( 'ext1' , SettingScope . Workspace ) ;
2025-09-22 12:55:43 -04:00
expect ( mockLogExtensionEnable ) . toHaveBeenCalled ( ) ;
expect ( ExtensionEnableEvent ) . toHaveBeenCalledWith (
2025-12-15 09:40:54 -08:00
'ext1' ,
2025-10-21 16:55:16 -04:00
hashValue ( 'ext1' ) ,
hashValue ( userExtensionsDir ) ,
2025-09-22 12:55:43 -04:00
SettingScope . Workspace ,
) ;
} ) ;
2025-08-26 14:36:55 +00:00
} ) ;
2025-11-20 10:44:02 -08:00
} ) ;
2025-11-20 20:57:59 -08:00
function isEnabled ( options : { name : string ; enabledForPath : string } ) {
const manager = new ExtensionEnablementManager ( ) ;
return manager . isEnabled ( options . name , options . enabledForPath ) ;
}