mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-28 05:55:17 -07:00
Optimize and improve test coverage for cli/src/config (#13485)
This commit is contained in:
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user