mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-12 15:10:59 -07:00
449 lines
12 KiB
TypeScript
449 lines
12 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/// <reference types="vitest/globals" />
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
validateSettings,
|
|
formatValidationError,
|
|
settingsZodSchema,
|
|
} from './settings-validation.js';
|
|
import { z } from 'zod';
|
|
|
|
describe('settings-validation', () => {
|
|
describe('validateSettings', () => {
|
|
it('should accept valid settings with correct model.name as string', () => {
|
|
const validSettings = {
|
|
model: {
|
|
name: 'gemini-2.0-flash-exp',
|
|
maxSessionTurns: 10,
|
|
},
|
|
ui: {
|
|
theme: 'dark',
|
|
},
|
|
};
|
|
|
|
const result = validateSettings(validSettings);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should reject model.name as object instead of string', () => {
|
|
const invalidSettings = {
|
|
model: {
|
|
name: {
|
|
skipNextSpeakerCheck: true,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = validateSettings(invalidSettings);
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBeDefined();
|
|
|
|
if (result.error) {
|
|
const issues = result.error.issues;
|
|
expect(issues.length).toBeGreaterThan(0);
|
|
expect(issues[0]?.path).toEqual(['model', 'name']);
|
|
expect(issues[0]?.code).toBe('invalid_type');
|
|
}
|
|
});
|
|
|
|
it('should accept valid model.summarizeToolOutput structure', () => {
|
|
const validSettings = {
|
|
model: {
|
|
summarizeToolOutput: {
|
|
run_shell_command: {
|
|
tokenBudget: 500,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = validateSettings(validSettings);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should reject invalid model.summarizeToolOutput structure', () => {
|
|
const invalidSettings = {
|
|
model: {
|
|
summarizeToolOutput: {
|
|
run_shell_command: {
|
|
tokenBudget: 500,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
// First test with valid structure
|
|
let result = validateSettings(invalidSettings);
|
|
expect(result.success).toBe(true);
|
|
|
|
// Now test with wrong type (string instead of object)
|
|
const actuallyInvalidSettings = {
|
|
model: {
|
|
summarizeToolOutput: 'invalid',
|
|
},
|
|
};
|
|
|
|
result = validateSettings(actuallyInvalidSettings);
|
|
expect(result.success).toBe(false);
|
|
if (result.error) {
|
|
expect(result.error.issues.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it('should accept empty settings object', () => {
|
|
const emptySettings = {};
|
|
const result = validateSettings(emptySettings);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should accept unknown top-level keys (for migration compatibility)', () => {
|
|
const settingsWithUnknownKey = {
|
|
unknownKey: 'some value',
|
|
};
|
|
|
|
const result = validateSettings(settingsWithUnknownKey);
|
|
expect(result.success).toBe(true);
|
|
// Unknown keys are allowed via .passthrough() for migration scenarios
|
|
});
|
|
|
|
it('should accept nested valid settings', () => {
|
|
const validSettings = {
|
|
ui: {
|
|
theme: 'dark',
|
|
hideWindowTitle: true,
|
|
footer: {
|
|
hideCWD: false,
|
|
hideModelInfo: true,
|
|
},
|
|
},
|
|
tools: {
|
|
sandbox: 'inherit',
|
|
},
|
|
};
|
|
|
|
const result = validateSettings(validSettings);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should validate array types correctly', () => {
|
|
const validSettings = {
|
|
tools: {
|
|
allowed: ['git', 'npm'],
|
|
exclude: ['dangerous-tool'],
|
|
},
|
|
context: {
|
|
includeDirectories: ['/path/1', '/path/2'],
|
|
},
|
|
};
|
|
|
|
const result = validateSettings(validSettings);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should reject invalid types in arrays', () => {
|
|
const invalidSettings = {
|
|
tools: {
|
|
allowed: ['git', 123],
|
|
},
|
|
};
|
|
|
|
const result = validateSettings(invalidSettings);
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('should validate boolean fields correctly', () => {
|
|
const validSettings = {
|
|
general: {
|
|
vimMode: true,
|
|
disableAutoUpdate: false,
|
|
},
|
|
};
|
|
|
|
const result = validateSettings(validSettings);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should reject non-boolean values for boolean fields', () => {
|
|
const invalidSettings = {
|
|
general: {
|
|
vimMode: 'yes',
|
|
},
|
|
};
|
|
|
|
const result = validateSettings(invalidSettings);
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('should validate number fields correctly', () => {
|
|
const validSettings = {
|
|
model: {
|
|
maxSessionTurns: 50,
|
|
compressionThreshold: 0.2,
|
|
},
|
|
};
|
|
|
|
const result = validateSettings(validSettings);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should validate complex nested mcpServers configuration', () => {
|
|
const invalidSettings = {
|
|
mcpServers: {
|
|
'my-server': {
|
|
command: 123, // Should be string
|
|
args: ['arg1'],
|
|
env: {
|
|
VAR: 'value',
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = validateSettings(invalidSettings);
|
|
expect(result.success).toBe(false);
|
|
if (result.error) {
|
|
expect(result.error.issues.length).toBeGreaterThan(0);
|
|
// Path should be mcpServers.my-server.command
|
|
const issue = result.error.issues.find((i) =>
|
|
i.path.includes('command'),
|
|
);
|
|
expect(issue).toBeDefined();
|
|
expect(issue?.code).toBe('invalid_type');
|
|
}
|
|
});
|
|
|
|
it('should validate mcpServers with type field for all transport types', () => {
|
|
const validSettings = {
|
|
mcpServers: {
|
|
'sse-server': {
|
|
url: 'https://example.com/sse',
|
|
type: 'sse',
|
|
headers: { 'X-API-Key': 'key' },
|
|
},
|
|
'http-server': {
|
|
url: 'https://example.com/mcp',
|
|
type: 'http',
|
|
},
|
|
'stdio-server': {
|
|
command: '/usr/bin/mcp-server',
|
|
type: 'stdio',
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = validateSettings(validSettings);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should reject invalid type values in mcpServers', () => {
|
|
const invalidSettings = {
|
|
mcpServers: {
|
|
'bad-server': {
|
|
url: 'https://example.com/mcp',
|
|
type: 'invalid-type',
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = validateSettings(invalidSettings);
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('should validate mcpServers without type field', () => {
|
|
const validSettings = {
|
|
mcpServers: {
|
|
'stdio-server': {
|
|
command: '/usr/bin/mcp-server',
|
|
args: ['--port', '8080'],
|
|
},
|
|
'url-server': {
|
|
url: 'https://example.com/mcp',
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = validateSettings(validSettings);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should validate complex nested customThemes configuration', () => {
|
|
const invalidSettings = {
|
|
ui: {
|
|
customThemes: {
|
|
'my-theme': {
|
|
type: 'custom',
|
|
// Missing 'name' property which is required
|
|
text: {
|
|
primary: '#ffffff',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = validateSettings(invalidSettings);
|
|
expect(result.success).toBe(false);
|
|
if (result.error) {
|
|
expect(result.error.issues.length).toBeGreaterThan(0);
|
|
// Should complain about missing 'name'
|
|
const issue = result.error.issues.find(
|
|
(i) => i.code === 'invalid_type' && i.message.includes('Required'),
|
|
);
|
|
expect(issue).toBeDefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('formatValidationError', () => {
|
|
it('should format error with file path and helpful message for model.name', () => {
|
|
const invalidSettings = {
|
|
model: {
|
|
name: {
|
|
skipNextSpeakerCheck: true,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = validateSettings(invalidSettings);
|
|
expect(result.success).toBe(false);
|
|
|
|
if (result.error) {
|
|
const formatted = formatValidationError(
|
|
result.error,
|
|
'/path/to/settings.json',
|
|
);
|
|
|
|
expect(formatted).toContain('/path/to/settings.json');
|
|
expect(formatted).toContain('model.name');
|
|
expect(formatted).toContain('Expected: string, but received: object');
|
|
expect(formatted).toContain('Please fix the configuration.');
|
|
expect(formatted).toContain(
|
|
'https://geminicli.com/docs/reference/configuration/',
|
|
);
|
|
}
|
|
});
|
|
|
|
it('should format error for model.summarizeToolOutput', () => {
|
|
const invalidSettings = {
|
|
model: {
|
|
summarizeToolOutput: 'wrong type',
|
|
},
|
|
};
|
|
|
|
const result = validateSettings(invalidSettings);
|
|
expect(result.success).toBe(false);
|
|
|
|
if (result.error) {
|
|
const formatted = formatValidationError(
|
|
result.error,
|
|
'~/.gemini/settings.json',
|
|
);
|
|
|
|
expect(formatted).toContain('~/.gemini/settings.json');
|
|
expect(formatted).toContain('model.summarizeToolOutput');
|
|
}
|
|
});
|
|
|
|
it('should include link to documentation', () => {
|
|
const invalidSettings = {
|
|
model: {
|
|
name: { invalid: 'object' }, // model.name should be a string
|
|
},
|
|
};
|
|
|
|
const result = validateSettings(invalidSettings);
|
|
expect(result.success).toBe(false);
|
|
|
|
if (result.error) {
|
|
const formatted = formatValidationError(result.error, 'test.json');
|
|
|
|
expect(formatted).toContain(
|
|
'https://geminicli.com/docs/reference/configuration/',
|
|
);
|
|
}
|
|
});
|
|
|
|
it('should list all validation errors', () => {
|
|
const invalidSettings = {
|
|
model: {
|
|
name: { invalid: 'object' },
|
|
maxSessionTurns: 'not a number',
|
|
},
|
|
};
|
|
|
|
const result = validateSettings(invalidSettings);
|
|
expect(result.success).toBe(false);
|
|
|
|
if (result.error) {
|
|
const formatted = formatValidationError(result.error, 'test.json');
|
|
|
|
// Should have multiple errors listed
|
|
expect(formatted.match(/Error in:/g)?.length).toBeGreaterThan(1);
|
|
}
|
|
});
|
|
|
|
it('should format array paths correctly (e.g. tools.allowed[0])', () => {
|
|
const invalidSettings = {
|
|
tools: {
|
|
allowed: ['git', 123], // 123 is invalid, expected string
|
|
},
|
|
};
|
|
|
|
const result = validateSettings(invalidSettings);
|
|
expect(result.success).toBe(false);
|
|
|
|
if (result.error) {
|
|
const formatted = formatValidationError(result.error, 'test.json');
|
|
expect(formatted).toContain('tools.allowed[1]');
|
|
}
|
|
});
|
|
|
|
it('should limit the number of displayed errors', () => {
|
|
const invalidSettings = {
|
|
tools: {
|
|
// Create 6 invalid items to trigger the limit
|
|
allowed: [1, 2, 3, 4, 5, 6],
|
|
},
|
|
};
|
|
|
|
const result = validateSettings(invalidSettings);
|
|
expect(result.success).toBe(false);
|
|
|
|
if (result.error) {
|
|
const formatted = formatValidationError(result.error, 'test.json');
|
|
// Should see the first 5
|
|
expect(formatted).toContain('tools.allowed[0]');
|
|
expect(formatted).toContain('tools.allowed[4]');
|
|
// Should NOT see the 6th
|
|
expect(formatted).not.toContain('tools.allowed[5]');
|
|
// Should see the summary
|
|
expect(formatted).toContain('...and 1 more errors.');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('settingsZodSchema', () => {
|
|
it('should be a valid Zod object schema', () => {
|
|
expect(settingsZodSchema).toBeInstanceOf(z.ZodObject);
|
|
});
|
|
|
|
it('should have optional fields', () => {
|
|
// All top-level fields should be optional
|
|
const shape = settingsZodSchema.shape;
|
|
expect(shape['model']).toBeDefined();
|
|
expect(shape['ui']).toBeDefined();
|
|
expect(shape['tools']).toBeDefined();
|
|
|
|
// Test that empty object is valid (all fields optional)
|
|
const result = settingsZodSchema.safeParse({});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
});
|