Download packages/cli/src/config/settings-validation.test.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 16.1 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/config/settings-validation.test.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/cli/src/config/settings-validation.test.ts
-
curl -L -o settings-validation.test.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/config/settings-validation.test.ts
16.1 kB
| /** | |
| * @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'; | |
| import { type Settings } from './settingsSchema.js'; | |
| 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(); | |
| } | |
| }); | |
| it('should accept customThemes with text.response color override', () => { | |
| // Regression test for #25610: `response` is a documented and | |
| // implemented color override for model responses (see | |
| // packages/cli/src/ui/themes/theme.ts and semantic-tokens.ts), | |
| // but was missing from the CustomTheme validation schema. | |
| const validSettings = { | |
| ui: { | |
| theme: 'LimeWhite', | |
| customThemes: { | |
| LimeWhite: { | |
| type: 'custom', | |
| name: 'LimeWhite', | |
| text: { | |
| primary: '#00FF00', | |
| response: '#FFFFFF', | |
| secondary: '#a0a0a0', | |
| accent: '#00FF00', | |
| }, | |
| }, | |
| }, | |
| }, | |
| }; | |
| const result = validateSettings(validSettings); | |
| expect(result.success).toBe(true); | |
| }); | |
| describe('type casting', () => { | |
| it('should cast "true" and "false" strings to booleans', () => { | |
| const settings = { | |
| ui: { | |
| autoThemeSwitching: 'true', | |
| hideWindowTitle: 'false', | |
| }, | |
| }; | |
| const result = validateSettings(settings); | |
| expect(result.success).toBe(true); | |
| const data = result.data as Settings; | |
| expect(data.ui?.autoThemeSwitching).toBe(true); | |
| expect(data.ui?.hideWindowTitle).toBe(false); | |
| }); | |
| it('should cast boolean strings case-insensitively', () => { | |
| const settings = { | |
| ui: { | |
| autoThemeSwitching: 'TRUE', | |
| hideWindowTitle: 'fAlSe', | |
| }, | |
| }; | |
| const result = validateSettings(settings); | |
| expect(result.success).toBe(true); | |
| const data = result.data as Settings; | |
| expect(data.ui?.autoThemeSwitching).toBe(true); | |
| expect(data.ui?.hideWindowTitle).toBe(false); | |
| }); | |
| it('should cast numeric strings to numbers', () => { | |
| const settings = { | |
| model: { | |
| maxSessionTurns: '42', | |
| compressionThreshold: '0.5', | |
| }, | |
| }; | |
| const result = validateSettings(settings); | |
| expect(result.success).toBe(true); | |
| const data = result.data as Settings; | |
| expect(data.model?.maxSessionTurns).toBe(42); | |
| expect(data.model?.compressionThreshold).toBe(0.5); | |
| }); | |
| it('should reject invalid castable strings', () => { | |
| const settings = { | |
| ui: { | |
| autoThemeSwitching: 'not-a-boolean', | |
| }, | |
| model: { | |
| maxSessionTurns: 'not-a-number', | |
| }, | |
| }; | |
| const result = validateSettings(settings); | |
| expect(result.success).toBe(false); | |
| expect(result.error?.issues).toHaveLength(2); | |
| expect(result.error?.issues[0].message).toContain( | |
| 'Expected boolean, received string', | |
| ); | |
| expect(result.error?.issues[1].message).toContain( | |
| 'Expected number, received string', | |
| ); | |
| }); | |
| it('should cast strings to booleans/numbers in shared definitions (refs)', () => { | |
| const settings = { | |
| mcpServers: { | |
| 'test-server': { | |
| command: 'node', | |
| trust: 'true', // from boolean ref | |
| }, | |
| }, | |
| }; | |
| const result = validateSettings(settings); | |
| expect(result.success).toBe(true); | |
| const data = result.data as Settings; | |
| expect(data.mcpServers?.['test-server'].trust).toBe(true); | |
| }); | |
| }); | |
| }); | |
| 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); | |
| }); | |
| }); | |
| }); | |