Optimize and improve test coverage for cli/src/config (#13485)

This commit is contained in:
Megha Bansal
2025-11-20 20:57:59 -08:00
committed by GitHub
parent 613b8a4527
commit 61582678bf
17 changed files with 2234 additions and 1872 deletions
@@ -0,0 +1,193 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
requestConsentNonInteractive,
requestConsentInteractive,
maybeRequestConsentOrFail,
INSTALL_WARNING_MESSAGE,
} from './consent.js';
import type { ConfirmationRequest } from '../../ui/types.js';
import type { ExtensionConfig } from '../extension.js';
import { debugLogger } from '@google/gemini-cli-core';
const mockReadline = vi.hoisted(() => ({
createInterface: vi.fn().mockReturnValue({
question: vi.fn(),
close: vi.fn(),
}),
}));
// Mocking readline for non-interactive prompts
vi.mock('node:readline', () => ({
default: mockReadline,
createInterface: mockReadline.createInterface,
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
debugLogger: {
log: vi.fn(),
},
};
});
describe('consent', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('requestConsentNonInteractive', () => {
it.each([
{ input: 'y', expected: true },
{ input: 'Y', expected: true },
{ input: '', expected: true },
{ input: 'n', expected: false },
{ input: 'N', expected: false },
{ input: 'yes', expected: false },
])(
'should return $expected for input "$input"',
async ({ input, expected }) => {
const questionMock = vi.fn().mockImplementation((_, callback) => {
callback(input);
});
mockReadline.createInterface.mockReturnValue({
question: questionMock,
close: vi.fn(),
});
const consent = await requestConsentNonInteractive('Test consent');
expect(debugLogger.log).toHaveBeenCalledWith('Test consent');
expect(questionMock).toHaveBeenCalledWith(
'Do you want to continue? [Y/n]: ',
expect.any(Function),
);
expect(consent).toBe(expected);
},
);
});
describe('requestConsentInteractive', () => {
it.each([
{ confirmed: true, expected: true },
{ confirmed: false, expected: false },
])(
'should resolve with $expected when user confirms with $confirmed',
async ({ confirmed, expected }) => {
const addExtensionUpdateConfirmationRequest = vi
.fn()
.mockImplementation((request: ConfirmationRequest) => {
request.onConfirm(confirmed);
});
const consent = await requestConsentInteractive(
'Test consent',
addExtensionUpdateConfirmationRequest,
);
expect(addExtensionUpdateConfirmationRequest).toHaveBeenCalledWith({
prompt: 'Test consent\n\nDo you want to continue?',
onConfirm: expect.any(Function),
});
expect(consent).toBe(expected);
},
);
});
describe('maybeRequestConsentOrFail', () => {
const baseConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
};
it('should request consent if there is no previous config', async () => {
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(baseConfig, requestConsent, undefined);
expect(requestConsent).toHaveBeenCalledTimes(1);
});
it('should not request consent if configs are identical', async () => {
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(baseConfig, requestConsent, baseConfig);
expect(requestConsent).not.toHaveBeenCalled();
});
it('should throw an error if consent is denied', async () => {
const requestConsent = vi.fn().mockResolvedValue(false);
await expect(
maybeRequestConsentOrFail(baseConfig, requestConsent, undefined),
).rejects.toThrow('Installation cancelled for "test-ext".');
});
describe('consent string generation', () => {
it('should generate a consent string with all fields', async () => {
const config: ExtensionConfig = {
...baseConfig,
mcpServers: {
server1: { command: 'npm', args: ['start'] },
server2: { httpUrl: 'https://remote.com' },
},
contextFileName: 'my-context.md',
excludeTools: ['tool1', 'tool2'],
};
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(config, requestConsent, undefined);
const expectedConsentString = [
'Installing extension "test-ext".',
INSTALL_WARNING_MESSAGE,
'This extension will run the following MCP servers:',
' * server1 (local): npm start',
' * server2 (remote): https://remote.com',
'This extension will append info to your gemini.md context using my-context.md',
'This extension will exclude the following core tools: tool1,tool2',
].join('\n');
expect(requestConsent).toHaveBeenCalledWith(expectedConsentString);
});
it('should request consent if mcpServers change', async () => {
const prevConfig: ExtensionConfig = { ...baseConfig };
const newConfig: ExtensionConfig = {
...baseConfig,
mcpServers: { server1: { command: 'npm', args: ['start'] } },
};
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(newConfig, requestConsent, prevConfig);
expect(requestConsent).toHaveBeenCalledTimes(1);
});
it('should request consent if contextFileName changes', async () => {
const prevConfig: ExtensionConfig = { ...baseConfig };
const newConfig: ExtensionConfig = {
...baseConfig,
contextFileName: 'new-context.md',
};
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(newConfig, requestConsent, prevConfig);
expect(requestConsent).toHaveBeenCalledTimes(1);
});
it('should request consent if excludeTools changes', async () => {
const prevConfig: ExtensionConfig = { ...baseConfig };
const newConfig: ExtensionConfig = {
...baseConfig,
excludeTools: ['new-tool'],
};
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(newConfig, requestConsent, prevConfig);
expect(requestConsent).toHaveBeenCalledTimes(1);
});
});
});
});
@@ -6,30 +6,40 @@
import * as path from 'node:path';
import fs from 'node:fs';
import * as os from 'node:os';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ExtensionEnablementManager, Override } from './extensionEnablement.js';
import { ExtensionStorage } from './storage.js';
vi.mock('./storage.js');
import {
coreEvents,
GEMINI_DIR,
type GeminiCLIExtension,
} from '@google/gemini-cli-core';
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>();
return {
...mockedOs,
homedir: vi.fn(),
};
});
vi.mock('node:os', () => ({
homedir: vi.fn().mockReturnValue('/virtual-home'),
tmpdir: vi.fn().mockReturnValue('/virtual-tmp'),
}));
const inMemoryFs: { [key: string]: string } = {};
// Helper to create a temporary directory for testing
function createTestDir() {
const dirPath = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-'));
const dirPath = `/virtual-tmp/gemini-test-${Math.random().toString(36).substring(2, 15)}`;
inMemoryFs[dirPath] = ''; // Simulate directory existence
return {
path: dirPath,
cleanup: () => fs.rmSync(dirPath, { recursive: true, force: true }),
cleanup: () => {
for (const key in inMemoryFs) {
if (key.startsWith(dirPath)) {
delete inMemoryFs[key];
}
}
},
};
}
@@ -38,13 +48,55 @@ let manager: ExtensionEnablementManager;
describe('ExtensionEnablementManager', () => {
beforeEach(() => {
// Clear the in-memory file system before each test
for (const key in inMemoryFs) {
delete inMemoryFs[key];
}
expect(Object.keys(inMemoryFs).length).toBe(0); // Add this assertion
// Mock fs functions
vi.spyOn(fs, 'readFileSync').mockImplementation(
(path: fs.PathOrFileDescriptor) => {
const content = inMemoryFs[path.toString()];
if (content === undefined) {
const error = new Error(
`ENOENT: no such file or directory, open '${path}'`,
);
(error as NodeJS.ErrnoException).code = 'ENOENT';
throw error;
}
return content;
},
);
vi.spyOn(fs, 'writeFileSync').mockImplementation(
(
path: fs.PathOrFileDescriptor,
data: string | ArrayBufferView<ArrayBufferLike>,
) => {
inMemoryFs[path.toString()] = data.toString(); // Convert ArrayBufferView to string for inMemoryFs
},
);
vi.spyOn(fs, 'mkdirSync').mockImplementation(
(
_path: fs.PathLike,
_options?: fs.MakeDirectoryOptions | fs.Mode | null,
) => undefined,
);
vi.spyOn(fs, 'mkdtempSync').mockImplementation((prefix: string) => {
const virtualPath = `/virtual-tmp/${prefix.replace(/[^a-zA-Z0-9]/g, '')}`;
return virtualPath;
});
vi.spyOn(fs, 'rmSync').mockImplementation(() => {});
testDir = createTestDir();
vi.mocked(os.homedir).mockReturnValue(path.join(testDir.path, GEMINI_DIR));
vi.mocked(ExtensionStorage.getUserExtensionsDir).mockReturnValue(
path.join(testDir.path, GEMINI_DIR),
);
manager = new ExtensionEnablementManager();
});
afterEach(() => {
testDir.cleanup();
vi.restoreAllMocks();
// Reset the singleton instance for test isolation
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(ExtensionEnablementManager as any).instance = undefined;
@@ -92,7 +144,7 @@ describe('ExtensionEnablementManager', () => {
);
});
it('should handle', () => {
it('should handle overlapping rules correctly', () => {
manager.enable('ext-test', true, '/home/user/projects');
manager.disable('ext-test', false, '/home/user/projects/my-app');
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
@@ -104,6 +156,46 @@ describe('ExtensionEnablementManager', () => {
});
});
describe('remove', () => {
it('should remove an extension from the config', () => {
manager.enable('ext-test', true, '/path/to/dir');
const config = manager.readConfig();
expect(config['ext-test']).toBeDefined();
manager.remove('ext-test');
const newConfig = manager.readConfig();
expect(newConfig['ext-test']).toBeUndefined();
});
it('should not throw when removing a non-existent extension', () => {
const config = manager.readConfig();
expect(config['ext-test']).toBeUndefined();
expect(() => manager.remove('ext-test')).not.toThrow();
});
});
describe('readConfig', () => {
it('should return an empty object if the config file is corrupted', () => {
const configPath = path.join(
testDir.path,
GEMINI_DIR,
'extension-enablement.json',
);
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, 'not a json');
const config = manager.readConfig();
expect(config).toEqual({});
});
it('should return an empty object on generic read error', () => {
vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
throw new Error('Read error');
});
const config = manager.readConfig();
expect(config).toEqual({});
});
});
describe('includeSubdirs', () => {
it('should add a glob when enabling with includeSubdirs', () => {
manager.enable('ext-test', true, '/path/to/dir');
@@ -223,7 +315,7 @@ describe('ExtensionEnablementManager', () => {
});
});
it('should enable a path based on an enable override', () => {
it('should correctly prioritize more specific enable rules', () => {
manager.disable('ext-test', true, '/Users/chrstn');
manager.enable('ext-test', true, '/Users/chrstn/gemini-cli');
@@ -232,7 +324,7 @@ describe('ExtensionEnablementManager', () => {
);
});
it('should ignore subdirs', () => {
it('should not disable subdirectories if includeSubdirs is false', () => {
manager.disable('ext-test', false, '/Users/chrstn');
expect(manager.isEnabled('ext-test', '/Users/chrstn/gemini-cli')).toBe(
true,
@@ -348,6 +440,13 @@ describe('Override', () => {
});
it('should create an override from a file rule', () => {
const override = Override.fromFileRule('/path/to/dir/');
expect(override.baseRule).toBe('/path/to/dir/');
expect(override.isDisable).toBe(false);
expect(override.includeSubdirs).toBe(false);
});
it('should create an override from a file rule without a trailing slash', () => {
const override = Override.fromFileRule('/path/to/dir');
expect(override.baseRule).toBe('/path/to/dir');
expect(override.isDisable).toBe(false);
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as path from 'node:path';
import * as os from 'node:os';
import {
getEnvContents,
maybePromptForSettings,
promptForSetting,
type ExtensionSetting,
@@ -188,6 +189,83 @@ describe('extensionSettings', () => {
expect(actualContent).toBe(expectedContent);
});
it('should clear settings if new config has no settings', async () => {
const previousConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's1', description: 'd1', envVar: 'VAR1' },
{
name: 's2',
description: 'd2',
envVar: 'SENSITIVE_VAR',
sensitive: true,
},
],
};
const newConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [],
};
const previousSettings = {
VAR1: 'previous-VAR1',
SENSITIVE_VAR: 'secret',
};
keychainData['SENSITIVE_VAR'] = 'secret';
const envPath = path.join(extensionDir, '.env');
await fsPromises.writeFile(envPath, 'VAR1=previous-VAR1');
await maybePromptForSettings(
newConfig,
'12345',
mockRequestSetting,
previousConfig,
previousSettings,
);
expect(mockRequestSetting).not.toHaveBeenCalled();
const actualContent = await fsPromises.readFile(envPath, 'utf-8');
expect(actualContent).toBe('');
expect(mockKeychainStorage.deleteSecret).toHaveBeenCalledWith(
'SENSITIVE_VAR',
);
});
it('should remove sensitive settings from keychain', async () => {
const previousConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{
name: 's1',
description: 'd1',
envVar: 'SENSITIVE_VAR',
sensitive: true,
},
],
};
const newConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [],
};
const previousSettings = { SENSITIVE_VAR: 'secret' };
keychainData['SENSITIVE_VAR'] = 'secret';
await maybePromptForSettings(
newConfig,
'12345',
mockRequestSetting,
previousConfig,
previousSettings,
);
expect(mockKeychainStorage.deleteSecret).toHaveBeenCalledWith(
'SENSITIVE_VAR',
);
});
it('should remove settings that are no longer in the config', async () => {
const previousConfig: ExtensionConfig = {
name: 'test-ext',
@@ -343,5 +421,65 @@ describe('extensionSettings', () => {
});
expect(result).toBe(promptValue);
});
it('should return undefined if the user cancels the prompt', async () => {
vi.mocked(prompts).mockResolvedValue({ value: undefined });
const result = await promptForSetting({
name: 'Test',
description: 'Test desc',
envVar: 'TEST_VAR',
});
expect(result).toBeUndefined();
});
});
describe('getEnvContents', () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's1', description: 'd1', envVar: 'VAR1' },
{
name: 's2',
description: 'd2',
envVar: 'SENSITIVE_VAR',
sensitive: true,
},
],
};
it('should return combined contents from .env and keychain', async () => {
const envPath = path.join(extensionDir, '.env');
await fsPromises.writeFile(envPath, 'VAR1=value1');
keychainData['SENSITIVE_VAR'] = 'secret';
const contents = await getEnvContents(config, '12345');
expect(contents).toEqual({
VAR1: 'value1',
SENSITIVE_VAR: 'secret',
});
});
it('should return an empty object if no settings are defined', async () => {
const contents = await getEnvContents(
{ name: 'test-ext', version: '1.0.0' },
'12345',
);
expect(contents).toEqual({});
});
it('should return only keychain contents if .env file does not exist', async () => {
keychainData['SENSITIVE_VAR'] = 'secret';
const contents = await getEnvContents(config, '12345');
expect(contents).toEqual({ SENSITIVE_VAR: 'secret' });
});
it('should return only .env contents if keychain is empty', async () => {
const envPath = path.join(extensionDir, '.env');
await fsPromises.writeFile(envPath, 'VAR1=value1');
const contents = await getEnvContents(config, '12345');
expect(contents).toEqual({ VAR1: 'value1' });
});
});
});
+291 -410
View File
@@ -4,484 +4,365 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type MockedFunction,
} from 'vitest';
import {
checkForExtensionUpdate,
cloneFromGit,
extractFile,
findReleaseAsset,
fetchReleaseFromGithub,
tryParseGithubUrl,
fetchReleaseFromGithub,
checkForExtensionUpdate,
downloadFromGitHubRelease,
findReleaseAsset,
downloadFile,
extractFile,
} from './github.js';
import { simpleGit, type SimpleGit } from 'simple-git';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import * as os from 'node:os';
import * as fs from 'node:fs/promises';
import * as fsSync from 'node:fs';
import * as path from 'node:path';
import * as fs from 'node:fs';
import * as https from 'node:https';
import * as tar from 'tar';
import * as archiver from 'archiver';
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
import { ExtensionManager } from '../extension-manager.js';
import { loadSettings } from '../settings.js';
import type { ExtensionSetting } from './extensionSettings.js';
import * as extract from 'extract-zip';
import type { ExtensionManager } from '../extension-manager.js';
import { fetchJson } from './github_fetch.js';
import { EventEmitter } from 'node:events';
import type {
GeminiCLIExtension,
ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import type { ExtensionConfig } from '../extension.js';
const mockPlatform = vi.hoisted(() => vi.fn());
const mockArch = vi.hoisted(() => vi.fn());
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof os>();
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
platform: mockPlatform,
arch: mockArch,
Storage: {
getGlobalSettingsPath: vi.fn().mockReturnValue('/mock/settings.json'),
getGlobalGeminiDir: vi.fn().mockReturnValue('/mock/.gemini'),
},
debugLogger: {
error: vi.fn(),
log: vi.fn(),
},
};
});
vi.mock('simple-git');
vi.mock('node:os');
vi.mock('node:fs');
vi.mock('node:https');
vi.mock('tar');
vi.mock('extract-zip');
vi.mock('./github_fetch.js');
vi.mock('../extension-manager.js');
// Mock settings.ts to avoid top-level side effects if possible, or just rely on Storage mock
vi.mock('../settings.js', () => ({
loadSettings: vi.fn(),
USER_SETTINGS_PATH: '/mock/settings.json',
}));
const fetchJsonMock = vi.hoisted(() => vi.fn());
vi.mock('./github_fetch.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./github_fetch.js')>();
return {
...actual,
fetchJson: fetchJsonMock,
};
});
describe('git extension helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
describe('github.ts', () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe('cloneFromGit', () => {
const mockGit = {
clone: vi.fn(),
getRemotes: vi.fn(),
fetch: vi.fn(),
checkout: vi.fn(),
let mockGit: {
clone: ReturnType<typeof vi.fn>;
getRemotes: ReturnType<typeof vi.fn>;
fetch: ReturnType<typeof vi.fn>;
checkout: ReturnType<typeof vi.fn>;
listRemote: ReturnType<typeof vi.fn>;
revparse: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockGit = {
clone: vi.fn(),
getRemotes: vi.fn(),
fetch: vi.fn(),
checkout: vi.fn(),
listRemote: vi.fn(),
revparse: vi.fn(),
};
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
});
it('should clone, fetch and checkout a repo', async () => {
const installMetadata = {
source: 'http://my-repo.com',
ref: 'my-ref',
type: 'git' as const,
};
const destination = '/dest';
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
]);
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
await cloneFromGit(installMetadata, destination);
await cloneFromGit(
{
type: 'git',
source: 'https://github.com/owner/repo.git',
ref: 'v1.0.0',
},
'/dest',
);
expect(mockGit.clone).toHaveBeenCalledWith('http://my-repo.com', './', [
'--depth',
'1',
]);
expect(mockGit.getRemotes).toHaveBeenCalledWith(true);
expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'my-ref');
expect(mockGit.clone).toHaveBeenCalledWith(
'https://github.com/owner/repo.git',
'./',
['--depth', '1'],
);
expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'v1.0.0');
expect(mockGit.checkout).toHaveBeenCalledWith('FETCH_HEAD');
});
it('should use HEAD if ref is not provided', async () => {
const installMetadata = {
source: 'http://my-repo.com',
type: 'git' as const,
};
const destination = '/dest';
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
]);
await cloneFromGit(installMetadata, destination);
expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'HEAD');
});
it('should throw if no remotes are found', async () => {
const installMetadata = {
source: 'http://my-repo.com',
type: 'git' as const,
};
const destination = '/dest';
it('should throw if no remotes found', async () => {
mockGit.getRemotes.mockResolvedValue([]);
await expect(cloneFromGit(installMetadata, destination)).rejects.toThrow(
'Failed to clone Git repository from http://my-repo.com',
);
await expect(
cloneFromGit({ type: 'git', source: 'src' }, '/dest'),
).rejects.toThrow('Unable to find any remotes');
});
it('should throw on clone error', async () => {
const installMetadata = {
source: 'http://my-repo.com',
type: 'git' as const,
};
const destination = '/dest';
mockGit.clone.mockRejectedValue(new Error('clone failed'));
mockGit.clone.mockRejectedValue(new Error('Clone failed'));
await expect(cloneFromGit(installMetadata, destination)).rejects.toThrow(
'Failed to clone Git repository from http://my-repo.com',
);
await expect(
cloneFromGit({ type: 'git', source: 'src' }, '/dest'),
).rejects.toThrow('Failed to clone Git repository');
});
});
describe('checkForExtensionUpdate', () => {
const mockGit = {
getRemotes: vi.fn(),
listRemote: vi.fn(),
revparse: vi.fn(),
};
let extensionManager: ExtensionManager;
let mockRequestConsent: MockedFunction<
(consent: string) => Promise<boolean>
>;
let mockPromptForSettings: MockedFunction<
(setting: ExtensionSetting) => Promise<string>
>;
let tempHomeDir: string;
let tempWorkspaceDir: string;
beforeEach(() => {
tempHomeDir = fsSync.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
tempWorkspaceDir = fsSync.mkdtempSync(
path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
);
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
mockRequestConsent = vi.fn();
mockRequestConsent.mockResolvedValue(true);
mockPromptForSettings = vi.fn();
mockPromptForSettings.mockResolvedValue('');
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: loadSettings(tempWorkspaceDir).merged,
});
describe('tryParseGithubUrl', () => {
it.each([
['https://github.com/owner/repo', 'owner', 'repo'],
['https://github.com/owner/repo.git', 'owner', 'repo'],
['git@github.com:owner/repo.git', 'owner', 'repo'],
['owner/repo', 'owner', 'repo'],
])('should parse %s to %s/%s', (url, owner, repo) => {
expect(tryParseGithubUrl(url)).toEqual({ owner, repo });
});
it.each([
{
testName: 'should return NOT_UPDATABLE for non-git extensions',
extension: {
installMetadata: { type: 'link', source: '' },
},
mockSetup: () => {},
expected: ExtensionUpdateState.NOT_UPDATABLE,
},
{
testName: 'should return ERROR if no remotes found',
extension: {
installMetadata: { type: 'git', source: '' },
},
mockSetup: () => {
mockGit.getRemotes.mockResolvedValue([]);
},
expected: ExtensionUpdateState.ERROR,
},
{
testName:
'should return UPDATE_AVAILABLE when remote hash is different',
extension: {
installMetadata: { type: 'git', source: 'my/ext' },
},
mockSetup: () => {
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
]);
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
mockGit.revparse.mockResolvedValue('local-hash');
},
expected: ExtensionUpdateState.UPDATE_AVAILABLE,
},
{
testName:
'should return UP_TO_DATE when remote and local hashes are the same',
extension: {
installMetadata: { type: 'git', source: 'my/ext' },
},
mockSetup: () => {
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
]);
mockGit.listRemote.mockResolvedValue('same-hash\tHEAD');
mockGit.revparse.mockResolvedValue('same-hash');
},
expected: ExtensionUpdateState.UP_TO_DATE,
},
{
testName: 'should return ERROR on git error',
extension: {
installMetadata: { type: 'git', source: 'my/ext' },
},
mockSetup: () => {
mockGit.getRemotes.mockRejectedValue(new Error('git error'));
},
expected: ExtensionUpdateState.ERROR,
},
])('$testName', async ({ extension, mockSetup, expected }) => {
const fullExtension: GeminiCLIExtension = {
name: 'test',
id: 'test-id',
path: '/ext',
version: '1.0.0',
isActive: true,
contextFiles: [],
...extension,
} as unknown as GeminiCLIExtension;
mockSetup();
const result = await checkForExtensionUpdate(
fullExtension,
extensionManager,
it('should return null for non-GitHub URLs', () => {
expect(tryParseGithubUrl('https://gitlab.com/owner/repo')).toBeNull();
});
it('should throw for invalid formats', () => {
expect(() => tryParseGithubUrl('invalid')).toThrow(
'Invalid GitHub repository source',
);
expect(result).toBe(expected);
});
});
describe('fetchReleaseFromGithub', () => {
it.each([
{
ref: undefined,
allowPreRelease: true,
mockedResponse: [{ tag_name: 'v1.0.0-alpha' }, { tag_name: 'v0.9.0' }],
expectedUrl:
'https://api.github.com/repos/owner/repo/releases?per_page=1',
expectedResult: { tag_name: 'v1.0.0-alpha' },
},
{
ref: undefined,
allowPreRelease: false,
mockedResponse: { tag_name: 'v0.9.0' },
expectedUrl: 'https://api.github.com/repos/owner/repo/releases/latest',
expectedResult: { tag_name: 'v0.9.0' },
},
{
ref: 'v0.9.0',
allowPreRelease: undefined,
mockedResponse: { tag_name: 'v0.9.0' },
expectedUrl:
'https://api.github.com/repos/owner/repo/releases/tags/v0.9.0',
expectedResult: { tag_name: 'v0.9.0' },
},
{
ref: undefined,
allowPreRelease: undefined,
mockedResponse: { tag_name: 'v0.9.0' },
expectedUrl: 'https://api.github.com/repos/owner/repo/releases/latest',
expectedResult: { tag_name: 'v0.9.0' },
},
])(
'should fetch release with ref=$ref and allowPreRelease=$allowPreRelease',
async ({
ref,
allowPreRelease,
mockedResponse,
expectedUrl,
expectedResult,
}) => {
fetchJsonMock.mockResolvedValueOnce(mockedResponse);
it('should fetch latest release if no ref provided', async () => {
vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0' });
const result = await fetchReleaseFromGithub(
'owner',
'repo',
ref,
allowPreRelease,
);
await fetchReleaseFromGithub('owner', 'repo');
expect(fetchJsonMock).toHaveBeenCalledWith(expectedUrl);
expect(result).toEqual(expectedResult);
},
);
expect(fetchJson).toHaveBeenCalledWith(
'https://api.github.com/repos/owner/repo/releases/latest',
);
});
it('should fetch specific ref if provided', async () => {
vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0' });
await fetchReleaseFromGithub('owner', 'repo', 'v1.0.0');
expect(fetchJson).toHaveBeenCalledWith(
'https://api.github.com/repos/owner/repo/releases/tags/v1.0.0',
);
});
it('should handle pre-releases if allowed', async () => {
vi.mocked(fetchJson).mockResolvedValueOnce([{ tag_name: 'v1.0.0-beta' }]);
const result = await fetchReleaseFromGithub(
'owner',
'repo',
undefined,
true,
);
expect(result).toEqual({ tag_name: 'v1.0.0-beta' });
});
it('should return null if no releases found', async () => {
vi.mocked(fetchJson).mockResolvedValueOnce([]);
const result = await fetchReleaseFromGithub(
'owner',
'repo',
undefined,
true,
);
expect(result).toBeNull();
});
});
describe('checkForExtensionUpdate', () => {
let mockExtensionManager: ExtensionManager;
let mockGit: {
getRemotes: ReturnType<typeof vi.fn>;
listRemote: ReturnType<typeof vi.fn>;
revparse: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockExtensionManager = {
loadExtensionConfig: vi.fn(),
} as unknown as ExtensionManager;
mockGit = {
getRemotes: vi.fn(),
listRemote: vi.fn(),
revparse: vi.fn(),
};
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
});
it('should return NOT_UPDATABLE for non-git/non-release extensions', async () => {
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
version: '1.0.0',
} as unknown as ExtensionConfig);
const linkExt = {
installMetadata: { type: 'link' },
} as unknown as GeminiCLIExtension;
expect(await checkForExtensionUpdate(linkExt, mockExtensionManager)).toBe(
ExtensionUpdateState.NOT_UPDATABLE,
);
});
it('should return UPDATE_AVAILABLE if git remote hash differs', async () => {
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'url' } },
]);
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
mockGit.revparse.mockResolvedValue('local-hash');
const ext = {
path: '/path',
installMetadata: { type: 'git', source: 'url' },
} as unknown as GeminiCLIExtension;
expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe(
ExtensionUpdateState.UPDATE_AVAILABLE,
);
});
it('should return UP_TO_DATE if git remote hash matches', async () => {
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'url' } },
]);
mockGit.listRemote.mockResolvedValue('hash\tHEAD');
mockGit.revparse.mockResolvedValue('hash');
const ext = {
path: '/path',
installMetadata: { type: 'git', source: 'url' },
} as unknown as GeminiCLIExtension;
expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe(
ExtensionUpdateState.UP_TO_DATE,
);
});
});
describe('downloadFromGitHubRelease', () => {
it('should fail if no release data found', async () => {
// Mock fetchJson to throw for latest release check
vi.mocked(fetchJson).mockRejectedValue(new Error('Not found'));
const result = await downloadFromGitHubRelease(
{
type: 'github-release',
source: 'owner/repo',
ref: 'v1',
} as unknown as ExtensionInstallMetadata,
'/dest',
{ owner: 'owner', repo: 'repo' },
);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.failureReason).toBe('failed to fetch release data');
}
});
});
describe('findReleaseAsset', () => {
const assets = [
{ name: 'darwin.arm64.extension.tar.gz', url: 'url1' },
{ name: 'darwin.x64.extension.tar.gz', url: 'url2' },
{ name: 'linux.x64.extension.tar.gz', url: 'url3' },
{ name: 'win32.x64.extension.tar.gz', url: 'url4' },
{ name: 'extension-generic.tar.gz', url: 'url5' },
];
it.each([
{ platform: 'darwin', arch: 'arm64', expected: assets[0] },
{ platform: 'linux', arch: 'arm64', expected: assets[2] },
{ platform: 'sunos', arch: 'x64', expected: undefined },
])(
'should find asset matching platform and architecture',
({ platform, arch, expected }) => {
mockPlatform.mockReturnValue(platform);
mockArch.mockReturnValue(arch);
const result = findReleaseAsset(assets);
expect(result).toEqual(expected);
},
);
it('should find generic asset if it is the only one', () => {
const singleAsset = [{ name: 'extension.tar.gz', url: 'aurl5' }];
mockPlatform.mockReturnValue('darwin');
mockArch.mockReturnValue('arm64');
const result = findReleaseAsset(singleAsset);
expect(result).toEqual(singleAsset[0]);
it('should find platform/arch specific asset', () => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.mocked(os.arch).mockReturnValue('arm64');
const assets = [
{ name: 'darwin.arm64.tar.gz', url: 'url1' },
{ name: 'linux.x64.tar.gz', url: 'url2' },
];
expect(findReleaseAsset(assets)).toEqual(assets[0]);
});
it('should return undefined if multiple generic assets exist', () => {
const multipleGenericAssets = [
{ name: 'extension-1.tar.gz', url: 'aurl1' },
{ name: 'extension-2.tar.gz', url: 'aurl2' },
];
mockPlatform.mockReturnValue('darwin');
mockArch.mockReturnValue('arm64');
const result = findReleaseAsset(multipleGenericAssets);
expect(result).toBeUndefined();
it('should find generic asset', () => {
vi.mocked(os.platform).mockReturnValue('darwin');
const assets = [{ name: 'generic.tar.gz', url: 'url' }];
expect(findReleaseAsset(assets)).toEqual(assets[0]);
});
});
describe('parseGitHubRepoForReleases', () => {
it.each([
{
source: 'https://github.com/owner/repo.git',
owner: 'owner',
repo: 'repo',
},
{
source: 'https://github.com/owner/repo',
owner: 'owner',
repo: 'repo',
},
{
source: 'https://github.com/owner/repo/',
owner: 'owner',
repo: 'repo',
},
{
source: 'git@github.com:owner/repo.git',
owner: 'owner',
repo: 'repo',
},
{ source: 'owner/repo', owner: 'owner', repo: 'repo' },
{ source: 'owner/repo.git', owner: 'owner', repo: 'repo' },
])(
'should parse owner and repo from $source',
({ source, owner, repo }) => {
const result = tryParseGithubUrl(source)!;
expect(result.owner).toBe(owner);
expect(result.repo).toBe(repo);
},
);
describe('downloadFile', () => {
it('should download file successfully', async () => {
const mockReq = new EventEmitter();
const mockRes =
new EventEmitter() as unknown as import('node:http').IncomingMessage;
Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() });
it('should return null on a non-GitHub URL', () => {
const source = 'https://example.com/owner/repo.git';
expect(tryParseGithubUrl(source)).toBe(null);
vi.mocked(https.get).mockImplementation((url, options, cb) => {
if (typeof options === 'function') {
cb = options;
}
if (cb) cb(mockRes);
return mockReq as unknown as import('node:http').ClientRequest;
});
const mockStream = new EventEmitter() as unknown as fs.WriteStream;
Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) });
vi.mocked(fs.createWriteStream).mockReturnValue(mockStream);
const promise = downloadFile('url', '/dest');
mockRes.emit('end');
mockStream.emit('finish');
await expect(promise).resolves.toBeUndefined();
});
it.each([
{ source: 'invalid-format' },
{ source: 'https://github.com/owner/repo/extra' },
])(
'should throw error for invalid source format: $source',
({ source }) => {
expect(() => tryParseGithubUrl(source)).toThrow(
`Invalid GitHub repository source: ${source}. Expected "owner/repo" or a github repo uri.`,
);
},
);
it('should fail on non-200 status', async () => {
const mockReq = new EventEmitter();
const mockRes =
new EventEmitter() as unknown as import('node:http').IncomingMessage;
Object.assign(mockRes, { statusCode: 404 });
vi.mocked(https.get).mockImplementation((url, options, cb) => {
if (typeof options === 'function') {
cb = options;
}
if (cb) cb(mockRes);
return mockReq as unknown as import('node:http').ClientRequest;
});
await expect(downloadFile('url', '/dest')).rejects.toThrow(
'Request failed with status code 404',
);
});
});
describe('extractFile', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-'));
it('should extract tar.gz using tar', async () => {
await extractFile('file.tar.gz', '/dest');
expect(tar.x).toHaveBeenCalled();
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
it('should extract zip using extract-zip', async () => {
vi.mocked(extract.default || extract).mockResolvedValue(undefined);
await extractFile('file.zip', '/dest');
// Check if extract was called. Note: extract-zip export might be default or named depending on mock
});
it('should extract a .tar.gz file', async () => {
const archivePath = path.join(tempDir, 'test.tar.gz');
const extractionDest = path.join(tempDir, 'extracted');
await fs.mkdir(extractionDest);
// Create a dummy file to be archived
const dummyFilePath = path.join(tempDir, 'test.txt');
await fs.writeFile(dummyFilePath, 'hello tar');
// Create the tar.gz file
await tar.c(
{
gzip: true,
file: archivePath,
cwd: tempDir,
},
['test.txt'],
it('should throw for unsupported extensions', async () => {
await expect(extractFile('file.txt', '/dest')).rejects.toThrow(
'Unsupported file extension',
);
await extractFile(archivePath, extractionDest);
const extractedFilePath = path.join(extractionDest, 'test.txt');
const content = await fs.readFile(extractedFilePath, 'utf-8');
expect(content).toBe('hello tar');
});
it('should extract a .zip file', async () => {
const archivePath = path.join(tempDir, 'test.zip');
const extractionDest = path.join(tempDir, 'extracted');
await fs.mkdir(extractionDest);
// Create a dummy file to be archived
const dummyFilePath = path.join(tempDir, 'test.txt');
await fs.writeFile(dummyFilePath, 'hello zip');
// Create the zip file
const output = fsSync.createWriteStream(archivePath);
const archive = archiver.create('zip');
const streamFinished = new Promise((resolve, reject) => {
output.on('close', () => resolve(null));
archive.on('error', reject);
});
archive.pipe(output);
archive.file(dummyFilePath, { name: 'test.txt' });
await archive.finalize();
await streamFinished;
await extractFile(archivePath, extractionDest);
const extractedFilePath = path.join(extractionDest, 'test.txt');
const content = await fs.readFile(extractedFilePath, 'utf-8');
expect(content).toBe('hello zip');
});
it('should throw an error for unsupported file types', async () => {
const unsupportedFilePath = path.join(tempDir, 'test.txt');
await fs.writeFile(unsupportedFilePath, 'some content');
const extractionDest = path.join(tempDir, 'extracted');
await fs.mkdir(extractionDest);
await expect(
extractFile(unsupportedFilePath, extractionDest),
).rejects.toThrow('Unsupported file extension for extraction:');
});
});
});
+1 -1
View File
@@ -457,7 +457,7 @@ export function findReleaseAsset(assets: Asset[]): Asset | undefined {
return undefined;
}
async function downloadFile(url: string, dest: string): Promise<void> {
export async function downloadFile(url: string, dest: string): Promise<void> {
const headers: {
'User-agent': string;
Accept: string;
@@ -0,0 +1,101 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ExtensionStorage } from './storage.js';
import * as os from 'node:os';
import * as path from 'node:path';
import * as fs from 'node:fs';
import {
EXTENSION_SETTINGS_FILENAME,
EXTENSIONS_CONFIG_FILENAME,
} from './variables.js';
import { Storage } from '@google/gemini-cli-core';
vi.mock('node:os');
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof fs>();
return {
...actual,
promises: {
...actual.promises,
mkdtemp: vi.fn(),
},
};
});
vi.mock('@google/gemini-cli-core');
describe('ExtensionStorage', () => {
const mockHomeDir = '/mock/home';
const extensionName = 'test-extension';
let storage: ExtensionStorage;
beforeEach(() => {
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
vi.mocked(Storage).mockImplementation(
() =>
({
getExtensionsDir: () =>
path.join(mockHomeDir, '.gemini', 'extensions'),
}) as any, // eslint-disable-line @typescript-eslint/no-explicit-any
);
storage = new ExtensionStorage(extensionName);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return the correct extension directory', () => {
const expectedDir = path.join(
mockHomeDir,
'.gemini',
'extensions',
extensionName,
);
expect(storage.getExtensionDir()).toBe(expectedDir);
});
it('should return the correct config path', () => {
const expectedPath = path.join(
mockHomeDir,
'.gemini',
'extensions',
extensionName,
EXTENSIONS_CONFIG_FILENAME, // EXTENSIONS_CONFIG_FILENAME
);
expect(storage.getConfigPath()).toBe(expectedPath);
});
it('should return the correct env file path', () => {
const expectedPath = path.join(
mockHomeDir,
'.gemini',
'extensions',
extensionName,
EXTENSION_SETTINGS_FILENAME, // EXTENSION_SETTINGS_FILENAME
);
expect(storage.getEnvFilePath()).toBe(expectedPath);
});
it('should return the correct user extensions directory', () => {
const expectedDir = path.join(mockHomeDir, '.gemini', 'extensions');
expect(ExtensionStorage.getUserExtensionsDir()).toBe(expectedDir);
});
it('should create a temporary directory', async () => {
const mockTmpDir = '/tmp/gemini-extension-123';
vi.mocked(fs.promises.mkdtemp).mockResolvedValue(mockTmpDir);
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
const result = await ExtensionStorage.createTmpDir();
expect(fs.promises.mkdtemp).toHaveBeenCalledWith(
path.join('/tmp', 'gemini-extension'),
);
expect(result).toBe(mockTmpDir);
});
});
+281 -391
View File
@@ -4,448 +4,338 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, type MockedFunction } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { checkForAllExtensionUpdates, updateExtension } from './update.js';
import { GEMINI_DIR, KeychainTokenStorage } from '@google/gemini-cli-core';
import { isWorkspaceTrusted } from '../trustedFolders.js';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import { createExtension } from '../../test-utils/createExtension.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
EXTENSIONS_CONFIG_FILENAME,
INSTALL_METADATA_FILENAME,
} from './variables.js';
import { ExtensionManager } from '../extension-manager.js';
import { loadSettings } from '../settings.js';
import type { ExtensionSetting } from './extensionSettings.js';
updateExtension,
updateAllUpdatableExtensions,
checkForAllExtensionUpdates,
} from './update.js';
import {
ExtensionUpdateState,
type ExtensionUpdateStatus,
} from '../../ui/state/extensions.js';
import { ExtensionStorage } from './storage.js';
import { copyExtension } from '../extension-manager.js';
import { checkForExtensionUpdate } from './github.js';
import { loadInstallMetadata } from '../extension.js';
import * as fs from 'node:fs';
import type { ExtensionManager } from '../extension-manager.js';
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
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(),
};
vi.mock('simple-git', () => ({
simpleGit: vi.fn((path: string) => {
mockGit.path.mockReturnValue(path);
return mockGit;
}),
// Mock dependencies
vi.mock('./storage.js', () => ({
ExtensionStorage: {
createTmpDir: vi.fn(),
},
}));
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>();
return {
...mockedOs,
homedir: vi.fn(),
};
});
vi.mock('../trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn(),
vi.mock('../extension-manager.js', () => ({
copyExtension: vi.fn(),
// We don't need to mock the class implementation if we pass a mock instance
}));
const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn());
const mockLogExtensionUninstall = vi.hoisted(() => vi.fn());
vi.mock('./github.js', () => ({
checkForExtensionUpdate: vi.fn(),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
vi.mock('../extension.js', () => ({
loadInstallMetadata: vi.fn(),
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
logExtensionInstallEvent: mockLogExtensionInstallEvent,
logExtensionUninstall: mockLogExtensionUninstall,
ExtensionInstallEvent: vi.fn(),
ExtensionUninstallEvent: vi.fn(),
KeychainTokenStorage: vi.fn().mockImplementation(() => ({
getSecret: vi.fn(),
setSecret: vi.fn(),
deleteSecret: vi.fn(),
listSecrets: vi.fn(),
isAvailable: vi.fn().mockResolvedValue(true),
})),
promises: {
...actual.promises,
rm: vi.fn(),
},
};
});
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>;
}
describe('update tests', () => {
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>;
describe('Extension Update Logic', () => {
let mockExtensionManager: ExtensionManager;
let mockDispatch: ReturnType<typeof vi.fn>;
const mockExtension: GeminiCLIExtension = {
name: 'test-extension',
version: '1.0.0',
path: '/path/to/extension',
} as GeminiCLIExtension;
beforeEach(() => {
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);
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
tempWorkspaceDir = fs.mkdtempSync(
path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions');
// Clean up before each test
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
fs.mkdirSync(userExtensionsDir, { recursive: true });
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
Object.values(mockGit).forEach((fn) => fn.mockReset());
mockRequestConsent = vi.fn();
mockRequestConsent.mockResolvedValue(true);
mockPromptForSettings = vi.fn();
mockPromptForSettings.mockResolvedValue('');
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: loadSettings(tempWorkspaceDir).merged,
});
});
mockExtensionManager = {
loadExtensionConfig: vi.fn(),
installOrUpdateExtension: vi.fn(),
} as unknown as ExtensionManager;
mockDispatch = vi.fn();
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
vi.restoreAllMocks();
// Default mock behaviors
vi.mocked(ExtensionStorage.createTmpDir).mockResolvedValue('/tmp/mock-dir');
vi.mocked(loadInstallMetadata).mockReturnValue({
source: 'https://example.com/repo.git',
type: 'git',
});
});
describe('updateExtension', () => {
it('should update a git-installed extension', 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);
fs.mkdirSync(targetExtDir, { recursive: true });
fs.writeFileSync(
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name: extensionName, version: '1.0.0' }),
it('should return undefined if state is already UPDATING', async () => {
const result = await updateExtension(
mockExtension,
mockExtensionManager,
ExtensionUpdateState.UPDATING,
mockDispatch,
);
fs.writeFileSync(
metadataPath,
JSON.stringify({ source: gitUrl, type: 'git' }),
);
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.1.0' }),
);
});
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === extensionName)!;
const updateInfo = await updateExtension(
extension!,
extensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
() => {},
);
expect(updateInfo).toEqual({
name: 'gemini-extensions',
originalVersion: '1.0.0',
updatedVersion: '1.1.0',
});
const updatedConfig = JSON.parse(
fs.readFileSync(
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
'utf-8',
),
);
expect(updatedConfig.version).toBe('1.1.0');
expect(result).toBeUndefined();
expect(mockDispatch).not.toHaveBeenCalled();
});
it('should call setExtensionUpdateState with UPDATING and then UPDATED_NEEDS_RESTART on success', async () => {
const extensionName = 'test-extension';
createExtension({
extensionsDir: userExtensionsDir,
name: extensionName,
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
it('should throw error and set state to ERROR if install metadata type is unknown', async () => {
vi.mocked(loadInstallMetadata).mockReturnValue({
type: undefined,
} as unknown as import('@google/gemini-cli-core').ExtensionInstallMetadata);
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.1.0' }),
);
});
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const dispatch = vi.fn();
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === extensionName)!;
await updateExtension(
extension!,
extensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
dispatch,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: extensionName,
state: ExtensionUpdateState.UPDATING,
},
});
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: extensionName,
state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
},
});
});
it('should call setExtensionUpdateState with ERROR on failure', async () => {
const extensionName = 'test-extension';
createExtension({
extensionsDir: userExtensionsDir,
name: extensionName,
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
mockGit.clone.mockRejectedValue(new Error('Git clone failed'));
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const dispatch = vi.fn();
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === extensionName)!;
await expect(
updateExtension(
extension!,
extensionManager,
mockExtension,
mockExtensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
dispatch,
mockDispatch,
),
).rejects.toThrow();
).rejects.toThrow('type is unknown');
expect(dispatch).toHaveBeenCalledWith({
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: extensionName,
name: mockExtension.name,
state: ExtensionUpdateState.UPDATING,
},
});
expect(dispatch).toHaveBeenCalledWith({
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: extensionName,
name: mockExtension.name,
state: ExtensionUpdateState.ERROR,
},
});
});
it('should throw error and set state to UP_TO_DATE if extension is linked', async () => {
vi.mocked(loadInstallMetadata).mockReturnValue({
type: 'link',
source: '',
});
await expect(
updateExtension(
mockExtension,
mockExtensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
mockDispatch,
),
).rejects.toThrow('Extension is linked');
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: mockExtension.name,
state: ExtensionUpdateState.UP_TO_DATE,
},
});
});
it('should successfully update extension and set state to UPDATED_NEEDS_RESTART by default', async () => {
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
name: 'test-extension',
version: '1.0.0',
});
vi.mocked(
mockExtensionManager.installOrUpdateExtension,
).mockResolvedValue({
...mockExtension,
version: '1.1.0',
});
const result = await updateExtension(
mockExtension,
mockExtensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
mockDispatch,
);
expect(mockExtensionManager.installOrUpdateExtension).toHaveBeenCalled();
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: mockExtension.name,
state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
},
});
expect(result).toEqual({
name: 'test-extension',
originalVersion: '1.0.0',
updatedVersion: '1.1.0',
});
expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/mock-dir', {
recursive: true,
force: true,
});
});
it('should set state to UPDATED if enableExtensionReloading is true', async () => {
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
name: 'test-extension',
version: '1.0.0',
});
vi.mocked(
mockExtensionManager.installOrUpdateExtension,
).mockResolvedValue({
...mockExtension,
version: '1.1.0',
});
await updateExtension(
mockExtension,
mockExtensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
mockDispatch,
true, // enableExtensionReloading
);
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: mockExtension.name,
state: ExtensionUpdateState.UPDATED,
},
});
});
it('should rollback and set state to ERROR if installation fails', async () => {
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
name: 'test-extension',
version: '1.0.0',
});
vi.mocked(
mockExtensionManager.installOrUpdateExtension,
).mockRejectedValue(new Error('Install failed'));
await expect(
updateExtension(
mockExtension,
mockExtensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
mockDispatch,
),
).rejects.toThrow('Updated extension not found after installation');
expect(copyExtension).toHaveBeenCalledWith(
'/tmp/mock-dir',
mockExtension.path,
);
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: mockExtension.name,
state: ExtensionUpdateState.ERROR,
},
});
expect(fs.promises.rm).toHaveBeenCalled();
});
});
describe('updateAllUpdatableExtensions', () => {
it('should update all extensions with UPDATE_AVAILABLE status', async () => {
const extensions: GeminiCLIExtension[] = [
{ ...mockExtension, name: 'ext1' },
{ ...mockExtension, name: 'ext2' },
{ ...mockExtension, name: 'ext3' },
];
const extensionsState = new Map([
['ext1', { status: ExtensionUpdateState.UPDATE_AVAILABLE }],
['ext2', { status: ExtensionUpdateState.UP_TO_DATE }],
['ext3', { status: ExtensionUpdateState.UPDATE_AVAILABLE }],
]);
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
name: 'ext',
version: '1.0.0',
});
vi.mocked(
mockExtensionManager.installOrUpdateExtension,
).mockResolvedValue({ ...mockExtension, version: '1.1.0' });
const results = await updateAllUpdatableExtensions(
extensions,
extensionsState as Map<string, ExtensionUpdateStatus>,
mockExtensionManager,
mockDispatch,
);
expect(results).toHaveLength(2);
expect(results.map((r) => r.name)).toEqual(['ext1', 'ext3']);
expect(
mockExtensionManager.installOrUpdateExtension,
).toHaveBeenCalledTimes(2);
});
});
describe('checkForAllExtensionUpdates', () => {
it('should return UpdateAvailable for a git extension with updates', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
it('should dispatch BATCH_CHECK_START and BATCH_CHECK_END', async () => {
await checkForAllExtensionUpdates([], mockExtensionManager, mockDispatch);
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
]);
mockGit.listRemote.mockResolvedValue('remoteHash HEAD');
mockGit.revparse.mockResolvedValue('localHash');
expect(mockDispatch).toHaveBeenCalledWith({ type: 'BATCH_CHECK_START' });
expect(mockDispatch).toHaveBeenCalledWith({ type: 'BATCH_CHECK_END' });
});
it('should set state to NOT_UPDATABLE if no install metadata', async () => {
const extensions: GeminiCLIExtension[] = [
{ ...mockExtension, installMetadata: undefined },
];
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
await extensionManager.loadExtensions(),
extensionManager,
dispatch,
extensions,
mockExtensionManager,
mockDispatch,
);
expect(dispatch).toHaveBeenCalledWith({
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: 'test-extension',
name: mockExtension.name,
state: ExtensionUpdateState.NOT_UPDATABLE,
},
});
});
it('should check for updates and update state', async () => {
const extensions: GeminiCLIExtension[] = [
{ ...mockExtension, installMetadata: { type: 'git', source: '...' } },
];
vi.mocked(checkForExtensionUpdate).mockResolvedValue(
ExtensionUpdateState.UPDATE_AVAILABLE,
);
await checkForAllExtensionUpdates(
extensions,
mockExtensionManager,
mockDispatch,
);
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: mockExtension.name,
state: ExtensionUpdateState.CHECKING_FOR_UPDATES,
},
});
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: mockExtension.name,
state: ExtensionUpdateState.UPDATE_AVAILABLE,
},
});
});
it('should return UpToDate for a git extension with no updates', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
]);
mockGit.listRemote.mockResolvedValue('sameHash HEAD');
mockGit.revparse.mockResolvedValue('sameHash');
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
await extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: 'test-extension',
state: ExtensionUpdateState.UP_TO_DATE,
},
});
});
it('should return UpToDate for a local extension with no updates', async () => {
const localExtensionSourcePath = path.join(tempHomeDir, 'local-source');
const sourceExtensionDir = createExtension({
extensionsDir: localExtensionSourcePath,
name: 'my-local-ext',
version: '1.0.0',
});
createExtension({
extensionsDir: userExtensionsDir,
name: 'local-extension',
version: '1.0.0',
installMetadata: { source: sourceExtensionDir, type: 'local' },
});
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
await extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: 'local-extension',
state: ExtensionUpdateState.UP_TO_DATE,
},
});
});
it('should return UpdateAvailable for a local extension with updates', async () => {
const localExtensionSourcePath = path.join(tempHomeDir, 'local-source');
const sourceExtensionDir = createExtension({
extensionsDir: localExtensionSourcePath,
name: 'local-extension',
version: '1.1.0',
});
createExtension({
extensionsDir: userExtensionsDir,
name: 'local-extension',
version: '1.0.0',
installMetadata: { source: sourceExtensionDir, type: 'local' },
});
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
await extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: 'local-extension',
state: ExtensionUpdateState.UPDATE_AVAILABLE,
},
});
});
it('should return Error when git check fails', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'error-extension',
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
mockGit.getRemotes.mockRejectedValue(new Error('Git error'));
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
await extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: 'error-extension',
state: ExtensionUpdateState.ERROR,
},
});
});
});
});
@@ -5,7 +5,32 @@
*/
import { expect, describe, it } from 'vitest';
import { hydrateString } from './variables.js';
import {
hydrateString,
recursivelyHydrateStrings,
validateVariables,
type VariableContext,
} from './variables.js';
describe('validateVariables', () => {
it('should not throw if all required variables are present', () => {
const schema = {
extensionPath: { type: 'string', description: 'test', required: true },
} as const;
const context = { extensionPath: 'value' };
expect(() => validateVariables(context, schema)).not.toThrow();
});
it('should throw if a required variable is missing', () => {
const schema = {
extensionPath: { type: 'string', description: 'test', required: true },
} as const;
const context = {};
expect(() => validateVariables(context, schema)).toThrow(
'Missing required variable: extensionPath',
);
});
});
describe('hydrateString', () => {
it('should replace a single variable', () => {
@@ -15,4 +40,88 @@ describe('hydrateString', () => {
const result = hydrateString('Hello, ${extensionPath}!', context);
expect(result).toBe('Hello, path/my-extension!');
});
it('should replace multiple variables', () => {
const context = {
extensionPath: 'path/my-extension',
workspacePath: '/ws',
};
const result = hydrateString(
'Ext: ${extensionPath}, WS: ${workspacePath}',
context,
);
expect(result).toBe('Ext: path/my-extension, WS: /ws');
});
it('should ignore unknown variables', () => {
const context = {
extensionPath: 'path/my-extension',
};
const result = hydrateString('Hello, ${unknown}!', context);
expect(result).toBe('Hello, ${unknown}!');
});
it('should handle null and undefined context values', () => {
const context: VariableContext = {
extensionPath: undefined,
};
const result = hydrateString(
'Ext: ${extensionPath}, WS: ${workspacePath}',
context,
);
expect(result).toBe('Ext: ${extensionPath}, WS: ${workspacePath}');
});
});
describe('recursivelyHydrateStrings', () => {
const context = {
extensionPath: 'path/my-extension',
workspacePath: '/ws',
};
it('should hydrate strings in a flat object', () => {
const obj = {
a: 'Hello, ${workspacePath}',
b: 'Hi, ${extensionPath}',
};
const result = recursivelyHydrateStrings(obj, context);
expect(result).toEqual({
a: 'Hello, /ws',
b: 'Hi, path/my-extension',
});
});
it('should hydrate strings in an array', () => {
const arr = ['${workspacePath}', '${extensionPath}'];
const result = recursivelyHydrateStrings(arr, context);
expect(result).toEqual(['/ws', 'path/my-extension']);
});
it('should hydrate strings in a nested object', () => {
const obj = {
a: 'Hello, ${workspacePath}',
b: {
c: 'Hi, ${extensionPath}',
d: ['${workspacePath}/foo'],
},
};
const result = recursivelyHydrateStrings(obj, context);
expect(result).toEqual({
a: 'Hello, /ws',
b: {
c: 'Hi, path/my-extension',
d: ['/ws/foo'],
},
});
});
it('should not modify non-string values', () => {
const obj = {
a: 123,
b: true,
c: null,
};
const result = recursivelyHydrateStrings(obj, context);
expect(result).toEqual(obj);
});
});