| |
| |
| |
| |
| |
|
|
| import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; |
| import * as fs from 'node:fs'; |
| import * as path from 'node:path'; |
| import * as os from 'node:os'; |
| import { updateSettingsFilePreservingFormat } from './commentJson.js'; |
| import { coreEvents } from '@google/gemini-cli-core'; |
|
|
| vi.mock('@google/gemini-cli-core', () => ({ |
| coreEvents: { |
| emitFeedback: vi.fn(), |
| }, |
| })); |
|
|
| describe('commentJson', () => { |
| let tempDir: string; |
| let testFilePath: string; |
|
|
| beforeEach(() => { |
| |
| tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'preserve-format-test-')); |
| testFilePath = path.join(tempDir, 'settings.json'); |
| }); |
|
|
| afterEach(() => { |
| |
| if (fs.existsSync(tempDir)) { |
| fs.rmSync(tempDir, { recursive: true, force: true }); |
| } |
| }); |
|
|
| describe('updateSettingsFilePreservingFormat', () => { |
| it('should preserve comments when updating settings', () => { |
| const originalContent = `{ |
| // Model configuration |
| "model": "gemini-2.5-pro", |
| "ui": { |
| // Theme setting |
| "theme": "dark" |
| } |
| }`; |
|
|
| fs.writeFileSync(testFilePath, originalContent, 'utf-8'); |
|
|
| updateSettingsFilePreservingFormat(testFilePath, { |
| model: 'gemini-2.5-flash', |
| ui: { |
| theme: 'dark', |
| }, |
| }); |
|
|
| const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); |
|
|
| expect(updatedContent).toContain('// Model configuration'); |
| expect(updatedContent).toContain('// Theme setting'); |
| expect(updatedContent).toContain('"model": "gemini-2.5-flash"'); |
| expect(updatedContent).toContain('"theme": "dark"'); |
| }); |
|
|
| it('should handle nested object updates', () => { |
| const originalContent = `{ |
| "ui": { |
| "theme": "dark", |
| "showLineNumbers": true |
| } |
| }`; |
|
|
| fs.writeFileSync(testFilePath, originalContent, 'utf-8'); |
|
|
| updateSettingsFilePreservingFormat(testFilePath, { |
| ui: { |
| theme: 'light', |
| showLineNumbers: true, |
| }, |
| }); |
|
|
| const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); |
| expect(updatedContent).toContain('"theme": "light"'); |
| expect(updatedContent).toContain('"showLineNumbers": true'); |
| }); |
|
|
| it('should add new fields while preserving existing structure', () => { |
| const originalContent = `{ |
| // Existing config |
| "model": "gemini-2.5-pro" |
| }`; |
|
|
| fs.writeFileSync(testFilePath, originalContent, 'utf-8'); |
|
|
| updateSettingsFilePreservingFormat(testFilePath, { |
| model: 'gemini-2.5-pro', |
| newField: 'newValue', |
| }); |
|
|
| const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); |
| expect(updatedContent).toContain('// Existing config'); |
| expect(updatedContent).toContain('"newField": "newValue"'); |
| }); |
|
|
| it('should create file if it does not exist', () => { |
| updateSettingsFilePreservingFormat(testFilePath, { |
| model: 'gemini-2.5-pro', |
| }); |
|
|
| expect(fs.existsSync(testFilePath)).toBe(true); |
| const content = fs.readFileSync(testFilePath, 'utf-8'); |
| expect(content).toContain('"model": "gemini-2.5-pro"'); |
| }); |
|
|
| it('should handle complex real-world scenario', () => { |
| const complexContent = `{ |
| // Settings |
| "model": "gemini-2.5-pro", |
| "mcpServers": { |
| // Active server |
| "context7": { |
| "headers": { |
| "API_KEY": "test-key" // API key |
| } |
| } |
| } |
| }`; |
|
|
| fs.writeFileSync(testFilePath, complexContent, 'utf-8'); |
|
|
| updateSettingsFilePreservingFormat(testFilePath, { |
| model: 'gemini-2.5-flash', |
| mcpServers: { |
| context7: { |
| headers: { |
| API_KEY: 'new-test-key', |
| }, |
| }, |
| }, |
| newSection: { |
| setting: 'value', |
| }, |
| }); |
|
|
| const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); |
|
|
| |
| expect(updatedContent).toContain('// Settings'); |
| expect(updatedContent).toContain('// Active server'); |
| expect(updatedContent).toContain('// API key'); |
|
|
| |
| expect(updatedContent).toContain('"model": "gemini-2.5-flash"'); |
| expect(updatedContent).toContain('"newSection"'); |
| expect(updatedContent).toContain('"API_KEY": "new-test-key"'); |
| }); |
|
|
| it('should handle corrupted JSON files gracefully', () => { |
| const corruptedContent = `{ |
| "model": "gemini-2.5-pro", |
| "ui": { |
| "theme": "dark" |
| // Missing closing brace |
| `; |
|
|
| fs.writeFileSync(testFilePath, corruptedContent, 'utf-8'); |
|
|
| expect(() => { |
| updateSettingsFilePreservingFormat(testFilePath, { |
| model: 'gemini-2.5-flash', |
| }); |
| }).not.toThrow(); |
|
|
| expect(coreEvents.emitFeedback).toHaveBeenCalledWith( |
| 'error', |
| 'Error parsing settings file. Please check the JSON syntax.', |
| expect.any(Error), |
| ); |
|
|
| const unchangedContent = fs.readFileSync(testFilePath, 'utf-8'); |
| expect(unchangedContent).toBe(corruptedContent); |
| }); |
|
|
| it('should handle array updates while preserving comments', () => { |
| const originalContent = `{ |
| // Server configurations |
| "servers": [ |
| // First server |
| "server1", |
| "server2" // Second server |
| ] |
| }`; |
|
|
| fs.writeFileSync(testFilePath, originalContent, 'utf-8'); |
|
|
| updateSettingsFilePreservingFormat(testFilePath, { |
| servers: ['server1', 'server3'], |
| }); |
|
|
| const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); |
| expect(updatedContent).toContain('// Server configurations'); |
| expect(updatedContent).toContain('"server1"'); |
| expect(updatedContent).toContain('"server3"'); |
| expect(updatedContent).not.toContain('"server2"'); |
| }); |
|
|
| it('should sync nested objects, removing omitted fields', () => { |
| const originalContent = `{ |
| // Configuration |
| "model": "gemini-2.5-pro", |
| "ui": { |
| "theme": "dark", |
| "existingSetting": "value" |
| }, |
| "preservedField": "keep me" |
| }`; |
|
|
| fs.writeFileSync(testFilePath, originalContent, 'utf-8'); |
|
|
| updateSettingsFilePreservingFormat(testFilePath, { |
| model: 'gemini-2.5-flash', |
| ui: { |
| theme: 'light', |
| }, |
| preservedField: 'keep me', |
| }); |
|
|
| const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); |
| expect(updatedContent).toContain('// Configuration'); |
| expect(updatedContent).toContain('"model": "gemini-2.5-flash"'); |
| expect(updatedContent).toContain('"theme": "light"'); |
| expect(updatedContent).not.toContain('"existingSetting": "value"'); |
| expect(updatedContent).toContain('"preservedField": "keep me"'); |
| }); |
|
|
| it('should handle mcpServers field deletion properly', () => { |
| const originalContent = `{ |
| "model": "gemini-2.5-pro", |
| "mcpServers": { |
| // Server to keep |
| "context7": { |
| "command": "node", |
| "args": ["server.js"] |
| }, |
| // Server to remove |
| "oldServer": { |
| "command": "old", |
| "args": ["old.js"] |
| } |
| } |
| }`; |
|
|
| fs.writeFileSync(testFilePath, originalContent, 'utf-8'); |
|
|
| updateSettingsFilePreservingFormat(testFilePath, { |
| model: 'gemini-2.5-pro', |
| mcpServers: { |
| context7: { |
| command: 'node', |
| args: ['server.js'], |
| }, |
| }, |
| }); |
|
|
| const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); |
| expect(updatedContent).toContain('// Server to keep'); |
| expect(updatedContent).toContain('"context7"'); |
| expect(updatedContent).not.toContain('"oldServer"'); |
| |
| expect(updatedContent).toContain('// Server to remove'); |
| }); |
|
|
| it('preserves sibling-level commented-out blocks when removing another key', () => { |
| const originalContent = `{ |
| "mcpServers": { |
| // "sleep": { |
| // "command": "node", |
| // "args": [ |
| // "/Users/testUser/test-mcp-server/sleep-mcp/build/index.js" |
| // ], |
| // "timeout": 300000 |
| // }, |
| "playwright": { |
| "command": "npx", |
| "args": [ |
| "@playwright/mcp@latest", |
| "--headless", |
| "--isolated" |
| ] |
| } |
| } |
| }`; |
|
|
| fs.writeFileSync(testFilePath, originalContent, 'utf-8'); |
|
|
| updateSettingsFilePreservingFormat(testFilePath, { |
| mcpServers: {}, |
| }); |
|
|
| const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); |
| expect(updatedContent).toContain('// "sleep": {'); |
| expect(updatedContent).toContain('"mcpServers"'); |
| expect(updatedContent).not.toContain('"playwright"'); |
| }); |
|
|
| it('should handle type conversion from object to array', () => { |
| const originalContent = `{ |
| "data": { |
| "key": "value" |
| } |
| }`; |
|
|
| fs.writeFileSync(testFilePath, originalContent, 'utf-8'); |
|
|
| updateSettingsFilePreservingFormat(testFilePath, { |
| data: ['item1', 'item2'], |
| }); |
|
|
| const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); |
| expect(updatedContent).toContain('"data": ['); |
| expect(updatedContent).toContain('"item1"'); |
| expect(updatedContent).toContain('"item2"'); |
| }); |
|
|
| it('should remove both nested and non-nested objects when omitted', () => { |
| const originalContent = `{ |
| // Top-level config |
| "topLevelObject": { |
| "field1": "value1", |
| "field2": "value2" |
| }, |
| // Parent object |
| "parent": { |
| "nestedObject": { |
| "nestedField1": "value1", |
| "nestedField2": "value2" |
| }, |
| "keepThis": "value" |
| }, |
| // This should be preserved |
| "preservedObject": { |
| "data": "keep" |
| } |
| }`; |
|
|
| fs.writeFileSync(testFilePath, originalContent, 'utf-8'); |
|
|
| updateSettingsFilePreservingFormat(testFilePath, { |
| parent: { |
| keepThis: 'value', |
| }, |
| preservedObject: { |
| data: 'keep', |
| }, |
| }); |
|
|
| const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); |
|
|
| expect(updatedContent).not.toContain('"topLevelObject"'); |
|
|
| expect(updatedContent).not.toContain('"nestedObject"'); |
|
|
| expect(updatedContent).toContain('"keepThis": "value"'); |
| expect(updatedContent).toContain('"preservedObject"'); |
| expect(updatedContent).toContain('"data": "keep"'); |
|
|
| expect(updatedContent).toContain('// This should be preserved'); |
| }); |
| }); |
| }); |
|
|