mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-15 07:41:03 -07:00
Optimize and improve test coverage for cli/src/config (#13485)
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { AuthType } from '@google/gemini-cli-core';
|
||||
import { vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { validateAuthMethod } from './auth.js';
|
||||
|
||||
vi.mock('./settings.js', () => ({
|
||||
@@ -17,7 +17,6 @@ vi.mock('./settings.js', () => ({
|
||||
|
||||
describe('validateAuthMethod', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('GEMINI_API_KEY', undefined);
|
||||
vi.stubEnv('GOOGLE_CLOUD_PROJECT', undefined);
|
||||
vi.stubEnv('GOOGLE_CLOUD_LOCATION', undefined);
|
||||
@@ -28,53 +27,73 @@ describe('validateAuthMethod', () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should return null for LOGIN_WITH_GOOGLE', () => {
|
||||
expect(validateAuthMethod(AuthType.LOGIN_WITH_GOOGLE)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for COMPUTE_ADC', () => {
|
||||
expect(validateAuthMethod(AuthType.COMPUTE_ADC)).toBeNull();
|
||||
});
|
||||
|
||||
describe('USE_GEMINI', () => {
|
||||
it('should return null if GEMINI_API_KEY is set', () => {
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-key');
|
||||
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return an error message if GEMINI_API_KEY is not set', () => {
|
||||
vi.stubEnv('GEMINI_API_KEY', undefined);
|
||||
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('USE_VERTEX_AI', () => {
|
||||
it('should return null if GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION are set', () => {
|
||||
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'test-project');
|
||||
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'test-location');
|
||||
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if GOOGLE_API_KEY is set', () => {
|
||||
vi.stubEnv('GOOGLE_API_KEY', 'test-api-key');
|
||||
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return an error message if no required environment variables are set', () => {
|
||||
vi.stubEnv('GOOGLE_CLOUD_PROJECT', undefined);
|
||||
vi.stubEnv('GOOGLE_CLOUD_LOCATION', undefined);
|
||||
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBe(
|
||||
it.each([
|
||||
{
|
||||
description: 'should return null for LOGIN_WITH_GOOGLE',
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
envs: {},
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
description: 'should return null for COMPUTE_ADC',
|
||||
authType: AuthType.COMPUTE_ADC,
|
||||
envs: {},
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
description: 'should return null for USE_GEMINI if GEMINI_API_KEY is set',
|
||||
authType: AuthType.USE_GEMINI,
|
||||
envs: { GEMINI_API_KEY: 'test-key' },
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should return an error message for USE_GEMINI if GEMINI_API_KEY is not set',
|
||||
authType: AuthType.USE_GEMINI,
|
||||
envs: {},
|
||||
expected:
|
||||
'When using Gemini API, you must specify the GEMINI_API_KEY environment variable.\n' +
|
||||
'Update your environment and try again (no reload needed if using .env)!',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should return null for USE_VERTEX_AI if GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION are set',
|
||||
authType: AuthType.USE_VERTEX_AI,
|
||||
envs: {
|
||||
GOOGLE_CLOUD_PROJECT: 'test-project',
|
||||
GOOGLE_CLOUD_LOCATION: 'test-location',
|
||||
},
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should return null for USE_VERTEX_AI if GOOGLE_API_KEY is set',
|
||||
authType: AuthType.USE_VERTEX_AI,
|
||||
envs: { GOOGLE_API_KEY: 'test-api-key' },
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should return an error message for USE_VERTEX_AI if no required environment variables are set',
|
||||
authType: AuthType.USE_VERTEX_AI,
|
||||
envs: {},
|
||||
expected:
|
||||
'When using Vertex AI, you must specify either:\n' +
|
||||
'• GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables.\n' +
|
||||
'• GOOGLE_API_KEY environment variable (if using express mode).\n' +
|
||||
'Update your environment and try again (no reload needed if using .env)!',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an error message for an invalid auth method', () => {
|
||||
expect(validateAuthMethod('invalid-method')).toBe(
|
||||
'Invalid auth method selected.',
|
||||
);
|
||||
'• GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables.\n' +
|
||||
'• GOOGLE_API_KEY environment variable (if using express mode).\n' +
|
||||
'Update your environment and try again (no reload needed if using .env)!',
|
||||
},
|
||||
{
|
||||
description: 'should return an error message for an invalid auth method',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
authType: 'invalid-method' as any,
|
||||
envs: {},
|
||||
expected: 'Invalid auth method selected.',
|
||||
},
|
||||
])('$description', ({ authType, envs, expected }) => {
|
||||
for (const [key, value] of Object.entries(envs)) {
|
||||
vi.stubEnv(key, value as string);
|
||||
}
|
||||
expect(validateAuthMethod(authType)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,12 @@ export function validateAuthMethod(authMethod: string): string | null {
|
||||
}
|
||||
|
||||
if (authMethod === AuthType.USE_GEMINI) {
|
||||
if (!process.env['GEMINI_API_KEY']) {
|
||||
return (
|
||||
'When using Gemini API, you must specify the GEMINI_API_KEY environment variable.\n' +
|
||||
'Update your environment and try again (no reload needed if using .env)!'
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,16 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
@@ -64,106 +73,33 @@ describe('Configuration Integration Tests', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('File Filtering Configuration', () => {
|
||||
it('should load default file filtering settings', async () => {
|
||||
const configParams: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
cwd: '/tmp',
|
||||
model: 'test-model',
|
||||
embeddingModel: 'test-embedding-model',
|
||||
sandbox: undefined,
|
||||
targetDir: tempDir,
|
||||
debugMode: false,
|
||||
};
|
||||
|
||||
const config = new Config(configParams);
|
||||
|
||||
expect(config.getFileFilteringRespectGitIgnore()).toBe(
|
||||
DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
|
||||
);
|
||||
});
|
||||
|
||||
it('should load custom file filtering settings from configuration', async () => {
|
||||
const configParams: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
cwd: '/tmp',
|
||||
model: 'test-model',
|
||||
embeddingModel: 'test-embedding-model',
|
||||
sandbox: undefined,
|
||||
targetDir: tempDir,
|
||||
debugMode: false,
|
||||
fileFiltering: {
|
||||
respectGitIgnore: false,
|
||||
},
|
||||
};
|
||||
|
||||
const config = new Config(configParams);
|
||||
|
||||
expect(config.getFileFilteringRespectGitIgnore()).toBe(false);
|
||||
});
|
||||
|
||||
it('should merge user and workspace file filtering settings', async () => {
|
||||
const configParams: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
cwd: '/tmp',
|
||||
model: 'test-model',
|
||||
embeddingModel: 'test-embedding-model',
|
||||
sandbox: undefined,
|
||||
targetDir: tempDir,
|
||||
debugMode: false,
|
||||
fileFiltering: {
|
||||
respectGitIgnore: true,
|
||||
},
|
||||
};
|
||||
|
||||
const config = new Config(configParams);
|
||||
|
||||
expect(config.getFileFilteringRespectGitIgnore()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configuration Integration', () => {
|
||||
it('should handle partial configuration objects gracefully', async () => {
|
||||
const configParams: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
cwd: '/tmp',
|
||||
model: 'test-model',
|
||||
embeddingModel: 'test-embedding-model',
|
||||
sandbox: undefined,
|
||||
targetDir: tempDir,
|
||||
debugMode: false,
|
||||
fileFiltering: {
|
||||
respectGitIgnore: false,
|
||||
},
|
||||
};
|
||||
|
||||
const config = new Config(configParams);
|
||||
|
||||
// Specified settings should be applied
|
||||
expect(config.getFileFilteringRespectGitIgnore()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty configuration objects gracefully', async () => {
|
||||
const configParams: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
cwd: '/tmp',
|
||||
model: 'test-model',
|
||||
embeddingModel: 'test-embedding-model',
|
||||
sandbox: undefined,
|
||||
targetDir: tempDir,
|
||||
debugMode: false,
|
||||
describe('File Filtering and Configuration', () => {
|
||||
it.each([
|
||||
{
|
||||
description:
|
||||
'should load default file filtering settings when fileFiltering is missing',
|
||||
fileFiltering: undefined,
|
||||
expected: DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should load custom file filtering settings from configuration',
|
||||
fileFiltering: { respectGitIgnore: false },
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should respect file filtering settings from configuration',
|
||||
fileFiltering: { respectGitIgnore: true },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should handle empty fileFiltering object gracefully and use defaults',
|
||||
fileFiltering: {},
|
||||
};
|
||||
|
||||
const config = new Config(configParams);
|
||||
|
||||
// All settings should use defaults
|
||||
expect(config.getFileFilteringRespectGitIgnore()).toBe(
|
||||
DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing configuration sections gracefully', async () => {
|
||||
expected: DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
|
||||
},
|
||||
])('$description', async ({ fileFiltering, expected }) => {
|
||||
const configParams: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
cwd: '/tmp',
|
||||
@@ -172,20 +108,26 @@ describe('Configuration Integration Tests', () => {
|
||||
sandbox: undefined,
|
||||
targetDir: tempDir,
|
||||
debugMode: false,
|
||||
// Missing fileFiltering configuration
|
||||
fileFiltering,
|
||||
};
|
||||
|
||||
const config = new Config(configParams);
|
||||
|
||||
// All git-aware settings should use defaults
|
||||
expect(config.getFileFilteringRespectGitIgnore()).toBe(
|
||||
DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
|
||||
);
|
||||
expect(config.getFileFilteringRespectGitIgnore()).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-world Configuration Scenarios', () => {
|
||||
it('should handle a security-focused configuration', async () => {
|
||||
it.each([
|
||||
{
|
||||
description: 'should handle a security-focused configuration',
|
||||
respectGitIgnore: true,
|
||||
},
|
||||
{
|
||||
description: 'should handle a CI/CD environment configuration',
|
||||
respectGitIgnore: false,
|
||||
},
|
||||
])('$description', async ({ respectGitIgnore }) => {
|
||||
const configParams: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
cwd: '/tmp',
|
||||
@@ -195,32 +137,13 @@ describe('Configuration Integration Tests', () => {
|
||||
targetDir: tempDir,
|
||||
debugMode: false,
|
||||
fileFiltering: {
|
||||
respectGitIgnore: true,
|
||||
respectGitIgnore,
|
||||
},
|
||||
};
|
||||
|
||||
const config = new Config(configParams);
|
||||
|
||||
expect(config.getFileFilteringRespectGitIgnore()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle a CI/CD environment configuration', async () => {
|
||||
const configParams: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
cwd: '/tmp',
|
||||
model: 'test-model',
|
||||
embeddingModel: 'test-embedding-model',
|
||||
sandbox: undefined,
|
||||
targetDir: tempDir,
|
||||
debugMode: false,
|
||||
fileFiltering: {
|
||||
respectGitIgnore: false,
|
||||
}, // CI might need to see all files
|
||||
};
|
||||
|
||||
const config = new Config(configParams);
|
||||
|
||||
expect(config.getFileFilteringRespectGitIgnore()).toBe(false);
|
||||
expect(config.getFileFilteringRespectGitIgnore()).toBe(respectGitIgnore);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -252,139 +175,70 @@ describe('Configuration Integration Tests', () => {
|
||||
parseArguments = parseArgs;
|
||||
});
|
||||
|
||||
it('should parse --approval-mode=auto_edit correctly through the full argument parsing flow', async () => {
|
||||
const originalArgv = process.argv;
|
||||
|
||||
try {
|
||||
process.argv = [
|
||||
it.each([
|
||||
{
|
||||
description: 'should parse --approval-mode=auto_edit correctly',
|
||||
argv: [
|
||||
'node',
|
||||
'script.js',
|
||||
'--approval-mode',
|
||||
'auto_edit',
|
||||
'-p',
|
||||
'test',
|
||||
];
|
||||
|
||||
const argv = await parseArguments({} as Settings);
|
||||
|
||||
// Verify that the argument was parsed correctly
|
||||
expect(argv.approvalMode).toBe('auto_edit');
|
||||
expect(argv.prompt).toBe('test');
|
||||
expect(argv.yolo).toBe(false);
|
||||
],
|
||||
expected: { approvalMode: 'auto_edit', prompt: 'test', yolo: false },
|
||||
},
|
||||
{
|
||||
description: 'should parse --approval-mode=yolo correctly',
|
||||
argv: ['node', 'script.js', '--approval-mode', 'yolo', '-p', 'test'],
|
||||
expected: { approvalMode: 'yolo', prompt: 'test', yolo: false },
|
||||
},
|
||||
{
|
||||
description: 'should parse --approval-mode=default correctly',
|
||||
argv: ['node', 'script.js', '--approval-mode', 'default', '-p', 'test'],
|
||||
expected: { approvalMode: 'default', prompt: 'test', yolo: false },
|
||||
},
|
||||
{
|
||||
description: 'should parse legacy --yolo flag correctly',
|
||||
argv: ['node', 'script.js', '--yolo', '-p', 'test'],
|
||||
expected: { yolo: true, approvalMode: undefined, prompt: 'test' },
|
||||
},
|
||||
{
|
||||
description: 'should handle no approval mode arguments',
|
||||
argv: ['node', 'script.js', '-p', 'test'],
|
||||
expected: { approvalMode: undefined, yolo: false, prompt: 'test' },
|
||||
},
|
||||
])('$description', async ({ argv, expected }) => {
|
||||
const originalArgv = process.argv;
|
||||
try {
|
||||
process.argv = argv;
|
||||
const parsedArgs = await parseArguments({} as Settings);
|
||||
expect(parsedArgs.approvalMode).toBe(expected.approvalMode);
|
||||
expect(parsedArgs.prompt).toBe(expected.prompt);
|
||||
expect(parsedArgs.yolo).toBe(expected.yolo);
|
||||
} finally {
|
||||
process.argv = originalArgv;
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse --approval-mode=yolo correctly through the full argument parsing flow', async () => {
|
||||
it.each([
|
||||
{
|
||||
description: 'should reject invalid approval mode values',
|
||||
argv: ['node', 'script.js', '--approval-mode', 'invalid_mode'],
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should reject conflicting --yolo and --approval-mode flags',
|
||||
argv: ['node', 'script.js', '--yolo', '--approval-mode', 'default'],
|
||||
},
|
||||
])('$description', async ({ argv }) => {
|
||||
const originalArgv = process.argv;
|
||||
|
||||
try {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--approval-mode',
|
||||
'yolo',
|
||||
'-p',
|
||||
'test',
|
||||
];
|
||||
|
||||
const argv = await parseArguments({} as Settings);
|
||||
|
||||
expect(argv.approvalMode).toBe('yolo');
|
||||
expect(argv.prompt).toBe('test');
|
||||
expect(argv.yolo).toBe(false); // Should NOT be set when using --approval-mode
|
||||
} finally {
|
||||
process.argv = originalArgv;
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse --approval-mode=default correctly through the full argument parsing flow', async () => {
|
||||
const originalArgv = process.argv;
|
||||
|
||||
try {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--approval-mode',
|
||||
'default',
|
||||
'-p',
|
||||
'test',
|
||||
];
|
||||
|
||||
const argv = await parseArguments({} as Settings);
|
||||
|
||||
expect(argv.approvalMode).toBe('default');
|
||||
expect(argv.prompt).toBe('test');
|
||||
expect(argv.yolo).toBe(false);
|
||||
} finally {
|
||||
process.argv = originalArgv;
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse legacy --yolo flag correctly', async () => {
|
||||
const originalArgv = process.argv;
|
||||
|
||||
try {
|
||||
process.argv = ['node', 'script.js', '--yolo', '-p', 'test'];
|
||||
|
||||
const argv = await parseArguments({} as Settings);
|
||||
|
||||
expect(argv.yolo).toBe(true);
|
||||
expect(argv.approvalMode).toBeUndefined(); // Should NOT be set when using --yolo
|
||||
expect(argv.prompt).toBe('test');
|
||||
} finally {
|
||||
process.argv = originalArgv;
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid approval mode values during argument parsing', async () => {
|
||||
const originalArgv = process.argv;
|
||||
|
||||
try {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'invalid_mode'];
|
||||
|
||||
// Should throw during argument parsing due to yargs validation
|
||||
process.argv = argv;
|
||||
await expect(parseArguments({} as Settings)).rejects.toThrow();
|
||||
} finally {
|
||||
process.argv = originalArgv;
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject conflicting --yolo and --approval-mode flags', async () => {
|
||||
const originalArgv = process.argv;
|
||||
|
||||
try {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--yolo',
|
||||
'--approval-mode',
|
||||
'default',
|
||||
];
|
||||
|
||||
// Should throw during argument parsing due to conflict validation
|
||||
await expect(parseArguments({} as Settings)).rejects.toThrow();
|
||||
} finally {
|
||||
process.argv = originalArgv;
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle backward compatibility with mixed scenarios', async () => {
|
||||
const originalArgv = process.argv;
|
||||
|
||||
try {
|
||||
// Test that no approval mode arguments defaults to no flags set
|
||||
process.argv = ['node', 'script.js', '-p', 'test'];
|
||||
|
||||
const argv = await parseArguments({} as Settings);
|
||||
|
||||
expect(argv.approvalMode).toBeUndefined();
|
||||
expect(argv.yolo).toBe(false);
|
||||
expect(argv.prompt).toBe('test');
|
||||
} finally {
|
||||
process.argv = originalArgv;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,307 +149,264 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('parseArguments', () => {
|
||||
it('should throw an error when both --prompt and --prompt-interactive are used together', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--prompt',
|
||||
'test prompt',
|
||||
'--prompt-interactive',
|
||||
'interactive prompt',
|
||||
];
|
||||
it.each([
|
||||
{
|
||||
description: 'long flags',
|
||||
argv: [
|
||||
'node',
|
||||
'script.js',
|
||||
'--prompt',
|
||||
'test prompt',
|
||||
'--prompt-interactive',
|
||||
'interactive prompt',
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'short flags',
|
||||
argv: [
|
||||
'node',
|
||||
'script.js',
|
||||
'-p',
|
||||
'test prompt',
|
||||
'-i',
|
||||
'interactive prompt',
|
||||
],
|
||||
},
|
||||
])(
|
||||
'should throw an error when using conflicting prompt flags ($description)',
|
||||
async ({ argv }) => {
|
||||
process.argv = argv;
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
const mockConsoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const debugErrorSpy = vi
|
||||
.spyOn(debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const mockConsoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await expect(parseArguments({} as Settings)).rejects.toThrow(
|
||||
'process.exit called',
|
||||
await expect(parseArguments({} as Settings)).rejects.toThrow(
|
||||
'process.exit called',
|
||||
);
|
||||
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
|
||||
),
|
||||
);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
description: 'should allow --prompt without --prompt-interactive',
|
||||
argv: ['node', 'script.js', '--prompt', 'test prompt'],
|
||||
expected: { prompt: 'test prompt', promptInteractive: undefined },
|
||||
},
|
||||
{
|
||||
description: 'should allow --prompt-interactive without --prompt',
|
||||
argv: ['node', 'script.js', '--prompt-interactive', 'interactive prompt'],
|
||||
expected: { prompt: undefined, promptInteractive: 'interactive prompt' },
|
||||
},
|
||||
{
|
||||
description: 'should allow -i flag as alias for --prompt-interactive',
|
||||
argv: ['node', 'script.js', '-i', 'interactive prompt'],
|
||||
expected: { prompt: undefined, promptInteractive: 'interactive prompt' },
|
||||
},
|
||||
])('$description', async ({ argv, expected }) => {
|
||||
process.argv = argv;
|
||||
const parsedArgs = await parseArguments({} as Settings);
|
||||
expect(parsedArgs.prompt).toBe(expected.prompt);
|
||||
expect(parsedArgs.promptInteractive).toBe(expected.promptInteractive);
|
||||
});
|
||||
|
||||
describe('positional arguments and @commands', () => {
|
||||
it.each([
|
||||
{
|
||||
description:
|
||||
'should convert positional query argument to prompt by default',
|
||||
argv: ['node', 'script.js', 'Hi Gemini'],
|
||||
expectedQuery: 'Hi Gemini',
|
||||
expectedModel: undefined,
|
||||
debug: false,
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should map @path to prompt (one-shot) when it starts with @',
|
||||
argv: ['node', 'script.js', '@path ./file.md'],
|
||||
expectedQuery: '@path ./file.md',
|
||||
expectedModel: undefined,
|
||||
debug: false,
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should map @path to prompt even when config flags are present',
|
||||
argv: [
|
||||
'node',
|
||||
'script.js',
|
||||
'@path',
|
||||
'./file.md',
|
||||
'--model',
|
||||
'gemini-2.5-pro',
|
||||
],
|
||||
expectedQuery: '@path ./file.md',
|
||||
expectedModel: 'gemini-2.5-pro',
|
||||
debug: false,
|
||||
},
|
||||
{
|
||||
description:
|
||||
'maps unquoted positional @path + arg to prompt (one-shot)',
|
||||
argv: ['node', 'script.js', '@path', './file.md'],
|
||||
expectedQuery: '@path ./file.md',
|
||||
expectedModel: undefined,
|
||||
debug: false,
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should handle multiple @path arguments in a single command (one-shot)',
|
||||
argv: [
|
||||
'node',
|
||||
'script.js',
|
||||
'@path',
|
||||
'./file1.md',
|
||||
'@path',
|
||||
'./file2.md',
|
||||
],
|
||||
expectedQuery: '@path ./file1.md @path ./file2.md',
|
||||
expectedModel: undefined,
|
||||
debug: false,
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should handle mixed quoted and unquoted @path arguments (one-shot)',
|
||||
argv: [
|
||||
'node',
|
||||
'script.js',
|
||||
'@path ./file1.md',
|
||||
'@path',
|
||||
'./file2.md',
|
||||
'additional text',
|
||||
],
|
||||
expectedQuery: '@path ./file1.md @path ./file2.md additional text',
|
||||
expectedModel: undefined,
|
||||
debug: false,
|
||||
},
|
||||
{
|
||||
description: 'should map @path to prompt with ambient flags (debug)',
|
||||
argv: ['node', 'script.js', '@path', './file.md', '--debug'],
|
||||
expectedQuery: '@path ./file.md',
|
||||
expectedModel: undefined,
|
||||
debug: true,
|
||||
},
|
||||
{
|
||||
description: 'should map @include to prompt (one-shot)',
|
||||
argv: ['node', 'script.js', '@include src/'],
|
||||
expectedQuery: '@include src/',
|
||||
expectedModel: undefined,
|
||||
debug: false,
|
||||
},
|
||||
{
|
||||
description: 'should map @search to prompt (one-shot)',
|
||||
argv: ['node', 'script.js', '@search pattern'],
|
||||
expectedQuery: '@search pattern',
|
||||
expectedModel: undefined,
|
||||
debug: false,
|
||||
},
|
||||
{
|
||||
description: 'should map @web to prompt (one-shot)',
|
||||
argv: ['node', 'script.js', '@web query'],
|
||||
expectedQuery: '@web query',
|
||||
expectedModel: undefined,
|
||||
debug: false,
|
||||
},
|
||||
{
|
||||
description: 'should map @git to prompt (one-shot)',
|
||||
argv: ['node', 'script.js', '@git status'],
|
||||
expectedQuery: '@git status',
|
||||
expectedModel: undefined,
|
||||
debug: false,
|
||||
},
|
||||
{
|
||||
description: 'should handle @command with leading whitespace',
|
||||
argv: ['node', 'script.js', ' @path ./file.md'],
|
||||
expectedQuery: ' @path ./file.md',
|
||||
expectedModel: undefined,
|
||||
debug: false,
|
||||
},
|
||||
])(
|
||||
'$description',
|
||||
async ({ argv, expectedQuery, expectedModel, debug }) => {
|
||||
process.argv = argv;
|
||||
const parsedArgs = await parseArguments({} as Settings);
|
||||
expect(parsedArgs.query).toBe(expectedQuery);
|
||||
expect(parsedArgs.prompt).toBe(expectedQuery);
|
||||
expect(parsedArgs.promptInteractive).toBeUndefined();
|
||||
if (expectedModel) {
|
||||
expect(parsedArgs.model).toBe(expectedModel);
|
||||
}
|
||||
if (debug) {
|
||||
expect(parsedArgs.debug).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
expect(debugErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
|
||||
),
|
||||
);
|
||||
// yargs.showHelp() calls console.error
|
||||
expect(mockConsoleError).toHaveBeenCalled();
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
debugErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should throw an error when using short flags -p and -i together', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'-p',
|
||||
'test prompt',
|
||||
'-i',
|
||||
'interactive prompt',
|
||||
];
|
||||
it.each([
|
||||
{
|
||||
description: 'long flags',
|
||||
argv: ['node', 'script.js', '--yolo', '--approval-mode', 'default'],
|
||||
},
|
||||
{
|
||||
description: 'short flags',
|
||||
argv: ['node', 'script.js', '-y', '--approval-mode', 'yolo'],
|
||||
},
|
||||
])(
|
||||
'should throw an error when using conflicting yolo/approval-mode flags ($description)',
|
||||
async ({ argv }) => {
|
||||
process.argv = argv;
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
const mockConsoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const debugErrorSpy = vi
|
||||
.spyOn(debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const mockConsoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await expect(parseArguments({} as Settings)).rejects.toThrow(
|
||||
'process.exit called',
|
||||
);
|
||||
await expect(parseArguments({} as Settings)).rejects.toThrow(
|
||||
'process.exit called',
|
||||
);
|
||||
|
||||
expect(debugErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
|
||||
),
|
||||
);
|
||||
expect(mockConsoleError).toHaveBeenCalled();
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
|
||||
),
|
||||
);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
debugErrorSpy.mockRestore();
|
||||
});
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
},
|
||||
);
|
||||
|
||||
it('should allow --prompt without --prompt-interactive', async () => {
|
||||
process.argv = ['node', 'script.js', '--prompt', 'test prompt'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
expect(argv.prompt).toBe('test prompt');
|
||||
expect(argv.promptInteractive).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow --prompt-interactive without --prompt', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--prompt-interactive',
|
||||
'interactive prompt',
|
||||
];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
expect(argv.promptInteractive).toBe('interactive prompt');
|
||||
expect(argv.prompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow -i flag as alias for --prompt-interactive', async () => {
|
||||
process.argv = ['node', 'script.js', '-i', 'interactive prompt'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
expect(argv.promptInteractive).toBe('interactive prompt');
|
||||
expect(argv.prompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should convert positional query argument to prompt by default', async () => {
|
||||
process.argv = ['node', 'script.js', 'Hi Gemini'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
expect(argv.query).toBe('Hi Gemini');
|
||||
expect(argv.prompt).toBe('Hi Gemini');
|
||||
expect(argv.promptInteractive).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should map @path to prompt (one-shot) when it starts with @', async () => {
|
||||
process.argv = ['node', 'script.js', '@path ./file.md'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
expect(argv.query).toBe('@path ./file.md');
|
||||
expect(argv.prompt).toBe('@path ./file.md');
|
||||
expect(argv.promptInteractive).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should map @path to prompt even when config flags are present', async () => {
|
||||
// @path queries should now go to one-shot mode regardless of other flags
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'@path',
|
||||
'./file.md',
|
||||
'--model',
|
||||
'gemini-2.5-pro',
|
||||
];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
expect(argv.query).toBe('@path ./file.md');
|
||||
expect(argv.prompt).toBe('@path ./file.md'); // Should map to one-shot
|
||||
expect(argv.promptInteractive).toBeUndefined();
|
||||
expect(argv.model).toBe('gemini-2.5-pro');
|
||||
});
|
||||
|
||||
it('maps unquoted positional @path + arg to prompt (one-shot)', async () => {
|
||||
// Simulate: gemini @path ./file.md
|
||||
process.argv = ['node', 'script.js', '@path', './file.md'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
// After normalization, query is a single string
|
||||
expect(argv.query).toBe('@path ./file.md');
|
||||
// And it's mapped to one-shot prompt when no -p/-i flags are set
|
||||
expect(argv.prompt).toBe('@path ./file.md');
|
||||
expect(argv.promptInteractive).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle multiple @path arguments in a single command (one-shot)', async () => {
|
||||
// Simulate: gemini @path ./file1.md @path ./file2.md
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'@path',
|
||||
'./file1.md',
|
||||
'@path',
|
||||
'./file2.md',
|
||||
];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
// After normalization, all arguments are joined with spaces
|
||||
expect(argv.query).toBe('@path ./file1.md @path ./file2.md');
|
||||
// And it's mapped to one-shot prompt
|
||||
expect(argv.prompt).toBe('@path ./file1.md @path ./file2.md');
|
||||
expect(argv.promptInteractive).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle mixed quoted and unquoted @path arguments (one-shot)', async () => {
|
||||
// Simulate: gemini "@path ./file1.md" @path ./file2.md "additional text"
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'@path ./file1.md',
|
||||
'@path',
|
||||
'./file2.md',
|
||||
'additional text',
|
||||
];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
// After normalization, all arguments are joined with spaces
|
||||
expect(argv.query).toBe(
|
||||
'@path ./file1.md @path ./file2.md additional text',
|
||||
);
|
||||
// And it's mapped to one-shot prompt
|
||||
expect(argv.prompt).toBe(
|
||||
'@path ./file1.md @path ./file2.md additional text',
|
||||
);
|
||||
expect(argv.promptInteractive).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should map @path to prompt with ambient flags (debug)', async () => {
|
||||
// Ambient flags like debug should NOT affect routing
|
||||
process.argv = ['node', 'script.js', '@path', './file.md', '--debug'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
expect(argv.query).toBe('@path ./file.md');
|
||||
expect(argv.prompt).toBe('@path ./file.md'); // Should map to one-shot
|
||||
expect(argv.promptInteractive).toBeUndefined();
|
||||
expect(argv.debug).toBe(true);
|
||||
});
|
||||
|
||||
it('should map any @command to prompt (one-shot)', async () => {
|
||||
// Test that all @commands now go to one-shot mode
|
||||
const testCases = [
|
||||
'@path ./file.md',
|
||||
'@include src/',
|
||||
'@search pattern',
|
||||
'@web query',
|
||||
'@git status',
|
||||
];
|
||||
|
||||
for (const testQuery of testCases) {
|
||||
process.argv = ['node', 'script.js', testQuery];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
expect(argv.query).toBe(testQuery);
|
||||
expect(argv.prompt).toBe(testQuery);
|
||||
expect(argv.promptInteractive).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle @command with leading whitespace', async () => {
|
||||
// Test that trim() + routing handles leading whitespace correctly
|
||||
process.argv = ['node', 'script.js', ' @path ./file.md'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
expect(argv.query).toBe(' @path ./file.md');
|
||||
expect(argv.prompt).toBe(' @path ./file.md');
|
||||
expect(argv.promptInteractive).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw an error when both --yolo and --approval-mode are used together', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--yolo',
|
||||
'--approval-mode',
|
||||
'default',
|
||||
];
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
const mockConsoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const debugErrorSpy = vi
|
||||
.spyOn(debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await expect(parseArguments({} as Settings)).rejects.toThrow(
|
||||
'process.exit called',
|
||||
);
|
||||
|
||||
expect(debugErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
|
||||
),
|
||||
);
|
||||
expect(mockConsoleError).toHaveBeenCalled();
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
debugErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should throw an error when using short flags -y and --approval-mode together', async () => {
|
||||
process.argv = ['node', 'script.js', '-y', '--approval-mode', 'yolo'];
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
const mockConsoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const debugErrorSpy = vi
|
||||
.spyOn(debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await expect(parseArguments({} as Settings)).rejects.toThrow(
|
||||
'process.exit called',
|
||||
);
|
||||
|
||||
expect(debugErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
|
||||
),
|
||||
);
|
||||
expect(mockConsoleError).toHaveBeenCalled();
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
debugErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should allow --approval-mode without --yolo', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
expect(argv.approvalMode).toBe('auto_edit');
|
||||
expect(argv.yolo).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow --yolo without --approval-mode', async () => {
|
||||
process.argv = ['node', 'script.js', '--yolo'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
expect(argv.yolo).toBe(true);
|
||||
expect(argv.approvalMode).toBeUndefined();
|
||||
it.each([
|
||||
{
|
||||
description: 'should allow --approval-mode without --yolo',
|
||||
argv: ['node', 'script.js', '--approval-mode', 'auto_edit'],
|
||||
expected: { approvalMode: 'auto_edit', yolo: false },
|
||||
},
|
||||
{
|
||||
description: 'should allow --yolo without --approval-mode',
|
||||
argv: ['node', 'script.js', '--yolo'],
|
||||
expected: { approvalMode: undefined, yolo: true },
|
||||
},
|
||||
])('$description', async ({ argv, expected }) => {
|
||||
process.argv = argv;
|
||||
const parsedArgs = await parseArguments({} as Settings);
|
||||
expect(parsedArgs.approvalMode).toBe(expected.approvalMode);
|
||||
expect(parsedArgs.yolo).toBe(expected.yolo);
|
||||
});
|
||||
|
||||
it('should reject invalid --approval-mode values', async () => {
|
||||
|
||||
@@ -4,7 +4,16 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, type MockedFunction } from 'vitest';
|
||||
import {
|
||||
vi,
|
||||
type MockedFunction,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
afterAll,
|
||||
} from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
@@ -14,7 +23,6 @@ import {
|
||||
ExtensionDisableEvent,
|
||||
ExtensionEnableEvent,
|
||||
KeychainTokenStorage,
|
||||
debugLogger,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { loadSettings, SettingScope } from './settings.js';
|
||||
import {
|
||||
@@ -127,18 +135,18 @@ interface MockKeychainStorage {
|
||||
isAvailable: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
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 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>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
keychainData = {};
|
||||
@@ -496,8 +504,8 @@ describe('extension tests', () => {
|
||||
});
|
||||
|
||||
it('should skip extensions with invalid JSON and log a warning', async () => {
|
||||
const debugErrorSpy = vi
|
||||
.spyOn(debugLogger, 'error')
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
// Good extension
|
||||
@@ -517,18 +525,18 @@ describe('extension tests', () => {
|
||||
|
||||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].name).toBe('good-ext');
|
||||
expect(debugErrorSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
expect(consoleSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.stringContaining(
|
||||
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`,
|
||||
),
|
||||
);
|
||||
|
||||
debugErrorSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should skip extensions with missing name and log a warning', async () => {
|
||||
const debugErrorSpy = vi
|
||||
.spyOn(debugLogger, 'error')
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
// Good extension
|
||||
@@ -548,13 +556,13 @@ describe('extension tests', () => {
|
||||
|
||||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].name).toBe('good-ext');
|
||||
expect(debugErrorSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
expect(consoleSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.stringContaining(
|
||||
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
|
||||
),
|
||||
);
|
||||
|
||||
debugErrorSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should filter trust out of mcp servers', async () => {
|
||||
@@ -589,48 +597,13 @@ describe('extension tests', () => {
|
||||
const extension = extensions.find((e) => e.name === 'bad_name');
|
||||
|
||||
expect(extension).toBeUndefined();
|
||||
// This test is a bit ambiguous, loadExtensions catches errors and logs them, returning null for that extension.
|
||||
// The implementation in loadExtension uses debugLogger.error.
|
||||
// However, this test previously expected console.error.
|
||||
// Wait, if I change source code to use debugLogger, I should update this.
|
||||
// But let's verify what loadExtension uses. It uses debugLogger.error (checked in previous turn).
|
||||
// So I should spy on debugLogger.error here too.
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid extension name: "bad_name"'),
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ... (rest of the file)
|
||||
|
||||
it('should not load github extensions if blockGitExtensions is set', async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-ext',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: 'http://somehost.com/foo/bar',
|
||||
},
|
||||
});
|
||||
|
||||
const blockGitExtensionsSetting = {
|
||||
security: {
|
||||
blockGitExtensions: true,
|
||||
},
|
||||
};
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings: blockGitExtensionsSetting,
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const extension = extensions.find((e) => e.name === 'my-ext');
|
||||
|
||||
expect(extension).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('id generation', () => {
|
||||
it('should generate id from source for non-github git urls', async () => {
|
||||
it('should not load github extensions if blockGitExtensions is set', async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-ext',
|
||||
@@ -640,103 +613,111 @@ describe('extension tests', () => {
|
||||
source: 'http://somehost.com/foo/bar',
|
||||
},
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const extension = extensions.find((e) => e.name === 'my-ext');
|
||||
expect(extension?.id).toBe(hashValue('http://somehost.com/foo/bar'));
|
||||
});
|
||||
|
||||
it('should generate id from owner/repo for github http urls', async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-ext',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: 'http://github.com/foo/bar',
|
||||
},
|
||||
});
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const extension = extensions.find((e) => e.name === 'my-ext');
|
||||
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
|
||||
});
|
||||
|
||||
it('should generate id from owner/repo for github ssh urls', async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-ext',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: 'git@github.com:foo/bar',
|
||||
},
|
||||
});
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const extension = extensions.find((e) => e.name === 'my-ext');
|
||||
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
|
||||
});
|
||||
|
||||
it('should generate id from source for github-release extension', async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-ext',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
type: 'github-release',
|
||||
source: 'https://github.com/foo/bar',
|
||||
const blockGitExtensionsSetting = {
|
||||
security: {
|
||||
blockGitExtensions: true,
|
||||
},
|
||||
};
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings: blockGitExtensionsSetting,
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const extension = extensions.find((e) => e.name === 'my-ext');
|
||||
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
|
||||
|
||||
expect(extension).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should generate id from the original source for local extension', async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'local-ext-name',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
type: 'local',
|
||||
source: '/some/path',
|
||||
describe('id generation', () => {
|
||||
it.each([
|
||||
{
|
||||
description: 'should generate id from source for non-github git urls',
|
||||
installMetadata: {
|
||||
type: 'git' as const,
|
||||
source: 'http://somehost.com/foo/bar',
|
||||
},
|
||||
expectedIdSource: 'http://somehost.com/foo/bar',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should generate id from owner/repo for github http urls',
|
||||
installMetadata: {
|
||||
type: 'git' as const,
|
||||
source: 'http://github.com/foo/bar',
|
||||
},
|
||||
expectedIdSource: 'https://github.com/foo/bar',
|
||||
},
|
||||
{
|
||||
description: 'should generate id from owner/repo for github ssh urls',
|
||||
installMetadata: {
|
||||
type: 'git' as const,
|
||||
source: 'git@github.com:foo/bar',
|
||||
},
|
||||
expectedIdSource: 'https://github.com/foo/bar',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should generate id from source for github-release extension',
|
||||
installMetadata: {
|
||||
type: 'github-release' as const,
|
||||
source: 'https://github.com/foo/bar',
|
||||
},
|
||||
expectedIdSource: 'https://github.com/foo/bar',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should generate id from the original source for local extension',
|
||||
installMetadata: {
|
||||
type: 'local' as const,
|
||||
source: '/some/path',
|
||||
},
|
||||
expectedIdSource: '/some/path',
|
||||
},
|
||||
])('$description', async ({ installMetadata, expectedIdSource }) => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-ext',
|
||||
version: '1.0.0',
|
||||
installMetadata,
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const extension = extensions.find((e) => e.name === 'my-ext');
|
||||
expect(extension?.id).toBe(hashValue(expectedIdSource));
|
||||
});
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const extension = extensions.find((e) => e.name === 'local-ext-name');
|
||||
expect(extension?.id).toBe(hashValue('/some/path'));
|
||||
});
|
||||
it('should generate id from the original source for linked extensions', async () => {
|
||||
const extDevelopmentDir = path.join(tempHomeDir, 'local_extensions');
|
||||
const actualExtensionDir = createExtension({
|
||||
extensionsDir: extDevelopmentDir,
|
||||
name: 'link-ext-name',
|
||||
version: '1.0.0',
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
await extensionManager.installOrUpdateExtension({
|
||||
type: 'link',
|
||||
source: actualExtensionDir,
|
||||
});
|
||||
|
||||
it('should generate id from the original source for linked extensions', async () => {
|
||||
const extDevelopmentDir = path.join(tempHomeDir, 'local_extensions');
|
||||
const actualExtensionDir = createExtension({
|
||||
extensionsDir: extDevelopmentDir,
|
||||
name: 'link-ext-name',
|
||||
version: '1.0.0',
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
await extensionManager.installOrUpdateExtension({
|
||||
type: 'link',
|
||||
source: actualExtensionDir,
|
||||
const extension = extensionManager
|
||||
.getExtensions()
|
||||
.find((e) => e.name === 'link-ext-name');
|
||||
expect(extension?.id).toBe(hashValue(actualExtensionDir));
|
||||
});
|
||||
|
||||
const extension = extensionManager
|
||||
.getExtensions()
|
||||
.find((e) => e.name === 'link-ext-name');
|
||||
expect(extension?.id).toBe(hashValue(actualExtensionDir));
|
||||
});
|
||||
it('should generate id from name for extension with no install metadata', async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'no-meta-name',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
it('should generate id from name for extension with no install metadata', async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'no-meta-name',
|
||||
version: '1.0.0',
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const extension = extensions.find((e) => e.name === 'no-meta-name');
|
||||
expect(extension?.id).toBe(hashValue('no-meta-name'));
|
||||
});
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const extension = extensions.find((e) => e.name === 'no-meta-name');
|
||||
expect(extension?.id).toBe(hashValue('no-meta-name'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1913,9 +1894,9 @@ This extension will run the following MCP servers:
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function isEnabled(options: { name: string; enabledForPath: string }) {
|
||||
const manager = new ExtensionEnablementManager();
|
||||
return manager.isEnabled(options.name, options.enabledForPath);
|
||||
}
|
||||
});
|
||||
|
||||
function isEnabled(options: { name: string; enabledForPath: string }) {
|
||||
const manager = new ExtensionEnablementManager();
|
||||
return manager.isEnabled(options.name, options.enabledForPath);
|
||||
}
|
||||
|
||||
193
packages/cli/src/config/extensions/consent.test.ts
Normal file
193
packages/cli/src/config/extensions/consent.test.ts
Normal file
@@ -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;
|
||||
|
||||
101
packages/cli/src/config/extensions/storage.test.ts
Normal file
101
packages/cli/src/config/extensions/storage.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
228
packages/cli/src/config/sandboxConfig.test.ts
Normal file
228
packages/cli/src/config/sandboxConfig.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { getPackageJson } from '@google/gemini-cli-core';
|
||||
import commandExists from 'command-exists';
|
||||
import * as os from 'node:os';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...(actual as object),
|
||||
getPackageJson: vi.fn(),
|
||||
FatalSandboxError: class extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'FatalSandboxError';
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('command-exists', () => ({
|
||||
default: {
|
||||
sync: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...(actual as object),
|
||||
platform: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedGetPackageJson = vi.mocked(getPackageJson);
|
||||
const mockedCommandExistsSync = vi.mocked(commandExists.sync);
|
||||
const mockedOsPlatform = vi.mocked(os.platform);
|
||||
|
||||
describe('loadSandboxConfig', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
mockedGetPackageJson.mockResolvedValue({
|
||||
config: { sandboxImageUri: 'default/image' },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should return undefined if sandbox is explicitly disabled via argv', async () => {
|
||||
const config = await loadSandboxConfig({}, { sandbox: false });
|
||||
expect(config).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined if sandbox is explicitly disabled via settings', async () => {
|
||||
const config = await loadSandboxConfig({ tools: { sandbox: false } }, {});
|
||||
expect(config).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined if sandbox is not configured', async () => {
|
||||
const config = await loadSandboxConfig({}, {});
|
||||
expect(config).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined if already inside a sandbox (SANDBOX env var is set)', async () => {
|
||||
process.env['SANDBOX'] = '1';
|
||||
const config = await loadSandboxConfig({}, { sandbox: true });
|
||||
expect(config).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('with GEMINI_SANDBOX environment variable', () => {
|
||||
it('should use docker if GEMINI_SANDBOX=docker and it exists', async () => {
|
||||
process.env['GEMINI_SANDBOX'] = 'docker';
|
||||
mockedCommandExistsSync.mockReturnValue(true);
|
||||
const config = await loadSandboxConfig({}, {});
|
||||
expect(config).toEqual({ command: 'docker', image: 'default/image' });
|
||||
expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker');
|
||||
});
|
||||
|
||||
it('should throw if GEMINI_SANDBOX is an invalid command', async () => {
|
||||
process.env['GEMINI_SANDBOX'] = 'invalid-command';
|
||||
await expect(loadSandboxConfig({}, {})).rejects.toThrow(
|
||||
"Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec",
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if GEMINI_SANDBOX command does not exist', async () => {
|
||||
process.env['GEMINI_SANDBOX'] = 'docker';
|
||||
mockedCommandExistsSync.mockReturnValue(false);
|
||||
await expect(loadSandboxConfig({}, {})).rejects.toThrow(
|
||||
"Missing sandbox command 'docker' (from GEMINI_SANDBOX)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with sandbox: true', () => {
|
||||
it('should use sandbox-exec on darwin if available', async () => {
|
||||
mockedOsPlatform.mockReturnValue('darwin');
|
||||
mockedCommandExistsSync.mockImplementation(
|
||||
(cmd) => cmd === 'sandbox-exec',
|
||||
);
|
||||
const config = await loadSandboxConfig({}, { sandbox: true });
|
||||
expect(config).toEqual({
|
||||
command: 'sandbox-exec',
|
||||
image: 'default/image',
|
||||
});
|
||||
});
|
||||
|
||||
it('should prefer sandbox-exec over docker on darwin', async () => {
|
||||
mockedOsPlatform.mockReturnValue('darwin');
|
||||
mockedCommandExistsSync.mockReturnValue(true); // all commands exist
|
||||
const config = await loadSandboxConfig({}, { sandbox: true });
|
||||
expect(config).toEqual({
|
||||
command: 'sandbox-exec',
|
||||
image: 'default/image',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use docker if available and sandbox is true', async () => {
|
||||
mockedOsPlatform.mockReturnValue('linux');
|
||||
mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'docker');
|
||||
const config = await loadSandboxConfig({ tools: { sandbox: true } }, {});
|
||||
expect(config).toEqual({ command: 'docker', image: 'default/image' });
|
||||
});
|
||||
|
||||
it('should use podman if available and docker is not', async () => {
|
||||
mockedOsPlatform.mockReturnValue('linux');
|
||||
mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'podman');
|
||||
const config = await loadSandboxConfig({}, { sandbox: true });
|
||||
expect(config).toEqual({ command: 'podman', image: 'default/image' });
|
||||
});
|
||||
|
||||
it('should throw if sandbox: true but no command is found', async () => {
|
||||
mockedOsPlatform.mockReturnValue('linux');
|
||||
mockedCommandExistsSync.mockReturnValue(false);
|
||||
await expect(loadSandboxConfig({}, { sandbox: true })).rejects.toThrow(
|
||||
'GEMINI_SANDBOX is true but failed to determine command for sandbox; ' +
|
||||
'install docker or podman or specify command in GEMINI_SANDBOX',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with sandbox: 'command'", () => {
|
||||
it('should use the specified command if it exists', async () => {
|
||||
mockedCommandExistsSync.mockReturnValue(true);
|
||||
const config = await loadSandboxConfig({}, { sandbox: 'podman' });
|
||||
expect(config).toEqual({ command: 'podman', image: 'default/image' });
|
||||
expect(mockedCommandExistsSync).toHaveBeenCalledWith('podman');
|
||||
});
|
||||
|
||||
it('should throw if the specified command does not exist', async () => {
|
||||
mockedCommandExistsSync.mockReturnValue(false);
|
||||
await expect(
|
||||
loadSandboxConfig({}, { sandbox: 'podman' }),
|
||||
).rejects.toThrow(
|
||||
"Missing sandbox command 'podman' (from GEMINI_SANDBOX)",
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if the specified command is invalid', async () => {
|
||||
await expect(
|
||||
loadSandboxConfig({}, { sandbox: 'invalid-command' }),
|
||||
).rejects.toThrow(
|
||||
"Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('image configuration', () => {
|
||||
it('should use image from GEMINI_SANDBOX_IMAGE env var if set', async () => {
|
||||
process.env['GEMINI_SANDBOX_IMAGE'] = 'env/image';
|
||||
process.env['GEMINI_SANDBOX'] = 'docker';
|
||||
mockedCommandExistsSync.mockReturnValue(true);
|
||||
const config = await loadSandboxConfig({}, {});
|
||||
expect(config).toEqual({ command: 'docker', image: 'env/image' });
|
||||
});
|
||||
|
||||
it('should use image from package.json if env var is not set', async () => {
|
||||
process.env['GEMINI_SANDBOX'] = 'docker';
|
||||
mockedCommandExistsSync.mockReturnValue(true);
|
||||
const config = await loadSandboxConfig({}, {});
|
||||
expect(config).toEqual({ command: 'docker', image: 'default/image' });
|
||||
});
|
||||
|
||||
it('should return undefined if command is found but no image is configured', async () => {
|
||||
mockedGetPackageJson.mockResolvedValue({}); // no sandboxImageUri
|
||||
process.env['GEMINI_SANDBOX'] = 'docker';
|
||||
mockedCommandExistsSync.mockReturnValue(true);
|
||||
const config = await loadSandboxConfig({}, {});
|
||||
expect(config).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('truthy/falsy sandbox values', () => {
|
||||
beforeEach(() => {
|
||||
mockedOsPlatform.mockReturnValue('linux');
|
||||
mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'docker');
|
||||
});
|
||||
|
||||
it.each([true, 'true', '1'])(
|
||||
'should enable sandbox for value: %s',
|
||||
async (value) => {
|
||||
const config = await loadSandboxConfig({}, { sandbox: value });
|
||||
expect(config).toEqual({ command: 'docker', image: 'default/image' });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, 'false', '0', undefined, null, ''])(
|
||||
'should disable sandbox for value: %s',
|
||||
async (value) => {
|
||||
// \`null\` is not a valid type for the arg, but good to test falsiness
|
||||
const config = await loadSandboxConfig({}, { sandbox: value });
|
||||
expect(config).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ const __dirname = path.dirname(__filename);
|
||||
// This is a stripped-down version of the CliArgs interface from config.ts
|
||||
// to avoid circular dependencies.
|
||||
interface SandboxCliArgs {
|
||||
sandbox?: boolean | string;
|
||||
sandbox?: boolean | string | null;
|
||||
}
|
||||
const VALID_SANDBOX_COMMANDS: ReadonlyArray<SandboxConfig['command']> = [
|
||||
'docker',
|
||||
@@ -34,7 +34,7 @@ function isSandboxCommand(value: string): value is SandboxConfig['command'] {
|
||||
}
|
||||
|
||||
function getSandboxCommand(
|
||||
sandbox?: boolean | string,
|
||||
sandbox?: boolean | string | null,
|
||||
): SandboxConfig['command'] | '' {
|
||||
// If the SANDBOX env var is set, we're already inside the sandbox.
|
||||
if (process.env['SANDBOX']) {
|
||||
|
||||
26
packages/cli/src/config/settingPaths.test.ts
Normal file
26
packages/cli/src/config/settingPaths.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SettingPaths } from './settingPaths.js';
|
||||
|
||||
describe('SettingPaths', () => {
|
||||
it('should have the correct structure', () => {
|
||||
expect(SettingPaths).toEqual({
|
||||
General: {
|
||||
PreferredEditor: 'general.preferredEditor',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should be immutable', () => {
|
||||
expect(Object.isFrozen(SettingPaths)).toBe(false); // It's not frozen by default in JS unless Object.freeze is called, but it's `as const` in TS.
|
||||
// However, we can check if the values are correct.
|
||||
expect(SettingPaths.General.PreferredEditor).toBe(
|
||||
'general.preferredEditor',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -157,107 +157,53 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(settings.merged).toEqual({});
|
||||
});
|
||||
|
||||
it('should load system settings if only system file exists', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === getSystemSettingsPath(),
|
||||
);
|
||||
const systemSettingsContent = {
|
||||
ui: {
|
||||
theme: 'system-default',
|
||||
it.each([
|
||||
{
|
||||
scope: 'system',
|
||||
path: getSystemSettingsPath(),
|
||||
content: {
|
||||
ui: { theme: 'system-default' },
|
||||
tools: { sandbox: false },
|
||||
},
|
||||
tools: {
|
||||
sandbox: false,
|
||||
},
|
||||
{
|
||||
scope: 'user',
|
||||
path: USER_SETTINGS_PATH,
|
||||
content: {
|
||||
ui: { theme: 'dark' },
|
||||
context: { fileName: 'USER_CONTEXT.md' },
|
||||
},
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemSettingsPath())
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
{
|
||||
scope: 'workspace',
|
||||
path: MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
content: {
|
||||
tools: { sandbox: true },
|
||||
context: { fileName: 'WORKSPACE_CONTEXT.md' },
|
||||
},
|
||||
);
|
||||
},
|
||||
])(
|
||||
'should load $scope settings if only $scope file exists',
|
||||
({ scope, path, content }) => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === path,
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === path) return JSON.stringify(content);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||
getSystemSettingsPath(),
|
||||
'utf-8',
|
||||
);
|
||||
expect(settings.system.settings).toEqual(systemSettingsContent);
|
||||
expect(settings.user.settings).toEqual({});
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
expect(settings.merged).toEqual({
|
||||
...systemSettingsContent,
|
||||
});
|
||||
});
|
||||
|
||||
it('should load user settings if only user file exists', () => {
|
||||
const expectedUserSettingsPath = USER_SETTINGS_PATH; // Use the path actually resolved by the (mocked) module
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === expectedUserSettingsPath,
|
||||
);
|
||||
const userSettingsContent = {
|
||||
ui: {
|
||||
theme: 'dark',
|
||||
},
|
||||
context: {
|
||||
fileName: 'USER_CONTEXT.md',
|
||||
},
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === expectedUserSettingsPath)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||
expectedUserSettingsPath,
|
||||
'utf-8',
|
||||
);
|
||||
expect(settings.user.settings).toEqual(userSettingsContent);
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
expect(settings.merged).toEqual({
|
||||
...userSettingsContent,
|
||||
});
|
||||
});
|
||||
|
||||
it('should load workspace settings if only workspace file exists', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
);
|
||||
const workspaceSettingsContent = {
|
||||
tools: {
|
||||
sandbox: true,
|
||||
},
|
||||
context: {
|
||||
fileName: 'WORKSPACE_CONTEXT.md',
|
||||
},
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||
MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
'utf-8',
|
||||
);
|
||||
expect(settings.user.settings).toEqual({});
|
||||
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
|
||||
expect(settings.merged).toEqual({
|
||||
...workspaceSettingsContent,
|
||||
});
|
||||
});
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(path, 'utf-8');
|
||||
expect(
|
||||
settings[scope as 'system' | 'user' | 'workspace'].settings,
|
||||
).toEqual(content);
|
||||
expect(settings.merged).toEqual(content);
|
||||
},
|
||||
);
|
||||
|
||||
it('should merge system, user and workspace settings, with system taking precedence over workspace, and workspace over user', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
@@ -662,88 +608,63 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(settings.merged.security?.disableYoloMode).toBe(true); // System setting should be used
|
||||
});
|
||||
|
||||
it('should handle contextFileName correctly when only in user settings', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = { context: { fileName: 'CUSTOM.md' } };
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '';
|
||||
it.each([
|
||||
{
|
||||
description: 'contextFileName in user settings',
|
||||
path: USER_SETTINGS_PATH,
|
||||
content: { context: { fileName: 'CUSTOM.md' } },
|
||||
expected: { key: 'context.fileName', value: 'CUSTOM.md' },
|
||||
},
|
||||
{
|
||||
description: 'contextFileName in workspace settings',
|
||||
path: MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
content: { context: { fileName: 'PROJECT_SPECIFIC.md' } },
|
||||
expected: { key: 'context.fileName', value: 'PROJECT_SPECIFIC.md' },
|
||||
},
|
||||
{
|
||||
description: 'excludedProjectEnvVars in user settings',
|
||||
path: USER_SETTINGS_PATH,
|
||||
content: {
|
||||
advanced: { excludedEnvVars: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'] },
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.context?.fileName).toBe('CUSTOM.md');
|
||||
});
|
||||
|
||||
it('should handle contextFileName correctly when only in workspace settings', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
);
|
||||
const workspaceSettingsContent = {
|
||||
context: { fileName: 'PROJECT_SPECIFIC.md' },
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '';
|
||||
expected: {
|
||||
key: 'advanced.excludedEnvVars',
|
||||
value: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'],
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.context?.fileName).toBe('PROJECT_SPECIFIC.md');
|
||||
});
|
||||
|
||||
it('should handle excludedProjectEnvVars correctly when only in user settings', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = {
|
||||
general: {},
|
||||
advanced: { excludedEnvVars: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'] },
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '';
|
||||
},
|
||||
{
|
||||
description: 'excludedProjectEnvVars in workspace settings',
|
||||
path: MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
content: {
|
||||
advanced: { excludedEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'] },
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.advanced?.excludedEnvVars).toEqual([
|
||||
'DEBUG',
|
||||
'NODE_ENV',
|
||||
'CUSTOM_VAR',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle excludedProjectEnvVars correctly when only in workspace settings', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
);
|
||||
const workspaceSettingsContent = {
|
||||
general: {},
|
||||
advanced: { excludedEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'] },
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '';
|
||||
expected: {
|
||||
key: 'advanced.excludedEnvVars',
|
||||
value: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'],
|
||||
},
|
||||
);
|
||||
},
|
||||
])(
|
||||
'should handle $description correctly',
|
||||
({ path, content, expected }) => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === path,
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === path) return JSON.stringify(content);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.advanced?.excludedEnvVars).toEqual([
|
||||
'WORKSPACE_DEBUG',
|
||||
'WORKSPACE_VAR',
|
||||
]);
|
||||
});
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
const keys = expected.key.split('.');
|
||||
let result: unknown = settings.merged;
|
||||
for (const key of keys) {
|
||||
result = (result as { [key: string]: unknown })[key];
|
||||
}
|
||||
expect(result).toEqual(expected.value);
|
||||
},
|
||||
);
|
||||
|
||||
it('should merge excludedProjectEnvVars with workspace taking precedence over user', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
@@ -810,37 +731,35 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(settings.merged.context?.fileName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should load telemetry setting from user settings', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = { telemetry: { enabled: true } };
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.telemetry?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should load telemetry setting from workspace settings', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
);
|
||||
const workspaceSettingsContent = { telemetry: { enabled: false } };
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.telemetry?.enabled).toBe(false);
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
scope: 'user',
|
||||
path: USER_SETTINGS_PATH,
|
||||
content: { telemetry: { enabled: true } },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
scope: 'workspace',
|
||||
path: MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
content: { telemetry: { enabled: false } },
|
||||
expected: false,
|
||||
},
|
||||
])(
|
||||
'should load telemetry setting from $scope settings',
|
||||
({ path, content, expected }) => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === path,
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === path) return JSON.stringify(content);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.telemetry?.enabled).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('should prioritize workspace telemetry setting over user setting', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
@@ -932,63 +851,60 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle MCP servers when only in user settings', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = {
|
||||
mcpServers: {
|
||||
it.each([
|
||||
{
|
||||
scope: 'user',
|
||||
path: USER_SETTINGS_PATH,
|
||||
content: {
|
||||
mcpServers: {
|
||||
'user-only-server': {
|
||||
command: 'user-only-command',
|
||||
description: 'User only server',
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: {
|
||||
'user-only-server': {
|
||||
command: 'user-only-command',
|
||||
description: 'User only server',
|
||||
},
|
||||
},
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '';
|
||||
},
|
||||
{
|
||||
scope: 'workspace',
|
||||
path: MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
content: {
|
||||
mcpServers: {
|
||||
'workspace-only-server': {
|
||||
command: 'workspace-only-command',
|
||||
description: 'Workspace only server',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.mcpServers).toEqual({
|
||||
'user-only-server': {
|
||||
command: 'user-only-command',
|
||||
description: 'User only server',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle MCP servers when only in workspace settings', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
);
|
||||
const workspaceSettingsContent = {
|
||||
mcpServers: {
|
||||
expected: {
|
||||
'workspace-only-server': {
|
||||
command: 'workspace-only-command',
|
||||
description: 'Workspace only server',
|
||||
},
|
||||
},
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '';
|
||||
},
|
||||
);
|
||||
},
|
||||
])(
|
||||
'should handle MCP servers when only in $scope settings',
|
||||
({ path, content, expected }) => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === path,
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === path) return JSON.stringify(content);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.mcpServers).toEqual({
|
||||
'workspace-only-server': {
|
||||
command: 'workspace-only-command',
|
||||
description: 'Workspace only server',
|
||||
},
|
||||
});
|
||||
});
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.mcpServers).toEqual(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('should have mcpServers as undefined if not in any settings file', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist
|
||||
@@ -1104,85 +1020,49 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge compressionThreshold settings, with workspace taking precedence', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const userSettingsContent = {
|
||||
general: {},
|
||||
model: { compressionThreshold: 0.5 },
|
||||
};
|
||||
const workspaceSettingsContent = {
|
||||
general: {},
|
||||
model: { compressionThreshold: 0.8 },
|
||||
};
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
describe('compressionThreshold settings', () => {
|
||||
it.each([
|
||||
{
|
||||
description:
|
||||
'should be taken from user settings if only present there',
|
||||
userContent: { model: { compressionThreshold: 0.5 } },
|
||||
workspaceContent: {},
|
||||
expected: 0.5,
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(settings.user.settings.model?.compressionThreshold).toEqual(0.5);
|
||||
expect(settings.workspace.settings.model?.compressionThreshold).toEqual(
|
||||
0.8,
|
||||
);
|
||||
expect(settings.merged.model?.compressionThreshold).toEqual(0.8);
|
||||
});
|
||||
|
||||
it('should merge output format settings, with workspace taking precedence', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const userSettingsContent = {
|
||||
output: { format: 'text' },
|
||||
};
|
||||
const workspaceSettingsContent = {
|
||||
output: { format: 'json' },
|
||||
};
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
{
|
||||
description:
|
||||
'should be taken from workspace settings if only present there',
|
||||
userContent: {},
|
||||
workspaceContent: { model: { compressionThreshold: 0.8 } },
|
||||
expected: 0.8,
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(settings.merged.output?.format).toBe('json');
|
||||
});
|
||||
|
||||
it('should handle compressionThreshold when only in user settings', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = {
|
||||
general: {},
|
||||
model: { compressionThreshold: 0.5 },
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
{
|
||||
description:
|
||||
'should prioritize workspace settings over user settings',
|
||||
userContent: { model: { compressionThreshold: 0.5 } },
|
||||
workspaceContent: { model: { compressionThreshold: 0.8 } },
|
||||
expected: 0.8,
|
||||
},
|
||||
);
|
||||
{
|
||||
description: 'should be undefined if not in any settings file',
|
||||
userContent: {},
|
||||
workspaceContent: {},
|
||||
expected: undefined,
|
||||
},
|
||||
])('$description', ({ userContent, workspaceContent, expected }) => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH) return JSON.stringify(userContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.model?.compressionThreshold).toEqual(0.5);
|
||||
});
|
||||
|
||||
it('should have model as undefined if not in any settings file', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist
|
||||
(fs.readFileSync as Mock).mockReturnValue('{}');
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.model).toBeUndefined();
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.model?.compressionThreshold).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use user compressionThreshold if workspace does not define it', () => {
|
||||
|
||||
Reference in New Issue
Block a user